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
226
src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs
Normal file
226
src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the body-backed, manager-less branch of retail
|
||||
/// <c>CPhysicsObj::UpdateObjectInternal</c> (0x005156B0). A retained canonical
|
||||
/// body can temporarily outlive its RemoteMotion or projectile component; it
|
||||
/// must still compose the complete PartArray Frame, integrate physics, sweep
|
||||
/// through Transition, commit its cell, and move its collision shadow.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityOrdinaryPhysicsUpdater
|
||||
{
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly Func<uint, WorldEntity, (float Radius, float Height)>
|
||||
_getSetupCylinder;
|
||||
|
||||
public LiveEntityOrdinaryPhysicsUpdater(
|
||||
PhysicsEngine physics,
|
||||
Func<uint, WorldEntity, (float Radius, float Height)> getSetupCylinder)
|
||||
{
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_getSetupCylinder = getSetupCylinder
|
||||
?? throw new ArgumentNullException(nameof(getSetupCylinder));
|
||||
}
|
||||
|
||||
public bool Tick(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
Frame rootFrame,
|
||||
float objectScale,
|
||||
float quantum,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
ulong objectClockEpoch,
|
||||
AnimationSequencer? sequencer,
|
||||
Action<uint, AnimationSequencer> captureAnimationHooks)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(rootFrame);
|
||||
ArgumentNullException.ThrowIfNull(captureAnimationHooks);
|
||||
if (record.PhysicsBody is not { } body
|
||||
|| !IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
body.State = record.FinalPhysicsState;
|
||||
Vector3 priorPosition = body.Position;
|
||||
Quaternion priorOrientation = body.Orientation;
|
||||
bool previousContact = body.InContact;
|
||||
bool previousOnWalkable = body.OnWalkable;
|
||||
|
||||
// UpdatePositionInternal 0x00512CA1: PartArray root translation is
|
||||
// scaled only while transient OnWalkable is set. Its orientation stays
|
||||
// in the complete Frame and composes regardless of that translation
|
||||
// gate.
|
||||
Vector3 candidatePosition = priorPosition;
|
||||
if (body.OnWalkable && rootFrame.Origin != Vector3.Zero)
|
||||
{
|
||||
candidatePosition += Vector3.Transform(
|
||||
rootFrame.Origin * objectScale,
|
||||
priorOrientation);
|
||||
}
|
||||
|
||||
Quaternion candidateOrientation = priorOrientation;
|
||||
if (!rootFrame.Orientation.IsIdentity)
|
||||
{
|
||||
candidateOrientation = FrameOps.SetRotate(
|
||||
candidatePosition,
|
||||
priorOrientation,
|
||||
priorOrientation * rootFrame.Orientation);
|
||||
}
|
||||
|
||||
body.SetFrameInCurrentCell(candidatePosition, candidateOrientation);
|
||||
body.calc_acceleration();
|
||||
body.UpdatePhysicsInternal(quantum);
|
||||
// Omega integration writes Orientation directly; mirror it into the
|
||||
// retained Position frame before Transition consumes the candidate.
|
||||
body.SetFrameInCurrentCell(body.Position, body.Orientation);
|
||||
|
||||
// UpdatePositionInternal processes PartArray hooks after physics but
|
||||
// before UpdateObjectInternal performs its transition sweep.
|
||||
if (sequencer is not null)
|
||||
captureAnimationHooks(entity.Id, sequencer);
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
return false;
|
||||
|
||||
Vector3 integratedPosition = body.Position;
|
||||
uint sourceCellId = record.FullCellId;
|
||||
uint resolvedCellId = sourceCellId;
|
||||
bool frameChanged = integratedPosition != priorPosition
|
||||
|| body.Orientation != priorOrientation;
|
||||
var (radius, height) = _getSetupCylinder(record.ServerGuid, entity);
|
||||
|
||||
if (integratedPosition != priorPosition
|
||||
&& sourceCellId != 0
|
||||
&& radius >= 0.05f
|
||||
&& _physics.LandblockCount > 0)
|
||||
{
|
||||
ResolveResult resolved = _physics.ResolveWithTransition(
|
||||
priorPosition,
|
||||
integratedPosition,
|
||||
sourceCellId,
|
||||
radius,
|
||||
height,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f,
|
||||
isOnGround: previousOnWalkable,
|
||||
body: body,
|
||||
moverFlags: IsPlayerGuid(record.ServerGuid)
|
||||
? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide
|
||||
: ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: entity.Id);
|
||||
|
||||
if (resolved.Ok)
|
||||
{
|
||||
resolvedCellId = resolved.CellId != 0
|
||||
? resolved.CellId
|
||||
: sourceCellId;
|
||||
body.CommitTransitionPosition(resolvedCellId, resolved.Position);
|
||||
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
body,
|
||||
resolved.InContact,
|
||||
resolved.OnWalkable,
|
||||
resolved.CollisionNormalValid,
|
||||
resolved.CollisionNormal,
|
||||
previousContact,
|
||||
previousOnWalkable);
|
||||
body.CachedVelocity = quantum > 0f
|
||||
? (body.Position - priorPosition) / quantum
|
||||
: Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
// transition() returned null: UpdateObjectInternal keeps the
|
||||
// integrated frame in the current cell and reports no realized
|
||||
// transition velocity.
|
||||
body.CachedVelocity = Vector3.Zero;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// The no-PartArray-sphere arm calls set_frame and writes zero
|
||||
// cached_velocity rather than fabricating a collision shape.
|
||||
body.CachedVelocity = Vector3.Zero;
|
||||
}
|
||||
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
return false;
|
||||
|
||||
entity.SetPosition(body.Position);
|
||||
entity.Rotation = body.Orientation;
|
||||
entity.ParentCellId = resolvedCellId;
|
||||
if (resolvedCellId != sourceCellId
|
||||
&& !runtime.RebucketLiveEntity(record.ServerGuid, resolvedCellId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
return false;
|
||||
|
||||
if (frameChanged && record.IsSpatiallyVisible && record.FullCellId != 0)
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_physics.ShadowObjects,
|
||||
entity.Id,
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
}
|
||||
|
||||
return IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch);
|
||||
}
|
||||
|
||||
private static bool IsCurrent(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
PhysicsBody body,
|
||||
ulong objectClockEpoch) =>
|
||||
runtime.IsCurrentSpatialRootObject(record)
|
||||
&& record.ObjectClockEpoch == objectClockEpoch
|
||||
&& ReferenceEquals(record.WorldEntity, entity)
|
||||
&& ReferenceEquals(record.PhysicsBody, body)
|
||||
&& record.RemoteMotionRuntime is null
|
||||
&& record.ProjectileRuntime is null;
|
||||
|
||||
private static bool IsPlayerGuid(uint guid) =>
|
||||
(guid & 0xFF000000u) == 0x50000000u;
|
||||
}
|
||||
56
src/AcDream.App/Physics/LiveEntityShadowPublisher.cs
Normal file
56
src/AcDream.App/Physics/LiveEntityShadowPublisher.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Incarnation/residency gate for authoritative ordinary-remote collision
|
||||
/// publication after a canonical rebucket callback.
|
||||
/// </summary>
|
||||
internal static class LiveEntityShadowPublisher
|
||||
{
|
||||
internal static bool TryPublishRemote(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
ILiveEntityRemoteMotionRuntime remote,
|
||||
ulong positionAuthorityVersion,
|
||||
Action publish)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
ArgumentNullException.ThrowIfNull(publish);
|
||||
|
||||
if (!CanPublish(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
remote,
|
||||
positionAuthorityVersion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
publish();
|
||||
return CanPublish(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
remote,
|
||||
positionAuthorityVersion);
|
||||
}
|
||||
|
||||
private static bool CanPublish(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
ILiveEntityRemoteMotionRuntime remote,
|
||||
ulong positionAuthorityVersion) =>
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Hidden) == 0
|
||||
&& ReferenceEquals(record.WorldEntity, entity)
|
||||
&& runtime.IsCurrentPositionAuthority(record, positionAuthorityVersion)
|
||||
&& runtime.IsCurrentSpatialRemoteMotion(record, remote);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
145
src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs
Normal file
145
src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the remote-object half of retail
|
||||
/// <c>MovementManager::unpack_movement</c> (<c>0x00524440</c>) after the
|
||||
/// packet timestamp gate has accepted an UpdateMotion event.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The packet owner is deliberately independent of a render PartArray. A
|
||||
/// normal animated object supplies a <see cref="MotionTableDispatchSink"/>;
|
||||
/// an animation-less object supplies <see langword="null"/> and
|
||||
/// <c>CMotionInterp</c> retains the same state on its canonical physics body.
|
||||
/// This keeps the interrupt/style/type-0/sticky ordering identical for both
|
||||
/// object shapes instead of maintaining a second headless protocol funnel.
|
||||
/// </remarks>
|
||||
internal sealed class RemoteInboundMotionDispatcher
|
||||
{
|
||||
private readonly Func<
|
||||
MovementManager,
|
||||
uint,
|
||||
WorldSession.EntityMotionUpdate,
|
||||
bool> _routeServerMoveTo;
|
||||
private readonly Action<IPhysicsObjHost?, uint> _stickToObject;
|
||||
|
||||
public RemoteInboundMotionDispatcher(
|
||||
Func<MovementManager, uint, WorldSession.EntityMotionUpdate, bool>
|
||||
routeServerMoveTo,
|
||||
Action<IPhysicsObjHost?, uint> stickToObject)
|
||||
{
|
||||
_routeServerMoveTo = routeServerMoveTo
|
||||
?? throw new ArgumentNullException(nameof(routeServerMoveTo));
|
||||
_stickToObject = stickToObject
|
||||
?? throw new ArgumentNullException(nameof(stickToObject));
|
||||
}
|
||||
|
||||
public RemoteInboundMotionDispatchResult Apply(
|
||||
WorldSession.EntityMotionUpdate update,
|
||||
MovementManager movement,
|
||||
IInterpretedMotionSink? animationSink,
|
||||
IPhysicsObjHost? host,
|
||||
uint cellId,
|
||||
uint fallbackForwardClass,
|
||||
Func<bool>? isCurrent = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(movement);
|
||||
bool Current() => isCurrent?.Invoke() ?? true;
|
||||
|
||||
MotionInterpreter motion = movement.Minterp;
|
||||
uint previousForward = motion.InterpretedState.ForwardCommand;
|
||||
RemoteInboundMotionDispatchResult Superseded() => new(
|
||||
RoutedMoveTo: false,
|
||||
AppliedInterpretedState: false,
|
||||
PreviousForwardCommand: previousForward,
|
||||
CurrentForwardCommand: motion.InterpretedState.ForwardCommand,
|
||||
Superseded: true);
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
|
||||
// MovementManager::unpack_movement @00524440: these two calls occur
|
||||
// before the movement-type switch for every accepted packet.
|
||||
motion.InterruptCurrentMovement?.Invoke();
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
motion.UnstickFromObject?.Invoke();
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
|
||||
uint wireStyle = update.MotionState.Stance != 0
|
||||
? 0x80000000u | update.MotionState.Stance
|
||||
: 0x8000003Du;
|
||||
if (motion.InterpretedState.CurrentStyle != wireStyle)
|
||||
{
|
||||
motion.DoMotion(
|
||||
wireStyle,
|
||||
new MovementParameters());
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
}
|
||||
|
||||
// Cases 6/7/8/9 are owned by MoveToManager and return directly.
|
||||
bool routedMoveTo = _routeServerMoveTo(movement, cellId, update);
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
if (routedMoveTo)
|
||||
{
|
||||
return new RemoteInboundMotionDispatchResult(
|
||||
RoutedMoveTo: true,
|
||||
AppliedInterpretedState: false,
|
||||
PreviousForwardCommand: previousForward,
|
||||
CurrentForwardCommand: motion.InterpretedState.ForwardCommand);
|
||||
}
|
||||
|
||||
// Retail's ten-way switch returns zero for every unrecognised type;
|
||||
// only case 0 enters the wholesale interpreted-state funnel.
|
||||
if (update.MotionState.MovementType != 0)
|
||||
{
|
||||
return new RemoteInboundMotionDispatchResult(
|
||||
RoutedMoveTo: false,
|
||||
AppliedInterpretedState: false,
|
||||
PreviousForwardCommand: previousForward,
|
||||
CurrentForwardCommand: motion.InterpretedState.ForwardCommand);
|
||||
}
|
||||
|
||||
InboundInterpretedState interpreted =
|
||||
InboundInterpretedMotionFactory.Create(
|
||||
update.MotionState,
|
||||
fallbackForwardClass);
|
||||
motion.MoveToInterpretedState(interpreted, animationSink);
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
|
||||
// Case-0 tail order @00524583-0052458E: stick after the wholesale
|
||||
// state copy, then write standing_longjump unconditionally.
|
||||
if (update.MotionState.StickyObjectGuid is { } stickyGuid
|
||||
&& stickyGuid != 0)
|
||||
{
|
||||
_stickToObject(host, stickyGuid);
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
}
|
||||
motion.StandingLongJump = update.MotionState.StandingLongJump;
|
||||
|
||||
return new RemoteInboundMotionDispatchResult(
|
||||
RoutedMoveTo: false,
|
||||
AppliedInterpretedState: true,
|
||||
PreviousForwardCommand: previousForward,
|
||||
CurrentForwardCommand: interpreted.ForwardCommand);
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct RemoteInboundMotionDispatchResult(
|
||||
bool RoutedMoveTo,
|
||||
bool AppliedInterpretedState,
|
||||
uint PreviousForwardCommand,
|
||||
uint CurrentForwardCommand,
|
||||
bool Superseded = false)
|
||||
{
|
||||
public bool ForwardCommandChanged =>
|
||||
AppliedInterpretedState
|
||||
&& PreviousForwardCommand != CurrentForwardCommand;
|
||||
}
|
||||
|
|
@ -21,9 +21,7 @@ namespace AcDream.App.Physics;
|
|||
/// <c>ResolveWithTransition</c> sweep + shadow-follows-resolved, so packed PLAYER
|
||||
/// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c>
|
||||
/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is
|
||||
/// the omega handling (players keep the <c>ObservedOmega||seqOmega</c> world-frame
|
||||
/// fallback; NPCs + airborne bodies use <c>ObservedOmega</c>-only body-frame) and
|
||||
/// the <c>!IsPlayerGuid</c>-gated stale-velocity anim-cycle stop. See
|
||||
/// the <c>!IsPlayerGuid</c>-gated stale-velocity animation-cycle stop. See
|
||||
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
|
||||
///
|
||||
/// <para>Shared helpers that GameWindow also calls elsewhere are injected:
|
||||
|
|
@ -68,33 +66,87 @@ internal sealed class RemotePhysicsUpdater
|
|||
AcDream.App.World.LiveEntityRuntime liveEntities,
|
||||
uint localPlayerServerGuid,
|
||||
float dt,
|
||||
System.Action<AcDream.Core.World.WorldEntity> publishRootPose)
|
||||
System.Action<AcDream.Core.World.WorldEntity> publishRootPose,
|
||||
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
|
||||
processAnimationHooks = null,
|
||||
System.Action<uint>? markPartPoseDirty = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(liveEntities);
|
||||
ArgumentNullException.ThrowIfNull(publishRootPose);
|
||||
|
||||
System.Numerics.Vector3? playerPosition = null;
|
||||
if (localPlayerServerGuid != 0
|
||||
&& liveEntities.TryGetWorldEntity(
|
||||
localPlayerServerGuid,
|
||||
out AcDream.Core.World.WorldEntity playerEntity))
|
||||
{
|
||||
playerPosition = playerEntity.Position;
|
||||
}
|
||||
|
||||
liveEntities.CopySpatialRemoteMotionRecordsTo(_spatialRemoteSnapshot);
|
||||
foreach (AcDream.App.World.LiveEntityRecord record in _spatialRemoteSnapshot)
|
||||
{
|
||||
if (record.ServerGuid == localPlayerServerGuid
|
||||
|| (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0
|
||||
|| !liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)
|
||||
|| record.RemoteMotionRuntime is not RemoteMotion remote
|
||||
|| !liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
continue;
|
||||
|
||||
System.Action? handlePartArray = record.AnimationRuntime is AnimatedEntity
|
||||
{ Sequencer: { } sequencer }
|
||||
? sequencer.Manager.UseTime
|
||||
: null;
|
||||
TickHidden(remote, entity, dt, handlePartArray);
|
||||
AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity;
|
||||
AcDream.Core.Physics.RetailObjectActivityResult activity =
|
||||
AcDream.Core.Physics.RetailObjectActivityGate.Evaluate(
|
||||
record.ObjectClock,
|
||||
remote.Body,
|
||||
liveEntities.GetRootObjectClockDisposition(record.ServerGuid)
|
||||
is AcDream.Core.Physics.RetailObjectClockDisposition.Advance,
|
||||
hasPartArray: record.HasPartArray,
|
||||
isStatic: (record.FinalPhysicsState
|
||||
& AcDream.Core.Physics.PhysicsStateFlags.Static) != 0,
|
||||
entity.Position,
|
||||
playerPosition,
|
||||
dt);
|
||||
if (activity is not AcDream.Core.Physics.RetailObjectActivityResult.Active)
|
||||
continue;
|
||||
|
||||
AcDream.Core.Physics.AnimationSequencer? sequencer = animation?.Sequencer;
|
||||
ulong objectClockEpoch = record.ObjectClockEpoch;
|
||||
AcDream.Core.Physics.RetailObjectQuantumBatch batch =
|
||||
record.ObjectClock.Advance(dt);
|
||||
for (int qi = 0; qi < batch.Count; qi++)
|
||||
{
|
||||
if (!liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|
||||
|| !ReferenceEquals(record.WorldEntity, entity)
|
||||
|| record.ObjectClockEpoch != objectClockEpoch)
|
||||
break;
|
||||
if (!TickHidden(
|
||||
remote,
|
||||
entity,
|
||||
batch.GetQuantum(qi),
|
||||
sequencer?.Manager,
|
||||
processAnimationHooks,
|
||||
sequencer,
|
||||
liveEntities,
|
||||
record,
|
||||
objectClockEpoch))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
// PositionManager callbacks can rebucket, delete, or replace this
|
||||
// GUID. Publish only while the captured record/runtime pair still
|
||||
// owns a loaded spatial projection.
|
||||
if (liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|
||||
&& ReferenceEquals(record.WorldEntity, entity))
|
||||
if (batch.Count > 0
|
||||
&& liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|
||||
&& ReferenceEquals(record.WorldEntity, entity)
|
||||
&& record.ObjectClockEpoch == objectClockEpoch)
|
||||
{
|
||||
// Hidden suppresses CPartArray::Update, but HandleEnterWorld
|
||||
// may already have replaced the current sequence pose. Tell
|
||||
// the presentation pass to sample that retained pose once for
|
||||
// this admitted object quantum rather than rebuilding it on
|
||||
// every render frame.
|
||||
markPartPoseDirty?.Invoke(record.ServerGuid);
|
||||
publishRootPose(entity);
|
||||
}
|
||||
}
|
||||
|
|
@ -106,28 +158,75 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// body. <c>serverGuid</c> + the entity id derive from
|
||||
/// <paramref name="ae"/>.Entity; <paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>
|
||||
/// are passed per-call (they change on streaming recentre — never snapshot
|
||||
/// them in the constructor). <paramref name="rootMotionLocalDelta"/> is
|
||||
/// the complete local displacement produced by this frame's preceding
|
||||
/// them in the constructor). <paramref name="rootMotionLocalFrame"/> is
|
||||
/// the complete local Frame produced by this frame's preceding
|
||||
/// <c>CSequence::update</c>, matching retail's
|
||||
/// <c>CPartArray::Update → PositionManager::adjust_offset</c> order.
|
||||
/// </summary>
|
||||
public void Tick(
|
||||
public bool Tick(
|
||||
RemoteMotion rm,
|
||||
AnimatedEntity ae,
|
||||
float dt,
|
||||
System.Numerics.Vector3 rootMotionLocalDelta,
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
int liveCenterY,
|
||||
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
|
||||
processAnimationHooks = null)
|
||||
=> Tick(
|
||||
rm,
|
||||
ae.Entity,
|
||||
ae.Scale,
|
||||
ae.Sequencer,
|
||||
ae,
|
||||
dt,
|
||||
rootMotionLocalFrame,
|
||||
liveCenterX,
|
||||
liveCenterY,
|
||||
processAnimationHooks);
|
||||
|
||||
/// <summary>
|
||||
/// Canonical ordinary-object tick. Render animation is optional: retail
|
||||
/// walks <c>CPhysics::object_maint</c>, so a live object with a
|
||||
/// MovementManager or PositionManager must continue even when it has no
|
||||
/// <c>AnimatedEntity</c> presentation component.
|
||||
/// </summary>
|
||||
public bool Tick(
|
||||
RemoteMotion rm,
|
||||
AcDream.Core.World.WorldEntity entity,
|
||||
float objectScale,
|
||||
AcDream.Core.Physics.AnimationSequencer? sequencer,
|
||||
AnimatedEntity? animationForVelocityCycle,
|
||||
float dt,
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
|
||||
processAnimationHooks = null,
|
||||
AcDream.App.World.LiveEntityRuntime? ownerRuntime = null,
|
||||
AcDream.App.World.LiveEntityRecord? ownerRecord = null,
|
||||
ulong ownerClockEpoch = 0)
|
||||
{
|
||||
uint serverGuid = ae.Entity.ServerGuid;
|
||||
ArgumentNullException.ThrowIfNull(rm);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(rootMotionLocalFrame);
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
uint serverGuid = entity.ServerGuid;
|
||||
// #184 Slice 2b — the UNIFIED per-remote tick. The former Path A
|
||||
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
|
||||
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's
|
||||
// problem" premise) is GONE — every remote now runs the SAME catch-up +
|
||||
// sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap
|
||||
// exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO
|
||||
// player/remote fork; the only surviving player/NPC split is the omega
|
||||
// handling (Step 2 below) and the !IsPlayerGuid-gated anim-cycle stop.
|
||||
// player/remote fork; only the stale animation-cycle stop below remains
|
||||
// player/NPC-specific.
|
||||
//
|
||||
// Stop detection stays explicit on packet receipt (UpdateMotion
|
||||
// ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared ->
|
||||
|
|
@ -143,8 +242,8 @@ internal sealed class RemotePhysicsUpdater
|
|||
// remotes below are explicitly OnWalkable and airborne remotes use
|
||||
// their authoritative velocity/gravity arc, so this is the same
|
||||
// branch expressed through our retained runtime state.
|
||||
System.Numerics.Vector3 scaledRootMotionLocalDelta = !rm.Airborne
|
||||
? rootMotionLocalDelta * ae.Scale
|
||||
System.Numerics.Vector3 scaledRootMotionLocalOrigin = !rm.Airborne
|
||||
? rootMotionLocalFrame.Origin * objectScale
|
||||
: System.Numerics.Vector3.Zero;
|
||||
|
||||
// Step 1: re-apply current motion commands → body.Velocity.
|
||||
|
|
@ -199,11 +298,14 @@ internal sealed class RemotePhysicsUpdater
|
|||
{
|
||||
rm.ServerVelocity = System.Numerics.Vector3.Zero;
|
||||
rm.HasServerVelocity = false;
|
||||
_applyServerControlledVelocityCycle(
|
||||
serverGuid,
|
||||
ae,
|
||||
rm,
|
||||
System.Numerics.Vector3.Zero);
|
||||
if (animationForVelocityCycle is not null)
|
||||
{
|
||||
_applyServerControlledVelocityCycle(
|
||||
serverGuid,
|
||||
animationForVelocityCycle,
|
||||
rm,
|
||||
System.Numerics.Vector3.Zero);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,58 +325,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
// Step 2: integrate rotation manually per tick. We can't
|
||||
// rely on PhysicsBody.update_object here — its MinQuantum
|
||||
// gate (1/30 s) causes it to SKIP integration when our
|
||||
// 60fps render dt (~0.016s) is below the quantum, meaning
|
||||
// rotation never advances. Measured snap per UP was ~129°
|
||||
// = the full expected 1s × 2.24 rad/s, confirming zero
|
||||
// between-tick rotation.
|
||||
//
|
||||
// Manual integration matches retail's FUN_005256b0
|
||||
// apply_physics (Orientation *= quat(ω × dt)). Use
|
||||
// ObservedOmega derived from server UP rotation deltas so
|
||||
// the rate exactly matches server physics — hard-snap on
|
||||
// next UP becomes invisible by construction.
|
||||
// #184 Slice 2b: PLAYERS keep the ObservedOmega||seqOmega fallback +
|
||||
// world-frame (pre-multiply, Concatenate) application inherited from the
|
||||
// former Path A — a circling player sends RunForward+TurnLeft on ONE UM
|
||||
// whose RunForward cycle synthesises zero omega, so ObservedOmega (from
|
||||
// the wire TurnCommand) must carry the turn or the body would not rotate
|
||||
// between UPs ("rectangle when running circles"). NPCs + AIRBORNE bodies
|
||||
// keep ObservedOmega-only, body-frame (post-multiply, Multiply) — a
|
||||
// seqOmega fallback would change NPC turning (handoff 4.1), so the split
|
||||
// is preserved. For an upright body + a yaw (world-Z) omega the two
|
||||
// multiplication orders commute, so this fork is faithful, not cosmetic.
|
||||
// calc_acceleration zeroes Body.Omega for grounded bodies before
|
||||
// UpdatePhysicsInternal; the explicit zero here covers the airborne case
|
||||
// (a wire-set Body.Omega would otherwise double-integrate on top of the
|
||||
// manual rotation).
|
||||
rm.Body.Omega = System.Numerics.Vector3.Zero;
|
||||
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
|
||||
{
|
||||
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
System.Numerics.Vector3 omegaToApply =
|
||||
rm.ObservedOmega.LengthSquared() > 1e-9f ? rm.ObservedOmega : seqOmega;
|
||||
if (omegaToApply.LengthSquared() > 1e-9f)
|
||||
{
|
||||
float angleDelta = omegaToApply.Length() * (float)dt;
|
||||
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
|
||||
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
|
||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
|
||||
}
|
||||
}
|
||||
else if (rm.ObservedOmega.LengthSquared() > 1e-8f)
|
||||
{
|
||||
float omegaMag = rm.ObservedOmega.Length();
|
||||
var axis = rm.ObservedOmega / omegaMag;
|
||||
float angle = omegaMag * dt;
|
||||
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
|
||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
|
||||
}
|
||||
// Step 2: CSequence's complete Frame carries motion-table omega through
|
||||
// the same compose as root translation. PhysicsBody.Omega remains
|
||||
// reserved for the object's physical angular velocity and is
|
||||
// integrated once by UpdatePhysicsInternal below.
|
||||
|
||||
// Step 3: integrate physics — retail FUN_005111D0
|
||||
// UpdatePhysicsInternal. Pure Euler:
|
||||
|
|
@ -289,11 +343,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
// from the UP hard-snap, producing a visible teleport-stride
|
||||
// on slopes (the "staircase" the user reported).
|
||||
//
|
||||
// PlayerMovementController.cs:358 calls UpdatePhysicsInternal
|
||||
// directly for the same reason. Remote motion mirrors that.
|
||||
// Omega is already integrated manually above, so we zero it
|
||||
// here to prevent UpdatePhysicsInternal's own omega pass from
|
||||
// double-integrating.
|
||||
// PlayerMovementController calls UpdatePhysicsInternal directly
|
||||
// for the same reason. Remote motion mirrors that. CSequence's
|
||||
// authored orientation has already entered the shared delta Frame;
|
||||
// Body.Omega remains the separate physical angular-velocity source.
|
||||
var preIntegratePos = rm.Body.Position;
|
||||
// R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
|
||||
// Sticky over ONE shared delta frame (PositionManager::adjust_offset
|
||||
|
|
@ -312,54 +365,73 @@ internal sealed class RemotePhysicsUpdater
|
|||
// adds no translation on top of the catch-up — no double-move.
|
||||
if (rm.Host is { } npcHost)
|
||||
{
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta;
|
||||
if (!rm.Airborne)
|
||||
{
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc =
|
||||
_physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X, rm.Body.Position.Y);
|
||||
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
|
||||
dt: (double)dt,
|
||||
currentBodyPosition: rm.Body.Position,
|
||||
rootMotionLocalDelta: scaledRootMotionLocalDelta,
|
||||
ori: rm.Body.Orientation,
|
||||
interp: rm.Interp,
|
||||
maxSpeed: maxSpeedNpc,
|
||||
terrainNormal: terrainNormalNpc);
|
||||
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
{
|
||||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||
rm.Body.Orientation, offsetNpc),
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
}
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
|
||||
rm.PositionManagerDeltaScratch;
|
||||
pmDelta.Origin = scaledRootMotionLocalOrigin;
|
||||
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
|
||||
? _physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X,
|
||||
rm.Body.Position.Y)
|
||||
: null;
|
||||
rm.Position.ComposeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
pmDelta,
|
||||
rm.Interp,
|
||||
maxSpeedNpc,
|
||||
pmDelta,
|
||||
terrainNormalNpc,
|
||||
inContact: rm.Body.InContact);
|
||||
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
|
||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||
}
|
||||
else if (!rm.Airborne)
|
||||
else
|
||||
{
|
||||
// No PositionManager host yet (pre-binding): apply the catch-up
|
||||
// directly, matching Path A's fallback (:10202).
|
||||
// Airborne suppresses only CPartArray Origin. Retail keeps the
|
||||
// complete root orientation live across the OnWalkable scale
|
||||
// gate in UpdatePositionInternal (0x00512C30).
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
|
||||
rm.PositionManagerDeltaScratch;
|
||||
pmDelta.Origin = scaledRootMotionLocalOrigin;
|
||||
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc =
|
||||
_physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X, rm.Body.Position.Y);
|
||||
rm.Body.Position += rm.Position.ComputeOffset(
|
||||
dt: (double)dt,
|
||||
currentBodyPosition: rm.Body.Position,
|
||||
rootMotionLocalDelta: scaledRootMotionLocalDelta,
|
||||
ori: rm.Body.Orientation,
|
||||
interp: rm.Interp,
|
||||
maxSpeed: maxSpeedNpc,
|
||||
terrainNormal: terrainNormalNpc);
|
||||
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
|
||||
? _physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X,
|
||||
rm.Body.Position.Y)
|
||||
: null;
|
||||
rm.Position.ComposeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
pmDelta,
|
||||
rm.Interp,
|
||||
maxSpeedNpc,
|
||||
pmDelta,
|
||||
terrainNormalNpc,
|
||||
inContact: rm.Body.InContact);
|
||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||
}
|
||||
rm.Body.calc_acceleration();
|
||||
rm.Body.UpdatePhysicsInternal(dt);
|
||||
if (sequencer is { } hookSequencer)
|
||||
processAnimationHooks?.Invoke(entity.Id, hookSequencer);
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var postIntegratePos = rm.Body.Position;
|
||||
uint committedCellId = rm.CellId;
|
||||
|
||||
// Step 4: collision sweep — retail FUN_00514B90 →
|
||||
// FUN_005148A0 → Transition::FindTransitionalPosition.
|
||||
|
|
@ -399,7 +471,7 @@ internal sealed class RemotePhysicsUpdater
|
|||
// Fallback to the human capsule for a shapeless / unresolvable
|
||||
// Setup (GetSetupCylinder returns (0,0)); a zero radius would
|
||||
// degenerate the sweep.
|
||||
var (deR, deH) = _getSetupCylinder(serverGuid, ae.Entity);
|
||||
var (deR, deH) = _getSetupCylinder(serverGuid, entity);
|
||||
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
|
||||
var resolveResult = _physicsEngine.ResolveWithTransition(
|
||||
preIntegratePos, postIntegratePos, rm.CellId,
|
||||
|
|
@ -442,11 +514,11 @@ internal sealed class RemotePhysicsUpdater
|
|||
// the remote's own cylinder and produces ~1m of
|
||||
// horizontal drift on the first jump frame
|
||||
// (validated by [SWEEP-OBJ] traces).
|
||||
movingEntityId: ae.Entity.Id);
|
||||
movingEntityId: entity.Id);
|
||||
|
||||
rm.Body.Position = resolveResult.Position;
|
||||
if (resolveResult.CellId != 0)
|
||||
rm.CellId = resolveResult.CellId;
|
||||
committedCellId = resolveResult.CellId;
|
||||
|
||||
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
|
||||
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
|
||||
|
|
@ -460,9 +532,9 @@ internal sealed class RemotePhysicsUpdater
|
|||
// player would collide with a shadow offset from where the monster
|
||||
// renders (the reverted attempt's "stuck on an invisible monster").
|
||||
// Syncing the shadow to the resolved body every tick makes the
|
||||
// de-overlap PERSIST and keeps collision == render. Re-flood is cheap
|
||||
// MOVEMENT-GATED (#184 review): re-flood only when the resolved
|
||||
// body moved > ~1 cm since the last shadow registration. This is
|
||||
// de-overlap PERSIST and keeps collision == render. Re-flood is
|
||||
// POSE/CELL-GATED (#184 review): re-flood only when the resolved
|
||||
// body moved > ~1 cm, rotated, or entered another cell. This is
|
||||
// SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for
|
||||
// players too — every remote's shadow (player + NPC) is written ONLY
|
||||
// by this loop + the UP-branch tail, both to the resolved body, so a
|
||||
|
|
@ -473,12 +545,6 @@ internal sealed class RemotePhysicsUpdater
|
|||
// to actually-moving remotes — the perf risk the review flagged for
|
||||
// a packed town. (In-place shadow-move + cell-relink-on-change is a
|
||||
// further optimization if profiling still shows churn.)
|
||||
if (System.Numerics.Vector3.DistanceSquared(
|
||||
rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
|
||||
{
|
||||
SyncRemoteShadowToBody(ae.Entity.Id, rm, liveCenterX, liveCenterY);
|
||||
}
|
||||
|
||||
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
|
||||
// (pc:282699-282715) runs after EVERY SetPositionInternal —
|
||||
// remote objects included; a VectorUpdate-launched jump arc
|
||||
|
|
@ -578,21 +644,69 @@ internal sealed class RemotePhysicsUpdater
|
|||
// airborne UseTime contact gate; without it a
|
||||
// chasing NPC that lands stalls until ACE's
|
||||
// ~1 Hz re-emit.
|
||||
ulong landingStateAuthorityVersion =
|
||||
ownerRecord?.StateAuthorityVersion ?? 0UL;
|
||||
rm.Movement.HitGround();
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// DR bookkeeping only (partner of the jump-start
|
||||
// `State |= Gravity`): stops the per-tick gravity
|
||||
// integration for the grounded body.
|
||||
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||||
if (ownerRecord is null
|
||||
|| ownerRecord.StateAuthorityVersion
|
||||
== landingStateAuthorityVersion)
|
||||
{
|
||||
rm.Body.State &=
|
||||
~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
|
||||
if (rm.CellId != 0)
|
||||
ae.Entity.ParentCellId = rm.CellId;
|
||||
ae.Entity.Rotation = rm.Body.Orientation;
|
||||
// SetPositionInternal commits the resolved body/contact result and
|
||||
// root frame as one object before changing cell membership. The
|
||||
// canonical CellId writer can synchronously rebucket loaded to
|
||||
// pending, delete, or replace this GUID, so it is the transaction's
|
||||
// final callback boundary before shadow publication.
|
||||
entity.SetPosition(rm.Body.Position);
|
||||
entity.ParentCellId = committedCellId;
|
||||
entity.Rotation = rm.Body.Orientation;
|
||||
bool cellChanged = committedCellId != 0
|
||||
&& committedCellId != rm.CellId;
|
||||
if (cellChanged)
|
||||
rm.CellId = committedCellId;
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// #184: shadow follows the resolved body, but only after the
|
||||
// canonical rebucket proves this exact owner is still spatially
|
||||
// resident. A pending destination keeps its retained registration
|
||||
// suspended; a callback-created replacement owns another local ID.
|
||||
if (ShouldSynchronizeShadow(
|
||||
cellChanged,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
rm.LastShadowSyncPos,
|
||||
rm.LastShadowSyncOrientation))
|
||||
{
|
||||
SyncRemoteShadowToBody(entity.Id, rm, liveCenterX, liveCenterY);
|
||||
}
|
||||
}
|
||||
|
||||
// R5-V3 (#171): retail UpdateObjectInternal tail —
|
||||
|
|
@ -602,21 +716,17 @@ internal sealed class RemotePhysicsUpdater
|
|||
// sticky 1 s lease watchdog (StickyManager::UseTime
|
||||
// 0x00555610 — a stick not re-issued by a fresh server arm
|
||||
// within 1 s tears itself down). No-op while nothing is stuck.
|
||||
System.Action? handleTargeting = rm.Host is { } targetHost
|
||||
? targetHost.HandleTargetting
|
||||
: null;
|
||||
System.Action? partArrayHandleMovement = ae.Sequencer is { } sequencer
|
||||
? sequencer.Manager.UseTime
|
||||
: null;
|
||||
System.Action? positionUseTime = rm.Host is { } positionHost
|
||||
? positionHost.PositionManager.UseTime
|
||||
: null;
|
||||
AcDream.Core.Physics.RetailObjectManagerTail.Run(
|
||||
checkDetection: null,
|
||||
handleTargeting,
|
||||
movementUseTime: rm.Movement.UseTime,
|
||||
partArrayHandleMovement,
|
||||
positionUseTime);
|
||||
rm.Host?.TargetManager,
|
||||
rm.Movement,
|
||||
sequencer?.Manager,
|
||||
rm.Host?.PositionManager);
|
||||
return IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -628,37 +738,69 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// position managers still consume time. Physics-script and particle owners
|
||||
/// tick later in the shared frame pipeline.
|
||||
/// </summary>
|
||||
public void TickHidden(
|
||||
public bool TickHidden(
|
||||
RemoteMotion rm,
|
||||
AcDream.Core.World.WorldEntity entity,
|
||||
float dt,
|
||||
System.Action? partArrayHandleMovement = null)
|
||||
AcDream.Core.Physics.Motion.MotionTableManager?
|
||||
partArrayHandleMovement = null,
|
||||
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
|
||||
processAnimationHooks = null,
|
||||
AcDream.Core.Physics.AnimationSequencer? sequencer = null,
|
||||
AcDream.App.World.LiveEntityRuntime? ownerRuntime = null,
|
||||
AcDream.App.World.LiveEntityRecord? ownerRecord = null,
|
||||
ulong ownerClockEpoch = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rm);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 preComposePosition = rm.Body.Position;
|
||||
|
||||
// The part-array contribution is the identity frame while Hidden.
|
||||
// Interpolation is the first PositionManager stage in retail; acdream
|
||||
// retains it in RemoteMotionCombiner, ahead of Sticky/Constraint.
|
||||
System.Numerics.Vector3 interpolationOffset = rm.Position.ComputeOffset(
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame positionDelta =
|
||||
rm.PositionManagerDeltaScratch;
|
||||
positionDelta.Reset();
|
||||
rm.Position.ComposeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
System.Numerics.Vector3.Zero,
|
||||
rm.Body.Orientation,
|
||||
positionDelta,
|
||||
rm.Interp,
|
||||
rm.Motion.GetMaxSpeed());
|
||||
var positionDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
{
|
||||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||
rm.Body.Orientation,
|
||||
interpolationOffset),
|
||||
};
|
||||
rm.Motion.GetMaxSpeed(),
|
||||
positionDelta,
|
||||
inContact: rm.Body.InContact);
|
||||
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt);
|
||||
ApplyPositionManagerDelta(rm.Body, positionDelta);
|
||||
|
||||
// Hidden suppresses CPartArray::Update, but process_hooks remains the
|
||||
// final UpdatePositionInternal step. Drain any hook already pending on
|
||||
// the retained sequence before transition/manager time, exactly like
|
||||
// the visible path above.
|
||||
if (sequencer is not null)
|
||||
processAnimationHooks?.Invoke(entity.Id, sequencer);
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 composedPosition = rm.Body.Position;
|
||||
uint committedCellId = rm.CellId;
|
||||
if (rm.CellId != 0
|
||||
&& composedPosition != preComposePosition
|
||||
&& _physicsEngine.LandblockCount > 0)
|
||||
|
|
@ -689,8 +831,8 @@ internal sealed class RemotePhysicsUpdater
|
|||
movingEntityId: entity.Id);
|
||||
rm.Body.Position = resolved.Position;
|
||||
if (resolved.CellId != 0)
|
||||
rm.CellId = resolved.CellId;
|
||||
AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
committedCellId = resolved.CellId;
|
||||
if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
rm.Body,
|
||||
resolved.InContact,
|
||||
resolved.OnWalkable,
|
||||
|
|
@ -699,39 +841,71 @@ internal sealed class RemotePhysicsUpdater
|
|||
previousContact,
|
||||
previousOnWalkable,
|
||||
rm.Movement.HitGround,
|
||||
rm.Motion.LeaveGround);
|
||||
rm.Motion.LeaveGround,
|
||||
() => IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
rm.Airborne = !rm.Body.OnWalkable;
|
||||
}
|
||||
|
||||
// Hidden suppresses mesh/part updates, not SetPositionInternal. Commit
|
||||
// the resolved root before the canonical cell writer enters the same
|
||||
// re-entrant rebucket boundary as the visible path.
|
||||
entity.SetPosition(rm.Body.Position);
|
||||
if (rm.CellId != 0)
|
||||
entity.ParentCellId = rm.CellId;
|
||||
entity.ParentCellId = committedCellId;
|
||||
entity.Rotation = rm.Body.Orientation;
|
||||
if (committedCellId != 0 && committedCellId != rm.CellId)
|
||||
rm.CellId = committedCellId;
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
System.Action? handleTargeting = rm.Host is { } targetHost
|
||||
? targetHost.HandleTargetting
|
||||
: null;
|
||||
System.Action? positionUseTime = rm.Host is { } positionHost
|
||||
? positionHost.PositionManager.UseTime
|
||||
: null;
|
||||
AcDream.Core.Physics.RetailObjectManagerTail.Run(
|
||||
checkDetection: null,
|
||||
handleTargeting,
|
||||
movementUseTime: rm.Movement.UseTime,
|
||||
rm.Host?.TargetManager,
|
||||
rm.Movement,
|
||||
partArrayHandleMovement,
|
||||
positionUseTime);
|
||||
rm.Host?.PositionManager);
|
||||
return IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch);
|
||||
}
|
||||
|
||||
private static bool IsCurrentOwner(
|
||||
AcDream.App.World.LiveEntityRuntime? ownerRuntime,
|
||||
AcDream.App.World.LiveEntityRecord? ownerRecord,
|
||||
RemoteMotion remote,
|
||||
AcDream.Core.World.WorldEntity entity,
|
||||
ulong ownerClockEpoch) =>
|
||||
ownerRuntime is null
|
||||
|| ownerRecord is null
|
||||
|| (ownerRecord.ObjectClockEpoch == ownerClockEpoch
|
||||
&& ownerRuntime.IsCurrentSpatialRemoteMotion(ownerRecord, remote)
|
||||
&& ReferenceEquals(ownerRecord.WorldEntity, entity));
|
||||
|
||||
/// <summary>
|
||||
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
|
||||
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
|
||||
/// stand-in for retail's <c>Frame::combine</c> in
|
||||
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
|
||||
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
|
||||
/// <c>globaltolocalvec</c> output — 0x00555430), so combining = rotating it
|
||||
/// out by the body orientation. An untouched (identity) rotation means "no
|
||||
/// turn"; the P5 pin (identity quaternion = heading 0) makes compass addition
|
||||
/// the exact frame-combine here. Moved from GameWindow (#184 Slice 2a); the
|
||||
/// <c>globaltolocalvec</c> output — 0x00555430), so combining rotates it
|
||||
/// out by the body's current orientation and post-multiplies the complete
|
||||
/// relative orientation. Moved from GameWindow (#184 Slice 2a); the
|
||||
/// DR tick is its only caller.
|
||||
/// </summary>
|
||||
private static void ApplyPositionManagerDelta(
|
||||
|
|
@ -741,10 +915,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
if (delta.Origin != System.Numerics.Vector3.Zero)
|
||||
body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
|
||||
if (!delta.Orientation.IsIdentity)
|
||||
body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
|
||||
body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation)
|
||||
+ delta.GetHeading());
|
||||
body.Orientation * delta.Orientation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -756,7 +930,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is
|
||||
/// passed in (<paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>)
|
||||
/// rather than snapshotted, since it moves on recentre. Updates
|
||||
/// <see cref="RemoteMotion.LastShadowSyncPos"/> so callers can movement-gate.
|
||||
/// <see cref="RemoteMotion.LastShadowSyncPos"/> and
|
||||
/// <see cref="RemoteMotion.LastShadowSyncOrientation"/> so callers can
|
||||
/// pose-gate. Rotation matters because Setup collision geometry may be
|
||||
/// multipart or offset from the root.
|
||||
/// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
|
||||
/// UP-branch tail.
|
||||
/// </summary>
|
||||
|
|
@ -774,8 +951,53 @@ internal sealed class RemotePhysicsUpdater
|
|||
liveCenterY,
|
||||
authoritativeCellId ?? rm.CellId);
|
||||
rm.LastShadowSyncPosition = rm.Body.Position;
|
||||
rm.LastShadowSyncOrientation = rm.Body.Orientation;
|
||||
}
|
||||
|
||||
internal static bool ShouldSynchronizeShadowPose(
|
||||
System.Numerics.Vector3 currentPosition,
|
||||
System.Numerics.Quaternion currentOrientation,
|
||||
System.Numerics.Vector3 lastPosition,
|
||||
System.Numerics.Quaternion lastOrientation)
|
||||
{
|
||||
if (System.Numerics.Vector3.DistanceSquared(
|
||||
currentPosition,
|
||||
lastPosition) > 1e-4f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float currentLengthSquared = currentOrientation.LengthSquared();
|
||||
float lastLengthSquared = lastOrientation.LengthSquared();
|
||||
if (!float.IsFinite(currentLengthSquared)
|
||||
|| !float.IsFinite(lastLengthSquared)
|
||||
|| currentLengthSquared < 1e-12f
|
||||
|| lastLengthSquared < 1e-12f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float normalizedDot = MathF.Abs(
|
||||
System.Numerics.Quaternion.Dot(
|
||||
currentOrientation,
|
||||
lastOrientation)
|
||||
/ MathF.Sqrt(currentLengthSquared * lastLengthSquared));
|
||||
return !float.IsFinite(normalizedDot) || normalizedDot < 0.99999f;
|
||||
}
|
||||
|
||||
internal static bool ShouldSynchronizeShadow(
|
||||
bool cellChanged,
|
||||
System.Numerics.Vector3 currentPosition,
|
||||
System.Numerics.Quaternion currentOrientation,
|
||||
System.Numerics.Vector3 lastPosition,
|
||||
System.Numerics.Quaternion lastOrientation) =>
|
||||
cellChanged
|
||||
|| ShouldSynchronizeShadowPose(
|
||||
currentPosition,
|
||||
currentOrientation,
|
||||
lastPosition,
|
||||
lastOrientation);
|
||||
|
||||
public void SyncRemoteShadowToBody(
|
||||
uint entityId,
|
||||
AcDream.Core.Physics.PhysicsBody body,
|
||||
|
|
|
|||
|
|
@ -65,9 +65,13 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
bool ContactResolved,
|
||||
Vector3 Position,
|
||||
uint CellId,
|
||||
Quaternion Orientation);
|
||||
Quaternion Orientation,
|
||||
bool Superseded = false);
|
||||
|
||||
private readonly record struct PendingPlacement(
|
||||
LiveEntityRecord Record,
|
||||
ulong PositionAuthorityVersion,
|
||||
ulong VelocityAuthorityVersion,
|
||||
ILiveEntityRemotePlacementRuntime Remote,
|
||||
PhysicsBody Body,
|
||||
WorldEntity Entity,
|
||||
|
|
@ -95,9 +99,53 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
bool Airborne,
|
||||
Vector3 LastServerPosition,
|
||||
double LastServerPositionTime,
|
||||
Vector3 LastShadowSyncPosition);
|
||||
Vector3 LastShadowSyncPosition,
|
||||
Quaternion LastShadowSyncOrientation);
|
||||
|
||||
/// <summary>
|
||||
/// Applies a placement to whichever exact incarnation is current at this
|
||||
/// call boundary. Packet handlers should use the token-bearing overload;
|
||||
/// this form serves non-wire orchestration and retains the same callback
|
||||
/// revalidation once admitted.
|
||||
/// </summary>
|
||||
internal Result TryApply(
|
||||
ILiveEntityRemotePlacementRuntime remote,
|
||||
WorldEntity entity,
|
||||
Vector3 requestedWorldPosition,
|
||||
uint requestedCellId,
|
||||
Vector3 requestedCellLocalPosition,
|
||||
Quaternion requestedOrientation,
|
||||
double gameTime,
|
||||
bool destinationProjectionVisible,
|
||||
ushort generation,
|
||||
ushort positionSequence)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord record))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Remote placement for 0x{entity.ServerGuid:X8} requires a live incarnation.");
|
||||
}
|
||||
|
||||
return TryApply(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
record.VelocityAuthorityVersion,
|
||||
remote,
|
||||
entity,
|
||||
requestedWorldPosition,
|
||||
requestedCellId,
|
||||
requestedCellLocalPosition,
|
||||
requestedOrientation,
|
||||
gameTime,
|
||||
destinationProjectionVisible,
|
||||
generation,
|
||||
positionSequence);
|
||||
}
|
||||
|
||||
internal Result TryApply(
|
||||
LiveEntityRecord expectedRecord,
|
||||
ulong expectedPositionAuthorityVersion,
|
||||
ulong expectedVelocityAuthorityVersion,
|
||||
ILiveEntityRemotePlacementRuntime remote,
|
||||
WorldEntity entity,
|
||||
Vector3 requestedWorldPosition,
|
||||
|
|
@ -111,8 +159,15 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord liveRecord)
|
||||
|| !ReferenceEquals(liveRecord, expectedRecord)
|
||||
|| !_liveEntities.IsCurrentPositionAuthority(
|
||||
expectedRecord,
|
||||
expectedPositionAuthorityVersion)
|
||||
|| liveRecord.Generation != generation
|
||||
|| !ReferenceEquals(liveRecord.WorldEntity, entity)
|
||||
|| !ReferenceEquals(liveRecord.RemoteMotionRuntime, remote)
|
||||
|| liveRecord.PhysicsBody is null
|
||||
|| !ReferenceEquals(liveRecord.PhysicsBody, remote.Body))
|
||||
{
|
||||
|
|
@ -124,6 +179,7 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
bool wasOnWalkable = liveRecord.PhysicsBody.OnWalkable;
|
||||
RollbackPlacement rollback = CaptureRollback(remote, liveRecord.PhysicsBody);
|
||||
if (_pending.TryGetValue(entity.ServerGuid, out PendingPlacement prior)
|
||||
&& ReferenceEquals(prior.Record, liveRecord)
|
||||
&& prior.Generation == generation)
|
||||
{
|
||||
wasInContact = prior.WasInContact;
|
||||
|
|
@ -132,6 +188,9 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
}
|
||||
|
||||
PendingPlacement request = new(
|
||||
liveRecord,
|
||||
expectedPositionAuthorityVersion,
|
||||
expectedVelocityAuthorityVersion,
|
||||
remote,
|
||||
liveRecord.PhysicsBody,
|
||||
entity,
|
||||
|
|
@ -146,7 +205,8 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
rollback);
|
||||
if (!destinationProjectionVisible)
|
||||
{
|
||||
ParkPending(request, requestedCellLocalPosition);
|
||||
if (!ParkPending(request, requestedCellLocalPosition))
|
||||
return Superseded(request);
|
||||
return new Result(
|
||||
true,
|
||||
false,
|
||||
|
|
@ -155,10 +215,13 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
requestedOrientation);
|
||||
}
|
||||
ResolveResult placement = Resolve(request);
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (!placement.Ok)
|
||||
{
|
||||
_pending.Remove(entity.ServerGuid);
|
||||
RollBackDeferredPlacement(request);
|
||||
if (!RollBackDeferredPlacement(request))
|
||||
return Superseded(request);
|
||||
return new Result(
|
||||
false,
|
||||
false,
|
||||
|
|
@ -250,7 +313,9 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
|
||||
private Result CommitResolved(PendingPlacement request, ResolveResult placement)
|
||||
{
|
||||
RemoteTeleportPlacement.Apply(
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (!RemoteTeleportPlacement.Apply(
|
||||
request.Remote,
|
||||
request.Body,
|
||||
placement,
|
||||
|
|
@ -258,16 +323,29 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
request.RequestedOrientation,
|
||||
request.GameTime,
|
||||
request.WasInContact,
|
||||
request.WasOnWalkable);
|
||||
request.WasOnWalkable,
|
||||
() => IsCurrent(request),
|
||||
() => IsVelocityCurrent(request)))
|
||||
{
|
||||
return Superseded(request);
|
||||
}
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (request.Remote.CellId != placement.CellId)
|
||||
request.Remote.CellId = placement.CellId;
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
request.Remote.LastServerPosition = placement.Position;
|
||||
request.Remote.LastServerPositionTime = request.GameTime;
|
||||
request.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||
request.Remote.LastShadowSyncOrientation = Quaternion.Zero;
|
||||
request.Entity.SetPosition(request.Body.Position);
|
||||
request.Entity.ParentCellId = placement.CellId;
|
||||
request.Entity.Rotation = request.Body.Orientation;
|
||||
if (_liveEntities.TryGetRecord(request.Entity.ServerGuid, out LiveEntityRecord record))
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (_liveEntities.TryGetRecord(request.Entity.ServerGuid, out LiveEntityRecord record)
|
||||
&& ReferenceEquals(record, request.Record))
|
||||
{
|
||||
bool deferShadowRestore =
|
||||
record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)
|
||||
|
|
@ -276,10 +354,15 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
request.Entity.ServerGuid,
|
||||
request.Generation,
|
||||
deferShadowRestore);
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (!deferShadowRestore)
|
||||
{
|
||||
request.Remote.LastShadowSyncPosition = request.Body.Position;
|
||||
request.Remote.LastShadowSyncOrientation = request.Body.Orientation;
|
||||
_syncResolvedShadow(request.Entity, request.Body, placement.CellId);
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
}
|
||||
}
|
||||
return new Result(
|
||||
|
|
@ -290,8 +373,10 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
request.Body.Orientation);
|
||||
}
|
||||
|
||||
private void ParkPending(PendingPlacement request, Vector3 requestedCellLocalPosition)
|
||||
private bool ParkPending(PendingPlacement request, Vector3 requestedCellLocalPosition)
|
||||
{
|
||||
if (!IsCurrent(request))
|
||||
return false;
|
||||
request.Body.Orientation = request.RequestedOrientation;
|
||||
request.Body.SnapToCell(
|
||||
request.RequestedCellId,
|
||||
|
|
@ -308,13 +393,19 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
request.Remote.Airborne = true;
|
||||
if (request.Remote.CellId != request.RequestedCellId)
|
||||
request.Remote.CellId = request.RequestedCellId;
|
||||
if (!IsCurrent(request))
|
||||
return false;
|
||||
request.Remote.LastServerPosition = request.RequestedWorldPosition;
|
||||
request.Remote.LastServerPositionTime = request.GameTime;
|
||||
request.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||
request.Remote.LastShadowSyncOrientation = Quaternion.Zero;
|
||||
request.Entity.SetPosition(request.RequestedWorldPosition);
|
||||
request.Entity.ParentCellId = request.RequestedCellId;
|
||||
request.Entity.Rotation = request.RequestedOrientation;
|
||||
if (!IsCurrent(request))
|
||||
return false;
|
||||
_pending[request.Entity.ServerGuid] = request;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
|
|
@ -325,6 +416,7 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
if (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending))
|
||||
{
|
||||
if (record.WorldEntity is null
|
||||
|| !ReferenceEquals(record, pending.Record)
|
||||
|| !ReferenceEquals(record.WorldEntity, pending.Entity)
|
||||
|| record.Generation != pending.Generation)
|
||||
{
|
||||
|
|
@ -351,7 +443,10 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
pending = pending with { Remote = currentRemote };
|
||||
_pending[record.ServerGuid] = pending;
|
||||
}
|
||||
if (record.Snapshot.PositionSequence != pending.PositionSequence)
|
||||
if (!_liveEntities.IsCurrentPositionAuthority(
|
||||
pending.Record,
|
||||
pending.PositionAuthorityVersion)
|
||||
|| record.Snapshot.PositionSequence != pending.PositionSequence)
|
||||
{
|
||||
// A newer accepted UpdatePosition rebucketed the projection
|
||||
// before its TryApply call could replace this request. Keep
|
||||
|
|
@ -361,6 +456,8 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
|
||||
_pending.Remove(record.ServerGuid);
|
||||
ResolveResult placement = Resolve(pending);
|
||||
if (!IsCurrent(pending))
|
||||
return;
|
||||
if (!placement.Ok)
|
||||
RollBackDeferredPlacement(pending);
|
||||
else
|
||||
|
|
@ -388,11 +485,14 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
remote.Airborne,
|
||||
remote.LastServerPosition,
|
||||
remote.LastServerPositionTime,
|
||||
remote.LastShadowSyncPosition);
|
||||
remote.LastShadowSyncPosition,
|
||||
remote.LastShadowSyncOrientation);
|
||||
}
|
||||
|
||||
private void RollBackDeferredPlacement(PendingPlacement pending)
|
||||
private bool RollBackDeferredPlacement(PendingPlacement pending)
|
||||
{
|
||||
if (!IsCurrentAuthority(pending))
|
||||
return false;
|
||||
RollbackPlacement rollback = pending.Rollback;
|
||||
PhysicsBody body = pending.Body;
|
||||
body.Orientation = rollback.Orientation;
|
||||
|
|
@ -412,21 +512,26 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
pending.Remote.LastServerPosition = rollback.LastServerPosition;
|
||||
pending.Remote.LastServerPositionTime = rollback.LastServerPositionTime;
|
||||
pending.Remote.LastShadowSyncPosition = rollback.LastShadowSyncPosition;
|
||||
pending.Remote.LastShadowSyncOrientation = rollback.LastShadowSyncOrientation;
|
||||
pending.Entity.SetPosition(rollback.Position);
|
||||
pending.Entity.ParentCellId = rollback.CellId;
|
||||
pending.Entity.Rotation = rollback.Orientation;
|
||||
if (!IsCurrentAuthority(pending))
|
||||
return false;
|
||||
|
||||
if (rollback.CellId == 0)
|
||||
_liveEntities.WithdrawLiveEntityProjectionToCellless(pending.Entity.ServerGuid);
|
||||
else
|
||||
_liveEntities.RebucketLiveEntity(pending.Entity.ServerGuid, rollback.CellId);
|
||||
|
||||
if (!_liveEntities.TryGetRecord(
|
||||
if (!IsCurrentAuthority(pending)
|
||||
|| !_liveEntities.TryGetRecord(
|
||||
pending.Entity.ServerGuid,
|
||||
out LiveEntityRecord restored)
|
||||
|| !ReferenceEquals(restored, pending.Record)
|
||||
|| restored.Generation != pending.Generation)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rollback.CellId == 0)
|
||||
|
|
@ -435,7 +540,7 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
pending.Entity.ServerGuid,
|
||||
pending.Generation,
|
||||
false);
|
||||
return;
|
||||
return IsCurrentAuthority(pending);
|
||||
}
|
||||
|
||||
bool deferShadowRestore =
|
||||
|
|
@ -445,13 +550,42 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
pending.Entity.ServerGuid,
|
||||
pending.Generation,
|
||||
deferShadowRestore);
|
||||
if (!IsCurrentAuthority(pending))
|
||||
return false;
|
||||
if (deferShadowRestore)
|
||||
return;
|
||||
return true;
|
||||
|
||||
pending.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||
pending.Remote.LastShadowSyncOrientation = Quaternion.Zero;
|
||||
_syncResolvedShadow(pending.Entity, pending.Body, rollback.CellId);
|
||||
return IsCurrentAuthority(pending);
|
||||
}
|
||||
|
||||
private bool IsCurrentAuthority(PendingPlacement request) =>
|
||||
_liveEntities.IsCurrentPositionAuthority(
|
||||
request.Record,
|
||||
request.PositionAuthorityVersion)
|
||||
&& request.Record.Generation == request.Generation
|
||||
&& ReferenceEquals(request.Record.WorldEntity, request.Entity)
|
||||
&& ReferenceEquals(request.Record.PhysicsBody, request.Body);
|
||||
|
||||
private bool IsCurrent(PendingPlacement request) =>
|
||||
IsCurrentAuthority(request)
|
||||
&& ReferenceEquals(request.Record.RemoteMotionRuntime, request.Remote);
|
||||
|
||||
private bool IsVelocityCurrent(PendingPlacement request) =>
|
||||
_liveEntities.IsCurrentVelocityAuthority(
|
||||
request.Record,
|
||||
request.VelocityAuthorityVersion);
|
||||
|
||||
private static Result Superseded(PendingPlacement request) => new(
|
||||
Applied: false,
|
||||
ContactResolved: false,
|
||||
Position: request.Body.Position,
|
||||
CellId: request.Remote.CellId,
|
||||
Orientation: request.Body.Orientation,
|
||||
Superseded: true);
|
||||
|
||||
private static bool IsPlayerGuid(uint guid) =>
|
||||
(guid & 0xFF000000u) == 0x50000000u;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ public static class RemoteTeleportHook
|
|||
{
|
||||
private const WeenieError TeleportCancelContext = (WeenieError)0x3Cu;
|
||||
|
||||
public static void Execute(RemoteTeleportHookActions actions)
|
||||
public static bool Execute(
|
||||
RemoteTeleportHookActions actions,
|
||||
Func<bool>? isCurrent = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actions.CancelMoveTo);
|
||||
ArgumentNullException.ThrowIfNull(actions.UnStick);
|
||||
|
|
@ -21,12 +23,26 @@ public static class RemoteTeleportHook
|
|||
ArgumentNullException.ThrowIfNull(actions.NotifyTeleported);
|
||||
ArgumentNullException.ThrowIfNull(actions.ReportCollisionEnd);
|
||||
|
||||
bool Current() => isCurrent?.Invoke() ?? true;
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.CancelMoveTo(TeleportCancelContext);
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.UnStick();
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.StopInterpolating();
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.UnConstrain();
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.NotifyTeleported();
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.ReportCollisionEnd();
|
||||
return Current();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ namespace AcDream.App.Physics;
|
|||
/// </summary>
|
||||
internal static class RemoteTeleportPlacement
|
||||
{
|
||||
internal static void Apply(
|
||||
internal static bool Apply(
|
||||
ILiveEntityRemotePlacementRuntime remote,
|
||||
PhysicsBody body,
|
||||
ResolveResult placement,
|
||||
|
|
@ -20,7 +20,9 @@ internal static class RemoteTeleportPlacement
|
|||
Quaternion orientation,
|
||||
double gameTime,
|
||||
bool previousContact,
|
||||
bool previousOnWalkable)
|
||||
bool previousOnWalkable,
|
||||
Func<bool>? isCurrent = null,
|
||||
Func<bool>? isVelocityCurrent = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
|
|
@ -55,7 +57,7 @@ internal static class RemoteTeleportPlacement
|
|||
body.ContactPlaneIsWater = false;
|
||||
}
|
||||
|
||||
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
if (!PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
body,
|
||||
placement.InContact,
|
||||
placement.OnWalkable,
|
||||
|
|
@ -64,9 +66,15 @@ internal static class RemoteTeleportPlacement
|
|||
previousContact,
|
||||
previousOnWalkable,
|
||||
remote.HitGround,
|
||||
remote.LeaveGround);
|
||||
remote.Airborne = !body.OnWalkable;
|
||||
|
||||
remote.LeaveGround,
|
||||
isCurrent,
|
||||
isVelocityCurrent))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (isVelocityCurrent?.Invoke() != false)
|
||||
remote.Airborne = !body.OnWalkable;
|
||||
return isCurrent?.Invoke() ?? true;
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector3 value) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue