acdream/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs
Erik e6457cc849 fix(physics): close the AP-156 fix review — real containment oracle, type-level invariant, AP-158
Both review lenses PASSED; this is the cleanup, not a rescue. Evidence:
docs/research/2026-08-06-ap156-review-closure.md (the review itself is
committed alongside it as the received artifact).

R1 — the load-bearing containment test could not fail. Its truth and flood
values were two hand-copies of the same expression over the same part set,
so the shortfall was algebraically identically zero for any DAT input. The
oracle is now PHYSICS-POLYGON VERTICES — a different DAT field from the
bounding sphere the builder emits, so the two sides can genuinely disagree.
Sabotage-verified three ways after full cleans: dropping the bounds centre
in production reddens it (428 Setups, worst 35.869 m on 0x0200129A, matching
an independent out-of-repo sweep exactly); dropping only the scale on the
centre reddens it (326); and corrupting the TEST's own bounds oracle reddens
it (467) where under the shipped oracle that same corruption was invisible
by algebra. Renamed accordingly. A6's stale "cap control" comment corrected:
that loop is the test's own uncapped re-implementation and cannot observe a
cap regression — the cap is covered in Core.

R2 — the population was understated. 172 is AP-152's DISPATCH population;
AP-156's is 530 BSP-bearing Setups, of which 525 have a flood sphere move
and 428 fail vertex containment before the fix (412 at a 1 cm tolerance —
the review's figure; the gap is 16 Setups between 1.4 mm and 10 mm, real
geometry). 0 fail after, at any tolerance down to zero. Corrected in the
AP-156 row, the section-3 header, the C5c handoff and two test docstrings.
Dated review artifacts are left as written — "170 of 172" was correct for
what they measured, and rewriting evidence to match a later measurement
loses provenance.

A1 — BoundsCenter = default reopened at the type what the commit closed at
the seam. Dropping the default alone would NOT have closed the review's own
scenario (a copied Cylinder call site would write Vector3.Zero explicitly
and stay green), so ShadowShape's constructor is now private and BSP shapes
are built only through ShadowShape.Bsp(..., FlatCollisionSphere localBounds),
which takes radius and centre as ONE value and scales them together. There
is no expression a caller can write that carries one and drops the other.
22 construction sites converted; the same sabotage now reddens 5 Core tests
where the review's sabotage A reached 4, because both BSP producers share
one scaling path.

A2 — #333 is real and bigger than filed, and its retail question is
answered. I disassembled CObjCell::find_obj_collisions @0x0052b750 from the
PDB-paired binary myself (check_exe_pdb.py MATCH) rather than inheriting the
claim: its only early-out is sphere_path.insert_type == INITIAL_PLACEMENT_
INSERT, then it calls FindObjCollisions on every unparented non-self shadow
object UNCONDITIONALLY. Retail has NO distance pre-filter, so acdream's
"+ movement + 2f" reach filter is an invention with no register row — filed
as AP-158, carrying the disassembly, the F_EPSILON = 0.0002 m contrast, and
the measured blast radius (118 of 477 unique installed physics-BSP GfxObjs
exceed its ~2.5 m budget, 46 exceed 5 m). Active AP rows 109 -> 110.

Recorded prominently in three places a reader will hit: TALL PROPS MAY SHOW
NO VISIBLE CHANGE UNTIL #333 LANDS, and a null result at the connected gate
is EXPECTED, not evidence against AP-156.

LOW items. R3: the comment claiming the cited evidence justified the whole
cap line is corrected, but int.MaxValue on the sorting-sphere branch stays —
capping at 1 would take Spheres[0], and retail's one sphere is
CSetup::sorting_sphere, a different DAT field; capping keeps the wrong field
AND flips the substitution under-inclusive (#98/#168 direction). AP-157
already owns it. R4: acdream scales the flood sphere where retail's
find_transit_cells never reads gfxobj_scale — added as a second residual on
AP-156. R5: retail's slack constant carried into AP-158 and #333. A3: the
per-call delegate allocation is back to a cached field, still derived from
the single bounds resolver. A5: noted; b52967de's message cannot be amended.

Gates: all 44 bin/obj deleted before every verdict-deciding build, each test
run gated on a verified "Build succeeded" in the same invocation. Release
build 0 errors / 21 pre-existing warnings. Complete suite 11,208 passed /
4 skipped / 0 failed — reconciles exactly with the e2b2d04c baseline; one
test renamed, none added, removed or skipped. Nothing conflated with the
known load-sensitive flakes #302 / #308 / #321.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:44:48 +02:00

890 lines
31 KiB
C#

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;
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 Rebind_RetainsSequencerAndPublishesIntoReplacementEntity()
{
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => { },
(_, _, _) => { });
Setup setup = MakeSetup();
WorldEntity first = MakeEntity();
ScriptActivationInfo info = new(
ScriptId: 0,
PartTransforms: first.IndexedPartTransforms,
PartAvailability: first.IndexedPartAvailable,
Setup: setup,
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true);
scheduler.Register(first, info);
scheduler.Tick(1f / 60f);
float phaseBeforeRebind = first.MeshRefs[0].PartTransform.Translation.X;
WorldEntity replacement = MakeEntity();
replacement.Position = new Vector3(4f, 5f, 6f);
scheduler.Rebind(replacement, info with
{
PartTransforms = replacement.IndexedPartTransforms,
PartAvailability = replacement.IndexedPartAvailable,
});
scheduler.Tick(1f / 60f);
Assert.Equal(1, scheduler.Count);
Assert.True(
replacement.MeshRefs[0].PartTransform.Translation.X > phaseBeforeRebind);
}
[Fact]
public void ShortStaticAnimFrame_RetainsCompleteTrailingPartState()
{
const uint trailingGfx = GfxId;
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => { },
(_, _, _) => { });
Setup setup = MakeSetup();
setup.Parts.Add(trailingGfx);
setup.DefaultScale.Add(new Vector3(3f, 4f, 5f));
Matrix4x4 visualRest = Matrix4x4.CreateScale(6f)
* Matrix4x4.CreateTranslation(7f, 8f, 9f);
Matrix4x4 rigidRest = Matrix4x4.CreateTranslation(10f, 11f, 12f);
WorldEntity entity = MakeEntity();
entity.MeshRefs =
[
entity.MeshRefs[0],
new MeshRef(trailingGfx, visualRest),
];
entity.SetIndexedPartPoses(
[Matrix4x4.Identity, rigidRest],
[true, true]);
scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: setup,
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true));
scheduler.Tick(1f / 60f);
Assert.Equal(visualRest, entity.MeshRefs[1].PartTransform);
Assert.Equal(rigidRest, entity.IndexedPartTransforms[1]);
}
[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.TryTakePreparedFramesForTest(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 PendingLiveStaticOwner_BindsCanonicalRuntimeOwnerOnFirstTick()
{
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
Setup setup = MakeSetup();
WorldEntity entity = MakeEntity(serverGuid: 0x70000001u);
var sequencer = new AnimationSequencer(
setup,
new MotionTable(),
loader);
var body = new PhysicsBody
{
Orientation = entity.Rotation,
};
body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero);
LiveEntityAnimationState animation =
LiveState(entity, setup, sequencer);
int resolutions = 0;
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => { },
(_, _, _) => { },
resolveLiveOwner: candidate =>
{
Assert.Same(entity, candidate);
resolutions++;
return (animation, body);
});
Assert.True(scheduler.Register(entity, new ScriptActivationInfo(
ScriptId: 0,
PartTransforms: entity.IndexedPartTransforms,
PartAvailability: entity.IndexedPartAvailable,
Setup: setup,
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true)));
scheduler.Tick(0.02f);
scheduler.Tick(0.02f);
Assert.Equal(1, resolutions);
Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out _));
}
[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[]
{
ShadowShape.Cylinder(
GfxId,
Vector3.UnitX,
Quaternion.Identity,
scale: 1f,
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.TryTakePreparedFramesForTest(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.TryTakePreparedFramesForTest(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.TryTakePreparedFramesForTest(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,
LiveState(entity, setup, sequencer),
body));
sequencer.MotionDoneTarget = (_, success) =>
{
Assert.True(success);
order.Add("animation_done");
};
scheduler.Tick(0.2f);
Assert.DoesNotContain("animation_done", order);
Assert.True(scheduler.TryTakePreparedFramesForTest(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);
}
[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 = LiveEntityTestFixture.CreateExactProjectionRecord(
Spawn(serverGuid));
record.WorldEntity = entity;
record.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();
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 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();
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,
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();
public void Add(uint id, Animation animation) => _animations[id] = animation;
public Animation? LoadAnimation(uint id) =>
_animations.TryGetValue(id, out Animation? animation)
? animation
: null;
}
}