diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 4efb9c41..835d3dc8 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -671,23 +671,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
public System.Numerics.Vector3 PrevServerPos;
public double PrevServerPosTime;
public double LastOmegaDiagLogTime;
- ///
- /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): own
- /// throttle clock for the SEQSTATE log line in TickAnimations.
- /// Previously SEQSTATE shared 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).
- ///
- public double LastSeqStateLogTime;
- ///
- /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): own
- /// throttle clock for the PARTSDIAG log line in TickAnimations
- /// (D5). One log per remote per ~1s.
- ///
- public double LastPartsDiagLogTime;
+
///
/// 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;
diff --git a/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs b/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs
index 58f20787..aa511fb1 100644
--- a/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs
+++ b/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs
@@ -17,5 +17,6 @@ internal interface ILiveStaticPartFrameSource
LiveEntityAnimationState animation,
ulong objectClockEpoch,
ulong projectionMutationVersion,
+ ulong presentationRevision,
out IReadOnlyList frames);
}
diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs
index 5725c65c..3bc51b3a 100644
--- a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs
+++ b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs
@@ -19,6 +19,7 @@ internal sealed class LiveEntityAnimationPresenter
private readonly AnimationPresentationDiagnostics _diagnostics;
private readonly int _hidePartIndex;
private readonly List _snapshot = new();
+ private bool _isPresenting;
public LiveEntityAnimationPresenter(
Func 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? 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 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? 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 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? 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;
}
diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
index 7ff42e05..5e156244 100644
--- a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
+++ b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
@@ -139,13 +139,18 @@ internal sealed class LiveEntityAnimationScheduler
projectile,
objectClockEpoch))
{
+ IReadOnlyList? 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);
diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationState.cs b/src/AcDream.App/Rendering/LiveEntityAnimationState.cs
index 1d145e85..ebf23377 100644
--- a/src/AcDream.App/Rendering/LiveEntityAnimationState.cs
+++ b/src/AcDream.App/Rendering/LiveEntityAnimationState.cs
@@ -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 SequenceFramesScratch = new();
+ public readonly List ScheduleFramesScratch = new();
/// Reusable compact drawable channel, consumed before draw.
public readonly List MeshRefsScratch = new();
@@ -45,16 +47,40 @@ internal sealed class LiveEntityAnimationState : ILiveEntityAnimationRuntime
///
public readonly List 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 CaptureSequenceFrames(
+ IReadOnlyList source)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+ SequenceFramesScratch.Clear();
+ for (int i = 0; i < source.Count; i++)
+ SequenceFramesScratch.Add(source[i]);
+ return SequenceFramesScratch;
+ }
+
+ public IReadOnlyList CaptureScheduleFrames(
+ IReadOnlyList 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(
diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs
index 7f7cb659..3c4ca4ea 100644
--- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs
+++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs
@@ -34,7 +34,11 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
public PhysicsBody? Body;
public AnimationSequencer? PendingProcessHooks;
public ulong PendingResidencyVersion;
- public IReadOnlyList? PreparedLivePartFrames;
+ public readonly List 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 MeshRefs = new();
@@ -137,11 +141,11 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
///
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 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();
+ 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();
+ 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();
+ return false;
+ }
- frames = Array.Empty();
- return false;
+ if (!ReferenceEquals(owner.PreparedFrameSequencer, animation.Sequencer)
+ || !IsResidentAtVersion(owner, owner.PendingResidencyVersion))
+ {
+ InvalidatePending(owner);
+ frames = Array.Empty();
+ 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();
+ return false;
+ }
+
+ owner.HasPreparedLivePartFrames = false;
+ frames = owner.PreparedLivePartFrames;
+ return true;
}
///
@@ -207,18 +244,25 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
uint ownerId,
out IReadOnlyList 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();
+ return false;
}
- if (_owners.TryGetValue(ownerId, out owner))
+ if (!owner.HasPreparedLivePartFrames)
+ {
+ frames = Array.Empty();
+ return false;
+ }
+ if (!IsResidentAtVersion(owner, owner.PendingResidencyVersion))
+ {
InvalidatePending(owner);
- frames = Array.Empty();
- return false;
+ frames = Array.Empty();
+ 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 frames = sequencer.Advance(
- (float)ownerElapsed);
+ IReadOnlyList 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 frames)
{
EnsureRetainedPoses(owner);
diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs
index f1bcb1b5..676c20f8 100644
--- a/src/AcDream.Core/Physics/AnimationSequencer.cs
+++ b/src/AcDream.Core/Physics/AnimationSequencer.cs
@@ -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 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;
diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs
index 2420f877..7c1dd26d 100644
--- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs
@@ -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
+ {
+ [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
+ {
+ [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
+ {
+ [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
+ {
+ [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;
}
}
diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs
index 1199c2ac..ca14c642 100644
--- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs
@@ -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 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()
{
diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs
index 2a6b903e..7d3954ea 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs
@@ -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 _animations = new();