refactor(runtime): extract the live object frame

This commit is contained in:
Erik 2026-07-22 00:42:26 +02:00
parent 99a3e819c4
commit 4e4aac2c5a
27 changed files with 1217 additions and 371 deletions

View file

@ -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 - [x] A — freeze the complete production phase graph and introduce the typed
orchestration contract plus structural/order guards. orchestration contract plus structural/order guards.
- [ ] B — extract the pre-network live-object presentation phase and the - [x] B — extract the pre-network live-object presentation phase and the
non-advancing post-network spatial reconciler; remove callbacks into non-advancing post-network spatial reconciler; remove callbacks into
`GameWindow` from `RetailLiveFrameCoordinator`. `GameWindow` from `RetailLiveFrameCoordinator`.
- [ ] C — extract streaming-origin convergence, observer selection, dungeon - [ ] 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 18 focused Release tests and the full 2,709-test App project pass, and all
three corrected-diff reviews are clean. 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 ## 1. Outcome and non-goals
At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one
@ -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 the one published PES clock remains visible from hook capture through the
script tick. 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 ### C — streaming frame
- extract readiness/recenter advancement, observer selection, dungeon gate, - extract readiness/recenter advancement, observer selection, dungeon gate,

View file

@ -0,0 +1,30 @@
using AcDream.Core.Physics;
namespace AcDream.App.Input;
internal interface ILocalPlayerIdentitySource
{
uint ServerGuid { get; }
}
/// <summary>
/// Session-scoped identity slot shared by focused update owners.
/// </summary>
internal sealed class LocalPlayerIdentityState : ILocalPlayerIdentitySource
{
public uint ServerGuid { get; set; }
}
internal interface ILocalPlayerControllerSource
{
PlayerMovementController? Controller { get; }
}
/// <summary>
/// The one mutable local movement-controller slot. Player-mode lifecycle owns
/// assignment; update and presentation owners receive the read-only seam.
/// </summary>
internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource
{
public PlayerMovementController? Controller { get; set; }
}

View file

@ -1,3 +1,5 @@
using AcDream.App.Update;
namespace AcDream.App.Input; namespace AcDream.App.Input;
/// <summary> /// <summary>
@ -10,7 +12,7 @@ namespace AcDream.App.Input;
/// state. The cached input result is presentation-only after the barrier: its /// state. The cached input result is presentation-only after the barrier: its
/// one-shot commands are never replayed against a later authoritative cell. /// one-shot commands are never replayed against a later authoritative cell.
/// </remarks> /// </remarks>
public sealed class RetailLocalPlayerFrameController internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFramePhase
{ {
private readonly Func<bool> _canPresentPlayer; private readonly Func<bool> _canPresentPlayer;
private readonly Func<PlayerMovementController?> _getController; private readonly Func<PlayerMovementController?> _getController;

View file

@ -2,6 +2,7 @@ using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.App.Update;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
namespace AcDream.App.Net; namespace AcDream.App.Net;
@ -157,7 +158,7 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
/// publication, exact-generation Tick, graceful replacement, and convergent /// publication, exact-generation Tick, graceful replacement, and convergent
/// teardown. Retail wire behavior remains inside WorldSession. /// teardown. Retail wire behavior remains inside WorldSession.
/// </summary> /// </summary>
public sealed class LiveSessionController : IDisposable internal sealed class LiveSessionController : IDisposable, ILiveSessionFramePhase
{ {
private sealed class SessionScope( private sealed class SessionScope(
WorldSession session, WorldSession session,

View file

@ -1,5 +1,7 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Content;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
@ -9,6 +11,29 @@ using RemoteMotion = AcDream.App.Physics.RemoteMotion;
namespace AcDream.App.Physics; 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<Setup>(setupId);
}
}
/// <summary> /// <summary>
/// Update-thread presentation owner for live retail missiles. Logical identity, /// Update-thread presentation owner for live retail missiles. Logical identity,
/// generation, accepted network state, and teardown remain owned by /// generation, accepted network state, and teardown remain owned by
@ -56,26 +81,26 @@ internal sealed class ProjectileController
private readonly LiveEntityRuntime _liveEntities; private readonly LiveEntityRuntime _liveEntities;
private readonly ProjectilePhysicsStepper _stepper; private readonly ProjectilePhysicsStepper _stepper;
private readonly ShadowObjectRegistry _shadows; private readonly ShadowObjectRegistry _shadows;
private readonly Func<uint, Setup?> _setupResolver; private readonly IProjectileSetupResolver? _setupResolver;
private readonly Action<WorldEntity> _publishRootPose; private readonly IEntityRootPosePublisher? _rootPoses;
private readonly Func<(int X, int Y)> _liveCenterProvider; private readonly LiveWorldOriginState? _origin;
private readonly List<LiveEntityRecord> _spatialProjectileSnapshot = new(); private readonly List<LiveEntityRecord> _spatialProjectileSnapshot = new();
private double _lastFiniteGameTime; private double _lastFiniteGameTime;
internal ProjectileController( internal ProjectileController(
LiveEntityRuntime liveEntities, LiveEntityRuntime liveEntities,
PhysicsEngine physicsEngine, PhysicsEngine physicsEngine,
Func<uint, Setup?>? setupResolver = null, IProjectileSetupResolver? setupResolver = null,
Action<WorldEntity>? publishRootPose = null, IEntityRootPosePublisher? rootPoses = null,
Func<(int X, int Y)>? liveCenterProvider = null) LiveWorldOriginState? origin = null)
{ {
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
ArgumentNullException.ThrowIfNull(physicsEngine); ArgumentNullException.ThrowIfNull(physicsEngine);
_stepper = new ProjectilePhysicsStepper(physicsEngine); _stepper = new ProjectilePhysicsStepper(physicsEngine);
_shadows = physicsEngine.ShadowObjects; _shadows = physicsEngine.ShadowObjects;
_setupResolver = setupResolver ?? (_ => null); _setupResolver = setupResolver;
_publishRootPose = publishRootPose ?? (_ => { }); _rootPoses = rootPoses;
_liveCenterProvider = liveCenterProvider ?? (() => (0, 0)); _origin = origin;
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
} }
@ -322,7 +347,7 @@ internal sealed class ProjectileController
{ {
SuspendOutsideWorld(runtime, entity.Id); SuspendOutsideWorld(runtime, entity.Id);
} }
_publishRootPose(entity); _rootPoses?.UpdateRoot(entity);
return true; return true;
} }
@ -472,7 +497,7 @@ internal sealed class ProjectileController
// A newer State packet can make an already materialized object a // A newer State packet can make an already materialized object a
// missile. TryBind adopts an existing MovementManager body so the // missile. TryBind adopts an existing MovementManager body so the
// live record still represents retail's one CPhysicsObj. // live record still represents retail's one CPhysicsObj.
Setup? setup = _setupResolver(entity.SourceGfxObjOrSetupId); Setup? setup = _setupResolver?.Resolve(entity.SourceGfxObjOrSetupId);
if (setup is null) if (setup is null)
{ {
DiagnosticSink?.Invoke( DiagnosticSink?.Invoke(
@ -617,7 +642,7 @@ internal sealed class ProjectileController
{ {
SuspendOutsideWorld(runtime, entity.Id); SuspendOutsideWorld(runtime, entity.Id);
} }
_publishRootPose(entity); _rootPoses?.UpdateRoot(entity);
return true; return true;
} }
@ -712,7 +737,7 @@ internal sealed class ProjectileController
record.FullCellId, record.FullCellId,
liveCenterX, liveCenterX,
liveCenterY); liveCenterY);
_publishRootPose(entity); _rootPoses?.UpdateRoot(entity);
} }
if (IsHidden(record)) if (IsHidden(record))
@ -909,7 +934,7 @@ internal sealed class ProjectileController
current.FullCellId, current.FullCellId,
liveCenterX, liveCenterX,
liveCenterY); liveCenterY);
_publishRootPose(entity); _rootPoses?.UpdateRoot(entity);
if (!IsCurrentQuantumIdentity(step)) if (!IsCurrentQuantumIdentity(step))
return false; return false;
} }
@ -966,7 +991,8 @@ internal sealed class ProjectileController
// not lazily on the next simulated quantum: a zero-velocity or // not lazily on the next simulated quantum: a zero-velocity or
// newly ordinary retained projectile may have no later projectile // newly ordinary retained projectile may have no later projectile
// step from which to repair its shadow membership. // 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( ShadowPositionSynchronizer.Sync(
_shadows, _shadows,
entity.Id, entity.Id,
@ -975,7 +1001,7 @@ internal sealed class ProjectileController
record.FullCellId, record.FullCellId,
liveCenterX, liveCenterX,
liveCenterY); liveCenterY);
_publishRootPose(entity); _rootPoses?.UpdateRoot(entity);
return; return;
} }

View file

@ -10,7 +10,7 @@ using Silk.NET.Windowing;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext public sealed class GameWindow : IDisposable
{ {
private static double ClientTimerNow() => private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp() System.Diagnostics.Stopwatch.GetTimestamp()
@ -152,12 +152,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_remoteInboundMotion; _remoteInboundMotion;
private readonly AcDream.App.World.RetailInboundEventDispatcher private readonly AcDream.App.World.RetailInboundEventDispatcher
_inboundEntityEvents = new(); _inboundEntityEvents = new();
private readonly LiveEntityAnimationScheduler _liveAnimationScheduler; private LiveEntityAnimationScheduler _liveAnimationScheduler = null!;
private readonly LiveEntityAnimationPresenter _animationPresenter; private LiveEntityAnimationPresenter _animationPresenter = null!;
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator; private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!;
private AcDream.App.Update.LiveSpatialPresentationReconciler
_liveSpatialReconciler = null!;
private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new(); private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new();
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map; // Step 7 projectile presentation. The controller owns no identity map;
@ -247,6 +249,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// appear in this map. /// appear in this map.
/// </summary> /// </summary>
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animatedEntities; private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animatedEntities;
private readonly AcDream.App.World.LiveEntityRuntimeSlot _liveEntityRuntimeSlot = new();
// MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys. // MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys.
// WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically // WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically
@ -520,11 +523,21 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private int _weatherDebugStep = 0; private int _weatherDebugStep = 0;
// Phase B.2: player movement mode. // 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.ChaseCamera? _chaseCamera;
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera; private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
private bool _playerMode; 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 // Retail Default_CharacterOption (acclient.h:3434). PlayerDescription
// replaces this before any in-world drag can normally occur. // replaces this before any in-world drag can normally occur.
private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 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)); _selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry; _uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(() => _liveEntities); _animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(
_liveEntityRuntimeSlot);
SpellBook = new AcDream.Core.Spells.Spellbook(); SpellBook = new AcDream.Core.Spells.Spellbook();
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
// #184 Slice 2a: the extracted per-remote DR tick. Its stateful // #184 Slice 2a: the extracted per-remote DR tick. Its stateful
@ -761,25 +775,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
movement, cellId, update), movement, cellId, update),
(host, targetGuid) => (host, targetGuid) =>
_liveEntityMotionBindings.StickToObjectFromWire(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( _localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController(
resolveEntity: () => resolveEntity: () =>
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
@ -824,16 +819,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
objectClockDisposition: () => objectClockDisposition: () =>
_liveEntities?.GetRootObjectClockDisposition(_playerServerGuid) _liveEntities?.GetRootObjectClockDisposition(_playerServerGuid)
?? AcDream.Core.Physics.RetailObjectClockDisposition.Advance); ?? 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() 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. // dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale.
AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null; AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null;
var staticLiveRootCommitter = new StaticLiveRootCommitter( var staticLiveRootCommitter = new StaticLiveRootCommitter(
() => _liveEntities, _liveEntityRuntimeSlot,
_physicsEngine.ShadowObjects, _physicsEngine.ShadowObjects,
() => (_liveCenterX, _liveCenterY), _liveWorldOrigin,
entity => _effectPoses.UpdateRoot(entity)); _effectPoses);
var staticAnimationResidency = new LiveStaticAnimationResidency(
_liveEntityRuntimeSlot);
_staticAnimationScheduler = new RetailStaticAnimatingObjectScheduler( _staticAnimationScheduler = new RetailStaticAnimatingObjectScheduler(
capturedAnimLoader!, capturedAnimLoader!,
CaptureAnimationHooks, _animationHookFrames!.Capture,
(entity, poses, availability) => _effectPoses.Publish,
_effectPoses.Publish(entity, poses, availability), staticAnimationResidency.IsResident,
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),
(entity, body) => (entity, body) =>
_ = staticLiveRootCommitter.Commit(entity, body), _ = staticLiveRootCommitter.Commit(entity, body),
entity => entity.ServerGuid == 0 staticAnimationResidency.ProjectionVersion);
? 0UL
: _liveEntities?.TryGetRecord(
entity.ServerGuid,
out LiveEntityRecord resident) == true
&& ReferenceEquals(resident.WorldEntity, entity)
? resident.ProjectionMutationVersion
: ulong.MaxValue);
AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e) AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e)
{ {
try try
@ -2183,6 +2154,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
entityScriptActivator.OnCreate, entityScriptActivator.OnCreate,
entityScriptActivator.OnRemove)), entityScriptActivator.OnRemove)),
liveEntityComponentLifecycle); liveEntityComponentLifecycle);
_liveEntityRuntimeSlot.Bind(_liveEntities);
liveEntityMotionRuntime = liveEntityMotionRuntime =
new AcDream.App.Physics.LiveEntityMotionRuntimeController( new AcDream.App.Physics.LiveEntityMotionRuntimeController(
_liveEntities, _liveEntities,
@ -2204,17 +2176,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_projectileController = new AcDream.App.Physics.ProjectileController( _projectileController = new AcDream.App.Physics.ProjectileController(
_liveEntities, _liveEntities,
_physicsEngine, _physicsEngine,
setupId => new AcDream.App.Physics.DatProjectileSetupResolver(_dats!, _datLock),
{ new EntityRootPosePublisher(_effectPoses),
// State can classify an already-live object as Missile _liveWorldOrigin)
// 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<DatReaderWriter.DBObjs.Setup>(setupId);
},
entity => _effectPoses.UpdateRoot(entity),
() => (_liveCenterX, _liveCenterY))
{ {
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"), DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
}; };
@ -2233,6 +2197,29 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_lightingSink!, _lightingSink!,
setupId => _dats!.Get<DatReaderWriter.DBObjs.Setup>(setupId)); setupId => _dats!.Get<DatReaderWriter.DBObjs.Setup>(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(); parentAcceptance = new AcDream.App.World.DeferredLiveEntityParentAcceptance();
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController( _equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!, _dats!,
@ -2627,7 +2614,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_remoteTeleportController!, _remoteTeleportController!,
_animatedEntities, _animatedEntities,
new LiveEntityRemoteMotionRuntimeView<RemoteMotion>( new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(
() => _liveEntities), _liveEntityRuntimeSlot),
_remoteMovementObservations, _remoteMovementObservations,
_remotePhysicsUpdater, _remotePhysicsUpdater,
_remoteInboundMotion, _remoteInboundMotion,
@ -2667,6 +2654,44 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged. // gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
_liveWorldOrigin.SetPlaceholder(centerX, centerY); _liveWorldOrigin.SetPlaceholder(centerX, centerY);
_liveSessionController = new AcDream.App.Net.LiveSessionController(); _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 = AcDream.App.Net.LiveSessionStartResult liveStart =
_liveSessionController.Start( _liveSessionController.Start(
_options, _options,
@ -3520,7 +3545,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// camera path re-extends the boom. Updating directly from the stale // camera path re-extends the boom. Updating directly from the stale
// source-world viewer made the destination bend across the reveal. // source-world viewer made the destination bend across the reveal.
_retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw); _retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw);
ReconcileLiveObjectSpatialPresentation(); _liveSpatialReconciler.Reconcile();
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}"); "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. // The player was materialized by this frame's inbound pass.
// Its non-advancing initial projection must publish child and // Its non-advancing initial projection must publish child and
// effect anchors before the first draw too. // effect anchors before the first draw too.
ReconcileLiveObjectSpatialPresentation(); _liveSpatialReconciler.Reconcile();
} }
// Update chase camera(s). The CameraController exposes whichever // Update chase camera(s). The CameraController exposes whichever
@ -5715,102 +5740,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
LastFrameProfile: _frameProfiler.LastReport); LastFrameProfile: _frameProfiler.LastReport);
} }
/// <summary>
/// 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.
/// </summary>
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<uint, LiveEntityAnimationSchedule> 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);
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary> /// <summary>
/// Phase 6.4: advance every animated entity's frame counter by /// Phase 6.4: advance every animated entity's frame counter by
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's /// <paramref name="dt"/> * Framerate, wrapping around the cycle's
@ -5855,33 +5784,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
&& _animatedEntities.TryGetValue(entity.Id, out var ae) && _animatedEntities.TryGetValue(entity.Id, out var ae)
&& ae.Sequencer is { } sequencer) && 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 // IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass
// narrowing that dropped settled-open doors / faded-out walls back onto the // 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 // stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the

View file

@ -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);
}

View file

@ -0,0 +1,46 @@
using AcDream.App.Input;
using AcDream.App.Physics;
using AcDream.App.World;
using AcDream.Core.Physics;
namespace AcDream.App.Rendering;
/// <summary>
/// Resolves the current motion owner without retaining the application window.
/// </summary>
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;
}
}

View file

@ -12,8 +12,8 @@ namespace AcDream.App.Rendering;
/// </summary> /// </summary>
internal sealed class LiveEntityAnimationPresenter internal sealed class LiveEntityAnimationPresenter
{ {
private readonly Func<LiveEntityRuntime?> _liveEntities; private readonly LiveEntityRuntime _liveEntities;
private readonly Func<ILiveStaticPartFrameSource?> _staticFrames; private readonly ILiveStaticPartFrameSource _staticFrames;
private readonly EntityEffectPoseRegistry _effectPoses; private readonly EntityEffectPoseRegistry _effectPoses;
private readonly ILiveAnimationPresentationContext _context; private readonly ILiveAnimationPresentationContext _context;
private readonly AnimationPresentationDiagnostics _diagnostics; private readonly AnimationPresentationDiagnostics _diagnostics;
@ -22,8 +22,8 @@ internal sealed class LiveEntityAnimationPresenter
private bool _isPresenting; private bool _isPresenting;
public LiveEntityAnimationPresenter( public LiveEntityAnimationPresenter(
Func<LiveEntityRuntime?> liveEntities, LiveEntityRuntime liveEntities,
Func<ILiveStaticPartFrameSource?> staticFrames, ILiveStaticPartFrameSource staticFrames,
EntityEffectPoseRegistry effectPoses, EntityEffectPoseRegistry effectPoses,
ILiveAnimationPresentationContext context, ILiveAnimationPresentationContext context,
AnimationPresentationDiagnostics diagnostics, AnimationPresentationDiagnostics diagnostics,
@ -43,9 +43,8 @@ internal sealed class LiveEntityAnimationPresenter
{ {
ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(animation); ArgumentNullException.ThrowIfNull(animation);
LiveEntityRuntime? runtime = _liveEntities(); LiveEntityRuntime runtime = _liveEntities;
if (runtime is null if (!runtime.IsCurrentSpatialAnimation(record, animation)
|| !runtime.IsCurrentSpatialAnimation(record, animation)
|| !ReferenceEquals(record.WorldEntity, animation.Entity) || !ReferenceEquals(record.WorldEntity, animation.Entity)
|| animation.Sequencer is not { MotionDoneTarget: null } sequencer) || animation.Sequencer is not { MotionDoneTarget: null } sequencer)
{ {
@ -55,9 +54,8 @@ internal sealed class LiveEntityAnimationPresenter
WorldEntity capturedEntity = animation.Entity; WorldEntity capturedEntity = animation.Entity;
sequencer.MotionDoneTarget = (motion, success) => sequencer.MotionDoneTarget = (motion, success) =>
{ {
LiveEntityRuntime? current = _liveEntities(); LiveEntityRuntime current = _liveEntities;
if (current is null if (!current.IsCurrentSpatialAnimation(record, animation)
|| !current.IsCurrentSpatialAnimation(record, animation)
|| !ReferenceEquals(record.WorldEntity, capturedEntity) || !ReferenceEquals(record.WorldEntity, capturedEntity)
|| !ReferenceEquals(animation.Entity, capturedEntity)) || !ReferenceEquals(animation.Entity, capturedEntity))
{ {
@ -79,8 +77,8 @@ internal sealed class LiveEntityAnimationPresenter
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules) IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules)
{ {
ArgumentNullException.ThrowIfNull(schedules); ArgumentNullException.ThrowIfNull(schedules);
LiveEntityRuntime? runtime = _liveEntities(); LiveEntityRuntime runtime = _liveEntities;
if (runtime is null || _isPresenting) if (_isPresenting)
return; return;
// EffectPoseChanged is synchronous. Presentation is one consuming // EffectPoseChanged is synchronous. Presentation is one consuming
@ -112,7 +110,7 @@ internal sealed class LiveEntityAnimationPresenter
ulong projectionVersion = record.ProjectionMutationVersion; ulong projectionVersion = record.ProjectionMutationVersion;
ulong presentationRevision = animation.PresentationRevision; ulong presentationRevision = animation.PresentationRevision;
if (_staticFrames()?.TryTakeLivePartFrames( if (_staticFrames.TryTakeLivePartFrames(
record, record,
entity, entity,
animation, animation,

View file

@ -1,4 +1,6 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Physics; using AcDream.Core.Physics;
@ -17,41 +19,38 @@ namespace AcDream.App.Rendering;
/// </summary> /// </summary>
internal sealed class LiveEntityAnimationScheduler internal sealed class LiveEntityAnimationScheduler
{ {
private readonly Func<LiveEntityRuntime?> _liveEntities; private readonly LiveEntityRuntime _liveEntities;
private readonly Func<uint> _localPlayerGuid; private readonly ILocalPlayerIdentitySource _localPlayer;
private readonly RemotePhysicsUpdater _remotePhysics; private readonly RemotePhysicsUpdater _remotePhysics;
private readonly LiveEntityOrdinaryPhysicsUpdater _ordinaryPhysics; private readonly LiveEntityOrdinaryPhysicsUpdater _ordinaryPhysics;
private readonly Func<ProjectileController?> _projectiles; private readonly ProjectileController _projectiles;
private readonly Action<WorldEntity> _publishRootPose; private readonly IEntityRootPosePublisher _rootPoses;
private readonly Action<uint, AnimationSequencer> _captureAnimationHooks; private readonly IAnimationHookCaptureSink _animationHooks;
private readonly List<LiveEntityRecord> _rootSnapshot = new(); private readonly List<LiveEntityRecord> _rootSnapshot = new();
private readonly Dictionary<uint, LiveEntityAnimationSchedule> _schedules = new(); private readonly Dictionary<uint, LiveEntityAnimationSchedule> _schedules = new();
private readonly Frame _rootFrameScratch = new(); private readonly Frame _rootFrameScratch = new();
private readonly MotionDeltaFrame _rootDeltaScratch = new(); private readonly MotionDeltaFrame _rootDeltaScratch = new();
public LiveEntityAnimationScheduler( public LiveEntityAnimationScheduler(
Func<LiveEntityRuntime?> liveEntities, LiveEntityRuntime liveEntities,
Func<uint> localPlayerGuid, ILocalPlayerIdentitySource localPlayer,
RemotePhysicsUpdater remotePhysics, RemotePhysicsUpdater remotePhysics,
LiveEntityOrdinaryPhysicsUpdater ordinaryPhysics, LiveEntityOrdinaryPhysicsUpdater ordinaryPhysics,
Func<ProjectileController?> projectiles, ProjectileController projectiles,
Action<WorldEntity> publishRootPose, IEntityRootPosePublisher rootPoses,
Action<uint, AnimationSequencer> captureAnimationHooks) IAnimationHookCaptureSink animationHooks)
{ {
_liveEntities = liveEntities _liveEntities = liveEntities
?? throw new ArgumentNullException(nameof(liveEntities)); ?? throw new ArgumentNullException(nameof(liveEntities));
_localPlayerGuid = localPlayerGuid _localPlayer = localPlayer
?? throw new ArgumentNullException(nameof(localPlayerGuid)); ?? throw new ArgumentNullException(nameof(localPlayer));
_remotePhysics = remotePhysics _remotePhysics = remotePhysics
?? throw new ArgumentNullException(nameof(remotePhysics)); ?? throw new ArgumentNullException(nameof(remotePhysics));
_ordinaryPhysics = ordinaryPhysics _ordinaryPhysics = ordinaryPhysics
?? throw new ArgumentNullException(nameof(ordinaryPhysics)); ?? throw new ArgumentNullException(nameof(ordinaryPhysics));
_projectiles = projectiles _projectiles = projectiles ?? throw new ArgumentNullException(nameof(projectiles));
?? throw new ArgumentNullException(nameof(projectiles)); _rootPoses = rootPoses ?? throw new ArgumentNullException(nameof(rootPoses));
_publishRootPose = publishRootPose _animationHooks = animationHooks ?? throw new ArgumentNullException(nameof(animationHooks));
?? throw new ArgumentNullException(nameof(publishRootPose));
_captureAnimationHooks = captureAnimationHooks
?? throw new ArgumentNullException(nameof(captureAnimationHooks));
} }
/// <summary> /// <summary>
@ -69,9 +68,7 @@ internal sealed class LiveEntityAnimationScheduler
Action<LiveEntityRecord, LiveEntityAnimationState>? prepareAnimation = null) Action<LiveEntityRecord, LiveEntityAnimationState>? prepareAnimation = null)
{ {
_schedules.Clear(); _schedules.Clear();
LiveEntityRuntime? runtime = _liveEntities(); LiveEntityRuntime runtime = _liveEntities;
if (runtime is null)
return _schedules;
runtime.CopySpatialRootObjectRecordsTo(_rootSnapshot); runtime.CopySpatialRootObjectRecordsTo(_rootSnapshot);
foreach (LiveEntityRecord record in _rootSnapshot) foreach (LiveEntityRecord record in _rootSnapshot)
@ -180,7 +177,7 @@ internal sealed class LiveEntityAnimationScheduler
// PlayerMovementController owns this exact record clock and advances // PlayerMovementController owns this exact record clock and advances
// its PartArray before local collision. Consume that prepared pose; // its PartArray before local collision. Consume that prepared pose;
// never tick the same clock or sequence a second time here. // never tick the same clock or sequence a second time here.
if (serverGuid == _localPlayerGuid()) if (serverGuid == _localPlayer.ServerGuid)
{ {
IReadOnlyList<PartTransform>? prepared = IReadOnlyList<PartTransform>? prepared =
animation?.PreparedSequenceFrames; animation?.PreparedSequenceFrames;
@ -208,7 +205,7 @@ internal sealed class LiveEntityAnimationScheduler
// that are smaller than an admitted object quantum. Publish that // that are smaller than an admitted object quantum. Publish that
// current root independently of PartArray recomposition so local // current root independently of PartArray recomposition so local
// attached effects and lights never trail the rendered character. // attached effects and lights never trail the rendered character.
_publishRootPose(entity); _rootPoses.UpdateRoot(entity);
if (!IsCurrent( if (!IsCurrent(
runtime, runtime,
record, record,
@ -249,7 +246,7 @@ internal sealed class LiveEntityAnimationScheduler
IReadOnlyList<PartTransform>? frames = null; IReadOnlyList<PartTransform>? frames = null;
float legacyElapsed = 0f; float legacyElapsed = 0f;
bool completed = true; bool completed = true;
ProjectileController? projectileController = _projectiles(); ProjectileController projectileController = _projectiles;
bool projectileHandlesMovement = projectile is not null bool projectileHandlesMovement = projectile is not null
&& projectileController?.HandlesMovement(serverGuid) == true; && projectileController?.HandlesMovement(serverGuid) == true;
float objectScale = animation?.Scale float objectScale = animation?.Scale
@ -275,7 +272,7 @@ internal sealed class LiveEntityAnimationScheduler
entity, entity,
quantum, quantum,
sequencer?.Manager, sequencer?.Manager,
_captureAnimationHooks, _animationHooks.Capture,
sequencer, sequencer,
runtime, runtime,
record, record,
@ -289,7 +286,7 @@ internal sealed class LiveEntityAnimationScheduler
{ {
if (sequencer is not null) if (sequencer is not null)
{ {
_captureAnimationHooks(entity.Id, sequencer); _animationHooks.Capture(entity.Id, sequencer);
if (!IsCurrent( if (!IsCurrent(
runtime, runtime,
record, record,
@ -355,7 +352,7 @@ internal sealed class LiveEntityAnimationScheduler
rootDelta, rootDelta,
liveCenterX, liveCenterX,
liveCenterY, liveCenterY,
_captureAnimationHooks, _animationHooks.Capture,
runtime, runtime,
record, record,
objectClockEpoch)) objectClockEpoch))
@ -381,7 +378,7 @@ internal sealed class LiveEntityAnimationScheduler
liveCenterY, liveCenterY,
objectClockEpoch, objectClockEpoch,
sequencer, sequencer,
_captureAnimationHooks)) _animationHooks.Capture))
{ {
completed = false; completed = false;
break; break;
@ -405,7 +402,7 @@ internal sealed class LiveEntityAnimationScheduler
out projectileStep) == true; out projectileStep) == true;
if (!ordinaryBodyHandlesMovement && sequencer is not null) if (!ordinaryBodyHandlesMovement && sequencer is not null)
_captureAnimationHooks(entity.Id, sequencer); _animationHooks.Capture(entity.Id, sequencer);
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
{ {
completed = false; completed = false;
@ -443,7 +440,7 @@ internal sealed class LiveEntityAnimationScheduler
return default; return default;
} }
_publishRootPose(entity); _rootPoses.UpdateRoot(entity);
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
return default; return default;

View file

@ -0,0 +1,34 @@
using AcDream.App.World;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// 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.
/// </summary>
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;
}

View file

@ -1,4 +1,5 @@
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.Rendering.Vfx;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World; using AcDream.Core.World;
@ -12,30 +13,28 @@ namespace AcDream.App.Rendering;
/// </summary> /// </summary>
internal sealed class StaticLiveRootCommitter internal sealed class StaticLiveRootCommitter
{ {
private readonly Func<LiveEntityRuntime?> _runtime; private readonly ILiveEntityRuntimeSource _runtime;
private readonly ShadowObjectRegistry _shadows; private readonly ShadowObjectRegistry _shadows;
private readonly Func<(int X, int Y)> _liveCenter; private readonly LiveWorldOriginState _origin;
private readonly Action<WorldEntity> _updateEffectRoot; private readonly EntityEffectPoseRegistry _effectPoses;
public StaticLiveRootCommitter( public StaticLiveRootCommitter(
Func<LiveEntityRuntime?> runtime, ILiveEntityRuntimeSource runtime,
ShadowObjectRegistry shadows, ShadowObjectRegistry shadows,
Func<(int X, int Y)> liveCenter, LiveWorldOriginState origin,
Action<WorldEntity> updateEffectRoot) EntityEffectPoseRegistry effectPoses)
{ {
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_liveCenter = liveCenter _origin = origin ?? throw new ArgumentNullException(nameof(origin));
?? throw new ArgumentNullException(nameof(liveCenter)); _effectPoses = effectPoses ?? throw new ArgumentNullException(nameof(effectPoses));
_updateEffectRoot = updateEffectRoot
?? throw new ArgumentNullException(nameof(updateEffectRoot));
} }
public bool Commit(WorldEntity entity, PhysicsBody body) public bool Commit(WorldEntity entity, PhysicsBody body)
{ {
ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(body); ArgumentNullException.ThrowIfNull(body);
LiveEntityRuntime? runtime = _runtime(); LiveEntityRuntime? runtime = _runtime.Current;
if (runtime is null if (runtime is null
|| !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord record) || !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord record)
|| !ReferenceEquals(record.WorldEntity, entity) || !ReferenceEquals(record.WorldEntity, entity)
@ -46,15 +45,14 @@ internal sealed class StaticLiveRootCommitter
// The retained pose owner follows the CPhysicsObj root even while its // The retained pose owner follows the CPhysicsObj root even while its
// mesh is hidden. Hidden is presentation state, not logical teardown. // mesh is hidden. Hidden is presentation state, not logical teardown.
_updateEffectRoot(entity); _effectPoses.UpdateRoot(entity);
// EffectPoseChanged observers run synchronously and may delete this // EffectPoseChanged observers run synchronously and may delete this
// incarnation (or replace the GUID) before returning. Do not let the // incarnation (or replace the GUID) before returning. Do not let the
// stale root restore collision shadows for an owner which no longer // stale root restore collision shadows for an owner which no longer
// exists. This is the same callback-boundary lifetime rule used by the // exists. This is the same callback-boundary lifetime rule used by the
// live schedulers and movement controllers. // live schedulers and movement controllers.
if (!ReferenceEquals(_runtime(), runtime) if (!runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord current)
|| !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, record) || !ReferenceEquals(current, record)
|| !ReferenceEquals(current.WorldEntity, entity) || !ReferenceEquals(current.WorldEntity, entity)
|| !ReferenceEquals(current.PhysicsBody, body)) || !ReferenceEquals(current.PhysicsBody, body))
@ -76,15 +74,14 @@ internal sealed class StaticLiveRootCommitter
uint cellId = body.CellPosition.ObjCellId != 0 uint cellId = body.CellPosition.ObjCellId != 0
? body.CellPosition.ObjCellId ? body.CellPosition.ObjCellId
: record.FullCellId; : record.FullCellId;
(int centerX, int centerY) = _liveCenter();
ShadowPositionSynchronizer.Sync( ShadowPositionSynchronizer.Sync(
_shadows, _shadows,
entity.Id, entity.Id,
body.Position, body.Position,
body.Orientation, body.Orientation,
cellId, cellId,
centerX, _origin.CenterX,
centerY); _origin.CenterY);
return true; return true;
} }
} }

View file

@ -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;
}
/// <summary>
/// Owns the shared effect-manager tail that follows ordinary and static
/// object worksets in acdream's registered TS-50/TS-51 adaptation.
/// </summary>
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);
}
}
/// <summary>
/// Owns the complete pre-network local/ordinary/static live-object phase.
/// </summary>
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<LiveEntityAnimationState>
_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<LiveEntityAnimationState> 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<uint, LiveEntityAnimationSchedule> 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);
}
}
/// <summary>
/// Re-composes spatial derivatives after authoritative root mutations without
/// advancing animation, physics, effects, particles, scripts, or fades.
/// </summary>
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();
}
}

View file

@ -0,0 +1,24 @@
namespace AcDream.App.World;
internal interface ILiveEntityRuntimeSource
{
LiveEntityRuntime? Current { get; }
}
/// <summary>
/// One-time composition bridge for the resource-activation/runtime cycle.
/// It owns no identity or entity state; all authority remains in the bound
/// <see cref="LiveEntityRuntime"/>.
/// </summary>
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;
}
}

View file

@ -9,21 +9,21 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
: IEnumerable<KeyValuePair<uint, TAnimation>> : IEnumerable<KeyValuePair<uint, TAnimation>>
where TAnimation : class, ILiveEntityAnimationRuntime where TAnimation : class, ILiveEntityAnimationRuntime
{ {
private readonly Func<LiveEntityRuntime?> _runtime; private readonly ILiveEntityRuntimeSource _runtime;
private readonly List<KeyValuePair<uint, TAnimation>> _iterationSnapshot = new(); private readonly List<KeyValuePair<uint, TAnimation>> _iterationSnapshot = new();
public LiveEntityAnimationRuntimeView(Func<LiveEntityRuntime?> runtime) => internal LiveEntityAnimationRuntimeView(ILiveEntityRuntimeSource runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(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<uint> Keys => public IEnumerable<uint> Keys =>
_runtime()?.SpatialAnimationRuntimes.Keys ?? Array.Empty<uint>(); _runtime.Current?.SpatialAnimationRuntimes.Keys ?? Array.Empty<uint>();
public TAnimation this[uint localEntityId] public TAnimation this[uint localEntityId]
{ {
set set
{ {
LiveEntityRuntime runtime = _runtime() LiveEntityRuntime runtime = _runtime.Current
?? throw new InvalidOperationException("Live entity runtime is not initialized."); ?? throw new InvalidOperationException("Live entity runtime is not initialized.");
if (!runtime.TryGetServerGuid(localEntityId, out uint guid)) if (!runtime.TryGetServerGuid(localEntityId, out uint guid))
throw new InvalidOperationException($"No live entity owns local id 0x{localEntityId:X8}."); throw new InvalidOperationException($"No live entity owns local id 0x{localEntityId:X8}.");
@ -33,7 +33,7 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
public bool TryGetValue(uint localEntityId, out TAnimation animation) 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) && found is TAnimation typed)
{ {
animation = typed; animation = typed;
@ -46,7 +46,7 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
public bool Remove(uint localEntityId) public bool Remove(uint localEntityId)
{ {
LiveEntityRuntime? runtime = _runtime(); LiveEntityRuntime? runtime = _runtime.Current;
return runtime is not null return runtime is not null
&& runtime.TryGetServerGuid(localEntityId, out uint guid) && runtime.TryGetServerGuid(localEntityId, out uint guid)
&& runtime.ClearAnimationRuntime(guid); && runtime.ClearAnimationRuntime(guid);
@ -57,7 +57,7 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
public void CopySpatialIdsTo(HashSet<uint> destination) public void CopySpatialIdsTo(HashSet<uint> destination)
{ {
ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(destination);
LiveEntityRuntime? runtime = _runtime(); LiveEntityRuntime? runtime = _runtime.Current;
if (runtime is null) if (runtime is null)
destination.Clear(); destination.Clear();
else else
@ -66,7 +66,7 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
public Enumerator GetEnumerator() public Enumerator GetEnumerator()
{ {
LiveEntityRuntime? runtime = _runtime(); LiveEntityRuntime? runtime = _runtime.Current;
if (runtime is null) if (runtime is null)
_iterationSnapshot.Clear(); _iterationSnapshot.Clear();
else else
@ -135,21 +135,21 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
public sealed class LiveEntityRemoteMotionRuntimeView<TRemoteMotion> public sealed class LiveEntityRemoteMotionRuntimeView<TRemoteMotion>
where TRemoteMotion : class, ILiveEntityRemoteMotionRuntime where TRemoteMotion : class, ILiveEntityRemoteMotionRuntime
{ {
private readonly Func<LiveEntityRuntime?> _runtime; private readonly ILiveEntityRuntimeSource _runtime;
public LiveEntityRemoteMotionRuntimeView(Func<LiveEntityRuntime?> runtime) => internal LiveEntityRemoteMotionRuntimeView(ILiveEntityRuntimeSource runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
public TRemoteMotion this[uint serverGuid] public TRemoteMotion this[uint serverGuid]
{ {
set => (_runtime() set => (_runtime.Current
?? throw new InvalidOperationException("Live entity runtime is not initialized.")) ?? throw new InvalidOperationException("Live entity runtime is not initialized."))
.SetRemoteMotionRuntime(serverGuid, value); .SetRemoteMotionRuntime(serverGuid, value);
} }
public bool TryGetValue(uint serverGuid, out TRemoteMotion motion) 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) && found is TRemoteMotion typed)
{ {
motion = typed; motion = typed;
@ -161,5 +161,5 @@ public sealed class LiveEntityRemoteMotionRuntimeView<TRemoteMotion>
} }
public bool Remove(uint serverGuid) => public bool Remove(uint serverGuid) =>
_runtime()?.ClearRemoteMotionRuntime(serverGuid) == true; _runtime.Current?.ClearRemoteMotionRuntime(serverGuid) == true;
} }

View file

@ -1,4 +1,7 @@
using AcDream.App.Update; using AcDream.App.Update;
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.App.Streaming;
namespace AcDream.App.World; namespace AcDream.App.World;
@ -14,36 +17,37 @@ namespace AcDream.App.World;
/// from freezing an action due to complete this frame while ensuring the /// from freezing an action due to complete this frame while ensuring the
/// periodic AutonomousPosition check observes accepted inbound state. /// periodic AutonomousPosition check observes accepted inbound state.
/// </remarks> /// </remarks>
public sealed class RetailLiveFrameCoordinator internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
{ {
private readonly Action<float> _advanceObjectRuntime; private readonly ILiveObjectFramePhase _objects;
private readonly Action _dispatchInboundNetwork; private readonly GpuWorldState _worldState;
private readonly Action _runPostNetworkCommands; private readonly ILiveSessionFramePhase _session;
private readonly Action _reconcileSpatialPresentation; private readonly IPostNetworkCommandFramePhase _localPlayer;
private readonly ILiveSpatialReconcilePhase _spatialReconciler;
public RetailLiveFrameCoordinator( public RetailLiveFrameCoordinator(
Action<float> advanceObjectRuntime, ILiveObjectFramePhase objects,
Action dispatchInboundNetwork, GpuWorldState worldState,
Action runPostNetworkCommands, ILiveSessionFramePhase session,
Action reconcileSpatialPresentation) IPostNetworkCommandFramePhase localPlayer,
ILiveSpatialReconcilePhase spatialReconciler)
{ {
_advanceObjectRuntime = advanceObjectRuntime _objects = objects ?? throw new ArgumentNullException(nameof(objects));
?? throw new ArgumentNullException(nameof(advanceObjectRuntime)); _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
_dispatchInboundNetwork = dispatchInboundNetwork _session = session ?? throw new ArgumentNullException(nameof(session));
?? throw new ArgumentNullException(nameof(dispatchInboundNetwork)); _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
_runPostNetworkCommands = runPostNetworkCommands _spatialReconciler = spatialReconciler
?? throw new ArgumentNullException(nameof(runPostNetworkCommands)); ?? throw new ArgumentNullException(nameof(spatialReconciler));
_reconcileSpatialPresentation = reconcileSpatialPresentation
?? throw new ArgumentNullException(nameof(reconcileSpatialPresentation));
} }
public void Tick(float deltaSeconds) public void Tick(float deltaSeconds)
{ {
float frameDelta = (float)NormalizeDeltaSeconds(deltaSeconds); float frameDelta = (float)NormalizeDeltaSeconds(deltaSeconds);
_advanceObjectRuntime(frameDelta); _objects.Tick(frameDelta);
_dispatchInboundNetwork(); using (_worldState.BeginMutationBatch())
_runPostNetworkCommands(); _session.Tick();
_reconcileSpatialPresentation(); _localPlayer.RunPostNetworkCommandPhase();
_spatialReconciler.Reconcile();
} }
public static double NormalizeDeltaSeconds(double deltaSeconds) => public static double NormalizeDeltaSeconds(double deltaSeconds) =>

View file

@ -0,0 +1,27 @@
using AcDream.App.Update;
namespace AcDream.App.Tests;
internal sealed class TestLiveObjectFramePhase(Action<float> 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();
}

View file

@ -1,5 +1,6 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.App.Streaming;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Physics; using AcDream.Core.Physics;
@ -33,22 +34,23 @@ public sealed class RetailLocalPlayerFrameControllerTests
postNetwork.Add(owner.State); postNetwork.Add(owner.State);
}); });
var live = new RetailLiveFrameCoordinator( var live = new RetailLiveFrameCoordinator(
dt => new TestLiveObjectFramePhase(dt =>
{ {
calls.Add("objects"); calls.Add("objects");
local.AdvanceBeforeNetwork(dt); local.AdvanceBeforeNetwork(dt);
}, }),
() => new GpuWorldState(),
new TestLiveSessionFramePhase(() =>
{ {
calls.Add("f751"); calls.Add("f751");
controller.State = PlayerState.PortalSpace; controller.State = PlayerState.PortalSpace;
}, }),
() => new TestPostNetworkCommandFramePhase(() =>
{ {
calls.Add("commands"); calls.Add("commands");
local.RunPostNetworkCommandPhase(); local.RunPostNetworkCommandPhase();
}, }),
() => calls.Add("spatial")); new TestLiveSpatialReconcilePhase(() => calls.Add("spatial")));
// CPhysicsObj::update_object admits manager time only after the strict // CPhysicsObj::update_object admits manager time only after the strict
// minimum quantum. Use one admitted object tick; the test's concern is // minimum quantum. Use one admitted object tick; the test's concern is
@ -118,10 +120,12 @@ public sealed class RetailLocalPlayerFrameControllerTests
sendPreNetwork: (_, _, _) => { }, sendPreNetwork: (_, _, _) => { },
sendPostNetwork: (_, _) => { }); sendPostNetwork: (_, _) => { });
var live = new RetailLiveFrameCoordinator( var live = new RetailLiveFrameCoordinator(
local.AdvanceBeforeNetwork, new TestLiveObjectFramePhase(local.AdvanceBeforeNetwork),
() => projectedRoot = new Vector3(999f, 999f, 999f), new GpuWorldState(),
local.RunPostNetworkCommandPhase, new TestLiveSessionFramePhase(
() => { }); () => projectedRoot = new Vector3(999f, 999f, 999f)),
local,
new TestLiveSpatialReconcilePhase(() => { }));
live.Tick(1f / 60f); live.Tick(1f / 60f);
float timeAfterOneTick = controller.SimTimeSeconds; float timeAfterOneTick = controller.SimTimeSeconds;

View file

@ -1430,6 +1430,24 @@ public sealed class ProjectileControllerTests
private sealed class Fixture private sealed class Fixture
{ {
private sealed class SetupResolver : IProjectileSetupResolver
{
private readonly Func<Setup?> _resolve;
internal SetupResolver(Func<Setup?> resolve) => _resolve = resolve;
public Setup? Resolve(uint setupId) => _resolve();
}
private sealed class RootPosePublisher : IEntityRootPosePublisher
{
private readonly Action<WorldEntity> _publish;
internal RootPosePublisher(Action<WorldEntity> publish) => _publish = publish;
public void UpdateRoot(WorldEntity entity) => _publish(entity);
}
internal Fixture( internal Fixture(
bool loadInitialLandblock = true, bool loadInitialLandblock = true,
PhysicsEngine? physicsEngine = null, PhysicsEngine? physicsEngine = null,
@ -1439,12 +1457,15 @@ public sealed class ProjectileControllerTests
Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
Live = new LiveEntityRuntime(Spatial, Resources); Live = new LiveEntityRuntime(Spatial, Resources);
Engine = physicsEngine ?? new PhysicsEngine(); Engine = physicsEngine ?? new PhysicsEngine();
Origin.Recenter(1, 1);
Controller = new ProjectileController( Controller = new ProjectileController(
Live, Live,
Engine, Engine,
_ => ResolvedSetup, new SetupResolver(() => ResolvedSetup),
publishRootPose ?? (entity => Poses.UpdateRoot(entity)), new RootPosePublisher(
() => LiveCenter); publishRootPose ?? new Action<WorldEntity>(
entity => { _ = Poses.UpdateRoot(entity); })),
Origin);
} }
internal GpuWorldState Spatial { get; } = new(); internal GpuWorldState Spatial { get; } = new();
@ -1452,8 +1473,8 @@ public sealed class ProjectileControllerTests
internal LiveEntityRuntime Live { get; } internal LiveEntityRuntime Live { get; }
internal PhysicsEngine Engine { get; } internal PhysicsEngine Engine { get; }
internal EntityEffectPoseRegistry Poses { get; } = new(); internal EntityEffectPoseRegistry Poses { get; } = new();
internal LiveWorldOriginState Origin { get; } = new();
internal Setup ResolvedSetup { get; set; } = ProjectileSetup(); internal Setup ResolvedSetup { get; set; } = ProjectileSetup();
internal (int X, int Y) LiveCenter { get; set; } = (1, 1);
internal ProjectileController Controller { get; } internal ProjectileController Controller { get; }
internal LiveEntityRecord Spawn( internal LiveEntityRecord Spawn(

View file

@ -364,13 +364,29 @@ public sealed class LiveEntityAnimationPresenterTests
LiveEntityRuntime live, LiveEntityRuntime live,
EntityEffectPoseRegistry poses, EntityEffectPoseRegistry poses,
Context context) => new( Context context) => new(
() => live, live,
() => null, new EmptyStaticPartFrameSource(),
poses, poses,
context, context,
AnimationPresentationDiagnostics.Disabled, AnimationPresentationDiagnostics.Disabled,
hidePartIndex: -1); hidePartIndex: -1);
private sealed class EmptyStaticPartFrameSource : ILiveStaticPartFrameSource
{
public bool TryTakeLivePartFrames(
LiveEntityRecord record,
WorldEntity entity,
LiveEntityAnimationState animation,
ulong objectClockEpoch,
ulong projectionMutationVersion,
ulong presentationRevision,
out IReadOnlyList<PartTransform> partFrames)
{
partFrames = Array.Empty<PartTransform>();
return false;
}
}
private static IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> Schedules( private static IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> Schedules(
Fixture fixture, Fixture fixture,
IReadOnlyList<PartTransform> frames) => IReadOnlyList<PartTransform> frames) =>

View file

@ -1,12 +1,15 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Core.Vfx;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
using DatReaderWriter.Types; using DatReaderWriter.Types;
@ -629,14 +632,42 @@ public sealed class LiveEntityAnimationSchedulerTests
var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater( var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater(
physics, physics,
(_, _) => (0.48f, 1.835f)); (_, _) => (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( return new LiveEntityAnimationScheduler(
() => live, live,
() => localPlayerGuid, identity,
remotePhysics, remotePhysics,
ordinaryPhysics, ordinaryPhysics,
() => projectiles, projectiles,
publishRootPose ?? (_ => { }), new TestRootPosePublisher(poses, publishRootPose),
captureHooks ?? ((_, _) => { })); animationHooks);
}
private sealed class TestAnimationHookCaptureSink(
Action<uint, AnimationSequencer> capture) : IAnimationHookCaptureSink
{
public void Capture(uint ownerLocalId, AnimationSequencer sequencer) =>
capture(ownerLocalId, sequencer);
}
private sealed class TestRootPosePublisher(
EntityEffectPoseRegistry poses,
Action<WorldEntity>? published) : IEntityRootPosePublisher
{
public void UpdateRoot(WorldEntity entity)
{
poses.UpdateRoot(entity);
published?.Invoke(entity);
}
} }
private static (LiveEntityRuntime Live, LiveEntityRecord Record, private static (LiveEntityRuntime Live, LiveEntityRecord Record,

View file

@ -47,6 +47,10 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData("DispatchRemoteInboundMotion")] [InlineData("DispatchRemoteInboundMotion")]
[InlineData("CreateRemoteMotion")] [InlineData("CreateRemoteMotion")]
[InlineData("WillAdvanceRemoteMotion")] [InlineData("WillAdvanceRemoteMotion")]
[InlineData("AdvanceLiveObjectRuntime")]
[InlineData("AdvanceLiveObjectRuntimeCore")]
[InlineData("ReconcileLiveObjectSpatialPresentation")]
[InlineData("CaptureAnimationHooks")]
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName) public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
{ {
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation)); Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
@ -73,6 +77,13 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData(typeof(LiveEntityMotionRuntimeController))] [InlineData(typeof(LiveEntityMotionRuntimeController))]
[InlineData(typeof(LiveEntityInboundAuthorityGate))] [InlineData(typeof(LiveEntityInboundAuthorityGate))]
[InlineData(typeof(DeferredLiveEntityMotionRuntimeBindings))] [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) public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType)
{ {
foreach (FieldInfo field in helperType.GetFields( foreach (FieldInfo field in helperType.GetFields(
@ -83,6 +94,89 @@ public sealed class GameWindowLiveEntityCompositionTests
string typeName = field.FieldType.FullName ?? field.FieldType.Name; string typeName = field.FieldType.FullName ?? field.FieldType.Name;
Assert.DoesNotContain("Silk.NET", typeName, StringComparison.Ordinal); Assert.DoesNotContain("Silk.NET", typeName, StringComparison.Ordinal);
Assert.DoesNotContain("OpenGL", typeName, StringComparison.OrdinalIgnoreCase); 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<LiveEntityAnimationState>)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic),
field => field.Name == "_runtime");
FieldInfo remoteRuntime = Assert.Single(
typeof(LiveEntityRemoteMotionRuntimeView<RemoteMotion>)
.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<LiveEntityAnimationState>(() =>",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() =>",
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.");
}
} }

View file

@ -202,10 +202,7 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests
lifecycle); lifecycle);
Projectiles = new ProjectileController( Projectiles = new ProjectileController(
Live, Live,
Physics, Physics);
_ => null,
_ => { },
() => (0, 0));
Controller = new LiveEntityProjectionWithdrawalController( Controller = new LiveEntityProjectionWithdrawalController(
Live, Live,
Projectiles, Projectiles,

View file

@ -17,6 +17,20 @@ namespace AcDream.App.Tests.World;
public sealed class LiveEntityRuntimeTests 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<InvalidOperationException>(() => slot.Bind(runtime));
}
private sealed class RecordingResources : ILiveEntityResourceLifecycle private sealed class RecordingResources : ILiveEntityResourceLifecycle
{ {
public int RegisterCount { get; private set; } public int RegisterCount { get; private set; }
@ -245,11 +259,18 @@ public sealed class LiveEntityRuntimeTests
landblockId: 0x01010000u, landblockId: 0x01010000u,
seedCellId: 0x01010001u); seedCellId: 0x01010001u);
int poseUpdates = 0; 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( var committer = new StaticLiveRootCommitter(
() => runtime, runtimeSlot,
shadows, shadows,
() => (1, 1), origin,
_ => poseUpdates++); poses);
body.SetFrameInCurrentCell( body.SetFrameInCurrentCell(
body.Position, body.Position,
@ -282,13 +303,14 @@ public sealed class LiveEntityRuntimeTests
[Fact] [Fact]
public void StaticRootCommit_BeforeLiveRuntimeCompositionIsSafeNoOp() public void StaticRootCommit_BeforeLiveRuntimeCompositionIsSafeNoOp()
{ {
LiveEntityRuntime? runtime = null;
int poseUpdates = 0; int poseUpdates = 0;
var poses = new EntityEffectPoseRegistry();
poses.EffectPoseChanged += _ => poseUpdates++;
var committer = new StaticLiveRootCommitter( var committer = new StaticLiveRootCommitter(
() => runtime, new LiveEntityRuntimeSlot(),
new ShadowObjectRegistry(), new ShadowObjectRegistry(),
() => (0, 0), new LiveWorldOriginState(),
_ => poseUpdates++); poses);
Assert.False(committer.Commit( Assert.False(committer.Commit(
Entity(0x7F000001u, 0x70000001u), Entity(0x7F000001u, 0x70000001u),
@ -347,26 +369,33 @@ public sealed class LiveEntityRuntimeTests
worldOffsetY: 0f, worldOffsetY: 0f,
landblockId: 0x01010000u, landblockId: 0x01010000u,
seedCellId: 0x01010001u); 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( var committer = new StaticLiveRootCommitter(
() => runtime, runtimeSlot,
shadows, shadows,
() => (1, 1), origin,
_ => poses);
{
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));
});
oldBody.SetFrameInCurrentCell( oldBody.SetFrameInCurrentCell(
oldBody.Position, oldBody.Position,
@ -453,7 +482,7 @@ public sealed class LiveEntityRuntimeTests
id => Entity(id, guid))!; id => Entity(id, guid))!;
var animation = new AnimationRuntime(entity); var animation = new AnimationRuntime(entity);
runtime.SetAnimationRuntime(guid, animation); runtime.SetAnimationRuntime(guid, animation);
var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(() => runtime); var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(BoundSlot(runtime));
Assert.Equal(1, view.Count); Assert.Equal(1, view.Count);
Assert.Equal(entity.Id, Assert.Single(view.Keys)); Assert.Equal(entity.Id, Assert.Single(view.Keys));
@ -489,7 +518,7 @@ public sealed class LiveEntityRuntimeTests
id => Entity(id, guid))!; id => Entity(id, guid))!;
runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity)); runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity));
} }
var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(() => runtime); var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(BoundSlot(runtime));
var visited = new List<uint>(); var visited = new List<uint>();
foreach (KeyValuePair<uint, AnimationRuntime> pair in view) foreach (KeyValuePair<uint, AnimationRuntime> pair in view)
@ -519,7 +548,7 @@ public sealed class LiveEntityRuntimeTests
id => Entity(id, guid))!; id => Entity(id, guid))!;
runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity)); runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity));
var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>( var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(
() => runtime); BoundSlot(runtime));
var ids = new HashSet<uint>(); var ids = new HashSet<uint>();
view.CopySpatialIdsTo(ids); view.CopySpatialIdsTo(ids);
@ -2803,6 +2832,13 @@ public sealed class LiveEntityRuntimeTests
private static LoadedLandblock EmptyLandblock(uint canonicalId) => private static LoadedLandblock EmptyLandblock(uint canonicalId) =>
new(canonicalId, new LandBlock(), Array.Empty<WorldEntity>()); new(canonicalId, new LandBlock(), Array.Empty<WorldEntity>());
private static LiveEntityRuntimeSlot BoundSlot(LiveEntityRuntime runtime)
{
var slot = new LiveEntityRuntimeSlot();
slot.Bind(runtime);
return slot;
}
private static void ResolveParent(LiveEntityRuntime runtime, uint childGuid) => private static void ResolveParent(LiveEntityRuntime runtime, uint childGuid) =>
runtime.ParentAttachments.Resolve( runtime.ParentAttachments.Resolve(
childGuid, childGuid,

View file

@ -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<Key, ModifierMask>? KeyDown;
public event Action<Key, ModifierMask>? 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<MouseButton, ModifierMask>? MouseDown;
public event Action<MouseButton, ModifierMask>? MouseUp;
public event Action<float, float>? MouseMove;
public event Action<float>? Scroll;
#pragma warning restore CS0067
public bool IsHeld(MouseButton button) => false;
public bool WantCaptureMouse => false;
public bool WantCaptureKeyboard => false;
}
}

View file

@ -1,9 +1,11 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Streaming;
using AcDream.App.Update; using AcDream.App.Update;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Content.Vfx; using AcDream.Content.Vfx;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Options; using DatReaderWriter.Options;
@ -25,10 +27,11 @@ public sealed class RecallTeleportAnimationTests
{ {
var calls = new List<string>(); var calls = new List<string>();
var frame = new RetailLiveFrameCoordinator( var frame = new RetailLiveFrameCoordinator(
_ => calls.Add("objects"), new TestLiveObjectFramePhase(_ => calls.Add("objects")),
() => calls.Add("network"), new GpuWorldState(),
() => calls.Add("commands"), new TestLiveSessionFramePhase(() => calls.Add("network")),
() => calls.Add("spatial")); new TestPostNetworkCommandFramePhase(() => calls.Add("commands")),
new TestLiveSpatialReconcilePhase(() => calls.Add("spatial")));
frame.Tick(1f / 60f); frame.Tick(1f / 60f);
@ -39,25 +42,96 @@ public sealed class RecallTeleportAnimationTests
public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch() public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch()
{ {
var deltas = new List<float>(); var deltas = new List<float>();
int networkDispatches = 0; var calls = new List<string>();
var frame = new RetailLiveFrameCoordinator( var frame = new RetailLiveFrameCoordinator(
deltas.Add, new TestLiveObjectFramePhase(delta =>
() => networkDispatches++, {
() => { }, 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.NaN);
frame.Tick(float.PositiveInfinity); frame.Tick(float.PositiveInfinity);
frame.Tick(-1f); frame.Tick(-1f);
Assert.Equal([0f, 0f, 0f], deltas); 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.NaN));
Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.NegativeInfinity)); Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.NegativeInfinity));
Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.PositiveInfinity)); Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.PositiveInfinity));
Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.MaxValue)); Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.MaxValue));
} }
[Fact]
public void LiveFrame_CommitsTheInboundMutationBatchBeforeCommandsAndReconcile()
{
const uint landblock = 0x0101FFFFu;
var calls = new List<string>();
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<string>();
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<InvalidOperationException>(
() => 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] [Fact]
public void InstalledRecall_HiddenEnterWorldPublishesFinishedCyclicPoseWithoutAdvancingTime() public void InstalledRecall_HiddenEnterWorldPublishesFinishedCyclicPoseWithoutAdvancingTime()
{ {
@ -108,4 +182,14 @@ public sealed class RecallTeleportAnimationTests
Quaternion.Normalize(recallTail[index].Orientation), Quaternion.Normalize(recallTail[index].Orientation),
Quaternion.Normalize(finishedPose[index].Orientation))) < 0.999999f); 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<MeshRef>(),
};
} }

View file

@ -228,6 +228,16 @@ public sealed class UpdateFrameOrchestratorTests
FieldInfo[] ownerFields = owner.GetFields( FieldInfo[] ownerFields = owner.GetFields(
BindingFlags.Instance | BindingFlags.NonPublic); BindingFlags.Instance | BindingFlags.NonPublic);
Assert.DoesNotContain(ownerFields, field => field.FieldType == typeof(GameWindow)); 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( Assert.DoesNotContain(
ownerFields, ownerFields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType)); field => typeof(Delegate).IsAssignableFrom(field.FieldType));
@ -274,6 +284,71 @@ public sealed class UpdateFrameOrchestratorTests
Assert.Equal(1, source.Split("PublishTime(", StringSplitOptions.None).Length - 1); 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( private static UpdateFrameOrchestrator Create(
List<string> calls, List<string> calls,
RecordingTeardown? teardown = null, RecordingTeardown? teardown = null,
@ -432,4 +507,19 @@ public sealed class UpdateFrameOrchestratorTests
throw new DirectoryNotFoundException("Could not find AcDream.slnx."); 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;
} }