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

View file

@ -142,15 +142,16 @@ public sealed class AnimationSequencer
/// <para>
/// Crucially this is **not** per-node: while a link animation plays, the
/// surfaced velocity is still the cycle's velocity (the cycle was added
/// last, so SetVelocity's latest call wins). Remote entity dead-reckoning
/// reads this to integrate position without gapping during stance
/// transitions.
/// last, so SetVelocity's latest call wins). <c>CSequence::apply_physics</c>
/// contributes it directly to the complete root Frame; no separate body-
/// velocity reconstruction is performed from this accessor.
/// </para>
/// </summary>
public Vector3 CurrentVelocity => _core.Velocity;
/// <summary>
/// Sequence-wide omega, matching <see cref="CurrentVelocity"/>'s semantics.
/// Sequence-wide omega. <c>CSequence::apply_physics</c> rotates the same
/// complete root Frame that carries PosFrames and sequence velocity.
/// </summary>
public Vector3 CurrentOmega => _core.Omega;
@ -283,6 +284,12 @@ public sealed class AnimationSequencer
_table = new CMotionTable(motionTable);
_state = new MotionState();
_manager = new MotionTableManager(_table, _state, _core, new ForwardingMotionDoneSink(this));
// CPartArray::InitDefaults (0x00518980) runs for every setup-backed
// PartArray, before CPhysicsObj::InitDefaults installs its motion
// table. Static is only a later workset-membership decision; it does
// not gate installation of Setup.DefaultAnimation itself.
InitializeSetupDefaultAnimation((uint)setup.DefaultAnimation);
}
/// <summary>
@ -399,38 +406,9 @@ public sealed class AnimationSequencer
if (dispatchResult != MotionTableManagerError.Success)
return;
// ── Synthesize CurrentOmega for turn cycles ───────────────────────
// Humanoid turn MotionData often ships without HasOmega. Until the
// remaining R6 rotation path consumes CSequence's complete Frame
// orientation, the remote ObservedOmega seam needs the retail
// turn-rate fallback. Decompile references:
// FUN_00529210 apply_current_movement (writes Omega)
// chunk_00520000.c TurnRate globals (~π/2 rad/s for speed=1)
// The ACE port uses `omega.z = ±(π/2) × turnSpeed` for right/left
// turns (holtburger confirms the same via motion_resolution.rs).
if (_core.Omega.LengthSquared() < 1e-9f)
{
float zomega = 0f;
uint low = motion & 0xFFu;
switch (low)
{
case 0x0D: // TurnRight — clockwise from above = -Z in right-handed.
zomega = -(MathF.PI / 2f) * adjustedSpeed;
break;
case 0x0E: // TurnLeft — counter-clockwise = +Z.
// adjust_motion above ALREADY remapped 0x0E → 0x0D
// with adjustedSpeed = -speedMod, so the same
// formula as 0x0D applied to the negated speed
// produces the correct +Z (CCW) result. Using a
// different sign here would double-negate and
// animate a left turn as a right turn — that was
// the bug observed before this fix (commit follows).
zomega = -(MathF.PI / 2f) * adjustedSpeed;
break;
}
if (zomega != 0f)
_core.SetOmega(new Vector3(0f, 0f, zomega));
}
// Rotation stays DAT-authored. Retail MotionData::add_motion
// (0x005224B0) contributes the entry's literal omega to CSequence;
// CSequence::apply_physics emits it through the complete Frame.
}
/// <summary>
@ -453,6 +431,36 @@ public sealed class AnimationSequencer
/// </summary>
public void InitializeState() => EnsureInitialized();
/// <summary>
/// Retail <c>CPartArray::InitDefaults</c> (0x00518980): a Setup
/// <c>DefaultAnimation</c> bypasses the MotionTable, clears the sequence,
/// and appends one direct animation over frames <c>0..-1</c> at 30 fps.
/// Installation is unconditional for every setup-backed PartArray.
/// <c>CPhysicsObj::InitDefaults</c> separately decides whether a Static
/// owner enters <c>CPhysics::static_animating_objects</c>; ordinary motion
/// table initialization may subsequently replace this direct sequence.
/// </summary>
public bool InitializeSetupDefaultAnimation(uint animationId)
{
if (animationId == 0)
return false;
// CPartArray::InitDefaults (0x00518980) calls only
// CSequence::clear_animations (0x00524DC0). Sequence velocity,
// omega, and placement state belong to the surrounding PartArray
// lifetime and survive replacement of the animation list.
_core.ClearAnimations();
_pendingHooks.Clear();
_core.AppendAnimation(new AnimData
{
AnimId = (QualifiedDataId<Animation>)animationId,
LowFrame = 0,
HighFrame = -1,
Framerate = 30f,
});
return _core.CurrAnim is not null;
}
/// <summary>
/// R2-Q5: the single dispatch entry — lazy initialize_state, then
/// <see cref="MotionTableManager.PerformMovement"/>. The resulting

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Physics;
@ -15,15 +16,9 @@ namespace AcDream.Core.Physics;
// InterpolationManager::NodeCompleted acclient @ 0x005559A0
// InterpolationManager::StopInterpolating acclient @ 0x00555950
//
// FIFO position-waypoint queue (cap 20). Each physics tick the caller passes
// current body position + max-speed from the motion table; we return the
// world-space delta vector to apply to the body for this frame.
//
// Public C# API kept Vector3-based for compatibility with PositionManager and
// GameWindow callsites; retail-spec method names are documented inline. The
// retail Frame mutation pattern collapses to "return a Vector3 delta" because
// adjust_offset's offset Frame is rotation-zero (translation-only) for this
// queue's purposes — see audit 04-interp-manager.md § 4.
// FIFO Position-waypoint queue (cap 20). The compatibility overload returns
// only its world-space origin, while the production overload carries retail's
// complete relative Frame from Position::subtract2, including orientation.
//
// Bug fixes applied vs prior port (audit § 7):
// #1: progress_quantum accumulates dt (not step magnitude).
@ -38,8 +33,7 @@ namespace AcDream.Core.Physics;
internal sealed class InterpolationNode
{
public Vector3 TargetPosition;
public float Heading;
public bool IsHeadingValid;
public Quaternion TargetOrientation = Quaternion.Identity;
}
/// <summary>
@ -120,6 +114,7 @@ public sealed class InterpolationManager
private float _progressQuantum = 0f; // progress_quantum (sum of dt)
private float _originalDistance = OriginalDistanceSentinel; // original_distance
private int _failCount = 0; // node_fail_counter
private bool _keepHeading; // keep_heading
// ── public API ────────────────────────────────────────────────────────────
@ -167,11 +162,31 @@ public sealed class InterpolationManager
/// Pass <c>null</c> if not available — far/near classification falls back
/// to "near" (no pre-armed blip).
/// </param>
public void Enqueue(
public Quaternion? Enqueue(
Vector3 targetPosition,
float heading,
bool isMovingTo,
Vector3? currentBodyPosition = null)
=> Enqueue(
targetPosition,
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, heading),
isMovingTo,
currentBodyPosition,
currentBodyOrientation: null);
/// <summary>
/// Complete-frame overload of retail <c>InterpolateTo</c>. The node keeps
/// the target quaternion; a near enqueue assigns
/// <paramref name="isMovingTo"/> to retail's manager-wide
/// <c>keep_heading</c> flag. The far branch deliberately retains the
/// manager's prior flag, matching the retail early return.
/// </summary>
public Quaternion? Enqueue(
Vector3 targetPosition,
Quaternion targetOrientation,
bool isMovingTo,
Vector3? currentBodyPosition = null,
Quaternion? currentBodyOrientation = null)
{
// Retail compares dist against either the tail's stored position
// (if tail exists AND tail->type == 1) or the body's m_position.
@ -195,10 +210,17 @@ public sealed class InterpolationManager
// Far branch (retail line 352918, dist > GetAutonomyBlipDistance):
if (dist > AutonomyBlipDistance)
{
EnqueueRaw(targetPosition, heading, isMovingTo);
// The far branch does not assign keep_heading from arg3. It uses
// the manager's existing flag when storing this Position.
EnqueueRaw(
targetPosition,
StoreTargetOrientation(
targetOrientation,
currentBodyOrientation,
_keepHeading));
// Pre-arm immediate blip on next AdjustOffset (audit § 7 #3).
_failCount = StallFailCountThreshold + 1;
return;
return null;
}
// Near & already-close branch (retail line 352962):
@ -209,7 +231,14 @@ public sealed class InterpolationManager
if (bodyDist <= DesiredDistance)
{
Clear();
return;
// InterpolateTo 0x00555C08 calls CPhysicsObj::set_heading
// with the target Frame's heading. It does not install the
// target's pitch/roll at this already-close seam.
return isMovingTo
? null
: MoveToMath.SetHeading(
targetOrientation,
MoveToMath.GetHeading(targetOrientation));
}
}
@ -227,19 +256,43 @@ public sealed class InterpolationManager
_queue.RemoveFirst();
// 3. Append.
EnqueueRaw(targetPosition, heading, isMovingTo);
_keepHeading = isMovingTo;
EnqueueRaw(
targetPosition,
StoreTargetOrientation(
targetOrientation,
currentBodyOrientation,
_keepHeading));
return null;
}
private void EnqueueRaw(Vector3 target, float heading, bool isMovingTo)
private void EnqueueRaw(
Vector3 target,
Quaternion targetOrientation)
{
_queue.AddLast(new InterpolationNode
{
TargetPosition = target,
Heading = heading,
IsHeadingValid = isMovingTo,
TargetOrientation = targetOrientation,
});
}
private static Quaternion StoreTargetOrientation(
Quaternion targetOrientation,
Quaternion? currentBodyOrientation,
bool keepHeading)
{
if (!keepHeading || currentBodyOrientation is not { } current)
return targetOrientation;
// InterpolateTo stores the object's current heading into the node
// when keep_heading is active. Frame::set_heading intentionally
// discards the target Position's pitch/roll at this seam.
return MoveToMath.SetHeading(
targetOrientation,
MoveToMath.GetHeading(current));
}
/// <summary>
/// Compute the per-frame world-space correction delta. Combines the retail
/// <c>UseTime</c> blip-check (fail_count &gt; 3 → snap to tail, clear queue)
@ -259,14 +312,74 @@ public sealed class InterpolationManager
/// Max motion-table speed for this entity's current cycle (m/s).
/// Pass 0 to use the <see cref="MaxInterpolatedVelocity"/> fallback.
/// </param>
public Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp)
public Vector3 AdjustOffset(
double dt,
Vector3 currentBodyPosition,
float maxSpeedFromMinterp,
bool inContact = true)
{
if (!inContact)
return Vector3.Zero;
InterpolationStep step = ComputeStep(
dt,
currentBodyPosition,
maxSpeedFromMinterp);
return step.Overwrites ? step.WorldOrigin : Vector3.Zero;
}
/// <summary>
/// Retail <c>InterpolationManager::adjust_offset</c> complete-Frame path
/// (0x00555D30). When interpolation is active it replaces both components
/// of <paramref name="offset"/> with <c>Position::subtract2</c>'s relative
/// target frame, then scales only Origin to the catch-up step. A MoveTo
/// node keeps heading by replacing the relative rotation with identity.
/// When the queue is empty or a node completes, the incoming PartArray
/// frame remains untouched.
/// </summary>
public bool AdjustOffset(
double dt,
Vector3 currentBodyPosition,
Quaternion currentBodyOrientation,
float maxSpeedFromMinterp,
MotionDeltaFrame offset,
bool inContact = true)
{
ArgumentNullException.ThrowIfNull(offset);
if (!inContact)
return false;
InterpolationStep step = ComputeStep(
dt,
currentBodyPosition,
maxSpeedFromMinterp);
if (!step.Overwrites)
return false;
offset.Origin = MoveToMath.GlobalToLocalVec(
currentBodyOrientation,
step.WorldOrigin);
offset.Orientation = _keepHeading
? Quaternion.Identity
: FrameOps.SetRotate(
offset.Origin,
Quaternion.Identity,
Quaternion.Inverse(currentBodyOrientation)
* step.TargetOrientation);
return true;
}
private InterpolationStep ComputeStep(
double dt,
Vector3 currentBodyPosition,
float maxSpeedFromMinterp)
{
// dt sanity guard — protects PhysicsBody.Position from NaN poisoning.
if (dt <= 0 || double.IsNaN(dt))
return Vector3.Zero;
return default;
if (_queue.First is null)
return Vector3.Zero;
return default;
// Distance to head node (retail line 353083).
var head = _queue.First.Value;
@ -278,7 +391,7 @@ public sealed class InterpolationManager
if (dist <= DesiredDistance)
{
NodeCompleted(popHead: true, currentBodyPosition);
return Vector3.Zero;
return default;
}
// Catch-up speed (retail line 353122 + 353128 fallback).
@ -344,9 +457,13 @@ public sealed class InterpolationManager
// Retail splits this into a separate UseTime call; we collapse it.
if (_failCount > StallFailCountThreshold)
{
Vector3 tailPos = _queue.Last!.Value.TargetPosition;
InterpolationNode tail = _queue.Last!.Value;
Vector3 tailDelta = tail.TargetPosition - currentBodyPosition;
Clear();
return tailPos - currentBodyPosition;
return new InterpolationStep(
true,
tailDelta,
tail.TargetOrientation);
}
// Per-frame step magnitude (retail line 353218).
@ -358,9 +475,17 @@ public sealed class InterpolationManager
// Direction × step.
Vector3 delta = ((head.TargetPosition - currentBodyPosition) / dist) * step;
return delta;
return new InterpolationStep(
true,
delta,
head.TargetOrientation);
}
private readonly record struct InterpolationStep(
bool Overwrites,
Vector3 WorldOrigin,
Quaternion TargetOrientation);
/// <summary>
/// Retail NodeCompleted (@ 0x005559A0). popHead=true after head reached;
/// popHead=false during stall fail (re-baseline only). For our collapsed

