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:
Erik 2026-07-20 09:10:31 +02:00
parent 31a0889f08
commit f961d70023
77 changed files with 12513 additions and 1871 deletions

View file

@ -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;
}
}

View file

@ -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]

View file

@ -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 =

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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,