feat(runtime): prepare authored SetPosition movers

This commit is contained in:
Erik 2026-08-01 09:16:09 +02:00
parent 237d1184d2
commit 442cb8f97b
8 changed files with 1516 additions and 70 deletions

View file

@ -320,7 +320,7 @@ public sealed class RuntimeEntityDirectory
PhysicsStateFlags state)
{
EnsureKnown(record);
record.FinalPhysicsState = state;
record.SetFinalPhysicsState(state);
}
internal bool StopMissileAfterCollision(

View file

@ -164,6 +164,14 @@ public sealed class RuntimeEntityRecord
PhysicsBody.State = FinalPhysicsState;
}
internal void SetFinalPhysicsState(PhysicsStateFlags state)
{
if (state == FinalPhysicsState)
return;
PhysicsStateMutationVersion++;
FinalPhysicsState = state;
}
/// <summary>
/// Retail collision reporting clears Missile, AlignPath, and PathClipped
/// directly on the live CPhysicsObj. Keep the canonical record and its

View file

@ -0,0 +1,156 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Physics;
internal enum RuntimeSetPositionMoverPreparationStatus
{
Prepared,
RetrySetupUnavailable,
RejectedAuthority,
InvalidData,
}
/// <summary>
/// Distinguishes a Setup payload which has not arrived yet from a completed
/// lookup whose result is absent. Retail supplies its dummy placement sphere
/// only inside <c>CPhysicsObj::SetPosition</c>; an unavailable lookup must not
/// manufacture that fallback while asynchronous preparation is still live.
/// </summary>
internal readonly record struct RuntimeSetPositionMoverSetup(
bool IsResolved,
uint SetupTableId,
FlatSetupCollision? Collision)
{
internal static RuntimeSetPositionMoverSetup Unavailable => default;
internal static RuntimeSetPositionMoverSetup ResolvedAbsent =>
new(true, 0u, null);
internal static RuntimeSetPositionMoverSetup Resolved(
uint setupTableId,
FlatSetupCollision collision) =>
new(
true,
setupTableId != 0u
? setupTableId
: throw new ArgumentOutOfRangeException(nameof(setupTableId)),
collision ?? throw new ArgumentNullException(nameof(collision)));
}
/// <summary>
/// Explicit, immutable inputs surrounding retail's authored mover shape.
/// None of these values are inferred from presentation state.
/// </summary>
internal readonly record struct RuntimeSetPositionMoverPreparation(
RuntimeSetPositionMoverSetup Setup,
RuntimeSetPositionOperationKind Kind,
double GameTime,
PhysicsPlacementClass PlacementClass,
PhysicsSetPositionFlags Flags,
Vector3 Line = default,
float ScatterRadiusX = 0f,
float ScatterRadiusY = 0f,
uint ScatterAttempts = 0u,
float ShadowWorldOffsetX = 0f,
float ShadowWorldOffsetY = 0f,
RuntimePortalPlacementAuthority Portal = default);
/// <summary>
/// Pure preparation port of the mover inputs consumed by retail
/// <c>CPhysicsObj::SetPosition</c> (0x005160C0). It preserves the complete DAT
/// sphere order; Core's SPHEREPATH port applies retail's two-sphere cap later.
/// </summary>
internal static class RuntimeSetPositionMoverPreparer
{
internal static bool TryBuild(
RuntimeEntityRecord record,
in CreateObject.ServerPosition acceptedPosition,
uint canonicalSetupTableId,
RuntimeSetPositionOperationKind acceptedKind,
RuntimePortalPlacementAuthority acceptedPortal,
ulong velocityAuthorityVersion,
in RuntimeSetPositionMoverPreparation preparation,
out RuntimeSetPositionCommand command)
{
ArgumentNullException.ThrowIfNull(record);
command = default;
if (!preparation.Setup.IsResolved
|| preparation.Kind != acceptedKind
|| preparation.Portal != acceptedPortal
|| (canonicalSetupTableId == 0u
? preparation.Setup.SetupTableId != 0u
|| preparation.Setup.Collision is not null
: preparation.Setup.SetupTableId != canonicalSetupTableId
|| preparation.Setup.Collision is null))
{
return false;
}
CreateObject.ServerPosition position = acceptedPosition;
Vector3 cellLocal = new(
position.PositionX,
position.PositionY,
position.PositionZ);
Vector3 world = new(
cellLocal.X + preparation.ShadowWorldOffsetX,
cellLocal.Y + preparation.ShadowWorldOffsetY,
cellLocal.Z);
Quaternion orientation = new(
position.RotationX,
position.RotationY,
position.RotationZ,
position.RotationW);
float scale = record.Snapshot.Physics?.Scale
?? record.Snapshot.ObjScale
?? 1f;
FlatSetupCollision? setup = preparation.Setup.Collision;
ImmutableArray<FlatCollisionSphere> spheres = setup?.Spheres
?? ImmutableArray<FlatCollisionSphere>.Empty;
// CPartArray::GetStepUpHeight/GetStepDownHeight are Setup properties,
// not sphere properties. An authored Setup with no collision spheres
// still supplies both values; only a genuinely absent Setup takes the
// retail dummy path with exact zero steps and no scale multiplication.
float stepUp = setup is not null ? setup.StepUpHeight * scale : 0f;
float stepDown = setup is not null ? setup.StepDownHeight * scale : 0f;
EntityCollisionFlags collisionFlags =
EntityCollisionFlagsExt.FromPwdBitfield(
record.Snapshot.ObjectDescriptionFlags ?? 0u);
ObjectInfoState moverFlags = collisionFlags.ToMoverState();
if (collisionFlags.HasFlag(EntityCollisionFlags.IsPlayer))
moverFlags |= ObjectInfoState.IsPlayer;
var request = new PhysicsSetPositionRequest(
world,
orientation,
position.LandblockId,
cellLocal,
spheres,
scale,
stepUp,
stepDown,
record.FinalPhysicsState,
moverFlags,
record.Key?.LocalEntityId ?? 0u,
preparation.PlacementClass,
preparation.Flags,
preparation.Line,
preparation.ScatterRadiusX,
preparation.ScatterRadiusY,
preparation.ScatterAttempts);
command = new RuntimeSetPositionCommand(
request,
preparation.Kind,
preparation.GameTime,
velocityAuthorityVersion,
preparation.ShadowWorldOffsetX,
preparation.ShadowWorldOffsetY,
preparation.Portal);
return true;
}
}

