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 { [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 { [old.Entity.Id] = stale, }); Assert.Empty(replacement.MeshRefsScratch); Assert.Empty(replacement.EffectPartPosesScratch); } [Fact] public void ShortFirstFrame_RetainsDistinctVisualAndRigidRestPoses() { var fixture = Build(partCount: 2, scale: 2f); Matrix4x4 visualRest = Matrix4x4.CreateScale(7f) * Matrix4x4.CreateTranslation(8f, 9f, 10f); Matrix4x4 rigidRest = Matrix4x4.CreateTranslation(11f, 12f, 13f); fixture.Entity.MeshRefs = [ new MeshRef(0x01000001u, Matrix4x4.Identity), new MeshRef(0x01000002u, visualRest), ]; fixture.Entity.SetIndexedPartPoses( [Matrix4x4.Identity, rigidRest], [true, true]); var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), new Context()); presenter.Present(Schedules(fixture, [new PartTransform(Vector3.UnitX, Quaternion.Identity)])); Assert.Equal(visualRest, fixture.Entity.MeshRefs[1].PartTransform); Assert.Equal(rigidRest, fixture.Entity.IndexedPartTransforms[1]); } [Fact] public void LaterShortFrame_RetainsPreviouslyPresentedTrailingPart() { var fixture = Build(partCount: 2); var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), new Context()); presenter.Present(Schedules(fixture, [ new PartTransform(Vector3.UnitX, Quaternion.Identity), new PartTransform(new Vector3(4f, 5f, 6f), Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f)), ])); Matrix4x4 visual = fixture.Entity.MeshRefs[1].PartTransform; Matrix4x4 rigid = fixture.Entity.IndexedPartTransforms[1]; presenter.Present(Schedules(fixture, [new PartTransform(Vector3.UnitY, Quaternion.Identity)])); Assert.Equal(visual, fixture.Entity.MeshRefs[1].PartTransform); Assert.Equal(rigid, fixture.Entity.IndexedPartTransforms[1]); } [Fact] public void AppearanceShrink_ClearsAllTrailingPresentationState() { var fixture = Build(partCount: 3); var poses = new EntityEffectPoseRegistry(); var presenter = Presenter(fixture.Live, poses, new Context()); presenter.Present(Schedules(fixture, [ new PartTransform(Vector3.UnitX, Quaternion.Identity), new PartTransform(Vector3.UnitY, Quaternion.Identity), new PartTransform(Vector3.UnitZ, Quaternion.Identity), ])); var replacementSetup = new Setup(); replacementSetup.Parts.Add(0x01000009u); replacementSetup.DefaultScale.Add(Vector3.One); fixture.Entity.MeshRefs = [new MeshRef(0x01000009u, Matrix4x4.Identity)]; fixture.Entity.SetIndexedPartPoses([Matrix4x4.Identity], [true]); GameWindow.RebindAnimatedEntityForAppearance( fixture.State, fixture.Entity, replacementSetup, 1f, [new LiveAnimationPartTemplate(0x01000009u, null, true)], [true]); presenter.Present(Schedules(fixture, [new PartTransform(new Vector3(9f, 0f, 0f), Quaternion.Identity)])); Assert.Single(fixture.Entity.MeshRefs); Assert.Single(fixture.Entity.IndexedPartTransforms); Assert.Single(fixture.State.VisualPartPosesScratch); Assert.Single(fixture.State.EffectPartPosesScratch); Assert.True(poses.TryGetPartPoseSnapshot( fixture.Entity.Id, out IReadOnlyList published, out IReadOnlyList available)); Assert.Single(published); Assert.Single(available); } [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 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(); first.Live.CopySpatialRootObjectRecordsTo(nested); Assert.Equal(2, nested.Count); }; var presenter = Presenter(first.Live, poses, new Context()); var schedules = new Dictionary { [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 Schedules( Fixture fixture, IReadOnlyList frames) => new Dictionary { [fixture.Entity.Id] = Schedule(fixture, frames), }; private static LiveEntityAnimationSchedule Schedule( Fixture fixture, IReadOnlyList 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())); 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(), 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); }