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

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