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

@ -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();