View file

@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
@ -31,11 +32,18 @@ internal enum RuntimeEntityPlacementStage
CancelledAwaitingAcknowledgement,
}
internal enum RuntimeEntityPlacementPreparationKind : byte
{
LegacyDirect,
AuthoredMover,
}
internal readonly record struct RuntimeEntityPlacementToken(
ulong SessionLifetimeVersion,
RuntimeEntityKey Entity,
ulong PositionAuthorityVersion,
ulong OperationId)
ulong OperationId,
RuntimeEntityPlacementPreparationKind PreparationKind)
{
internal bool IsValid => OperationId != 0UL
&& Entity.LocalEntityId != 0u
@ -132,14 +140,16 @@ internal readonly record struct RuntimeSetPositionOwnershipSnapshot(
int DeferredBucketOrderCount,
int UnboundDeferredCellCount,
int UnboundDeferredCellOrderCount,
int PreparedMoverCount)
int PreparedMoverCount,
int MoverPreparationAuthorityCount)
{
internal bool IndexesConsistent =>
LostDeadlineCount == LostDeadlineNodeCount
&& LostDeadlineCount == LostDeadlineIndexCount
&& ExpiredLostCellCount == ExpiredLostCellIndexCount
&& DeferredBucketCount == DeferredBucketOrderCount
&& UnboundDeferredCellCount == UnboundDeferredCellOrderCount;
&& UnboundDeferredCellCount == UnboundDeferredCellOrderCount
&& MoverPreparationAuthorityCount <= ActiveOperationCount;
internal bool IsConverged => ActiveOperationCount == 0
&& AwaitingPreparationCount == 0
@ -154,7 +164,8 @@ internal readonly record struct RuntimeSetPositionOwnershipSnapshot(
&& DeferredBucketOrderCount == 0
&& UnboundDeferredCellCount == 0
&& UnboundDeferredCellOrderCount == 0
&& PreparedMoverCount == 0;
&& PreparedMoverCount == 0
&& MoverPreparationAuthorityCount == 0;
}
/// <summary>
@ -176,6 +187,20 @@ internal sealed class RuntimeSetPositionState : IDisposable
uint CellId,
ulong CollisionGeneration);
private readonly record struct MoverPreparationAuthority(
ulong OperationId,
CreateObject.ServerPosition AcceptedPosition,
uint SetupTableId,
ulong PositionAuthorityVersion,
ulong VelocityAuthorityVersion,
ulong StateAuthorityVersion,
ulong VectorAuthorityVersion,
ulong ObjDescAuthorityVersion,
ulong CreateIntegrationVersion,
ulong PhysicsStateMutationVersion,
bool Prepared,
RuntimeSetPositionCommand PreparedCommand);
private sealed class Operation
{
internal required RuntimeEntityRecord Record { get; init; }
@ -249,6 +274,8 @@ internal sealed class RuntimeSetPositionState : IDisposable
_lostDeadlineNodeIndex = [];
private readonly Dictionary<RuntimeEntityKey, PhysicsSetPositionRequest>
_preparedMovers = [];
private readonly Dictionary<RuntimeEntityKey, MoverPreparationAuthority>
_moverPreparationAuthorities = [];
private readonly LinkedList<RuntimeEntityKey> _expiredLostCells = [];
private readonly Dictionary<RuntimeEntityKey, LinkedListNode<RuntimeEntityKey>>
_expiredLostCellNodes = [];
@ -299,7 +326,8 @@ internal sealed class RuntimeSetPositionState : IDisposable
_deferredBucketOrder.Count,
_unboundDeferredByCell.Count,
_unboundDeferredCellOrder.Count,
_preparedMovers.Count);
_preparedMovers.Count,
_moverPreparationAuthorities.Count);
}
internal int PendingProjectionCount => _pendingProjection.Count;
@ -345,35 +373,71 @@ internal sealed class RuntimeSetPositionState : IDisposable
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
RuntimeEntityPlacementToken token = BeginAcceptedPlacement(
RuntimeEntityPlacementToken token = BeginAcceptedPlacementCore(
record,
expectedPositionAuthorityVersion,
command.Kind,
command.Portal);
command.Portal,
captureMoverPreparationAuthority: false);
if (!token.IsValid)
{
return Rejected(command.Physics);
}
return SubmitPreparedPlacement(token, command);
return SubmitPreparedPlacementCore(
token,
command,
allowDirectUnsealed: true);
}
internal RuntimeEntityPlacementToken BeginAcceptedPlacement(
RuntimeEntityRecord record,
ulong expectedPositionAuthorityVersion,
RuntimeSetPositionOperationKind kind,
RuntimePortalPlacementAuthority portal = default)
RuntimePortalPlacementAuthority portal = default) =>
BeginAcceptedPlacementCore(
record,
expectedPositionAuthorityVersion,
kind,
portal,
captureMoverPreparationAuthority: false);
internal RuntimeEntityPlacementToken BeginAuthoredPlacement(
RuntimeEntityRecord record,
ulong expectedPositionAuthorityVersion,
RuntimeSetPositionOperationKind kind,
RuntimePortalPlacementAuthority portal = default) =>
BeginAcceptedPlacementCore(
record,
expectedPositionAuthorityVersion,
kind,
portal,
captureMoverPreparationAuthority: true);
private RuntimeEntityPlacementToken BeginAcceptedPlacementCore(
RuntimeEntityRecord record,
ulong expectedPositionAuthorityVersion,
RuntimeSetPositionOperationKind kind,
RuntimePortalPlacementAuthority portal,
bool captureMoverPreparationAuthority)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(record);
CreateObject.ServerPosition? acceptedPosition =
record.Snapshot.Physics?.Position ?? record.Snapshot.Position;
if (record.Key is not { } key
|| !_entities.IsCurrent(record)
|| record.PositionAuthorityVersion
!= expectedPositionAuthorityVersion
|| (captureMoverPreparationAuthority
&& acceptedPosition is null)
|| !(portal.IsEmpty
|| (portal.IsValid
&& kind is RuntimeSetPositionOperationKind
.LocalAuthoritative)))
.LocalAuthoritative
&& acceptedPosition is { } portalPosition
&& portal.Projection.DestinationCell
== portalPosition.LandblockId)))
{
return default;
}
@ -382,7 +446,10 @@ internal sealed class RuntimeSetPositionState : IDisposable
_entities.SessionLifetimeVersion,
key,
expectedPositionAuthorityVersion,
checked(++_nextOperationId));
checked(++_nextOperationId),
captureMoverPreparationAuthority
? RuntimeEntityPlacementPreparationKind.AuthoredMover
: RuntimeEntityPlacementPreparationKind.LegacyDirect);
var replacement = new Operation
{
Record = record,
@ -442,20 +509,95 @@ internal sealed class RuntimeSetPositionState : IDisposable
displaced.CollisionGeneration;
}
_operations[key] = replacement;
if (captureMoverPreparationAuthority)
{
_moverPreparationAuthorities[key] = CapturePreparationAuthority(
replacement,
acceptedPosition!.Value,
prepared: false);
}
if (discard is { } cancelled)
PublishPlacement(cancelled);
return IsCurrent(replacement) ? token : default;
}
internal RuntimeSetPositionMoverPreparationStatus PrepareMover(
in RuntimeEntityPlacementToken token,
in RuntimeSetPositionMoverPreparation preparation,
out RuntimeSetPositionCommand command)
{
EnsureNotDisposed();
command = default;
if (!token.IsValid
|| !_operations.TryGetValue(token.Entity, out Operation? operation)
|| operation.Token != token
|| operation.Stage
is not RuntimeEntityPlacementStage.AwaitingPreparation
|| !IsCurrent(operation)
|| !_moverPreparationAuthorities.TryGetValue(
token.Entity,
out MoverPreparationAuthority authority)
|| authority.OperationId != token.OperationId
|| !IsPreparationAuthorityCurrent(operation, authority))
{
return RuntimeSetPositionMoverPreparationStatus.RejectedAuthority;
}
if (!preparation.Setup.IsResolved)
{
return RuntimeSetPositionMoverPreparationStatus
.RetrySetupUnavailable;
}
if (!RuntimeSetPositionMoverPreparer.TryBuild(
operation.Record,
authority.AcceptedPosition,
authority.SetupTableId,
operation.Kind,
operation.Portal,
authority.VelocityAuthorityVersion,
preparation,
out command)
|| !IsStructurallyValid(command.Physics))
{
command = default;
return RuntimeSetPositionMoverPreparationStatus.InvalidData;
}
_moverPreparationAuthorities[token.Entity] = authority with
{
Prepared = true,
PreparedCommand = command,
};
return RuntimeSetPositionMoverPreparationStatus.Prepared;
}
internal RuntimeSetPositionOutcome SubmitPreparedPlacement(
in RuntimeEntityPlacementToken token,
in RuntimeSetPositionCommand command)
in RuntimeSetPositionCommand command) =>
SubmitPreparedPlacementCore(
token,
command,
allowDirectUnsealed: token.PreparationKind
is RuntimeEntityPlacementPreparationKind.LegacyDirect);
private RuntimeSetPositionOutcome SubmitPreparedPlacementCore(
in RuntimeEntityPlacementToken token,
in RuntimeSetPositionCommand command,
bool allowDirectUnsealed)
{
EnsureNotDisposed();
Operation? operation = null;
bool ownsToken = token.IsValid
&& _operations.TryGetValue(token.Entity, out operation)
&& operation.Token == token;
MoverPreparationAuthority exactAuthority = default;
bool hasPreparationAuthority = token.IsValid
&& _moverPreparationAuthorities.TryGetValue(
token.Entity,
out exactAuthority)
&& exactAuthority.OperationId == token.OperationId;
if (!ownsToken
|| operation is null
|| operation.Stage
@ -465,6 +607,13 @@ internal sealed class RuntimeSetPositionState : IDisposable
|| !double.IsFinite(command.GameTime)
|| command.Kind != operation.Kind
|| command.Portal != operation.Portal
|| (hasPreparationAuthority
? !exactAuthority.Prepared
|| exactAuthority.PreparedCommand != command
|| !IsPreparationAuthorityCurrent(
operation,
exactAuthority)
: !allowDirectUnsealed)
|| (command.ExpectedVelocityAuthorityVersion != 0UL
&& operation.Record.VelocityAuthorityVersion
!= command.ExpectedVelocityAuthorityVersion))
@ -511,6 +660,13 @@ internal sealed class RuntimeSetPositionState : IDisposable
};
var canonicalCommand = command with { Physics = canonicalRequest };
operation.Command = canonicalCommand;
if (hasPreparationAuthority)
{
_moverPreparationAuthorities[token.Entity] = exactAuthority with
{
PreparedCommand = canonicalCommand,
};
}
if (operation.InheritedLostDeadline
&& !operation.WithdrawalAcknowledged
@ -657,11 +813,13 @@ internal sealed class RuntimeSetPositionState : IDisposable
if (pending.Kind is RuntimePlacementProjectionKind.Place)
{
operation.Stage = RuntimeEntityPlacementStage.AwaitingCommitAcknowledgement;
_moverPreparationAuthorities.Remove(operation.Key);
return _operations.Remove(operation.Key);
}
if (!operation.WakeableLostCell
&& !operation.InheritedLostDeadline)
{
_moverPreparationAuthorities.Remove(operation.Key);
return _operations.Remove(operation.Key);
}
@ -934,7 +1092,8 @@ internal sealed class RuntimeSetPositionState : IDisposable
_entities.SessionLifetimeVersion,
key,
record.PositionAuthorityVersion,
checked(++_nextOperationId)),
checked(++_nextOperationId),
RuntimeEntityPlacementPreparationKind.AuthoredMover),
Key = key,
PositionAuthorityVersion = record.PositionAuthorityVersion,
SessionLifetimeVersion = _entities.SessionLifetimeVersion,
@ -953,6 +1112,14 @@ internal sealed class RuntimeSetPositionState : IDisposable
RequiresPreparation = !hasPrepared,
};
_operations.Add(key, operation);
_moverPreparationAuthorities[key] = CapturePreparationAuthority(
operation,
ServerPositionFrom(
cellId,
body.CellPosition.Frame.Origin,
body.Orientation),
prepared: hasPrepared,
command);
RuntimeSetPositionOutcome parked = ParkDeferred(
operation,
result,
@ -1159,6 +1326,7 @@ internal sealed class RuntimeSetPositionState : IDisposable
_lostDeadlineNodes.Clear();
_lostDeadlineNodeIndex.Clear();
_preparedMovers.Clear();
_moverPreparationAuthorities.Clear();
_pendingProjection.Clear();
_expiredLostCells.Clear();
_expiredLostCellNodes.Clear();
@ -1215,6 +1383,22 @@ internal sealed class RuntimeSetPositionState : IDisposable
CurrentCellId = null,
},
};
if (_moverPreparationAuthorities.ContainsKey(operation.Key))
{
RebindPreparedCommand(operation);
}
else
{
_moverPreparationAuthorities[operation.Key] =
CapturePreparationAuthority(
operation,
ServerPositionFrom(
result.CellId,
result.CellLocalPosition,
result.Orientation),
prepared: true,
operation.Command);
}
IndexDeferred(operation);
operation.Stage = operation.RequiresPreparation
? RuntimeEntityPlacementStage.AwaitingPreparation
@ -1243,12 +1427,16 @@ internal sealed class RuntimeSetPositionState : IDisposable
return;
}
if (!IsDeferredWakePreparationCurrent(operation))
return;
UnindexDeferred(operation);
operation.Command = operation.Command with
{
GameTime = _physics.PlacementSimulationTime(
operation.Command.GameTime),
};
RebindPreparedCommand(operation);
PhysicsSetPositionResult result = IsStructurallyValid(
operation.Command.Physics)
? _physics.Engine.SetPosition(
@ -1626,7 +1814,8 @@ internal sealed class RuntimeSetPositionState : IDisposable
_entities.SessionLifetimeVersion,
key,
record.PositionAuthorityVersion,
checked(++_nextOperationId)),
checked(++_nextOperationId),
RuntimeEntityPlacementPreparationKind.LegacyDirect),
Key = key,
PositionAuthorityVersion = record.PositionAuthorityVersion,
SessionLifetimeVersion = _entities.SessionLifetimeVersion,
@ -1673,6 +1862,104 @@ internal sealed class RuntimeSetPositionState : IDisposable
|| operation.Record.VelocityAuthorityVersion
== operation.SourceVelocityAuthorityVersion;
private static MoverPreparationAuthority CapturePreparationAuthority(
Operation operation,
in CreateObject.ServerPosition acceptedPosition,
bool prepared,
in RuntimeSetPositionCommand preparedCommand = default) => new(
operation.Token.OperationId,
acceptedPosition,
CanonicalSetupTableId(operation.Record),
operation.Record.PositionAuthorityVersion,
operation.Record.VelocityAuthorityVersion,
operation.Record.StateAuthorityVersion,
operation.Record.VectorAuthorityVersion,
operation.Record.ObjDescAuthorityVersion,
operation.Record.CreateIntegrationVersion,
operation.Record.PhysicsStateMutationVersion,
prepared,
preparedCommand);
private static uint CanonicalSetupTableId(RuntimeEntityRecord record) =>
record.Snapshot.Physics?.SetupTableId
?? record.Snapshot.SetupTableId
?? 0u;
private static CreateObject.ServerPosition ServerPositionFrom(
uint cellId,
Vector3 cellLocal,
Quaternion orientation) => new(
cellId,
cellLocal.X,
cellLocal.Y,
cellLocal.Z,
orientation.W,
orientation.X,
orientation.Y,
orientation.Z);
private static bool IsPreparationAuthorityCurrent(
Operation operation,
in MoverPreparationAuthority authority) =>
operation.Record.PositionAuthorityVersion
== authority.PositionAuthorityVersion
&& operation.Record.VelocityAuthorityVersion
== authority.VelocityAuthorityVersion
&& operation.Record.StateAuthorityVersion
== authority.StateAuthorityVersion
&& operation.Record.VectorAuthorityVersion
== authority.VectorAuthorityVersion
&& operation.Record.ObjDescAuthorityVersion
== authority.ObjDescAuthorityVersion
&& operation.Record.CreateIntegrationVersion
== authority.CreateIntegrationVersion
&& operation.Record.PhysicsStateMutationVersion
== authority.PhysicsStateMutationVersion;
private void RebindPreparedCommand(Operation operation)
{
if (_moverPreparationAuthorities.TryGetValue(
operation.Key,
out MoverPreparationAuthority authority)
&& authority.OperationId == operation.Token.OperationId
&& authority.Prepared)
{
_moverPreparationAuthorities[operation.Key] = authority with
{
PreparedCommand = operation.Command,
};
}
}
private bool IsDeferredWakePreparationCurrent(Operation operation)
{
if (_moverPreparationAuthorities.TryGetValue(
operation.Key,
out MoverPreparationAuthority authority)
&& authority.OperationId == operation.Token.OperationId
&& authority.Prepared
&& authority.PreparedCommand == operation.Command
&& IsPreparationAuthorityCurrent(operation, authority))
{
return true;
}
operation.SourceVelocityAuthorityVersion =
operation.Record.VelocityAuthorityVersion;
operation.RequiresPreparation = true;
operation.Stage = RuntimeEntityPlacementStage.AwaitingPreparation;
operation.PreparedCommandAwaitingWithdrawalAck = null;
_moverPreparationAuthorities[operation.Key] =
CapturePreparationAuthority(
operation,
ServerPositionFrom(
operation.ExactCellId,
operation.Result.CellLocalPosition,
operation.Result.Orientation),
prepared: false);
return false;
}
internal void PublishCancellation(
in RuntimePlacementCancellationReceipt receipt)
{
@ -1712,6 +1999,7 @@ internal sealed class RuntimeSetPositionState : IDisposable
CancelExactLostKey(key);
if (!_operations.Remove(key, out Operation? operation))
return false;
_moverPreparationAuthorities.Remove(key);
UnindexDeferred(operation);
if (!preserveLostFamily && cancelLostFamily)
CancelLostFamilyDeadlines(operation);