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 335153ac..00790c44 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
@@ -15,7 +15,7 @@ never hidden behind a retry, delay, suppression flag, or reordered callback.
- [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
+- [x] B — extract the pre-network live-object presentation phase and the
non-advancing post-network spatial reconciler; remove callbacks into
`GameWindow` from `RetailLiveFrameCoordinator`.
- [ ] C — extract streaming-origin convergence, observer selection, dungeon
@@ -42,6 +42,14 @@ oracle, exact teardown and invalid-delta tests, and guards against a transitive
18 focused Release tests and the full 2,709-test App project pass, and all
three corrected-diff reviews are clean.
+Checkpoint B moved the complete local/ordinary/static/effect body into
+`LiveObjectFrameController`, made `RetailLiveFrameCoordinator`'s
+object → inbound batch → command → spatial-reconcile barrier fully typed,
+and removed animation/runtime/projectile back-references to `GameWindow`.
+`GameWindow.cs` is now 8,716 raw lines. The corrected diff passes 239 focused
+Release tests, the 2,732-test App suite, and the full 7,090-test Release suite
+(5 fixture/environment skips); all three independent reviews are clean.
+
## 1. Outcome and non-goals
At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one
@@ -352,6 +360,13 @@ multiple ordinary/static owners, invalid delta, and reentrant inbound work;
the one published PES clock remains visible from hook capture through the
script tick.
+Checkpoint B removes the coordinator, animation presenter/scheduler, runtime
+view, static-root, and projectile callbacks into `GameWindow`. The callback
+composition still retained by `RetailLocalPlayerFrameController` is explicitly
+deferred: checkpoint D owns its input/capture sources and checkpoint F owns its
+mutable player-mode/controller/session sources. A structural test permits that
+one named legacy owner only; D/F must remove the exception before final cutover.
+
### C — streaming frame
- extract readiness/recenter advancement, observer selection, dungeon gate,
diff --git a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
new file mode 100644
index 00000000..02a1b2ad
--- /dev/null
+++ b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
@@ -0,0 +1,30 @@
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Input;
+
+internal interface ILocalPlayerIdentitySource
+{
+ uint ServerGuid { get; }
+}
+
+///
+/// Session-scoped identity slot shared by focused update owners.
+///
+internal sealed class LocalPlayerIdentityState : ILocalPlayerIdentitySource
+{
+ public uint ServerGuid { get; set; }
+}
+
+internal interface ILocalPlayerControllerSource
+{
+ PlayerMovementController? Controller { get; }
+}
+
+///
+/// The one mutable local movement-controller slot. Player-mode lifecycle owns
+/// assignment; update and presentation owners receive the read-only seam.
+///
+internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource
+{
+ public PlayerMovementController? Controller { get; set; }
+}
diff --git a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
index 67be00bc..51bb8fd7 100644
--- a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
+++ b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
@@ -1,3 +1,5 @@
+using AcDream.App.Update;
+
namespace AcDream.App.Input;
///
@@ -10,7 +12,7 @@ namespace AcDream.App.Input;
/// state. The cached input result is presentation-only after the barrier: its
/// one-shot commands are never replayed against a later authoritative cell.
///
-public sealed class RetailLocalPlayerFrameController
+internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFramePhase
{
private readonly Func _canPresentPlayer;
private readonly Func _getController;
diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs
index eecab525..02664eaa 100644
--- a/src/AcDream.App/Net/LiveSessionController.cs
+++ b/src/AcDream.App/Net/LiveSessionController.cs
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Sockets;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
+using AcDream.App.Update;
using AcDream.UI.Abstractions;
namespace AcDream.App.Net;
@@ -157,7 +158,7 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
/// publication, exact-generation Tick, graceful replacement, and convergent
/// teardown. Retail wire behavior remains inside WorldSession.
///
-public sealed class LiveSessionController : IDisposable
+internal sealed class LiveSessionController : IDisposable, ILiveSessionFramePhase
{
private sealed class SessionScope(
WorldSession session,
diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs
index 7e4ad350..6758d8c9 100644
--- a/src/AcDream.App/Physics/ProjectileController.cs
+++ b/src/AcDream.App/Physics/ProjectileController.cs
@@ -1,5 +1,7 @@
using System.Numerics;
+using AcDream.App.Rendering;
using AcDream.App.World;
+using AcDream.Content;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
@@ -9,6 +11,29 @@ using RemoteMotion = AcDream.App.Physics.RemoteMotion;
namespace AcDream.App.Physics;
+internal interface IProjectileSetupResolver
+{
+ Setup? Resolve(uint setupId);
+}
+
+internal sealed class DatProjectileSetupResolver : IProjectileSetupResolver
+{
+ private readonly IDatReaderWriter _dats;
+ private readonly object _datLock;
+
+ public DatProjectileSetupResolver(IDatReaderWriter dats, object datLock)
+ {
+ _dats = dats ?? throw new ArgumentNullException(nameof(dats));
+ _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
+ }
+
+ public Setup? Resolve(uint setupId)
+ {
+ lock (_datLock)
+ return _dats.Get(setupId);
+ }
+}
+
///
/// Update-thread presentation owner for live retail missiles. Logical identity,
/// generation, accepted network state, and teardown remain owned by
@@ -56,26 +81,26 @@ internal sealed class ProjectileController
private readonly LiveEntityRuntime _liveEntities;
private readonly ProjectilePhysicsStepper _stepper;
private readonly ShadowObjectRegistry _shadows;
- private readonly Func _setupResolver;
- private readonly Action _publishRootPose;
- private readonly Func<(int X, int Y)> _liveCenterProvider;
+ private readonly IProjectileSetupResolver? _setupResolver;
+ private readonly IEntityRootPosePublisher? _rootPoses;
+ private readonly LiveWorldOriginState? _origin;
private readonly List _spatialProjectileSnapshot = new();
private double _lastFiniteGameTime;
internal ProjectileController(
LiveEntityRuntime liveEntities,
PhysicsEngine physicsEngine,
- Func? setupResolver = null,
- Action? publishRootPose = null,
- Func<(int X, int Y)>? liveCenterProvider = null)
+ IProjectileSetupResolver? setupResolver = null,
+ IEntityRootPosePublisher? rootPoses = null,
+ LiveWorldOriginState? origin = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
ArgumentNullException.ThrowIfNull(physicsEngine);
_stepper = new ProjectilePhysicsStepper(physicsEngine);
_shadows = physicsEngine.ShadowObjects;
- _setupResolver = setupResolver ?? (_ => null);
- _publishRootPose = publishRootPose ?? (_ => { });
- _liveCenterProvider = liveCenterProvider ?? (() => (0, 0));
+ _setupResolver = setupResolver;
+ _rootPoses = rootPoses;
+ _origin = origin;
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
@@ -322,7 +347,7 @@ internal sealed class ProjectileController
{
SuspendOutsideWorld(runtime, entity.Id);
}
- _publishRootPose(entity);
+ _rootPoses?.UpdateRoot(entity);
return true;
}
@@ -472,7 +497,7 @@ internal sealed class ProjectileController
// A newer State packet can make an already materialized object a
// missile. TryBind adopts an existing MovementManager body so the
// live record still represents retail's one CPhysicsObj.
- Setup? setup = _setupResolver(entity.SourceGfxObjOrSetupId);
+ Setup? setup = _setupResolver?.Resolve(entity.SourceGfxObjOrSetupId);
if (setup is null)
{
DiagnosticSink?.Invoke(
@@ -617,7 +642,7 @@ internal sealed class ProjectileController
{
SuspendOutsideWorld(runtime, entity.Id);
}
- _publishRootPose(entity);
+ _rootPoses?.UpdateRoot(entity);
return true;
}
@@ -712,7 +737,7 @@ internal sealed class ProjectileController
record.FullCellId,
liveCenterX,
liveCenterY);
- _publishRootPose(entity);
+ _rootPoses?.UpdateRoot(entity);
}
if (IsHidden(record))
@@ -909,7 +934,7 @@ internal sealed class ProjectileController
current.FullCellId,
liveCenterX,
liveCenterY);
- _publishRootPose(entity);
+ _rootPoses?.UpdateRoot(entity);
if (!IsCurrentQuantumIdentity(step))
return false;
}
@@ -966,7 +991,8 @@ internal sealed class ProjectileController
// not lazily on the next simulated quantum: a zero-velocity or
// newly ordinary retained projectile may have no later projectile
// step from which to repair its shadow membership.
- (int liveCenterX, int liveCenterY) = _liveCenterProvider();
+ int liveCenterX = _origin?.CenterX ?? 0;
+ int liveCenterY = _origin?.CenterY ?? 0;
ShadowPositionSynchronizer.Sync(
_shadows,
entity.Id,
@@ -975,7 +1001,7 @@ internal sealed class ProjectileController
record.FullCellId,
liveCenterX,
liveCenterY);
- _publishRootPose(entity);
+ _rootPoses?.UpdateRoot(entity);
return;
}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index c06fa66e..2f7b502f 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -10,7 +10,7 @@ using Silk.NET.Windowing;
namespace AcDream.App.Rendering;
-public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
+public sealed class GameWindow : IDisposable
{
private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp()
@@ -152,12 +152,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_remoteInboundMotion;
private readonly AcDream.App.World.RetailInboundEventDispatcher
_inboundEntityEvents = new();
- private readonly LiveEntityAnimationScheduler _liveAnimationScheduler;
- private readonly LiveEntityAnimationPresenter _animationPresenter;
+ private LiveEntityAnimationScheduler _liveAnimationScheduler = null!;
+ private LiveEntityAnimationPresenter _animationPresenter = null!;
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
- private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
+ private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!;
+ private AcDream.App.Update.LiveSpatialPresentationReconciler
+ _liveSpatialReconciler = null!;
private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new();
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map;
@@ -247,6 +249,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// appear in this map.
///
private readonly LiveEntityAnimationRuntimeView _animatedEntities;
+ private readonly AcDream.App.World.LiveEntityRuntimeSlot _liveEntityRuntimeSlot = new();
// MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys.
// WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically
@@ -520,11 +523,21 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private int _weatherDebugStep = 0;
// Phase B.2: player movement mode.
- private AcDream.App.Input.PlayerMovementController? _playerController;
+ private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new();
+ private AcDream.App.Input.PlayerMovementController? _playerController
+ {
+ get => _playerControllerSlot.Controller;
+ set => _playerControllerSlot.Controller = value;
+ }
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
private bool _playerMode;
- private uint _playerServerGuid;
+ private readonly AcDream.App.Input.LocalPlayerIdentityState _localPlayerIdentity = new();
+ private uint _playerServerGuid
+ {
+ get => _localPlayerIdentity.ServerGuid;
+ set => _localPlayerIdentity.ServerGuid = value;
+ }
// Retail Default_CharacterOption (acclient.h:3434). PlayerDescription
// replaces this before any in-world drag can normally occur.
private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
@@ -745,7 +758,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry;
- _animatedEntities = new LiveEntityAnimationRuntimeView(() => _liveEntities);
+ _animatedEntities = new LiveEntityAnimationRuntimeView(
+ _liveEntityRuntimeSlot);
SpellBook = new AcDream.Core.Spells.Spellbook();
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
// #184 Slice 2a: the extracted per-remote DR tick. Its stateful
@@ -761,25 +775,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
movement, cellId, update),
(host, targetGuid) =>
_liveEntityMotionBindings.StickToObjectFromWire(host, targetGuid));
- var ordinaryPhysicsUpdater =
- new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater(
- _physicsEngine,
- _liveEntityMotionBindings.GetSetupCylinder);
- _liveAnimationScheduler = new LiveEntityAnimationScheduler(
- () => _liveEntities,
- () => _playerServerGuid,
- _remotePhysicsUpdater,
- ordinaryPhysicsUpdater,
- () => _projectileController,
- entity => _effectPoses.UpdateRoot(entity),
- CaptureAnimationHooks);
- _animationPresenter = new LiveEntityAnimationPresenter(
- () => _liveEntities,
- () => _staticAnimationScheduler,
- _effectPoses,
- this,
- _animationDiagnostics,
- options.HidePartIndex);
_localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController(
resolveEntity: () =>
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
@@ -824,16 +819,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
objectClockDisposition: () =>
_liveEntities?.GetRootObjectClockDisposition(_playerServerGuid)
?? AcDream.Core.Physics.RetailObjectClockDisposition.Advance);
- _liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
- AdvanceLiveObjectRuntime,
- () =>
- {
- using AcDream.App.Streaming.GpuWorldState.MutationBatch spatialBatch =
- _worldState.BeginMutationBatch();
- _liveSessionController?.Tick();
- },
- _localPlayerFrame.RunPostNetworkCommandPhase,
- ReconcileLiveObjectSpatialPresentation);
}
public void Run()
@@ -2080,34 +2065,20 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale.
AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null;
var staticLiveRootCommitter = new StaticLiveRootCommitter(
- () => _liveEntities,
+ _liveEntityRuntimeSlot,
_physicsEngine.ShadowObjects,
- () => (_liveCenterX, _liveCenterY),
- entity => _effectPoses.UpdateRoot(entity));
+ _liveWorldOrigin,
+ _effectPoses);
+ var staticAnimationResidency = new LiveStaticAnimationResidency(
+ _liveEntityRuntimeSlot);
_staticAnimationScheduler = new RetailStaticAnimatingObjectScheduler(
capturedAnimLoader!,
- CaptureAnimationHooks,
- (entity, poses, availability) =>
- _effectPoses.Publish(entity, poses, availability),
- entity => entity.ServerGuid == 0
- || (_liveEntities?.TryGetRecord(
- entity.ServerGuid,
- out LiveEntityRecord resident) == true
- && resident.ProjectionKind is LiveEntityProjectionKind.World
- && ReferenceEquals(resident.WorldEntity, entity)
- && resident.IsSpatiallyProjected
- && resident.IsSpatiallyVisible
- && resident.FullCellId != 0),
+ _animationHookFrames!.Capture,
+ _effectPoses.Publish,
+ staticAnimationResidency.IsResident,
(entity, body) =>
_ = staticLiveRootCommitter.Commit(entity, body),
- entity => entity.ServerGuid == 0
- ? 0UL
- : _liveEntities?.TryGetRecord(
- entity.ServerGuid,
- out LiveEntityRecord resident) == true
- && ReferenceEquals(resident.WorldEntity, entity)
- ? resident.ProjectionMutationVersion
- : ulong.MaxValue);
+ staticAnimationResidency.ProjectionVersion);
AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e)
{
try
@@ -2183,6 +2154,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
entityScriptActivator.OnCreate,
entityScriptActivator.OnRemove)),
liveEntityComponentLifecycle);
+ _liveEntityRuntimeSlot.Bind(_liveEntities);
liveEntityMotionRuntime =
new AcDream.App.Physics.LiveEntityMotionRuntimeController(
_liveEntities,
@@ -2204,17 +2176,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_projectileController = new AcDream.App.Physics.ProjectileController(
_liveEntities,
_physicsEngine,
- setupId =>
- {
- // State can classify an already-live object as Missile
- // outside the spawn handler's lock. DatCollection shares
- // one mutable reader cursor with the streaming worker, so
- // this resolver must serialize both initial and late bind.
- lock (_datLock)
- return _dats?.Get(setupId);
- },
- entity => _effectPoses.UpdateRoot(entity),
- () => (_liveCenterX, _liveCenterY))
+ new AcDream.App.Physics.DatProjectileSetupResolver(_dats!, _datLock),
+ new EntityRootPosePublisher(_effectPoses),
+ _liveWorldOrigin)
{
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
};
@@ -2233,6 +2197,29 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_lightingSink!,
setupId => _dats!.Get(setupId));
+ var ordinaryPhysicsUpdater =
+ new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater(
+ _physicsEngine,
+ _liveEntityMotionBindings.GetSetupCylinder);
+ _liveAnimationScheduler = new LiveEntityAnimationScheduler(
+ _liveEntities,
+ _localPlayerIdentity,
+ _remotePhysicsUpdater,
+ ordinaryPhysicsUpdater,
+ _projectileController,
+ new EntityRootPosePublisher(_effectPoses),
+ new AnimationHookCaptureSink(_animationHookFrames!));
+ _animationPresenter = new LiveEntityAnimationPresenter(
+ _liveEntities,
+ _staticAnimationScheduler,
+ _effectPoses,
+ new LiveAnimationPresentationContext(
+ _liveEntities,
+ _localPlayerIdentity,
+ _playerControllerSlot),
+ _animationDiagnostics,
+ _options.HidePartIndex);
+
parentAcceptance = new AcDream.App.World.DeferredLiveEntityParentAcceptance();
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!,
@@ -2627,7 +2614,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_remoteTeleportController!,
_animatedEntities,
new LiveEntityRemoteMotionRuntimeView(
- () => _liveEntities),
+ _liveEntityRuntimeSlot),
_remoteMovementObservations,
_remotePhysicsUpdater,
_remoteInboundMotion,
@@ -2667,6 +2654,44 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
_liveWorldOrigin.SetPlaceholder(centerX, centerY);
_liveSessionController = new AcDream.App.Net.LiveSessionController();
+ var liveEffectFrame = new AcDream.App.Update.LiveEffectFrameController(
+ _translucencyFades,
+ _animationHookFrames!,
+ _entityEffects!,
+ _particleSink!,
+ _liveEntityLights!,
+ _particleVisibility,
+ _particleSystem!,
+ _scriptRunner!,
+ _updateFrameClock,
+ new AcDream.App.Update.SettingsParticleRangeSource(
+ _settingsVm,
+ _persistedDisplay.ParticleRange));
+ var liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController(
+ _inboundEntityEvents,
+ _localPlayerFrame,
+ _selectionInteractions,
+ _liveEntities,
+ _localPlayerIdentity,
+ _liveWorldOrigin,
+ _liveAnimationScheduler,
+ _staticAnimationScheduler,
+ _animationPresenter,
+ _animatedEntities,
+ _equippedChildRenderer!,
+ liveEffectFrame);
+ _liveSpatialReconciler =
+ new AcDream.App.Update.LiveSpatialPresentationReconciler(
+ _entityEffects!,
+ _equippedChildRenderer!,
+ _particleSink!,
+ _liveEntityLights!);
+ _liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
+ liveObjectFrame,
+ _worldState,
+ _liveSessionController,
+ _localPlayerFrame,
+ _liveSpatialReconciler);
AcDream.App.Net.LiveSessionStartResult liveStart =
_liveSessionController.Start(
_options,
@@ -3520,7 +3545,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// camera path re-extends the boom. Updating directly from the stale
// source-world viewer made the destination bend across the reveal.
_retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw);
- ReconcileLiveObjectSpatialPresentation();
+ _liveSpatialReconciler.Reconcile();
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}");
@@ -4203,7 +4228,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// The player was materialized by this frame's inbound pass.
// Its non-advancing initial projection must publish child and
// effect anchors before the first draw too.
- ReconcileLiveObjectSpatialPresentation();
+ _liveSpatialReconciler.Reconcile();
}
// Update chase camera(s). The CameraController exposes whichever
@@ -5715,102 +5740,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
LastFrameProfile: _frameProfiler.LastReport);
}
- ///
- /// Advances the live object's retail UseTime phase before inbound packet
- /// dispatch. Final root/part poses and their animation hooks are published
- /// together so rendering consumes one coherent update snapshot.
- ///
- private void AdvanceLiveObjectRuntime(float dt) =>
- _inboundEntityEvents.Run(
- this,
- dt,
- static (window, deltaTime) =>
- window.AdvanceLiveObjectRuntimeCore(deltaTime));
-
- private void AdvanceLiveObjectRuntimeCore(float dt)
- {
- // The local body participates in the same retail CPhysics phase as
- // remote objects. Its outbound movement snapshot is completed here,
- // before SmartBox dispatch can apply F751/ForcePosition.
- _localPlayerFrame.AdvanceBeforeNetwork(dt);
- // Retail input ordering: movement edges reach MoveToState before a
- // later Use/PickUp action from the same input frame. ACE cancels an
- // active server MoveTo chain when it receives MoveToState, so sending
- // 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();
- System.Numerics.Vector3? playerPosition =
- _entitiesByServerGuid.TryGetValue(
- _playerServerGuid,
- out var ordinaryObjectViewer)
- ? ordinaryObjectViewer.Position
- : null;
- IReadOnlyDictionary animationSchedules =
- _liveAnimationScheduler.Tick(
- dt,
- playerPosition,
- _localPlayerFrame.HiddenPartPoseDirty,
- _liveCenterX,
- _liveCenterY,
- _animationPresenter.PrepareAnimation);
- // CPhysics::UseTime (0x00509950) processes the ordinary object table
- // first, then its distinct static_animating_objects workset.
- _staticAnimationScheduler?.Tick(dt);
- if (_animatedEntities.Count > 0)
- _animationPresenter.Present(animationSchedules);
- _equippedChildRenderer?.Tick();
-
- // CPhysicsObj::animate_static_object (0x00513DF0) reaches
- // process_hooks only after UpdatePartsInternal and
- // UpdateChildrenInternal. Ordinary objects complete AnimationDone in
- // their earlier UpdatePositionInternal slot; the static workset has
- // this distinct tail.
- _staticAnimationScheduler?.ProcessHooks();
-
- // Advance already-active FP hooks before routing hooks emitted by
- // this PartArray update. A newly created translucency hook begins at
- // its authored Start value this frame; it does not consume dt twice.
- _translucencyFades.AdvanceAll(dt);
- _animationHookFrames?.Drain();
- _entityEffects?.RefreshLiveOwnerPoses();
- _particleSink?.RefreshAttachedEmitters();
- _liveEntityLights?.Refresh();
-
- if (_particleSystem is not null)
- {
- var particleRange = _settingsVm?.DisplayDraft.ParticleRange
- ?? _persistedDisplay.ParticleRange;
- float rangeMultiplier = particleRange
- == AcDream.UI.Abstractions.Panels.Settings.ParticleRange.Extended
- ? AcDream.App.Rendering.Vfx.ParticleVisibilityController.ExtendedRangeMultiplier
- : 1f;
- _particleVisibility.Apply(_particleSystem, rangeMultiplier);
- }
-
- // Retail CPhysicsObj::UpdateObjectInternal (0x005156B0) advances
- // ParticleManager before ScriptManager. A particle created by a PES
- // hook therefore begins simulation on the following object frame.
- _particleSystem?.Tick(dt);
- _scriptRunner?.Tick(_updateFrameClock.CurrentScriptTime);
- }
-
- ///
- /// Re-composes spatial derivatives after authoritative packet handlers
- /// move roots. This pass advances no time, animation, scripts, particles,
- /// or fades; it only makes children and live anchors agree with the root
- /// that the same frame will draw.
- ///
- private void ReconcileLiveObjectSpatialPresentation()
- {
- // Authoritative handlers mutate WorldEntity roots directly. Publish
- // those roots before children read their parent's pose; child Tick
- // then publishes each newly composed child pose for attached effects.
- _entityEffects?.RefreshLiveOwnerPoses();
- _equippedChildRenderer?.Tick();
- _particleSink?.RefreshAttachedEmitters();
- _liveEntityLights?.Refresh();
- }
-
///
/// Phase 6.4: advance every animated entity's frame counter by
/// * Framerate, wrapping around the cycle's
@@ -5855,33 +5784,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
&& _animatedEntities.TryGetValue(entity.Id, out var ae)
&& ae.Sequencer is { } sequencer)
{
- CaptureAnimationHooks(ae.Entity.Id, sequencer);
+ _animationHookFrames?.Capture(ae.Entity.Id, sequencer);
}
}
- private void CaptureAnimationHooks(
- uint ownerLocalId,
- AcDream.Core.Physics.AnimationSequencer sequencer) =>
- _animationHookFrames?.Capture(ownerLocalId, sequencer);
-
- uint ILiveAnimationPresentationContext.LocalPlayerGuid => _playerServerGuid;
-
- AcDream.Core.Physics.MotionInterpreter?
- ILiveAnimationPresentationContext.ResolveMotionInterpreter(
- LiveEntityRecord record)
- {
- if (_liveEntities?.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) != true
- || !ReferenceEquals(current, record))
- {
- return null;
- }
- if (record.ServerGuid == _playerServerGuid)
- return _playerController?.Motion;
- return record.RemoteMotionRuntime is RemoteMotion remote
- ? remote.Motion
- : null;
- }
-
// IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass
// narrowing that dropped settled-open doors / faded-out walls back onto the
// stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the
diff --git a/src/AcDream.App/Rendering/IAnimationHookCaptureSink.cs b/src/AcDream.App/Rendering/IAnimationHookCaptureSink.cs
new file mode 100644
index 00000000..81134d2c
--- /dev/null
+++ b/src/AcDream.App/Rendering/IAnimationHookCaptureSink.cs
@@ -0,0 +1,36 @@
+using AcDream.App.Rendering.Vfx;
+using AcDream.Core.Physics;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering;
+
+internal interface IAnimationHookCaptureSink
+{
+ void Capture(uint ownerLocalId, AnimationSequencer sequencer);
+}
+
+internal sealed class AnimationHookCaptureSink : IAnimationHookCaptureSink
+{
+ private readonly AnimationHookFrameQueue _queue;
+
+ public AnimationHookCaptureSink(AnimationHookFrameQueue queue) =>
+ _queue = queue ?? throw new ArgumentNullException(nameof(queue));
+
+ public void Capture(uint ownerLocalId, AnimationSequencer sequencer) =>
+ _queue.Capture(ownerLocalId, sequencer);
+}
+
+internal interface IEntityRootPosePublisher
+{
+ void UpdateRoot(WorldEntity entity);
+}
+
+internal sealed class EntityRootPosePublisher : IEntityRootPosePublisher
+{
+ private readonly EntityEffectPoseRegistry _poses;
+
+ public EntityRootPosePublisher(EntityEffectPoseRegistry poses) =>
+ _poses = poses ?? throw new ArgumentNullException(nameof(poses));
+
+ public void UpdateRoot(WorldEntity entity) => _poses.UpdateRoot(entity);
+}
diff --git a/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs b/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs
new file mode 100644
index 00000000..ab75fc0d
--- /dev/null
+++ b/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs
@@ -0,0 +1,46 @@
+using AcDream.App.Input;
+using AcDream.App.Physics;
+using AcDream.App.World;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Resolves the current motion owner without retaining the application window.
+///
+internal sealed class LiveAnimationPresentationContext
+ : ILiveAnimationPresentationContext
+{
+ private readonly LiveEntityRuntime _runtime;
+ private readonly ILocalPlayerIdentitySource _localPlayer;
+ private readonly ILocalPlayerControllerSource _playerController;
+
+ public LiveAnimationPresentationContext(
+ LiveEntityRuntime runtime,
+ ILocalPlayerIdentitySource localPlayer,
+ ILocalPlayerControllerSource playerController)
+ {
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+ _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
+ _playerController = playerController
+ ?? throw new ArgumentNullException(nameof(playerController));
+ }
+
+ public uint LocalPlayerGuid => _localPlayer.ServerGuid;
+
+ public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ if (!_runtime.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
+ || !ReferenceEquals(current, record))
+ {
+ return null;
+ }
+
+ if (record.ServerGuid == _localPlayer.ServerGuid)
+ return _playerController.Controller?.Motion;
+ return record.RemoteMotionRuntime is RemoteMotion remote
+ ? remote.Motion
+ : null;
+ }
+}
diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs
index 3bc51b3a..45768801 100644
--- a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs
+++ b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs
@@ -12,8 +12,8 @@ namespace AcDream.App.Rendering;
///
internal sealed class LiveEntityAnimationPresenter
{
- private readonly Func _liveEntities;
- private readonly Func _staticFrames;
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly ILiveStaticPartFrameSource _staticFrames;
private readonly EntityEffectPoseRegistry _effectPoses;
private readonly ILiveAnimationPresentationContext _context;
private readonly AnimationPresentationDiagnostics _diagnostics;
@@ -22,8 +22,8 @@ internal sealed class LiveEntityAnimationPresenter
private bool _isPresenting;
public LiveEntityAnimationPresenter(
- Func liveEntities,
- Func staticFrames,
+ LiveEntityRuntime liveEntities,
+ ILiveStaticPartFrameSource staticFrames,
EntityEffectPoseRegistry effectPoses,
ILiveAnimationPresentationContext context,
AnimationPresentationDiagnostics diagnostics,
@@ -43,9 +43,8 @@ internal sealed class LiveEntityAnimationPresenter
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(animation);
- LiveEntityRuntime? runtime = _liveEntities();
- if (runtime is null
- || !runtime.IsCurrentSpatialAnimation(record, animation)
+ LiveEntityRuntime runtime = _liveEntities;
+ if (!runtime.IsCurrentSpatialAnimation(record, animation)
|| !ReferenceEquals(record.WorldEntity, animation.Entity)
|| animation.Sequencer is not { MotionDoneTarget: null } sequencer)
{
@@ -55,9 +54,8 @@ internal sealed class LiveEntityAnimationPresenter
WorldEntity capturedEntity = animation.Entity;
sequencer.MotionDoneTarget = (motion, success) =>
{
- LiveEntityRuntime? current = _liveEntities();
- if (current is null
- || !current.IsCurrentSpatialAnimation(record, animation)
+ LiveEntityRuntime current = _liveEntities;
+ if (!current.IsCurrentSpatialAnimation(record, animation)
|| !ReferenceEquals(record.WorldEntity, capturedEntity)
|| !ReferenceEquals(animation.Entity, capturedEntity))
{
@@ -79,8 +77,8 @@ internal sealed class LiveEntityAnimationPresenter
IReadOnlyDictionary schedules)
{
ArgumentNullException.ThrowIfNull(schedules);
- LiveEntityRuntime? runtime = _liveEntities();
- if (runtime is null || _isPresenting)
+ LiveEntityRuntime runtime = _liveEntities;
+ if (_isPresenting)
return;
// EffectPoseChanged is synchronous. Presentation is one consuming
@@ -112,7 +110,7 @@ internal sealed class LiveEntityAnimationPresenter
ulong projectionVersion = record.ProjectionMutationVersion;
ulong presentationRevision = animation.PresentationRevision;
- if (_staticFrames()?.TryTakeLivePartFrames(
+ if (_staticFrames.TryTakeLivePartFrames(
record,
entity,
animation,
diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
index b7b275cf..73a7ea1c 100644
--- a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
+++ b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
@@ -1,4 +1,6 @@
using System.Numerics;
+using AcDream.App.Input;
+using AcDream.App.Rendering.Vfx;
using AcDream.App.Physics;
using AcDream.App.World;
using AcDream.Core.Physics;
@@ -17,41 +19,38 @@ namespace AcDream.App.Rendering;
///
internal sealed class LiveEntityAnimationScheduler
{
- private readonly Func _liveEntities;
- private readonly Func _localPlayerGuid;
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly ILocalPlayerIdentitySource _localPlayer;
private readonly RemotePhysicsUpdater _remotePhysics;
private readonly LiveEntityOrdinaryPhysicsUpdater _ordinaryPhysics;
- private readonly Func _projectiles;
- private readonly Action _publishRootPose;
- private readonly Action _captureAnimationHooks;
+ private readonly ProjectileController _projectiles;
+ private readonly IEntityRootPosePublisher _rootPoses;
+ private readonly IAnimationHookCaptureSink _animationHooks;
private readonly List _rootSnapshot = new();
private readonly Dictionary _schedules = new();
private readonly Frame _rootFrameScratch = new();
private readonly MotionDeltaFrame _rootDeltaScratch = new();
public LiveEntityAnimationScheduler(
- Func liveEntities,
- Func localPlayerGuid,
+ LiveEntityRuntime liveEntities,
+ ILocalPlayerIdentitySource localPlayer,
RemotePhysicsUpdater remotePhysics,
LiveEntityOrdinaryPhysicsUpdater ordinaryPhysics,
- Func projectiles,
- Action publishRootPose,
- Action captureAnimationHooks)
+ ProjectileController projectiles,
+ IEntityRootPosePublisher rootPoses,
+ IAnimationHookCaptureSink animationHooks)
{
_liveEntities = liveEntities
?? throw new ArgumentNullException(nameof(liveEntities));
- _localPlayerGuid = localPlayerGuid
- ?? throw new ArgumentNullException(nameof(localPlayerGuid));
+ _localPlayer = localPlayer
+ ?? throw new ArgumentNullException(nameof(localPlayer));
_remotePhysics = remotePhysics
?? throw new ArgumentNullException(nameof(remotePhysics));
_ordinaryPhysics = ordinaryPhysics
?? throw new ArgumentNullException(nameof(ordinaryPhysics));
- _projectiles = projectiles
- ?? throw new ArgumentNullException(nameof(projectiles));
- _publishRootPose = publishRootPose
- ?? throw new ArgumentNullException(nameof(publishRootPose));
- _captureAnimationHooks = captureAnimationHooks
- ?? throw new ArgumentNullException(nameof(captureAnimationHooks));
+ _projectiles = projectiles ?? throw new ArgumentNullException(nameof(projectiles));
+ _rootPoses = rootPoses ?? throw new ArgumentNullException(nameof(rootPoses));
+ _animationHooks = animationHooks ?? throw new ArgumentNullException(nameof(animationHooks));
}
///
@@ -69,9 +68,7 @@ internal sealed class LiveEntityAnimationScheduler
Action? prepareAnimation = null)
{
_schedules.Clear();
- LiveEntityRuntime? runtime = _liveEntities();
- if (runtime is null)
- return _schedules;
+ LiveEntityRuntime runtime = _liveEntities;
runtime.CopySpatialRootObjectRecordsTo(_rootSnapshot);
foreach (LiveEntityRecord record in _rootSnapshot)
@@ -180,7 +177,7 @@ internal sealed class LiveEntityAnimationScheduler
// PlayerMovementController owns this exact record clock and advances
// its PartArray before local collision. Consume that prepared pose;
// never tick the same clock or sequence a second time here.
- if (serverGuid == _localPlayerGuid())
+ if (serverGuid == _localPlayer.ServerGuid)
{
IReadOnlyList? prepared =
animation?.PreparedSequenceFrames;
@@ -208,7 +205,7 @@ internal sealed class LiveEntityAnimationScheduler
// that are smaller than an admitted object quantum. Publish that
// current root independently of PartArray recomposition so local
// attached effects and lights never trail the rendered character.
- _publishRootPose(entity);
+ _rootPoses.UpdateRoot(entity);
if (!IsCurrent(
runtime,
record,
@@ -249,7 +246,7 @@ internal sealed class LiveEntityAnimationScheduler
IReadOnlyList? frames = null;
float legacyElapsed = 0f;
bool completed = true;
- ProjectileController? projectileController = _projectiles();
+ ProjectileController projectileController = _projectiles;
bool projectileHandlesMovement = projectile is not null
&& projectileController?.HandlesMovement(serverGuid) == true;
float objectScale = animation?.Scale
@@ -275,7 +272,7 @@ internal sealed class LiveEntityAnimationScheduler
entity,
quantum,
sequencer?.Manager,
- _captureAnimationHooks,
+ _animationHooks.Capture,
sequencer,
runtime,
record,
@@ -289,7 +286,7 @@ internal sealed class LiveEntityAnimationScheduler
{
if (sequencer is not null)
{
- _captureAnimationHooks(entity.Id, sequencer);
+ _animationHooks.Capture(entity.Id, sequencer);
if (!IsCurrent(
runtime,
record,
@@ -355,7 +352,7 @@ internal sealed class LiveEntityAnimationScheduler
rootDelta,
liveCenterX,
liveCenterY,
- _captureAnimationHooks,
+ _animationHooks.Capture,
runtime,
record,
objectClockEpoch))
@@ -381,7 +378,7 @@ internal sealed class LiveEntityAnimationScheduler
liveCenterY,
objectClockEpoch,
sequencer,
- _captureAnimationHooks))
+ _animationHooks.Capture))
{
completed = false;
break;
@@ -405,7 +402,7 @@ internal sealed class LiveEntityAnimationScheduler
out projectileStep) == true;
if (!ordinaryBodyHandlesMovement && sequencer is not null)
- _captureAnimationHooks(entity.Id, sequencer);
+ _animationHooks.Capture(entity.Id, sequencer);
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
{
completed = false;
@@ -443,7 +440,7 @@ internal sealed class LiveEntityAnimationScheduler
return default;
}
- _publishRootPose(entity);
+ _rootPoses.UpdateRoot(entity);
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
return default;
diff --git a/src/AcDream.App/Rendering/LiveStaticAnimationResidency.cs b/src/AcDream.App/Rendering/LiveStaticAnimationResidency.cs
new file mode 100644
index 00000000..3738f97c
--- /dev/null
+++ b/src/AcDream.App/Rendering/LiveStaticAnimationResidency.cs
@@ -0,0 +1,34 @@
+using AcDream.App.World;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Resolves static-workset residency against the canonical live incarnation.
+/// DAT-only statics have no server identity and remain resident for their
+/// enclosing landblock resource lifetime.
+///
+internal sealed class LiveStaticAnimationResidency
+{
+ private readonly ILiveEntityRuntimeSource _runtime;
+
+ public LiveStaticAnimationResidency(ILiveEntityRuntimeSource runtime) =>
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+
+ public bool IsResident(WorldEntity entity) =>
+ entity.ServerGuid == 0
+ || (_runtime.Current?.TryGetRecord(entity.ServerGuid, out LiveEntityRecord resident) == true
+ && resident.ProjectionKind is LiveEntityProjectionKind.World
+ && ReferenceEquals(resident.WorldEntity, entity)
+ && resident.IsSpatiallyProjected
+ && resident.IsSpatiallyVisible
+ && resident.FullCellId != 0);
+
+ public ulong ProjectionVersion(WorldEntity entity) =>
+ entity.ServerGuid == 0
+ ? 0UL
+ : _runtime.Current?.TryGetRecord(entity.ServerGuid, out LiveEntityRecord resident) == true
+ && ReferenceEquals(resident.WorldEntity, entity)
+ ? resident.ProjectionMutationVersion
+ : ulong.MaxValue;
+}
diff --git a/src/AcDream.App/Rendering/StaticLiveRootCommitter.cs b/src/AcDream.App/Rendering/StaticLiveRootCommitter.cs
index a41696cc..de9d9e3b 100644
--- a/src/AcDream.App/Rendering/StaticLiveRootCommitter.cs
+++ b/src/AcDream.App/Rendering/StaticLiveRootCommitter.cs
@@ -1,4 +1,5 @@
using AcDream.App.Physics;
+using AcDream.App.Rendering.Vfx;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
@@ -12,30 +13,28 @@ namespace AcDream.App.Rendering;
///
internal sealed class StaticLiveRootCommitter
{
- private readonly Func _runtime;
+ private readonly ILiveEntityRuntimeSource _runtime;
private readonly ShadowObjectRegistry _shadows;
- private readonly Func<(int X, int Y)> _liveCenter;
- private readonly Action _updateEffectRoot;
+ private readonly LiveWorldOriginState _origin;
+ private readonly EntityEffectPoseRegistry _effectPoses;
public StaticLiveRootCommitter(
- Func runtime,
+ ILiveEntityRuntimeSource runtime,
ShadowObjectRegistry shadows,
- Func<(int X, int Y)> liveCenter,
- Action updateEffectRoot)
+ LiveWorldOriginState origin,
+ EntityEffectPoseRegistry effectPoses)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
- _liveCenter = liveCenter
- ?? throw new ArgumentNullException(nameof(liveCenter));
- _updateEffectRoot = updateEffectRoot
- ?? throw new ArgumentNullException(nameof(updateEffectRoot));
+ _origin = origin ?? throw new ArgumentNullException(nameof(origin));
+ _effectPoses = effectPoses ?? throw new ArgumentNullException(nameof(effectPoses));
}
public bool Commit(WorldEntity entity, PhysicsBody body)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(body);
- LiveEntityRuntime? runtime = _runtime();
+ LiveEntityRuntime? runtime = _runtime.Current;
if (runtime is null
|| !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord record)
|| !ReferenceEquals(record.WorldEntity, entity)
@@ -46,15 +45,14 @@ internal sealed class StaticLiveRootCommitter
// The retained pose owner follows the CPhysicsObj root even while its
// mesh is hidden. Hidden is presentation state, not logical teardown.
- _updateEffectRoot(entity);
+ _effectPoses.UpdateRoot(entity);
// EffectPoseChanged observers run synchronously and may delete this
// incarnation (or replace the GUID) before returning. Do not let the
// stale root restore collision shadows for an owner which no longer
// exists. This is the same callback-boundary lifetime rule used by the
// live schedulers and movement controllers.
- if (!ReferenceEquals(_runtime(), runtime)
- || !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord current)
+ if (!runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, record)
|| !ReferenceEquals(current.WorldEntity, entity)
|| !ReferenceEquals(current.PhysicsBody, body))
@@ -76,15 +74,14 @@ internal sealed class StaticLiveRootCommitter
uint cellId = body.CellPosition.ObjCellId != 0
? body.CellPosition.ObjCellId
: record.FullCellId;
- (int centerX, int centerY) = _liveCenter();
ShadowPositionSynchronizer.Sync(
_shadows,
entity.Id,
body.Position,
body.Orientation,
cellId,
- centerX,
- centerY);
+ _origin.CenterX,
+ _origin.CenterY);
return true;
}
}
diff --git a/src/AcDream.App/Update/LiveObjectFrameController.cs b/src/AcDream.App/Update/LiveObjectFrameController.cs
new file mode 100644
index 00000000..21b3739c
--- /dev/null
+++ b/src/AcDream.App/Update/LiveObjectFrameController.cs
@@ -0,0 +1,246 @@
+using System.Numerics;
+using AcDream.App.Input;
+using AcDream.App.Interaction;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Vfx;
+using AcDream.App.World;
+using AcDream.Core.Physics;
+using AcDream.Core.Rendering;
+using AcDream.Core.Vfx;
+using AcDream.Core.World;
+using AcDream.UI.Abstractions.Panels.Settings;
+
+namespace AcDream.App.Update;
+
+internal interface ILiveObjectFramePhase
+{
+ void Tick(float deltaSeconds);
+}
+
+internal interface ILiveSessionFramePhase
+{
+ void Tick();
+}
+
+internal interface IPostNetworkCommandFramePhase
+{
+ void RunPostNetworkCommandPhase();
+}
+
+internal interface ILiveSpatialReconcilePhase
+{
+ void Reconcile();
+}
+
+internal interface IParticleRangeSource
+{
+ float RangeMultiplier { get; }
+}
+
+internal sealed class SettingsParticleRangeSource : IParticleRangeSource
+{
+ private readonly SettingsVM? _settings;
+ private readonly ParticleRange _fallback;
+
+ public SettingsParticleRangeSource(SettingsVM? settings, ParticleRange fallback)
+ {
+ _settings = settings;
+ _fallback = fallback;
+ }
+
+ public float RangeMultiplier =>
+ (_settings?.DisplayDraft.ParticleRange ?? _fallback) == ParticleRange.Extended
+ ? ParticleVisibilityController.ExtendedRangeMultiplier
+ : 1f;
+}
+
+///
+/// Owns the shared effect-manager tail that follows ordinary and static
+/// object worksets in acdream's registered TS-50/TS-51 adaptation.
+///
+internal sealed class LiveEffectFrameController
+{
+ private readonly TranslucencyFadeManager _translucencyFades;
+ private readonly AnimationHookFrameQueue _animationHooks;
+ private readonly EntityEffectController _entityEffects;
+ private readonly ParticleHookSink _particleSink;
+ private readonly LiveEntityLightController _lights;
+ private readonly ParticleVisibilityController _particleVisibility;
+ private readonly ParticleSystem _particles;
+ private readonly PhysicsScriptRunner _scripts;
+ private readonly IPhysicsScriptTimeSource _scriptTime;
+ private readonly IParticleRangeSource _particleRange;
+
+ public LiveEffectFrameController(
+ TranslucencyFadeManager translucencyFades,
+ AnimationHookFrameQueue animationHooks,
+ EntityEffectController entityEffects,
+ ParticleHookSink particleSink,
+ LiveEntityLightController lights,
+ ParticleVisibilityController particleVisibility,
+ ParticleSystem particles,
+ PhysicsScriptRunner scripts,
+ IPhysicsScriptTimeSource scriptTime,
+ IParticleRangeSource particleRange)
+ {
+ _translucencyFades = translucencyFades
+ ?? throw new ArgumentNullException(nameof(translucencyFades));
+ _animationHooks = animationHooks ?? throw new ArgumentNullException(nameof(animationHooks));
+ _entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects));
+ _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
+ _lights = lights ?? throw new ArgumentNullException(nameof(lights));
+ _particleVisibility = particleVisibility
+ ?? throw new ArgumentNullException(nameof(particleVisibility));
+ _particles = particles ?? throw new ArgumentNullException(nameof(particles));
+ _scripts = scripts ?? throw new ArgumentNullException(nameof(scripts));
+ _scriptTime = scriptTime ?? throw new ArgumentNullException(nameof(scriptTime));
+ _particleRange = particleRange ?? throw new ArgumentNullException(nameof(particleRange));
+ }
+
+ public void Tick(float deltaSeconds)
+ {
+ // Retail's ordinary-object UpdatePositionInternal @ 0x00512C30 calls
+ // CPhysicsObj::process_hooks @ 0x00511550 before its manager tail.
+ // acdream currently keeps non-AnimationDone hooks at this deferred
+ // shared boundary under TS-50; this extraction must not silently
+ // claim that adaptation is gone.
+ _translucencyFades.AdvanceAll(deltaSeconds);
+ _animationHooks.Drain();
+ _entityEffects.RefreshLiveOwnerPoses();
+ _particleSink.RefreshAttachedEmitters();
+ _lights.Refresh();
+ _particleVisibility.Apply(_particles, _particleRange.RangeMultiplier);
+
+ // Retail CPhysicsObj::UpdateObjectInternal @ 0x005156B0 advances the
+ // ordinary-object Particle manager before Script. acdream's shared
+ // once-per-host tail and its static-order difference remain TS-51.
+ _particles.Tick(deltaSeconds);
+ _scripts.Tick(_scriptTime.CurrentScriptTime);
+ }
+}
+
+///
+/// Owns the complete pre-network local/ordinary/static live-object phase.
+///
+internal sealed class LiveObjectFrameController : ILiveObjectFramePhase
+{
+ private readonly RetailInboundEventDispatcher _inboundEvents;
+ private readonly RetailLocalPlayerFrameController _localPlayerFrame;
+ private readonly SelectionInteractionController? _selectionInteractions;
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly ILocalPlayerIdentitySource _localPlayer;
+ private readonly LiveWorldOriginState _origin;
+ private readonly LiveEntityAnimationScheduler _animations;
+ private readonly RetailStaticAnimatingObjectScheduler _staticAnimations;
+ private readonly LiveEntityAnimationPresenter _animationPresenter;
+ private readonly LiveEntityAnimationRuntimeView
+ _animatedEntities;
+ private readonly EquippedChildRenderController _equippedChildren;
+ private readonly LiveEffectFrameController _effects;
+
+ public LiveObjectFrameController(
+ RetailInboundEventDispatcher inboundEvents,
+ RetailLocalPlayerFrameController localPlayerFrame,
+ SelectionInteractionController? selectionInteractions,
+ LiveEntityRuntime liveEntities,
+ ILocalPlayerIdentitySource localPlayer,
+ LiveWorldOriginState origin,
+ LiveEntityAnimationScheduler animations,
+ RetailStaticAnimatingObjectScheduler staticAnimations,
+ LiveEntityAnimationPresenter animationPresenter,
+ LiveEntityAnimationRuntimeView animatedEntities,
+ EquippedChildRenderController equippedChildren,
+ LiveEffectFrameController effects)
+ {
+ _inboundEvents = inboundEvents ?? throw new ArgumentNullException(nameof(inboundEvents));
+ _localPlayerFrame = localPlayerFrame
+ ?? throw new ArgumentNullException(nameof(localPlayerFrame));
+ _selectionInteractions = selectionInteractions;
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
+ _origin = origin ?? throw new ArgumentNullException(nameof(origin));
+ _animations = animations ?? throw new ArgumentNullException(nameof(animations));
+ _staticAnimations = staticAnimations
+ ?? throw new ArgumentNullException(nameof(staticAnimations));
+ _animationPresenter = animationPresenter
+ ?? throw new ArgumentNullException(nameof(animationPresenter));
+ _animatedEntities = animatedEntities
+ ?? throw new ArgumentNullException(nameof(animatedEntities));
+ _equippedChildren = equippedChildren
+ ?? throw new ArgumentNullException(nameof(equippedChildren));
+ _effects = effects ?? throw new ArgumentNullException(nameof(effects));
+ }
+
+ public void Tick(float deltaSeconds) =>
+ _inboundEvents.Run(
+ this,
+ deltaSeconds,
+ static (controller, elapsed) => controller.TickCore(elapsed));
+
+ private void TickCore(float deltaSeconds)
+ {
+ _localPlayerFrame.AdvanceBeforeNetwork(deltaSeconds);
+ _selectionInteractions?.DrainOutbound();
+
+ Vector3? playerPosition =
+ _liveEntities.MaterializedWorldEntities.TryGetValue(
+ _localPlayer.ServerGuid,
+ out WorldEntity? player)
+ ? player.Position
+ : null;
+ IReadOnlyDictionary schedules =
+ _animations.Tick(
+ deltaSeconds,
+ playerPosition,
+ _localPlayerFrame.HiddenPartPoseDirty,
+ _origin.CenterX,
+ _origin.CenterY,
+ _animationPresenter.PrepareAnimation);
+
+ // CPhysics::UseTime @ 0x00509950 walks ordinary objects before the
+ // distinct static_animating_objects workset.
+ _staticAnimations.Tick(deltaSeconds);
+ if (_animatedEntities.Count > 0)
+ _animationPresenter.Present(schedules);
+ _equippedChildren.Tick();
+ // Retail CPhysicsObj::animate_static_object @ 0x00513DF0 runs
+ // Script -> Particle -> process_hooks. acdream intentionally retains
+ // the TS-51 static-order difference here: ProcessHooks precedes the
+ // shared once-per-host Particle -> Script tail below.
+ _staticAnimations.ProcessHooks();
+ _effects.Tick(deltaSeconds);
+ }
+}
+
+///
+/// Re-composes spatial derivatives after authoritative root mutations without
+/// advancing animation, physics, effects, particles, scripts, or fades.
+///
+internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcilePhase
+{
+ private readonly EntityEffectController _entityEffects;
+ private readonly EquippedChildRenderController _equippedChildren;
+ private readonly ParticleHookSink _particleSink;
+ private readonly LiveEntityLightController _lights;
+
+ public LiveSpatialPresentationReconciler(
+ EntityEffectController entityEffects,
+ EquippedChildRenderController equippedChildren,
+ ParticleHookSink particleSink,
+ LiveEntityLightController lights)
+ {
+ _entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects));
+ _equippedChildren = equippedChildren
+ ?? throw new ArgumentNullException(nameof(equippedChildren));
+ _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
+ _lights = lights ?? throw new ArgumentNullException(nameof(lights));
+ }
+
+ public void Reconcile()
+ {
+ _entityEffects.RefreshLiveOwnerPoses();
+ _equippedChildren.Tick();
+ _particleSink.RefreshAttachedEmitters();
+ _lights.Refresh();
+ }
+}
diff --git a/src/AcDream.App/World/LiveEntityRuntimeSlot.cs b/src/AcDream.App/World/LiveEntityRuntimeSlot.cs
new file mode 100644
index 00000000..e9c677d3
--- /dev/null
+++ b/src/AcDream.App/World/LiveEntityRuntimeSlot.cs
@@ -0,0 +1,24 @@
+namespace AcDream.App.World;
+
+internal interface ILiveEntityRuntimeSource
+{
+ LiveEntityRuntime? Current { get; }
+}
+
+///
+/// One-time composition bridge for the resource-activation/runtime cycle.
+/// It owns no identity or entity state; all authority remains in the bound
+/// .
+///
+internal sealed class LiveEntityRuntimeSlot : ILiveEntityRuntimeSource
+{
+ public LiveEntityRuntime? Current { get; private set; }
+
+ public void Bind(LiveEntityRuntime runtime)
+ {
+ ArgumentNullException.ThrowIfNull(runtime);
+ if (Current is not null)
+ throw new InvalidOperationException("The live entity runtime is already bound.");
+ Current = runtime;
+ }
+}
diff --git a/src/AcDream.App/World/LiveEntityRuntimeViews.cs b/src/AcDream.App/World/LiveEntityRuntimeViews.cs
index 5d6620e0..0ce8cef0 100644
--- a/src/AcDream.App/World/LiveEntityRuntimeViews.cs
+++ b/src/AcDream.App/World/LiveEntityRuntimeViews.cs
@@ -9,21 +9,21 @@ public sealed class LiveEntityAnimationRuntimeView
: IEnumerable>
where TAnimation : class, ILiveEntityAnimationRuntime
{
- private readonly Func _runtime;
+ private readonly ILiveEntityRuntimeSource _runtime;
private readonly List> _iterationSnapshot = new();
- public LiveEntityAnimationRuntimeView(Func runtime) =>
+ internal LiveEntityAnimationRuntimeView(ILiveEntityRuntimeSource runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
- public int Count => _runtime()?.SpatialAnimationRuntimes.Count ?? 0;
+ public int Count => _runtime.Current?.SpatialAnimationRuntimes.Count ?? 0;
public IEnumerable Keys =>
- _runtime()?.SpatialAnimationRuntimes.Keys ?? Array.Empty();
+ _runtime.Current?.SpatialAnimationRuntimes.Keys ?? Array.Empty();
public TAnimation this[uint localEntityId]
{
set
{
- LiveEntityRuntime runtime = _runtime()
+ LiveEntityRuntime runtime = _runtime.Current
?? throw new InvalidOperationException("Live entity runtime is not initialized.");
if (!runtime.TryGetServerGuid(localEntityId, out uint guid))
throw new InvalidOperationException($"No live entity owns local id 0x{localEntityId:X8}.");
@@ -33,7 +33,7 @@ public sealed class LiveEntityAnimationRuntimeView
public bool TryGetValue(uint localEntityId, out TAnimation animation)
{
- if (_runtime()?.TryGetAnimationRuntime(localEntityId, out var found) == true
+ if (_runtime.Current?.TryGetAnimationRuntime(localEntityId, out var found) == true
&& found is TAnimation typed)
{
animation = typed;
@@ -46,7 +46,7 @@ public sealed class LiveEntityAnimationRuntimeView
public bool Remove(uint localEntityId)
{
- LiveEntityRuntime? runtime = _runtime();
+ LiveEntityRuntime? runtime = _runtime.Current;
return runtime is not null
&& runtime.TryGetServerGuid(localEntityId, out uint guid)
&& runtime.ClearAnimationRuntime(guid);
@@ -57,7 +57,7 @@ public sealed class LiveEntityAnimationRuntimeView
public void CopySpatialIdsTo(HashSet destination)
{
ArgumentNullException.ThrowIfNull(destination);
- LiveEntityRuntime? runtime = _runtime();
+ LiveEntityRuntime? runtime = _runtime.Current;
if (runtime is null)
destination.Clear();
else
@@ -66,7 +66,7 @@ public sealed class LiveEntityAnimationRuntimeView
public Enumerator GetEnumerator()
{
- LiveEntityRuntime? runtime = _runtime();
+ LiveEntityRuntime? runtime = _runtime.Current;
if (runtime is null)
_iterationSnapshot.Clear();
else
@@ -135,21 +135,21 @@ public sealed class LiveEntityAnimationRuntimeView
public sealed class LiveEntityRemoteMotionRuntimeView
where TRemoteMotion : class, ILiveEntityRemoteMotionRuntime
{
- private readonly Func _runtime;
+ private readonly ILiveEntityRuntimeSource _runtime;
- public LiveEntityRemoteMotionRuntimeView(Func runtime) =>
+ internal LiveEntityRemoteMotionRuntimeView(ILiveEntityRuntimeSource runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
public TRemoteMotion this[uint serverGuid]
{
- set => (_runtime()
+ set => (_runtime.Current
?? throw new InvalidOperationException("Live entity runtime is not initialized."))
.SetRemoteMotionRuntime(serverGuid, value);
}
public bool TryGetValue(uint serverGuid, out TRemoteMotion motion)
{
- if (_runtime()?.TryGetRemoteMotionRuntime(serverGuid, out var found) == true
+ if (_runtime.Current?.TryGetRemoteMotionRuntime(serverGuid, out var found) == true
&& found is TRemoteMotion typed)
{
motion = typed;
@@ -161,5 +161,5 @@ public sealed class LiveEntityRemoteMotionRuntimeView
}
public bool Remove(uint serverGuid) =>
- _runtime()?.ClearRemoteMotionRuntime(serverGuid) == true;
+ _runtime.Current?.ClearRemoteMotionRuntime(serverGuid) == true;
}
diff --git a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs
index 7ac15993..6e9cbb80 100644
--- a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs
+++ b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs
@@ -1,4 +1,7 @@
using AcDream.App.Update;
+using AcDream.App.Input;
+using AcDream.App.Net;
+using AcDream.App.Streaming;
namespace AcDream.App.World;
@@ -14,36 +17,37 @@ namespace AcDream.App.World;
/// from freezing an action due to complete this frame while ensuring the
/// periodic AutonomousPosition check observes accepted inbound state.
///
-public sealed class RetailLiveFrameCoordinator
+internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
{
- private readonly Action _advanceObjectRuntime;
- private readonly Action _dispatchInboundNetwork;
- private readonly Action _runPostNetworkCommands;
- private readonly Action _reconcileSpatialPresentation;
+ private readonly ILiveObjectFramePhase _objects;
+ private readonly GpuWorldState _worldState;
+ private readonly ILiveSessionFramePhase _session;
+ private readonly IPostNetworkCommandFramePhase _localPlayer;
+ private readonly ILiveSpatialReconcilePhase _spatialReconciler;
public RetailLiveFrameCoordinator(
- Action advanceObjectRuntime,
- Action dispatchInboundNetwork,
- Action runPostNetworkCommands,
- Action reconcileSpatialPresentation)
+ ILiveObjectFramePhase objects,
+ GpuWorldState worldState,
+ ILiveSessionFramePhase session,
+ IPostNetworkCommandFramePhase localPlayer,
+ ILiveSpatialReconcilePhase spatialReconciler)
{
- _advanceObjectRuntime = advanceObjectRuntime
- ?? throw new ArgumentNullException(nameof(advanceObjectRuntime));
- _dispatchInboundNetwork = dispatchInboundNetwork
- ?? throw new ArgumentNullException(nameof(dispatchInboundNetwork));
- _runPostNetworkCommands = runPostNetworkCommands
- ?? throw new ArgumentNullException(nameof(runPostNetworkCommands));
- _reconcileSpatialPresentation = reconcileSpatialPresentation
- ?? throw new ArgumentNullException(nameof(reconcileSpatialPresentation));
+ _objects = objects ?? throw new ArgumentNullException(nameof(objects));
+ _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
+ _session = session ?? throw new ArgumentNullException(nameof(session));
+ _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
+ _spatialReconciler = spatialReconciler
+ ?? throw new ArgumentNullException(nameof(spatialReconciler));
}
public void Tick(float deltaSeconds)
{
float frameDelta = (float)NormalizeDeltaSeconds(deltaSeconds);
- _advanceObjectRuntime(frameDelta);
- _dispatchInboundNetwork();
- _runPostNetworkCommands();
- _reconcileSpatialPresentation();
+ _objects.Tick(frameDelta);
+ using (_worldState.BeginMutationBatch())
+ _session.Tick();
+ _localPlayer.RunPostNetworkCommandPhase();
+ _spatialReconciler.Reconcile();
}
public static double NormalizeDeltaSeconds(double deltaSeconds) =>
diff --git a/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs b/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs
new file mode 100644
index 00000000..50812877
--- /dev/null
+++ b/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs
@@ -0,0 +1,27 @@
+using AcDream.App.Update;
+
+namespace AcDream.App.Tests;
+
+internal sealed class TestLiveObjectFramePhase(Action tick)
+ : ILiveObjectFramePhase
+{
+ public void Tick(float deltaSeconds) => tick(deltaSeconds);
+}
+
+internal sealed class TestLiveSessionFramePhase(Action tick)
+ : ILiveSessionFramePhase
+{
+ public void Tick() => tick();
+}
+
+internal sealed class TestPostNetworkCommandFramePhase(Action run)
+ : IPostNetworkCommandFramePhase
+{
+ public void RunPostNetworkCommandPhase() => run();
+}
+
+internal sealed class TestLiveSpatialReconcilePhase(Action reconcile)
+ : ILiveSpatialReconcilePhase
+{
+ public void Reconcile() => reconcile();
+}
diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
index 00964469..058c4694 100644
--- a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
+++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
@@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.App.Input;
+using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Physics;
@@ -33,22 +34,23 @@ public sealed class RetailLocalPlayerFrameControllerTests
postNetwork.Add(owner.State);
});
var live = new RetailLiveFrameCoordinator(
- dt =>
+ new TestLiveObjectFramePhase(dt =>
{
calls.Add("objects");
local.AdvanceBeforeNetwork(dt);
- },
- () =>
+ }),
+ new GpuWorldState(),
+ new TestLiveSessionFramePhase(() =>
{
calls.Add("f751");
controller.State = PlayerState.PortalSpace;
- },
- () =>
+ }),
+ new TestPostNetworkCommandFramePhase(() =>
{
calls.Add("commands");
local.RunPostNetworkCommandPhase();
- },
- () => calls.Add("spatial"));
+ }),
+ new TestLiveSpatialReconcilePhase(() => calls.Add("spatial")));
// CPhysicsObj::update_object admits manager time only after the strict
// minimum quantum. Use one admitted object tick; the test's concern is
@@ -118,10 +120,12 @@ public sealed class RetailLocalPlayerFrameControllerTests
sendPreNetwork: (_, _, _) => { },
sendPostNetwork: (_, _) => { });
var live = new RetailLiveFrameCoordinator(
- local.AdvanceBeforeNetwork,
- () => projectedRoot = new Vector3(999f, 999f, 999f),
- local.RunPostNetworkCommandPhase,
- () => { });
+ new TestLiveObjectFramePhase(local.AdvanceBeforeNetwork),
+ new GpuWorldState(),
+ new TestLiveSessionFramePhase(
+ () => projectedRoot = new Vector3(999f, 999f, 999f)),
+ local,
+ new TestLiveSpatialReconcilePhase(() => { }));
live.Tick(1f / 60f);
float timeAfterOneTick = controller.SimTimeSeconds;
diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs
index f0fd81a4..7d6644df 100644
--- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs
+++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs
@@ -1430,6 +1430,24 @@ public sealed class ProjectileControllerTests
private sealed class Fixture
{
+ private sealed class SetupResolver : IProjectileSetupResolver
+ {
+ private readonly Func _resolve;
+
+ internal SetupResolver(Func resolve) => _resolve = resolve;
+
+ public Setup? Resolve(uint setupId) => _resolve();
+ }
+
+ private sealed class RootPosePublisher : IEntityRootPosePublisher
+ {
+ private readonly Action _publish;
+
+ internal RootPosePublisher(Action publish) => _publish = publish;
+
+ public void UpdateRoot(WorldEntity entity) => _publish(entity);
+ }
+
internal Fixture(
bool loadInitialLandblock = true,
PhysicsEngine? physicsEngine = null,
@@ -1439,12 +1457,15 @@ public sealed class ProjectileControllerTests
Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
Live = new LiveEntityRuntime(Spatial, Resources);
Engine = physicsEngine ?? new PhysicsEngine();
+ Origin.Recenter(1, 1);
Controller = new ProjectileController(
Live,
Engine,
- _ => ResolvedSetup,
- publishRootPose ?? (entity => Poses.UpdateRoot(entity)),
- () => LiveCenter);
+ new SetupResolver(() => ResolvedSetup),
+ new RootPosePublisher(
+ publishRootPose ?? new Action(
+ entity => { _ = Poses.UpdateRoot(entity); })),
+ Origin);
}
internal GpuWorldState Spatial { get; } = new();
@@ -1452,8 +1473,8 @@ public sealed class ProjectileControllerTests
internal LiveEntityRuntime Live { get; }
internal PhysicsEngine Engine { get; }
internal EntityEffectPoseRegistry Poses { get; } = new();
+ internal LiveWorldOriginState Origin { get; } = new();
internal Setup ResolvedSetup { get; set; } = ProjectileSetup();
- internal (int X, int Y) LiveCenter { get; set; } = (1, 1);
internal ProjectileController Controller { get; }
internal LiveEntityRecord Spawn(
diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs
index 334d7dc3..0450e6d3 100644
--- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs
@@ -364,13 +364,29 @@ public sealed class LiveEntityAnimationPresenterTests
LiveEntityRuntime live,
EntityEffectPoseRegistry poses,
Context context) => new(
- () => live,
- () => null,
+ live,
+ new EmptyStaticPartFrameSource(),
poses,
context,
AnimationPresentationDiagnostics.Disabled,
hidePartIndex: -1);
+ private sealed class EmptyStaticPartFrameSource : ILiveStaticPartFrameSource
+ {
+ public bool TryTakeLivePartFrames(
+ LiveEntityRecord record,
+ WorldEntity entity,
+ LiveEntityAnimationState animation,
+ ulong objectClockEpoch,
+ ulong projectionMutationVersion,
+ ulong presentationRevision,
+ out IReadOnlyList partFrames)
+ {
+ partFrames = Array.Empty();
+ return false;
+ }
+ }
+
private static IReadOnlyDictionary Schedules(
Fixture fixture,
IReadOnlyList frames) =>
diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs
index c81d5a2b..ca8d646b 100644
--- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs
@@ -1,12 +1,15 @@
using System.Numerics;
+using AcDream.App.Input;
using AcDream.App.Physics;
using AcDream.App.Rendering;
+using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
+using AcDream.Core.Vfx;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
@@ -629,14 +632,42 @@ public sealed class LiveEntityAnimationSchedulerTests
var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater(
physics,
(_, _) => (0.48f, 1.835f));
+ var identity = new LocalPlayerIdentityState { ServerGuid = localPlayerGuid };
+ var poses = new EntityEffectPoseRegistry();
+ foreach (WorldEntity entity in live.MaterializedWorldEntities.Values)
+ poses.PublishMeshRefs(entity);
+ IAnimationHookCaptureSink animationHooks = captureHooks is null
+ ? new AnimationHookCaptureSink(new AnimationHookFrameQueue(
+ new AnimationHookRouter(),
+ poses))
+ : new TestAnimationHookCaptureSink(captureHooks);
+ projectiles ??= new ProjectileController(live, physics);
return new LiveEntityAnimationScheduler(
- () => live,
- () => localPlayerGuid,
+ live,
+ identity,
remotePhysics,
ordinaryPhysics,
- () => projectiles,
- publishRootPose ?? (_ => { }),
- captureHooks ?? ((_, _) => { }));
+ projectiles,
+ new TestRootPosePublisher(poses, publishRootPose),
+ animationHooks);
+ }
+
+ private sealed class TestAnimationHookCaptureSink(
+ Action capture) : IAnimationHookCaptureSink
+ {
+ public void Capture(uint ownerLocalId, AnimationSequencer sequencer) =>
+ capture(ownerLocalId, sequencer);
+ }
+
+ private sealed class TestRootPosePublisher(
+ EntityEffectPoseRegistry poses,
+ Action? published) : IEntityRootPosePublisher
+ {
+ public void UpdateRoot(WorldEntity entity)
+ {
+ poses.UpdateRoot(entity);
+ published?.Invoke(entity);
+ }
}
private static (LiveEntityRuntime Live, LiveEntityRecord Record,
diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
index ab53bcac..52460ba3 100644
--- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
+++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
@@ -47,6 +47,10 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData("DispatchRemoteInboundMotion")]
[InlineData("CreateRemoteMotion")]
[InlineData("WillAdvanceRemoteMotion")]
+ [InlineData("AdvanceLiveObjectRuntime")]
+ [InlineData("AdvanceLiveObjectRuntimeCore")]
+ [InlineData("ReconcileLiveObjectSpatialPresentation")]
+ [InlineData("CaptureAnimationHooks")]
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
{
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
@@ -73,6 +77,13 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData(typeof(LiveEntityMotionRuntimeController))]
[InlineData(typeof(LiveEntityInboundAuthorityGate))]
[InlineData(typeof(DeferredLiveEntityMotionRuntimeBindings))]
+ [InlineData(typeof(AcDream.App.Update.LiveObjectFrameController))]
+ [InlineData(typeof(AcDream.App.Update.LiveEffectFrameController))]
+ [InlineData(typeof(AcDream.App.Update.LiveSpatialPresentationReconciler))]
+ [InlineData(typeof(LiveEntityAnimationPresenter))]
+ [InlineData(typeof(LiveAnimationPresentationContext))]
+ [InlineData(typeof(StaticLiveRootCommitter))]
+ [InlineData(typeof(RetailLiveFrameCoordinator))]
public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType)
{
foreach (FieldInfo field in helperType.GetFields(
@@ -83,6 +94,89 @@ public sealed class GameWindowLiveEntityCompositionTests
string typeName = field.FieldType.FullName ?? field.FieldType.Name;
Assert.DoesNotContain("Silk.NET", typeName, StringComparison.Ordinal);
Assert.DoesNotContain("OpenGL", typeName, StringComparison.OrdinalIgnoreCase);
+ Assert.NotEqual(typeof(GameWindow), field.FieldType);
}
}
+
+ [Fact]
+ public void ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks()
+ {
+ Type[] owners =
+ [
+ typeof(AcDream.App.Update.LiveObjectFrameController),
+ typeof(AcDream.App.Update.LiveEffectFrameController),
+ typeof(AcDream.App.Update.LiveSpatialPresentationReconciler),
+ typeof(LiveEntityAnimationScheduler),
+ typeof(LiveEntityAnimationPresenter),
+ typeof(LiveAnimationPresentationContext),
+ typeof(StaticLiveRootCommitter),
+ typeof(RetailLiveFrameCoordinator),
+ ];
+
+ foreach (Type owner in owners)
+ {
+ Assert.DoesNotContain(
+ owner.GetFields(BindingFlags.Instance | BindingFlags.NonPublic),
+ field => typeof(Delegate).IsAssignableFrom(field.FieldType));
+ }
+
+ Assert.False(typeof(ILiveAnimationPresentationContext).IsAssignableFrom(
+ typeof(GameWindow)));
+ }
+
+ [Fact]
+ public void RuntimeViewsAndProjectileController_RetainTypedSourcesNotWindowClosures()
+ {
+ FieldInfo animationRuntime = Assert.Single(
+ typeof(LiveEntityAnimationRuntimeView)
+ .GetFields(BindingFlags.Instance | BindingFlags.NonPublic),
+ field => field.Name == "_runtime");
+ FieldInfo remoteRuntime = Assert.Single(
+ typeof(LiveEntityRemoteMotionRuntimeView)
+ .GetFields(BindingFlags.Instance | BindingFlags.NonPublic),
+ field => field.Name == "_runtime");
+ Assert.Equal(typeof(ILiveEntityRuntimeSource), animationRuntime.FieldType);
+ Assert.Equal(typeof(ILiveEntityRuntimeSource), remoteRuntime.FieldType);
+
+ FieldInfo[] projectileFields = typeof(ProjectileController).GetFields(
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.Equal(
+ typeof(IProjectileSetupResolver),
+ Assert.Single(projectileFields, field => field.Name == "_setupResolver").FieldType);
+ Assert.Equal(
+ typeof(IEntityRootPosePublisher),
+ Assert.Single(projectileFields, field => field.Name == "_rootPoses").FieldType);
+ Assert.Equal(
+ typeof(LiveWorldOriginState),
+ Assert.Single(projectileFields, field => field.Name == "_origin").FieldType);
+ Assert.DoesNotContain(
+ projectileFields,
+ field => typeof(Delegate).IsAssignableFrom(field.FieldType)
+ && !field.Name.Contains("DiagnosticSink", StringComparison.Ordinal));
+
+ string source = File.ReadAllText(Path.Combine(
+ FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs"));
+ Assert.DoesNotContain(
+ "new LiveEntityAnimationRuntimeView(() =>",
+ source,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "new LiveEntityRemoteMotionRuntimeView(() =>",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains("new AcDream.App.Physics.DatProjectileSetupResolver", source);
+ }
+
+ 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.");
+ }
}
diff --git a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs
index 20195c3d..4e5080ac 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs
@@ -202,10 +202,7 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests
lifecycle);
Projectiles = new ProjectileController(
Live,
- Physics,
- _ => null,
- _ => { },
- () => (0, 0));
+ Physics);
Controller = new LiveEntityProjectionWithdrawalController(
Live,
Projectiles,
diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
index dfa9a0d4..f0dd7c8d 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
@@ -17,6 +17,20 @@ namespace AcDream.App.Tests.World;
public sealed class LiveEntityRuntimeTests
{
+ [Fact]
+ public void RuntimeSlot_BindsExactlyOnceWithoutOwningEntityState()
+ {
+ var spatial = new GpuWorldState();
+ var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
+ var slot = new LiveEntityRuntimeSlot();
+
+ Assert.Null(slot.Current);
+ slot.Bind(runtime);
+
+ Assert.Same(runtime, slot.Current);
+ Assert.Throws(() => slot.Bind(runtime));
+ }
+
private sealed class RecordingResources : ILiveEntityResourceLifecycle
{
public int RegisterCount { get; private set; }
@@ -245,11 +259,18 @@ public sealed class LiveEntityRuntimeTests
landblockId: 0x01010000u,
seedCellId: 0x01010001u);
int poseUpdates = 0;
+ var runtimeSlot = new LiveEntityRuntimeSlot();
+ runtimeSlot.Bind(runtime);
+ var origin = new LiveWorldOriginState();
+ origin.Recenter(1, 1);
+ var poses = new EntityEffectPoseRegistry();
+ poses.PublishMeshRefs(entity);
+ poses.EffectPoseChanged += _ => poseUpdates++;
var committer = new StaticLiveRootCommitter(
- () => runtime,
+ runtimeSlot,
shadows,
- () => (1, 1),
- _ => poseUpdates++);
+ origin,
+ poses);
body.SetFrameInCurrentCell(
body.Position,
@@ -282,13 +303,14 @@ public sealed class LiveEntityRuntimeTests
[Fact]
public void StaticRootCommit_BeforeLiveRuntimeCompositionIsSafeNoOp()
{
- LiveEntityRuntime? runtime = null;
int poseUpdates = 0;
+ var poses = new EntityEffectPoseRegistry();
+ poses.EffectPoseChanged += _ => poseUpdates++;
var committer = new StaticLiveRootCommitter(
- () => runtime,
+ new LiveEntityRuntimeSlot(),
new ShadowObjectRegistry(),
- () => (0, 0),
- _ => poseUpdates++);
+ new LiveWorldOriginState(),
+ poses);
Assert.False(committer.Commit(
Entity(0x7F000001u, 0x70000001u),
@@ -347,26 +369,33 @@ public sealed class LiveEntityRuntimeTests
worldOffsetY: 0f,
landblockId: 0x01010000u,
seedCellId: 0x01010001u);
+ var runtimeSlot = new LiveEntityRuntimeSlot();
+ runtimeSlot.Bind(runtime);
+ var origin = new LiveWorldOriginState();
+ origin.Recenter(1, 1);
+ var poses = new EntityEffectPoseRegistry();
+ poses.PublishMeshRefs(oldEntity);
+ poses.EffectPoseChanged += _ =>
+ {
+ Assert.True(shadows.Suspend(oldEntity.Id));
+ Assert.True(runtime.UnregisterLiveEntity(
+ new DeleteObject.Parsed(guid, InstanceSequence: 1),
+ isLocalPlayer: false));
+ runtime.RegisterLiveEntity(Spawn(
+ guid,
+ instance: 2,
+ positionSequence: 1,
+ cell: 0x01010001u));
+ runtime.MaterializeLiveEntity(
+ guid,
+ 0x01010001u,
+ id => Entity(id, guid));
+ };
var committer = new StaticLiveRootCommitter(
- () => runtime,
+ runtimeSlot,
shadows,
- () => (1, 1),
- _ =>
- {
- Assert.True(shadows.Suspend(oldEntity.Id));
- Assert.True(runtime.UnregisterLiveEntity(
- new DeleteObject.Parsed(guid, InstanceSequence: 1),
- isLocalPlayer: false));
- runtime.RegisterLiveEntity(Spawn(
- guid,
- instance: 2,
- positionSequence: 1,
- cell: 0x01010001u));
- runtime.MaterializeLiveEntity(
- guid,
- 0x01010001u,
- id => Entity(id, guid));
- });
+ origin,
+ poses);
oldBody.SetFrameInCurrentCell(
oldBody.Position,
@@ -453,7 +482,7 @@ public sealed class LiveEntityRuntimeTests
id => Entity(id, guid))!;
var animation = new AnimationRuntime(entity);
runtime.SetAnimationRuntime(guid, animation);
- var view = new LiveEntityAnimationRuntimeView(() => runtime);
+ var view = new LiveEntityAnimationRuntimeView(BoundSlot(runtime));
Assert.Equal(1, view.Count);
Assert.Equal(entity.Id, Assert.Single(view.Keys));
@@ -489,7 +518,7 @@ public sealed class LiveEntityRuntimeTests
id => Entity(id, guid))!;
runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity));
}
- var view = new LiveEntityAnimationRuntimeView(() => runtime);
+ var view = new LiveEntityAnimationRuntimeView(BoundSlot(runtime));
var visited = new List();
foreach (KeyValuePair pair in view)
@@ -519,7 +548,7 @@ public sealed class LiveEntityRuntimeTests
id => Entity(id, guid))!;
runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity));
var view = new LiveEntityAnimationRuntimeView(
- () => runtime);
+ BoundSlot(runtime));
var ids = new HashSet();
view.CopySpatialIdsTo(ids);
@@ -2803,6 +2832,13 @@ public sealed class LiveEntityRuntimeTests
private static LoadedLandblock EmptyLandblock(uint canonicalId) =>
new(canonicalId, new LandBlock(), Array.Empty());
+ private static LiveEntityRuntimeSlot BoundSlot(LiveEntityRuntime runtime)
+ {
+ var slot = new LiveEntityRuntimeSlot();
+ slot.Bind(runtime);
+ return slot;
+ }
+
private static void ResolveParent(LiveEntityRuntime runtime, uint childGuid) =>
runtime.ParentAttachments.Resolve(
childGuid,
diff --git a/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs b/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs
new file mode 100644
index 00000000..fdb9982c
--- /dev/null
+++ b/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs
@@ -0,0 +1,84 @@
+using AcDream.App.Rendering.Vfx;
+using AcDream.App.Update;
+using AcDream.UI.Abstractions.Input;
+using AcDream.UI.Abstractions.Panels.Settings;
+using Silk.NET.Input;
+
+namespace AcDream.App.Tests.World;
+
+public sealed class LiveObjectFrameControllerTests
+{
+ [Theory]
+ [InlineData(ParticleRange.Retail, 1f)]
+ [InlineData(
+ ParticleRange.Extended,
+ ParticleVisibilityController.ExtendedRangeMultiplier)]
+ public void ParticleRange_NullSettingsUsesConfiguredFallback(
+ ParticleRange fallback,
+ float expected)
+ {
+ var source = new SettingsParticleRangeSource(null, fallback);
+
+ Assert.Equal(expected, source.RangeMultiplier);
+ }
+
+ [Fact]
+ public void ParticleRange_LiveSettingsDraftOverridesFallbackAndUpdatesImmediately()
+ {
+ SettingsVM settings = CreateSettings();
+ var source = new SettingsParticleRangeSource(settings, ParticleRange.Retail);
+
+ settings.SetDisplay(settings.DisplayDraft with { ParticleRange = ParticleRange.Retail });
+ Assert.Equal(1f, source.RangeMultiplier);
+
+ settings.SetDisplay(settings.DisplayDraft with { ParticleRange = ParticleRange.Extended });
+ Assert.Equal(
+ ParticleVisibilityController.ExtendedRangeMultiplier,
+ source.RangeMultiplier);
+ }
+
+ private static SettingsVM CreateSettings()
+ {
+ var dispatcher = new InputDispatcher(
+ new NullKeyboardSource(),
+ new NullMouseSource(),
+ new KeyBindings());
+ return new SettingsVM(
+ new KeyBindings(),
+ dispatcher,
+ static _ => { },
+ DisplaySettings.Default,
+ static _ => { },
+ AudioSettings.Default,
+ static _ => { },
+ GameplaySettings.Default,
+ static _ => { },
+ ChatSettings.Default,
+ static _ => { },
+ CharacterSettings.Default,
+ static _ => { });
+ }
+
+ private sealed class NullKeyboardSource : IKeyboardSource
+ {
+#pragma warning disable CS0067
+ public event Action? KeyDown;
+ public event Action? KeyUp;
+#pragma warning restore CS0067
+ public bool IsHeld(Key key) => false;
+ public ModifierMask CurrentModifiers => ModifierMask.None;
+ }
+
+ private sealed class NullMouseSource : IMouseSource
+ {
+#pragma warning disable CS0067
+ public event Action? MouseDown;
+ public event Action? MouseUp;
+ public event Action? MouseMove;
+ public event Action? Scroll;
+#pragma warning restore CS0067
+ public bool IsHeld(MouseButton button) => false;
+ public bool WantCaptureMouse => false;
+ public bool WantCaptureKeyboard => false;
+ }
+}
diff --git a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
index d1810824..a57ae719 100644
--- a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
+++ b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
@@ -1,9 +1,11 @@
using System.Numerics;
using AcDream.App.Rendering;
+using AcDream.App.Streaming;
using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Content.Vfx;
using AcDream.Core.Physics;
+using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
@@ -25,10 +27,11 @@ public sealed class RecallTeleportAnimationTests
{
var calls = new List();
var frame = new RetailLiveFrameCoordinator(
- _ => calls.Add("objects"),
- () => calls.Add("network"),
- () => calls.Add("commands"),
- () => calls.Add("spatial"));
+ new TestLiveObjectFramePhase(_ => calls.Add("objects")),
+ new GpuWorldState(),
+ new TestLiveSessionFramePhase(() => calls.Add("network")),
+ new TestPostNetworkCommandFramePhase(() => calls.Add("commands")),
+ new TestLiveSpatialReconcilePhase(() => calls.Add("spatial")));
frame.Tick(1f / 60f);
@@ -39,25 +42,96 @@ public sealed class RecallTeleportAnimationTests
public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch()
{
var deltas = new List();
- int networkDispatches = 0;
+ var calls = new List();
var frame = new RetailLiveFrameCoordinator(
- deltas.Add,
- () => networkDispatches++,
- () => { },
- () => { });
+ new TestLiveObjectFramePhase(delta =>
+ {
+ deltas.Add(delta);
+ calls.Add("objects");
+ }),
+ new GpuWorldState(),
+ new TestLiveSessionFramePhase(() => calls.Add("network")),
+ new TestPostNetworkCommandFramePhase(() => calls.Add("commands")),
+ new TestLiveSpatialReconcilePhase(() => calls.Add("spatial")));
frame.Tick(float.NaN);
frame.Tick(float.PositiveInfinity);
frame.Tick(-1f);
Assert.Equal([0f, 0f, 0f], deltas);
- Assert.Equal(3, networkDispatches);
+ Assert.Equal(
+ Enumerable.Repeat(new[] { "objects", "network", "commands", "spatial" }, 3)
+ .SelectMany(static frameCalls => frameCalls),
+ calls);
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]
+ public void LiveFrame_CommitsTheInboundMutationBatchBeforeCommandsAndReconcile()
+ {
+ const uint landblock = 0x0101FFFFu;
+ var calls = new List();
+ var world = new GpuWorldState();
+ long before = world.VisibilityCommitCount;
+ var frame = new RetailLiveFrameCoordinator(
+ new TestLiveObjectFramePhase(_ => calls.Add("objects")),
+ world,
+ new TestLiveSessionFramePhase(() =>
+ {
+ calls.Add("network");
+ world.PlaceLiveEntityProjection(landblock, Entity(1));
+ Assert.Equal(before, world.VisibilityCommitCount);
+ }),
+ new TestPostNetworkCommandFramePhase(() =>
+ {
+ calls.Add("commands");
+ Assert.Equal(before + 1, world.VisibilityCommitCount);
+ }),
+ new TestLiveSpatialReconcilePhase(() =>
+ {
+ calls.Add("spatial");
+ Assert.Equal(before + 1, world.VisibilityCommitCount);
+ }));
+
+ frame.Tick(1f / 60f);
+
+ Assert.Equal(["objects", "network", "commands", "spatial"], calls);
+ Assert.Equal(before + 1, world.VisibilityCommitCount);
+ Assert.Equal(1, world.PendingLiveEntityCount);
+ }
+
+ [Fact]
+ public void LiveFrame_SessionFailureDisposesMutationBatchAndSuppressesLaterPhases()
+ {
+ const uint landblock = 0x0101FFFFu;
+ var calls = new List();
+ var world = new GpuWorldState();
+ long before = world.VisibilityCommitCount;
+ var frame = new RetailLiveFrameCoordinator(
+ new TestLiveObjectFramePhase(_ => calls.Add("objects")),
+ world,
+ new TestLiveSessionFramePhase(() =>
+ {
+ calls.Add("network");
+ world.PlaceLiveEntityProjection(landblock, Entity(2));
+ Assert.Equal(before, world.VisibilityCommitCount);
+ throw new InvalidOperationException("inbound failure");
+ }),
+ new TestPostNetworkCommandFramePhase(() => calls.Add("commands")),
+ new TestLiveSpatialReconcilePhase(() => calls.Add("spatial")));
+
+ InvalidOperationException error = Assert.Throws(
+ () => frame.Tick(1f / 60f));
+
+ Assert.Equal("inbound failure", error.Message);
+ Assert.Equal(["objects", "network"], calls);
+ Assert.Equal(before + 1, world.VisibilityCommitCount);
+ Assert.Equal(1, world.PendingLiveEntityCount);
+ }
+
[Fact]
public void InstalledRecall_HiddenEnterWorldPublishesFinishedCyclicPoseWithoutAdvancingTime()
{
@@ -108,4 +182,14 @@ public sealed class RecallTeleportAnimationTests
Quaternion.Normalize(recallTail[index].Orientation),
Quaternion.Normalize(finishedPose[index].Orientation))) < 0.999999f);
}
+
+ private static WorldEntity Entity(uint id) => new()
+ {
+ Id = id,
+ ServerGuid = 0x70000000u + id,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ };
}
diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
index e22cf03c..64e35436 100644
--- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
+++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
@@ -228,6 +228,16 @@ public sealed class UpdateFrameOrchestratorTests
FieldInfo[] ownerFields = owner.GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.DoesNotContain(ownerFields, field => field.FieldType == typeof(GameWindow));
+ if (owner == typeof(AcDream.App.Input.RetailLocalPlayerFrameController))
+ {
+ // Checkpoint B deliberately leaves this single legacy callback
+ // composition for D/F, which own input capture and the mutable
+ // player-mode/controller slot. No other phase owner may add one.
+ Assert.Contains(
+ ownerFields,
+ field => typeof(Delegate).IsAssignableFrom(field.FieldType));
+ continue;
+ }
Assert.DoesNotContain(
ownerFields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
@@ -274,6 +284,71 @@ public sealed class UpdateFrameOrchestratorTests
Assert.Equal(1, source.Split("PublishTime(", StringSplitOptions.None).Length - 1);
}
+ [Fact]
+ public void ExtractedLiveObjectSource_PinsRetailAndRegisteredAdaptationOrder()
+ {
+ string source = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "src",
+ "AcDream.App",
+ "Update",
+ "LiveObjectFrameController.cs"));
+
+ AssertAppearsInOrder(
+ source,
+ "_localPlayerFrame.AdvanceBeforeNetwork",
+ "_selectionInteractions?.DrainOutbound",
+ "_animations.Tick",
+ "_staticAnimations.Tick",
+ "_animationPresenter.Present",
+ "_equippedChildren.Tick",
+ "_staticAnimations.ProcessHooks",
+ "_effects.Tick");
+ AssertAppearsInOrder(
+ source,
+ "_translucencyFades.AdvanceAll",
+ "_animationHooks.Drain",
+ "_entityEffects.RefreshLiveOwnerPoses",
+ "_particleSink.RefreshAttachedEmitters",
+ "_lights.Refresh",
+ "_particleVisibility.Apply",
+ "_particles.Tick",
+ "_scripts.Tick");
+ AssertAppearsInOrder(
+ source,
+ "public void Reconcile()",
+ "_entityEffects.RefreshLiveOwnerPoses",
+ "_equippedChildren.Tick",
+ "_particleSink.RefreshAttachedEmitters",
+ "_lights.Refresh");
+
+ Assert.Equal(1, CountOccurrences(source, "_staticAnimations.Tick("));
+ Assert.Equal(1, CountOccurrences(source, "_staticAnimations.ProcessHooks("));
+ Assert.Equal(1, CountOccurrences(source, "_effects.Tick("));
+ Assert.Equal(1, CountOccurrences(source, "_particles.Tick("));
+ Assert.Equal(1, CountOccurrences(source, "_scripts.Tick("));
+ }
+
+ [Fact]
+ public void GameWindow_ComposesTheLiveFrameOwnersWithoutOwningTheirBodies()
+ {
+ string source = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "src",
+ "AcDream.App",
+ "Rendering",
+ "GameWindow.cs"));
+
+ Assert.Contains("new AcDream.App.Update.LiveObjectFrameController(", source);
+ Assert.Contains("new AcDream.App.Update.LiveSpatialPresentationReconciler(", source);
+ Assert.Contains("new AcDream.App.World.RetailLiveFrameCoordinator(", source);
+ Assert.DoesNotContain("AdvanceLiveObjectRuntime", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("ReconcileLiveObjectSpatialPresentation", source,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("ILiveAnimationPresentationContext", source,
+ StringComparison.Ordinal);
+ }
+
private static UpdateFrameOrchestrator Create(
List calls,
RecordingTeardown? teardown = null,
@@ -432,4 +507,19 @@ public sealed class UpdateFrameOrchestratorTests
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
+
+ private static void AssertAppearsInOrder(string source, params string[] markers)
+ {
+ int previous = -1;
+ foreach (string marker in markers)
+ {
+ int current = source.IndexOf(marker, previous + 1, StringComparison.Ordinal);
+ Assert.True(current >= 0, $"Missing source marker: {marker}");
+ Assert.True(current > previous, $"Out-of-order source marker: {marker}");
+ previous = current;
+ }
+ }
+
+ private static int CountOccurrences(string source, string marker) =>
+ source.Split(marker, StringSplitOptions.None).Length - 1;
}