diff --git a/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md b/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md index 5fdc05d3..fd8276d2 100644 --- a/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md +++ b/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md @@ -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. diff --git a/docs/research/2026-08-01-runtime-set-position-authored-mover-preparation.md b/docs/research/2026-08-01-runtime-set-position-authored-mover-preparation.md new file mode 100644 index 00000000..7e3193d6 --- /dev/null +++ b/docs/research/2026-08-01-runtime-set-position-authored-mover-preparation.md @@ -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. diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index 578a558b..bb4a353f 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -320,7 +320,7 @@ public sealed class RuntimeEntityDirectory PhysicsStateFlags state) { EnsureKnown(record); - record.FinalPhysicsState = state; + record.SetFinalPhysicsState(state); } internal bool StopMissileAfterCollision( diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs index 55b66168..369c7861 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs @@ -164,6 +164,14 @@ public sealed class RuntimeEntityRecord PhysicsBody.State = FinalPhysicsState; } + internal void SetFinalPhysicsState(PhysicsStateFlags state) + { + if (state == FinalPhysicsState) + return; + PhysicsStateMutationVersion++; + FinalPhysicsState = state; + } + /// /// Retail collision reporting clears Missile, AlignPath, and PathClipped /// directly on the live CPhysicsObj. Keep the canonical record and its diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs new file mode 100644 index 00000000..e2f97302 --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs @@ -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, +} + +/// +/// 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 CPhysicsObj::SetPosition; an unavailable lookup must not +/// manufacture that fallback while asynchronous preparation is still live. +/// +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))); +} + +/// +/// Explicit, immutable inputs surrounding retail's authored mover shape. +/// None of these values are inferred from presentation state. +/// +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); + +/// +/// Pure preparation port of the mover inputs consumed by retail +/// CPhysicsObj::SetPosition (0x005160C0). It preserves the complete DAT +/// sphere order; Core's SPHEREPATH port applies retail's two-sphere cap later. +/// +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 spheres = setup?.Spheres + ?? ImmutableArray.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; + } +} diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index b0692f5a..70c04794 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -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; } /// @@ -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 _preparedMovers = []; + private readonly Dictionary + _moverPreparationAuthorities = []; private readonly LinkedList _expiredLostCells = []; private readonly Dictionary> _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); diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionMoverPreparationTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionMoverPreparationTests.cs new file mode 100644 index 00000000..808f699e --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionMoverPreparationTests.cs @@ -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 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.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.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.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.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.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.Empty, 0f, 0f)); + + private static FlatSetupCollision Setup( + ImmutableArray spheres, + float stepUp, + float stepDown) => new( + ImmutableArray.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(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + 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, + } +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index 6a2b14a3..92368bdd 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -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.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 spheres) + { + var setup = new FlatSetupCollision( + ImmutableArray.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,