fix(animation): harden presentation phase handoffs

Own every borrowed PartArray frame across callbacks, version presentation state across appearance rebinding, reject recursive phase consumption, and preserve static MotionDone when only a stale visual pose is discarded.
This commit is contained in:
Erik 2026-07-21 10:01:25 +02:00
parent a2fd61684a
commit 833520253a
10 changed files with 657 additions and 140 deletions

View file

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