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>
542 lines
19 KiB
C#
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;
|
|
}
|
|
}
|