From 833520253a90e8e6dd0da80ac2b014a203b8b4ea Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 10:01:25 +0200 Subject: [PATCH] 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. --- src/AcDream.App/Rendering/GameWindow.cs | 23 +- .../Rendering/ILiveStaticPartFrameSource.cs | 1 + .../Rendering/LiveEntityAnimationPresenter.cs | 178 +++++++------ .../Rendering/LiveEntityAnimationScheduler.cs | 20 +- .../Rendering/LiveEntityAnimationState.cs | 26 ++ .../RetailStaticAnimatingObjectScheduler.cs | 117 ++++++--- .../Physics/AnimationSequencer.cs | 7 +- .../LiveEntityAnimationPresenterTests.cs | 165 +++++++++++- .../LiveEntityAnimationSchedulerTests.cs | 25 ++ ...tailStaticAnimatingObjectSchedulerTests.cs | 235 +++++++++++++++++- 10 files changed, 657 insertions(+), 140 deletions(-) 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();