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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,31 @@ public sealed class LiveEntityAnimationSchedulerTests
|
|||
Assert.True(animation.Entity.Position.X > startX + 1f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleOwnsPartFramesAcrossLaterSequencerSampling()
|
||||
{
|
||||
var (live, _, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid);
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid);
|
||||
LiveEntityAnimationSchedule schedule = scheduler.Tick(
|
||||
0.1f,
|
||||
animation.Entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0)[animation.Entity.Id];
|
||||
PartTransform retained = schedule.SequenceFrames![0];
|
||||
animation.CaptureSequenceFrames(
|
||||
[new PartTransform(new Vector3(77f, 76f, 75f), Quaternion.Identity)]);
|
||||
foreach (AnimationFrame frame in animation.Animation.PartFrames)
|
||||
frame.Frames[0].Origin = new Vector3(99f, 98f, 97f);
|
||||
|
||||
IReadOnlyList<PartTransform> borrowed = animation.Sequencer!.SampleCurrentPose();
|
||||
|
||||
Assert.Equal(new Vector3(99f, 98f, 97f), borrowed[0].Origin);
|
||||
Assert.Equal(retained.Origin, schedule.SequenceFrames[0].Origin);
|
||||
Assert.NotEqual(animation.SequenceFramesScratch[0].Origin, schedule.SequenceFrames[0].Origin);
|
||||
Assert.NotEqual(borrowed[0].Origin, schedule.SequenceFrames[0].Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BodylessNonWalkableAnimation_SuppressesRootOriginButPreservesOrientation()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
|
@ -451,7 +454,10 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
|
|||
Omega = new Vector3(0f, 0f, MathF.PI / 2f),
|
||||
};
|
||||
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
|
||||
Assert.True(scheduler.BindLiveOwner(entity, sequencer, body));
|
||||
Assert.True(scheduler.BindLiveOwner(
|
||||
entity,
|
||||
LiveState(entity, setup, sequencer),
|
||||
body));
|
||||
sequencer.MotionDoneTarget = (_, success) =>
|
||||
{
|
||||
Assert.True(success);
|
||||
|
|
@ -475,6 +481,154 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
|
|||
Assert.Empty(sequencer.Manager.PendingAnimations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebindingLiveOwner_DiscardsFramesPreparedByPriorSequencer()
|
||||
{
|
||||
var loader = new Loader();
|
||||
loader.Add(AnimationId, TwoFrameAnimation());
|
||||
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||
loader,
|
||||
(_, _) => { },
|
||||
(_, _, _) => { });
|
||||
Setup setup = MakeSetup();
|
||||
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
|
||||
scheduler.Register(entity, new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: entity.IndexedPartTransforms,
|
||||
PartAvailability: entity.IndexedPartAvailable,
|
||||
Setup: setup,
|
||||
DefaultAnimationId: AnimationId,
|
||||
UsesStaticAnimationWorkset: true));
|
||||
AnimationSequencer first = BindLiveOwner(
|
||||
scheduler, entity, setup, loader, out PhysicsBody body);
|
||||
scheduler.Tick(0.02f);
|
||||
AnimationSequencer second = new(setup, new MotionTable(), loader);
|
||||
|
||||
Assert.NotSame(first, second);
|
||||
Assert.True(scheduler.BindLiveOwner(
|
||||
entity,
|
||||
LiveState(entity, setup, second),
|
||||
body));
|
||||
Assert.False(scheduler.TryTakePreparedFramesForTest(OwnerId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedTakeAfterConsumption_PreservesPendingProcessHooks()
|
||||
{
|
||||
var loader = new Loader();
|
||||
loader.Add(AnimationId, TwoFrameAnimation());
|
||||
int captures = 0;
|
||||
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||
loader,
|
||||
(_, _) => captures++,
|
||||
(_, _, _) => { });
|
||||
Setup setup = MakeSetup();
|
||||
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
|
||||
scheduler.Register(entity, new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: entity.IndexedPartTransforms,
|
||||
PartAvailability: entity.IndexedPartAvailable,
|
||||
Setup: setup,
|
||||
DefaultAnimationId: AnimationId,
|
||||
UsesStaticAnimationWorkset: true));
|
||||
BindLiveOwner(scheduler, entity, setup, loader, out _);
|
||||
scheduler.Tick(0.02f);
|
||||
|
||||
Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out _));
|
||||
Assert.False(scheduler.TryTakePreparedFramesForTest(OwnerId, out _));
|
||||
scheduler.ProcessHooks();
|
||||
|
||||
Assert.Equal(1, captures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppearanceRevisionRejectsPreparedPose_ButPreservesMotionDone()
|
||||
{
|
||||
const uint style = 0x8000003Eu;
|
||||
const uint readyMotion = 0x41000003u;
|
||||
const uint actionMotion = 0x10000058u;
|
||||
const uint actionAnimationId = 0x0300AA03u;
|
||||
const uint serverGuid = 0x70000001u;
|
||||
var loader = new Loader();
|
||||
loader.Add(AnimationId, TwoFrameAnimation());
|
||||
loader.Add(actionAnimationId, OffsetAnimation(20f));
|
||||
Setup setup = MakeSetup();
|
||||
var table = new MotionTable
|
||||
{
|
||||
DefaultStyle = (DRWMotionCommand)style,
|
||||
};
|
||||
table.StyleDefaults[(DRWMotionCommand)style] =
|
||||
(DRWMotionCommand)readyMotion;
|
||||
int readyKey = unchecked((int)((style << 16) | (readyMotion & 0xFFFFFFu)));
|
||||
table.Cycles[readyKey] = MakeMotionData(AnimationId);
|
||||
var links = new MotionCommandData();
|
||||
links.MotionData[unchecked((int)actionMotion)] =
|
||||
MakeMotionData(actionAnimationId);
|
||||
table.Links[readyKey] = links;
|
||||
|
||||
WorldEntity entity = MakeEntity(serverGuid);
|
||||
var sequencer = new AnimationSequencer(setup, table, loader);
|
||||
sequencer.SetCycle(style, readyMotion);
|
||||
sequencer.ConsumePendingHooks();
|
||||
sequencer.PlayAction(actionMotion);
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
poses.Publish(entity, entity.IndexedPartTransforms, entity.IndexedPartAvailable);
|
||||
var hookFrames = new AnimationHookFrameQueue(
|
||||
new AnimationHookRouter(),
|
||||
poses);
|
||||
int captures = 0;
|
||||
var scheduler = new RetailStaticAnimatingObjectScheduler(
|
||||
loader,
|
||||
(ownerId, ownerSequencer) =>
|
||||
{
|
||||
captures++;
|
||||
hookFrames.Capture(ownerId, ownerSequencer);
|
||||
},
|
||||
(_, _, _) => { });
|
||||
scheduler.Register(entity, new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: entity.IndexedPartTransforms,
|
||||
PartAvailability: entity.IndexedPartAvailable,
|
||||
Setup: setup,
|
||||
DefaultAnimationId: AnimationId,
|
||||
UsesStaticAnimationWorkset: true));
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Orientation = entity.Rotation,
|
||||
};
|
||||
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
|
||||
LiveEntityAnimationState state = LiveState(entity, setup, sequencer);
|
||||
Assert.True(scheduler.BindLiveOwner(entity, state, body));
|
||||
var record = new LiveEntityRecord(Spawn(serverGuid))
|
||||
{
|
||||
WorldEntity = entity,
|
||||
AnimationRuntime = state,
|
||||
};
|
||||
int motionDone = 0;
|
||||
sequencer.MotionDoneTarget = (_, success) =>
|
||||
{
|
||||
Assert.True(success);
|
||||
motionDone++;
|
||||
};
|
||||
|
||||
scheduler.Tick(0.2f);
|
||||
state.InvalidatePresentationPoses();
|
||||
|
||||
Assert.False(scheduler.TryTakeLivePartFrames(
|
||||
record,
|
||||
entity,
|
||||
state,
|
||||
record.ObjectClockEpoch,
|
||||
record.ProjectionMutationVersion,
|
||||
state.PresentationRevision,
|
||||
out _));
|
||||
scheduler.ProcessHooks();
|
||||
|
||||
Assert.Equal(1, captures);
|
||||
Assert.True(motionDone > 0);
|
||||
Assert.Empty(sequencer.Manager.PendingAnimations);
|
||||
}
|
||||
|
||||
private static Setup MakeSetup()
|
||||
{
|
||||
var setup = new Setup();
|
||||
|
|
@ -503,6 +657,61 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
|
|||
return entity;
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid)
|
||||
{
|
||||
const uint cell = 0x01010001u;
|
||||
var position = new CreateObject.ServerPosition(
|
||||
cell,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f);
|
||||
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: 0u,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: null,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
Timestamps: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
0x02000001u,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
"static appearance fixture",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
PhysicsState: 0u,
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
private static Animation TwoFrameAnimation()
|
||||
{
|
||||
var animation = new Animation();
|
||||
|
|
@ -565,10 +774,32 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
|
|||
Orientation = entity.Rotation,
|
||||
};
|
||||
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
|
||||
Assert.True(scheduler.BindLiveOwner(entity, sequencer, body));
|
||||
Assert.True(scheduler.BindLiveOwner(
|
||||
entity,
|
||||
LiveState(entity, setup, sequencer),
|
||||
body));
|
||||
return sequencer;
|
||||
}
|
||||
|
||||
private static LiveEntityAnimationState LiveState(
|
||||
WorldEntity entity,
|
||||
Setup setup,
|
||||
AnimationSequencer sequencer) => new()
|
||||
{
|
||||
Entity = entity,
|
||||
Setup = setup,
|
||||
Animation = new Animation(),
|
||||
LowFrame = 0,
|
||||
HighFrame = 0,
|
||||
Framerate = 0f,
|
||||
Scale = entity.Scale,
|
||||
PartTemplate = setup.Parts.Select(
|
||||
part => new LiveAnimationPartTemplate((uint)part, null, true))
|
||||
.ToArray(),
|
||||
PartAvailability = Enumerable.Repeat(true, setup.Parts.Count).ToArray(),
|
||||
Sequencer = sequencer,
|
||||
};
|
||||
|
||||
private sealed class Loader : IAnimationLoader
|
||||
{
|
||||
private readonly Dictionary<uint, Animation> _animations = new();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue