refactor(animation): extract live PartArray presenter

Move final live part composition, exact-incarnation schedule handoff, MotionDone binding, and presentation diagnostics out of GameWindow while preserving the retail frame order. Reject stale schedule and completion ABA at the new owner boundary.

Co-Authored-By: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-21 09:37:02 +02:00
parent 9760ff5a8d
commit f004ac5562
14 changed files with 963 additions and 438 deletions

View file

@ -1,5 +1,5 @@
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion; using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity; using LiveEntityAnimationState = AcDream.App.Rendering.LiveEntityAnimationState;
namespace AcDream.App.Physics; namespace AcDream.App.Physics;
@ -40,13 +40,13 @@ internal sealed class RemotePhysicsUpdater
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine; private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine;
private readonly System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> _getSetupCylinder; private readonly System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> _getSetupCylinder;
private readonly System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> _applyServerControlledVelocityCycle; private readonly System.Action<uint, LiveEntityAnimationState, RemoteMotion, System.Numerics.Vector3> _applyServerControlledVelocityCycle;
private readonly List<AcDream.App.World.LiveEntityRecord> _spatialRemoteSnapshot = new(); private readonly List<AcDream.App.World.LiveEntityRecord> _spatialRemoteSnapshot = new();
internal RemotePhysicsUpdater( internal RemotePhysicsUpdater(
AcDream.Core.Physics.PhysicsEngine physicsEngine, AcDream.Core.Physics.PhysicsEngine physicsEngine,
System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> getSetupCylinder, System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> getSetupCylinder,
System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> applyServerControlledVelocityCycle) System.Action<uint, LiveEntityAnimationState, RemoteMotion, System.Numerics.Vector3> applyServerControlledVelocityCycle)
{ {
_physicsEngine = physicsEngine; _physicsEngine = physicsEngine;
_getSetupCylinder = getSetupCylinder; _getSetupCylinder = getSetupCylinder;
@ -58,7 +58,7 @@ internal sealed class RemotePhysicsUpdater
/// <summary> /// <summary>
/// Advances the retained PositionManager path for every hidden remote, /// Advances the retained PositionManager path for every hidden remote,
/// including objects without an <c>AnimatedEntity</c> (missiles commonly /// including objects without an <c>LiveEntityAnimationState</c> (missiles commonly
/// have no PartArray). Retail gates this path on the live CPhysicsObj, not /// have no PartArray). Retail gates this path on the live CPhysicsObj, not
/// on whether a render-animation owner exists. /// on whether a render-animation owner exists.
/// </summary> /// </summary>
@ -93,7 +93,7 @@ internal sealed class RemotePhysicsUpdater
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
continue; continue;
AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity; LiveEntityAnimationState? animation = record.AnimationRuntime as LiveEntityAnimationState;
AcDream.Core.Physics.RetailObjectActivityResult activity = AcDream.Core.Physics.RetailObjectActivityResult activity =
AcDream.Core.Physics.RetailObjectActivityGate.Evaluate( AcDream.Core.Physics.RetailObjectActivityGate.Evaluate(
record.ObjectClock, record.ObjectClock,
@ -165,7 +165,7 @@ internal sealed class RemotePhysicsUpdater
/// </summary> /// </summary>
public bool Tick( public bool Tick(
RemoteMotion rm, RemoteMotion rm,
AnimatedEntity ae, LiveEntityAnimationState ae,
float dt, float dt,
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame, AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
int liveCenterX, int liveCenterX,
@ -188,14 +188,14 @@ internal sealed class RemotePhysicsUpdater
/// Canonical ordinary-object tick. Render animation is optional: retail /// Canonical ordinary-object tick. Render animation is optional: retail
/// walks <c>CPhysics::object_maint</c>, so a live object with a /// walks <c>CPhysics::object_maint</c>, so a live object with a
/// MovementManager or PositionManager must continue even when it has no /// MovementManager or PositionManager must continue even when it has no
/// <c>AnimatedEntity</c> presentation component. /// <c>LiveEntityAnimationState</c> presentation component.
/// </summary> /// </summary>
public bool Tick( public bool Tick(
RemoteMotion rm, RemoteMotion rm,
AcDream.Core.World.WorldEntity entity, AcDream.Core.World.WorldEntity entity,
float objectScale, float objectScale,
AcDream.Core.Physics.AnimationSequencer? sequencer, AcDream.Core.Physics.AnimationSequencer? sequencer,
AnimatedEntity? animationForVelocityCycle, LiveEntityAnimationState? animationForVelocityCycle,
float dt, float dt,
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame, AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
int liveCenterX, int liveCenterX,

View file

@ -0,0 +1,21 @@
namespace AcDream.App.Rendering;
/// <summary>Startup-resolved diagnostic gates for final PartArray presentation.</summary>
internal sealed record AnimationPresentationDiagnostics(
bool RemoteVelocityEnabled,
bool DumpMotionEnabled,
Func<double> Now)
{
public static AnimationPresentationDiagnostics FromEnvironment() => new(
RemoteVelocityEnabled:
Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1",
DumpMotionEnabled:
Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1",
Now: static () =>
(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds);
public static AnimationPresentationDiagnostics Disabled { get; } = new(
false,
false,
static () => 0d);
}

View file

@ -9,7 +9,7 @@ using Silk.NET.Windowing;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
public sealed class GameWindow : IDisposable public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
{ {
private static double ClientTimerNow() => private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp() System.Diagnostics.Stopwatch.GetTimestamp()
@ -182,6 +182,7 @@ public sealed class GameWindow : IDisposable
private readonly AcDream.App.World.RetailInboundEventDispatcher private readonly AcDream.App.World.RetailInboundEventDispatcher
_inboundEntityEvents = new(); _inboundEntityEvents = new();
private readonly LiveEntityAnimationScheduler _liveAnimationScheduler; private readonly LiveEntityAnimationScheduler _liveAnimationScheduler;
private readonly LiveEntityAnimationPresenter _animationPresenter;
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;
@ -278,7 +279,7 @@ public sealed class GameWindow : IDisposable
/// Static decorations and entities with no motion table never /// Static decorations and entities with no motion table never
/// appear in this map. /// appear in this map.
/// </summary> /// </summary>
private readonly LiveEntityAnimationRuntimeView<AnimatedEntity> _animatedEntities; private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animatedEntities;
// 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
@ -298,73 +299,9 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new(); private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new();
// #184 Slice 2a: internal (was private) so the extracted
// RemotePhysicsUpdater in AcDream.App.Physics can take it by type. Matches
// RemoteMotion's existing internal visibility.
internal sealed class AnimatedEntity : AcDream.App.World.ILiveEntityAnimationRuntime
{
public required AcDream.Core.World.WorldEntity Entity;
AcDream.Core.World.WorldEntity AcDream.App.World.ILiveEntityAnimationRuntime.Entity => Entity;
uint AcDream.App.World.ILiveEntityAnimationRuntime.CurrentMotion =>
Sequencer?.CurrentMotion ?? 0u;
public required DatReaderWriter.DBObjs.Setup Setup;
public required DatReaderWriter.DBObjs.Animation Animation;
public required int LowFrame;
public required int HighFrame;
public required float Framerate; // frames per second
public required float Scale; // server ObjScale baked into part transforms each tick
/// <summary>
/// Per-part identity carried over from the hydration pass: the
/// (post-AnimPartChanges) GfxObjId and the (post-resolution)
/// surface override map. The transform is recomputed every tick
/// from the current animation frame; only these two fields are
/// reused unchanged.
/// </summary>
public required IReadOnlyList<AnimatedPartTemplate> PartTemplate;
public required IReadOnlyList<bool> PartAvailability;
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
// The local player's CPartArray is advanced from
// PlayerMovementController before its 30 Hz physics integration so
// the exact authored root delta enters the collision sweep. Retain
// that same advance's part pose for TickAnimations; advancing the
// sequencer a second time would double both animation time and hooks.
public IReadOnlyList<AcDream.Core.Physics.PartTransform>? PreparedSequenceFrames;
public bool SequenceAdvancedBeforeAnimationPass;
public readonly DatReaderWriter.Types.Frame RootMotionScratch = new();
public readonly AcDream.Core.Physics.Motion.MotionDeltaFrame
RootMotionDeltaScratch = new();
/// <summary>
/// MP-Alloc (2026-07-05): reusable per-entity MeshRefs buffer. Every
/// animated entity (including idle NPCs on a breathe cycle) used to
/// get a fresh `List&lt;MeshRef&gt;(partCount)` reassigned to
/// <see cref="AcDream.Core.World.WorldEntity.MeshRefs"/> every tick —
/// the old list became garbage every frame. This buffer is cleared
/// and refilled in place instead; <c>Entity.MeshRefs</c> is set to
/// this SAME list reference once it's populated (assigning the
/// unchanged reference back is a no-op cost-wise). Safe because
/// TickAnimations runs single-threaded to completion before any
/// draw-side consumer reads MeshRefs, and the only cross-frame
/// consumer (RefreshPaperdollDoll) takes an explicit
/// `new List&lt;MeshRef&gt;(pe.MeshRefs)` VALUE copy — MeshRef is a
/// readonly record struct, so that copy is unaffected by later
/// in-place mutation of this list.
/// </summary>
public readonly List<AcDream.Core.World.MeshRef> MeshRefsScratch = new();
/// <summary>Indexed final part poses, including debug-hidden parts.</summary>
public readonly List<System.Numerics.Matrix4x4> EffectPartPosesScratch = new();
}
internal readonly record struct AnimatedPartTemplate(
uint GfxObjId,
IReadOnlyDictionary<uint, uint>? SurfaceOverrides,
bool IsDrawable);
private sealed record AppearanceUpdateState( private sealed record AppearanceUpdateState(
AcDream.Core.World.WorldEntity Entity, AcDream.Core.World.WorldEntity Entity,
AnimatedEntity? Animation); LiveEntityAnimationState? Animation);
private AcDream.Core.Physics.IAnimationLoader? _animLoader; private AcDream.Core.Physics.IAnimationLoader? _animLoader;
@ -1203,7 +1140,7 @@ public sealed class GameWindow : IDisposable
_worldEvents = worldEvents; _worldEvents = worldEvents;
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_uiRegistry = uiRegistry; _uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<AnimatedEntity>(() => _liveEntities); _animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(() => _liveEntities);
_remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() => _liveEntities); _remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() => _liveEntities);
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);
@ -1229,6 +1166,13 @@ public sealed class GameWindow : IDisposable
() => _projectileController, () => _projectileController,
entity => _effectPoses.UpdateRoot(entity), entity => _effectPoses.UpdateRoot(entity),
CaptureAnimationHooks); CaptureAnimationHooks);
_animationPresenter = new LiveEntityAnimationPresenter(
() => _liveEntities,
() => _staticAnimationScheduler,
_effectPoses,
this,
AnimationPresentationDiagnostics.FromEnvironment(),
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)
@ -4140,7 +4084,7 @@ public sealed class GameWindow : IDisposable
var meshRefs = new List<AcDream.Core.World.MeshRef>(); var meshRefs = new List<AcDream.Core.World.MeshRef>();
var indexedPartTransforms = new System.Numerics.Matrix4x4[parts.Count]; var indexedPartTransforms = new System.Numerics.Matrix4x4[parts.Count];
var indexedPartAvailable = new bool[parts.Count]; var indexedPartAvailable = new bool[parts.Count];
var animatedPartTemplate = new AnimatedPartTemplate[parts.Count]; var animatedPartTemplate = new LiveAnimationPartTemplate[parts.Count];
var liveBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); var liveBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
int dumpClothingTotalTris = 0; int dumpClothingTotalTris = 0;
for (int partIdx = 0; partIdx < parts.Count; partIdx++) for (int partIdx = 0; partIdx < parts.Count; partIdx++)
@ -4160,7 +4104,7 @@ public sealed class GameWindow : IDisposable
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId); var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
bool isDrawable = gfx is not null; bool isDrawable = gfx is not null;
indexedPartAvailable[partIdx] = isDrawable; indexedPartAvailable[partIdx] = isDrawable;
animatedPartTemplate[partIdx] = new AnimatedPartTemplate( animatedPartTemplate[partIdx] = new LiveAnimationPartTemplate(
mr.GfxObjId, mr.GfxObjId,
surfaceOverrides, surfaceOverrides,
isDrawable); isDrawable);
@ -4419,7 +4363,7 @@ public sealed class GameWindow : IDisposable
// Snapshot per-part identity from the hydrated meshRefs so the // Snapshot per-part identity from the hydrated meshRefs so the
// tick can rebuild MeshRefs without redoing AnimPartChanges or // tick can rebuild MeshRefs without redoing AnimPartChanges or
// texture-override resolution every frame. // texture-override resolution every frame.
AnimatedPartTemplate[] template = animatedPartTemplate; LiveAnimationPartTemplate[] template = animatedPartTemplate;
// Create an AnimationSequencer if we can load the MotionTable. // Create an AnimationSequencer if we can load the MotionTable.
AcDream.Core.Physics.AnimationSequencer? sequencer = null; AcDream.Core.Physics.AnimationSequencer? sequencer = null;
@ -4437,7 +4381,7 @@ public sealed class GameWindow : IDisposable
} }
} }
_animatedEntities[entity.Id] = new AnimatedEntity _animatedEntities[entity.Id] = new LiveEntityAnimationState
{ {
Entity = entity, Entity = entity,
Setup = setup, Setup = setup,
@ -4492,9 +4436,9 @@ public sealed class GameWindow : IDisposable
var sequencer = SpawnMotionInitializer.Create( var sequencer = SpawnMotionInitializer.Create(
setup, mtable, _animLoader, spawn.MotionState); setup, mtable, _animLoader, spawn.MotionState);
AnimatedPartTemplate[] template = animatedPartTemplate; LiveAnimationPartTemplate[] template = animatedPartTemplate;
_animatedEntities[entity.Id] = new AnimatedEntity _animatedEntities[entity.Id] = new LiveEntityAnimationState
{ {
Entity = entity, Entity = entity,
Setup = setup, Setup = setup,
@ -4541,7 +4485,7 @@ public sealed class GameWindow : IDisposable
setupDefaultLoader); setupDefaultLoader);
if (sequencer.HasCurrentNode) if (sequencer.HasCurrentNode)
{ {
_animatedEntities[entity.Id] = new AnimatedEntity _animatedEntities[entity.Id] = new LiveEntityAnimationState
{ {
Entity = entity, Entity = entity,
Setup = setup, Setup = setup,
@ -4565,7 +4509,8 @@ public sealed class GameWindow : IDisposable
// unmatched node that permanently starves later MoveTo/TurnTo work. // unmatched node that permanently starves later MoveTo/TurnTo work.
if (_animatedEntities.TryGetValue(entity.Id, out var registeredAnimation)) if (_animatedEntities.TryGetValue(entity.Id, out var registeredAnimation))
{ {
EnsureMotionDoneBinding(spawn.Guid, registeredAnimation); if (_liveEntities.TryGetRecord(spawn.Guid, out LiveEntityRecord bindingRecord))
_animationPresenter.PrepareAnimation(bindingRecord, registeredAnimation);
if (isPhysicsStatic if (isPhysicsStatic
&& registeredAnimation.Sequencer is { } staticSequencer && registeredAnimation.Sequencer is { } staticSequencer
@ -4736,11 +4681,11 @@ public sealed class GameWindow : IDisposable
} }
internal static void RebindAnimatedEntityForAppearance( internal static void RebindAnimatedEntityForAppearance(
AnimatedEntity animation, LiveEntityAnimationState animation,
AcDream.Core.World.WorldEntity entity, AcDream.Core.World.WorldEntity entity,
DatReaderWriter.DBObjs.Setup setup, DatReaderWriter.DBObjs.Setup setup,
float scale, float scale,
IReadOnlyList<AnimatedPartTemplate> partTemplate, IReadOnlyList<LiveAnimationPartTemplate> partTemplate,
IReadOnlyList<bool> partAvailability) IReadOnlyList<bool> partAvailability)
{ {
animation.Entity = entity; animation.Entity = entity;
@ -4748,6 +4693,7 @@ public sealed class GameWindow : IDisposable
animation.Scale = scale; animation.Scale = scale;
animation.PartTemplate = partTemplate; animation.PartTemplate = partTemplate;
animation.PartAvailability = partAvailability; animation.PartAvailability = partAvailability;
animation.InvalidatePresentationPoses();
} }
/// <summary> /// <summary>
@ -5208,7 +5154,7 @@ public sealed class GameWindow : IDisposable
/// the VectorUpdate path regardless of arrival order. /// the VectorUpdate path regardless of arrival order.
/// </summary> /// </summary>
private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings( private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
RemoteMotion rm, AnimatedEntity? ae, uint serverGuid) RemoteMotion rm, LiveEntityAnimationState? ae, uint serverGuid)
{ {
AcDream.Core.Physics.AnimationSequencer? sequencer = ae?.Sequencer; AcDream.Core.Physics.AnimationSequencer? sequencer = ae?.Sequencer;
if (sequencer is not null && rm.Sink is null) if (sequencer is not null && rm.Sink is null)
@ -5812,7 +5758,7 @@ public sealed class GameWindow : IDisposable
/// <summary> /// <summary>
/// Phase 6.6: the server says an entity's motion has changed. Look up /// Phase 6.6: the server says an entity's motion has changed. Look up
/// the AnimatedEntity for that guid, re-resolve the idle cycle with the /// the LiveEntityAnimationState for that guid, re-resolve the idle cycle with the
/// new (stance, forward-command) override, and if the cycle is still /// new (stance, forward-command) override, and if the cycle is still
/// animated, swap in the new animation/frame range. Entities not in /// animated, swap in the new animation/frame range. Entities not in
/// the animated map (static props, entities rejected at spawn time) /// the animated map (static props, entities rejected at spawn time)
@ -6220,7 +6166,7 @@ public sealed class GameWindow : IDisposable
DispatchRemoteInboundMotion( DispatchRemoteInboundMotion(
AcDream.Core.Net.WorldSession.EntityMotionUpdate update, AcDream.Core.Net.WorldSession.EntityMotionUpdate update,
AcDream.Core.World.WorldEntity entity, AcDream.Core.World.WorldEntity entity,
AnimatedEntity? ae, LiveEntityAnimationState? ae,
LiveEntityRecord acceptedRecord, LiveEntityRecord acceptedRecord,
ulong acceptedMovementAuthorityVersion, ulong acceptedMovementAuthorityVersion,
ulong acceptedVelocityAuthorityVersion) ulong acceptedVelocityAuthorityVersion)
@ -6557,7 +6503,7 @@ public sealed class GameWindow : IDisposable
private void ApplyServerControlledVelocityCycle( private void ApplyServerControlledVelocityCycle(
uint serverGuid, uint serverGuid,
AnimatedEntity ae, LiveEntityAnimationState ae,
RemoteMotion rm, RemoteMotion rm,
System.Numerics.Vector3 velocity) System.Numerics.Vector3 velocity)
{ {
@ -7355,7 +7301,7 @@ public sealed class GameWindow : IDisposable
// Enqueue only if the canonical ordinary-object workset // Enqueue only if the canonical ordinary-object workset
// will consume the queue. Animation is optional in retail; // will consume the queue. Animation is optional in retail;
// LiveEntityAnimationScheduler advances a spatial // LiveEntityAnimationScheduler advances a spatial
// RemoteMotion even when no AnimatedEntity exists. // RemoteMotion even when no LiveEntityAnimationState exists.
bool willBeDrTickedNpc = WillAdvanceRemoteMotion( bool willBeDrTickedNpc = WillAdvanceRemoteMotion(
update.Guid, update.Guid,
rmState); rmState);
@ -11505,12 +11451,12 @@ public sealed class GameWindow : IDisposable
_localPlayerFrame.HiddenPartPoseDirty, _localPlayerFrame.HiddenPartPoseDirty,
_liveCenterX, _liveCenterX,
_liveCenterY, _liveCenterY,
EnsureMotionDoneBinding); _animationPresenter.PrepareAnimation);
// CPhysics::UseTime (0x00509950) processes the ordinary object table // CPhysics::UseTime (0x00509950) processes the ordinary object table
// first, then its distinct static_animating_objects workset. // first, then its distinct static_animating_objects workset.
_staticAnimationScheduler?.Tick(dt); _staticAnimationScheduler?.Tick(dt);
if (_animatedEntities.Count > 0) if (_animatedEntities.Count > 0)
TickAnimations(animationSchedules); _animationPresenter.Present(animationSchedules);
_equippedChildRenderer?.Tick(); _equippedChildRenderer?.Tick();
// CPhysicsObj::animate_static_object (0x00513DF0) reaches // CPhysicsObj::animate_static_object (0x00513DF0) reaches
@ -11569,7 +11515,7 @@ public sealed class GameWindow : IDisposable
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's /// <paramref name="dt"/> * Framerate, wrapping around the cycle's
/// [LowFrame..HighFrame] interval, then rebuild that entity's /// [LowFrame..HighFrame] interval, then rebuild that entity's
/// MeshRefs from the new frame's per-part transforms. Static /// MeshRefs from the new frame's per-part transforms. Static
/// entities (no AnimatedEntity record) are untouched. The static /// entities (no LiveEntityAnimationState record) are untouched. The static
/// renderer reads the new MeshRefs on the next Draw call. /// renderer reads the new MeshRefs on the next Draw call.
/// </summary> /// </summary>
private void AdvanceLocalPlayerAnimationRoot( private void AdvanceLocalPlayerAnimationRoot(
@ -11588,7 +11534,8 @@ public sealed class GameWindow : IDisposable
return; return;
} }
EnsureMotionDoneBinding(_playerServerGuid, ae); if (_liveEntities?.TryGetRecord(_playerServerGuid, out LiveEntityRecord bindingRecord) == true)
_animationPresenter.PrepareAnimation(bindingRecord, ae);
DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch; DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch;
rootFrame.Origin = System.Numerics.Vector3.Zero; rootFrame.Origin = System.Numerics.Vector3.Zero;
@ -11615,320 +11562,22 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Physics.AnimationSequencer sequencer) => AcDream.Core.Physics.AnimationSequencer sequencer) =>
_animationHookFrames?.Capture(ownerLocalId, sequencer); _animationHookFrames?.Capture(ownerLocalId, sequencer);
private void TickAnimations( uint ILiveAnimationPresentationContext.LocalPlayerGuid => _playerServerGuid;
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> animationSchedules)
AcDream.Core.Physics.MotionInterpreter?
ILiveAnimationPresentationContext.ResolveMotionInterpreter(
LiveEntityRecord record)
{ {
// Retail has NO stop-detection heuristic — it relies on the if (_liveEntities?.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) != true
// server sending explicit UpdateMotion(Ready). The server-side || !ReferenceEquals(current, record))
// stop signal flows the same way as any other motion change:
// alt releases W → MoveToState(Ready) → ACE broadcasts
// UpdateMotion(Ready) + UpdatePosition with zero velocity.
// On our side, only UpdateMotion changes interpreted state;
// UpdatePosition updates physics pose/velocity and never selects an
// animation. Anything else is server buggery (packet loss, ACE bug)
// — don't guess client-side.
foreach (var kv in _animatedEntities)
{ {
var ae = kv.Value; return null;
// The server guid is carried on the entity itself
// (WorldEntity.ServerGuid, set == the _entitiesByServerGuid key at
// construction — see OnLiveEntitySpawnedLocked: `ServerGuid = spawn.Guid`
// and `_entitiesByServerGuid[spawn.Guid] = entity`). Read it directly —
// O(1) — instead of the former reverse ReferenceEquals scan over ALL of
// _entitiesByServerGuid, which made this loop O(animated × all-entities).
// That super-linear cost is the FPS-drops-per-teleport sink: world
// entities accumulate without bound (pruned only on DeleteObject, and ACE
// does not re-send / clear KnownObjects across a teleport), so N climbs
// every hop. ServerGuid defaults to 0 for entities not server-tracked,
// exactly matching the old scan's miss case.
uint serverGuid = ae.Entity.ServerGuid;
// Defensive for a runtime retained across visual rehydration.
// This is deliberately before every early-out and before
// Sequencer.Advance: no completion may precede its consumer.
EnsureMotionDoneBinding(serverGuid, ae);
if (_liveEntities?.TryGetRecord(
serverGuid,
out LiveEntityRecord liveRecord) != true)
{
continue;
}
animationSchedules.TryGetValue(kv.Key, out LiveEntityAnimationSchedule schedule);
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames =
schedule.SequenceFrames;
bool composeParts = schedule.ComposeParts;
if (_staticAnimationScheduler?.TryTakeLivePartFrames(
kv.Key,
out var staticPartFrames) == true)
{
seqFrames = staticPartFrames;
composeParts = true;
}
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
if (ae.Sequencer is not null)
{
// Per-tick sequencer-state diag: prove whether the sequencer
// for the observed retail char actually holds the latest
// motion (= SetCycle landed) OR is stuck on an old motion
// (= something elsewhere is reverting). Throttled to once
// per second per remote.
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
&& serverGuid != 0
&& serverGuid != _playerServerGuid)
{
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmDiag)
&& nowSec - rmDiag.LastSeqStateLogTime > 1.0)
{
// D1 (2026-05-03): SEQSTATE has its own throttle clock
// (LastSeqStateLogTime) so it isn't silently swallowed by
// OMEGA_DIAG resetting LastOmegaDiagLogTime when the
// observed remote happens to be turning.
System.Console.WriteLine(
$"[SEQSTATE] guid={serverGuid:X8} CurrentMotion=0x{ae.Sequencer.CurrentMotion:X8} "
+ $"CurrentSpeedMod={ae.Sequencer.CurrentSpeedMod:F3}");
// #39 fix-3 evidence (2026-05-06): CURRNODE proves
// whether _currNode is actually on the cycle (anim
// ref hash matches FirstCyclic) or stuck somewhere
// else. SCFULL captures _currNode==_firstCyclic only
// at SetCycle return; this captures it per render
// tick so we can see if something resets it later.
var d = ae.Sequencer.CurrentNodeDiag;
int firstHash = ae.Sequencer.FirstCyclicAnimRefHash;
System.Console.WriteLine(
$"[CURRNODE] guid={serverGuid:X8} "
+ $"animRef=0x{d.AnimRefHash:X8} firstCyclicAnimRef=0x{firstHash:X8} "
+ $"isOnCyclic={d.AnimRefHash == firstHash && firstHash != 0} "
+ $"isLooping={d.IsLooping} fr={d.Framerate:F2} "
+ $"frame=[{d.StartFrame}..{d.EndFrame}] "
+ $"pos={d.FramePosition:F2} qCount={d.QueueCount}");
rmDiag.LastSeqStateLogTime = nowSec;
}
}
}
else
{
// Legacy path (entities without a MotionTable / sequencer).
int span = ae.HighFrame - ae.LowFrame;
bool hiddenLegacy = (liveRecord.FinalPhysicsState
& AcDream.Core.Physics.PhysicsStateFlags.Hidden) != 0;
if (span <= 0 && !hiddenLegacy) continue;
if (span > 0 && schedule.LegacyAdvanceSeconds > 0f)
{
ae.CurrFrame += schedule.LegacyAdvanceSeconds * ae.Framerate;
if (ae.CurrFrame > ae.HighFrame)
{
float over = ae.CurrFrame - ae.LowFrame;
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
}
else if (ae.CurrFrame < ae.LowFrame)
ae.CurrFrame = ae.LowFrame;
}
}
int partCount = ae.PartTemplate.Count;
// D5 (Commit A 2026-05-03): PARTSDIAG — proves whether
// PartTemplate.Count diverges from seqFrames.Count (silent
// identity-quat fallback freezes parts → H5) and whether the
// per-part frames returned by Advance actually change between
// Walk and Run cycles. The seqFrames hash is a sum-of-components
// proxy: cheap, unitless, monotonically distinct between cycles
// for any non-degenerate animation. If [PARTSDIAG] shows the
// hash unchanged across a Walk→Run transition while [SEQSTATE]
// shows CurrentMotion flipping, the sequencer is serving stale
// frames despite the cycle being correct.
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
&& serverGuid != 0
&& serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rmParts))
{
double nowSecParts = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
if (nowSecParts - rmParts.LastPartsDiagLogTime > 1.0)
{
int seqCount = seqFrames?.Count ?? -1;
int setupParts = ae.Setup.Parts.Count;
// #62: B.4c introduced `Animation = null!` for sequencer-driven
// door entities (parallel branch sites at line 2867 + 7892).
// Doors don't currently enter _remoteDeadReckon, so this
// path isn't reachable for them today — but the read was
// unguarded and would NRE the moment something flips.
// Local-var + `is not null` keeps the guard scoped: the
// legacy slerp branch below treats ae.Animation as non-
// null (per its non-nullable declared type) unchanged.
var animMaybeNull = ae.Animation;
int animFrame0Parts =
animMaybeNull is not null && animMaybeNull.PartFrames.Count > 0
? animMaybeNull.PartFrames[0].Frames.Count
: -1;
double seqHash = 0.0;
if (seqFrames is not null)
{
for (int hi = 0; hi < seqFrames.Count; hi++)
{
var f = seqFrames[hi];
seqHash += f.Origin.X + f.Origin.Y + f.Origin.Z
+ f.Orientation.X + f.Orientation.Y
+ f.Orientation.Z + f.Orientation.W;
}
}
System.Console.WriteLine(
$"[PARTSDIAG] guid={serverGuid:X8} "
+ $"pt.Count={partCount} seqFrames.Count={seqCount} "
+ $"setup.Parts.Count={setupParts} "
+ $"anim.PartFrames[0].Frames.Count={animFrame0Parts} "
+ $"seqHash={seqHash:F4}");
rmParts.LastPartsDiagLogTime = nowSecParts;
}
}
if (!composeParts)
continue;
// MP-Alloc (2026-07-05): reuse the entity's cached MeshRefs list
// instead of allocating a fresh List<MeshRef>(partCount) every
// tick (see AnimatedEntity.MeshRefsScratch for the safety
// argument — single-threaded tick-before-draw ordering, no
// consumer caches a stale reference or diffs list identity).
var newMeshRefs = ae.MeshRefsScratch;
newMeshRefs.Clear();
var effectPartPoses = ae.EffectPartPosesScratch;
effectPartPoses.Clear();
var scaleMat = ae.Scale == 1.0f
? System.Numerics.Matrix4x4.Identity
: System.Numerics.Matrix4x4.CreateScale(ae.Scale);
for (int i = 0; i < partCount; i++)
{
System.Numerics.Vector3 origin;
System.Numerics.Quaternion orientation;
if (seqFrames is not null)
{
// Sequencer path.
if (i < seqFrames.Count)
{
origin = seqFrames[i].Origin;
orientation = seqFrames[i].Orientation;
}
else
{
origin = System.Numerics.Vector3.Zero;
orientation = System.Numerics.Quaternion.Identity;
}
}
else
{
// Legacy slerp path.
int frameIdx = (int)Math.Floor(ae.CurrFrame);
if (frameIdx < ae.LowFrame || frameIdx > ae.HighFrame
|| frameIdx >= ae.Animation.PartFrames.Count)
frameIdx = ae.LowFrame;
int nextIdx = frameIdx + 1;
if (nextIdx > ae.HighFrame || nextIdx >= ae.Animation.PartFrames.Count)
nextIdx = ae.LowFrame;
float t = ae.CurrFrame - frameIdx;
if (t < 0f) t = 0f; else if (t > 1f) t = 1f;
var partFrames = ae.Animation.PartFrames[frameIdx].Frames;
var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames;
if (i < partFrames.Count)
{
var f0 = partFrames[i];
var f1 = i < partFramesNext.Count ? partFramesNext[i] : f0;
origin = System.Numerics.Vector3.Lerp(f0.Origin, f1.Origin, t);
orientation = System.Numerics.Quaternion.Slerp(f0.Orientation, f1.Orientation, t);
}
else
{
origin = System.Numerics.Vector3.Zero;
orientation = System.Numerics.Quaternion.Identity;
}
}
// Per-part default scale from the Setup, matching SetupMesh.Flatten's
// composition order: scale → rotate → translate.
var defaultScale = i < ae.Setup.DefaultScale.Count
? ae.Setup.DefaultScale[i]
: System.Numerics.Vector3.One;
var visualPartTransform =
System.Numerics.Matrix4x4.CreateScale(defaultScale) *
System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) *
System.Numerics.Matrix4x4.CreateTranslation(origin);
// Bake the entity's ObjScale on top, matching the hydration
// order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned.
if (ae.Scale != 1.0f)
visualPartTransform *= scaleMat;
// Retail CPartArray::UpdateParts (0x005190F0) scales only the
// frame origin. Setup DefaultScale is visual gfxobj_scale,
// not part-frame state (SetScaleInternal 0x00518A00).
var rigidPartTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) *
System.Numerics.Matrix4x4.CreateTranslation(origin * ae.Scale);
effectPartPoses.Add(rigidPartTransform);
var template = ae.PartTemplate[i];
if (!template.IsDrawable
|| (_options.HidePartIndex >= 0 && i == _options.HidePartIndex && partCount >= 10))
{
continue;
}
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, visualPartTransform)
{
SurfaceOverrides = template.SurfaceOverrides,
});
}
// Re-assigning the SAME list reference every tick is cheap (a
// property store) and keeps this line's shape identical to the
// pre-MP-Alloc code for anyone grepping the history.
ae.Entity.MeshRefs = newMeshRefs;
ae.Entity.SetIndexedPartPoses(effectPartPoses, ae.PartAvailability);
_effectPoses.Publish(ae.Entity, effectPartPoses, ae.PartAvailability);
} }
} if (record.ServerGuid == _playerServerGuid)
return _playerController?.Motion;
/// <summary> return record.RemoteMotionRuntime is RemoteMotion remote
/// R3-W2: binds the animation queue's completion seam to the entity's ? remote.Motion
/// real <c>CMotionInterp::MotionDone</c> consumer : null;
/// (<c>CPhysicsObj::MotionDone</c> 0x0050FDB0). Retail owns both queues
/// under one CPhysicsObj/CPartArray lifetime; acdream's split owners must
/// establish the relationship before the first animation can finish.
/// </summary>
private void EnsureMotionDoneBinding(uint serverGuid, AnimatedEntity ae)
{
if (ae.Sequencer is not { MotionDoneTarget: null } sequencer)
return;
uint ownerGuid = serverGuid;
// Resolve at callback time: a remote MotionInterpreter can be created
// after its animation runtime, and the player controller is created
// during EnterPlayerModeNow. Eagerly capturing either would preserve
// a legitimate null forever.
sequencer.MotionDoneTarget = (motion, success) =>
{
AcDream.Core.Physics.MotionInterpreter? interpreter = null;
if (ownerGuid != 0 && ownerGuid == _playerServerGuid)
interpreter = _playerController?.Motion;
else if (ownerGuid != 0
&& _remoteDeadReckon.TryGetValue(ownerGuid, out var remote))
interpreter = remote.Motion;
interpreter?.MotionDone(motion, success);
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
{
System.Console.WriteLine(
$"[MOTIONDONE] guid={ownerGuid:X8} motion=0x{motion:X8} "
+ $"success={success} pending={(interpreter?.MotionsPending() ?? false)}");
}
};
} }
// IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass // IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass

View file

@ -0,0 +1,10 @@
using AcDream.App.World;
using AcDream.Core.Physics;
namespace AcDream.App.Rendering;
internal interface ILiveAnimationPresentationContext
{
uint LocalPlayerGuid { get; }
MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record);
}

View file

@ -0,0 +1,21 @@
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Late presentation handoff from retail's separate
/// <c>static_animating_objects</c> workset. Every take is bound to the exact
/// live incarnation that prepared the frames.
/// </summary>
internal interface ILiveStaticPartFrameSource
{
bool TryTakeLivePartFrames(
LiveEntityRecord record,
WorldEntity entity,
LiveEntityAnimationState animation,
ulong objectClockEpoch,
ulong projectionMutationVersion,
out IReadOnlyList<PartTransform> frames);
}

View file

@ -0,0 +1,403 @@
using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Publishes the final visual and rigid part channels after retail object and
/// static-animation scheduling. It advances no time and drains no hooks.
/// </summary>
internal sealed class LiveEntityAnimationPresenter
{
private readonly Func<LiveEntityRuntime?> _liveEntities;
private readonly Func<ILiveStaticPartFrameSource?> _staticFrames;
private readonly EntityEffectPoseRegistry _effectPoses;
private readonly ILiveAnimationPresentationContext _context;
private readonly AnimationPresentationDiagnostics _diagnostics;
private readonly int _hidePartIndex;
private readonly List<LiveEntityRecord> _snapshot = new();
public LiveEntityAnimationPresenter(
Func<LiveEntityRuntime?> liveEntities,
Func<ILiveStaticPartFrameSource?> staticFrames,
EntityEffectPoseRegistry effectPoses,
ILiveAnimationPresentationContext context,
AnimationPresentationDiagnostics diagnostics,
int hidePartIndex)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_staticFrames = staticFrames ?? throw new ArgumentNullException(nameof(staticFrames));
_effectPoses = effectPoses ?? throw new ArgumentNullException(nameof(effectPoses));
_context = context ?? throw new ArgumentNullException(nameof(context));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_hidePartIndex = hidePartIndex;
}
public void PrepareAnimation(
LiveEntityRecord record,
LiveEntityAnimationState animation)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(animation);
LiveEntityRuntime? runtime = _liveEntities();
if (runtime is null
|| !runtime.IsCurrentSpatialAnimation(record, animation)
|| !ReferenceEquals(record.WorldEntity, animation.Entity)
|| animation.Sequencer is not { MotionDoneTarget: null } sequencer)
{
return;
}
WorldEntity capturedEntity = animation.Entity;
sequencer.MotionDoneTarget = (motion, success) =>
{
LiveEntityRuntime? current = _liveEntities();
if (current is null
|| !current.IsCurrentSpatialAnimation(record, animation)
|| !ReferenceEquals(record.WorldEntity, capturedEntity)
|| !ReferenceEquals(animation.Entity, capturedEntity))
{
return;
}
MotionInterpreter? interpreter = _context.ResolveMotionInterpreter(record);
interpreter?.MotionDone(motion, success);
if (_diagnostics.DumpMotionEnabled)
{
Console.WriteLine(
$"[MOTIONDONE] guid={record.ServerGuid:X8} motion=0x{motion:X8} "
+ $"success={success} pending={(interpreter?.MotionsPending() ?? false)}");
}
};
}
public void Present(
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules)
{
ArgumentNullException.ThrowIfNull(schedules);
LiveEntityRuntime? runtime = _liveEntities();
if (runtime is null)
return;
// Private snapshot: EffectPoseChanged is synchronous and may perform a
// nested runtime-view enumeration. Never borrow that view's scratch.
runtime.CopySpatialRootObjectRecordsTo(_snapshot);
foreach (LiveEntityRecord record in _snapshot)
{
if (!TryGetCurrent(runtime, record, out WorldEntity entity, out LiveEntityAnimationState animation))
continue;
PrepareAnimation(record, animation);
if (!TryGetCurrent(runtime, record, entity, animation))
continue;
schedules.TryGetValue(entity.Id, out LiveEntityAnimationSchedule schedule);
bool hasOrdinarySchedule = IsCurrentSchedule(runtime, schedule, record, entity, animation);
IReadOnlyList<PartTransform>? sequenceFrames = hasOrdinarySchedule
? schedule.SequenceFrames
: null;
bool composeParts = hasOrdinarySchedule && schedule.ComposeParts;
float legacyAdvanceSeconds = hasOrdinarySchedule
? schedule.LegacyAdvanceSeconds
: 0f;
ulong objectClockEpoch = record.ObjectClockEpoch;
ulong projectionVersion = record.ProjectionMutationVersion;
if (_staticFrames()?.TryTakeLivePartFrames(
record,
entity,
animation,
objectClockEpoch,
projectionVersion,
out IReadOnlyList<PartTransform> staticPartFrames) == true)
{
if (!TryGetCurrent(
runtime,
record,
entity,
animation,
objectClockEpoch,
projectionVersion))
{
continue;
}
sequenceFrames = staticPartFrames;
composeParts = true;
}
if (animation.Sequencer is not null)
{
EmitSequenceDiagnostics(record, animation, sequenceFrames);
}
else
{
int span = animation.HighFrame - animation.LowFrame;
bool hiddenLegacy = (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
if (span <= 0 && !hiddenLegacy)
continue;
if (span > 0 && legacyAdvanceSeconds > 0f)
{
animation.CurrFrame += legacyAdvanceSeconds * animation.Framerate;
if (animation.CurrFrame > animation.HighFrame)
{
float over = animation.CurrFrame - animation.LowFrame;
animation.CurrFrame = animation.LowFrame + (over % (span + 1));
}
else if (animation.CurrFrame < animation.LowFrame)
{
animation.CurrFrame = animation.LowFrame;
}
}
}
if (!composeParts
|| !TryGetCurrent(
runtime,
record,
entity,
animation,
objectClockEpoch,
projectionVersion))
{
continue;
}
ComposeAndPublish(runtime, record, entity, animation, sequenceFrames,
objectClockEpoch, projectionVersion);
}
}
private void ComposeAndPublish(
LiveEntityRuntime runtime,
LiveEntityRecord record,
WorldEntity entity,
LiveEntityAnimationState animation,
IReadOnlyList<PartTransform>? sequenceFrames,
ulong objectClockEpoch,
ulong projectionVersion)
{
int partCount = animation.PartTemplate.Count;
EmitPartDiagnostics(record, animation, sequenceFrames, partCount);
List<MeshRef> meshRefs = animation.MeshRefsScratch;
meshRefs.Clear();
List<Matrix4x4> rigidPoses = animation.EffectPartPosesScratch;
rigidPoses.Clear();
Matrix4x4 scale = animation.Scale == 1f
? Matrix4x4.Identity
: Matrix4x4.CreateScale(animation.Scale);
for (int i = 0; i < partCount; i++)
{
ResolvePartFrame(animation, sequenceFrames, i, out Vector3 origin, out Quaternion orientation);
Vector3 defaultScale = i < animation.Setup.DefaultScale.Count
? animation.Setup.DefaultScale[i]
: Vector3.One;
Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(origin);
if (animation.Scale != 1f)
visual *= scale;
Matrix4x4 rigid = Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(origin * animation.Scale);
rigidPoses.Add(rigid);
LiveAnimationPartTemplate template = animation.PartTemplate[i];
if (!template.IsDrawable
|| (_hidePartIndex >= 0 && i == _hidePartIndex && partCount >= 10))
{
continue;
}
meshRefs.Add(new MeshRef(template.GfxObjId, visual)
{
SurfaceOverrides = template.SurfaceOverrides,
});
}
if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion))
return;
entity.MeshRefs = meshRefs;
entity.SetIndexedPartPoses(rigidPoses, animation.PartAvailability);
if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion))
return;
_effectPoses.Publish(entity, rigidPoses, animation.PartAvailability);
}
private static void ResolvePartFrame(
LiveEntityAnimationState animation,
IReadOnlyList<PartTransform>? sequenceFrames,
int partIndex,
out Vector3 origin,
out Quaternion orientation)
{
if (sequenceFrames is not null)
{
if (partIndex < sequenceFrames.Count)
{
origin = sequenceFrames[partIndex].Origin;
orientation = sequenceFrames[partIndex].Orientation;
}
else
{
origin = Vector3.Zero;
orientation = Quaternion.Identity;
}
return;
}
int frameIndex = (int)Math.Floor(animation.CurrFrame);
if (frameIndex < animation.LowFrame
|| frameIndex > animation.HighFrame
|| frameIndex >= animation.Animation.PartFrames.Count)
{
frameIndex = animation.LowFrame;
}
int nextIndex = frameIndex + 1;
if (nextIndex > animation.HighFrame
|| nextIndex >= animation.Animation.PartFrames.Count)
{
nextIndex = animation.LowFrame;
}
float t = Math.Clamp(animation.CurrFrame - frameIndex, 0f, 1f);
var frames = animation.Animation.PartFrames[frameIndex].Frames;
var nextFrames = animation.Animation.PartFrames[nextIndex].Frames;
if (partIndex < frames.Count)
{
var first = frames[partIndex];
var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first;
origin = Vector3.Lerp(first.Origin, next.Origin, t);
orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t);
}
else
{
origin = Vector3.Zero;
orientation = Quaternion.Identity;
}
}
private void EmitSequenceDiagnostics(
LiveEntityRecord record,
LiveEntityAnimationState animation,
IReadOnlyList<PartTransform>? sequenceFrames)
{
if (!_diagnostics.RemoteVelocityEnabled
|| record.ServerGuid == 0
|| record.ServerGuid == _context.LocalPlayerGuid
|| record.RemoteMotionRuntime is null)
{
return;
}
double now = _diagnostics.Now();
if (now - animation.LastSequenceDiagnosticTime <= 1d)
return;
AnimationSequencer sequencer = animation.Sequencer!;
Console.WriteLine(
$"[SEQSTATE] guid={record.ServerGuid:X8} CurrentMotion=0x{sequencer.CurrentMotion:X8} "
+ $"CurrentSpeedMod={sequencer.CurrentSpeedMod:F3}");
var node = sequencer.CurrentNodeDiag;
int firstHash = sequencer.FirstCyclicAnimRefHash;
Console.WriteLine(
$"[CURRNODE] guid={record.ServerGuid:X8} animRef=0x{node.AnimRefHash:X8} "
+ $"firstCyclicAnimRef=0x{firstHash:X8} "
+ $"isOnCyclic={node.AnimRefHash == firstHash && firstHash != 0} "
+ $"isLooping={node.IsLooping} fr={node.Framerate:F2} "
+ $"frame=[{node.StartFrame}..{node.EndFrame}] "
+ $"pos={node.FramePosition:F2} qCount={node.QueueCount}");
animation.LastSequenceDiagnosticTime = now;
}
private void EmitPartDiagnostics(
LiveEntityRecord record,
LiveEntityAnimationState animation,
IReadOnlyList<PartTransform>? sequenceFrames,
int partCount)
{
if (!_diagnostics.RemoteVelocityEnabled
|| record.ServerGuid == 0
|| record.ServerGuid == _context.LocalPlayerGuid
|| record.RemoteMotionRuntime is null)
{
return;
}
double now = _diagnostics.Now();
if (now - animation.LastPartDiagnosticTime <= 1d)
return;
int animationPartCount = animation.Animation is not null
&& animation.Animation.PartFrames.Count > 0
? animation.Animation.PartFrames[0].Frames.Count
: -1;
double hash = 0d;
if (sequenceFrames is not null)
{
foreach (PartTransform frame in sequenceFrames)
{
hash += frame.Origin.X + frame.Origin.Y + frame.Origin.Z
+ frame.Orientation.X + frame.Orientation.Y
+ frame.Orientation.Z + frame.Orientation.W;
}
}
Console.WriteLine(
$"[PARTSDIAG] guid={record.ServerGuid:X8} pt.Count={partCount} "
+ $"seqFrames.Count={sequenceFrames?.Count ?? -1} "
+ $"setup.Parts.Count={animation.Setup.Parts.Count} "
+ $"anim.PartFrames[0].Frames.Count={animationPartCount} seqHash={hash:F4}");
animation.LastPartDiagnosticTime = now;
}
private static bool IsCurrentSchedule(
LiveEntityRuntime runtime,
LiveEntityAnimationSchedule schedule,
LiveEntityRecord record,
WorldEntity entity,
LiveEntityAnimationState animation) =>
schedule.ComposeParts
&& ReferenceEquals(schedule.Record, record)
&& ReferenceEquals(schedule.Entity, entity)
&& ReferenceEquals(schedule.Animation, animation)
&& TryGetCurrent(
runtime,
record,
entity,
animation,
schedule.ObjectClockEpoch,
schedule.ProjectionMutationVersion);
private static bool TryGetCurrent(
LiveEntityRuntime runtime,
LiveEntityRecord record,
out WorldEntity entity,
out LiveEntityAnimationState animation)
{
WorldEntity? currentEntity = record.WorldEntity;
LiveEntityAnimationState? currentAnimation =
record.AnimationRuntime as LiveEntityAnimationState;
entity = currentEntity!;
animation = currentAnimation!;
return currentEntity is not null
&& currentAnimation is not null
&& TryGetCurrent(runtime, record, entity, animation);
}
private static bool TryGetCurrent(
LiveEntityRuntime runtime,
LiveEntityRecord record,
WorldEntity entity,
LiveEntityAnimationState animation) =>
runtime.IsCurrentSpatialAnimation(record, animation)
&& ReferenceEquals(record.WorldEntity, entity)
&& ReferenceEquals(animation.Entity, entity);
private static bool TryGetCurrent(
LiveEntityRuntime runtime,
LiveEntityRecord record,
WorldEntity entity,
LiveEntityAnimationState animation,
ulong objectClockEpoch,
ulong projectionVersion) =>
TryGetCurrent(runtime, record, entity, animation)
&& record.ObjectClockEpoch == objectClockEpoch
&& record.ProjectionMutationVersion == projectionVersion;
}

