Flips the SetPositionSimple classification (contact, PlayerDistance >= 96 m) for remotes onto 4b-1's drive controller and deletes both legacy far blocks, both duplicated 96f/4f constant pairs, and both `?? Vector3.Zero` fabrications. The 4 m constant now exists exactly once. Teleport and cell-less stay legacy for 4b-3. Retail: MoveOrTeleport @0x00516330's far branch runs StopInterpolating @0x005163CB before SetPositionSimple @0x005163D9 and returns 1 @0x005163E8 regardless — the SetPositionError is discarded — so HandleReceivedPosition arms ConstrainTo @0x00454272 post-move on commit AND on failure. The x87 parity decode at @0x00516393-@0x0051639E puts exactly 96.0 on the far branch. SetPositionSimple @0x005162B0 builds flags 0x1012 at @0x005162C4. Non-commit outcomes still advance the body, because retail's SetPositionInternal @0x00515BD0 commits the destination via store_position @0x00515CE2 when no cell resolves. The partition is by STAGE, not heuristic, enforced by an exhaustive switch: Refused/Contention/NotApplicable/RejectedPreparation store (the placement never executed); Committed/Deferred/RejectedByPlacement do not (the engine ran and refused, matching retail's non-storing returns @0x00515CB2 and @0x00515CD5). Without this a refused far snap froze the remote with an emptied queue. Also fixes a shipped defect this route made live: ParkDeferred's quiescence parks withdrew the entity (InWorld=false, clock suspended, residency removed) and were never restorable, while Forget(restoreCancelledPark: true) runs for every accepted Position on every entity. The restorable decision now lives inside ParkDeferred AFTER SnapToCell, reading body.CellPosition.ObjCellId — the value RestoreParkWithdrawal actually restores at — against every live quiescence rather than one minimum-OperationId token. The three pre-snap fields are hoisted into locals because SnapToCell ends with InWorld = true. ParkCollisionResidents passes restorableOnCancel: false explicitly; the plain unplaceable park is provably unchanged. RestoreParkWithdrawal re-tests the prefix at restore time so a retained route-2 park cannot re-admit into a prefix that began quiescing during the park. CanAttemptDestination is retained as an OPTIMISATION only, with the two Core predicates it cannot reproduce written down at the pre-flight, plus the two properties that depend on it staying there. Four fix rounds and eight Opus reviews. The slice was fully green at 10,990, 10,997 and 11,004 while containing real defects — a frozen remote pinned as correct by its own test, a fallback that over-wrote on the exact retail paths that decline to store, and a park guard incomplete on two independent axes. Register: AP-137 (leftover classifications take AP-87's catch-up; states the cell-less enqueue-vs-place delta deferred to 4b-3, that RejectedData is applied anyway, and the headless divergence), AP-138 (the refusable far placement), AP-136 narrowed to match the relocation. #309's acceptance steps rewritten — step 5 previously asserted a recovery the code does not perform — and gated on a new ACDREAM_PROBE_PARK=1 signal so the check cannot pass while broken. Suite 11,009 passed / 4 skipped / 0 failed against a measured 10,968 baseline. The 10,973 figure recorded earlier was wrong and is corrected here. Connected gate outstanding: the two-client far-snap walk and #309. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
403 lines
17 KiB
C#
403 lines
17 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Physics.Motion;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Physics;
|
|
|
|
namespace AcDream.Runtime.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// C4 route 4a: focused tests for the Runtime-owned decision that replaces
|
|
/// the two independent per-kind (player-remote / NPC-remote) copies that
|
|
/// used to live in <c>LiveEntityNetworkUpdateController</c>. Each test below
|
|
/// was verified to actually discriminate its own fix by temporarily
|
|
/// reverting the corresponding condition in
|
|
/// <see cref="RuntimeRemoteSteadyStatePosition"/> and confirming the
|
|
/// matching test failed, then restoring it — see the route 4a implementation
|
|
/// report for the revert/restore log.
|
|
/// </summary>
|
|
public sealed class RuntimeRemoteSteadyStatePositionTests
|
|
{
|
|
private const uint Cell = 0x0101FFFFu;
|
|
|
|
/// <summary>A remote that has already received at least one server
|
|
/// sample, so AP-87's <c>firstUp</c> hint is false and the other two
|
|
/// conditions are the ones under test.</summary>
|
|
private static RemoteMotion MakeSampledRemote(Vector3 bodyPosition)
|
|
{
|
|
var remote = new RemoteMotion();
|
|
remote.Body.Position = bodyPosition;
|
|
remote.Body.Orientation = Quaternion.Identity;
|
|
remote.LastServerPosTime = 1_700_000_000d;
|
|
return remote;
|
|
}
|
|
|
|
private static (RemoteMotion Remote, EntityPhysicsHost Host) MakeRemoteWithHost(
|
|
Vector3 bodyPosition)
|
|
{
|
|
RemoteMotion remote = MakeSampledRemote(bodyPosition);
|
|
EntityPhysicsHost host = new(
|
|
id: 0x70000001u,
|
|
getPosition: () => new Position(0x0001u, remote.Body.Position, remote.Body.Orientation),
|
|
getVelocity: () => remote.Body.Velocity,
|
|
getRadius: () => 0.48f,
|
|
inContact: () => remote.Body.InContact,
|
|
minterpMaxSpeed: () => null,
|
|
curTime: () => 0d,
|
|
physicsTimerTime: () => 0d,
|
|
getObjectA: _ => null,
|
|
handleUpdateTarget: _ => { },
|
|
interruptCurrentMovement: () => { });
|
|
remote.BindCanonicalRuntime(
|
|
() => host,
|
|
() => 0x0001u,
|
|
_ => { });
|
|
remote.MarkFullPhysicsHostBound();
|
|
return (remote, host);
|
|
}
|
|
|
|
// ── AP-87 (register row, carried forward) ──────────────────────────────
|
|
|
|
[Fact]
|
|
public void ApplyInterpolate_BodyFarFromTarget_SnapsRatherThanEnqueues()
|
|
{
|
|
// AP-87's load-bearing condition: |Body.Position - worldPos| > 4 m.
|
|
// An unplaced body (spawn-seed origin) must SNAP, never enqueue —
|
|
// enqueuing here is exactly the #184 invisible-but-solid regression
|
|
// this backstop exists to prevent.
|
|
RemoteMotion remote = MakeSampledRemote(new Vector3(0f, 0f, 0f));
|
|
var target = new Vector3(50f, 0f, 0f); // 50 m away — far beyond 4 m.
|
|
|
|
RuntimeRemoteSteadyStatePosition.Action action =
|
|
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
|
|
remote,
|
|
target,
|
|
Quaternion.Identity,
|
|
isMovingTo: false,
|
|
willBeDrTicked: true);
|
|
|
|
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Snapped, action);
|
|
Assert.Equal(target, remote.Body.Position);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyInterpolate_NotDrTicked_SnapsEvenWhenClose()
|
|
{
|
|
// AP-87's second condition: !willBeDrTicked. A body with no consumer
|
|
// to walk the queue must snap even for a 1 m correction, or it never
|
|
// reaches the target at all.
|
|
RemoteMotion remote = MakeSampledRemote(new Vector3(10f, 10f, 5f));
|
|
var target = new Vector3(10.5f, 10f, 5f); // 0.5 m — well within 4 m.
|
|
|
|
RuntimeRemoteSteadyStatePosition.Action action =
|
|
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
|
|
remote,
|
|
target,
|
|
Quaternion.Identity,
|
|
isMovingTo: false,
|
|
willBeDrTicked: false);
|
|
|
|
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Snapped, action);
|
|
Assert.Equal(target, remote.Body.Position);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyInterpolate_FirstUpBeforeAnyServerSample_Snaps()
|
|
{
|
|
// AP-87's third condition, carried forward from the NPC copy rather
|
|
// than silently dropped: a remote that has never been stamped with a
|
|
// server sample snaps even when both other conditions are benign.
|
|
// Structurally unreachable for player remotes, whose caller stamps
|
|
// LastServerPosTime before it routes — which is exactly why keeping
|
|
// it costs the player arm nothing.
|
|
var remote = new RemoteMotion();
|
|
remote.Body.Position = new Vector3(10f, 10f, 5f);
|
|
remote.Body.Orientation = Quaternion.Identity;
|
|
Assert.Equal(0d, remote.LastServerPosTime);
|
|
var target = new Vector3(10.5f, 10f, 5f);
|
|
|
|
RuntimeRemoteSteadyStatePosition.Action action =
|
|
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
|
|
remote,
|
|
target,
|
|
Quaternion.Identity,
|
|
isMovingTo: false,
|
|
willBeDrTicked: true);
|
|
|
|
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Snapped, action);
|
|
Assert.Equal(target, remote.Body.Position);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyInterpolate_NearAndDrTicked_EnqueuesWithoutTouchingBodyPosition()
|
|
{
|
|
// The ordinary retail near case: no AP-87 condition holds, so the
|
|
// body is queued for the per-tick catch-up, not hard-snapped.
|
|
RemoteMotion remote = MakeSampledRemote(new Vector3(10f, 10f, 5f));
|
|
var target = new Vector3(10.5f, 10f, 5f);
|
|
|
|
RuntimeRemoteSteadyStatePosition.Action action =
|
|
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
|
|
remote,
|
|
target,
|
|
Quaternion.Identity,
|
|
isMovingTo: false,
|
|
willBeDrTicked: true);
|
|
|
|
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Enqueued, action);
|
|
// The body itself is not hard-moved by the enqueue path — the
|
|
// per-tick InterpolationManager/adjust_offset chain walks it there.
|
|
Assert.Equal(new Vector3(10f, 10f, 5f), remote.Body.Position);
|
|
}
|
|
|
|
/// <summary>
|
|
/// TS-44 stays an NPC-only CALLER gate: the seam itself is the
|
|
/// kind-agnostic retail decision, so a sticky-armed host does NOT change
|
|
/// what it does. Folding the sticky check in here would have silently
|
|
/// extended TS-44 to player remotes, which have never had one.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ApplyInterpolate_IsIndifferentToTheStickyLease()
|
|
{
|
|
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
|
|
new Vector3(0f, 0f, 0f));
|
|
host.PositionManager.StickTo(objectId: 0x70000002u, radius: 1f, height: 1f);
|
|
Assert.NotEqual(0u, host.PositionManager.GetStickyObjectId());
|
|
|
|
RuntimeRemoteSteadyStatePosition.Action action =
|
|
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
|
|
remote,
|
|
new Vector3(50f, 0f, 0f),
|
|
Quaternion.Identity,
|
|
isMovingTo: false,
|
|
willBeDrTicked: true);
|
|
|
|
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Snapped, action);
|
|
}
|
|
|
|
// ── Per-entity currency ─────────────────────────────────────────────────
|
|
// Contract item 4: N remotes, no shared pending slot — unlike route 2's
|
|
// single-slot RuntimeAcceptedPositionDriveController, this decision
|
|
// carries no state of its own between calls. Two remotes classified in
|
|
// the same tick must not observe or mutate each other.
|
|
|
|
[Fact]
|
|
public void TwoRemotesInTheSameTick_ApplyIndependentlyWithNoCrossContamination()
|
|
{
|
|
RemoteMotion farRemote = MakeSampledRemote(new Vector3(0f, 0f, 0f)); // will snap.
|
|
RemoteMotion nearRemote = MakeSampledRemote(new Vector3(10f, 10f, 5f)); // will enqueue.
|
|
|
|
RuntimeRemoteSteadyStatePosition.Action farAction =
|
|
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
|
|
farRemote,
|
|
new Vector3(50f, 0f, 0f),
|
|
Quaternion.Identity,
|
|
isMovingTo: false,
|
|
willBeDrTicked: true);
|
|
RuntimeRemoteSteadyStatePosition.Action nearAction =
|
|
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
|
|
nearRemote,
|
|
new Vector3(10.5f, 10f, 5f),
|
|
Quaternion.Identity,
|
|
isMovingTo: false,
|
|
willBeDrTicked: true);
|
|
|
|
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Snapped, farAction);
|
|
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Enqueued, nearAction);
|
|
Assert.Equal(new Vector3(50f, 0f, 0f), farRemote.Body.Position);
|
|
// nearRemote's body is untouched by farRemote's snap — no shared state.
|
|
Assert.Equal(new Vector3(10f, 10f, 5f), nearRemote.Body.Position);
|
|
}
|
|
|
|
// ── Ownership: exactly two dispositions, everything else falls through ──
|
|
|
|
[Fact]
|
|
public void OwnsSteadyState_IsTrueForExactlyTheTwoNoPlacementDispositions()
|
|
{
|
|
Assert.True(RuntimeRemoteSteadyStatePosition.OwnsSteadyState(
|
|
Classify(hasContact: false, playerDistance: 1f)));
|
|
Assert.True(RuntimeRemoteSteadyStatePosition.OwnsSteadyState(
|
|
Classify(hasContact: true, playerDistance: 10f)));
|
|
}
|
|
|
|
[Theory]
|
|
// Far (>=96 m) — SetPositionSimple, route 4b.
|
|
[InlineData(true, 200f, Cell, 10, 10, PositionTimestampDisposition.Apply)]
|
|
// Cell-less canonical body — SetPosition, route 4b.
|
|
[InlineData(true, 10f, 0u, 10, 10, PositionTimestampDisposition.Apply)]
|
|
// Fresh TELEPORT_TS — SetPosition, route 4b.
|
|
[InlineData(true, 10f, Cell, 10, 11, PositionTimestampDisposition.Apply)]
|
|
// Rejected admission — RejectedAuthority.
|
|
[InlineData(true, 10f, Cell, 10, 10, PositionTimestampDisposition.Rejected)]
|
|
// Nonfinite derived distance — RejectedData.
|
|
[InlineData(true, float.NaN, Cell, 10, 10, PositionTimestampDisposition.Apply)]
|
|
public void OwnsSteadyState_IsFalseForEveryOtherClassification(
|
|
bool hasContact,
|
|
float playerDistance,
|
|
uint committedCellId,
|
|
ushort previousTeleport,
|
|
ushort acceptedTeleport,
|
|
PositionTimestampDisposition disposition)
|
|
{
|
|
RuntimeAuthoritativePositionRoute route = Classify(
|
|
hasContact,
|
|
playerDistance,
|
|
committedCellId,
|
|
previousTeleport,
|
|
acceptedTeleport,
|
|
disposition);
|
|
|
|
Assert.NotEqual(
|
|
RuntimeAuthoritativePositionDisposition.Interpolate,
|
|
route.Disposition);
|
|
Assert.NotEqual(
|
|
RuntimeAuthoritativePositionDisposition.NoPositionOperation,
|
|
route.Disposition);
|
|
Assert.False(RuntimeRemoteSteadyStatePosition.OwnsSteadyState(route));
|
|
}
|
|
|
|
[Fact]
|
|
public void OwnsSteadyState_IsFalseWhenNothingWasClassified()
|
|
{
|
|
// A null route is "route 4a has no opinion" — the legacy path runs
|
|
// completely unchanged, exactly as its doc comment claims.
|
|
Assert.False(RuntimeRemoteSteadyStatePosition.OwnsSteadyState(null));
|
|
Assert.False(RuntimeRemoteSteadyStatePosition.IsAirborneNoOperation(null));
|
|
Assert.False(RuntimeRemoteSteadyStatePosition.IsNearInterpolate(null));
|
|
}
|
|
|
|
// ── D2: ConstrainTo timing/anchor ───────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void ArmConstraintAfterOperation_AnchorsToTheHostsCurrentLivePosition()
|
|
{
|
|
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
|
|
new Vector3(1f, 2f, 3f));
|
|
|
|
// Simulate the operation having already moved the body (a Snapped
|
|
// outcome) BEFORE ConstrainTo arms — D2 requires the anchor to read
|
|
// the POST-move position, never the pre-move one.
|
|
remote.Body.Position = new Vector3(9f, 9f, 9f);
|
|
|
|
RuntimeRemoteSteadyStatePosition.ArmConstraintAfterOperation(host);
|
|
|
|
ConstraintManager? constraint = host.PositionManager.Constraint;
|
|
Assert.NotNull(constraint);
|
|
Assert.True(constraint!.IsConstrained);
|
|
Assert.Equal(new Vector3(9f, 9f, 9f), constraint.ConstraintPos.Frame.Origin);
|
|
// Anchored to itself at arm time -> zero initial offset, matching
|
|
// retail's per-packet re-anchor.
|
|
Assert.Equal(0f, constraint.ConstraintPosOffset, 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryArmConstraintAfterOperation_ArmsForTheNearInterpolateBranch()
|
|
{
|
|
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
|
|
new Vector3(1f, 2f, 3f));
|
|
|
|
Assert.True(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
|
Classify(hasContact: true, playerDistance: 10f),
|
|
remote));
|
|
Assert.True(host.PositionManager.Constraint?.IsConstrained);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryArmConstraintAfterOperation_SkipsTheAirborneNoOperation()
|
|
{
|
|
// MoveOrTeleport returns 0 for arg4 == 0, so HandleReceivedPosition's
|
|
// `if (MoveOrTeleport(...) != 0)` never reaches ConstrainTo.
|
|
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
|
|
new Vector3(1f, 2f, 3f));
|
|
|
|
Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
|
Classify(hasContact: false, playerDistance: 10f),
|
|
remote));
|
|
Assert.Null(host.PositionManager.Constraint);
|
|
}
|
|
|
|
/// <summary>
|
|
/// C4 route 4b-2: the far branch moved onto the post-operation arm with
|
|
/// route 4a's two. Retail's `MoveOrTeleport` returns 1 @0x005163E8 there,
|
|
/// so `HandleReceivedPosition`'s single `ConstrainTo` @0x00454272 runs —
|
|
/// and the App's legacy PRE-operation call site now reads the same
|
|
/// predicate and skips it, so it is armed exactly once.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TryArmConstraintAfterOperation_ArmsForTheFarSnapBranch()
|
|
{
|
|
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
|
|
new Vector3(1f, 2f, 3f));
|
|
|
|
RuntimeAuthoritativePositionRoute far =
|
|
Classify(hasContact: true, playerDistance: 200f);
|
|
Assert.Equal(
|
|
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
|
far.Disposition);
|
|
|
|
Assert.True(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
|
far,
|
|
remote));
|
|
Assert.True(host.PositionManager.Constraint?.IsConstrained);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryArmConstraintAfterOperation_SkipsClassificationsNoArmOwns()
|
|
{
|
|
// The cell-less half, the two rejections, and "no classification at
|
|
// all" still arm their leash through the untouched legacy
|
|
// PRE-operation call site, not through here. Arming here too would
|
|
// double-arm them.
|
|
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
|
|
new Vector3(1f, 2f, 3f));
|
|
|
|
Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
|
Classify(hasContact: true, playerDistance: 10f, committedCellId: 0u),
|
|
remote));
|
|
Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
|
Classify(
|
|
hasContact: true,
|
|
playerDistance: 10f,
|
|
disposition: PositionTimestampDisposition.Rejected),
|
|
remote));
|
|
Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
|
Classify(hasContact: true, playerDistance: float.NaN),
|
|
remote));
|
|
Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
|
null,
|
|
remote));
|
|
Assert.Null(host.PositionManager.Constraint);
|
|
}
|
|
|
|
private static RuntimeAuthoritativePositionRoute Classify(
|
|
bool hasContact,
|
|
float playerDistance,
|
|
uint committedCellId = Cell,
|
|
ushort previousTeleport = 10,
|
|
ushort acceptedTeleport = 10,
|
|
PositionTimestampDisposition disposition =
|
|
PositionTimestampDisposition.Apply) =>
|
|
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(
|
|
new RuntimeAcceptedPositionRouteRequest(
|
|
new RuntimeAuthoritativePositionAuthority(
|
|
new RuntimeGenerationToken(7),
|
|
new RuntimeEntityKey(0x70000001u, 3),
|
|
PositionAuthorityVersion: 11UL,
|
|
AcceptedPositionSequence: 20,
|
|
previousTeleport,
|
|
acceptedTeleport,
|
|
disposition),
|
|
RuntimePositionEntityKind.Remote,
|
|
RuntimeAcceptedPositionSource.PositionEvent,
|
|
new CreateObject.ServerPosition(
|
|
Cell, 10f, 20f, 30f, 1f, 0f, 0f, 0f),
|
|
PlacementFrame: 0u,
|
|
PositionPackVelocity: Vector3.Zero,
|
|
committedCellId,
|
|
hasContact,
|
|
playerDistance,
|
|
UsePositionFromServer: false,
|
|
HasAnimations: false,
|
|
default));
|
|
}
|