fix(animation): harden presentation phase handoffs

Own every borrowed PartArray frame across callbacks, version presentation state across appearance rebinding, reject recursive phase consumption, and preserve static MotionDone when only a stale visual pose is discarded.
This commit is contained in:
Erik 2026-07-21 10:01:25 +02:00
parent a2fd61684a
commit 833520253a
10 changed files with 657 additions and 140 deletions

View file

@ -671,23 +671,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
public System.Numerics.Vector3 PrevServerPos;
public double PrevServerPosTime;
public double LastOmegaDiagLogTime;
/// <summary>
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): own
/// throttle clock for the SEQSTATE log line in TickAnimations.
/// Previously SEQSTATE shared <see cref="LastOmegaDiagLogTime"/> with
/// the OMEGA_DIAG block, which fires at 0.5s and resets the clock —
/// any remote that turned during a transition silently swallowed
/// SEQSTATE for 0.51.5s, masking the bug we're trying to diagnose
/// (walk↔run leg-cycle sticking on observed retail chars). Split
/// 2026-05-03 (Commit A).
/// </summary>
public double LastSeqStateLogTime;
/// <summary>
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): own
/// throttle clock for the PARTSDIAG log line in TickAnimations
/// (D5). One log per remote per ~1s.
/// </summary>
public double LastPartsDiagLogTime;
/// <summary>
/// Diagnostic-only: maximum scaled CSequence root-motion speed
/// observed since the last UpdatePosition arrival. The next UP
@ -4539,7 +4523,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
});
_staticAnimationScheduler?.BindLiveOwner(
entity,
staticSequencer,
registeredAnimation,
staticBody);
}
}
@ -11540,7 +11524,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch;
rootFrame.Origin = System.Numerics.Vector3.Zero;
rootFrame.Orientation = System.Numerics.Quaternion.Identity;
ae.PreparedSequenceFrames = sequencer.Advance(dt, rootFrame);
ae.PreparedSequenceFrames = ae.CaptureSequenceFrames(
sequencer.Advance(dt, rootFrame));
ae.SequenceAdvancedBeforeAnimationPass = true;
output.Origin = rootFrame.Origin;

View file

@ -17,5 +17,6 @@ internal interface ILiveStaticPartFrameSource
LiveEntityAnimationState animation,
ulong objectClockEpoch,
ulong projectionMutationVersion,
ulong presentationRevision,
out IReadOnlyList<PartTransform> frames);
}

View file

@ -19,6 +19,7 @@ internal sealed class LiveEntityAnimationPresenter
private readonly AnimationPresentationDiagnostics _diagnostics;
private readonly int _hidePartIndex;
private readonly List<LiveEntityRecord> _snapshot = new();
private bool _isPresenting;
public LiveEntityAnimationPresenter(
Func<LiveEntityRuntime?> liveEntities,
@ -79,94 +80,107 @@ internal sealed class LiveEntityAnimationPresenter
{
ArgumentNullException.ThrowIfNull(schedules);
LiveEntityRuntime? runtime = _liveEntities();
if (runtime is null)
if (runtime is null || _isPresenting)
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)
// EffectPoseChanged is synchronous. Presentation is one consuming
// object-frame phase; recursive entry cannot consume legacy elapsed or
// a static prepared frame a second time.
_isPresenting = true;
try
{
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)
runtime.CopySpatialRootObjectRecordsTo(_snapshot);
foreach (LiveEntityRecord record in _snapshot)
{
if (!TryGetCurrent(
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;
ulong presentationRevision = animation.PresentationRevision;
if (_staticFrames()?.TryTakeLivePartFrames(
record,
entity,
animation,
objectClockEpoch,
projectionVersion,
presentationRevision,
out IReadOnlyList<PartTransform> staticPartFrames) == true)
{
if (!TryGetCurrent(
runtime,
record,
entity,
animation,
objectClockEpoch,
projectionVersion,
presentationRevision))
{
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))
projectionVersion,
presentationRevision))
{
continue;
}
sequenceFrames = staticPartFrames;
composeParts = true;
}
if (animation.Sequencer is not null)
{
EmitSequenceDiagnostics(record, animation, sequenceFrames);
ComposeAndPublish(runtime, record, entity, animation, sequenceFrames,
objectClockEpoch, projectionVersion, presentationRevision);
}
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);
}
finally
{
_isPresenting = false;
}
}
@ -177,7 +191,8 @@ internal sealed class LiveEntityAnimationPresenter
LiveEntityAnimationState animation,
IReadOnlyList<PartTransform>? sequenceFrames,
ulong objectClockEpoch,
ulong projectionVersion)
ulong projectionVersion,
ulong presentationRevision)
{
int partCount = animation.PartTemplate.Count;
EmitPartDiagnostics(record, animation, sequenceFrames, partCount);
@ -224,11 +239,11 @@ internal sealed class LiveEntityAnimationPresenter
});
}
if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion))
if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion, presentationRevision))
return;
entity.MeshRefs = meshRefs;
entity.SetIndexedPartPoses(rigidPoses, animation.PartAvailability);
if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion))
if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion, presentationRevision))
return;
_effectPoses.Publish(entity, rigidPoses, animation.PartAvailability);
}
@ -415,7 +430,8 @@ internal sealed class LiveEntityAnimationPresenter
entity,
animation,
schedule.ObjectClockEpoch,
schedule.ProjectionMutationVersion);
schedule.ProjectionMutationVersion,
schedule.PresentationRevision);
private static bool TryGetCurrent(
LiveEntityRuntime runtime,
@ -448,8 +464,10 @@ internal sealed class LiveEntityAnimationPresenter
WorldEntity entity,
LiveEntityAnimationState animation,
ulong objectClockEpoch,
ulong projectionVersion) =>
ulong projectionVersion,
ulong presentationRevision) =>
TryGetCurrent(runtime, record, entity, animation)
&& record.ObjectClockEpoch == objectClockEpoch
&& record.ProjectionMutationVersion == projectionVersion;
&& record.ProjectionMutationVersion == projectionVersion
&& animation.PresentationRevision == presentationRevision;
}

View file

@ -139,13 +139,18 @@ internal sealed class LiveEntityAnimationScheduler
projectile,
objectClockEpoch))
{
IReadOnlyList<PartTransform>? ownedFrames = schedule.SequenceFrames is { } produced
? animation.CaptureScheduleFrames(produced)
: null;
_schedules[entity.Id] = schedule with
{
SequenceFrames = ownedFrames,
Record = record,
Entity = entity,
Animation = animation,
ObjectClockEpoch = objectClockEpoch,
ProjectionMutationVersion = record.ProjectionMutationVersion,
PresentationRevision = animation.PresentationRevision,
};
}
}
@ -217,7 +222,9 @@ internal sealed class LiveEntityAnimationScheduler
}
return new LiveEntityAnimationSchedule(
prepared ?? (composeHidden ? sequencer?.SampleCurrentPose() : null),
prepared ?? (composeHidden && sequencer is not null
? animation?.CaptureSequenceFrames(sequencer.SampleCurrentPose())
: null),
0f,
ComposeParts: advanced || composeHidden);
}
@ -311,7 +318,9 @@ internal sealed class LiveEntityAnimationScheduler
rootFrame.Origin = Vector3.Zero;
rootFrame.Orientation = Quaternion.Identity;
if (sequencer is not null)
frames = sequencer.Advance(quantum, rootFrame);
frames = animation?.CaptureSequenceFrames(
sequencer.Advance(quantum, rootFrame))
?? sequencer.Advance(quantum, rootFrame);
else if (animation is not null)
legacyElapsed += quantum;
@ -441,7 +450,9 @@ internal sealed class LiveEntityAnimationScheduler
if (hidden)
{
return new LiveEntityAnimationSchedule(
sequencer?.SampleCurrentPose(),
sequencer is not null && animation is not null
? animation.CaptureSequenceFrames(sequencer.SampleCurrentPose())
: null,
0f,
ComposeParts: animation is not null);
}
@ -531,4 +542,5 @@ internal readonly record struct LiveEntityAnimationSchedule(
WorldEntity? Entity = null,
LiveEntityAnimationState? Animation = null,
ulong ObjectClockEpoch = 0UL,
ulong ProjectionMutationVersion = 0UL);
ulong ProjectionMutationVersion = 0UL,
ulong PresentationRevision = 0UL);

View file

@ -34,6 +34,8 @@ internal sealed class LiveEntityAnimationState : ILiveEntityAnimationRuntime
public bool SequenceAdvancedBeforeAnimationPass;
public readonly Frame RootMotionScratch = new();
public readonly MotionDeltaFrame RootMotionDeltaScratch = new();
public readonly List<PartTransform> SequenceFramesScratch = new();
public readonly List<PartTransform> ScheduleFramesScratch = new();
/// <summary>Reusable compact drawable channel, consumed before draw.</summary>
public readonly List<MeshRef> MeshRefsScratch = new();
@ -45,16 +47,40 @@ internal sealed class LiveEntityAnimationState : ILiveEntityAnimationRuntime
/// </summary>
public readonly List<Matrix4x4> VisualPartPosesScratch = new();
public bool PresentationPosesInitialized;
public ulong PresentationRevision { get; private set; } = 1UL;
public double LastSequenceDiagnosticTime;
public double LastPartDiagnosticTime;
public void InvalidatePresentationPoses()
{
PresentationRevision++;
if (PresentationRevision == 0UL)
PresentationRevision++;
PresentationPosesInitialized = false;
VisualPartPosesScratch.Clear();
EffectPartPosesScratch.Clear();
}
public IReadOnlyList<PartTransform> CaptureSequenceFrames(
IReadOnlyList<PartTransform> source)
{
ArgumentNullException.ThrowIfNull(source);
SequenceFramesScratch.Clear();
for (int i = 0; i < source.Count; i++)
SequenceFramesScratch.Add(source[i]);
return SequenceFramesScratch;
}
public IReadOnlyList<PartTransform> CaptureScheduleFrames(
IReadOnlyList<PartTransform> source)
{
ArgumentNullException.ThrowIfNull(source);
ScheduleFramesScratch.Clear();
for (int i = 0; i < source.Count; i++)
ScheduleFramesScratch.Add(source[i]);
return ScheduleFramesScratch;
}
}
internal readonly record struct LiveAnimationPartTemplate(

View file

@ -34,7 +34,11 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
public PhysicsBody? Body;
public AnimationSequencer? PendingProcessHooks;
public ulong PendingResidencyVersion;
public IReadOnlyList<PartTransform>? PreparedLivePartFrames;
public readonly List<PartTransform> PreparedLivePartFrames = new();
public bool HasPreparedLivePartFrames;
public AnimationSequencer? PreparedFrameSequencer;
public ulong PreparedPresentationRevision;
public LiveEntityAnimationState? LiveAnimation;
public double ElapsedSinceUpdate;
public readonly Frame RootFrameScratch = new();
public readonly List<MeshRef> MeshRefs = new();
@ -137,11 +141,11 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
/// </summary>
public bool BindLiveOwner(
WorldEntity entity,
AnimationSequencer sequencer,
LiveEntityAnimationState animation,
PhysicsBody body)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(sequencer);
ArgumentNullException.ThrowIfNull(animation);
ArgumentNullException.ThrowIfNull(body);
if (!_owners.TryGetValue(entity.Id, out Owner? owner))
return false;
@ -150,10 +154,12 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
throw new InvalidOperationException(
$"Static animation owner 0x{entity.Id:X8} does not match this live incarnation.");
}
if (!sequencer.HasCurrentNode)
if (animation.Sequencer is not { HasCurrentNode: true } sequencer)
return false;
InvalidatePending(owner);
owner.Sequencer = sequencer;
owner.LiveAnimation = animation;
owner.Body = body;
return true;
}
@ -169,33 +175,64 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
LiveEntityAnimationState animation,
ulong objectClockEpoch,
ulong projectionMutationVersion,
ulong presentationRevision,
out IReadOnlyList<PartTransform> frames)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(animation);
uint ownerId = entity.Id;
if (_owners.TryGetValue(ownerId, out Owner? owner)
&& ReferenceEquals(record.WorldEntity, entity)
if (!_owners.TryGetValue(ownerId, out Owner? owner))
{
frames = Array.Empty<PartTransform>();
return false;
}
bool exactOwner = ReferenceEquals(record.WorldEntity, entity)
&& ReferenceEquals(record.AnimationRuntime, animation)
&& record.ObjectClockEpoch == objectClockEpoch
&& record.ProjectionMutationVersion == projectionMutationVersion
&& animation.PresentationRevision == presentationRevision
&& ReferenceEquals(owner.Entity, entity)
&& ReferenceEquals(owner.LiveAnimation, animation)
&& ReferenceEquals(owner.Sequencer, animation.Sequencer)
&& owner.Entity.ServerGuid != 0
&& owner.PreparedLivePartFrames is { } prepared
&& IsResidentAtVersion(owner, owner.PendingResidencyVersion))
&& owner.Entity.ServerGuid != 0;
if (!exactOwner)
{
owner.PreparedLivePartFrames = null;
frames = prepared;
return true;
InvalidatePending(owner);
frames = Array.Empty<PartTransform>();
return false;
}
if (_owners.TryGetValue(ownerId, out owner))
InvalidatePending(owner);
// An already-consumed frame is a normal same-phase recursive query;
// process_hooks still belongs to the outer presentation tail.
if (!owner.HasPreparedLivePartFrames)
{
frames = Array.Empty<PartTransform>();
return false;
}
frames = Array.Empty<PartTransform>();
return false;
if (!ReferenceEquals(owner.PreparedFrameSequencer, animation.Sequencer)
|| !IsResidentAtVersion(owner, owner.PendingResidencyVersion))
{
InvalidatePending(owner);
frames = Array.Empty<PartTransform>();
return false;
}
if (owner.PreparedPresentationRevision != presentationRevision)
{
// Appearance rebinding invalidates only the prepared visual pose.
// The exact live owner and sequencer still own process_hooks from
// this quantum, including MotionDone, so retain that semantic tail.
InvalidatePreparedFrame(owner);
frames = Array.Empty<PartTransform>();
return false;
}
owner.HasPreparedLivePartFrames = false;
frames = owner.PreparedLivePartFrames;
return true;
}
/// <summary>
@ -207,18 +244,25 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
uint ownerId,
out IReadOnlyList<PartTransform> frames)
{
if (_owners.TryGetValue(ownerId, out Owner? owner)
&& owner.PreparedLivePartFrames is { } prepared
&& IsResidentAtVersion(owner, owner.PendingResidencyVersion))
if (!_owners.TryGetValue(ownerId, out Owner? owner))
{
owner.PreparedLivePartFrames = null;
frames = prepared;
return true;
frames = Array.Empty<PartTransform>();
return false;
}
if (_owners.TryGetValue(ownerId, out owner))
if (!owner.HasPreparedLivePartFrames)
{
frames = Array.Empty<PartTransform>();
return false;
}
if (!IsResidentAtVersion(owner, owner.PendingResidencyVersion))
{
InvalidatePending(owner);
frames = Array.Empty<PartTransform>();
return false;
frames = Array.Empty<PartTransform>();
return false;
}
owner.HasPreparedLivePartFrames = false;
frames = owner.PreparedLivePartFrames;
return true;
}
public void Unregister(uint ownerId) => _owners.Remove(ownerId);
@ -273,8 +317,10 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
continue;
}
IReadOnlyList<PartTransform> frames = sequencer.Advance(
(float)ownerElapsed);
IReadOnlyList<PartTransform> frames = sequencer.Advance((float)ownerElapsed);
owner.PreparedLivePartFrames.Clear();
for (int i = 0; i < frames.Count; i++)
owner.PreparedLivePartFrames.Add(frames[i]);
if (owner.Body is { } body)
{
@ -319,11 +365,14 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
if (owner.Entity.ServerGuid != 0)
{
owner.PreparedLivePartFrames = frames;
owner.HasPreparedLivePartFrames = true;
owner.PreparedFrameSequencer = sequencer;
owner.PreparedPresentationRevision =
owner.LiveAnimation?.PresentationRevision ?? 0UL;
}
else
{
Compose(owner, frames);
Compose(owner, owner.PreparedLivePartFrames);
_publishPartPoses(owner.Entity, owner.PartPoses, owner.PartAvailable);
if (!_owners.TryGetValue(owner.Entity.Id, out current)
|| !ReferenceEquals(current, owner)
@ -383,11 +432,19 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
private static void InvalidatePending(Owner owner)
{
owner.PreparedLivePartFrames = null;
InvalidatePreparedFrame(owner);
owner.PendingProcessHooks = null;
owner.PendingResidencyVersion = 0UL;
}
private static void InvalidatePreparedFrame(Owner owner)
{
owner.PreparedLivePartFrames.Clear();
owner.HasPreparedLivePartFrames = false;
owner.PreparedFrameSequencer = null;
owner.PreparedPresentationRevision = 0UL;
}
private static void Compose(Owner owner, IReadOnlyList<PartTransform> frames)
{
EnsureRetainedPoses(owner);

View file

@ -248,10 +248,9 @@ public sealed class AnimationSequencer
// allocation instead of one `new PartTransform[partCount]` per Advance()
// call (every animated entity, every tick, including idle NPCs on a
// breathe cycle). BuildBlendedFrame/BuildIdentityFrame overwrite every
// slot before returning this array, so the values are bit-identical to
// the old fresh-array version; callers (GameWindow.TickAnimations)
// consume the returned IReadOnlyList<PartTransform> synchronously within
// the same loop iteration and never cache it across Advance() calls.
// authored slot before returning this view. The view is borrowed only for
// the producing call: App schedulers copy its authored prefix into their
// incarnation-owned handoff buffer before any callback or later phase.
private readonly PartTransform[] _partTransformScratch;
private readonly PartTransformBuffer _partTransformView;