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>
295 lines
10 KiB
C#
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);
|
|
}
|