View file

@ -5,7 +5,6 @@ using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion; using AcDream.Core.Physics.Motion;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity;
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion; using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -67,7 +66,7 @@ internal sealed class LiveEntityAnimationScheduler
bool localHiddenPartPoseDirty, bool localHiddenPartPoseDirty,
int liveCenterX, int liveCenterX,
int liveCenterY, int liveCenterY,
Action<uint, AnimatedEntity>? prepareAnimation = null) Action<LiveEntityRecord, LiveEntityAnimationState>? prepareAnimation = null)
{ {
_schedules.Clear(); _schedules.Clear();
LiveEntityRuntime? runtime = _liveEntities(); LiveEntityRuntime? runtime = _liveEntities();
@ -83,14 +82,12 @@ internal sealed class LiveEntityAnimationScheduler
continue; continue;
} }
AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity; LiveEntityAnimationState? animation = record.AnimationRuntime as LiveEntityAnimationState;
RemoteMotion? remote = record.RemoteMotionRuntime as RemoteMotion; RemoteMotion? remote = record.RemoteMotionRuntime as RemoteMotion;
ProjectileController.Runtime? projectile = ProjectileController.Runtime? projectile =
record.ProjectileRuntime as ProjectileController.Runtime; record.ProjectileRuntime as ProjectileController.Runtime;
ulong objectClockEpoch = record.ObjectClockEpoch; ulong objectClockEpoch = record.ObjectClockEpoch;
if (animation is not null)
prepareAnimation?.Invoke(record.ServerGuid, animation);
if (!IsCurrent( if (!IsCurrent(
runtime, runtime,
record, record,
@ -101,6 +98,22 @@ internal sealed class LiveEntityAnimationScheduler
objectClockEpoch)) objectClockEpoch))
continue; continue;
if (animation is not null)
{
prepareAnimation?.Invoke(record, animation);
if (!IsCurrent(
runtime,
record,
entity,
animation,
remote,
projectile,
objectClockEpoch))
{
continue;
}
}
LiveEntityAnimationSchedule schedule = AdvanceRecord( LiveEntityAnimationSchedule schedule = AdvanceRecord(
runtime, runtime,
record, record,
@ -126,7 +139,14 @@ internal sealed class LiveEntityAnimationScheduler
projectile, projectile,
objectClockEpoch)) objectClockEpoch))
{ {
_schedules[entity.Id] = schedule; _schedules[entity.Id] = schedule with
{
Record = record,
Entity = entity,
Animation = animation,
ObjectClockEpoch = objectClockEpoch,
ProjectionMutationVersion = record.ProjectionMutationVersion,
};
} }
} }
@ -137,7 +157,7 @@ internal sealed class LiveEntityAnimationScheduler
LiveEntityRuntime runtime, LiveEntityRuntime runtime,
LiveEntityRecord record, LiveEntityRecord record,
WorldEntity entity, WorldEntity entity,
AnimatedEntity? animation, LiveEntityAnimationState? animation,
RemoteMotion? remote, RemoteMotion? remote,
ProjectileController.Runtime? projectile, ProjectileController.Runtime? projectile,
float elapsedSeconds, float elapsedSeconds,
@ -472,7 +492,7 @@ internal sealed class LiveEntityAnimationScheduler
LiveEntityRuntime runtime, LiveEntityRuntime runtime,
LiveEntityRecord record, LiveEntityRecord record,
WorldEntity entity, WorldEntity entity,
AnimatedEntity? animation, LiveEntityAnimationState? animation,
RemoteMotion? remote, RemoteMotion? remote,
ProjectileController.Runtime? projectile, ProjectileController.Runtime? projectile,
ulong objectClockEpoch) => ulong objectClockEpoch) =>
@ -506,4 +526,9 @@ internal sealed class LiveEntityAnimationScheduler
internal readonly record struct LiveEntityAnimationSchedule( internal readonly record struct LiveEntityAnimationSchedule(
IReadOnlyList<PartTransform>? SequenceFrames, IReadOnlyList<PartTransform>? SequenceFrames,
float LegacyAdvanceSeconds, float LegacyAdvanceSeconds,
bool ComposeParts); bool ComposeParts,
LiveEntityRecord? Record = null,
WorldEntity? Entity = null,
LiveEntityAnimationState? Animation = null,
ulong ObjectClockEpoch = 0UL,
ulong ProjectionMutationVersion = 0UL);

