fix(physics): #307 — PreviousTeleport was always 0 on the live Position path

Shipped defect in route 2 (9966b531), found while reviewing route 4a.

`InboundPhysicsStateController.TryApplyPosition` built its AcceptedPhysicsTimestamps
via `Current(gate, teleportAdvanced: ...)`, omitting `previousTeleport`, which
defaulted to a literal 0. The only site that populated it was the deferred
initial-create path — which is why the continuation executor was correct and
every newer consumer was not.

Consequence in shipped code: route 2 feeds this into
`ValidAcceptedAuthority`, which requires Previous == Accepted for a
ForcePosition. Any local player whose TELEPORT_TS is nonzero — anyone who has
portalled or recalled this session — had the authority rejected and the force
correction SILENTLY DROPPED. The user's @pklite acceptance was genuine but
narrow: that character had not teleported, so the stamp was still 0.

Second latent consequence: with an accepted stamp >= 0x8000, wrap-safe
TeleportRegressed also fires against the 0 and rejects ordinary Apply positions,
not just ForcePosition.

The fix captures `previousTeleport = gate.TeleportTimestamp` BEFORE
`TryAcceptPositionEvent` mutates it, matching the shape the deferred path
already used. Ordering is the whole point: capturing after would make
Previous == Accepted unconditionally, so ValidAcceptedAuthority's check would
pass vacuously — the symptom would disappear while the semantics broke.

Also removes the footgun that allowed it. `Current`'s parameter is now
`ushort? previousTeleport = null` resolving to `gate.TeleportTimestamp`, so the
eleven non-Position channels — none of which can move TELEPORT_TS — get
"previous == current" by omission rather than a literal 0 that is
indistinguishable from a genuine "never teleported".

Consumer audit: only TryApplyPosition was defective. The two route-2 call sites
trace back to it; the RuntimeEntityObjectLifetime sites source from
TryAcceptDeferredPosition and were already correct.

Tests discrimination-verified by reverting the argument to 0: the stamp test
fails Expected 10 / Actual 0, and the classifier test fails Expected
SetPositionSimple / Actual RejectedAuthority — the shipped defect reproduced
exactly.

Gates: complete Release solution 10,935 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-03 23:48:09 +02:00
parent b633b10967
commit 19d9509497
3 changed files with 206 additions and 4 deletions

View file

