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,123 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class LocalPlayerProjectionControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Project_PreservesCompleteAuthoritativeBodyQuaternion()
|
||||
{
|
||||
const uint cellId = 0x01010001u;
|
||||
Quaternion complete = Quaternion.Normalize(
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.7f)
|
||||
* Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.4f));
|
||||
var movement = new PlayerMovementController(new PhysicsEngine());
|
||||
movement.SetPosition(Vector3.Zero, cellId, Vector3.Zero);
|
||||
movement.SetBodyOrientation(complete);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 7u,
|
||||
ServerGuid = 0x50000001u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
var projection = new LocalPlayerProjectionController(
|
||||
() => entity,
|
||||
() => 1,
|
||||
() => 1,
|
||||
(_, _) => { },
|
||||
(_, _) => { },
|
||||
_ => true,
|
||||
_ => { });
|
||||
|
||||
projection.Project(
|
||||
movement,
|
||||
movement.CapturePresentationResult(),
|
||||
hidden: false);
|
||||
|
||||
Assert.InRange(
|
||||
MathF.Abs(Quaternion.Dot(
|
||||
complete,
|
||||
Quaternion.Normalize(entity.Rotation))),
|
||||
0.99999f,
|
||||
1.00001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_AlreadyPendingChangedPose_DoesNotResurrectShadow()
|
||||
{
|
||||
const uint cellId = 0x02020001u;
|
||||
var movement = new PlayerMovementController(new PhysicsEngine());
|
||||
movement.SetPosition(new Vector3(12f, 8f, 3f), cellId, Vector3.Zero);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 8u,
|
||||
ServerGuid = 0x50000001u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
var order = new List<string>();
|
||||
var projection = new LocalPlayerProjectionController(
|
||||
() => entity,
|
||||
() => 2,
|
||||
() => 2,
|
||||
(_, _) => order.Add("shadow"),
|
||||
(_, _) => order.Add("rebucket"),
|
||||
_ => false,
|
||||
_ => order.Add("suspend"));
|
||||
|
||||
projection.Project(
|
||||
movement,
|
||||
movement.CapturePresentationResult(),
|
||||
hidden: false);
|
||||
|
||||
Assert.Equal(["rebucket", "suspend"], order);
|
||||
Assert.Equal(movement.Position, entity.Position);
|
||||
Assert.Equal(cellId, entity.ParentCellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_PendingToLoaded_RebucketsBeforeStationaryShadowRestore()
|
||||
{
|
||||
const uint cellId = 0x02020001u;
|
||||
var movement = new PlayerMovementController(new PhysicsEngine());
|
||||
movement.SetPosition(new Vector3(12f, 8f, 3f), cellId, Vector3.Zero);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 9u,
|
||||
ServerGuid = 0x50000001u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = movement.Position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
bool visible = false;
|
||||
var order = new List<string>();
|
||||
var projection = new LocalPlayerProjectionController(
|
||||
() => entity,
|
||||
() => 2,
|
||||
() => 2,
|
||||
(_, _) => order.Add("shadow"),
|
||||
(_, _) =>
|
||||
{
|
||||
order.Add("rebucket");
|
||||
visible = true;
|
||||
},
|
||||
_ => visible,
|
||||
_ => order.Add("suspend"));
|
||||
|
||||
projection.Project(
|
||||
movement,
|
||||
movement.CapturePresentationResult(),
|
||||
hidden: false);
|
||||
|
||||
Assert.Equal(["rebucket", "shadow"], order);
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,12 @@ public sealed class PlayerMouseLookMovementTests
|
|||
|
||||
float yawBefore = controller.Yaw;
|
||||
controller.SubmitMouseTurnAdjustment(-0.4f, new MovementInput());
|
||||
MovementResult turning = controller.Update(1f / 60f, new MovementInput());
|
||||
// Rotation is authored/applied on an admitted retail object quantum,
|
||||
// not directly by the mouse sample. Stay below the outbound mouse
|
||||
// cadence while crossing the strict physics minimum.
|
||||
MovementResult turning = controller.Update(
|
||||
PhysicsBody.MinQuantum + 0.001f,
|
||||
new MovementInput());
|
||||
|
||||
Assert.Equal(MotionCommand.TurnRight, turning.TurnCommand);
|
||||
Assert.Equal(0.8f, turning.TurnSpeed);
|
||||
|
|
|
|||
|
|
@ -50,7 +50,10 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
|||
},
|
||||
() => calls.Add("spatial"));
|
||||
|
||||
live.Tick(1f / 60f);
|
||||
// CPhysicsObj::update_object admits manager time only after the strict
|
||||
// minimum quantum. Use one admitted object tick; the test's concern is
|
||||
// the network barrier, not render-fragment accumulation.
|
||||
live.Tick(PhysicsBody.MaxQuantum);
|
||||
Assert.True(local.TryGetPresentationAfterNetwork(out var presentation));
|
||||
|
||||
Assert.Equal(
|
||||
|
|
@ -150,6 +153,119 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
|||
Assert.Equal(controller.Position, positionSeenByTargetManager);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrozenObject_IsPresentedWithoutPhysicsInputOrOutboundState()
|
||||
{
|
||||
PlayerMovementController controller = CreateController();
|
||||
Vector3 initial = controller.Position;
|
||||
int inputCaptures = 0;
|
||||
int projections = 0;
|
||||
int preNetwork = 0;
|
||||
int postNetwork = 0;
|
||||
var local = new RetailLocalPlayerFrameController(
|
||||
canPresentPlayer: () => true,
|
||||
getController: () => controller,
|
||||
captureInput: () =>
|
||||
{
|
||||
inputCaptures++;
|
||||
return new MovementInput(Forward: true);
|
||||
},
|
||||
resolveLocalEntityId: () => 7u,
|
||||
handleTargeting: () => throw new InvalidOperationException(
|
||||
"Frozen object advanced its manager tail."),
|
||||
isHidden: () => false,
|
||||
project: (_, _, _) => projections++,
|
||||
sendPreNetwork: (_, _, _) => preNetwork++,
|
||||
sendPostNetwork: (_, _) => postNetwork++,
|
||||
objectClockDisposition: () =>
|
||||
RetailObjectClockDisposition.Suspend);
|
||||
|
||||
local.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum);
|
||||
local.RunPostNetworkCommandPhase();
|
||||
Assert.True(local.TryGetPresentationAfterNetwork(out var presentation));
|
||||
|
||||
Assert.Equal(initial, controller.Position);
|
||||
Assert.Equal(PhysicsBody.MaxQuantum, controller.SimTimeSeconds);
|
||||
Assert.Equal(0, inputCaptures);
|
||||
Assert.Equal(2, projections);
|
||||
Assert.Equal(0, preNetwork);
|
||||
Assert.Equal(0, postNetwork);
|
||||
Assert.False(presentation.AdvancedBeforeNetwork);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HiddenPoseIsDirtyOnlyWhenACompleteObjectQuantumRuns()
|
||||
{
|
||||
PlayerMovementController controller = CreateController();
|
||||
var local = new RetailLocalPlayerFrameController(
|
||||
canPresentPlayer: () => true,
|
||||
getController: () => controller,
|
||||
captureInput: () => new MovementInput(),
|
||||
resolveLocalEntityId: () => 7u,
|
||||
handleTargeting: () => { },
|
||||
isHidden: () => true,
|
||||
project: (_, _, _) => { },
|
||||
sendPreNetwork: (_, _, _) => { },
|
||||
sendPostNetwork: (_, _) => { });
|
||||
|
||||
local.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum);
|
||||
Assert.True(local.HiddenPartPoseDirty);
|
||||
|
||||
local.AdvanceBeforeNetwork(PhysicsBody.MinQuantum / 2f);
|
||||
Assert.False(local.HiddenPartPoseDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidElapsed_PublishesSnapshotWithoutInputOrNetworkCallbacks()
|
||||
{
|
||||
PlayerMovementController controller = CreateController();
|
||||
int inputCaptures = 0;
|
||||
int targeting = 0;
|
||||
int projections = 0;
|
||||
int preNetwork = 0;
|
||||
int postNetwork = 0;
|
||||
var local = new RetailLocalPlayerFrameController(
|
||||
canPresentPlayer: () => true,
|
||||
getController: () => controller,
|
||||
captureInput: () =>
|
||||
{
|
||||
inputCaptures++;
|
||||
return new MovementInput(Forward: true);
|
||||
},
|
||||
resolveLocalEntityId: () => 7u,
|
||||
handleTargeting: () => targeting++,
|
||||
isHidden: () => false,
|
||||
project: (_, _, _) => projections++,
|
||||
sendPreNetwork: (_, _, _) => preNetwork++,
|
||||
sendPostNetwork: (_, _) => postNetwork++);
|
||||
float initialTime = controller.SimTimeSeconds;
|
||||
Vector3 initialPosition = controller.Position;
|
||||
|
||||
foreach (float elapsed in new[]
|
||||
{
|
||||
float.NaN,
|
||||
float.PositiveInfinity,
|
||||
float.NegativeInfinity,
|
||||
-1f,
|
||||
0f,
|
||||
})
|
||||
{
|
||||
local.AdvanceBeforeNetwork(elapsed);
|
||||
local.RunPostNetworkCommandPhase();
|
||||
Assert.True(local.TryGetPresentationAfterNetwork(out var frame));
|
||||
Assert.False(frame.AdvancedBeforeNetwork);
|
||||
Assert.False(local.HiddenPartPoseDirty);
|
||||
}
|
||||
|
||||
Assert.Equal(initialTime, controller.SimTimeSeconds);
|
||||
Assert.Equal(initialPosition, controller.Position);
|
||||
Assert.Equal(0, inputCaptures);
|
||||
Assert.Equal(0, targeting);
|
||||
Assert.Equal(0, preNetwork);
|
||||
Assert.Equal(0, postNetwork);
|
||||
Assert.Equal(10, projections);
|
||||
}
|
||||
|
||||
private static PlayerMovementController CreateController()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
|
|
@ -167,8 +283,10 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
|||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
|
||||
var controller = new PlayerMovementController(engine);
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
var controller = new PlayerMovementController(engine, clock);
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
|
||||
Assert.True(clock.IsActive);
|
||||
return controller;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,246 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Physics;
|
||||
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.Types;
|
||||
|
||||
namespace AcDream.App.Tests.Physics;
|
||||
|
||||
public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
|
||||
{
|
||||
private const uint Guid = 0x70000071u;
|
||||
private const uint SourceCell = 0xA9B40039u;
|
||||
private const uint DestinationCell = 0xAAB40001u;
|
||||
|
||||
[Fact]
|
||||
public void CompleteRootFrame_CrossesTransitionAndCommitsCanonicalCell()
|
||||
{
|
||||
PhysicsEngine physics = BuildBoundaryEngine();
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu));
|
||||
spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu));
|
||||
var live = new LiveEntityRuntime(
|
||||
spatial,
|
||||
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
||||
LiveEntityRecord record = live.RegisterLiveEntity(Spawn()).Record!;
|
||||
WorldEntity entity = live.MaterializeLiveEntity(
|
||||
Guid,
|
||||
SourceCell,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = Guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(191f, 10f, 50f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = SourceCell,
|
||||
})!;
|
||||
|
||||
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion
|
||||
{
|
||||
CellId = SourceCell,
|
||||
};
|
||||
remote.Body.TransientState = TransientStateFlags.Active
|
||||
| TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable;
|
||||
remote.Body.SnapToCell(
|
||||
SourceCell,
|
||||
entity.Position,
|
||||
entity.Position);
|
||||
live.SetRemoteMotionRuntime(Guid, remote);
|
||||
Assert.True(live.ClearRemoteMotionRuntime(Guid));
|
||||
ulong epoch = record.ObjectClockEpoch;
|
||||
|
||||
var updater = new LiveEntityOrdinaryPhysicsUpdater(
|
||||
physics,
|
||||
(_, _) => (0.48f, 1.835f));
|
||||
var rootFrame = new Frame
|
||||
{
|
||||
Origin = new Vector3(2f, 0f, 0f),
|
||||
Orientation = Quaternion.Identity,
|
||||
};
|
||||
|
||||
Assert.True(updater.Tick(
|
||||
live,
|
||||
record,
|
||||
entity,
|
||||
rootFrame,
|
||||
objectScale: 1f,
|
||||
quantum: 0.1f,
|
||||
liveCenterX: 0xA9,
|
||||
liveCenterY: 0xB4,
|
||||
objectClockEpoch: epoch,
|
||||
sequencer: null,
|
||||
captureAnimationHooks: (_, _) => { }));
|
||||
|
||||
Assert.Equal(DestinationCell, record.FullCellId);
|
||||
Assert.Equal(DestinationCell, entity.ParentCellId);
|
||||
Assert.Equal(DestinationCell, record.PhysicsBody!.CellPosition.ObjCellId);
|
||||
Assert.True(entity.Position.X > 192f);
|
||||
Assert.Equal(entity.Position, record.PhysicsBody.Position);
|
||||
Assert.Equal(epoch, record.ObjectClockEpoch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HookDeletion_PreventsTransitionAndStaleEntityCommit()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu));
|
||||
var live = new LiveEntityRuntime(
|
||||
spatial,
|
||||
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
||||
LiveEntityRecord record = live.RegisterLiveEntity(Spawn()).Record!;
|
||||
WorldEntity entity = live.MaterializeLiveEntity(
|
||||
Guid,
|
||||
SourceCell,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = Guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(191f, 10f, 50f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = SourceCell,
|
||||
})!;
|
||||
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion
|
||||
{
|
||||
CellId = SourceCell,
|
||||
};
|
||||
remote.Body.TransientState = TransientStateFlags.Active
|
||||
| TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable;
|
||||
remote.Body.SnapToCell(SourceCell, entity.Position, entity.Position);
|
||||
live.SetRemoteMotionRuntime(Guid, remote);
|
||||
live.ClearRemoteMotionRuntime(Guid);
|
||||
var setup = new DatReaderWriter.DBObjs.Setup();
|
||||
var sequencer = new AnimationSequencer(
|
||||
setup,
|
||||
new DatReaderWriter.DBObjs.MotionTable(),
|
||||
new NullAnimationLoader());
|
||||
Vector3 retainedEntityPosition = entity.Position;
|
||||
var updater = new LiveEntityOrdinaryPhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
(_, _) => (0.48f, 1.835f));
|
||||
|
||||
Assert.False(updater.Tick(
|
||||
live,
|
||||
record,
|
||||
entity,
|
||||
new Frame
|
||||
{
|
||||
Origin = Vector3.UnitX,
|
||||
Orientation = Quaternion.Identity,
|
||||
},
|
||||
1f,
|
||||
0.1f,
|
||||
0xA9,
|
||||
0xB4,
|
||||
record.ObjectClockEpoch,
|
||||
sequencer,
|
||||
(_, _) => live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, record.Generation),
|
||||
isLocalPlayer: false)));
|
||||
|
||||
Assert.False(live.TryGetRecord(Guid, out _));
|
||||
Assert.Equal(retainedEntityPosition, entity.Position);
|
||||
}
|
||||
|
||||
private static PhysicsEngine BuildBoundaryEngine()
|
||||
{
|
||||
static TerrainSurface FlatTerrain()
|
||||
{
|
||||
var heights = new byte[81];
|
||||
var table = new float[256];
|
||||
Array.Fill(table, 50f);
|
||||
return new TerrainSurface(heights, table);
|
||||
}
|
||||
|
||||
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
engine.AddLandblock(
|
||||
0xA9B4FFFFu,
|
||||
FlatTerrain(),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
0f,
|
||||
0f);
|
||||
engine.AddLandblock(
|
||||
0xAAB4FFFFu,
|
||||
FlatTerrain(),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
192f,
|
||||
0f);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static LoadedLandblock EmptyLandblock(uint landblockId) =>
|
||||
new(
|
||||
landblockId,
|
||||
new DatReaderWriter.DBObjs.LandBlock(),
|
||||
Array.Empty<WorldEntity>());
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn()
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
SourceCell,
|
||||
191f,
|
||||
10f,
|
||||
50f,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f);
|
||||
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: 0,
|
||||
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,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"ordinary physics fixture",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
PhysicsState: 0,
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,13 +23,12 @@ public sealed class PlayerOutboundPositionTests
|
|||
0xA9B20021u,
|
||||
new Vector3(116.07f, 22.56f, 83.76f));
|
||||
|
||||
Assert.True(controller.TryGetOutboundPosition(
|
||||
Quaternion.Identity,
|
||||
out uint cellId,
|
||||
out Vector3 localOrigin));
|
||||
Assert.Equal(0xA9B20021u, cellId);
|
||||
Assert.Equal(new Vector3(116.07f, 22.56f, 83.76f), localOrigin);
|
||||
Assert.NotEqual(controller.Position, localOrigin);
|
||||
Assert.True(controller.TryGetOutboundPosition(out Position outbound));
|
||||
Assert.Equal(0xA9B20021u, outbound.ObjCellId);
|
||||
Assert.Equal(
|
||||
new Vector3(116.07f, 22.56f, 83.76f),
|
||||
outbound.Frame.Origin);
|
||||
Assert.NotEqual(controller.Position, outbound.Frame.Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -37,10 +36,30 @@ public sealed class PlayerOutboundPositionTests
|
|||
{
|
||||
var controller = new PlayerMovementController(new PhysicsEngine());
|
||||
|
||||
Assert.False(controller.TryGetOutboundPosition(
|
||||
Quaternion.Identity,
|
||||
out _,
|
||||
out _));
|
||||
Assert.False(controller.TryGetOutboundPosition(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutboundPosition_PreservesCompleteAuthoritativeQuaternion()
|
||||
{
|
||||
var controller = new PlayerMovementController(new PhysicsEngine());
|
||||
controller.SetPosition(
|
||||
new Vector3(116.07f, 214.56f, 83.76f),
|
||||
0xA9B20021u,
|
||||
new Vector3(116.07f, 22.56f, 83.76f));
|
||||
Quaternion complete = Quaternion.Normalize(
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.7f)
|
||||
* Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.4f));
|
||||
controller.SetBodyOrientation(complete);
|
||||
|
||||
Assert.True(controller.TryGetOutboundPosition(out Position outbound));
|
||||
|
||||
Assert.InRange(
|
||||
MathF.Abs(Quaternion.Dot(
|
||||
complete,
|
||||
Quaternion.Normalize(outbound.Frame.Orientation))),
|
||||
0.99999f,
|
||||
1.00001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -77,19 +77,19 @@ public sealed class ProjectileControllerTests
|
|||
| PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.IgnoreCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 1.1, 1, 1));
|
||||
record, record.FinalPhysicsState, 1.1, 1, 1));
|
||||
Vector3 hiddenPosition = entity.Position;
|
||||
fixture.Controller.Tick(5.0, 1, 1, playerWorldPosition: null);
|
||||
|
||||
Assert.Equal(hiddenPosition, entity.Position);
|
||||
Assert.True(body.InWorld);
|
||||
Assert.True(body.IsActive);
|
||||
Assert.Equal(5.0, body.LastUpdateTime, 8);
|
||||
Assert.Equal(0.0, record.ObjectClock.PendingSeconds, 8);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 5.0, 1, 1));
|
||||
record, record.FinalPhysicsState, 5.0, 1, 1));
|
||||
fixture.Controller.Tick(5.1, 1, 1, playerWorldPosition: null);
|
||||
|
||||
Assert.True(entity.Position.X > hiddenPosition.X);
|
||||
|
|
@ -116,7 +116,7 @@ public sealed class ProjectileControllerTests
|
|||
| PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.IgnoreCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 1.1, 1, 1));
|
||||
record, record.FinalPhysicsState, 1.1, 1, 1));
|
||||
remote.Interp.Enqueue(
|
||||
entity.Position + Vector3.UnitX,
|
||||
heading: 0f,
|
||||
|
|
@ -148,14 +148,27 @@ public sealed class ProjectileControllerTests
|
|||
var fixture = new Fixture(loadInitialLandblock: true);
|
||||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
WorldEntity entity = record.WorldEntity!;
|
||||
fixture.Engine.ShadowObjects.Register(
|
||||
entity.Id,
|
||||
entity.SourceGfxObjOrSetupId,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
radius: 0.5f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0x01010000u,
|
||||
state: (uint)MissileState,
|
||||
seedCellId: CellA,
|
||||
isStatic: false);
|
||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0));
|
||||
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
Guid,
|
||||
worldPosition: new Vector3(202f, 10f, 5f),
|
||||
record,
|
||||
worldPosition: new Vector3(10f, 202f, 5f),
|
||||
cellLocalPosition: new Vector3(10f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: new Vector3(1f, 2f, 3f),
|
||||
fullCellId: CellB,
|
||||
currentTime: 2.1,
|
||||
liveCenterX: 1,
|
||||
|
|
@ -177,24 +190,36 @@ public sealed class ProjectileControllerTests
|
|||
Assert.False(runtime.Body.IsActive);
|
||||
|
||||
fixture.Spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
||||
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.True(runtime.Body.InWorld);
|
||||
Assert.True(runtime.Body.IsActive);
|
||||
Assert.Equal(pendingPosition, entity.Position);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
ShadowEntry restoredShadow = Assert.Single(
|
||||
fixture.Engine.ShadowObjects.AllEntriesForDebug());
|
||||
Assert.Equal(entity.Id, restoredShadow.EntityId);
|
||||
Assert.Equal(pendingPosition, restoredShadow.Position);
|
||||
|
||||
fixture.Controller.Tick(
|
||||
2.5,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1,
|
||||
playerWorldPosition: null);
|
||||
playerWorldPosition: pendingPosition);
|
||||
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.Same(entity, record.WorldEntity);
|
||||
Assert.Same(runtime, record.ProjectileRuntime);
|
||||
Assert.Equal(1, fixture.Resources.Registers);
|
||||
Assert.Equal(pendingPosition, entity.Position);
|
||||
Vector3 firstReentryTick = entity.Position;
|
||||
Assert.True(firstReentryTick.X > pendingPosition.X);
|
||||
|
||||
fixture.Controller.Tick(
|
||||
2.6,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1,
|
||||
playerWorldPosition: null);
|
||||
Assert.True(entity.Position.X > pendingPosition.X);
|
||||
playerWorldPosition: pendingPosition);
|
||||
Assert.True(entity.Position.X > firstReentryTick.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -229,7 +254,15 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
fixture.Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
Assert.Single(fixture.Live.SpatialProjectileRuntimes);
|
||||
fixture.Controller.Tick(1.1, 1, 1, playerWorldPosition: null);
|
||||
Assert.True(record.PhysicsBody.InWorld);
|
||||
Assert.True(record.PhysicsBody.IsActive);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
|
||||
fixture.Controller.Tick(
|
||||
1.1,
|
||||
1,
|
||||
1,
|
||||
playerWorldPosition: record.WorldEntity!.Position);
|
||||
|
||||
Assert.True(record.PhysicsBody.InWorld);
|
||||
Assert.True(record.PhysicsBody.IsActive);
|
||||
|
|
@ -304,9 +337,10 @@ public sealed class ProjectileControllerTests
|
|||
playerWorldPosition: null);
|
||||
|
||||
Assert.Equal(0xAAB40001u, record.FullCellId);
|
||||
Assert.Equal(0xAAB40001u, record.PhysicsBody!.CellPosition.ObjCellId);
|
||||
Assert.False(record.IsSpatiallyVisible);
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
Assert.False(record.PhysicsBody!.InWorld);
|
||||
Assert.False(record.PhysicsBody.InWorld);
|
||||
Assert.False(record.PhysicsBody.IsActive);
|
||||
Assert.Empty(fixture.Engine.ShadowObjects.GetObjectsInCell(startCell));
|
||||
Assert.Empty(fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u));
|
||||
|
|
@ -315,13 +349,14 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 0.06, 0xA9, 0xB4));
|
||||
record, record.FinalPhysicsState, 0.06, 0xA9, 0xB4));
|
||||
fixture.Spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu));
|
||||
fixture.Controller.Tick(0.07, 0xA9, 0xB4, playerWorldPosition: null);
|
||||
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.Equal(0xAAB40001u, record.PhysicsBody.CellPosition.ObjCellId);
|
||||
Assert.True(record.PhysicsBody.InWorld);
|
||||
Assert.False(record.PhysicsBody.IsActive);
|
||||
Assert.True(record.PhysicsBody.IsActive);
|
||||
Assert.Contains(
|
||||
fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u),
|
||||
entry => entry.EntityId == entity.Id);
|
||||
|
|
@ -336,7 +371,7 @@ public sealed class ProjectileControllerTests
|
|||
PhysicsBody body = record.PhysicsBody!;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
Guid,
|
||||
record,
|
||||
new Vector3(7f, 8f, 9f),
|
||||
new Vector3(0f, 0f, 2f),
|
||||
currentTime: 4.5));
|
||||
|
|
@ -344,11 +379,13 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Equal(new Vector3(0f, 0f, 2f), body.Omega);
|
||||
|
||||
var correction = new Vector3(30f, 31f, 32f);
|
||||
var correctedVelocity = new Vector3(3f, 4f, 5f);
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
Guid,
|
||||
record,
|
||||
correction,
|
||||
new Vector3(30f, 31f, 32f),
|
||||
Quaternion.Identity,
|
||||
correctedVelocity,
|
||||
CellA,
|
||||
currentTime: 5.0,
|
||||
liveCenterX: 1,
|
||||
|
|
@ -357,7 +394,95 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Same(body, record.PhysicsBody);
|
||||
Assert.Equal(correction, body.Position);
|
||||
Assert.Equal(correction, record.WorldEntity!.Position);
|
||||
Assert.Equal(new Vector3(7f, 8f, 9f), body.Velocity);
|
||||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
|
||||
// InboundPhysicsStateController normalizes an absent PositionPack
|
||||
// velocity to zero before this contract is called.
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correction,
|
||||
new Vector3(30f, 31f, 32f),
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
CellA,
|
||||
currentTime: 5.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.Equal(Vector3.Zero, body.Velocity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AuthoritativeMutation.Vector)]
|
||||
[InlineData(AuthoritativeMutation.Position)]
|
||||
[InlineData(AuthoritativeMutation.State)]
|
||||
public void AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction(
|
||||
AuthoritativeMutation mutation)
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1));
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
Assert.True(fixture.Controller.TryBeginQuantum(
|
||||
record,
|
||||
quantum: 0.05f,
|
||||
out ProjectileController.QuantumStep step));
|
||||
|
||||
Vector3 correctedPosition = new(30f, 31f, 32f);
|
||||
Vector3 correctedVelocity = new(7f, 8f, 9f);
|
||||
Vector3 correctedOmega = new(0f, 0f, 2f);
|
||||
PhysicsStateFlags correctedState = MissileState | PhysicsStateFlags.Gravity;
|
||||
switch (mutation)
|
||||
{
|
||||
case AuthoritativeMutation.Vector:
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
record,
|
||||
correctedVelocity,
|
||||
correctedOmega,
|
||||
currentTime: 1.02));
|
||||
break;
|
||||
|
||||
case AuthoritativeMutation.Position:
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correctedPosition,
|
||||
correctedPosition,
|
||||
Quaternion.Identity,
|
||||
correctedVelocity,
|
||||
CellA,
|
||||
currentTime: 1.02,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
break;
|
||||
|
||||
case AuthoritativeMutation.State:
|
||||
record.FinalPhysicsState = correctedState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
record,
|
||||
correctedState,
|
||||
currentTime: 1.02,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(mutation));
|
||||
}
|
||||
|
||||
Assert.False(fixture.Controller.CompleteQuantum(step, 1, 1));
|
||||
switch (mutation)
|
||||
{
|
||||
case AuthoritativeMutation.Vector:
|
||||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
Assert.Equal(correctedOmega, body.Omega);
|
||||
break;
|
||||
case AuthoritativeMutation.Position:
|
||||
Assert.Equal(correctedPosition, body.Position);
|
||||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
break;
|
||||
case AuthoritativeMutation.State:
|
||||
Assert.Equal(correctedState, body.State);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -392,7 +517,7 @@ public sealed class ProjectileControllerTests
|
|||
PhysicsBody body = runtime.Body;
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid,
|
||||
record,
|
||||
PhysicsStateFlags.ReportCollisions,
|
||||
currentTime: 1.1,
|
||||
liveCenterX: 1,
|
||||
|
|
@ -411,7 +536,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid,
|
||||
record,
|
||||
MissileState,
|
||||
currentTime: 1.3,
|
||||
liveCenterX: 1,
|
||||
|
|
@ -452,7 +577,7 @@ public sealed class ProjectileControllerTests
|
|||
StateSequence: 2),
|
||||
out _));
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 0.06, 0xA9, 0xB4));
|
||||
record, record.FinalPhysicsState, 0.06, 0xA9, 0xB4));
|
||||
Assert.Equal(destinationCell, record.FullCellId);
|
||||
Assert.True(fixture.Live.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
|
|
@ -462,7 +587,7 @@ public sealed class ProjectileControllerTests
|
|||
StateSequence: 3),
|
||||
out _));
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 0.07, 0xA9, 0xB4));
|
||||
record, MissileState, 0.07, 0xA9, 0xB4));
|
||||
|
||||
Assert.Same(runtime, record.ProjectileRuntime);
|
||||
Assert.Same(runtime.Body, record.PhysicsBody);
|
||||
|
|
@ -481,7 +606,7 @@ public sealed class ProjectileControllerTests
|
|||
body.LastUpdateTime = 1.0;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
Guid,
|
||||
record,
|
||||
new Vector3(100f, 0f, 0f),
|
||||
Vector3.Zero,
|
||||
currentTime: 20.0));
|
||||
|
|
@ -506,7 +631,7 @@ public sealed class ProjectileControllerTests
|
|||
body.TransientState &= ~TransientStateFlags.Active;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid,
|
||||
record,
|
||||
MissileState | PhysicsStateFlags.Inelastic,
|
||||
currentTime: 2.0,
|
||||
liveCenterX: 1,
|
||||
|
|
@ -516,11 +641,11 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 2.1, 1, 1));
|
||||
record, record.FinalPhysicsState, 2.1, 1, 1));
|
||||
Assert.False(body.IsActive);
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 2.2, 1, 1));
|
||||
record, MissileState, 2.2, 1, 1));
|
||||
Assert.False(body.IsActive);
|
||||
}
|
||||
|
||||
|
|
@ -533,10 +658,10 @@ public sealed class ProjectileControllerTests
|
|||
PhysicsBody body = record.PhysicsBody!;
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 1.01, 1, 1));
|
||||
record, record.FinalPhysicsState, 1.01, 1, 1));
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
Guid,
|
||||
record,
|
||||
new Vector3(100f, 0f, 0f),
|
||||
new Vector3(0f, 0f, 3f),
|
||||
1.02));
|
||||
|
|
@ -561,11 +686,11 @@ public sealed class ProjectileControllerTests
|
|||
fixture.Live.SetRemoteMotionRuntime(Guid, new GameWindow.RemoteMotion(body));
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
record, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
Vector3 before = body.Velocity;
|
||||
|
||||
Assert.False(fixture.Controller.ApplyAuthoritativeVector(
|
||||
Guid,
|
||||
record,
|
||||
new Vector3(0f, 0f, 12f),
|
||||
new Vector3(0f, 0f, 2f),
|
||||
3.0));
|
||||
|
|
@ -586,14 +711,14 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
record, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
body.TransientState |= TransientStateFlags.Active;
|
||||
body.LastUpdateTime = 9.5; // generic remote interval used another clock owner
|
||||
body.set_velocity(new Vector3(40f, 0f, 0f));
|
||||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 10.0, 1, 1));
|
||||
record, MissileState, 10.0, 1, 1));
|
||||
Assert.Equal(10.0, body.LastUpdateTime, 8);
|
||||
Assert.True(body.IsActive);
|
||||
|
||||
|
|
@ -611,13 +736,13 @@ public sealed class ProjectileControllerTests
|
|||
PhysicsBody body = record.PhysicsBody!;
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
record, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
PhysicsStateFlags stateBefore = body.State;
|
||||
double clockBefore = body.LastUpdateTime;
|
||||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, double.NaN, 1, 1));
|
||||
record, MissileState, double.NaN, 1, 1));
|
||||
|
||||
Assert.Equal(record.FinalPhysicsState, body.State);
|
||||
Assert.NotEqual(stateBefore, body.State);
|
||||
|
|
@ -647,6 +772,41 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Equal(entity.Position, after.Translation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RootPoseCallbackGuidReuse_MakesQuantumReportSuperseded()
|
||||
{
|
||||
Fixture? fixture = null;
|
||||
LiveEntityRecord? first = null;
|
||||
LiveEntityRecord? replacement = null;
|
||||
bool replaced = false;
|
||||
fixture = new Fixture(
|
||||
publishRootPose: _ =>
|
||||
{
|
||||
if (replaced)
|
||||
return;
|
||||
replaced = true;
|
||||
Assert.NotNull(first);
|
||||
Assert.True(fixture!.Live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, first!.Generation),
|
||||
isLocalPlayer: false));
|
||||
replacement = fixture.Spawn(instance: 2);
|
||||
});
|
||||
first = fixture.Spawn(instance: 1);
|
||||
Assert.True(fixture.Controller.TryBind(first, ProjectileSetup(), 1.0, 1, 1));
|
||||
|
||||
Assert.False(fixture.Controller.AdvanceQuantum(
|
||||
first,
|
||||
quantum: 0.05f,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
|
||||
Assert.True(replaced);
|
||||
Assert.NotNull(replacement);
|
||||
Assert.True(fixture.Live.TryGetRecord(Guid, out LiveEntityRecord current));
|
||||
Assert.Same(replacement, current);
|
||||
Assert.Null(current.ProjectileRuntime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_PartArrayUsesRetailNinetySixUnitActivationBoundary()
|
||||
{
|
||||
|
|
@ -699,17 +859,18 @@ public sealed class ProjectileControllerTests
|
|||
Vector3 position = body.Position;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
Guid,
|
||||
record,
|
||||
new Vector3(float.NaN, 0f, 0f),
|
||||
Vector3.Zero,
|
||||
currentTime: 2.0));
|
||||
Assert.Equal(velocity, body.Velocity);
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
Guid,
|
||||
record,
|
||||
new Vector3(float.PositiveInfinity, 0f, 0f),
|
||||
new Vector3(float.PositiveInfinity, 0f, 0f),
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
CellA,
|
||||
currentTime: 2.1,
|
||||
liveCenterX: 1,
|
||||
|
|
@ -772,7 +933,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 2.0, 1, 1));
|
||||
record, MissileState, 2.0, 1, 1));
|
||||
Assert.NotNull(record.ProjectileRuntime);
|
||||
Assert.True(float.IsFinite(record.PhysicsBody!.Velocity.X));
|
||||
Assert.True(float.IsFinite(record.PhysicsBody.Velocity.Y));
|
||||
|
|
@ -793,7 +954,7 @@ public sealed class ProjectileControllerTests
|
|||
PhysicsStateFlags.Gravity | PhysicsStateFlags.IgnoreCollisions;
|
||||
record.FinalPhysicsState = ordinaryState;
|
||||
Assert.False(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, ordinaryState, 1.0, 1, 1));
|
||||
record, ordinaryState, 1.0, 1, 1));
|
||||
|
||||
Assert.Equal(ordinaryState, remote.Body.State);
|
||||
Assert.Null(record.ProjectileRuntime);
|
||||
|
|
@ -803,7 +964,7 @@ public sealed class ProjectileControllerTests
|
|||
ordinaryState | PhysicsStateFlags.Missile;
|
||||
record.FinalPhysicsState = unsupportedMissile;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, unsupportedMissile, 1.1, 1, 1));
|
||||
record, unsupportedMissile, 1.1, 1, 1));
|
||||
|
||||
Assert.Equal(unsupportedMissile, remote.Body.State);
|
||||
Assert.Null(record.ProjectileRuntime);
|
||||
|
|
@ -819,7 +980,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, double.NaN, 1, 1));
|
||||
record, MissileState, double.NaN, 1, 1));
|
||||
|
||||
Assert.NotNull(record.ProjectileRuntime);
|
||||
Assert.Equal(MissileState, record.PhysicsBody!.State);
|
||||
|
|
@ -827,7 +988,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 0.1, 1, 1));
|
||||
record, MissileState, 0.1, 1, 1));
|
||||
Assert.Same(runtime, record.ProjectileRuntime);
|
||||
}
|
||||
|
||||
|
|
@ -843,7 +1004,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, double.PositiveInfinity, 1, 1));
|
||||
record, MissileState, double.PositiveInfinity, 1, 1));
|
||||
|
||||
Assert.NotNull(record.ProjectileRuntime);
|
||||
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
|
||||
|
|
@ -922,7 +1083,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid,
|
||||
record,
|
||||
MissileState,
|
||||
currentTime: 1.2,
|
||||
liveCenterX: 0xA9,
|
||||
|
|
@ -941,7 +1102,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 1.5, 0xA9, 0xB4));
|
||||
record, record.FinalPhysicsState, 1.5, 0xA9, 0xB4));
|
||||
Assert.Equal(destinationCell, remote.CellId);
|
||||
Assert.Equal(destinationCell, record.FullCellId);
|
||||
|
||||
|
|
@ -960,7 +1121,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 1.7, 0xA9, 0xB4));
|
||||
record, MissileState, 1.7, 0xA9, 0xB4));
|
||||
Assert.Equal(startCell, record.FullCellId);
|
||||
Assert.Equal(startCell, remote.CellId);
|
||||
Assert.Equal(startCell, body.CellPosition.ObjCellId);
|
||||
|
|
@ -986,7 +1147,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 1.0, 1, 1));
|
||||
record, MissileState, 1.0, 1, 1));
|
||||
|
||||
Assert.NotNull(record.ProjectileRuntime);
|
||||
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
|
||||
|
|
@ -1010,7 +1171,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 1.0, 1, 1));
|
||||
record, MissileState, 1.0, 1, 1));
|
||||
|
||||
Assert.Null(record.ProjectileRuntime);
|
||||
Assert.Same(remote.Body, record.PhysicsBody);
|
||||
|
|
@ -1113,15 +1274,22 @@ public sealed class ProjectileControllerTests
|
|||
_ => throw new InvalidOperationException("re-entry recreated the entity")));
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
Guid,
|
||||
record,
|
||||
worldPosition: new Vector3(12f, 10f, 5f),
|
||||
cellLocalPosition: new Vector3(12f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: Vector3.Zero,
|
||||
fullCellId: CellA,
|
||||
currentTime: 10.0,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.Equal(10.0, record.PhysicsBody!.LastUpdateTime, 8);
|
||||
// The incarnation-stable RetailObjectQuantumClock is canonical after
|
||||
// the R6 cutover; PhysicsBody.LastUpdateTime is only a legacy absolute
|
||||
// clock mirror and need not equal the packet receipt time once the
|
||||
// visibility edge has already performed prepare_to_enter_world.
|
||||
Assert.Equal(0d, record.ObjectClock.PendingSeconds, 8);
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.True(record.PhysicsBody!.IsActive);
|
||||
Assert.Contains(
|
||||
fixture.Engine.ShadowObjects.GetObjectsInCell(CellA),
|
||||
entry => entry.EntityId == entity.Id);
|
||||
|
|
@ -1130,8 +1298,14 @@ public sealed class ProjectileControllerTests
|
|||
10.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1,
|
||||
playerWorldPosition: null);
|
||||
Assert.Equal(13f, entity.Position.X, 3);
|
||||
playerWorldPosition: entity.Position);
|
||||
Assert.Equal(12f, entity.Position.X, 3);
|
||||
fixture.Controller.Tick(
|
||||
10.2,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1,
|
||||
playerWorldPosition: entity.Position);
|
||||
Assert.Equal(12f, entity.Position.X, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1148,14 +1322,94 @@ public sealed class ProjectileControllerTests
|
|||
isLocalPlayer: false));
|
||||
LiveEntityRecord second = fixture.Spawn(instance: 8);
|
||||
Assert.True(fixture.Controller.TryBind(second, ProjectileSetup(), 2.0));
|
||||
ILiveEntityProjectileRuntime secondRuntime = second.ProjectileRuntime!;
|
||||
|
||||
Assert.False(fixture.Controller.TryBind(first, ProjectileSetup(), 3.0));
|
||||
|
||||
Assert.NotSame(firstEntity, second.WorldEntity);
|
||||
Assert.NotSame(firstRuntime, second.ProjectileRuntime);
|
||||
Assert.Same(secondRuntime, second.ProjectileRuntime);
|
||||
Assert.Equal(1, fixture.Controller.Count);
|
||||
Assert.Equal(2, fixture.Resources.Registers);
|
||||
Assert.Equal(1, fixture.Resources.Unregisters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBind_ReentrantVisibilityGuidReuseNeverBindsOldBodyToReplacement()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord first = fixture.Spawn(
|
||||
instance: 7,
|
||||
cellId: CellA,
|
||||
worldPosition: new Vector3(10f, 202f, 5f),
|
||||
cellLocalPosition: new Vector3(10f, 202f, 5f));
|
||||
LiveEntityRecord? replacement = null;
|
||||
bool replaced = false;
|
||||
fixture.Live.ProjectionVisibilityChanged += (record, visible) =>
|
||||
{
|
||||
if (replaced || visible || !ReferenceEquals(record, first))
|
||||
return;
|
||||
replaced = true;
|
||||
Assert.True(fixture.Live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, InstanceSequence: 7),
|
||||
isLocalPlayer: false));
|
||||
replacement = fixture.Spawn(instance: 8);
|
||||
};
|
||||
|
||||
Assert.False(fixture.Controller.TryBind(
|
||||
first,
|
||||
ProjectileSetup(),
|
||||
currentTime: 1.0,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
|
||||
Assert.NotNull(replacement);
|
||||
Assert.True(fixture.Live.TryGetRecord(Guid, out var current));
|
||||
Assert.Same(replacement, current);
|
||||
Assert.Null(current.ProjectileRuntime);
|
||||
Assert.Null(current.PhysicsBody);
|
||||
Assert.Equal(0, fixture.Controller.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativePosition_ReentrantGuidReuseStopsOldPostRebucketWork()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord first = fixture.Spawn(instance: 7);
|
||||
Assert.True(fixture.Controller.TryBind(first, ProjectileSetup(), 1.0, 1, 1));
|
||||
LiveEntityRecord? replacement = null;
|
||||
bool replaced = false;
|
||||
fixture.Live.ProjectionVisibilityChanged += (record, visible) =>
|
||||
{
|
||||
if (replaced || visible || !ReferenceEquals(record, first))
|
||||
return;
|
||||
replaced = true;
|
||||
Assert.True(fixture.Live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, InstanceSequence: 7),
|
||||
isLocalPlayer: false));
|
||||
replacement = fixture.Spawn(instance: 8);
|
||||
};
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
first,
|
||||
worldPosition: new Vector3(10f, 202f, 5f),
|
||||
cellLocalPosition: new Vector3(10f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: new Vector3(44f, 0f, 0f),
|
||||
fullCellId: CellB,
|
||||
currentTime: 1.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
|
||||
Assert.NotNull(replacement);
|
||||
Assert.True(fixture.Live.TryGetRecord(Guid, out var current));
|
||||
Assert.Same(replacement, current);
|
||||
Assert.Null(current.ProjectileRuntime);
|
||||
Assert.Null(current.PhysicsBody);
|
||||
Assert.Equal(new Vector3(10f, 10f, 5f), current.WorldEntity!.Position);
|
||||
Assert.Equal(0, fixture.Controller.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingRetailSphere_FailsWithoutFabricatingCollisionBody()
|
||||
{
|
||||
|
|
@ -1175,7 +1429,8 @@ public sealed class ProjectileControllerTests
|
|||
{
|
||||
internal Fixture(
|
||||
bool loadInitialLandblock = true,
|
||||
PhysicsEngine? physicsEngine = null)
|
||||
PhysicsEngine? physicsEngine = null,
|
||||
Action<WorldEntity>? publishRootPose = null)
|
||||
{
|
||||
if (loadInitialLandblock)
|
||||
Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
|
|
@ -1185,7 +1440,8 @@ public sealed class ProjectileControllerTests
|
|||
Live,
|
||||
Engine,
|
||||
_ => ResolvedSetup,
|
||||
entity => Poses.UpdateRoot(entity));
|
||||
publishRootPose ?? (entity => Poses.UpdateRoot(entity)),
|
||||
() => LiveCenter);
|
||||
}
|
||||
|
||||
internal GpuWorldState Spatial { get; } = new();
|
||||
|
|
@ -1194,6 +1450,7 @@ public sealed class ProjectileControllerTests
|
|||
internal PhysicsEngine Engine { get; }
|
||||
internal EntityEffectPoseRegistry Poses { get; } = new();
|
||||
internal Setup ResolvedSetup { get; set; } = ProjectileSetup();
|
||||
internal (int X, int Y) LiveCenter { get; set; } = (1, 1);
|
||||
internal ProjectileController Controller { get; }
|
||||
|
||||
internal LiveEntityRecord Spawn(
|
||||
|
|
@ -1221,6 +1478,7 @@ public sealed class ProjectileControllerTests
|
|||
elasticity);
|
||||
LiveEntityRegistrationResult registration = Live.RegisterLiveEntity(spawn);
|
||||
Assert.NotNull(registration.Record);
|
||||
registration.Record!.HasPartArray = true;
|
||||
WorldEntity? entity = Live.MaterializeLiveEntity(
|
||||
Guid,
|
||||
cellId,
|
||||
|
|
@ -1242,6 +1500,13 @@ public sealed class ProjectileControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
public enum AuthoritativeMutation
|
||||
{
|
||||
Vector,
|
||||
Position,
|
||||
State,
|
||||
}
|
||||
|
||||
private static Setup ProjectileSetup(float radius = 0.102f) => new()
|
||||
{
|
||||
Spheres =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
using AcDream.App.Physics;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.App.Tests.Physics;
|
||||
|
||||
public sealed class RemoteInboundMotionDispatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public void AnimationlessTypeZero_UsesCompleteRetailPacketFunnel()
|
||||
{
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
TransientState = TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable
|
||||
| TransientStateFlags.Active,
|
||||
};
|
||||
var motion = new MotionInterpreter(body);
|
||||
var movement = new MovementManager(motion);
|
||||
var calls = new List<string>();
|
||||
motion.InterruptCurrentMovement = () => calls.Add("interrupt");
|
||||
motion.UnstickFromObject = () => calls.Add("unstick");
|
||||
var dispatcher = new RemoteInboundMotionDispatcher(
|
||||
(owner, cellId, update) =>
|
||||
{
|
||||
Assert.Same(movement, owner);
|
||||
Assert.Equal(0x01010001u, cellId);
|
||||
calls.Add("route");
|
||||
return false;
|
||||
},
|
||||
(host, stickyGuid) =>
|
||||
{
|
||||
Assert.Null(host);
|
||||
Assert.Equal(0x70000099u, stickyGuid);
|
||||
calls.Add("stick");
|
||||
});
|
||||
var wire = new CreateObject.ServerMotionState(
|
||||
Stance: 0x3F,
|
||||
ForwardCommand: 0x0007,
|
||||
ForwardSpeed: 2.5f,
|
||||
SideStepCommand: 0x000F,
|
||||
SideStepSpeed: 0.75f,
|
||||
TurnCommand: 0x000D,
|
||||
TurnSpeed: 1.25f,
|
||||
MovementType: 0,
|
||||
StickyObjectGuid: 0x70000099u,
|
||||
StandingLongJump: true);
|
||||
var update = new WorldSession.EntityMotionUpdate(
|
||||
Guid: 0x70000001u,
|
||||
MotionState: wire,
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 2,
|
||||
ServerControlSequence: 3,
|
||||
IsAutonomous: false);
|
||||
|
||||
RemoteInboundMotionDispatchResult result = dispatcher.Apply(
|
||||
update,
|
||||
movement,
|
||||
animationSink: null,
|
||||
host: null,
|
||||
cellId: 0x01010001u,
|
||||
fallbackForwardClass: 0x41000000u);
|
||||
|
||||
Assert.False(result.RoutedMoveTo);
|
||||
Assert.True(result.AppliedInterpretedState);
|
||||
Assert.True(result.ForwardCommandChanged);
|
||||
Assert.Equal(0x8000003Fu, motion.InterpretedState.CurrentStyle);
|
||||
Assert.Equal(0x44000007u, motion.InterpretedState.ForwardCommand);
|
||||
Assert.Equal(2.5f, motion.InterpretedState.ForwardSpeed);
|
||||
Assert.Equal(0x6500000Fu, motion.InterpretedState.SideStepCommand);
|
||||
Assert.Equal(0.75f, motion.InterpretedState.SideStepSpeed);
|
||||
Assert.Equal(0x6500000Du, motion.InterpretedState.TurnCommand);
|
||||
Assert.Equal(1.25f, motion.InterpretedState.TurnSpeed);
|
||||
Assert.True(motion.StandingLongJump);
|
||||
Assert.Equal("interrupt", calls[0]);
|
||||
Assert.Equal("unstick", calls[1]);
|
||||
Assert.Contains("route", calls);
|
||||
Assert.Equal("stick", calls[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownMovementType_RunsHeadButDoesNotEnterTypeZeroFunnel()
|
||||
{
|
||||
var body = new PhysicsBody();
|
||||
var motion = new MotionInterpreter(body);
|
||||
var movement = new MovementManager(motion);
|
||||
int interrupts = 0;
|
||||
int unsticks = 0;
|
||||
motion.InterruptCurrentMovement = () => interrupts++;
|
||||
motion.UnstickFromObject = () => unsticks++;
|
||||
var dispatcher = new RemoteInboundMotionDispatcher(
|
||||
(_, _, _) => false,
|
||||
(_, _) => throw new InvalidOperationException(
|
||||
"Unknown movement type reached the type-zero tail."));
|
||||
var update = new WorldSession.EntityMotionUpdate(
|
||||
Guid: 0x70000001u,
|
||||
MotionState: new CreateObject.ServerMotionState(
|
||||
Stance: 0x3E,
|
||||
ForwardCommand: 0x0007,
|
||||
MovementType: 5),
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 2,
|
||||
ServerControlSequence: 3,
|
||||
IsAutonomous: false);
|
||||
|
||||
RemoteInboundMotionDispatchResult result = dispatcher.Apply(
|
||||
update,
|
||||
movement,
|
||||
animationSink: null,
|
||||
host: null,
|
||||
cellId: 0x01010001u,
|
||||
fallbackForwardClass: 0x41000000u);
|
||||
|
||||
Assert.False(result.RoutedMoveTo);
|
||||
Assert.False(result.AppliedInterpretedState);
|
||||
Assert.True(interrupts >= 1);
|
||||
Assert.Equal(1, unsticks);
|
||||
Assert.Equal(0x41000003u, motion.InterpretedState.ForwardCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupersededDuringInterrupt_StopsBeforeEveryLaterPacketSideEffect()
|
||||
{
|
||||
var body = new PhysicsBody();
|
||||
var motion = new MotionInterpreter(body);
|
||||
var movement = new MovementManager(motion);
|
||||
bool current = true;
|
||||
int unsticks = 0;
|
||||
int routes = 0;
|
||||
int sticks = 0;
|
||||
motion.InterruptCurrentMovement = () => current = false;
|
||||
motion.UnstickFromObject = () => unsticks++;
|
||||
var dispatcher = new RemoteInboundMotionDispatcher(
|
||||
(_, _, _) =>
|
||||
{
|
||||
routes++;
|
||||
return false;
|
||||
},
|
||||
(_, _) => sticks++);
|
||||
var update = new WorldSession.EntityMotionUpdate(
|
||||
Guid: 0x70000001u,
|
||||
MotionState: new CreateObject.ServerMotionState(
|
||||
Stance: 0x3F,
|
||||
ForwardCommand: 0x0007,
|
||||
MovementType: 0,
|
||||
StickyObjectGuid: 0x70000099u,
|
||||
StandingLongJump: true),
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 2,
|
||||
ServerControlSequence: 3,
|
||||
IsAutonomous: false);
|
||||
|
||||
RemoteInboundMotionDispatchResult result = dispatcher.Apply(
|
||||
update,
|
||||
movement,
|
||||
animationSink: null,
|
||||
host: null,
|
||||
cellId: 0x01010001u,
|
||||
fallbackForwardClass: 0x41000000u,
|
||||
isCurrent: () => current);
|
||||
|
||||
Assert.True(result.Superseded);
|
||||
Assert.False(result.AppliedInterpretedState);
|
||||
Assert.Equal(0, unsticks);
|
||||
Assert.Equal(0, routes);
|
||||
Assert.Equal(0, sticks);
|
||||
Assert.False(motion.StandingLongJump);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ using AcDream.App.World;
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
|
|
@ -14,6 +15,296 @@ namespace AcDream.App.Tests.Physics;
|
|||
|
||||
public sealed class RemotePhysicsUpdaterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ShadowPoseGate_TracksTranslationAndSignInvariantOrientation()
|
||||
{
|
||||
Quaternion turn = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 2f);
|
||||
|
||||
Assert.False(RemotePhysicsUpdater.ShouldSynchronizeShadowPose(
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity));
|
||||
Assert.False(RemotePhysicsUpdater.ShouldSynchronizeShadowPose(
|
||||
Vector3.Zero,
|
||||
turn,
|
||||
Vector3.Zero,
|
||||
new Quaternion(-turn.X, -turn.Y, -turn.Z, -turn.W)));
|
||||
Assert.True(RemotePhysicsUpdater.ShouldSynchronizeShadowPose(
|
||||
new Vector3(0.02f, 0f, 0f),
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity));
|
||||
Assert.True(RemotePhysicsUpdater.ShouldSynchronizeShadowPose(
|
||||
Vector3.Zero,
|
||||
turn,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity));
|
||||
Assert.True(RemotePhysicsUpdater.ShouldSynchronizeShadow(
|
||||
cellChanged: true,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_InPlaceCompleteRootTurn_UpdatesOffsetCollisionShadow()
|
||||
{
|
||||
const uint cellId = 0x01010001u;
|
||||
Quaternion turn = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 2f);
|
||||
var engine = new PhysicsEngine();
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 13u,
|
||||
ServerGuid = 0x80000013u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = cellId,
|
||||
};
|
||||
var motion = new GameWindow.RemoteMotion
|
||||
{
|
||||
LastShadowSyncPos = Vector3.Zero,
|
||||
LastShadowSyncOrientation = Quaternion.Identity,
|
||||
};
|
||||
motion.Body.Position = Vector3.Zero;
|
||||
motion.Body.Orientation = Quaternion.Identity;
|
||||
motion.CellId = cellId;
|
||||
engine.ShadowObjects.RegisterMultiPart(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
[
|
||||
new ShadowShape(
|
||||
0x01000001u,
|
||||
new Vector3(1f, 0f, 0f),
|
||||
Quaternion.Identity,
|
||||
Scale: 1f,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: 0.25f,
|
||||
CylHeight: 1f),
|
||||
],
|
||||
state: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
flags: EntityCollisionFlags.None,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0x01010000u,
|
||||
seedCellId: cellId);
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
engine,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
|
||||
updater.Tick(
|
||||
motion,
|
||||
entity,
|
||||
objectScale: 1f,
|
||||
sequencer: null,
|
||||
animationForVelocityCycle: null,
|
||||
dt: 0.1f,
|
||||
new MotionDeltaFrame { Orientation = turn },
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1);
|
||||
|
||||
ShadowEntry entry = Assert.Single(
|
||||
engine.ShadowObjects.AllEntriesForDebug(),
|
||||
candidate => candidate.EntityId == entity.Id);
|
||||
Assert.Equal(0f, entry.Position.X, 3);
|
||||
Assert.Equal(1f, entry.Position.Y, 3);
|
||||
Assert.InRange(
|
||||
MathF.Abs(Quaternion.Dot(
|
||||
turn,
|
||||
Quaternion.Normalize(motion.LastShadowSyncOrientation))),
|
||||
0.99999f,
|
||||
1.00001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_ComposesCompleteSequenceFrameWithoutOmegaSideChannel()
|
||||
{
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 10u,
|
||||
ServerGuid = 0x80000010u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(10f, 20f, 30f),
|
||||
Rotation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
-MathF.PI / 2f),
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
var animated = new GameWindow.AnimatedEntity
|
||||
{
|
||||
Entity = entity,
|
||||
Setup = new Setup(),
|
||||
Animation = new Animation(),
|
||||
LowFrame = 0,
|
||||
HighFrame = 0,
|
||||
Framerate = 0f,
|
||||
Scale = 1f,
|
||||
PartTemplate = Array.Empty<GameWindow.AnimatedPartTemplate>(),
|
||||
PartAvailability = Array.Empty<bool>(),
|
||||
};
|
||||
var motion = new GameWindow.RemoteMotion();
|
||||
motion.Body.Position = entity.Position;
|
||||
motion.Body.Orientation = entity.Rotation;
|
||||
motion.Body.TransientState = TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable
|
||||
| TransientStateFlags.Active;
|
||||
var sequenceFrame = new MotionDeltaFrame
|
||||
{
|
||||
Origin = new Vector3(0f, 0.1f, 0f),
|
||||
Orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 2f),
|
||||
};
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
|
||||
updater.Tick(
|
||||
motion,
|
||||
animated,
|
||||
dt: 0.1f,
|
||||
sequenceFrame,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0);
|
||||
|
||||
// Current body faces east, so the sequence's local +Y origin moves
|
||||
// east before its quarter-turn is composed. No ObservedOmega/manual
|
||||
// integrator participates.
|
||||
Assert.Equal(10.1f, motion.Body.Position.X, 3);
|
||||
Assert.Equal(20f, motion.Body.Position.Y, 3);
|
||||
Assert.InRange(
|
||||
MathF.Abs(Quaternion.Dot(
|
||||
Quaternion.Identity,
|
||||
Quaternion.Normalize(motion.Body.Orientation))),
|
||||
0.99999f,
|
||||
1.00001f);
|
||||
Assert.Equal(motion.Body.Orientation, entity.Rotation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_HostlessAirborneRemote_SuppressesOriginButPreservesRootOrientation()
|
||||
{
|
||||
Quaternion initial = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f);
|
||||
Quaternion rootTurn = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 11u,
|
||||
ServerGuid = 0x80000011u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(10f, 20f, 30f),
|
||||
Rotation = initial,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
var animated = new GameWindow.AnimatedEntity
|
||||
{
|
||||
Entity = entity,
|
||||
Setup = new Setup(),
|
||||
Animation = new Animation(),
|
||||
LowFrame = 0,
|
||||
HighFrame = 0,
|
||||
Framerate = 0f,
|
||||
Scale = 1f,
|
||||
PartTemplate = Array.Empty<GameWindow.AnimatedPartTemplate>(),
|
||||
PartAvailability = Array.Empty<bool>(),
|
||||
};
|
||||
var motion = new GameWindow.RemoteMotion
|
||||
{
|
||||
Airborne = true,
|
||||
};
|
||||
motion.Body.Position = entity.Position;
|
||||
motion.Body.Orientation = initial;
|
||||
motion.Body.TransientState = TransientStateFlags.Active;
|
||||
var root = new MotionDeltaFrame
|
||||
{
|
||||
Origin = new Vector3(0f, 10f, 0f),
|
||||
Orientation = rootTurn,
|
||||
};
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
|
||||
updater.Tick(motion, animated, 0.1f, root, 0, 0);
|
||||
|
||||
Assert.Equal(new Vector3(10f, 20f, 30f), motion.Body.Position);
|
||||
Quaternion expected = Quaternion.Normalize(initial * rootTurn);
|
||||
Assert.InRange(
|
||||
MathF.Abs(Quaternion.Dot(
|
||||
expected,
|
||||
Quaternion.Normalize(motion.Body.Orientation))),
|
||||
0.99999f,
|
||||
1.00001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_ActiveInterpolation_ReplacesRootWithCompleteTargetFrame()
|
||||
{
|
||||
Quaternion initial = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f);
|
||||
Quaternion target = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 12u,
|
||||
ServerGuid = 0x80000012u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(10f, 20f, 30f),
|
||||
Rotation = initial,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
var animated = new GameWindow.AnimatedEntity
|
||||
{
|
||||
Entity = entity,
|
||||
Setup = new Setup(),
|
||||
Animation = new Animation(),
|
||||
LowFrame = 0,
|
||||
HighFrame = 0,
|
||||
Framerate = 0f,
|
||||
Scale = 1f,
|
||||
PartTemplate = Array.Empty<GameWindow.AnimatedPartTemplate>(),
|
||||
PartAvailability = Array.Empty<bool>(),
|
||||
};
|
||||
var motion = new GameWindow.RemoteMotion();
|
||||
motion.Body.Position = entity.Position;
|
||||
motion.Body.Orientation = initial;
|
||||
motion.Interp.Enqueue(
|
||||
entity.Position + Vector3.UnitX,
|
||||
target,
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: entity.Position);
|
||||
var root = new MotionDeltaFrame
|
||||
{
|
||||
Origin = new Vector3(0f, 10f, 0f),
|
||||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.7f),
|
||||
};
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
|
||||
updater.Tick(motion, animated, 0.1f, root, 0, 0);
|
||||
|
||||
// InterpolationManager::adjust_offset clamps the 1 m correction to
|
||||
// maxSpeed (8 m/s) * 0.1 s = 0.8 m and replaces the authored 10 m
|
||||
// root origin rather than adding to it.
|
||||
Assert.InRange(motion.Body.Position.X, 10.79f, 10.81f);
|
||||
Assert.InRange(
|
||||
MathF.Abs(Quaternion.Dot(
|
||||
Quaternion.Normalize(target),
|
||||
Quaternion.Normalize(motion.Body.Orientation))),
|
||||
0.99999f,
|
||||
1.00001f);
|
||||
Assert.Equal(motion.Body.Orientation, entity.Rotation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickHidden_AppliesPositionManagerOffsetWithoutAdvancingPhysics()
|
||||
{
|
||||
|
|
@ -74,15 +365,55 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
int partArrayTailCalls = 0;
|
||||
AnimationSequencer sequencer = CreateSequencer();
|
||||
sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0);
|
||||
|
||||
updater.TickHidden(
|
||||
motion,
|
||||
entity,
|
||||
0.1f,
|
||||
() => partArrayTailCalls++);
|
||||
sequencer.Manager);
|
||||
|
||||
Assert.Equal(1, partArrayTailCalls);
|
||||
Assert.Empty(sequencer.Manager.PendingAnimations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickHidden_ProcessesPendingHooksBeforeManagerTail()
|
||||
{
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
var motion = new GameWindow.RemoteMotion();
|
||||
motion.Body.Orientation = Quaternion.Identity;
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 3u,
|
||||
ServerGuid = 0x70000003u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
AnimationSequencer sequencer = CreateSequencer();
|
||||
sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0);
|
||||
var order = new List<string>();
|
||||
|
||||
updater.TickHidden(
|
||||
motion,
|
||||
entity,
|
||||
0.1f,
|
||||
partArrayHandleMovement: sequencer.Manager,
|
||||
processAnimationHooks: (_, observed) =>
|
||||
{
|
||||
Assert.Same(sequencer, observed);
|
||||
Assert.NotEmpty(sequencer.Manager.PendingAnimations);
|
||||
order.Add("hooks");
|
||||
},
|
||||
sequencer);
|
||||
|
||||
Assert.Equal(["hooks"], order);
|
||||
Assert.Empty(sequencer.Manager.PendingAnimations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -133,7 +464,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void TickHidden_LandingSynchronizesRemoteAirborneState()
|
||||
public void TickHidden_OutOfContactDoesNotInterpolateOrInventLanding()
|
||||
{
|
||||
var engine = BuildBoundaryEngine();
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
|
|
@ -165,9 +496,190 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
|
||||
updater.TickHidden(motion, entity, 0.1f);
|
||||
|
||||
Assert.True(motion.Body.InContact);
|
||||
Assert.True(motion.Body.OnWalkable);
|
||||
Assert.False(motion.Airborne);
|
||||
Assert.False(motion.Body.InContact);
|
||||
Assert.False(motion.Body.OnWalkable);
|
||||
Assert.True(motion.Airborne);
|
||||
Assert.Equal(new Vector3(10f, 10f, 50f), motion.Body.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_CrossCellIntoPending_CommitsRootAndSuspendsShadowBeforeReturning()
|
||||
{
|
||||
using var fixture = new BoundaryRemoteFixture();
|
||||
Vector3 sourceShadowPosition = fixture.Remote.LastShadowSyncPos;
|
||||
ulong clockEpoch = fixture.Record.ObjectClockEpoch;
|
||||
|
||||
bool current = fixture.Updater.Tick(
|
||||
fixture.Remote,
|
||||
fixture.Entity,
|
||||
objectScale: 1f,
|
||||
sequencer: null,
|
||||
animationForVelocityCycle: null,
|
||||
dt: 2f,
|
||||
rootMotionLocalFrame: new MotionDeltaFrame(),
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0,
|
||||
ownerRuntime: fixture.Live,
|
||||
ownerRecord: fixture.Record,
|
||||
ownerClockEpoch: clockEpoch);
|
||||
|
||||
Assert.False(current);
|
||||
Assert.Equal(BoundaryRemoteFixture.DestinationCell, fixture.Record.FullCellId);
|
||||
Assert.False(fixture.Record.IsSpatiallyVisible);
|
||||
Assert.Equal(fixture.Remote.Body.Position, fixture.Entity.Position);
|
||||
Assert.Equal(BoundaryRemoteFixture.DestinationCell, fixture.Entity.ParentCellId);
|
||||
Assert.True(fixture.Entity.Position.X > 192f);
|
||||
Assert.Equal(sourceShadowPosition, fixture.Remote.LastShadowSyncPos);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
||||
|
||||
fixture.HydrateDestination();
|
||||
|
||||
Assert.True(fixture.Record.IsSpatiallyVisible);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
||||
ShadowEntry restored = Assert.Single(
|
||||
fixture.Engine.ShadowObjects.AllEntriesForDebug());
|
||||
Assert.Equal(fixture.Entity.Id, restored.EntityId);
|
||||
Assert.Equal(fixture.Entity.Position, restored.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeShadowPublisher_AlreadyPendingCannotResurrectShadow()
|
||||
{
|
||||
using var fixture = new BoundaryRemoteFixture();
|
||||
int publications = 0;
|
||||
|
||||
Assert.True(LiveEntityShadowPublisher.TryPublishRemote(
|
||||
fixture.Live,
|
||||
fixture.Record,
|
||||
fixture.Entity,
|
||||
fixture.Remote,
|
||||
fixture.Record.PositionAuthorityVersion,
|
||||
() => publications++));
|
||||
Assert.Equal(1, publications);
|
||||
|
||||
Assert.True(fixture.Live.RebucketLiveEntity(
|
||||
BoundaryRemoteFixture.Guid,
|
||||
BoundaryRemoteFixture.DestinationCell));
|
||||
Assert.False(fixture.Record.IsSpatiallyVisible);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
||||
|
||||
Assert.False(LiveEntityShadowPublisher.TryPublishRemote(
|
||||
fixture.Live,
|
||||
fixture.Record,
|
||||
fixture.Entity,
|
||||
fixture.Remote,
|
||||
fixture.Record.PositionAuthorityVersion,
|
||||
() => publications++));
|
||||
Assert.Equal(1, publications);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
||||
|
||||
fixture.HydrateDestination();
|
||||
|
||||
Assert.True(fixture.Record.IsSpatiallyVisible);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeShadowPublisher_StaleAuthorityAndGuidReuseCannotPublish()
|
||||
{
|
||||
using var fixture = new BoundaryRemoteFixture();
|
||||
LiveEntityRecord oldRecord = fixture.Record;
|
||||
WorldEntity oldEntity = fixture.Entity;
|
||||
GameWindow.RemoteMotion oldRemote = fixture.Remote;
|
||||
ulong oldAuthority = oldRecord.PositionAuthorityVersion;
|
||||
int publications = 0;
|
||||
|
||||
Assert.False(LiveEntityShadowPublisher.TryPublishRemote(
|
||||
fixture.Live,
|
||||
oldRecord,
|
||||
oldEntity,
|
||||
oldRemote,
|
||||
oldAuthority + 1,
|
||||
() => publications++));
|
||||
|
||||
var replacement = fixture.ReplaceAtSource();
|
||||
|
||||
Assert.False(LiveEntityShadowPublisher.TryPublishRemote(
|
||||
fixture.Live,
|
||||
oldRecord,
|
||||
oldEntity,
|
||||
oldRemote,
|
||||
oldAuthority,
|
||||
() => publications++));
|
||||
Assert.Equal(0, publications);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Contains(
|
||||
fixture.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == replacement.Entity.Id);
|
||||
Assert.DoesNotContain(
|
||||
fixture.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == oldEntity.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_CrossCellVisibilityCallbackGuidReuse_CannotPublishOldShadow()
|
||||
{
|
||||
using var fixture = new BoundaryRemoteFixture();
|
||||
LiveEntityRecord oldRecord = fixture.Record;
|
||||
WorldEntity oldEntity = fixture.Entity;
|
||||
GameWindow.RemoteMotion oldRemote = fixture.Remote;
|
||||
WorldEntity? replacementEntity = null;
|
||||
GameWindow.RemoteMotion? replacementRemote = null;
|
||||
ulong clockEpoch = oldRecord.ObjectClockEpoch;
|
||||
|
||||
fixture.Live.ProjectionVisibilityChanged += (record, visible) =>
|
||||
{
|
||||
if (visible
|
||||
|| replacementEntity is not null
|
||||
|| !ReferenceEquals(record, oldRecord))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
(LiveEntityRecord replacementRecord,
|
||||
replacementEntity,
|
||||
replacementRemote) = fixture.ReplaceAtSource();
|
||||
Assert.NotSame(oldRecord, replacementRecord);
|
||||
};
|
||||
|
||||
bool current = fixture.Updater.Tick(
|
||||
oldRemote,
|
||||
oldEntity,
|
||||
objectScale: 1f,
|
||||
sequencer: null,
|
||||
animationForVelocityCycle: null,
|
||||
dt: 2f,
|
||||
rootMotionLocalFrame: new MotionDeltaFrame(),
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0,
|
||||
ownerRuntime: fixture.Live,
|
||||
ownerRecord: oldRecord,
|
||||
ownerClockEpoch: clockEpoch);
|
||||
|
||||
Assert.False(current);
|
||||
Assert.NotNull(replacementEntity);
|
||||
Assert.NotNull(replacementRemote);
|
||||
Assert.True(fixture.Live.TryGetRecord(
|
||||
BoundaryRemoteFixture.Guid,
|
||||
out LiveEntityRecord currentRecord));
|
||||
Assert.NotSame(oldRecord, currentRecord);
|
||||
Assert.Same(replacementEntity, currentRecord.WorldEntity);
|
||||
Assert.Same(replacementRemote, currentRecord.RemoteMotionRuntime);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
||||
Assert.Contains(
|
||||
fixture.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == replacementEntity.Id);
|
||||
Assert.DoesNotContain(
|
||||
fixture.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == oldEntity.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -192,6 +704,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
var published = new List<uint>();
|
||||
var partPoseDirty = new List<uint>();
|
||||
updater.TickHiddenEntities(
|
||||
live,
|
||||
localPlayerServerGuid: 0x50000001u,
|
||||
|
|
@ -203,9 +716,11 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Assert.True(live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(other, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
});
|
||||
},
|
||||
markPartPoseDirty: partPoseDirty.Add);
|
||||
|
||||
Assert.Single(published);
|
||||
Assert.Equal(published, partPoseDirty);
|
||||
Assert.Single(live.SpatialRemoteMotionRuntimes);
|
||||
}
|
||||
|
||||
|
|
@ -309,4 +824,208 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
currentBodyPosition: entity.Position);
|
||||
live.SetRemoteMotionRuntime(guid, remote);
|
||||
}
|
||||
|
||||
private sealed class BoundaryRemoteFixture : IDisposable
|
||||
{
|
||||
public const uint Guid = 0x70000071u;
|
||||
public const uint SourceCell = 0xA9B40039u;
|
||||
public const uint DestinationCell = 0xAAB40001u;
|
||||
|
||||
private LiveEntityPresentationController? _presentation;
|
||||
|
||||
public BoundaryRemoteFixture()
|
||||
{
|
||||
Engine = BuildBoundaryEngine();
|
||||
Spatial.AddLandblock(new LoadedLandblock(
|
||||
0xA9B4FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
Live = new LiveEntityRuntime(
|
||||
Spatial,
|
||||
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
|
||||
record =>
|
||||
{
|
||||
if (record.WorldEntity is { } entity)
|
||||
Engine.ShadowObjects.Deregister(entity.Id);
|
||||
_presentation?.Forget(record);
|
||||
});
|
||||
(Record, Entity, Remote) = SpawnAndBind(
|
||||
instanceSequence: 1,
|
||||
new Vector3(191f, 10f, 50f),
|
||||
enqueueDestination: true);
|
||||
_presentation = new LiveEntityPresentationController(
|
||||
Live,
|
||||
Engine.ShadowObjects,
|
||||
(_, _, _) => true,
|
||||
liveCenter: () => (0, 0));
|
||||
RegisterShadow(Entity, Remote);
|
||||
Assert.True(_presentation.OnLiveEntityReady(Guid));
|
||||
Updater = new RemotePhysicsUpdater(
|
||||
Engine,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
}
|
||||
|
||||
public PhysicsEngine Engine { get; }
|
||||
public GpuWorldState Spatial { get; } = new();
|
||||
public LiveEntityRuntime Live { get; }
|
||||
public RemotePhysicsUpdater Updater { get; }
|
||||
public LiveEntityRecord Record { get; }
|
||||
public WorldEntity Entity { get; }
|
||||
public GameWindow.RemoteMotion Remote { get; }
|
||||
|
||||
public void HydrateDestination() =>
|
||||
Spatial.AddLandblock(new LoadedLandblock(
|
||||
0xAAB4FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
|
||||
public (LiveEntityRecord Record, WorldEntity Entity,
|
||||
GameWindow.RemoteMotion Remote) ReplaceAtSource()
|
||||
{
|
||||
Assert.True(Live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
var replacement = SpawnAndBind(
|
||||
instanceSequence: 2,
|
||||
new Vector3(180f, 20f, 50f),
|
||||
enqueueDestination: false);
|
||||
RegisterShadow(replacement.Entity, replacement.Remote);
|
||||
Assert.True(_presentation!.OnLiveEntityReady(Guid));
|
||||
return replacement;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Live.Clear();
|
||||
_presentation?.Dispose();
|
||||
}
|
||||
|
||||
private (LiveEntityRecord Record, WorldEntity Entity,
|
||||
GameWindow.RemoteMotion Remote) SpawnAndBind(
|
||||
ushort instanceSequence,
|
||||
Vector3 position,
|
||||
bool enqueueDestination)
|
||||
{
|
||||
LiveEntityRegistrationResult registration = Live.RegisterLiveEntity(
|
||||
Spawn(instanceSequence, position));
|
||||
LiveEntityRecord record = Assert.IsType<LiveEntityRecord>(
|
||||
registration.Record);
|
||||
WorldEntity entity = Assert.IsType<WorldEntity>(
|
||||
Live.MaterializeLiveEntity(
|
||||
Guid,
|
||||
SourceCell,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = Guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = SourceCell,
|
||||
}));
|
||||
var remote = new GameWindow.RemoteMotion();
|
||||
remote.Body.Position = position;
|
||||
remote.Body.Orientation = Quaternion.Identity;
|
||||
remote.CellId = SourceCell;
|
||||
remote.LastShadowSyncPos = position;
|
||||
remote.LastShadowSyncOrientation = Quaternion.Identity;
|
||||
if (enqueueDestination)
|
||||
{
|
||||
remote.Interp.Enqueue(
|
||||
new Vector3(193f, 10f, 50f),
|
||||
heading: 0f,
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: position);
|
||||
}
|
||||
Live.SetRemoteMotionRuntime(Guid, remote);
|
||||
return (record, entity, remote);
|
||||
}
|
||||
|
||||
private void RegisterShadow(
|
||||
WorldEntity entity,
|
||||
GameWindow.RemoteMotion remote)
|
||||
{
|
||||
Engine.ShadowObjects.Register(
|
||||
entity.Id,
|
||||
entity.SourceGfxObjOrSetupId,
|
||||
remote.Body.Position,
|
||||
remote.Body.Orientation,
|
||||
radius: 0.48f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0xA9B40000u,
|
||||
collisionType: ShadowCollisionType.Cylinder,
|
||||
cylHeight: 1.835f,
|
||||
state: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
seedCellId: SourceCell,
|
||||
isStatic: false);
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
ushort instanceSequence,
|
||||
Vector3 position)
|
||||
{
|
||||
const PhysicsStateFlags state = PhysicsStateFlags.ReportCollisions;
|
||||
var serverPosition = new CreateObject.ServerPosition(
|
||||
SourceCell,
|
||||
position.X,
|
||||
position.Y,
|
||||
position.Z,
|
||||
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: serverPosition,
|
||||
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,
|
||||
serverPosition,
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"boundary remote",
|
||||
null,
|
||||
null,
|
||||
MotionTableId: 0x09000001u,
|
||||
PhysicsState: (uint)state,
|
||||
InstanceSequence: instanceSequence,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
}
|
||||
|
||||
private static AnimationSequencer CreateSequencer() =>
|
||||
new(new Setup(), new MotionTable(), new NullAnimationLoader());
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
public Vector3 LastServerPosition { get; set; }
|
||||
public double LastServerPositionTime { get; set; }
|
||||
public Vector3 LastShadowSyncPosition { get; set; }
|
||||
public Quaternion LastShadowSyncOrientation { get; set; }
|
||||
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
|
||||
{
|
||||
_readCell = read;
|
||||
|
|
@ -64,6 +65,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
public Vector3 LastServerPosition { get; set; }
|
||||
public double LastServerPositionTime { get; set; }
|
||||
public Vector3 LastShadowSyncPosition { get; set; }
|
||||
public Quaternion LastShadowSyncOrientation { get; set; }
|
||||
public void BindCanonicalCell(Func<uint> read, Action<uint> write) { }
|
||||
public void HitGround() { }
|
||||
public void LeaveGround() { }
|
||||
|
|
@ -921,6 +923,165 @@ public sealed class RemoteTeleportControllerTests
|
|||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolverAcceptsNewerPosition_OlderPlacementReturnsSuperseded()
|
||||
{
|
||||
const uint guid = 0x70000060u;
|
||||
const uint sourceCell = 0xA9B40039u;
|
||||
const uint destinationCell = 0xAAB40001u;
|
||||
var fixture = CreateLoadedRemoteFixture(guid, sourceCell);
|
||||
Assert.True(fixture.Live.TryApplyPosition(
|
||||
PositionUpdate(guid, destinationCell, x: 1f, sequence: 2),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
Assert.True(fixture.Live.RebucketLiveEntity(guid, destinationCell));
|
||||
Assert.True(fixture.Live.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
ulong outerPositionAuthority = record.PositionAuthorityVersion;
|
||||
ulong outerVelocityAuthority = record.VelocityAuthorityVersion;
|
||||
Vector3 newerWorldPosition = new(195f, 10f, 50f);
|
||||
RemoteTeleportController? controller = null;
|
||||
RemoteTeleportController.Result nestedResult = default;
|
||||
bool nested = false;
|
||||
controller = new RemoteTeleportController(
|
||||
fixture.Engine,
|
||||
fixture.Live,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
CellLocal,
|
||||
(_, _, _) => { },
|
||||
(_, _, _) => { },
|
||||
(_, _) => { },
|
||||
resolvePlacement: (position, cell, _, _, _, _) =>
|
||||
{
|
||||
if (!nested)
|
||||
{
|
||||
nested = true;
|
||||
Assert.True(fixture.Live.TryApplyPosition(
|
||||
PositionUpdate(guid, destinationCell, x: 3f, sequence: 3),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
Assert.True(fixture.Live.TryGetRecord(guid, out var current));
|
||||
nestedResult = controller!.TryApply(
|
||||
current,
|
||||
current.PositionAuthorityVersion,
|
||||
current.VelocityAuthorityVersion,
|
||||
fixture.Remote,
|
||||
fixture.Entity,
|
||||
newerWorldPosition,
|
||||
destinationCell,
|
||||
new Vector3(3f, 10f, 50f),
|
||||
Quaternion.Identity,
|
||||
gameTime: 6.0,
|
||||
destinationProjectionVisible: true,
|
||||
generation: 1,
|
||||
positionSequence: 3);
|
||||
}
|
||||
|
||||
return SuccessfulPlacement(position, cell);
|
||||
});
|
||||
|
||||
RemoteTeleportController.Result outerResult = controller.TryApply(
|
||||
record,
|
||||
outerPositionAuthority,
|
||||
outerVelocityAuthority,
|
||||
fixture.Remote,
|
||||
fixture.Entity,
|
||||
new Vector3(193f, 10f, 50f),
|
||||
destinationCell,
|
||||
new Vector3(1f, 10f, 50f),
|
||||
Quaternion.Identity,
|
||||
gameTime: 5.0,
|
||||
destinationProjectionVisible: true,
|
||||
generation: 1,
|
||||
positionSequence: 2);
|
||||
|
||||
Assert.True(nestedResult.Applied);
|
||||
Assert.True(nestedResult.ContactResolved);
|
||||
Assert.True(outerResult.Superseded);
|
||||
Assert.False(outerResult.Applied);
|
||||
Assert.Equal(newerWorldPosition, fixture.Remote.Body.Position);
|
||||
Assert.Equal(newerWorldPosition, fixture.Entity.Position);
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolverAcceptsIndependentState_PositionPlacementStillCommits()
|
||||
{
|
||||
const uint guid = 0x70000061u;
|
||||
const uint sourceCell = 0xA9B40039u;
|
||||
const uint destinationCell = 0xAAB40001u;
|
||||
var fixture = CreateLoadedRemoteFixture(guid, sourceCell);
|
||||
Assert.True(fixture.Live.TryApplyPosition(
|
||||
PositionUpdate(guid, destinationCell, x: 1f, sequence: 2),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
Assert.True(fixture.Live.RebucketLiveEntity(guid, destinationCell));
|
||||
Assert.True(fixture.Live.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
ulong positionAuthority = record.PositionAuthorityVersion;
|
||||
ulong velocityAuthority = record.VelocityAuthorityVersion;
|
||||
bool stateAccepted = false;
|
||||
using var controller = new RemoteTeleportController(
|
||||
fixture.Engine,
|
||||
fixture.Live,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
CellLocal,
|
||||
(_, _, _) => { },
|
||||
(_, _, _) => { },
|
||||
(_, _) => { },
|
||||
resolvePlacement: (position, cell, _, _, _, _) =>
|
||||
{
|
||||
if (!stateAccepted)
|
||||
{
|
||||
stateAccepted = true;
|
||||
Assert.True(fixture.Live.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
guid,
|
||||
(uint)(PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.ReportCollisions),
|
||||
1,
|
||||
2),
|
||||
out _));
|
||||
}
|
||||
return SuccessfulPlacement(position, cell);
|
||||
});
|
||||
Vector3 destination = new(193f, 10f, 50f);
|
||||
|
||||
RemoteTeleportController.Result result = controller.TryApply(
|
||||
record,
|
||||
positionAuthority,
|
||||
velocityAuthority,
|
||||
fixture.Remote,
|
||||
fixture.Entity,
|
||||
destination,
|
||||
destinationCell,
|
||||
new Vector3(1f, 10f, 50f),
|
||||
Quaternion.Identity,
|
||||
gameTime: 5.0,
|
||||
destinationProjectionVisible: true,
|
||||
generation: 1,
|
||||
positionSequence: 2);
|
||||
|
||||
Assert.True(stateAccepted);
|
||||
Assert.True(result.Applied);
|
||||
Assert.True(result.ContactResolved);
|
||||
Assert.False(result.Superseded);
|
||||
Assert.Equal(destination, fixture.Remote.Body.Position);
|
||||
Assert.Equal(destination, fixture.Entity.Position);
|
||||
Assert.True(record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden));
|
||||
Assert.Equal(record.FinalPhysicsState, fixture.Remote.Body.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TeardownCallbackFailure_StillForgetsPendingBeforeGuidReuse()
|
||||
{
|
||||
|
|
@ -1009,6 +1170,75 @@ public sealed class RemoteTeleportControllerTests
|
|||
return engine;
|
||||
}
|
||||
|
||||
private static LoadedRemoteFixture CreateLoadedRemoteFixture(
|
||||
uint guid,
|
||||
uint sourceCell)
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu));
|
||||
spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu));
|
||||
var live = new LiveEntityRuntime(spatial, new Resources());
|
||||
live.RegisterLiveEntity(Spawn(guid, sourceCell));
|
||||
WorldEntity entity = live.MaterializeLiveEntity(
|
||||
guid,
|
||||
sourceCell,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(191f, 10f, 50f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
})!;
|
||||
var remote = new GameWindow.RemoteMotion();
|
||||
remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position);
|
||||
live.SetRemoteMotionRuntime(guid, remote);
|
||||
return new LoadedRemoteFixture(
|
||||
live,
|
||||
entity,
|
||||
remote,
|
||||
BuildEngine(includeDestination: true));
|
||||
}
|
||||
|
||||
private static WorldSession.EntityPositionUpdate PositionUpdate(
|
||||
uint guid,
|
||||
uint cell,
|
||||
float x,
|
||||
ushort sequence) => new(
|
||||
guid,
|
||||
new CreateObject.ServerPosition(
|
||||
cell,
|
||||
x,
|
||||
10f,
|
||||
50f,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
1,
|
||||
sequence,
|
||||
0,
|
||||
0);
|
||||
|
||||
private static ResolveResult SuccessfulPlacement(Vector3 position, uint cell) => new(
|
||||
position,
|
||||
cell,
|
||||
IsOnGround: true,
|
||||
InContact: true,
|
||||
OnWalkable: true,
|
||||
ContactPlane: new Plane(Vector3.UnitZ, 0f),
|
||||
ContactPlaneCellId: cell);
|
||||
|
||||
private readonly record struct LoadedRemoteFixture(
|
||||
LiveEntityRuntime Live,
|
||||
WorldEntity Entity,
|
||||
GameWindow.RemoteMotion Remote,
|
||||
PhysicsEngine Engine);
|
||||
|
||||
private static void AddDestination(PhysicsEngine engine) =>
|
||||
engine.AddLandblock(
|
||||
0xAAB4FFFFu,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -797,6 +797,7 @@ public sealed class LiveEntityLifecycleStressTests
|
|||
public Vector3 LastServerPosition { get; set; }
|
||||
public double LastServerPositionTime { get; set; }
|
||||
public Vector3 LastShadowSyncPosition { get; set; }
|
||||
public Quaternion LastShadowSyncOrientation { get; set; }
|
||||
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
|
||||
{
|
||||
_readCell = read;
|
||||
|
|
|
|||
|
|
@ -164,6 +164,113 @@ public sealed class LiveEntityPresentationControllerTests
|
|||
Assert.Equal([fixture.Entity.Id], fixture.PartArrayEnterWorld);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateAcceptedDuringHiddenEffect_DrainsAfterCompleteHiddenTail()
|
||||
{
|
||||
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
||||
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||
bool nestedAccepted = false;
|
||||
fixture.OnTypedPlay = type =>
|
||||
{
|
||||
if (type != LiveEntityPresentationController.HiddenScriptType
|
||||
|| nestedAccepted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
nestedAccepted = true;
|
||||
Assert.True(fixture.Runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
Fixture.Guid,
|
||||
(uint)PhysicsStateFlags.ReportCollisions,
|
||||
1,
|
||||
3),
|
||||
out _));
|
||||
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
||||
};
|
||||
|
||||
Assert.True(fixture.Runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
Fixture.Guid,
|
||||
(uint)(PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.ReportCollisions),
|
||||
1,
|
||||
2),
|
||||
out _));
|
||||
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
||||
|
||||
Assert.True(nestedAccepted);
|
||||
Assert.Equal(
|
||||
[
|
||||
"effect:76", "children:True", "part-array", "target",
|
||||
"effect:75", "children:False", "part-array",
|
||||
],
|
||||
fixture.PresentationOrder);
|
||||
Assert.True(fixture.Entity.IsDrawVisible);
|
||||
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteAndGuidReuseDuringHiddenEffect_StopsOldTransitionTail()
|
||||
{
|
||||
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
||||
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||
Assert.True(fixture.Runtime.TryGetRecord(
|
||||
Fixture.Guid,
|
||||
out LiveEntityRecord oldRecord));
|
||||
WorldEntity? replacement = null;
|
||||
fixture.OnTypedPlay = type =>
|
||||
{
|
||||
if (type != LiveEntityPresentationController.HiddenScriptType
|
||||
|| replacement is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
fixture.Controller.Forget(oldRecord);
|
||||
fixture.Shadows.Deregister(fixture.Entity.Id);
|
||||
Assert.True(fixture.Runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Fixture.Guid, 1),
|
||||
isLocalPlayer: false));
|
||||
fixture.Runtime.RegisterLiveEntity(Fixture.Spawn(
|
||||
PhysicsStateFlags.ReportCollisions,
|
||||
instanceSequence: 2));
|
||||
replacement = fixture.Runtime.MaterializeLiveEntity(
|
||||
Fixture.Guid,
|
||||
0x01010001u,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = Fixture.Guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(20f, 20f, 5f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
});
|
||||
Assert.NotNull(replacement);
|
||||
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||
};
|
||||
|
||||
Assert.True(fixture.Runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
Fixture.Guid,
|
||||
(uint)(PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.ReportCollisions),
|
||||
1,
|
||||
2),
|
||||
out _));
|
||||
Assert.False(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
||||
|
||||
Assert.NotNull(replacement);
|
||||
Assert.Equal(["effect:76"], fixture.PresentationOrder);
|
||||
Assert.Empty(fixture.ChildNoDraw);
|
||||
Assert.Empty(fixture.PartArrayEnterWorld);
|
||||
Assert.Empty(fixture.InvalidTargets);
|
||||
Assert.True(fixture.Runtime.TryGetRecord(Fixture.Guid, out var current));
|
||||
Assert.Same(replacement, current.WorldEntity);
|
||||
Assert.NotSame(oldRecord, current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnHideWhileLandblockPending_RestoresShadowWhenProjectionReturns()
|
||||
{
|
||||
|
|
@ -200,6 +307,285 @@ public sealed class LiveEntityPresentationControllerTests
|
|||
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VisibleOrdinaryProjectionPending_SuspendsAndHydrationRestoresShadow()
|
||||
{
|
||||
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
||||
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
||||
|
||||
Assert.True(fixture.Runtime.RebucketLiveEntity(
|
||||
Fixture.Guid,
|
||||
0x02020001u));
|
||||
|
||||
Assert.False(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid));
|
||||
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
||||
Assert.Equal(1, fixture.Shadows.RetainedRegistrationCount);
|
||||
Assert.Equal(1, fixture.Shadows.SuspendedRegistrationCount);
|
||||
Assert.True(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
|
||||
|
||||
fixture.Spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0202FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
|
||||
Assert.Same(
|
||||
fixture.Entity,
|
||||
Assert.Single(fixture.Runtime.WorldEntities).Value);
|
||||
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
||||
Assert.Equal(0, fixture.Shadows.SuspendedRegistrationCount);
|
||||
Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingVisibilityCallback_GuidReuseCannotRestoreOldShadowOwner()
|
||||
{
|
||||
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
||||
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||
Assert.True(fixture.Runtime.TryGetRecord(
|
||||
Fixture.Guid,
|
||||
out LiveEntityRecord oldRecord));
|
||||
WorldEntity oldEntity = fixture.Entity;
|
||||
WorldEntity? replacement = null;
|
||||
|
||||
fixture.Runtime.ProjectionVisibilityChanged += (record, visible) =>
|
||||
{
|
||||
if (visible
|
||||
|| replacement is not null
|
||||
|| !ReferenceEquals(record, oldRecord))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
fixture.Controller.Forget(oldRecord);
|
||||
fixture.Shadows.Deregister(oldEntity.Id);
|
||||
Assert.True(fixture.Runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Fixture.Guid, 1),
|
||||
isLocalPlayer: false));
|
||||
fixture.Runtime.RegisterLiveEntity(Fixture.Spawn(
|
||||
PhysicsStateFlags.ReportCollisions,
|
||||
instanceSequence: 2));
|
||||
replacement = fixture.Runtime.MaterializeLiveEntity(
|
||||
Fixture.Guid,
|
||||
0x01010001u,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = Fixture.Guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(20f, 20f, 5f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
});
|
||||
Assert.NotNull(replacement);
|
||||
fixture.Shadows.Register(
|
||||
replacement.Id,
|
||||
0x01000001u,
|
||||
replacement.Position,
|
||||
replacement.Rotation,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0x01010000u,
|
||||
state: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
seedCellId: 0x01010001u);
|
||||
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||
};
|
||||
|
||||
Assert.False(fixture.Runtime.RebucketLiveEntity(
|
||||
Fixture.Guid,
|
||||
0x02020001u));
|
||||
|
||||
Assert.NotNull(replacement);
|
||||
Assert.True(fixture.Runtime.TryGetRecord(Fixture.Guid, out var current));
|
||||
Assert.NotSame(oldRecord, current);
|
||||
Assert.Same(replacement, current.WorldEntity);
|
||||
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
||||
Assert.Equal(1, fixture.Shadows.RetainedRegistrationCount);
|
||||
Assert.Equal(0, fixture.Shadows.SuspendedRegistrationCount);
|
||||
Assert.Contains(
|
||||
fixture.Shadows.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == replacement.Id);
|
||||
Assert.DoesNotContain(
|
||||
fixture.Shadows.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == oldEntity.Id);
|
||||
Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialPendingProjection_ReadyBarrierSuspendsAndHydrationRestoresShadow()
|
||||
{
|
||||
const uint pendingCell = 0x02020001u;
|
||||
var spatial = new GpuWorldState();
|
||||
var shadows = new ShadowObjectRegistry();
|
||||
var runtime = new LiveEntityRuntime(
|
||||
spatial,
|
||||
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
||||
runtime.RegisterLiveEntity(Fixture.Spawn(
|
||||
PhysicsStateFlags.ReportCollisions));
|
||||
WorldEntity entity = Assert.IsType<WorldEntity>(
|
||||
runtime.MaterializeLiveEntity(
|
||||
Fixture.Guid,
|
||||
pendingCell,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = Fixture.Guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(10f, 10f, 5f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = pendingCell,
|
||||
}));
|
||||
shadows.Register(
|
||||
entity.Id,
|
||||
0x01000001u,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0x02020000u,
|
||||
state: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
seedCellId: pendingCell);
|
||||
using var controller = new LiveEntityPresentationController(
|
||||
runtime,
|
||||
shadows,
|
||||
(_, _, _) => true,
|
||||
liveCenter: () => (2, 2));
|
||||
|
||||
Assert.True(controller.OnLiveEntityReady(Fixture.Guid));
|
||||
|
||||
Assert.Equal(0, shadows.TotalRegistered);
|
||||
Assert.Equal(1, shadows.RetainedRegistrationCount);
|
||||
Assert.Equal(1, shadows.SuspendedRegistrationCount);
|
||||
Assert.True(controller.HasDeferredShadowRestore(Fixture.Guid));
|
||||
|
||||
spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0202FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
|
||||
Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value);
|
||||
Assert.Equal(1, shadows.TotalRegistered);
|
||||
Assert.Equal(0, shadows.SuspendedRegistrationCount);
|
||||
Assert.False(controller.HasDeferredShadowRestore(Fixture.Guid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialPendingHydrationCallback_GuidReuseCannotRestoreOldShadow()
|
||||
{
|
||||
const uint pendingCell = 0x02020001u;
|
||||
var spatial = new GpuWorldState();
|
||||
var shadows = new ShadowObjectRegistry();
|
||||
var runtime = new LiveEntityRuntime(
|
||||
spatial,
|
||||
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
||||
runtime.RegisterLiveEntity(Fixture.Spawn(
|
||||
PhysicsStateFlags.ReportCollisions));
|
||||
WorldEntity oldEntity = Assert.IsType<WorldEntity>(
|
||||
runtime.MaterializeLiveEntity(
|
||||
Fixture.Guid,
|
||||
pendingCell,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = Fixture.Guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(10f, 10f, 5f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = pendingCell,
|
||||
}));
|
||||
Assert.True(runtime.TryGetRecord(Fixture.Guid, out LiveEntityRecord oldRecord));
|
||||
shadows.Register(
|
||||
oldEntity.Id,
|
||||
0x01000001u,
|
||||
oldEntity.Position,
|
||||
oldEntity.Rotation,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0x02020000u,
|
||||
state: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
seedCellId: pendingCell);
|
||||
|
||||
LiveEntityPresentationController? controller = null;
|
||||
WorldEntity? replacement = null;
|
||||
runtime.ProjectionVisibilityChanged += (record, visible) =>
|
||||
{
|
||||
if (!visible
|
||||
|| replacement is not null
|
||||
|| !ReferenceEquals(record, oldRecord))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
controller!.Forget(oldRecord);
|
||||
shadows.Deregister(oldEntity.Id);
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Fixture.Guid, 1),
|
||||
isLocalPlayer: false));
|
||||
runtime.RegisterLiveEntity(Fixture.Spawn(
|
||||
PhysicsStateFlags.ReportCollisions,
|
||||
instanceSequence: 2));
|
||||
replacement = runtime.MaterializeLiveEntity(
|
||||
Fixture.Guid,
|
||||
pendingCell,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = Fixture.Guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(20f, 20f, 5f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = pendingCell,
|
||||
});
|
||||
Assert.NotNull(replacement);
|
||||
shadows.Register(
|
||||
replacement.Id,
|
||||
0x01000001u,
|
||||
replacement.Position,
|
||||
replacement.Rotation,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0x02020000u,
|
||||
state: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
seedCellId: pendingCell);
|
||||
Assert.True(controller.OnLiveEntityReady(Fixture.Guid));
|
||||
};
|
||||
controller = new LiveEntityPresentationController(
|
||||
runtime,
|
||||
shadows,
|
||||
(_, _, _) => true,
|
||||
liveCenter: () => (2, 2));
|
||||
Assert.True(controller.OnLiveEntityReady(Fixture.Guid));
|
||||
Assert.Equal(1, shadows.SuspendedRegistrationCount);
|
||||
|
||||
spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0202FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
|
||||
Assert.NotNull(replacement);
|
||||
Assert.True(runtime.TryGetRecord(Fixture.Guid, out var current));
|
||||
Assert.NotSame(oldRecord, current);
|
||||
Assert.Same(replacement, current.WorldEntity);
|
||||
Assert.Equal(1, shadows.TotalRegistered);
|
||||
Assert.Equal(1, shadows.RetainedRegistrationCount);
|
||||
Assert.Equal(0, shadows.SuspendedRegistrationCount);
|
||||
Assert.Contains(
|
||||
shadows.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == replacement.Id);
|
||||
Assert.DoesNotContain(
|
||||
shadows.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == oldEntity.Id);
|
||||
Assert.False(controller.HasDeferredShadowRestore(Fixture.Guid));
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivePlacement_IsGenerationScopedAndClearsOnTeardownAndReset()
|
||||
{
|
||||
|
|
@ -260,6 +646,7 @@ public sealed class LiveEntityPresentationControllerTests
|
|||
public ShadowObjectRegistry Shadows { get; } = new();
|
||||
public WorldEntity Entity { get; }
|
||||
public LiveEntityPresentationController Controller { get; }
|
||||
public Action<uint>? OnTypedPlay { get; set; }
|
||||
|
||||
public Fixture(
|
||||
PhysicsStateFlags initialState,
|
||||
|
|
@ -306,6 +693,7 @@ public sealed class LiveEntityPresentationControllerTests
|
|||
{
|
||||
TypedPlays.Add((owner, type, intensity));
|
||||
PresentationOrder.Add($"effect:{type:X2}");
|
||||
OnTypedPlay?.Invoke(type);
|
||||
return true;
|
||||
},
|
||||
(parent, noDraw) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Net;
|
||||
|
|
@ -189,6 +191,195 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.Equal(new[] { false, true }, visibilityEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticRootCommit_HiddenObjectUpdatesPoseWithoutRestoringShadow()
|
||||
{
|
||||
const uint guid = 0x70000071u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
WorldSession.EntitySpawn spawn = Spawn(
|
||||
guid,
|
||||
instance: 1,
|
||||
positionSequence: 1,
|
||||
cell: 0x01010001u,
|
||||
state: PhysicsStateFlags.Static | PhysicsStateFlags.ReportCollisions);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid))!;
|
||||
entity.Position = new Vector3(12f, 12f, 5f);
|
||||
PhysicsBody body = runtime.GetOrCreatePhysicsBody(
|
||||
guid,
|
||||
_ =>
|
||||
{
|
||||
var created = new PhysicsBody();
|
||||
created.SnapToCell(
|
||||
0x01010001u,
|
||||
entity.Position,
|
||||
new Vector3(12f, 12f, 5f));
|
||||
return created;
|
||||
});
|
||||
var shadows = new ShadowObjectRegistry();
|
||||
shadows.RegisterMultiPart(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
new[]
|
||||
{
|
||||
new ShadowShape(
|
||||
0x01000001u,
|
||||
Vector3.UnitX,
|
||||
Quaternion.Identity,
|
||||
Scale: 1f,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: 0.5f,
|
||||
CylHeight: 1f),
|
||||
},
|
||||
state: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
flags: EntityCollisionFlags.None,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0x01010000u,
|
||||
seedCellId: 0x01010001u);
|
||||
int poseUpdates = 0;
|
||||
var committer = new StaticLiveRootCommitter(
|
||||
() => runtime,
|
||||
shadows,
|
||||
() => (1, 1),
|
||||
_ => poseUpdates++);
|
||||
|
||||
body.SetFrameInCurrentCell(
|
||||
body.Position,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f));
|
||||
entity.Rotation = body.Orientation;
|
||||
Assert.True(committer.Commit(entity, body));
|
||||
ShadowEntry visible = Assert.Single(shadows.AllEntriesForDebug());
|
||||
Assert.InRange(visible.Position.X, 11.999f, 12.001f);
|
||||
Assert.InRange(visible.Position.Y, 12.999f, 13.001f);
|
||||
|
||||
Assert.True(runtime.TryApplyState(new SetState.Parsed(
|
||||
guid,
|
||||
(uint)(PhysicsStateFlags.Static
|
||||
| PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.IgnoreCollisions),
|
||||
InstanceSequence: 1,
|
||||
StateSequence: 2), out _));
|
||||
Assert.True(shadows.Suspend(entity.Id));
|
||||
body.SetFrameInCurrentCell(
|
||||
body.Position,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI));
|
||||
entity.Rotation = body.Orientation;
|
||||
|
||||
Assert.True(committer.Commit(entity, body));
|
||||
Assert.Equal(2, poseUpdates);
|
||||
Assert.Empty(shadows.AllEntriesForDebug());
|
||||
Assert.Equal(1, shadows.SuspendedRegistrationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticRootCommit_BeforeLiveRuntimeCompositionIsSafeNoOp()
|
||||
{
|
||||
LiveEntityRuntime? runtime = null;
|
||||
int poseUpdates = 0;
|
||||
var committer = new StaticLiveRootCommitter(
|
||||
() => runtime,
|
||||
new ShadowObjectRegistry(),
|
||||
() => (0, 0),
|
||||
_ => poseUpdates++);
|
||||
|
||||
Assert.False(committer.Commit(
|
||||
Entity(0x7F000001u, 0x70000001u),
|
||||
new PhysicsBody()));
|
||||
Assert.Equal(0, poseUpdates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticRootCommit_EffectCallbackReplacesGuid_DoesNotRestoreOldShadow()
|
||||
{
|
||||
const uint guid = 0x70000074u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(
|
||||
guid,
|
||||
instance: 1,
|
||||
positionSequence: 1,
|
||||
cell: 0x01010001u,
|
||||
state: PhysicsStateFlags.Static | PhysicsStateFlags.ReportCollisions));
|
||||
WorldEntity oldEntity = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid))!;
|
||||
oldEntity.Position = new Vector3(12f, 12f, 5f);
|
||||
PhysicsBody oldBody = runtime.GetOrCreatePhysicsBody(
|
||||
guid,
|
||||
_ =>
|
||||
{
|
||||
var created = new PhysicsBody();
|
||||
created.SnapToCell(
|
||||
0x01010001u,
|
||||
oldEntity.Position,
|
||||
oldEntity.Position);
|
||||
return created;
|
||||
});
|
||||
var shadows = new ShadowObjectRegistry();
|
||||
shadows.RegisterMultiPart(
|
||||
oldEntity.Id,
|
||||
oldEntity.Position,
|
||||
oldEntity.Rotation,
|
||||
new[]
|
||||
{
|
||||
new ShadowShape(
|
||||
0x01000001u,
|
||||
Vector3.UnitX,
|
||||
Quaternion.Identity,
|
||||
Scale: 1f,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: 0.5f,
|
||||
CylHeight: 1f),
|
||||
},
|
||||
state: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
flags: EntityCollisionFlags.None,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0x01010000u,
|
||||
seedCellId: 0x01010001u);
|
||||
var committer = new StaticLiveRootCommitter(
|
||||
() => runtime,
|
||||
shadows,
|
||||
() => (1, 1),
|
||||
_ =>
|
||||
{
|
||||
Assert.True(shadows.Suspend(oldEntity.Id));
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(guid, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
runtime.RegisterLiveEntity(Spawn(
|
||||
guid,
|
||||
instance: 2,
|
||||
positionSequence: 1,
|
||||
cell: 0x01010001u));
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid));
|
||||
});
|
||||
|
||||
oldBody.SetFrameInCurrentCell(
|
||||
oldBody.Position,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f));
|
||||
oldEntity.Rotation = oldBody.Orientation;
|
||||
|
||||
Assert.False(committer.Commit(oldEntity, oldBody));
|
||||
Assert.Empty(shadows.AllEntriesForDebug());
|
||||
Assert.Equal(1, shadows.SuspendedRegistrationCount);
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
|
||||
Assert.Equal((ushort)2, replacement.Generation);
|
||||
Assert.NotSame(oldEntity, replacement.WorldEntity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpatialComponentWorksets_FollowLoadedProjectionWhileLogicalOwnersSurvive()
|
||||
{
|
||||
|
|
@ -313,6 +504,41 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.Equal(2, runtime.AnimationRuntimes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnimationView_HotSpatialTraversalDoesNotAllocateAfterWarmup()
|
||||
{
|
||||
const uint guid = 0x70000075u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid))!;
|
||||
runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity));
|
||||
var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(
|
||||
() => runtime);
|
||||
var ids = new HashSet<uint>();
|
||||
|
||||
view.CopySpatialIdsTo(ids);
|
||||
foreach (KeyValuePair<uint, AnimationRuntime> _ in view) { }
|
||||
|
||||
int visits = 0;
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int i = 0; i < 1_000; i++)
|
||||
{
|
||||
view.CopySpatialIdsTo(ids);
|
||||
foreach (KeyValuePair<uint, AnimationRuntime> _ in view)
|
||||
visits++;
|
||||
}
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.Equal(0, allocated);
|
||||
Assert.Equal(1_000, visits);
|
||||
Assert.Equal(entity.Id, Assert.Single(ids));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpatialComponentWorksets_IsolateGuidReuseAcrossVisibilityCallbacks()
|
||||
{
|
||||
|
|
@ -350,6 +576,60 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.NotSame(oldAnimation, indexed.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalPhysicsBody_IsCreatedOncePerIncarnationAndLaterReused()
|
||||
{
|
||||
const uint guid = 0x70000073u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid));
|
||||
int factoryCalls = 0;
|
||||
|
||||
PhysicsBody first = runtime.GetOrCreatePhysicsBody(
|
||||
guid,
|
||||
_ =>
|
||||
{
|
||||
factoryCalls++;
|
||||
return new PhysicsBody();
|
||||
});
|
||||
PhysicsBody retained = runtime.GetOrCreatePhysicsBody(
|
||||
guid,
|
||||
_ =>
|
||||
{
|
||||
factoryCalls++;
|
||||
return new PhysicsBody();
|
||||
});
|
||||
|
||||
Assert.Same(first, retained);
|
||||
Assert.Equal(1, factoryCalls);
|
||||
Assert.True(runtime.TryGetRecord(guid, out var firstRecord));
|
||||
Assert.Same(first, firstRecord.PhysicsBody);
|
||||
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(guid, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u));
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid));
|
||||
PhysicsBody replacement = runtime.GetOrCreatePhysicsBody(
|
||||
guid,
|
||||
_ =>
|
||||
{
|
||||
factoryCalls++;
|
||||
return new PhysicsBody();
|
||||
});
|
||||
|
||||
Assert.NotSame(first, replacement);
|
||||
Assert.Equal(2, factoryCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectProfile_SurvivesRebucketAndClearsWithLogicalTeardown()
|
||||
{
|
||||
|
|
@ -667,15 +947,24 @@ public sealed class LiveEntityRuntimeTests
|
|||
id => Entity(id, guid))!;
|
||||
var animation = new AnimationRuntime(entity);
|
||||
runtime.SetAnimationRuntime(guid, animation);
|
||||
var remote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(guid, remote);
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
ulong initialClockEpoch = record.ObjectClockEpoch;
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.True(remote.Body.IsActive);
|
||||
uint localId = entity.Id;
|
||||
Assert.True(runtime.TryMarkWorldSpawnPublished(guid));
|
||||
|
||||
Assert.True(runtime.TryApplyPickup(
|
||||
new PickupEvent.Parsed(guid, spawn.InstanceSequence, 11),
|
||||
out _));
|
||||
Assert.False(record.ObjectClock.IsActive);
|
||||
Assert.False(remote.Body.IsActive);
|
||||
Assert.Equal(initialClockEpoch + 1, record.ObjectClockEpoch);
|
||||
Assert.True(runtime.WithdrawLiveEntityProjection(guid));
|
||||
Assert.Equal(initialClockEpoch + 1, record.ObjectClockEpoch);
|
||||
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
Assert.Equal(0u, record.FullCellId);
|
||||
Assert.Equal(0u, record.CanonicalLandblockId);
|
||||
Assert.False(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
|
|
@ -711,6 +1000,9 @@ public sealed class LiveEntityRuntimeTests
|
|||
|
||||
Assert.Same(entity, same);
|
||||
Assert.Equal(localId, same.Id);
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.True(remote.Body.IsActive);
|
||||
Assert.Equal(initialClockEpoch + 2, record.ObjectClockEpoch);
|
||||
Assert.False(runtime.TryMarkWorldSpawnPublished(guid));
|
||||
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
Assert.Equal(0x01010002u, record.FullCellId);
|
||||
|
|
@ -760,6 +1052,267 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectClock_SurvivesLoadedRebucketAndRebasesPendingReentry()
|
||||
{
|
||||
const uint guid = 0x70000047u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
|
||||
LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!;
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid));
|
||||
RetailObjectQuantumClock clock = record.ObjectClock;
|
||||
Assert.Equal(0, clock.Advance(0.02).Count);
|
||||
|
||||
Assert.True(runtime.RebucketLiveEntity(guid, 0x01020001u));
|
||||
Assert.Same(clock, record.ObjectClock);
|
||||
Assert.True(clock.IsActive);
|
||||
Assert.Equal(0.02, clock.PendingSeconds, 8);
|
||||
|
||||
Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u));
|
||||
Assert.False(clock.IsActive);
|
||||
Assert.Equal(0.02, clock.PendingSeconds, 8);
|
||||
spatial.AddLandblock(EmptyLandblock(0x0202FFFFu));
|
||||
Assert.True(clock.IsActive);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
|
||||
Assert.Equal(
|
||||
RetailObjectActivityResult.Active,
|
||||
RetailObjectActivityGate.Evaluate(
|
||||
clock,
|
||||
body: null,
|
||||
lifecycleEligible: runtime.ShouldAdvanceRootRuntime(guid),
|
||||
hasPartArray: true,
|
||||
isStatic: false,
|
||||
objectPosition: Vector3.Zero,
|
||||
playerPosition: Vector3.Zero,
|
||||
elapsedSeconds: 0.1));
|
||||
Assert.Same(clock, record.ObjectClock);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitiallyVisibleStaticObject_RebasesWithoutBecomingActive()
|
||||
{
|
||||
const uint guid = 0x70000049u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
WorldSession.EntitySpawn spawn = Spawn(
|
||||
guid,
|
||||
1,
|
||||
1,
|
||||
0x01010001u,
|
||||
PhysicsStateFlags.Static);
|
||||
LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!;
|
||||
var remote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(guid, remote);
|
||||
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid));
|
||||
|
||||
Assert.False(record.ObjectClock.IsActive);
|
||||
Assert.Equal(0.0, record.ObjectClock.PendingSeconds, 8);
|
||||
Assert.False(remote.Body.TransientState.HasFlag(TransientStateFlags.Active));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MovementActivation_ReactivatesExactIncarnation_ButStaticIsNoOp()
|
||||
{
|
||||
const uint ordinaryGuid = 0x70000050u;
|
||||
const uint staticGuid = 0x70000051u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
|
||||
LiveEntityRecord ordinary = runtime.RegisterLiveEntity(
|
||||
Spawn(ordinaryGuid, 1, 1, 0x01010001u)).Record!;
|
||||
var ordinaryRemote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(ordinaryGuid, ordinaryRemote);
|
||||
runtime.MaterializeLiveEntity(
|
||||
ordinaryGuid,
|
||||
0x01010001u,
|
||||
id => Entity(id, ordinaryGuid));
|
||||
ordinary.ObjectClock.Deactivate();
|
||||
ordinaryRemote.Body.TransientState &= ~TransientStateFlags.Active;
|
||||
|
||||
Assert.True(runtime.TryActivateOrdinaryObject(ordinaryGuid, ordinaryRemote));
|
||||
Assert.True(ordinary.ObjectClock.IsActive);
|
||||
Assert.True(ordinaryRemote.Body.TransientState.HasFlag(TransientStateFlags.Active));
|
||||
|
||||
LiveEntityRecord staticRecord = runtime.RegisterLiveEntity(
|
||||
Spawn(staticGuid, 1, 1, 0x01010001u, PhysicsStateFlags.Static)).Record!;
|
||||
var staticRemote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(staticGuid, staticRemote);
|
||||
runtime.MaterializeLiveEntity(
|
||||
staticGuid,
|
||||
0x01010001u,
|
||||
id => Entity(id, staticGuid));
|
||||
|
||||
Assert.False(runtime.TryActivateOrdinaryObject(staticGuid, staticRemote));
|
||||
Assert.False(staticRecord.ObjectClock.IsActive);
|
||||
Assert.False(staticRemote.Body.TransientState.HasFlag(TransientStateFlags.Active));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeVector_WakesOrdinaryClockButPreservesStaticActivity()
|
||||
{
|
||||
const uint ordinaryGuid = 0x70000052u;
|
||||
const uint defensiveGuid = 0x70000053u;
|
||||
const uint staticGuid = 0x70000054u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
|
||||
LiveEntityRecord ordinary = runtime.RegisterLiveEntity(
|
||||
Spawn(ordinaryGuid, 1, 1, 0x01010001u)).Record!;
|
||||
var ordinaryRemote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(ordinaryGuid, ordinaryRemote);
|
||||
runtime.MaterializeLiveEntity(
|
||||
ordinaryGuid,
|
||||
0x01010001u,
|
||||
id => Entity(id, ordinaryGuid));
|
||||
ordinary.ObjectClock.Deactivate();
|
||||
ordinaryRemote.Body.TransientState &= ~TransientStateFlags.Active;
|
||||
ordinaryRemote.Body.LastUpdateTime = 1.0;
|
||||
|
||||
Assert.True(runtime.TryCommitAuthoritativeVector(
|
||||
ordinary,
|
||||
ordinaryRemote.Body,
|
||||
new Vector3(1f, 2f, 3f),
|
||||
new Vector3(4f, 5f, 6f),
|
||||
currentTime: 8.0));
|
||||
Assert.True(ordinary.ObjectClock.IsActive);
|
||||
Assert.True(ordinaryRemote.Body.IsActive);
|
||||
Assert.Equal(8.0, ordinaryRemote.Body.LastUpdateTime);
|
||||
Assert.Equal(new Vector3(1f, 2f, 3f), ordinaryRemote.Body.Velocity);
|
||||
Assert.Equal(new Vector3(4f, 5f, 6f), ordinaryRemote.Body.Omega);
|
||||
|
||||
// Defensive split state: the retained object clock is already active,
|
||||
// but a body consumer cleared its legacy Active bit. set_velocity must
|
||||
// still rebase that body's compatibility timestamp.
|
||||
LiveEntityRecord defensive = runtime.RegisterLiveEntity(
|
||||
Spawn(defensiveGuid, 1, 1, 0x01010001u)).Record!;
|
||||
var defensiveRemote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(defensiveGuid, defensiveRemote);
|
||||
runtime.MaterializeLiveEntity(
|
||||
defensiveGuid,
|
||||
0x01010001u,
|
||||
id => Entity(id, defensiveGuid));
|
||||
Assert.True(defensive.ObjectClock.IsActive);
|
||||
defensiveRemote.Body.TransientState &= ~TransientStateFlags.Active;
|
||||
defensiveRemote.Body.LastUpdateTime = 2.0;
|
||||
|
||||
Assert.True(runtime.TryCommitAuthoritativeVelocity(
|
||||
defensive,
|
||||
defensiveRemote.Body,
|
||||
Vector3.UnitX,
|
||||
currentTime: 9.0));
|
||||
Assert.True(defensive.ObjectClock.IsActive);
|
||||
Assert.True(defensiveRemote.Body.IsActive);
|
||||
Assert.Equal(9.0, defensiveRemote.Body.LastUpdateTime);
|
||||
|
||||
LiveEntityRecord staticRecord = runtime.RegisterLiveEntity(
|
||||
Spawn(
|
||||
staticGuid,
|
||||
1,
|
||||
1,
|
||||
0x01010001u,
|
||||
PhysicsStateFlags.Static)).Record!;
|
||||
var staticRemote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(staticGuid, staticRemote);
|
||||
runtime.MaterializeLiveEntity(
|
||||
staticGuid,
|
||||
0x01010001u,
|
||||
id => Entity(id, staticGuid));
|
||||
staticRemote.Body.TransientState &= ~TransientStateFlags.Active;
|
||||
staticRemote.Body.LastUpdateTime = 3.0;
|
||||
|
||||
Assert.True(runtime.TryCommitAuthoritativeVector(
|
||||
staticRecord,
|
||||
staticRemote.Body,
|
||||
new Vector3(7f, 8f, 9f),
|
||||
new Vector3(10f, 11f, 12f),
|
||||
currentTime: 10.0));
|
||||
Assert.False(staticRecord.ObjectClock.IsActive);
|
||||
Assert.False(staticRemote.Body.IsActive);
|
||||
Assert.Equal(3.0, staticRemote.Body.LastUpdateTime);
|
||||
Assert.Equal(new Vector3(7f, 8f, 9f), staticRemote.Body.Velocity);
|
||||
Assert.Equal(new Vector3(10f, 11f, 12f), staticRemote.Body.Omega);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParentAndPickupInvalidateOlderPositionAndVelocityAuthority()
|
||||
{
|
||||
const uint parentGuid = 0x50000010u;
|
||||
const uint childGuid = 0x70000055u;
|
||||
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(parentGuid, 1, 1, 0x01010001u));
|
||||
LiveEntityRecord child = runtime.RegisterLiveEntity(
|
||||
Spawn(childGuid, 1, 1, 0x01010001u)).Record!;
|
||||
ulong initialPosition = child.PositionAuthorityVersion;
|
||||
ulong initialVelocity = child.VelocityAuthorityVersion;
|
||||
|
||||
Assert.True(runtime.TryApplyParent(
|
||||
new ParentEvent.Parsed(
|
||||
parentGuid,
|
||||
childGuid,
|
||||
ParentLocation: 0x00000001u,
|
||||
PlacementId: 0x00000002u,
|
||||
ParentInstanceSequence: 1,
|
||||
ChildPositionSequence: 2),
|
||||
out _));
|
||||
Assert.False(runtime.IsCurrentPositionAuthority(child, initialPosition));
|
||||
Assert.False(runtime.IsCurrentVelocityAuthority(child, initialVelocity));
|
||||
ulong parentedPosition = child.PositionAuthorityVersion;
|
||||
ulong parentedVelocity = child.VelocityAuthorityVersion;
|
||||
|
||||
Assert.True(runtime.TryApplyPickup(
|
||||
new PickupEvent.Parsed(childGuid, 1, 3),
|
||||
out _));
|
||||
Assert.False(runtime.IsCurrentPositionAuthority(child, parentedPosition));
|
||||
Assert.False(runtime.IsCurrentVelocityAuthority(child, parentedVelocity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectClock_WithdrawalRetainsIncarnationButGuidReuseGetsFreshOwner()
|
||||
{
|
||||
const uint guid = 0x70000048u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
WorldSession.EntitySpawn firstSpawn = Spawn(guid, 1, 1, 0x01010001u);
|
||||
LiveEntityRecord first = runtime.RegisterLiveEntity(firstSpawn).Record!;
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
firstSpawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid));
|
||||
RetailObjectQuantumClock firstClock = first.ObjectClock;
|
||||
Assert.Equal(0, firstClock.Advance(0.02).Count);
|
||||
|
||||
Assert.True(runtime.WithdrawLiveEntityProjection(guid));
|
||||
Assert.Same(firstClock, first.ObjectClock);
|
||||
Assert.False(firstClock.IsActive);
|
||||
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(guid, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
WorldSession.EntitySpawn secondSpawn = Spawn(guid, 2, 2, 0x01010001u);
|
||||
LiveEntityRecord second = runtime.RegisterLiveEntity(secondSpawn).Record!;
|
||||
|
||||
Assert.NotSame(first, second);
|
||||
Assert.NotSame(firstClock, second.ObjectClock);
|
||||
Assert.True(second.ObjectClock.IsActive);
|
||||
Assert.Equal(0.0, second.ObjectClock.PendingSeconds, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhysicsStateAndRemoteBodyStaySynchronizedAcrossEitherArrivalOrder()
|
||||
{
|
||||
|
|
@ -1625,6 +2178,94 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.Same(record.WorldEntity, Assert.Single(spatial.Entities));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferredWithdrawFalseEdge_RebucketSameRecordSupersedesOuterWithdrawal()
|
||||
{
|
||||
const uint guid = 0x70000075u;
|
||||
var spatial = new GpuWorldState();
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
|
||||
WorldEntity pending = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid))!;
|
||||
Assert.False(runtime.TryGetRecord(guid, out LiveEntityRecord before)
|
||||
&& before.IsSpatiallyVisible);
|
||||
bool? nestedWithdrawResult = null;
|
||||
bool nestedWithdrawStarted = false;
|
||||
bool reprojected = false;
|
||||
runtime.ProjectionVisibilityChanged += (_, visible) =>
|
||||
{
|
||||
if (visible && !nestedWithdrawStarted)
|
||||
{
|
||||
// GpuWorldState is currently draining the outer visible edge,
|
||||
// so Withdraw must manually publish its false edge.
|
||||
nestedWithdrawStarted = true;
|
||||
nestedWithdrawResult = runtime.WithdrawLiveEntityProjection(guid);
|
||||
return;
|
||||
}
|
||||
if (!visible && !reprojected)
|
||||
{
|
||||
reprojected = true;
|
||||
Assert.True(runtime.RebucketLiveEntity(guid, 0x01010022u));
|
||||
}
|
||||
};
|
||||
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
|
||||
Assert.False(nestedWithdrawResult);
|
||||
Assert.True(reprojected);
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
Assert.Equal(0x01010022u, record.FullCellId);
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.Same(pending, record.WorldEntity);
|
||||
Assert.Same(pending, Assert.Single(runtime.MaterializedWorldEntities).Value);
|
||||
Assert.Same(pending, Assert.Single(runtime.WorldEntities).Value);
|
||||
Assert.Same(pending, Assert.Single(spatial.Entities));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaterializeVisibilityCallback_GuidReplacementNeverReturnsOldTombstone()
|
||||
{
|
||||
const uint guid = 0x70000076u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new FailingOnceUnregisterResources(guid);
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
|
||||
WorldEntity? replacementEntity = null;
|
||||
bool replaced = false;
|
||||
runtime.ProjectionVisibilityChanged += (edgeRecord, visible) =>
|
||||
{
|
||||
if (replaced || !visible || edgeRecord.Generation != 1)
|
||||
return;
|
||||
replaced = true;
|
||||
LiveEntityRegistrationResult replacement = runtime.RegisterLiveEntity(
|
||||
Spawn(guid, 2, 1, 0x01010002u));
|
||||
Assert.NotNull(replacement.PriorGenerationCleanupFailure);
|
||||
replacementEntity = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010002u,
|
||||
id => Entity(id, guid));
|
||||
};
|
||||
|
||||
WorldEntity? displaced = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid));
|
||||
|
||||
Assert.Null(displaced);
|
||||
Assert.NotNull(replacementEntity);
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
|
||||
Assert.Equal((ushort)2, current.Generation);
|
||||
Assert.Same(replacementEntity, current.WorldEntity);
|
||||
Assert.Same(replacementEntity, Assert.Single(runtime.WorldEntities).Value);
|
||||
Assert.Equal(1, runtime.PendingTeardownCount);
|
||||
Assert.Equal(1, runtime.RetryPendingTeardowns());
|
||||
Assert.Equal(0, runtime.PendingTeardownCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebucketSpatialCallback_NewerSameRecordRebucketSupersedesOuterMove()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
using AcDream.App.World;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
public sealed class RetailInboundEventDispatcherTests
|
||||
{
|
||||
private sealed class Counter
|
||||
{
|
||||
public int Value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedEvents_DrainAfterCompleteOuterTail_InArrivalOrder()
|
||||
{
|
||||
var dispatcher = new RetailInboundEventDispatcher();
|
||||
var calls = new List<string>();
|
||||
|
||||
dispatcher.Run(() =>
|
||||
{
|
||||
calls.Add("position-head");
|
||||
dispatcher.Run(() => calls.Add("state"));
|
||||
dispatcher.Run(() => calls.Add("vector"));
|
||||
calls.Add("position-tail");
|
||||
});
|
||||
|
||||
Assert.Equal(
|
||||
["position-head", "position-tail", "state", "vector"],
|
||||
calls);
|
||||
Assert.False(dispatcher.IsDraining);
|
||||
Assert.Equal(0, dispatcher.PendingCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameOperation_DefersInboundMutationUntilQuantumTail()
|
||||
{
|
||||
var dispatcher = new RetailInboundEventDispatcher();
|
||||
var calls = new List<string>();
|
||||
|
||||
dispatcher.Run(() =>
|
||||
{
|
||||
calls.Add("physics");
|
||||
dispatcher.Run(() => calls.Add("position"));
|
||||
calls.Add("shadow-and-hooks");
|
||||
});
|
||||
|
||||
Assert.Equal(["physics", "shadow-and-hooks", "position"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedStateOperations_DrainAfterOuterTail_InArrivalOrder()
|
||||
{
|
||||
var dispatcher = new RetailInboundEventDispatcher();
|
||||
var calls = new List<string>();
|
||||
|
||||
dispatcher.Run(
|
||||
dispatcher,
|
||||
calls,
|
||||
static (active, output) =>
|
||||
{
|
||||
output.Add("position-head");
|
||||
active.Run(
|
||||
output,
|
||||
"state",
|
||||
static (target, value) => target.Add(value));
|
||||
output.Add("position-tail");
|
||||
});
|
||||
|
||||
Assert.Equal(["position-head", "position-tail", "state"], calls);
|
||||
Assert.False(dispatcher.IsDraining);
|
||||
Assert.Equal(0, dispatcher.PendingCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_DiscardsQueuedTailAndLeavesDispatcherReusable()
|
||||
{
|
||||
var dispatcher = new RetailInboundEventDispatcher();
|
||||
bool queuedRan = false;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => dispatcher.Run(() =>
|
||||
{
|
||||
dispatcher.Run(() => queuedRan = true);
|
||||
throw new InvalidOperationException("packet failed");
|
||||
}));
|
||||
|
||||
Assert.False(queuedRan);
|
||||
Assert.False(dispatcher.IsDraining);
|
||||
Assert.Equal(0, dispatcher.PendingCount);
|
||||
|
||||
dispatcher.Run(() => queuedRan = true);
|
||||
Assert.True(queuedRan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateFastPath_DoesNotAllocateAfterWarmup()
|
||||
{
|
||||
var dispatcher = new RetailInboundEventDispatcher();
|
||||
var counter = new Counter();
|
||||
Action<Counter, int> increment =
|
||||
static (target, amount) => target.Value += amount;
|
||||
dispatcher.Run(counter, 1, increment);
|
||||
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int i = 0; i < 1_000; i++)
|
||||
{
|
||||
dispatcher.Run(counter, 1, increment);
|
||||
}
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.Equal(0, allocated);
|
||||
Assert.Equal(1_001, counter.Value);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue