acdream/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs
Erik f961d70023 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>
2026-07-20 09:10:31 +02:00

1031 lines
37 KiB
C#

using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
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()
{
var motion = new GameWindow.RemoteMotion();
motion.Body.Position = Vector3.Zero;
motion.Body.Orientation = Quaternion.Identity;
motion.Body.Velocity = new Vector3(4f, 0f, 5f);
motion.Body.Acceleration = new Vector3(0f, 0f, -9.8f);
motion.CellId = 0x01010001u;
motion.Interp.Enqueue(
new Vector3(1f, 0f, 0f),
heading: 0f,
isMovingTo: false,
currentBodyPosition: Vector3.Zero);
var entity = new WorldEntity
{
Id = 1u,
ServerGuid = 0x70000001u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
var updater = new RemotePhysicsUpdater(
new PhysicsEngine(),
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var poses = new EntityEffectPoseRegistry();
poses.Publish(entity, Array.Empty<Matrix4x4>());
updater.TickHidden(motion, entity, 0.1f);
Assert.True(poses.UpdateRoot(entity));
Assert.InRange(motion.Body.Position.X, 0.01f, 1f);
Assert.Equal(0f, motion.Body.Position.Z);
Assert.Equal(new Vector3(4f, 0f, 5f), motion.Body.Velocity);
Assert.Equal(motion.Body.Position, entity.Position);
Assert.Equal(motion.CellId, entity.ParentCellId);
Assert.True(poses.TryGetRootPose(entity.Id, out Matrix4x4 root));
Assert.Equal(entity.Position, root.Translation);
}
[Fact]
public void TickHidden_RunsPartArrayManagerTailWithoutAdvancingSequence()
{
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 = 2u,
ServerGuid = 0x70000002u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
AnimationSequencer sequencer = CreateSequencer();
sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0);
updater.TickHidden(
motion,
entity,
0.1f,
sequencer.Manager);
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]
public void TickHidden_CrossCellCommitUpdatesCanonicalCellBeforeUnhide()
{
var engine = BuildBoundaryEngine();
var updater = new RemotePhysicsUpdater(
engine,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var motion = new GameWindow.RemoteMotion();
motion.Body.Position = new Vector3(191f, 10f, 50f);
motion.Body.Orientation = Quaternion.Identity;
motion.Body.TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable;
uint canonicalCell = 0xA9B40039u;
int canonicalWrites = 0;
motion.BindCanonicalCell(
() => canonicalCell,
value =>
{
canonicalCell = value;
canonicalWrites++;
});
motion.CellId = canonicalCell;
canonicalWrites = 0;
motion.Interp.Enqueue(
new Vector3(193f, 10f, 50f),
heading: 0f,
isMovingTo: false,
currentBodyPosition: motion.Body.Position);
var entity = new WorldEntity
{
Id = 1u,
ServerGuid = 0x70000002u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = motion.Body.Position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
updater.TickHidden(motion, entity, 2f);
Assert.Equal(0xAAB40001u, canonicalCell);
Assert.Equal(canonicalCell, entity.ParentCellId);
Assert.True(canonicalWrites > 0);
Assert.True(entity.Position.X > 192f);
}
[Fact]
public void TickHidden_OutOfContactDoesNotInterpolateOrInventLanding()
{
var engine = BuildBoundaryEngine();
var updater = new RemotePhysicsUpdater(
engine,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var motion = new GameWindow.RemoteMotion
{
Airborne = true,
};
motion.Body.Position = new Vector3(10f, 10f, 50f);
motion.Body.Orientation = Quaternion.Identity;
motion.Body.TransientState = TransientStateFlags.Active;
motion.CellId = 0xA9B40001u;
motion.Interp.Enqueue(
new Vector3(11f, 10f, 50f),
heading: 0f,
isMovingTo: false,
currentBodyPosition: motion.Body.Position);
var entity = new WorldEntity
{
Id = 3u,
ServerGuid = 0x70000003u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = motion.Body.Position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
updater.TickHidden(motion, entity, 0.1f);
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]
public void TickHiddenEntities_DeletionCallbackCannotAdvanceStaleSnapshotOwner()
{
const uint firstGuid = 0x70000011u;
const uint secondGuid = 0x70000012u;
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
var live = new LiveEntityRuntime(
spatial,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
BindHiddenRemote(live, firstGuid);
BindHiddenRemote(live, secondGuid);
Assert.Equal(2, live.SpatialRemoteMotionRuntimes.Count);
var updater = new RemotePhysicsUpdater(
new PhysicsEngine(),
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var published = new List<uint>();
var partPoseDirty = new List<uint>();
updater.TickHiddenEntities(
live,
localPlayerServerGuid: 0x50000001u,
dt: 0.1f,
entity =>
{
published.Add(entity.ServerGuid);
uint other = entity.ServerGuid == firstGuid ? secondGuid : firstGuid;
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);
}
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 void BindHiddenRemote(LiveEntityRuntime live, uint guid)
{
const uint cellId = 0x01010001u;
PhysicsStateFlags state = PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions;
var position = new CreateObject.ServerPosition(
cellId, 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);
live.RegisterLiveEntity(new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"hidden fixture",
null,
null,
0x09000001u,
PhysicsState: (uint)state,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics));
WorldEntity entity = live.MaterializeLiveEntity(
guid,
cellId,
id => new WorldEntity
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(10f, 10f, 5f),
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = cellId,
})!;
var remote = new GameWindow.RemoteMotion();
remote.Body.Position = entity.Position;
remote.Body.Orientation = entity.Rotation;
remote.CellId = cellId;
remote.Interp.Enqueue(
entity.Position + Vector3.UnitX,
heading: 0f,
isMovingTo: false,
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;
}
}