feat(physics): port retail complete object frame pipeline
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>
This commit is contained in:
parent
31a0889f08
commit
f961d70023
77 changed files with 12513 additions and 1871 deletions
|
|
@ -0,0 +1,835 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering;
|
||||
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.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class LiveEntityAnimationSchedulerTests
|
||||
{
|
||||
private const uint LocalGuid = 0x50000001u;
|
||||
private const uint RemoteGuid = 0x70000001u;
|
||||
private const uint Cell = 0x01010001u;
|
||||
|
||||
[Fact]
|
||||
public void HiddenLocalPose_IsSampledOnlyForMarkedObjectQuantum()
|
||||
{
|
||||
var (live, record, animation) = BuildHiddenAnimated(LocalGuid);
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(
|
||||
live,
|
||||
localPlayerGuid: LocalGuid);
|
||||
|
||||
Assert.True(scheduler.Tick(
|
||||
PhysicsBody.MaxQuantum,
|
||||
animation.Entity.Position,
|
||||
localHiddenPartPoseDirty: true,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0)
|
||||
.TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule marked));
|
||||
bool repeated = scheduler.Tick(
|
||||
PhysicsBody.MaxQuantum,
|
||||
animation.Entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0)
|
||||
.TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule consumed);
|
||||
|
||||
Assert.True(marked.ComposeParts);
|
||||
Assert.NotNull(marked.SequenceFrames);
|
||||
Assert.False(repeated);
|
||||
Assert.False(consumed.ComposeParts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HiddenRemotePose_IsSampledOnceAfterAdmittedHiddenQuantum()
|
||||
{
|
||||
var (live, record, animation) = BuildHiddenAnimated(RemoteGuid);
|
||||
var remote = new GameWindow.RemoteMotion
|
||||
{
|
||||
CellId = Cell,
|
||||
};
|
||||
remote.Body.Position = animation.Entity.Position;
|
||||
remote.Body.Orientation = animation.Entity.Rotation;
|
||||
remote.Body.InWorld = true;
|
||||
live.SetRemoteMotionRuntime(RemoteGuid, remote);
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(
|
||||
live,
|
||||
localPlayerGuid: LocalGuid);
|
||||
|
||||
Assert.True(scheduler.Tick(
|
||||
PhysicsBody.MaxQuantum,
|
||||
playerPosition: null,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0)
|
||||
.TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule marked));
|
||||
bool repeated = scheduler.Tick(
|
||||
elapsedSeconds: 0f,
|
||||
playerPosition: null,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0)
|
||||
.TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule consumed);
|
||||
|
||||
Assert.True(marked.ComposeParts);
|
||||
Assert.NotNull(marked.SequenceFrames);
|
||||
Assert.False(repeated);
|
||||
Assert.False(consumed.ComposeParts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VisibleAnimationWithoutRemoteMotion_AppliesCompleteRootFrame()
|
||||
{
|
||||
var (live, _, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid);
|
||||
var retainedBodyOwner = BuildRemote(animation.Entity);
|
||||
retainedBodyOwner.Body.SnapToCell(
|
||||
Cell,
|
||||
animation.Entity.Position,
|
||||
animation.Entity.Position);
|
||||
live.SetRemoteMotionRuntime(RemoteGuid, retainedBodyOwner);
|
||||
Assert.True(live.ClearRemoteMotionRuntime(RemoteGuid));
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid);
|
||||
float startX = animation.Entity.Position.X;
|
||||
|
||||
Assert.True(scheduler.Tick(
|
||||
0.1f,
|
||||
animation.Entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0)
|
||||
.ContainsKey(animation.Entity.Id));
|
||||
|
||||
Assert.True(animation.Entity.Position.X > startX + 1f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BodylessNonWalkableAnimation_SuppressesRootOriginButPreservesOrientation()
|
||||
{
|
||||
Quaternion rootTurn = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 2f);
|
||||
var (live, _, animation) = BuildVisibleAnimatedWithPosFrames(
|
||||
RemoteGuid,
|
||||
rootTurn);
|
||||
Vector3 startPosition = animation.Entity.Position;
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid);
|
||||
|
||||
scheduler.Tick(
|
||||
PhysicsBody.MinQuantum * 1.01f,
|
||||
animation.Entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0);
|
||||
|
||||
Assert.Equal(startPosition, animation.Entity.Position);
|
||||
Assert.InRange(
|
||||
MathF.Abs(Quaternion.Dot(
|
||||
rootTurn,
|
||||
Quaternion.Normalize(animation.Entity.Rotation))),
|
||||
0.99999f,
|
||||
1.00001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteWithoutAnimation_RemainsInOrdinaryObjectWorkset()
|
||||
{
|
||||
var (live, record, entity) = BuildMaterialized(
|
||||
RemoteGuid,
|
||||
PhysicsStateFlags.Gravity);
|
||||
var remote = new GameWindow.RemoteMotion
|
||||
{
|
||||
CellId = Cell,
|
||||
Airborne = true,
|
||||
};
|
||||
remote.Body.Position = entity.Position;
|
||||
remote.Body.Orientation = entity.Rotation;
|
||||
remote.Body.Velocity = new Vector3(3f, 0f, 0f);
|
||||
remote.Body.State = PhysicsStateFlags.Gravity;
|
||||
remote.Body.TransientState = TransientStateFlags.Active;
|
||||
live.SetRemoteMotionRuntime(RemoteGuid, remote);
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid);
|
||||
float startX = entity.Position.X;
|
||||
|
||||
scheduler.Tick(
|
||||
0.1f,
|
||||
entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0);
|
||||
|
||||
Assert.True(entity.Position.X > startX);
|
||||
Assert.Same(record, live.SpatialRootObjects[RemoteGuid]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnimationlessRemote_NearPositionCorrectionIsQueuedAndConsumed()
|
||||
{
|
||||
var (live, record, entity) = BuildMaterialized(
|
||||
RemoteGuid,
|
||||
PhysicsStateFlags.ReportCollisions);
|
||||
var remote = BuildRemote(entity);
|
||||
remote.Interp.Enqueue(
|
||||
entity.Position + Vector3.UnitX,
|
||||
heading: 0f,
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: remote.Body.Position);
|
||||
live.SetRemoteMotionRuntime(RemoteGuid, remote);
|
||||
Assert.Null(record.AnimationRuntime);
|
||||
Assert.True(live.IsCurrentSpatialRemoteMotion(record, remote));
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid);
|
||||
float startX = entity.Position.X;
|
||||
|
||||
scheduler.Tick(
|
||||
0.1f,
|
||||
entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1);
|
||||
|
||||
Assert.True(entity.Position.X > startX + 0.5f);
|
||||
Assert.True(remote.Interp.IsActive || entity.Position.X >= startX + 0.99f);
|
||||
Assert.Same(record, live.SpatialRootObjects[RemoteGuid]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedProjectileWithoutRemote_WhenMissileClears_AdvancesOnceAsOrdinaryBody()
|
||||
{
|
||||
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(
|
||||
RemoteGuid,
|
||||
rootOrigin: Vector3.Zero);
|
||||
var physics = new PhysicsEngine();
|
||||
var projectiles = new ProjectileController(live, physics);
|
||||
record.FinalPhysicsState = ProjectileState;
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.True(projectiles.TryBind(
|
||||
record,
|
||||
ProjectileSetup(),
|
||||
currentTime: 1.0,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
body.set_velocity(new Vector3(3f, 0f, 0f));
|
||||
body.TransientState = TransientStateFlags.Active;
|
||||
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions
|
||||
| PhysicsStateFlags.Gravity;
|
||||
Assert.True(projectiles.ApplyAuthoritativeState(
|
||||
record,
|
||||
record.FinalPhysicsState,
|
||||
currentTime: 1.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.True(projectiles.HandlesMovement(RemoteGuid));
|
||||
float startX = animation.Entity.Position.X;
|
||||
int rootPublishes = 0;
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(
|
||||
live,
|
||||
LocalGuid,
|
||||
publishRootPose: _ => rootPublishes++,
|
||||
physics: physics,
|
||||
projectiles: projectiles);
|
||||
|
||||
scheduler.Tick(
|
||||
0.1f,
|
||||
animation.Entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1);
|
||||
|
||||
float distance = animation.Entity.Position.X - startX;
|
||||
Assert.InRange(distance, 0.2f, 0.4f);
|
||||
Assert.Equal(1, rootPublishes);
|
||||
Assert.Same(body, record.PhysicsBody);
|
||||
Assert.Same(body, record.ProjectileRuntime!.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedProjectileWithRemote_WhenMissileClears_TransfersMovementToRemoteOnce()
|
||||
{
|
||||
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(
|
||||
RemoteGuid,
|
||||
rootOrigin: Vector3.Zero);
|
||||
var physics = new PhysicsEngine();
|
||||
var projectiles = new ProjectileController(live, physics);
|
||||
record.FinalPhysicsState = ProjectileState;
|
||||
var remote = new GameWindow.RemoteMotion
|
||||
{
|
||||
CellId = Cell,
|
||||
Airborne = false,
|
||||
};
|
||||
remote.Body.Position = animation.Entity.Position;
|
||||
remote.Body.Orientation = animation.Entity.Rotation;
|
||||
remote.Body.State = ProjectileState;
|
||||
remote.Body.InWorld = true;
|
||||
remote.Body.TransientState = TransientStateFlags.Active
|
||||
| TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable;
|
||||
live.SetRemoteMotionRuntime(RemoteGuid, remote);
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.Equal(Cell, record.FullCellId);
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.True(projectiles.TryBind(
|
||||
record,
|
||||
ProjectileSetup(),
|
||||
currentTime: 1.0,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.Equal(Cell, record.FullCellId);
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
|
||||
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions
|
||||
| PhysicsStateFlags.Gravity;
|
||||
Assert.True(projectiles.ApplyAuthoritativeState(
|
||||
record,
|
||||
record.FinalPhysicsState,
|
||||
currentTime: 1.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.False(projectiles.HandlesMovement(RemoteGuid));
|
||||
remote.Interp.Enqueue(
|
||||
animation.Entity.Position + Vector3.UnitX,
|
||||
heading: 0f,
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: remote.Body.Position);
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.True(remote.Body.IsActive);
|
||||
Assert.True(live.IsCurrentSpatialRemoteMotion(record, remote));
|
||||
Assert.True(live.IsCurrentSpatialAnimation(record, animation));
|
||||
float startX = animation.Entity.Position.X;
|
||||
int rootPublishes = 0;
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(
|
||||
live,
|
||||
LocalGuid,
|
||||
publishRootPose: _ => rootPublishes++,
|
||||
physics: physics,
|
||||
projectiles: projectiles);
|
||||
|
||||
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules = scheduler.Tick(
|
||||
0.1f,
|
||||
animation.Entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1);
|
||||
|
||||
Assert.Contains(animation.Entity.Id, schedules.Keys);
|
||||
Assert.Equal(1, rootPublishes);
|
||||
float distance = animation.Entity.Position.X - startX;
|
||||
Assert.InRange(distance, 0.79f, 0.81f);
|
||||
Assert.Same(remote.Body, record.PhysicsBody);
|
||||
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteFromFirstQuantumCallback_StopsCatchUpBatchAndPosePublish()
|
||||
{
|
||||
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid);
|
||||
int captures = 0;
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(
|
||||
live,
|
||||
LocalGuid,
|
||||
(_, _) =>
|
||||
{
|
||||
captures++;
|
||||
live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(RemoteGuid, record.Generation),
|
||||
isLocalPlayer: false);
|
||||
});
|
||||
|
||||
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules = scheduler.Tick(
|
||||
PhysicsBody.MaxQuantum * 2.5f,
|
||||
animation.Entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0);
|
||||
|
||||
Assert.Equal(1, captures);
|
||||
Assert.Empty(schedules);
|
||||
Assert.False(live.TryGetRecord(RemoteGuid, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VisibleRemoteDeleteInsideHook_StopsBeforeStaleEntityPublication()
|
||||
{
|
||||
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid);
|
||||
var remote = BuildRemote(animation.Entity);
|
||||
live.SetRemoteMotionRuntime(RemoteGuid, remote);
|
||||
Vector3 retainedEntityPosition = animation.Entity.Position;
|
||||
int captures = 0;
|
||||
int rootPublishes = 0;
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(
|
||||
live,
|
||||
LocalGuid,
|
||||
(_, _) =>
|
||||
{
|
||||
captures++;
|
||||
live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(RemoteGuid, record.Generation),
|
||||
isLocalPlayer: false);
|
||||
},
|
||||
_ => rootPublishes++);
|
||||
|
||||
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules = scheduler.Tick(
|
||||
PhysicsBody.MaxQuantum * 2.5f,
|
||||
animation.Entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0);
|
||||
|
||||
Assert.Equal(1, captures);
|
||||
Assert.Equal(0, rootPublishes);
|
||||
Assert.Empty(schedules);
|
||||
Assert.Equal(retainedEntityPosition, animation.Entity.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HiddenRemoteDeleteInsideHook_StopsBeforeStaleEntityPublication()
|
||||
{
|
||||
var (live, record, animation) = BuildHiddenAnimated(RemoteGuid);
|
||||
var remote = BuildRemote(animation.Entity);
|
||||
live.SetRemoteMotionRuntime(RemoteGuid, remote);
|
||||
Vector3 retainedEntityPosition = animation.Entity.Position;
|
||||
int captures = 0;
|
||||
int rootPublishes = 0;
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(
|
||||
live,
|
||||
LocalGuid,
|
||||
(_, _) =>
|
||||
{
|
||||
captures++;
|
||||
live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(RemoteGuid, record.Generation),
|
||||
isLocalPlayer: false);
|
||||
},
|
||||
_ => rootPublishes++);
|
||||
|
||||
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules = scheduler.Tick(
|
||||
PhysicsBody.MaxQuantum * 2.5f,
|
||||
animation.Entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0);
|
||||
|
||||
Assert.Equal(1, captures);
|
||||
Assert.Equal(0, rootPublishes);
|
||||
Assert.Empty(schedules);
|
||||
Assert.Equal(retainedEntityPosition, animation.Entity.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HiddenAnimationWithoutRemoteDeleteInsideHook_StopsBeforeManagerTailOrPublication()
|
||||
{
|
||||
var (live, record, animation) = BuildHiddenAnimated(RemoteGuid);
|
||||
int captures = 0;
|
||||
int rootPublishes = 0;
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(
|
||||
live,
|
||||
LocalGuid,
|
||||
(_, _) =>
|
||||
{
|
||||
captures++;
|
||||
live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(RemoteGuid, record.Generation),
|
||||
isLocalPlayer: false);
|
||||
},
|
||||
_ => rootPublishes++);
|
||||
|
||||
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules = scheduler.Tick(
|
||||
PhysicsBody.MaxQuantum * 2.5f,
|
||||
animation.Entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0);
|
||||
|
||||
Assert.Equal(1, captures);
|
||||
Assert.Equal(0, rootPublishes);
|
||||
Assert.Empty(schedules);
|
||||
Assert.False(live.TryGetRecord(RemoteGuid, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithdrawAndReenterInsideHook_InvalidatesOldCatchUpBatch()
|
||||
{
|
||||
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid);
|
||||
ulong startingEpoch = record.ObjectClockEpoch;
|
||||
int captures = 0;
|
||||
int rootPublishes = 0;
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(
|
||||
live,
|
||||
LocalGuid,
|
||||
(_, _) =>
|
||||
{
|
||||
captures++;
|
||||
Assert.True(live.WithdrawLiveEntityProjection(RemoteGuid));
|
||||
Assert.Same(
|
||||
animation.Entity,
|
||||
live.MaterializeLiveEntity(
|
||||
RemoteGuid,
|
||||
Cell,
|
||||
_ => throw new InvalidOperationException(
|
||||
"A retained projection must not be reconstructed.")));
|
||||
},
|
||||
_ => rootPublishes++);
|
||||
|
||||
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules = scheduler.Tick(
|
||||
PhysicsBody.MaxQuantum * 2.5f,
|
||||
animation.Entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0);
|
||||
|
||||
Assert.Equal(1, captures);
|
||||
Assert.Equal(0, rootPublishes);
|
||||
Assert.Empty(schedules);
|
||||
Assert.Equal(startingEpoch + 2, record.ObjectClockEpoch);
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.Equal(0d, record.ObjectClock.PendingSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalRootPosePublishesWithoutAnimationAndBetweenObjectQuanta()
|
||||
{
|
||||
var (live, _, entity) = BuildMaterialized(LocalGuid, PhysicsStateFlags.None);
|
||||
var published = new List<Vector3>();
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(
|
||||
live,
|
||||
LocalGuid,
|
||||
publishRootPose: current => published.Add(current.Position));
|
||||
|
||||
scheduler.Tick(
|
||||
PhysicsBody.MinQuantum * 0.25f,
|
||||
entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0);
|
||||
entity.SetPosition(entity.Position + new Vector3(0.25f, 0f, 0f));
|
||||
scheduler.Tick(
|
||||
PhysicsBody.MinQuantum * 0.25f,
|
||||
entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0);
|
||||
|
||||
Assert.Equal(2, published.Count);
|
||||
Assert.Equal(entity.Position, published[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachedProjection_IsNotAnIndependentOrdinaryRoot()
|
||||
{
|
||||
var (live, record, entity) = BuildMaterialized(
|
||||
RemoteGuid,
|
||||
PhysicsStateFlags.None);
|
||||
int rootPublishes = 0;
|
||||
LiveEntityAnimationScheduler scheduler = BuildScheduler(
|
||||
live,
|
||||
LocalGuid,
|
||||
publishRootPose: _ => rootPublishes++);
|
||||
|
||||
Assert.Same(
|
||||
entity,
|
||||
live.MaterializeLiveEntity(
|
||||
RemoteGuid,
|
||||
Cell,
|
||||
_ => throw new InvalidOperationException(),
|
||||
LiveEntityProjectionKind.Attached));
|
||||
Assert.False(record.ObjectClock.IsActive);
|
||||
Assert.DoesNotContain(RemoteGuid, live.SpatialRootObjects.Keys);
|
||||
|
||||
scheduler.Tick(
|
||||
PhysicsBody.MaxQuantum,
|
||||
entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0);
|
||||
Assert.Equal(0, rootPublishes);
|
||||
|
||||
Assert.Same(
|
||||
entity,
|
||||
live.MaterializeLiveEntity(
|
||||
RemoteGuid,
|
||||
Cell,
|
||||
_ => throw new InvalidOperationException(),
|
||||
LiveEntityProjectionKind.World));
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.Contains(RemoteGuid, live.SpatialRootObjects.Keys);
|
||||
|
||||
scheduler.Tick(
|
||||
PhysicsBody.MaxQuantum,
|
||||
entity.Position,
|
||||
localHiddenPartPoseDirty: false,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0);
|
||||
Assert.Equal(1, rootPublishes);
|
||||
}
|
||||
|
||||
private static GameWindow.RemoteMotion BuildRemote(WorldEntity entity)
|
||||
{
|
||||
var remote = new GameWindow.RemoteMotion
|
||||
{
|
||||
CellId = Cell,
|
||||
};
|
||||
remote.Body.Position = entity.Position;
|
||||
remote.Body.Orientation = entity.Rotation;
|
||||
remote.Body.InWorld = true;
|
||||
remote.Body.TransientState = TransientStateFlags.Active
|
||||
| TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable;
|
||||
return remote;
|
||||
}
|
||||
|
||||
private static LiveEntityAnimationScheduler BuildScheduler(
|
||||
LiveEntityRuntime live,
|
||||
uint localPlayerGuid,
|
||||
Action<uint, AnimationSequencer>? captureHooks = null,
|
||||
Action<WorldEntity>? publishRootPose = null,
|
||||
PhysicsEngine? physics = null,
|
||||
ProjectileController? projectiles = null)
|
||||
{
|
||||
physics ??= new PhysicsEngine();
|
||||
var remotePhysics = new RemotePhysicsUpdater(
|
||||
physics,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater(
|
||||
physics,
|
||||
(_, _) => (0.48f, 1.835f));
|
||||
return new LiveEntityAnimationScheduler(
|
||||
() => live,
|
||||
() => localPlayerGuid,
|
||||
remotePhysics,
|
||||
ordinaryPhysics,
|
||||
() => projectiles,
|
||||
publishRootPose ?? (_ => { }),
|
||||
captureHooks ?? ((_, _) => { }));
|
||||
}
|
||||
|
||||
private static (LiveEntityRuntime Live, LiveEntityRecord Record,
|
||||
GameWindow.AnimatedEntity Animation) BuildHiddenAnimated(uint guid)
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0101FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var live = new LiveEntityRuntime(
|
||||
spatial,
|
||||
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
||||
LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid)).Record!;
|
||||
WorldEntity entity = live.MaterializeLiveEntity(
|
||||
guid,
|
||||
Cell,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(10f, 10f, 5f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = Cell,
|
||||
})!;
|
||||
var setup = new Setup();
|
||||
var animation = new GameWindow.AnimatedEntity
|
||||
{
|
||||
Entity = entity,
|
||||
Setup = setup,
|
||||
Animation = new Animation(),
|
||||
LowFrame = 0,
|
||||
HighFrame = 0,
|
||||
Framerate = 0f,
|
||||
Scale = 1f,
|
||||
PartTemplate = Array.Empty<GameWindow.AnimatedPartTemplate>(),
|
||||
PartAvailability = Array.Empty<bool>(),
|
||||
Sequencer = new AnimationSequencer(
|
||||
setup,
|
||||
new MotionTable(),
|
||||
new NullAnimationLoader()),
|
||||
};
|
||||
record.HasPartArray = true;
|
||||
live.SetAnimationRuntime(guid, animation);
|
||||
return (live, record, animation);
|
||||
}
|
||||
|
||||
private static (LiveEntityRuntime Live, LiveEntityRecord Record,
|
||||
GameWindow.AnimatedEntity Animation) BuildVisibleAnimatedWithPosFrames(
|
||||
uint guid,
|
||||
Quaternion? rootOrientation = null,
|
||||
Vector3? rootOrigin = null)
|
||||
{
|
||||
var (live, record, entity) = BuildMaterialized(guid, PhysicsStateFlags.None);
|
||||
const uint animationId = 0x0300AA01u;
|
||||
var setup = new Setup();
|
||||
setup.Parts.Add(0x0100AA01u);
|
||||
setup.DefaultScale.Add(Vector3.One);
|
||||
var datAnimation = new Animation
|
||||
{
|
||||
Flags = AnimationFlags.PosFrames,
|
||||
};
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var partFrame = new AnimationFrame(1);
|
||||
partFrame.Frames.Add(new Frame
|
||||
{
|
||||
Origin = Vector3.Zero,
|
||||
Orientation = Quaternion.Identity,
|
||||
});
|
||||
datAnimation.PartFrames.Add(partFrame);
|
||||
datAnimation.PosFrames.Add(new Frame
|
||||
{
|
||||
Origin = rootOrigin ?? Vector3.UnitX,
|
||||
Orientation = rootOrientation ?? Quaternion.Identity,
|
||||
});
|
||||
}
|
||||
var loader = new DictionaryAnimationLoader(animationId, datAnimation);
|
||||
var sequencer = new AnimationSequencer(setup, new MotionTable(), loader);
|
||||
Assert.True(sequencer.InitializeSetupDefaultAnimation(animationId));
|
||||
var animation = new GameWindow.AnimatedEntity
|
||||
{
|
||||
Entity = entity,
|
||||
Setup = setup,
|
||||
Animation = datAnimation,
|
||||
LowFrame = 0,
|
||||
HighFrame = 3,
|
||||
Framerate = 30f,
|
||||
Scale = 1f,
|
||||
PartTemplate = new[]
|
||||
{
|
||||
new GameWindow.AnimatedPartTemplate(
|
||||
0x0100AA01u,
|
||||
SurfaceOverrides: null,
|
||||
IsDrawable: true),
|
||||
},
|
||||
PartAvailability = new[] { true },
|
||||
Sequencer = sequencer,
|
||||
};
|
||||
record.HasPartArray = true;
|
||||
live.SetAnimationRuntime(guid, animation);
|
||||
return (live, record, animation);
|
||||
}
|
||||
|
||||
private static readonly PhysicsStateFlags ProjectileState =
|
||||
PhysicsStateFlags.ReportCollisions
|
||||
| PhysicsStateFlags.Missile
|
||||
| PhysicsStateFlags.AlignPath
|
||||
| PhysicsStateFlags.PathClipped;
|
||||
|
||||
private static Setup ProjectileSetup() => new()
|
||||
{
|
||||
Spheres =
|
||||
{
|
||||
new Sphere
|
||||
{
|
||||
Origin = Vector3.Zero,
|
||||
Radius = 0.1f,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
private static (LiveEntityRuntime Live, LiveEntityRecord Record, WorldEntity Entity)
|
||||
BuildMaterialized(uint guid, PhysicsStateFlags state)
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0101FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var live = new LiveEntityRuntime(
|
||||
spatial,
|
||||
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
||||
LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid, state)).Record!;
|
||||
WorldEntity entity = live.MaterializeLiveEntity(
|
||||
guid,
|
||||
Cell,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(10f, 10f, 5f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = Cell,
|
||||
})!;
|
||||
return (live, record, entity);
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
uint guid,
|
||||
PhysicsStateFlags state = PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.IgnoreCollisions)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
Cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)state,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
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,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"hidden scheduler fixture",
|
||||
null,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: (uint)state,
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
|
||||
private sealed class DictionaryAnimationLoader : IAnimationLoader
|
||||
{
|
||||
private readonly uint _id;
|
||||
private readonly Animation _animation;
|
||||
|
||||
public DictionaryAnimationLoader(uint id, Animation animation)
|
||||
{
|
||||
_id = id;
|
||||
_animation = animation;
|
||||
}
|
||||
|
||||
public Animation? LoadAnimation(uint id) => id == _id ? _animation : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,542 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -119,6 +119,143 @@ public sealed class EntityEffectPoseRegistryTests
|
|||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnimationHookQueue_DeleteAndLocalIdReuse_DropsOldIncarnationHooks()
|
||||
{
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
WorldEntity first = Entity(9u, Vector3.Zero);
|
||||
poses.Publish(first, Array.Empty<Matrix4x4>());
|
||||
var router = new AnimationHookRouter();
|
||||
var sink = new RecordingSink();
|
||||
router.Register(sink);
|
||||
var queue = new AnimationHookFrameQueue(router, poses);
|
||||
var sequencer = new AnimationSequencer(
|
||||
new Setup(),
|
||||
new MotionTable(),
|
||||
new NullAnimationLoader());
|
||||
|
||||
queue.Capture(9u, sequencer, new AnimationHook[] { new SoundHook() });
|
||||
Assert.True(poses.Remove(9u));
|
||||
WorldEntity replacement = Entity(9u, new Vector3(40f, 50f, 60f));
|
||||
poses.Publish(replacement, Array.Empty<Matrix4x4>());
|
||||
|
||||
queue.Drain();
|
||||
|
||||
Assert.Empty(sink.Calls);
|
||||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnimationDoneReuseDuringCapture_DropsOldIncarnationHooks()
|
||||
{
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
poses.Publish(Entity(9u, Vector3.Zero), Array.Empty<Matrix4x4>());
|
||||
var router = new AnimationHookRouter();
|
||||
var sink = new RecordingSink();
|
||||
router.Register(sink);
|
||||
var queue = new AnimationHookFrameQueue(router, poses);
|
||||
var sequencer = new AnimationSequencer(
|
||||
new Setup(),
|
||||
new MotionTable(),
|
||||
new NullAnimationLoader());
|
||||
sequencer.Manager.AddToQueue(0x10000001u, ticks: 1u);
|
||||
sequencer.MotionDoneTarget = (_, success) =>
|
||||
{
|
||||
Assert.True(success);
|
||||
Assert.True(poses.Remove(9u));
|
||||
poses.Publish(
|
||||
Entity(9u, new Vector3(40f, 50f, 60f)),
|
||||
Array.Empty<Matrix4x4>());
|
||||
};
|
||||
|
||||
queue.Capture(
|
||||
9u,
|
||||
sequencer,
|
||||
new AnimationHook[]
|
||||
{
|
||||
new AnimationDoneHook(),
|
||||
new SoundHook(),
|
||||
});
|
||||
queue.Drain();
|
||||
|
||||
Assert.Empty(sink.Calls);
|
||||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleAnimationDoneHooks_StopAdvancingDisplacedSequencerAfterReuse()
|
||||
{
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
poses.Publish(Entity(9u, Vector3.Zero), Array.Empty<Matrix4x4>());
|
||||
var queue = new AnimationHookFrameQueue(
|
||||
new AnimationHookRouter(),
|
||||
poses);
|
||||
var sequencer = new AnimationSequencer(
|
||||
new Setup(),
|
||||
new MotionTable(),
|
||||
new NullAnimationLoader());
|
||||
sequencer.Manager.AddToQueue(0x10000001u, ticks: 1u);
|
||||
sequencer.Manager.AddToQueue(0x10000002u, ticks: 1u);
|
||||
int completions = 0;
|
||||
sequencer.MotionDoneTarget = (_, success) =>
|
||||
{
|
||||
Assert.True(success);
|
||||
completions++;
|
||||
Assert.True(poses.Remove(9u));
|
||||
poses.Publish(
|
||||
Entity(9u, new Vector3(40f, 50f, 60f)),
|
||||
Array.Empty<Matrix4x4>());
|
||||
};
|
||||
|
||||
queue.Capture(
|
||||
9u,
|
||||
sequencer,
|
||||
new AnimationHook[]
|
||||
{
|
||||
new AnimationDoneHook(),
|
||||
new AnimationDoneHook(),
|
||||
});
|
||||
|
||||
Assert.Equal(1, completions);
|
||||
Assert.Single(sequencer.Manager.PendingAnimations);
|
||||
queue.Drain();
|
||||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HookSinkReuseDuringDrain_StopsRemainingOldIncarnationHooks()
|
||||
{
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
poses.Publish(Entity(9u, Vector3.Zero), Array.Empty<Matrix4x4>());
|
||||
var router = new AnimationHookRouter();
|
||||
bool replaced = false;
|
||||
var sink = new CallbackSink(() =>
|
||||
{
|
||||
if (replaced)
|
||||
return;
|
||||
replaced = true;
|
||||
Assert.True(poses.Remove(9u));
|
||||
poses.Publish(
|
||||
Entity(9u, new Vector3(40f, 50f, 60f)),
|
||||
Array.Empty<Matrix4x4>());
|
||||
});
|
||||
router.Register(sink);
|
||||
var queue = new AnimationHookFrameQueue(router, poses);
|
||||
var sequencer = new AnimationSequencer(
|
||||
new Setup(),
|
||||
new MotionTable(),
|
||||
new NullAnimationLoader());
|
||||
|
||||
queue.Capture(
|
||||
9u,
|
||||
sequencer,
|
||||
new AnimationHook[] { new SoundHook(), new SoundHook() });
|
||||
queue.Drain();
|
||||
|
||||
Assert.Equal(1, sink.Calls);
|
||||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnimationHookQueue_CompletesMotionStateAtCaptureEvenWhenPoseWasRemoved()
|
||||
{
|
||||
|
|
@ -217,6 +354,17 @@ public sealed class EntityEffectPoseRegistryTests
|
|||
Calls.Add((entityId, entityWorldPosition));
|
||||
}
|
||||
|
||||
private sealed class CallbackSink(Action callback) : IAnimationHookSink
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
{
|
||||
Calls++;
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public Animation? LoadAnimation(uint animationId) => null;
|
||||
|
|
|
|||
|
|
@ -260,6 +260,193 @@ public sealed class EntityScriptActivatorTests
|
|||
Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatStaticAnimationOwner_RegistersAndUnregistersExactlyOnce()
|
||||
{
|
||||
var p = BuildPipeline();
|
||||
int registerCount = 0;
|
||||
int unregisterCount = 0;
|
||||
WorldEntity? registeredEntity = null;
|
||||
ScriptActivationInfo info = new(
|
||||
ScriptId: 0,
|
||||
PartTransforms: Array.Empty<Matrix4x4>(),
|
||||
DefaultAnimationId: 0x03000001u,
|
||||
UsesStaticAnimationWorkset: true);
|
||||
var activator = new EntityScriptActivator(
|
||||
p.Runner,
|
||||
p.Sink,
|
||||
p.Poses,
|
||||
_ => info,
|
||||
registerDatStaticAnimationOwner: (entity, actual) =>
|
||||
{
|
||||
registerCount++;
|
||||
registeredEntity = entity;
|
||||
Assert.Same(info, actual);
|
||||
},
|
||||
unregisterDatStaticAnimationOwner: ownerId =>
|
||||
{
|
||||
unregisterCount++;
|
||||
Assert.Equal(0x40A9B420u, ownerId);
|
||||
});
|
||||
WorldEntity entity = MakeDatStatic(0x40A9B420u, Vector3.One);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
activator.OnCreate(entity);
|
||||
activator.OnRemove(entity);
|
||||
activator.OnRemove(entity);
|
||||
|
||||
Assert.Equal(1, registerCount);
|
||||
Assert.Equal(1, unregisterCount);
|
||||
Assert.Same(entity, registeredEntity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveNonStaticEntity_DoesNotEnterStaticAnimationWorkset()
|
||||
{
|
||||
var p = BuildPipeline();
|
||||
int registerCount = 0;
|
||||
var activator = new EntityScriptActivator(
|
||||
p.Runner,
|
||||
p.Sink,
|
||||
p.Poses,
|
||||
_ => new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: Array.Empty<Matrix4x4>(),
|
||||
DefaultAnimationId: 0x03000001u),
|
||||
registerDatStaticAnimationOwner: (_, _) => registerCount++);
|
||||
|
||||
activator.OnCreate(MakeEntity(0x70000420u, Vector3.Zero));
|
||||
|
||||
Assert.Equal(0, registerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LivePhysicsStaticEntity_EntersAndLeavesStaticAnimationWorkset()
|
||||
{
|
||||
var p = BuildPipeline();
|
||||
int registerCount = 0;
|
||||
int unregisterCount = 0;
|
||||
var activator = new EntityScriptActivator(
|
||||
p.Runner,
|
||||
p.Sink,
|
||||
p.Poses,
|
||||
_ => new ScriptActivationInfo(
|
||||
ScriptId: 0,
|
||||
PartTransforms: Array.Empty<Matrix4x4>(),
|
||||
DefaultAnimationId: 0x03000001u,
|
||||
UsesStaticAnimationWorkset: true),
|
||||
registerDatStaticAnimationOwner: (_, _) => registerCount++,
|
||||
unregisterDatStaticAnimationOwner: _ => unregisterCount++);
|
||||
WorldEntity entity = MakeEntity(0x70000421u, Vector3.Zero);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
activator.OnRemove(entity);
|
||||
|
||||
Assert.Equal(1, registerCount);
|
||||
Assert.Equal(1, unregisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatStaticActivation_RetriesOnlyIncompleteAcquisitionStages()
|
||||
{
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((10.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
WorldEntity entity = MakeDatStatic(0x40A9B421u, Vector3.Zero);
|
||||
var setup = new DatReaderWriter.DBObjs.Setup();
|
||||
ScriptActivationInfo info = new(
|
||||
ScriptId: 0xAAu,
|
||||
PartTransforms: Array.Empty<Matrix4x4>(),
|
||||
EffectProfile: EntityEffectProfile.CreateDatStatic(setup),
|
||||
Setup: setup,
|
||||
DefaultAnimationId: 0x03000001u,
|
||||
UsesStaticAnimationWorkset: true);
|
||||
int effectAttempts = 0;
|
||||
int animationAttempts = 0;
|
||||
var activator = new EntityScriptActivator(
|
||||
p.Runner,
|
||||
p.Sink,
|
||||
p.Poses,
|
||||
_ => info,
|
||||
registerDatStaticEffectOwner: (_, _, _) =>
|
||||
{
|
||||
effectAttempts++;
|
||||
if (effectAttempts == 1)
|
||||
throw new InvalidOperationException("effect acquire fault");
|
||||
},
|
||||
registerDatStaticAnimationOwner: (_, _) =>
|
||||
{
|
||||
animationAttempts++;
|
||||
if (animationAttempts == 1)
|
||||
throw new InvalidOperationException("animation acquire fault");
|
||||
});
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => activator.OnCreate(entity));
|
||||
Assert.Equal(1, effectAttempts);
|
||||
Assert.Equal(0, animationAttempts);
|
||||
Assert.Equal(0, p.Runner.ActiveOwnerCount);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => activator.OnCreate(entity));
|
||||
Assert.Equal(2, effectAttempts);
|
||||
Assert.Equal(1, animationAttempts);
|
||||
Assert.Equal(0, p.Runner.ActiveOwnerCount);
|
||||
|
||||
activator.OnCreate(entity);
|
||||
activator.OnCreate(entity);
|
||||
Assert.Equal(2, effectAttempts);
|
||||
Assert.Equal(2, animationAttempts);
|
||||
Assert.Equal(1, p.Runner.ActiveOwnerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatStaticRemoval_RetriesOnlyIncompleteReleaseStages()
|
||||
{
|
||||
var p = BuildPipeline();
|
||||
WorldEntity entity = MakeDatStatic(0x40A9B422u, Vector3.Zero);
|
||||
var setup = new DatReaderWriter.DBObjs.Setup();
|
||||
ScriptActivationInfo info = new(
|
||||
ScriptId: 0,
|
||||
PartTransforms: Array.Empty<Matrix4x4>(),
|
||||
EffectProfile: EntityEffectProfile.CreateDatStatic(setup),
|
||||
Setup: setup,
|
||||
DefaultAnimationId: 0x03000001u,
|
||||
UsesStaticAnimationWorkset: true);
|
||||
int animationReleaseAttempts = 0;
|
||||
int effectReleaseAttempts = 0;
|
||||
var activator = new EntityScriptActivator(
|
||||
p.Runner,
|
||||
p.Sink,
|
||||
p.Poses,
|
||||
_ => info,
|
||||
registerDatStaticEffectOwner: (_, _, _) => { },
|
||||
unregisterDatStaticEffectOwner: _ =>
|
||||
{
|
||||
effectReleaseAttempts++;
|
||||
if (effectReleaseAttempts == 1)
|
||||
throw new InvalidOperationException("effect release fault");
|
||||
},
|
||||
registerDatStaticAnimationOwner: (_, _) => { },
|
||||
unregisterDatStaticAnimationOwner: _ =>
|
||||
{
|
||||
animationReleaseAttempts++;
|
||||
if (animationReleaseAttempts == 1)
|
||||
throw new InvalidOperationException("animation release fault");
|
||||
});
|
||||
activator.OnCreate(entity);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => activator.OnRemove(entity));
|
||||
Assert.Equal(1, animationReleaseAttempts);
|
||||
Assert.Equal(0, effectReleaseAttempts);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => activator.OnRemove(entity));
|
||||
Assert.Equal(2, animationReleaseAttempts);
|
||||
Assert.Equal(1, effectReleaseAttempts);
|
||||
|
||||
activator.OnRemove(entity);
|
||||
activator.OnRemove(entity);
|
||||
Assert.Equal(2, animationReleaseAttempts);
|
||||
Assert.Equal(2, effectReleaseAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollidingDatEntityIds_FailFastAtAllocatorBoundary()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue