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

@ -11,15 +11,11 @@ namespace AcDream.App.Input;
/// </summary>
public sealed class LocalPlayerOutboundController
{
private readonly Func<float, Quaternion> _toWireRotation;
private readonly Action<string, uint, MovementResult, Vector3, uint, byte> _diagnostic;
public LocalPlayerOutboundController(
Func<float, Quaternion> toWireRotation,
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
{
_toWireRotation = toWireRotation
?? throw new ArgumentNullException(nameof(toWireRotation));
_diagnostic = diagnostic
?? throw new ArgumentNullException(nameof(diagnostic));
}
@ -38,14 +34,13 @@ public sealed class LocalPlayerOutboundController
if (session is null || hidden)
return;
Quaternion wireRotation = _toWireRotation(controller.Yaw);
if (!controller.TryGetOutboundPosition(
wireRotation,
out uint wireCellId,
out Vector3 wirePosition))
if (!controller.TryGetOutboundPosition(out Position outboundPosition))
{
return;
}
uint wireCellId = outboundPosition.ObjCellId;
Vector3 wirePosition = outboundPosition.Frame.Origin;
Quaternion wireRotation = outboundPosition.Frame.Orientation;
if (movement.ShouldSendMovementEvent)
TrySendMovement(session, controller, movement);
@ -82,16 +77,14 @@ public sealed class LocalPlayerOutboundController
if (session is null || hidden)
return;
Quaternion wireRotation = _toWireRotation(controller.Yaw);
if (!controller.TryGetOutboundPosition(
wireRotation,
out uint wireCellId,
out Vector3 wirePosition))
if (!controller.TryGetOutboundPosition(out Position position))
{
return;
}
uint wireCellId = position.ObjCellId;
Vector3 wirePosition = position.Frame.Origin;
Quaternion wireRotation = position.Frame.Orientation;
var position = new Position(wireCellId, wirePosition, wireRotation);
if (!controller.ShouldSendPositionEvent(
position,
controller.ContactPlane,
@ -136,14 +129,13 @@ public sealed class LocalPlayerOutboundController
if (session is null || controller is null)
return false;
Quaternion wireRotation = _toWireRotation(controller.Yaw);
if (!controller.TryGetOutboundPosition(
wireRotation,
out uint wireCellId,
out Vector3 wirePosition))
if (!controller.TryGetOutboundPosition(out Position outboundPosition))
{
return false;
}
uint wireCellId = outboundPosition.ObjCellId;
Vector3 wirePosition = outboundPosition.Frame.Origin;
Quaternion wireRotation = outboundPosition.Frame.Orientation;
byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0;
RawMotionState rawMotionState = BuildRawMotionState(movement);

View file

@ -13,19 +13,27 @@ public sealed class LocalPlayerProjectionController
private readonly Func<int> _liveCenterY;
private readonly Action<WorldEntity, uint> _syncShadow;
private readonly Action<uint, uint> _rebucket;
private readonly Func<WorldEntity, bool> _isCurrentVisibleProjection;
private readonly Action<WorldEntity> _suspendShadow;
public LocalPlayerProjectionController(
Func<WorldEntity?> resolveEntity,
Func<int> liveCenterX,
Func<int> liveCenterY,
Action<WorldEntity, uint> syncShadow,
Action<uint, uint> rebucket)
Action<uint, uint> rebucket,
Func<WorldEntity, bool> isCurrentVisibleProjection,
Action<WorldEntity> suspendShadow)
{
_resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
_liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX));
_liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY));
_syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow));
_rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket));
_isCurrentVisibleProjection = isCurrentVisibleProjection
?? throw new ArgumentNullException(nameof(isCurrentVisibleProjection));
_suspendShadow = suspendShadow
?? throw new ArgumentNullException(nameof(suspendShadow));
}
public void Project(
@ -39,11 +47,10 @@ public sealed class LocalPlayerProjectionController
entity.SetPosition(movement.RenderPosition);
entity.ParentCellId = movement.CellId;
entity.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitZ,
controller.Yaw - MathF.PI / 2f);
if (!hidden)
_syncShadow(entity, movement.CellId);
// Retail's root is a complete Frame. Project the authoritative body
// quaternion directly so pitch/roll from a server correction or DAT
// root frame cannot be flattened by the presentation layer.
entity.Rotation = controller.BodyOrientation;
uint currentLandblock;
if (movement.CellId != 0 && (movement.CellId & 0xFFFFu) >= 0x0100u)
@ -62,7 +69,22 @@ public sealed class LocalPlayerProjectionController
// The teleport owner alone projects the destination while the local
// controller deliberately retains its frozen source cell.
if (controller.State != PlayerState.PortalSpace)
_rebucket(entity.ServerGuid, currentLandblock);
if (controller.State == PlayerState.PortalSpace)
return;
// SetPositionInternal commits the complete root before changing cell
// membership, then replaces collision rows only while the object still
// owns a loaded cell. Rebucket is a synchronous callback boundary: it
// can move this incarnation to pending or replace the GUID entirely.
_rebucket(entity.ServerGuid, currentLandblock);
if (hidden
|| movement.CellId == 0
|| !_isCurrentVisibleProjection(entity))
{
_suspendShadow(entity);
return;
}
_syncShadow(entity, movement.CellId);
}
}

File diff suppressed because it is too large Load diff

View file

@ -21,18 +21,33 @@ public sealed class RetailLocalPlayerFrameController
private readonly Action<PlayerMovementController, MovementResult, bool> _project;
private readonly Action<PlayerMovementController, MovementResult, bool> _sendPreNetwork;
private readonly Action<PlayerMovementController, bool> _sendPostNetwork;
private readonly Func<AcDream.Core.Physics.RetailObjectClockDisposition>
_objectClockDisposition;
private AdvancedFrame? _advancedFrame;
private readonly record struct AdvancedFrame(
PlayerMovementController Controller,
MovementResult Movement);
MovementResult Movement,
bool ObjectAdvanced,
bool Hidden,
bool ObjectQuantumAdvanced);
public readonly record struct PresentationFrame(
MovementResult Movement,
bool Hidden,
bool AdvancedBeforeNetwork);
/// <summary>
/// Whether the pre-network Hidden player update admitted a complete object
/// quantum whose retained CPartArray pose must be sampled this frame.
/// </summary>
internal bool HiddenPartPoseDirty => _advancedFrame is
{
Hidden: true,
ObjectQuantumAdvanced: true,
};
public RetailLocalPlayerFrameController(
Func<bool> canPresentPlayer,
Func<PlayerMovementController?> getController,
@ -42,7 +57,9 @@ public sealed class RetailLocalPlayerFrameController
Func<bool> isHidden,
Action<PlayerMovementController, MovementResult, bool> project,
Action<PlayerMovementController, MovementResult, bool> sendPreNetwork,
Action<PlayerMovementController, bool> sendPostNetwork)
Action<PlayerMovementController, bool> sendPostNetwork,
Func<AcDream.Core.Physics.RetailObjectClockDisposition>?
objectClockDisposition = null)
{
_canPresentPlayer = canPresentPlayer
?? throw new ArgumentNullException(nameof(canPresentPlayer));
@ -62,6 +79,8 @@ public sealed class RetailLocalPlayerFrameController
?? throw new ArgumentNullException(nameof(sendPreNetwork));
_sendPostNetwork = sendPostNetwork
?? throw new ArgumentNullException(nameof(sendPostNetwork));
_objectClockDisposition = objectClockDisposition
?? (() => AcDream.Core.Physics.RetailObjectClockDisposition.Advance);
}
/// <summary>
@ -75,16 +94,54 @@ public sealed class RetailLocalPlayerFrameController
if (!_canPresentPlayer() || controller is null)
return;
if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f)
{
bool rejectedHidden = _isHidden();
MovementResult rejected = controller.CapturePresentationResult();
_project(controller, rejected, rejectedHidden);
_advancedFrame = new AdvancedFrame(
controller,
rejected,
ObjectAdvanced: false,
Hidden: rejectedHidden,
ObjectQuantumAdvanced: false);
return;
}
controller.LocalEntityId = _resolveLocalEntityId();
bool hidden = _isHidden();
if (_objectClockDisposition()
is AcDream.Core.Physics.RetailObjectClockDisposition.Suspend)
{
// CPhysicsObj::update_object returns before advancing time for a
// Frozen or cell-less object. Keep the retained projection live,
// but emit neither input actions nor AutonomousPosition from a
// physics tick that retail did not run.
controller.SuspendObjectUpdate(deltaSeconds);
MovementResult paused = controller.CapturePresentationResult();
_project(controller, paused, hidden);
_advancedFrame = new AdvancedFrame(
controller,
paused,
ObjectAdvanced: false,
Hidden: hidden,
ObjectQuantumAdvanced: false);
return;
}
MovementResult movement = hidden
? controller.TickHidden(deltaSeconds, _handleTargeting)
: controller.Update(deltaSeconds, _captureInput(), _handleTargeting);
_project(controller, movement, hidden);
_sendPreNetwork(controller, movement, hidden);
_advancedFrame = new AdvancedFrame(controller, movement);
_advancedFrame = new AdvancedFrame(
controller,
movement,
ObjectAdvanced: true,
Hidden: hidden,
ObjectQuantumAdvanced: controller.AdvancedObjectQuantumLastTick);
}
/// <summary>
@ -106,7 +163,15 @@ public sealed class RetailLocalPlayerFrameController
advanced.Movement,
controller);
_project(controller, movement, hidden);
_advancedFrame = new AdvancedFrame(controller, movement);
_advancedFrame = new AdvancedFrame(
controller,
movement,
advanced.ObjectAdvanced,
advanced.Hidden,
advanced.ObjectQuantumAdvanced);
if (!advanced.ObjectAdvanced)
return;
}
_sendPostNetwork(controller, hidden);
@ -131,7 +196,10 @@ public sealed class RetailLocalPlayerFrameController
MovementResult movement = RefreshSpatialFields(
advanced.Movement,
controller);
frame = new PresentationFrame(movement, hidden, AdvancedBeforeNetwork: true);
frame = new PresentationFrame(
movement,
hidden,
AdvancedBeforeNetwork: advanced.ObjectAdvanced);
return true;
}

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,509 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using DatReaderWriter.Types;
using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity;
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
namespace AcDream.App.Rendering;
/// <summary>
/// Owns retail's ordinary live-object workset. <c>CPhysics::UseTime</c>
/// (0x00509950) walks the object table, not the render-animation table; an
/// animation, MovementManager, projectile body, or effect owner is therefore
/// an optional component of one canonical <see cref="LiveEntityRecord"/>.
/// </summary>
internal sealed class LiveEntityAnimationScheduler
{
private readonly Func<LiveEntityRuntime?> _liveEntities;
private readonly Func<uint> _localPlayerGuid;
private readonly RemotePhysicsUpdater _remotePhysics;
private readonly LiveEntityOrdinaryPhysicsUpdater _ordinaryPhysics;
private readonly Func<ProjectileController?> _projectiles;
private readonly Action<WorldEntity> _publishRootPose;
private readonly Action<uint, AnimationSequencer> _captureAnimationHooks;
private readonly List<LiveEntityRecord> _rootSnapshot = new();
private readonly Dictionary<uint, LiveEntityAnimationSchedule> _schedules = new();
private readonly Frame _rootFrameScratch = new();
private readonly MotionDeltaFrame _rootDeltaScratch = new();
public LiveEntityAnimationScheduler(
Func<LiveEntityRuntime?> liveEntities,
Func<uint> localPlayerGuid,
RemotePhysicsUpdater remotePhysics,
LiveEntityOrdinaryPhysicsUpdater ordinaryPhysics,
Func<ProjectileController?> projectiles,
Action<WorldEntity> publishRootPose,
Action<uint, AnimationSequencer> captureAnimationHooks)
{
_liveEntities = liveEntities
?? throw new ArgumentNullException(nameof(liveEntities));
_localPlayerGuid = localPlayerGuid
?? throw new ArgumentNullException(nameof(localPlayerGuid));
_remotePhysics = remotePhysics
?? throw new ArgumentNullException(nameof(remotePhysics));
_ordinaryPhysics = ordinaryPhysics
?? throw new ArgumentNullException(nameof(ordinaryPhysics));
_projectiles = projectiles
?? throw new ArgumentNullException(nameof(projectiles));
_publishRootPose = publishRootPose
?? throw new ArgumentNullException(nameof(publishRootPose));
_captureAnimationHooks = captureAnimationHooks
?? throw new ArgumentNullException(nameof(captureAnimationHooks));
}
/// <summary>
/// Advances a stable snapshot of the complete ordinary-object table and
/// returns pose work only for animation owners that survived every
/// callback. The returned dictionary is reused and valid until the next
/// call.
/// </summary>
public IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> Tick(
float elapsedSeconds,
Vector3? playerPosition,
bool localHiddenPartPoseDirty,
int liveCenterX,
int liveCenterY,
Action<uint, AnimatedEntity>? prepareAnimation = null)
{
_schedules.Clear();
LiveEntityRuntime? runtime = _liveEntities();
if (runtime is null)
return _schedules;
runtime.CopySpatialRootObjectRecordsTo(_rootSnapshot);
foreach (LiveEntityRecord record in _rootSnapshot)
{
if (!runtime.IsCurrentSpatialRootObject(record)
|| record.WorldEntity is not { } entity)
{
continue;
}
AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity;
RemoteMotion? remote = record.RemoteMotionRuntime as RemoteMotion;
ProjectileController.Runtime? projectile =
record.ProjectileRuntime as ProjectileController.Runtime;
ulong objectClockEpoch = record.ObjectClockEpoch;
if (animation is not null)
prepareAnimation?.Invoke(record.ServerGuid, animation);
if (!IsCurrent(
runtime,
record,
entity,
animation,
remote,
projectile,
objectClockEpoch))
continue;
LiveEntityAnimationSchedule schedule = AdvanceRecord(
runtime,
record,
entity,
animation,
remote,
projectile,
elapsedSeconds,
playerPosition,
localHiddenPartPoseDirty,
liveCenterX,
liveCenterY,
objectClockEpoch);
if (animation is not null
&& schedule.ComposeParts
&& IsCurrent(
runtime,
record,
entity,
animation,
remote,
projectile,
objectClockEpoch))
{
_schedules[entity.Id] = schedule;
}
}
return _schedules;
}
private LiveEntityAnimationSchedule AdvanceRecord(
LiveEntityRuntime runtime,
LiveEntityRecord record,
WorldEntity entity,
AnimatedEntity? animation,
RemoteMotion? remote,
ProjectileController.Runtime? projectile,
float elapsedSeconds,
Vector3? playerPosition,
bool localHiddenPartPoseDirty,
int liveCenterX,
int liveCenterY,
ulong objectClockEpoch)
{
uint serverGuid = record.ServerGuid;
AnimationSequencer? sequencer = animation?.Sequencer;
PhysicsStateFlags state = record.FinalPhysicsState;
bool hidden = (state & PhysicsStateFlags.Hidden) != 0;
// PlayerMovementController owns this exact record clock and advances
// its PartArray before local collision. Consume that prepared pose;
// never tick the same clock or sequence a second time here.
if (serverGuid == _localPlayerGuid())
{
IReadOnlyList<PartTransform>? prepared =
animation?.PreparedSequenceFrames;
bool advanced = animation?.SequenceAdvancedBeforeAnimationPass == true;
if (animation is not null)
{
animation.PreparedSequenceFrames = null;
animation.SequenceAdvancedBeforeAnimationPass = false;
}
bool composeHidden = hidden && localHiddenPartPoseDirty;
if (!IsCurrent(
runtime,
record,
entity,
animation,
remote,
projectile,
objectClockEpoch))
{
return default;
}
// Local projection interpolates the visible root on render frames
// that are smaller than an admitted object quantum. Publish that
// current root independently of PartArray recomposition so local
// attached effects and lights never trail the rendered character.
_publishRootPose(entity);
if (!IsCurrent(
runtime,
record,
entity,
animation,
remote,
projectile,
objectClockEpoch))
{
return default;
}
return new LiveEntityAnimationSchedule(
prepared ?? (composeHidden ? sequencer?.SampleCurrentPose() : null),
0f,
ComposeParts: advanced || composeHidden);
}
RetailObjectActivityResult activity = RetailObjectActivityGate.Evaluate(
record.ObjectClock,
remote?.Body ?? projectile?.Body ?? record.PhysicsBody,
runtime.GetRootObjectClockDisposition(serverGuid)
is RetailObjectClockDisposition.Advance,
record.HasPartArray,
(state & PhysicsStateFlags.Static) != 0,
entity.Position,
playerPosition,
elapsedSeconds);
if (activity is not RetailObjectActivityResult.Active)
return default;
RetailObjectQuantumBatch batch = record.ObjectClock.Advance(elapsedSeconds);
if (batch.Count == 0)
return default;
IReadOnlyList<PartTransform>? frames = null;
float legacyElapsed = 0f;
bool completed = true;
ProjectileController? projectileController = _projectiles();
bool projectileHandlesMovement = projectile is not null
&& projectileController?.HandlesMovement(serverGuid) == true;
float objectScale = animation?.Scale
?? record.Snapshot.Physics?.Scale
?? record.Snapshot.ObjScale
?? entity.Scale;
for (int qi = 0; qi < batch.Count; qi++)
{
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
{
completed = false;
break;
}
float quantum = batch.GetQuantum(qi);
if (hidden)
{
if (remote is not null)
{
if (!_remotePhysics.TickHidden(
remote,
entity,
quantum,
sequencer?.Manager,
_captureAnimationHooks,
sequencer,
runtime,
record,
objectClockEpoch))
{
completed = false;
break;
}
}
else
{
if (sequencer is not null)
{
_captureAnimationHooks(entity.Id, sequencer);
if (!IsCurrent(
runtime,
record,
entity,
animation,
remote,
projectile,
objectClockEpoch))
{
completed = false;
break;
}
}
RunManagerTail(remote, sequencer?.Manager);
}
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
{
completed = false;
break;
}
continue;
}
Frame rootFrame = animation?.RootMotionScratch ?? _rootFrameScratch;
rootFrame.Origin = Vector3.Zero;
rootFrame.Orientation = Quaternion.Identity;
if (sequencer is not null)
frames = sequencer.Advance(quantum, rootFrame);
else if (animation is not null)
legacyElapsed += quantum;
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
{
completed = false;
break;
}
if (remote is not null && !projectileHandlesMovement)
{
if (animation is not null && quantum > 0f)
{
float rootMotionSpeed = rootFrame.Origin.Length()
* objectScale / quantum;
remote.MaxRootMotionSpeedSinceLastUP = MathF.Max(
remote.MaxRootMotionSpeedSinceLastUP,
rootMotionSpeed);
}
MotionDeltaFrame rootDelta = animation?.RootMotionDeltaScratch
?? _rootDeltaScratch;
rootDelta.Origin = rootFrame.Origin;
rootDelta.Orientation = rootFrame.Orientation;
if (!_remotePhysics.Tick(
remote,
entity,
objectScale,
sequencer,
animation,
quantum,
rootDelta,
liveCenterX,
liveCenterY,
_captureAnimationHooks,
runtime,
record,
objectClockEpoch))
{
completed = false;
break;
}
}
else
{
bool ordinaryBodyHandlesMovement = projectile is null
&& record.PhysicsBody is not null;
if (ordinaryBodyHandlesMovement)
{
if (!_ordinaryPhysics.Tick(
runtime,
record,
entity,
rootFrame,
objectScale,
quantum,
liveCenterX,
liveCenterY,
objectClockEpoch,
sequencer,
_captureAnimationHooks))
{
completed = false;
break;
}
}
else
{
ApplyRootFrame(record, entity, rootFrame, objectScale);
}
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
{
completed = false;
break;
}
ProjectileController.QuantumStep projectileStep = default;
bool beganProjectile = projectileHandlesMovement
&& projectileController?.TryBeginQuantum(
record,
quantum,
out projectileStep) == true;
if (!ordinaryBodyHandlesMovement && sequencer is not null)
_captureAnimationHooks(entity.Id, sequencer);
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
{
completed = false;
break;
}
if (beganProjectile
&& !projectileController!.CompleteQuantum(
projectileStep,
liveCenterX,
liveCenterY))
{
completed = false;
break;
}
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
{
completed = false;
break;
}
RunManagerTail(remote, sequencer?.Manager);
}
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
{
completed = false;
break;
}
}
if (!completed
|| !IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
{
return default;
}
_publishRootPose(entity);
if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch))
return default;
if (hidden)
{
return new LiveEntityAnimationSchedule(
sequencer?.SampleCurrentPose(),
0f,
ComposeParts: animation is not null);
}
return new LiveEntityAnimationSchedule(
frames,
legacyElapsed,
ComposeParts: animation is not null);
}
private static void ApplyRootFrame(
LiveEntityRecord record,
WorldEntity entity,
Frame rootFrame,
float objectScale)
{
PhysicsBody? body = record.PhysicsBody;
Vector3 position = body?.Position ?? entity.Position;
Quaternion orientation = body?.Orientation ?? entity.Rotation;
Vector3 localOrigin = body?.OnWalkable == true
? rootFrame.Origin * objectScale
: Vector3.Zero;
if (localOrigin != Vector3.Zero)
position += Vector3.Transform(localOrigin, orientation);
if (!rootFrame.Orientation.IsIdentity)
{
orientation = FrameOps.SetRotate(
position,
orientation,
orientation * rootFrame.Orientation);
}
if (body is not null)
{
body.Position = position;
body.Orientation = orientation;
// Projectile integration follows this compose. Manager-less
// ordinary bodies use LiveEntityOrdinaryPhysicsUpdater so their
// complete Frame travels through Transition/SetPositionInternal.
}
entity.SetPosition(position);
entity.Rotation = orientation;
}
private static bool IsCurrent(
LiveEntityRuntime runtime,
LiveEntityRecord record,
WorldEntity entity,
AnimatedEntity? animation,
RemoteMotion? remote,
ProjectileController.Runtime? projectile,
ulong objectClockEpoch) =>
runtime.IsCurrentSpatialRootObject(record)
&& record.ObjectClockEpoch == objectClockEpoch
&& ReferenceEquals(record.WorldEntity, entity)
&& ReferenceEquals(record.AnimationRuntime, animation)
&& ReferenceEquals(record.RemoteMotionRuntime, remote)
&& ReferenceEquals(record.ProjectileRuntime, projectile)
&& (animation is null || runtime.IsCurrentSpatialAnimation(record, animation));
private static void RunManagerTail(
RemoteMotion? remote,
MotionTableManager? partArray)
{
if (remote is not null)
{
RetailObjectManagerTail.Run(
remote.Host?.TargetManager,
remote.Movement,
partArray,
remote.Host?.PositionManager);
}
else
{
partArray?.UseTime();
}
}
}
internal readonly record struct LiveEntityAnimationSchedule(
IReadOnlyList<PartTransform>? SequenceFrames,
float LegacyAdvanceSeconds,
bool ComposeParts);

View file

@ -0,0 +1,432 @@
using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering;
/// <summary>
/// Ports retail's separate <c>CPhysics::static_animating_objects</c> workset
/// (<c>CPhysics::UseTime</c> 0x00509950 and
/// <c>CPhysicsObj::animate_static_object</c> 0x00513DF0). Membership is
/// Setup/state-driven: any physics-Static object with Setup.DefaultAnimation
/// enters, whether DAT-hydrated or server-created, and leaves with its logical
/// resource lifetime. Setup default installation itself is unconditional and
/// belongs to PartArray construction; this class owns only the Static workset.
/// </summary>
internal sealed class RetailStaticAnimatingObjectScheduler
{
private const double FrameEpsilon = 0.000199999995;
private const float MaximumElapsed = 2f;
private sealed class Owner
{
public required WorldEntity Entity;
public required Setup Setup;
public required AnimationSequencer? Sequencer;
public required uint[] PartGfxIds;
public required IReadOnlyDictionary<uint, uint>?[] SurfaceOverrides;
public required bool[] PartAvailable;
public PhysicsBody? Body;
public AnimationSequencer? PendingProcessHooks;
public ulong PendingResidencyVersion;
public IReadOnlyList<PartTransform>? PreparedLivePartFrames;
public double ElapsedSinceUpdate;
public readonly Frame RootFrameScratch = new();
public readonly List<MeshRef> MeshRefs = new();
public readonly List<Matrix4x4> PartPoses = new();
}
private readonly IAnimationLoader _animationLoader;
private readonly Action<uint, AnimationSequencer> _captureHooks;
private readonly Action<WorldEntity, IReadOnlyList<Matrix4x4>, IReadOnlyList<bool>>
_publishPartPoses;
private readonly Func<WorldEntity, bool> _isResident;
private readonly Action<WorldEntity, PhysicsBody> _commitLiveRoot;
private readonly Func<WorldEntity, ulong> _residencyVersion;
private readonly Dictionary<uint, Owner> _owners = new();
private readonly List<Owner> _snapshot = new();
private readonly List<Owner> _hookSnapshot = new();
public RetailStaticAnimatingObjectScheduler(
IAnimationLoader animationLoader,
Action<uint, AnimationSequencer> captureHooks,
Action<WorldEntity, IReadOnlyList<Matrix4x4>, IReadOnlyList<bool>> publishPartPoses,
Func<WorldEntity, bool>? isResident = null,
Action<WorldEntity, PhysicsBody>? commitLiveRoot = null,
Func<WorldEntity, ulong>? residencyVersion = null)
{
_animationLoader = animationLoader
?? throw new ArgumentNullException(nameof(animationLoader));
_captureHooks = captureHooks
?? throw new ArgumentNullException(nameof(captureHooks));
_publishPartPoses = publishPartPoses
?? throw new ArgumentNullException(nameof(publishPartPoses));
_isResident = isResident ?? (_ => true);
_commitLiveRoot = commitLiveRoot ?? ((_, _) => { });
_residencyVersion = residencyVersion ?? (_ => 0UL);
}
internal int Count => _owners.Count;
public void Register(WorldEntity entity, ScriptActivationInfo info)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(info);
if (!info.UsesStaticAnimationWorkset
|| info.Setup is not { } setup
|| info.DefaultAnimationId == 0)
{
return;
}
if (_owners.TryGetValue(entity.Id, out Owner? retained))
{
if (ReferenceEquals(retained.Entity, entity))
return;
throw new InvalidOperationException(
$"DAT-static animation owner 0x{entity.Id:X8} is already registered.");
}
// A live CPhysicsObj already owns its canonical PartArray through
// AnimatedEntity. Resource registration occurs before that App owner
// is constructed, so retain a pending workset member and bind the
// exact sequencer later. DAT-only statics have no live owner and may
// construct their PartArray here.
AnimationSequencer? sequencer = null;
if (entity.ServerGuid == 0)
{
sequencer = new AnimationSequencer(
setup,
new MotionTable(),
_animationLoader);
if (!sequencer.HasCurrentNode)
return;
}
int partCount = setup.Parts.Count;
uint[] gfxIds = BuildPartGfxIds(setup, entity);
bool[] available = new bool[partCount];
if (info.PartAvailability is { } supplied)
{
for (int i = 0; i < partCount && i < supplied.Count; i++)
available[i] = supplied[i];
}
var surfaces = MatchSurfaceOverrides(entity, gfxIds, available);
_owners.Add(entity.Id, new Owner
{
Entity = entity,
Setup = setup,
Sequencer = sequencer,
PartGfxIds = gfxIds,
SurfaceOverrides = surfaces,
PartAvailable = available,
});
}
/// <summary>
/// Completes a live Physics-Static registration with the same sequencer
/// and body owned by the canonical live entity.
/// Retail's static workset stores a <c>CPhysicsObj*</c>; it never clones a
/// second PartArray for this alternate scheduling path.
/// </summary>
public bool BindLiveOwner(
WorldEntity entity,
AnimationSequencer sequencer,
PhysicsBody body)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(sequencer);
ArgumentNullException.ThrowIfNull(body);
if (!_owners.TryGetValue(entity.Id, out Owner? owner))
return false;
if (!ReferenceEquals(owner.Entity, entity) || entity.ServerGuid == 0)
{
throw new InvalidOperationException(
$"Static animation owner 0x{entity.Id:X8} does not match this live incarnation.");
}
if (!sequencer.HasCurrentNode)
return false;
owner.Sequencer = sequencer;
owner.Body = body;
return true;
}
/// <summary>
/// Transfers the current live PartArray result to GameWindow's canonical
/// appearance composer. The scheduler owns timing; AnimatedEntity remains
/// the single owner of mesh identity, overrides, and appearance rebinding.
/// </summary>
public bool TryTakeLivePartFrames(
uint ownerId,
out IReadOnlyList<PartTransform> frames)
{
if (_owners.TryGetValue(ownerId, out Owner? owner)
&& owner.Entity.ServerGuid != 0
&& owner.PreparedLivePartFrames is { } prepared
&& IsResidentAtVersion(owner, owner.PendingResidencyVersion))
{
owner.PreparedLivePartFrames = null;
frames = prepared;
return true;
}
if (_owners.TryGetValue(ownerId, out owner))
InvalidatePending(owner);
frames = Array.Empty<PartTransform>();
return false;
}
public void Unregister(uint ownerId) => _owners.Remove(ownerId);
public void CopyAnimatedEntityIdsTo(HashSet<uint> destination)
{
ArgumentNullException.ThrowIfNull(destination);
foreach ((uint ownerId, Owner owner) in _owners)
{
if (owner.Sequencer is not null)
destination.Add(ownerId);
}
}
public void Tick(float elapsedSeconds)
{
if (!float.IsFinite(elapsedSeconds)
|| elapsedSeconds <= 0f
|| _owners.Count == 0)
{
return;
}
_snapshot.Clear();
_snapshot.AddRange(_owners.Values);
foreach (Owner owner in _snapshot)
{
if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current)
|| !ReferenceEquals(current, owner)
|| owner.Sequencer is not { } sequencer)
{
continue;
}
owner.ElapsedSinceUpdate += elapsedSeconds;
if (!_isResident(owner.Entity))
{
InvalidatePending(owner);
// animate_static_object returns before touching update_time
// while cell == null. A short leave-world interval therefore
// catches up on re-entry; a long one is discarded by the
// retail two-second guard below.
continue;
}
ulong residencyVersion = _residencyVersion(owner.Entity);
double ownerElapsed = owner.ElapsedSinceUpdate;
owner.ElapsedSinceUpdate = 0d;
if (ownerElapsed <= FrameEpsilon
|| ownerElapsed > MaximumElapsed)
{
continue;
}
IReadOnlyList<PartTransform> frames = sequencer.Advance(
(float)ownerElapsed);
if (owner.Body is { } body)
{
// CPhysicsObj::animate_static_object 0x00513DF0 passes the
// stored omega vector directly to Frame::grotate after the
// PartArray update. Unlike UpdatePhysicsInternal, this odd
// static branch does not multiply omega by elapsed time.
Quaternion previousOrientation = body.Orientation;
owner.RootFrameScratch.Origin = body.Position;
owner.RootFrameScratch.Orientation = previousOrientation;
FrameOps.GRotate(owner.RootFrameScratch, body.Omega);
body.SetFrameInCurrentCell(
body.Position,
owner.RootFrameScratch.Orientation);
owner.Entity.Rotation = body.Orientation;
// animate_static_object rotates the root before
// UpdatePartsInternal/UpdateChildrenInternal. Commit every
// root consumer (collision shadows and effect anchors) at
// that same boundary; a rendered-only rotation leaves the
// canonical CPhysicsObj split across two orientations.
// The common case is a zero omega. Do not turn that no-op
// into a per-frame ShadowObjectRegistry reflood; only a real
// root change has canonical consumers to commit.
if (body.Orientation != previousOrientation)
{
_commitLiveRoot(owner.Entity, body);
if (!_owners.TryGetValue(owner.Entity.Id, out current)
|| !ReferenceEquals(current, owner)
|| !IsResidentAtVersion(owner, residencyVersion))
{
InvalidatePending(owner);
continue;
}
}
}
if (!IsResidentAtVersion(owner, residencyVersion))
{
InvalidatePending(owner);
continue;
}
if (owner.Entity.ServerGuid != 0)
{
owner.PreparedLivePartFrames = frames;
}
else
{
Compose(owner, frames);
_publishPartPoses(owner.Entity, owner.PartPoses, owner.PartAvailable);
if (!_owners.TryGetValue(owner.Entity.Id, out current)
|| !ReferenceEquals(current, owner)
|| !IsResidentAtVersion(owner, residencyVersion))
{
InvalidatePending(owner);
continue;
}
}
// animate_static_object runs process_hooks only after the root,
// parts, and attached children have their final transforms. Live
// part/child publication occurs outside this owner, so retain the
// exact PartArray and let GameWindow call ProcessHooks at that
// later boundary instead of completing AnimationDone here.
owner.PendingProcessHooks = sequencer;
owner.PendingResidencyVersion = residencyVersion;
}
}
/// <summary>
/// Runs the static workset's retail <c>process_hooks</c> tail after final
/// root, part, and child pose publication. Presentation hooks remain in
/// the shared frame queue until its normal drain.
/// </summary>
public void ProcessHooks()
{
_hookSnapshot.Clear();
foreach (Owner owner in _owners.Values)
{
if (owner.PendingProcessHooks is not null)
_hookSnapshot.Add(owner);
}
foreach (Owner owner in _hookSnapshot)
{
if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current)
|| !ReferenceEquals(current, owner)
|| owner.PendingProcessHooks is not { } sequencer
|| !ReferenceEquals(owner.Sequencer, sequencer)
|| !IsResidentAtVersion(owner, owner.PendingResidencyVersion))
{
InvalidatePending(owner);
continue;
}
// Clear before the callback: hook delivery may unregister or
// replace the owner, and a nested caller must not replay this tail.
owner.PendingProcessHooks = null;
_captureHooks(owner.Entity.Id, sequencer);
}
}
private bool IsResidentAtVersion(Owner owner, ulong version) =>
_isResident(owner.Entity)
&& _residencyVersion(owner.Entity) == version;
private static void InvalidatePending(Owner owner)
{
owner.PreparedLivePartFrames = null;
owner.PendingProcessHooks = null;
owner.PendingResidencyVersion = 0UL;
}
private static void Compose(Owner owner, IReadOnlyList<PartTransform> frames)
{
owner.MeshRefs.Clear();
owner.PartPoses.Clear();
Matrix4x4 objectScale = owner.Entity.Scale == 1f
? Matrix4x4.Identity
: Matrix4x4.CreateScale(owner.Entity.Scale);
int partCount = owner.Setup.Parts.Count;
for (int i = 0; i < partCount; i++)
{
Vector3 origin = i < frames.Count ? frames[i].Origin : Vector3.Zero;
Quaternion orientation = i < frames.Count
? frames[i].Orientation
: Quaternion.Identity;
Vector3 defaultScale = i < owner.Setup.DefaultScale.Count
? owner.Setup.DefaultScale[i]
: Vector3.One;
Matrix4x4 visual =
Matrix4x4.CreateScale(defaultScale)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(origin);
if (owner.Entity.Scale != 1f)
visual *= objectScale;
owner.PartPoses.Add(
Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(origin * owner.Entity.Scale));
if (!owner.PartAvailable[i])
continue;
owner.MeshRefs.Add(new MeshRef(owner.PartGfxIds[i], visual)
{
SurfaceOverrides = owner.SurfaceOverrides[i],
});
}
owner.Entity.MeshRefs = owner.MeshRefs;
owner.Entity.SetIndexedPartPoses(owner.PartPoses, owner.PartAvailable);
}
private static uint[] BuildPartGfxIds(Setup setup, WorldEntity entity)
{
var result = new uint[setup.Parts.Count];
for (int i = 0; i < result.Length; i++)
result[i] = (uint)setup.Parts[i];
foreach (PartOverride replacement in entity.PartOverrides)
{
if (replacement.PartIndex < result.Length)
result[replacement.PartIndex] = replacement.GfxObjId;
}
return result;
}
private static IReadOnlyDictionary<uint, uint>?[] MatchSurfaceOverrides(
WorldEntity entity,
uint[] gfxIds,
bool[] available)
{
var result = new IReadOnlyDictionary<uint, uint>?[gfxIds.Length];
var consumed = new bool[entity.MeshRefs.Count];
for (int partIndex = 0; partIndex < gfxIds.Length; partIndex++)
{
for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++)
{
if (consumed[meshIndex]
|| entity.MeshRefs[meshIndex].GfxObjId != gfxIds[partIndex])
{
continue;
}
consumed[meshIndex] = true;
available[partIndex] = true;
result[partIndex] = entity.MeshRefs[meshIndex].SurfaceOverrides;
break;
}
}
return result;
}
}

