Slice A4 hung UI_EnterPortal on TeleportAnimEvent.EnterTunnel — the first tunnel-family frame — so the cue landed a whole TunnelFadeIn after retail plays it. Retail's site is gmSmartBoxUI::BeginTeleportAnimation @0x004D638E, i.e. Begin(), which the sequencer already marks as TeleportAnimEvent.PlayEnterSound. That event has existed since the R6 portal-space work, complete with a 'Begin(): sound_ui_enter_portal' comment, and no consumer has ever handled it — the switch in LocalPlayerTeleportController had cases for Place, EnterTunnel, PlayExitSound and FireLoginComplete only, so the sequencer emitted PlayEnterSound into nothing. A4 filled the gap in the wrong place rather than filling it. Both cues now go through named presentation methods driven by the matching events: PlayEnterCue on PlayEnterSound, PlayExitCue on PlayExitSound (the TunnelFadeOut -> WorldFadeIn edge, @0x004D7405, the same tick the world viewport is revealed). EnterTunnel/ExitTunnel are visuals again. The exit cue was already firing at the right moment, since ExitTunnel was called from inside the PlayExitSound case — correct by accident, explicit now. Found by the user asking when the recall cues play. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1550 lines
64 KiB
C#
1550 lines
64 KiB
C#
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;
|
|
|
|
public sealed class LocalPlayerTeleportControllerTests
|
|
{
|
|
[Fact]
|
|
public void DeferredNetworkOwnedBindingReleasesExactlyAndAllowsRebind()
|
|
{
|
|
var deferred = new DeferredLocalPlayerTeleportNetworkSink();
|
|
var first = new FakeNetworkSink();
|
|
var second = new FakeNetworkSink();
|
|
|
|
IDisposable firstBinding = deferred.BindOwned(first);
|
|
Assert.Throws<InvalidOperationException>(() => deferred.BindOwned(second));
|
|
|
|
firstBinding.Dispose();
|
|
using IDisposable secondBinding = deferred.BindOwned(second);
|
|
firstBinding.Dispose();
|
|
deferred.OnTeleportStarted(9);
|
|
|
|
Assert.Empty(first.Starts);
|
|
Assert.Equal([9u], second.Starts);
|
|
}
|
|
|
|
[Fact]
|
|
public void DestinationBeforeStart_IsReplayedOnceAfterPresentationActivates()
|
|
{
|
|
var harness = new Harness();
|
|
RuntimeTeleportDestination destination = Position(
|
|
cellId: 0x20210001u,
|
|
teleportSequence: 7,
|
|
x: 12f,
|
|
y: 34f,
|
|
z: 5f);
|
|
|
|
harness.OfferDestination(
|
|
destination,
|
|
teleportTimestampAdvanced: true);
|
|
Assert.Empty(harness.Streaming.Reservations);
|
|
|
|
harness.Controller.OnTeleportStarted(7);
|
|
|
|
Assert.Equal(1, harness.Input.EndCount);
|
|
Assert.Equal(1, harness.Mode.EnterPortalCount);
|
|
Assert.Equal(Matrix4x4.Identity, harness.Presentation.BeginProjection);
|
|
Assert.Equal(
|
|
(1L, 0x20210001u, harness.RevealWindow.FarRadius),
|
|
Assert.Single(harness.Streaming.Reservations));
|
|
Assert.True(harness.Reveal.Snapshot.IsActive);
|
|
Assert.Equal(RuntimePortalKind.Portal, harness.Reveal.Snapshot.Kind);
|
|
|
|
harness.OfferDestination(
|
|
destination,
|
|
teleportTimestampAdvanced: false);
|
|
Assert.Single(harness.Streaming.Reservations);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingPlayerController_KeepsStartAndDestinationPendingUntilProjectionExists()
|
|
{
|
|
var harness = new Harness();
|
|
harness.Mode.Controller = null;
|
|
|
|
harness.Controller.OnTeleportStarted(2);
|
|
harness.OfferDestination(
|
|
Position(0x20210001u, 2, 7f, 8f, 9f),
|
|
teleportTimestampAdvanced: true);
|
|
|
|
Assert.False(harness.Controller.IsActive);
|
|
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
|
Assert.Null(harness.Presentation.BeginProjection);
|
|
|
|
harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine());
|
|
harness.Mode.Controller.SeedPlacementForTest(
|
|
Vector3.Zero,
|
|
0x20210001u,
|
|
Vector3.Zero);
|
|
harness.Controller.Tick(0.016f);
|
|
|
|
Assert.True(harness.Controller.IsActive);
|
|
Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell);
|
|
Assert.NotNull(harness.Presentation.BeginProjection);
|
|
Assert.Single(harness.Streaming.Reservations);
|
|
}
|
|
|
|
[Fact]
|
|
public void ControllerWithdrawnAfterActivation_KeepsAcceptedDestinationUntilItReturns()
|
|
{
|
|
var harness = new Harness();
|
|
harness.Controller.OnTeleportStarted(14);
|
|
Assert.True(harness.Controller.IsActive);
|
|
|
|
harness.Mode.Controller = null;
|
|
harness.OfferDestination(
|
|
Position(0x20210001u, 14, 7f, 8f, 9f),
|
|
teleportTimestampAdvanced: true);
|
|
|
|
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
|
Assert.Empty(harness.Streaming.Reservations);
|
|
|
|
harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine());
|
|
harness.Mode.Controller.SeedPlacementForTest(Vector3.Zero, 0x20210001u, Vector3.Zero);
|
|
harness.Controller.Tick(0.016f);
|
|
|
|
Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell);
|
|
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.OfferDestination(
|
|
Position(0x20210001u, 15, 7f, 8f, 9f),
|
|
teleportTimestampAdvanced: true);
|
|
harness.Mode.Controller = null;
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
|
|
|
harness.Controller.Tick(0.016f);
|
|
|
|
Assert.False(harness.Placement.Called);
|
|
Assert.Empty(harness.Presentation.WorldReadyValues);
|
|
|
|
harness.Mode.RebuildOnEnter = () =>
|
|
{
|
|
var controller = new PlayerMovementController(new PhysicsEngine());
|
|
controller.SeedPlacementForTest(Vector3.Zero, 0x20210001u, Vector3.Zero);
|
|
return controller;
|
|
};
|
|
harness.Controller.Tick(0.016f);
|
|
|
|
Assert.NotNull(harness.Mode.Controller);
|
|
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.OfferDestination(
|
|
Position(0x20210123u, 3, 20f, 30f, 4f),
|
|
teleportTimestampAdvanced: true);
|
|
harness.Presentation.EmitPlaceWhenReady = true;
|
|
|
|
harness.Controller.Tick(0.016f);
|
|
|
|
Assert.Empty(harness.Streaming.Recenters);
|
|
// 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!.SeedPlacementForTest(
|
|
Vector3.Zero,
|
|
0x20210001u,
|
|
Vector3.Zero);
|
|
harness.Controller.OnTeleportStarted(4);
|
|
harness.Streaming.RecenterPending = true;
|
|
harness.OfferDestination(
|
|
Position(0x30310100u, 4, -2f, 8f, 9f),
|
|
teleportTimestampAdvanced: true);
|
|
harness.Presentation.EmitPlaceWhenReady = true;
|
|
|
|
harness.Controller.Tick(1f);
|
|
|
|
Assert.Equal((0x30, 0x31, true), Assert.Single(harness.Streaming.Recenters));
|
|
Assert.False(Assert.Single(harness.Presentation.WorldReadyValues));
|
|
Assert.False(harness.Placement.Called);
|
|
|
|
harness.Streaming.RecenterPending = false;
|
|
harness.Controller.Tick(0.016f);
|
|
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]
|
|
public void ReadinessHold_NeverRevealsIncompleteWorldAndShowsWaitCueAfterFiveSeconds()
|
|
{
|
|
var harness = new Harness(worldReady: false);
|
|
harness.Controller.OnTeleportStarted(5);
|
|
harness.OfferDestination(
|
|
Position(0x20210001u, 5, 1f, 2f, 3f),
|
|
teleportTimestampAdvanced: true);
|
|
harness.Presentation.EmitPlaceWhenReady = true;
|
|
|
|
harness.Controller.Tick(4.9f);
|
|
Assert.False(Assert.Single(harness.Presentation.WorldReadyValues));
|
|
Assert.False(harness.Presentation.WaitCueValues[^1]);
|
|
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.False(harness.Placement.Called);
|
|
Assert.True(harness.Reveal.WaitCueShown);
|
|
}
|
|
|
|
[Fact]
|
|
public void Place_ReconcilesInsidePlacementBeforeRevealMaterialized()
|
|
{
|
|
var order = new List<string>();
|
|
var harness = new Harness(order: order);
|
|
harness.Controller.OnTeleportStarted(8);
|
|
harness.OfferDestination(
|
|
Position(0x20210001u, 8, 4f, 5f, 6f),
|
|
teleportTimestampAdvanced: true);
|
|
order.Clear();
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
|
|
|
harness.Controller.Tick(0.016f);
|
|
|
|
Assert.True(Index(order, "placement") < Index(order, "[world-reveal] event=materialized"));
|
|
Assert.Equal(1, harness.Reveal.PortalMaterializationCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Place_RechecksExactRuntimeLifetimeBeforeMutatingPresentation()
|
|
{
|
|
var harness = new Harness();
|
|
harness.Controller.OnTeleportStarted(81);
|
|
harness.OfferDestination(
|
|
Position(0x20210001u, 81, 4f, 5f, 6f),
|
|
teleportTimestampAdvanced: true);
|
|
long generation = harness.Reveal.Snapshot.Generation;
|
|
Assert.True(harness.Transit.Cancel(generation));
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
|
|
|
harness.Controller.Tick(0.016f);
|
|
|
|
Assert.False(harness.Placement.Called);
|
|
Assert.Equal(0, harness.Reveal.PortalMaterializationCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoginComplete_EntersWorldThenSendsThenCompletesAndResets()
|
|
{
|
|
var order = new List<string>();
|
|
var harness = new Harness(order: order);
|
|
harness.Controller.OnTeleportStarted(9);
|
|
harness.OfferDestination(
|
|
Position(0x20210001u, 9, 4f, 5f, 6f),
|
|
teleportTimestampAdvanced: true);
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
|
harness.Controller.Tick(0.016f);
|
|
order.Clear();
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete);
|
|
|
|
harness.Controller.Tick(0.016f);
|
|
|
|
Assert.True(Index(order, "enter-world") < Index(order, "login-complete"));
|
|
Assert.True(Index(order, "login-complete") < Index(order, "[world-reveal] event=complete"));
|
|
Assert.True(Index(order, "[world-reveal] event=complete") < Index(order, "presentation-reset"));
|
|
Assert.False(harness.Controller.IsActive);
|
|
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
|
Assert.Equal(1, harness.Session.LoginCompleteCount);
|
|
Assert.True(harness.Reveal.Snapshot.Completed);
|
|
Assert.False(harness.Reveal.Snapshot.Cancelled);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExitSound_ReleasesDestinationBeforeHidingTunnelButKeepsProtocolActive()
|
|
{
|
|
var order = new List<string>();
|
|
var harness = new Harness(order: order);
|
|
harness.Controller.OnTeleportStarted(91);
|
|
harness.OfferDestination(
|
|
Position(0x20210001u, 91, 4f, 5f, 6f),
|
|
teleportTimestampAdvanced: true);
|
|
harness.Streaming.ReservationEnds.Clear();
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
|
|
|
|
harness.Controller.Tick(0.016f);
|
|
|
|
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
|
Assert.True(harness.Controller.IsActive);
|
|
Assert.False(harness.Reveal.Snapshot.Completed);
|
|
Assert.Single(harness.Streaming.ReservationEnds);
|
|
}
|
|
|
|
[Fact]
|
|
public void NewerStart_ReplacesOldDestinationWithoutReusingIt()
|
|
{
|
|
var harness = new Harness();
|
|
harness.Controller.OnTeleportStarted(10);
|
|
harness.OfferDestination(
|
|
Position(0x20210001u, 10, 1f, 1f, 1f),
|
|
teleportTimestampAdvanced: true);
|
|
|
|
harness.Controller.OnTeleportStarted(11);
|
|
harness.Controller.Tick(30f);
|
|
|
|
Assert.False(harness.Placement.Called);
|
|
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
|
|
|
harness.OfferDestination(
|
|
Position(0x20210001u, 11, 2f, 2f, 2f),
|
|
teleportTimestampAdvanced: true);
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
|
harness.Controller.Tick(0.016f);
|
|
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]
|
|
public void SessionReset_ClearsTransitAndLetsRecenterConvergeAcrossLaterFrames()
|
|
{
|
|
var harness = new Harness();
|
|
harness.Controller.OnTeleportStarted(12);
|
|
harness.OfferDestination(
|
|
Position(0x20210001u, 12, 1f, 2f, 3f),
|
|
teleportTimestampAdvanced: true);
|
|
|
|
harness.Controller.ResetSession();
|
|
|
|
Assert.False(harness.Controller.IsActive);
|
|
Assert.Equal(RuntimePortalSnapshot.Idle, harness.Reveal.Snapshot);
|
|
Assert.True(harness.Streaming.LastResetWasSessionEnding);
|
|
|
|
var failed = new Harness();
|
|
failed.Streaming.ResetResult = false;
|
|
failed.Controller.ResetSession();
|
|
Assert.False(failed.Controller.IsActive);
|
|
Assert.True(failed.Streaming.LastResetWasSessionEnding);
|
|
}
|
|
|
|
[Fact]
|
|
public void StaleStart_DoesNotMutateInputModeOrPresentation()
|
|
{
|
|
var harness = new Harness();
|
|
harness.Authority.IsFresh = false;
|
|
|
|
harness.Controller.OnTeleportStarted(13);
|
|
|
|
Assert.Equal(0, harness.Input.EndCount);
|
|
Assert.Equal(0, harness.Mode.EnterPortalCount);
|
|
Assert.Null(harness.Presentation.BeginProjection);
|
|
Assert.False(harness.Controller.IsActive);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReentrantNewStartDuringPlacement_CannotBeCompletedByOldTail()
|
|
{
|
|
var harness = new Harness();
|
|
harness.Controller.OnTeleportStarted(20);
|
|
harness.OfferDestination(
|
|
Position(0x20210001u, 20, 1f, 2f, 3f),
|
|
teleportTimestampAdvanced: true);
|
|
harness.Streaming.ReservationEnds.Clear();
|
|
harness.Placement.OnPlace = () => harness.Controller.OnTeleportStarted(21);
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
|
|
|
harness.Controller.Tick(0.016f);
|
|
|
|
Assert.True(harness.Controller.IsActive);
|
|
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
|
Assert.Equal(0, harness.Reveal.PortalMaterializationCount);
|
|
Assert.Single(harness.Streaming.ReservationEnds);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReentrantNewStartDuringLoginComplete_PreservesReplacementLifetime()
|
|
{
|
|
var harness = new Harness();
|
|
harness.Controller.OnTeleportStarted(30);
|
|
harness.OfferDestination(
|
|
Position(0x20210001u, 30, 1f, 2f, 3f),
|
|
teleportTimestampAdvanced: true);
|
|
harness.Session.OnSend = () => harness.Controller.OnTeleportStarted(31);
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete);
|
|
|
|
harness.Controller.Tick(0.016f);
|
|
|
|
Assert.True(harness.Controller.IsActive);
|
|
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
|
Assert.False(harness.Reveal.Snapshot.Completed);
|
|
Assert.Equal(1, harness.Session.LoginCompleteCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void DisposeFailure_CanBeRetriedWithoutDoubleDisposingAfterSuccess()
|
|
{
|
|
var harness = new Harness();
|
|
harness.Presentation.DisposeFailuresRemaining = 1;
|
|
|
|
Assert.Throws<InvalidOperationException>(() => harness.Controller.Dispose());
|
|
harness.Controller.Dispose();
|
|
harness.Controller.Dispose();
|
|
|
|
Assert.Equal(2, harness.Presentation.DisposeCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConcretePlacement_CommitsEntityControllerAndOneSpatialReconcile()
|
|
{
|
|
const uint guid = 0x50000001u;
|
|
const uint cell = 0x20210001u;
|
|
var world = new GpuWorldState();
|
|
world.AddLandblock(new LoadedLandblock(
|
|
0x2021FFFFu,
|
|
new DatReaderWriter.DBObjs.LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
var runtime = LiveEntityRuntimeFixture.Create(world, new NullResources());
|
|
runtime.RegisterLiveEntity(Spawn(guid, cell));
|
|
WorldEntity entity = runtime.MaterializeLiveEntity(
|
|
guid,
|
|
cell,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = guid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = Vector3.Zero,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
})!;
|
|
var identity = new LocalPlayerIdentityState { ServerGuid = guid };
|
|
var controllerSlot = new RuntimeLocalPlayerMovementState
|
|
{
|
|
Controller = new PlayerMovementController(new PhysicsEngine()),
|
|
};
|
|
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.SeedPlacementForTest(position, cell, position);
|
|
controllerSlot.Controller.SetBodyOrientation(rotation);
|
|
var cameras = new ChaseCameraInputState
|
|
{
|
|
Legacy = new ChaseCamera(),
|
|
Retail = new RetailChaseCamera(),
|
|
};
|
|
var spatial = new FakeSpatialReconcile(() => new PlacementSnapshot(
|
|
entity.Position,
|
|
entity.ParentCellId ?? 0u,
|
|
entity.Rotation,
|
|
controllerSlot.Controller.Position,
|
|
controllerSlot.Controller.CellId,
|
|
controllerSlot.Controller.BodyOrientation));
|
|
var placement = new LocalPlayerTeleportPlacement(
|
|
runtime,
|
|
identity,
|
|
controllerSlot,
|
|
new LocalPlayerPhysicsHostSlot(),
|
|
cameras,
|
|
spatial);
|
|
|
|
placement.Place(rotation);
|
|
|
|
Assert.Equal(entity.Position, controllerSlot.Controller.Position);
|
|
Assert.Equal(entity.ParentCellId, controllerSlot.Controller.CellId);
|
|
Assert.Equal(cell & 0xFFFF0000u, entity.ParentCellId & 0xFFFF0000u);
|
|
Assert.Equal(rotation, entity.Rotation);
|
|
Assert.Equal(rotation, controllerSlot.Controller.BodyOrientation);
|
|
Assert.Equal(1, spatial.Count);
|
|
Assert.Equal(entity.Position, spatial.Snapshot.EntityPosition);
|
|
Assert.Equal(entity.ParentCellId, spatial.Snapshot.EntityCell);
|
|
Assert.Equal(entity.Rotation, spatial.Snapshot.EntityRotation);
|
|
Assert.Equal(
|
|
controllerSlot.Controller.Position,
|
|
spatial.Snapshot.ControllerPosition);
|
|
Assert.Equal(
|
|
controllerSlot.Controller.CellId,
|
|
spatial.Snapshot.ControllerCell);
|
|
Assert.Equal(
|
|
controllerSlot.Controller.BodyOrientation,
|
|
spatial.Snapshot.ControllerRotation);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConcretePlacement_CommitsDestinationSpatialBucketBeforeReconcile()
|
|
{
|
|
const uint guid = 0x50000001u;
|
|
const uint sourceCell = 0x20210001u;
|
|
const uint destinationCell = 0x30310001u;
|
|
var world = new GpuWorldState();
|
|
world.AddLandblock(new LoadedLandblock(
|
|
sourceCell & 0xFFFF0000u | 0xFFFFu,
|
|
new DatReaderWriter.DBObjs.LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
world.AddLandblock(new LoadedLandblock(
|
|
destinationCell & 0xFFFF0000u | 0xFFFFu,
|
|
new DatReaderWriter.DBObjs.LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
var runtime = LiveEntityRuntimeFixture.Create(world, new NullResources());
|
|
runtime.RegisterLiveEntity(Spawn(guid, sourceCell));
|
|
WorldEntity entity = runtime.MaterializeLiveEntity(
|
|
guid,
|
|
sourceCell,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = guid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = Vector3.Zero,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
})!;
|
|
var identity = new LocalPlayerIdentityState { ServerGuid = guid };
|
|
var controllerSlot = new RuntimeLocalPlayerMovementState
|
|
{
|
|
Controller = new PlayerMovementController(new PhysicsEngine()),
|
|
};
|
|
controllerSlot.Controller.SeedPlacementForTest(
|
|
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.SeedPlacementForTest(
|
|
destinationPosition,
|
|
destinationCell,
|
|
destinationPosition);
|
|
var cameras = new ChaseCameraInputState
|
|
{
|
|
Legacy = new ChaseCamera(),
|
|
Retail = new RetailChaseCamera(),
|
|
};
|
|
LiveEntityRecord? recordAtReconcile = null;
|
|
var spatial = new FakeSpatialReconcile(() =>
|
|
{
|
|
Assert.True(runtime.TryGetRecord(guid, out recordAtReconcile));
|
|
return new PlacementSnapshot(
|
|
entity.Position,
|
|
entity.ParentCellId ?? 0u,
|
|
entity.Rotation,
|
|
controllerSlot.Controller.Position,
|
|
controllerSlot.Controller.CellId,
|
|
controllerSlot.Controller.BodyOrientation);
|
|
});
|
|
var placement = new LocalPlayerTeleportPlacement(
|
|
runtime,
|
|
identity,
|
|
controllerSlot,
|
|
new LocalPlayerPhysicsHostSlot(),
|
|
cameras,
|
|
spatial);
|
|
|
|
placement.Place(Quaternion.Identity);
|
|
|
|
Assert.NotNull(recordAtReconcile);
|
|
uint resolvedDestinationCell = controllerSlot.Controller.CellId;
|
|
Assert.Equal(
|
|
destinationCell & 0xFFFF0000u,
|
|
resolvedDestinationCell & 0xFFFF0000u);
|
|
Assert.Equal(resolvedDestinationCell, recordAtReconcile.FullCellId);
|
|
Assert.Equal(
|
|
destinationCell & 0xFFFF0000u | 0xFFFFu,
|
|
recordAtReconcile.CanonicalLandblockId);
|
|
Assert.True(recordAtReconcile.IsSpatiallyProjected);
|
|
Assert.True(recordAtReconcile.IsSpatiallyVisible);
|
|
Assert.Contains(entity, world.Entities);
|
|
Assert.Equal(resolvedDestinationCell, entity.ParentCellId);
|
|
Assert.Equal(1, spatial.Count);
|
|
}
|
|
|
|
private static int Index(IReadOnlyList<string> events, string prefix)
|
|
{
|
|
for (int i = 0; i < events.Count; i++)
|
|
{
|
|
if (events[i].StartsWith(prefix, StringComparison.Ordinal))
|
|
return i;
|
|
}
|
|
|
|
return int.MaxValue;
|
|
}
|
|
|
|
private static RuntimeTeleportDestination Position(
|
|
uint cellId,
|
|
ushort teleportSequence,
|
|
float x,
|
|
float y,
|
|
float z) => new(
|
|
EntityGuid: 0x50000001u,
|
|
InstanceSequence: 1,
|
|
PositionSequence: 1,
|
|
TeleportSequence: teleportSequence,
|
|
ForcePositionSequence: 1,
|
|
Position: new Position(
|
|
cellId,
|
|
new Vector3(x, y, z),
|
|
Quaternion.Identity));
|
|
|
|
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new WorldSession.EntitySpawn(
|
|
Guid: guid,
|
|
Position: new CreateObject.ServerPosition(
|
|
cell,
|
|
0f,
|
|
0f,
|
|
0f,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0f),
|
|
SetupTableId: 0x02000001u,
|
|
AnimPartChanges: [],
|
|
TextureChanges: [],
|
|
SubPalettes: [],
|
|
BasePaletteId: null,
|
|
ObjScale: null,
|
|
Name: "player",
|
|
ItemType: null,
|
|
MotionState: null,
|
|
MotionTableId: null).WithConsistentPhysics();
|
|
|
|
private sealed class NullResources : ILiveEntityResourceLifecycle
|
|
{
|
|
public void Register(WorldEntity entity) { }
|
|
public void Unregister(WorldEntity entity) { }
|
|
}
|
|
|
|
private sealed class FakeSpatialReconcile : ILiveSpatialReconcilePhase
|
|
{
|
|
private readonly Func<PlacementSnapshot> _capture;
|
|
|
|
public FakeSpatialReconcile(Func<PlacementSnapshot> capture) =>
|
|
_capture = capture;
|
|
|
|
public int Count;
|
|
public PlacementSnapshot Snapshot;
|
|
|
|
public void Reconcile()
|
|
{
|
|
Snapshot = _capture();
|
|
Count++;
|
|
}
|
|
}
|
|
|
|
private readonly record struct PlacementSnapshot(
|
|
Vector3 EntityPosition,
|
|
uint EntityCell,
|
|
Quaternion EntityRotation,
|
|
Vector3 ControllerPosition,
|
|
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;
|
|
public readonly FakeStreaming Streaming;
|
|
public readonly FakePlacement Placement;
|
|
public readonly FakeSession Session;
|
|
public readonly FakePresentation Presentation;
|
|
public readonly RuntimeWorldTransitState Transit;
|
|
public readonly WorldRevealCoordinator Reveal;
|
|
|
|
/// <summary>
|
|
/// The live streaming window this harness's reveal barrier derives its
|
|
/// outdoor radius from (#280). Assertions read this rather than a
|
|
/// literal so the test cannot silently re-encode the value under test.
|
|
/// </summary>
|
|
public StreamingRevealWindow RevealWindow { get; set; } =
|
|
new(NearRadius: 1, FarRadius: 1);
|
|
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,
|
|
int centerY = 0x21,
|
|
bool worldReady = true,
|
|
List<string>? order = null)
|
|
{
|
|
order ??= new List<string>();
|
|
Mode = new FakeMode(order);
|
|
Streaming = new FakeStreaming(centerX, centerY, order);
|
|
Placement = new FakePlacement(order);
|
|
Session = new FakeSession(order);
|
|
Presentation = new FakePresentation(order);
|
|
Transit = new RuntimeWorldTransitState(order.Add);
|
|
Reveal = new WorldRevealCoordinator(
|
|
Transit,
|
|
revealWindow: () => RevealWindow,
|
|
isRenderNeighborhoodReady: (_, _, _) => worldReady,
|
|
isSpawnCellReady: _ => worldReady,
|
|
isTerrainNeighborhoodReady: (_, _) => worldReady,
|
|
areCompositeTexturesReady: () => worldReady,
|
|
prepareCompositeTextures: (_, _) => { },
|
|
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,
|
|
Mode,
|
|
Streaming,
|
|
Transit,
|
|
Reveal,
|
|
Placement,
|
|
Session,
|
|
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()
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class FakeAuthority : ILocalPlayerTeleportAuthority
|
|
{
|
|
public bool IsFresh = true;
|
|
public bool IsFreshStart(ushort sequence) => IsFresh;
|
|
}
|
|
|
|
private sealed class FakeInput : ILocalPlayerTeleportInputLifetime
|
|
{
|
|
public int EndCount;
|
|
public void EndMouseLook() => EndCount++;
|
|
}
|
|
|
|
private sealed class FakeMode : ILocalPlayerTeleportModeOperations
|
|
{
|
|
private readonly List<string> _order;
|
|
|
|
public FakeMode(List<string> order)
|
|
{
|
|
_order = order;
|
|
Controller = new PlayerMovementController(new PhysicsEngine());
|
|
Controller.SeedPlacementForTest(Vector3.Zero, 0x20210001u, Vector3.Zero);
|
|
}
|
|
|
|
public PlayerMovementController? Controller { get; set; }
|
|
public Matrix4x4 Projection => Matrix4x4.Identity;
|
|
public int EnterPortalCount;
|
|
public Func<PlayerMovementController?>? RebuildOnEnter;
|
|
|
|
public bool TryEnterPortalSpace()
|
|
{
|
|
EnterPortalCount++;
|
|
Controller ??= RebuildOnEnter?.Invoke();
|
|
if (Controller is null)
|
|
return false;
|
|
Controller.State = PlayerState.PortalSpace;
|
|
return true;
|
|
}
|
|
|
|
public void EnterWorld()
|
|
{
|
|
_order.Add("enter-world");
|
|
if (Controller is { } controller)
|
|
controller.State = PlayerState.InWorld;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeStreaming
|
|
: ILocalPlayerTeleportStreamingOperations,
|
|
IWorldRevealStreamingScheduler
|
|
{
|
|
private readonly List<string> _order;
|
|
|
|
public FakeStreaming(int centerX, int centerY, List<string> order)
|
|
{
|
|
CenterX = centerX;
|
|
CenterY = centerY;
|
|
_order = order;
|
|
}
|
|
|
|
public int CenterX { get; }
|
|
public int CenterY { get; }
|
|
public bool IsRecenterPending => RecenterPending;
|
|
public bool RecenterPending;
|
|
public bool ResetResult = true;
|
|
public bool LastResetWasSessionEnding;
|
|
public readonly List<(int X, int Y, bool Sealed)> Recenters = new();
|
|
public readonly List<(long Generation, uint Cell, int Radius)>
|
|
Reservations = new();
|
|
public readonly List<long> ReservationEnds = new();
|
|
|
|
public bool BeginRecenter(int x, int y, bool isSealedDungeon)
|
|
{
|
|
Recenters.Add((x, y, isSealedDungeon));
|
|
return !RecenterPending;
|
|
}
|
|
|
|
public bool ResetRecenter(bool sessionEnding)
|
|
{
|
|
LastResetWasSessionEnding = sessionEnding;
|
|
return ResetResult;
|
|
}
|
|
|
|
public bool IsSealedDungeon(uint cellId) =>
|
|
(cellId & 0xFFFFu) >= 0x0100u;
|
|
|
|
public void BeginDestinationReservation(
|
|
long revealGeneration,
|
|
uint destinationCell,
|
|
int requiredRenderRadius) =>
|
|
Reservations.Add(
|
|
(revealGeneration, destinationCell, requiredRenderRadius));
|
|
|
|
public void EndDestinationReservation(long revealGeneration)
|
|
{
|
|
ReservationEnds.Add(revealGeneration);
|
|
_order.Add("reservation-end");
|
|
}
|
|
}
|
|
|
|
/// <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 bool Called;
|
|
public Quaternion Rotation;
|
|
public Action? OnPlace;
|
|
|
|
public void Place(Quaternion rotation)
|
|
{
|
|
_order.Add("placement");
|
|
Called = true;
|
|
Rotation = rotation;
|
|
OnPlace?.Invoke();
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSession : ILocalPlayerTeleportSession
|
|
{
|
|
private readonly List<string> _order;
|
|
|
|
public FakeSession(List<string> order) => _order = order;
|
|
|
|
public int LoginCompleteCount;
|
|
public Action? OnSend;
|
|
|
|
public void SendLoginComplete()
|
|
{
|
|
_order.Add("login-complete");
|
|
LoginCompleteCount++;
|
|
OnSend?.Invoke();
|
|
}
|
|
}
|
|
|
|
private sealed class FakeNetworkSink : ILocalPlayerTeleportNetworkSink
|
|
{
|
|
public List<uint> Starts { get; } = [];
|
|
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
|
|
public void OfferDestination(
|
|
RuntimeTeleportDestination destination,
|
|
bool teleportTimestampAdvanced)
|
|
{
|
|
}
|
|
public void ResetSession()
|
|
{
|
|
}
|
|
public void ResetGenerationPresentation()
|
|
{
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void PortalCues_FireOnTheSequencersOwnSoundEvents_NotOnTheTunnelVisuals()
|
|
{
|
|
// Retail plays Sound_UI_EnterPortal as the animation BEGINS
|
|
// (gmSmartBoxUI::BeginTeleportAnimation @ 0x004D638E) and
|
|
// Sound_UI_ExitPortal at the TunnelFadeOut -> WorldFadeIn edge
|
|
// (@ 0x004D7405). The sequencer marks both with dedicated events. Hanging
|
|
// the enter cue on EnterTunnel instead — the first tunnel-family frame —
|
|
// delays it by a whole TunnelFadeIn, which is what Campaign A slice A4
|
|
// originally did.
|
|
var harness = new Harness();
|
|
harness.Controller.OnTeleportStarted(21);
|
|
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.PlayEnterSound);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(["enter"], harness.Presentation.Cues);
|
|
|
|
// The tunnel becoming visible must NOT emit a second cue.
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(["enter"], harness.Presentation.Cues);
|
|
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
|
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(["enter", "exit"], harness.Presentation.Cues);
|
|
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
|
}
|
|
|
|
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
|
|
{
|
|
private readonly List<string> _order;
|
|
private readonly Queue<IReadOnlyList<TeleportAnimEvent>> _events = new();
|
|
|
|
public FakePresentation(List<string> order) => _order = order;
|
|
|
|
public bool IsPortalViewportVisible { get; private set; }
|
|
public int CurrentTunnelFrame => 72;
|
|
public Matrix4x4? BeginProjection;
|
|
public bool EmitPlaceWhenReady;
|
|
public readonly List<bool> WorldReadyValues = new();
|
|
public readonly List<bool> WaitCueValues = new();
|
|
public int DisposeFailuresRemaining;
|
|
public int DisposeCount;
|
|
|
|
public void Enqueue(params TeleportAnimEvent[] events) => _events.Enqueue(events);
|
|
|
|
public void Begin(Matrix4x4 projection)
|
|
{
|
|
BeginProjection = projection;
|
|
_order.Add("presentation-begin");
|
|
}
|
|
|
|
public (TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
|
|
Tick(float deltaSeconds, bool worldReady)
|
|
{
|
|
WorldReadyValues.Add(worldReady);
|
|
if (_events.Count > 0)
|
|
return (default, _events.Dequeue());
|
|
if (EmitPlaceWhenReady && worldReady)
|
|
{
|
|
EmitPlaceWhenReady = false;
|
|
return (default, new[] { TeleportAnimEvent.Place });
|
|
}
|
|
|
|
return (default, Array.Empty<TeleportAnimEvent>());
|
|
}
|
|
|
|
public void TickTunnel(float deltaSeconds) => _order.Add("tunnel-tick");
|
|
public readonly List<string> Cues = new();
|
|
public void PlayEnterCue() => Cues.Add("enter");
|
|
public void PlayExitCue() => Cues.Add("exit");
|
|
public void EnterTunnel() => IsPortalViewportVisible = true;
|
|
public void ExitTunnel() => IsPortalViewportVisible = false;
|
|
public void SetWaitCue(bool visible) => WaitCueValues.Add(visible);
|
|
|
|
public void Reset()
|
|
{
|
|
_order.Add("presentation-reset");
|
|
IsPortalViewportVisible = false;
|
|
BeginProjection = null;
|
|
_events.Clear();
|
|
}
|
|
|
|
public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) => projection;
|
|
public ICamera ApplyViewPlane(ICamera camera) => camera;
|
|
public void DrawPortalViewport(int width, int height, Matrix4x4 projection) { }
|
|
public void Dispose()
|
|
{
|
|
DisposeCount++;
|
|
if (DisposeFailuresRemaining-- > 0)
|
|
throw new InvalidOperationException("injected disposal failure");
|
|
}
|
|
}
|
|
}
|