acdream/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs
Erik f004ac5562 refactor(animation): extract live PartArray presenter
Move final live part composition, exact-incarnation schedule handoff, MotionDone binding, and presentation diagnostics out of GameWindow while preserving the retail frame order. Reject stale schedule and completion ABA at the new owner boundary.

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-21 09:37:02 +02:00

295 lines
10 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
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.Types;
namespace AcDream.App.Tests.Rendering;
public sealed class LiveEntityAnimationPresenterTests
{
private const uint Guid = 0x70000031u;
private const uint Cell = 0x01010001u;
[Fact]
public void Present_ComposesDistinctVisualAndRigidChannels()
{
var fixture = Build(partCount: 2, scale: 2f);
fixture.State.Setup.DefaultScale[0] = new Vector3(3f, 4f, 5f);
fixture.State.PartTemplate =
[
new LiveAnimationPartTemplate(0x01000001u, new Dictionary<uint, uint> { [1] = 2 }, true),
new LiveAnimationPartTemplate(0x01000002u, null, false),
];
Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
PartTransform[] frames =
[
new(new Vector3(1f, 2f, 3f), rotation),
new(new Vector3(4f, 5f, 6f), Quaternion.Identity),
];
var poses = new EntityEffectPoseRegistry();
var presenter = Presenter(fixture.Live, poses, new Context());
presenter.Present(Schedules(fixture, frames));
Assert.Single(fixture.Entity.MeshRefs);
Matrix4x4 expectedVisual = Matrix4x4.CreateScale(3f, 4f, 5f)
* Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(1f, 2f, 3f)
* Matrix4x4.CreateScale(2f);
Matrix4x4 expectedRigid = Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(2f, 4f, 6f);
Assert.Equal(expectedVisual, fixture.Entity.MeshRefs[0].PartTransform);
Assert.Equal(expectedRigid, fixture.Entity.IndexedPartTransforms[0]);
Assert.Equal(2, fixture.Entity.IndexedPartTransforms.Count);
Assert.True(poses.TryGetPartPose(fixture.Entity.Id, 0, out Matrix4x4 effectPose));
Assert.Equal(expectedRigid, effectPose);
Assert.Equal(2u, fixture.Entity.MeshRefs[0].SurfaceOverrides![1]);
}
[Fact]
public void OldSchedule_IsRejectedForCurrentOwnerEvenWhenIndexedByItsLocalId()
{
var old = Build(partCount: 1);
LiveEntityAnimationSchedule stale = Schedule(old,
[new PartTransform(new Vector3(99f, 0f, 0f), Quaternion.Identity)]);
Assert.True(old.Live.ClearAnimationRuntime(Guid));
LiveEntityAnimationState replacement = State(old.Entity, partCount: 1, scale: 1f);
old.Live.SetAnimationRuntime(Guid, replacement);
var presenter = Presenter(old.Live, new EntityEffectPoseRegistry(), new Context());
presenter.Present(new Dictionary<uint, LiveEntityAnimationSchedule>
{
[old.Entity.Id] = stale,
});
Assert.Empty(replacement.MeshRefsScratch);
Assert.Empty(replacement.EffectPartPosesScratch);
}
[Fact]
public void MotionDone_OldComponentCannotResolveReplacementByGuid()
{
var fixture = Build(partCount: 1, withSequencer: true);
var context = new Context();
var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), context);
presenter.PrepareAnimation(fixture.Record, fixture.State);
Action<uint, bool> oldCallback = fixture.State.Sequencer!.MotionDoneTarget!;
Assert.True(fixture.Live.ClearAnimationRuntime(Guid));
fixture.Live.SetAnimationRuntime(Guid, State(fixture.Entity, 1, 1f));
oldCallback(0x10000001u, true);
Assert.Equal(0, context.ResolveCount);
}
[Fact]
public void EffectPoseCallback_NestedRuntimeSnapshotDoesNotTruncatePresentation()
{
var first = Build(partCount: 1, guid: Guid);
var second = Add(first.Live, Guid + 1, partCount: 1);
var poses = new EntityEffectPoseRegistry();
poses.EffectPoseChanged += _ =>
{
var nested = new List<LiveEntityRecord>();
first.Live.CopySpatialRootObjectRecordsTo(nested);
Assert.Equal(2, nested.Count);
};
var presenter = Presenter(first.Live, poses, new Context());
var schedules = new Dictionary<uint, LiveEntityAnimationSchedule>
{
[first.Entity.Id] = Schedule(first,
[new PartTransform(Vector3.UnitX, Quaternion.Identity)]),
[second.Entity.Id] = Schedule(second,
[new PartTransform(Vector3.UnitY, Quaternion.Identity)]),
};
presenter.Present(schedules);
Assert.Single(first.Entity.MeshRefs);
Assert.Single(second.Entity.MeshRefs);
}
private static LiveEntityAnimationPresenter Presenter(
LiveEntityRuntime live,
EntityEffectPoseRegistry poses,
Context context) => new(
() => live,
() => null,
poses,
context,
AnimationPresentationDiagnostics.Disabled,
hidePartIndex: -1);
private static IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> Schedules(
Fixture fixture,
IReadOnlyList<PartTransform> frames) =>
new Dictionary<uint, LiveEntityAnimationSchedule>
{
[fixture.Entity.Id] = Schedule(fixture, frames),
};
private static LiveEntityAnimationSchedule Schedule(
Fixture fixture,
IReadOnlyList<PartTransform> frames) => new(
frames,
0f,
true,
fixture.Record,
fixture.Entity,
fixture.State,
fixture.Record.ObjectClockEpoch,
fixture.Record.ProjectionMutationVersion);
private static Fixture Build(
int partCount,
float scale = 1f,
bool withSequencer = true,
uint guid = Guid)
{
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
var live = new LiveEntityRuntime(
spatial,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
return Add(live, guid, partCount, scale, withSequencer);
}
private static Fixture Add(
LiveEntityRuntime live,
uint guid,
int partCount,
float scale = 1f,
bool withSequencer = true)
{
LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid)).Record!;
WorldEntity entity = live.MaterializeLiveEntity(
guid,
Cell,
id => new WorldEntity
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = Cell,
})!;
LiveEntityAnimationState state = State(entity, partCount, scale, withSequencer);
entity.SetIndexedPartPoses(
Enumerable.Repeat(Matrix4x4.Identity, partCount).ToArray(),
Enumerable.Repeat(true, partCount).ToArray());
record.HasPartArray = true;
live.SetAnimationRuntime(guid, state);
return new Fixture(live, record, entity, state);
}
private static LiveEntityAnimationState State(
WorldEntity entity,
int partCount,
float scale,
bool withSequencer = false)
{
var setup = new Setup();
for (int i = 0; i < partCount; i++)
{
setup.Parts.Add(0x01000001u + (uint)i);
setup.DefaultScale.Add(Vector3.One);
}
return new LiveEntityAnimationState
{
Entity = entity,
Setup = setup,
Animation = new Animation(),
LowFrame = 0,
HighFrame = 0,
Framerate = 0f,
Scale = scale,
PartTemplate = Enumerable.Range(0, partCount)
.Select(i => new LiveAnimationPartTemplate(0x01000001u + (uint)i, null, true))
.ToArray(),
PartAvailability = Enumerable.Repeat(true, partCount).ToArray(),
Sequencer = withSequencer
? new AnimationSequencer(setup, new MotionTable(), new NullLoader())
: null,
};
}
private static WorldSession.EntitySpawn Spawn(uint guid)
{
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,
"presenter fixture",
null,
null,
null,
PhysicsState: 0u,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private sealed class Context : ILiveAnimationPresentationContext
{
public uint LocalPlayerGuid => 0x50000001u;
public int ResolveCount { get; private set; }
public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record)
{
ResolveCount++;
return null;
}
}
private sealed class NullLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint id) => null;
}
private sealed record Fixture(
LiveEntityRuntime Live,
LiveEntityRecord Record,
WorldEntity Entity,
LiveEntityAnimationState State);
}