View file

@ -0,0 +1,90 @@
using AcDream.App.Physics;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Commits a changed root produced by retail's
/// <c>CPhysicsObj::animate_static_object</c> to the retained effect pose and
/// collision-shadow projections of the same live object incarnation.
/// </summary>
internal sealed class StaticLiveRootCommitter
{
private readonly Func<LiveEntityRuntime?> _runtime;
private readonly ShadowObjectRegistry _shadows;
private readonly Func<(int X, int Y)> _liveCenter;
private readonly Action<WorldEntity> _updateEffectRoot;
public StaticLiveRootCommitter(
Func<LiveEntityRuntime?> runtime,
ShadowObjectRegistry shadows,
Func<(int X, int Y)> liveCenter,
Action<WorldEntity> updateEffectRoot)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_liveCenter = liveCenter
?? throw new ArgumentNullException(nameof(liveCenter));
_updateEffectRoot = updateEffectRoot
?? throw new ArgumentNullException(nameof(updateEffectRoot));
}
public bool Commit(WorldEntity entity, PhysicsBody body)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(body);
LiveEntityRuntime? runtime = _runtime();
if (runtime is null
|| !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord record)
|| !ReferenceEquals(record.WorldEntity, entity)
|| !ReferenceEquals(record.PhysicsBody, body))
{
return false;
}
// The retained pose owner follows the CPhysicsObj root even while its
// mesh is hidden. Hidden is presentation state, not logical teardown.
_updateEffectRoot(entity);
// EffectPoseChanged observers run synchronously and may delete this
// incarnation (or replace the GUID) before returning. Do not let the
// stale root restore collision shadows for an owner which no longer
// exists. This is the same callback-boundary lifetime rule used by the
// live schedulers and movement controllers.
if (!ReferenceEquals(_runtime(), runtime)
|| !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, record)
|| !ReferenceEquals(current.WorldEntity, entity)
|| !ReferenceEquals(current.PhysicsBody, body))
{
return false;
}
// CObjCell::hide_object removes shadows. Never let a later static
// animation tick re-register collision for a hidden, withdrawn, or
// pending projection; UnHide/re-entry owns restoration.
if (record.ProjectionKind is not LiveEntityProjectionKind.World
|| !record.IsSpatiallyProjected
|| !record.IsSpatiallyVisible
|| runtime.IsHidden(entity.ServerGuid))
{
return true;
}
uint cellId = body.CellPosition.ObjCellId != 0
? body.CellPosition.ObjCellId
: record.FullCellId;
(int centerX, int centerY) = _liveCenter();
ShadowPositionSynchronizer.Sync(
_shadows,
entity.Id,
body.Position,
body.Orientation,
cellId,
centerX,
centerY);
return true;
}
}

View file

@ -20,6 +20,7 @@ public sealed class AnimationHookFrameQueue
{
private readonly AnimationHookRouter _router;
private readonly IEntityEffectPoseSource _poses;
private readonly IEntityEffectPoseLifetimeSource? _poseLifetimes;
private readonly List<Entry> _entries = new();
public AnimationHookFrameQueue(
@ -28,6 +29,7 @@ public sealed class AnimationHookFrameQueue
{
_router = router ?? throw new ArgumentNullException(nameof(router));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_poseLifetimes = poses as IEntityEffectPoseLifetimeSource;
}
public int Count => _entries.Count;
@ -45,6 +47,12 @@ public sealed class AnimationHookFrameQueue
{
ArgumentNullException.ThrowIfNull(sequencer);
ArgumentNullException.ThrowIfNull(hooks);
// Capture incarnation identity before AnimationDone: its semantic
// callback can synchronously delete this owner and reuse the local ID.
// Reading the version afterwards would mislabel the old hook batch as
// belonging to the replacement.
ulong ownerLifetimeVersion =
_poseLifetimes?.GetPoseOwnerLifetimeVersion(ownerLocalId) ?? 0UL;
// Retail CPhysicsObj::process_hooks (0x00511550) executes AnimDone
// inside UpdatePositionInternal, before the transition and every
@ -55,12 +63,25 @@ public sealed class AnimationHookFrameQueue
// authored hook stream after the final part poses are published.
for (int i = 0; i < hooks.Count; i++)
{
// MotionDone can synchronously delete this owner and reuse the
// local ID. Do not advance the displaced sequencer for later
// AnimDone hooks from the old catch-up batch.
if (_poseLifetimes is not null
&& _poseLifetimes.GetPoseOwnerLifetimeVersion(
ownerLocalId) != ownerLifetimeVersion)
{
break;
}
if (hooks[i] is AnimationDoneHook)
sequencer.Manager.AnimationDone(success: true);
}
if (hooks.Count == 0)
return;
_entries.Add(new Entry(
ownerLocalId,
ownerLifetimeVersion,
hooks));
}
@ -69,19 +90,35 @@ public sealed class AnimationHookFrameQueue
for (int i = 0; i < _entries.Count; i++)
{
Entry entry = _entries[i];
bool hasRootPose = _poses.TryGetRootPose(
entry.OwnerLocalId,
out Matrix4x4 rootWorld);
Vector3 worldPosition = hasRootPose
? rootWorld.Translation
: Vector3.Zero;
if (_poseLifetimes is not null
&& _poseLifetimes.GetPoseOwnerLifetimeVersion(
entry.OwnerLocalId) != entry.OwnerLifetimeVersion)
{
continue;
}
for (int hi = 0; hi < entry.Hooks.Count; hi++)
{
// A prior hook sink can tear down and replace this local ID.
// Revalidate per hook so the remainder of the old PES/animation
// batch can never spill into the replacement incarnation.
if (_poseLifetimes is not null
&& _poseLifetimes.GetPoseOwnerLifetimeVersion(
entry.OwnerLocalId) != entry.OwnerLifetimeVersion)
{
break;
}
AnimationHook? hook = entry.Hooks[hi];
if (hook is null)
continue;
if (hasRootPose)
_router.OnHook(entry.OwnerLocalId, worldPosition, hook);
if (_poses.TryGetRootPose(
entry.OwnerLocalId,
out Matrix4x4 rootWorld))
{
_router.OnHook(
entry.OwnerLocalId,
rootWorld.Translation,
hook);
}
}
}
_entries.Clear();
@ -91,5 +128,6 @@ public sealed class AnimationHookFrameQueue
private readonly record struct Entry(
uint OwnerLocalId,
ulong OwnerLifetimeVersion,
IReadOnlyList<AnimationHook> Hooks);
}

View file

@ -18,7 +18,8 @@ namespace AcDream.App.Rendering.Vfx;
public sealed class EntityEffectPoseRegistry :
IEntityEffectPoseSource,
IEntityEffectCellSource,
IEntityEffectPoseChangeSource
IEntityEffectPoseChangeSource,
IEntityEffectPoseLifetimeSource
{
private sealed class PoseRecord
{
@ -26,9 +27,11 @@ public sealed class EntityEffectPoseRegistry :
public Matrix4x4[] PartLocal = Array.Empty<Matrix4x4>();
public bool[] PartAvailable = Array.Empty<bool>();
public uint CellId;
public ulong LifetimeVersion;
}
private readonly Dictionary<uint, PoseRecord> _poses = new();
private ulong _nextLifetimeVersion;
public event Action<uint>? EffectPoseChanged;
@ -68,7 +71,7 @@ public sealed class EntityEffectPoseRegistry :
bool changed;
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
{
record = new PoseRecord();
record = new PoseRecord { LifetimeVersion = NextLifetimeVersion() };
_poses.Add(entity.Id, record);
changed = true;
}
@ -126,7 +129,7 @@ public sealed class EntityEffectPoseRegistry :
bool changed;
if (!_poses.TryGetValue(localEntityId, out PoseRecord? record))
{
record = new PoseRecord();
record = new PoseRecord { LifetimeVersion = NextLifetimeVersion() };
_poses.Add(localEntityId, record);
changed = true;
}
@ -176,7 +179,9 @@ public sealed class EntityEffectPoseRegistry :
uint[] removedOwners = _poses.Keys.ToArray();
_poses.Clear();
foreach (uint owner in removedOwners)
{
EffectPoseChanged?.Invoke(owner);
}
}
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
@ -248,6 +253,19 @@ public sealed class EntityEffectPoseRegistry :
return false;
}
public ulong GetPoseOwnerLifetimeVersion(uint localEntityId) =>
_poses.TryGetValue(localEntityId, out PoseRecord? record)
? record.LifetimeVersion
: 0UL;
private ulong NextLifetimeVersion()
{
_nextLifetimeVersion++;
if (_nextLifetimeVersion == 0UL)
_nextLifetimeVersion++;
return _nextLifetimeVersion;
}
private static bool CopyParts(
PoseRecord record,
IReadOnlyList<Matrix4x4> partLocal,
@ -277,3 +295,8 @@ public sealed class EntityEffectPoseRegistry :
return changed;
}
}
public interface IEntityEffectPoseLifetimeSource
{
ulong GetPoseOwnerLifetimeVersion(uint localEntityId);
}

