1127 lines
43 KiB
C#
1127 lines
43 KiB
C#
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<Setup>(setupId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update-thread presentation owner for live retail missiles. Logical identity,
|
|
/// generation, accepted network state, and teardown remain owned by
|
|
/// <see cref="LiveEntityRuntime"/>; this controller only advances the
|
|
/// <see cref="PhysicsBody"/> attached to that canonical record and commits its
|
|
/// frame through the existing <see cref="WorldEntity"/> projection.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Retail anchors are <c>CPhysicsObj::update_object</c> <c>0x00515D10</c>,
|
|
/// <c>CPhysicsObj::UpdateObjectInternal</c> <c>0x005156B0</c>, and
|
|
/// <c>CPhysicsObj::SetPositionInternal</c> <c>0x00515330</c>. The pure
|
|
/// integration/sweep is implemented by <see cref="ProjectilePhysicsStepper"/>.
|
|
/// ACE remains authoritative for collision outcomes, damage, effects, and
|
|
/// deletion; no such events are synthesized here.
|
|
/// </remarks>
|
|
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<LiveEntityRecord> _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<string>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs the projectile slice of one object quantum already admitted by
|
|
/// <see cref="LiveEntityRecord.ObjectClock"/>. Animated projectiles call
|
|
/// this between PartArray advancement and hook/manager processing.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|