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

@ -8,6 +8,7 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot(
int RetainedShadowRegistrationCount,
int SpatialRootCount,
int SpatialRemoteCount,
int SpatialProjectileCount,
int CollisionAdmissionCount,
int CollisionGenerationCount,
bool OwnsProductionDataCache,
@ -64,6 +65,8 @@ public sealed class RuntimePhysicsState : IDisposable
_spatialRoots = new();
private readonly Dictionary<RuntimeEntityKey, IRuntimeRemoteMotion>
_spatialRemotes = new();
private readonly Dictionary<RuntimeEntityKey, IRuntimeProjectile>
_spatialProjectiles = new();
private readonly Dictionary<uint, ulong> _collisionGenerations = new();
private readonly Dictionary<uint, RuntimeCollisionAdmission>
_collisionAdmissions = new();
@ -83,11 +86,22 @@ public sealed class RuntimePhysicsState : IDisposable
};
}
internal RuntimePhysicsState(
RuntimeEntityDirectory entities,
PhysicsEngine engine)
{
Entities = entities ?? throw new ArgumentNullException(nameof(entities));
Engine = engine ?? throw new ArgumentNullException(nameof(engine));
DataCache = engine.DataCache ?? PhysicsDataCache.CreateProduction();
Engine.DataCache = DataCache;
}
internal RuntimeEntityDirectory Entities { get; }
public PhysicsEngine Engine { get; }
public PhysicsDataCache DataCache { get; }
public int SpatialRootCount => _spatialRoots.Count;
public int SpatialRemoteCount => _spatialRemotes.Count;
public int SpatialProjectileCount => _spatialProjectiles.Count;
public RuntimePhysicsOwnershipSnapshot CaptureOwnership() =>
new(
@ -95,6 +109,7 @@ public sealed class RuntimePhysicsState : IDisposable
Engine.ShadowObjects.RetainedRegistrationCount,
_spatialRoots.Count,
_spatialRemotes.Count,
_spatialProjectiles.Count,
_collisionAdmissions.Count,
_collisionGenerations.Count,
ReferenceEquals(Engine.DataCache, DataCache),
@ -123,6 +138,10 @@ public sealed class RuntimePhysicsState : IDisposable
_spatialRemotes[key] = remote;
else
_spatialRemotes.Remove(key);
if (record.Projectile is { } projectile)
_spatialProjectiles[key] = projectile;
else
_spatialProjectiles.Remove(key);
return;
}
@ -148,6 +167,167 @@ public sealed class RuntimePhysicsState : IDisposable
_spatialRemotes.Remove(key);
}
public void RefreshProjectileComponent(RuntimeEntityRecord record)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
if (record.Key is not { } key
|| !_spatialRoots.TryGetValue(key, out RuntimeEntityRecord? root)
|| !ReferenceEquals(root, record)
|| !Entities.IsCurrent(record))
{
RemoveSpatialProjectile(record);
return;
}
if (record.Projectile is { } projectile)
_spatialProjectiles[key] = projectile;
else
_spatialProjectiles.Remove(key);
}
/// <summary>
/// Installs the one projectile component for an exact canonical
/// incarnation. App may prepare the DAT collision sphere, but Runtime owns
/// component identity, prediction invalidation, and workset membership.
/// </summary>
public IRuntimeProjectile BindProjectile(
RuntimeEntityRecord record,
PhysicsBody body,
ProjectileCollisionSphere collisionSphere,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(body);
EnsureCurrent(record);
if (!collisionSphere.IsValid)
{
throw new ArgumentOutOfRangeException(
nameof(collisionSphere),
"A Runtime projectile requires one valid prepared Setup sphere.");
}
if (!(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed external ownership before projectile binding.");
}
if (record.Projectile is { } retained)
{
if (!ReferenceEquals(retained.Body, body)
|| !ReferenceEquals(record.PhysicsBody, body))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} projectile changed its canonical physics body.");
}
retained.Body.State = record.FinalPhysicsState;
RefreshProjectileComponent(record);
return retained;
}
if (record.ProjectileBindingInProgress)
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} projectile binding is already in progress.");
}
if (!ReferenceEquals(record.PhysicsBody, body))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} projectile must borrow its canonical physics body.");
}
ulong sessionVersion = Entities.SessionLifetimeVersion;
Entities.SetProjectileBindingInProgress(record, true);
try
{
var component = new RuntimeProjectile(body, collisionSphere);
if (Entities.SessionLifetimeVersion != sessionVersion
|| !Entities.IsCurrent(record)
|| !ReferenceEquals(record.PhysicsBody, body)
|| record.Projectile is not null
|| !(externalOwnerValid?.Invoke() ?? true))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} changed ownership during projectile binding.");
}
Entities.SetProjectile(record, component);
body.State = record.FinalPhysicsState;
SynchronizeBodyActiveState(record);
RefreshProjectileComponent(record);
return component;
}
finally
{
if (Entities.IsCurrent(record))
Entities.SetProjectileBindingInProgress(record, false);
else
record.ProjectileBindingInProgress = false;
}
}
public bool ClearProjectile(RuntimeEntityRecord record)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
if (record.Projectile is null)
return false;
Entities.SetProjectile(record, null);
Entities.SetProjectileBindingInProgress(record, false);
RefreshProjectileComponent(record);
return true;
}
/// <summary>
/// Commits retail <c>CPhysicsObj::set_velocity</c> (0x005113F0) and,
/// when supplied, the paired <c>set_omega</c> write to the incarnation's
/// canonical body. Runtime owns the shared body/object-clock activation
/// edge; graphical hosts only validate and translate inbound payloads.
/// </summary>
internal bool TryCommitAuthoritativeVector(
RuntimeEntityRecord record,
PhysicsBody body,
System.Numerics.Vector3 velocity,
System.Numerics.Vector3? angularVelocity,
double currentTime,
Func<bool>? externalOwnerValid = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(body);
if (!IsFinite(velocity)
|| (angularVelocity is { } omega && !IsFinite(omega))
|| !double.IsFinite(currentTime)
|| !Entities.IsCurrent(record)
|| !ReferenceEquals(record.PhysicsBody, body)
|| !(externalOwnerValid?.Invoke() ?? true))
{
return false;
}
bool wasBodyActive =
(body.TransientState & TransientStateFlags.Active) != 0;
body.set_velocity(velocity);
if ((record.FinalPhysicsState & PhysicsStateFlags.Static) != 0)
{
if (!wasBodyActive)
body.TransientState &= ~TransientStateFlags.Active;
}
else
{
bool clockReactivated = record.ObjectClock.Activate();
if (clockReactivated || !wasBodyActive)
body.LastUpdateTime = currentTime;
}
if (angularVelocity is { } acceptedOmega)
body.Omega = acceptedOmega;
return Entities.IsCurrent(record)
&& ReferenceEquals(record.PhysicsBody, body)
&& (externalOwnerValid?.Invoke() ?? true);
}
public void SetRemoteMotion(
RuntimeEntityRecord record,
IRuntimeRemoteMotion runtime,
@ -476,6 +656,7 @@ public sealed class RuntimePhysicsState : IDisposable
_spatialRoots.Remove(key);
}
RemoveSpatialRemote(record);
RemoveSpatialProjectile(record);
}
public bool IsSpatialRoot(RuntimeEntityRecord record) =>
@ -493,6 +674,15 @@ public sealed class RuntimePhysicsState : IDisposable
&& _spatialRemotes.TryGetValue(key, out IRuntimeRemoteMotion? indexed)
&& ReferenceEquals(indexed, remote);
public bool IsSpatialProjectile(
RuntimeEntityRecord record,
IRuntimeProjectile projectile) =>
IsSpatialRoot(record)
&& ReferenceEquals(record.Projectile, projectile)
&& record.Key is { } key
&& _spatialProjectiles.TryGetValue(key, out IRuntimeProjectile? indexed)
&& ReferenceEquals(indexed, projectile);
public void CopySpatialRootsTo(List<RuntimeEntityRecord> destination)
{
EnsureNotDisposed();
@ -529,6 +719,26 @@ public sealed class RuntimePhysicsState : IDisposable
}
}
public void CopySpatialProjectilesTo(List<RuntimeEntityRecord> destination)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach ((RuntimeEntityKey key, IRuntimeProjectile projectile)
in _spatialProjectiles)
{
if (Entities.TryGetByLocalId(
key.LocalEntityId,
out RuntimeEntityRecord record)
&& record.Key == key
&& ReferenceEquals(record.Projectile, projectile)
&& IsSpatialRoot(record))
{
destination.Add(record);
}
}
}
internal bool CommitOrdinaryCell(
RuntimeEntityRecord record,
PhysicsBody body,
@ -551,10 +761,34 @@ public sealed class RuntimePhysicsState : IDisposable
IsExactOwner);
}
internal bool CommitProjectileCell(
RuntimeEntityRecord record,
IRuntimeProjectile projectile,
ulong predictionAuthorityVersion,
uint fullCellId,
Func<bool>? externalOwnerValid)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(projectile);
bool IsExactOwner() =>
Entities.IsCurrent(record)
&& ReferenceEquals(record.Projectile, projectile)
&& ReferenceEquals(record.PhysicsBody, projectile.Body)
&& projectile.PredictionAuthorityVersion
== predictionAuthorityVersion
&& (externalOwnerValid?.Invoke() ?? true);
return CommitCanonicalCell(
record,
fullCellId,
IsExactOwner);
}
public void ClearSpatialWorksets()
{
EnsureNotDisposed();
_spatialRemotes.Clear();
_spatialProjectiles.Clear();
_spatialRoots.Clear();
}
@ -672,6 +906,7 @@ public sealed class RuntimePhysicsState : IDisposable
if (_disposed)
return;
_spatialRemotes.Clear();
_spatialProjectiles.Clear();
_spatialRoots.Clear();
_collisionAdmissions.Clear();
_collisionGenerations.Clear();
@ -730,6 +965,21 @@ public sealed class RuntimePhysicsState : IDisposable
}
}
private void RemoveSpatialProjectile(RuntimeEntityRecord record)
{
if (record.Key is not { } key)
return;
if (_spatialProjectiles.TryGetValue(
key,
out IRuntimeProjectile? projectile)
&& (record.Projectile is null
|| ReferenceEquals(projectile, record.Projectile)
|| !Entities.IsCurrent(record)))
{
_spatialProjectiles.Remove(key);
}
}
private void EnsureNotDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this);
@ -775,6 +1025,11 @@ public sealed class RuntimePhysicsState : IDisposable
private static uint CanonicalLandblock(uint value) =>
(value & 0xFFFF0000u) | 0xFFFFu;
private static bool IsFinite(System.Numerics.Vector3 value) =>
float.IsFinite(value.X)
&& float.IsFinite(value.Y)
&& float.IsFinite(value.Z);
private static void SynchronizeBodyActiveState(RuntimeEntityRecord record)
{
if (record.PhysicsBody is not { } body)