View file

@ -25,6 +25,48 @@ public static class FrameOps
/// square against |v|².</summary>
public const float FEpsilon = 0.000199999995f;
/// <summary>
/// Retail <c>Frame::set_rotate</c> (0x00535080). The candidate is
/// normalized in extended precision, then the complete frame is checked
/// by <c>Frame::IsValid</c> (0x00534ED0). If that check fails, retail
/// restores the previous quaternion instead of allowing NaNs to poison
/// the live object transform.
/// </summary>
public static Quaternion SetRotate(
Vector3 frameOrigin,
Quaternion previous,
Quaternion candidate)
{
double lengthSquared =
((double)candidate.W * candidate.W)
+ ((double)candidate.X * candidate.X)
+ ((double)candidate.Y * candidate.Y)
+ ((double)candidate.Z * candidate.Z);
double inverseLength = 1.0 / Math.Sqrt(lengthSquared);
var normalized = new Quaternion(
(float)(candidate.X * inverseLength),
(float)(candidate.Y * inverseLength),
(float)(candidate.Z * inverseLength),
(float)(candidate.W * inverseLength));
if (float.IsNaN(frameOrigin.X)
|| float.IsNaN(frameOrigin.Y)
|| float.IsNaN(frameOrigin.Z)
|| float.IsNaN(normalized.W)
|| float.IsNaN(normalized.X)
|| float.IsNaN(normalized.Y)
|| float.IsNaN(normalized.Z))
{
return previous;
}
float normalizedLengthSquared = normalized.LengthSquared();
return !float.IsNaN(normalizedLengthSquared)
&& MathF.Abs(normalizedLengthSquared - 1f) < FEpsilon * 5f
? normalized
: previous;
}
/// <summary><c>Frame::grotate</c> — incremental WORLD-space rotation.</summary>
public static void GRotate(Frame frame, Vector3 rotationGlobal)
{
@ -48,7 +90,10 @@ public static class FrameOps
rotationGlobal.Y * s * invMag,
rotationGlobal.Z * s * invMag,
c);
frame.Orientation = Quaternion.Normalize(Quaternion.Multiply(r, frame.Orientation));
frame.Orientation = SetRotate(
frame.Origin,
frame.Orientation,
Quaternion.Multiply(r, frame.Orientation));
}
/// <summary><c>Frame::rotate</c> — LOCAL rotation vector, mapped to
@ -65,7 +110,10 @@ public static class FrameOps
public static void Combine(Frame frame, Frame pos)
{
frame.Origin += Vector3.Transform(pos.Origin, frame.Orientation);
frame.Orientation = Quaternion.Normalize(frame.Orientation * pos.Orientation);
frame.Orientation = SetRotate(
frame.Origin,
frame.Orientation,
frame.Orientation * pos.Orientation);
}
/// <summary>
@ -75,7 +123,9 @@ public static class FrameOps
/// </summary>
public static void Subtract1(Frame frame, Frame pos)
{
frame.Orientation = Quaternion.Normalize(
frame.Orientation = SetRotate(
frame.Origin,
frame.Orientation,
frame.Orientation * Quaternion.Conjugate(pos.Orientation));
frame.Origin -= Vector3.Transform(pos.Origin, frame.Orientation);
}

View file

@ -15,10 +15,10 @@ namespace AcDream.Core.Physics.Motion;
///
/// <para><see cref="Origin"/> = retail <c>m_fOrigin</c> (the accumulated
/// position delta, in the mover's LOCAL frame after
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> carries the
/// heading retail's <c>Frame::set_heading</c> writes; read/write it as a
/// compass heading via <see cref="GetHeading"/> / <see cref="SetHeading"/>
/// (P5 convention, degrees).</para>
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> is the full
/// relative quaternion carried by retail <c>Frame</c>; the heading accessors
/// are narrow helpers for managers that explicitly call
/// <c>Frame::set_heading</c>.</para>
/// </summary>
public sealed class MotionDeltaFrame
{
@ -26,10 +26,50 @@ public sealed class MotionDeltaFrame
/// delta (mover-local frame).</summary>
public Vector3 Origin;
/// <summary>Retail <c>Frame</c> rotation — carries the
/// <c>Frame::set_heading</c> output.</summary>
/// <summary>Retail <c>Frame</c>'s complete relative rotation.</summary>
public Quaternion Orientation = Quaternion.Identity;
/// <summary>
/// Restore retail's identity <c>Frame</c>: zero translation and identity
/// orientation. Object ticks reuse these accumulators to avoid allocating
/// one transform per entity per render frame.
/// </summary>
public void Reset()
{
Origin = Vector3.Zero;
Orientation = Quaternion.Identity;
}
/// <summary>
/// Retail <c>Frame::combine</c> (0x005122E0), specialized for the mutable
/// per-tick delta frame. The incoming translation is expressed in this
/// frame's local coordinates, so the current orientation rotates it before
/// addition; the incoming orientation is then post-multiplied. Translation
/// scaling is the separate <c>m_scale</c> operation performed by
/// <c>CPhysicsObj::UpdatePositionInternal</c>.
/// </summary>
public void Combine(
Vector3 localOrigin,
Quaternion localOrientation,
float originScale = 1f)
{
Origin += Vector3.Transform(localOrigin * originScale, Orientation);
Orientation = FrameOps.SetRotate(
Origin,
Orientation,
Orientation * localOrientation);
}
/// <summary>
/// Compose another complete delta frame using retail
/// <c>Frame::combine</c> semantics.
/// </summary>
public void Combine(MotionDeltaFrame delta, float originScale = 1f)
{
ArgumentNullException.ThrowIfNull(delta);
Combine(delta.Origin, delta.Orientation, originScale);
}
/// <summary>Retail <c>Frame::get_heading</c> (P5 compass degrees).</summary>
public float GetHeading() => MoveToMath.GetHeading(Orientation);

View file

@ -20,52 +20,25 @@ namespace AcDream.Core.Physics.Motion;
/// <c>combine_motion</c>/<c>re_modify</c> — the composition the retired
/// AP-73 row approximated with one cycle.
/// </para>
///
/// <para>
/// The <see cref="TurnApplied"/>/<see cref="TurnStopped"/> callbacks are the
/// interim ObservedOmega seam: the App seeds the remote body's angular
/// velocity from the wire turn so rotation starts the same tick (retail
/// rotates the body from the sequence omega inside the per-tick
/// <c>apply_physics</c> chain — R6 scope; register row). They fire BEFORE
/// the dispatch so a consumer sees the seed even if the dat lacks the
/// modifier entry.
/// </para>
/// </summary>
public sealed class MotionTableDispatchSink : IInterpretedMotionSink
{
private readonly AnimationSequencer _sequencer;
/// <summary>Turn-class dispatch observed: (motion, signedSpeed) —
/// TurnLeft arrives either as the explicit 0x0E command or as
/// TurnRight + negative speed (adjust_motion wire convention).</summary>
public Action<uint, float>? TurnApplied { get; set; }
/// <summary>Turn-class stop observed.</summary>
public Action? TurnStopped { get; set; }
public MotionTableDispatchSink(AnimationSequencer sequencer)
{
ArgumentNullException.ThrowIfNull(sequencer);
_sequencer = sequencer;
}
private static bool IsTurn(uint motion)
=> (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E;
public bool ApplyMotion(uint motion, float speed)
{
if (IsTurn(motion))
TurnApplied?.Invoke(motion, speed);
uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed));
return result == MotionTableManagerError.Success;
}
public bool StopMotion(uint motion)
{
if (IsTurn(motion))
TurnStopped?.Invoke();
uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f));
return result == MotionTableManagerError.Success;
}
@ -75,14 +48,10 @@ public sealed class MotionTableDispatchSink : IInterpretedMotionSink
/// (0x00518890): <c>MotionTableManager::PerformMovement(type 5)</c>.
/// The manager stops everything and queues its Ready-sentinel
/// pending_animations entry UNCONDITIONALLY — the completable partner
/// of the interp's A9 pending_motions node. A full stop ends any turn
/// cycle, so the ObservedOmega seam is notified like
/// <see cref="StopMotion"/>'s turn-class branch.
/// of the interp's A9 pending_motions node.
/// </summary>
public bool StopCompletely()
{
TurnStopped?.Invoke();
uint result = _sequencer.PerformMovement(MotionTableMovement.StopCompletely());
return result == MotionTableManagerError.Success;
}

View file

@ -114,12 +114,8 @@ public static class MoveToMath
/// <summary>
/// Retail <c>Frame::get_heading</c> (<c>0x00535760</c>, raw 319781) —
/// extracts the compass heading (P5 convention) from a body orientation
/// quaternion. <b>The packer-reuse trap (V0-pins §P5 correction):</b>
/// acdream's outbound packer (<c>GameWindow.YawToAcQuaternion</c>) is
/// wire-correct at the QUATERNION level but its internal scalar
/// intermediate (<c>headingDeg = 180 - yawDeg</c>) is holtburger's
/// SHIFTED convention, not retail's. This method uses the CORRECT
/// scalar bridge derived from acdream's own body convention
/// quaternion. This method uses the scalar bridge derived from acdream's
/// body convention
/// (<c>PlayerMovementController.cs:1022-1025</c>: <c>Orientation =
/// AxisAngle(Z, Yaw - PI/2)</c>, local-forward = +Y, Yaw=0 faces +X):
/// world-forward = <c>(cos Yaw, sin Yaw)</c>, so
@ -166,11 +162,10 @@ public static class MoveToMath
}
/// <summary>
/// R4-V5: the scalar leg of <see cref="GetHeading"/> for bodies whose
/// authoritative facing is a yaw ANGLE rather than a quaternion (the
/// local player: <c>PlayerMovementController.Yaw</c>, radians, Yaw=0
/// faces +X, re-synced into the body quaternion every Update). Same P5
/// bridge: <c>heading = (90 - yawDeg) mod 360</c>.
/// R4-V5: scalar projection of <see cref="GetHeading"/>. The local
/// player's <c>Yaw</c> property exposes this horizontal projection while
/// its complete body quaternion remains authoritative. Same P5 bridge:
/// <c>heading = (90 - yawDeg) mod 360</c>.
/// </summary>
public static float HeadingFromYaw(float yawRad)
{
@ -181,10 +176,10 @@ public static class MoveToMath
}
/// <summary>
/// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the
/// <c>set_heading</c> seam for yaw-authoritative bodies (the local
/// player's heading snap must write <c>Yaw</c>, NOT the body
/// quaternion, which the controller re-derives from Yaw every frame).
/// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the scalar
/// bridge used by an explicit <c>Frame::set_heading</c> operation. This
/// operation deliberately replaces pitch/roll; ordinary frame composition
/// does not pass through this projection.
/// Returns radians wrapped to [-π, π] matching the controller's own
/// wrap discipline.
/// </summary>

View file

@ -46,11 +46,11 @@ namespace AcDream.Core.Physics.Motion;
/// <c>SetWeenieObject</c>/<c>Destroy</c> have no acdream caller yet —
/// <c>get_minterp</c> 0x005242a0 ≡ the <see cref="Minterp"/> property.</para>
///
/// <para><b>PerformMovement's <c>set_active(1)</c> head</b>
/// (0x005240d9) is not re-asserted here: acdream bodies assert the Active
/// transient bit at spawn (<c>RemoteMotion</c> ctor) and the pre-facade
/// route never re-asserted it — status quo preserved (zero-behavior-change
/// slice), not a new deviation.</para>
/// <para><b>Activation.</b> Retail begins
/// <c>MovementManager::PerformMovement</c> (0x005240D0, call at 0x005240D9)
/// with an unconditional <c>CPhysicsObj::set_active(1)</c>, before validating
/// the movement type. <see cref="ActivatePhysicsObject"/> is the host seam for
/// that call. Static objects keep retail's no-op behavior in the host.</para>
/// </summary>
public sealed class MovementManager
{
@ -73,6 +73,14 @@ public sealed class MovementManager
/// <c>CPhysicsObj</c>.</summary>
public Func<MoveToManager>? MoveToFactory { get; set; }
/// <summary>
/// Host seam for retail's unconditional
/// <c>CPhysicsObj::set_active(1)</c> at the head of
/// <see cref="PerformMovement"/>. It is deliberately invoked even for an
/// invalid movement type.
/// </summary>
public Action? ActivatePhysicsObject { get; set; }
public MovementManager(MotionInterpreter minterp)
{
Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp));
@ -102,6 +110,8 @@ public sealed class MovementManager
/// </summary>
public WeenieError PerformMovement(MovementStruct mvs)
{
ActivatePhysicsObject?.Invoke();
switch (mvs.Type)
{
case MovementType.RawCommand:

View file

@ -774,7 +774,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// is set). Register row: releases a "stuck to object" sticky-manager
/// attachment — R5 wires the real StickyManager; until then this is an
/// optional callback the App layer may bind, matching the existing
/// <c>Action?</c> seam convention (see <c>MotionTableDispatchSink.TurnStopped</c>).
/// <c>Action?</c> seams on this runtime owner.
/// </summary>
public Action? UnstickFromObject { get; set; }

View file

@ -59,7 +59,7 @@ public static class PhysicsObjUpdate
/// must therefore observe any velocity change made by the movement
/// callback; moving that callback after reflection changes the result.
/// </remarks>
public static void CommitSetPositionTransition(
public static bool CommitSetPositionTransition(
PhysicsBody body,
bool inContact,
bool onWalkable,
@ -68,7 +68,9 @@ public static class PhysicsObjUpdate
bool previousContact,
bool previousOnWalkable,
Action? hitGround = null,
Action? leaveGround = null)
Action? leaveGround = null,
Func<bool>? isCurrent = null,
Func<bool>? isVelocityCurrent = null)
{
ArgumentNullException.ThrowIfNull(body);
@ -95,11 +97,26 @@ public static class PhysicsObjUpdate
body.TransientState &= ~TransientStateFlags.OnWalkable;
if (!previousOnWalkable && finalOnWalkable)
{
hitGround?.Invoke();
if (isCurrent?.Invoke() == false)
return false;
}
else if (previousOnWalkable && !finalOnWalkable)
{
leaveGround?.Invoke();
if (isCurrent?.Invoke() == false)
return false;
}
body.calc_acceleration();
// Position, Vector, and Movement are independently timestamped but
// can all install m_velocityVector. If a later one arrived from a
// callback above, retain its vector and finish the non-overlapping
// contact/pose commit without applying this older collision response.
if (isVelocityCurrent?.Invoke() == false)
return isCurrent?.Invoke() ?? true;
HandleAllCollisions(
body,
collisionNormalValid,
@ -107,6 +124,7 @@ public static class PhysicsObjUpdate
previousContact,
previousOnWalkable,
finalOnWalkable);
return isCurrent?.Invoke() ?? true;
}
/// <summary>

View file

@ -42,6 +42,39 @@ public readonly record struct ProjectileAdvanceResult(
Vector3 CollisionNormal,
bool TransitionOk);
/// <summary>
/// Candidate frame produced by UpdatePhysicsInternal and held across retail's
/// process_hooks slot before the outer transition/collision commit.
/// </summary>
public readonly record struct ProjectileQuantumPreparation(
uint CellId,
float Quantum,
bool Simulated,
bool RequiresTransition,
Vector3 BeginPosition,
Quaternion BeginOrientation,
Position BeginCellPosition,
bool BeginInWorld,
ProjectileQuantumDynamics BeginDynamics,
Vector3 CandidatePosition,
Quaternion CandidateOrientation,
ProjectileQuantumDynamics CandidateDynamics,
bool PreviousContact,
bool PreviousOnWalkable);
/// <summary>
/// Mutable kinematic fields produced by the candidate integration. The split
/// App scheduler holds these transactionally across <c>process_hooks</c> so a
/// re-entrant authoritative correction never inherits an abandoned impulse.
/// </summary>
public readonly record struct ProjectileQuantumDynamics(
Vector3 Velocity,
Vector3 CachedVelocity,
Vector3 Acceleration,
Vector3 Omega,
TransientStateFlags TransientState,
double LastUpdateTime);
/// <summary>
/// Core-only port of the retail live-projectile physics driver. It owns no
/// renderer, network, or world-lifetime state: the App controller supplies a
@ -152,32 +185,84 @@ public sealed class ProjectilePhysicsStepper
transitionOk);
}
private void StepQuantum(
/// <summary>
/// Advances exactly one quantum already admitted by the owning
/// <see cref="RetailObjectQuantumClock"/>. This is the live-object path:
/// one CPhysicsObj clock admits PartArray, projectile physics, hooks, and
/// the manager tail together. The absolute-time overload remains for the
/// pure stepper conformance surface.
/// </summary>
public ProjectileAdvanceResult AdvanceQuantum(
PhysicsBody body,
float dt,
ref uint cellId,
float quantum,
uint cellId,
ProjectileCollisionSphere sphere,
uint movingEntityId,
uint designatedTargetId,
ref bool collisionNormalValid,
ref Vector3 collisionNormal,
ref bool transitionOk)
uint designatedTargetId = 0,
bool isParented = false)
{
ProjectileQuantumPreparation preparation = BeginQuantum(
body,
quantum,
cellId,
sphere,
isParented);
return CompleteQuantum(
body,
preparation,
sphere,
movingEntityId,
designatedTargetId);
}
public ProjectileQuantumPreparation BeginQuantum(
PhysicsBody body,
float quantum,
uint cellId,
ProjectileCollisionSphere sphere,
bool isParented = false)
{
ArgumentNullException.ThrowIfNull(body);
if (!float.IsFinite(quantum)
|| quantum <= PhysicsGlobals.EPSILON
|| quantum > PhysicsBody.MaxQuantum)
{
throw new ArgumentOutOfRangeException(
nameof(quantum),
quantum,
"An admitted object quantum must be finite and no larger than MaxQuantum.");
}
if (!sphere.IsValid)
return NotSimulated(cellId, quantum);
if (isParented || !body.InWorld
|| body.State.HasFlag(PhysicsStateFlags.Frozen)
|| body.State.HasFlag(PhysicsStateFlags.Static)
|| cellId == 0)
{
body.TransientState &= ~TransientStateFlags.Active;
return NotSimulated(cellId, quantum);
}
if (!body.IsActive)
return NotSimulated(cellId, quantum);
Vector3 beginPosition = body.Position;
Quaternion beginOrientation = body.Orientation;
Position beginCellPosition = body.CellPosition;
bool beginInWorld = body.InWorld;
ProjectileQuantumDynamics beginDynamics = CaptureDynamics(body);
bool previousContact = body.InContact;
bool previousOnWalkable = body.OnWalkable;
// Final PhysicsState drives acceleration on every quantum. Network
// acceleration is retained by the protocol parser but not installed.
body.calc_acceleration();
body.UpdatePhysicsInternal(dt);
body.UpdatePhysicsInternal(quantum);
Vector3 candidatePosition = body.Position;
Quaternion candidateOrientation = body.Orientation;
Vector3 displacement = candidatePosition - beginPosition;
bool candidateMoved = displacement != Vector3.Zero;
if (candidateMoved && body.State.HasFlag(PhysicsStateFlags.AlignPath))
{
candidateOrientation = RetailFrameMath.SetVectorHeading(
@ -189,76 +274,218 @@ public sealed class ProjectilePhysicsStepper
{
body.CachedVelocity = Vector3.Zero;
body.Orientation = candidateOrientation;
return;
body.LastUpdateTime += quantum;
}
else
{
// process_hooks runs against the post-physics candidate while the
// authoritative root remains at the begin frame until transition.
body.Position = beginPosition;
body.Orientation = beginOrientation;
}
// The integrator produces a candidate without committing the root.
// Restore the authoritative start frame before transition setup so
// carried cell-relative position and self-shadow identity remain exact.
body.Position = beginPosition;
body.Orientation = beginOrientation;
ProjectileQuantumDynamics candidateDynamics = CaptureDynamics(body);
RestoreBeginFrame(
body,
beginPosition,
beginOrientation,
beginCellPosition,
beginInWorld);
ApplyDynamics(body, beginDynamics);
return new ProjectileQuantumPreparation(
cellId,
quantum,
Simulated: true,
RequiresTransition: candidateMoved,
beginPosition,
beginOrientation,
beginCellPosition,
beginInWorld,
beginDynamics,
candidatePosition,
candidateOrientation,
candidateDynamics,
previousContact,
previousOnWalkable);
}
public ProjectileAdvanceResult CompleteQuantum(
PhysicsBody body,
in ProjectileQuantumPreparation preparation,
ProjectileCollisionSphere sphere,
uint movingEntityId,
uint designatedTargetId = 0)
{
ArgumentNullException.ThrowIfNull(body);
if (!preparation.Simulated)
return new ProjectileAdvanceResult(preparation.CellId, 0, false, false, default, true);
ApplyDynamics(body, preparation.CandidateDynamics);
if (!preparation.RequiresTransition)
{
body.SetFrameInCurrentCell(
preparation.CandidatePosition,
preparation.CandidateOrientation);
return new ProjectileAdvanceResult(preparation.CellId, 1, true, false, default, true);
}
uint cellId = preparation.CellId;
ObjectInfoState moverFlags = body.State.HasFlag(PhysicsStateFlags.PathClipped)
? ObjectInfoState.PathClipped
: ObjectInfoState.None;
var resolved = _physics.ResolveWithTransition(
beginPosition,
candidatePosition,
preparation.BeginPosition,
preparation.CandidatePosition,
cellId,
sphereRadius: sphere.Radius,
sphereHeight: 0f,
stepUpHeight: PhysicsGlobals.DefaultStepHeight,
stepDownHeight: 0f,
isOnGround: previousOnWalkable,
isOnGround: preparation.PreviousOnWalkable,
body: body,
moverFlags: moverFlags,
movingEntityId: movingEntityId,
localSphereOrigin: sphere.LocalOrigin,
beginOrientation: beginOrientation,
endOrientation: candidateOrientation,
beginOrientation: preparation.BeginOrientation,
endOrientation: preparation.CandidateOrientation,
designatedTargetId: designatedTargetId);
bool collisionNormalValid = false;
Vector3 collisionNormal = default;
bool transitionOk = resolved.Ok;
if (!resolved.Ok)
{
// UpdateObjectInternal 0x005156B0: when transition() returns null,
// retail calls set_frame(candidate), zeros cached_velocity, and
// deliberately skips SetPositionInternal/handle_all_collisions.
// set_frame retains Position.objcell_id even when the candidate
// frame lies beyond the current outdoor cell's canonical range.
body.SetFrameInCurrentCell(candidatePosition, candidateOrientation);
body.SetFrameInCurrentCell(
preparation.CandidatePosition,
preparation.CandidateOrientation);
body.CachedVelocity = Vector3.Zero;
transitionOk = false;
return;
}
body.CachedVelocity = (resolved.Position - beginPosition) / dt;
body.Position = resolved.Position;
body.Orientation = resolved.Orientation == default
? candidateOrientation
: resolved.Orientation;
if (resolved.CellId != 0)
cellId = resolved.CellId;
// SetPositionInternal 0x00515330 commits Contact and OnWalkable as
// distinct facts before handle_all_collisions. A steep surface can be
// contact without becoming walkable ground.
PhysicsObjUpdate.ApplySetPositionContact(
body,
resolved.InContact,
resolved.OnWalkable);
PhysicsObjUpdate.HandleAllCollisions(
body,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
previousContact,
previousOnWalkable,
nowOnWalkable: body.OnWalkable);
if (resolved.CollisionNormalValid)
else
{
collisionNormalValid = true;
collisionNormal = resolved.CollisionNormal;
body.CachedVelocity = (resolved.Position - preparation.BeginPosition)
/ preparation.Quantum;
body.Position = resolved.Position;
body.Orientation = resolved.Orientation == default
? preparation.CandidateOrientation
: resolved.Orientation;
if (resolved.CellId != 0)
cellId = resolved.CellId;
PhysicsObjUpdate.ApplySetPositionContact(
body,
resolved.InContact,
resolved.OnWalkable);
PhysicsObjUpdate.HandleAllCollisions(
body,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
preparation.PreviousContact,
preparation.PreviousOnWalkable,
nowOnWalkable: body.OnWalkable);
if (resolved.CollisionNormalValid)
{
collisionNormalValid = true;
collisionNormal = resolved.CollisionNormal;
}
}
body.LastUpdateTime += preparation.Quantum;
return new ProjectileAdvanceResult(
cellId,
1,
true,
collisionNormalValid,
collisionNormal,
transitionOk);
}
private static ProjectileQuantumPreparation NotSimulated(
uint cellId,
float quantum) => new(
CellId: cellId,
Quantum: quantum,
Simulated: false,
RequiresTransition: false,
BeginPosition: default,
BeginOrientation: default,
BeginCellPosition: default,
BeginInWorld: false,
BeginDynamics: default,
CandidatePosition: default,
CandidateOrientation: default,
CandidateDynamics: default,
PreviousContact: false,
PreviousOnWalkable: false);
private static ProjectileQuantumDynamics CaptureDynamics(PhysicsBody body) => new(
body.Velocity,
body.CachedVelocity,
body.Acceleration,
body.Omega,
body.TransientState,
body.LastUpdateTime);
private static void ApplyDynamics(
PhysicsBody body,
in ProjectileQuantumDynamics dynamics)
{
body.Velocity = dynamics.Velocity;
body.CachedVelocity = dynamics.CachedVelocity;
body.Acceleration = dynamics.Acceleration;
body.Omega = dynamics.Omega;
body.TransientState = dynamics.TransientState;
body.LastUpdateTime = dynamics.LastUpdateTime;
}
private static void RestoreBeginFrame(
PhysicsBody body,
Vector3 beginPosition,
Quaternion beginOrientation,
in Position beginCellPosition,
bool beginInWorld)
{
body.Orientation = beginOrientation;
if (beginCellPosition.ObjCellId != 0)
{
body.SnapToCell(
beginCellPosition.ObjCellId,
beginPosition,
beginCellPosition.Frame.Origin);
}
else
{
body.SetFrameInCurrentCell(beginPosition, beginOrientation);
}
body.InWorld = beginInWorld;
}
private void StepQuantum(
PhysicsBody body,
float dt,
ref uint cellId,
ProjectileCollisionSphere sphere,
uint movingEntityId,
uint designatedTargetId,
ref bool collisionNormalValid,
ref Vector3 collisionNormal,
ref bool transitionOk)
{
ProjectileQuantumPreparation preparation = BeginQuantum(
body,
dt,
cellId,
sphere);
ProjectileAdvanceResult result = CompleteQuantum(
body,
preparation,
sphere,
movingEntityId,
designatedTargetId);
if (result.CellId != 0)
cellId = result.CellId;
collisionNormalValid |= result.CollisionNormalValid;
if (result.CollisionNormalValid)
collisionNormal = result.CollisionNormal;
transitionOk &= result.TransitionOk;
}
}

View file

@ -1,4 +1,5 @@
using System.Numerics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Physics;
@ -7,10 +8,10 @@ namespace AcDream.Core.Physics;
/// by <c>CSequence::update</c> + InterpolationManager catch-up correction.
/// Pure function — no side effects or hidden state.
///
/// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730):
/// rootOffset = CPartArray::Update(dt) // animation
/// PositionManager::adjust_offset(rootOffset) // adds correction
/// frame.origin += rootOffset
/// Mirrors retail <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512C30):
/// CPartArray writes one complete local Frame, then PositionManager mutates
/// that same Frame. Active interpolation replaces it with
/// <c>Position::subtract2</c>; later managers receive the result.
///
/// The animation root motion is the complete body-local <c>Frame.Origin</c>
/// accumulated by <c>CPartArray::Update</c>: authored PosFrames plus the
@ -30,6 +31,50 @@ namespace AcDream.Core.Physics;
/// </summary>
public sealed class RemoteMotionCombiner
{
/// <summary>
/// Compose retail's complete per-object delta frame. Interpolation, when
/// active, replaces the PartArray frame via
/// <c>Position::subtract2</c>; otherwise the authored root frame remains.
/// </summary>
/// <returns><c>true</c> when interpolation replaced the root frame.</returns>
public bool ComposeOffset(
double dt,
Vector3 currentBodyPosition,
Quaternion ori,
MotionDeltaFrame rootMotionLocalFrame,
InterpolationManager interp,
float maxSpeed,
MotionDeltaFrame output,
Vector3? terrainNormal = null,
bool inContact = true)
{
ArgumentNullException.ThrowIfNull(rootMotionLocalFrame);
ArgumentNullException.ThrowIfNull(interp);
ArgumentNullException.ThrowIfNull(output);
output.Origin = rootMotionLocalFrame.Origin;
output.Orientation = rootMotionLocalFrame.Orientation;
bool interpolationOverwrote = interp.AdjustOffset(
dt,
currentBodyPosition,
ori,
maxSpeed,
output,
inContact);
if (!interpolationOverwrote
&& terrainNormal.HasValue
&& terrainNormal.Value.Z > 0.01f)
{
Vector3 rootMotionWorld = Vector3.Transform(output.Origin, ori);
Vector3 normal = terrainNormal.Value;
rootMotionWorld -= normal * Vector3.Dot(rootMotionWorld, normal);
output.Origin = MoveToMath.GlobalToLocalVec(ori, rootMotionWorld);
}
return interpolationOverwrote;
}
/// <summary>
/// Compute the per-frame world-space delta to add to body.Position.
/// </summary>
@ -90,11 +135,21 @@ public sealed class RemoteMotionCombiner
// AdjustOffset returns Vector3.Zero in two cases mapped to retail's
// early-return: empty queue OR distance < DesiredDistance (0.05m).
// In both, body falls back to animation root motion.
Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
if (correction.LengthSquared() > 0f)
return correction;
Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocalDelta, ori);
var root = new MotionDeltaFrame
{
Origin = rootMotionLocalDelta,
};
var output = new MotionDeltaFrame();
ComposeOffset(
dt,
currentBodyPosition,
ori,
root,
interp,
maxSpeed,
output,
terrainNormal: null);
Vector3 rootMotionWorld = Vector3.Transform(output.Origin, ori);
// Slope projection (queue-empty fallback only). Locomotion cycles
// bake Z=0 in body-local, so without projection the body's Z stays

View file

@ -0,0 +1,101 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Ports the lifecycle and 96-world-unit Active gate at the head of retail
/// <c>CPhysicsObj::update_object</c> (0x00515D10) plus the inactive branch of
/// <c>UpdateObjectInternal</c> (0x005156B0).
/// </summary>
public static class RetailObjectActivityGate
{
public const float MaxPhysicsDistance = 96f;
public static RetailObjectActivityResult Evaluate(
RetailObjectQuantumClock clock,
PhysicsBody? body,
bool lifecycleEligible,
bool hasPartArray,
bool isStatic,
Vector3 objectPosition,
Vector3? playerPosition,
double elapsedSeconds)
{
ArgumentNullException.ThrowIfNull(clock);
if (!lifecycleEligible)
{
SetActive(clock, body, active: false);
return RetailObjectActivityResult.Suspended;
}
// Retail performs both the 96-unit decision and set_active(1) only
// while player_object exists. During login/session transitions a
// missing player preserves the prior Active state; it must not wake a
// previously distant object.
if (playerPosition is null)
{
if (clock.IsActive)
return RetailObjectActivityResult.Active;
clock.Advance(elapsedSeconds);
return RetailObjectActivityResult.Inactive;
}
bool withinActiveBubble = !hasPartArray
|| Vector3.Distance(objectPosition, playerPosition.Value)
<= MaxPhysicsDistance;
if (!withinActiveBubble)
{
SetActive(clock, body, active: false);
// Inactive ordinary objects still consume update_time and run
// their particle/script tail. Those owners tick elsewhere in
// acdream; consuming the batch here prevents later catch-up.
clock.Advance(elapsedSeconds);
return RetailObjectActivityResult.Inactive;
}
// CPhysicsObj::set_active(1) (0x0050FC40) is a no-op for Static.
// Static objects that were initialized or removed from visibility
// inactive therefore stay on UpdateObjectInternal's particle/script
// tail instead of entering root physics.
bool reactivated = false;
if (!isStatic)
{
reactivated = clock.Activate();
if (body is not null)
body.TransientState |= TransientStateFlags.Active;
}
if (!clock.IsActive)
{
clock.Advance(elapsedSeconds);
return RetailObjectActivityResult.Inactive;
}
return reactivated
? RetailObjectActivityResult.Reactivated
: RetailObjectActivityResult.Active;
}
private static void SetActive(
RetailObjectQuantumClock clock,
PhysicsBody? body,
bool active)
{
if (active)
clock.Activate();
else
clock.Deactivate();
if (body is not null && !active)
body.TransientState &= ~TransientStateFlags.Active;
}
}
public enum RetailObjectActivityResult
{
Suspended,
Inactive,
Reactivated,
Active,
}

View file

@ -12,6 +12,40 @@ namespace AcDream.Core.Physics;
/// </remarks>
public static class RetailObjectManagerTail
{
/// <summary>
/// Allocation-free production overload for the concrete retail manager
/// owners. DetectionManager is not ported, so its ordered slot is empty.
/// </summary>
public static void Run(
Motion.TargetManager? target,
Motion.MovementManager? movement,
Motion.MotionTableManager? partArray,
Motion.PositionManager? position)
{
target?.HandleTargetting();
movement?.UseTime();
partArray?.UseTime();
position?.UseTime();
}
/// <summary>
/// Allocation-free local-player overload. Its target action and PartArray
/// completion action are cached ownership seams; the other managers are
/// passed as concrete objects rather than allocating bound delegates per
/// quantum.
/// </summary>
public static void Run(
Action? handleTargeting,
Motion.MovementManager? movement,
Action? partArrayHandleMovement,
Motion.PositionManager? position)
{
handleTargeting?.Invoke();
movement?.UseTime();
partArrayHandleMovement?.Invoke();
position?.UseTime();
}
public static void Run(
Action? checkDetection,
Action? handleTargeting,

View file

@ -0,0 +1,122 @@
using System;
namespace AcDream.Core.Physics;
/// <summary>
/// Allocation-free port of <c>CPhysicsObj::update_object</c>
/// (0x00515D10). One clock belongs to one logical physics object. It retains
/// sub-minimum elapsed time, subdivides long frames into complete object
/// updates, and discards stale gaps greater than retail's huge quantum.
/// </summary>
public sealed class RetailObjectQuantumClock
{
private double _pending;
public double PendingSeconds => _pending;
public bool IsActive { get; private set; } = true;
public RetailObjectQuantumBatch Advance(double elapsedSeconds)
{
if (double.IsNaN(elapsedSeconds) || elapsedSeconds < 0.0)
{
_pending = 0.0;
return new RetailObjectQuantumBatch(0, 0f, Discarded: true);
}
double elapsed = _pending + elapsedSeconds;
if (elapsed <= FrameEpsilon)
{
_pending = 0.0;
return default;
}
if (elapsed > PhysicsBody.HugeQuantum)
{
_pending = 0.0;
return new RetailObjectQuantumBatch(0, 0f, Discarded: true);
}
int fullSteps = 0;
while (elapsed > PhysicsBody.MaxQuantum)
{
fullSteps++;
elapsed -= PhysicsBody.MaxQuantum;
}
float remainder = 0f;
if (elapsed > PhysicsBody.MinQuantum)
{
remainder = (float)elapsed;
elapsed = 0.0;
}
_pending = elapsed;
return new RetailObjectQuantumBatch(fullSteps, remainder, Discarded: false);
}
/// <summary>
/// Retail <c>set_active(0)</c>: suppress the full object path without
/// advancing or rebasing <c>update_time</c>.
/// </summary>
public void Deactivate() => IsActive = false;
/// <summary>
/// Retail <c>set_active(1)</c>. An inactive-to-active edge rebases
/// <c>update_time</c> to the current timer, so the reactivation frame does
/// not catch up suppressed time.
/// </summary>
/// <returns>True only when this call performed the reactivation edge.</returns>
public bool Activate()
{
if (IsActive)
return false;
IsActive = true;
_pending = 0.0;
return true;
}
public void Reset() => _pending = 0.0;
/// <summary>
/// Retail <c>CPhysicsObj::prepare_to_enter_world</c> (0x00511FA0): rebase
/// <c>update_time</c> to the current timer and immediately set Active when
/// the object is not Static. A Static object retains its prior Active bit;
/// normal initial entry and re-entry arrive here inactive. The next elapsed
/// frame of a non-Static object is therefore eligible for ordinary object-
/// quantum admission; there is no second activation frame to discard.
/// </summary>
public void ResetForEnterWorld(bool isStatic = false)
{
_pending = 0.0;
if (!isStatic)
IsActive = true;
}
private const double FrameEpsilon = 0.000199999995;
}
/// <summary>
/// How the owning live-object lifecycle treats this render frame before
/// entering retail <c>CPhysicsObj::update_object</c>.
/// </summary>
public enum RetailObjectClockDisposition
{
Advance,
Suspend,
}
/// <summary>Compact result of one retail object-clock admission pass.</summary>
public readonly record struct RetailObjectQuantumBatch(
int FullSteps,
float Remainder,
bool Discarded)
{
public int Count => FullSteps + (Remainder > 0f ? 1 : 0);
public float GetQuantum(int index)
{
if ((uint)index >= (uint)Count)
throw new ArgumentOutOfRangeException(nameof(index));
return index < FullSteps ? PhysicsBody.MaxQuantum : Remainder;
}
}