View file

@ -0,0 +1,63 @@
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering;
/// <summary>
/// Incarnation-owned live <c>CPartArray</c> presentation state. Time, root
/// motion, and hooks remain owned by <see cref="LiveEntityAnimationScheduler"/>;
/// this component retains the final part-presentation channels only.
/// </summary>
internal sealed class LiveEntityAnimationState : ILiveEntityAnimationRuntime
{
public required WorldEntity Entity;
WorldEntity ILiveEntityAnimationRuntime.Entity => Entity;
uint ILiveEntityAnimationRuntime.CurrentMotion => Sequencer?.CurrentMotion ?? 0u;
public required Setup Setup;
public required Animation Animation;
public required int LowFrame;
public required int HighFrame;
public required float Framerate;
public required float Scale;
public required IReadOnlyList<LiveAnimationPartTemplate> PartTemplate;
public required IReadOnlyList<bool> PartAvailability;
public float CurrFrame;
public AnimationSequencer? Sequencer;
public IReadOnlyList<PartTransform>? PreparedSequenceFrames;
public bool SequenceAdvancedBeforeAnimationPass;
public readonly Frame RootMotionScratch = new();
public readonly MotionDeltaFrame RootMotionDeltaScratch = new();
/// <summary>Reusable compact drawable channel, consumed before draw.</summary>
public readonly List<MeshRef> MeshRefsScratch = new();
/// <summary>Reusable indexed rigid/effect channel.</summary>
public readonly List<Matrix4x4> EffectPartPosesScratch = new();
/// <summary>
/// Indexed visible transforms retained separately from the rigid channel
/// because Setup DefaultScale affects geometry but not attachments.
/// </summary>
public readonly List<Matrix4x4> VisualPartPosesScratch = new();
public bool PresentationPosesInitialized;
public double LastSequenceDiagnosticTime;
public double LastPartDiagnosticTime;
public void InvalidatePresentationPoses()
{
PresentationPosesInitialized = false;
VisualPartPosesScratch.Clear();
EffectPartPosesScratch.Clear();
}
}
internal readonly record struct LiveAnimationPartTemplate(
uint GfxObjId,
IReadOnlyDictionary<uint, uint>? SurfaceOverrides,
bool IsDrawable);

View file

@ -1,5 +1,6 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.World;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion; using AcDream.Core.Physics.Motion;
using AcDream.Core.World; using AcDream.Core.World;
@ -17,7 +18,7 @@ namespace AcDream.App.Rendering;
/// resource lifetime. Setup default installation itself is unconditional and /// resource lifetime. Setup default installation itself is unconditional and
/// belongs to PartArray construction; this class owns only the Static workset. /// belongs to PartArray construction; this class owns only the Static workset.
/// </summary> /// </summary>
internal sealed class RetailStaticAnimatingObjectScheduler internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFrameSource
{ {
private const double FrameEpsilon = 0.000199999995; private const double FrameEpsilon = 0.000199999995;
private const float MaximumElapsed = 2f; private const float MaximumElapsed = 2f;
@ -162,10 +163,24 @@ internal sealed class RetailStaticAnimatingObjectScheduler
/// the single owner of mesh identity, overrides, and appearance rebinding. /// the single owner of mesh identity, overrides, and appearance rebinding.
/// </summary> /// </summary>
public bool TryTakeLivePartFrames( public bool TryTakeLivePartFrames(
uint ownerId, LiveEntityRecord record,
WorldEntity entity,
LiveEntityAnimationState animation,
ulong objectClockEpoch,
ulong projectionMutationVersion,
out IReadOnlyList<PartTransform> frames) out IReadOnlyList<PartTransform> frames)
{ {
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(animation);
uint ownerId = entity.Id;
if (_owners.TryGetValue(ownerId, out Owner? owner) if (_owners.TryGetValue(ownerId, out Owner? owner)
&& ReferenceEquals(record.WorldEntity, entity)
&& ReferenceEquals(record.AnimationRuntime, animation)
&& record.ObjectClockEpoch == objectClockEpoch
&& record.ProjectionMutationVersion == projectionMutationVersion
&& ReferenceEquals(owner.Entity, entity)
&& ReferenceEquals(owner.Sequencer, animation.Sequencer)
&& owner.Entity.ServerGuid != 0 && owner.Entity.ServerGuid != 0
&& owner.PreparedLivePartFrames is { } prepared && owner.PreparedLivePartFrames is { } prepared
&& IsResidentAtVersion(owner, owner.PendingResidencyVersion)) && IsResidentAtVersion(owner, owner.PendingResidencyVersion))
@ -182,6 +197,29 @@ internal sealed class RetailStaticAnimatingObjectScheduler
return false; return false;
} }
/// <summary>
/// Scheduler-only test seam for DAT/static timing tests which deliberately
/// do not construct a <see cref="LiveEntityRuntime"/>. Runtime integration
/// must use the exact-incarnation overload above.
/// </summary>
internal bool TryTakePreparedFramesForTest(
uint ownerId,
out IReadOnlyList<PartTransform> frames)
{
if (_owners.TryGetValue(ownerId, out Owner? owner)
&& owner.PreparedLivePartFrames is { } prepared
&& IsResidentAtVersion(owner, owner.PendingResidencyVersion))
{
owner.PreparedLivePartFrames = null;
frames = prepared;
return true;
}
if (_owners.TryGetValue(ownerId, out owner))
InvalidatePending(owner);
frames = Array.Empty<PartTransform>();
return false;
}
public void Unregister(uint ownerId) => _owners.Remove(ownerId); public void Unregister(uint ownerId) => _owners.Remove(ownerId);
public void CopyAnimatedEntityIdsTo(HashSet<uint> destination) public void CopyAnimatedEntityIdsTo(HashSet<uint> destination)

View file

@ -139,7 +139,7 @@ public sealed class RemotePhysicsUpdaterTests
-MathF.PI / 2f), -MathF.PI / 2f),
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}; };
var animated = new GameWindow.AnimatedEntity var animated = new LiveEntityAnimationState
{ {
Entity = entity, Entity = entity,
Setup = new Setup(), Setup = new Setup(),
@ -148,7 +148,7 @@ public sealed class RemotePhysicsUpdaterTests
HighFrame = 0, HighFrame = 0,
Framerate = 0f, Framerate = 0f,
Scale = 1f, Scale = 1f,
PartTemplate = Array.Empty<GameWindow.AnimatedPartTemplate>(), PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
PartAvailability = Array.Empty<bool>(), PartAvailability = Array.Empty<bool>(),
}; };
var motion = new GameWindow.RemoteMotion(); var motion = new GameWindow.RemoteMotion();
@ -205,7 +205,7 @@ public sealed class RemotePhysicsUpdaterTests
Rotation = initial, Rotation = initial,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}; };
var animated = new GameWindow.AnimatedEntity var animated = new LiveEntityAnimationState
{ {
Entity = entity, Entity = entity,
Setup = new Setup(), Setup = new Setup(),
@ -214,7 +214,7 @@ public sealed class RemotePhysicsUpdaterTests
HighFrame = 0, HighFrame = 0,
Framerate = 0f, Framerate = 0f,
Scale = 1f, Scale = 1f,
PartTemplate = Array.Empty<GameWindow.AnimatedPartTemplate>(), PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
PartAvailability = Array.Empty<bool>(), PartAvailability = Array.Empty<bool>(),
}; };
var motion = new GameWindow.RemoteMotion var motion = new GameWindow.RemoteMotion
@ -260,7 +260,7 @@ public sealed class RemotePhysicsUpdaterTests
Rotation = initial, Rotation = initial,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}; };
var animated = new GameWindow.AnimatedEntity var animated = new LiveEntityAnimationState
{ {
Entity = entity, Entity = entity,
Setup = new Setup(), Setup = new Setup(),
@ -269,7 +269,7 @@ public sealed class RemotePhysicsUpdaterTests
HighFrame = 0, HighFrame = 0,
Framerate = 0f, Framerate = 0f,
Scale = 1f, Scale = 1f,
PartTemplate = Array.Empty<GameWindow.AnimatedPartTemplate>(), PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
PartAvailability = Array.Empty<bool>(), PartAvailability = Array.Empty<bool>(),
}; };
var motion = new GameWindow.RemoteMotion(); var motion = new GameWindow.RemoteMotion();

View file

@ -15,7 +15,7 @@ public sealed class LiveAppearanceAnimationTests
var animation = new Animation(); var animation = new Animation();
var sequencer = new AnimationSequencer(setup, new MotionTable(), new NullLoader()); var sequencer = new AnimationSequencer(setup, new MotionTable(), new NullLoader());
var oldEntity = Entity(0x70000001u, 0x01000001u); var oldEntity = Entity(0x70000001u, 0x01000001u);
var state = new GameWindow.AnimatedEntity var state = new LiveEntityAnimationState
{ {
Entity = oldEntity, Entity = oldEntity,
Setup = setup, Setup = setup,
@ -24,7 +24,7 @@ public sealed class LiveAppearanceAnimationTests
HighFrame = 9, HighFrame = 9,
Framerate = 30f, Framerate = 30f,
Scale = 1f, Scale = 1f,
PartTemplate = [new GameWindow.AnimatedPartTemplate(0x01000001u, null, true)], PartTemplate = [new LiveAnimationPartTemplate(0x01000001u, null, true)],
PartAvailability = [true], PartAvailability = [true],
CurrFrame = 6.5f, CurrFrame = 6.5f,
Sequencer = sequencer, Sequencer = sequencer,
@ -42,8 +42,8 @@ public sealed class LiveAppearanceAnimationTests
setup, setup,
1.25f, 1.25f,
[ [
new GameWindow.AnimatedPartTemplate(0x01000002u, null, true), new LiveAnimationPartTemplate(0x01000002u, null, true),
new GameWindow.AnimatedPartTemplate(0x01000003u, null, true), new LiveAnimationPartTemplate(0x01000003u, null, true),
], ],
[true, true]); [true, true]);

