From 4e4aac2c5a2a6e45f854353746e84f9ac4183a31 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 00:42:26 +0200 Subject: [PATCH] refactor(runtime): extract the live object frame --- ...ndow-slice-6-update-frame-orchestration.md | 17 +- .../Input/LocalPlayerRuntimeState.cs | 30 ++ .../Input/RetailLocalPlayerFrameController.cs | 4 +- src/AcDream.App/Net/LiveSessionController.cs | 3 +- .../Physics/ProjectileController.cs | 58 +++- src/AcDream.App/Rendering/GameWindow.cs | 292 ++++++------------ .../Rendering/IAnimationHookCaptureSink.cs | 36 +++ .../LiveAnimationPresentationContext.cs | 46 +++ .../Rendering/LiveEntityAnimationPresenter.cs | 24 +- .../Rendering/LiveEntityAnimationScheduler.cs | 57 ++-- .../Rendering/LiveStaticAnimationResidency.cs | 34 ++ .../Rendering/StaticLiveRootCommitter.cs | 31 +- .../Update/LiveObjectFrameController.cs | 246 +++++++++++++++ .../World/LiveEntityRuntimeSlot.cs | 24 ++ .../World/LiveEntityRuntimeViews.cs | 28 +- .../World/RetailLiveFrameCoordinator.cs | 46 +-- .../FramePhaseTestDoubles.cs | 27 ++ .../RetailLocalPlayerFrameControllerTests.cs | 26 +- .../Physics/ProjectileControllerTests.cs | 29 +- .../LiveEntityAnimationPresenterTests.cs | 20 +- .../LiveEntityAnimationSchedulerTests.cs | 41 ++- .../GameWindowLiveEntityCompositionTests.cs | 94 ++++++ ...tityProjectionWithdrawalControllerTests.cs | 5 +- .../World/LiveEntityRuntimeTests.cs | 92 ++++-- .../World/LiveObjectFrameControllerTests.cs | 84 +++++ .../World/RecallTeleportAnimationTests.cs | 104 ++++++- .../World/UpdateFrameOrchestratorTests.cs | 90 ++++++ 27 files changed, 1217 insertions(+), 371 deletions(-) create mode 100644 src/AcDream.App/Input/LocalPlayerRuntimeState.cs create mode 100644 src/AcDream.App/Rendering/IAnimationHookCaptureSink.cs create mode 100644 src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs create mode 100644 src/AcDream.App/Rendering/LiveStaticAnimationResidency.cs create mode 100644 src/AcDream.App/Update/LiveObjectFrameController.cs create mode 100644 src/AcDream.App/World/LiveEntityRuntimeSlot.cs create mode 100644 tests/AcDream.App.Tests/FramePhaseTestDoubles.cs create mode 100644 tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md index 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; }