acdream/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs
Erik 2aee33569f refactor(runtime): own projectile simulation
Move projectile component identity, prediction invalidation, spatial worksets, authoritative corrections, and the retail physics step into AcDream.Runtime. Keep App as the DAT-shape and presentation adapter so ACE outcomes and visible behavior remain unchanged.
2026-07-26 14:17:42 +02:00

610 lines
22 KiB
C#

using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
using AcDream.Core.Physics.Motion;
using System.Numerics;
namespace AcDream.Runtime.Tests.Physics;
public sealed class RuntimePhysicsStateTests
{
[Fact]
public void EntityLifetimeConstructsOneCanonicalProductionPhysicsWorld()
{
using var lifetime = new RuntimeEntityObjectLifetime();
Assert.Same(
lifetime.Physics.DataCache,
lifetime.Physics.Engine.DataCache);
Assert.True(lifetime.Physics.CaptureOwnership().OwnsProductionDataCache);
Assert.Equal(0, lifetime.Physics.CaptureOwnership().LandblockCount);
Assert.False(lifetime.Physics.CaptureOwnership().IsDisposed);
}
[Fact]
public void ConcurrentRuntimeLifetimesDoNotShareMutablePhysicsState()
{
using var first = new RuntimeEntityObjectLifetime();
using var second = new RuntimeEntityObjectLifetime();
Assert.NotSame(first.Physics, second.Physics);
Assert.NotSame(first.Physics.Engine, second.Physics.Engine);
Assert.NotSame(first.Physics.DataCache, second.Physics.DataCache);
Assert.NotSame(
first.Physics.Engine.ShadowObjects,
second.Physics.Engine.ShadowObjects);
Assert.NotSame(
first.Physics.DataCache.CellGraph,
second.Physics.DataCache.CellGraph);
}
[Fact]
public void PhysicsOwnerDisposesWithItsEntityLifetime()
{
var lifetime = new RuntimeEntityObjectLifetime();
var physics = lifetime.Physics;
lifetime.Dispose();
Assert.True(physics.CaptureOwnership().IsDisposed);
}
[Fact]
public void CanonicalRecordAndPhysicsOwnerOwnRemoteComponentAndWorksets()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000001u, 1));
var remote = new TestRemoteMotion();
lifetime.Entities.SetPhysicsBody(record, remote.Body);
lifetime.Entities.SetRemoteMotion(record, remote);
lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
Assert.Same(remote, record.RemoteMotion);
Assert.Same(remote.Body, record.PhysicsBody);
Assert.True(lifetime.Physics.IsSpatialRoot(record));
Assert.True(lifetime.Physics.IsSpatialRemote(record, remote));
Assert.Equal(1, lifetime.Physics.SpatialRootCount);
Assert.Equal(1, lifetime.Physics.SpatialRemoteCount);
var roots = new List<RuntimeEntityRecord>();
var remotes = new List<RuntimeEntityRecord>();
lifetime.Physics.CopySpatialRootsTo(roots);
lifetime.Physics.CopySpatialRemotesTo(remotes);
Assert.Same(record, Assert.Single(roots));
Assert.Same(record, Assert.Single(remotes));
lifetime.Entities.SetRemoteMotion(record, null);
lifetime.Physics.RefreshRemoteComponent(record);
Assert.True(lifetime.Physics.IsSpatialRoot(record));
Assert.Equal(0, lifetime.Physics.SpatialRemoteCount);
lifetime.Physics.RemoveSpatialProjection(record);
Assert.Equal(0, lifetime.Physics.SpatialRootCount);
}
[Fact]
public void CanonicalRecordAndPhysicsOwnerOwnProjectileComponentAndWorkset()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000031u, 1));
lifetime.Entities.SetFullCell(record, 0x01010001u, 0x0101FFFFu);
lifetime.Entities.SetFinalPhysicsState(
record,
PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Missile
| PhysicsStateFlags.AlignPath
| PhysicsStateFlags.PathClipped);
var body = new PhysicsBody
{
Position = new Vector3(10f, 20f, 5f),
Orientation = Quaternion.Identity,
InWorld = true,
TransientState = TransientStateFlags.Active,
};
body.SnapToCell(
record.FullCellId,
body.Position,
body.Position);
lifetime.Entities.SetPhysicsBody(record, body);
var sphere = new ProjectileCollisionSphere(
new Vector3(0.1f, 0f, 0f),
0.25f);
IRuntimeProjectile projectile = lifetime.Physics.BindProjectile(
record,
body,
sphere);
lifetime.Physics.AcknowledgeSpatialProjection(
record,
spatial: true);
Assert.Same(projectile, record.Projectile);
Assert.Same(body, projectile.Body);
Assert.Equal(sphere, projectile.CollisionSphere);
Assert.True(
lifetime.Physics.IsSpatialProjectile(record, projectile));
Assert.Equal(1, lifetime.Physics.SpatialProjectileCount);
Assert.Equal(
1,
lifetime.Physics.CaptureOwnership().SpatialProjectileCount);
Assert.Same(
projectile,
lifetime.Physics.BindProjectile(record, body, sphere));
Assert.Throws<InvalidOperationException>(() =>
lifetime.Physics.BindProjectile(
record,
new PhysicsBody(),
sphere));
lifetime.Physics.RemoveSpatialProjection(record);
Assert.Equal(0, lifetime.Physics.SpatialProjectileCount);
Assert.Same(projectile, record.Projectile);
Assert.True(lifetime.Physics.ClearProjectile(record));
Assert.Null(record.Projectile);
}
[Fact]
public void ProjectileAuthoritativeMutationInvalidatesSplitPrediction()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000032u, 1));
lifetime.Entities.SetFullCell(record, 0x01010001u, 0x0101FFFFu);
lifetime.Entities.SetFinalPhysicsState(
record,
PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Missile
| PhysicsStateFlags.AlignPath
| PhysicsStateFlags.PathClipped);
var body = new PhysicsBody
{
Position = new Vector3(10f, 20f, 5f),
Orientation = Quaternion.Identity,
InWorld = true,
TransientState = TransientStateFlags.Active,
};
body.set_velocity(new Vector3(10f, 0f, 0f));
body.SnapToCell(
record.FullCellId,
body.Position,
body.Position);
lifetime.Entities.SetPhysicsBody(record, body);
IRuntimeProjectile projectile = lifetime.Physics.BindProjectile(
record,
body,
new ProjectileCollisionSphere(Vector3.Zero, 0.25f));
lifetime.Physics.AcknowledgeSpatialProjection(
record,
spatial: true);
var updater =
new RuntimeProjectilePhysicsUpdater(lifetime.Physics);
Assert.True(updater.TryBegin(
record,
quantum: 0.05f,
record.ObjectClockEpoch,
externalOwnerValid: null,
out RuntimeProjectilePhysicsCommit commit));
lifetime.Entities.AdvanceVectorAuthority(record);
Vector3 correction = new(7f, 8f, 9f);
Assert.True(updater.ApplyAuthoritativeVector(
record,
record.VectorAuthorityVersion,
record.VelocityAuthorityVersion,
correction,
Vector3.UnitZ,
currentTime: 1.0));
Assert.True(
projectile.PredictionAuthorityVersion
> commit.PredictionAuthorityVersion);
Assert.False(updater.Complete(
commit,
liveCenterX: 1,
liveCenterY: 1,
_ => true));
Assert.Equal(correction, body.Velocity);
Assert.Equal(Vector3.UnitZ, body.Omega);
}
[Fact]
public void ProjectileQuantumPublishesOneImmutableRuntimeFrame()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000033u, 1));
lifetime.Entities.SetFullCell(record, 0x01010001u, 0x0101FFFFu);
lifetime.Entities.SetFinalPhysicsState(
record,
PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Missile
| PhysicsStateFlags.PathClipped);
var body = new PhysicsBody
{
Position = new Vector3(10f, 20f, 5f),
Orientation = Quaternion.Identity,
InWorld = true,
TransientState = TransientStateFlags.Active,
};
body.SnapToCell(
record.FullCellId,
body.Position,
body.Position);
lifetime.Entities.SetPhysicsBody(record, body);
lifetime.Physics.BindProjectile(
record,
body,
new ProjectileCollisionSphere(Vector3.Zero, 0.25f));
lifetime.Physics.AcknowledgeSpatialProjection(
record,
spatial: true);
var updater =
new RuntimeProjectilePhysicsUpdater(lifetime.Physics);
Assert.True(updater.TryBegin(
record,
quantum: 0.05f,
record.ObjectClockEpoch,
externalOwnerValid: null,
out RuntimeProjectilePhysicsCommit commit));
int acknowledgements = 0;
RuntimePhysicsFrameSnapshot published = default;
Assert.True(updater.Complete(
commit,
liveCenterX: 1,
liveCenterY: 1,
snapshot =>
{
published = snapshot;
acknowledgements++;
return true;
}));
Assert.Equal(1, acknowledgements);
Assert.Equal(body.Position, published.Position);
Assert.Equal(body.Orientation, published.Orientation);
Assert.Equal(record.FullCellId, published.FullCellId);
Assert.Throws<InvalidOperationException>(() =>
updater.Complete(
commit,
liveCenterX: 1,
liveCenterY: 1,
_ => true));
}
[Fact]
public void EqualLocalKeysInConcurrentRuntimesDoNotShareWorksets()
{
using var first = new RuntimeEntityObjectLifetime();
using var second = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord firstRecord =
first.Entities.AddActive(Spawn(0x70000002u, 1));
RuntimeEntityRecord secondRecord =
second.Entities.AddActive(Spawn(0x70000003u, 1));
Assert.Equal(firstRecord.Key, secondRecord.Key);
first.Physics.AcknowledgeSpatialProjection(firstRecord, spatial: true);
Assert.True(first.Physics.IsSpatialRoot(firstRecord));
Assert.False(second.Physics.IsSpatialRoot(secondRecord));
Assert.Equal(1, first.Physics.SpatialRootCount);
Assert.Equal(0, second.Physics.SpatialRootCount);
}
[Fact]
public void CollisionAdmissionIsExactRuntimeScopedAndWithdrawalIsTyped()
{
using var first = new RuntimeEntityObjectLifetime();
using var second = new RuntimeEntityObjectLifetime();
RuntimeCollisionAdmission admission =
first.Physics.BeginCollisionAdmission(0xA9B4FFFFu);
RuntimeCollisionAdmission newer =
first.Physics.BeginCollisionAdmission(0xA9B4FFFFu);
Assert.Throws<InvalidOperationException>(() =>
first.Physics.AdmitCollisionAssets(
admission,
CollisionAssets(0xA9B4FFFFu)));
Assert.Throws<InvalidOperationException>(() =>
second.Physics.AdmitCollisionAssets(
newer,
CollisionAssets(0xA9B4FFFFu)));
first.Physics.AdmitCollisionAssets(
newer,
CollisionAssets(0xA9B4FFFFu));
RuntimeCollisionAcknowledgement completed =
first.Physics.CompleteCollisionAdmission(newer);
Assert.True(completed.WasResident);
Assert.Equal(1, first.Physics.Engine.LandblockCount);
Assert.Equal(
0,
first.Physics.CaptureOwnership().CollisionAdmissionCount);
Assert.Throws<InvalidOperationException>(() =>
first.Physics.CompleteCollisionAdmission(newer));
RuntimeCollisionAcknowledgement withdrawn =
first.Physics.WithdrawCollision(0xA9B4FFFFu);
Assert.True(withdrawn.WasResident);
Assert.True(withdrawn.Generation > completed.Generation);
Assert.Equal(0, first.Physics.Engine.LandblockCount);
}
[Fact]
public void RemoteBindingOwnsCanonicalBodyCellAndTypedCellPublication()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000021u, 1));
var remote = new RemoteMotion();
var commits = new List<RuntimePhysicsCellCommit>();
lifetime.Physics.CellCommitted += commits.Add;
lifetime.Physics.SetRemoteMotion(record, remote);
lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
remote.CellId = 0x02020001u;
Assert.Same(remote, record.RemoteMotion);
Assert.Same(remote.Body, record.PhysicsBody);
Assert.Equal(0x02020001u, record.FullCellId);
RuntimePhysicsCellCommit commit = Assert.Single(commits);
Assert.Same(record, commit.Record);
Assert.Equal(0x0101FFFFu, commit.PreviousFullCellId);
Assert.Equal(0x02020001u, commit.FullCellId);
Assert.True(lifetime.Physics.IsSpatialRemote(record, remote));
Assert.True(lifetime.Physics.ClearRemoteMotion(record));
Assert.Null(record.RemoteMotion);
Assert.Equal(0, lifetime.Physics.SpatialRemoteCount);
}
[Fact]
public void RemotePhysicsTickUsesCanonicalRuntimeAndPublishesPoseSnapshot()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000022u, 1));
var remote = new RemoteMotion();
remote.Body.Position = new Vector3(10f, 20f, 5f);
remote.Body.Orientation = Quaternion.Identity;
remote.Body.TransientState = TransientStateFlags.Active
| TransientStateFlags.Contact
| TransientStateFlags.OnWalkable;
lifetime.Physics.SetRemoteMotion(record, remote);
lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
var updater = new RuntimeRemotePhysicsUpdater(lifetime.Physics);
RuntimeRemotePhysicsSnapshot snapshot = default;
int publishes = 0;
Assert.True(updater.Tick(
record,
remote,
objectScale: 1f,
sequencer: null,
dt: 0.1f,
objectClockEpoch: record.ObjectClockEpoch,
new MotionDeltaFrame
{
Origin = Vector3.UnitX,
Orientation = Quaternion.Identity,
},
radius: 0.48f,
height: 1.835f,
liveCenterX: 1,
liveCenterY: 1,
acknowledgeProjection: value =>
{
snapshot = value;
publishes++;
return true;
}));
Assert.Equal(1, publishes);
Assert.True(snapshot.Position.X > 10f);
Assert.Equal(remote.Body.Position, snapshot.Position);
Assert.Equal(record.FullCellId, snapshot.FullCellId);
}
[Fact]
public void PhysicsBodyAcquisitionIsCanonicalAndRejectsGuidReuse()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord first =
lifetime.Entities.AddActive(Spawn(0x70000023u, 1));
var body = new PhysicsBody();
Assert.Same(
body,
lifetime.Physics.GetOrCreatePhysicsBody(first, _ => body));
Assert.Same(
body,
lifetime.Physics.GetOrCreatePhysicsBody(
first,
_ => throw new InvalidOperationException(
"The factory must not replay.")));
RuntimeEntityRecord stale =
lifetime.Entities.AddActive(Spawn(0x70000024u, 1));
Assert.Throws<InvalidOperationException>(() =>
lifetime.Physics.GetOrCreatePhysicsBody(
stale,
_ =>
{
Assert.True(lifetime.Entities.RemoveActive(stale));
lifetime.Entities.AddActive(Spawn(0x70000024u, 2));
return new PhysicsBody();
}));
Assert.Null(stale.PhysicsBody);
Assert.False(stale.PhysicsBodyAcquisitionInProgress);
}
[Fact]
public void PhysicsHostIdentityAndLookupBelongToExactRuntimeIncarnation()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000025u, 1));
EntityPhysicsHost initial = Host(record.ServerGuid, 1f);
EntityPhysicsHost replacement = Host(record.ServerGuid, 2f);
Assert.Same(
initial,
lifetime.Physics.InstallOrRebindPhysicsHost(record, initial));
Assert.True(
lifetime.Physics.TryGetPhysicsHost(
record.ServerGuid,
out IPhysicsObjHost resolved));
Assert.Same(initial, resolved);
Assert.Same(
initial,
lifetime.Physics.InstallOrRebindPhysicsHost(record, replacement));
Assert.Equal(2f, initial.Position.Frame.Origin.X);
Assert.True(lifetime.Entities.RemoveActive(record));
RuntimeEntityRecord next =
lifetime.Entities.AddActive(Spawn(record.ServerGuid, 2));
Assert.False(
lifetime.Physics.TryGetPhysicsHost(
record.ServerGuid,
out _));
Assert.Null(next.PhysicsHost);
}
[Fact]
public void OrdinaryCellCommitIsCanonicalBeforePublication()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000026u, 1));
var body = new PhysicsBody();
lifetime.Entities.SetPhysicsBody(record, body);
lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
RuntimePhysicsCellCommit observed = default;
lifetime.Physics.CellCommitted += value =>
{
Assert.Equal(value.FullCellId, value.Record.FullCellId);
observed = value;
};
Assert.True(lifetime.Physics.CommitOrdinaryCell(
record,
body,
record.ObjectClockEpoch,
0x02020001u,
externalOwnerValid: null));
Assert.Equal(0x02020001u, record.FullCellId);
Assert.Same(record, observed.Record);
Assert.Equal(record.SpatialAuthorityVersion, observed.SpatialAuthorityVersion);
}
private static RuntimeLandblockCollisionAssets CollisionAssets(
uint landblockId)
{
var heights = new byte[81];
var table = new float[256];
return new RuntimeLandblockCollisionAssets(
landblockId,
new TerrainSurface(heights, table),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f,
0u);
}
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance)
{
var position = new CreateObject.ServerPosition(
0x0101FFFFu,
10f,
20f,
5f,
1f,
0f,
0f,
0f);
var timestamps = new PhysicsTimestamps(
Position: 1,
Movement: 1,
State: 1,
Vector: 1,
Teleport: 0,
ServerControlledMove: 1,
ForcePosition: 0,
ObjDesc: 1,
Instance: instance);
var physics = new PhysicsSpawnData(
RawState: 0x408u,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
null,
null,
0x09000001u,
PhysicsState: 0x408u,
InstanceSequence: instance,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private static EntityPhysicsHost Host(uint guid, float x) =>
new(
guid,
getPosition: () => new Position(
0x0101FFFFu,
new Vector3(x, 0f, 0f),
Quaternion.Identity),
getVelocity: () => Vector3.Zero,
getRadius: () => 0.48f,
inContact: () => true,
minterpMaxSpeed: () => null,
curTime: () => 0d,
physicsTimerTime: () => 0d,
getObjectA: _ => null,
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
private sealed class TestRemoteMotion : IRuntimeRemoteMotion
{
public PhysicsBody Body { get; } = new();
}
}