View file

@ -0,0 +1,295 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Rendering;
public sealed class LiveEntityAnimationPresenterTests
{
private const uint Guid = 0x70000031u;
private const uint Cell = 0x01010001u;
[Fact]
public void Present_ComposesDistinctVisualAndRigidChannels()
{
var fixture = Build(partCount: 2, scale: 2f);
fixture.State.Setup.DefaultScale[0] = new Vector3(3f, 4f, 5f);
fixture.State.PartTemplate =
[
new LiveAnimationPartTemplate(0x01000001u, new Dictionary<uint, uint> { [1] = 2 }, true),
new LiveAnimationPartTemplate(0x01000002u, null, false),
];
Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
PartTransform[] frames =
[
new(new Vector3(1f, 2f, 3f), rotation),
new(new Vector3(4f, 5f, 6f), Quaternion.Identity),
];
var poses = new EntityEffectPoseRegistry();
var presenter = Presenter(fixture.Live, poses, new Context());
presenter.Present(Schedules(fixture, frames));
Assert.Single(fixture.Entity.MeshRefs);
Matrix4x4 expectedVisual = Matrix4x4.CreateScale(3f, 4f, 5f)
* Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(1f, 2f, 3f)
* Matrix4x4.CreateScale(2f);
Matrix4x4 expectedRigid = Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(2f, 4f, 6f);
Assert.Equal(expectedVisual, fixture.Entity.MeshRefs[0].PartTransform);
Assert.Equal(expectedRigid, fixture.Entity.IndexedPartTransforms[0]);
Assert.Equal(2, fixture.Entity.IndexedPartTransforms.Count);
Assert.True(poses.TryGetPartPose(fixture.Entity.Id, 0, out Matrix4x4 effectPose));
Assert.Equal(expectedRigid, effectPose);
Assert.Equal(2u, fixture.Entity.MeshRefs[0].SurfaceOverrides![1]);
}
[Fact]
public void OldSchedule_IsRejectedForCurrentOwnerEvenWhenIndexedByItsLocalId()
{
var old = Build(partCount: 1);
LiveEntityAnimationSchedule stale = Schedule(old,
[new PartTransform(new Vector3(99f, 0f, 0f), Quaternion.Identity)]);
Assert.True(old.Live.ClearAnimationRuntime(Guid));
LiveEntityAnimationState replacement = State(old.Entity, partCount: 1, scale: 1f);
old.Live.SetAnimationRuntime(Guid, replacement);
var presenter = Presenter(old.Live, new EntityEffectPoseRegistry(), new Context());
presenter.Present(new Dictionary<uint, LiveEntityAnimationSchedule>
{
[old.Entity.Id] = stale,
});
Assert.Empty(replacement.MeshRefsScratch);
Assert.Empty(replacement.EffectPartPosesScratch);
}
[Fact]
public void MotionDone_OldComponentCannotResolveReplacementByGuid()
{
var fixture = Build(partCount: 1, withSequencer: true);
var context = new Context();
var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), context);
presenter.PrepareAnimation(fixture.Record, fixture.State);
Action<uint, bool> oldCallback = fixture.State.Sequencer!.MotionDoneTarget!;
Assert.True(fixture.Live.ClearAnimationRuntime(Guid));
fixture.Live.SetAnimationRuntime(Guid, State(fixture.Entity, 1, 1f));
oldCallback(0x10000001u, true);
Assert.Equal(0, context.ResolveCount);
}
[Fact]
public void EffectPoseCallback_NestedRuntimeSnapshotDoesNotTruncatePresentation()
{
var first = Build(partCount: 1, guid: Guid);
var second = Add(first.Live, Guid + 1, partCount: 1);
var poses = new EntityEffectPoseRegistry();
poses.EffectPoseChanged += _ =>
{
var nested = new List<LiveEntityRecord>();
first.Live.CopySpatialRootObjectRecordsTo(nested);
Assert.Equal(2, nested.Count);
};
var presenter = Presenter(first.Live, poses, new Context());
var schedules = new Dictionary<uint, LiveEntityAnimationSchedule>
{
[first.Entity.Id] = Schedule(first,
[new PartTransform(Vector3.UnitX, Quaternion.Identity)]),
[second.Entity.Id] = Schedule(second,
[new PartTransform(Vector3.UnitY, Quaternion.Identity)]),
};
presenter.Present(schedules);
Assert.Single(first.Entity.MeshRefs);
Assert.Single(second.Entity.MeshRefs);
}
private static LiveEntityAnimationPresenter Presenter(
LiveEntityRuntime live,
EntityEffectPoseRegistry poses,
Context context) => new(
() => live,
() => null,
poses,
context,
AnimationPresentationDiagnostics.Disabled,
hidePartIndex: -1);
private static IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> Schedules(
Fixture fixture,
IReadOnlyList<PartTransform> frames) =>
new Dictionary<uint, LiveEntityAnimationSchedule>
{
[fixture.Entity.Id] = Schedule(fixture, frames),
};
private static LiveEntityAnimationSchedule Schedule(
Fixture fixture,
IReadOnlyList<PartTransform> frames) => new(
frames,
0f,
true,
fixture.Record,
fixture.Entity,
fixture.State,
fixture.Record.ObjectClockEpoch,
fixture.Record.ProjectionMutationVersion);
private static Fixture Build(
int partCount,
float scale = 1f,
bool withSequencer = true,
uint guid = Guid)
{
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
var live = new LiveEntityRuntime(
spatial,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
return Add(live, guid, partCount, scale, withSequencer);
}
private static Fixture Add(
LiveEntityRuntime live,
uint guid,
int partCount,
float scale = 1f,
bool withSequencer = true)
{
LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid)).Record!;
WorldEntity entity = live.MaterializeLiveEntity(
guid,
Cell,
id => new WorldEntity
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = Cell,
})!;
LiveEntityAnimationState state = State(entity, partCount, scale, withSequencer);
entity.SetIndexedPartPoses(
Enumerable.Repeat(Matrix4x4.Identity, partCount).ToArray(),
Enumerable.Repeat(true, partCount).ToArray());
record.HasPartArray = true;
live.SetAnimationRuntime(guid, state);
return new Fixture(live, record, entity, state);
}
private static LiveEntityAnimationState State(
WorldEntity entity,
int partCount,
float scale,
bool withSequencer = false)
{
var setup = new Setup();
for (int i = 0; i < partCount; i++)
{
setup.Parts.Add(0x01000001u + (uint)i);
setup.DefaultScale.Add(Vector3.One);
}
return new LiveEntityAnimationState
{
Entity = entity,
Setup = setup,
Animation = new Animation(),
LowFrame = 0,
HighFrame = 0,
Framerate = 0f,
Scale = scale,
PartTemplate = Enumerable.Range(0, partCount)
.Select(i => new LiveAnimationPartTemplate(0x01000001u + (uint)i, null, true))
.ToArray(),
PartAvailability = Enumerable.Repeat(true, partCount).ToArray(),
Sequencer = withSequencer
? new AnimationSequencer(setup, new MotionTable(), new NullLoader())
: null,
};
}
private static WorldSession.EntitySpawn Spawn(uint guid)
{
var position = new CreateObject.ServerPosition(Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
var physics = new PhysicsSpawnData(
RawState: 0u,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: null,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
[],
[],
[],
null,
null,
"presenter fixture",
null,
null,
null,
PhysicsState: 0u,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private sealed class Context : ILiveAnimationPresentationContext
{
public uint LocalPlayerGuid => 0x50000001u;
public int ResolveCount { get; private set; }
public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record)
{
ResolveCount++;
return null;
}
}
private sealed class NullLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint id) => null;
}
private sealed record Fixture(
LiveEntityRuntime Live,
LiveEntityRecord Record,
WorldEntity Entity,
LiveEntityAnimationState State);
}

