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
|
|
@ -1,15 +1,21 @@
|
|||
using System.Numerics;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Runtime.World;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
|
|
@ -45,7 +51,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
y: 34f,
|
||||
z: 5f);
|
||||
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
destination,
|
||||
teleportTimestampAdvanced: true);
|
||||
Assert.Empty(harness.Streaming.Reservations);
|
||||
|
|
@ -61,7 +67,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Assert.True(harness.Reveal.Snapshot.IsActive);
|
||||
Assert.Equal(RuntimePortalKind.Portal, harness.Reveal.Snapshot.Kind);
|
||||
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
destination,
|
||||
teleportTimestampAdvanced: false);
|
||||
Assert.Single(harness.Streaming.Reservations);
|
||||
|
|
@ -74,7 +80,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
harness.Mode.Controller = null;
|
||||
|
||||
harness.Controller.OnTeleportStarted(2);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 2, 7f, 8f, 9f),
|
||||
teleportTimestampAdvanced: true);
|
||||
|
||||
|
|
@ -103,7 +109,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Assert.True(harness.Controller.IsActive);
|
||||
|
||||
harness.Mode.Controller = null;
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 14, 7f, 8f, 9f),
|
||||
teleportTimestampAdvanced: true);
|
||||
|
||||
|
|
@ -118,12 +124,25 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Assert.Single(harness.Streaming.Reservations);
|
||||
}
|
||||
|
||||
// C4 route 3 root-caused (closed): the earlier "bare-fixture InvalidData"
|
||||
// diagnosis was WRONG - these never reached
|
||||
// RuntimeSetPositionMoverPreparer.TryBuild at all. The real defect was in
|
||||
// LocalPlayerTeleportController.TryExecuteCanonicalPortalPlacement: it
|
||||
// re-read _transit.TryGetAcceptedTeleportDestination at Place time, but
|
||||
// RuntimeWorldTransitState.TryBeginPortalReveal (driven by AimDestination
|
||||
// via WorldRevealCoordinator.TryBeginPortal) atomically CONSUMES that one
|
||||
// accepted-destination slot the instant Aim claims the reveal generation
|
||||
// - so the Place-time re-read always found it empty and refused with
|
||||
// "cause=host-token-unavailable" before ever building a mover. Fixed by
|
||||
// caching the destination at Aim time (_pendingDestination), mirroring
|
||||
// the existing _pendingCell/_pendingRotation/_pendingRevealGeneration
|
||||
// pattern in the same method. Production fix, not a fixture workaround.
|
||||
[Fact]
|
||||
public void ControllerWithdrawnAfterAim_HoldsPresentationUntilModeRebuildsIt()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(15);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 15, 7f, 8f, 9f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Mode.Controller = null;
|
||||
|
|
@ -131,7 +150,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.Empty(harness.Presentation.WorldReadyValues);
|
||||
|
||||
harness.Mode.RebuildOnEnter = () =>
|
||||
|
|
@ -143,15 +162,28 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.NotNull(harness.Mode.Controller);
|
||||
Assert.Equal(new Vector3(7f, 8f, 9f), harness.Placement.Position);
|
||||
Assert.True(harness.Placement.Called);
|
||||
// A6 review fix: assert the REAL canonical body resolved the
|
||||
// offered destination (7,8,9), not just that the acknowledge-only
|
||||
// suffix ran.
|
||||
Assert.Equal(
|
||||
new Vector3(7f, 8f, 9f),
|
||||
harness.Movement.Controller!.Position);
|
||||
Assert.Equal(0x20210001u, harness.Movement.Controller.CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameLandblockDestination_DoesNotRecenterAndKeepsTranslatedPosition()
|
||||
{
|
||||
var harness = new Harness(centerX: 0x20, centerY: 0x21);
|
||||
// 0x20210123 is an INDOOR cell (low word 0x0123 >= 0x0100) within the
|
||||
// same landblock the constructor already committed. It still needs
|
||||
// its own synthetic CellStruct - PhysicsEngine.IsSpawnCellReady
|
||||
// requires DataCache.GetCellStruct to resolve for indoor cells, and
|
||||
// a bare AddLandblock never populates one.
|
||||
harness.AddSyntheticIndoorCell(0x20210123u);
|
||||
harness.Controller.OnTeleportStarted(3);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210123u, 3, 20f, 30f, 4f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
|
|
@ -159,21 +191,175 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.Empty(harness.Streaming.Recenters);
|
||||
Assert.Equal(new Vector3(20f, 30f, 4f), harness.Placement.Position);
|
||||
Assert.Equal(0x20210123u, harness.Placement.CellId);
|
||||
// C4 route 3: the canonical Runtime arm - not this fake - resolves
|
||||
// and commits the destination; the acknowledge-only suffix carries
|
||||
// no position/cell to capture (D-T4). The observable fact left at
|
||||
// this layer is that the committed receipt reached the suffix.
|
||||
Assert.True(harness.Placement.Called);
|
||||
// A6/R4 review fix: assert the REAL canonical body resolved the
|
||||
// offered destination, not just that the suffix ran.
|
||||
Assert.Equal(
|
||||
new Vector3(20f, 30f, 4f),
|
||||
harness.Movement.Controller!.Position);
|
||||
Assert.Equal(0x20210123u, harness.Movement.Controller.CellId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A1/R1 review fix (2026-08-05): the retail/architecture review's own
|
||||
/// required test — "refused arm -> the anim stream does not reach
|
||||
/// RevealWorldViewport/FireLoginComplete with an unplaced body." Forces
|
||||
/// a genuine Runtime-level Contention refusal (a competing operation
|
||||
/// already owns the entity's placement token, same shape as the Runtime
|
||||
/// layer's <c>PortalContention_WhenTheEntityAlreadyOwnsAnActiveOperation</c>)
|
||||
/// and drives several ticks past where the OLD unconditional-march code
|
||||
/// would have already fired LoginComplete. The player must still be
|
||||
/// standing at the origin, in portal space, with the transit still
|
||||
/// active — never released into the world unplaced.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RefusedPlace_HoldsTheStreamAndConvergesOnlyAfterContentionClears()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(50);
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 50, 11f, 12f, 13f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
Vector3 positionBefore = harness.Movement.Controller!.Position;
|
||||
|
||||
// A competing operation already owns the entity's placement token -
|
||||
// the canonical Runtime Begin call inside TryAdvancePortalCommit
|
||||
// refuses with Contention, exactly like the Runtime-layer
|
||||
// PortalContention_WhenTheEntityAlreadyOwnsAnActiveOperation test.
|
||||
RuntimeEntityRecord record = harness.Lifetime.Entities
|
||||
.TryGetActive(0x50000001u, out RuntimeEntityRecord active)
|
||||
? active
|
||||
: throw new InvalidOperationException("fixture entity missing");
|
||||
RuntimeEntityPlacementToken displaced = harness.Lifetime.Physics
|
||||
.SetPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
RuntimeSetPositionOperationKind.LocalAuthoritative);
|
||||
Assert.True(displaced.IsValid);
|
||||
|
||||
// Several ticks, well past where the pre-fix code would already
|
||||
// have reached PlayExitSound -> WorldFadeIn -> FireLoginComplete
|
||||
// and reset the transit (the tunnel's own retail timing is 2-5s;
|
||||
// this drives 3x that in 0.1s steps).
|
||||
for (int i = 0; i < 100; i++)
|
||||
harness.Controller.Tick(0.1f);
|
||||
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.Equal(positionBefore, harness.Movement.Controller.Position);
|
||||
Assert.True(harness.Controller.IsActive);
|
||||
Assert.False(harness.Reveal.Snapshot.Completed);
|
||||
Assert.Equal(0, harness.Session.LoginCompleteCount);
|
||||
|
||||
// Release the competing operation - the SAME `dataReady` predicate
|
||||
// that already held the stream in Tunnel now lets the commit
|
||||
// succeed on the very next tick (D-T5 row 2's "next Tick
|
||||
// re-attempts the Place edge", made real without ever touching
|
||||
// TeleportAnimSequencer).
|
||||
RuntimePlacementCancellationReceipt cancellation = harness.Lifetime
|
||||
.Physics.SetPosition.ForgetExactPlacement(displaced);
|
||||
if (cancellation.IsValid)
|
||||
harness.Lifetime.Physics.SetPosition.PublishCancellation(cancellation);
|
||||
|
||||
harness.Controller.Tick(0.1f);
|
||||
|
||||
Assert.True(harness.Placement.Called);
|
||||
Assert.Equal(
|
||||
new Vector3(11f, 12f, 13f),
|
||||
harness.Movement.Controller.Position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix (2026-08-05): the required test #1 — a DeferredCell
|
||||
/// park killed by an ORDINARY merge-time <c>Forget</c> (not a
|
||||
/// collision-generation commit) must NOT be latched as "committed".
|
||||
/// The destination landblock is never committed
|
||||
/// (<c>CommitLandblockCollision</c> is deliberately not called), so the
|
||||
/// portal parks; an unrelated ordinary accepted Position for the same
|
||||
/// entity then Forgets the outstanding operation exactly like a normal
|
||||
/// 5-10 Hz ACE broadcast would — <c>RuntimeSetPositionState</c>'s own
|
||||
/// doc names this the EXPECTED way a far-destination park resolves
|
||||
/// without committing. Before the fix, <c>PendingCount</c> hitting 0
|
||||
/// made the host gate latch <c>_placementCommitted = true</c>, release
|
||||
/// the sequencer, and run the ENTIRE completion sequence
|
||||
/// (materialize/reveal/LoginComplete) against the unmoved body.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted()
|
||||
{
|
||||
var harness = new Harness();
|
||||
const uint destinationLandblock = 0x40410000u;
|
||||
harness.Controller.OnTeleportStarted(60);
|
||||
harness.OfferDestination(
|
||||
Position(destinationLandblock | 0x0001u, 60, 21f, 22f, 23f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
|
||||
// First tick: destinationLandblock's collision generation was never
|
||||
// committed, so the canonical arm parks DeferredCell.
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.Equal(1, harness.AcceptedPositionDrive.PendingCount);
|
||||
|
||||
// An ordinary, UNRELATED accepted Position for the same entity -
|
||||
// no new teleport, just a normal broadcast - Forgets the parked
|
||||
// operation the same way ACE's 5-10 Hz cadence would.
|
||||
harness.MergeOrdinaryPosition(
|
||||
new Vector3(48f, 49f, 50f), 0x20210001u, teleportSequence: 60);
|
||||
// Forget (inside TryApplyPosition) cancels the underlying
|
||||
// RuntimeSetPositionState operation immediately, but the drive's
|
||||
// OWN _pending cache only notices on its next Advance() pump - the
|
||||
// App host's real per-frame retry lease does this; the test drives
|
||||
// it explicitly.
|
||||
harness.AcceptedPositionDrive.Advance();
|
||||
Assert.Equal(0, harness.AcceptedPositionDrive.PendingCount);
|
||||
|
||||
// Drive well past where the pre-fix inference would have latched
|
||||
// "committed" on the very next tick and then marched to
|
||||
// LoginComplete.
|
||||
for (int i = 0; i < 100; i++)
|
||||
harness.Controller.Tick(0.1f);
|
||||
|
||||
// NOTE: the body's Position/CellId are NOT asserted unchanged here.
|
||||
// The DeferredCell park's dormant stage (RuntimeSetPositionState's
|
||||
// SubmitPreparedPlacementCore deferred-commit path,
|
||||
// StageDormantCellFrame) already writes the staged destination onto
|
||||
// the body's controller while body.InWorld stays false and the
|
||||
// canonical placement has not committed - the reader-visible
|
||||
// Position/CellId moving early is that benign staging mechanism, not
|
||||
// evidence the placement committed (see the analogous headless test
|
||||
// HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake
|
||||
// for the same finding). The discriminators below - the presentation
|
||||
// suffix never running, no materialize/reveal/LoginComplete, and the
|
||||
// controller staying active in an incomplete transit - are what
|
||||
// actually distinguish "committed" from "parked-then-forgotten".
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.Equal(0, harness.Reveal.PortalMaterializationCount);
|
||||
Assert.Equal(0, harness.Session.LoginCompleteCount);
|
||||
Assert.True(harness.Controller.IsActive);
|
||||
Assert.False(harness.Reveal.Snapshot.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossLandblockDestination_RecentersBeforeItCanBecomeReady()
|
||||
{
|
||||
var harness = new Harness(centerX: 0x20, centerY: 0x21);
|
||||
harness.CommitLandblockCollision(0x30310000u);
|
||||
// 0x30310100 is an INDOOR cell (low word 0x0100 >= 0x0100); see the
|
||||
// AddSyntheticIndoorCell doc comment for why CommitLandblockCollision
|
||||
// alone (terrain-only) cannot make it spawn-ready.
|
||||
harness.AddSyntheticIndoorCell(0x30310100u);
|
||||
harness.Mode.Controller!.SetPosition(
|
||||
Vector3.Zero,
|
||||
0x20210001u,
|
||||
Vector3.Zero);
|
||||
harness.Controller.OnTeleportStarted(4);
|
||||
harness.Streaming.RecenterPending = true;
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x30310100u, 4, -2f, 8f, 9f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
|
|
@ -182,11 +368,17 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
|
||||
Assert.Equal((0x30, 0x31, true), Assert.Single(harness.Streaming.Recenters));
|
||||
Assert.False(Assert.Single(harness.Presentation.WorldReadyValues));
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.False(harness.Placement.Called);
|
||||
|
||||
harness.Streaming.RecenterPending = false;
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(new Vector3(-2f, 8f, 9f), harness.Placement.Position);
|
||||
Assert.True(harness.Placement.Called);
|
||||
// A6/R4 review fix: assert the REAL canonical body resolved the
|
||||
// cross-landblock destination, not just that the suffix ran.
|
||||
Assert.Equal(
|
||||
new Vector3(-2f, 8f, 9f),
|
||||
harness.Movement.Controller!.Position);
|
||||
Assert.Equal(0x30310100u, harness.Movement.Controller.CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -194,7 +386,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
var harness = new Harness(worldReady: false);
|
||||
harness.Controller.OnTeleportStarted(5);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 5, 1f, 2f, 3f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
|
|
@ -202,14 +394,14 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
harness.Controller.Tick(4.9f);
|
||||
Assert.False(Assert.Single(harness.Presentation.WorldReadyValues));
|
||||
Assert.False(harness.Presentation.WaitCueValues[^1]);
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.False(harness.Placement.Called);
|
||||
|
||||
harness.Controller.Tick(0.1f);
|
||||
harness.Controller.Tick(30f);
|
||||
|
||||
Assert.All(harness.Presentation.WorldReadyValues, Assert.False);
|
||||
Assert.True(harness.Presentation.WaitCueValues[^1]);
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.True(harness.Reveal.WaitCueShown);
|
||||
}
|
||||
|
||||
|
|
@ -219,7 +411,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
var order = new List<string>();
|
||||
var harness = new Harness(order: order);
|
||||
harness.Controller.OnTeleportStarted(8);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 8, 4f, 5f, 6f),
|
||||
teleportTimestampAdvanced: true);
|
||||
order.Clear();
|
||||
|
|
@ -236,7 +428,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(81);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 81, 4f, 5f, 6f),
|
||||
teleportTimestampAdvanced: true);
|
||||
long generation = harness.Reveal.Snapshot.Generation;
|
||||
|
|
@ -245,7 +437,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.Equal(0, harness.Reveal.PortalMaterializationCount);
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +447,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
var order = new List<string>();
|
||||
var harness = new Harness(order: order);
|
||||
harness.Controller.OnTeleportStarted(9);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 9, 4f, 5f, 6f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
||||
|
|
@ -281,7 +473,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
var order = new List<string>();
|
||||
var harness = new Harness(order: order);
|
||||
harness.Controller.OnTeleportStarted(91);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 91, 4f, 5f, 6f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Streaming.ReservationEnds.Clear();
|
||||
|
|
@ -300,22 +492,35 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(10);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 10, 1f, 1f, 1f),
|
||||
teleportTimestampAdvanced: true);
|
||||
|
||||
harness.Controller.OnTeleportStarted(11);
|
||||
harness.Controller.Tick(30f);
|
||||
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.False(harness.Placement.Called);
|
||||
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
||||
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 11, 2f, 2f, 2f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(new Vector3(2f), harness.Placement.Position);
|
||||
Assert.True(harness.Placement.Called);
|
||||
// A6 review fix: this is the specific test A6 named — the removed
|
||||
// assertion was the ONLY thing proving the SECOND destination
|
||||
// (2,2,2) placed and not the superseded first one (1,1,1).
|
||||
// Assert.True(Placement.Called) alone cannot distinguish them. X/Y
|
||||
// are exact; Z settles to the Harness's uniform terrain height (5)
|
||||
// because both candidate wire Z values (1 and 2) sit BELOW the
|
||||
// floor and the placement resolve's anti-penetration push-out is
|
||||
// instant (unlike gravity settling from ABOVE the floor, which
|
||||
// takes further physics ticks this test never runs) - X/Y alone
|
||||
// already discriminate (1,1) from (2,2) unambiguously.
|
||||
Assert.Equal(2f, harness.Movement.Controller!.Position.X);
|
||||
Assert.Equal(2f, harness.Movement.Controller.Position.Y);
|
||||
Assert.Equal(0x20210001u, harness.Movement.Controller.CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -323,7 +528,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(12);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 12, 1f, 2f, 3f),
|
||||
teleportTimestampAdvanced: true);
|
||||
|
||||
|
|
@ -359,7 +564,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(20);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 20, 1f, 2f, 3f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Streaming.ReservationEnds.Clear();
|
||||
|
|
@ -379,7 +584,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(30);
|
||||
harness.Controller.OfferDestination(
|
||||
harness.OfferDestination(
|
||||
Position(0x20210001u, 30, 1f, 2f, 3f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Session.OnSend = () => harness.Controller.OnTeleportStarted(31);
|
||||
|
|
@ -435,14 +640,22 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
{
|
||||
Controller = new PlayerMovementController(new PhysicsEngine()),
|
||||
};
|
||||
controllerSlot.Controller.SetPosition(Vector3.Zero, cell, Vector3.Zero);
|
||||
var position = new Vector3(12f, 24f, 6f);
|
||||
Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f);
|
||||
// C4 route 3: the canonical Runtime commit (RuntimeSetPositionState
|
||||
// .CommitCanonical, driven by RuntimeAcceptedPositionDriveController
|
||||
// .ReconcileAndAcknowledgePortal -> PlayerMovementController
|
||||
// .CommitCanonicalTeleportFrame in production) already resolves the
|
||||
// body/cell/orientation BEFORE this suffix runs. Reproduce that
|
||||
// pre-state directly rather than re-resolving it here - Place() no
|
||||
// longer resolves anything (D-T4).
|
||||
controllerSlot.Controller.SetPosition(position, cell, position);
|
||||
controllerSlot.Controller.SetBodyOrientation(rotation);
|
||||
var cameras = new ChaseCameraInputState
|
||||
{
|
||||
Legacy = new ChaseCamera(),
|
||||
Retail = new RetailChaseCamera(),
|
||||
};
|
||||
var origin = new LiveWorldOriginState();
|
||||
origin.SetPlaceholder(0x20, 0x21);
|
||||
var spatial = new FakeSpatialReconcile(() => new PlacementSnapshot(
|
||||
entity.Position,
|
||||
entity.ParentCellId ?? 0u,
|
||||
|
|
@ -451,18 +664,14 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
controllerSlot.Controller.CellId,
|
||||
controllerSlot.Controller.BodyOrientation));
|
||||
var placement = new LocalPlayerTeleportPlacement(
|
||||
new PhysicsEngine(),
|
||||
runtime,
|
||||
identity,
|
||||
controllerSlot,
|
||||
new LocalPlayerPhysicsHostSlot(),
|
||||
cameras,
|
||||
origin,
|
||||
spatial);
|
||||
var position = new Vector3(12f, 24f, 6f);
|
||||
Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f);
|
||||
|
||||
placement.Place(position, cell, rotation);
|
||||
placement.Place(rotation);
|
||||
|
||||
Assert.Equal(entity.Position, controllerSlot.Controller.Position);
|
||||
Assert.Equal(entity.ParentCellId, controllerSlot.Controller.CellId);
|
||||
|
|
@ -522,13 +731,18 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Vector3.Zero,
|
||||
sourceCell,
|
||||
Vector3.Zero);
|
||||
// C4 route 3: reproduce the canonical Runtime commit's pre-state
|
||||
// (body/cell already resolved) - Place() no longer resolves it.
|
||||
var destinationPosition = new Vector3(12f, 24f, 6f);
|
||||
controllerSlot.Controller.SetPosition(
|
||||
destinationPosition,
|
||||
destinationCell,
|
||||
destinationPosition);
|
||||
var cameras = new ChaseCameraInputState
|
||||
{
|
||||
Legacy = new ChaseCamera(),
|
||||
Retail = new RetailChaseCamera(),
|
||||
};
|
||||
var origin = new LiveWorldOriginState();
|
||||
origin.SetPlaceholder(0x30, 0x31);
|
||||
LiveEntityRecord? recordAtReconcile = null;
|
||||
var spatial = new FakeSpatialReconcile(() =>
|
||||
{
|
||||
|
|
@ -542,19 +756,14 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
controllerSlot.Controller.BodyOrientation);
|
||||
});
|
||||
var placement = new LocalPlayerTeleportPlacement(
|
||||
new PhysicsEngine(),
|
||||
runtime,
|
||||
identity,
|
||||
controllerSlot,
|
||||
new LocalPlayerPhysicsHostSlot(),
|
||||
cameras,
|
||||
origin,
|
||||
spatial);
|
||||
|
||||
placement.Place(
|
||||
new Vector3(12f, 24f, 6f),
|
||||
destinationCell,
|
||||
Quaternion.Identity);
|
||||
placement.Place(Quaternion.Identity);
|
||||
|
||||
Assert.NotNull(recordAtReconcile);
|
||||
uint resolvedDestinationCell = controllerSlot.Controller.CellId;
|
||||
|
|
@ -652,8 +861,26 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
uint ControllerCell,
|
||||
Quaternion ControllerRotation);
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: the Place edge now drives a REAL
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController"/> portal arm
|
||||
/// against a REAL (bare, session-less) local-player canonical record -
|
||||
/// the class this file's <c>LocalPlayerTeleportController</c> fake
|
||||
/// wiring cannot substitute (it is a sealed Runtime type, not an
|
||||
/// interface). <see cref="OfferDestination"/> also performs the SAME
|
||||
/// upstream merge production runs before ever offering a destination
|
||||
/// (<c>RuntimeEntityObjectLifetime.TryApplyPosition</c>, called by
|
||||
/// <c>LiveEntityNetworkUpdateController.OnPosition</c> before
|
||||
/// <c>OfferDestination</c>) so <c>BeginAcceptedPlacementCore</c>'s
|
||||
/// portal-vs-latest-cell gate sees the SAME destination the offer
|
||||
/// carries - exactly the production ordering, not a shortcut.
|
||||
/// </summary>
|
||||
private sealed class Harness
|
||||
{
|
||||
private const uint PlayerGuid = 0x50000001u;
|
||||
private const uint HomeCell = 0x20210001u;
|
||||
private ushort _mergePositionSequence = 1;
|
||||
|
||||
public readonly FakeAuthority Authority = new();
|
||||
public readonly FakeInput Input = new();
|
||||
public readonly FakeMode Mode;
|
||||
|
|
@ -664,6 +891,17 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
public readonly RuntimeWorldTransitState Transit;
|
||||
public readonly WorldRevealCoordinator Reveal;
|
||||
public readonly LocalPlayerTeleportController Controller;
|
||||
public readonly RuntimeEntityObjectLifetime Lifetime;
|
||||
public readonly RuntimeAcceptedPositionDriveController AcceptedPositionDrive;
|
||||
/// <summary>
|
||||
/// A6/R4/A5 review fix (2026-08-05): exposed so tests can assert the
|
||||
/// REAL canonical body's resolved position/cell after a committed
|
||||
/// portal placement, not just <c>Placement.Called</c> — the property
|
||||
/// that let A6's superseded-teleport test lose its destination
|
||||
/// discriminator (which of two destinations actually placed).
|
||||
/// </summary>
|
||||
public readonly RuntimeLocalPlayerMovementState Movement;
|
||||
public IPreparedCollisionSource DiagnosticCollisionSource => new UnusedCollisionSource();
|
||||
|
||||
public Harness(
|
||||
int centerX = 0x20,
|
||||
|
|
@ -688,6 +926,99 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
invalidateCompositeTextures: () => { },
|
||||
isSpawnClaimUnhydratable: _ => false,
|
||||
streaming: Streaming);
|
||||
|
||||
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)5f);
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < heightTable.Length; i++)
|
||||
heightTable[i] = i;
|
||||
engine.AddLandblock(
|
||||
HomeCell & 0xFFFF0000u,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
Lifetime = new RuntimeEntityObjectLifetime(engine);
|
||||
// The bare AddLandblock above only seeds the ENGINE's terrain -
|
||||
// TryPrepareAndSubmitAuthoredPlacement's DeferredCell park keys
|
||||
// off the SEPARATE collision-GENERATION ledger
|
||||
// (RuntimeSetPositionState.BeginCollisionGeneration/
|
||||
// CommitCollisionGeneration), which a bare AddLandblock never
|
||||
// touches. Commit it too, or every placement parks forever.
|
||||
Lifetime.Physics.SetPosition.BeginCollisionGeneration(
|
||||
HomeCell & 0xFFFF0000u, 1UL);
|
||||
Lifetime.Physics.SetPosition.CommitCollisionGeneration(
|
||||
HomeCell & 0xFFFF0000u, 1UL, ready: true);
|
||||
Lifetime.Physics.ObserveLocalWorldFrame(HomeCell, teleportAdvanced: false);
|
||||
Lifetime.BindEventContext(
|
||||
static () => new RuntimeGenerationToken(1UL),
|
||||
static () => 1UL);
|
||||
Movement = new RuntimeLocalPlayerMovementState();
|
||||
RuntimeLocalPlayerMovementState movement = Movement;
|
||||
var identity = new RuntimeLocalPlayerIdentityState
|
||||
{
|
||||
ServerGuid = PlayerGuid,
|
||||
};
|
||||
movement.AttachPhysicsPublication(new RuntimeLocalPlayerPhysicsPublicationState(
|
||||
Lifetime.Entities, Lifetime.Physics, movement, identity));
|
||||
Lifetime.LocalPlayerFirstEntry.BindPublication(movement.PhysicsPublication);
|
||||
Lifetime.BindLiveInputs(() => false, () => movement.Controller?.Position);
|
||||
var clock = new GameRuntimeClock();
|
||||
// C4 route 3 fixture note: RuntimeFirstEntryDriveController's
|
||||
// ctor subscribes to RuntimeEntityObjectLifetime
|
||||
// .BindInitialResidenceBeginNotification - it must exist BEFORE
|
||||
// RegisterEntityWithInitialResidence runs, or it misses the
|
||||
// notification and never learns the residence exists at all
|
||||
// (production constructs it once per host session, ahead of
|
||||
// every Create).
|
||||
var firstEntry = new RuntimeFirstEntryDriveController(
|
||||
Lifetime,
|
||||
clock,
|
||||
new UnusedCollisionSource(),
|
||||
() => PlayerMovementConstructionOptions.Fallback,
|
||||
static _ => new RuntimeLocalPlayerPhysicsActivationPreparation(
|
||||
0.48f, 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
||||
// Production's OnSpawned shape: RegisterEntityWithInitialResidence
|
||||
// then ApplyAcceptedSpawn (RuntimeLiveEntitySessionController.cs).
|
||||
RuntimeEntityRegistrationResult registration =
|
||||
Lifetime.RegisterEntityWithInitialResidence(
|
||||
Spawn(PlayerGuid, HomeCell), isLocalPlayer: true);
|
||||
RuntimeEntityRecord canonical = registration.Canonical
|
||||
?? throw new InvalidOperationException(
|
||||
"fixture failed to register the local player");
|
||||
Lifetime.ApplyAcceptedSpawn(
|
||||
canonical,
|
||||
canonical.CreateIntegrationVersion,
|
||||
canonical.Snapshot,
|
||||
replaceGeneration: registration.Inbound.Disposition
|
||||
is CreateObjectTimestampDisposition.NewGeneration);
|
||||
for (int attempt = 0; attempt < 8 && firstEntry.PendingCount != 0; attempt++)
|
||||
{
|
||||
firstEntry.DriveAll();
|
||||
DrainPlacementFifo();
|
||||
}
|
||||
Assert.Equal(0, firstEntry.PendingCount);
|
||||
Assert.True(
|
||||
Lifetime.Entities.TryGetActive(PlayerGuid, out RuntimeEntityRecord seeded),
|
||||
"fixture failed to register the local player");
|
||||
Assert.False(
|
||||
Lifetime.TryGetInitialCreateResidence(seeded, out _),
|
||||
"fixture left the local player's initial-create residence open");
|
||||
|
||||
AcceptedPositionDrive = new RuntimeAcceptedPositionDriveController(
|
||||
Lifetime,
|
||||
clock,
|
||||
new UnusedCollisionSource(),
|
||||
new LocalPlayerOutboundController((_, _, _, _, _, _) => { }),
|
||||
() => new RuntimeGenerationToken(1UL),
|
||||
() => PlayerGuid,
|
||||
() => movement.Controller,
|
||||
() => false,
|
||||
() => null,
|
||||
() => movement);
|
||||
|
||||
Controller = new LocalPlayerTeleportController(
|
||||
Authority,
|
||||
Input,
|
||||
|
|
@ -697,7 +1028,258 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Reveal,
|
||||
Placement,
|
||||
Session,
|
||||
Presentation);
|
||||
Presentation,
|
||||
AcceptedPositionDrive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the destination into the SAME canonical record the portal
|
||||
/// arm reads from, exactly like production's upstream merge, then
|
||||
/// offers it to the controller.
|
||||
/// </summary>
|
||||
public void OfferDestination(
|
||||
RuntimeTeleportDestination destination,
|
||||
bool teleportTimestampAdvanced)
|
||||
{
|
||||
_mergePositionSequence++;
|
||||
var update = new WorldSession.EntityPositionUpdate(
|
||||
destination.EntityGuid,
|
||||
new CreateObject.ServerPosition(
|
||||
destination.Position.ObjCellId,
|
||||
destination.Position.Frame.Origin.X,
|
||||
destination.Position.Frame.Origin.Y,
|
||||
destination.Position.Frame.Origin.Z,
|
||||
destination.Position.Frame.Orientation.W,
|
||||
destination.Position.Frame.Orientation.X,
|
||||
destination.Position.Frame.Orientation.Y,
|
||||
destination.Position.Frame.Orientation.Z),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: _mergePositionSequence,
|
||||
TeleportSequence: destination.TeleportSequence,
|
||||
ForcePositionSequence: 0);
|
||||
Lifetime.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: Quaternion.Identity,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
Controller.OfferDestination(destination, teleportTimestampAdvanced);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix test support (2026-08-05): merges an ORDINARY
|
||||
/// accepted Position for the local player — no portal offer, no
|
||||
/// transit involvement — the same
|
||||
/// <c>RuntimeEntityObjectLifetime.TryApplyPosition</c> call every
|
||||
/// real inbound ACE broadcast makes. Its doc names this the
|
||||
/// mechanism that <c>Forget</c>s any outstanding SetPosition
|
||||
/// operation for the entity, including a DeferredCell park — "the
|
||||
/// exact far-destination case the park exists to serve".
|
||||
/// </summary>
|
||||
public void MergeOrdinaryPosition(
|
||||
Vector3 position, uint cellId, ushort teleportSequence)
|
||||
{
|
||||
_mergePositionSequence++;
|
||||
var update = new WorldSession.EntityPositionUpdate(
|
||||
PlayerGuid,
|
||||
new CreateObject.ServerPosition(
|
||||
cellId,
|
||||
position.X,
|
||||
position.Y,
|
||||
position.Z,
|
||||
1f, 0f, 0f, 0f),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: _mergePositionSequence,
|
||||
// The SAME already-accepted teleport sequence, not a
|
||||
// regression - an ordinary ACE broadcast Apply during an
|
||||
// active teleport still carries it unchanged; only a NEW
|
||||
// portal advances it.
|
||||
TeleportSequence: teleportSequence,
|
||||
ForcePositionSequence: 0);
|
||||
Lifetime.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: Quaternion.Identity,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits a SECOND landblock's collision so a cross-landblock
|
||||
/// portal destination can resolve <c>Committed</c> instead of
|
||||
/// parking <c>DeferredCell</c> - the constructor's own home
|
||||
/// landblock is the only one committed by default.
|
||||
/// </summary>
|
||||
public void CommitLandblockCollision(uint landblockId)
|
||||
{
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)5f);
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < heightTable.Length; i++)
|
||||
heightTable[i] = i;
|
||||
Lifetime.Physics.SetPosition.BeginCollisionGeneration(landblockId, 1UL);
|
||||
Lifetime.Physics.Engine.AddLandblock(
|
||||
landblockId,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
Lifetime.Physics.SetPosition.CommitCollisionGeneration(
|
||||
landblockId, 1UL, ready: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a minimal synthetic indoor cell so a portal destination
|
||||
/// whose low word is >= 0x0100 can resolve <c>Committed</c> on its
|
||||
/// FIRST placement attempt instead of parking <c>DeferredCell</c>
|
||||
/// forever. <see cref="PhysicsEngine.IsSpawnCellReady"/> treats every
|
||||
/// outdoor cell (low word < 0x0100) as trivially ready, but an
|
||||
/// indoor cell requires <c>DataCache.GetCellStruct</c> to resolve a
|
||||
/// real entry - the bare fixture's <c>AddLandblock</c> calls only
|
||||
/// ever pass an empty <c>CellSurface</c> list, so without this an
|
||||
/// indoor destination cell is permanently un-spawnable regardless of
|
||||
/// how many times collision generation is committed for its
|
||||
/// landblock. Mirrors
|
||||
/// <c>RuntimeSetPositionStateTests.AddSyntheticCell</c>.
|
||||
/// </summary>
|
||||
public void AddSyntheticIndoorCell(uint envCellId)
|
||||
{
|
||||
Lifetime.Physics.DataCache.RegisterCellStructForTest(
|
||||
envCellId,
|
||||
new CellPhysics
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
Portals = [new PortalInfo(0, 0, 0)],
|
||||
CellBSP = new DatReaderWriter.Types.CellBSPTree
|
||||
{
|
||||
Root = new DatReaderWriter.Types.CellBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
},
|
||||
},
|
||||
});
|
||||
Lifetime.Physics.DataCache.CellGraph.Add(
|
||||
new AcDream.Core.World.Cells.EnvCell(
|
||||
envCellId,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.Identity,
|
||||
Vector3.Zero,
|
||||
Vector3.One,
|
||||
Array.Empty<AcDream.Core.World.Cells.CellPortal>(),
|
||||
Array.Empty<uint>(),
|
||||
seenOutside: false,
|
||||
containmentBsp: null));
|
||||
}
|
||||
|
||||
public void DrainPlacementFifo()
|
||||
{
|
||||
while (Lifetime.Physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot head))
|
||||
{
|
||||
if (!Lifetime.Physics.SetPosition.AcknowledgeProjection(head.Token))
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
cell, 10f, 10f, 5f, 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.ReportCollisions,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: null,
|
||||
MotionTableId: null,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
Timestamps: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
"teleport-controller-fixture",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
PhysicsState: physics.RawState,
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
private sealed class UnusedCollisionSource : IPreparedCollisionSource
|
||||
{
|
||||
public PreparedAssetPresence ProbeCollision(
|
||||
AcDream.Content.Pak.PakAssetType type, uint sourceFileId) =>
|
||||
PreparedAssetPresence.Available;
|
||||
|
||||
public PreparedCollisionReadResult<FlatSetupCollision> ReadSetupCollision(
|
||||
uint sourceFileId, CancellationToken cancellationToken = default) =>
|
||||
PreparedCollisionReadResult<FlatSetupCollision>.Missing;
|
||||
|
||||
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset> ReadGfxObjCollision(
|
||||
uint sourceFileId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
|
||||
ReadCellStructureCollision(
|
||||
uint sourceFileId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public PreparedCollisionReadResult<FlatEnvCellTopology> ReadEnvCellTopology(
|
||||
uint sourceFileId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public PreparedCollisionSourceStats CollisionStats => default;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -800,22 +1382,29 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: acknowledge-only per D-T4 - no position/cell arguments
|
||||
/// (the canonical Runtime commit already resolved them; this suffix only
|
||||
/// runs on a COMMITTED receipt, never authors one). <see cref="Called"/>
|
||||
/// stands in for the deleted <c>Position</c>/<c>CellId</c> capture: it is
|
||||
/// the fact that matters now (whether the arm committed and the Place
|
||||
/// edge acknowledged it), never a value this fake could plausibly
|
||||
/// mis-record.
|
||||
/// </summary>
|
||||
private sealed class FakePlacement : ILocalPlayerTeleportPlacement
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
|
||||
public FakePlacement(List<string> order) => _order = order;
|
||||
|
||||
public Vector3 Position;
|
||||
public uint CellId;
|
||||
public bool Called;
|
||||
public Quaternion Rotation;
|
||||
public Action? OnPlace;
|
||||
|
||||
public void Place(Vector3 position, uint cellId, Quaternion rotation)
|
||||
public void Place(Quaternion rotation)
|
||||
{
|
||||
_order.Add("placement");
|
||||
Position = position;
|
||||
CellId = cellId;
|
||||
Called = true;
|
||||
Rotation = rotation;
|
||||
OnPlace?.Invoke();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -463,8 +463,22 @@ public sealed class RuntimePlacementPresentationSinkTests
|
|||
Assert.True(replacement.IsSpatiallyVisible);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B2 review fix (2026-08-05): a Place whose portal authority is stale
|
||||
/// (the transit ended or was superseded while a park sat outstanding)
|
||||
/// must be ACKNOWLEDGED-AND-IGNORED, not refused. Before the fix this
|
||||
/// test asserted <c>TryApply</c> returns <c>false</c> for both the
|
||||
/// superseded-host and wrong-sequence cases — but
|
||||
/// <c>RuntimePlacementProjectionSubscription.OnPlacement</c> only calls
|
||||
/// <c>Acknowledge</c> when <c>TryApply</c> returns <c>true</c>; a
|
||||
/// <c>false</c> here leaves the stale receipt at the FIFO head
|
||||
/// PERMANENTLY, wedging every later entity's placement receipt behind
|
||||
/// it. The entity's world position must still never move to the stale
|
||||
/// receipt's coordinates — "ignore" means the receipt is retired
|
||||
/// without being applied, not silently accepted as real.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PortalPlace_RequiresExactCurrentTransitHostAndSequence()
|
||||
public void PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored()
|
||||
{
|
||||
Fixture fixture = Fixture.Create();
|
||||
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
|
||||
|
|
@ -491,7 +505,10 @@ public sealed class RuntimePlacementPresentationSinkTests
|
|||
{
|
||||
WorldPosition = new Vector3(80f, 81f, 82f),
|
||||
};
|
||||
Assert.False(fixture.Sink.TryApply(in superseded));
|
||||
// Acknowledged (true) - the stale-authority Place is retired from
|
||||
// the FIFO head, not left wedged - but the position never moves to
|
||||
// the stale receipt's coordinates.
|
||||
Assert.True(fixture.Sink.TryApply(in superseded));
|
||||
Assert.Equal(current.WorldPosition, record.WorldEntity.Position);
|
||||
|
||||
RuntimePlacementProjectionSnapshot wrongSequence = current with
|
||||
|
|
@ -502,7 +519,7 @@ public sealed class RuntimePlacementPresentationSinkTests
|
|||
},
|
||||
WorldPosition = new Vector3(90f, 91f, 92f),
|
||||
};
|
||||
Assert.False(fixture.Sink.TryApply(in wrongSequence));
|
||||
Assert.True(fixture.Sink.TryApply(in wrongSequence));
|
||||
Assert.Equal(current.WorldPosition, record.WorldEntity.Position);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -383,10 +383,20 @@ public sealed class HeadlessSessionHostTests
|
|||
record.Snapshot,
|
||||
replaceGeneration: false));
|
||||
var collision = new FixtureCollisionNeighborhood();
|
||||
// R5/A7 review fix (2026-08-05): the drive controller is now wired
|
||||
// (was omitted in the first pass, leaving the canonical portal arm
|
||||
// a no-op by construction here and dual-host parity with zero
|
||||
// coverage). Mirrors production composition
|
||||
// (HeadlessSessionHost.cs's own construction order): the SAME
|
||||
// RuntimeAcceptedPositionDriveController drives both hosts through
|
||||
// the identical TryExecuteAcceptedPortalArrival entry point.
|
||||
RuntimeAcceptedPositionDriveController acceptedPositionDrive =
|
||||
CreateAcceptedPositionDrive(runtime);
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
collision,
|
||||
firstEntry);
|
||||
firstEntry,
|
||||
acceptedPositionDrive);
|
||||
|
||||
projection.ProjectSpawn(record, isLocalPlayer: true);
|
||||
PlayerMovementController controller =
|
||||
|
|
@ -410,6 +420,15 @@ public sealed class HeadlessSessionHostTests
|
|||
projection.BeginTeleport();
|
||||
Assert.Equal(PlayerState.PortalSpace, controller.State);
|
||||
|
||||
// R5/A7: the destination cell 0xA9B40001 is in the SAME landblock
|
||||
// (0xA9B40000) whose collision generation the test already
|
||||
// committed above, so the canonical portal arm resolves
|
||||
// Committed synchronously - no DeferredCell park needed to exercise
|
||||
// the real headless placement path. A1's headless retry loop
|
||||
// (RuntimeLiveEntitySessionController.PumpPortalCompletion) is
|
||||
// covered separately by
|
||||
// HeadlessPortalDeferredCellCommitsOnPumpAfterCollisionGenerationWake
|
||||
// below.
|
||||
RuntimeDestinationReadiness readiness =
|
||||
projection.PrepareDestination(
|
||||
revealGeneration: 7,
|
||||
|
|
@ -422,13 +441,393 @@ public sealed class HeadlessSessionHostTests
|
|||
new Position(
|
||||
0xA9B40001u,
|
||||
new Vector3(96f, 97f, 50f),
|
||||
Quaternion.Identity)));
|
||||
Quaternion.Identity)),
|
||||
// R5/A7: a real, valid token - `default` was fine for the
|
||||
// old no-op arm but RuntimePortalPlacementAuthority.IsValid
|
||||
// now genuinely gates TryExecuteAcceptedPortalArrival on it.
|
||||
new RuntimeWorldHostProjectionToken(7, 0xA9B40001u));
|
||||
|
||||
Assert.True(readiness.IsCollisionReady);
|
||||
Assert.False(readiness.IsUnhydratable);
|
||||
Assert.Equal(PlayerState.InWorld, controller.State);
|
||||
Assert.Equal(3, collision.CenterCount);
|
||||
Assert.Equal(2, collision.CenterCount);
|
||||
Assert.Equal(0xA9B40001u, collision.LastCell);
|
||||
// A4/dual-host parity: the canonical placement actually committed -
|
||||
// the body moved to the destination Position, not just the
|
||||
// collision-neighborhood bookkeeping that CenterCount/LastCell
|
||||
// alone would have proven even with the earlier no-op arm. Z
|
||||
// settles 0.005 above the wire value (the foot sphere's bottom
|
||||
// sits at origin + 0.475 - 0.48, LoadedSetupCollisionSource's own
|
||||
// doc comment, ISSUES.md #285) - X/Y are exact, Z is asserted
|
||||
// within that settle tolerance.
|
||||
Assert.Equal(96f, controller.Position.X);
|
||||
Assert.Equal(97f, controller.Position.Y);
|
||||
Assert.Equal(50f, controller.Position.Z, 0.01f);
|
||||
// The resolved outdoor sub-cell index is derived from X/Y within
|
||||
// the landblock (not the wire placeholder 0xA9B40001), same as the
|
||||
// FIRST ProjectPosition assertion above (":406-409") only checks
|
||||
// landblock+indoor-vs-outdoor, not the exact sub-cell.
|
||||
Assert.Equal(0xA9B40000u, controller.CellId & 0xFFFF0000u);
|
||||
Assert.True((controller.CellId & 0xFFFFu) < 0x0100u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A1/R5/A7 review fix (2026-08-05): headless has no per-frame anim
|
||||
/// sequencer the way the graphical host does, so its OWN equivalent of
|
||||
/// A1's "hold until committed" mechanism is
|
||||
/// <c>HeadlessSessionWorldProjection.PrepareDestination</c>'s
|
||||
/// <c>_awaitingPortalWake</c> polling. This proves it end to end: a
|
||||
/// destination in a landblock whose collision generation is NOT yet
|
||||
/// committed parks (<c>IsCollisionReady: false</c>, body unmoved, no
|
||||
/// throw — DeferredCell is a normal headless outcome per
|
||||
/// <c>PrepareDestination</c>'s own doc), and once the destination
|
||||
/// landblock's collision generation commits, the SAME park resolves on
|
||||
/// a later attempt WITHOUT a second concurrent Begin (Runtime's own
|
||||
/// Begin would refuse that with Contention if this class re-attempted
|
||||
/// blindly instead of polling <c>PendingCount</c>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
const uint player = 0x50000009u;
|
||||
const uint destinationLandblock = 0xAAB40000u;
|
||||
runtime.PlayerIdentity.ServerGuid = player;
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
0xA9B40000u, 1UL);
|
||||
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
0xA9B40000u, 1UL, ready: true);
|
||||
AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry =
|
||||
CreateFirstEntryDrive(runtime);
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
record,
|
||||
record.CreateIntegrationVersion,
|
||||
record.Snapshot,
|
||||
replaceGeneration: false));
|
||||
var collision = new FixtureCollisionNeighborhood();
|
||||
RuntimeAcceptedPositionDriveController acceptedPositionDrive =
|
||||
CreateAcceptedPositionDrive(runtime);
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
collision,
|
||||
firstEntry,
|
||||
acceptedPositionDrive);
|
||||
|
||||
projection.ProjectSpawn(record, isLocalPlayer: true);
|
||||
PlayerMovementController controller =
|
||||
Assert.IsType<PlayerMovementController>(
|
||||
runtime.MovementOwner.Controller);
|
||||
controller.SetPosition(
|
||||
new Vector3(48f, 49f, 50f),
|
||||
0xA9B40001u);
|
||||
projection.ProjectPosition(
|
||||
record,
|
||||
isLocalPlayer: true,
|
||||
PositionTimestampDisposition.Apply);
|
||||
projection.BeginTeleport();
|
||||
|
||||
var destination = new RuntimeTeleportDestination(
|
||||
player,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 2,
|
||||
TeleportSequence: 1,
|
||||
ForcePositionSequence: 0,
|
||||
new Position(
|
||||
destinationLandblock | 0x0001u,
|
||||
new Vector3(10f, 10f, 50f),
|
||||
Quaternion.Identity));
|
||||
var projectionToken = new RuntimeWorldHostProjectionToken(
|
||||
7, destinationLandblock | 0x0001u);
|
||||
|
||||
// Begin's own portal-vs-latest-cell gate (D-T5) requires the
|
||||
// destination's landblock to match the record's LATEST MERGED
|
||||
// Position, not just the transit's retained destination - mirror
|
||||
// what the real inbound Position handler already does before
|
||||
// TryCompletePortal ever runs (LiveEntityNetworkUpdateController's
|
||||
// App-side equivalent).
|
||||
Assert.True(runtime.EntityObjects.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
player,
|
||||
new CreateObject.ServerPosition(
|
||||
destination.Position.ObjCellId,
|
||||
destination.Position.Frame.Origin.X,
|
||||
destination.Position.Frame.Origin.Y,
|
||||
destination.Position.Frame.Origin.Z,
|
||||
destination.Position.Frame.Orientation.W,
|
||||
destination.Position.Frame.Orientation.X,
|
||||
destination.Position.Frame.Orientation.Y,
|
||||
destination.Position.Frame.Orientation.Z),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 3,
|
||||
TeleportSequence: destination.TeleportSequence,
|
||||
ForcePositionSequence: 0),
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: Quaternion.Identity,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
|
||||
// First attempt: destinationLandblock's collision generation was
|
||||
// never begun/committed, so the canonical arm parks DeferredCell.
|
||||
// Must NOT throw (a park is normal, not an error). The dormant
|
||||
// stage (RuntimeSetPositionState's SubmitPreparedPlacementCore
|
||||
// deferred-commit path) already stages the body's Position/CellId
|
||||
// at the destination while it waits (StageDormantCellFrame,
|
||||
// body.InWorld=false) - the reader-visible Position moving early is
|
||||
// that mechanism, not evidence the placement committed; only
|
||||
// PlayerState/IsCollisionReady distinguish "staged" from
|
||||
// "committed" here.
|
||||
RuntimeDestinationReadiness parked = projection.PrepareDestination(
|
||||
revealGeneration: 7, destination, projectionToken);
|
||||
Assert.False(parked.IsCollisionReady);
|
||||
Assert.Equal(PlayerState.PortalSpace, controller.State);
|
||||
|
||||
// A SECOND attempt while still parked must not double-Begin -
|
||||
// Runtime's own Begin would refuse a genuine second attempt with
|
||||
// Contention, but PrepareDestination's _awaitingPortalWake polls
|
||||
// PendingCount instead of re-attempting, so this must also report
|
||||
// not-ready without throwing.
|
||||
RuntimeDestinationReadiness stillParked =
|
||||
projection.PrepareDestination(
|
||||
revealGeneration: 7, destination, projectionToken);
|
||||
Assert.False(stillParked.IsCollisionReady);
|
||||
|
||||
// Commit the destination landblock's collision generation and pump
|
||||
// the drive's wake (mirrors HeadlessSessionHost.Tick's own
|
||||
// PumpFirstEntry -> _acceptedPositionDrive.Advance() ordering).
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
destinationLandblock, 1UL);
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)50);
|
||||
var heightTable = new float[256];
|
||||
for (int index = 0; index < heightTable.Length; index++)
|
||||
heightTable[index] = index;
|
||||
runtime.EntityObjects.Physics.Engine.AddLandblock(
|
||||
destinationLandblock,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
[],
|
||||
[],
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
destinationLandblock, 1UL, ready: true);
|
||||
// RuntimeAcceptedPositionDriveControllerTests.CommitLandblockCollision's
|
||||
// exact proven-working shape: the wake path's
|
||||
// resolveWorldOffsetFromRuntimeFrame requires the destination
|
||||
// landblock's world-frame offset to already be resolvable.
|
||||
runtime.EntityObjects.Physics.ObserveLocalWorldFrame(
|
||||
destinationLandblock | 0x0001u,
|
||||
teleportAdvanced: false);
|
||||
// Drain the placement projection FIFO AFTER committing (exact order
|
||||
// from RuntimeAcceptedPositionDriveControllerTests.DrainPlacementFifo's
|
||||
// call site: commit collision -> drain FIFO -> Advance) - the
|
||||
// deferred park's Withdraw notification is published as part of the
|
||||
// collision-generation commit, not before it.
|
||||
while (runtime.EntityObjects.Physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot head))
|
||||
{
|
||||
if (!runtime.EntityObjects.Physics.SetPosition
|
||||
.AcknowledgeProjection(head.Token))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
acceptedPositionDrive.Advance();
|
||||
|
||||
RuntimeDestinationReadiness committed =
|
||||
projection.PrepareDestination(
|
||||
revealGeneration: 7, destination, projectionToken);
|
||||
Assert.True(committed.IsCollisionReady);
|
||||
Assert.Equal(PlayerState.InWorld, controller.State);
|
||||
Assert.Equal(10f, controller.Position.X);
|
||||
Assert.Equal(10f, controller.Position.Y);
|
||||
Assert.Equal(destinationLandblock, controller.CellId & 0xFFFF0000u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix (2026-08-05): headless's required test #2 — the same
|
||||
/// unsound-commit-inference defect
|
||||
/// <see cref="HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake"/>
|
||||
/// proves the HAPPY path for, exercised on the FORGOTTEN path instead. A
|
||||
/// DeferredCell park killed by an ordinary, unrelated accepted Position
|
||||
/// merge (exactly the ACE 5-10 Hz broadcast <c>RuntimeSetPositionState</c>'s
|
||||
/// own doc names as the expected way a far-destination park resolves
|
||||
/// without committing) must leave <c>PrepareDestination</c> reporting
|
||||
/// NOT ready, <see cref="PlayerState.PortalSpace"/> unchanged, and the
|
||||
/// body never moved to the destination - before the fix,
|
||||
/// <c>PendingCount</c> hitting 0 made <c>PrepareDestination</c> infer
|
||||
/// "committed" and run the full readiness/materialize/LoginComplete
|
||||
/// sequence against an unmoved body.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
const uint player = 0x50000009u;
|
||||
const uint destinationLandblock = 0xAAB40000u;
|
||||
runtime.PlayerIdentity.ServerGuid = player;
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
0xA9B40000u, 1UL);
|
||||
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
0xA9B40000u, 1UL, ready: true);
|
||||
AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry =
|
||||
CreateFirstEntryDrive(runtime);
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
record,
|
||||
record.CreateIntegrationVersion,
|
||||
record.Snapshot,
|
||||
replaceGeneration: false));
|
||||
var collision = new FixtureCollisionNeighborhood();
|
||||
RuntimeAcceptedPositionDriveController acceptedPositionDrive =
|
||||
CreateAcceptedPositionDrive(runtime);
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
collision,
|
||||
firstEntry,
|
||||
acceptedPositionDrive);
|
||||
|
||||
projection.ProjectSpawn(record, isLocalPlayer: true);
|
||||
PlayerMovementController controller =
|
||||
Assert.IsType<PlayerMovementController>(
|
||||
runtime.MovementOwner.Controller);
|
||||
controller.SetPosition(
|
||||
new Vector3(48f, 49f, 50f),
|
||||
0xA9B40001u);
|
||||
projection.ProjectPosition(
|
||||
record,
|
||||
isLocalPlayer: true,
|
||||
PositionTimestampDisposition.Apply);
|
||||
projection.BeginTeleport();
|
||||
|
||||
var destination = new RuntimeTeleportDestination(
|
||||
player,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 2,
|
||||
TeleportSequence: 1,
|
||||
ForcePositionSequence: 0,
|
||||
new Position(
|
||||
destinationLandblock | 0x0001u,
|
||||
new Vector3(10f, 10f, 50f),
|
||||
Quaternion.Identity));
|
||||
var projectionToken = new RuntimeWorldHostProjectionToken(
|
||||
7, destinationLandblock | 0x0001u);
|
||||
|
||||
Assert.True(runtime.EntityObjects.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
player,
|
||||
new CreateObject.ServerPosition(
|
||||
destination.Position.ObjCellId,
|
||||
destination.Position.Frame.Origin.X,
|
||||
destination.Position.Frame.Origin.Y,
|
||||
destination.Position.Frame.Origin.Z,
|
||||
destination.Position.Frame.Orientation.W,
|
||||
destination.Position.Frame.Orientation.X,
|
||||
destination.Position.Frame.Orientation.Y,
|
||||
destination.Position.Frame.Orientation.Z),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 3,
|
||||
TeleportSequence: destination.TeleportSequence,
|
||||
ForcePositionSequence: 0),
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: Quaternion.Identity,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
|
||||
// First attempt: destinationLandblock's collision generation was
|
||||
// never begun/committed, so the canonical arm parks DeferredCell.
|
||||
RuntimeDestinationReadiness parked = projection.PrepareDestination(
|
||||
revealGeneration: 7, destination, projectionToken);
|
||||
Assert.False(parked.IsCollisionReady);
|
||||
Assert.Equal(PlayerState.PortalSpace, controller.State);
|
||||
Assert.Equal(1, acceptedPositionDrive.PendingCount);
|
||||
|
||||
// An ordinary, UNRELATED accepted Position for the same entity - no
|
||||
// new teleport, just a normal broadcast at the SAME already-accepted
|
||||
// teleport sequence - Forgets the parked operation the same way
|
||||
// ACE's 5-10 Hz cadence would (RuntimeSetPositionState.Forget, called
|
||||
// from TryApplyPosition for every accepted, non-Rejected Position).
|
||||
Assert.True(runtime.EntityObjects.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
player,
|
||||
new CreateObject.ServerPosition(
|
||||
0x20210001u, 48f, 49f, 50f, 1f, 0f, 0f, 0f),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 4,
|
||||
TeleportSequence: destination.TeleportSequence,
|
||||
ForcePositionSequence: 0),
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: Quaternion.Identity,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
// Forget (inside TryApplyPosition) cancels the underlying
|
||||
// RuntimeSetPositionState operation immediately, but the drive's OWN
|
||||
// _pending cache only notices on its next Advance() pump - the real
|
||||
// host does this every HeadlessSessionHost.Tick via PumpFirstEntry;
|
||||
// the test drives it explicitly, same as the App-level equivalent.
|
||||
acceptedPositionDrive.Advance();
|
||||
Assert.Equal(0, acceptedPositionDrive.PendingCount);
|
||||
|
||||
// Drive well past where the pre-fix inference would have latched
|
||||
// "committed" on the very next PrepareDestination call and then
|
||||
// marched to the full readiness/materialize/LoginComplete sequence.
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
RuntimeDestinationReadiness stillNotReady =
|
||||
projection.PrepareDestination(
|
||||
revealGeneration: 7, destination, projectionToken);
|
||||
Assert.False(stillNotReady.IsCollisionReady);
|
||||
}
|
||||
|
||||
Assert.Equal(PlayerState.PortalSpace, controller.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -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