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

@ -159,14 +159,16 @@ the pre-existing line-ending/stat-noise paths are deliberately excluded.
## Remaining work - required order
### 1. Exact authored mover preparation
### 1. Exact authored mover preparation - complete 2026-08-01
Build every SetPosition request from Setup's ordered authored spheres, exact
scale/presence semantics, StepUp/StepDown heights, flags, cell-local frame and
orientation, and current position/vector/state authority versions. Do not
synthesize a cylinder from visual radius/height or pre-mutate canonical state.
The dormant preparation contract is implemented and independently reviewed.
It binds the Runtime-owned accepted frame and exact Setup DID, preserves the
ordered authored spheres and scale/step semantics, seals the returned command,
and forces stale deferred residents through exact re-preparation without
pre-mutating canonical state. See
[`2026-08-01-runtime-set-position-authored-mover-preparation.md`](2026-08-01-runtime-set-position-authored-mover-preparation.md).
### 2. Atomic local-controller/body publication
### 2. Atomic local-controller/body publication - next
Prepare off-canonical, then perform one Runtime-validated atomic transaction
which publishes the exact same body to graphical and no-window controllers.

View file

@ -0,0 +1,106 @@
# Runtime SetPosition authored mover preparation - 2026-08-01
## Scope
This is placement Slice 4B2 checkpoint 3. It adds the dormant,
presentation-independent preparation contract used to turn an accepted Runtime
position into retail's exact `CPhysicsObj::SetPosition` mover input. No App or
Headless production route consumes the contract yet, so game behavior is
unchanged and AP-1/AD-1 remain open.
## Retail oracle
The implementation was checked against the named September 2013 client:
- `PhysicsDesc::PhysicsDesc` `0x0051D4D0`
- `PhysicsDesc::UnPack` `0x0051DDD0`
- `CPhysicsObj::set_description` `0x00514F40`
- `CPhysicsObj::SetPosition` `0x005160C0`
- `SPHEREPATH::init_sphere` `0x0050C670`
- `CPartArray::GetNumSphere` `0x00518060`
- `CPartArray::GetSphere` `0x00518070`
- `CPartArray::GetStepUpHeight` `0x005180D0`
- `CPartArray::GetStepDownHeight` `0x005180F0`
- `CTransition::init_object` `0x00509E40`
- `OBJECTINFO::init` `0x0050CF30`
SetPosition calls `CTransition::init_object(..., state = 0)` directly. It does
not use the ordinary-movement `CPhysicsObj::get_object_info` path. Consequently
the SetPosition state carries the player/PK/PKLite/impenetrable classifications
(plus acdream's pointer-free entry-restriction carrier), but it does not add
Contact, OnWalkable, PathClipped, FreeRotate, or EdgeSlide. Ethereal and
`step_down = !Missile` are separate `OBJECTINFO` fields derived from the current
physics state.
## Exact preparation contract
- Runtime captures the complete accepted server frame under the exact entity,
session, position, vector/velocity, wire-state, final-physics-state mutation,
object-description, and create-integration authorities. A host cannot
substitute a second position.
- Collision-world X/Y uses the target landblock's active live-centered offsets;
full cell ID, cell-local XYZ, and the complete quaternion remain unchanged.
- Setup resolution is bound to the canonical Setup DID. A known but unavailable
Setup remains retryable. Resolved-absent is valid only when the canonical
object has no Setup. An authored empty Setup remains distinct and still
contributes its scaled StepUp/StepDown heights.
- The complete ordered Setup sphere list is retained. Core later applies
retail's `min(count, 2)` traversal cap. The successfully resolved no-PartArray
or zero-sphere arm reaches Core with an empty list, where SetPosition supplies
the retail dummy sphere `(0,0,0.1)`, radius `0.1`, scale `1.0`.
- Scale precedence is `PhysicsDesc.Scale ?? EntitySpawn.ObjScale ?? 1.0`.
Present zero and finite negative values are preserved. Scale is not consumed
by a resolved-absent dummy mover.
- Every authored command is sealed to the exact preparation operation. Manual,
stale, replaced, or merely value-equivalent commands cannot bypass the seal.
- A wire-state, final-state mutation (including NoDraw and missile-stop),
vector/velocity, description, or create change during a deferred cell wait
returns the resident to `AwaitingPreparation`; the stale mover is never
replayed when the collision generation wakes.
- Preparation mutates no body, clock, FullCell, spatial/shadow registration,
bucket, camera, world entity, or presentation resource.
Legacy direct SetPosition remains a distinct token mode so the dormant slice
does not change existing call sites or their warmed allocation ceiling. If a
legacy operation becomes deferred and later needs new authored data, the
presence of Runtime's preparation authority makes the exact seal mandatory.
## Ownership and validation
`RuntimeSetPositionState` owns one exact-key preparation-authority entry only
for operations which require authored preparation. The entry dies with the
operation on replacement, acknowledgement, cancellation, delete, session
reset, or disposal and participates in terminal convergence accounting.
Preparation-only validation checks the exact cell frame, live-centered world
position, values consumed by the first two retail spheres, Setup-derived step
heights, line/scatter inputs, and bounded scatter attempts. The legacy direct
validator retains its prior behavior, including retail's dummy-sphere and
first-two-sphere semantics.
## Gates and review
- Focused authored-mover plus SetPosition residence tests: 80/80.
- Runtime Release build: zero warnings and zero errors.
- Complete Runtime project under invariant culture: 595/595.
- Complete Release solution with installed DAT/pak fixtures: 10,342 passed /
4 intentional skips.
- Retail-conformance review: canonical frame, DID binding, scale/step/sphere
behavior, exact SetPosition flags, and deferred wake checked against the
named addresses above.
- Architecture/adversarial review: command sealing, legacy promotion,
replacement, deferred wake, direct compatibility, allocation, reset, GUID
reuse, and ownership convergence checked.
The three ordinary current-culture Runtime failures are pre-existing Swedish-
locale formatting assumptions (`0,5` versus `0.5` and localized sky text); the
same complete project passes under invariant culture.
## Next checkpoint
Implement the dormant atomic local-player physics publication transaction:
prepare a private controller/body/clock without canonical mutation, evaluate
SetPosition against that candidate, then publish the exact same body relation
to `RuntimeEntityRecord` and `RuntimeLocalPlayerMovementState` in one callback-
free Runtime commit. App and Headless production activation remains a later
checkpoint.

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

View file

@ -0,0 +1,715 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Tests.Physics;
public sealed class RuntimeSetPositionMoverPreparationTests
{
private const uint Cell = 0xA9B40021u;
private const uint SetupId = 0x02000001u;
[Fact]
public void PreparedMoverPreservesCompleteAuthoredRetailInput()
{
using var lifetime = new RuntimeEntityObjectLifetime();
float half = MathF.Sqrt(0.5f);
var acceptedPosition = new CreateObject.ServerPosition(
Cell,
12.5f,
23.5f,
34.5f,
half,
0f,
half,
0f);
RuntimeEntityRecord record = CreateRecord(
lifetime,
objScale: 3f,
physicsScale: 2f,
objectDescriptionFlags: 0x8u | 0x20u | 0x200000u
| 0x2000000u | 0x100000u | 0x400000u,
position: acceptedPosition);
const PhysicsStateFlags state = PhysicsStateFlags.Gravity
| PhysicsStateFlags.EdgeSlide
| PhysicsStateFlags.PathClipped;
lifetime.Entities.SetFinalPhysicsState(record, state);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
ImmutableArray<FlatCollisionSphere> spheres =
[
new(new Vector3(1f, 2f, 3f), 0.1f),
new(new Vector3(4f, 5f, 6f), 0.2f),
new(new Vector3(7f, 8f, 9f), 0.3f),
];
FlatSetupCollision setup = Setup(spheres, 0.25f, -0.5f);
var portal = default(RuntimePortalPlacementAuthority);
var input = new RuntimeSetPositionMoverPreparation(
RuntimeSetPositionMoverSetup.Resolved(SetupId, setup),
RuntimeSetPositionOperationKind.RemoteAuthoritative,
GameTime: 42.25d,
PhysicsPlacementClass.Corpse,
PhysicsSetPositionFlags.Line | PhysicsSetPositionFlags.Scatter,
Line: new Vector3(3f, 4f, 5f),
ScatterRadiusX: 6f,
ScatterRadiusY: 7f,
ScatterAttempts: 8u,
ShadowWorldOffsetX: 9f,
ShadowWorldOffsetY: 10f,
Portal: portal);
RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics
.SetPosition.PrepareMover(token, input, out var command);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status);
Assert.Equal(spheres, command.Physics.Spheres);
Assert.Equal(2f, command.Physics.Scale);
Assert.Equal(0.5f, command.Physics.StepUpHeight);
Assert.Equal(-1f, command.Physics.StepDownHeight);
Assert.Equal(Cell, command.Physics.CellId);
Assert.Equal(new Vector3(12.5f, 23.5f, 34.5f),
command.Physics.CellLocalPosition);
Assert.Equal(new Vector3(
12.5f + 9f,
23.5f + 10f,
34.5f), command.Physics.Position);
Assert.Equal(new Quaternion(0f, half, 0f, half),
command.Physics.Orientation);
Assert.Equal(state, command.Physics.MoverPhysicsState);
Assert.Equal(
ObjectInfoState.IsPlayer
| ObjectInfoState.IsPK
| ObjectInfoState.IsPKLite
| ObjectInfoState.IsImpenetrable
| ObjectInfoState.CanBypassMoveRestrictions,
command.Physics.MoverFlags);
Assert.False(command.Physics.MoverFlags.HasFlag(
ObjectInfoState.EdgeSlide));
Assert.False(command.Physics.MoverFlags.HasFlag(
ObjectInfoState.PathClipped));
Assert.False(command.Physics.MoverFlags.HasFlag(
ObjectInfoState.FreeRotate));
Assert.False(command.Physics.MoverFlags.HasFlag(
ObjectInfoState.Contact));
Assert.False(command.Physics.MoverFlags.HasFlag(
ObjectInfoState.OnWalkable));
Assert.Equal(record.Key!.Value.LocalEntityId,
command.Physics.MovingEntityId);
Assert.Equal(PhysicsPlacementClass.Corpse,
command.Physics.PlacementClass);
Assert.Equal(input.Flags, command.Physics.Flags);
Assert.Equal(input.Line, command.Physics.Line);
Assert.Equal(6f, command.Physics.ScatterRadiusX);
Assert.Equal(7f, command.Physics.ScatterRadiusY);
Assert.Equal(8u, command.Physics.ScatterAttempts);
Assert.Equal(42.25d, command.GameTime);
Assert.Equal(9f, command.ShadowWorldOffsetX);
Assert.Equal(10f, command.ShadowWorldOffsetY);
Assert.Equal(portal, command.Portal);
Assert.Equal(record.VelocityAuthorityVersion,
command.ExpectedVelocityAuthorityVersion);
}
[Fact]
public void LocalPortalKindAndAuthorityArePreservedWithoutInference()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(lifetime);
var portal = new RuntimePortalPlacementAuthority(
Present: true,
RevealGeneration: 17,
TeleportSequence: 3,
new RuntimeWorldHostProjectionToken(17, Cell));
RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition
.BeginAuthoredPlacement(
record,
record.PositionAuthorityVersion,
RuntimeSetPositionOperationKind.LocalAuthoritative,
portal);
var input = Input(ResolvedEmptySetup()) with
{
Kind = RuntimeSetPositionOperationKind.LocalAuthoritative,
Portal = portal,
};
RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics
.SetPosition.PrepareMover(token, input, out var command);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status);
Assert.Equal(RuntimeSetPositionOperationKind.LocalAuthoritative,
command.Kind);
Assert.Equal(portal, command.Portal);
}
[Fact]
public void UnavailableSetupRetriesWithoutInventingDummyWhileAbsentAndEmptyResolve()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(lifetime, setupTableId: null);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
RuntimeSetPositionMoverPreparationStatus unavailable = lifetime.Physics
.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.Unavailable),
out RuntimeSetPositionCommand unavailableCommand);
Assert.Equal(
RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable,
unavailable);
Assert.Equal(default, unavailableCommand);
Assert.Equal(1, lifetime.Physics.CaptureOwnership()
.AwaitingSetPositionPreparationCount);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.ResolvedAbsent),
out RuntimeSetPositionCommand absent));
Assert.Empty(absent.Physics.Spheres);
record = CreateRecord(lifetime, guid: 0x70002002u);
token = Begin(lifetime, record);
FlatSetupCollision authoredEmpty = Setup(
ImmutableArray<FlatCollisionSphere>.Empty,
0f,
0f);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.Resolved(
SetupId,
authoredEmpty)),
out RuntimeSetPositionCommand empty));
Assert.Empty(empty.Physics.Spheres);
}
[Fact]
public void KnownSetupMustResolveTheExactCanonicalDid()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(lifetime);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
Assert.Equal(
RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.Unavailable),
out _));
Assert.Equal(
RuntimeSetPositionMoverPreparationStatus.InvalidData,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.ResolvedAbsent),
out _));
Assert.Equal(
RuntimeSetPositionMoverPreparationStatus.InvalidData,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.Resolved(
SetupId + 1u,
Setup(ImmutableArray<FlatCollisionSphere>.Empty, 0f, 0f))),
out _));
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(ResolvedEmptySetup()),
out _));
}
[Fact]
public void AbsentSetupRejectsInventedCollisionPayload()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(
lifetime,
setupTableId: null);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
Assert.Equal(
RuntimeSetPositionMoverPreparationStatus.InvalidData,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.Resolved(
SetupId,
Setup(ImmutableArray<FlatCollisionSphere>.Empty, 0f, 0f))),
out _));
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.ResolvedAbsent),
out _));
}
[Fact]
public void AuthoredEmptySetupStillSuppliesScaledStepHeights()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(
lifetime,
objScale: 2f);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
FlatSetupCollision authoredEmpty = Setup(
ImmutableArray<FlatCollisionSphere>.Empty,
0.25f,
-0.5f);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.Resolved(
SetupId,
authoredEmpty)),
out RuntimeSetPositionCommand command));
Assert.Empty(command.Physics.Spheres);
Assert.Equal(0.5f, command.Physics.StepUpHeight);
Assert.Equal(-1f, command.Physics.StepDownHeight);
}
[Fact]
public void AuthoredTokenRejectsManualUnsealedSubmission()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(lifetime);
AttachBody(lifetime, record);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
RuntimeSetPositionCommand invented = new(
new PhysicsSetPositionRequest(
new Vector3(1f, 2f, 3f),
Quaternion.Identity,
Cell,
new Vector3(1f, 2f, 3f),
ImmutableArray<FlatCollisionSphere>.Empty,
1f,
0f,
0f,
record.FinalPhysicsState,
ObjectInfoState.None,
record.Key!.Value.LocalEntityId),
RuntimeSetPositionOperationKind.RemoteAuthoritative,
1d,
record.VelocityAuthorityVersion);
RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition
.SubmitPreparedPlacement(token, invented);
Assert.Equal(RuntimeSetPositionStatus.Rejected, outcome.Status);
Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount);
Assert.Equal(1, lifetime.Physics.CaptureOwnership()
.AwaitingSetPositionPreparationCount);
}
[Fact]
public void RepreparationReplacesTheExactSealedCommand()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(lifetime);
AttachBody(lifetime, record);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(ResolvedEmptySetup()) with { GameTime = 1d },
out RuntimeSetPositionCommand first));
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(ResolvedEmptySetup()) with { GameTime = 2d },
out RuntimeSetPositionCommand second));
Assert.NotEqual(first, second);
RuntimeSetPositionOutcome stale = lifetime.Physics.SetPosition
.SubmitPreparedPlacement(token, first);
Assert.Equal(RuntimeSetPositionStatus.Rejected, stale.Status);
RuntimeSetPositionOutcome current = lifetime.Physics.SetPosition
.SubmitPreparedPlacement(token, second);
Assert.NotEqual(RuntimeSetPositionStatus.Rejected, current.Status);
}
[Theory]
[InlineData(null, null, 1f)]
[InlineData(0f, null, 0f)]
[InlineData(-2f, null, -2f)]
[InlineData(3f, 0f, 0f)]
[InlineData(3f, -4f, -4f)]
public void ScalePreservesAbsentZeroNegativeAndPhysicsPrecedence(
float? objScale,
float? physicsScale,
float expected)
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(
lifetime,
objScale,
physicsScale);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics
.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.Resolved(SetupId, Setup(
[new FlatCollisionSphere(Vector3.Zero, 0.5f)],
0.25f,
-0.5f))),
out RuntimeSetPositionCommand command);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status);
Assert.Equal(expected, command.Physics.Scale);
Assert.Equal(0.25f * expected, command.Physics.StepUpHeight);
Assert.Equal(-0.5f * expected, command.Physics.StepDownHeight);
}
[Theory]
[InlineData(float.NaN)]
[InlineData(float.PositiveInfinity)]
[InlineData(float.NegativeInfinity)]
public void NonFiniteScaleRejectsWhenAuthoredSphereConsumesIt(float scale)
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(lifetime, scale, null);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics
.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.Resolved(
SetupId,
Setup(
[new FlatCollisionSphere(Vector3.Zero, 0.5f)],
0f,
0f))),
out RuntimeSetPositionCommand command);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.InvalidData, status);
Assert.Equal(default, command);
Assert.Equal(1, lifetime.Physics.CaptureOwnership()
.AwaitingSetPositionPreparationCount);
}
[Theory]
[InlineData(float.NaN)]
[InlineData(float.PositiveInfinity)]
[InlineData(float.NegativeInfinity)]
public void NonFiniteScaleIsNotConsumedByResolvedAbsentDummy(float scale)
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(
lifetime,
objScale: scale,
setupTableId: null);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics
.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.ResolvedAbsent),
out RuntimeSetPositionCommand command);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status);
Assert.Equal(scale, command.Physics.Scale);
Assert.Equal(0f, command.Physics.StepUpHeight);
Assert.Equal(0f, command.Physics.StepDownHeight);
Assert.Empty(command.Physics.Spheres);
}
[Fact]
public void PreparationDoesNotMutateCanonicalOrPresentationState()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(lifetime);
var body = new PhysicsBody
{
Position = new Vector3(1f, 2f, 3f),
Orientation = Quaternion.Identity,
State = PhysicsStateFlags.Gravity,
InWorld = true,
};
body.SnapToCell(0xA8B40001u, body.Position, body.Position);
lifetime.Entities.SetPhysicsBody(record, body);
lifetime.Entities.SetFullCell(record, 0xA8B40001u, 0xA8B4FFFFu);
Vector3 priorPosition = body.Position;
uint priorCell = record.FullCellId;
ulong priorSpatial = record.SpatialAuthorityVersion;
RuntimeEntityPlacementToken token = Begin(lifetime, record);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(ResolvedEmptySetup()),
out _));
Assert.Same(body, record.PhysicsBody);
Assert.Equal(priorPosition, body.Position);
Assert.Equal(priorCell, record.FullCellId);
Assert.Equal(priorSpatial, record.SpatialAuthorityVersion);
Assert.True(body.InWorld);
Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount);
Assert.Equal(1, lifetime.Physics.CaptureOwnership()
.AwaitingSetPositionPreparationCount);
Assert.False(lifetime.Physics.SetPosition
.TryGetPreparedMoverSphereCount(record, out _));
}
[Theory]
[InlineData(AuthorityReplacement.Position)]
[InlineData(AuthorityReplacement.Velocity)]
[InlineData(AuthorityReplacement.Vector)]
[InlineData(AuthorityReplacement.State)]
[InlineData(AuthorityReplacement.ObjectDescription)]
[InlineData(AuthorityReplacement.Create)]
public void AnyAuthorityReplacementRejectsPreparedSubmissionWithoutMutation(
AuthorityReplacement replacement)
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(lifetime);
var body = new PhysicsBody
{
Position = new Vector3(1f, 2f, 3f),
Orientation = Quaternion.Identity,
State = record.FinalPhysicsState,
InWorld = true,
};
body.SnapToCell(0xA8B40001u, body.Position, body.Position);
lifetime.Entities.SetPhysicsBody(record, body);
lifetime.Entities.SetFullCell(record, 0xA8B40001u, 0xA8B4FFFFu);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared,
lifetime.Physics.SetPosition.PrepareMover(
token,
Input(ResolvedEmptySetup()),
out RuntimeSetPositionCommand command));
Vector3 priorPosition = body.Position;
uint priorCell = record.FullCellId;
ReplaceAuthority(lifetime, record, replacement);
RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition
.SubmitPreparedPlacement(token, command);
Assert.Equal(RuntimeSetPositionStatus.Rejected, outcome.Status);
Assert.Equal(priorPosition, body.Position);
Assert.Equal(priorCell, record.FullCellId);
Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount);
Assert.False(lifetime.Physics.SetPosition
.TryGetPreparedMoverSphereCount(record, out _));
RuntimeEntityPlacementToken replacementToken = Begin(lifetime, record);
Assert.True(replacementToken.IsValid);
Assert.NotEqual(token, replacementToken);
}
[Fact]
public void ThirdAuthoredSphereIsPreservedButNotConsumedByCoreValidation()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(lifetime);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
FlatSetupCollision setup = Setup(
[
new FlatCollisionSphere(Vector3.Zero, 0.1f),
new FlatCollisionSphere(Vector3.UnitZ, 0.2f),
new FlatCollisionSphere(
new Vector3(float.NaN, 0f, 0f),
0.3f),
], 0f, 0f);
RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics
.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.Resolved(SetupId, setup)),
out _);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status);
}
[Fact]
public void SessionReplacementInvalidatesExactTokenAndClearsPreparationAuthority()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(lifetime);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
_ = lifetime.BeginSessionClear();
RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics
.SetPosition.PrepareMover(
token,
Input(RuntimeSetPositionMoverSetup.ResolvedAbsent),
out RuntimeSetPositionCommand command);
Assert.Equal(
RuntimeSetPositionMoverPreparationStatus.RejectedAuthority,
status);
Assert.Equal(default, command);
RuntimeSetPositionOwnershipSnapshot ownership = lifetime.Physics
.SetPosition.CaptureOwnership();
Assert.Equal(0, ownership.MoverPreparationAuthorityCount);
Assert.Equal(0, ownership.ActiveOperationCount);
}
private static RuntimeEntityPlacementToken Begin(
RuntimeEntityObjectLifetime lifetime,
RuntimeEntityRecord record) => lifetime.Physics.SetPosition
.BeginAuthoredPlacement(
record,
record.PositionAuthorityVersion,
RuntimeSetPositionOperationKind.RemoteAuthoritative);
private static RuntimeSetPositionMoverPreparation Input(
RuntimeSetPositionMoverSetup setup) => new(
setup,
RuntimeSetPositionOperationKind.RemoteAuthoritative,
GameTime: 1d,
PhysicsPlacementClass.Ordinary,
PhysicsSetPositionFlags.Placement);
private static RuntimeSetPositionMoverSetup ResolvedEmptySetup() =>
RuntimeSetPositionMoverSetup.Resolved(
SetupId,
Setup(ImmutableArray<FlatCollisionSphere>.Empty, 0f, 0f));
private static FlatSetupCollision Setup(
ImmutableArray<FlatCollisionSphere> spheres,
float stepUp,
float stepDown) => new(
ImmutableArray<FlatCollisionCylinder>.Empty,
spheres,
height: 0f,
radius: 0f,
stepUp,
stepDown);
private static RuntimeEntityRecord CreateRecord(
RuntimeEntityObjectLifetime lifetime,
float? objScale = null,
float? physicsScale = null,
uint? objectDescriptionFlags = null,
uint guid = 0x70002001u,
uint? setupTableId = SetupId,
CreateObject.ServerPosition? position = null)
{
CreateObject.ServerPosition acceptedPosition = position ?? new(
Cell,
1f,
2f,
3f,
1f,
0f,
0f,
0f);
var timestamps = new PhysicsTimestamps(
Position: 1,
Movement: 1,
State: 1,
Vector: 1,
Teleport: 0,
ServerControlledMove: 1,
ForcePosition: 0,
ObjDesc: 1,
Instance: 1);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.Gravity,
Position: acceptedPosition,
Movement: null,
AnimationFrame: null,
SetupTableId: setupTableId,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: physicsScale,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
var spawn = new WorldSession.EntitySpawn(
Guid: guid,
Position: acceptedPosition,
SetupTableId: setupTableId,
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: objScale,
Name: "mover-preparation-fixture",
ItemType: null,
MotionState: null,
MotionTableId: 0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.Gravity,
ObjectDescriptionFlags: objectDescriptionFlags,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
return lifetime.RegisterEntity(spawn).Canonical!;
}
private static void AttachBody(
RuntimeEntityObjectLifetime lifetime,
RuntimeEntityRecord record)
{
var body = new PhysicsBody
{
Position = new Vector3(1f, 2f, 3f),
Orientation = Quaternion.Identity,
State = record.FinalPhysicsState,
InWorld = true,
};
body.SnapToCell(Cell, body.Position, body.Position);
lifetime.Entities.SetPhysicsBody(record, body);
lifetime.Entities.SetFullCell(record, Cell, Cell & 0xFFFF0000u);
}
private static void ReplaceAuthority(
RuntimeEntityObjectLifetime lifetime,
RuntimeEntityRecord record,
AuthorityReplacement replacement)
{
switch (replacement)
{
case AuthorityReplacement.Position:
lifetime.Entities.AdvancePositionAuthority(record);
break;
case AuthorityReplacement.Velocity:
lifetime.Entities.AdvanceMovementAuthority(record);
break;
case AuthorityReplacement.Vector:
lifetime.Entities.AdvanceVectorAuthority(record);
break;
case AuthorityReplacement.State:
_ = lifetime.Entities.ApplyRawPhysicsState(
record,
(uint)PhysicsStateFlags.Frozen);
break;
case AuthorityReplacement.ObjectDescription:
lifetime.Entities.AdvanceObjDescAuthority(record);
break;
case AuthorityReplacement.Create:
lifetime.Entities.AdvanceCreateAuthority(record);
break;
default:
throw new ArgumentOutOfRangeException(nameof(replacement));
}
}
public enum AuthorityReplacement
{
Position,
Velocity,
Vector,
State,
ObjectDescription,
Create,
}
}

View file

@ -57,7 +57,17 @@ public sealed class RuntimeSetPositionStateTests
record.PositionAuthorityVersion,
RuntimeSetPositionOperationKind.LocalAuthoritative,
invalidPortal);
Assert.True(portalToken.IsValid);
Assert.False(portalToken.IsValid);
var matchingPortal = invalidPortal with
{
Projection = new RuntimeWorldHostProjectionToken(7, SourceCell),
};
Assert.True(lifetime.Physics.SetPosition.BeginAuthoredPlacement(
record,
record.PositionAuthorityVersion,
RuntimeSetPositionOperationKind.LocalAuthoritative,
matchingPortal).IsValid);
}
[Fact]
@ -445,6 +455,146 @@ public sealed class RuntimeSetPositionStateTests
placed.Token));
}
[Fact]
public void LegacyDeferredWakeCannotBypassNewAuthoredSealAfterAuthorityStales()
{
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
using var lifetime = new RuntimeEntityObjectLifetime(engine);
RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000112Bu, 1);
PhysicsBody body = AttachBody(lifetime, record, SourceCell);
var observer = new PlacementObserver();
using IDisposable subscription = lifetime.Events.SubscribePlacement(
observer);
RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply(
record,
record.PositionAuthorityVersion,
Command(CrossLandblockRequest()));
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
deferred.Projection));
// The direct legacy command was valid when parked, but any accepted
// authority replacement requires a new authored mover preparation.
lifetime.Entities.AdvanceObjDescAuthority(record);
AddFlatLandblock(engine, DestinationLandblock, 192f);
lifetime.Physics.SetPosition.CommitCollisionGeneration(
DestinationLandblock,
generation: 1,
ready: true);
Assert.False(body.InWorld);
Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken(
record,
out RuntimeEntityPlacementToken token));
Assert.Equal(RuntimeEntityPlacementPreparationKind.LegacyDirect,
token.PreparationKind);
RuntimeSetPositionOutcome bypass = lifetime.Physics.SetPosition
.SubmitPreparedPlacement(token, Command(CrossLandblockRequest()));
Assert.Equal(RuntimeSetPositionStatus.Rejected, bypass.Status);
Assert.False(body.InWorld);
RuntimeSetPositionCommand prepared = PrepareAuthoredCommand(
lifetime,
token,
[new FlatCollisionSphere(Vector3.Zero, 0.4f)]);
RuntimeSetPositionOutcome resumed = lifetime.Physics.SetPosition
.SubmitPreparedPlacement(token, prepared);
Assert.Equal(PhysicsResidenceDisposition.Committed, resumed.Residence);
RuntimePlacementProjectionSnapshot placed = observer.Deltas[^1].Placement;
Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind);
Assert.True(body.InWorld);
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
placed.Token));
}
[Theory]
[InlineData(DeferredPhysicsStateMutation.ChildNoDraw)]
[InlineData(DeferredPhysicsStateMutation.StopMissile)]
[InlineData(DeferredPhysicsStateMutation.DirectFinalState)]
public void DeferredWakeRequiresRepreparationAfterFinalPhysicsStateMutation(
DeferredPhysicsStateMutation mutation)
{
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
using var lifetime = new RuntimeEntityObjectLifetime(engine);
RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000112Cu, 1);
PhysicsStateFlags initial = PhysicsStateFlags.Gravity;
if (mutation is DeferredPhysicsStateMutation.StopMissile)
{
initial |= PhysicsStateFlags.Missile
| PhysicsStateFlags.AlignPath
| PhysicsStateFlags.PathClipped;
}
PhysicsBody body = AttachBody(lifetime, record, SourceCell, initial);
RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply(
record,
record.PositionAuthorityVersion,
Command(CrossLandblockRequest()));
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
deferred.Projection));
ulong priorMutation = record.PhysicsStateMutationVersion;
switch (mutation)
{
case DeferredPhysicsStateMutation.ChildNoDraw:
lifetime.Entities.SetChildNoDraw(record, noDraw: true);
break;
case DeferredPhysicsStateMutation.StopMissile:
Assert.True(lifetime.Entities.StopMissileAfterCollision(
record,
requireCurrentMissile: true));
break;
case DeferredPhysicsStateMutation.DirectFinalState:
lifetime.Entities.SetFinalPhysicsState(
record,
record.FinalPhysicsState | PhysicsStateFlags.Frozen);
break;
default:
throw new ArgumentOutOfRangeException(nameof(mutation));
}
Assert.Equal(priorMutation + 1UL,
record.PhysicsStateMutationVersion);
if (mutation is DeferredPhysicsStateMutation.DirectFinalState)
Assert.NotEqual(record.FinalPhysicsState, body.State);
else
Assert.Equal(record.FinalPhysicsState, body.State);
AddFlatLandblock(engine, DestinationLandblock, 192f);
lifetime.Physics.SetPosition.CommitCollisionGeneration(
DestinationLandblock,
generation: 1,
ready: true);
Assert.False(body.InWorld);
Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken(
record,
out _));
Assert.Equal(1, lifetime.Physics.CaptureOwnership()
.AwaitingSetPositionPreparationCount);
}
[Fact]
public void DirectFinalPhysicsStateMutationAdvancesOnlyOnChangeAndLeavesBodyToItsOwner()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000112Du, 1);
PhysicsBody body = AttachBody(lifetime, record, SourceCell);
PhysicsStateFlags original = record.FinalPhysicsState;
ulong version = record.PhysicsStateMutationVersion;
lifetime.Entities.SetFinalPhysicsState(record, original);
Assert.Equal(version, record.PhysicsStateMutationVersion);
Assert.Equal(original, body.State);
PhysicsStateFlags changed = original | PhysicsStateFlags.Frozen;
lifetime.Entities.SetFinalPhysicsState(record, changed);
Assert.Equal(version + 1UL, record.PhysicsStateMutationVersion);
Assert.Equal(changed, record.FinalPhysicsState);
Assert.Equal(original, body.State);
lifetime.Entities.SetFinalPhysicsState(record, changed);
Assert.Equal(version + 1UL, record.PhysicsStateMutationVersion);
Assert.Equal(original, body.State);
}
[Fact]
public void RejectedPreparationKeepsValidatedMoverAndExactTokenRetryable()
{
@ -1943,15 +2093,12 @@ public sealed class RuntimeSetPositionStateTests
new FlatCollisionSphere(new Vector3(0.4f, 0f, 0f), 0.35f),
new FlatCollisionSphere(new Vector3(-0.3f, 0f, 0.5f), 0.2f),
];
RuntimeSetPositionCommand preparedCommand = PrepareAuthoredCommand(
lifetime,
token,
authored);
RuntimeSetPositionOutcome supplied = lifetime.Physics.SetPosition
.SubmitPreparedPlacement(
token,
Command(Request(
landblock | 0x0101u,
new Vector3(999f, 999f, 999f)) with
{
Spheres = authored,
}));
.SubmitPreparedPlacement(token, preparedCommand);
Assert.Equal(RuntimeSetPositionStatus.DeferredCell, supplied.Status);
Assert.Equal(0, lifetime.Physics.CaptureOwnership()
@ -1998,21 +2145,29 @@ public sealed class RuntimeSetPositionStateTests
Assert.Single(observer.Deltas).Placement.Token));
observer.Deltas.Clear();
RuntimeSetPositionCommand malformed = Command(Request(
exactCell,
new Vector3(10f, 18f, 7f)) with
{
Spheres =
[
new FlatCollisionSphere(
new Vector3(float.NaN, 0f, 0f),
0.4f),
],
});
RuntimeSetPositionOutcome acceptedMalformed = lifetime.Physics
.SetPosition.SubmitPreparedPlacement(token, malformed);
Assert.Equal(RuntimeSetPositionStatus.DeferredCell,
acceptedMalformed.Status);
var malformedSetup = new FlatSetupCollision(
ImmutableArray<FlatCollisionCylinder>.Empty,
[new FlatCollisionSphere(
new Vector3(float.NaN, 0f, 0f),
0.4f)],
height: 0f,
radius: 0f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f);
Assert.Equal(
RuntimeSetPositionMoverPreparationStatus.InvalidData,
lifetime.Physics.SetPosition.PrepareMover(
token,
new RuntimeSetPositionMoverPreparation(
RuntimeSetPositionMoverSetup.Resolved(
0x02000001u,
malformedSetup),
RuntimeSetPositionOperationKind.RemoteAuthoritative,
GameTime: 10d,
PhysicsPlacementClass.Ordinary,
PhysicsSetPositionFlags.Placement
| PhysicsSetPositionFlags.Slide),
out _));
RuntimeCollisionAdmission first = lifetime.Physics
.BeginCollisionAdmission(landblock);
@ -2032,44 +2187,25 @@ public sealed class RuntimeSetPositionStateTests
RuntimeSetPositionOwnershipSnapshot invalidWake = lifetime.Physics
.SetPosition.CaptureOwnership();
Assert.Equal(1, invalidWake.AwaitingPreparationCount);
Assert.Equal(1, invalidWake.DeferredBucketCount);
Assert.Equal(0, invalidWake.DeferredBucketCount);
Assert.Equal(1, invalidWake.LostDeadlineCount);
Assert.Empty(observer.Deltas);
Assert.False(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount(
record,
out _));
RuntimeSetPositionCommand corrected = Command(Request(
exactCell,
new Vector3(10f, 18f, 7f)) with
{
Spheres =
[
new FlatCollisionSphere(Vector3.Zero, 0.4f),
],
});
RuntimeSetPositionCommand corrected = PrepareAuthoredCommand(
lifetime,
token,
[new FlatCollisionSphere(Vector3.Zero, 0.4f)]);
RuntimeSetPositionOutcome correctedPending = lifetime.Physics
.SetPosition.SubmitPreparedPlacement(token, corrected);
Assert.Equal(RuntimeSetPositionStatus.DeferredCell,
correctedPending.Status);
Assert.Equal(PhysicsResidenceDisposition.DeferredCell,
Assert.Equal(PhysicsResidenceDisposition.Committed,
correctedPending.Residence);
Assert.Equal(exactCell, correctedPending.ExactCellId);
RuntimeCollisionAdmission second = lifetime.Physics
.BeginCollisionAdmission(landblock);
using PreparedLandblockCollisionGeneration preparedSecond = lifetime
.Physics.PrepareCollisionGeneration(second);
lifetime.Physics.StageCollisionAssets(
second,
preparedSecond,
CollisionAssets(landblock));
AddSyntheticCell(preparedSecond.DataCache, exactCell);
Assert.True(CommitPrepared(
lifetime.Physics,
second,
preparedSecond).Committed);
RuntimePlacementProjectionSnapshot placed = Assert.Single(observer.Deltas)
.Placement;
Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind);
@ -2178,6 +2314,34 @@ public sealed class RuntimeSetPositionStateTests
ShadowWorldOffsetX: request.CellId == SourceCell ? 0f : 192f,
ShadowWorldOffsetY: 0f);
private static RuntimeSetPositionCommand PrepareAuthoredCommand(
RuntimeEntityObjectLifetime lifetime,
RuntimeEntityPlacementToken token,
ImmutableArray<FlatCollisionSphere> spheres)
{
var setup = new FlatSetupCollision(
ImmutableArray<FlatCollisionCylinder>.Empty,
spheres,
height: 0f,
radius: 0f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f);
var preparation = new RuntimeSetPositionMoverPreparation(
RuntimeSetPositionMoverSetup.Resolved(0x02000001u, setup),
RuntimeSetPositionOperationKind.RemoteAuthoritative,
GameTime: 10d,
PhysicsPlacementClass.Ordinary,
PhysicsSetPositionFlags.Placement
| PhysicsSetPositionFlags.Slide);
Assert.Equal(
RuntimeSetPositionMoverPreparationStatus.Prepared,
lifetime.Physics.SetPosition.PrepareMover(
token,
preparation,
out RuntimeSetPositionCommand command));
return command;
}
private static PhysicsSetPositionRequest CrossLandblockRequest() =>
Request(
SourceCell,
@ -2480,6 +2644,13 @@ public sealed class RuntimeSetPositionStateTests
internal void JumpUtc(TimeSpan duration) => _utcNow += duration;
}
public enum DeferredPhysicsStateMutation
{
ChildNoDraw,
StopMissile,
DirectFinalState,
}
private enum CancellationChannel : uint
{
Position,