@ -2,7 +2,9 @@ using System.Numerics;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Tests.Entities;
@ -484,6 +486,142 @@ public sealed class InboundPhysicsStateControllerTests
Assert.Equal(liveVelocity, retained.Physics.Value.Velocity);
}
/// <summary>
/// #307: the live Position path must report the TELEPORT_TS value the gate
/// held BEFORE the event, not 0. Every consumer of
/// <c>AcceptedPhysicsTimestamps.PreviousTeleport</c> compares it against
/// the accepted stamp; a hard 0 makes "this packet did not advance
/// TELEPORT_TS" indistinguishable from "this packet regressed it" for any
/// entity that has ever teleported.
/// </summary>
[Fact]
public void TryApplyPosition_ReportsThePreEventTeleportStamp()
{
var controller = new InboundPhysicsStateController();
WorldSession.EntitySpawn spawn = WithTimestamps(
Spawn(0x50000010u, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u),
teleport: 10,
forcePosition: 0);
controller.AcceptCreate(spawn);
// An ordinary Position carrying the SAME TELEPORT_TS: previous and
// accepted must both be the live stamp, and nothing advanced.
Assert.True(controller.TryApplyPosition(
PositionUpdate(spawn.Guid, instance: 3, position: 11, teleport: 10),
isLocalPlayer: false,
forcePositionRotation: null,
currentLocalVelocity: null,
out PositionTimestampDisposition steady,
out _,
out AcceptedPhysicsTimestamps steadyStamps));
Assert.Equal(PositionTimestampDisposition.Apply, steady);
Assert.Equal((ushort)10, steadyStamps.PreviousTeleport);
Assert.Equal((ushort)10, steadyStamps.Teleport);
Assert.False(steadyStamps.TeleportAdvanced);
// A fresh TELEPORT_TS: previous is the PRE-event stamp, accepted is
// the new one. Capturing after the gate mutated would collapse both
// onto 11 and silently lose the advance.
Assert.True(controller.TryApplyPosition(
PositionUpdate(spawn.Guid, instance: 3, position: 12, teleport: 11),
isLocalPlayer: false,
forcePositionRotation: null,
currentLocalVelocity: null,
out PositionTimestampDisposition advanced,
out _,
out AcceptedPhysicsTimestamps advancedStamps));
Assert.Equal(PositionTimestampDisposition.Apply, advanced);
Assert.Equal((ushort)10, advancedStamps.PreviousTeleport);
Assert.Equal((ushort)11, advancedStamps.Teleport);
Assert.True(advancedStamps.TeleportAdvanced);
}
/// <summary>
/// #307, the shipped consequence: a local player who has portalled or
/// recalled this session holds a nonzero TELEPORT_TS. Retail's
/// FORCE_POSITION branch (<c>SmartBox::HandleReceivedPosition</c>
/// 0x00453FD0) fires only when the packet's teleport stamp EQUALS the live
/// one and never advances it, so the authority C4 route 2 builds from
/// these timestamps must satisfy
/// <c>PreviousTeleportSequence == AcceptedTeleportSequence</c>. With
/// PreviousTeleport pinned to 0 the classifier rejected the authority and
/// the force correction was silently dropped.
/// </summary>
[Fact]
public void LocalPlayerForcePositionAfterATeleport_ClassifiesAsAnAcceptedForceCorrection()
{
var controller = new InboundPhysicsStateController();
WorldSession.EntitySpawn spawn = WithTimestamps(
Spawn(0x50000011u, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u),
teleport: 10,
forcePosition: 0);
controller.AcceptCreate(spawn);
Assert.True(controller.TryApplyPosition(
PositionUpdate(
spawn.Guid,
instance: 3,
position: 9,
teleport: 10,
forcePosition: 1),
isLocalPlayer: true,
forcePositionRotation: Quaternion.Identity,
currentLocalVelocity: Vector3.Zero,
out PositionTimestampDisposition disposition,
out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps));
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
// Built exactly as the route-2 drive builds it from these outputs.
var authority = new RuntimeAuthoritativePositionAuthority(
new RuntimeGenerationToken(7),
new RuntimeEntityKey(spawn.Guid, 1),
PositionAuthorityVersion: 4UL,
AcceptedPositionSequence: 9,
timestamps.PreviousTeleport,
timestamps.Teleport,
disposition);
RuntimeAuthoritativePositionRoute route =
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(
new RuntimeAcceptedPositionRouteRequest(
authority,
RuntimePositionEntityKind.LocalPlayer,
RuntimeAcceptedPositionSource.PositionEvent,
accepted.Position!.Value,
PlacementFrame: 0u,
PositionPackVelocity: Vector3.Zero,
CommittedCellId: 0x0101FFFFu,
HasContact: true,
PlayerDistance: 0f,
UsePositionFromServer: true,
HasAnimations: false,
default));
Assert.Equal(
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
route.Disposition);
Assert.True(route.SendPositionImmediately);
Assert.Equal((ushort)10, timestamps.PreviousTeleport);
Assert.Equal((ushort)10, timestamps.Teleport);
}
private static WorldSession.EntityPositionUpdate PositionUpdate(
uint guid,
ushort instance,
ushort position,
ushort teleport,
ushort forcePosition = 0) =>
new(
guid,
Position(0x0101FFFFu, 20f),
null,
null,
true,
instance,
position,
teleport,
forcePosition);
private static WorldSession.EntitySpawn WithTimestamps(
WorldSession.EntitySpawn spawn,
ushort? movement = null,