From 2aee33569f0268d7bc0f4f52437dfa0b405432a4 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Jul 2026 14:17:42 +0200 Subject: [PATCH] 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. --- .../LivePresentationComposition.cs | 1 - .../Physics/ProjectileController.cs | 377 +++++-------- .../Rendering/LiveEntityAnimationScheduler.cs | 8 +- src/AcDream.App/World/LiveEntityRuntime.cs | 73 +-- .../Entities/RuntimeEntityDirectory.cs | 16 + .../Entities/RuntimeEntityObjectLifetime.cs | 16 + .../Entities/RuntimeEntityRecord.cs | 2 + .../Physics/RuntimePhysicsState.cs | 255 +++++++++ .../Physics/RuntimeProjectile.cs | 40 ++ .../RuntimeProjectilePhysicsUpdater.cs | 500 ++++++++++++++++++ .../LiveEntityRuntimeFixture.cs | 30 ++ .../Physics/ProjectileControllerTests.cs | 19 +- .../Physics/RemoteTeleportControllerTests.cs | 14 +- .../LiveEntityAnimationSchedulerTests.cs | 10 +- .../GameWindowLiveEntityCompositionTests.cs | 13 + .../World/LiveEntityLifecycleStressTests.cs | 4 +- ...tityProjectionWithdrawalControllerTests.cs | 6 +- .../World/LiveEntityRuntimeTests.cs | 11 +- .../Physics/RuntimePhysicsStateTests.cs | 195 +++++++ 19 files changed, 1255 insertions(+), 335 deletions(-) create mode 100644 src/AcDream.Runtime/Physics/RuntimeProjectile.cs create mode 100644 src/AcDream.Runtime/Physics/RuntimeProjectilePhysicsUpdater.cs 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() {