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:
Erik 2026-07-20 09:10:31 +02:00
parent 31a0889f08
commit f961d70023
77 changed files with 12513 additions and 1871 deletions

View 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;
}

View 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);
}

View file

@ -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,

View 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;
}

View file

@ -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,

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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) =>