using System.Numerics; using AcDream.App.Rendering; using AcDream.App.World; using AcDream.Content; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using DatReaderWriter.DBObjs; using RemoteMotion = AcDream.App.Physics.RemoteMotion; namespace AcDream.App.Physics; internal interface IProjectileSetupResolver { Setup? Resolve(uint setupId); } internal sealed class DatProjectileSetupResolver : IProjectileSetupResolver { private readonly IDatReaderWriter _dats; private readonly object _datLock; public DatProjectileSetupResolver(IDatReaderWriter dats, object datLock) { _dats = dats ?? throw new ArgumentNullException(nameof(dats)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); } public Setup? Resolve(uint setupId) { lock (_datLock) return _dats.Get(setupId); } } /// /// 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. /// /// /// Retail anchors are CPhysicsObj::update_object 0x00515D10, /// CPhysicsObj::UpdateObjectInternal 0x005156B0, and /// CPhysicsObj::SetPositionInternal 0x00515330. The pure /// integration/sweep is implemented by . /// 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); private readonly LiveEntityRuntime _liveEntities; private readonly ProjectilePhysicsStepper _stepper; private readonly ShadowObjectRegistry _shadows; private readonly IProjectileSetupResolver? _setupResolver; private readonly IEntityRootPosePublisher? _rootPoses; private readonly LiveWorldOriginState? _origin; private readonly List _spatialProjectileSnapshot = new(); private double _lastFiniteGameTime; 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; _setupResolver = setupResolver; _rootPoses = rootPoses; _origin = origin; _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; } internal Action? DiagnosticSink { get; set; } internal int Count => _liveEntities.Records.Count( static record => record.ProjectileRuntime is Runtime); internal bool CanAcceptVectorPayload( uint serverGuid, Vector3 velocity, Vector3 angularVelocity) { bool valid = IsFinite(velocity) && IsFinite(angularVelocity); if (!valid) DiagnosticSink?.Invoke( $"Rejected invalid VectorUpdate for live object 0x{serverGuid:X8}."); return valid; } internal bool CanAcceptPositionPayload( uint serverGuid, CreateObject.ServerPosition position, Vector3? velocity) { var origin = new Vector3( position.PositionX, position.PositionY, position.PositionZ); var orientation = new Quaternion( position.RotationX, position.RotationY, position.RotationZ, position.RotationW); bool valid = IsFinite(origin) && PositionFrameValidation.IsValid( position.LandblockId, origin, orientation) && (velocity is null || IsFinite(velocity.Value)); if (!valid) DiagnosticSink?.Invoke( $"Rejected invalid PositionUpdate for live object 0x{serverGuid:X8}."); return valid; } /// /// Installs a projectile body once, after the live record and render /// projection exist. Re-entry returns the same component and never replays /// logical registration or create-time effects. /// internal bool TryBind( LiveEntityRecord record, Setup setup, double currentTime, int liveCenterX = 0, int liveCenterY = 0) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(setup); if (!_liveEntities.TryGetRecord(record.ServerGuid, out var liveRecord) || !ReferenceEquals(liveRecord, record) || (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0) return false; if (record.ProjectileRuntime is Runtime retained) { retained.Body.State = record.FinalPhysicsState; return true; } if (record.WorldEntity is not { } entity || record.Snapshot.Physics is not { } physics || record.Snapshot.Position is not { } wirePosition) return false; float scale = physics.Scale ?? record.Snapshot.ObjScale ?? 1f; if (!TryGetCollisionSphere(setup, scale, out ProjectileCollisionSphere sphere)) { DiagnosticSink?.Invoke( $"Missile 0x{record.ServerGuid:X8} Setup 0x{record.Snapshot.SetupTableId ?? 0u:X8} " + $"does not have the supported retail one-sphere collision shape."); return false; } if (!double.IsFinite(currentTime)) { DiagnosticSink?.Invoke( $"Missile 0x{record.ServerGuid:X8} has an invalid physics clock."); return false; } _lastFiniteGameTime = currentTime; PhysicsBody body; if (record.PhysicsBody is { } sharedBody) { // Retail owns one CPhysicsObj. If a non-missile incarnation // already created its MovementManager or entered the Physics-Static // animation workset, classification adopts that same body instead // of replacing it or replaying CreateObject vectors. body = sharedBody; uint currentCellId = record.FullCellId; Vector3 currentCellLocal = CellLocalFromWorld( body.Position, currentCellId, liveCenterX, liveCenterY); if (!IsFinite(body.Position) || !IsFinite(body.Velocity) || !IsFinite(body.Omega) || !PositionFrameValidation.IsValid( currentCellId, currentCellLocal, body.Orientation)) { DiagnosticSink?.Invoke( $"Missile 0x{record.ServerGuid:X8} has an invalid current shared physics frame or vector."); return false; } body.State = record.FinalPhysicsState; body.Friction = NormalizeFriction( physics.Friction ?? record.Snapshot.Friction); body.Elasticity = NormalizeElasticity( physics.Elasticity ?? record.Snapshot.Elasticity ?? 0.05f); // The MovementManager can predate missile classification and may // already have advanced beyond the retained CreateObject frame. // Preserve its current velocity, omega, orientation, and Active // state exactly as retail SetState does. Only normalize the // carried cell frame and translate the legacy remote clock onto // the App projectile clock. body.SnapToCell( currentCellId, body.Position, currentCellLocal); body.LastUpdateTime = currentTime; } else { Vector3 velocity = physics.Velocity ?? Vector3.Zero; Vector3 omega = physics.AngularVelocity ?? Vector3.Zero; if (!IsFinite(entity.Position) || !PositionFrameValidation.IsValid( wirePosition.LandblockId, new Vector3( wirePosition.PositionX, wirePosition.PositionY, wirePosition.PositionZ), entity.Rotation) || !IsFinite(velocity) || !IsFinite(omega)) { DiagnosticSink?.Invoke( $"Missile 0x{record.ServerGuid:X8} has an invalid initial physics frame or vector."); return false; } // 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. body = _liveEntities.GetOrCreatePhysicsBody( record.ServerGuid, _ => { var created = new PhysicsBody { State = record.FinalPhysicsState, Friction = NormalizeFriction( physics.Friction ?? record.Snapshot.Friction), Elasticity = NormalizeElasticity( physics.Elasticity ?? record.Snapshot.Elasticity ?? 0.05f), Orientation = entity.Rotation, LastUpdateTime = currentTime, }; SetVelocity(created, velocity, currentTime); created.Omega = omega; created.SnapToCell( wirePosition.LandblockId, entity.Position, new Vector3( wirePosition.PositionX, wirePosition.PositionY, wirePosition.PositionZ)); return created; }); } uint canonicalCellId = body.CellPosition.ObjCellId; entity.SetPosition(body.Position); entity.Rotation = body.Orientation; entity.ParentCellId = canonicalCellId; if (!_liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId) || !_liveEntities.TryGetRecord(record.ServerGuid, out var currentRecord) || !ReferenceEquals(currentRecord, record) || !ReferenceEquals(currentRecord.WorldEntity, entity) || !ReferenceEquals(currentRecord.PhysicsBody, body) || (currentRecord.FinalPhysicsState & PhysicsStateFlags.Missile) == 0) { return false; } // Rebucket publishes projection callbacks synchronously. A callback // can re-enter classification for this same incarnation and install // 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) return ReferenceEquals(concurrentRuntime.Body, body); // A newer Position can legally arrive from a projection callback // while this independent State(Missile) classification is in flight. // Both packets must win: install the component around the current // 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); if (HasVisibleCell(record) && !IsHidden(record)) { ShadowPositionSynchronizer.Sync( _shadows, entity.Id, body.Position, body.Orientation, canonicalCellId, liveCenterX, liveCenterY); } else if (HasVisibleCell(record)) { // Hidden is a retained in-world CPhysicsObj, not leave_world. // Keep Active/identity but remove collision rows and consume the // hidden clock so UnHide cannot replay a time backlog. body.InWorld = true; body.LastUpdateTime = currentTime; _shadows.Suspend(entity.Id); } else { SuspendOutsideWorld(runtime, entity.Id); } _rootPoses?.UpdateRoot(entity); return true; } internal bool ApplyAuthoritativeVector( LiveEntityRecord expectedRecord, Vector3 velocity, Vector3 angularVelocity, double currentTime) => ApplyAuthoritativeVector( expectedRecord, expectedRecord.VectorAuthorityVersion, expectedRecord.VelocityAuthorityVersion, velocity, angularVelocity, currentTime); internal bool ApplyAuthoritativeVector( LiveEntityRecord expectedRecord, ulong expectedVectorAuthorityVersion, ulong expectedVelocityAuthorityVersion, Vector3 velocity, Vector3 angularVelocity, double currentTime) { ArgumentNullException.ThrowIfNull(expectedRecord); uint serverGuid = expectedRecord.ServerGuid; if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime)) return false; if (!ReferenceEquals(record, expectedRecord) || record.VectorAuthorityVersion != expectedVectorAuthorityVersion || record.VelocityAuthorityVersion != expectedVelocityAuthorityVersion) return false; // Once Missile is cleared, an existing MovementManager owns ordinary // jump/vector side effects (Airborne, gravity, LeaveGround) on this // same body. Let that established funnel consume the packet. A // projectile-only object still needs the unconditional retail // set_velocity/set_omega writes here. if ((record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0 && record.RemoteMotionRuntime is not null) return false; if (!IsFinite(velocity) || !IsFinite(angularVelocity) || !double.IsFinite(currentTime)) { DiagnosticSink?.Invoke( $"Rejected invalid VectorUpdate for missile 0x{serverGuid:X8}."); return true; } _lastFiniteGameTime = currentTime; runtime.InvalidatePrediction(); if (!_liveEntities.TryCommitAuthoritativeVector( record, runtime.Body, velocity, angularVelocity, currentTime)) { return true; } return true; } internal bool ApplyAuthoritativeState( LiveEntityRecord expectedRecord, PhysicsStateFlags state, double currentTime, int liveCenterX, int liveCenterY) => ApplyAuthoritativeState( expectedRecord, expectedRecord.StateAuthorityVersion, state, currentTime, liveCenterX, liveCenterY); internal bool ApplyAuthoritativeState( LiveEntityRecord expectedRecord, ulong expectedStateAuthorityVersion, PhysicsStateFlags state, double currentTime, int liveCenterX, int liveCenterY) { ArgumentNullException.ThrowIfNull(expectedRecord); uint serverGuid = expectedRecord.ServerGuid; if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)) return false; if (!ReferenceEquals(record, expectedRecord) || record.StateAuthorityVersion != expectedStateAuthorityVersion) return false; bool validClock = double.IsFinite(currentTime); double effectiveClock = validClock ? currentTime : _lastFiniteGameTime; if (validClock) _lastFiniteGameTime = currentTime; if (!validClock) DiagnosticSink?.Invoke( $"Ignored invalid State update clock for live object 0x{serverGuid:X8}; state remains authoritative."); if (record.ProjectileRuntime is Runtime 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; } // SetState applies to the canonical CPhysicsObj independently of // whether this state also makes projectile-component acquisition // possible. Missing DAT/shape data must never leave the existing body // on stale collision flags. if (record.RemoteMotionRuntime is { } remote) remote.Body.State = state; if ((state & PhysicsStateFlags.Missile) == 0 || record.WorldEntity is not { } entity) return false; // A newer State packet can make an already materialized object a // missile. TryBind adopts an existing MovementManager body so the // live record still represents retail's one CPhysicsObj. Setup? setup = _setupResolver?.Resolve(entity.SourceGfxObjOrSetupId); if (setup is null) { DiagnosticSink?.Invoke( $"Cannot activate missile 0x{serverGuid:X8}: Setup 0x{entity.SourceGfxObjOrSetupId:X8} is unavailable."); return true; } TryBind(record, setup, effectiveClock, liveCenterX, liveCenterY); return true; } /// /// Applies a timestamp-gated server correction to the same predicted body. /// The caller supplies both render-world and wire cell-local coordinates so /// streaming origin changes never leak into the canonical cell frame. /// internal bool ApplyAuthoritativePosition( LiveEntityRecord expectedRecord, Vector3 worldPosition, Vector3 cellLocalPosition, Quaternion orientation, Vector3 velocity, uint fullCellId, double currentTime, int liveCenterX, int liveCenterY) => ApplyAuthoritativePosition( expectedRecord, expectedRecord.PositionAuthorityVersion, expectedRecord.VelocityAuthorityVersion, worldPosition, cellLocalPosition, orientation, velocity, fullCellId, currentTime, liveCenterX, liveCenterY); internal bool ApplyAuthoritativePosition( LiveEntityRecord expectedRecord, ulong expectedPositionAuthorityVersion, ulong expectedVelocityAuthorityVersion, Vector3 worldPosition, Vector3 cellLocalPosition, Quaternion orientation, Vector3 velocity, uint fullCellId, double currentTime, int liveCenterX, int liveCenterY) { ArgumentNullException.ThrowIfNull(expectedRecord); uint serverGuid = expectedRecord.ServerGuid; if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime) || !ReferenceEquals(record, expectedRecord) || record.PositionAuthorityVersion != expectedPositionAuthorityVersion || (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0 || record.WorldEntity is not { } entity) return false; if (!double.IsFinite(currentTime) || !IsFinite(worldPosition) || !IsFinite(cellLocalPosition) || !IsFinite(velocity) || !PositionFrameValidation.IsValid( fullCellId, cellLocalPosition, orientation)) { DiagnosticSink?.Invoke( $"Rejected invalid PositionUpdate for missile 0x{serverGuid:X8}."); return true; } _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) { // 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; } /// /// Selects the retained body's ordinary-physics owner. Missile /// classification always uses the projectile transition path. If the /// authoritative Missile bit later clears, that same body still owns /// ordinary physics until a RemoteMotion runtime exists to take it over. /// Component lifetime and classification are deliberately separate. /// internal bool HandlesMovement(uint serverGuid) => TryGetCurrent(serverGuid, out LiveEntityRecord record, out _) && ((record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0 || record.RemoteMotionRuntime is null); internal bool TryGetBody(uint serverGuid, out PhysicsBody body) { if (TryGetCurrent(serverGuid, out _, out Runtime runtime)) { body = runtime.Body; return true; } body = null!; return false; } internal bool LeaveWorld(LiveEntityRecord record) { ArgumentNullException.ThrowIfNull(record); if (record.ProjectileRuntime is not Runtime runtime || runtime.Generation != record.Generation || record.WorldEntity is not { } entity) return false; SuspendOutsideWorld(runtime, entity.Id); return true; } internal void Tick( double currentTime, int liveCenterX, int liveCenterY, Vector3? playerWorldPosition) { double elapsed = currentTime - _lastFiniteGameTime; Tick( currentTime, double.IsFinite(elapsed) && elapsed > 0.0 ? (float)elapsed : 0f, liveCenterX, liveCenterY, playerWorldPosition); } internal void Tick( double currentTime, float elapsedSeconds, int liveCenterX, int liveCenterY, Vector3? playerWorldPosition) { if (!double.IsFinite(currentTime)) { DiagnosticSink?.Invoke("Rejected non-finite projectile update clock."); return; } _lastFiniteGameTime = currentTime; _liveEntities.CopySpatialProjectileRecordsTo(_spatialProjectileSnapshot); foreach (LiveEntityRecord record in _spatialProjectileSnapshot) { if (record.ProjectileRuntime is not Runtime runtime || runtime.Generation != record.Generation || !_liveEntities.IsCurrentSpatialProjectile(record, runtime) || record.WorldEntity is not { } entity) continue; runtime.Body.State = record.FinalPhysicsState; if (!HasVisibleCell(record)) { SuspendOutsideWorld(runtime, entity.Id); continue; } if (!runtime.Body.InWorld) { runtime.Body.LastUpdateTime = currentTime; runtime.Body.InWorld = true; ShadowPositionSynchronizer.Sync( _shadows, entity.Id, runtime.Body.Position, runtime.Body.Orientation, record.FullCellId, liveCenterX, liveCenterY); _rootPoses?.UpdateRoot(entity); } if (IsHidden(record)) { _shadows.Suspend(entity.Id); // A retained animation or PositionManager owner consumes the // same record clock in LiveEntityAnimationScheduler. A bare // projectile has no such owner, but Hidden still consumes its // ordinary update_time without integrating root physics. if (record.AnimationRuntime is null && record.RemoteMotionRuntime is null) { RetailObjectActivityResult hiddenActivity = RetailObjectActivityGate.Evaluate( record.ObjectClock, runtime.Body, _liveEntities.GetRootObjectClockDisposition(record.ServerGuid) is RetailObjectClockDisposition.Advance, record.HasPartArray, (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0, runtime.Body.Position, playerWorldPosition, elapsedSeconds); if (hiddenActivity is RetailObjectActivityResult.Active) record.ObjectClock.Advance(elapsedSeconds); } continue; } bool isMissile = (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0; if (!isMissile) { // World entry/exit is independent of Missile classification. // A projectile can enter a pending bucket, lose Missile, then // hydrate as an ordinary live object. Restore its retained // shadow/body membership without reactivating projectile // integration or consuming the out-of-world clock gap. // A retained body without MovementManager still remains an // active CPhysicsObj after Missile is cleared. Continue the // same one-sphere update path; PhysicsEngine reads the final // state and therefore disables missile_ignore automatically. if (record.RemoteMotionRuntime is not null) continue; } // Animated owners are admitted by LiveEntityAnimationScheduler so // their PartArray, projectile physics, hooks, and manager tail all // consume one shared object quantum in retail order. if (record.AnimationRuntime is not null) continue; RetailObjectActivityResult activity = RetailObjectActivityGate.Evaluate( record.ObjectClock, runtime.Body, _liveEntities.GetRootObjectClockDisposition(record.ServerGuid) is RetailObjectClockDisposition.Advance, record.HasPartArray, (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0, runtime.Body.Position, playerWorldPosition, elapsedSeconds); if (activity is not RetailObjectActivityResult.Active) continue; RetailObjectQuantumBatch batch = record.ObjectClock.Advance(elapsedSeconds); for (int qi = 0; qi < batch.Count; qi++) { if (!AdvanceQuantum( record, batch.GetQuantum(qi), liveCenterX, liveCenterY)) break; if (record.RemoteMotionRuntime is RemoteMotion remote) { RetailObjectManagerTail.Run( remote.Host?.TargetManager, remote.Movement, partArray: null, remote.Host?.PositionManager); } } } } /// /// Runs the projectile slice of one object quantum already admitted by /// . Animated projectiles call /// this between PartArray advancement and hook/manager processing. /// internal bool AdvanceQuantum( LiveEntityRecord record, float quantum, int liveCenterX, int liveCenterY) { if (!TryBeginQuantum(record, quantum, out QuantumStep step)) return false; return CompleteQuantum(step, liveCenterX, liveCenterY); } internal bool TryBeginQuantum( LiveEntityRecord record, float quantum, out QuantumStep step) { ArgumentNullException.ThrowIfNull(record); step = default; if (!TryGetCurrent(record.ServerGuid, out LiveEntityRecord current, out Runtime 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) return false; step = new QuantumStep( current, runtime, entity, preparation, runtime.PredictionAuthorityVersion); return true; } internal bool CompleteQuantum( in QuantumStep step, int liveCenterX, 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; } 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); } 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) && ReferenceEquals(current, step.Record) && ReferenceEquals(runtime, step.Runtime) && ReferenceEquals(current.WorldEntity, step.Entity) && runtime.PredictionAuthorityVersion == step.PredictionAuthorityVersion; private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) { if (record.ProjectileRuntime is not Runtime runtime || record.WorldEntity is not { } entity || !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) || !ReferenceEquals(current, record) || !ReferenceEquals(current.ProjectileRuntime, runtime)) { return; } if (visible) { // prepare_to_enter_world belongs to the authoritative projection // edge, not to a legacy frame scanner. LiveEntityRuntime has // already rebased the canonical object clock and Active bit before // publishing this notification; restore the remaining CPhysicsObj // residency bit so the first admitted scheduler quantum can run. runtime.Body.State = record.FinalPhysicsState; runtime.Body.InWorld = true; if (IsHidden(record)) { _shadows.Suspend(entity.Id); return; } // add_object_to_cell installs the object's collision shadow as // part of prepare_to_enter_world. Do this on the residency edge, // not lazily on the next simulated quantum: a zero-velocity or // newly ordinary retained projectile may have no later projectile // step from which to repair its shadow membership. int liveCenterX = _origin?.CenterX ?? 0; int liveCenterY = _origin?.CenterY ?? 0; ShadowPositionSynchronizer.Sync( _shadows, entity.Id, runtime.Body.Position, runtime.Body.Orientation, record.FullCellId, liveCenterX, liveCenterY); _rootPoses?.UpdateRoot(entity); return; } // Pending/cell-less records retain their logical projectile runtime, // but retail exit_world removes them from active physics and collision // immediately. They are absent from the frame workset after this edge, // so suspension belongs on the authoritative residency transition. SuspendOutsideWorld(runtime, entity.Id); } private static bool HasVisibleCell(LiveEntityRecord record) => record.IsSpatiallyProjected && record.IsSpatiallyVisible && record.FullCellId != 0; private static bool IsHidden(LiveEntityRecord record) => (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0; private void SuspendOutsideWorld(Runtime 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; private static void SetVelocity( PhysicsBody body, Vector3 velocity, double currentTime) { bool wasActive = (body.TransientState & TransientStateFlags.Active) != 0; body.set_velocity(velocity); if ((body.State & PhysicsStateFlags.Static) != 0) { Deactivate(body); return; } if (!wasActive && (body.State & PhysicsStateFlags.Static) == 0) body.LastUpdateTime = currentTime; } private static float NormalizeFriction(float? value) => value is >= 0f and <= 1f && float.IsFinite(value.Value) ? value.Value : PhysicsBody.DefaultFriction; private static float NormalizeElasticity(float value) { if (float.IsNaN(value) || value <= 0f) return 0f; return MathF.Min(value, 0.1f); } private static bool IsFinite(Vector3 value) => float.IsFinite(value.X) && float.IsFinite(value.Y) && float.IsFinite(value.Z); 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 bool TryGetCurrent( uint serverGuid, out LiveEntityRecord record, out Runtime runtime) { if (_liveEntities.TryGetRecord(serverGuid, out record!) && record.ProjectileRuntime is Runtime found && found.Generation == record.Generation) { runtime = found; return true; } record = null!; runtime = null!; return false; } private static bool TryGetCollisionSphere( Setup setup, float scale, out ProjectileCollisionSphere sphere) { // Installed DAT audit in 2026-07-13-retail-projectile-vfx-pseudocode.md // pins every required arrow, bolt, and spell projectile to one ordinary // Setup sphere (including Force Bolt's non-centered origin). Do not // fabricate a mesh-derived radius for an unsupported Setup. if (setup.Spheres.Count == 1) { var source = setup.Spheres[0]; sphere = new ProjectileCollisionSphere(source.Origin, source.Radius, scale); if (sphere.IsValid) return true; } sphere = default; return false; } }