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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue