feat(physics): port retail complete object frame pipeline

Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates.

Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-20 09:10:31 +02:00
parent 31a0889f08
commit f961d70023
77 changed files with 12513 additions and 1871 deletions

View file

@ -0,0 +1,835 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Rendering;
public sealed class LiveEntityAnimationSchedulerTests
{
private const uint LocalGuid = 0x50000001u;
private const uint RemoteGuid = 0x70000001u;
private const uint Cell = 0x01010001u;
[Fact]
public void HiddenLocalPose_IsSampledOnlyForMarkedObjectQuantum()
{
var (live, record, animation) = BuildHiddenAnimated(LocalGuid);
LiveEntityAnimationScheduler scheduler = BuildScheduler(
live,
localPlayerGuid: LocalGuid);
Assert.True(scheduler.Tick(
PhysicsBody.MaxQuantum,
animation.Entity.Position,
localHiddenPartPoseDirty: true,
liveCenterX: 0,
liveCenterY: 0)
.TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule marked));
bool repeated = scheduler.Tick(
PhysicsBody.MaxQuantum,
animation.Entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0)
.TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule consumed);
Assert.True(marked.ComposeParts);
Assert.NotNull(marked.SequenceFrames);
Assert.False(repeated);
Assert.False(consumed.ComposeParts);
}
[Fact]
public void HiddenRemotePose_IsSampledOnceAfterAdmittedHiddenQuantum()
{
var (live, record, animation) = BuildHiddenAnimated(RemoteGuid);
var remote = new GameWindow.RemoteMotion
{
CellId = Cell,
};
remote.Body.Position = animation.Entity.Position;
remote.Body.Orientation = animation.Entity.Rotation;
remote.Body.InWorld = true;
live.SetRemoteMotionRuntime(RemoteGuid, remote);
LiveEntityAnimationScheduler scheduler = BuildScheduler(
live,
localPlayerGuid: LocalGuid);
Assert.True(scheduler.Tick(
PhysicsBody.MaxQuantum,
playerPosition: null,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0)
.TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule marked));
bool repeated = scheduler.Tick(
elapsedSeconds: 0f,
playerPosition: null,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0)
.TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule consumed);
Assert.True(marked.ComposeParts);
Assert.NotNull(marked.SequenceFrames);
Assert.False(repeated);
Assert.False(consumed.ComposeParts);
}
[Fact]
public void VisibleAnimationWithoutRemoteMotion_AppliesCompleteRootFrame()
{
var (live, _, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid);
var retainedBodyOwner = BuildRemote(animation.Entity);
retainedBodyOwner.Body.SnapToCell(
Cell,
animation.Entity.Position,
animation.Entity.Position);
live.SetRemoteMotionRuntime(RemoteGuid, retainedBodyOwner);
Assert.True(live.ClearRemoteMotionRuntime(RemoteGuid));
LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid);
float startX = animation.Entity.Position.X;
Assert.True(scheduler.Tick(
0.1f,
animation.Entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0)
.ContainsKey(animation.Entity.Id));
Assert.True(animation.Entity.Position.X > startX + 1f);
}
[Fact]
public void BodylessNonWalkableAnimation_SuppressesRootOriginButPreservesOrientation()
{
Quaternion rootTurn = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
MathF.PI / 2f);
var (live, _, animation) = BuildVisibleAnimatedWithPosFrames(
RemoteGuid,
rootTurn);
Vector3 startPosition = animation.Entity.Position;
LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid);
scheduler.Tick(
PhysicsBody.MinQuantum * 1.01f,
animation.Entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0);
Assert.Equal(startPosition, animation.Entity.Position);
Assert.InRange(
MathF.Abs(Quaternion.Dot(
rootTurn,
Quaternion.Normalize(animation.Entity.Rotation))),
0.99999f,
1.00001f);
}
[Fact]
public void RemoteWithoutAnimation_RemainsInOrdinaryObjectWorkset()
{
var (live, record, entity) = BuildMaterialized(
RemoteGuid,
PhysicsStateFlags.Gravity);
var remote = new GameWindow.RemoteMotion
{
CellId = Cell,
Airborne = true,
};
remote.Body.Position = entity.Position;
remote.Body.Orientation = entity.Rotation;
remote.Body.Velocity = new Vector3(3f, 0f, 0f);
remote.Body.State = PhysicsStateFlags.Gravity;
remote.Body.TransientState = TransientStateFlags.Active;
live.SetRemoteMotionRuntime(RemoteGuid, remote);
LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid);
float startX = entity.Position.X;
scheduler.Tick(
0.1f,
entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0);
Assert.True(entity.Position.X > startX);
Assert.Same(record, live.SpatialRootObjects[RemoteGuid]);
}
[Fact]
public void AnimationlessRemote_NearPositionCorrectionIsQueuedAndConsumed()
{
var (live, record, entity) = BuildMaterialized(
RemoteGuid,
PhysicsStateFlags.ReportCollisions);
var remote = BuildRemote(entity);
remote.Interp.Enqueue(
entity.Position + Vector3.UnitX,
heading: 0f,
isMovingTo: false,
currentBodyPosition: remote.Body.Position);
live.SetRemoteMotionRuntime(RemoteGuid, remote);
Assert.Null(record.AnimationRuntime);
Assert.True(live.IsCurrentSpatialRemoteMotion(record, remote));
LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid);
float startX = entity.Position.X;
scheduler.Tick(
0.1f,
entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 1,
liveCenterY: 1);
Assert.True(entity.Position.X > startX + 0.5f);
Assert.True(remote.Interp.IsActive || entity.Position.X >= startX + 0.99f);
Assert.Same(record, live.SpatialRootObjects[RemoteGuid]);
}
[Fact]
public void RetainedProjectileWithoutRemote_WhenMissileClears_AdvancesOnceAsOrdinaryBody()
{
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(
RemoteGuid,
rootOrigin: Vector3.Zero);
var physics = new PhysicsEngine();
var projectiles = new ProjectileController(live, physics);
record.FinalPhysicsState = ProjectileState;
Assert.True(record.ObjectClock.IsActive);
Assert.True(projectiles.TryBind(
record,
ProjectileSetup(),
currentTime: 1.0,
liveCenterX: 1,
liveCenterY: 1));
Assert.True(record.ObjectClock.IsActive);
PhysicsBody body = record.PhysicsBody!;
body.set_velocity(new Vector3(3f, 0f, 0f));
body.TransientState = TransientStateFlags.Active;
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Gravity;
Assert.True(projectiles.ApplyAuthoritativeState(
record,
record.FinalPhysicsState,
currentTime: 1.1,
liveCenterX: 1,
liveCenterY: 1));
Assert.True(projectiles.HandlesMovement(RemoteGuid));
float startX = animation.Entity.Position.X;
int rootPublishes = 0;
LiveEntityAnimationScheduler scheduler = BuildScheduler(
live,
LocalGuid,
publishRootPose: _ => rootPublishes++,
physics: physics,
projectiles: projectiles);
scheduler.Tick(
0.1f,
animation.Entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 1,
liveCenterY: 1);
float distance = animation.Entity.Position.X - startX;
Assert.InRange(distance, 0.2f, 0.4f);
Assert.Equal(1, rootPublishes);
Assert.Same(body, record.PhysicsBody);
Assert.Same(body, record.ProjectileRuntime!.Body);
}
[Fact]
public void RetainedProjectileWithRemote_WhenMissileClears_TransfersMovementToRemoteOnce()
{
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(
RemoteGuid,
rootOrigin: Vector3.Zero);
var physics = new PhysicsEngine();
var projectiles = new ProjectileController(live, physics);
record.FinalPhysicsState = ProjectileState;
var remote = new GameWindow.RemoteMotion
{
CellId = Cell,
Airborne = false,
};
remote.Body.Position = animation.Entity.Position;
remote.Body.Orientation = animation.Entity.Rotation;
remote.Body.State = ProjectileState;
remote.Body.InWorld = true;
remote.Body.TransientState = TransientStateFlags.Active
| TransientStateFlags.Contact
| TransientStateFlags.OnWalkable;
live.SetRemoteMotionRuntime(RemoteGuid, remote);
Assert.True(record.ObjectClock.IsActive);
Assert.Equal(Cell, record.FullCellId);
Assert.True(record.IsSpatiallyVisible);
Assert.True(projectiles.TryBind(
record,
ProjectileSetup(),
currentTime: 1.0,
liveCenterX: 1,
liveCenterY: 1));
Assert.Equal(Cell, record.FullCellId);
Assert.True(record.IsSpatiallyVisible);
Assert.True(record.ObjectClock.IsActive);
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Gravity;
Assert.True(projectiles.ApplyAuthoritativeState(
record,
record.FinalPhysicsState,
currentTime: 1.1,
liveCenterX: 1,
liveCenterY: 1));
Assert.False(projectiles.HandlesMovement(RemoteGuid));
remote.Interp.Enqueue(
animation.Entity.Position + Vector3.UnitX,
heading: 0f,
isMovingTo: false,
currentBodyPosition: remote.Body.Position);
Assert.True(record.ObjectClock.IsActive);
Assert.True(remote.Body.IsActive);
Assert.True(live.IsCurrentSpatialRemoteMotion(record, remote));
Assert.True(live.IsCurrentSpatialAnimation(record, animation));
float startX = animation.Entity.Position.X;
int rootPublishes = 0;
LiveEntityAnimationScheduler scheduler = BuildScheduler(
live,
LocalGuid,
publishRootPose: _ => rootPublishes++,
physics: physics,
projectiles: projectiles);
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules = scheduler.Tick(
0.1f,
animation.Entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 1,
liveCenterY: 1);
Assert.Contains(animation.Entity.Id, schedules.Keys);
Assert.Equal(1, rootPublishes);
float distance = animation.Entity.Position.X - startX;
Assert.InRange(distance, 0.79f, 0.81f);
Assert.Same(remote.Body, record.PhysicsBody);
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
}
[Fact]
public void DeleteFromFirstQuantumCallback_StopsCatchUpBatchAndPosePublish()
{
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid);
int captures = 0;
LiveEntityAnimationScheduler scheduler = BuildScheduler(
live,
LocalGuid,
(_, _) =>
{
captures++;
live.UnregisterLiveEntity(
new DeleteObject.Parsed(RemoteGuid, record.Generation),
isLocalPlayer: false);
});
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules = scheduler.Tick(
PhysicsBody.MaxQuantum * 2.5f,
animation.Entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0);
Assert.Equal(1, captures);
Assert.Empty(schedules);
Assert.False(live.TryGetRecord(RemoteGuid, out _));
}
[Fact]
public void VisibleRemoteDeleteInsideHook_StopsBeforeStaleEntityPublication()
{
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid);
var remote = BuildRemote(animation.Entity);
live.SetRemoteMotionRuntime(RemoteGuid, remote);
Vector3 retainedEntityPosition = animation.Entity.Position;
int captures = 0;
int rootPublishes = 0;
LiveEntityAnimationScheduler scheduler = BuildScheduler(
live,
LocalGuid,
(_, _) =>
{
captures++;
live.UnregisterLiveEntity(
new DeleteObject.Parsed(RemoteGuid, record.Generation),
isLocalPlayer: false);
},
_ => rootPublishes++);
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules = scheduler.Tick(
PhysicsBody.MaxQuantum * 2.5f,
animation.Entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0);
Assert.Equal(1, captures);
Assert.Equal(0, rootPublishes);
Assert.Empty(schedules);
Assert.Equal(retainedEntityPosition, animation.Entity.Position);
}
[Fact]
public void HiddenRemoteDeleteInsideHook_StopsBeforeStaleEntityPublication()
{
var (live, record, animation) = BuildHiddenAnimated(RemoteGuid);
var remote = BuildRemote(animation.Entity);
live.SetRemoteMotionRuntime(RemoteGuid, remote);
Vector3 retainedEntityPosition = animation.Entity.Position;
int captures = 0;
int rootPublishes = 0;
LiveEntityAnimationScheduler scheduler = BuildScheduler(
live,
LocalGuid,
(_, _) =>
{
captures++;
live.UnregisterLiveEntity(
new DeleteObject.Parsed(RemoteGuid, record.Generation),
isLocalPlayer: false);
},
_ => rootPublishes++);
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules = scheduler.Tick(
PhysicsBody.MaxQuantum * 2.5f,
animation.Entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0);
Assert.Equal(1, captures);
Assert.Equal(0, rootPublishes);
Assert.Empty(schedules);
Assert.Equal(retainedEntityPosition, animation.Entity.Position);
}
[Fact]
public void HiddenAnimationWithoutRemoteDeleteInsideHook_StopsBeforeManagerTailOrPublication()
{
var (live, record, animation) = BuildHiddenAnimated(RemoteGuid);
int captures = 0;
int rootPublishes = 0;
LiveEntityAnimationScheduler scheduler = BuildScheduler(
live,
LocalGuid,
(_, _) =>
{
captures++;
live.UnregisterLiveEntity(
new DeleteObject.Parsed(RemoteGuid, record.Generation),
isLocalPlayer: false);
},
_ => rootPublishes++);
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules = scheduler.Tick(
PhysicsBody.MaxQuantum * 2.5f,
animation.Entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0);
Assert.Equal(1, captures);
Assert.Equal(0, rootPublishes);
Assert.Empty(schedules);
Assert.False(live.TryGetRecord(RemoteGuid, out _));
}
[Fact]
public void WithdrawAndReenterInsideHook_InvalidatesOldCatchUpBatch()
{
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid);
ulong startingEpoch = record.ObjectClockEpoch;
int captures = 0;
int rootPublishes = 0;
LiveEntityAnimationScheduler scheduler = BuildScheduler(
live,
LocalGuid,
(_, _) =>
{
captures++;
Assert.True(live.WithdrawLiveEntityProjection(RemoteGuid));
Assert.Same(
animation.Entity,
live.MaterializeLiveEntity(
RemoteGuid,
Cell,
_ => throw new InvalidOperationException(
"A retained projection must not be reconstructed.")));
},
_ => rootPublishes++);
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules = scheduler.Tick(
PhysicsBody.MaxQuantum * 2.5f,
animation.Entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0);
Assert.Equal(1, captures);
Assert.Equal(0, rootPublishes);
Assert.Empty(schedules);
Assert.Equal(startingEpoch + 2, record.ObjectClockEpoch);
Assert.True(record.ObjectClock.IsActive);
Assert.Equal(0d, record.ObjectClock.PendingSeconds);
}
[Fact]
public void LocalRootPosePublishesWithoutAnimationAndBetweenObjectQuanta()
{
var (live, _, entity) = BuildMaterialized(LocalGuid, PhysicsStateFlags.None);
var published = new List<Vector3>();
LiveEntityAnimationScheduler scheduler = BuildScheduler(
live,
LocalGuid,
publishRootPose: current => published.Add(current.Position));
scheduler.Tick(
PhysicsBody.MinQuantum * 0.25f,
entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0);
entity.SetPosition(entity.Position + new Vector3(0.25f, 0f, 0f));
scheduler.Tick(
PhysicsBody.MinQuantum * 0.25f,
entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0);
Assert.Equal(2, published.Count);
Assert.Equal(entity.Position, published[1]);
}
[Fact]
public void AttachedProjection_IsNotAnIndependentOrdinaryRoot()
{
var (live, record, entity) = BuildMaterialized(
RemoteGuid,
PhysicsStateFlags.None);
int rootPublishes = 0;
LiveEntityAnimationScheduler scheduler = BuildScheduler(
live,
LocalGuid,
publishRootPose: _ => rootPublishes++);
Assert.Same(
entity,
live.MaterializeLiveEntity(
RemoteGuid,
Cell,
_ => throw new InvalidOperationException(),
LiveEntityProjectionKind.Attached));
Assert.False(record.ObjectClock.IsActive);
Assert.DoesNotContain(RemoteGuid, live.SpatialRootObjects.Keys);
scheduler.Tick(
PhysicsBody.MaxQuantum,
entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0);
Assert.Equal(0, rootPublishes);
Assert.Same(
entity,
live.MaterializeLiveEntity(
RemoteGuid,
Cell,
_ => throw new InvalidOperationException(),
LiveEntityProjectionKind.World));
Assert.True(record.ObjectClock.IsActive);
Assert.Contains(RemoteGuid, live.SpatialRootObjects.Keys);
scheduler.Tick(
PhysicsBody.MaxQuantum,
entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 0,
liveCenterY: 0);
Assert.Equal(1, rootPublishes);
}
private static GameWindow.RemoteMotion BuildRemote(WorldEntity entity)
{
var remote = new GameWindow.RemoteMotion
{
CellId = Cell,
};
remote.Body.Position = entity.Position;
remote.Body.Orientation = entity.Rotation;
remote.Body.InWorld = true;
remote.Body.TransientState = TransientStateFlags.Active
| TransientStateFlags.Contact
| TransientStateFlags.OnWalkable;
return remote;
}
private static LiveEntityAnimationScheduler BuildScheduler(
LiveEntityRuntime live,
uint localPlayerGuid,
Action<uint, AnimationSequencer>? captureHooks = null,
Action<WorldEntity>? publishRootPose = null,
PhysicsEngine? physics = null,
ProjectileController? projectiles = null)
{
physics ??= new PhysicsEngine();
var remotePhysics = new RemotePhysicsUpdater(
physics,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater(
physics,
(_, _) => (0.48f, 1.835f));
return new LiveEntityAnimationScheduler(
() => live,
() => localPlayerGuid,
remotePhysics,
ordinaryPhysics,
() => projectiles,
publishRootPose ?? (_ => { }),
captureHooks ?? ((_, _) => { }));
}
private static (LiveEntityRuntime Live, LiveEntityRecord Record,
GameWindow.AnimatedEntity Animation) BuildHiddenAnimated(uint guid)
{
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
var live = new LiveEntityRuntime(
spatial,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid)).Record!;
WorldEntity entity = live.MaterializeLiveEntity(
guid,
Cell,
id => new WorldEntity
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(10f, 10f, 5f),
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = Cell,
})!;
var setup = new Setup();
var animation = new GameWindow.AnimatedEntity
{
Entity = entity,
Setup = setup,
Animation = new Animation(),
LowFrame = 0,
HighFrame = 0,
Framerate = 0f,
Scale = 1f,
PartTemplate = Array.Empty<GameWindow.AnimatedPartTemplate>(),
PartAvailability = Array.Empty<bool>(),
Sequencer = new AnimationSequencer(
setup,
new MotionTable(),
new NullAnimationLoader()),
};
record.HasPartArray = true;
live.SetAnimationRuntime(guid, animation);
return (live, record, animation);
}
private static (LiveEntityRuntime Live, LiveEntityRecord Record,
GameWindow.AnimatedEntity Animation) BuildVisibleAnimatedWithPosFrames(
uint guid,
Quaternion? rootOrientation = null,
Vector3? rootOrigin = null)
{
var (live, record, entity) = BuildMaterialized(guid, PhysicsStateFlags.None);
const uint animationId = 0x0300AA01u;
var setup = new Setup();
setup.Parts.Add(0x0100AA01u);
setup.DefaultScale.Add(Vector3.One);
var datAnimation = new Animation
{
Flags = AnimationFlags.PosFrames,
};
for (int i = 0; i < 4; i++)
{
var partFrame = new AnimationFrame(1);
partFrame.Frames.Add(new Frame
{
Origin = Vector3.Zero,
Orientation = Quaternion.Identity,
});
datAnimation.PartFrames.Add(partFrame);
datAnimation.PosFrames.Add(new Frame
{
Origin = rootOrigin ?? Vector3.UnitX,
Orientation = rootOrientation ?? Quaternion.Identity,
});
}
var loader = new DictionaryAnimationLoader(animationId, datAnimation);
var sequencer = new AnimationSequencer(setup, new MotionTable(), loader);
Assert.True(sequencer.InitializeSetupDefaultAnimation(animationId));
var animation = new GameWindow.AnimatedEntity
{
Entity = entity,
Setup = setup,
Animation = datAnimation,
LowFrame = 0,
HighFrame = 3,
Framerate = 30f,
Scale = 1f,
PartTemplate = new[]
{
new GameWindow.AnimatedPartTemplate(
0x0100AA01u,
SurfaceOverrides: null,
IsDrawable: true),
},
PartAvailability = new[] { true },
Sequencer = sequencer,
};
record.HasPartArray = true;
live.SetAnimationRuntime(guid, animation);
return (live, record, animation);
}
private static readonly PhysicsStateFlags ProjectileState =
PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Missile
| PhysicsStateFlags.AlignPath
| PhysicsStateFlags.PathClipped;
private static Setup ProjectileSetup() => new()
{
Spheres =
{
new Sphere
{
Origin = Vector3.Zero,
Radius = 0.1f,
},
},
};
private static (LiveEntityRuntime Live, LiveEntityRecord Record, WorldEntity Entity)
BuildMaterialized(uint guid, PhysicsStateFlags state)
{
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
var live = new LiveEntityRuntime(
spatial,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid, state)).Record!;
WorldEntity entity = live.MaterializeLiveEntity(
guid,
Cell,
id => new WorldEntity
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(10f, 10f, 5f),
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = Cell,
})!;
return (live, record, entity);
}
private static WorldSession.EntitySpawn Spawn(
uint guid,
PhysicsStateFlags state = PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions)
{
var position = new CreateObject.ServerPosition(
Cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
var physics = new PhysicsSpawnData(
RawState: (uint)state,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
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,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"hidden scheduler fixture",
null,
null,
0x09000001u,
PhysicsState: (uint)state,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private sealed class NullAnimationLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint id) => null;
}
private sealed class DictionaryAnimationLoader : IAnimationLoader
{
private readonly uint _id;
private readonly Animation _animation;
public DictionaryAnimationLoader(uint id, Animation animation)
{
_id = id;
_animation = animation;
}
public Animation? LoadAnimation(uint id) => id == _id ? _animation : null;
}
}

View file

@ -0,0 +1,542 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
namespace AcDream.App.Tests.Rendering;
public sealed class RetailStaticAnimatingObjectSchedulerTests
{
private const uint OwnerId = 0x7F000001u;
private const uint AnimationId = 0x0300AA01u;
private const uint GfxId = 0x0100AA01u;
[Fact]
public void DefaultAnimation_UsesSeparateWholeElapsedStaticWorkset()
{
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
int hookCaptures = 0;
int posePublishes = 0;
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => hookCaptures++,
(_, _, _) => posePublishes++);
Setup setup = MakeSetup();
WorldEntity entity = MakeEntity();
scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: setup,
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true));
var animatedIds = new HashSet<uint>();
scheduler.CopyAnimatedEntityIdsTo(animatedIds);
Assert.Contains(OwnerId, animatedIds);
scheduler.Tick(1f / 60f);
scheduler.ProcessHooks();
Assert.Equal(1, hookCaptures);
Assert.Equal(1, posePublishes);
Assert.Single(entity.MeshRefs);
Assert.True(entity.MeshRefs[0].PartTransform.Translation.X > 0f);
scheduler.Tick(2f);
scheduler.ProcessHooks();
Assert.Equal(2, hookCaptures);
Assert.Equal(2, posePublishes);
Matrix4x4 beforeDiscard = entity.MeshRefs[0].PartTransform;
scheduler.Tick(2.01f);
scheduler.ProcessHooks();
Assert.Equal(beforeDiscard, entity.MeshRefs[0].PartTransform);
Assert.Equal(2, hookCaptures);
Assert.Equal(2, posePublishes);
}
[Fact]
public void SubEpsilonElapsed_IsDiscardedInsteadOfAccumulated()
{
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
int hookCaptures = 0;
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => hookCaptures++,
(_, _, _) => { });
WorldEntity entity = MakeEntity();
scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: MakeSetup(),
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true));
scheduler.Tick(0.0001f);
scheduler.ProcessHooks();
scheduler.Tick(0.0001f);
scheduler.ProcessHooks();
Assert.Equal(0, hookCaptures);
scheduler.Tick(0.0003f);
scheduler.ProcessHooks();
Assert.Equal(1, hookCaptures);
}
[Fact]
public void ScriptOnlyOwner_DoesNotEnterAnimationWorkset()
{
var scheduler = new RetailStaticAnimatingObjectScheduler(
new Loader(),
(_, _) => { },
(_, _, _) => { });
Setup setup = MakeSetup();
WorldEntity entity = MakeEntity();
scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0x3300AA01u,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: setup));
Assert.Equal(0, scheduler.Count);
var animatedIds = new HashSet<uint>();
scheduler.CopyAnimatedEntityIdsTo(animatedIds);
Assert.Empty(animatedIds);
scheduler.Unregister(OwnerId);
}
[Fact]
public void LivePhysicsStaticOwner_CatchesUpShortNonResidentIntervalOnReentry()
{
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
bool resident = false;
int captures = 0;
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => captures++,
(_, _, _) => { },
_ => resident);
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);
scheduler.ProcessHooks();
Assert.Equal(0, captures);
resident = true;
scheduler.Tick(0.02f);
scheduler.ProcessHooks();
Assert.Equal(1, captures);
Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out var frames));
var reference = new AnimationSequencer(
setup,
new MotionTable(),
loader);
IReadOnlyList<PartTransform> referenceFrames = reference.Advance(0.04f);
Assert.Equal(referenceFrames[0], frames[0]);
}
[Fact]
public void LivePhysicsStaticOwner_DiscardsLongNonResidentIntervalOnReentry()
{
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
bool resident = false;
int captures = 0;
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => captures++,
(_, _, _) => { },
_ => resident);
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: MakeSetup(),
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true));
BindLiveOwner(
scheduler,
entity,
MakeSetup(),
loader,
out _);
scheduler.Tick(2.01f);
scheduler.ProcessHooks();
resident = true;
scheduler.Tick(0.1f);
scheduler.ProcessHooks();
Assert.Equal(0, captures);
scheduler.Tick(0.1f);
scheduler.ProcessHooks();
Assert.Equal(1, captures);
}
[Fact]
public void LivePhysicsStaticOwner_UsesCanonicalSequencerAndRawOmega()
{
const uint alternateAnimationId = 0x0300AA02u;
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
loader.Add(alternateAnimationId, OffsetAnimation(20f));
var shadows = new ShadowObjectRegistry();
Matrix4x4 committedRoot = Matrix4x4.Identity;
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => { },
(_, _, _) => { },
commitLiveRoot: (entity, body) =>
{
shadows.UpdatePosition(
entity.Id,
body.Position,
body.Orientation,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0x01010000u,
seedCellId: body.CellPosition.ObjCellId);
committedRoot = Matrix4x4.CreateFromQuaternion(entity.Rotation)
* Matrix4x4.CreateTranslation(entity.Position);
});
Setup setup = MakeSetup();
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
entity.Position = new Vector3(12f, 12f, 50f);
shadows.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
new[]
{
new ShadowShape(
GfxId,
Vector3.UnitX,
Quaternion.Identity,
Scale: 1f,
CollisionType: ShadowCollisionType.Cylinder,
Radius: 0.5f,
CylHeight: 1f),
},
state: 0u,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0x01010000u,
seedCellId: 0x01010001u);
scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: setup,
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true));
AnimationSequencer sequencer = BindLiveOwner(
scheduler,
entity,
setup,
loader,
out PhysicsBody body);
body.Omega = new Vector3(0f, 0f, MathF.PI / 2f);
// Simulates UpdateMotion replacing the sequence on the same PartArray.
Assert.True(sequencer.InitializeSetupDefaultAnimation(alternateAnimationId));
scheduler.Tick(0.01f);
Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out var frames));
Assert.True(frames[0].Origin.X >= 20f);
Vector3 rotatedX = Vector3.Transform(Vector3.UnitX, entity.Rotation);
Assert.InRange(rotatedX.X, -0.001f, 0.001f);
Assert.InRange(rotatedX.Y, 0.999f, 1.001f);
Assert.Equal(entity.Rotation, body.Orientation);
ShadowEntry rotatedShadow = Assert.Single(
shadows.AllEntriesForDebug(),
item => item.EntityId == entity.Id);
Assert.InRange(rotatedShadow.Position.X, 11.999f, 12.001f);
Assert.InRange(rotatedShadow.Position.Y, 12.999f, 13.001f);
Vector3 committedX = Vector3.TransformNormal(
Vector3.UnitX,
committedRoot);
Assert.InRange(committedX.X, -0.001f, 0.001f);
Assert.InRange(committedX.Y, 0.999f, 1.001f);
}
[Fact]
public void LivePhysicsStaticOwner_ZeroOmegaDoesNotCommitUnchangedRoot()
{
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
int rootCommits = 0;
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => { },
(_, _, _) => { },
commitLiveRoot: (_, _) => rootCommits++);
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.1f);
Assert.Equal(0, rootCommits);
Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out _));
}
[Fact]
public void LivePhysicsStaticOwner_WithdrawalDuringRootCommitDropsPreparedPoseAndHooks()
{
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
bool resident = true;
ulong residencyVersion = 1;
int captures = 0;
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => captures++,
(_, _, _) => { },
isResident: _ => resident,
commitLiveRoot: (_, _) =>
{
resident = false;
residencyVersion++;
},
residencyVersion: _ => residencyVersion);
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 PhysicsBody body);
body.Omega = new Vector3(0f, 0f, MathF.PI / 2f);
scheduler.Tick(0.1f);
scheduler.ProcessHooks();
Assert.False(scheduler.TryTakeLivePartFrames(OwnerId, out _));
Assert.Equal(0, captures);
}
[Fact]
public void LivePhysicsStaticOwner_NonCyclicAnimationDoneRunsAfterFinalPartAndChildPose()
{
const uint style = 0x8000003Eu;
const uint readyMotion = 0x41000003u;
const uint actionMotion = 0x10000058u;
const uint actionAnimationId = 0x0300AA03u;
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: 0x70000001u);
var sequencer = new AnimationSequencer(setup, table, loader);
sequencer.SetCycle(style, readyMotion);
sequencer.ConsumePendingHooks();
sequencer.PlayAction(actionMotion);
Assert.NotEmpty(sequencer.Manager.PendingAnimations);
var order = new List<string>();
var poses = new EntityEffectPoseRegistry();
poses.Publish(entity, entity.IndexedPartTransforms, entity.IndexedPartAvailable);
var hookFrames = new AnimationHookFrameQueue(
new AnimationHookRouter(),
poses);
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(ownerId, ownerSequencer) =>
{
order.Add("process_hooks");
hookFrames.Capture(ownerId, ownerSequencer);
},
(_, _, _) => { },
commitLiveRoot: (_, _) => order.Add("root_committed"));
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,
Omega = new Vector3(0f, 0f, MathF.PI / 2f),
};
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
Assert.True(scheduler.BindLiveOwner(entity, sequencer, body));
sequencer.MotionDoneTarget = (_, success) =>
{
Assert.True(success);
order.Add("animation_done");
};
scheduler.Tick(0.2f);
Assert.DoesNotContain("animation_done", order);
Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out _));
order.Add("parts_published");
order.Add("children_updated");
scheduler.ProcessHooks();
Assert.Equal("root_committed", order[0]);
Assert.Equal("parts_published", order[1]);
Assert.Equal("children_updated", order[2]);
Assert.Equal("process_hooks", order[3]);
Assert.True(order.Count > 4);
Assert.All(order.Skip(4), item => Assert.Equal("animation_done", item));
Assert.Empty(sequencer.Manager.PendingAnimations);
}
private static Setup MakeSetup()
{
var setup = new Setup();
setup.Parts.Add(GfxId);
setup.DefaultScale.Add(Vector3.One);
setup.DefaultAnimation = (QualifiedDataId<Animation>)AnimationId;
return setup;
}
private static WorldEntity MakeEntity(
uint serverGuid = 0,
uint ownerId = OwnerId)
{
var entity = new WorldEntity
{
Id = ownerId,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x0200AA01u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = new[] { new MeshRef(GfxId, Matrix4x4.Identity) },
};
entity.SetIndexedPartPoses(
new[] { Matrix4x4.Identity },
new[] { true });
return entity;
}
private static Animation TwoFrameAnimation()
{
var animation = new Animation();
var first = new AnimationFrame(1);
first.Frames.Add(new Frame
{
Origin = Vector3.Zero,
Orientation = Quaternion.Identity,
});
var second = new AnimationFrame(1);
second.Frames.Add(new Frame
{
Origin = new Vector3(2f, 0f, 0f),
Orientation = Quaternion.Identity,
});
animation.PartFrames.Add(first);
animation.PartFrames.Add(second);
return animation;
}
private static Animation OffsetAnimation(float x)
{
var animation = new Animation();
var frame = new AnimationFrame(1);
frame.Frames.Add(new Frame
{
Origin = new Vector3(x, 0f, 0f),
Orientation = Quaternion.Identity,
});
animation.PartFrames.Add(frame);
return animation;
}
private static MotionData MakeMotionData(uint animationId)
{
var data = new MotionData();
data.Anims.Add(new AnimData
{
AnimId = (QualifiedDataId<Animation>)animationId,
LowFrame = 0,
HighFrame = -1,
Framerate = 10f,
});
return data;
}
private static AnimationSequencer BindLiveOwner(
RetailStaticAnimatingObjectScheduler scheduler,
WorldEntity entity,
Setup setup,
IAnimationLoader loader,
out PhysicsBody body)
{
var sequencer = new AnimationSequencer(
setup,
new MotionTable(),
loader);
body = new PhysicsBody
{
Orientation = entity.Rotation,
};
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
Assert.True(scheduler.BindLiveOwner(entity, sequencer, body));
return sequencer;
}
private sealed class Loader : IAnimationLoader
{
private readonly Dictionary<uint, Animation> _animations = new();
public void Add(uint id, Animation animation) => _animations[id] = animation;
public Animation? LoadAnimation(uint id) =>
_animations.TryGetValue(id, out Animation? animation)
? animation
: null;
}
}

View file

@ -119,6 +119,143 @@ public sealed class EntityEffectPoseRegistryTests
Assert.Equal(0, queue.Count);
}
[Fact]
public void AnimationHookQueue_DeleteAndLocalIdReuse_DropsOldIncarnationHooks()
{
var poses = new EntityEffectPoseRegistry();
WorldEntity first = Entity(9u, Vector3.Zero);
poses.Publish(first, Array.Empty<Matrix4x4>());
var router = new AnimationHookRouter();
var sink = new RecordingSink();
router.Register(sink);
var queue = new AnimationHookFrameQueue(router, poses);
var sequencer = new AnimationSequencer(
new Setup(),
new MotionTable(),
new NullAnimationLoader());
queue.Capture(9u, sequencer, new AnimationHook[] { new SoundHook() });
Assert.True(poses.Remove(9u));
WorldEntity replacement = Entity(9u, new Vector3(40f, 50f, 60f));
poses.Publish(replacement, Array.Empty<Matrix4x4>());
queue.Drain();
Assert.Empty(sink.Calls);
Assert.Equal(0, queue.Count);
}
[Fact]
public void AnimationDoneReuseDuringCapture_DropsOldIncarnationHooks()
{
var poses = new EntityEffectPoseRegistry();
poses.Publish(Entity(9u, Vector3.Zero), Array.Empty<Matrix4x4>());
var router = new AnimationHookRouter();
var sink = new RecordingSink();
router.Register(sink);
var queue = new AnimationHookFrameQueue(router, poses);
var sequencer = new AnimationSequencer(
new Setup(),
new MotionTable(),
new NullAnimationLoader());
sequencer.Manager.AddToQueue(0x10000001u, ticks: 1u);
sequencer.MotionDoneTarget = (_, success) =>
{
Assert.True(success);
Assert.True(poses.Remove(9u));
poses.Publish(
Entity(9u, new Vector3(40f, 50f, 60f)),
Array.Empty<Matrix4x4>());
};
queue.Capture(
9u,
sequencer,
new AnimationHook[]
{
new AnimationDoneHook(),
new SoundHook(),
});
queue.Drain();
Assert.Empty(sink.Calls);
Assert.Equal(0, queue.Count);
}
[Fact]
public void MultipleAnimationDoneHooks_StopAdvancingDisplacedSequencerAfterReuse()
{
var poses = new EntityEffectPoseRegistry();
poses.Publish(Entity(9u, Vector3.Zero), Array.Empty<Matrix4x4>());
var queue = new AnimationHookFrameQueue(
new AnimationHookRouter(),
poses);
var sequencer = new AnimationSequencer(
new Setup(),
new MotionTable(),
new NullAnimationLoader());
sequencer.Manager.AddToQueue(0x10000001u, ticks: 1u);
sequencer.Manager.AddToQueue(0x10000002u, ticks: 1u);
int completions = 0;
sequencer.MotionDoneTarget = (_, success) =>
{
Assert.True(success);
completions++;
Assert.True(poses.Remove(9u));
poses.Publish(
Entity(9u, new Vector3(40f, 50f, 60f)),
Array.Empty<Matrix4x4>());
};
queue.Capture(
9u,
sequencer,
new AnimationHook[]
{
new AnimationDoneHook(),
new AnimationDoneHook(),
});
Assert.Equal(1, completions);
Assert.Single(sequencer.Manager.PendingAnimations);
queue.Drain();
Assert.Equal(0, queue.Count);
}
[Fact]
public void HookSinkReuseDuringDrain_StopsRemainingOldIncarnationHooks()
{
var poses = new EntityEffectPoseRegistry();
poses.Publish(Entity(9u, Vector3.Zero), Array.Empty<Matrix4x4>());
var router = new AnimationHookRouter();
bool replaced = false;
var sink = new CallbackSink(() =>
{
if (replaced)
return;
replaced = true;
Assert.True(poses.Remove(9u));
poses.Publish(
Entity(9u, new Vector3(40f, 50f, 60f)),
Array.Empty<Matrix4x4>());
});
router.Register(sink);
var queue = new AnimationHookFrameQueue(router, poses);
var sequencer = new AnimationSequencer(
new Setup(),
new MotionTable(),
new NullAnimationLoader());
queue.Capture(
9u,
sequencer,
new AnimationHook[] { new SoundHook(), new SoundHook() });
queue.Drain();
Assert.Equal(1, sink.Calls);
Assert.Equal(0, queue.Count);
}
[Fact]
public void AnimationHookQueue_CompletesMotionStateAtCaptureEvenWhenPoseWasRemoved()
{
@ -217,6 +354,17 @@ public sealed class EntityEffectPoseRegistryTests
Calls.Add((entityId, entityWorldPosition));
}
private sealed class CallbackSink(Action callback) : IAnimationHookSink
{
public int Calls { get; private set; }
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
Calls++;
callback();
}
}
private sealed class NullAnimationLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint animationId) => null;

View file

@ -260,6 +260,193 @@ public sealed class EntityScriptActivatorTests
Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos);
}
[Fact]
public void DatStaticAnimationOwner_RegistersAndUnregistersExactlyOnce()
{
var p = BuildPipeline();
int registerCount = 0;
int unregisterCount = 0;
WorldEntity? registeredEntity = null;
ScriptActivationInfo info = new(
ScriptId: 0,
PartTransforms: Array.Empty<Matrix4x4>(),
DefaultAnimationId: 0x03000001u,
UsesStaticAnimationWorkset: true);
var activator = new EntityScriptActivator(
p.Runner,
p.Sink,
p.Poses,
_ => info,
registerDatStaticAnimationOwner: (entity, actual) =>
{
registerCount++;
registeredEntity = entity;
Assert.Same(info, actual);
},
unregisterDatStaticAnimationOwner: ownerId =>
{
unregisterCount++;
Assert.Equal(0x40A9B420u, ownerId);
});
WorldEntity entity = MakeDatStatic(0x40A9B420u, Vector3.One);
activator.OnCreate(entity);
activator.OnCreate(entity);
activator.OnRemove(entity);
activator.OnRemove(entity);
Assert.Equal(1, registerCount);
Assert.Equal(1, unregisterCount);
Assert.Same(entity, registeredEntity);
}
[Fact]
public void LiveNonStaticEntity_DoesNotEnterStaticAnimationWorkset()
{
var p = BuildPipeline();
int registerCount = 0;
var activator = new EntityScriptActivator(
p.Runner,
p.Sink,
p.Poses,
_ => new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: Array.Empty<Matrix4x4>(),
DefaultAnimationId: 0x03000001u),
registerDatStaticAnimationOwner: (_, _) => registerCount++);
activator.OnCreate(MakeEntity(0x70000420u, Vector3.Zero));
Assert.Equal(0, registerCount);
}
[Fact]
public void LivePhysicsStaticEntity_EntersAndLeavesStaticAnimationWorkset()
{
var p = BuildPipeline();
int registerCount = 0;
int unregisterCount = 0;
var activator = new EntityScriptActivator(
p.Runner,
p.Sink,
p.Poses,
_ => new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: Array.Empty<Matrix4x4>(),
DefaultAnimationId: 0x03000001u,
UsesStaticAnimationWorkset: true),
registerDatStaticAnimationOwner: (_, _) => registerCount++,
unregisterDatStaticAnimationOwner: _ => unregisterCount++);
WorldEntity entity = MakeEntity(0x70000421u, Vector3.Zero);
activator.OnCreate(entity);
activator.OnRemove(entity);
Assert.Equal(1, registerCount);
Assert.Equal(1, unregisterCount);
}
[Fact]
public void DatStaticActivation_RetriesOnlyIncompleteAcquisitionStages()
{
var p = BuildPipeline(
(0xAAu, BuildScript((10.0, new CreateParticleHook { EmitterInfoId = 100 }))));
WorldEntity entity = MakeDatStatic(0x40A9B421u, Vector3.Zero);
var setup = new DatReaderWriter.DBObjs.Setup();
ScriptActivationInfo info = new(
ScriptId: 0xAAu,
PartTransforms: Array.Empty<Matrix4x4>(),
EffectProfile: EntityEffectProfile.CreateDatStatic(setup),
Setup: setup,
DefaultAnimationId: 0x03000001u,
UsesStaticAnimationWorkset: true);
int effectAttempts = 0;
int animationAttempts = 0;
var activator = new EntityScriptActivator(
p.Runner,
p.Sink,
p.Poses,
_ => info,
registerDatStaticEffectOwner: (_, _, _) =>
{
effectAttempts++;
if (effectAttempts == 1)
throw new InvalidOperationException("effect acquire fault");
},
registerDatStaticAnimationOwner: (_, _) =>
{
animationAttempts++;
if (animationAttempts == 1)
throw new InvalidOperationException("animation acquire fault");
});
Assert.Throws<InvalidOperationException>(() => activator.OnCreate(entity));
Assert.Equal(1, effectAttempts);
Assert.Equal(0, animationAttempts);
Assert.Equal(0, p.Runner.ActiveOwnerCount);
Assert.Throws<InvalidOperationException>(() => activator.OnCreate(entity));
Assert.Equal(2, effectAttempts);
Assert.Equal(1, animationAttempts);
Assert.Equal(0, p.Runner.ActiveOwnerCount);
activator.OnCreate(entity);
activator.OnCreate(entity);
Assert.Equal(2, effectAttempts);
Assert.Equal(2, animationAttempts);
Assert.Equal(1, p.Runner.ActiveOwnerCount);
}
[Fact]
public void DatStaticRemoval_RetriesOnlyIncompleteReleaseStages()
{
var p = BuildPipeline();
WorldEntity entity = MakeDatStatic(0x40A9B422u, Vector3.Zero);
var setup = new DatReaderWriter.DBObjs.Setup();
ScriptActivationInfo info = new(
ScriptId: 0,
PartTransforms: Array.Empty<Matrix4x4>(),
EffectProfile: EntityEffectProfile.CreateDatStatic(setup),
Setup: setup,
DefaultAnimationId: 0x03000001u,
UsesStaticAnimationWorkset: true);
int animationReleaseAttempts = 0;
int effectReleaseAttempts = 0;
var activator = new EntityScriptActivator(
p.Runner,
p.Sink,
p.Poses,
_ => info,
registerDatStaticEffectOwner: (_, _, _) => { },
unregisterDatStaticEffectOwner: _ =>
{
effectReleaseAttempts++;
if (effectReleaseAttempts == 1)
throw new InvalidOperationException("effect release fault");
},
registerDatStaticAnimationOwner: (_, _) => { },
unregisterDatStaticAnimationOwner: _ =>
{
animationReleaseAttempts++;
if (animationReleaseAttempts == 1)
throw new InvalidOperationException("animation release fault");
});
activator.OnCreate(entity);
Assert.Throws<InvalidOperationException>(() => activator.OnRemove(entity));
Assert.Equal(1, animationReleaseAttempts);
Assert.Equal(0, effectReleaseAttempts);
Assert.Throws<InvalidOperationException>(() => activator.OnRemove(entity));
Assert.Equal(2, animationReleaseAttempts);
Assert.Equal(1, effectReleaseAttempts);
activator.OnRemove(entity);
activator.OnRemove(entity);
Assert.Equal(2, animationReleaseAttempts);
Assert.Equal(2, effectReleaseAttempts);
}
[Fact]
public void CollidingDatEntityIds_FailFastAtAllocatorBoundary()
{