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;

View file

@ -157,6 +157,36 @@ public sealed class LiveEntityAnimationPresenterTests
Assert.Single(available);
}
[Fact]
public void SchedulePreparedBeforeAppearanceRebind_IsRejected()
{
var fixture = Build(partCount: 2);
LiveEntityAnimationSchedule stale = Schedule(fixture,
[
new PartTransform(Vector3.UnitX, Quaternion.Identity),
new PartTransform(Vector3.UnitY, Quaternion.Identity),
]);
var replacement = new Setup();
replacement.Parts.Add(0x01000099u);
replacement.DefaultScale.Add(Vector3.One);
GameWindow.RebindAnimatedEntityForAppearance(
fixture.State,
fixture.Entity,
replacement,
1f,
[new LiveAnimationPartTemplate(0x01000099u, null, true)],
[true]);
var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), new Context());
presenter.Present(new Dictionary<uint, LiveEntityAnimationSchedule>
{
[fixture.Entity.Id] = stale,
});
Assert.Empty(fixture.State.MeshRefsScratch);
Assert.Empty(fixture.State.EffectPartPosesScratch);
}
[Fact]
public void MotionDone_OldComponentCannotResolveReplacementByGuid()
{
@ -173,6 +203,22 @@ public sealed class LiveEntityAnimationPresenterTests
Assert.Equal(0, context.ResolveCount);
}
[Fact]
public void MotionDone_SameComponentStillResolvesAfterObjectClockRebase()
{
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);
fixture.Record.SuspendObjectClock();
fixture.Record.ResetObjectClockForEnterWorld(isStatic: false);
fixture.State.Sequencer!.MotionDoneTarget!(0x10000001u, true);
Assert.Equal(1, context.ResolveCount);
Assert.Same(fixture.Record, context.LastRecord);
}
[Fact]
public void EffectPoseCallback_NestedRuntimeSnapshotDoesNotTruncatePresentation()
{
@ -200,6 +246,120 @@ public sealed class LiveEntityAnimationPresenterTests
Assert.Single(second.Entity.MeshRefs);
}
[Fact]
public void EffectPoseCallback_ReplacesNextOwnerWithoutPublishingItsStaleSchedule()
{
var first = Build(partCount: 1, guid: Guid);
var second = Add(first.Live, Guid + 1, partCount: 1);
LiveEntityAnimationState replacement = State(second.Entity, 1, 1f, withSequencer: true);
var poses = new EntityEffectPoseRegistry();
bool replaced = false;
poses.EffectPoseChanged += ownerId =>
{
if (ownerId != first.Entity.Id || replaced)
return;
replaced = true;
Assert.True(first.Live.ClearAnimationRuntime(Guid + 1));
first.Live.SetAnimationRuntime(Guid + 1, replacement);
};
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(new Vector3(99f, 0f, 0f), Quaternion.Identity)]),
};
presenter.Present(schedules);
Assert.True(replaced);
Assert.Empty(replacement.MeshRefsScratch);
Assert.Empty(replacement.EffectPartPosesScratch);
}
[Fact]
public void EffectPoseCallback_RecursivePresentDoesNotTruncateOuterPass()
{
var first = Build(partCount: 1, guid: Guid);
var second = Add(first.Live, Guid + 1, partCount: 1);
var poses = new EntityEffectPoseRegistry();
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)]),
};
bool recursed = false;
poses.EffectPoseChanged += _ =>
{
if (recursed)
return;
recursed = true;
presenter.Present(schedules);
};
presenter.Present(schedules);
Assert.True(recursed);
Assert.Single(first.Entity.MeshRefs);
Assert.Single(second.Entity.MeshRefs);
}
[Fact]
public void EffectPoseCallback_RecursivePresentConsumesLegacyElapsedOnce()
{
var fixture = Build(partCount: 1);
fixture.State.Sequencer = null;
fixture.State.LowFrame = 0;
fixture.State.HighFrame = 1;
fixture.State.Framerate = 1f;
fixture.State.CurrFrame = 0f;
var animation = new Animation();
for (int i = 0; i < 2; i++)
{
var frame = new AnimationFrame(1);
frame.Frames.Add(new Frame
{
Origin = new Vector3(i, 0f, 0f),
Orientation = Quaternion.Identity,
});
animation.PartFrames.Add(frame);
}
fixture.State.Animation = animation;
var schedule = new LiveEntityAnimationSchedule(
SequenceFrames: null,
LegacyAdvanceSeconds: 0.5f,
ComposeParts: true,
fixture.Record,
fixture.Entity,
fixture.State,
fixture.Record.ObjectClockEpoch,
fixture.Record.ProjectionMutationVersion,
fixture.State.PresentationRevision);
var schedules = new Dictionary<uint, LiveEntityAnimationSchedule>
{
[fixture.Entity.Id] = schedule,
};
var poses = new EntityEffectPoseRegistry();
var presenter = Presenter(fixture.Live, poses, new Context());
bool recursed = false;
poses.EffectPoseChanged += _ =>
{
if (recursed)
return;
recursed = true;
presenter.Present(schedules);
};
presenter.Present(schedules);
Assert.True(recursed);
Assert.Equal(0.5f, fixture.State.CurrFrame);
}
private static LiveEntityAnimationPresenter Presenter(
LiveEntityRuntime live,
EntityEffectPoseRegistry poses,
@ -229,7 +389,8 @@ public sealed class LiveEntityAnimationPresenterTests
fixture.Entity,
fixture.State,
fixture.Record.ObjectClockEpoch,
fixture.Record.ProjectionMutationVersion);
fixture.Record.ProjectionMutationVersion,
fixture.State.PresentationRevision);
private static Fixture Build(
int partCount,
@ -359,9 +520,11 @@ public sealed class LiveEntityAnimationPresenterTests
{
public uint LocalPlayerGuid => 0x50000001u;
public int ResolveCount { get; private set; }
public LiveEntityRecord? LastRecord { get; private set; }
public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record)
{
ResolveCount++;
LastRecord = record;
return null;
}
}

View file

@ -110,6 +110,31 @@ public sealed class LiveEntityAnimationSchedulerTests
Assert.True(animation.Entity.Position.X > startX + 1f);
}
[Fact]
public void ScheduleOwnsPartFramesAcrossLaterSequencerSampling()
{
var (live, _, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid);
LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid);
LiveEntityAnimationSchedule schedule = scheduler.Tick(
0.1f,
animation.Entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0)[animation.Entity.Id];
PartTransform retained = schedule.SequenceFrames![0];
animation.CaptureSequenceFrames(
[new PartTransform(new Vector3(77f, 76f, 75f), Quaternion.Identity)]);
foreach (AnimationFrame frame in animation.Animation.PartFrames)
frame.Frames[0].Origin = new Vector3(99f, 98f, 97f);
IReadOnlyList<PartTransform> borrowed = animation.Sequencer!.SampleCurrentPose();
Assert.Equal(new Vector3(99f, 98f, 97f), borrowed[0].Origin);
Assert.Equal(retained.Origin, schedule.SequenceFrames[0].Origin);
Assert.NotEqual(animation.SequenceFramesScratch[0].Origin, schedule.SequenceFrames[0].Origin);
Assert.NotEqual(borrowed[0].Origin, schedule.SequenceFrames[0].Origin);
}
[Fact]
public void BodylessNonWalkableAnimation_SuppressesRootOriginButPreservesOrientation()
{

View file

@ -1,6 +1,9 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
@ -451,7 +454,10 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
Omega = new Vector3(0f, 0f, MathF.PI / 2f),
};
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
Assert.True(scheduler.BindLiveOwner(entity, sequencer, body));
Assert.True(scheduler.BindLiveOwner(
entity,
LiveState(entity, setup, sequencer),
body));
sequencer.MotionDoneTarget = (_, success) =>
{
Assert.True(success);
@ -475,6 +481,154 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
Assert.Empty(sequencer.Manager.PendingAnimations);
}
[Fact]
public void RebindingLiveOwner_DiscardsFramesPreparedByPriorSequencer()
{
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => { },
(_, _, _) => { });
Setup setup = MakeSetup();
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: setup,
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true));
AnimationSequencer first = BindLiveOwner(
scheduler, entity, setup, loader, out PhysicsBody body);
scheduler.Tick(0.02f);
AnimationSequencer second = new(setup, new MotionTable(), loader);
Assert.NotSame(first, second);
Assert.True(scheduler.BindLiveOwner(
entity,
LiveState(entity, setup, second),
body));
Assert.False(scheduler.TryTakePreparedFramesForTest(OwnerId, out _));
}
[Fact]
public void RepeatedTakeAfterConsumption_PreservesPendingProcessHooks()
{
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
int captures = 0;
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => captures++,
(_, _, _) => { });
Setup setup = MakeSetup();
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: setup,
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true));
BindLiveOwner(scheduler, entity, setup, loader, out _);
scheduler.Tick(0.02f);
Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out _));
Assert.False(scheduler.TryTakePreparedFramesForTest(OwnerId, out _));
scheduler.ProcessHooks();
Assert.Equal(1, captures);
}
[Fact]
public void AppearanceRevisionRejectsPreparedPose_ButPreservesMotionDone()
{
const uint style = 0x8000003Eu;
const uint readyMotion = 0x41000003u;
const uint actionMotion = 0x10000058u;
const uint actionAnimationId = 0x0300AA03u;
const uint serverGuid = 0x70000001u;
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
loader.Add(actionAnimationId, OffsetAnimation(20f));
Setup setup = MakeSetup();
var table = new MotionTable
{
DefaultStyle = (DRWMotionCommand)style,
};
table.StyleDefaults[(DRWMotionCommand)style] =
(DRWMotionCommand)readyMotion;
int readyKey = unchecked((int)((style << 16) | (readyMotion & 0xFFFFFFu)));
table.Cycles[readyKey] = MakeMotionData(AnimationId);
var links = new MotionCommandData();
links.MotionData[unchecked((int)actionMotion)] =
MakeMotionData(actionAnimationId);
table.Links[readyKey] = links;
WorldEntity entity = MakeEntity(serverGuid);
var sequencer = new AnimationSequencer(setup, table, loader);
sequencer.SetCycle(style, readyMotion);
sequencer.ConsumePendingHooks();
sequencer.PlayAction(actionMotion);
var poses = new EntityEffectPoseRegistry();
poses.Publish(entity, entity.IndexedPartTransforms, entity.IndexedPartAvailable);
var hookFrames = new AnimationHookFrameQueue(
new AnimationHookRouter(),
poses);
int captures = 0;
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(ownerId, ownerSequencer) =>
{
captures++;
hookFrames.Capture(ownerId, ownerSequencer);
},
(_, _, _) => { });
scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: setup,
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true));
var body = new PhysicsBody
{
Orientation = entity.Rotation,
};
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
LiveEntityAnimationState state = LiveState(entity, setup, sequencer);
Assert.True(scheduler.BindLiveOwner(entity, state, body));
var record = new LiveEntityRecord(Spawn(serverGuid))
{
WorldEntity = entity,
AnimationRuntime = state,
};
int motionDone = 0;
sequencer.MotionDoneTarget = (_, success) =>
{
Assert.True(success);
motionDone++;
};
scheduler.Tick(0.2f);
state.InvalidatePresentationPoses();
Assert.False(scheduler.TryTakeLivePartFrames(
record,
entity,
state,
record.ObjectClockEpoch,
record.ProjectionMutationVersion,
state.PresentationRevision,
out _));
scheduler.ProcessHooks();
Assert.Equal(1, captures);
Assert.True(motionDone > 0);
Assert.Empty(sequencer.Manager.PendingAnimations);
}
private static Setup MakeSetup()
{
var setup = new Setup();
@ -503,6 +657,61 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
return entity;
}
private static WorldSession.EntitySpawn Spawn(uint guid)
{
const uint cell = 0x01010001u;
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,
"static appearance fixture",
null,
null,
null,
PhysicsState: 0u,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private static Animation TwoFrameAnimation()
{
var animation = new Animation();
@ -565,10 +774,32 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
Orientation = entity.Rotation,
};
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
Assert.True(scheduler.BindLiveOwner(entity, sequencer, body));
Assert.True(scheduler.BindLiveOwner(
entity,
LiveState(entity, setup, sequencer),
body));
return sequencer;
}
private static LiveEntityAnimationState LiveState(
WorldEntity entity,
Setup setup,
AnimationSequencer sequencer) => new()
{
Entity = entity,
Setup = setup,
Animation = new Animation(),
LowFrame = 0,
HighFrame = 0,
Framerate = 0f,
Scale = entity.Scale,
PartTemplate = setup.Parts.Select(
part => new LiveAnimationPartTemplate((uint)part, null, true))
.ToArray(),
PartAvailability = Enumerable.Repeat(true, setup.Parts.Count).ToArray(),
Sequencer = sequencer,
};
private sealed class Loader : IAnimationLoader
{
private readonly Dictionary<uint, Animation> _animations = new();