View file

@ -615,7 +615,7 @@ public sealed class LiveEntityAnimationSchedulerTests
} }
private static (LiveEntityRuntime Live, LiveEntityRecord Record, private static (LiveEntityRuntime Live, LiveEntityRecord Record,
GameWindow.AnimatedEntity Animation) BuildHiddenAnimated(uint guid) LiveEntityAnimationState Animation) BuildHiddenAnimated(uint guid)
{ {
var spatial = new GpuWorldState(); var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock( spatial.AddLandblock(new LoadedLandblock(
@ -640,7 +640,7 @@ public sealed class LiveEntityAnimationSchedulerTests
ParentCellId = Cell, ParentCellId = Cell,
})!; })!;
var setup = new Setup(); var setup = new Setup();
var animation = new GameWindow.AnimatedEntity var animation = new LiveEntityAnimationState
{ {
Entity = entity, Entity = entity,
Setup = setup, Setup = setup,
@ -649,7 +649,7 @@ public sealed class LiveEntityAnimationSchedulerTests
HighFrame = 0, HighFrame = 0,
Framerate = 0f, Framerate = 0f,
Scale = 1f, Scale = 1f,
PartTemplate = Array.Empty<GameWindow.AnimatedPartTemplate>(), PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
PartAvailability = Array.Empty<bool>(), PartAvailability = Array.Empty<bool>(),
Sequencer = new AnimationSequencer( Sequencer = new AnimationSequencer(
setup, setup,
@ -662,7 +662,7 @@ public sealed class LiveEntityAnimationSchedulerTests
} }
private static (LiveEntityRuntime Live, LiveEntityRecord Record, private static (LiveEntityRuntime Live, LiveEntityRecord Record,
GameWindow.AnimatedEntity Animation) BuildVisibleAnimatedWithPosFrames( LiveEntityAnimationState Animation) BuildVisibleAnimatedWithPosFrames(
uint guid, uint guid,
Quaternion? rootOrientation = null, Quaternion? rootOrientation = null,
Vector3? rootOrigin = null) Vector3? rootOrigin = null)
@ -694,7 +694,7 @@ public sealed class LiveEntityAnimationSchedulerTests
var loader = new DictionaryAnimationLoader(animationId, datAnimation); var loader = new DictionaryAnimationLoader(animationId, datAnimation);
var sequencer = new AnimationSequencer(setup, new MotionTable(), loader); var sequencer = new AnimationSequencer(setup, new MotionTable(), loader);
Assert.True(sequencer.InitializeSetupDefaultAnimation(animationId)); Assert.True(sequencer.InitializeSetupDefaultAnimation(animationId));
var animation = new GameWindow.AnimatedEntity var animation = new LiveEntityAnimationState
{ {
Entity = entity, Entity = entity,
Setup = setup, Setup = setup,
@ -705,7 +705,7 @@ public sealed class LiveEntityAnimationSchedulerTests
Scale = 1f, Scale = 1f,
PartTemplate = new[] PartTemplate = new[]
{ {
new GameWindow.AnimatedPartTemplate( new LiveAnimationPartTemplate(
0x0100AA01u, 0x0100AA01u,
SurfaceOverrides: null, SurfaceOverrides: null,
IsDrawable: true), IsDrawable: true),

View file

@ -150,7 +150,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
scheduler.Tick(0.02f); scheduler.Tick(0.02f);
scheduler.ProcessHooks(); scheduler.ProcessHooks();
Assert.Equal(1, captures); Assert.Equal(1, captures);
Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out var frames)); Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out var frames));
var reference = new AnimationSequencer( var reference = new AnimationSequencer(
setup, setup,
@ -269,7 +269,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
Assert.True(sequencer.InitializeSetupDefaultAnimation(alternateAnimationId)); Assert.True(sequencer.InitializeSetupDefaultAnimation(alternateAnimationId));
scheduler.Tick(0.01f); scheduler.Tick(0.01f);
Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out var frames)); Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out var frames));
Assert.True(frames[0].Origin.X >= 20f); Assert.True(frames[0].Origin.X >= 20f);
Vector3 rotatedX = Vector3.Transform(Vector3.UnitX, entity.Rotation); Vector3 rotatedX = Vector3.Transform(Vector3.UnitX, entity.Rotation);
Assert.InRange(rotatedX.X, -0.001f, 0.001f); Assert.InRange(rotatedX.X, -0.001f, 0.001f);
@ -312,7 +312,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
scheduler.Tick(0.1f); scheduler.Tick(0.1f);
Assert.Equal(0, rootCommits); Assert.Equal(0, rootCommits);
Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out _)); Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out _));
} }
[Fact] [Fact]
@ -349,7 +349,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
scheduler.Tick(0.1f); scheduler.Tick(0.1f);
scheduler.ProcessHooks(); scheduler.ProcessHooks();
Assert.False(scheduler.TryTakeLivePartFrames(OwnerId, out _)); Assert.False(scheduler.TryTakePreparedFramesForTest(OwnerId, out _));
Assert.Equal(0, captures); Assert.Equal(0, captures);
} }
@ -422,7 +422,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
scheduler.Tick(0.2f); scheduler.Tick(0.2f);
Assert.DoesNotContain("animation_done", order); Assert.DoesNotContain("animation_done", order);
Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out _)); Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out _));
order.Add("parts_published"); order.Add("parts_published");
order.Add("children_updated"); order.Add("children_updated");
scheduler.ProcessHooks(); scheduler.ProcessHooks();