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.
This commit is contained in:
Erik 2026-07-26 14:17:42 +02:00
parent fce8e7b18e
commit 2aee33569f
19 changed files with 1255 additions and 335 deletions

View file

@ -1,5 +1,6 @@
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.App.Tests;
@ -20,6 +21,18 @@ internal static class LiveEntityRuntimeFixture
return new LiveEntityRuntime(spatial, resources, lifetime);
}
public static LiveEntityRuntime Create(
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
PhysicsEngine physicsEngine,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{
var lifetime = new RuntimeEntityObjectLifetime(
physicsEngine,
firstLocalEntityId);
return new LiveEntityRuntime(spatial, resources, lifetime);
}
public static LiveEntityRuntime Create(
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
@ -47,4 +60,21 @@ internal static class LiveEntityRuntimeFixture
runtimeComponentLifecycle,
lifetime);
}
public static LiveEntityRuntime Create(
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
ILiveEntityRuntimeComponentLifecycle runtimeComponentLifecycle,
PhysicsEngine physicsEngine,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{
var lifetime = new RuntimeEntityObjectLifetime(
physicsEngine,
firstLocalEntityId);
return new LiveEntityRuntime(
spatial,
resources,
runtimeComponentLifecycle,
lifetime);
}
}

View file

@ -162,7 +162,7 @@ public sealed class ProjectileControllerTests
seedCellId: CellA,
isStatic: false);
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0));
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!;
IRuntimeProjectile runtime = record.ProjectileRuntime!;
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
record,
@ -339,6 +339,7 @@ public sealed class ProjectileControllerTests
Assert.Equal(0xAAB40001u, record.FullCellId);
Assert.Equal(0xAAB40001u, record.PhysicsBody!.CellPosition.ObjCellId);
Assert.Equal(record.PhysicsBody.Position, entity.Position);
Assert.False(record.IsSpatiallyVisible);
Assert.True(record.IsSpatiallyProjected);
Assert.False(record.PhysicsBody.InWorld);
@ -514,7 +515,7 @@ public sealed class ProjectileControllerTests
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
Vector3 before = record.WorldEntity!.Position;
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!;
IRuntimeProjectile runtime = record.ProjectileRuntime!;
PhysicsBody body = runtime.Body;
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState(
@ -565,7 +566,7 @@ public sealed class ProjectileControllerTests
cellLocalPosition: new Vector3(191f, 10f, 50f),
velocity: new Vector3(40f, 0f, 0f));
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(0.5f), 0.0));
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!;
IRuntimeProjectile runtime = record.ProjectileRuntime!;
fixture.Controller.Tick(0.05, 0xA9, 0xB4, playerWorldPosition: null);
Assert.Equal(destinationCell, record.FullCellId);
@ -988,7 +989,7 @@ public sealed class ProjectileControllerTests
Assert.Equal(MissileState, record.PhysicsBody!.State);
Assert.True(double.IsFinite(record.PhysicsBody.LastUpdateTime));
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!;
IRuntimeProjectile runtime = record.ProjectileRuntime!;
Assert.True(fixture.Controller.ApplyAuthoritativeState(
record, MissileState, 0.1, 1, 1));
Assert.Same(runtime, record.ProjectileRuntime);
@ -1320,14 +1321,14 @@ public sealed class ProjectileControllerTests
LiveEntityRecord first = fixture.Spawn(instance: 7);
Assert.True(fixture.Controller.TryBind(first, ProjectileSetup(), 1.0));
WorldEntity firstEntity = first.WorldEntity!;
ILiveEntityProjectileRuntime firstRuntime = first.ProjectileRuntime!;
IRuntimeProjectile firstRuntime = first.ProjectileRuntime!;
Assert.True(fixture.Live.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, InstanceSequence: 7),
isLocalPlayer: false));
LiveEntityRecord second = fixture.Spawn(instance: 8);
Assert.True(fixture.Controller.TryBind(second, ProjectileSetup(), 2.0));
ILiveEntityProjectileRuntime secondRuntime = second.ProjectileRuntime!;
IRuntimeProjectile secondRuntime = second.ProjectileRuntime!;
Assert.False(fixture.Controller.TryBind(first, ProjectileSetup(), 3.0));
@ -1457,12 +1458,14 @@ public sealed class ProjectileControllerTests
{
if (loadInitialLandblock)
Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
Live = LiveEntityRuntimeFixture.Create(Spatial, Resources);
Engine = physicsEngine ?? new PhysicsEngine();
Live = LiveEntityRuntimeFixture.Create(
Spatial,
Resources,
Engine);
Origin.Recenter(1, 1);
Controller = new ProjectileController(
Live,
Engine,
new SetupResolver(() => ResolvedSetup),
new RootPosePublisher(
publishRootPose ?? new Action<WorldEntity>(

View file

@ -71,14 +71,6 @@ public sealed class RemoteTeleportControllerTests
public void LeaveGround() { }
}
private sealed class AlternatingProjectileRuntime(
PhysicsBody first,
PhysicsBody later) : ILiveEntityProjectileRuntime
{
private int _reads;
public PhysicsBody Body => _reads++ == 0 ? first : later;
}
[Fact]
public void PendingDestination_ParksAuthoritativeFrameThenResolvesContactOnLoad()
{
@ -867,8 +859,10 @@ public sealed class RemoteTeleportControllerTests
Assert.Same(canonicalBody, record.PhysicsBody);
Assert.Same(remote, record.RemoteMotionRuntime);
var projectile = new AlternatingProjectileRuntime(canonicalBody, otherBody);
fixture.Live.SetProjectileRuntime(RollbackFixture.Guid, projectile);
IRuntimeProjectile projectile = fixture.Live.BindProjectileRuntime(
RollbackFixture.Guid,
canonicalBody,
new ProjectileCollisionSphere(Vector3.Zero, 0.25f));
Assert.Same(canonicalBody, record.PhysicsBody);
Assert.Same(projectile, record.ProjectileRuntime);

View file

@ -234,8 +234,8 @@ public sealed class LiveEntityAnimationSchedulerTests
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(
RemoteGuid,
rootOrigin: Vector3.Zero);
var physics = new PhysicsEngine();
var projectiles = new ProjectileController(live, physics);
PhysicsEngine physics = live.Physics.Engine;
var projectiles = new ProjectileController(live);
record.FinalPhysicsState = ProjectileState;
Assert.True(record.ObjectClock.IsActive);
Assert.True(projectiles.TryBind(
@ -287,8 +287,8 @@ public sealed class LiveEntityAnimationSchedulerTests
var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(
RemoteGuid,
rootOrigin: Vector3.Zero);
var physics = new PhysicsEngine();
var projectiles = new ProjectileController(live, physics);
PhysicsEngine physics = live.Physics.Engine;
var projectiles = new ProjectileController(live);
record.FinalPhysicsState = ProjectileState;
var remote = new AcDream.Runtime.Physics.RemoteMotion
{
@ -642,7 +642,7 @@ public sealed class LiveEntityAnimationSchedulerTests
new AnimationHookRouter(),
poses))
: new TestAnimationHookCaptureSink(captureHooks);
projectiles ??= new ProjectileController(live, physics);
projectiles ??= new ProjectileController(live);
return new LiveEntityAnimationScheduler(
live,
identity,

View file

@ -3,6 +3,7 @@ using System.Reflection;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.World;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.World;
@ -161,6 +162,18 @@ public sealed class GameWindowLiveEntityCompositionTests
Assert.Equal(
typeof(LiveWorldOriginState),
Assert.Single(projectileFields, field => field.Name == "_origin").FieldType);
Assert.Equal(
typeof(RuntimeProjectilePhysicsUpdater),
Assert.Single(
projectileFields,
field => field.Name == "_runtimeUpdater").FieldType);
Assert.DoesNotContain(
projectileFields,
field => field.FieldType == typeof(PhysicsEngine)
|| field.FieldType == typeof(ProjectilePhysicsStepper)
|| field.FieldType.IsGenericType
&& field.FieldType.GetGenericTypeDefinition()
== typeof(Dictionary<,>));
Assert.DoesNotContain(
projectileFields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType)

View file

@ -422,7 +422,7 @@ public sealed class LiveEntityLifecycleStressTests
randomUnit: () => 0.5,
canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true);
EntityObjects = new RuntimeEntityObjectLifetime();
EntityObjects = new RuntimeEntityObjectLifetime(Engine);
Runtime = new LiveEntityRuntime(
Spatial,
new DelegateLiveEntityResourceLifecycle(
@ -463,7 +463,7 @@ public sealed class LiveEntityLifecycleStressTests
Poses);
_effects = Effects;
Router.Register(Effects);
Projectiles = new ProjectileController(Runtime, Engine);
Projectiles = new ProjectileController(Runtime);
LiveLights = new LiveEntityLightController(
Runtime,
Poses,

View file

@ -199,10 +199,10 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests
Live = LiveEntityRuntimeFixture.Create(
Spatial,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
lifecycle);
Projectiles = new ProjectileController(
Live,
lifecycle,
Physics);
Projectiles = new ProjectileController(
Live);
Controller = new LiveEntityProjectionWithdrawalController(
Live,
Projectiles,

View file

@ -53,11 +53,6 @@ public sealed class LiveEntityRuntimeTests
public PhysicsBody Body { get; } = new();
}
private sealed class ProjectileRuntime(PhysicsBody body) : ILiveEntityProjectileRuntime
{
public PhysicsBody Body { get; } = body;
}
private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle
{
public int RegisterCount { get; private set; }
@ -427,10 +422,12 @@ public sealed class LiveEntityRuntimeTests
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
var animation = new AnimationRuntime(entity);
var remote = new RemoteMotionRuntime();
var projectile = new ProjectileRuntime(remote.Body);
runtime.SetAnimationRuntime(guid, animation);
runtime.SetRemoteMotionRuntime(guid, remote);
runtime.SetProjectileRuntime(guid, projectile);
IRuntimeProjectile projectile = runtime.BindProjectileRuntime(
guid,
remote.Body,
new ProjectileCollisionSphere(Vector3.Zero, 0.25f));
Assert.True(runtime.IsCurrentSpatialAnimation(record, animation));
Assert.True(runtime.IsCurrentSpatialRemoteMotion(record, remote));

View file

@ -86,6 +86,201 @@ public sealed class RuntimePhysicsStateTests
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()
{