USER-DIRECTED deviation from retail (register row AD-109, same commit): retail presents the empty pre-player gameplay screen — black behind the retained UI — from the Enter click (CPlayerSystem::LogOnCharacter @0x0055F890 -> CM_Login::SendNotice_BeginEnterWorld @0x006AD810, UI mode 0x10000008) until CreatePlayer raises SmartBox::teleport_in_progress @0x00451C20 and gmSmartBoxUI::UseTime @0x004D6EAB begins TAS_TUNNEL. The user prefers the tunnel to cover that whole wait. - ILocalPlayerTeleportNetworkSink.ArmLoginTunnel: begins the login wormhole presentation at the Enter click, consuming the sequencer's begin-edge events SYNCHRONOUSLY (the Enter command blocks the update thread for the whole ServerReady round trip, so a deferred first tick would leave exactly the black window this deviation removes). The enter cue plays at the click: retail's own rule is cue-at-animation- begin (Sound_UI_EnterPortal @0x004D638E, unconditional inside BeginTeleportAnimation), and the animation begin moved to the click. - Armed pre-reveal pump: tunnel animates across the round trip (worldReady pinned false, sequencer holds in Tunnel); the hold clock accumulates from the click. - Adoption: the Runtime login reveal ADOPTS the running presentation (no re-Begin, no second cue); rejected EnterWorld (lifecycle back to AwaitingSelection) disarms and retires the tunnel. - Wired at the ONE host edge every entry route shares: ILiveSessionLifecycleHost.ApplySelectedCharacter (direct connect, roster Enter, enter-after-create) via LiveSessionSelectionBindings.ArmLoginTunnel (default no-op keeps headless and every existing construction site unchanged). - ILocalPlayerLoginLifecycleSource: typed seam (not a stored delegate — the frame-phase owner delegate-field guard) projecting the Runtime character-selection lifecycle for the disarm edge. - Frame contract update: [login-frames] over a login is tunnel -> world from the click — no void, and no black between click and world. Tests: 4 new armed-tunnel tests (arm/adopt/disarm/frame-shape); App suite live-DAT 5516 passed / 3 skipped (baseline 5512/3 + 4 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1954 lines
82 KiB
C#
1954 lines
82 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();
|
|
|
|
/// <summary>
|
|
/// Enter-world round (2026-08-17): mutable so a login-arm test can
|
|
/// flip destination readiness mid-flight (the login tunnel's hold is
|
|
/// exactly "readiness not yet true"). Initialized from the ctor's
|
|
/// existing <c>worldReady</c> parameter, so every prior test reads
|
|
/// identically.
|
|
/// </summary>
|
|
public bool WorldReady;
|
|
|
|
/// <summary>
|
|
/// Enter-click round (2026-08-17): the Runtime character-selection
|
|
/// lifecycle the click-armed tunnel projects, resolved per call.
|
|
/// Defaults to EnteringWorld (an enter transaction in flight — the
|
|
/// state at every real arm site); armed-tunnel tests regress it to
|
|
/// AwaitingSelection to drive the disarm.
|
|
/// </summary>
|
|
public RuntimeCharacterSelectionLifecycle SelectionLifecycle
|
|
{
|
|
get => LoginLifecycle.SelectionLifecycle;
|
|
set => LoginLifecycle.SelectionLifecycle = value;
|
|
}
|
|
|
|
public readonly FakeLoginLifecycleSource LoginLifecycle = new();
|
|
|
|
public Harness(
|
|
int centerX = 0x20,
|
|
int centerY = 0x21,
|
|
bool worldReady = true,
|
|
List<string>? order = null)
|
|
{
|
|
WorldReady = worldReady;
|
|
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,
|
|
LoginLifecycle);
|
|
}
|
|
|
|
/// <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 int EnterPortalForLoginCount;
|
|
|
|
public bool TryEnterPortalSpaceForLogin()
|
|
{
|
|
EnterPortalForLoginCount++;
|
|
return TryEnterPortalSpace();
|
|
}
|
|
|
|
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 int FirstEntryCompletions;
|
|
public int LoginTunnelArms;
|
|
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
|
|
public void OfferDestination(
|
|
RuntimeTeleportDestination destination,
|
|
bool teleportTimestampAdvanced)
|
|
{
|
|
}
|
|
public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++;
|
|
public void ArmLoginTunnel() => LoginTunnelArms++;
|
|
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);
|
|
}
|
|
|
|
// ── Enter-world round (2026-08-17): the LOGIN portal-space arm ──────
|
|
//
|
|
// Retail runs the identical TAS_TUNNEL wormhole on initial login —
|
|
// SmartBox::teleport_in_progress @ 0x00451C20 goes high the moment the
|
|
// login player exists with position_update_complete == 0, and
|
|
// gmSmartBoxUI::UseTime @ 0x004D6EAB begins the same animation (and the
|
|
// same Sound_UI_EnterPortal @ 0x004D638E) it begins for an F751. These
|
|
// tests drive the login reveal (RuntimeWorldTransitState.BeginLoginReveal
|
|
// via WorldRevealCoordinator.BeginLogin — the shared edge of every entry
|
|
// route) through the controller's login pump.
|
|
|
|
[Fact]
|
|
public void LoginReveal_ArmsThePortalSpacePresentation_WithRetailCues()
|
|
{
|
|
var order = new List<string>();
|
|
var harness = new Harness(worldReady: false, order: order);
|
|
|
|
harness.Reveal.BeginLogin(0x20210001u);
|
|
Assert.Equal(RuntimePortalKind.Login, harness.Reveal.Snapshot.Kind);
|
|
|
|
// First tick: the arm claims the reveal, enters portal space, and
|
|
// begins the presentation.
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(1, harness.Mode.EnterPortalCount);
|
|
Assert.Equal(Matrix4x4.Identity, harness.Presentation.BeginProjection);
|
|
Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell);
|
|
|
|
// Enter cue at animation begin, tunnel viewport on its own edge —
|
|
// the same event contract as the F751 pump.
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.PlayEnterSound);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(["enter"], harness.Presentation.Cues);
|
|
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
|
|
|
// Destination not ready and first placement not yet completed: the
|
|
// sequencer must keep seeing worldReady == false (its Tunnel hold).
|
|
Assert.All(harness.Presentation.WorldReadyValues, value => Assert.False(value));
|
|
Assert.Equal(0, harness.Session.LoginCompleteCount);
|
|
|
|
// Readiness alone is not enough — the first-entry conductor's
|
|
// canonical placement is half of the latch.
|
|
harness.WorldReady = true;
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.False(harness.Presentation.WorldReadyValues[^1]);
|
|
|
|
harness.Controller.OnLocalPlayerFirstEntryCompleted();
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.True(harness.Presentation.WorldReadyValues[^1]);
|
|
|
|
// The login pump has no Place edge of its own: the conductor already
|
|
// placed the player (retail: SmartBox::UseTime @ 0x00455483 only
|
|
// flips position_update_complete).
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.False(harness.Placement.Called);
|
|
|
|
// Viewport swap: reservation release + exit cue + tunnel retire
|
|
// (gmSmartBoxUI::UseTime, Sound_UI_ExitPortal @ 0x004D7405).
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(["enter", "exit"], harness.Presentation.Cues);
|
|
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
|
Assert.Single(harness.Streaming.ReservationEnds);
|
|
|
|
// WorldFadeIn end: EnterWorld + the ONE LoginComplete + reveal
|
|
// completion (gmSmartBoxUI::UseTime @ 0x004D745D).
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Contains("enter-world", order);
|
|
Assert.Equal(1, harness.Session.LoginCompleteCount);
|
|
Assert.True(harness.Reveal.Snapshot.Completed);
|
|
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
|
|
|
// The completed reveal stays completed; no re-arm, no second send.
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(1, harness.Mode.EnterPortalCount);
|
|
Assert.Equal(1, harness.Session.LoginCompleteCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoginPump_SendsLoginCompleteOnlyAtThePresentationEnd()
|
|
{
|
|
var harness = new Harness(worldReady: true);
|
|
harness.Reveal.BeginLogin(0x20210001u);
|
|
harness.Controller.OnLocalPlayerFirstEntryCompleted();
|
|
|
|
// Ticks before the FireLoginComplete edge never send.
|
|
for (int i = 0; i < 5; i++)
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(0, harness.Session.LoginCompleteCount);
|
|
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(1, harness.Session.LoginCompleteCount);
|
|
Assert.True(harness.Reveal.Snapshot.Completed);
|
|
}
|
|
|
|
[Fact]
|
|
public void RealTeleportStart_SupersedesTheLoginPresentation()
|
|
{
|
|
var order = new List<string>();
|
|
var harness = new Harness(worldReady: true, order: order);
|
|
harness.Reveal.BeginLogin(0x20210001u);
|
|
harness.Controller.OnLocalPlayerFirstEntryCompleted();
|
|
harness.Controller.Tick(0.016f);
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
|
|
|
// An F751 mid-login-tunnel (ACE can relocate a broken spawn at
|
|
// login): the portal pump takes the presentation over and owns the
|
|
// single LoginComplete, mirroring retail's one teleportInProgress
|
|
// flag spanning both.
|
|
harness.Controller.OnTeleportStarted(3);
|
|
Assert.Contains("presentation-reset", order);
|
|
Assert.True(harness.Controller.IsActive);
|
|
Assert.Equal(0, harness.Session.LoginCompleteCount);
|
|
|
|
// The login claim is gone: ticking the portal-active controller
|
|
// never re-enters the login pump (no second presentation-begin from
|
|
// the login arm beyond the teleport activation's own).
|
|
int loginCompletes = harness.Session.LoginCompleteCount;
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(loginCompletes, harness.Session.LoginCompleteCount);
|
|
}
|
|
|
|
// ── Gate-fix round (2026-08-17): void frames at the wormhole edges ──
|
|
|
|
[Fact]
|
|
public void LoginPlaceEdge_ReleasesWorldSimulationWhileTunnelInFront()
|
|
{
|
|
var harness = new Harness(worldReady: false);
|
|
harness.Reveal.BeginLogin(0x20210001u);
|
|
harness.Controller.Tick(0.016f);
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
|
Assert.False(harness.Transit.IsWorldSimulationAvailable);
|
|
|
|
// The hold ends: destination readiness + canonical first placement.
|
|
harness.WorldReady = true;
|
|
harness.Controller.OnLocalPlayerFirstEntryCompleted();
|
|
harness.Controller.Tick(0.016f);
|
|
|
|
// The login Place edge is retail's blocking-ends edge —
|
|
// CObjectMaint/CPhysics resume (SmartBox::UseTime @ 0x00455410)
|
|
// while the tunnel is STILL in front. The world generation must be
|
|
// available before the viewport swap so WorldFadeIn's first frame
|
|
// draws the world instead of an empty void.
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
|
Assert.True(harness.Transit.IsWorldSimulationAvailable);
|
|
Assert.True(harness.Transit.Snapshot.Materialized);
|
|
Assert.False(harness.Transit.Snapshot.Completed);
|
|
Assert.False(harness.Placement.Called);
|
|
|
|
// Swap edge: the tunnel retires on this same tick and the world is
|
|
// already available — no frame can exist where neither presents.
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
|
Assert.True(harness.Transit.IsWorldSimulationAvailable);
|
|
|
|
// The pump completes exactly as before.
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(1, harness.Session.LoginCompleteCount);
|
|
Assert.True(harness.Reveal.Snapshot.Completed);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoginCover_PresentsPortalViewportShapeUntilChaseModeEntered()
|
|
{
|
|
// Entry-edge contract: retail's pre-player gameplay screen draws NO
|
|
// world (black behind the UI) between char-select Enter and
|
|
// CreatePlayer (CPlayerSystem::LogOnCharacter @ 0x0055F890 queues
|
|
// game mode immediately; SmartBox::teleport_in_progress
|
|
// @ 0x00451C20 stays low until the player exists). The composed
|
|
// render-state source must therefore present the portal-viewport
|
|
// frame shape for every live pre-world frame — the sky-only void
|
|
// backdrop must never present.
|
|
var harness = new Harness(worldReady: false);
|
|
var login = new StubLoginState { IsWaitingForLogin = true };
|
|
var source = new LocalPlayerTeleportRenderStateSource(
|
|
harness.Controller, login);
|
|
|
|
// Pre-reveal: no tunnel scene, but the login cover holds the frame
|
|
// black.
|
|
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
|
Assert.True(source.IsPortalViewportVisible);
|
|
|
|
// Tunnel armed (the activation tick flips ChaseModeEverEntered and
|
|
// the tunnel visibility together, before the next render): the
|
|
// cover hands off to the tunnel with no gap.
|
|
harness.Reveal.BeginLogin(0x20210001u);
|
|
harness.Controller.Tick(0.016f);
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel);
|
|
harness.Controller.Tick(0.016f);
|
|
login.IsWaitingForLogin = false;
|
|
Assert.True(source.IsPortalViewportVisible);
|
|
|
|
// Swap edge: tunnel retires -> the world frame presents (the cover
|
|
// stays off; ChaseModeEverEntered never reverts mid-session).
|
|
harness.WorldReady = true;
|
|
harness.Controller.OnLocalPlayerFirstEntryCompleted();
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
|
harness.Controller.Tick(0.016f);
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.False(source.IsPortalViewportVisible);
|
|
}
|
|
|
|
// ── Enter-click round (2026-08-17): the click-armed login tunnel ────
|
|
//
|
|
// Registered deviation AD-109 (user-directed): retail shows BLACK from
|
|
// the char-select Enter click until CreatePlayer begins TAS_TUNNEL
|
|
// (gmSmartBoxUI::UseTime @ 0x004D6EAB); acdream arms the same wormhole
|
|
// presentation AT the click (ApplySelectedCharacter host edge — before
|
|
// the EnterWorld round trip) and the reveal later ADOPTS it.
|
|
|
|
[Fact]
|
|
public void ArmLoginTunnel_ShowsTunnelAndPlaysCueSynchronously()
|
|
{
|
|
var order = new List<string>();
|
|
var harness = new Harness(worldReady: false, order: order);
|
|
|
|
// The production sequencer queues PlayEnterSound + EnterTunnel at
|
|
// Begin; the arm consumes them in the SAME call (the Enter command
|
|
// blocks the update thread for the whole round trip afterwards).
|
|
harness.Presentation.Enqueue(
|
|
TeleportAnimEvent.PlayEnterSound,
|
|
TeleportAnimEvent.EnterTunnel);
|
|
harness.Controller.ArmLoginTunnel();
|
|
|
|
Assert.Contains("presentation-begin", order);
|
|
Assert.Equal(["enter"], harness.Presentation.Cues);
|
|
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
|
|
|
// Pre-reveal ticks keep the tunnel animating (worldReady pinned
|
|
// false — the sequencer holds in Tunnel) and never re-fire the cue.
|
|
harness.Controller.Tick(0.016f);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(["enter"], harness.Presentation.Cues);
|
|
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
|
Assert.All(
|
|
harness.Presentation.WorldReadyValues,
|
|
value => Assert.False(value));
|
|
Assert.Contains("tunnel-tick", order);
|
|
|
|
// Idempotent: a second arm (double-click, re-entrant host edge)
|
|
// never restarts the presentation.
|
|
int begins = order.Count(entry => entry == "presentation-begin");
|
|
harness.Controller.ArmLoginTunnel();
|
|
Assert.Equal(begins, order.Count(entry => entry == "presentation-begin"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ArmedLoginTunnel_IsAdoptedByTheRevealWithoutRestartOrSecondCue()
|
|
{
|
|
var order = new List<string>();
|
|
var harness = new Harness(worldReady: false, order: order);
|
|
harness.Presentation.Enqueue(
|
|
TeleportAnimEvent.PlayEnterSound,
|
|
TeleportAnimEvent.EnterTunnel);
|
|
harness.Controller.ArmLoginTunnel();
|
|
int beginsAtArm = order.Count(entry => entry == "presentation-begin");
|
|
|
|
// CreatePlayer's first accepted position begins the Runtime login
|
|
// reveal; the next tick ADOPTS the running presentation.
|
|
harness.Reveal.BeginLogin(0x20210001u);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(1, harness.Mode.EnterPortalCount);
|
|
Assert.Equal(
|
|
beginsAtArm,
|
|
order.Count(entry => entry == "presentation-begin"));
|
|
Assert.Equal(["enter"], harness.Presentation.Cues);
|
|
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
|
Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell);
|
|
|
|
// The adopted presentation completes exactly like the reveal-armed
|
|
// one: hold ends, viewport swap, one LoginComplete.
|
|
harness.WorldReady = true;
|
|
harness.Controller.OnLocalPlayerFirstEntryCompleted();
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.True(harness.Presentation.WorldReadyValues[^1]);
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
|
harness.Controller.Tick(0.016f);
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(["enter", "exit"], harness.Presentation.Cues);
|
|
harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete);
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.Equal(1, harness.Session.LoginCompleteCount);
|
|
Assert.True(harness.Reveal.Snapshot.Completed);
|
|
}
|
|
|
|
[Fact]
|
|
public void ArmedLoginTunnel_DisarmsWhenTheEnterFallsBackToSelection()
|
|
{
|
|
var order = new List<string>();
|
|
var harness = new Harness(worldReady: false, order: order);
|
|
harness.Presentation.Enqueue(
|
|
TeleportAnimEvent.PlayEnterSound,
|
|
TeleportAnimEvent.EnterTunnel);
|
|
harness.Controller.ArmLoginTunnel();
|
|
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
|
|
|
// Rejected EnterWorld: LiveSessionController.EnterHighlightedCore
|
|
// applies the error and returns the lifecycle to AwaitingSelection.
|
|
// The armed pump must retire the tunnel — the character-select
|
|
// screen is in front again and retail shows no tunnel there.
|
|
harness.SelectionLifecycle =
|
|
RuntimeCharacterSelectionLifecycle.AwaitingSelection;
|
|
harness.Controller.Tick(0.016f);
|
|
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
|
Assert.Contains("presentation-reset", order);
|
|
|
|
// A later successful Enter arms a fresh tunnel.
|
|
harness.SelectionLifecycle =
|
|
RuntimeCharacterSelectionLifecycle.EnteringWorld;
|
|
harness.Presentation.Enqueue(
|
|
TeleportAnimEvent.PlayEnterSound,
|
|
TeleportAnimEvent.EnterTunnel);
|
|
harness.Controller.ArmLoginTunnel();
|
|
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
|
Assert.Equal(["enter", "enter"], harness.Presentation.Cues);
|
|
}
|
|
|
|
[Fact]
|
|
public void ArmedLoginTunnel_PresentsTunnelNotBlack_FromTheClickFrame()
|
|
{
|
|
// AD-109's frame contract: from the Enter click the composed render
|
|
// source presents the portal-viewport shape AND the tunnel scene is
|
|
// already visible — no bare-black CreatePlayer window.
|
|
var harness = new Harness(worldReady: false);
|
|
var login = new StubLoginState { IsWaitingForLogin = true };
|
|
var source = new LocalPlayerTeleportRenderStateSource(
|
|
harness.Controller, login);
|
|
|
|
harness.Presentation.Enqueue(
|
|
TeleportAnimEvent.PlayEnterSound,
|
|
TeleportAnimEvent.EnterTunnel);
|
|
harness.Controller.ArmLoginTunnel();
|
|
Assert.True(source.IsPortalViewportVisible);
|
|
Assert.True(harness.Presentation.IsPortalViewportVisible);
|
|
}
|
|
|
|
private sealed class StubLoginState : IRenderLoginStateSource
|
|
{
|
|
public bool IsWaitingForLogin { get; set; }
|
|
}
|
|
|
|
private sealed class FakeLoginLifecycleSource
|
|
: ILocalPlayerLoginLifecycleSource
|
|
{
|
|
public RuntimeCharacterSelectionLifecycle SelectionLifecycle
|
|
{
|
|
get;
|
|
set;
|
|
} = RuntimeCharacterSelectionLifecycle.EnteringWorld;
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
}
|