fix(physics): C4 route 3 — portal placement authority (local player)
Removes a duplicate placement authority for local-player portal arrival. Portalling worked before this change and works after it — this is not a bug fix, EXCEPT that it found and fixed one dead-code production bug. THE PRODUCTION BUG: TryExecuteCanonicalPortalPlacement re-read the accepted destination at Place time, but TryBeginPortalReveal already consumes that slot at Aim time — so the arm was 100% dead code and every real portal Place refused with host-token-unavailable. Found only because we refused to accept 7 skipped tests instead of chasing the count to zero. RETAIL IS THE GENERIC PATH FOR THE THIRD ROUTE RUNNING: SmartBox::TeleportPlayer @0x00453910 = SetPositionSimple(dest, 1) with flags 0x1012, followed by PlayerPositionUpdated. BOTH INVERSIONS, WITH THEIR ANCHORS: unlike route 2, the leash IS armed here (ConstrainTo @0x0045418A) and velocity is zeroed (set_velocity @0x004541B4); unlike route 4b-3, the local teleport_hook runs AFTER placement (@0x004538AE). THE THREE-ROUND DEFECT CHAIN, HONESTLY: - Round 1 released the player at the pre-teleport position while the anim stream marched on — the contract wrongly assumed Place re-fires (process rule 1's third occurrence this campaign). - Round 2's fix inferred commit from a global PendingCount, which three non-committing paths also clear — making the SAME bug complete cleanly and silently. Strictly worse than round 1: round 1 at least tripped portal-complete-before-materialized. - Round 3 latches the commit where it actually happens (ReconcileAndAcknowledgePortal), keyed on reveal generation and teleport sequence, via TryConsumePortalCommit. Two of the three required regression tests landed and are sabotage-verified on both hosts (ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted / HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted). The third (force-arm-takes-the-slot) was judged unnecessary on review: with the inference gone, PendingCount is only a "don't ask yet" guard at both gates, so a force operation occupying or vacating the slot no longer changes an input the commit decision reads — the case collapses into what the landed test already discriminates. THE B2/P3 RESOLUTION: both round-2 reviews were right about different branches of the same synchronous call. RuntimePlacementProjectionSubscription .OnPlacement acknowledges the FIFO head only when TryApply returns true; a Place whose portal authority went stale (transit ended/superseded while parked) used to return false, wedging every later entity's placement receipt behind it forever. Both sinks (RuntimePlacementPresentationSink, HeadlessRuntimePlacementProjectionSink) now acknowledge-and-ignore a stale-authority Place instead of refusing it. The regression test (RuntimePlacementPresentationSinkTests .PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored) had been asserting the old, wrong `false` behaviour; it now asserts and sabotage-verifies the fix. Also lands: AP-144 (register discipline — the portal movement-event send reuses the stricter UsePositionFromServer gate where retail's SendMovementEvent is the looser autonomy_level != 0 test, diverging only at level 1, currently unreachable), AP-145 + issue #318 (the local-player collision-shadow presentation write bypasses its own publisher's ShadowObjects write via a direct cache .Set(), self-healing only once dedup diverges — filed, not fixed, pending a composition test), AD-42 deleted (its last citation retired by the canonical portal arm), AD-2 updated (the wait-cue's trigger predicate now covers a second cause), and two documentation corrections: the enter_world misattribution (both call sites are in SmartBox::HandleCreateObject, only one in the player branch — portal arrival is TeleportPlayer, not enter_world) and the stale "local player never reaches this path" comment on the generic-remote-render-pose write. Suite: 11,090 passed / 4 skipped / 0 failed. No new skips, nothing weakened. STILL OWED: the connected two-client gate, with ACDREAM_PROBE_LOCAL_TELEPORT=1, scored only if [local-tp] lines actually appear in the capture — and explicitly NOT scored as covering issue #318 (no composition test yet asserts PhysicsEngine.ShadowObjects directly). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
cd3129e9d6
commit
e0f96a55bf
24 changed files with 5261 additions and 243 deletions
|
|
@ -1207,6 +1207,580 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
#region C4 route 3 - portal arm
|
||||
|
||||
/// <summary>
|
||||
/// The main D-T2/D-T3 happy path, asserting the positive facts §8 item 2
|
||||
/// requires rather than only <c>InWorld</c>/clock: the body moved to the
|
||||
/// resolved destination, velocity zeroed (retail
|
||||
/// <c>set_velocity(player, 0, 1)</c> @0x004541B4), the leash re-armed
|
||||
/// EXACTLY ONCE at the resolved position (Inversion A — proven by
|
||||
/// pre-arming at a stale anchor first, so a stale anchor surviving would
|
||||
/// fail the assertion), autorun cancelled (the <c>PlayerTeleported</c>
|
||||
/// port), and exactly one outbound movement event with ZERO
|
||||
/// AutonomousPosition packets (route's <c>SendPositionImmediately</c> is
|
||||
/// always false).
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// R7 review fix (2026-08-05): retail <c>CommandInterpreter::SendMovementEvent</c>
|
||||
/// @0x006B4680 (<c>PlayerTeleported</c>'s tail-jump) gates on
|
||||
/// <c>autonomy_level != 0</c> — under server control (this class's
|
||||
/// <c>_usePositionFromServer</c>, retail's <c>UsePositionFromServer()</c>)
|
||||
/// retail sends nothing. Everything else about the commit (body move,
|
||||
/// leash re-arm, autorun cancel) is unaffected by autonomy; only the
|
||||
/// wire send is gated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalCommitted_UnderServerControlSendsNoMovementEvent()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
Assert.True(runtime.CharacterOwner.TrySetAutonomyLevel(0u));
|
||||
Assert.True(runtime.CharacterOwner.UsePositionFromServer);
|
||||
|
||||
const ushort teleportSequence = 6;
|
||||
var destinationPosition = new Vector3(31f, 33f, SpawnHeight);
|
||||
WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate(
|
||||
destinationPosition, SpawnLandblock | 0x0001u, teleportSequence);
|
||||
MergeAccepted(runtime, controller, destinationUpdate);
|
||||
(RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) =
|
||||
BeginPortal(runtime, SpawnLandblock | 0x0001u, teleportSequence, destinationUpdate);
|
||||
// B8/A8 review fix: guarantees ConvergePortalHost runs even if an
|
||||
// assertion below fails first - see PortalHostConvergenceGuard's doc.
|
||||
using var portalHostGuard = new PortalHostConvergenceGuard(
|
||||
runtime, portal.RevealGeneration, portal.Projection);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, portal);
|
||||
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Committed, status);
|
||||
Assert.Equal(destinationPosition, controller.Position);
|
||||
Assert.False(runtime.MovementOwner.AutoRunActive);
|
||||
Assert.Empty(gameActions);
|
||||
ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalCommitted_MovesBodyArmsLeashOnceCancelsAutorunAndSendsExactlyOneMovementEvent()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
|
||||
// Pre-arm the leash at a STALE anchor - a re-arm that merely leaves
|
||||
// the OLD anchor in place (i.e. no re-arm at all) would fail the
|
||||
// ConstraintPos assertion below.
|
||||
var staleAnchor = new Position(
|
||||
SpawnLandblock | 0x0001u,
|
||||
new Vector3(1f, 1f, SpawnHeight),
|
||||
Quaternion.Identity);
|
||||
controller.PositionManager!.ConstrainTo(staleAnchor, 1f, 2f);
|
||||
Assert.True(controller.PositionManager.Constraint!.IsConstrained);
|
||||
|
||||
runtime.MovementOwner.Execute(RuntimeMovementCommand.ToggleRunLock);
|
||||
Assert.True(runtime.MovementOwner.AutoRunActive);
|
||||
|
||||
const ushort teleportSequence = 5;
|
||||
var destinationPosition = new Vector3(30f, 32f, SpawnHeight);
|
||||
WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate(
|
||||
destinationPosition, SpawnLandblock | 0x0001u, teleportSequence);
|
||||
(PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) =
|
||||
MergeAccepted(runtime, controller, destinationUpdate);
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
Assert.True(timestamps.TeleportAdvanced);
|
||||
|
||||
(RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) =
|
||||
BeginPortal(runtime, SpawnLandblock | 0x0001u, teleportSequence, destinationUpdate);
|
||||
// B8/A8 review fix: guarantees ConvergePortalHost runs even if an
|
||||
// assertion below fails first - see PortalHostConvergenceGuard's doc.
|
||||
using var portalHostGuard = new PortalHostConvergenceGuard(
|
||||
runtime, portal.RevealGeneration, portal.Projection);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, portal);
|
||||
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Committed, status);
|
||||
Assert.Equal(destinationPosition, controller.Position);
|
||||
Assert.Equal(Vector3.Zero, controller.BodyVelocity);
|
||||
Assert.True(controller.PositionManager.Constraint!.IsConstrained);
|
||||
Assert.Equal(
|
||||
controller.Position,
|
||||
controller.PositionManager.Constraint.ConstraintPos.Frame.Origin);
|
||||
Assert.False(runtime.MovementOwner.AutoRunActive);
|
||||
// Exactly one outbound wire packet total: the movement-event refresh.
|
||||
// Zero AutonomousPosition - the portal route never sends one.
|
||||
Assert.Single(gameActions);
|
||||
ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D-T1's own safety property, proved live: a portal authority whose
|
||||
/// generation the transit no longer recognizes (this generation was
|
||||
/// never begun) is structurally invalid, so the arm returns
|
||||
/// <c>NotApplicable</c> without writing anything - the D-T5 refusal
|
||||
/// shape. A superseded token is unobtainable by construction; this
|
||||
/// exercises the same <c>IsValid</c> gate a stale re-derivation would
|
||||
/// fail on.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalProducerInvalidAuthority_ArmDoesNotRunAndNothingMutates()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
Vector3 positionBefore = controller.Position;
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
var destination = new RuntimeTeleportDestination(
|
||||
PlayerGuid,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 2,
|
||||
TeleportSequence: 5,
|
||||
ForcePositionSequence: 0,
|
||||
new Position(
|
||||
SpawnLandblock | 0x0001u,
|
||||
new Vector3(30f, 32f, SpawnHeight),
|
||||
Quaternion.Identity));
|
||||
// Present but structurally invalid: RevealGeneration 0 fails
|
||||
// RuntimePortalPlacementAuthority.IsValid outright - the exact shape
|
||||
// a stale-generation TryRegisterHostProjection re-derivation refusal
|
||||
// would leave the producer holding (default).
|
||||
var invalidAuthority = new RuntimePortalPlacementAuthority(
|
||||
Present: true,
|
||||
RevealGeneration: 0,
|
||||
TeleportSequence: 5,
|
||||
Projection: default);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, invalidAuthority);
|
||||
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.NotApplicable, status);
|
||||
Assert.Equal(positionBefore, controller.Position);
|
||||
Assert.Empty(gameActions);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D-T5's genuinely new edge: transit pins the FIRST accepted
|
||||
/// destination per generation, while <c>BeginAcceptedPlacementCore</c>
|
||||
/// validates the portal's destination cell against the LATEST merged
|
||||
/// snapshot. A second local Position merging a DIFFERENT landblock
|
||||
/// between the offer and the Place edge makes Begin refuse. Positive
|
||||
/// half: nothing mutates and the transit stays exactly as it was
|
||||
/// (D-T5's "no half-state" invariant).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalBeginCellMismatch_RefusesWithoutMutatingBodyOrTransit()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
|
||||
const ushort teleportSequence = 6;
|
||||
var destinationPosition = new Vector3(30f, 32f, SpawnHeight);
|
||||
WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate(
|
||||
destinationPosition, SpawnLandblock | 0x0001u, teleportSequence);
|
||||
MergeAccepted(runtime, controller, destinationUpdate);
|
||||
(RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) =
|
||||
BeginPortal(runtime, SpawnLandblock | 0x0001u, teleportSequence, destinationUpdate);
|
||||
// B8/A8 review fix: guarantees ConvergePortalHost runs even if an
|
||||
// assertion below fails first - see PortalHostConvergenceGuard's doc.
|
||||
using var portalHostGuard = new PortalHostConvergenceGuard(
|
||||
runtime, portal.RevealGeneration, portal.Projection);
|
||||
|
||||
// A SECOND local Position merges a DIFFERENT landblock after the
|
||||
// portal offer/registration but before Place - record.Snapshot's
|
||||
// latest accepted position no longer matches the portal's
|
||||
// destination cell. SAME teleport sequence (an ordinary in-flight
|
||||
// Apply, not a fresh teleport) so PhysicsTimestampGate admits it.
|
||||
const uint otherLandblock = 0x02020000u;
|
||||
(PositionTimestampDisposition secondDisposition, _) = MergeAccepted(
|
||||
runtime,
|
||||
controller,
|
||||
PortalDestinationUpdate(
|
||||
new Vector3(1f, 1f, SpawnHeight),
|
||||
otherLandblock | 0x0001u,
|
||||
teleportSequence,
|
||||
positionSequence: 3));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, secondDisposition);
|
||||
Vector3 positionBefore = controller.Position;
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, portal);
|
||||
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Contention, status);
|
||||
Assert.Equal(positionBefore, controller.Position);
|
||||
Assert.Empty(gameActions);
|
||||
// The transit is untouched by the refusal - still active, still
|
||||
// holding the SAME accepted destination, not cancelled.
|
||||
Assert.True(runtime.TransitOwner.IsTeleportActive);
|
||||
Assert.False(runtime.TransitOwner.Snapshot.Cancelled);
|
||||
ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalContention_WhenTheEntityAlreadyOwnsAnActiveOperation()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
Vector3 positionBefore = controller.Position;
|
||||
|
||||
const ushort teleportSequence = 7;
|
||||
WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate(
|
||||
new Vector3(30f, 32f, SpawnHeight), SpawnLandblock | 0x0001u, teleportSequence);
|
||||
MergeAccepted(runtime, controller, destinationUpdate);
|
||||
(RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) =
|
||||
BeginPortal(runtime, SpawnLandblock | 0x0001u, teleportSequence, destinationUpdate);
|
||||
// B8/A8 review fix: guarantees ConvergePortalHost runs even if an
|
||||
// assertion below fails first - see PortalHostConvergenceGuard's doc.
|
||||
using var portalHostGuard = new PortalHostConvergenceGuard(
|
||||
runtime, portal.RevealGeneration, portal.Projection);
|
||||
|
||||
RuntimeEntityPlacementToken displaced = runtime.EntityObjects.Physics
|
||||
.SetPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
RuntimeSetPositionOperationKind.LocalAuthoritative);
|
||||
Assert.True(displaced.IsValid);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, portal);
|
||||
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Contention, status);
|
||||
Assert.Equal(positionBefore, controller.Position);
|
||||
Assert.Empty(gameActions);
|
||||
|
||||
RuntimePlacementCancellationReceipt cancellation = runtime.EntityObjects
|
||||
.Physics.SetPosition.ForgetExactPlacement(displaced);
|
||||
if (cancellation.IsValid)
|
||||
{
|
||||
runtime.EntityObjects.Physics.SetPosition
|
||||
.PublishCancellation(cancellation);
|
||||
}
|
||||
ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D-T2.4: a portal DeferredCell park reuses the SAME retained-operation
|
||||
/// machinery the force arm uses, but on wake it commits WITHOUT the force
|
||||
/// funnel's re-issue decision (trap T7 - <see cref="_newestForce"/> is
|
||||
/// never touched) and WITHOUT a double commit however many times
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.Advance"/> is
|
||||
/// pumped afterward.
|
||||
///
|
||||
/// <para>Root-cause note (closed): an earlier revision of this test
|
||||
/// asserted the wrong post-commit position (the FORCE arm's
|
||||
/// cross-landblock +192/+192 delta) and asserted zero outbound game
|
||||
/// actions after a commit that legitimately sends one. Because both
|
||||
/// were WRONG, the assertion failure fired before this method's own
|
||||
/// <c>ConvergePortalHost</c> cleanup call ever ran, which left the
|
||||
/// portal's host projection unconverged - and <c>StartedRuntime</c>'s
|
||||
/// `using`-triggered <c>Dispose()</c> then threw "hosts=1, pending=1"
|
||||
/// while unwinding, MASKING the real (first) failure as a teardown
|
||||
/// defect. The actual cause: an accepted Position merge with
|
||||
/// <c>TeleportAdvanced</c> (this is one - #283) rebases
|
||||
/// <c>RuntimePhysicsState</c>'s world-frame center onto the DESTINATION
|
||||
/// landblock immediately, before the placement itself ever resolves -
|
||||
/// exactly as <c>ObserveLocalWorldFrame</c>'s doc comment states ("only
|
||||
/// an accepted teleport moves it afterward"). So by the time this
|
||||
/// deferred park's retry runs, the destination landblock IS the frame
|
||||
/// center and the correct expected offset is zero, not the FORCE
|
||||
/// arm's stale-frame cross-landblock delta. No production code
|
||||
/// changed to fix this; both fixes were test-assertion corrections.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalDeferredCell_ParksThenCommitsExactlyOnceOnTheCollisionGenerationWake()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
const uint deferredLandblock = 0x02020000u;
|
||||
var deferredPosition = new Vector3(10f, 10f, SpawnHeight);
|
||||
const ushort teleportSequence = 8;
|
||||
WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate(
|
||||
deferredPosition, deferredLandblock | 0x0001u, teleportSequence);
|
||||
(PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps mergeTimestamps) =
|
||||
MergeAccepted(runtime, controller, destinationUpdate);
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
// #283: RuntimePhysicsState.ObserveLocalWorldFrame rebases the world
|
||||
// frame center to the DESTINATION landblock the instant an accepted
|
||||
// Position merges with TeleportAdvanced (RuntimeEntityObjectLifetime
|
||||
// .TryApplyPosition calls it with teleportAdvanced: timestamps
|
||||
// .TeleportAdvanced) - by design, "only an accepted teleport moves
|
||||
// it afterward" (RuntimePhysicsState.cs doc comment). This merge IS
|
||||
// that accepted teleport, so by the time the placement itself runs,
|
||||
// TryGetWorldFrameOffset(deferredLandblock) is already (0,0) - the
|
||||
// frame is centered ON the destination, not still on SpawnLandblock.
|
||||
Assert.True(mergeTimestamps.TeleportAdvanced);
|
||||
(RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) =
|
||||
BeginPortal(runtime, deferredLandblock | 0x0001u, teleportSequence, destinationUpdate);
|
||||
// B8/A8 review fix: guarantees ConvergePortalHost runs even if an
|
||||
// assertion below fails first - see PortalHostConvergenceGuard's doc.
|
||||
using var portalHostGuard = new PortalHostConvergenceGuard(
|
||||
runtime, portal.RevealGeneration, portal.Projection);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus parked =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, portal);
|
||||
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.DeferredCell, parked);
|
||||
Assert.Empty(gameActions);
|
||||
Assert.Equal(1, drive.PendingCount);
|
||||
|
||||
drive.Advance();
|
||||
Assert.Equal(1, drive.PendingCount);
|
||||
|
||||
CommitLandblockCollision(runtime, deferredLandblock);
|
||||
DrainPlacementFifo(runtime);
|
||||
drive.Advance();
|
||||
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
// Zero offset, not +192/+192: the accepted-teleport merge above
|
||||
// already rebased the world frame onto deferredLandblock (#283),
|
||||
// so the placement resolves in a frame ALREADY centered on the
|
||||
// destination. Asserting the old cross-landblock delta here would
|
||||
// be asserting a stale frame the merge already retired.
|
||||
Assert.Equal(deferredPosition, controller.Position);
|
||||
|
||||
// The committed portal placement sends its ONE outbound movement
|
||||
// event exactly like PortalCommitted_* asserts (D-T3's
|
||||
// PlayerTeleported port; never an AutonomousPosition -
|
||||
// SendPositionImmediately is always false for the portal route).
|
||||
Assert.Single(gameActions);
|
||||
|
||||
// No re-issue path exists for a portal pending (trap T7): further
|
||||
// pumps are pure no-ops, never a second commit or a stray ack.
|
||||
drive.Advance();
|
||||
drive.Advance();
|
||||
Assert.Single(gameActions);
|
||||
ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A2/D-T2.4 review fix (2026-08-05): the wake path must re-validate
|
||||
/// the portal authority before committing, so a park that resolves
|
||||
/// AFTER something has made the authority stale (transit ended or was
|
||||
/// superseded) does not run the reconcile/ack suffix — architecture
|
||||
/// review A2's FIFO-wedge shape ("a Place receipt whose Token.Portal
|
||||
/// still names the ended reveal"). The stub predicate below always
|
||||
/// reports stale, standing in for that condition without needing to
|
||||
/// actually end the transit mid-park.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalDeferredCell_WakeAbandonsInsteadOfReconcilingWhenAuthorityWentStale()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(
|
||||
runtime,
|
||||
out List<byte[]> gameActions,
|
||||
isPortalAuthorityCurrent: static _ => false);
|
||||
|
||||
const uint deferredLandblock = 0x02020000u;
|
||||
var deferredPosition = new Vector3(10f, 10f, SpawnHeight);
|
||||
const ushort teleportSequence = 9;
|
||||
WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate(
|
||||
deferredPosition, deferredLandblock | 0x0001u, teleportSequence);
|
||||
MergeAccepted(runtime, controller, destinationUpdate);
|
||||
(RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) =
|
||||
BeginPortal(runtime, deferredLandblock | 0x0001u, teleportSequence, destinationUpdate);
|
||||
// B8/A8 review fix: guarantees ConvergePortalHost runs even if an
|
||||
// assertion below fails first - see PortalHostConvergenceGuard's doc.
|
||||
using var portalHostGuard = new PortalHostConvergenceGuard(
|
||||
runtime, portal.RevealGeneration, portal.Projection);
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus parked =
|
||||
drive.TryExecuteAcceptedPortalArrival(destination, portal);
|
||||
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.DeferredCell, parked);
|
||||
Assert.Equal(1, drive.PendingCount);
|
||||
|
||||
CommitLandblockCollision(runtime, deferredLandblock);
|
||||
DrainPlacementFifo(runtime);
|
||||
drive.Advance();
|
||||
|
||||
// The park resolved (PendingCount converges to zero either way —
|
||||
// the wake always retires its retained operation) but because the
|
||||
// stub reports the authority stale, NO reconcile/ack ran: zero
|
||||
// outbound movement events, autorun untouched. The underlying body
|
||||
// commit (RetryDeferred) is a SEPARATE, asynchronous mechanism this
|
||||
// class cannot prevent (see the class doc on
|
||||
// IsPortalAuthorityCurrent) — it still lands.
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
Assert.Empty(gameActions);
|
||||
Assert.Equal(deferredPosition, controller.Position);
|
||||
|
||||
ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
private static WorldSession.EntityPositionUpdate PortalDestinationUpdate(
|
||||
Vector3 position,
|
||||
uint landblockId,
|
||||
ushort teleportSequence,
|
||||
ushort positionSequence = 2) =>
|
||||
new(
|
||||
PlayerGuid,
|
||||
new CreateObject.ServerPosition(
|
||||
landblockId,
|
||||
position.X,
|
||||
position.Y,
|
||||
position.Z,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: positionSequence,
|
||||
TeleportSequence: teleportSequence,
|
||||
ForcePositionSequence: 0);
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <see cref="RuntimePlacementPresentationSinkTests.Fixture.BeginPortal"/>'s
|
||||
/// shape against <see cref="GameRuntime.TransitOwner"/> directly - F751 →
|
||||
/// offer → begin reveal → register the host token exactly like the
|
||||
/// graphical/headless producers (D-T1).
|
||||
/// </summary>
|
||||
private static (RuntimePortalPlacementAuthority Portal, RuntimeTeleportDestination Destination)
|
||||
BeginPortal(
|
||||
GameRuntime runtime,
|
||||
uint destinationCell,
|
||||
ushort teleportSequence,
|
||||
in WorldSession.EntityPositionUpdate destinationUpdate)
|
||||
{
|
||||
RuntimeWorldTransitState transit = runtime.TransitOwner;
|
||||
Assert.True(transit.TryQueueTeleportStart(teleportSequence));
|
||||
Assert.True(transit.ActivateQueuedTeleport());
|
||||
var destination = new RuntimeTeleportDestination(
|
||||
PlayerGuid,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: destinationUpdate.PositionSequence,
|
||||
TeleportSequence: teleportSequence,
|
||||
ForcePositionSequence: 0,
|
||||
new Position(
|
||||
destinationCell,
|
||||
new Vector3(
|
||||
destinationUpdate.Position.PositionX,
|
||||
destinationUpdate.Position.PositionY,
|
||||
destinationUpdate.Position.PositionZ),
|
||||
Quaternion.Identity));
|
||||
Assert.True(transit.OfferTeleportDestination(
|
||||
destination,
|
||||
teleportTimestampAdvanced: true));
|
||||
Assert.True(transit.TryBeginPortalReveal(
|
||||
teleportSequence,
|
||||
destinationCell,
|
||||
out long generation));
|
||||
Assert.True(transit.TryRegisterHostProjection(
|
||||
generation,
|
||||
destinationCell,
|
||||
out RuntimeWorldHostProjectionToken host));
|
||||
return (
|
||||
new RuntimePortalPlacementAuthority(true, generation, teleportSequence, host),
|
||||
destination);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only host-side convergence: cancels the reveal generation and
|
||||
/// drains the 4-stage host acknowledgement suffix
|
||||
/// (<see cref="RuntimeWorldTransitState.RequireTerminalHostProjection"/>'s
|
||||
/// SimulationReleaseProjected + DestinationReservationReleased +
|
||||
/// TerminalProjected) so <see cref="RuntimeWorldTransitState.ResetSession"/>
|
||||
/// does not throw during <c>StartedRuntime.Dispose()</c> teardown. A REAL
|
||||
/// host (graphical/headless) always runs this suffix itself
|
||||
/// (<c>LocalPlayerTeleportController.ResetTransit</c> /
|
||||
/// <c>RuntimeLiveEntitySessionController.TryCompletePortal</c>'s
|
||||
/// <c>Complete</c> path); this fixture stands in for that host exactly
|
||||
/// like <c>DrainPlacementFifo</c> stands in for the placement
|
||||
/// subscription.
|
||||
/// </summary>
|
||||
private static void ConvergePortalHost(
|
||||
GameRuntime runtime,
|
||||
long generation,
|
||||
RuntimeWorldHostProjectionToken projection)
|
||||
{
|
||||
RuntimeWorldTransitState transit = runtime.TransitOwner;
|
||||
if (!transit.Snapshot.Cancelled && !transit.Snapshot.Completed)
|
||||
transit.Cancel(generation);
|
||||
transit.AcknowledgeHostProjection(new RuntimeWorldHostAcknowledgement(
|
||||
projection, RuntimeWorldHostAcknowledgementStage.SimulationReleaseProjected));
|
||||
transit.AcknowledgeHostProjection(new RuntimeWorldHostAcknowledgement(
|
||||
projection, RuntimeWorldHostAcknowledgementStage.DestinationReservationReleased));
|
||||
transit.AcknowledgeHostProjection(new RuntimeWorldHostAcknowledgement(
|
||||
projection, RuntimeWorldHostAcknowledgementStage.TerminalProjected));
|
||||
transit.EndTeleport();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B8/A8 review fix (2026-08-05): a plain trailing-statement call to
|
||||
/// <see cref="ConvergePortalHost"/> is masked whenever an assertion
|
||||
/// EARLIER in the same test body fails first - the real failure never
|
||||
/// reaches xUnit because <c>StartedRuntime.Dispose()</c>'s own
|
||||
/// <c>RuntimeWorldTransitState.ResetSession</c> throws a SECOND,
|
||||
/// unrelated-looking "hosts=1, pending=1" exception while unwinding,
|
||||
/// which is the one that actually surfaces. A <c>using var</c> local of
|
||||
/// this guard, declared immediately after <see cref="BeginPortal"/>
|
||||
/// returns, disposes on EVERY exit path (normal return AND exception
|
||||
/// unwind) via C#'s own <c>using</c> semantics - equivalent to a
|
||||
/// try/finally wrapping the rest of the method without the nesting.
|
||||
/// <see cref="ConvergePortalHost"/> is idempotent against an already-
|
||||
/// converged host (every step it performs is a no-op past the first
|
||||
/// successful run), so a test that ALSO calls it explicitly on its own
|
||||
/// success path is safe to leave as-is; this guard exists purely to
|
||||
/// guarantee the call still happens when that explicit call is never
|
||||
/// reached.
|
||||
/// </summary>
|
||||
private readonly struct PortalHostConvergenceGuard : IDisposable
|
||||
{
|
||||
private readonly GameRuntime _runtime;
|
||||
private readonly long _generation;
|
||||
private readonly RuntimeWorldHostProjectionToken _projection;
|
||||
|
||||
public PortalHostConvergenceGuard(
|
||||
GameRuntime runtime,
|
||||
long generation,
|
||||
RuntimeWorldHostProjectionToken projection)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_generation = generation;
|
||||
_projection = projection;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
ConvergePortalHost(_runtime, _generation, _projection);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static void AssertConverged(GameRuntime runtime)
|
||||
{
|
||||
RuntimeEntityObjectOwnershipSnapshot ownership =
|
||||
|
|
@ -1353,7 +1927,8 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
/// </summary>
|
||||
private static RuntimeAcceptedPositionDriveController CreateAcceptedPositionDrive(
|
||||
GameRuntime runtime,
|
||||
out List<byte[]> gameActions)
|
||||
out List<byte[]> gameActions,
|
||||
Func<RuntimePortalPlacementAuthority, bool>? isPortalAuthorityCurrent = null)
|
||||
{
|
||||
var captured = new List<byte[]>();
|
||||
gameActions = captured;
|
||||
|
|
@ -1372,7 +1947,15 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
() => runtime.PlayerIdentity.ServerGuid,
|
||||
() => runtime.MovementOwner.Controller,
|
||||
() => runtime.CharacterOwner.UsePositionFromServer,
|
||||
() => liveSession);
|
||||
() => liveSession,
|
||||
// C4 route 3: the portal arm's PlayerTeleported port needs the
|
||||
// autorun latch owner. Unused on the force arm this factory has
|
||||
// always served, so every existing route-2 test is unaffected.
|
||||
() => runtime.MovementOwner,
|
||||
// A2/D-T2.4 review fix (2026-08-05): defaults to null (existing
|
||||
// callers unaffected - every retained portal pending is treated
|
||||
// as current, today's unconditional behaviour).
|
||||
isPortalAuthorityCurrent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1614,7 +2197,8 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
|
||||
public RuntimeDestinationReadiness PrepareDestination(
|
||||
long revealGeneration,
|
||||
RuntimeTeleportDestination destination) =>
|
||||
RuntimeTeleportDestination destination,
|
||||
RuntimeWorldHostProjectionToken portal) =>
|
||||
new(
|
||||
revealGeneration,
|
||||
destination.CellId,
|
||||
|
|
|
|||
|
|
@ -901,7 +901,8 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
|
||||
public RuntimeDestinationReadiness PrepareDestination(
|
||||
long revealGeneration,
|
||||
RuntimeTeleportDestination destination)
|
||||
RuntimeTeleportDestination destination,
|
||||
RuntimeWorldHostProjectionToken portal)
|
||||
{
|
||||
PrepareCount++;
|
||||
LastDestination = destination;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue