feat(physics): port retail complete object frame pipeline
Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates. Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
31a0889f08
commit
f961d70023
77 changed files with 12513 additions and 1871 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
226
src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs
Normal file
226
src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the body-backed, manager-less branch of retail
|
||||
/// <c>CPhysicsObj::UpdateObjectInternal</c> (0x005156B0). A retained canonical
|
||||
/// body can temporarily outlive its RemoteMotion or projectile component; it
|
||||
/// must still compose the complete PartArray Frame, integrate physics, sweep
|
||||
/// through Transition, commit its cell, and move its collision shadow.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityOrdinaryPhysicsUpdater
|
||||
{
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly Func<uint, WorldEntity, (float Radius, float Height)>
|
||||
_getSetupCylinder;
|
||||
|
||||
public LiveEntityOrdinaryPhysicsUpdater(
|
||||
PhysicsEngine physics,
|
||||
Func<uint, WorldEntity, (float Radius, float Height)> getSetupCylinder)
|
||||
{
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_getSetupCylinder = getSetupCylinder
|
||||
?? throw new ArgumentNullException(nameof(getSetupCylinder));
|
||||
}
|
||||
|
||||
public bool Tick(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
Frame rootFrame,
|
||||
float objectScale,
|
||||
float quantum,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
ulong objectClockEpoch,
|
||||
AnimationSequencer? sequencer,
|
||||
Action<uint, AnimationSequencer> captureAnimationHooks)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(rootFrame);
|
||||
ArgumentNullException.ThrowIfNull(captureAnimationHooks);
|
||||
if (record.PhysicsBody is not { } body
|
||||
|| !IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
body.State = record.FinalPhysicsState;
|
||||
Vector3 priorPosition = body.Position;
|
||||
Quaternion priorOrientation = body.Orientation;
|
||||
bool previousContact = body.InContact;
|
||||
bool previousOnWalkable = body.OnWalkable;
|
||||
|
||||
// UpdatePositionInternal 0x00512CA1: PartArray root translation is
|
||||
// scaled only while transient OnWalkable is set. Its orientation stays
|
||||
// in the complete Frame and composes regardless of that translation
|
||||
// gate.
|
||||
Vector3 candidatePosition = priorPosition;
|
||||
if (body.OnWalkable && rootFrame.Origin != Vector3.Zero)
|
||||
{
|
||||
candidatePosition += Vector3.Transform(
|
||||
rootFrame.Origin * objectScale,
|
||||
priorOrientation);
|
||||
}
|
||||
|
||||
Quaternion candidateOrientation = priorOrientation;
|
||||
if (!rootFrame.Orientation.IsIdentity)
|
||||
{
|
||||
candidateOrientation = FrameOps.SetRotate(
|
||||
candidatePosition,
|
||||
priorOrientation,
|
||||
priorOrientation * rootFrame.Orientation);
|
||||
}
|
||||
|
||||
body.SetFrameInCurrentCell(candidatePosition, candidateOrientation);
|
||||
body.calc_acceleration();
|
||||
body.UpdatePhysicsInternal(quantum);
|
||||
// Omega integration writes Orientation directly; mirror it into the
|
||||
// retained Position frame before Transition consumes the candidate.
|
||||
body.SetFrameInCurrentCell(body.Position, body.Orientation);
|
||||
|
||||
// UpdatePositionInternal processes PartArray hooks after physics but
|
||||
// before UpdateObjectInternal performs its transition sweep.
|
||||
if (sequencer is not null)
|
||||
captureAnimationHooks(entity.Id, sequencer);
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
return false;
|
||||
|
||||
Vector3 integratedPosition = body.Position;
|
||||
uint sourceCellId = record.FullCellId;
|
||||
uint resolvedCellId = sourceCellId;
|
||||
bool frameChanged = integratedPosition != priorPosition
|
||||
|| body.Orientation != priorOrientation;
|
||||
var (radius, height) = _getSetupCylinder(record.ServerGuid, entity);
|
||||
|
||||
if (integratedPosition != priorPosition
|
||||
&& sourceCellId != 0
|
||||
&& radius >= 0.05f
|
||||
&& _physics.LandblockCount > 0)
|
||||
{
|
||||
ResolveResult resolved = _physics.ResolveWithTransition(
|
||||
priorPosition,
|
||||
integratedPosition,
|
||||
sourceCellId,
|
||||
radius,
|
||||
height,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f,
|
||||
isOnGround: previousOnWalkable,
|
||||
body: body,
|
||||
moverFlags: IsPlayerGuid(record.ServerGuid)
|
||||
? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide
|
||||
: ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: entity.Id);
|
||||
|
||||
if (resolved.Ok)
|
||||
{
|
||||
resolvedCellId = resolved.CellId != 0
|
||||
? resolved.CellId
|
||||
: sourceCellId;
|
||||
body.CommitTransitionPosition(resolvedCellId, resolved.Position);
|
||||
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
body,
|
||||
resolved.InContact,
|
||||
resolved.OnWalkable,
|
||||
resolved.CollisionNormalValid,
|
||||
resolved.CollisionNormal,
|
||||
previousContact,
|
||||
previousOnWalkable);
|
||||
body.CachedVelocity = quantum > 0f
|
||||
? (body.Position - priorPosition) / quantum
|
||||
: Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
// transition() returned null: UpdateObjectInternal keeps the
|
||||
// integrated frame in the current cell and reports no realized
|
||||
// transition velocity.
|
||||
body.CachedVelocity = Vector3.Zero;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// The no-PartArray-sphere arm calls set_frame and writes zero
|
||||
// cached_velocity rather than fabricating a collision shape.
|
||||
body.CachedVelocity = Vector3.Zero;
|
||||
}
|
||||
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
return false;
|
||||
|
||||
entity.SetPosition(body.Position);
|
||||
entity.Rotation = body.Orientation;
|
||||
entity.ParentCellId = resolvedCellId;
|
||||
if (resolvedCellId != sourceCellId
|
||||
&& !runtime.RebucketLiveEntity(record.ServerGuid, resolvedCellId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch))
|
||||
return false;
|
||||
|
||||
if (frameChanged && record.IsSpatiallyVisible && record.FullCellId != 0)
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_physics.ShadowObjects,
|
||||
entity.Id,
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
}
|
||||
|
||||
return IsCurrent(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
body,
|
||||
objectClockEpoch);
|
||||
}
|
||||
|
||||
private static bool IsCurrent(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
PhysicsBody body,
|
||||
ulong objectClockEpoch) =>
|
||||
runtime.IsCurrentSpatialRootObject(record)
|
||||
&& record.ObjectClockEpoch == objectClockEpoch
|
||||
&& ReferenceEquals(record.WorldEntity, entity)
|
||||
&& ReferenceEquals(record.PhysicsBody, body)
|
||||
&& record.RemoteMotionRuntime is null
|
||||
&& record.ProjectileRuntime is null;
|
||||
|
||||
private static bool IsPlayerGuid(uint guid) =>
|
||||
(guid & 0xFF000000u) == 0x50000000u;
|
||||
}
|
||||
56
src/AcDream.App/Physics/LiveEntityShadowPublisher.cs
Normal file
56
src/AcDream.App/Physics/LiveEntityShadowPublisher.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Incarnation/residency gate for authoritative ordinary-remote collision
|
||||
/// publication after a canonical rebucket callback.
|
||||
/// </summary>
|
||||
internal static class LiveEntityShadowPublisher
|
||||
{
|
||||
internal static bool TryPublishRemote(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
ILiveEntityRemoteMotionRuntime remote,
|
||||
ulong positionAuthorityVersion,
|
||||
Action publish)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
ArgumentNullException.ThrowIfNull(publish);
|
||||
|
||||
if (!CanPublish(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
remote,
|
||||
positionAuthorityVersion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
publish();
|
||||
return CanPublish(
|
||||
runtime,
|
||||
record,
|
||||
entity,
|
||||
remote,
|
||||
positionAuthorityVersion);
|
||||
}
|
||||
|
||||
private static bool CanPublish(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
ILiveEntityRemoteMotionRuntime remote,
|
||||
ulong positionAuthorityVersion) =>
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Hidden) == 0
|
||||
&& ReferenceEquals(record.WorldEntity, entity)
|
||||
&& runtime.IsCurrentPositionAuthority(record, positionAuthorityVersion)
|
||||
&& runtime.IsCurrentSpatialRemoteMotion(record, remote);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ using AcDream.Core.Net.Messages;
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
|
|
@ -30,26 +31,34 @@ internal sealed class ProjectileController
|
|||
internal Runtime(
|
||||
ushort generation,
|
||||
PhysicsBody body,
|
||||
ProjectileCollisionSphere collisionSphere,
|
||||
bool hasPartArray)
|
||||
ProjectileCollisionSphere collisionSphere)
|
||||
{
|
||||
Generation = generation;
|
||||
Body = body;
|
||||
CollisionSphere = collisionSphere;
|
||||
HasPartArray = hasPartArray;
|
||||
}
|
||||
|
||||
internal ushort Generation { get; }
|
||||
public PhysicsBody Body { get; }
|
||||
internal ProjectileCollisionSphere CollisionSphere { get; }
|
||||
internal bool HasPartArray { get; }
|
||||
internal ulong PredictionAuthorityVersion { get; private set; }
|
||||
internal void InvalidatePrediction() =>
|
||||
PredictionAuthorityVersion++;
|
||||
}
|
||||
|
||||
internal readonly record struct QuantumStep(
|
||||
LiveEntityRecord Record,
|
||||
Runtime Runtime,
|
||||
WorldEntity Entity,
|
||||
ProjectileQuantumPreparation Preparation,
|
||||
ulong PredictionAuthorityVersion);
|
||||
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ProjectilePhysicsStepper _stepper;
|
||||
private readonly ShadowObjectRegistry _shadows;
|
||||
private readonly Func<uint, Setup?> _setupResolver;
|
||||
private readonly Action<WorldEntity> _publishRootPose;
|
||||
private readonly Func<(int X, int Y)> _liveCenterProvider;
|
||||
private readonly List<LiveEntityRecord> _spatialProjectileSnapshot = new();
|
||||
private double _lastFiniteGameTime;
|
||||
|
||||
|
|
@ -57,7 +66,8 @@ internal sealed class ProjectileController
|
|||
LiveEntityRuntime liveEntities,
|
||||
PhysicsEngine physicsEngine,
|
||||
Func<uint, Setup?>? setupResolver = null,
|
||||
Action<WorldEntity>? publishRootPose = null)
|
||||
Action<WorldEntity>? publishRootPose = null,
|
||||
Func<(int X, int Y)>? liveCenterProvider = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
ArgumentNullException.ThrowIfNull(physicsEngine);
|
||||
|
|
@ -65,6 +75,7 @@ internal sealed class ProjectileController
|
|||
_shadows = physicsEngine.ShadowObjects;
|
||||
_setupResolver = setupResolver ?? (_ => null);
|
||||
_publishRootPose = publishRootPose ?? (_ => { });
|
||||
_liveCenterProvider = liveCenterProvider ?? (() => (0, 0));
|
||||
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +137,9 @@ internal sealed class ProjectileController
|
|||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(setup);
|
||||
|
||||
if ((record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
|
||||
if (!_liveEntities.TryGetRecord(record.ServerGuid, out var liveRecord)
|
||||
|| !ReferenceEquals(liveRecord, record)
|
||||
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
|
||||
return false;
|
||||
|
||||
if (record.ProjectileRuntime is Runtime retained)
|
||||
|
|
@ -158,12 +171,13 @@ internal sealed class ProjectileController
|
|||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
PhysicsBody body;
|
||||
if (record.RemoteMotionRuntime is { } remote)
|
||||
if (record.PhysicsBody is { } sharedBody)
|
||||
{
|
||||
// Retail owns one CPhysicsObj. If a non-missile incarnation
|
||||
// already created its MovementManager, classification adopts that
|
||||
// same body instead of replacing it.
|
||||
body = remote.Body;
|
||||
// already created its MovementManager or entered the Physics-Static
|
||||
// animation workset, classification adopts that same body instead
|
||||
// of replacing it or replaying CreateObject vectors.
|
||||
body = sharedBody;
|
||||
uint currentCellId = record.FullCellId;
|
||||
Vector3 currentCellLocal = CellLocalFromWorld(
|
||||
body.Position,
|
||||
|
|
@ -219,38 +233,69 @@ internal sealed class ProjectileController
|
|||
$"Missile 0x{record.ServerGuid:X8} has an invalid initial physics frame or vector.");
|
||||
return false;
|
||||
}
|
||||
body = new PhysicsBody
|
||||
{
|
||||
State = record.FinalPhysicsState,
|
||||
Friction = NormalizeFriction(
|
||||
physics.Friction ?? record.Snapshot.Friction),
|
||||
Elasticity = NormalizeElasticity(
|
||||
physics.Elasticity ?? record.Snapshot.Elasticity ?? 0.05f),
|
||||
Orientation = entity.Rotation,
|
||||
LastUpdateTime = currentTime,
|
||||
};
|
||||
SetVelocity(body, velocity, currentTime);
|
||||
body.Omega = omega;
|
||||
body.SnapToCell(
|
||||
wirePosition.LandblockId,
|
||||
entity.Position,
|
||||
new Vector3(
|
||||
wirePosition.PositionX,
|
||||
wirePosition.PositionY,
|
||||
wirePosition.PositionZ));
|
||||
// Claim the incarnation's one CPhysicsObj before Rebucket can
|
||||
// publish visibility callbacks. A callback that also needs a body
|
||||
// must observe and adopt this same instance, never race a private
|
||||
// projectile candidate into SetProjectileRuntime.
|
||||
body = _liveEntities.GetOrCreatePhysicsBody(
|
||||
record.ServerGuid,
|
||||
_ =>
|
||||
{
|
||||
var created = new PhysicsBody
|
||||
{
|
||||
State = record.FinalPhysicsState,
|
||||
Friction = NormalizeFriction(
|
||||
physics.Friction ?? record.Snapshot.Friction),
|
||||
Elasticity = NormalizeElasticity(
|
||||
physics.Elasticity ?? record.Snapshot.Elasticity ?? 0.05f),
|
||||
Orientation = entity.Rotation,
|
||||
LastUpdateTime = currentTime,
|
||||
};
|
||||
SetVelocity(created, velocity, currentTime);
|
||||
created.Omega = omega;
|
||||
created.SnapToCell(
|
||||
wirePosition.LandblockId,
|
||||
entity.Position,
|
||||
new Vector3(
|
||||
wirePosition.PositionX,
|
||||
wirePosition.PositionY,
|
||||
wirePosition.PositionZ));
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
uint canonicalCellId = body.CellPosition.ObjCellId;
|
||||
entity.SetPosition(body.Position);
|
||||
entity.Rotation = body.Orientation;
|
||||
entity.ParentCellId = canonicalCellId;
|
||||
_liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId);
|
||||
if (!_liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId)
|
||||
|| !_liveEntities.TryGetRecord(record.ServerGuid, out var currentRecord)
|
||||
|| !ReferenceEquals(currentRecord, record)
|
||||
|| !ReferenceEquals(currentRecord.WorldEntity, entity)
|
||||
|| !ReferenceEquals(currentRecord.PhysicsBody, body)
|
||||
|| (currentRecord.FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rebucket publishes projection callbacks synchronously. A callback
|
||||
// can re-enter classification for this same incarnation and install
|
||||
// the one projectile component before this outer call resumes. Adopt
|
||||
// that component; never replace it with a second Runtime that merely
|
||||
// happens to share the canonical CPhysicsObj body.
|
||||
if (currentRecord.ProjectileRuntime is Runtime concurrentRuntime)
|
||||
return ReferenceEquals(concurrentRuntime.Body, body);
|
||||
|
||||
// A newer Position can legally arrive from a projection callback
|
||||
// while this independent State(Missile) classification is in flight.
|
||||
// Both packets must win: install the component around the current
|
||||
// canonical body frame, never the pre-callback cell captured above.
|
||||
canonicalCellId = body.CellPosition.ObjCellId;
|
||||
|
||||
var runtime = new Runtime(
|
||||
record.Generation,
|
||||
body,
|
||||
sphere,
|
||||
entity.MeshRefs.Count > 0);
|
||||
sphere);
|
||||
_liveEntities.SetProjectileRuntime(record.ServerGuid, runtime);
|
||||
|
||||
if (HasVisibleCell(record) && !IsHidden(record))
|
||||
|
|
@ -258,8 +303,8 @@ internal sealed class ProjectileController
|
|||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
canonicalCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
|
|
@ -282,13 +327,34 @@ internal sealed class ProjectileController
|
|||
}
|
||||
|
||||
internal bool ApplyAuthoritativeVector(
|
||||
uint serverGuid,
|
||||
LiveEntityRecord expectedRecord,
|
||||
Vector3 velocity,
|
||||
Vector3 angularVelocity,
|
||||
double currentTime) =>
|
||||
ApplyAuthoritativeVector(
|
||||
expectedRecord,
|
||||
expectedRecord.VectorAuthorityVersion,
|
||||
expectedRecord.VelocityAuthorityVersion,
|
||||
velocity,
|
||||
angularVelocity,
|
||||
currentTime);
|
||||
|
||||
internal bool ApplyAuthoritativeVector(
|
||||
LiveEntityRecord expectedRecord,
|
||||
ulong expectedVectorAuthorityVersion,
|
||||
ulong expectedVelocityAuthorityVersion,
|
||||
Vector3 velocity,
|
||||
Vector3 angularVelocity,
|
||||
double currentTime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
uint serverGuid = expectedRecord.ServerGuid;
|
||||
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime))
|
||||
return false;
|
||||
if (!ReferenceEquals(record, expectedRecord)
|
||||
|| record.VectorAuthorityVersion != expectedVectorAuthorityVersion
|
||||
|| record.VelocityAuthorityVersion != expectedVelocityAuthorityVersion)
|
||||
return false;
|
||||
|
||||
// Once Missile is cleared, an existing MovementManager owns ordinary
|
||||
// jump/vector side effects (Airborne, gravity, LeaveGround) on this
|
||||
|
|
@ -309,23 +375,48 @@ internal sealed class ProjectileController
|
|||
}
|
||||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
SetVelocity(runtime.Body, velocity, currentTime);
|
||||
runtime.Body.Omega = angularVelocity;
|
||||
if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid)
|
||||
&& !IsHidden(record))
|
||||
Deactivate(runtime.Body);
|
||||
runtime.InvalidatePrediction();
|
||||
if (!_liveEntities.TryCommitAuthoritativeVector(
|
||||
record,
|
||||
runtime.Body,
|
||||
velocity,
|
||||
angularVelocity,
|
||||
currentTime))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool ApplyAuthoritativeState(
|
||||
uint serverGuid,
|
||||
LiveEntityRecord expectedRecord,
|
||||
PhysicsStateFlags state,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY) =>
|
||||
ApplyAuthoritativeState(
|
||||
expectedRecord,
|
||||
expectedRecord.StateAuthorityVersion,
|
||||
state,
|
||||
currentTime,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
|
||||
internal bool ApplyAuthoritativeState(
|
||||
LiveEntityRecord expectedRecord,
|
||||
ulong expectedStateAuthorityVersion,
|
||||
PhysicsStateFlags state,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
uint serverGuid = expectedRecord.ServerGuid;
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record))
|
||||
return false;
|
||||
if (!ReferenceEquals(record, expectedRecord)
|
||||
|| record.StateAuthorityVersion != expectedStateAuthorityVersion)
|
||||
return false;
|
||||
|
||||
bool validClock = double.IsFinite(currentTime);
|
||||
double effectiveClock = validClock
|
||||
|
|
@ -339,6 +430,7 @@ internal sealed class ProjectileController
|
|||
|
||||
if (record.ProjectileRuntime is Runtime runtime)
|
||||
{
|
||||
runtime.InvalidatePrediction();
|
||||
bool wasMissile =
|
||||
(runtime.Body.State & PhysicsStateFlags.Missile) != 0;
|
||||
runtime.Body.State = state;
|
||||
|
|
@ -398,16 +490,46 @@ internal sealed class ProjectileController
|
|||
/// streaming origin changes never leak into the canonical cell frame.
|
||||
/// </summary>
|
||||
internal bool ApplyAuthoritativePosition(
|
||||
uint serverGuid,
|
||||
LiveEntityRecord expectedRecord,
|
||||
Vector3 worldPosition,
|
||||
Vector3 cellLocalPosition,
|
||||
Quaternion orientation,
|
||||
Vector3 velocity,
|
||||
uint fullCellId,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY) =>
|
||||
ApplyAuthoritativePosition(
|
||||
expectedRecord,
|
||||
expectedRecord.PositionAuthorityVersion,
|
||||
expectedRecord.VelocityAuthorityVersion,
|
||||
worldPosition,
|
||||
cellLocalPosition,
|
||||
orientation,
|
||||
velocity,
|
||||
fullCellId,
|
||||
currentTime,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
|
||||
internal bool ApplyAuthoritativePosition(
|
||||
LiveEntityRecord expectedRecord,
|
||||
ulong expectedPositionAuthorityVersion,
|
||||
ulong expectedVelocityAuthorityVersion,
|
||||
Vector3 worldPosition,
|
||||
Vector3 cellLocalPosition,
|
||||
Quaternion orientation,
|
||||
Vector3 velocity,
|
||||
uint fullCellId,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
uint serverGuid = expectedRecord.ServerGuid;
|
||||
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime)
|
||||
|| !ReferenceEquals(record, expectedRecord)
|
||||
|| record.PositionAuthorityVersion != expectedPositionAuthorityVersion
|
||||
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0
|
||||
|| record.WorldEntity is not { } entity)
|
||||
return false;
|
||||
|
|
@ -415,6 +537,7 @@ internal sealed class ProjectileController
|
|||
if (!double.IsFinite(currentTime)
|
||||
|| !IsFinite(worldPosition)
|
||||
|| !IsFinite(cellLocalPosition)
|
||||
|| !IsFinite(velocity)
|
||||
|| !PositionFrameValidation.IsValid(
|
||||
fullCellId,
|
||||
cellLocalPosition,
|
||||
|
|
@ -427,20 +550,53 @@ internal sealed class ProjectileController
|
|||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
PhysicsBody body = runtime.Body;
|
||||
runtime.InvalidatePrediction();
|
||||
bool wasInWorld = body.InWorld;
|
||||
body.Orientation = orientation;
|
||||
body.SnapToCell(fullCellId, worldPosition, cellLocalPosition);
|
||||
body.State = record.FinalPhysicsState;
|
||||
// PositionPack::UnPack initializes an absent velocity to zero and
|
||||
// MoveOrTeleport installs that exact vector through set_velocity.
|
||||
// Apply it to this retained CPhysicsObj before any callback can
|
||||
// displace the incarnation; a replacement record owns another body.
|
||||
if (record.VelocityAuthorityVersion == expectedVelocityAuthorityVersion
|
||||
&& !_liveEntities.TryCommitAuthoritativeVelocity(
|
||||
record,
|
||||
body,
|
||||
velocity,
|
||||
currentTime))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
uint canonicalCellId = body.CellPosition.ObjCellId;
|
||||
|
||||
entity.SetPosition(worldPosition);
|
||||
entity.Rotation = orientation;
|
||||
entity.ParentCellId = canonicalCellId;
|
||||
_liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId);
|
||||
if (!_liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId)
|
||||
|| !TryGetCurrent(serverGuid, out LiveEntityRecord afterRebucket, out Runtime afterRuntime)
|
||||
|| !ReferenceEquals(afterRebucket, record)
|
||||
|| !ReferenceEquals(afterRuntime, runtime)
|
||||
|| !ReferenceEquals(afterRebucket.WorldEntity, entity)
|
||||
|| afterRebucket.PositionAuthorityVersion
|
||||
!= expectedPositionAuthorityVersion)
|
||||
{
|
||||
// This packet was a projectile correction when admitted. A
|
||||
// re-entrant observer displaced that incarnation; report handled
|
||||
// so the generic remote path cannot mutate the replacement.
|
||||
return true;
|
||||
}
|
||||
if (HasVisibleCell(record) && !IsHidden(record))
|
||||
{
|
||||
if (!wasInWorld)
|
||||
{
|
||||
// prepare_to_enter_world rebases the CPhysicsObj timestamp in
|
||||
// the same operation that restores Active. The record clock is
|
||||
// canonical, but keep this legacy body field synchronized for
|
||||
// the absolute-time Core API and diagnostics.
|
||||
body.LastUpdateTime = currentTime;
|
||||
Activate(body, currentTime);
|
||||
}
|
||||
body.InWorld = true;
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
|
|
@ -466,13 +622,16 @@ internal sealed class ProjectileController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Movement packets use the ordinary MovementManager/animation funnel on
|
||||
/// this same body. The generic remote translation tick consults this gate
|
||||
/// so only the projectile integrator commits its root frame.
|
||||
/// Selects the retained body's ordinary-physics owner. Missile
|
||||
/// classification always uses the projectile transition path. If the
|
||||
/// authoritative Missile bit later clears, that same body still owns
|
||||
/// ordinary physics until a RemoteMotion runtime exists to take it over.
|
||||
/// Component lifetime and classification are deliberately separate.
|
||||
/// </summary>
|
||||
internal bool HandlesMovement(uint serverGuid) =>
|
||||
TryGetCurrent(serverGuid, out LiveEntityRecord record, out _)
|
||||
&& (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0;
|
||||
&& ((record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0
|
||||
|| record.RemoteMotionRuntime is null);
|
||||
|
||||
internal bool TryGetBody(uint serverGuid, out PhysicsBody body)
|
||||
{
|
||||
|
|
@ -501,6 +660,22 @@ internal sealed class ProjectileController
|
|||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
Vector3? playerWorldPosition)
|
||||
{
|
||||
double elapsed = currentTime - _lastFiniteGameTime;
|
||||
Tick(
|
||||
currentTime,
|
||||
double.IsFinite(elapsed) && elapsed > 0.0 ? (float)elapsed : 0f,
|
||||
liveCenterX,
|
||||
liveCenterY,
|
||||
playerWorldPosition);
|
||||
}
|
||||
|
||||
internal void Tick(
|
||||
double currentTime,
|
||||
float elapsedSeconds,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
Vector3? playerWorldPosition)
|
||||
{
|
||||
if (!double.IsFinite(currentTime))
|
||||
{
|
||||
|
|
@ -519,22 +694,50 @@ internal sealed class ProjectileController
|
|||
continue;
|
||||
|
||||
runtime.Body.State = record.FinalPhysicsState;
|
||||
if (!HasVisibleCell(record))
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!runtime.Body.InWorld)
|
||||
{
|
||||
runtime.Body.LastUpdateTime = currentTime;
|
||||
runtime.Body.InWorld = true;
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
|
||||
if (IsHidden(record))
|
||||
{
|
||||
if (!HasVisibleCell(record))
|
||||
_shadows.Suspend(entity.Id);
|
||||
// A retained animation or PositionManager owner consumes the
|
||||
// same record clock in LiveEntityAnimationScheduler. A bare
|
||||
// projectile has no such owner, but Hidden still consumes its
|
||||
// ordinary update_time without integrating root physics.
|
||||
if (record.AnimationRuntime is null
|
||||
&& record.RemoteMotionRuntime is null)
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// UpdatePositionInternal skips root physics/PartArray
|
||||
// while Hidden, but update_object still advances its
|
||||
// clock and does not clear Active.
|
||||
runtime.Body.InWorld = true;
|
||||
runtime.Body.LastUpdateTime = currentTime;
|
||||
if ((record.FinalPhysicsState & PhysicsStateFlags.Frozen) != 0)
|
||||
Deactivate(runtime.Body);
|
||||
_shadows.Suspend(entity.Id);
|
||||
RetailObjectActivityResult hiddenActivity =
|
||||
RetailObjectActivityGate.Evaluate(
|
||||
record.ObjectClock,
|
||||
runtime.Body,
|
||||
_liveEntities.GetRootObjectClockDisposition(record.ServerGuid)
|
||||
is RetailObjectClockDisposition.Advance,
|
||||
record.HasPartArray,
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Static) != 0,
|
||||
runtime.Body.Position,
|
||||
playerWorldPosition,
|
||||
elapsedSeconds);
|
||||
if (hiddenActivity is RetailObjectActivityResult.Active)
|
||||
record.ObjectClock.Advance(elapsedSeconds);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -548,23 +751,6 @@ internal sealed class ProjectileController
|
|||
// hydrate as an ordinary live object. Restore its retained
|
||||
// shadow/body membership without reactivating projectile
|
||||
// integration or consuming the out-of-world clock gap.
|
||||
if (!HasVisibleCell(record))
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
else if (!runtime.Body.InWorld)
|
||||
{
|
||||
runtime.Body.InWorld = true;
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
// A retained body without MovementManager still remains an
|
||||
// active CPhysicsObj after Missile is cleared. Continue the
|
||||
// same one-sphere update path; PhysicsEngine reads the final
|
||||
|
|
@ -573,111 +759,185 @@ internal sealed class ProjectileController
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid))
|
||||
{
|
||||
if (!HasVisibleCell(record))
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
else
|
||||
Deactivate(runtime.Body); // Frozen retains its cell/shadow.
|
||||
// Animated owners are admitted by LiveEntityAnimationScheduler so
|
||||
// their PartArray, projectile physics, hooks, and manager tail all
|
||||
// consume one shared object quantum in retail order.
|
||||
if (record.AnimationRuntime is not null)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!runtime.Body.InWorld)
|
||||
{
|
||||
runtime.Body.InWorld = true;
|
||||
Activate(runtime.Body, currentTime);
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
|
||||
// CPhysicsObj::update_object 0x00515D4D-0x00515DB2 keeps
|
||||
// rendered objects active only within 96 world units of the
|
||||
// player. Objects with no PartArray are exempt.
|
||||
if (runtime.HasPartArray && playerWorldPosition is { } playerPosition)
|
||||
{
|
||||
float playerDistance = Vector3.Distance(runtime.Body.Position, playerPosition);
|
||||
if (!float.IsFinite(playerDistance) || playerDistance > 96f)
|
||||
{
|
||||
Deactivate(runtime.Body);
|
||||
continue;
|
||||
}
|
||||
Activate(runtime.Body, currentTime);
|
||||
}
|
||||
|
||||
bool isParented = record.Snapshot.ParentGuid is not null
|
||||
|| record.Snapshot.Physics?.Parent is not null;
|
||||
ProjectileAdvanceResult result = _stepper.Advance(
|
||||
RetailObjectActivityResult activity = RetailObjectActivityGate.Evaluate(
|
||||
record.ObjectClock,
|
||||
runtime.Body,
|
||||
currentTime,
|
||||
record.FullCellId,
|
||||
runtime.CollisionSphere,
|
||||
entity.Id,
|
||||
designatedTargetId: 0u,
|
||||
isParented);
|
||||
|
||||
// An owner can be deleted/replaced by a callback reached from a
|
||||
// future collision sink. Never commit a completed step into a new
|
||||
// incarnation that reused the same server GUID.
|
||||
if (!TryGetCurrent(record.ServerGuid, out LiveEntityRecord current, out Runtime currentRuntime)
|
||||
|| !ReferenceEquals(runtime, currentRuntime)
|
||||
|| !ReferenceEquals(record, current)
|
||||
|| !result.Simulated)
|
||||
_liveEntities.GetRootObjectClockDisposition(record.ServerGuid)
|
||||
is RetailObjectClockDisposition.Advance,
|
||||
record.HasPartArray,
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Static) != 0,
|
||||
runtime.Body.Position,
|
||||
playerWorldPosition,
|
||||
elapsedSeconds);
|
||||
if (activity is not RetailObjectActivityResult.Active)
|
||||
continue;
|
||||
|
||||
uint priorCellId = record.FullCellId;
|
||||
uint resolvedCellId = result.CellId != 0
|
||||
? result.CellId
|
||||
: priorCellId;
|
||||
|
||||
entity.SetPosition(runtime.Body.Position);
|
||||
entity.Rotation = runtime.Body.Orientation;
|
||||
entity.ParentCellId = resolvedCellId;
|
||||
if (resolvedCellId != priorCellId)
|
||||
_liveEntities.RebucketLiveEntity(record.ServerGuid, resolvedCellId);
|
||||
runtime.Body.SnapToCell(
|
||||
resolvedCellId,
|
||||
runtime.Body.Position,
|
||||
CellLocalFromWorld(
|
||||
runtime.Body.Position,
|
||||
resolvedCellId,
|
||||
liveCenterX,
|
||||
liveCenterY));
|
||||
|
||||
// A successful transition can cross into an unloaded landblock.
|
||||
// Rebucket first, then make the physics/shadow decision from the
|
||||
// resulting projection state in this same update quantum. Retail
|
||||
// never leaves a CPhysicsObj active in a cell that is outside the
|
||||
// current object-maintenance world.
|
||||
if (HasVisibleCell(record))
|
||||
RetailObjectQuantumBatch batch = record.ObjectClock.Advance(elapsedSeconds);
|
||||
for (int qi = 0; qi < batch.Count; qi++)
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
if (!AdvanceQuantum(
|
||||
record,
|
||||
batch.GetQuantum(qi),
|
||||
liveCenterX,
|
||||
liveCenterY))
|
||||
break;
|
||||
if (record.RemoteMotionRuntime is RemoteMotion remote)
|
||||
{
|
||||
RetailObjectManagerTail.Run(
|
||||
remote.Host?.TargetManager,
|
||||
remote.Movement,
|
||||
partArray: null,
|
||||
remote.Host?.PositionManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the projectile slice of one object quantum already admitted by
|
||||
/// <see cref="LiveEntityRecord.ObjectClock"/>. Animated projectiles call
|
||||
/// this between PartArray advancement and hook/manager processing.
|
||||
/// </summary>
|
||||
internal bool AdvanceQuantum(
|
||||
LiveEntityRecord record,
|
||||
float quantum,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
{
|
||||
if (!TryBeginQuantum(record, quantum, out QuantumStep step))
|
||||
return false;
|
||||
return CompleteQuantum(step, liveCenterX, liveCenterY);
|
||||
}
|
||||
|
||||
internal bool TryBeginQuantum(
|
||||
LiveEntityRecord record,
|
||||
float quantum,
|
||||
out QuantumStep step)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
step = default;
|
||||
if (!TryGetCurrent(record.ServerGuid, out LiveEntityRecord current, out Runtime runtime)
|
||||
|| !ReferenceEquals(record, current)
|
||||
|| current.WorldEntity is not { } entity
|
||||
|| IsHidden(current)
|
||||
|| !HasVisibleCell(current))
|
||||
return false;
|
||||
|
||||
runtime.Body.State = current.FinalPhysicsState;
|
||||
bool isParented = current.Snapshot.ParentGuid is not null
|
||||
|| current.Snapshot.Physics?.Parent is not null;
|
||||
ProjectileQuantumPreparation preparation = _stepper.BeginQuantum(
|
||||
runtime.Body,
|
||||
quantum,
|
||||
current.FullCellId,
|
||||
runtime.CollisionSphere,
|
||||
isParented);
|
||||
if (!preparation.Simulated)
|
||||
return false;
|
||||
step = new QuantumStep(
|
||||
current,
|
||||
runtime,
|
||||
entity,
|
||||
preparation,
|
||||
runtime.PredictionAuthorityVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool CompleteQuantum(
|
||||
in QuantumStep step,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
{
|
||||
LiveEntityRecord current = step.Record;
|
||||
Runtime runtime = step.Runtime;
|
||||
WorldEntity entity = step.Entity;
|
||||
if (!IsCurrentQuantumStep(step))
|
||||
return false;
|
||||
|
||||
ProjectileAdvanceResult result = _stepper.CompleteQuantum(
|
||||
runtime.Body,
|
||||
step.Preparation,
|
||||
runtime.CollisionSphere,
|
||||
entity.Id,
|
||||
designatedTargetId: 0u);
|
||||
if (!result.Simulated || !IsCurrentQuantumStep(step))
|
||||
return false;
|
||||
|
||||
uint priorCellId = current.FullCellId;
|
||||
uint resolvedCellId = result.CellId != 0 ? result.CellId : priorCellId;
|
||||
// Commit the retained CPhysicsObj's complete frame before Rebucket
|
||||
// publishes a loaded-to-pending edge. Pending is still the same live
|
||||
// object; its canonical body must already name the destination cell
|
||||
// so later hydration cannot resurrect the source-cell frame.
|
||||
runtime.Body.SnapToCell(
|
||||
resolvedCellId,
|
||||
runtime.Body.Position,
|
||||
CellLocalFromWorld(
|
||||
runtime.Body.Position,
|
||||
resolvedCellId,
|
||||
liveCenterX,
|
||||
liveCenterY));
|
||||
entity.SetPosition(runtime.Body.Position);
|
||||
entity.Rotation = runtime.Body.Orientation;
|
||||
entity.ParentCellId = resolvedCellId;
|
||||
if (resolvedCellId != priorCellId
|
||||
&& !_liveEntities.RebucketLiveEntity(current.ServerGuid, resolvedCellId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// A legitimate destination can now be pending and therefore absent
|
||||
// from the spatial-projectile index. Revalidate identity/mutation,
|
||||
// not visibility, after the canonical cell commit.
|
||||
if (!IsCurrentQuantumIdentity(step))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HasVisibleCell(current))
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
current.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
if (!IsCurrentQuantumIdentity(step))
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
return IsCurrentQuantumIdentity(step);
|
||||
}
|
||||
|
||||
private bool IsCurrentQuantumStep(in QuantumStep step) =>
|
||||
IsCurrentQuantumIdentity(step)
|
||||
&& _liveEntities.IsCurrentSpatialProjectile(step.Record, step.Runtime);
|
||||
|
||||
private bool IsCurrentQuantumIdentity(in QuantumStep step) =>
|
||||
TryGetCurrent(
|
||||
step.Record.ServerGuid,
|
||||
out LiveEntityRecord current,
|
||||
out Runtime runtime)
|
||||
&& ReferenceEquals(current, step.Record)
|
||||
&& ReferenceEquals(runtime, step.Runtime)
|
||||
&& ReferenceEquals(current.WorldEntity, step.Entity)
|
||||
&& runtime.PredictionAuthorityVersion
|
||||
== step.PredictionAuthorityVersion;
|
||||
|
||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
{
|
||||
if (visible
|
||||
|| record.ProjectileRuntime is not Runtime runtime
|
||||
if (record.ProjectileRuntime is not Runtime runtime
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, record)
|
||||
|
|
@ -686,6 +946,39 @@ internal sealed class ProjectileController
|
|||
return;
|
||||
}
|
||||
|
||||
if (visible)
|
||||
{
|
||||
// prepare_to_enter_world belongs to the authoritative projection
|
||||
// edge, not to a legacy frame scanner. LiveEntityRuntime has
|
||||
// already rebased the canonical object clock and Active bit before
|
||||
// publishing this notification; restore the remaining CPhysicsObj
|
||||
// residency bit so the first admitted scheduler quantum can run.
|
||||
runtime.Body.State = record.FinalPhysicsState;
|
||||
runtime.Body.InWorld = true;
|
||||
if (IsHidden(record))
|
||||
{
|
||||
_shadows.Suspend(entity.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// add_object_to_cell installs the object's collision shadow as
|
||||
// part of prepare_to_enter_world. Do this on the residency edge,
|
||||
// not lazily on the next simulated quantum: a zero-velocity or
|
||||
// newly ordinary retained projectile may have no later projectile
|
||||
// step from which to repair its shadow membership.
|
||||
(int liveCenterX, int liveCenterY) = _liveCenterProvider();
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pending/cell-less records retain their logical projectile runtime,
|
||||
// but retail exit_world removes them from active physics and collision
|
||||
// immediately. They are absent from the frame workset after this edge,
|
||||
|
|
|
|||
145
src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs
Normal file
145
src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the remote-object half of retail
|
||||
/// <c>MovementManager::unpack_movement</c> (<c>0x00524440</c>) after the
|
||||
/// packet timestamp gate has accepted an UpdateMotion event.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The packet owner is deliberately independent of a render PartArray. A
|
||||
/// normal animated object supplies a <see cref="MotionTableDispatchSink"/>;
|
||||
/// an animation-less object supplies <see langword="null"/> and
|
||||
/// <c>CMotionInterp</c> retains the same state on its canonical physics body.
|
||||
/// This keeps the interrupt/style/type-0/sticky ordering identical for both
|
||||
/// object shapes instead of maintaining a second headless protocol funnel.
|
||||
/// </remarks>
|
||||
internal sealed class RemoteInboundMotionDispatcher
|
||||
{
|
||||
private readonly Func<
|
||||
MovementManager,
|
||||
uint,
|
||||
WorldSession.EntityMotionUpdate,
|
||||
bool> _routeServerMoveTo;
|
||||
private readonly Action<IPhysicsObjHost?, uint> _stickToObject;
|
||||
|
||||
public RemoteInboundMotionDispatcher(
|
||||
Func<MovementManager, uint, WorldSession.EntityMotionUpdate, bool>
|
||||
routeServerMoveTo,
|
||||
Action<IPhysicsObjHost?, uint> stickToObject)
|
||||
{
|
||||
_routeServerMoveTo = routeServerMoveTo
|
||||
?? throw new ArgumentNullException(nameof(routeServerMoveTo));
|
||||
_stickToObject = stickToObject
|
||||
?? throw new ArgumentNullException(nameof(stickToObject));
|
||||
}
|
||||
|
||||
public RemoteInboundMotionDispatchResult Apply(
|
||||
WorldSession.EntityMotionUpdate update,
|
||||
MovementManager movement,
|
||||
IInterpretedMotionSink? animationSink,
|
||||
IPhysicsObjHost? host,
|
||||
uint cellId,
|
||||
uint fallbackForwardClass,
|
||||
Func<bool>? isCurrent = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(movement);
|
||||
bool Current() => isCurrent?.Invoke() ?? true;
|
||||
|
||||
MotionInterpreter motion = movement.Minterp;
|
||||
uint previousForward = motion.InterpretedState.ForwardCommand;
|
||||
RemoteInboundMotionDispatchResult Superseded() => new(
|
||||
RoutedMoveTo: false,
|
||||
AppliedInterpretedState: false,
|
||||
PreviousForwardCommand: previousForward,
|
||||
CurrentForwardCommand: motion.InterpretedState.ForwardCommand,
|
||||
Superseded: true);
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
|
||||
// MovementManager::unpack_movement @00524440: these two calls occur
|
||||
// before the movement-type switch for every accepted packet.
|
||||
motion.InterruptCurrentMovement?.Invoke();
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
motion.UnstickFromObject?.Invoke();
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
|
||||
uint wireStyle = update.MotionState.Stance != 0
|
||||
? 0x80000000u | update.MotionState.Stance
|
||||
: 0x8000003Du;
|
||||
if (motion.InterpretedState.CurrentStyle != wireStyle)
|
||||
{
|
||||
motion.DoMotion(
|
||||
wireStyle,
|
||||
new MovementParameters());
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
}
|
||||
|
||||
// Cases 6/7/8/9 are owned by MoveToManager and return directly.
|
||||
bool routedMoveTo = _routeServerMoveTo(movement, cellId, update);
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
if (routedMoveTo)
|
||||
{
|
||||
return new RemoteInboundMotionDispatchResult(
|
||||
RoutedMoveTo: true,
|
||||
AppliedInterpretedState: false,
|
||||
PreviousForwardCommand: previousForward,
|
||||
CurrentForwardCommand: motion.InterpretedState.ForwardCommand);
|
||||
}
|
||||
|
||||
// Retail's ten-way switch returns zero for every unrecognised type;
|
||||
// only case 0 enters the wholesale interpreted-state funnel.
|
||||
if (update.MotionState.MovementType != 0)
|
||||
{
|
||||
return new RemoteInboundMotionDispatchResult(
|
||||
RoutedMoveTo: false,
|
||||
AppliedInterpretedState: false,
|
||||
PreviousForwardCommand: previousForward,
|
||||
CurrentForwardCommand: motion.InterpretedState.ForwardCommand);
|
||||
}
|
||||
|
||||
InboundInterpretedState interpreted =
|
||||
InboundInterpretedMotionFactory.Create(
|
||||
update.MotionState,
|
||||
fallbackForwardClass);
|
||||
motion.MoveToInterpretedState(interpreted, animationSink);
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
|
||||
// Case-0 tail order @00524583-0052458E: stick after the wholesale
|
||||
// state copy, then write standing_longjump unconditionally.
|
||||
if (update.MotionState.StickyObjectGuid is { } stickyGuid
|
||||
&& stickyGuid != 0)
|
||||
{
|
||||
_stickToObject(host, stickyGuid);
|
||||
if (!Current())
|
||||
return Superseded();
|
||||
}
|
||||
motion.StandingLongJump = update.MotionState.StandingLongJump;
|
||||
|
||||
return new RemoteInboundMotionDispatchResult(
|
||||
RoutedMoveTo: false,
|
||||
AppliedInterpretedState: true,
|
||||
PreviousForwardCommand: previousForward,
|
||||
CurrentForwardCommand: interpreted.ForwardCommand);
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct RemoteInboundMotionDispatchResult(
|
||||
bool RoutedMoveTo,
|
||||
bool AppliedInterpretedState,
|
||||
uint PreviousForwardCommand,
|
||||
uint CurrentForwardCommand,
|
||||
bool Superseded = false)
|
||||
{
|
||||
public bool ForwardCommandChanged =>
|
||||
AppliedInterpretedState
|
||||
&& PreviousForwardCommand != CurrentForwardCommand;
|
||||
}
|
||||
|
|
@ -21,9 +21,7 @@ namespace AcDream.App.Physics;
|
|||
/// <c>ResolveWithTransition</c> sweep + shadow-follows-resolved, so packed PLAYER
|
||||
/// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c>
|
||||
/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is
|
||||
/// the omega handling (players keep the <c>ObservedOmega||seqOmega</c> world-frame
|
||||
/// fallback; NPCs + airborne bodies use <c>ObservedOmega</c>-only body-frame) and
|
||||
/// the <c>!IsPlayerGuid</c>-gated stale-velocity anim-cycle stop. See
|
||||
/// the <c>!IsPlayerGuid</c>-gated stale-velocity animation-cycle stop. See
|
||||
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
|
||||
///
|
||||
/// <para>Shared helpers that GameWindow also calls elsewhere are injected:
|
||||
|
|
@ -68,33 +66,87 @@ internal sealed class RemotePhysicsUpdater
|
|||
AcDream.App.World.LiveEntityRuntime liveEntities,
|
||||
uint localPlayerServerGuid,
|
||||
float dt,
|
||||
System.Action<AcDream.Core.World.WorldEntity> publishRootPose)
|
||||
System.Action<AcDream.Core.World.WorldEntity> publishRootPose,
|
||||
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
|
||||
processAnimationHooks = null,
|
||||
System.Action<uint>? markPartPoseDirty = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(liveEntities);
|
||||
ArgumentNullException.ThrowIfNull(publishRootPose);
|
||||
|
||||
System.Numerics.Vector3? playerPosition = null;
|
||||
if (localPlayerServerGuid != 0
|
||||
&& liveEntities.TryGetWorldEntity(
|
||||
localPlayerServerGuid,
|
||||
out AcDream.Core.World.WorldEntity playerEntity))
|
||||
{
|
||||
playerPosition = playerEntity.Position;
|
||||
}
|
||||
|
||||
liveEntities.CopySpatialRemoteMotionRecordsTo(_spatialRemoteSnapshot);
|
||||
foreach (AcDream.App.World.LiveEntityRecord record in _spatialRemoteSnapshot)
|
||||
{
|
||||
if (record.ServerGuid == localPlayerServerGuid
|
||||
|| (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0
|
||||
|| !liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)
|
||||
|| record.RemoteMotionRuntime is not RemoteMotion remote
|
||||
|| !liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
continue;
|
||||
|
||||
System.Action? handlePartArray = record.AnimationRuntime is AnimatedEntity
|
||||
{ Sequencer: { } sequencer }
|
||||
? sequencer.Manager.UseTime
|
||||
: null;
|
||||
TickHidden(remote, entity, dt, handlePartArray);
|
||||
AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity;
|
||||
AcDream.Core.Physics.RetailObjectActivityResult activity =
|
||||
AcDream.Core.Physics.RetailObjectActivityGate.Evaluate(
|
||||
record.ObjectClock,
|
||||
remote.Body,
|
||||
liveEntities.GetRootObjectClockDisposition(record.ServerGuid)
|
||||
is AcDream.Core.Physics.RetailObjectClockDisposition.Advance,
|
||||
hasPartArray: record.HasPartArray,
|
||||
isStatic: (record.FinalPhysicsState
|
||||
& AcDream.Core.Physics.PhysicsStateFlags.Static) != 0,
|
||||
entity.Position,
|
||||
playerPosition,
|
||||
dt);
|
||||
if (activity is not AcDream.Core.Physics.RetailObjectActivityResult.Active)
|
||||
continue;
|
||||
|
||||
AcDream.Core.Physics.AnimationSequencer? sequencer = animation?.Sequencer;
|
||||
ulong objectClockEpoch = record.ObjectClockEpoch;
|
||||
AcDream.Core.Physics.RetailObjectQuantumBatch batch =
|
||||
record.ObjectClock.Advance(dt);
|
||||
for (int qi = 0; qi < batch.Count; qi++)
|
||||
{
|
||||
if (!liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|
||||
|| !ReferenceEquals(record.WorldEntity, entity)
|
||||
|| record.ObjectClockEpoch != objectClockEpoch)
|
||||
break;
|
||||
if (!TickHidden(
|
||||
remote,
|
||||
entity,
|
||||
batch.GetQuantum(qi),
|
||||
sequencer?.Manager,
|
||||
processAnimationHooks,
|
||||
sequencer,
|
||||
liveEntities,
|
||||
record,
|
||||
objectClockEpoch))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
// PositionManager callbacks can rebucket, delete, or replace this
|
||||
// GUID. Publish only while the captured record/runtime pair still
|
||||
// owns a loaded spatial projection.
|
||||
if (liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|
||||
&& ReferenceEquals(record.WorldEntity, entity))
|
||||
if (batch.Count > 0
|
||||
&& liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|
||||
&& ReferenceEquals(record.WorldEntity, entity)
|
||||
&& record.ObjectClockEpoch == objectClockEpoch)
|
||||
{
|
||||
// Hidden suppresses CPartArray::Update, but HandleEnterWorld
|
||||
// may already have replaced the current sequence pose. Tell
|
||||
// the presentation pass to sample that retained pose once for
|
||||
// this admitted object quantum rather than rebuilding it on
|
||||
// every render frame.
|
||||
markPartPoseDirty?.Invoke(record.ServerGuid);
|
||||
publishRootPose(entity);
|
||||
}
|
||||
}
|
||||
|
|
@ -106,28 +158,75 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// body. <c>serverGuid</c> + the entity id derive from
|
||||
/// <paramref name="ae"/>.Entity; <paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>
|
||||
/// are passed per-call (they change on streaming recentre — never snapshot
|
||||
/// them in the constructor). <paramref name="rootMotionLocalDelta"/> is
|
||||
/// the complete local displacement produced by this frame's preceding
|
||||
/// them in the constructor). <paramref name="rootMotionLocalFrame"/> is
|
||||
/// the complete local Frame produced by this frame's preceding
|
||||
/// <c>CSequence::update</c>, matching retail's
|
||||
/// <c>CPartArray::Update → PositionManager::adjust_offset</c> order.
|
||||
/// </summary>
|
||||
public void Tick(
|
||||
public bool Tick(
|
||||
RemoteMotion rm,
|
||||
AnimatedEntity ae,
|
||||
float dt,
|
||||
System.Numerics.Vector3 rootMotionLocalDelta,
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
int liveCenterY,
|
||||
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
|
||||
processAnimationHooks = null)
|
||||
=> Tick(
|
||||
rm,
|
||||
ae.Entity,
|
||||
ae.Scale,
|
||||
ae.Sequencer,
|
||||
ae,
|
||||
dt,
|
||||
rootMotionLocalFrame,
|
||||
liveCenterX,
|
||||
liveCenterY,
|
||||
processAnimationHooks);
|
||||
|
||||
/// <summary>
|
||||
/// Canonical ordinary-object tick. Render animation is optional: retail
|
||||
/// walks <c>CPhysics::object_maint</c>, so a live object with a
|
||||
/// MovementManager or PositionManager must continue even when it has no
|
||||
/// <c>AnimatedEntity</c> presentation component.
|
||||
/// </summary>
|
||||
public bool Tick(
|
||||
RemoteMotion rm,
|
||||
AcDream.Core.World.WorldEntity entity,
|
||||
float objectScale,
|
||||
AcDream.Core.Physics.AnimationSequencer? sequencer,
|
||||
AnimatedEntity? animationForVelocityCycle,
|
||||
float dt,
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
|
||||
processAnimationHooks = null,
|
||||
AcDream.App.World.LiveEntityRuntime? ownerRuntime = null,
|
||||
AcDream.App.World.LiveEntityRecord? ownerRecord = null,
|
||||
ulong ownerClockEpoch = 0)
|
||||
{
|
||||
uint serverGuid = ae.Entity.ServerGuid;
|
||||
ArgumentNullException.ThrowIfNull(rm);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(rootMotionLocalFrame);
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
uint serverGuid = entity.ServerGuid;
|
||||
// #184 Slice 2b — the UNIFIED per-remote tick. The former Path A
|
||||
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
|
||||
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's
|
||||
// problem" premise) is GONE — every remote now runs the SAME catch-up +
|
||||
// sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap
|
||||
// exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO
|
||||
// player/remote fork; the only surviving player/NPC split is the omega
|
||||
// handling (Step 2 below) and the !IsPlayerGuid-gated anim-cycle stop.
|
||||
// player/remote fork; only the stale animation-cycle stop below remains
|
||||
// player/NPC-specific.
|
||||
//
|
||||
// Stop detection stays explicit on packet receipt (UpdateMotion
|
||||
// ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared ->
|
||||
|
|
@ -143,8 +242,8 @@ internal sealed class RemotePhysicsUpdater
|
|||
// remotes below are explicitly OnWalkable and airborne remotes use
|
||||
// their authoritative velocity/gravity arc, so this is the same
|
||||
// branch expressed through our retained runtime state.
|
||||
System.Numerics.Vector3 scaledRootMotionLocalDelta = !rm.Airborne
|
||||
? rootMotionLocalDelta * ae.Scale
|
||||
System.Numerics.Vector3 scaledRootMotionLocalOrigin = !rm.Airborne
|
||||
? rootMotionLocalFrame.Origin * objectScale
|
||||
: System.Numerics.Vector3.Zero;
|
||||
|
||||
// Step 1: re-apply current motion commands → body.Velocity.
|
||||
|
|
@ -199,11 +298,14 @@ internal sealed class RemotePhysicsUpdater
|
|||
{
|
||||
rm.ServerVelocity = System.Numerics.Vector3.Zero;
|
||||
rm.HasServerVelocity = false;
|
||||
_applyServerControlledVelocityCycle(
|
||||
serverGuid,
|
||||
ae,
|
||||
rm,
|
||||
System.Numerics.Vector3.Zero);
|
||||
if (animationForVelocityCycle is not null)
|
||||
{
|
||||
_applyServerControlledVelocityCycle(
|
||||
serverGuid,
|
||||
animationForVelocityCycle,
|
||||
rm,
|
||||
System.Numerics.Vector3.Zero);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,58 +325,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
// Step 2: integrate rotation manually per tick. We can't
|
||||
// rely on PhysicsBody.update_object here — its MinQuantum
|
||||
// gate (1/30 s) causes it to SKIP integration when our
|
||||
// 60fps render dt (~0.016s) is below the quantum, meaning
|
||||
// rotation never advances. Measured snap per UP was ~129°
|
||||
// = the full expected 1s × 2.24 rad/s, confirming zero
|
||||
// between-tick rotation.
|
||||
//
|
||||
// Manual integration matches retail's FUN_005256b0
|
||||
// apply_physics (Orientation *= quat(ω × dt)). Use
|
||||
// ObservedOmega derived from server UP rotation deltas so
|
||||
// the rate exactly matches server physics — hard-snap on
|
||||
// next UP becomes invisible by construction.
|
||||
// #184 Slice 2b: PLAYERS keep the ObservedOmega||seqOmega fallback +
|
||||
// world-frame (pre-multiply, Concatenate) application inherited from the
|
||||
// former Path A — a circling player sends RunForward+TurnLeft on ONE UM
|
||||
// whose RunForward cycle synthesises zero omega, so ObservedOmega (from
|
||||
// the wire TurnCommand) must carry the turn or the body would not rotate
|
||||
// between UPs ("rectangle when running circles"). NPCs + AIRBORNE bodies
|
||||
// keep ObservedOmega-only, body-frame (post-multiply, Multiply) — a
|
||||
// seqOmega fallback would change NPC turning (handoff 4.1), so the split
|
||||
// is preserved. For an upright body + a yaw (world-Z) omega the two
|
||||
// multiplication orders commute, so this fork is faithful, not cosmetic.
|
||||
// calc_acceleration zeroes Body.Omega for grounded bodies before
|
||||
// UpdatePhysicsInternal; the explicit zero here covers the airborne case
|
||||
// (a wire-set Body.Omega would otherwise double-integrate on top of the
|
||||
// manual rotation).
|
||||
rm.Body.Omega = System.Numerics.Vector3.Zero;
|
||||
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
|
||||
{
|
||||
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
System.Numerics.Vector3 omegaToApply =
|
||||
rm.ObservedOmega.LengthSquared() > 1e-9f ? rm.ObservedOmega : seqOmega;
|
||||
if (omegaToApply.LengthSquared() > 1e-9f)
|
||||
{
|
||||
float angleDelta = omegaToApply.Length() * (float)dt;
|
||||
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
|
||||
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
|
||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
|
||||
}
|
||||
}
|
||||
else if (rm.ObservedOmega.LengthSquared() > 1e-8f)
|
||||
{
|
||||
float omegaMag = rm.ObservedOmega.Length();
|
||||
var axis = rm.ObservedOmega / omegaMag;
|
||||
float angle = omegaMag * dt;
|
||||
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
|
||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
|
||||
}
|
||||
// Step 2: CSequence's complete Frame carries motion-table omega through
|
||||
// the same compose as root translation. PhysicsBody.Omega remains
|
||||
// reserved for the object's physical angular velocity and is
|
||||
// integrated once by UpdatePhysicsInternal below.
|
||||
|
||||
// Step 3: integrate physics — retail FUN_005111D0
|
||||
// UpdatePhysicsInternal. Pure Euler:
|
||||
|
|
@ -289,11 +343,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
// from the UP hard-snap, producing a visible teleport-stride
|
||||
// on slopes (the "staircase" the user reported).
|
||||
//
|
||||
// PlayerMovementController.cs:358 calls UpdatePhysicsInternal
|
||||
// directly for the same reason. Remote motion mirrors that.
|
||||
// Omega is already integrated manually above, so we zero it
|
||||
// here to prevent UpdatePhysicsInternal's own omega pass from
|
||||
// double-integrating.
|
||||
// PlayerMovementController calls UpdatePhysicsInternal directly
|
||||
// for the same reason. Remote motion mirrors that. CSequence's
|
||||
// authored orientation has already entered the shared delta Frame;
|
||||
// Body.Omega remains the separate physical angular-velocity source.
|
||||
var preIntegratePos = rm.Body.Position;
|
||||
// R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
|
||||
// Sticky over ONE shared delta frame (PositionManager::adjust_offset
|
||||
|
|
@ -312,54 +365,73 @@ internal sealed class RemotePhysicsUpdater
|
|||
// adds no translation on top of the catch-up — no double-move.
|
||||
if (rm.Host is { } npcHost)
|
||||
{
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta;
|
||||
if (!rm.Airborne)
|
||||
{
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc =
|
||||
_physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X, rm.Body.Position.Y);
|
||||
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
|
||||
dt: (double)dt,
|
||||
currentBodyPosition: rm.Body.Position,
|
||||
rootMotionLocalDelta: scaledRootMotionLocalDelta,
|
||||
ori: rm.Body.Orientation,
|
||||
interp: rm.Interp,
|
||||
maxSpeed: maxSpeedNpc,
|
||||
terrainNormal: terrainNormalNpc);
|
||||
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
{
|
||||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||
rm.Body.Orientation, offsetNpc),
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
}
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
|
||||
rm.PositionManagerDeltaScratch;
|
||||
pmDelta.Origin = scaledRootMotionLocalOrigin;
|
||||
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
|
||||
? _physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X,
|
||||
rm.Body.Position.Y)
|
||||
: null;
|
||||
rm.Position.ComposeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
pmDelta,
|
||||
rm.Interp,
|
||||
maxSpeedNpc,
|
||||
pmDelta,
|
||||
terrainNormalNpc,
|
||||
inContact: rm.Body.InContact);
|
||||
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
|
||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||
}
|
||||
else if (!rm.Airborne)
|
||||
else
|
||||
{
|
||||
// No PositionManager host yet (pre-binding): apply the catch-up
|
||||
// directly, matching Path A's fallback (:10202).
|
||||
// Airborne suppresses only CPartArray Origin. Retail keeps the
|
||||
// complete root orientation live across the OnWalkable scale
|
||||
// gate in UpdatePositionInternal (0x00512C30).
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
|
||||
rm.PositionManagerDeltaScratch;
|
||||
pmDelta.Origin = scaledRootMotionLocalOrigin;
|
||||
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc =
|
||||
_physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X, rm.Body.Position.Y);
|
||||
rm.Body.Position += rm.Position.ComputeOffset(
|
||||
dt: (double)dt,
|
||||
currentBodyPosition: rm.Body.Position,
|
||||
rootMotionLocalDelta: scaledRootMotionLocalDelta,
|
||||
ori: rm.Body.Orientation,
|
||||
interp: rm.Interp,
|
||||
maxSpeed: maxSpeedNpc,
|
||||
terrainNormal: terrainNormalNpc);
|
||||
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
|
||||
? _physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X,
|
||||
rm.Body.Position.Y)
|
||||
: null;
|
||||
rm.Position.ComposeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
pmDelta,
|
||||
rm.Interp,
|
||||
maxSpeedNpc,
|
||||
pmDelta,
|
||||
terrainNormalNpc,
|
||||
inContact: rm.Body.InContact);
|
||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||
}
|
||||
rm.Body.calc_acceleration();
|
||||
rm.Body.UpdatePhysicsInternal(dt);
|
||||
if (sequencer is { } hookSequencer)
|
||||
processAnimationHooks?.Invoke(entity.Id, hookSequencer);
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var postIntegratePos = rm.Body.Position;
|
||||
uint committedCellId = rm.CellId;
|
||||
|
||||
// Step 4: collision sweep — retail FUN_00514B90 →
|
||||
// FUN_005148A0 → Transition::FindTransitionalPosition.
|
||||
|
|
@ -399,7 +471,7 @@ internal sealed class RemotePhysicsUpdater
|
|||
// Fallback to the human capsule for a shapeless / unresolvable
|
||||
// Setup (GetSetupCylinder returns (0,0)); a zero radius would
|
||||
// degenerate the sweep.
|
||||
var (deR, deH) = _getSetupCylinder(serverGuid, ae.Entity);
|
||||
var (deR, deH) = _getSetupCylinder(serverGuid, entity);
|
||||
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
|
||||
var resolveResult = _physicsEngine.ResolveWithTransition(
|
||||
preIntegratePos, postIntegratePos, rm.CellId,
|
||||
|
|
@ -442,11 +514,11 @@ internal sealed class RemotePhysicsUpdater
|
|||
// the remote's own cylinder and produces ~1m of
|
||||
// horizontal drift on the first jump frame
|
||||
// (validated by [SWEEP-OBJ] traces).
|
||||
movingEntityId: ae.Entity.Id);
|
||||
movingEntityId: entity.Id);
|
||||
|
||||
rm.Body.Position = resolveResult.Position;
|
||||
if (resolveResult.CellId != 0)
|
||||
rm.CellId = resolveResult.CellId;
|
||||
committedCellId = resolveResult.CellId;
|
||||
|
||||
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
|
||||
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
|
||||
|
|
@ -460,9 +532,9 @@ internal sealed class RemotePhysicsUpdater
|
|||
// player would collide with a shadow offset from where the monster
|
||||
// renders (the reverted attempt's "stuck on an invisible monster").
|
||||
// Syncing the shadow to the resolved body every tick makes the
|
||||
// de-overlap PERSIST and keeps collision == render. Re-flood is cheap
|
||||
// MOVEMENT-GATED (#184 review): re-flood only when the resolved
|
||||
// body moved > ~1 cm since the last shadow registration. This is
|
||||
// de-overlap PERSIST and keeps collision == render. Re-flood is
|
||||
// POSE/CELL-GATED (#184 review): re-flood only when the resolved
|
||||
// body moved > ~1 cm, rotated, or entered another cell. This is
|
||||
// SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for
|
||||
// players too — every remote's shadow (player + NPC) is written ONLY
|
||||
// by this loop + the UP-branch tail, both to the resolved body, so a
|
||||
|
|
@ -473,12 +545,6 @@ internal sealed class RemotePhysicsUpdater
|
|||
// to actually-moving remotes — the perf risk the review flagged for
|
||||
// a packed town. (In-place shadow-move + cell-relink-on-change is a
|
||||
// further optimization if profiling still shows churn.)
|
||||
if (System.Numerics.Vector3.DistanceSquared(
|
||||
rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
|
||||
{
|
||||
SyncRemoteShadowToBody(ae.Entity.Id, rm, liveCenterX, liveCenterY);
|
||||
}
|
||||
|
||||
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
|
||||
// (pc:282699-282715) runs after EVERY SetPositionInternal —
|
||||
// remote objects included; a VectorUpdate-launched jump arc
|
||||
|
|
@ -578,21 +644,69 @@ internal sealed class RemotePhysicsUpdater
|
|||
// airborne UseTime contact gate; without it a
|
||||
// chasing NPC that lands stalls until ACE's
|
||||
// ~1 Hz re-emit.
|
||||
ulong landingStateAuthorityVersion =
|
||||
ownerRecord?.StateAuthorityVersion ?? 0UL;
|
||||
rm.Movement.HitGround();
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// DR bookkeeping only (partner of the jump-start
|
||||
// `State |= Gravity`): stops the per-tick gravity
|
||||
// integration for the grounded body.
|
||||
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||||
if (ownerRecord is null
|
||||
|| ownerRecord.StateAuthorityVersion
|
||||
== landingStateAuthorityVersion)
|
||||
{
|
||||
rm.Body.State &=
|
||||
~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
|
||||
if (rm.CellId != 0)
|
||||
ae.Entity.ParentCellId = rm.CellId;
|
||||
ae.Entity.Rotation = rm.Body.Orientation;
|
||||
// SetPositionInternal commits the resolved body/contact result and
|
||||
// root frame as one object before changing cell membership. The
|
||||
// canonical CellId writer can synchronously rebucket loaded to
|
||||
// pending, delete, or replace this GUID, so it is the transaction's
|
||||
// final callback boundary before shadow publication.
|
||||
entity.SetPosition(rm.Body.Position);
|
||||
entity.ParentCellId = committedCellId;
|
||||
entity.Rotation = rm.Body.Orientation;
|
||||
bool cellChanged = committedCellId != 0
|
||||
&& committedCellId != rm.CellId;
|
||||
if (cellChanged)
|
||||
rm.CellId = committedCellId;
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// #184: shadow follows the resolved body, but only after the
|
||||
// canonical rebucket proves this exact owner is still spatially
|
||||
// resident. A pending destination keeps its retained registration
|
||||
// suspended; a callback-created replacement owns another local ID.
|
||||
if (ShouldSynchronizeShadow(
|
||||
cellChanged,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
rm.LastShadowSyncPos,
|
||||
rm.LastShadowSyncOrientation))
|
||||
{
|
||||
SyncRemoteShadowToBody(entity.Id, rm, liveCenterX, liveCenterY);
|
||||
}
|
||||
}
|
||||
|
||||
// R5-V3 (#171): retail UpdateObjectInternal tail —
|
||||
|
|
@ -602,21 +716,17 @@ internal sealed class RemotePhysicsUpdater
|
|||
// sticky 1 s lease watchdog (StickyManager::UseTime
|
||||
// 0x00555610 — a stick not re-issued by a fresh server arm
|
||||
// within 1 s tears itself down). No-op while nothing is stuck.
|
||||
System.Action? handleTargeting = rm.Host is { } targetHost
|
||||
? targetHost.HandleTargetting
|
||||
: null;
|
||||
System.Action? partArrayHandleMovement = ae.Sequencer is { } sequencer
|
||||
? sequencer.Manager.UseTime
|
||||
: null;
|
||||
System.Action? positionUseTime = rm.Host is { } positionHost
|
||||
? positionHost.PositionManager.UseTime
|
||||
: null;
|
||||
AcDream.Core.Physics.RetailObjectManagerTail.Run(
|
||||
checkDetection: null,
|
||||
handleTargeting,
|
||||
movementUseTime: rm.Movement.UseTime,
|
||||
partArrayHandleMovement,
|
||||
positionUseTime);
|
||||
rm.Host?.TargetManager,
|
||||
rm.Movement,
|
||||
sequencer?.Manager,
|
||||
rm.Host?.PositionManager);
|
||||
return IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -628,37 +738,69 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// position managers still consume time. Physics-script and particle owners
|
||||
/// tick later in the shared frame pipeline.
|
||||
/// </summary>
|
||||
public void TickHidden(
|
||||
public bool TickHidden(
|
||||
RemoteMotion rm,
|
||||
AcDream.Core.World.WorldEntity entity,
|
||||
float dt,
|
||||
System.Action? partArrayHandleMovement = null)
|
||||
AcDream.Core.Physics.Motion.MotionTableManager?
|
||||
partArrayHandleMovement = null,
|
||||
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
|
||||
processAnimationHooks = null,
|
||||
AcDream.Core.Physics.AnimationSequencer? sequencer = null,
|
||||
AcDream.App.World.LiveEntityRuntime? ownerRuntime = null,
|
||||
AcDream.App.World.LiveEntityRecord? ownerRecord = null,
|
||||
ulong ownerClockEpoch = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rm);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 preComposePosition = rm.Body.Position;
|
||||
|
||||
// The part-array contribution is the identity frame while Hidden.
|
||||
// Interpolation is the first PositionManager stage in retail; acdream
|
||||
// retains it in RemoteMotionCombiner, ahead of Sticky/Constraint.
|
||||
System.Numerics.Vector3 interpolationOffset = rm.Position.ComputeOffset(
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame positionDelta =
|
||||
rm.PositionManagerDeltaScratch;
|
||||
positionDelta.Reset();
|
||||
rm.Position.ComposeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
System.Numerics.Vector3.Zero,
|
||||
rm.Body.Orientation,
|
||||
positionDelta,
|
||||
rm.Interp,
|
||||
rm.Motion.GetMaxSpeed());
|
||||
var positionDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
{
|
||||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||
rm.Body.Orientation,
|
||||
interpolationOffset),
|
||||
};
|
||||
rm.Motion.GetMaxSpeed(),
|
||||
positionDelta,
|
||||
inContact: rm.Body.InContact);
|
||||
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt);
|
||||
ApplyPositionManagerDelta(rm.Body, positionDelta);
|
||||
|
||||
// Hidden suppresses CPartArray::Update, but process_hooks remains the
|
||||
// final UpdatePositionInternal step. Drain any hook already pending on
|
||||
// the retained sequence before transition/manager time, exactly like
|
||||
// the visible path above.
|
||||
if (sequencer is not null)
|
||||
processAnimationHooks?.Invoke(entity.Id, sequencer);
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 composedPosition = rm.Body.Position;
|
||||
uint committedCellId = rm.CellId;
|
||||
if (rm.CellId != 0
|
||||
&& composedPosition != preComposePosition
|
||||
&& _physicsEngine.LandblockCount > 0)
|
||||
|
|
@ -689,8 +831,8 @@ internal sealed class RemotePhysicsUpdater
|
|||
movingEntityId: entity.Id);
|
||||
rm.Body.Position = resolved.Position;
|
||||
if (resolved.CellId != 0)
|
||||
rm.CellId = resolved.CellId;
|
||||
AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
committedCellId = resolved.CellId;
|
||||
if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
rm.Body,
|
||||
resolved.InContact,
|
||||
resolved.OnWalkable,
|
||||
|
|
@ -699,39 +841,71 @@ internal sealed class RemotePhysicsUpdater
|
|||
previousContact,
|
||||
previousOnWalkable,
|
||||
rm.Movement.HitGround,
|
||||
rm.Motion.LeaveGround);
|
||||
rm.Motion.LeaveGround,
|
||||
() => IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
rm.Airborne = !rm.Body.OnWalkable;
|
||||
}
|
||||
|
||||
// Hidden suppresses mesh/part updates, not SetPositionInternal. Commit
|
||||
// the resolved root before the canonical cell writer enters the same
|
||||
// re-entrant rebucket boundary as the visible path.
|
||||
entity.SetPosition(rm.Body.Position);
|
||||
if (rm.CellId != 0)
|
||||
entity.ParentCellId = rm.CellId;
|
||||
entity.ParentCellId = committedCellId;
|
||||
entity.Rotation = rm.Body.Orientation;
|
||||
if (committedCellId != 0 && committedCellId != rm.CellId)
|
||||
rm.CellId = committedCellId;
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
System.Action? handleTargeting = rm.Host is { } targetHost
|
||||
? targetHost.HandleTargetting
|
||||
: null;
|
||||
System.Action? positionUseTime = rm.Host is { } positionHost
|
||||
? positionHost.PositionManager.UseTime
|
||||
: null;
|
||||
AcDream.Core.Physics.RetailObjectManagerTail.Run(
|
||||
checkDetection: null,
|
||||
handleTargeting,
|
||||
movementUseTime: rm.Movement.UseTime,
|
||||
rm.Host?.TargetManager,
|
||||
rm.Movement,
|
||||
partArrayHandleMovement,
|
||||
positionUseTime);
|
||||
rm.Host?.PositionManager);
|
||||
return IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch);
|
||||
}
|
||||
|
||||
private static bool IsCurrentOwner(
|
||||
AcDream.App.World.LiveEntityRuntime? ownerRuntime,
|
||||
AcDream.App.World.LiveEntityRecord? ownerRecord,
|
||||
RemoteMotion remote,
|
||||
AcDream.Core.World.WorldEntity entity,
|
||||
ulong ownerClockEpoch) =>
|
||||
ownerRuntime is null
|
||||
|| ownerRecord is null
|
||||
|| (ownerRecord.ObjectClockEpoch == ownerClockEpoch
|
||||
&& ownerRuntime.IsCurrentSpatialRemoteMotion(ownerRecord, remote)
|
||||
&& ReferenceEquals(ownerRecord.WorldEntity, entity));
|
||||
|
||||
/// <summary>
|
||||
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
|
||||
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
|
||||
/// stand-in for retail's <c>Frame::combine</c> in
|
||||
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
|
||||
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
|
||||
/// <c>globaltolocalvec</c> output — 0x00555430), so combining = rotating it
|
||||
/// out by the body orientation. An untouched (identity) rotation means "no
|
||||
/// turn"; the P5 pin (identity quaternion = heading 0) makes compass addition
|
||||
/// the exact frame-combine here. Moved from GameWindow (#184 Slice 2a); the
|
||||
/// <c>globaltolocalvec</c> output — 0x00555430), so combining rotates it
|
||||
/// out by the body's current orientation and post-multiplies the complete
|
||||
/// relative orientation. Moved from GameWindow (#184 Slice 2a); the
|
||||
/// DR tick is its only caller.
|
||||
/// </summary>
|
||||
private static void ApplyPositionManagerDelta(
|
||||
|
|
@ -741,10 +915,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
if (delta.Origin != System.Numerics.Vector3.Zero)
|
||||
body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
|
||||
if (!delta.Orientation.IsIdentity)
|
||||
body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
|
||||
body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation)
|
||||
+ delta.GetHeading());
|
||||
body.Orientation * delta.Orientation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -756,7 +930,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is
|
||||
/// passed in (<paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>)
|
||||
/// rather than snapshotted, since it moves on recentre. Updates
|
||||
/// <see cref="RemoteMotion.LastShadowSyncPos"/> so callers can movement-gate.
|
||||
/// <see cref="RemoteMotion.LastShadowSyncPos"/> and
|
||||
/// <see cref="RemoteMotion.LastShadowSyncOrientation"/> so callers can
|
||||
/// pose-gate. Rotation matters because Setup collision geometry may be
|
||||
/// multipart or offset from the root.
|
||||
/// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
|
||||
/// UP-branch tail.
|
||||
/// </summary>
|
||||
|
|
@ -774,8 +951,53 @@ internal sealed class RemotePhysicsUpdater
|
|||
liveCenterY,
|
||||
authoritativeCellId ?? rm.CellId);
|
||||
rm.LastShadowSyncPosition = rm.Body.Position;
|
||||
rm.LastShadowSyncOrientation = rm.Body.Orientation;
|
||||
}
|
||||
|
||||
internal static bool ShouldSynchronizeShadowPose(
|
||||
System.Numerics.Vector3 currentPosition,
|
||||
System.Numerics.Quaternion currentOrientation,
|
||||
System.Numerics.Vector3 lastPosition,
|
||||
System.Numerics.Quaternion lastOrientation)
|
||||
{
|
||||
if (System.Numerics.Vector3.DistanceSquared(
|
||||
currentPosition,
|
||||
lastPosition) > 1e-4f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float currentLengthSquared = currentOrientation.LengthSquared();
|
||||
float lastLengthSquared = lastOrientation.LengthSquared();
|
||||
if (!float.IsFinite(currentLengthSquared)
|
||||
|| !float.IsFinite(lastLengthSquared)
|
||||
|| currentLengthSquared < 1e-12f
|
||||
|| lastLengthSquared < 1e-12f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float normalizedDot = MathF.Abs(
|
||||
System.Numerics.Quaternion.Dot(
|
||||
currentOrientation,
|
||||
lastOrientation)
|
||||
/ MathF.Sqrt(currentLengthSquared * lastLengthSquared));
|
||||
return !float.IsFinite(normalizedDot) || normalizedDot < 0.99999f;
|
||||
}
|
||||
|
||||
internal static bool ShouldSynchronizeShadow(
|
||||
bool cellChanged,
|
||||
System.Numerics.Vector3 currentPosition,
|
||||
System.Numerics.Quaternion currentOrientation,
|
||||
System.Numerics.Vector3 lastPosition,
|
||||
System.Numerics.Quaternion lastOrientation) =>
|
||||
cellChanged
|
||||
|| ShouldSynchronizeShadowPose(
|
||||
currentPosition,
|
||||
currentOrientation,
|
||||
lastPosition,
|
||||
lastOrientation);
|
||||
|
||||
public void SyncRemoteShadowToBody(
|
||||
uint entityId,
|
||||
AcDream.Core.Physics.PhysicsBody body,
|
||||
|
|
|
|||
|
|
@ -65,9 +65,13 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
bool ContactResolved,
|
||||
Vector3 Position,
|
||||
uint CellId,
|
||||
Quaternion Orientation);
|
||||
Quaternion Orientation,
|
||||
bool Superseded = false);
|
||||
|
||||
private readonly record struct PendingPlacement(
|
||||
LiveEntityRecord Record,
|
||||
ulong PositionAuthorityVersion,
|
||||
ulong VelocityAuthorityVersion,
|
||||
ILiveEntityRemotePlacementRuntime Remote,
|
||||
PhysicsBody Body,
|
||||
WorldEntity Entity,
|
||||
|
|
@ -95,9 +99,53 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
bool Airborne,
|
||||
Vector3 LastServerPosition,
|
||||
double LastServerPositionTime,
|
||||
Vector3 LastShadowSyncPosition);
|
||||
Vector3 LastShadowSyncPosition,
|
||||
Quaternion LastShadowSyncOrientation);
|
||||
|
||||
/// <summary>
|
||||
/// Applies a placement to whichever exact incarnation is current at this
|
||||
/// call boundary. Packet handlers should use the token-bearing overload;
|
||||
/// this form serves non-wire orchestration and retains the same callback
|
||||
/// revalidation once admitted.
|
||||
/// </summary>
|
||||
internal Result TryApply(
|
||||
ILiveEntityRemotePlacementRuntime remote,
|
||||
WorldEntity entity,
|
||||
Vector3 requestedWorldPosition,
|
||||
uint requestedCellId,
|
||||
Vector3 requestedCellLocalPosition,
|
||||
Quaternion requestedOrientation,
|
||||
double gameTime,
|
||||
bool destinationProjectionVisible,
|
||||
ushort generation,
|
||||
ushort positionSequence)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord record))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Remote placement for 0x{entity.ServerGuid:X8} requires a live incarnation.");
|
||||
}
|
||||
|
||||
return TryApply(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
record.VelocityAuthorityVersion,
|
||||
remote,
|
||||
entity,
|
||||
requestedWorldPosition,
|
||||
requestedCellId,
|
||||
requestedCellLocalPosition,
|
||||
requestedOrientation,
|
||||
gameTime,
|
||||
destinationProjectionVisible,
|
||||
generation,
|
||||
positionSequence);
|
||||
}
|
||||
|
||||
internal Result TryApply(
|
||||
LiveEntityRecord expectedRecord,
|
||||
ulong expectedPositionAuthorityVersion,
|
||||
ulong expectedVelocityAuthorityVersion,
|
||||
ILiveEntityRemotePlacementRuntime remote,
|
||||
WorldEntity entity,
|
||||
Vector3 requestedWorldPosition,
|
||||
|
|
@ -111,8 +159,15 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord liveRecord)
|
||||
|| !ReferenceEquals(liveRecord, expectedRecord)
|
||||
|| !_liveEntities.IsCurrentPositionAuthority(
|
||||
expectedRecord,
|
||||
expectedPositionAuthorityVersion)
|
||||
|| liveRecord.Generation != generation
|
||||
|| !ReferenceEquals(liveRecord.WorldEntity, entity)
|
||||
|| !ReferenceEquals(liveRecord.RemoteMotionRuntime, remote)
|
||||
|| liveRecord.PhysicsBody is null
|
||||
|| !ReferenceEquals(liveRecord.PhysicsBody, remote.Body))
|
||||
{
|
||||
|
|
@ -124,6 +179,7 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
bool wasOnWalkable = liveRecord.PhysicsBody.OnWalkable;
|
||||
RollbackPlacement rollback = CaptureRollback(remote, liveRecord.PhysicsBody);
|
||||
if (_pending.TryGetValue(entity.ServerGuid, out PendingPlacement prior)
|
||||
&& ReferenceEquals(prior.Record, liveRecord)
|
||||
&& prior.Generation == generation)
|
||||
{
|
||||
wasInContact = prior.WasInContact;
|
||||
|
|
@ -132,6 +188,9 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
}
|
||||
|
||||
PendingPlacement request = new(
|
||||
liveRecord,
|
||||
expectedPositionAuthorityVersion,
|
||||
expectedVelocityAuthorityVersion,
|
||||
remote,
|
||||
liveRecord.PhysicsBody,
|
||||
entity,
|
||||
|
|
@ -146,7 +205,8 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
rollback);
|
||||
if (!destinationProjectionVisible)
|
||||
{
|
||||
ParkPending(request, requestedCellLocalPosition);
|
||||
if (!ParkPending(request, requestedCellLocalPosition))
|
||||
return Superseded(request);
|
||||
return new Result(
|
||||
true,
|
||||
false,
|
||||
|
|
@ -155,10 +215,13 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
requestedOrientation);
|
||||
}
|
||||
ResolveResult placement = Resolve(request);
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (!placement.Ok)
|
||||
{
|
||||
_pending.Remove(entity.ServerGuid);
|
||||
RollBackDeferredPlacement(request);
|
||||
if (!RollBackDeferredPlacement(request))
|
||||
return Superseded(request);
|
||||
return new Result(
|
||||
false,
|
||||
false,
|
||||
|
|
@ -250,7 +313,9 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
|
||||
private Result CommitResolved(PendingPlacement request, ResolveResult placement)
|
||||
{
|
||||
RemoteTeleportPlacement.Apply(
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (!RemoteTeleportPlacement.Apply(
|
||||
request.Remote,
|
||||
request.Body,
|
||||
placement,
|
||||
|
|
@ -258,16 +323,29 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
request.RequestedOrientation,
|
||||
request.GameTime,
|
||||
request.WasInContact,
|
||||
request.WasOnWalkable);
|
||||
request.WasOnWalkable,
|
||||
() => IsCurrent(request),
|
||||
() => IsVelocityCurrent(request)))
|
||||
{
|
||||
return Superseded(request);
|
||||
}
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (request.Remote.CellId != placement.CellId)
|
||||
request.Remote.CellId = placement.CellId;
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
request.Remote.LastServerPosition = placement.Position;
|
||||
request.Remote.LastServerPositionTime = request.GameTime;
|
||||
request.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||
request.Remote.LastShadowSyncOrientation = Quaternion.Zero;
|
||||
request.Entity.SetPosition(request.Body.Position);
|
||||
request.Entity.ParentCellId = placement.CellId;
|
||||
request.Entity.Rotation = request.Body.Orientation;
|
||||
if (_liveEntities.TryGetRecord(request.Entity.ServerGuid, out LiveEntityRecord record))
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (_liveEntities.TryGetRecord(request.Entity.ServerGuid, out LiveEntityRecord record)
|
||||
&& ReferenceEquals(record, request.Record))
|
||||
{
|
||||
bool deferShadowRestore =
|
||||
record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)
|
||||
|
|
@ -276,10 +354,15 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
request.Entity.ServerGuid,
|
||||
request.Generation,
|
||||
deferShadowRestore);
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (!deferShadowRestore)
|
||||
{
|
||||
request.Remote.LastShadowSyncPosition = request.Body.Position;
|
||||
request.Remote.LastShadowSyncOrientation = request.Body.Orientation;
|
||||
_syncResolvedShadow(request.Entity, request.Body, placement.CellId);
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
}
|
||||
}
|
||||
return new Result(
|
||||
|
|
@ -290,8 +373,10 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
request.Body.Orientation);
|
||||
}
|
||||
|
||||
private void ParkPending(PendingPlacement request, Vector3 requestedCellLocalPosition)
|
||||
private bool ParkPending(PendingPlacement request, Vector3 requestedCellLocalPosition)
|
||||
{
|
||||
if (!IsCurrent(request))
|
||||
return false;
|
||||
request.Body.Orientation = request.RequestedOrientation;
|
||||
request.Body.SnapToCell(
|
||||
request.RequestedCellId,
|
||||
|
|
@ -308,13 +393,19 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
request.Remote.Airborne = true;
|
||||
if (request.Remote.CellId != request.RequestedCellId)
|
||||
request.Remote.CellId = request.RequestedCellId;
|
||||
if (!IsCurrent(request))
|
||||
return false;
|
||||
request.Remote.LastServerPosition = request.RequestedWorldPosition;
|
||||
request.Remote.LastServerPositionTime = request.GameTime;
|
||||
request.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||
request.Remote.LastShadowSyncOrientation = Quaternion.Zero;
|
||||
request.Entity.SetPosition(request.RequestedWorldPosition);
|
||||
request.Entity.ParentCellId = request.RequestedCellId;
|
||||
request.Entity.Rotation = request.RequestedOrientation;
|
||||
if (!IsCurrent(request))
|
||||
return false;
|
||||
_pending[request.Entity.ServerGuid] = request;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
|
|
@ -325,6 +416,7 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
if (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending))
|
||||
{
|
||||
if (record.WorldEntity is null
|
||||
|| !ReferenceEquals(record, pending.Record)
|
||||
|| !ReferenceEquals(record.WorldEntity, pending.Entity)
|
||||
|| record.Generation != pending.Generation)
|
||||
{
|
||||
|
|
@ -351,7 +443,10 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
pending = pending with { Remote = currentRemote };
|
||||
_pending[record.ServerGuid] = pending;
|
||||
}
|
||||
if (record.Snapshot.PositionSequence != pending.PositionSequence)
|
||||
if (!_liveEntities.IsCurrentPositionAuthority(
|
||||
pending.Record,
|
||||
pending.PositionAuthorityVersion)
|
||||
|| record.Snapshot.PositionSequence != pending.PositionSequence)
|
||||
{
|
||||
// A newer accepted UpdatePosition rebucketed the projection
|
||||
// before its TryApply call could replace this request. Keep
|
||||
|
|
@ -361,6 +456,8 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
|
||||
_pending.Remove(record.ServerGuid);
|
||||
ResolveResult placement = Resolve(pending);
|
||||
if (!IsCurrent(pending))
|
||||
return;
|
||||
if (!placement.Ok)
|
||||
RollBackDeferredPlacement(pending);
|
||||
else
|
||||
|
|
@ -388,11 +485,14 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
remote.Airborne,
|
||||
remote.LastServerPosition,
|
||||
remote.LastServerPositionTime,
|
||||
remote.LastShadowSyncPosition);
|
||||
remote.LastShadowSyncPosition,
|
||||
remote.LastShadowSyncOrientation);
|
||||
}
|
||||
|
||||
private void RollBackDeferredPlacement(PendingPlacement pending)
|
||||
private bool RollBackDeferredPlacement(PendingPlacement pending)
|
||||
{
|
||||
if (!IsCurrentAuthority(pending))
|
||||
return false;
|
||||
RollbackPlacement rollback = pending.Rollback;
|
||||
PhysicsBody body = pending.Body;
|
||||
body.Orientation = rollback.Orientation;
|
||||
|
|
@ -412,21 +512,26 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
pending.Remote.LastServerPosition = rollback.LastServerPosition;
|
||||
pending.Remote.LastServerPositionTime = rollback.LastServerPositionTime;
|
||||
pending.Remote.LastShadowSyncPosition = rollback.LastShadowSyncPosition;
|
||||
pending.Remote.LastShadowSyncOrientation = rollback.LastShadowSyncOrientation;
|
||||
pending.Entity.SetPosition(rollback.Position);
|
||||
pending.Entity.ParentCellId = rollback.CellId;
|
||||
pending.Entity.Rotation = rollback.Orientation;
|
||||
if (!IsCurrentAuthority(pending))
|
||||
return false;
|
||||
|
||||
if (rollback.CellId == 0)
|
||||
_liveEntities.WithdrawLiveEntityProjectionToCellless(pending.Entity.ServerGuid);
|
||||
else
|
||||
_liveEntities.RebucketLiveEntity(pending.Entity.ServerGuid, rollback.CellId);
|
||||
|
||||
if (!_liveEntities.TryGetRecord(
|
||||
if (!IsCurrentAuthority(pending)
|
||||
|| !_liveEntities.TryGetRecord(
|
||||
pending.Entity.ServerGuid,
|
||||
out LiveEntityRecord restored)
|
||||
|| !ReferenceEquals(restored, pending.Record)
|
||||
|| restored.Generation != pending.Generation)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rollback.CellId == 0)
|
||||
|
|
@ -435,7 +540,7 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
pending.Entity.ServerGuid,
|
||||
pending.Generation,
|
||||
false);
|
||||
return;
|
||||
return IsCurrentAuthority(pending);
|
||||
}
|
||||
|
||||
bool deferShadowRestore =
|
||||
|
|
@ -445,13 +550,42 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
pending.Entity.ServerGuid,
|
||||
pending.Generation,
|
||||
deferShadowRestore);
|
||||
if (!IsCurrentAuthority(pending))
|
||||
return false;
|
||||
if (deferShadowRestore)
|
||||
return;
|
||||
return true;
|
||||
|
||||
pending.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||
pending.Remote.LastShadowSyncOrientation = Quaternion.Zero;
|
||||
_syncResolvedShadow(pending.Entity, pending.Body, rollback.CellId);
|
||||
return IsCurrentAuthority(pending);
|
||||
}
|
||||
|
||||
private bool IsCurrentAuthority(PendingPlacement request) =>
|
||||
_liveEntities.IsCurrentPositionAuthority(
|
||||
request.Record,
|
||||
request.PositionAuthorityVersion)
|
||||
&& request.Record.Generation == request.Generation
|
||||
&& ReferenceEquals(request.Record.WorldEntity, request.Entity)
|
||||
&& ReferenceEquals(request.Record.PhysicsBody, request.Body);
|
||||
|
||||
private bool IsCurrent(PendingPlacement request) =>
|
||||
IsCurrentAuthority(request)
|
||||
&& ReferenceEquals(request.Record.RemoteMotionRuntime, request.Remote);
|
||||
|
||||
private bool IsVelocityCurrent(PendingPlacement request) =>
|
||||
_liveEntities.IsCurrentVelocityAuthority(
|
||||
request.Record,
|
||||
request.VelocityAuthorityVersion);
|
||||
|
||||
private static Result Superseded(PendingPlacement request) => new(
|
||||
Applied: false,
|
||||
ContactResolved: false,
|
||||
Position: request.Body.Position,
|
||||
CellId: request.Remote.CellId,
|
||||
Orientation: request.Body.Orientation,
|
||||
Superseded: true);
|
||||
|
||||
private static bool IsPlayerGuid(uint guid) =>
|
||||
(guid & 0xFF000000u) == 0x50000000u;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ public static class RemoteTeleportHook
|
|||
{
|
||||
private const WeenieError TeleportCancelContext = (WeenieError)0x3Cu;
|
||||
|
||||
public static void Execute(RemoteTeleportHookActions actions)
|
||||
public static bool Execute(
|
||||
RemoteTeleportHookActions actions,
|
||||
Func<bool>? isCurrent = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actions.CancelMoveTo);
|
||||
ArgumentNullException.ThrowIfNull(actions.UnStick);
|
||||
|
|
@ -21,12 +23,26 @@ public static class RemoteTeleportHook
|
|||
ArgumentNullException.ThrowIfNull(actions.NotifyTeleported);
|
||||
ArgumentNullException.ThrowIfNull(actions.ReportCollisionEnd);
|
||||
|
||||
bool Current() => isCurrent?.Invoke() ?? true;
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.CancelMoveTo(TeleportCancelContext);
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.UnStick();
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.StopInterpolating();
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.UnConstrain();
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.NotifyTeleported();
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.ReportCollisionEnd();
|
||||
return Current();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ namespace AcDream.App.Physics;
|
|||
/// </summary>
|
||||
internal static class RemoteTeleportPlacement
|
||||
{
|
||||
internal static void Apply(
|
||||
internal static bool Apply(
|
||||
ILiveEntityRemotePlacementRuntime remote,
|
||||
PhysicsBody body,
|
||||
ResolveResult placement,
|
||||
|
|
@ -20,7 +20,9 @@ internal static class RemoteTeleportPlacement
|
|||
Quaternion orientation,
|
||||
double gameTime,
|
||||
bool previousContact,
|
||||
bool previousOnWalkable)
|
||||
bool previousOnWalkable,
|
||||
Func<bool>? isCurrent = null,
|
||||
Func<bool>? isVelocityCurrent = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
|
|
@ -55,7 +57,7 @@ internal static class RemoteTeleportPlacement
|
|||
body.ContactPlaneIsWater = false;
|
||||
}
|
||||
|
||||
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
if (!PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
body,
|
||||
placement.InContact,
|
||||
placement.OnWalkable,
|
||||
|
|
@ -64,9 +66,15 @@ internal static class RemoteTeleportPlacement
|
|||
previousContact,
|
||||
previousOnWalkable,
|
||||
remote.HitGround,
|
||||
remote.LeaveGround);
|
||||
remote.Airborne = !body.OnWalkable;
|
||||
|
||||
remote.LeaveGround,
|
||||
isCurrent,
|
||||
isVelocityCurrent))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (isVelocityCurrent?.Invoke() != false)
|
||||
remote.Airborne = !body.OnWalkable;
|
||||
return isCurrent?.Invoke() ?? true;
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector3 value) =>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
509
src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
Normal file
509
src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
Normal 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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
90
src/AcDream.App/Rendering/StaticLiveRootCommitter.cs
Normal file
90
src/AcDream.App/Rendering/StaticLiveRootCommitter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
123
src/AcDream.App/World/RetailInboundEventDispatcher.cs
Normal file
123
src/AcDream.App/World/RetailInboundEventDispatcher.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue