diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index 50a4449c..ca2f7c1e 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -437,7 +437,6 @@ internal sealed class LivePresentationCompositionPhase
var projectileController = new ProjectileController(
liveEntities,
- d.PhysicsEngine,
new DatProjectileSetupResolver(content.Dats, d.DatLock),
new EntityRootPosePublisher(d.EffectPoses),
d.WorldOrigin)
diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs
index 70c490c8..e7a836ea 100644
--- a/src/AcDream.App/Physics/ProjectileController.cs
+++ b/src/AcDream.App/Physics/ProjectileController.cs
@@ -35,51 +35,30 @@ internal sealed class DatProjectileSetupResolver : IProjectileSetupResolver
}
///
-/// Update-thread presentation owner for live retail missiles. Logical identity,
-/// generation, accepted network state, and teardown remain owned by
-/// ; this controller only advances the
-/// attached to that canonical record and commits its
-/// frame through the existing projection.
+/// Update-thread presentation adapter for live retail missiles. Runtime owns
+/// logical identity, the projectile component, prediction versions, the
+/// canonical body/cell, and the split simulation transaction. This adapter
+/// prepares the DAT Setup sphere, preserves animation-hook ordering, and
+/// projects committed frames into and effect poses.
///
///
/// Retail anchors are CPhysicsObj::update_object 0x00515D10,
/// CPhysicsObj::UpdateObjectInternal 0x005156B0, and
/// CPhysicsObj::SetPositionInternal 0x00515330. The pure
-/// integration/sweep is implemented by .
+/// integration/sweep is invoked by Runtime through
+/// .
/// ACE remains authoritative for collision outcomes, damage, effects, and
/// deletion; no such events are synthesized here.
///
internal sealed class ProjectileController
{
- internal sealed class Runtime : ILiveEntityProjectileRuntime
- {
- internal Runtime(
- ushort generation,
- PhysicsBody body,
- ProjectileCollisionSphere collisionSphere)
- {
- Generation = generation;
- Body = body;
- CollisionSphere = collisionSphere;
- }
-
- internal ushort Generation { get; }
- public PhysicsBody Body { get; }
- internal ProjectileCollisionSphere CollisionSphere { get; }
- internal ulong PredictionAuthorityVersion { get; private set; }
- internal void InvalidatePrediction() =>
- PredictionAuthorityVersion++;
- }
-
internal readonly record struct QuantumStep(
LiveEntityRecord Record,
- Runtime Runtime,
WorldEntity Entity,
- ProjectileQuantumPreparation Preparation,
- ulong PredictionAuthorityVersion);
+ RuntimeProjectilePhysicsCommit RuntimeCommit);
private readonly LiveEntityRuntime _liveEntities;
- private readonly ProjectilePhysicsStepper _stepper;
+ private readonly RuntimeProjectilePhysicsUpdater _runtimeUpdater;
private readonly ShadowObjectRegistry _shadows;
private readonly IProjectileSetupResolver? _setupResolver;
private readonly IEntityRootPosePublisher? _rootPoses;
@@ -89,15 +68,14 @@ internal sealed class ProjectileController
internal ProjectileController(
LiveEntityRuntime liveEntities,
- PhysicsEngine physicsEngine,
IProjectileSetupResolver? setupResolver = null,
IEntityRootPosePublisher? rootPoses = null,
LiveWorldOriginState? origin = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
- ArgumentNullException.ThrowIfNull(physicsEngine);
- _stepper = new ProjectilePhysicsStepper(physicsEngine);
- _shadows = physicsEngine.ShadowObjects;
+ _runtimeUpdater = new RuntimeProjectilePhysicsUpdater(
+ liveEntities.Physics);
+ _shadows = liveEntities.Physics.Engine.ShadowObjects;
_setupResolver = setupResolver;
_rootPoses = rootPoses;
_origin = origin;
@@ -107,7 +85,7 @@ internal sealed class ProjectileController
internal Action? DiagnosticSink { get; set; }
internal int Count => _liveEntities.Records.Count(
- static record => record.ProjectileRuntime is Runtime);
+ static record => record.ProjectileRuntime is not null);
internal bool CanAcceptVectorPayload(
uint serverGuid,
@@ -167,7 +145,7 @@ internal sealed class ProjectileController
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
return false;
- if (record.ProjectileRuntime is Runtime retained)
+ if (record.ProjectileRuntime is RuntimeProjectile retained)
{
retained.Body.State = record.FinalPhysicsState;
return true;
@@ -261,7 +239,7 @@ internal sealed class ProjectileController
// Claim the incarnation's one CPhysicsObj before Rebucket can
// publish visibility callbacks. A callback that also needs a body
// must observe and adopt this same instance, never race a private
- // projectile candidate into SetProjectileRuntime.
+ // projectile candidate into Runtime's canonical component slot.
body = _liveEntities.GetOrCreatePhysicsBody(
record.ServerGuid,
_ =>
@@ -308,7 +286,7 @@ internal sealed class ProjectileController
// the one projectile component before this outer call resumes. Adopt
// that component; never replace it with a second Runtime that merely
// happens to share the canonical CPhysicsObj body.
- if (currentRecord.ProjectileRuntime is Runtime concurrentRuntime)
+ if (currentRecord.ProjectileRuntime is RuntimeProjectile concurrentRuntime)
return ReferenceEquals(concurrentRuntime.Body, body);
// A newer Position can legally arrive from a projection callback
@@ -317,11 +295,14 @@ internal sealed class ProjectileController
// canonical body frame, never the pre-callback cell captured above.
canonicalCellId = body.CellPosition.ObjCellId;
- var runtime = new Runtime(
- record.Generation,
- body,
- sphere);
- _liveEntities.SetProjectileRuntime(record.ServerGuid, runtime);
+ RuntimeProjectile runtime = (RuntimeProjectile)
+ _liveEntities.BindProjectileRuntime(
+ record.ServerGuid,
+ body,
+ sphere,
+ () => _liveEntities.IsCurrentRecord(record)
+ && ReferenceEquals(record.WorldEntity, entity)
+ && ReferenceEquals(record.PhysicsBody, body));
if (HasVisibleCell(record) && !IsHidden(record))
{
@@ -374,7 +355,7 @@ internal sealed class ProjectileController
{
ArgumentNullException.ThrowIfNull(expectedRecord);
uint serverGuid = expectedRecord.ServerGuid;
- if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime))
+ if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out RuntimeProjectile runtime))
return false;
if (!ReferenceEquals(record, expectedRecord)
|| record.VectorAuthorityVersion != expectedVectorAuthorityVersion
@@ -400,17 +381,19 @@ internal sealed class ProjectileController
}
_lastFiniteGameTime = currentTime;
- runtime.InvalidatePrediction();
- if (!_liveEntities.TryCommitAuthoritativeVector(
- record,
- runtime.Body,
+ return _runtimeUpdater.ApplyAuthoritativeVector(
+ record.Canonical,
+ expectedVectorAuthorityVersion,
+ expectedVelocityAuthorityVersion,
velocity,
angularVelocity,
- currentTime))
- {
- return true;
- }
- return true;
+ currentTime,
+ () => TryGetCurrent(
+ serverGuid,
+ out LiveEntityRecord current,
+ out RuntimeProjectile currentRuntime)
+ && ReferenceEquals(current, record)
+ && ReferenceEquals(currentRuntime, runtime));
}
internal bool ApplyAuthoritativeState(
@@ -453,34 +436,22 @@ internal sealed class ProjectileController
DiagnosticSink?.Invoke(
$"Ignored invalid State update clock for live object 0x{serverGuid:X8}; state remains authoritative.");
- if (record.ProjectileRuntime is Runtime runtime)
+ if (record.ProjectileRuntime is RuntimeProjectile runtime)
{
- runtime.InvalidatePrediction();
- bool wasMissile =
- (runtime.Body.State & PhysicsStateFlags.Missile) != 0;
- runtime.Body.State = state;
- if ((state & PhysicsStateFlags.Missile) != 0 && !wasMissile)
- {
- // Ownership transfers back from generic remote motion to the
- // absolute projectile clock. State alone must not activate,
- // but an already-active body keeps moving from now rather
- // than replaying the ordinary-motion interval. A malformed
- // local receipt clock is not retail state and cannot clear
- // Active; translate to the last finite App clock instead.
- runtime.Body.LastUpdateTime = effectiveClock;
- if (record.FullCellId != 0)
- {
- runtime.Body.SnapToCell(
- record.FullCellId,
- runtime.Body.Position,
- CellLocalFromWorld(
- runtime.Body.Position,
- record.FullCellId,
- liveCenterX,
- liveCenterY));
- }
- }
- return true;
+ return _runtimeUpdater.ApplyAuthoritativeState(
+ record.Canonical,
+ expectedStateAuthorityVersion,
+ state,
+ effectiveClock,
+ liveCenterX,
+ liveCenterY,
+ () => _liveEntities.TryGetRecord(
+ serverGuid,
+ out LiveEntityRecord current)
+ && ReferenceEquals(current, record)
+ && ReferenceEquals(
+ current.ProjectileRuntime,
+ runtime));
}
// SetState applies to the canonical CPhysicsObj independently of
@@ -552,7 +523,7 @@ internal sealed class ProjectileController
{
ArgumentNullException.ThrowIfNull(expectedRecord);
uint serverGuid = expectedRecord.ServerGuid;
- if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime)
+ if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out RuntimeProjectile runtime)
|| !ReferenceEquals(record, expectedRecord)
|| record.PositionAuthorityVersion != expectedPositionAuthorityVersion
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0
@@ -574,76 +545,39 @@ internal sealed class ProjectileController
}
_lastFiniteGameTime = currentTime;
- PhysicsBody body = runtime.Body;
- runtime.InvalidatePrediction();
- bool wasInWorld = body.InWorld;
- body.Orientation = orientation;
- body.SnapToCell(fullCellId, worldPosition, cellLocalPosition);
- body.State = record.FinalPhysicsState;
- // PositionPack::UnPack initializes an absent velocity to zero and
- // MoveOrTeleport installs that exact vector through set_velocity.
- // Apply it to this retained CPhysicsObj before any callback can
- // displace the incarnation; a replacement record owns another body.
- if (record.VelocityAuthorityVersion == expectedVelocityAuthorityVersion
- && !_liveEntities.TryCommitAuthoritativeVelocity(
- record,
- body,
- velocity,
- currentTime))
- {
- return true;
- }
- uint canonicalCellId = body.CellPosition.ObjCellId;
-
- entity.SetPosition(worldPosition);
- entity.Rotation = orientation;
- entity.ParentCellId = canonicalCellId;
- if (!_liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId)
- || !TryGetCurrent(serverGuid, out LiveEntityRecord afterRebucket, out Runtime afterRuntime)
- || !ReferenceEquals(afterRebucket, record)
- || !ReferenceEquals(afterRuntime, runtime)
- || !ReferenceEquals(afterRebucket.WorldEntity, entity)
- || afterRebucket.PositionAuthorityVersion
- != expectedPositionAuthorityVersion)
- {
- // This packet was a projectile correction when admitted. A
- // re-entrant observer displaced that incarnation; report handled
- // so the generic remote path cannot mutate the replacement.
- return true;
- }
- if (HasVisibleCell(record) && !IsHidden(record))
- {
- if (!wasInWorld)
+ bool ExternalOwnerValid() =>
+ TryGetCurrent(
+ serverGuid,
+ out LiveEntityRecord current,
+ out RuntimeProjectile currentRuntime)
+ && ReferenceEquals(current, record)
+ && ReferenceEquals(currentRuntime, runtime)
+ && ReferenceEquals(current.WorldEntity, entity)
+ && current.PositionAuthorityVersion
+ == expectedPositionAuthorityVersion;
+ return _runtimeUpdater.ApplyAuthoritativePosition(
+ record.Canonical,
+ expectedPositionAuthorityVersion,
+ expectedVelocityAuthorityVersion,
+ worldPosition,
+ cellLocalPosition,
+ orientation,
+ velocity,
+ fullCellId,
+ currentTime,
+ liveCenterX,
+ liveCenterY,
+ snapshot =>
{
- // prepare_to_enter_world rebases the CPhysicsObj timestamp in
- // the same operation that restores Active. The record clock is
- // canonical, but keep this legacy body field synchronized for
- // the absolute-time Core API and diagnostics.
- body.LastUpdateTime = currentTime;
- Activate(body, currentTime);
- }
- body.InWorld = true;
- ShadowPositionSynchronizer.Sync(
- _shadows,
- entity.Id,
- worldPosition,
- orientation,
- canonicalCellId,
- liveCenterX,
- liveCenterY);
- }
- else if (HasVisibleCell(record))
- {
- body.InWorld = true;
- body.LastUpdateTime = currentTime;
- _shadows.Suspend(entity.Id);
- }
- else
- {
- SuspendOutsideWorld(runtime, entity.Id);
- }
- _rootPoses?.UpdateRoot(entity);
- return true;
+ if (!ExternalOwnerValid())
+ return false;
+ entity.SetPosition(snapshot.Position);
+ entity.Rotation = snapshot.Orientation;
+ entity.ParentCellId = snapshot.FullCellId;
+ _rootPoses?.UpdateRoot(entity);
+ return ExternalOwnerValid();
+ },
+ ExternalOwnerValid);
}
///
@@ -660,7 +594,7 @@ internal sealed class ProjectileController
internal bool TryGetBody(uint serverGuid, out PhysicsBody body)
{
- if (TryGetCurrent(serverGuid, out _, out Runtime runtime))
+ if (TryGetCurrent(serverGuid, out _, out RuntimeProjectile runtime))
{
body = runtime.Body;
return true;
@@ -672,8 +606,7 @@ internal sealed class ProjectileController
internal bool LeaveWorld(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
- if (record.ProjectileRuntime is not Runtime runtime
- || runtime.Generation != record.Generation
+ if (record.ProjectileRuntime is not RuntimeProjectile runtime
|| record.WorldEntity is not { } entity)
return false;
SuspendOutsideWorld(runtime, entity.Id);
@@ -712,8 +645,7 @@ internal sealed class ProjectileController
_liveEntities.CopySpatialProjectileRecordsTo(_spatialProjectileSnapshot);
foreach (LiveEntityRecord record in _spatialProjectileSnapshot)
{
- if (record.ProjectileRuntime is not Runtime runtime
- || runtime.Generation != record.Generation
+ if (record.ProjectileRuntime is not RuntimeProjectile runtime
|| !_liveEntities.IsCurrentSpatialProjectile(record, runtime)
|| record.WorldEntity is not { } entity)
continue;
@@ -847,30 +779,34 @@ internal sealed class ProjectileController
{
ArgumentNullException.ThrowIfNull(record);
step = default;
- if (!TryGetCurrent(record.ServerGuid, out LiveEntityRecord current, out Runtime runtime)
+ if (!TryGetCurrent(
+ record.ServerGuid,
+ out LiveEntityRecord current,
+ out RuntimeProjectile runtime)
|| !ReferenceEquals(record, current)
|| current.WorldEntity is not { } entity
|| IsHidden(current)
|| !HasVisibleCell(current))
return false;
- runtime.Body.State = current.FinalPhysicsState;
- bool isParented = current.Snapshot.ParentGuid is not null
- || current.Snapshot.Physics?.Parent is not null;
- ProjectileQuantumPreparation preparation = _stepper.BeginQuantum(
- runtime.Body,
- quantum,
- current.FullCellId,
- runtime.CollisionSphere,
- isParented);
- if (!preparation.Simulated)
+ bool ExternalOwnerValid() =>
+ _liveEntities.IsCurrentRecord(current)
+ && ReferenceEquals(current.WorldEntity, entity)
+ && ReferenceEquals(current.ProjectileRuntime, runtime);
+ if (!_runtimeUpdater.TryBegin(
+ current.Canonical,
+ quantum,
+ current.ObjectClockEpoch,
+ ExternalOwnerValid,
+ out RuntimeProjectilePhysicsCommit runtimeCommit))
+ {
return false;
+ }
+
step = new QuantumStep(
current,
- runtime,
entity,
- preparation,
- runtime.PredictionAuthorityVersion);
+ runtimeCommit);
return true;
}
@@ -880,89 +816,42 @@ internal sealed class ProjectileController
int liveCenterY)
{
LiveEntityRecord current = step.Record;
- Runtime runtime = step.Runtime;
WorldEntity entity = step.Entity;
- if (!IsCurrentQuantumStep(step))
- return false;
-
- ProjectileAdvanceResult result = _stepper.CompleteQuantum(
- runtime.Body,
- step.Preparation,
- runtime.CollisionSphere,
- entity.Id,
- designatedTargetId: 0u);
- if (!result.Simulated || !IsCurrentQuantumStep(step))
- return false;
-
- uint priorCellId = current.FullCellId;
- uint resolvedCellId = result.CellId != 0 ? result.CellId : priorCellId;
- // Commit the retained CPhysicsObj's complete frame before Rebucket
- // publishes a loaded-to-pending edge. Pending is still the same live
- // object; its canonical body must already name the destination cell
- // so later hydration cannot resurrect the source-cell frame.
- runtime.Body.SnapToCell(
- resolvedCellId,
- runtime.Body.Position,
- CellLocalFromWorld(
- runtime.Body.Position,
- resolvedCellId,
- liveCenterX,
- liveCenterY));
- entity.SetPosition(runtime.Body.Position);
- entity.Rotation = runtime.Body.Orientation;
- entity.ParentCellId = resolvedCellId;
- if (resolvedCellId != priorCellId
- && !_liveEntities.RebucketLiveEntity(current.ServerGuid, resolvedCellId))
- {
- return false;
- }
- // A legitimate destination can now be pending and therefore absent
- // from the spatial-projectile index. Revalidate identity/mutation,
- // not visibility, after the canonical cell commit.
if (!IsCurrentQuantumIdentity(step))
- {
return false;
- }
+ QuantumStep exactStep = step;
- if (HasVisibleCell(current))
- {
- ShadowPositionSynchronizer.Sync(
- _shadows,
- entity.Id,
- runtime.Body.Position,
- runtime.Body.Orientation,
- current.FullCellId,
- liveCenterX,
- liveCenterY);
- _rootPoses?.UpdateRoot(entity);
- if (!IsCurrentQuantumIdentity(step))
- return false;
- }
- else
- {
- SuspendOutsideWorld(runtime, entity.Id);
- }
- return IsCurrentQuantumIdentity(step);
+ return _runtimeUpdater.Complete(
+ exactStep.RuntimeCommit,
+ liveCenterX,
+ liveCenterY,
+ snapshot =>
+ {
+ if (!IsCurrentQuantumIdentity(exactStep))
+ return false;
+ entity.SetPosition(snapshot.Position);
+ entity.Rotation = snapshot.Orientation;
+ entity.ParentCellId = snapshot.FullCellId;
+ if (HasVisibleCell(current))
+ _rootPoses?.UpdateRoot(entity);
+ return IsCurrentQuantumIdentity(exactStep);
+ });
}
- private bool IsCurrentQuantumStep(in QuantumStep step) =>
- IsCurrentQuantumIdentity(step)
- && _liveEntities.IsCurrentSpatialProjectile(step.Record, step.Runtime);
-
private bool IsCurrentQuantumIdentity(in QuantumStep step) =>
TryGetCurrent(
step.Record.ServerGuid,
out LiveEntityRecord current,
- out Runtime runtime)
+ out RuntimeProjectile runtime)
&& ReferenceEquals(current, step.Record)
- && ReferenceEquals(runtime, step.Runtime)
&& ReferenceEquals(current.WorldEntity, step.Entity)
+ && ReferenceEquals(runtime, step.RuntimeCommit.Projectile)
&& runtime.PredictionAuthorityVersion
- == step.PredictionAuthorityVersion;
+ == step.RuntimeCommit.PredictionAuthorityVersion;
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
- if (record.ProjectileRuntime is not Runtime runtime
+ if (record.ProjectileRuntime is not RuntimeProjectile runtime
|| record.WorldEntity is not { } entity
|| !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, record)
@@ -1020,22 +909,13 @@ internal sealed class ProjectileController
private static bool IsHidden(LiveEntityRecord record) =>
(record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
- private void SuspendOutsideWorld(Runtime runtime, uint localEntityId)
+ private void SuspendOutsideWorld(RuntimeProjectile runtime, uint localEntityId)
{
runtime.Body.InWorld = false;
Deactivate(runtime.Body);
_shadows.Suspend(localEntityId);
}
- private static void Activate(PhysicsBody body, double currentTime)
- {
- if ((body.State & PhysicsStateFlags.Static) != 0)
- return;
- if ((body.TransientState & TransientStateFlags.Active) == 0)
- body.LastUpdateTime = currentTime;
- body.TransientState |= TransientStateFlags.Active;
- }
-
private static void Deactivate(PhysicsBody body) =>
body.TransientState &= ~TransientStateFlags.Active;
@@ -1089,11 +969,10 @@ internal sealed class ProjectileController
private bool TryGetCurrent(
uint serverGuid,
out LiveEntityRecord record,
- out Runtime runtime)
+ out RuntimeProjectile runtime)
{
if (_liveEntities.TryGetRecord(serverGuid, out record!)
- && record.ProjectileRuntime is Runtime found
- && found.Generation == record.Generation)
+ && record.ProjectileRuntime is RuntimeProjectile found)
{
runtime = found;
return true;
diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
index 74e030c4..c97e622a 100644
--- a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
+++ b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
@@ -83,8 +83,8 @@ internal sealed class LiveEntityAnimationScheduler
LiveEntityAnimationState? animation = record.AnimationRuntime as LiveEntityAnimationState;
RemoteMotion? remote = record.RemoteMotionRuntime as RemoteMotion;
- ProjectileController.Runtime? projectile =
- record.ProjectileRuntime as ProjectileController.Runtime;
+ RuntimeProjectile? projectile =
+ record.ProjectileRuntime as RuntimeProjectile;
ulong objectClockEpoch = record.ObjectClockEpoch;
if (!IsCurrent(
@@ -167,7 +167,7 @@ internal sealed class LiveEntityAnimationScheduler
WorldEntity entity,
LiveEntityAnimationState? animation,
RemoteMotion? remote,
- ProjectileController.Runtime? projectile,
+ RuntimeProjectile? projectile,
float elapsedSeconds,
Vector3? playerPosition,
bool localHiddenPartPoseDirty,
@@ -508,7 +508,7 @@ internal sealed class LiveEntityAnimationScheduler
WorldEntity entity,
LiveEntityAnimationState? animation,
RemoteMotion? remote,
- ProjectileController.Runtime? projectile,
+ RuntimeProjectile? projectile,
ulong objectClockEpoch) =>
runtime.IsCurrentSpatialRootObject(record)
&& record.ObjectClockEpoch == objectClockEpoch
diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs
index ae2b9d6a..9c74d95b 100644
--- a/src/AcDream.App/World/LiveEntityRuntime.cs
+++ b/src/AcDream.App/World/LiveEntityRuntime.cs
@@ -20,12 +20,6 @@ public interface ILiveEntityAnimationRuntime
uint CurrentMotion { get; }
}
-/// Projectile physics state owned by a live object.
-public interface ILiveEntityProjectileRuntime
-{
- PhysicsBody Body { get; }
-}
-
/// Marker for the DAT-driven effect profile added by the effect phase.
public interface ILiveEntityEffectProfile { }
@@ -257,7 +251,7 @@ public sealed class LiveEntityRecord
get => Canonical.RequiresRemotePlacementRuntime;
set => _directory.SetRequiresRemotePlacementRuntime(Canonical, value);
}
- public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
+ public IRuntimeProjectile? ProjectileRuntime => Canonical.Projectile;
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
public bool ResourcesRegistered { get; internal set; }
public bool IsSpatiallyProjected { get; internal set; }
@@ -396,7 +390,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
// projection. Hidden and Frozen remain members: their state controls what
// each retail update path advances after the O(active) lookup.
private readonly Dictionary _spatialAnimations = new();
- private readonly Dictionary _spatialProjectiles = new();
private readonly List _spatialRootCanonicalScratch = new();
private readonly List _spatialRemoteCanonicalScratch = new();
private bool _isClearing;
@@ -477,7 +470,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
}
internal int SpatialAnimationRuntimeCount => _spatialAnimations.Count;
internal int SpatialRemoteMotionRuntimeCount => _physics.SpatialRemoteCount;
- internal int SpatialProjectileRuntimeCount => _spatialProjectiles.Count;
+ internal int SpatialProjectileRuntimeCount => _physics.SpatialProjectileCount;
internal int SpatialRootObjectCount => _physics.SpatialRootCount;
public ParentAttachmentState ParentAttachments => _directory.ParentAttachments;
@@ -1475,9 +1468,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return true;
}
- public void SetProjectileRuntime(uint serverGuid, ILiveEntityProjectileRuntime runtime)
+ internal IRuntimeProjectile BindProjectileRuntime(
+ uint serverGuid,
+ PhysicsBody body,
+ ProjectileCollisionSphere collisionSphere,
+ Func? externalOwnerValid = null)
{
- ArgumentNullException.ThrowIfNull(runtime);
+ ArgumentNullException.ThrowIfNull(body);
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is null)
{
@@ -1487,24 +1484,28 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot bind projectile physics during physics-body acquisition.");
- PhysicsBody candidateBody = runtime.Body;
if (record.PhysicsBody is { } canonicalBody
- && !ReferenceEquals(canonicalBody, candidateBody))
+ && !ReferenceEquals(canonicalBody, body))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
}
- record.ProjectileRuntime = runtime;
- record.PhysicsBody = candidateBody;
- candidateBody.State = record.FinalPhysicsState;
- SynchronizePhysicsBodyActiveState(record);
+ IRuntimeProjectile runtime = _physics.BindProjectile(
+ record.Canonical,
+ body,
+ collisionSphere,
+ () => IsCurrentRecord(record)
+ && record.WorldEntity is { } entity
+ && record.LocalEntityId == entity.Id
+ && (externalOwnerValid?.Invoke() ?? true));
RefreshSpatialRuntimeIndexes(record);
+ return runtime;
}
public bool TryGetProjectileRuntime(
uint serverGuid,
- out ILiveEntityProjectileRuntime runtime)
+ out IRuntimeProjectile runtime)
{
if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
&& record.ProjectileRuntime is { } found)
@@ -1523,7 +1524,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|| record.ProjectileRuntime is null)
return false;
- record.ProjectileRuntime = null;
+ _physics.ClearProjectile(record.Canonical);
RefreshSpatialRuntimeIndexes(record);
return true;
}
@@ -2021,11 +2022,11 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
- foreach ((RuntimeEntityKey key, ILiveEntityProjectileRuntime runtime)
- in _spatialProjectiles)
+ _physics.CopySpatialProjectilesTo(_spatialRootCanonicalScratch);
+ for (int i = 0; i < _spatialRootCanonicalScratch.Count; i++)
{
- if (_projections.TryGet(key, out LiveEntityRecord? record)
- && ReferenceEquals(record.ProjectileRuntime, runtime)
+ RuntimeEntityRecord canonical = _spatialRootCanonicalScratch[i];
+ if (_projections.TryGet(canonical, out LiveEntityRecord? record)
&& HasSpatialRuntimeProjection(record))
{
destination.Add(record);
@@ -2035,12 +2036,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
internal bool IsCurrentSpatialProjectile(
LiveEntityRecord record,
- ILiveEntityProjectileRuntime runtime) =>
+ IRuntimeProjectile runtime) =>
IsCurrentRecord(record)
- && ReferenceEquals(record.ProjectileRuntime, runtime)
- && record.ProjectionKey is { } key
- && _spatialProjectiles.TryGetValue(key, out var indexed)
- && ReferenceEquals(indexed, runtime)
+ && _physics.IsSpatialProjectile(record.Canonical, runtime)
&& HasSpatialRuntimeProjection(record);
public bool IsHidden(uint serverGuid) =>
@@ -2459,17 +2457,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
}
}
- if (spatial && record.ProjectileRuntime is { } projectile)
- {
- _spatialProjectiles[key] = projectile;
- }
- else if (current
- || (record.ProjectileRuntime is { } retainedProjectile
- && _spatialProjectiles.TryGetValue(key, out var indexedProjectile)
- && ReferenceEquals(indexedProjectile, retainedProjectile)))
- {
- _spatialProjectiles.Remove(key);
- }
}
private void RemoveSpatialRuntimeIndexes(LiveEntityRecord record)
@@ -2486,11 +2473,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_spatialAnimations.Remove(key);
}
- if (_spatialProjectiles.TryGetValue(key, out var indexedProjectile)
- && ReferenceEquals(indexedProjectile, record.ProjectileRuntime))
- {
- _spatialProjectiles.Remove(key);
- }
}
private void OnSpatialVisibilityChanged(RuntimeEntityKey key, bool visible)
@@ -2681,7 +2663,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
}
_spatialAnimations.Clear();
- _spatialProjectiles.Clear();
_physics.ClearSpatialWorksets();
_projections.ClearConverged();
_spatial.ClearLiveEntityLifetimeState();
@@ -2717,6 +2698,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
// a retryable tombstone until every external cleanup acknowledges
// success.
RemoveSpatialRuntimeIndexes(record);
+ _physics.ClearProjectile(record.Canonical);
if (!record.RuntimeComponentsTeardownCompleted)
{
@@ -2756,7 +2738,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
record.PhysicsHost = null;
record.RequiresRemotePlacementRuntime = false;
record.PhysicsBody = null;
- record.ProjectileRuntime = null;
record.EffectProfile = null;
record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false;
diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
index e5ffd086..02d2722b 100644
--- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
+++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
@@ -355,6 +355,22 @@ public sealed class RuntimeEntityDirectory
record.RemoteMotionBindingInProgress = value;
}
+ public void SetProjectile(
+ RuntimeEntityRecord record,
+ IRuntimeProjectile? projectile)
+ {
+ EnsureKnown(record);
+ record.Projectile = projectile;
+ }
+
+ public void SetProjectileBindingInProgress(
+ RuntimeEntityRecord record,
+ bool value)
+ {
+ EnsureKnown(record);
+ record.ProjectileBindingInProgress = value;
+ }
+
public void SetRequiresRemotePlacementRuntime(
RuntimeEntityRecord record,
bool value)
diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs
index 0b2c1761..8f6c6039 100644
--- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs
+++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs
@@ -97,6 +97,20 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Events = new RuntimeEntityObjectEventStream(Entities, Objects);
}
+ internal RuntimeEntityObjectLifetime(
+ PhysicsEngine physicsEngine,
+ uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
+ {
+ ArgumentNullException.ThrowIfNull(physicsEngine);
+ Entities = new RuntimeEntityDirectory(firstLocalEntityId);
+ Physics = new RuntimePhysicsState(Entities, physicsEngine);
+ Objects = new ClientObjectTable();
+ var views = new RuntimeEntityObjectViews(Entities, Objects);
+ EntityView = views.Entities;
+ InventoryView = views.Inventory;
+ Events = new RuntimeEntityObjectEventStream(Entities, Objects);
+ }
+
public RuntimeEntityDirectory Entities { get; }
public RuntimePhysicsState Physics { get; }
public ClientObjectTable Objects { get; }
@@ -344,6 +358,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Physics.RemoveSpatialProjection(canonical);
Entities.SetRemoteMotion(canonical, null);
Entities.SetRemoteMotionBindingInProgress(canonical, false);
+ Entities.SetProjectile(canonical, null);
+ Entities.SetProjectileBindingInProgress(canonical, false);
Entities.SetRequiresRemotePlacementRuntime(canonical, false);
Entities.SetPhysicsHost(canonical, null);
Entities.SetPhysicsBody(canonical, null);
diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs
index 42bdd6b2..57ffb766 100644
--- a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs
+++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs
@@ -71,6 +71,8 @@ public sealed class RuntimeEntityRecord
public bool PhysicsBodyAcquisitionInProgress { get; internal set; }
public IRuntimeRemoteMotion? RemoteMotion { get; internal set; }
public bool RemoteMotionBindingInProgress { get; internal set; }
+ public IRuntimeProjectile? Projectile { get; internal set; }
+ public bool ProjectileBindingInProgress { get; internal set; }
public bool RequiresRemotePlacementRuntime { get; internal set; }
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; }
public ulong PositionAuthorityVersion { get; private set; }
diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
index 9ba514e4..18dc4608 100644
--- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
+++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
@@ -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
_spatialRemotes = new();
+ private readonly Dictionary
+ _spatialProjectiles = new();
private readonly Dictionary _collisionGenerations = new();
private readonly Dictionary
_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);
+ }
+
+ ///
+ /// 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.
+ ///
+ public IRuntimeProjectile BindProjectile(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ ProjectileCollisionSphere collisionSphere,
+ Func? 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;
+ }
+
+ ///
+ /// Commits retail CPhysicsObj::set_velocity (0x005113F0) and,
+ /// when supplied, the paired set_omega write to the incarnation's
+ /// canonical body. Runtime owns the shared body/object-clock activation
+ /// edge; graphical hosts only validate and translate inbound payloads.
+ ///
+ internal bool TryCommitAuthoritativeVector(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ System.Numerics.Vector3 velocity,
+ System.Numerics.Vector3? angularVelocity,
+ double currentTime,
+ Func? 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 destination)
{
EnsureNotDisposed();
@@ -529,6 +719,26 @@ public sealed class RuntimePhysicsState : IDisposable
}
}
+ public void CopySpatialProjectilesTo(List 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? 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)
diff --git a/src/AcDream.Runtime/Physics/RuntimeProjectile.cs b/src/AcDream.Runtime/Physics/RuntimeProjectile.cs
new file mode 100644
index 00000000..f31646fa
--- /dev/null
+++ b/src/AcDream.Runtime/Physics/RuntimeProjectile.cs
@@ -0,0 +1,40 @@
+using AcDream.Core.Physics;
+
+namespace AcDream.Runtime.Physics;
+
+///
+/// Presentation-free projectile component attached to one canonical Runtime
+/// entity incarnation. The body is retail's one CPhysicsObj; the
+/// prepared Setup sphere and prediction version die with the same incarnation.
+///
+public interface IRuntimeProjectile
+{
+ PhysicsBody Body { get; }
+ ProjectileCollisionSphere CollisionSphere { get; }
+ ulong PredictionAuthorityVersion { get; }
+}
+
+internal sealed class RuntimeProjectile : IRuntimeProjectile
+{
+ internal RuntimeProjectile(
+ PhysicsBody body,
+ ProjectileCollisionSphere collisionSphere)
+ {
+ Body = body ?? throw new ArgumentNullException(nameof(body));
+ if (!collisionSphere.IsValid)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(collisionSphere),
+ "A Runtime projectile requires one valid prepared Setup sphere.");
+ }
+
+ CollisionSphere = collisionSphere;
+ }
+
+ public PhysicsBody Body { get; }
+ public ProjectileCollisionSphere CollisionSphere { get; }
+ public ulong PredictionAuthorityVersion { get; private set; }
+
+ internal void InvalidatePrediction() =>
+ PredictionAuthorityVersion++;
+}
diff --git a/src/AcDream.Runtime/Physics/RuntimeProjectilePhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeProjectilePhysicsUpdater.cs
new file mode 100644
index 00000000..325b1493
--- /dev/null
+++ b/src/AcDream.Runtime/Physics/RuntimeProjectilePhysicsUpdater.cs
@@ -0,0 +1,500 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Runtime.Entities;
+
+namespace AcDream.Runtime.Physics;
+
+internal sealed class RuntimeProjectilePhysicsCommit
+{
+ internal required RuntimeProjectilePhysicsUpdater Owner { get; init; }
+ internal required RuntimeEntityRecord Record { get; init; }
+ internal required RuntimeProjectile Projectile { get; init; }
+ internal required ProjectileQuantumPreparation Preparation { get; init; }
+ internal required ulong PredictionAuthorityVersion { get; init; }
+ internal required ulong ObjectClockEpoch { get; init; }
+ internal required Func? ExternalOwnerValid { get; init; }
+ internal bool Completed { get; set; }
+}
+
+///
+/// Presentation-free owner of the projectile slice in retail
+/// CPhysicsObj::UpdateObjectInternal (0x005156B0). The caller
+/// preserves retail hook ordering by holding the returned transaction across
+/// process_hooks; Runtime owns integration, transition, prediction
+/// versions, canonical cell state, and the physics shadow.
+///
+internal sealed class RuntimeProjectilePhysicsUpdater
+{
+ private readonly RuntimePhysicsState _physics;
+ private readonly ProjectilePhysicsStepper _stepper;
+
+ internal RuntimeProjectilePhysicsUpdater(RuntimePhysicsState physics)
+ {
+ _physics = physics ?? throw new ArgumentNullException(nameof(physics));
+ _stepper = new ProjectilePhysicsStepper(physics.Engine);
+ }
+
+ internal bool TryBegin(
+ RuntimeEntityRecord record,
+ float quantum,
+ ulong objectClockEpoch,
+ Func? externalOwnerValid,
+ out RuntimeProjectilePhysicsCommit commit)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ if (record.Projectile is not RuntimeProjectile projectile
+ || !IsSpatialCurrent(
+ record,
+ projectile,
+ projectile.PredictionAuthorityVersion,
+ objectClockEpoch,
+ externalOwnerValid)
+ || (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
+ || record.FullCellId == 0)
+ {
+ commit = null!;
+ return false;
+ }
+
+ projectile.Body.State = record.FinalPhysicsState;
+ bool isParented = record.Snapshot.ParentGuid is not null
+ || record.Snapshot.Physics?.Parent is not null;
+ ProjectileQuantumPreparation preparation = _stepper.BeginQuantum(
+ projectile.Body,
+ quantum,
+ record.FullCellId,
+ projectile.CollisionSphere,
+ isParented);
+ if (!preparation.Simulated
+ || !IsSpatialCurrent(
+ record,
+ projectile,
+ projectile.PredictionAuthorityVersion,
+ objectClockEpoch,
+ externalOwnerValid))
+ {
+ commit = null!;
+ return false;
+ }
+
+ commit = new RuntimeProjectilePhysicsCommit
+ {
+ Owner = this,
+ Record = record,
+ Projectile = projectile,
+ Preparation = preparation,
+ PredictionAuthorityVersion =
+ projectile.PredictionAuthorityVersion,
+ ObjectClockEpoch = objectClockEpoch,
+ ExternalOwnerValid = externalOwnerValid,
+ };
+ return true;
+ }
+
+ internal bool Complete(
+ RuntimeProjectilePhysicsCommit commit,
+ int liveCenterX,
+ int liveCenterY,
+ Func acknowledgeProjection)
+ {
+ ArgumentNullException.ThrowIfNull(commit);
+ ArgumentNullException.ThrowIfNull(acknowledgeProjection);
+ if (!ReferenceEquals(commit.Owner, this))
+ {
+ throw new InvalidOperationException(
+ "A projectile-physics commit belongs to another Runtime owner.");
+ }
+ if (commit.Completed)
+ {
+ throw new InvalidOperationException(
+ "A projectile-physics commit has already completed.");
+ }
+ commit.Completed = true;
+
+ if (!IsSpatialCurrent(
+ commit.Record,
+ commit.Projectile,
+ commit.PredictionAuthorityVersion,
+ commit.ObjectClockEpoch,
+ commit.ExternalOwnerValid))
+ {
+ return false;
+ }
+
+ PhysicsBody body = commit.Projectile.Body;
+ ProjectileAdvanceResult result = _stepper.CompleteQuantum(
+ body,
+ commit.Preparation,
+ commit.Projectile.CollisionSphere,
+ commit.Record.LocalEntityId ?? 0u,
+ designatedTargetId: 0u);
+ if (!result.Simulated
+ || !IsSpatialCurrent(
+ commit.Record,
+ commit.Projectile,
+ commit.PredictionAuthorityVersion,
+ commit.ObjectClockEpoch,
+ commit.ExternalOwnerValid))
+ {
+ return false;
+ }
+
+ uint resolvedCellId = result.CellId != 0
+ ? result.CellId
+ : commit.Record.FullCellId;
+ body.SnapToCell(
+ resolvedCellId,
+ body.Position,
+ CellLocalFromWorld(
+ body.Position,
+ resolvedCellId,
+ liveCenterX,
+ liveCenterY));
+
+ var snapshot = new RuntimePhysicsFrameSnapshot(
+ body.Position,
+ body.Orientation,
+ resolvedCellId);
+ if (!_physics.CommitProjectileCell(
+ commit.Record,
+ commit.Projectile,
+ commit.PredictionAuthorityVersion,
+ resolvedCellId,
+ commit.ExternalOwnerValid)
+ || !IsIdentityCurrent(
+ commit.Record,
+ commit.Projectile,
+ commit.PredictionAuthorityVersion,
+ commit.ExternalOwnerValid)
+ || !acknowledgeProjection(snapshot)
+ || !IsIdentityCurrent(
+ commit.Record,
+ commit.Projectile,
+ commit.PredictionAuthorityVersion,
+ commit.ExternalOwnerValid))
+ {
+ return false;
+ }
+
+ if (_physics.IsSpatialProjectile(
+ commit.Record,
+ commit.Projectile)
+ && (commit.Record.FinalPhysicsState
+ & PhysicsStateFlags.Hidden) == 0)
+ {
+ ShadowPositionSynchronizer.Sync(
+ _physics.Engine.ShadowObjects,
+ commit.Record.LocalEntityId ?? 0u,
+ body.Position,
+ body.Orientation,
+ commit.Record.FullCellId,
+ liveCenterX,
+ liveCenterY);
+ }
+ else
+ {
+ _physics.Engine.ShadowObjects.Suspend(
+ commit.Record.LocalEntityId ?? 0u);
+ }
+
+ return IsIdentityCurrent(
+ commit.Record,
+ commit.Projectile,
+ commit.PredictionAuthorityVersion,
+ commit.ExternalOwnerValid);
+ }
+
+ internal bool ApplyAuthoritativeVector(
+ RuntimeEntityRecord record,
+ ulong expectedVectorAuthorityVersion,
+ ulong expectedVelocityAuthorityVersion,
+ Vector3 velocity,
+ Vector3 angularVelocity,
+ double currentTime,
+ Func? externalOwnerValid = null)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ if (!TryGetCurrent(
+ record,
+ externalOwnerValid,
+ out RuntimeProjectile projectile)
+ || record.VectorAuthorityVersion
+ != expectedVectorAuthorityVersion
+ || record.VelocityAuthorityVersion
+ != expectedVelocityAuthorityVersion)
+ {
+ return false;
+ }
+
+ if ((record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0
+ && record.RemoteMotion is not null)
+ {
+ return false;
+ }
+ if (!IsFinite(velocity)
+ || !IsFinite(angularVelocity)
+ || !double.IsFinite(currentTime))
+ {
+ return true;
+ }
+
+ projectile.InvalidatePrediction();
+ _ = _physics.TryCommitAuthoritativeVector(
+ record,
+ projectile.Body,
+ velocity,
+ angularVelocity,
+ currentTime,
+ externalOwnerValid);
+ return true;
+ }
+
+ internal bool ApplyAuthoritativeState(
+ RuntimeEntityRecord record,
+ ulong expectedStateAuthorityVersion,
+ PhysicsStateFlags state,
+ double effectiveClock,
+ int liveCenterX,
+ int liveCenterY,
+ Func? externalOwnerValid = null)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ if (!TryGetCurrent(
+ record,
+ externalOwnerValid,
+ out RuntimeProjectile projectile)
+ || record.StateAuthorityVersion
+ != expectedStateAuthorityVersion)
+ {
+ return false;
+ }
+
+ projectile.InvalidatePrediction();
+ PhysicsBody body = projectile.Body;
+ if (!double.IsFinite(effectiveClock))
+ {
+ effectiveClock = double.IsFinite(body.LastUpdateTime)
+ ? body.LastUpdateTime
+ : 0d;
+ }
+ bool wasMissile =
+ (body.State & PhysicsStateFlags.Missile) != 0;
+ body.State = state;
+ if ((state & PhysicsStateFlags.Missile) != 0 && !wasMissile)
+ {
+ body.LastUpdateTime = effectiveClock;
+ if (record.FullCellId != 0)
+ {
+ body.SnapToCell(
+ record.FullCellId,
+ body.Position,
+ CellLocalFromWorld(
+ body.Position,
+ record.FullCellId,
+ liveCenterX,
+ liveCenterY));
+ }
+ }
+ return true;
+ }
+
+ internal bool ApplyAuthoritativePosition(
+ RuntimeEntityRecord record,
+ ulong expectedPositionAuthorityVersion,
+ ulong expectedVelocityAuthorityVersion,
+ Vector3 worldPosition,
+ Vector3 cellLocalPosition,
+ Quaternion orientation,
+ Vector3 velocity,
+ uint fullCellId,
+ double currentTime,
+ int liveCenterX,
+ int liveCenterY,
+ Func acknowledgeProjection,
+ Func? externalOwnerValid = null)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ ArgumentNullException.ThrowIfNull(acknowledgeProjection);
+ if (!TryGetCurrent(
+ record,
+ externalOwnerValid,
+ out RuntimeProjectile projectile)
+ || record.PositionAuthorityVersion
+ != expectedPositionAuthorityVersion
+ || (record.FinalPhysicsState
+ & PhysicsStateFlags.Missile) == 0)
+ {
+ return false;
+ }
+ if (!double.IsFinite(currentTime)
+ || !IsFinite(worldPosition)
+ || !IsFinite(cellLocalPosition)
+ || !IsFinite(velocity)
+ || !PositionFrameValidation.IsValid(
+ fullCellId,
+ cellLocalPosition,
+ orientation))
+ {
+ return true;
+ }
+
+ PhysicsBody body = projectile.Body;
+ projectile.InvalidatePrediction();
+ ulong predictionVersion = projectile.PredictionAuthorityVersion;
+ bool wasInWorld = body.InWorld;
+ body.Orientation = orientation;
+ body.SnapToCell(fullCellId, worldPosition, cellLocalPosition);
+ body.State = record.FinalPhysicsState;
+ if (record.VelocityAuthorityVersion
+ == expectedVelocityAuthorityVersion)
+ {
+ _ = _physics.TryCommitAuthoritativeVector(
+ record,
+ body,
+ velocity,
+ angularVelocity: null,
+ currentTime,
+ externalOwnerValid);
+ }
+
+ bool IsExactOwner() =>
+ TryGetCurrent(
+ record,
+ externalOwnerValid,
+ out RuntimeProjectile current)
+ && ReferenceEquals(current, projectile)
+ && current.PredictionAuthorityVersion == predictionVersion
+ && record.PositionAuthorityVersion
+ == expectedPositionAuthorityVersion;
+
+ if (!_physics.CommitProjectileCell(
+ record,
+ projectile,
+ predictionVersion,
+ body.CellPosition.ObjCellId,
+ IsExactOwner)
+ || !IsExactOwner())
+ {
+ // This packet was accepted for the old incarnation. A re-entrant
+ // observer displaced it, so the replacement owns another body.
+ return true;
+ }
+
+ var snapshot = new RuntimePhysicsFrameSnapshot(
+ body.Position,
+ body.Orientation,
+ body.CellPosition.ObjCellId);
+ if (!acknowledgeProjection(snapshot) || !IsExactOwner())
+ return true;
+
+ bool spatial = _physics.IsSpatialProjectile(record, projectile);
+ bool hidden =
+ (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
+ uint localId = record.LocalEntityId ?? 0u;
+ if (spatial && !hidden)
+ {
+ if (!wasInWorld)
+ {
+ body.LastUpdateTime = currentTime;
+ Activate(body, currentTime);
+ }
+ body.InWorld = true;
+ ShadowPositionSynchronizer.Sync(
+ _physics.Engine.ShadowObjects,
+ localId,
+ body.Position,
+ body.Orientation,
+ record.FullCellId,
+ liveCenterX,
+ liveCenterY);
+ }
+ else if (spatial)
+ {
+ body.InWorld = true;
+ body.LastUpdateTime = currentTime;
+ _physics.Engine.ShadowObjects.Suspend(localId);
+ }
+ else
+ {
+ body.InWorld = false;
+ Deactivate(body);
+ _physics.Engine.ShadowObjects.Suspend(localId);
+ }
+ return true;
+ }
+
+ private bool IsSpatialCurrent(
+ RuntimeEntityRecord record,
+ RuntimeProjectile projectile,
+ ulong predictionAuthorityVersion,
+ ulong objectClockEpoch,
+ Func? externalOwnerValid) =>
+ _physics.IsSpatialProjectile(record, projectile)
+ && record.ObjectClockEpoch == objectClockEpoch
+ && IsIdentityCurrent(
+ record,
+ projectile,
+ predictionAuthorityVersion,
+ externalOwnerValid);
+
+ private bool IsIdentityCurrent(
+ RuntimeEntityRecord record,
+ RuntimeProjectile projectile,
+ ulong predictionAuthorityVersion,
+ Func? externalOwnerValid) =>
+ _physics.Entities.IsCurrent(record)
+ && ReferenceEquals(record.Projectile, projectile)
+ && ReferenceEquals(record.PhysicsBody, projectile.Body)
+ && projectile.PredictionAuthorityVersion
+ == predictionAuthorityVersion
+ && (externalOwnerValid?.Invoke() ?? true);
+
+ private bool TryGetCurrent(
+ RuntimeEntityRecord record,
+ Func? externalOwnerValid,
+ out RuntimeProjectile projectile)
+ {
+ if (_physics.Entities.IsCurrent(record)
+ && record.Projectile is RuntimeProjectile current
+ && ReferenceEquals(record.PhysicsBody, current.Body)
+ && (externalOwnerValid?.Invoke() ?? true))
+ {
+ projectile = current;
+ return true;
+ }
+
+ projectile = null!;
+ return false;
+ }
+
+ private static Vector3 CellLocalFromWorld(
+ Vector3 worldPosition,
+ uint cellId,
+ int liveCenterX,
+ int liveCenterY)
+ {
+ int landblockX = (int)((cellId >> 24) & 0xFFu);
+ int landblockY = (int)((cellId >> 16) & 0xFFu);
+ return worldPosition - new Vector3(
+ (landblockX - liveCenterX) * 192f,
+ (landblockY - liveCenterY) * 192f,
+ 0f);
+ }
+
+ private static void Activate(PhysicsBody body, double currentTime)
+ {
+ if ((body.State & PhysicsStateFlags.Static) != 0)
+ return;
+ if ((body.TransientState & TransientStateFlags.Active) == 0)
+ body.LastUpdateTime = currentTime;
+ body.TransientState |= TransientStateFlags.Active;
+ }
+
+ private static void Deactivate(PhysicsBody body) =>
+ body.TransientState &= ~TransientStateFlags.Active;
+
+ private static bool IsFinite(Vector3 value) =>
+ float.IsFinite(value.X)
+ && float.IsFinite(value.Y)
+ && float.IsFinite(value.Z);
+}
diff --git a/tests/AcDream.App.Tests/LiveEntityRuntimeFixture.cs b/tests/AcDream.App.Tests/LiveEntityRuntimeFixture.cs
index 0031c108..b1292cf3 100644
--- a/tests/AcDream.App.Tests/LiveEntityRuntimeFixture.cs
+++ b/tests/AcDream.App.Tests/LiveEntityRuntimeFixture.cs
@@ -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);
+ }
}
diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs
index bc55ffe6..1434b067 100644
--- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs
+++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs
@@ -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(
diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs
index adc454af..abbcce91 100644
--- a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs
+++ b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs
@@ -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);
diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs
index 99dc8a1c..94fd25f5 100644
--- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs
@@ -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,
diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
index 59376cce..99f5ecc5 100644
--- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
+++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
@@ -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)
diff --git a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs
index c097c2ae..14fc2a48 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs
@@ -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,
diff --git a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs
index 5912b8b0..ad291825 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs
@@ -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,
diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
index e5d9e222..cd689b67 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
@@ -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));
diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs
index c91870dc..6a245381 100644
--- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs
@@ -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(() =>
+ 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(() =>
+ updater.Complete(
+ commit,
+ liveCenterX: 1,
+ liveCenterY: 1,
+ _ => true));
+ }
+
[Fact]
public void EqualLocalKeysInConcurrentRuntimesDoNotShareWorksets()
{