View file

@ -12,7 +12,10 @@ public sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms,
EntityEffectProfile? EffectProfile = null,
IReadOnlyList<bool>? PartAvailability = null);
IReadOnlyList<bool>? PartAvailability = null,
DatReaderWriter.DBObjs.Setup? Setup = null,
uint DefaultAnimationId = 0,
bool UsesStaticAnimationWorkset = false);
/// <summary>
/// Owns create-time Setup script activation and its symmetric teardown for
@ -25,13 +28,29 @@ public sealed record ScriptActivationInfo(
/// </remarks>
public sealed class EntityScriptActivator
{
private sealed class StaticOwnerState
{
public required WorldEntity Entity;
public required ScriptActivationInfo Info;
public bool PosePublished;
public bool EffectOwnerRegistered;
public bool AnimationOwnerRegistered;
public bool ScriptStarted;
public bool ReleaseStarted;
public bool ScriptsStopped;
public bool EmittersStopped;
public bool PoseRemoved;
}
private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink;
private readonly EntityEffectPoseRegistry _poses;
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner;
private readonly Action<uint>? _unregisterDatStaticEffectOwner;
private readonly Dictionary<uint, WorldEntity> _activeStaticOwners = new();
private readonly Action<WorldEntity, ScriptActivationInfo>? _registerDatStaticAnimationOwner;
private readonly Action<uint>? _unregisterDatStaticAnimationOwner;
private readonly Dictionary<uint, StaticOwnerState> _staticOwners = new();
public EntityScriptActivator(
PhysicsScriptRunner scriptRunner,
@ -39,7 +58,9 @@ public sealed class EntityScriptActivator
EntityEffectPoseRegistry poses,
Func<WorldEntity, ScriptActivationInfo?> resolver,
Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null,
Action<uint>? unregisterDatStaticEffectOwner = null)
Action<uint>? unregisterDatStaticEffectOwner = null,
Action<WorldEntity, ScriptActivationInfo>? registerDatStaticAnimationOwner = null,
Action<uint>? unregisterDatStaticAnimationOwner = null)
{
_scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner));
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
@ -47,6 +68,8 @@ public sealed class EntityScriptActivator
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_registerDatStaticEffectOwner = registerDatStaticEffectOwner;
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
_registerDatStaticAnimationOwner = registerDatStaticAnimationOwner;
_unregisterDatStaticAnimationOwner = unregisterDatStaticAnimationOwner;
}
public void OnCreate(WorldEntity entity)
@ -57,33 +80,93 @@ public sealed class EntityScriptActivator
return;
if (entity.ServerGuid == 0
&& _activeStaticOwners.TryGetValue(key, out WorldEntity? existing))
&& _staticOwners.TryGetValue(key, out StaticOwnerState? retained))
{
if (ReferenceEquals(existing, entity))
return;
throw new InvalidOperationException(
$"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique.");
if (!ReferenceEquals(retained.Entity, entity))
{
throw new InvalidOperationException(
$"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique.");
}
if (retained.ReleaseStarted)
{
throw new InvalidOperationException(
$"Dat-static WorldEntity id 0x{key:X8} cannot reactivate while teardown is pending.");
}
ActivateStaticOwner(retained);
return;
}
ScriptActivationInfo? info = _resolver(entity);
if (info is null)
return;
// Particle::Init (0x0051C930) samples the current root/part frames.
// Publish before the Setup default script can execute; effects never
// fall back to the camera or a cached spawn anchor.
_poses.Publish(entity, info.PartTransforms, info.PartAvailability);
if (entity.ServerGuid == 0)
_activeStaticOwners.Add(key, entity);
if (entity.ServerGuid == 0 && info.EffectProfile is { } profile)
_registerDatStaticEffectOwner?.Invoke(key, entity, profile);
{
var state = new StaticOwnerState
{
Entity = entity,
Info = info,
};
_staticOwners.Add(key, state);
ActivateStaticOwner(state);
return;
}
// Live registration is an atomic logical-lifetime edge owned by
// LiveEntityRuntime and is never retried at this activator in isolation.
_poses.Publish(entity, info.PartTransforms, info.PartAvailability);
if (info.UsesStaticAnimationWorkset
&& info.DefaultAnimationId != 0)
{
_registerDatStaticAnimationOwner?.Invoke(entity, info);
}
if (info.ScriptId != 0)
_scriptRunner.Play(info.ScriptId, key, entity.Position);
}
private void ActivateStaticOwner(StaticOwnerState state)
{
WorldEntity entity = state.Entity;
ScriptActivationInfo info = state.Info;
uint key = entity.Id;
// Particle::Init (0x0051C930) samples the current root/part frames.
// Commit each acquisition only after its callback returns. A later
// failure can then retry without replaying completed create-time work.
if (!state.PosePublished)
{
_poses.Publish(entity, info.PartTransforms, info.PartAvailability);
state.PosePublished = true;
}
if (!state.EffectOwnerRegistered
&& info.EffectProfile is { } profile
&& _registerDatStaticEffectOwner is not null)
{
_registerDatStaticEffectOwner(key, entity, profile);
state.EffectOwnerRegistered = true;
}
// TS-51: script-only statics still use the shared script runner. The
// dedicated static animation scheduler owns only real DefaultAnimation
// PartArray work, so script-only owners incur no empty frame scan.
if (!state.AnimationOwnerRegistered
&& info.UsesStaticAnimationWorkset
&& info.DefaultAnimationId != 0
&& _registerDatStaticAnimationOwner is not null)
{
_registerDatStaticAnimationOwner(entity, info);
state.AnimationOwnerRegistered = true;
}
if (!state.ScriptStarted && info.ScriptId != 0)
{
_scriptRunner.Play(info.ScriptId, key, entity.Position);
state.ScriptStarted = true;
}
}
public void OnRemove(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
@ -91,17 +174,48 @@ public sealed class EntityScriptActivator
if (key == 0)
return;
if (entity.ServerGuid == 0
&& (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing)
|| !ReferenceEquals(existing, entity)))
if (entity.ServerGuid == 0)
{
if (!_staticOwners.TryGetValue(key, out StaticOwnerState? state)
|| !ReferenceEquals(state.Entity, entity))
{
return;
}
state.ReleaseStarted = true;
if (!state.ScriptsStopped)
{
_scriptRunner.StopAllForEntity(key);
state.ScriptsStopped = true;
}
if (!state.EmittersStopped)
{
_particleSink.StopAllForEntity(key, fadeOut: false);
state.EmittersStopped = true;
}
if (!state.PoseRemoved)
{
_poses.Remove(key);
state.PoseRemoved = true;
}
if (state.AnimationOwnerRegistered)
{
_unregisterDatStaticAnimationOwner?.Invoke(key);
state.AnimationOwnerRegistered = false;
}
if (state.EffectOwnerRegistered)
{
_unregisterDatStaticEffectOwner?.Invoke(key);
state.EffectOwnerRegistered = false;
}
_staticOwners.Remove(key);
return;
}
_unregisterDatStaticAnimationOwner?.Invoke(key);
_scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(key, fadeOut: false);
_poses.Remove(key);
if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key))
_unregisterDatStaticEffectOwner?.Invoke(key);
}
}

View file

@ -31,6 +31,7 @@ public sealed class LiveEntityPresentationController : IDisposable
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
private readonly HashSet<LiveEntityRecord> _drainingRecords = new();
private bool _disposed;
public LiveEntityPresentationController(
@ -69,8 +70,20 @@ public sealed class LiveEntityPresentationController : IDisposable
}
_readyGenerationByGuid[serverGuid] = record.Generation;
ApplyPendingTransitions(record);
return true;
if (!ApplyPendingTransitions(record)
|| record.WorldEntity is not { } entity
|| !IsCurrent(record, entity))
{
return false;
}
// An object can materialize into a pending landblock before collision
// registration and before this ready barrier opens. Its initial false
// visibility edge therefore had nothing to suspend. Reconcile here,
// after every create-time owner exists, so the retained registration
// cannot remain active offscreen and hydration has a restore marker.
SuspendOrdinaryShadowOutsideProjection(record, entity);
return IsCurrent(record, entity);
}
/// <summary>Drains a newly accepted SetState when this incarnation is ready.</summary>
@ -83,8 +96,7 @@ public sealed class LiveEntityPresentationController : IDisposable
return false;
}
ApplyPendingTransitions(record);
return true;
return ApplyPendingTransitions(record);
}
/// <summary>
@ -188,6 +200,7 @@ public sealed class LiveEntityPresentationController : IDisposable
{
_activePlacementGenerationByGuid.Remove(record.ServerGuid);
}
_drainingRecords.Remove(record);
}
public void Clear()
@ -195,6 +208,7 @@ public sealed class LiveEntityPresentationController : IDisposable
_readyGenerationByGuid.Clear();
_suspendedShadowGenerationByGuid.Clear();
_activePlacementGenerationByGuid.Clear();
_drainingRecords.Clear();
}
public void Dispose()
@ -206,46 +220,83 @@ public sealed class LiveEntityPresentationController : IDisposable
Clear();
}
private void ApplyPendingTransitions(LiveEntityRecord record)
private bool ApplyPendingTransitions(LiveEntityRecord record)
{
while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition))
// State callbacks can synchronously accept another SetState. Retail's
// update thread completes the current transition before the next FIFO
// entry; a recursive drain would interleave Visible halfway through
// Hidden and then let the older side effects win last.
if (!_drainingRecords.Add(record))
return IsCurrent(record, record.WorldEntity);
try
{
if (record.WorldEntity is not { } entity)
return;
// set_state writes the final state before any PartArray/cell side
// effect. Retained shadow registrations must see those same bits
// even while their cell rows are suspended.
_shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState);
switch (transition.HiddenTransition)
while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition))
{
case RetailHiddenTransition.BecameHidden:
_playTyped(entity.Id, HiddenScriptType, 1f);
_setDirectChildrenNoDraw(record.ServerGuid, true);
_shadows.Suspend(entity.Id);
if (!IsPlacementActive(record))
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
// Retail CPhysicsObj::set_hidden @ 0x00514C60 calls
// CPartArray::HandleEnterWorld after hiding the object
// from its cell. Despite the name, this is the motion
// timeline boundary: it strips link animations and
// aborts every pending completion through
// MotionTableManager::HandleEnterWorld @ 0x0051BDD0.
_handlePartArrayEnterWorld(entity.Id);
_clearInvalidTarget(record.ServerGuid);
break;
if (record.WorldEntity is not { } entity
|| !IsCurrent(record, entity))
{
return false;
}
case RetailHiddenTransition.BecameVisible:
_playTyped(entity.Id, UnHideScriptType, 1f);
_setDirectChildrenNoDraw(record.ServerGuid, false);
// Retail invokes the same PartArray boundary before
// CObjCell::unhide_object restores cell visibility.
_handlePartArrayEnterWorld(entity.Id);
if (!IsPlacementActive(record) && RestoreShadow(record, entity))
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
break;
// set_state writes the final state before any PartArray/cell side
// effect. Retained shadow registrations must see those same bits
// even while their cell rows are suspended.
_shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState);
switch (transition.HiddenTransition)
{
case RetailHiddenTransition.BecameHidden:
_playTyped(entity.Id, HiddenScriptType, 1f);
if (!IsCurrent(record, entity))
return false;
_setDirectChildrenNoDraw(record.ServerGuid, true);
if (!IsCurrent(record, entity))
return false;
_shadows.Suspend(entity.Id);
if (!IsPlacementActive(record))
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
// Retail CPhysicsObj::set_hidden @ 0x00514C60 calls
// CPartArray::HandleEnterWorld after hiding the object
// from its cell. Despite the name, this is the motion
// timeline boundary: it strips link animations and
// aborts every pending completion through
// MotionTableManager::HandleEnterWorld @ 0x0051BDD0.
_handlePartArrayEnterWorld(entity.Id);
if (!IsCurrent(record, entity))
return false;
_clearInvalidTarget(record.ServerGuid);
if (!IsCurrent(record, entity))
return false;
break;
case RetailHiddenTransition.BecameVisible:
_playTyped(entity.Id, UnHideScriptType, 1f);
if (!IsCurrent(record, entity))
return false;
_setDirectChildrenNoDraw(record.ServerGuid, false);
if (!IsCurrent(record, entity))
return false;
// Retail invokes the same PartArray boundary before
// CObjCell::unhide_object restores cell visibility.
_handlePartArrayEnterWorld(entity.Id);
if (!IsCurrent(record, entity))
return false;
bool restored = !IsPlacementActive(record)
&& RestoreShadow(record, entity);
if (!IsCurrent(record, entity))
return false;
if (restored)
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
break;
}
}
return IsCurrent(record, record.WorldEntity);
}
finally
{
_drainingRecords.Remove(record);
}
}
@ -273,9 +324,25 @@ public sealed class LiveEntityPresentationController : IDisposable
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (!visible
|| record.WorldEntity is not { } entity
|| (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
if (record.WorldEntity is not { } entity
|| !IsCurrent(record, entity))
{
return;
}
// exit_world removes an ordinary object's collision rows on the same
// projection edge that suspends its object clock. Keep the retained
// registration so a stationary object can be restored immediately on
// hydration; it may have no later movement quantum to repair itself.
// Projectiles and active authoritative placements own their matching
// suspend/restore transaction in their dedicated controllers.
if (!visible)
{
SuspendOrdinaryShadowOutsideProjection(record, entity);
return;
}
if ((record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|| IsPlacementActive(record)
|| !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration)
|| readyGeneration != record.Generation
@ -287,10 +354,40 @@ public sealed class LiveEntityPresentationController : IDisposable
return;
}
if (RestoreShadow(record, entity))
bool restored = RestoreShadow(record, entity);
if (!IsCurrent(record, entity))
return;
if (restored)
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
}
private void SuspendOrdinaryShadowOutsideProjection(
LiveEntityRecord record,
AcDream.Core.World.WorldEntity entity)
{
if (record.IsSpatiallyVisible
|| record.ProjectileRuntime is not null
|| IsPlacementActive(record)
|| !_readyGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort readyGeneration)
|| readyGeneration != record.Generation
|| !_shadows.Suspend(entity.Id))
{
return;
}
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
}
private bool IsCurrent(
LiveEntityRecord record,
AcDream.Core.World.WorldEntity? entity) =>
entity is not null
&& _liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
&& ReferenceEquals(current, record)
&& ReferenceEquals(current.WorldEntity, entity);
private bool IsPlacementActive(LiveEntityRecord record) =>
_activePlacementGenerationByGuid.TryGetValue(
record.ServerGuid,

View file

@ -48,6 +48,7 @@ public interface ILiveEntityRemotePlacementRuntime :
Vector3 LastServerPosition { get; set; }
double LastServerPositionTime { get; set; }
Vector3 LastShadowSyncPosition { get; set; }
Quaternion LastShadowSyncOrientation { get; set; }
void HitGround();
void LeaveGround();
}
@ -111,6 +112,13 @@ public sealed class LiveEntityRecord
ServerGuid = snapshot.Guid;
Snapshot = snapshot;
RefreshDerivedState();
PositionAuthorityVersion = snapshot.Position is null ? 0UL : 1UL;
VectorAuthorityVersion = snapshot.Physics is null ? 0UL : 1UL;
VelocityAuthorityVersion = snapshot.Position is null
&& snapshot.Physics is null
? 0UL
: 1UL;
MovementAuthorityVersion = 1UL;
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
ApplyRawPhysicsState(RawPhysicsState);
}
@ -124,7 +132,29 @@ public sealed class LiveEntityRecord
public uint CanonicalLandblockId { get; internal set; }
public uint RawPhysicsState { get; internal set; }
public PhysicsStateFlags FinalPhysicsState { get; internal set; }
/// <summary>
/// Incarnation-stable retail <c>CPhysicsObj::update_time</c> owner. It
/// survives animation and MovementManager attachment/replacement, spatial
/// rebucketing, and appearance rehydration, and dies with this record.
/// </summary>
public RetailObjectQuantumClock ObjectClock { get; } = new();
/// <summary>
/// Changes whenever this record leaves or re-enters the ordinary-object
/// workset. Unlike <see cref="ProjectionMutationVersion"/>, loaded-to-loaded
/// rebucketing does not change this token. A scheduler can therefore stop a
/// catch-up batch that crossed a leave/enter boundary without rejecting an
/// ordinary same-frame cell move.
/// </summary>
internal ulong ObjectClockEpoch { get; private set; }
/// <summary>
/// True after this incarnation has successfully constructed its retail
/// <c>CPartArray</c>. This is an object-lifetime fact, not an animation-
/// runtime proxy: static poses and objects without a MotionTable still
/// own a PartArray and participate in the 96-unit Active gate.
/// </summary>
public bool HasPartArray { get; internal set; }
public PhysicsBody? PhysicsBody { get; internal set; }
internal bool PhysicsBodyAcquisitionInProgress { get; set; }
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
internal bool RequiresRemotePlacementRuntime { get; set; }
@ -134,6 +164,16 @@ public sealed class LiveEntityRecord
public bool IsSpatiallyProjected { get; internal set; }
public bool IsSpatiallyVisible { get; internal set; }
internal ulong ProjectionMutationVersion { get; set; }
/// <summary>
/// Monotonic App operation tokens for accepted wire authority. They let a
/// handler detect a newer same-incarnation packet delivered re-entrantly
/// while an older packet is crossing presentation or spatial callbacks.
/// </summary>
internal ulong PositionAuthorityVersion { get; private set; }
internal ulong StateAuthorityVersion { get; private set; }
internal ulong VectorAuthorityVersion { get; private set; }
internal ulong VelocityAuthorityVersion { get; private set; }
internal ulong MovementAuthorityVersion { get; private set; }
public bool WorldSpawnPublished { get; internal set; }
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
internal bool RuntimeComponentsTeardownCompleted { get; set; }
@ -145,8 +185,21 @@ public sealed class LiveEntityRecord
internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) =>
_pendingStateTransitions.TryDequeue(out transition);
internal void SuspendObjectClock()
{
ObjectClock.Deactivate();
ObjectClockEpoch++;
}
internal void ResetObjectClockForEnterWorld(bool isStatic)
{
ObjectClock.ResetForEnterWorld(isStatic);
ObjectClockEpoch++;
}
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState)
{
StateAuthorityVersion++;
RawPhysicsState = rawState;
RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply(
FinalPhysicsState,
@ -158,6 +211,33 @@ public sealed class LiveEntityRecord
return transition;
}
internal void AdvancePositionAuthority()
{
PositionAuthorityVersion++;
VelocityAuthorityVersion++;
}
internal void AdvanceVectorAuthority()
{
VectorAuthorityVersion++;
VelocityAuthorityVersion++;
}
internal void AdvanceMovementAuthority()
{
MovementAuthorityVersion++;
VelocityAuthorityVersion++;
}
internal void AdvanceCreateAuthority()
{
PositionAuthorityVersion++;
StateAuthorityVersion++;
VectorAuthorityVersion++;
VelocityAuthorityVersion++;
MovementAuthorityVersion++;
}
internal void SetChildNoDraw(bool noDraw)
{
FinalPhysicsState = noDraw
@ -241,6 +321,10 @@ public sealed class LiveEntityRuntime
private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _spatialAnimationsByLocalId = new();
private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _spatialRemoteMotionByGuid = new();
private readonly Dictionary<uint, ILiveEntityProjectileRuntime> _spatialProjectilesByGuid = new();
// Retail CPhysics::UseTime walks the ordinary object table, not a render-
// animation component table. This index is the loaded/cell-backed root
// workset; component-specific indexes remain lookup accelerators only.
private readonly Dictionary<uint, LiveEntityRecord> _spatialRootObjectsByGuid = new();
private bool _isClearing;
private bool _sessionClearPendingFinalization;
private bool _isRegisteringResources;
@ -297,6 +381,8 @@ public sealed class LiveEntityRuntime
_spatialRemoteMotionByGuid;
internal IReadOnlyDictionary<uint, ILiveEntityProjectileRuntime> SpatialProjectileRuntimes =>
_spatialProjectilesByGuid;
internal IReadOnlyDictionary<uint, LiveEntityRecord> SpatialRootObjects =>
_spatialRootObjectsByGuid;
public ParentAttachmentState ParentAttachments { get; } = new();
/// <summary>
@ -327,6 +413,7 @@ public sealed class LiveEntityRuntime
{
retained.Snapshot = result.Snapshot;
retained.RefreshDerivedState();
retained.AdvanceCreateAuthority();
RefreshSpatialRuntimeIndexes(retained);
return new LiveEntityRegistrationResult(result, retained, false, false);
}
@ -510,8 +597,18 @@ public sealed class LiveEntityRuntime
record.WorldEntity!.IsAncestorDrawVisible = true;
}
RefreshPresentation(record);
RebucketLiveEntity(serverGuid, fullCellId);
return record.WorldEntity;
WorldEntity materialized = record.WorldEntity!;
if (!RebucketLiveEntity(serverGuid, fullCellId)
|| !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current)
|| !ReferenceEquals(current, record)
|| !ReferenceEquals(current.WorldEntity, materialized))
{
// Visibility observers may replace this GUID while rebucketing.
// A failed teardown can retain the displaced entity as a retry
// tombstone; it is not the result of this materialization.
return null;
}
return materialized;
}
/// <summary>Changes spatial buckets only. No logical resource callbacks run.</summary>
@ -523,6 +620,10 @@ public sealed class LiveEntityRuntime
bool wasProjected = record.IsSpatiallyProjected;
bool wasVisible = record.IsSpatiallyVisible;
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
serverGuid,
out LiveEntityRecord? indexedRoot)
&& ReferenceEquals(indexedRoot, record);
ulong projectionOperation = ++record.ProjectionMutationVersion;
// GpuWorldState reports an intermediate false/true pair while moving
// between two loaded buckets. Suppress those implementation details
@ -570,6 +671,38 @@ public sealed class LiveEntityRuntime
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
? 0u
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
&& record.IsSpatiallyProjected
&& record.IsSpatiallyVisible
&& record.FullCellId != 0;
// prepare_to_enter_world (0x00511FA0) writes update_time = cur_time.
// Only a root-workset membership edge rebases the clock. Ordinary
// loaded-to-loaded movement preserves it; parented/attached objects
// take retail update_object's parent early-out and remain suspended.
if (!wasProjected && !isOrdinaryRoot)
{
record.SuspendObjectClock();
SynchronizePhysicsBodyActiveState(record);
}
else if (wasOrdinaryRoot != isOrdinaryRoot)
{
if (isOrdinaryRoot)
{
// InitObjectBegin and every leave-visibility edge are inactive
// before retail prepare_to_enter_world. Preserve that
// prerequisite for a first materialization as well.
if (!wasProjected)
record.ObjectClock.Deactivate();
record.ResetObjectClockForEnterWorld(
(record.FinalPhysicsState & PhysicsStateFlags.Static) != 0);
SynchronizePhysicsBodyActiveState(record);
}
else
{
record.SuspendObjectClock();
SynchronizePhysicsBodyActiveState(record);
}
}
RefreshSpatialRuntimeIndexes(record);
Exception? runtimeNotificationFailure = null;
if (!wasProjected || wasVisible != visible)
@ -583,6 +716,18 @@ public sealed class LiveEntityRuntime
runtimeNotificationFailure = error;
}
}
// Final observers are intentionally re-entrant. A callback can delete
// and recreate this GUID or start a newer rebucket on the same record;
// the outer caller must not continue with captures from that displaced
// projection operation.
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
{
ThrowAfterCommittedProjectionChange(
serverGuid,
spatialNotificationFailure,
runtimeNotificationFailure);
return false;
}
ThrowAfterCommittedProjectionChange(
serverGuid,
spatialNotificationFailure,
@ -600,6 +745,11 @@ public sealed class LiveEntityRuntime
|| record.WorldEntity is null)
return false;
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
serverGuid,
out LiveEntityRecord? rootRecord)
&& ReferenceEquals(rootRecord, record);
ulong objectClockEpoch = record.ObjectClockEpoch;
ulong projectionOperation = ++record.ProjectionMutationVersion;
Exception? spatialNotificationFailure = null;
try
@ -631,6 +781,14 @@ public sealed class LiveEntityRuntime
_visibleWorldEntitiesByGuid.Remove(serverGuid);
record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false;
// GpuWorldState normally publishes the leave edge synchronously and
// OnSpatialVisibilityChanged suspends this exact clock there. During a
// reentrant notification the edge is deferred, so commit the missing
// suspension here. One root-workset leave is one epoch edge -- never
// advance it once in the observer and again in this owner.
if (wasOrdinaryRoot && record.ObjectClockEpoch == objectClockEpoch)
record.SuspendObjectClock();
SynchronizePhysicsBodyActiveState(record);
RefreshSpatialRuntimeIndexes(record);
Exception? runtimeNotificationFailure = null;
if (spatialEdgeWasDeferred)
@ -644,6 +802,18 @@ public sealed class LiveEntityRuntime
runtimeNotificationFailure = error;
}
}
// The manually published deferred edge is an arbitrary callback
// boundary just like Rebucket's final visibility edge. It may
// reproject this record or replace the GUID; the displaced outer
// withdrawal must not report itself as the current operation.
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
{
ThrowAfterCommittedProjectionChange(
serverGuid,
spatialNotificationFailure,
runtimeNotificationFailure);
return false;
}
ThrowAfterCommittedProjectionChange(
serverGuid,
spatialNotificationFailure,
@ -845,11 +1015,70 @@ public sealed class LiveEntityRuntime
return true;
}
/// <summary>
/// Acquires the one retail <c>CPhysicsObj</c> body for this exact live
/// incarnation. Static animation may need the body before a MovementManager
/// or projectile component exists; later components must adopt this same
/// instance instead of replaying CreateObject vector initialization.
/// </summary>
internal PhysicsBody GetOrCreatePhysicsBody(
uint serverGuid,
Func<LiveEntityRecord, PhysicsBody> factory)
{
ArgumentNullException.ThrowIfNull(factory);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is null)
{
throw new InvalidOperationException(
$"Cannot acquire physics body before live entity 0x{serverGuid:X8} is materialized.");
}
if (record.PhysicsBody is { } retained)
return retained;
if (record.PhysicsBodyAcquisitionInProgress)
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} physics-body acquisition is already in progress.");
}
record.PhysicsBodyAcquisitionInProgress = true;
try
{
PhysicsBody candidate = factory(record)
?? throw new InvalidOperationException("Physics-body factory returned null.");
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current)
|| !ReferenceEquals(current, record)
|| current.WorldEntity is null)
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} changed incarnation during physics-body acquisition.");
}
if (record.PhysicsBody is { } concurrentlyBound)
{
if (ReferenceEquals(concurrentlyBound, candidate))
return concurrentlyBound;
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} acquired two physics bodies within one incarnation.");
}
record.PhysicsBody = candidate;
candidate.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
return candidate;
}
finally
{
record.PhysicsBodyAcquisitionInProgress = false;
}
}
public void SetRemoteMotionRuntime(uint serverGuid, ILiveEntityRemoteMotionRuntime runtime)
{
ArgumentNullException.ThrowIfNull(runtime);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot bind remote motion during physics-body acquisition.");
PhysicsBody candidateBody = runtime.Body;
if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody))
@ -868,6 +1097,7 @@ public sealed class LiveEntityRuntime
record.RemoteMotionRuntime = runtime;
record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
{
// Capture the incarnation, not only its GUID. A callback retained
@ -919,6 +1149,9 @@ public sealed class LiveEntityRuntime
throw new InvalidOperationException(
$"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized.");
}
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot bind projectile physics during physics-body acquisition.");
PhysicsBody candidateBody = runtime.Body;
if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody))
@ -930,6 +1163,7 @@ public sealed class LiveEntityRuntime
record.ProjectileRuntime = runtime;
record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
RefreshSpatialRuntimeIndexes(record);
}
@ -999,6 +1233,8 @@ public sealed class LiveEntityRuntime
if (applied)
{
RefreshRecord(update.Guid, accepted);
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
ClearWorldCell(update.Guid);
ParentAttachments.EndChildProjection(update.Guid);
}
@ -1011,6 +1247,8 @@ public sealed class LiveEntityRuntime
if (applied)
{
RefreshRecord(update.ChildGuid, accepted);
if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
ClearWorldCell(update.ChildGuid);
}
return applied;
@ -1022,6 +1260,8 @@ public sealed class LiveEntityRuntime
if (applied)
{
RefreshRecord(update.ChildGuid, accepted);
if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
ClearWorldCell(update.ChildGuid);
}
return applied;
@ -1036,13 +1276,24 @@ public sealed class LiveEntityRuntime
bool applied = _inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps);
if (_inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
RefreshRecord(update.Guid, snapshot);
if (applied
&& retainPayload
&& _recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
{
record.AdvanceMovementAuthority();
}
return applied;
}
public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted)
{
bool applied = _inbound.TryApplyVector(update, out accepted);
if (applied) RefreshRecord(update.Guid, accepted);
if (applied)
{
RefreshRecord(update.Guid, accepted);
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
record.AdvanceVectorAuthority();
}
return applied;
}
@ -1117,6 +1368,13 @@ public sealed class LiveEntityRuntime
RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition);
if (acceptedPosition)
{
if (_recordsByGuid.TryGetValue(
update.Guid,
out LiveEntityRecord? acceptedRecord)
&& ReferenceEquals(acceptedRecord, beforePosition))
{
acceptedRecord.AdvancePositionAuthority();
}
ParentAttachments.EndChildProjection(update.Guid);
}
}
@ -1136,12 +1394,231 @@ public sealed class LiveEntityRuntime
/// script/effect owners but clears the cell and withdraws the root
/// projection until a fresh Position re-enters it.
/// </summary>
public RetailObjectClockDisposition GetRootObjectClockDisposition(
uint serverGuid)
{
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.ProjectionKind is not LiveEntityProjectionKind.World
|| !record.IsSpatiallyProjected
|| !record.IsSpatiallyVisible
|| record.FullCellId == 0)
{
return RetailObjectClockDisposition.Suspend;
}
return (record.FinalPhysicsState & PhysicsStateFlags.Frozen) != 0
? RetailObjectClockDisposition.Suspend
: RetailObjectClockDisposition.Advance;
}
public bool ShouldAdvanceRootRuntime(uint serverGuid) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.IsSpatiallyProjected
&& record.IsSpatiallyVisible
&& record.FullCellId != 0
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
GetRootObjectClockDisposition(serverGuid)
is RetailObjectClockDisposition.Advance;
internal bool IsCurrentPositionAuthority(
LiveEntityRecord record,
ulong authorityVersion) =>
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
&& ReferenceEquals(current, record)
&& current.PositionAuthorityVersion == authorityVersion;
internal bool IsCurrentStateAuthority(
LiveEntityRecord record,
ulong authorityVersion) =>
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
&& ReferenceEquals(current, record)
&& current.StateAuthorityVersion == authorityVersion;
internal bool IsCurrentVectorAuthority(
LiveEntityRecord record,
ulong authorityVersion) =>
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
&& ReferenceEquals(current, record)
&& current.VectorAuthorityVersion == authorityVersion;
internal bool IsCurrentVelocityAuthority(
LiveEntityRecord record,
ulong authorityVersion) =>
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
&& ReferenceEquals(current, record)
&& current.VelocityAuthorityVersion == authorityVersion;
internal bool IsCurrentMovementAuthority(
LiveEntityRecord record,
ulong authorityVersion) =>
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
&& ReferenceEquals(current, record)
&& current.MovementAuthorityVersion == authorityVersion;
/// <summary>
/// Copies the canonical loaded root-object workset used by retail
/// <c>CPhysics::UseTime</c>. The record reference is the incarnation token;
/// callers must revalidate it after callbacks that can delete or rebucket.
/// </summary>
internal void CopySpatialRootObjectRecordsTo(List<LiveEntityRecord> destination)
{
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach ((uint serverGuid, LiveEntityRecord indexed) in _spatialRootObjectsByGuid)
{
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current)
&& ReferenceEquals(current, indexed)
&& HasSpatialRuntimeProjection(current))
{
destination.Add(current);
}
}
}
internal bool IsCurrentSpatialRootObject(LiveEntityRecord record) =>
IsCurrentRecord(record)
&& _spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexed)
&& ReferenceEquals(indexed, record)
&& HasSpatialRuntimeProjection(record);
internal bool IsCurrentSpatialAnimation(
LiveEntityRecord record,
ILiveEntityAnimationRuntime animation) =>
IsCurrentSpatialRootObject(record)
&& ReferenceEquals(record.AnimationRuntime, animation)
&& record.WorldEntity is { } entity
&& _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexed)
&& ReferenceEquals(indexed, animation);
/// <summary>
/// Copies the currently spatial animation owners through the concrete
/// dictionary enumerator. This keeps per-frame traversal allocation-free;
/// exposing the workset as <see cref="IReadOnlyDictionary{TKey,TValue}"/>
/// would box its enumerator at a hot call site.
/// </summary>
internal void CopySpatialAnimationRuntimesTo<TAnimation>(
List<KeyValuePair<uint, TAnimation>> destination)
where TAnimation : class, ILiveEntityAnimationRuntime
{
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach ((uint localId, ILiveEntityAnimationRuntime animation)
in _spatialAnimationsByLocalId)
{
if (animation is TAnimation typed)
{
destination.Add(
new KeyValuePair<uint, TAnimation>(localId, typed));
}
}
}
internal void CopySpatialAnimationLocalIdsTo(HashSet<uint> destination)
{
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach (uint localId in _spatialAnimationsByLocalId.Keys)
destination.Add(localId);
}
/// <summary>
/// Host side of retail <c>CPhysicsObj::set_active(1)</c> for a movement
/// manager owned by this exact incarnation. Static is a required no-op.
/// </summary>
internal bool TryActivateOrdinaryObject(
uint serverGuid,
ILiveEntityRemoteMotionRuntime runtime)
{
ArgumentNullException.ThrowIfNull(runtime);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| !ReferenceEquals(record.RemoteMotionRuntime, runtime)
|| (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0)
{
return false;
}
record.ObjectClock.Activate();
runtime.Body.TransientState |= TransientStateFlags.Active;
return true;
}
/// <summary>
/// Commits retail <c>CPhysicsObj::set_velocity</c> (0x005113F0) to the
/// canonical body for this exact incarnation. The body and the retained
/// object clock are one CPhysicsObj: a non-Static inactive-to-active edge
/// rebases both together, while Static preserves its prior Active state.
/// </summary>
internal bool TryCommitAuthoritativeVelocity(
LiveEntityRecord record,
PhysicsBody body,
Vector3 velocity,
double currentTime) =>
TryCommitAuthoritativeVectorCore(
record,
body,
velocity,
angularVelocity: null,
currentTime);
/// <summary>
/// Commits the paired retail VectorUpdate writes
/// (<c>set_velocity</c>, then <c>set_omega</c>) to one canonical body.
/// Validation is atomic: malformed wire vectors cannot partially mutate
/// the live object.
/// </summary>
internal bool TryCommitAuthoritativeVector(
LiveEntityRecord record,
PhysicsBody body,
Vector3 velocity,
Vector3 angularVelocity,
double currentTime) =>
TryCommitAuthoritativeVectorCore(
record,
body,
velocity,
angularVelocity,
currentTime);
private bool TryCommitAuthoritativeVectorCore(
LiveEntityRecord record,
PhysicsBody body,
Vector3 velocity,
Vector3? angularVelocity,
double currentTime)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(body);
if (!IsFinite(velocity)
|| (angularVelocity is { } omega && !IsFinite(omega))
|| !double.IsFinite(currentTime)
|| !_recordsByGuid.TryGetValue(
record.ServerGuid,
out LiveEntityRecord? current)
|| !ReferenceEquals(current, record)
|| !ReferenceEquals(current.PhysicsBody, body))
{
return false;
}
bool wasBodyActive =
(body.TransientState & TransientStateFlags.Active) != 0;
body.set_velocity(velocity);
if ((current.FinalPhysicsState & PhysicsStateFlags.Static) != 0)
{
// PhysicsBody models set_velocity's leaf write and therefore sets
// Active unconditionally. Retail CPhysicsObj::set_active(1) is a
// no-op for Static, so restore the exact prior bit here.
if (!wasBodyActive)
body.TransientState &= ~TransientStateFlags.Active;
}
else
{
bool clockReactivated = current.ObjectClock.Activate();
// PhysicsBody.LastUpdateTime remains only the compatibility clock
// for older absolute-time helpers; the record clock is canonical.
if (clockReactivated || !wasBodyActive)
body.LastUpdateTime = currentTime;
}
if (angularVelocity is { } acceptedOmega)
body.Omega = acceptedOmega;
return true;
}
/// <summary>
/// Copies the currently spatial remote-motion owners into caller-owned
@ -1393,6 +1870,19 @@ public sealed class LiveEntityRuntime
{
if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record))
return;
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
guid,
out LiveEntityRecord? indexedRoot)
&& ReferenceEquals(indexedRoot, record);
// Pickup/Parent is retail's leave-world edge. Suspend the canonical
// object clock while the record is still indexed as a root; clearing
// FullCellId first removes that evidence and makes the later projection
// withdrawal look like a duplicate non-root edge.
if (wasOrdinaryRoot)
{
record.SuspendObjectClock();
SynchronizePhysicsBodyActiveState(record);
}
record.FullCellId = 0;
record.CanonicalLandblockId = 0;
RefreshSpatialRuntimeIndexes(record);
@ -1404,6 +1894,7 @@ public sealed class LiveEntityRuntime
private static bool HasSpatialRuntimeProjection(LiveEntityRecord record) =>
record.WorldEntity is not null
&& record.ProjectionKind is LiveEntityProjectionKind.World
&& record.IsSpatiallyProjected
&& record.IsSpatiallyVisible
&& record.FullCellId != 0;
@ -1413,6 +1904,17 @@ public sealed class LiveEntityRuntime
bool current = IsCurrentRecord(record);
bool spatial = current && HasSpatialRuntimeProjection(record);
if (spatial)
{
_spatialRootObjectsByGuid[record.ServerGuid] = record;
}
else if (current
|| (_spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexedRoot)
&& ReferenceEquals(indexedRoot, record)))
{
_spatialRootObjectsByGuid.Remove(record.ServerGuid);
}
if (record.WorldEntity is { } entity)
{
if (spatial && record.AnimationRuntime is { } animation)
@ -1455,6 +1957,12 @@ public sealed class LiveEntityRuntime
private void RemoveSpatialRuntimeIndexes(LiveEntityRecord record)
{
if (_spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexedRoot)
&& ReferenceEquals(indexedRoot, record))
{
_spatialRootObjectsByGuid.Remove(record.ServerGuid);
}
if (record.WorldEntity is { } entity
&& _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexedAnimation)
&& ReferenceEquals(indexedAnimation, record.AnimationRuntime))
@ -1506,13 +2014,46 @@ public sealed class LiveEntityRuntime
return;
bool wasVisible = record.IsSpatiallyVisible;
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
serverGuid,
out LiveEntityRecord? indexedRoot)
&& ReferenceEquals(indexedRoot, record);
record.IsSpatiallyVisible = visible;
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
&& record.IsSpatiallyProjected
&& visible
&& record.FullCellId != 0;
if (_rebucketingGuid != serverGuid && wasOrdinaryRoot != isOrdinaryRoot)
{
if (isOrdinaryRoot)
record.ResetObjectClockForEnterWorld(
(record.FinalPhysicsState & PhysicsStateFlags.Static) != 0);
else
record.SuspendObjectClock();
SynchronizePhysicsBodyActiveState(record);
}
RefreshSpatialRuntimeIndexes(record);
RefreshPresentation(record);
if (_rebucketingGuid != serverGuid && wasVisible != visible)
PublishProjectionVisibilityChanged(record, visible);
}
private static void SynchronizePhysicsBodyActiveState(
LiveEntityRecord record)
{
if (record.PhysicsBody is not { } body)
return;
if (record.ObjectClock.IsActive)
body.TransientState |= TransientStateFlags.Active;
else
body.TransientState &= ~TransientStateFlags.Active;
}
private static bool IsFinite(Vector3 value) =>
float.IsFinite(value.X)
&& float.IsFinite(value.Y)
&& float.IsFinite(value.Z);
private void PublishProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
Delegate[] subscribers = ProjectionVisibilityChanged?.GetInvocationList()
@ -1617,6 +2158,7 @@ public sealed class LiveEntityRuntime
_spatialAnimationsByLocalId.Clear();
_spatialRemoteMotionByGuid.Clear();
_spatialProjectilesByGuid.Clear();
_spatialRootObjectsByGuid.Clear();
ParentAttachments.Clear();
_inbound.Clear();
_spatial.ClearLiveEntityLifetimeState();

View file

@ -52,21 +52,25 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
&& runtime.ClearAnimationRuntime(guid);
}
/// <summary>Copies only currently resident local IDs without boxing a
/// dictionary-key enumerator.</summary>
public void CopySpatialIdsTo(HashSet<uint> destination)
{
ArgumentNullException.ThrowIfNull(destination);
LiveEntityRuntime? runtime = _runtime();
if (runtime is null)
destination.Clear();
else
runtime.CopySpatialAnimationLocalIdsTo(destination);
}
public Enumerator GetEnumerator()
{
_iterationSnapshot.Clear();
LiveEntityRuntime? runtime = _runtime();
if (runtime is not null)
{
foreach (var pair in runtime.SpatialAnimationRuntimes)
{
if (pair.Value is TAnimation typed)
{
_iterationSnapshot.Add(
new KeyValuePair<uint, TAnimation>(pair.Key, typed));
}
}
}
if (runtime is null)
_iterationSnapshot.Clear();
else
runtime.CopySpatialAnimationRuntimesTo(_iterationSnapshot);
return new Enumerator(runtime, _iterationSnapshot);
}

View file

@ -0,0 +1,123 @@
namespace AcDream.App.World;
/// <summary>
/// Serializes retail network/event mutations on the update thread.
/// </summary>
/// <remarks>
/// Retail SmartBox dispatch and CPhysics::UseTime never interleave two packet
/// bodies on one CPhysicsObj call stack. App callbacks are synchronous, so an
/// observer can otherwise re-enter a session event while an older packet or
/// object quantum is still unwinding. Nested work is retained in arrival
/// order and drained only after the current operation reaches its complete
/// retail tail. This preserves the protocol's independent timestamp channels:
/// an accepted State never cancels an accepted Position merely because their
/// callbacks touched the same object.
/// </remarks>
internal sealed class RetailInboundEventDispatcher
{
private interface IPendingOperation
{
void Invoke();
}
private sealed class PendingAction(Action operation) : IPendingOperation
{
public void Invoke() => operation();
}
private sealed class PendingStateOperation<TReceiver, TState>(
TReceiver receiver,
TState state,
Action<TReceiver, TState> operation) : IPendingOperation
{
public void Invoke() => operation(receiver, state);
}
private readonly Queue<IPendingOperation> _pending = new();
private bool _isDraining;
internal int PendingCount => _pending.Count;
internal bool IsDraining => _isDraining;
internal void Run(Action operation)
{
ArgumentNullException.ThrowIfNull(operation);
if (_isDraining)
{
_pending.Enqueue(new PendingAction(operation));
return;
}
_isDraining = true;
try
{
operation();
DrainPending();
}
catch
{
// A failed packet cannot leave later packets stranded for an
// unrelated future frame, or replay them after partial teardown.
_pending.Clear();
throw;
}
finally
{
_isDraining = false;
}
}
/// <summary>
/// Allocation-free non-reentrant dispatch for hot frame and packet paths.
/// A heterogeneous wrapper is created only when an operation genuinely
/// re-enters the active retail FIFO and therefore must outlive this call.
/// </summary>
internal void Run<TReceiver, TState>(
TReceiver receiver,
TState state,
Action<TReceiver, TState> operation)
{
ArgumentNullException.ThrowIfNull(operation);
if (_isDraining)
{
_pending.Enqueue(
new PendingStateOperation<TReceiver, TState>(
receiver,
state,
operation));
return;
}
_isDraining = true;
try
{
operation(receiver, state);
DrainPending();
}
catch
{
_pending.Clear();
throw;
}
finally
{
_isDraining = false;
}
}
private void DrainPending()
{
while (_pending.TryDequeue(out IPendingOperation? next))
next.Invoke();
}
internal void Clear()
{
if (_isDraining)
{
throw new InvalidOperationException(
"Inbound event work cannot be cleared while its retail FIFO is active.");
}
_pending.Clear();
}
}