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:
parent
a2fd61684a
commit
833520253a
10 changed files with 657 additions and 140 deletions
|
|
@ -671,23 +671,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
public System.Numerics.Vector3 PrevServerPos;
|
public System.Numerics.Vector3 PrevServerPos;
|
||||||
public double PrevServerPosTime;
|
public double PrevServerPosTime;
|
||||||
public double LastOmegaDiagLogTime;
|
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.5–1.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>
|
/// <summary>
|
||||||
/// Diagnostic-only: maximum scaled CSequence root-motion speed
|
/// Diagnostic-only: maximum scaled CSequence root-motion speed
|
||||||
/// observed since the last UpdatePosition arrival. The next UP
|
/// observed since the last UpdatePosition arrival. The next UP
|
||||||
|
|
@ -4539,7 +4523,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
});
|
});
|
||||||
_staticAnimationScheduler?.BindLiveOwner(
|
_staticAnimationScheduler?.BindLiveOwner(
|
||||||
entity,
|
entity,
|
||||||
staticSequencer,
|
registeredAnimation,
|
||||||
staticBody);
|
staticBody);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -11540,7 +11524,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch;
|
DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch;
|
||||||
rootFrame.Origin = System.Numerics.Vector3.Zero;
|
rootFrame.Origin = System.Numerics.Vector3.Zero;
|
||||||
rootFrame.Orientation = System.Numerics.Quaternion.Identity;
|
rootFrame.Orientation = System.Numerics.Quaternion.Identity;
|
||||||
ae.PreparedSequenceFrames = sequencer.Advance(dt, rootFrame);
|
ae.PreparedSequenceFrames = ae.CaptureSequenceFrames(
|
||||||
|
sequencer.Advance(dt, rootFrame));
|
||||||
ae.SequenceAdvancedBeforeAnimationPass = true;
|
ae.SequenceAdvancedBeforeAnimationPass = true;
|
||||||
|
|
||||||
output.Origin = rootFrame.Origin;
|
output.Origin = rootFrame.Origin;
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,6 @@ internal interface ILiveStaticPartFrameSource
|
||||||
LiveEntityAnimationState animation,
|
LiveEntityAnimationState animation,
|
||||||
ulong objectClockEpoch,
|
ulong objectClockEpoch,
|
||||||
ulong projectionMutationVersion,
|
ulong projectionMutationVersion,
|
||||||
|
ulong presentationRevision,
|
||||||
out IReadOnlyList<PartTransform> frames);
|
out IReadOnlyList<PartTransform> frames);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ internal sealed class LiveEntityAnimationPresenter
|
||||||
private readonly AnimationPresentationDiagnostics _diagnostics;
|
private readonly AnimationPresentationDiagnostics _diagnostics;
|
||||||
private readonly int _hidePartIndex;
|
private readonly int _hidePartIndex;
|
||||||
private readonly List<LiveEntityRecord> _snapshot = new();
|
private readonly List<LiveEntityRecord> _snapshot = new();
|
||||||
|
private bool _isPresenting;
|
||||||
|
|
||||||
public LiveEntityAnimationPresenter(
|
public LiveEntityAnimationPresenter(
|
||||||
Func<LiveEntityRuntime?> liveEntities,
|
Func<LiveEntityRuntime?> liveEntities,
|
||||||
|
|
@ -79,94 +80,107 @@ internal sealed class LiveEntityAnimationPresenter
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(schedules);
|
ArgumentNullException.ThrowIfNull(schedules);
|
||||||
LiveEntityRuntime? runtime = _liveEntities();
|
LiveEntityRuntime? runtime = _liveEntities();
|
||||||
if (runtime is null)
|
if (runtime is null || _isPresenting)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Private snapshot: EffectPoseChanged is synchronous and may perform a
|
// EffectPoseChanged is synchronous. Presentation is one consuming
|
||||||
// nested runtime-view enumeration. Never borrow that view's scratch.
|
// object-frame phase; recursive entry cannot consume legacy elapsed or
|
||||||
runtime.CopySpatialRootObjectRecordsTo(_snapshot);
|
// a static prepared frame a second time.
|
||||||
foreach (LiveEntityRecord record in _snapshot)
|
_isPresenting = true;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
if (!TryGetCurrent(runtime, record, out WorldEntity entity, out LiveEntityAnimationState animation))
|
runtime.CopySpatialRootObjectRecordsTo(_snapshot);
|
||||||
continue;
|
foreach (LiveEntityRecord record in _snapshot)
|
||||||
|
|
||||||
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(
|
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,
|
runtime,
|
||||||
record,
|
record,
|
||||||
entity,
|
entity,
|
||||||
animation,
|
animation,
|
||||||
objectClockEpoch,
|
objectClockEpoch,
|
||||||
projectionVersion))
|
projectionVersion,
|
||||||
|
presentationRevision))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
sequenceFrames = staticPartFrames;
|
|
||||||
composeParts = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (animation.Sequencer is not null)
|
ComposeAndPublish(runtime, record, entity, animation, sequenceFrames,
|
||||||
{
|
objectClockEpoch, projectionVersion, presentationRevision);
|
||||||
EmitSequenceDiagnostics(record, animation, sequenceFrames);
|
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
{
|
finally
|
||||||
int span = animation.HighFrame - animation.LowFrame;
|
{
|
||||||
bool hiddenLegacy = (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
|
_isPresenting = false;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,7 +191,8 @@ internal sealed class LiveEntityAnimationPresenter
|
||||||
LiveEntityAnimationState animation,
|
LiveEntityAnimationState animation,
|
||||||
IReadOnlyList<PartTransform>? sequenceFrames,
|
IReadOnlyList<PartTransform>? sequenceFrames,
|
||||||
ulong objectClockEpoch,
|
ulong objectClockEpoch,
|
||||||
ulong projectionVersion)
|
ulong projectionVersion,
|
||||||
|
ulong presentationRevision)
|
||||||
{
|
{
|
||||||
int partCount = animation.PartTemplate.Count;
|
int partCount = animation.PartTemplate.Count;
|
||||||
EmitPartDiagnostics(record, animation, sequenceFrames, partCount);
|
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;
|
return;
|
||||||
entity.MeshRefs = meshRefs;
|
entity.MeshRefs = meshRefs;
|
||||||
entity.SetIndexedPartPoses(rigidPoses, animation.PartAvailability);
|
entity.SetIndexedPartPoses(rigidPoses, animation.PartAvailability);
|
||||||
if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion))
|
if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion, presentationRevision))
|
||||||
return;
|
return;
|
||||||
_effectPoses.Publish(entity, rigidPoses, animation.PartAvailability);
|
_effectPoses.Publish(entity, rigidPoses, animation.PartAvailability);
|
||||||
}
|
}
|
||||||
|
|
@ -415,7 +430,8 @@ internal sealed class LiveEntityAnimationPresenter
|
||||||
entity,
|
entity,
|
||||||
animation,
|
animation,
|
||||||
schedule.ObjectClockEpoch,
|
schedule.ObjectClockEpoch,
|
||||||
schedule.ProjectionMutationVersion);
|
schedule.ProjectionMutationVersion,
|
||||||
|
schedule.PresentationRevision);
|
||||||
|
|
||||||
private static bool TryGetCurrent(
|
private static bool TryGetCurrent(
|
||||||
LiveEntityRuntime runtime,
|
LiveEntityRuntime runtime,
|
||||||
|
|
@ -448,8 +464,10 @@ internal sealed class LiveEntityAnimationPresenter
|
||||||
WorldEntity entity,
|
WorldEntity entity,
|
||||||
LiveEntityAnimationState animation,
|
LiveEntityAnimationState animation,
|
||||||
ulong objectClockEpoch,
|
ulong objectClockEpoch,
|
||||||
ulong projectionVersion) =>
|
ulong projectionVersion,
|
||||||
|
ulong presentationRevision) =>
|
||||||
TryGetCurrent(runtime, record, entity, animation)
|
TryGetCurrent(runtime, record, entity, animation)
|
||||||
&& record.ObjectClockEpoch == objectClockEpoch
|
&& record.ObjectClockEpoch == objectClockEpoch
|
||||||
&& record.ProjectionMutationVersion == projectionVersion;
|
&& record.ProjectionMutationVersion == projectionVersion
|
||||||
|
&& animation.PresentationRevision == presentationRevision;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,13 +139,18 @@ internal sealed class LiveEntityAnimationScheduler
|
||||||
projectile,
|
projectile,
|
||||||
objectClockEpoch))
|
objectClockEpoch))
|
||||||
{
|
{
|
||||||
|
IReadOnlyList<PartTransform>? ownedFrames = schedule.SequenceFrames is { } produced
|
||||||
|
? animation.CaptureScheduleFrames(produced)
|
||||||
|
: null;
|
||||||
_schedules[entity.Id] = schedule with
|
_schedules[entity.Id] = schedule with
|
||||||
{
|
{
|
||||||
|
SequenceFrames = ownedFrames,
|
||||||
Record = record,
|
Record = record,
|
||||||
Entity = entity,
|
Entity = entity,
|
||||||
Animation = animation,
|
Animation = animation,
|
||||||
ObjectClockEpoch = objectClockEpoch,
|
ObjectClockEpoch = objectClockEpoch,
|
||||||
ProjectionMutationVersion = record.ProjectionMutationVersion,
|
ProjectionMutationVersion = record.ProjectionMutationVersion,
|
||||||
|
PresentationRevision = animation.PresentationRevision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -217,7 +222,9 @@ internal sealed class LiveEntityAnimationScheduler
|
||||||
}
|
}
|
||||||
|
|
||||||
return new LiveEntityAnimationSchedule(
|
return new LiveEntityAnimationSchedule(
|
||||||
prepared ?? (composeHidden ? sequencer?.SampleCurrentPose() : null),
|
prepared ?? (composeHidden && sequencer is not null
|
||||||
|
? animation?.CaptureSequenceFrames(sequencer.SampleCurrentPose())
|
||||||
|
: null),
|
||||||
0f,
|
0f,
|
||||||
ComposeParts: advanced || composeHidden);
|
ComposeParts: advanced || composeHidden);
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +318,9 @@ internal sealed class LiveEntityAnimationScheduler
|
||||||
rootFrame.Origin = Vector3.Zero;
|
rootFrame.Origin = Vector3.Zero;
|
||||||
rootFrame.Orientation = Quaternion.Identity;
|
rootFrame.Orientation = Quaternion.Identity;
|
||||||
if (sequencer is not null)
|
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)
|
else if (animation is not null)
|
||||||
legacyElapsed += quantum;
|
legacyElapsed += quantum;
|
||||||
|
|
||||||
|
|
@ -441,7 +450,9 @@ internal sealed class LiveEntityAnimationScheduler
|
||||||
if (hidden)
|
if (hidden)
|
||||||
{
|
{
|
||||||
return new LiveEntityAnimationSchedule(
|
return new LiveEntityAnimationSchedule(
|
||||||
sequencer?.SampleCurrentPose(),
|
sequencer is not null && animation is not null
|
||||||
|
? animation.CaptureSequenceFrames(sequencer.SampleCurrentPose())
|
||||||
|
: null,
|
||||||
0f,
|
0f,
|
||||||
ComposeParts: animation is not null);
|
ComposeParts: animation is not null);
|
||||||
}
|
}
|
||||||
|
|
@ -531,4 +542,5 @@ internal readonly record struct LiveEntityAnimationSchedule(
|
||||||
WorldEntity? Entity = null,
|
WorldEntity? Entity = null,
|
||||||
LiveEntityAnimationState? Animation = null,
|
LiveEntityAnimationState? Animation = null,
|
||||||
ulong ObjectClockEpoch = 0UL,
|
ulong ObjectClockEpoch = 0UL,
|
||||||
ulong ProjectionMutationVersion = 0UL);
|
ulong ProjectionMutationVersion = 0UL,
|
||||||
|
ulong PresentationRevision = 0UL);
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ internal sealed class LiveEntityAnimationState : ILiveEntityAnimationRuntime
|
||||||
public bool SequenceAdvancedBeforeAnimationPass;
|
public bool SequenceAdvancedBeforeAnimationPass;
|
||||||
public readonly Frame RootMotionScratch = new();
|
public readonly Frame RootMotionScratch = new();
|
||||||
public readonly MotionDeltaFrame RootMotionDeltaScratch = 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>
|
/// <summary>Reusable compact drawable channel, consumed before draw.</summary>
|
||||||
public readonly List<MeshRef> MeshRefsScratch = new();
|
public readonly List<MeshRef> MeshRefsScratch = new();
|
||||||
|
|
@ -45,16 +47,40 @@ internal sealed class LiveEntityAnimationState : ILiveEntityAnimationRuntime
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly List<Matrix4x4> VisualPartPosesScratch = new();
|
public readonly List<Matrix4x4> VisualPartPosesScratch = new();
|
||||||
public bool PresentationPosesInitialized;
|
public bool PresentationPosesInitialized;
|
||||||
|
public ulong PresentationRevision { get; private set; } = 1UL;
|
||||||
|
|
||||||
public double LastSequenceDiagnosticTime;
|
public double LastSequenceDiagnosticTime;
|
||||||
public double LastPartDiagnosticTime;
|
public double LastPartDiagnosticTime;
|
||||||
|
|
||||||
public void InvalidatePresentationPoses()
|
public void InvalidatePresentationPoses()
|
||||||
{
|
{
|
||||||
|
PresentationRevision++;
|
||||||
|
if (PresentationRevision == 0UL)
|
||||||
|
PresentationRevision++;
|
||||||
PresentationPosesInitialized = false;
|
PresentationPosesInitialized = false;
|
||||||
VisualPartPosesScratch.Clear();
|
VisualPartPosesScratch.Clear();
|
||||||
EffectPartPosesScratch.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(
|
internal readonly record struct LiveAnimationPartTemplate(
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,11 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
public PhysicsBody? Body;
|
public PhysicsBody? Body;
|
||||||
public AnimationSequencer? PendingProcessHooks;
|
public AnimationSequencer? PendingProcessHooks;
|
||||||
public ulong PendingResidencyVersion;
|
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 double ElapsedSinceUpdate;
|
||||||
public readonly Frame RootFrameScratch = new();
|
public readonly Frame RootFrameScratch = new();
|
||||||
public readonly List<MeshRef> MeshRefs = new();
|
public readonly List<MeshRef> MeshRefs = new();
|
||||||
|
|
@ -137,11 +141,11 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool BindLiveOwner(
|
public bool BindLiveOwner(
|
||||||
WorldEntity entity,
|
WorldEntity entity,
|
||||||
AnimationSequencer sequencer,
|
LiveEntityAnimationState animation,
|
||||||
PhysicsBody body)
|
PhysicsBody body)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(entity);
|
ArgumentNullException.ThrowIfNull(entity);
|
||||||
ArgumentNullException.ThrowIfNull(sequencer);
|
ArgumentNullException.ThrowIfNull(animation);
|
||||||
ArgumentNullException.ThrowIfNull(body);
|
ArgumentNullException.ThrowIfNull(body);
|
||||||
if (!_owners.TryGetValue(entity.Id, out Owner? owner))
|
if (!_owners.TryGetValue(entity.Id, out Owner? owner))
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -150,10 +154,12 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Static animation owner 0x{entity.Id:X8} does not match this live incarnation.");
|
$"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;
|
return false;
|
||||||
|
|
||||||
|
InvalidatePending(owner);
|
||||||
owner.Sequencer = sequencer;
|
owner.Sequencer = sequencer;
|
||||||
|
owner.LiveAnimation = animation;
|
||||||
owner.Body = body;
|
owner.Body = body;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -169,33 +175,64 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
LiveEntityAnimationState animation,
|
LiveEntityAnimationState animation,
|
||||||
ulong objectClockEpoch,
|
ulong objectClockEpoch,
|
||||||
ulong projectionMutationVersion,
|
ulong projectionMutationVersion,
|
||||||
|
ulong presentationRevision,
|
||||||
out IReadOnlyList<PartTransform> frames)
|
out IReadOnlyList<PartTransform> frames)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(record);
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
ArgumentNullException.ThrowIfNull(entity);
|
ArgumentNullException.ThrowIfNull(entity);
|
||||||
ArgumentNullException.ThrowIfNull(animation);
|
ArgumentNullException.ThrowIfNull(animation);
|
||||||
uint ownerId = entity.Id;
|
uint ownerId = entity.Id;
|
||||||
if (_owners.TryGetValue(ownerId, out Owner? owner)
|
if (!_owners.TryGetValue(ownerId, out Owner? owner))
|
||||||
&& ReferenceEquals(record.WorldEntity, entity)
|
{
|
||||||
|
frames = Array.Empty<PartTransform>();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool exactOwner = ReferenceEquals(record.WorldEntity, entity)
|
||||||
&& ReferenceEquals(record.AnimationRuntime, animation)
|
&& ReferenceEquals(record.AnimationRuntime, animation)
|
||||||
&& record.ObjectClockEpoch == objectClockEpoch
|
&& record.ObjectClockEpoch == objectClockEpoch
|
||||||
&& record.ProjectionMutationVersion == projectionMutationVersion
|
&& record.ProjectionMutationVersion == projectionMutationVersion
|
||||||
|
&& animation.PresentationRevision == presentationRevision
|
||||||
&& ReferenceEquals(owner.Entity, entity)
|
&& ReferenceEquals(owner.Entity, entity)
|
||||||
|
&& ReferenceEquals(owner.LiveAnimation, animation)
|
||||||
&& ReferenceEquals(owner.Sequencer, animation.Sequencer)
|
&& ReferenceEquals(owner.Sequencer, animation.Sequencer)
|
||||||
&& owner.Entity.ServerGuid != 0
|
&& owner.Entity.ServerGuid != 0;
|
||||||
&& owner.PreparedLivePartFrames is { } prepared
|
if (!exactOwner)
|
||||||
&& IsResidentAtVersion(owner, owner.PendingResidencyVersion))
|
|
||||||
{
|
{
|
||||||
owner.PreparedLivePartFrames = null;
|
InvalidatePending(owner);
|
||||||
frames = prepared;
|
frames = Array.Empty<PartTransform>();
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_owners.TryGetValue(ownerId, out owner))
|
// An already-consumed frame is a normal same-phase recursive query;
|
||||||
InvalidatePending(owner);
|
// process_hooks still belongs to the outer presentation tail.
|
||||||
|
if (!owner.HasPreparedLivePartFrames)
|
||||||
|
{
|
||||||
|
frames = Array.Empty<PartTransform>();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
frames = Array.Empty<PartTransform>();
|
if (!ReferenceEquals(owner.PreparedFrameSequencer, animation.Sequencer)
|
||||||
return false;
|
|| !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>
|
/// <summary>
|
||||||
|
|
@ -207,18 +244,25 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
uint ownerId,
|
uint ownerId,
|
||||||
out IReadOnlyList<PartTransform> frames)
|
out IReadOnlyList<PartTransform> frames)
|
||||||
{
|
{
|
||||||
if (_owners.TryGetValue(ownerId, out Owner? owner)
|
if (!_owners.TryGetValue(ownerId, out Owner? owner))
|
||||||
&& owner.PreparedLivePartFrames is { } prepared
|
|
||||||
&& IsResidentAtVersion(owner, owner.PendingResidencyVersion))
|
|
||||||
{
|
{
|
||||||
owner.PreparedLivePartFrames = null;
|
frames = Array.Empty<PartTransform>();
|
||||||
frames = prepared;
|
return false;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
if (_owners.TryGetValue(ownerId, out owner))
|
if (!owner.HasPreparedLivePartFrames)
|
||||||
|
{
|
||||||
|
frames = Array.Empty<PartTransform>();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!IsResidentAtVersion(owner, owner.PendingResidencyVersion))
|
||||||
|
{
|
||||||
InvalidatePending(owner);
|
InvalidatePending(owner);
|
||||||
frames = Array.Empty<PartTransform>();
|
frames = Array.Empty<PartTransform>();
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
owner.HasPreparedLivePartFrames = false;
|
||||||
|
frames = owner.PreparedLivePartFrames;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Unregister(uint ownerId) => _owners.Remove(ownerId);
|
public void Unregister(uint ownerId) => _owners.Remove(ownerId);
|
||||||
|
|
@ -273,8 +317,10 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
IReadOnlyList<PartTransform> frames = sequencer.Advance(
|
IReadOnlyList<PartTransform> frames = sequencer.Advance((float)ownerElapsed);
|
||||||
(float)ownerElapsed);
|
owner.PreparedLivePartFrames.Clear();
|
||||||
|
for (int i = 0; i < frames.Count; i++)
|
||||||
|
owner.PreparedLivePartFrames.Add(frames[i]);
|
||||||
|
|
||||||
if (owner.Body is { } body)
|
if (owner.Body is { } body)
|
||||||
{
|
{
|
||||||
|
|
@ -319,11 +365,14 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
|
|
||||||
if (owner.Entity.ServerGuid != 0)
|
if (owner.Entity.ServerGuid != 0)
|
||||||
{
|
{
|
||||||
owner.PreparedLivePartFrames = frames;
|
owner.HasPreparedLivePartFrames = true;
|
||||||
|
owner.PreparedFrameSequencer = sequencer;
|
||||||
|
owner.PreparedPresentationRevision =
|
||||||
|
owner.LiveAnimation?.PresentationRevision ?? 0UL;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Compose(owner, frames);
|
Compose(owner, owner.PreparedLivePartFrames);
|
||||||
_publishPartPoses(owner.Entity, owner.PartPoses, owner.PartAvailable);
|
_publishPartPoses(owner.Entity, owner.PartPoses, owner.PartAvailable);
|
||||||
if (!_owners.TryGetValue(owner.Entity.Id, out current)
|
if (!_owners.TryGetValue(owner.Entity.Id, out current)
|
||||||
|| !ReferenceEquals(current, owner)
|
|| !ReferenceEquals(current, owner)
|
||||||
|
|
@ -383,11 +432,19 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|
||||||
|
|
||||||
private static void InvalidatePending(Owner owner)
|
private static void InvalidatePending(Owner owner)
|
||||||
{
|
{
|
||||||
owner.PreparedLivePartFrames = null;
|
InvalidatePreparedFrame(owner);
|
||||||
owner.PendingProcessHooks = null;
|
owner.PendingProcessHooks = null;
|
||||||
owner.PendingResidencyVersion = 0UL;
|
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)
|
private static void Compose(Owner owner, IReadOnlyList<PartTransform> frames)
|
||||||
{
|
{
|
||||||
EnsureRetainedPoses(owner);
|
EnsureRetainedPoses(owner);
|
||||||
|
|
|
||||||
|
|
@ -248,10 +248,9 @@ public sealed class AnimationSequencer
|
||||||
// allocation instead of one `new PartTransform[partCount]` per Advance()
|
// allocation instead of one `new PartTransform[partCount]` per Advance()
|
||||||
// call (every animated entity, every tick, including idle NPCs on a
|
// call (every animated entity, every tick, including idle NPCs on a
|
||||||
// breathe cycle). BuildBlendedFrame/BuildIdentityFrame overwrite every
|
// breathe cycle). BuildBlendedFrame/BuildIdentityFrame overwrite every
|
||||||
// slot before returning this array, so the values are bit-identical to
|
// authored slot before returning this view. The view is borrowed only for
|
||||||
// the old fresh-array version; callers (GameWindow.TickAnimations)
|
// the producing call: App schedulers copy its authored prefix into their
|
||||||
// consume the returned IReadOnlyList<PartTransform> synchronously within
|
// incarnation-owned handoff buffer before any callback or later phase.
|
||||||
// the same loop iteration and never cache it across Advance() calls.
|
|
||||||
private readonly PartTransform[] _partTransformScratch;
|
private readonly PartTransform[] _partTransformScratch;
|
||||||
private readonly PartTransformBuffer _partTransformView;
|
private readonly PartTransformBuffer _partTransformView;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,36 @@ public sealed class LiveEntityAnimationPresenterTests
|
||||||
Assert.Single(available);
|
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]
|
[Fact]
|
||||||
public void MotionDone_OldComponentCannotResolveReplacementByGuid()
|
public void MotionDone_OldComponentCannotResolveReplacementByGuid()
|
||||||
{
|
{
|
||||||
|
|
@ -173,6 +203,22 @@ public sealed class LiveEntityAnimationPresenterTests
|
||||||
Assert.Equal(0, context.ResolveCount);
|
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]
|
[Fact]
|
||||||
public void EffectPoseCallback_NestedRuntimeSnapshotDoesNotTruncatePresentation()
|
public void EffectPoseCallback_NestedRuntimeSnapshotDoesNotTruncatePresentation()
|
||||||
{
|
{
|
||||||
|
|
@ -200,6 +246,120 @@ public sealed class LiveEntityAnimationPresenterTests
|
||||||
Assert.Single(second.Entity.MeshRefs);
|
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(
|
private static LiveEntityAnimationPresenter Presenter(
|
||||||
LiveEntityRuntime live,
|
LiveEntityRuntime live,
|
||||||
EntityEffectPoseRegistry poses,
|
EntityEffectPoseRegistry poses,
|
||||||
|
|
@ -229,7 +389,8 @@ public sealed class LiveEntityAnimationPresenterTests
|
||||||
fixture.Entity,
|
fixture.Entity,
|
||||||
fixture.State,
|
fixture.State,
|
||||||
fixture.Record.ObjectClockEpoch,
|
fixture.Record.ObjectClockEpoch,
|
||||||
fixture.Record.ProjectionMutationVersion);
|
fixture.Record.ProjectionMutationVersion,
|
||||||
|
fixture.State.PresentationRevision);
|
||||||
|
|
||||||
private static Fixture Build(
|
private static Fixture Build(
|
||||||
int partCount,
|
int partCount,
|
||||||
|
|
@ -359,9 +520,11 @@ public sealed class LiveEntityAnimationPresenterTests
|
||||||
{
|
{
|
||||||
public uint LocalPlayerGuid => 0x50000001u;
|
public uint LocalPlayerGuid => 0x50000001u;
|
||||||
public int ResolveCount { get; private set; }
|
public int ResolveCount { get; private set; }
|
||||||
|
public LiveEntityRecord? LastRecord { get; private set; }
|
||||||
public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record)
|
public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record)
|
||||||
{
|
{
|
||||||
ResolveCount++;
|
ResolveCount++;
|
||||||
|
LastRecord = record;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,31 @@ public sealed class LiveEntityAnimationSchedulerTests
|
||||||
Assert.True(animation.Entity.Position.X > startX + 1f);
|
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]
|
[Fact]
|
||||||
public void BodylessNonWalkableAnimation_SuppressesRootOriginButPreservesOrientation()
|
public void BodylessNonWalkableAnimation_SuppressesRootOriginButPreservesOrientation()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.App.Rendering.Vfx;
|
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.Physics;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
|
|
@ -451,7 +454,10 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
|
||||||
Omega = new Vector3(0f, 0f, MathF.PI / 2f),
|
Omega = new Vector3(0f, 0f, MathF.PI / 2f),
|
||||||
};
|
};
|
||||||
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
|
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) =>
|
sequencer.MotionDoneTarget = (_, success) =>
|
||||||
{
|
{
|
||||||
Assert.True(success);
|
Assert.True(success);
|
||||||
|
|
@ -475,6 +481,154 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
|
||||||
Assert.Empty(sequencer.Manager.PendingAnimations);
|
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()
|
private static Setup MakeSetup()
|
||||||
{
|
{
|
||||||
var setup = new Setup();
|
var setup = new Setup();
|
||||||
|
|
@ -503,6 +657,61 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
|
||||||
return entity;
|
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()
|
private static Animation TwoFrameAnimation()
|
||||||
{
|
{
|
||||||
var animation = new Animation();
|
var animation = new Animation();
|
||||||
|
|
@ -565,10 +774,32 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
|
||||||
Orientation = entity.Rotation,
|
Orientation = entity.Rotation,
|
||||||
};
|
};
|
||||||
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
|
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;
|
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 sealed class Loader : IAnimationLoader
|
||||||
{
|
{
|
||||||
private readonly Dictionary<uint, Animation> _animations = new();
|
private readonly Dictionary<uint, Animation> _animations = new();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue