acdream/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs
Erik f961d70023 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>
2026-07-20 09:10:31 +02:00

542 lines
19 KiB
C#

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;
}
}