feat(physics): port retail complete object frame pipeline
Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates. Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
31a0889f08
commit
f961d70023
77 changed files with 12513 additions and 1871 deletions
|
|
@ -5,6 +5,7 @@ using AcDream.Core.Net.Messages;
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
|
|
@ -30,26 +31,34 @@ internal sealed class ProjectileController
|
|||
internal Runtime(
|
||||
ushort generation,
|
||||
PhysicsBody body,
|
||||
ProjectileCollisionSphere collisionSphere,
|
||||
bool hasPartArray)
|
||||
ProjectileCollisionSphere collisionSphere)
|
||||
{
|
||||
Generation = generation;
|
||||
Body = body;
|
||||
CollisionSphere = collisionSphere;
|
||||
HasPartArray = hasPartArray;
|
||||
}
|
||||
|
||||
internal ushort Generation { get; }
|
||||
public PhysicsBody Body { get; }
|
||||
internal ProjectileCollisionSphere CollisionSphere { get; }
|
||||
internal bool HasPartArray { 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 Func<uint, Setup?> _setupResolver;
|
||||
private readonly Action<WorldEntity> _publishRootPose;
|
||||
private readonly Func<(int X, int Y)> _liveCenterProvider;
|
||||
private readonly List<LiveEntityRecord> _spatialProjectileSnapshot = new();
|
||||
private double _lastFiniteGameTime;
|
||||
|
||||
|
|
@ -57,7 +66,8 @@ internal sealed class ProjectileController
|
|||
LiveEntityRuntime liveEntities,
|
||||
PhysicsEngine physicsEngine,
|
||||
Func<uint, Setup?>? setupResolver = null,
|
||||
Action<WorldEntity>? publishRootPose = null)
|
||||
Action<WorldEntity>? publishRootPose = null,
|
||||
Func<(int X, int Y)>? liveCenterProvider = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
ArgumentNullException.ThrowIfNull(physicsEngine);
|
||||
|
|
@ -65,6 +75,7 @@ internal sealed class ProjectileController
|
|||
_shadows = physicsEngine.ShadowObjects;
|
||||
_setupResolver = setupResolver ?? (_ => null);
|
||||
_publishRootPose = publishRootPose ?? (_ => { });
|
||||
_liveCenterProvider = liveCenterProvider ?? (() => (0, 0));
|
||||
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +137,9 @@ internal sealed class ProjectileController
|
|||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(setup);
|
||||
|
||||
if ((record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
|
||||
if (!_liveEntities.TryGetRecord(record.ServerGuid, out var liveRecord)
|
||||
|| !ReferenceEquals(liveRecord, record)
|
||||
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
|
||||
return false;
|
||||
|
||||
if (record.ProjectileRuntime is Runtime retained)
|
||||
|
|
@ -158,12 +171,13 @@ internal sealed class ProjectileController
|
|||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
PhysicsBody body;
|
||||
if (record.RemoteMotionRuntime is { } remote)
|
||||
if (record.PhysicsBody is { } sharedBody)
|
||||
{
|
||||
// Retail owns one CPhysicsObj. If a non-missile incarnation
|
||||
// already created its MovementManager, classification adopts that
|
||||
// same body instead of replacing it.
|
||||
body = remote.Body;
|
||||
// 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,
|
||||
|
|
@ -219,38 +233,69 @@ internal sealed class ProjectileController
|
|||
$"Missile 0x{record.ServerGuid:X8} has an invalid initial physics frame or vector.");
|
||||
return false;
|
||||
}
|
||||
body = 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(body, velocity, currentTime);
|
||||
body.Omega = omega;
|
||||
body.SnapToCell(
|
||||
wirePosition.LandblockId,
|
||||
entity.Position,
|
||||
new Vector3(
|
||||
wirePosition.PositionX,
|
||||
wirePosition.PositionY,
|
||||
wirePosition.PositionZ));
|
||||
// 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;
|
||||
_liveEntities.RebucketLiveEntity(record.ServerGuid, 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,
|
||||
entity.MeshRefs.Count > 0);
|
||||
sphere);
|
||||
_liveEntities.SetProjectileRuntime(record.ServerGuid, runtime);
|
||||
|
||||
if (HasVisibleCell(record) && !IsHidden(record))
|
||||
|
|
@ -258,8 +303,8 @@ internal sealed class ProjectileController
|
|||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
canonicalCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
|
|
@ -282,13 +327,34 @@ internal sealed class ProjectileController
|
|||
}
|
||||
|
||||
internal bool ApplyAuthoritativeVector(
|
||||
uint serverGuid,
|
||||
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
|
||||
|
|
@ -309,23 +375,48 @@ internal sealed class ProjectileController
|
|||
}
|
||||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
SetVelocity(runtime.Body, velocity, currentTime);
|
||||
runtime.Body.Omega = angularVelocity;
|
||||
if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid)
|
||||
&& !IsHidden(record))
|
||||
Deactivate(runtime.Body);
|
||||
runtime.InvalidatePrediction();
|
||||
if (!_liveEntities.TryCommitAuthoritativeVector(
|
||||
record,
|
||||
runtime.Body,
|
||||
velocity,
|
||||
angularVelocity,
|
||||
currentTime))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool ApplyAuthoritativeState(
|
||||
uint serverGuid,
|
||||
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
|
||||
|
|
@ -339,6 +430,7 @@ internal sealed class ProjectileController
|
|||
|
||||
if (record.ProjectileRuntime is Runtime runtime)
|
||||
{
|
||||
runtime.InvalidatePrediction();
|
||||
bool wasMissile =
|
||||
(runtime.Body.State & PhysicsStateFlags.Missile) != 0;
|
||||
runtime.Body.State = state;
|
||||
|
|
@ -398,16 +490,46 @@ internal sealed class ProjectileController
|
|||
/// streaming origin changes never leak into the canonical cell frame.
|
||||
/// </summary>
|
||||
internal bool ApplyAuthoritativePosition(
|
||||
uint serverGuid,
|
||||
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;
|
||||
|
|
@ -415,6 +537,7 @@ internal sealed class ProjectileController
|
|||
if (!double.IsFinite(currentTime)
|
||||
|| !IsFinite(worldPosition)
|
||||
|| !IsFinite(cellLocalPosition)
|
||||
|| !IsFinite(velocity)
|
||||
|| !PositionFrameValidation.IsValid(
|
||||
fullCellId,
|
||||
cellLocalPosition,
|
||||
|
|
@ -427,20 +550,53 @@ 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;
|
||||
_liveEntities.RebucketLiveEntity(serverGuid, 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,
|
||||
|
|
@ -466,13 +622,16 @@ internal sealed class ProjectileController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Movement packets use the ordinary MovementManager/animation funnel on
|
||||
/// this same body. The generic remote translation tick consults this gate
|
||||
/// so only the projectile integrator commits its root frame.
|
||||
/// 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.FinalPhysicsState & PhysicsStateFlags.Missile) != 0
|
||||
|| record.RemoteMotionRuntime is null);
|
||||
|
||||
internal bool TryGetBody(uint serverGuid, out PhysicsBody body)
|
||||
{
|
||||
|
|
@ -501,6 +660,22 @@ internal sealed class ProjectileController
|
|||
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))
|
||||
{
|
||||
|
|
@ -519,22 +694,50 @@ internal sealed class ProjectileController
|
|||
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);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
|
||||
if (IsHidden(record))
|
||||
{
|
||||
if (!HasVisibleCell(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)
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// UpdatePositionInternal skips root physics/PartArray
|
||||
// while Hidden, but update_object still advances its
|
||||
// clock and does not clear Active.
|
||||
runtime.Body.InWorld = true;
|
||||
runtime.Body.LastUpdateTime = currentTime;
|
||||
if ((record.FinalPhysicsState & PhysicsStateFlags.Frozen) != 0)
|
||||
Deactivate(runtime.Body);
|
||||
_shadows.Suspend(entity.Id);
|
||||
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;
|
||||
}
|
||||
|
|
@ -548,23 +751,6 @@ internal sealed class ProjectileController
|
|||
// hydrate as an ordinary live object. Restore its retained
|
||||
// shadow/body membership without reactivating projectile
|
||||
// integration or consuming the out-of-world clock gap.
|
||||
if (!HasVisibleCell(record))
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
else if (!runtime.Body.InWorld)
|
||||
{
|
||||
runtime.Body.InWorld = true;
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
// 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
|
||||
|
|
@ -573,111 +759,185 @@ internal sealed class ProjectileController
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid))
|
||||
{
|
||||
if (!HasVisibleCell(record))
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
else
|
||||
Deactivate(runtime.Body); // Frozen retains its cell/shadow.
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (!runtime.Body.InWorld)
|
||||
{
|
||||
runtime.Body.InWorld = true;
|
||||
Activate(runtime.Body, currentTime);
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
|
||||
// CPhysicsObj::update_object 0x00515D4D-0x00515DB2 keeps
|
||||
// rendered objects active only within 96 world units of the
|
||||
// player. Objects with no PartArray are exempt.
|
||||
if (runtime.HasPartArray && playerWorldPosition is { } playerPosition)
|
||||
{
|
||||
float playerDistance = Vector3.Distance(runtime.Body.Position, playerPosition);
|
||||
if (!float.IsFinite(playerDistance) || playerDistance > 96f)
|
||||
{
|
||||
Deactivate(runtime.Body);
|
||||
continue;
|
||||
}
|
||||
Activate(runtime.Body, currentTime);
|
||||
}
|
||||
|
||||
bool isParented = record.Snapshot.ParentGuid is not null
|
||||
|| record.Snapshot.Physics?.Parent is not null;
|
||||
ProjectileAdvanceResult result = _stepper.Advance(
|
||||
RetailObjectActivityResult activity = RetailObjectActivityGate.Evaluate(
|
||||
record.ObjectClock,
|
||||
runtime.Body,
|
||||
currentTime,
|
||||
record.FullCellId,
|
||||
runtime.CollisionSphere,
|
||||
entity.Id,
|
||||
designatedTargetId: 0u,
|
||||
isParented);
|
||||
|
||||
// An owner can be deleted/replaced by a callback reached from a
|
||||
// future collision sink. Never commit a completed step into a new
|
||||
// incarnation that reused the same server GUID.
|
||||
if (!TryGetCurrent(record.ServerGuid, out LiveEntityRecord current, out Runtime currentRuntime)
|
||||
|| !ReferenceEquals(runtime, currentRuntime)
|
||||
|| !ReferenceEquals(record, current)
|
||||
|| !result.Simulated)
|
||||
_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;
|
||||
|
||||
uint priorCellId = record.FullCellId;
|
||||
uint resolvedCellId = result.CellId != 0
|
||||
? result.CellId
|
||||
: priorCellId;
|
||||
|
||||
entity.SetPosition(runtime.Body.Position);
|
||||
entity.Rotation = runtime.Body.Orientation;
|
||||
entity.ParentCellId = resolvedCellId;
|
||||
if (resolvedCellId != priorCellId)
|
||||
_liveEntities.RebucketLiveEntity(record.ServerGuid, resolvedCellId);
|
||||
runtime.Body.SnapToCell(
|
||||
resolvedCellId,
|
||||
runtime.Body.Position,
|
||||
CellLocalFromWorld(
|
||||
runtime.Body.Position,
|
||||
resolvedCellId,
|
||||
liveCenterX,
|
||||
liveCenterY));
|
||||
|
||||
// A successful transition can cross into an unloaded landblock.
|
||||
// Rebucket first, then make the physics/shadow decision from the
|
||||
// resulting projection state in this same update quantum. Retail
|
||||
// never leaves a CPhysicsObj active in a cell that is outside the
|
||||
// current object-maintenance world.
|
||||
if (HasVisibleCell(record))
|
||||
RetailObjectQuantumBatch batch = record.ObjectClock.Advance(elapsedSeconds);
|
||||
for (int qi = 0; qi < batch.Count; qi++)
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
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);
|
||||
_publishRootPose(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 (visible
|
||||
|| record.ProjectileRuntime is not Runtime runtime
|
||||
if (record.ProjectileRuntime is not Runtime runtime
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, record)
|
||||
|
|
@ -686,6 +946,39 @@ internal sealed class ProjectileController
|
|||
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, int liveCenterY) = _liveCenterProvider();
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue