Retires AD-74 (Exit to Character Selection 'behaves as Exit Game') and files AD-110 (the composed handoff edge) — register rows in this commit. Retail derivation (named decomp): - gmGamePlayUI::UseTime @0x004EA3A0: confirmed Yes drains into CPlayerSystem::LogOffCharacter(0) when grounded (transient_state & CONTACT); the grounded three-way branch now also covers the indicator-bar end-session control (it was Options-only). - CPlayerSystem::LogOffCharacter @0x00563520: SaveToServer FIRST (the existing pre-logoff flush hook), then RequestLogOff @0x00562DD0: 'Logging off...' chat (type 0), 0xF653 via Proto_UI::LogOffCharacter @0x00546A20, logOffRequestTime = now + 3.0 (+20.0 when IsPlayerKiller @0x0058C910 — PWD bits 0x20|0x2000000), and CommandInterpreter::HandleLogOff @0x006B3330 -> Disable. - The log-off ANIMATION is server-driven: ACE broadcasts MotionCommand.LogOut (0x1000011E, Player.cs:596 SendMotionAsCommands) and it plays on the local player through the existing inbound unpack_movement funnel during the 3 s hold — retail plays nothing locally; Disable() is the whole client-side effect. - gmSmartBoxUI::UseTime @0x004D6E64: hold elapsed -> BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @0x004D6E83 (enter cue @0x004D638E, unconditional) -> TunnelFadeIn -> Tunnel. The tunnel plays the SAME forward 40 fps animation; nothing renders backwards, and NO exit cue ever fires on logout (the char-select swap preempts the TunnelContinue/FadeOut tail). - Inbound 0xF653 echo (dispatch case 3 @0x0055C963) -> ExecuteLogOff @0x0055D780: world teardown with the LOGON CONNECTION KEPT (ExitWorldDisconnect @0x00541E00 removes every connection except logonRecID_ — one connection against ACE) and Proto_UI::SetEventCounter(0) @0x00541E79; the fresh CharacterList in the same batch re-shows character management (gmGamePlayUI::Update @0x004E9CD0 -> QueueUIMode(0x1000000a)). ACE mirrors it: SendFinalLogOffMessages (Session.cs:249) sends 0xF653 + CharacterList + ServerName >=6 s after the request and leaves the session AuthConnected — a second EnterWorld needs no re-handshake. Implementation: - RuntimeWorldTransitState: the canonical logout lifecycle (Requested/PresentationActive/Confirmed, retail 3 s/+20 s holds, cancel/reset/ownership convergence). - WorldSession: RequestCharacterLogOff (non-blocking 0xF653), IsCharacterLogOffConfirmed, ReturnToCharacterSelect (InWorld -> InCharacterSelect + game-action sequence reset; transport untouched). - LiveSessionController: BeginCharacterLogOff (flush-first request) and CompleteCharacterLogOff — the return-to-selection transaction (ReconnectCore minus the transport swap: retire the world generation's routes, host reset, state flip, fresh generation re-bind, roster re-applied from the pushed CharacterList; failures degrade to the full StopCore teardown). - RuntimeLocalPlayerMovementState.DisableCommandInterpreter + DispatcherMovementInputSource gate: retail's Disable() — held keys produce no movement while the server LogOut motion plays; cleared by the generation reset. - LocalPlayerTeleportController: the logout pump as the third arm of the one wormhole machine (request/hold/wormhole/confirmed handoff; teleport starts refused during logout; the handoff runs the session transaction whose world reset retires the tunnel as the fresh selection state re-shows the character screen). - UI: both end-session surfaces share the retail three-way grounded gate and now run the REAL flow; Options' Exit Game keeps the app exit (window close -> the existing graceful-shutdown logoff). Tests: +5 transit lifecycle, +4 session transaction, +7 logout pump. Runtime 1756/0 (baseline 1747), App live-DAT 5523/3 (baseline 5512/3 + 11 this round), Core.Net 1004/0, full solution green (0 failures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1979 lines
83 KiB
C#
1979 lines
83 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Net;
|
|
using AcDream.App.Physics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Update;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Audio;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Rendering;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Physics;
|
|
using AcDream.Runtime.Session;
|
|
using AcDream.Runtime.World;
|
|
|
|
namespace AcDream.App.Streaming;
|
|
|
|
internal interface ILocalPlayerTeleportNetworkSink
|
|
{
|
|
void OnTeleportStarted(uint sequence);
|
|
|
|
void OfferDestination(
|
|
RuntimeTeleportDestination destination,
|
|
bool teleportTimestampAdvanced);
|
|
|
|
/// <summary>
|
|
/// Enter-world round (2026-08-17): the graphical host's local-player
|
|
/// first-entry conductor completed its canonical initial placement.
|
|
/// This is the login analogue of retail's
|
|
/// <c>SmartBox::UseTime @ 0x00455410</c> setting
|
|
/// <c>position_update_complete = 1</c> — the fact that lets the login
|
|
/// portal-space presentation leave its Tunnel hold. LoginComplete
|
|
/// (0xA1) itself now rides the presentation's own
|
|
/// <see cref="TeleportAnimEvent.FireLoginComplete"/> edge, matching
|
|
/// retail's send at the WorldFadeIn end
|
|
/// (<c>gmSmartBoxUI::UseTime @ 0x004D745D</c> →
|
|
/// <c>CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90</c>).
|
|
/// </summary>
|
|
void OnLocalPlayerFirstEntryCompleted();
|
|
|
|
/// <summary>
|
|
/// Enter-click round (2026-08-17): arm the login wormhole PRESENTATION at
|
|
/// the character-select Enter click (and its direct-connect /
|
|
/// enter-after-create equivalents), BEFORE the EnterWorld server
|
|
/// round-trip. This is a REGISTERED user-directed deviation from retail:
|
|
/// retail shows the empty pre-player gameplay screen (black behind the
|
|
/// UI) from <c>CPlayerSystem::LogOnCharacter @ 0x0055F890</c> /
|
|
/// <c>CM_Login::SendNotice_BeginEnterWorld @ 0x006AD810</c> until
|
|
/// CreatePlayer flips <c>SmartBox::teleport_in_progress @ 0x00451C20</c>
|
|
/// and <c>gmSmartBoxUI::UseTime @ 0x004D6EAB</c> begins TAS_TUNNEL. The
|
|
/// user prefers the tunnel to cover that whole wait — see the divergence
|
|
/// register row added with this method. Invoked from the ONE host edge
|
|
/// every entry route shares: <c>ILiveSessionLifecycleHost
|
|
/// .ApplySelectedCharacter</c>, which runs immediately before the
|
|
/// EnterWorld wire send on all three routes.
|
|
/// </summary>
|
|
void ArmLoginTunnel();
|
|
|
|
/// <summary>
|
|
/// Logout round (2026-08-17): the confirmed exit-to-character-select
|
|
/// click — retail's <c>gmGamePlayUI::UseTime @ 0x004EA454</c> →
|
|
/// <c>CPlayerSystem::LogOffCharacter(0)</c> drain, forwarded through
|
|
/// this construction-order bridge so the retained UI (built before the
|
|
/// controller) can reach the one wormhole owner. Refusals are logged by
|
|
/// the controller; the grounded gate runs upstream in
|
|
/// <c>RetailUiRuntime</c> (the <c>transient_state & CONTACT</c>
|
|
/// branch @ 0x004EA445).
|
|
/// </summary>
|
|
void RequestLogout();
|
|
|
|
void ResetSession();
|
|
|
|
void ResetGenerationPresentation();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Construction-order bridge for the inbound session. It carries no teleport
|
|
/// state: after binding, every packet and reset is forwarded to the one
|
|
/// <see cref="LocalPlayerTeleportController"/> owner.
|
|
/// </summary>
|
|
internal sealed class DeferredLocalPlayerTeleportNetworkSink
|
|
: ILocalPlayerTeleportNetworkSink
|
|
{
|
|
private ILocalPlayerTeleportNetworkSink? _inner;
|
|
|
|
public void Bind(ILocalPlayerTeleportNetworkSink inner)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(inner);
|
|
if (Interlocked.CompareExchange(ref _inner, inner, null) is not null)
|
|
throw new InvalidOperationException("The local teleport sink is already bound.");
|
|
}
|
|
|
|
public IDisposable BindOwned(ILocalPlayerTeleportNetworkSink inner)
|
|
{
|
|
Bind(inner);
|
|
return new Binding(this, inner);
|
|
}
|
|
|
|
private void Unbind(ILocalPlayerTeleportNetworkSink expected)
|
|
{
|
|
_ = Interlocked.CompareExchange(ref _inner, null, expected);
|
|
}
|
|
|
|
public void OnTeleportStarted(uint sequence) => Required().OnTeleportStarted(sequence);
|
|
|
|
public void OfferDestination(
|
|
RuntimeTeleportDestination destination,
|
|
bool teleportTimestampAdvanced) =>
|
|
Required().OfferDestination(destination, teleportTimestampAdvanced);
|
|
|
|
public void OnLocalPlayerFirstEntryCompleted() =>
|
|
Required().OnLocalPlayerFirstEntryCompleted();
|
|
|
|
public void ArmLoginTunnel() => Required().ArmLoginTunnel();
|
|
|
|
public void RequestLogout() => Required().RequestLogout();
|
|
|
|
public void ResetSession() => Required().ResetSession();
|
|
|
|
public void ResetGenerationPresentation() =>
|
|
Required().ResetGenerationPresentation();
|
|
|
|
private ILocalPlayerTeleportNetworkSink Required() =>
|
|
_inner ?? throw new InvalidOperationException(
|
|
"The local teleport sink was used before composition completed.");
|
|
|
|
private sealed class Binding : IDisposable
|
|
{
|
|
private DeferredLocalPlayerTeleportNetworkSink? _owner;
|
|
private readonly ILocalPlayerTeleportNetworkSink _expected;
|
|
|
|
public Binding(
|
|
DeferredLocalPlayerTeleportNetworkSink owner,
|
|
ILocalPlayerTeleportNetworkSink expected)
|
|
{
|
|
_owner = owner;
|
|
_expected = expected;
|
|
}
|
|
|
|
public void Dispose() =>
|
|
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
|
|
}
|
|
}
|
|
|
|
internal interface ILocalPlayerTeleportInputLifetime
|
|
{
|
|
void EndMouseLook();
|
|
}
|
|
|
|
internal interface ILocalPlayerTeleportModeOperations
|
|
{
|
|
PlayerMovementController? Controller { get; }
|
|
Matrix4x4 Projection { get; }
|
|
bool TryEnterPortalSpace();
|
|
|
|
/// <summary>
|
|
/// Enter-world round (2026-08-17): the LOGIN arm's portal-space entry.
|
|
/// Unlike <see cref="TryEnterPortalSpace"/> — whose F751 callers run when
|
|
/// player-mode presentation is already attached (or deliberately absent,
|
|
/// e.g. fly mode) — the login tunnel begins BEFORE any player-mode entry
|
|
/// has happened, so this operation must also perform the same
|
|
/// presentation attach the post-reveal auto-entry used to do (chase
|
|
/// camera, shadow, animation sinks) before flipping the controller into
|
|
/// portal space. Refuses (retryable) until the Runtime first-entry
|
|
/// conductor has published the movement controller.
|
|
/// </summary>
|
|
bool TryEnterPortalSpaceForLogin();
|
|
void EnterWorld();
|
|
}
|
|
|
|
internal interface ILocalPlayerTeleportAuthority
|
|
{
|
|
bool IsFreshStart(ushort sequence);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enter-click round (2026-08-17): the armed pre-reveal login tunnel's read
|
|
/// of the Runtime character-selection lifecycle (a typed seam, not a stored
|
|
/// delegate — frame-phase owners hold no delegate fields per the GameWindow
|
|
/// decomposition invariant). Resolved per call against the live owner.
|
|
/// </summary>
|
|
internal interface ILocalPlayerLoginLifecycleSource
|
|
{
|
|
RuntimeCharacterSelectionLifecycle SelectionLifecycle { get; }
|
|
}
|
|
|
|
/// <summary>Production adapter over the canonical GameRuntime owner.</summary>
|
|
internal sealed class RuntimeLoginLifecycleSource
|
|
: ILocalPlayerLoginLifecycleSource
|
|
{
|
|
private readonly GameRuntime _runtime;
|
|
|
|
public RuntimeLoginLifecycleSource(GameRuntime runtime) =>
|
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
|
|
|
public RuntimeCharacterSelectionLifecycle SelectionLifecycle =>
|
|
_runtime.CharacterSelection.Snapshot.Lifecycle;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logout round (2026-08-17): the logout pump's typed seams — the wire/session
|
|
/// transaction pair on the canonical Runtime session owner, the local-player
|
|
/// PK fact, and the retail side effects of the request itself (chat line +
|
|
/// command-interpreter disable). See <c>CPlayerSystem::RequestLogOff
|
|
/// @ 0x00562DD0</c> for the retail body these mirror.
|
|
/// </summary>
|
|
internal interface ILocalPlayerLogoutOperations
|
|
{
|
|
/// <summary>
|
|
/// Retail <c>ACCWeenieObject::IsPlayerKiller @ 0x0058C910</c>: PWD
|
|
/// bitfield <c>0x20</c> (PK) or <c>0x2000000</c> (PKLite). Drives the
|
|
/// +20 s logoff hold (<c>RequestLogOff @ 0x00562E4E-0x00562E67</c>).
|
|
/// </summary>
|
|
bool IsLocalPlayerKiller { get; }
|
|
|
|
/// <summary>
|
|
/// Retail <c>CPlayerSystem::LogOffCharacter(0) @ 0x00563520</c> +
|
|
/// <c>RequestLogOff @ 0x00562DD0</c>: options flush, "Logging off..."
|
|
/// chat line (type 0, <c>AddTextToScroll @ 0x00562DF2</c>), the 0xF653
|
|
/// wire send, and the command-interpreter disable
|
|
/// (<c>HandleLogOff @ 0x006B3330</c>).
|
|
/// </summary>
|
|
bool BeginCharacterLogOff();
|
|
|
|
/// <summary>The server's opcode-only 0xF653 echo has landed.</summary>
|
|
bool IsCharacterLogOffConfirmed { get; }
|
|
|
|
/// <summary>
|
|
/// The return-to-character-select session transaction
|
|
/// (<see cref="AcDream.Runtime.Session.LiveSessionController.CompleteCharacterLogOff"/>).
|
|
/// </summary>
|
|
bool CompleteCharacterLogOff();
|
|
}
|
|
|
|
/// <summary>Production adapter over the canonical Runtime owners.</summary>
|
|
internal sealed class RuntimeLocalPlayerLogoutOperations
|
|
: ILocalPlayerLogoutOperations
|
|
{
|
|
private readonly GameRuntime _runtime;
|
|
private readonly RuntimeLocalPlayerMovementState _movement;
|
|
private readonly ILiveWorldSessionSource _session;
|
|
private readonly AcDream.Core.Items.ClientObjectTable _objects;
|
|
private readonly ILocalPlayerIdentitySource _identity;
|
|
|
|
public RuntimeLocalPlayerLogoutOperations(
|
|
GameRuntime runtime,
|
|
RuntimeLocalPlayerMovementState movement,
|
|
ILiveWorldSessionSource session,
|
|
AcDream.Core.Items.ClientObjectTable objects,
|
|
ILocalPlayerIdentitySource identity)
|
|
{
|
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
|
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
|
|
_session = session ?? throw new ArgumentNullException(nameof(session));
|
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
|
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
|
}
|
|
|
|
public bool IsLocalPlayerKiller
|
|
{
|
|
get
|
|
{
|
|
uint bitfield = _objects.Get(_identity.ServerGuid)
|
|
?.PublicWeenieBitfield ?? 0u;
|
|
// IsPlayerKiller @ 0x0058C910: (bitfield & 0x20) | (bitfield &
|
|
// 0x2000000) — the PK and PKLite PWD bits.
|
|
return (bitfield & 0x20u) != 0u || (bitfield & 0x2000000u) != 0u;
|
|
}
|
|
}
|
|
|
|
public bool BeginCharacterLogOff()
|
|
{
|
|
// The Runtime command runs retail's SaveToServer-first ordering
|
|
// (the pre-logoff flush) then sends 0xF653.
|
|
if (!_runtime.Session.BeginCharacterLogOff(_runtime.Generation)
|
|
.Accepted)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Retail RequestLogOff's own side effects, in its order: the chat
|
|
// line (@ 0x00562DF2, AddTextToScroll(str, 0, 1, 0)) and the
|
|
// command-interpreter disable (@ 0x00562E6D).
|
|
_runtime.CommunicationOwner.AddText(
|
|
"Logging off...",
|
|
AcDream.Core.Chat.RetailLogTextType.Default);
|
|
_movement.DisableCommandInterpreter();
|
|
return true;
|
|
}
|
|
|
|
public bool IsCharacterLogOffConfirmed =>
|
|
_session.CurrentSession?.IsCharacterLogOffConfirmed == true;
|
|
|
|
public bool CompleteCharacterLogOff() =>
|
|
_runtime.Session.CompleteCharacterLogOff(_runtime.Generation)
|
|
.Accepted;
|
|
}
|
|
|
|
internal sealed class LiveLocalPlayerTeleportAuthority
|
|
: ILocalPlayerTeleportAuthority
|
|
{
|
|
private readonly LiveEntityRuntime _liveEntities;
|
|
private readonly ILocalPlayerIdentitySource _identity;
|
|
|
|
public LiveLocalPlayerTeleportAuthority(
|
|
LiveEntityRuntime liveEntities,
|
|
ILocalPlayerIdentitySource identity)
|
|
{
|
|
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
|
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
|
}
|
|
|
|
public bool IsFreshStart(ushort sequence) =>
|
|
_liveEntities.IsFreshTeleportStart(_identity.ServerGuid, sequence);
|
|
}
|
|
|
|
internal interface ILocalPlayerTeleportStreamingOperations
|
|
{
|
|
int CenterX { get; }
|
|
int CenterY { get; }
|
|
bool IsRecenterPending { get; }
|
|
bool BeginRecenter(int x, int y, bool isSealedDungeon);
|
|
bool ResetRecenter(bool sessionEnding);
|
|
bool IsSealedDungeon(uint cellId);
|
|
}
|
|
|
|
internal sealed class LocalPlayerTeleportStreamingOperations
|
|
: ILocalPlayerTeleportStreamingOperations
|
|
{
|
|
private readonly LiveWorldOriginState _origin;
|
|
private readonly StreamingOriginRecenterCoordinator _recenter;
|
|
private readonly StreamingController _streaming;
|
|
private readonly ISealedDungeonCellClassifier _sealedDungeonCells;
|
|
|
|
public LocalPlayerTeleportStreamingOperations(
|
|
LiveWorldOriginState origin,
|
|
StreamingOriginRecenterCoordinator recenter,
|
|
StreamingController streaming,
|
|
ISealedDungeonCellClassifier sealedDungeonCells)
|
|
{
|
|
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
|
_recenter = recenter ?? throw new ArgumentNullException(nameof(recenter));
|
|
_streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
|
|
_sealedDungeonCells = sealedDungeonCells
|
|
?? throw new ArgumentNullException(nameof(sealedDungeonCells));
|
|
}
|
|
|
|
public int CenterX => _origin.CenterX;
|
|
public int CenterY => _origin.CenterY;
|
|
public bool IsRecenterPending => _recenter.IsPending;
|
|
|
|
public bool BeginRecenter(int x, int y, bool isSealedDungeon) =>
|
|
_recenter.Begin(x, y, isSealedDungeon);
|
|
|
|
public bool ResetRecenter(bool sessionEnding) =>
|
|
_recenter.Reset(sessionEnding);
|
|
|
|
public bool IsSealedDungeon(uint cellId) =>
|
|
_sealedDungeonCells.IsSealedDungeon(cellId);
|
|
|
|
}
|
|
|
|
internal interface ILocalPlayerTeleportPlacement
|
|
{
|
|
void Place(Quaternion rotation);
|
|
}
|
|
|
|
/// <summary>
|
|
/// C4 route 3: acknowledges the local player's deferred portal arrival —
|
|
/// the canonical placement itself now runs through
|
|
/// <see cref="RuntimeAcceptedPositionDriveController.TryExecuteAcceptedPortalArrival"/>
|
|
/// (a portal arm sharing route 2's Runtime SetPosition owner), retiring the
|
|
/// duplicate Resolve/SetPosition authority this class used to own (D1;
|
|
/// docs/research/2026-08-04-c4-route-3-contract.md D-T4). This class runs
|
|
/// AFTER that commit succeeds. A10 review fix (2026-08-05): the render
|
|
/// <see cref="WorldEntity"/> pose write and rebucket this method performs
|
|
/// are REDUNDANT repeats of a mutation the canonical Place receipt already
|
|
/// made — <c>RuntimePlacementPresentationSink.TryApply</c> →
|
|
/// <c>LiveEntityRuntime.TryApplyRuntimePlacementPlace</c> already calls
|
|
/// <c>entity.SetPosition</c>/sets <c>Rotation</c>/<c>ParentCellId</c> and
|
|
/// rebuckets, synchronously, before <c>TryPublishPlace</c>'s OWN snapshot
|
|
/// even runs (proof obligation P2's ordering). This method's writes are
|
|
/// therefore harmless-but-duplicate, not the render entity's ONLY mover as
|
|
/// an earlier revision of this comment claimed; kept because they cost
|
|
/// nothing extra and this is also where the retail teleport_hook tail's
|
|
/// remaining local-player-visible actions run (target-watcher
|
|
/// notification, camera reset, spatial reconcile).
|
|
/// <paramref name="rotation"/> is the retained accepted destination's wire
|
|
/// orientation — the resolved body orientation was already committed
|
|
/// identically by <c>CommitCanonical</c> (retail's teleport branch does not
|
|
/// independently reorient the mover), so re-deriving it here would only add
|
|
/// a second copy of the same source of truth.
|
|
/// </summary>
|
|
internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlacement
|
|
{
|
|
private readonly LiveEntityRuntime _liveEntities;
|
|
private readonly ILocalPlayerIdentitySource _identity;
|
|
private readonly IRuntimeLocalPlayerControllerSource _controller;
|
|
private readonly ILocalPlayerPhysicsHostSource _host;
|
|
private readonly ChaseCameraInputState _cameras;
|
|
private readonly ILiveSpatialReconcilePhase _spatial;
|
|
|
|
public LocalPlayerTeleportPlacement(
|
|
LiveEntityRuntime liveEntities,
|
|
ILocalPlayerIdentitySource identity,
|
|
IRuntimeLocalPlayerControllerSource controller,
|
|
ILocalPlayerPhysicsHostSource host,
|
|
ChaseCameraInputState cameras,
|
|
ILiveSpatialReconcilePhase spatial)
|
|
{
|
|
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
|
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
|
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
|
|
_host = host ?? throw new ArgumentNullException(nameof(host));
|
|
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
|
|
_spatial = spatial ?? throw new ArgumentNullException(nameof(spatial));
|
|
}
|
|
|
|
public void Place(Quaternion rotation)
|
|
{
|
|
PlayerMovementController controller = _controller.Controller
|
|
?? throw new InvalidOperationException(
|
|
"Teleport Place ran without the local player controller.");
|
|
|
|
uint playerGuid = _identity.ServerGuid;
|
|
if (_liveEntities.TryGetWorldEntity(
|
|
playerGuid,
|
|
out WorldEntity? entity))
|
|
{
|
|
entity.SetPosition(controller.Position);
|
|
entity.ParentCellId = controller.CellId;
|
|
entity.Rotation = rotation;
|
|
|
|
// Retail CPhysicsObj::enter_world installs the object in its
|
|
// destination CObjCell before hidden scripts/particles resume.
|
|
// The canonical commit has already advanced FullCellId, but that
|
|
// fact alone does not move acdream's retained projection out of
|
|
// its source/pending GPU bucket. Commit both halves of the
|
|
// spatial move here, while portal space still owns the
|
|
// viewport, so CPhysicsObj::update_object's cell-gated tail can
|
|
// advance the Hidden/UnHide PES chain at retail's boundary.
|
|
if (!_liveEntities.RebucketLiveEntity(playerGuid, controller.CellId))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Teleport Place could not commit local player 0x{playerGuid:X8} "
|
|
+ $"to destination cell 0x{controller.CellId:X8}.");
|
|
}
|
|
}
|
|
|
|
// Retail teleport_hook tail @ 0x00514ED0 clears the local target and
|
|
// notifies every watcher that this object teleported. The body's
|
|
// constraint leash re-arm and orientation are already the canonical
|
|
// commit's job (RuntimeAcceptedPositionDriveController
|
|
// .ReconcileAndAcknowledgePortal -> PlayerMovementController
|
|
// .CommitCanonicalTeleportFrame), so this suffix only acknowledges
|
|
// the result into presentation.
|
|
_host.Host?.NotifyTeleported();
|
|
|
|
_cameras.Legacy?.Update(controller.Position, controller.Yaw);
|
|
_cameras.Retail?.ResetViewerToPlayer(controller.Position, controller.Yaw);
|
|
_spatial.Reconcile();
|
|
|
|
PhysicsDiagnostics.LogTeleport(
|
|
"PLACED",
|
|
controller.CellId,
|
|
"readiness=complete");
|
|
Console.WriteLine(
|
|
$"live: teleport materialized - snapped to {controller.Position} "
|
|
+ $"cell=0x{controller.CellId:X8}");
|
|
}
|
|
}
|
|
|
|
internal interface ILocalPlayerTeleportSession
|
|
{
|
|
void SendLoginComplete();
|
|
}
|
|
|
|
internal sealed class LocalPlayerTeleportSession : ILocalPlayerTeleportSession
|
|
{
|
|
private readonly ILiveWorldSessionSource _session;
|
|
|
|
public LocalPlayerTeleportSession(ILiveWorldSessionSource session) =>
|
|
_session = session ?? throw new ArgumentNullException(nameof(session));
|
|
|
|
public void SendLoginComplete() =>
|
|
_session.CurrentSession?.SendGameAction(
|
|
GameActionLoginComplete.Build());
|
|
}
|
|
|
|
internal interface ILocalPlayerTeleportPresentation : IDisposable
|
|
{
|
|
bool IsPortalViewportVisible { get; }
|
|
int CurrentTunnelFrame { get; }
|
|
void Begin(Matrix4x4 projection);
|
|
|
|
/// <summary>
|
|
/// Logout round (2026-08-17): the wormhole run in REVERSE ORDER —
|
|
/// retail's <c>BeginTeleportAnimation(TAS_WORLD_FADE_OUT)
|
|
/// @ 0x004D6E83</c>. The sequencer enters at WorldFadeOut (the world
|
|
/// stays drawn while the view plane pulls in), then TunnelFadeIn →
|
|
/// Tunnel; the tunnel scene itself plays the SAME forward 40 fps
|
|
/// animation (<c>set_sequence_animation @ 0x004D6F70</c> runs
|
|
/// identically for every tunnel-family entry — nothing renders
|
|
/// backwards), and the enter cue plays at this begin
|
|
/// (<c>Sound_UI_EnterPortal @ 0x004D638E</c>, unconditional). No exit
|
|
/// cue ever plays on logout: the character-select swap preempts
|
|
/// retail's TunnelContinue/FadeOut tail.
|
|
/// </summary>
|
|
void BeginLogout(Matrix4x4 projection);
|
|
(TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
|
|
Tick(float deltaSeconds, bool worldReady);
|
|
void TickTunnel(float deltaSeconds);
|
|
void PlayEnterCue();
|
|
void PlayExitCue();
|
|
void EnterTunnel();
|
|
void ExitTunnel();
|
|
void SetWaitCue(bool visible);
|
|
void Reset();
|
|
Matrix4x4 ApplyViewPlane(Matrix4x4 projection);
|
|
ICamera ApplyViewPlane(ICamera camera);
|
|
void DrawPortalViewport(int width, int height, Matrix4x4 projection);
|
|
}
|
|
|
|
internal sealed class LocalPlayerTeleportPresentation
|
|
: ILocalPlayerTeleportPresentation
|
|
{
|
|
private readonly TeleportAnimSequencer _animation = new();
|
|
private readonly TeleportViewPlaneController _viewPlane = new();
|
|
private readonly PortalTunnelPresentation _tunnel;
|
|
|
|
public LocalPlayerTeleportPresentation(PortalTunnelPresentation tunnel) =>
|
|
_tunnel = tunnel ?? throw new ArgumentNullException(nameof(tunnel));
|
|
|
|
public bool IsPortalViewportVisible => _tunnel.IsVisible;
|
|
public int CurrentTunnelFrame => _tunnel.CurrentAnimationFrame;
|
|
|
|
public void Begin(Matrix4x4 projection)
|
|
{
|
|
_viewPlane.Begin(projection);
|
|
_animation.Begin(TeleportEntryKind.Portal);
|
|
}
|
|
|
|
public void BeginLogout(Matrix4x4 projection)
|
|
{
|
|
_viewPlane.Begin(projection);
|
|
_animation.Begin(TeleportEntryKind.Logout);
|
|
}
|
|
|
|
public (TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
|
|
Tick(float deltaSeconds, bool worldReady)
|
|
{
|
|
var (snapshot, events) = _animation.Tick(
|
|
deltaSeconds,
|
|
worldReady,
|
|
CurrentTunnelFrame);
|
|
_viewPlane.Update(snapshot);
|
|
return (snapshot, events);
|
|
}
|
|
|
|
public void TickTunnel(float deltaSeconds) => _tunnel.Tick(deltaSeconds);
|
|
|
|
/// <summary>
|
|
/// Interface-sound sink for the portal cues. Retail plays these from the
|
|
/// teleport-animation boundary, not from the tunnel renderer:
|
|
/// <c>PlaySoundFromCenter(Sound_UI_EnterPortal, GetUISoundTable())</c> at
|
|
/// <c>0x004D638E</c> inside <c>gmSmartBoxUI::BeginTeleportAnimation</c>, and
|
|
/// <c>Sound_UI_ExitPortal</c> at <c>0x004D7405</c>. Assigned at composition;
|
|
/// null (and therefore silent) when audio is unavailable.
|
|
/// </summary>
|
|
public Action<SoundId>? UiSoundSink { get; set; }
|
|
|
|
/// <summary>
|
|
/// <c>Sound_UI_EnterPortal</c>, at retail's moment: the START of the
|
|
/// teleport animation (<c>gmSmartBoxUI::BeginTeleportAnimation</c> @
|
|
/// <c>0x004D638E</c>), which the sequencer marks as
|
|
/// <c>TeleportAnimEvent.PlayEnterSound</c> — NOT when the tunnel viewport
|
|
/// first appears, which is a TunnelFadeIn later.
|
|
/// </summary>
|
|
public void PlayEnterCue() => UiSoundSink?.Invoke(SoundId.UI_EnterPortal);
|
|
|
|
/// <summary>
|
|
/// <c>Sound_UI_ExitPortal</c> @ <c>0x004D7405</c>, at the
|
|
/// TunnelFadeOut to WorldFadeIn edge — the sequencer's
|
|
/// <c>TeleportAnimEvent.PlayExitSound</c>, the same tick the world viewport
|
|
/// is revealed.
|
|
/// </summary>
|
|
public void PlayExitCue() => UiSoundSink?.Invoke(SoundId.UI_ExitPortal);
|
|
|
|
public void EnterTunnel() => _tunnel.Enter();
|
|
|
|
public void ExitTunnel() => _tunnel.Exit();
|
|
public void SetWaitCue(bool visible) => _tunnel.SetWaitCue(visible);
|
|
|
|
public void Reset()
|
|
{
|
|
_animation.Reset();
|
|
_viewPlane.Reset();
|
|
_tunnel.Exit();
|
|
}
|
|
|
|
public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) =>
|
|
_viewPlane.Apply(projection);
|
|
|
|
public ICamera ApplyViewPlane(ICamera camera) => _viewPlane.ApplyTo(camera);
|
|
|
|
public void DrawPortalViewport(int width, int height, Matrix4x4 projection) =>
|
|
_tunnel.Draw(width, height, ApplyViewPlane(projection));
|
|
|
|
public void Dispose() => _tunnel.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Graphical-host orchestrator for Runtime's local F751/Position-correlated
|
|
/// portal lifetime. Runtime owns sequence/destination/reveal authority; this
|
|
/// update-thread adapter owns portal viewport activation, render-space aim,
|
|
/// readiness preparation, placement callbacks, and LoginComplete presentation.
|
|
/// </summary>
|
|
internal sealed class LocalPlayerTeleportController
|
|
: ILocalPlayerTeleportFramePhase,
|
|
ILocalPlayerTeleportNetworkSink,
|
|
AcDream.App.Interaction.ISelectionViewPlaneSource,
|
|
IDisposable
|
|
{
|
|
private readonly RuntimeWorldTransitState _transit;
|
|
private readonly ILocalPlayerTeleportAuthority _authority;
|
|
private readonly ILocalPlayerTeleportInputLifetime _input;
|
|
private readonly ILocalPlayerTeleportModeOperations _mode;
|
|
private readonly ILocalPlayerTeleportStreamingOperations _streaming;
|
|
private readonly WorldRevealCoordinator _worldReveal;
|
|
private readonly ILocalPlayerTeleportPlacement _placement;
|
|
private readonly ILocalPlayerTeleportSession _session;
|
|
private readonly ILocalPlayerTeleportPresentation _presentation;
|
|
private readonly RuntimeAcceptedPositionDriveController _acceptedPositionDrive;
|
|
|
|
private uint _pendingCell;
|
|
private Quaternion _pendingRotation = Quaternion.Identity;
|
|
private long _pendingRevealGeneration;
|
|
private RuntimeTeleportDestination _pendingDestination;
|
|
private bool _hasPendingDestination;
|
|
|
|
/// <summary>
|
|
/// A1 review fix (2026-08-05): true once the canonical Runtime commit
|
|
/// for THIS teleport lifetime has actually happened. See the class doc
|
|
/// on <see cref="TryAdvancePortalCommit"/> for why this exists.
|
|
/// </summary>
|
|
private bool _placementCommitted;
|
|
|
|
/// <summary>
|
|
/// A1 review fix: true while a <c>DeferredCell</c> park is outstanding
|
|
/// for the local player's one possible pending operation. See
|
|
/// <see cref="TryAdvancePortalCommit"/>.
|
|
/// </summary>
|
|
private bool _awaitingDeferredWake;
|
|
|
|
private float _holdSeconds;
|
|
private long _lifetimeGeneration;
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Enter-world round (2026-08-17): the login reveal generation this
|
|
/// controller's presentation currently owns (0 = none). Retail runs the
|
|
/// SAME wormhole machine at initial login as at an F751 teleport —
|
|
/// <c>SmartBox::teleport_in_progress @ 0x00451C20</c> returns 1 whenever
|
|
/// the SmartBox has a player whose <c>position_update_complete</c> is
|
|
/// still 0, which is true the moment the login CreatePlayer lands, and
|
|
/// <c>gmSmartBoxUI::UseTime @ 0x004D6EAB</c> edge-detects that flag into
|
|
/// <c>BeginTeleportAnimation(TAS_TUNNEL) @ 0x004D6EC9</c> (which plays
|
|
/// <c>Sound_UI_EnterPortal @ 0x004D638E</c>) with no F751 involved.
|
|
/// acdream's canonical equivalent of that condition is Runtime's login
|
|
/// reveal (<see cref="RuntimeWorldTransitState.BeginLoginReveal"/>,
|
|
/// begun on the first accepted local-player position) — so this arm keys
|
|
/// the same presentation off that Runtime-owned lifecycle instead of a
|
|
/// teleport start.
|
|
/// </summary>
|
|
private long _loginRevealGeneration;
|
|
private bool _loginPresentationActive;
|
|
|
|
/// <summary>
|
|
/// Enter-click round (2026-08-17): true while the login tunnel is armed
|
|
/// PRE-REVEAL — from the character-select Enter click (host edge
|
|
/// <c>ApplySelectedCharacter</c>, shared by direct connect, roster Enter,
|
|
/// and enter-after-create) until the Runtime login reveal adopts the
|
|
/// running presentation, or the enter transaction falls back to character
|
|
/// select (rejected EnterWorld), or a session/teleport reset withdraws
|
|
/// it. Registered deviation from retail's pre-CreatePlayer black — see
|
|
/// <see cref="ILocalPlayerTeleportNetworkSink.ArmLoginTunnel"/>.
|
|
/// </summary>
|
|
private bool _loginTunnelArmed;
|
|
|
|
/// <summary>
|
|
/// Latched by <see cref="OnLocalPlayerFirstEntryCompleted"/> — the
|
|
/// first-entry conductor's canonical initial placement committed. The
|
|
/// login pump's <c>worldReady</c> requires it, so the sequencer cannot
|
|
/// leave its Tunnel hold (and therefore cannot reach
|
|
/// <see cref="TeleportAnimEvent.FireLoginComplete"/>) before the same
|
|
/// placement contract that previously gated the immediate LoginComplete
|
|
/// send. Session-scoped: cleared only by session-level resets.
|
|
/// </summary>
|
|
private bool _loginPlacementCompleted;
|
|
private float _loginHoldSeconds;
|
|
|
|
/// <summary>
|
|
/// Enter-click round (2026-08-17): resolved PER CALL (never captured —
|
|
/// claude-memory/feedback_resolve_deferred_funcs_per_call.md). The armed
|
|
/// pre-reveal tunnel projects the Runtime character-selection lifecycle:
|
|
/// <c>EnteringWorld</c>/<c>InWorld</c> keep it armed; a regression to
|
|
/// <c>AwaitingSelection</c> (rejected EnterWorld —
|
|
/// <c>LiveSessionController.EnterHighlightedCore</c>'s
|
|
/// <c>ReturnToSelection</c>) disarms it so the user is not left staring
|
|
/// at a tunnel on the character-select screen.
|
|
/// </summary>
|
|
private readonly ILocalPlayerLoginLifecycleSource _loginLifecycle;
|
|
|
|
/// <summary>
|
|
/// Logout round (2026-08-17): the logout pump's Runtime seams — see
|
|
/// <see cref="ILocalPlayerLogoutOperations"/>. The pump itself is the
|
|
/// third arm of retail's ONE wormhole machine
|
|
/// (<c>gmSmartBoxUI::UseTime @ 0x004D6E30</c> drives login, teleport,
|
|
/// and logout from the same function).
|
|
/// </summary>
|
|
private readonly ILocalPlayerLogoutOperations _logout;
|
|
|
|
public LocalPlayerTeleportController(
|
|
ILocalPlayerTeleportAuthority authority,
|
|
ILocalPlayerTeleportInputLifetime input,
|
|
ILocalPlayerTeleportModeOperations mode,
|
|
ILocalPlayerTeleportStreamingOperations streaming,
|
|
RuntimeWorldTransitState transit,
|
|
WorldRevealCoordinator worldReveal,
|
|
ILocalPlayerTeleportPlacement placement,
|
|
ILocalPlayerTeleportSession session,
|
|
ILocalPlayerTeleportPresentation presentation,
|
|
RuntimeAcceptedPositionDriveController acceptedPositionDrive,
|
|
ILocalPlayerLoginLifecycleSource loginLifecycle,
|
|
ILocalPlayerLogoutOperations logout)
|
|
{
|
|
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
|
|
_input = input ?? throw new ArgumentNullException(nameof(input));
|
|
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
|
|
_streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
|
|
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
|
_worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal));
|
|
_placement = placement ?? throw new ArgumentNullException(nameof(placement));
|
|
_session = session ?? throw new ArgumentNullException(nameof(session));
|
|
_presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
|
|
_acceptedPositionDrive = acceptedPositionDrive
|
|
?? throw new ArgumentNullException(nameof(acceptedPositionDrive));
|
|
_loginLifecycle = loginLifecycle
|
|
?? throw new ArgumentNullException(nameof(loginLifecycle));
|
|
_logout = logout ?? throw new ArgumentNullException(nameof(logout));
|
|
}
|
|
|
|
public bool IsActive => _transit.IsTeleportActive;
|
|
public bool IsPortalViewportVisible => _presentation.IsPortalViewportVisible;
|
|
|
|
/// <summary>
|
|
/// The destination the portal viewport is currently holding for. The
|
|
/// render frame's reveal-preparation arm
|
|
/// (<see cref="AcDream.App.Rendering.RuntimeRenderFrameLivePreparation"/>)
|
|
/// keys composite-texture preparation and readiness evaluation off this
|
|
/// cell; the login presentation must report its own destination here
|
|
/// because entering portal space flips <c>ChaseModeEverEntered</c>, which
|
|
/// retires the fallback "waiting for login" cell source that used to keep
|
|
/// preparation running pre-entry.
|
|
/// </summary>
|
|
public uint ActiveDestinationCell
|
|
{
|
|
get
|
|
{
|
|
if (_transit.IsTeleportActive)
|
|
return _pendingCell;
|
|
if (_loginPresentationActive)
|
|
{
|
|
RuntimePortalSnapshot snapshot = _transit.Snapshot;
|
|
if (snapshot.Kind == RuntimePortalKind.Login
|
|
&& snapshot.Generation == _loginRevealGeneration)
|
|
{
|
|
return snapshot.Readiness.DestinationCell;
|
|
}
|
|
}
|
|
return 0u;
|
|
}
|
|
}
|
|
|
|
public void OnTeleportStarted(uint sequence)
|
|
{
|
|
ThrowIfDisposed();
|
|
// Logout round (2026-08-17): a logoff in flight owns the wormhole;
|
|
// the character is leaving the world and no F751 may supersede the
|
|
// logout presentation (ACE does not teleport a logging-off player —
|
|
// Player.LogOut sets IsBusy/IsLoggingOut before any motion runs).
|
|
if (_transit.IsLogoutActive)
|
|
{
|
|
Console.WriteLine(
|
|
$"live: teleport start ignored during logout (seq={sequence})");
|
|
return;
|
|
}
|
|
ushort teleportSequence = (ushort)sequence;
|
|
if (!_authority.IsFreshStart(teleportSequence)
|
|
|| !_transit.CanQueueTeleportStart(teleportSequence))
|
|
{
|
|
return;
|
|
}
|
|
|
|
long observedGeneration = _lifetimeGeneration;
|
|
_input.EndMouseLook();
|
|
if (_lifetimeGeneration != observedGeneration)
|
|
return;
|
|
|
|
long resetGeneration = ResetTransit(clearSession: false);
|
|
if (_lifetimeGeneration != resetGeneration)
|
|
return;
|
|
|
|
if (!_transit.TryQueueTeleportStart(teleportSequence))
|
|
return;
|
|
TryActivatePendingPresentation();
|
|
Console.WriteLine($"live: teleport queued (seq={sequence})");
|
|
}
|
|
|
|
public void OfferDestination(
|
|
RuntimeTeleportDestination destination,
|
|
bool teleportTimestampAdvanced)
|
|
{
|
|
ThrowIfDisposed();
|
|
_transit.OfferTeleportDestination(
|
|
destination,
|
|
teleportTimestampAdvanced);
|
|
TryAimAcceptedDestination();
|
|
}
|
|
|
|
public void OnLocalPlayerFirstEntryCompleted()
|
|
{
|
|
ThrowIfDisposed();
|
|
_loginPlacementCompleted = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enter-click round (2026-08-17): begin the login tunnel PRESENTATION at
|
|
/// the Enter click, before the EnterWorld server round-trip (registered
|
|
/// user-directed deviation — retail presents black here; see
|
|
/// <see cref="ILocalPlayerTeleportNetworkSink.ArmLoginTunnel"/>).
|
|
///
|
|
/// <para>
|
|
/// The enter cue plays HERE, at the click: retail's rule is "cue at the
|
|
/// animation begin" (<c>gmSmartBoxUI::BeginTeleportAnimation</c> plays
|
|
/// <c>Sound_UI_EnterPortal</c> unconditionally at <c>0x004D638E</c>), and
|
|
/// this deviation moves the animation begin to the click — so the cue
|
|
/// moves with it, keeping cue-and-tunnel continuous instead of splitting
|
|
/// them across the round-trip.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The first sequencer tick is consumed SYNCHRONOUSLY (dt = 0) rather
|
|
/// than left to the frame pump: the Enter command that follows this call
|
|
/// blocks the update thread for the whole ServerReady round-trip
|
|
/// (<c>WorldSession.EnterWorldCore</c>), so a deferred first tick would
|
|
/// leave the frame black for exactly the wait this deviation exists to
|
|
/// cover. The render thread draws the tunnel scene independently every
|
|
/// frame once it is visible.
|
|
/// </para>
|
|
/// </summary>
|
|
public void ArmLoginTunnel()
|
|
{
|
|
ThrowIfDisposed();
|
|
if (_loginTunnelArmed
|
|
|| _loginPresentationActive
|
|
|| _transit.IsTeleportActive
|
|
|| _transit.HasPendingTeleportStart)
|
|
{
|
|
return;
|
|
}
|
|
|
|
long generation = _lifetimeGeneration;
|
|
_presentation.Begin(_mode.Projection);
|
|
if (_lifetimeGeneration != generation)
|
|
return;
|
|
|
|
var (_, events) = _presentation.Tick(0f, worldReady: false);
|
|
if (_lifetimeGeneration != generation)
|
|
return;
|
|
if (!ProcessArmedLoginTunnelEvents(events, generation))
|
|
return;
|
|
|
|
_loginTunnelArmed = true;
|
|
_loginHoldSeconds = 0f;
|
|
Console.WriteLine("live: login tunnel armed at enter click");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The armed pre-reveal tunnel's event consumer — only the two
|
|
/// begin-edge events can occur while <c>worldReady</c> is pinned false
|
|
/// (the sequencer holds in Tunnel); anything else is ignored. Returns
|
|
/// false when a nested callback retired this lifetime.
|
|
/// </summary>
|
|
private bool ProcessArmedLoginTunnelEvents(
|
|
IReadOnlyList<TeleportAnimEvent> events,
|
|
long generation)
|
|
{
|
|
foreach (TeleportAnimEvent teleportEvent in events)
|
|
{
|
|
switch (teleportEvent)
|
|
{
|
|
case TeleportAnimEvent.PlayEnterSound:
|
|
Console.WriteLine(
|
|
"live: login portal-space enter cue "
|
|
+ "(Sound_UI_EnterPortal)");
|
|
_presentation.PlayEnterCue();
|
|
if (_lifetimeGeneration != generation)
|
|
return false;
|
|
break;
|
|
case TeleportAnimEvent.EnterTunnel:
|
|
_presentation.EnterTunnel();
|
|
if (_lifetimeGeneration != generation)
|
|
return false;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// ── Logout round (2026-08-17): the logout arm of retail's ONE wormhole
|
|
// machine. Retail derivation:
|
|
// gmGamePlayUI::UseTime @ 0x004EA3A0 — confirmed Yes drains into
|
|
// CPlayerSystem::LogOffCharacter(0) when the player is grounded
|
|
// (transient_state & CONTACT — the upstream RetailUiRuntime gate).
|
|
// CPlayerSystem::LogOffCharacter @ 0x00563520 — SaveToServer first.
|
|
// CPlayerSystem::RequestLogOff @ 0x00562DD0 — "Logging off..." chat,
|
|
// 0xF653 send, logOffRequestTime = now + 3.0 (+20.0 PK),
|
|
// CommandInterpreter::HandleLogOff @ 0x006B3330 → Disable. The
|
|
// SERVER then broadcasts the LogOut motion (ACE Player.cs:596 →
|
|
// SendMotionAsCommands), which plays on the local player through
|
|
// the ordinary inbound movement funnel during this hold.
|
|
// gmSmartBoxUI::UseTime @ 0x004D6E64 — hold elapsed →
|
|
// BeginTeleportAnimation(TAS_WORLD_FADE_OUT) (enter cue) →
|
|
// TunnelFadeIn → Tunnel.
|
|
// Inbound 0xF653 echo (dispatch case @ 0x0055C963) →
|
|
// CPlayerSystem::ExecuteLogOff @ 0x0055D780 — world teardown with
|
|
// the logon connection kept; the fresh CharacterList in the same
|
|
// server batch re-shows character management
|
|
// (gmGamePlayUI::Update @ 0x004E9CD0 → QueueUIMode(0x1000000a)).
|
|
// No exit cue: the swap preempts the TunnelContinue/FadeOut tail.
|
|
|
|
/// <summary>The sink-forwarded UI entry — see
|
|
/// <see cref="ILocalPlayerTeleportNetworkSink.RequestLogout"/>.</summary>
|
|
public void RequestLogout()
|
|
{
|
|
if (!TryRequestLogout())
|
|
Console.WriteLine("live: character logoff request refused");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The confirmed exit-to-character-select click. Returns false when a
|
|
/// logout, teleport, or login presentation already owns the machine or
|
|
/// the wire request refused.
|
|
/// </summary>
|
|
public bool TryRequestLogout()
|
|
{
|
|
ThrowIfDisposed();
|
|
if (_transit.IsLogoutActive
|
|
|| _transit.IsTeleportActive
|
|
|| _transit.HasPendingTeleportStart
|
|
|| _loginPresentationActive
|
|
|| _loginTunnelArmed)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!_transit.TryBeginLogoutRequest(_logout.IsLocalPlayerKiller))
|
|
return false;
|
|
|
|
long generation = _lifetimeGeneration;
|
|
if (!_logout.BeginCharacterLogOff())
|
|
{
|
|
// Nothing went on the wire — roll the request back rather than
|
|
// running a wormhole for a logoff the server never heard.
|
|
if (_lifetimeGeneration == generation)
|
|
_transit.CancelLogoutRequest();
|
|
return false;
|
|
}
|
|
if (_lifetimeGeneration != generation)
|
|
return true;
|
|
|
|
// Retail's Disable() also ends any mouse-driven turning; the same
|
|
// input-lifetime call every teleport start already makes.
|
|
_input.EndMouseLook();
|
|
Console.WriteLine("live: character logoff requested");
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-frame logout pump — the third arm of the wormhole machine (see
|
|
/// the derivation block above). Confirmation is polled every tick in
|
|
/// every pre-confirmed stage; on the Confirmed edge the handoff runs
|
|
/// IMMEDIATELY, exactly like retail's ExecuteLogOff-on-echo — the
|
|
/// character-select swap tears down whatever presentation state exists
|
|
/// (normally the held tunnel; on a fast confirmation, less).
|
|
/// </summary>
|
|
private void TickLogout(float deltaSeconds)
|
|
{
|
|
long generation = _lifetimeGeneration;
|
|
|
|
if (_transit.LogoutStage is RuntimeLogoutStage.Requested
|
|
or RuntimeLogoutStage.PresentationActive
|
|
&& _logout.IsCharacterLogOffConfirmed)
|
|
{
|
|
_transit.AcknowledgeLogoutConfirmed();
|
|
}
|
|
|
|
switch (_transit.LogoutStage)
|
|
{
|
|
case RuntimeLogoutStage.Requested:
|
|
// The 3 s (23 s PK) hold: the server-broadcast LogOut
|
|
// motion is playing on the player in-world.
|
|
if (_transit.AdvanceLogoutHold(deltaSeconds))
|
|
{
|
|
_presentation.BeginLogout(_mode.Projection);
|
|
if (_lifetimeGeneration != generation)
|
|
return;
|
|
PumpLogoutPresentation(0f, generation);
|
|
}
|
|
return;
|
|
case RuntimeLogoutStage.PresentationActive:
|
|
PumpLogoutPresentation(deltaSeconds, generation);
|
|
return;
|
|
case RuntimeLogoutStage.Confirmed:
|
|
CompleteLogoutHandoff(generation);
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
private void PumpLogoutPresentation(float deltaSeconds, long generation)
|
|
{
|
|
var (_, events) = _presentation.Tick(deltaSeconds, worldReady: false);
|
|
if (_lifetimeGeneration != generation)
|
|
return;
|
|
|
|
foreach (TeleportAnimEvent teleportEvent in events)
|
|
{
|
|
switch (teleportEvent)
|
|
{
|
|
case TeleportAnimEvent.PlayEnterSound:
|
|
// Sound_UI_EnterPortal @ 0x004D638E — unconditional at
|
|
// BeginTeleportAnimation, INCLUDING the logout's
|
|
// TAS_WORLD_FADE_OUT entry.
|
|
Console.WriteLine(
|
|
"live: logout portal-space enter cue "
|
|
+ "(Sound_UI_EnterPortal)");
|
|
_presentation.PlayEnterCue();
|
|
if (_lifetimeGeneration != generation)
|
|
return;
|
|
break;
|
|
case TeleportAnimEvent.EnterTunnel:
|
|
_presentation.EnterTunnel();
|
|
if (_lifetimeGeneration != generation)
|
|
return;
|
|
break;
|
|
default:
|
|
// worldReady is pinned false: Place / PlayExitSound /
|
|
// FireLoginComplete cannot fire (the sequencer holds in
|
|
// Tunnel), matching retail's preempted logout tail.
|
|
break;
|
|
}
|
|
}
|
|
|
|
_presentation.TickTunnel(deltaSeconds);
|
|
}
|
|
|
|
private void CompleteLogoutHandoff(long generation)
|
|
{
|
|
if (!_transit.CompleteLogout() || _lifetimeGeneration != generation)
|
|
return;
|
|
|
|
Console.WriteLine(
|
|
"live: logout confirmed — returning to character select");
|
|
if (_logout.CompleteCharacterLogOff())
|
|
{
|
|
// The transaction's world reset already ran this controller's
|
|
// ResetGenerationPresentation (retiring the tunnel) and the
|
|
// fresh selection state re-shows the character-management
|
|
// screen — retail's QueueUIMode(0x1000000a) analogue.
|
|
return;
|
|
}
|
|
|
|
// The transaction refused or degraded to a full stop. If a reset
|
|
// reached this controller the lifetime moved and everything is
|
|
// already clean; otherwise retire the presentation here so a
|
|
// refused transaction can never leave a stranded tunnel over a
|
|
// still-running world.
|
|
if (_lifetimeGeneration == generation)
|
|
{
|
|
Console.Error.WriteLine(
|
|
"live: return-to-character-select refused — retiring the "
|
|
+ "logout presentation");
|
|
_presentation.Reset();
|
|
}
|
|
}
|
|
|
|
public void Tick(float deltaSeconds)
|
|
{
|
|
ThrowIfDisposed();
|
|
// Logout round (2026-08-17): an active logout owns the whole
|
|
// wormhole machine, exactly as retail's one teleportInProgress flag
|
|
// does (SetTeleportInProgress(1) at the logout begin,
|
|
// gmSmartBoxUI::UseTime @ 0x004D6E8C). Teleport starts are refused
|
|
// while it runs (OnTeleportStarted's own guard).
|
|
if (_transit.IsLogoutActive)
|
|
{
|
|
TickLogout(deltaSeconds);
|
|
return;
|
|
}
|
|
|
|
TryActivatePendingPresentation();
|
|
TryAimAcceptedDestination();
|
|
if (!_transit.IsTeleportActive)
|
|
{
|
|
TickLoginPresentation(deltaSeconds);
|
|
return;
|
|
}
|
|
|
|
long generation = _lifetimeGeneration;
|
|
ushort sequence = _transit.ActiveTeleportSequence;
|
|
if (!_mode.TryEnterPortalSpace()
|
|
|| !IsCurrentLifetime(generation, sequence)
|
|
|| _mode.Controller is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool haveDestination = _pendingCell != 0u;
|
|
bool originReady = !_streaming.IsRecenterPending;
|
|
bool dataReady = haveDestination
|
|
&& originReady
|
|
&& _worldReveal.Evaluate(_pendingCell).IsReady;
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
|
|
// A1 review fix (2026-08-05, retail/architecture review): the
|
|
// sequencer's Tunnel -> TunnelContinue transition
|
|
// (TeleportAnimSequencer.cs:134-141) is unconditional and
|
|
// irreversible the instant it observes `worldReady` true; by the
|
|
// time a failed placement is discovered the stream has already left
|
|
// Tunnel with no path back, and TeleportAnimSequencer itself is
|
|
// untouched (stop condition 2 forbids sequencer timing changes). So
|
|
// the boolean fed into the sequencer must never mean "the data is
|
|
// ready" alone - it must mean "the canonical Runtime commit has
|
|
// ALREADY happened", checked/attempted fresh every tick via
|
|
// TryAdvancePortalCommit. This makes D-T5 row 2's "the NEXT Tick
|
|
// re-attempts the Place edge" real: the sequencer simply never
|
|
// leaves Tunnel while the commit keeps refusing, and by the time it
|
|
// finally does leave Tunnel and fire Place, TryAdvancePortalCommit
|
|
// has already made the Runtime side succeed - the Place-event
|
|
// handler below only ever runs the presentation suffix.
|
|
bool placementReady = dataReady && TryAdvancePortalCommit(sequence);
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
|
|
if (haveDestination && !placementReady)
|
|
_holdSeconds += deltaSeconds;
|
|
_presentation.SetWaitCue(
|
|
haveDestination
|
|
&& !placementReady
|
|
&& _worldReveal.ObserveWait(
|
|
TimeSpan.FromSeconds(_holdSeconds)));
|
|
|
|
var (_, events) = _presentation.Tick(deltaSeconds, placementReady);
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
|
|
foreach (TeleportAnimEvent teleportEvent in events)
|
|
{
|
|
switch (teleportEvent)
|
|
{
|
|
case TeleportAnimEvent.Place:
|
|
// TryAdvancePortalCommit above is the only path that
|
|
// makes `placementReady` (and, with the REAL sequencer,
|
|
// this event) true, so the canonical Runtime commit has
|
|
// ALREADY succeeded by construction on that path - this
|
|
// only runs the presentation suffix (D-T4). The
|
|
// _placementCommitted re-check stays defensive: it is
|
|
// the exact same shape of guard IsCurrentLifetime below
|
|
// already applies to every other step of this case, for
|
|
// a transit that goes stale between the gate above and
|
|
// this line being reached.
|
|
if (!_placementCommitted)
|
|
return;
|
|
// B7 review fix (2026-08-05): re-derived, not assumed -
|
|
// if the reveal was cancelled/superseded in the window
|
|
// between the commit above and this event being
|
|
// processed, ObserveMaterialized below would refuse but
|
|
// Place/the presentation suffix would already have run
|
|
// against a reveal that is no longer current. Same
|
|
// check TryExecuteCanonicalPortalPlacementCore itself
|
|
// gates on; idempotent to repeat here.
|
|
if (!_worldReveal.CanPlacePortalDestination(
|
|
_pendingRevealGeneration, sequence, _pendingCell))
|
|
{
|
|
return;
|
|
}
|
|
_placement.Place(_pendingRotation);
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
_worldReveal.ObserveMaterialized(
|
|
_pendingRevealGeneration,
|
|
sequence,
|
|
_pendingCell);
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
break;
|
|
case TeleportAnimEvent.PlayEnterSound:
|
|
// Retail plays the enter cue as the animation BEGINS, before
|
|
// the tunnel is visible. The sequencer has always emitted
|
|
// this event; nothing consumed it until Campaign A.
|
|
_presentation.PlayEnterCue();
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
break;
|
|
case TeleportAnimEvent.EnterTunnel:
|
|
_presentation.EnterTunnel();
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
break;
|
|
case TeleportAnimEvent.PlayExitSound:
|
|
// gmSmartBoxUI::UseTime @ 0x004D6E30 releases destination
|
|
// cell blocking at the exact portal/world viewport swap.
|
|
// LoginComplete remains one WorldFadeIn second later.
|
|
_worldReveal.RevealWorldViewport();
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
_presentation.PlayExitCue();
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
_presentation.ExitTunnel();
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
break;
|
|
case TeleportAnimEvent.FireLoginComplete:
|
|
_mode.EnterWorld();
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
_session.SendLoginComplete();
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
_worldReveal.Complete();
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return;
|
|
ResetTransit(clearSession: false);
|
|
return;
|
|
default:
|
|
// ClientUISystem sound-table enum playback remains outside
|
|
// this already-accepted presentation extraction.
|
|
break;
|
|
}
|
|
}
|
|
|
|
_presentation.TickTunnel(deltaSeconds);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A1 review fix (2026-08-05): the one gate that decides whether the
|
|
/// anim sequencer is allowed to see <c>worldReady=true</c>. Returns
|
|
/// <see langword="true"/> ONLY once the canonical Runtime commit for
|
|
/// THIS teleport lifetime has actually happened — never speculatively,
|
|
/// never optimistically. Three states:
|
|
///
|
|
/// <list type="bullet">
|
|
/// <item><description>Already committed
|
|
/// (<see cref="_placementCommitted"/>) — returns
|
|
/// <see langword="true"/> immediately, every subsequent Tick.</description></item>
|
|
/// <item><description>A <c>DeferredCell</c> park is outstanding
|
|
/// (<see cref="_awaitingDeferredWake"/>) — polls
|
|
/// <see cref="RuntimeAcceptedPositionDriveController.PendingCount"/>
|
|
/// only to decide whether to re-attempt the Runtime call: Runtime's own
|
|
/// Begin would just refuse a second overlapping attempt with
|
|
/// <c>Contention</c> while a park is outstanding (the drive tracks at
|
|
/// most one pending operation for the local player), so retrying blind
|
|
/// would only add noise. B1 review fix (2026-08-05): once
|
|
/// <c>PendingCount</c> returns to 0 the park is DONE, but "done" is not
|
|
/// "committed" — the drive's own doc names a merge-time <c>Forget</c>
|
|
/// (an ordinary ACE broadcast arriving mid-park) as the EXPECTED way a
|
|
/// park resolves without committing, and A2's own abandon branches are
|
|
/// a second way. The OLD code inferred commit from the empty slot
|
|
/// alone; this now asks
|
|
/// <see cref="RuntimeAcceptedPositionDriveController.TryConsumePortalCommit"/>,
|
|
/// which the drive latches ONLY inside a REAL
|
|
/// <c>ReconcileAndAcknowledgePortal</c> call, keyed to this exact
|
|
/// reveal generation/sequence. A "no" here is NOT a failure — it just
|
|
/// means the park ended without placing, so <see cref="_awaitingDeferredWake"/>
|
|
/// clears and the method falls through to a fresh attempt below,
|
|
/// safely (nothing is pending anymore).</description></item>
|
|
/// <item><description>Neither — attempt the canonical placement fresh
|
|
/// this tick. <c>Committed</c> latches
|
|
/// <see cref="_placementCommitted"/>; <c>DeferredCell</c> latches
|
|
/// <see cref="_awaitingDeferredWake"/>; every other status (Contention,
|
|
/// Rejected, NotApplicable, or the transit no longer owning this
|
|
/// reveal) is the D-T5 refusal shape — nothing mutates, and the SAME
|
|
/// predicate retries automatically on the NEXT Tick, which is what
|
|
/// makes D-T5 row 2's "the next Tick re-attempts the Place edge"
|
|
/// mechanism real without ever touching
|
|
/// <see cref="AcDream.Core.World.TeleportAnimSequencer"/>.</description></item>
|
|
/// </list>
|
|
/// </summary>
|
|
private bool TryAdvancePortalCommit(ushort sequence)
|
|
{
|
|
if (_placementCommitted)
|
|
return true;
|
|
|
|
if (_awaitingDeferredWake)
|
|
{
|
|
if (_acceptedPositionDrive.PendingCount != 0)
|
|
return false;
|
|
_awaitingDeferredWake = false;
|
|
if (_acceptedPositionDrive.TryConsumePortalCommit(
|
|
_pendingRevealGeneration, sequence))
|
|
{
|
|
_placementCommitted = true;
|
|
return true;
|
|
}
|
|
// The park ended without placing (Forgotten, or abandoned by
|
|
// A2's re-validation). Fall through to the fresh-attempt path
|
|
// below in this SAME call — nothing is pending, so it is safe.
|
|
}
|
|
|
|
if (!_worldReveal.CanPlacePortalDestination(
|
|
_pendingRevealGeneration,
|
|
sequence,
|
|
_pendingCell))
|
|
{
|
|
PhysicsDiagnostics.LogTeleport(
|
|
"REFUSED", _pendingCell, "cause=stale-reveal");
|
|
// R8 residual fix (2026-08-05): this refusal previously only
|
|
// logged through PhysicsDiagnostics.LogTeleport, gated by the
|
|
// DIFFERENT ACDREAM_PROBE_TELEPORT flag — invisible under
|
|
// ACDREAM_PROBE_LOCAL_TELEPORT, the gate the rest of this
|
|
// route's arrival/commit lines use. No placement was attempted,
|
|
// so there is no resolved cell/leash/autorun fact to report.
|
|
PhysicsDiagnostics.LogLocalTeleportArrival(
|
|
cause: "stale-reveal",
|
|
placementStatus: "Refused",
|
|
portalGeneration: _pendingRevealGeneration,
|
|
teleportSequence: sequence,
|
|
destinationCell: _pendingCell,
|
|
resolvedCell: 0u,
|
|
hookTailRan: false,
|
|
leashArmed: false,
|
|
autorunCancelled: false);
|
|
return false;
|
|
}
|
|
|
|
RuntimeAcceptedPositionExecutionStatus status =
|
|
TryExecuteCanonicalPortalPlacementCore(sequence);
|
|
switch (status)
|
|
{
|
|
case RuntimeAcceptedPositionExecutionStatus.Committed:
|
|
_placementCommitted = true;
|
|
return true;
|
|
case RuntimeAcceptedPositionExecutionStatus.DeferredCell:
|
|
_awaitingDeferredWake = true;
|
|
return false;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// C4 route 3 (D-T1/D-T2): builds the producer's
|
|
/// <see cref="RuntimePortalPlacementAuthority"/> from live transit facts
|
|
/// and drives the canonical Runtime portal arm. No new
|
|
/// <see cref="WorldRevealCoordinator"/> exposure is needed — the host
|
|
/// token is RE-DERIVED through the transit owner's idempotent
|
|
/// <c>TryRegisterHostProjection</c> (the same generation+cell returns
|
|
/// the token <see cref="WorldRevealCoordinator.TryBeginPortal"/> already
|
|
/// registered at Aim time; a stale generation, wrong cell, cancelled, or
|
|
/// completed reveal refuses), which makes a superseded token unobtainable
|
|
/// by construction.
|
|
///
|
|
/// <para>
|
|
/// The destination itself is <see cref="_pendingDestination"/> — the
|
|
/// value <see cref="AimDestination"/> captured at Aim time — and NOT a
|
|
/// fresh <c>_transit.TryGetAcceptedTeleportDestination</c> read.
|
|
/// <see cref="RuntimeWorldTransitState.TryBeginPortalReveal"/> atomically
|
|
/// CONSUMES the transit's one accepted-destination slot the instant Aim
|
|
/// claims the reveal generation (it clears
|
|
/// <c>_hasAcceptedDestination</c> so a stale destination can never be
|
|
/// re-claimed by a later portal) — by Place time that slot is already
|
|
/// empty, so re-querying it here always fails. This mirrors why
|
|
/// <see cref="_pendingCell"/>/<see cref="_pendingRotation"/>/
|
|
/// <see cref="_pendingRevealGeneration"/> are themselves Aim-time
|
|
/// snapshots rather than live transit reads.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// A9 review fix (2026-08-05): <paramref name="sequence"/> is
|
|
/// <see cref="_pendingDestination"/>'s OWN
|
|
/// <see cref="RuntimeTeleportDestination.TeleportSequence"/>, not the
|
|
/// transit's separately-tracked <c>ActiveTeleportSequence</c> the caller
|
|
/// otherwise threads through — one source for the fact this method's
|
|
/// authority carries, asserted equal to the caller's copy so the two
|
|
/// can never silently diverge.
|
|
/// </para>
|
|
/// </summary>
|
|
private RuntimeAcceptedPositionExecutionStatus
|
|
TryExecuteCanonicalPortalPlacementCore(ushort sequence)
|
|
{
|
|
System.Diagnostics.Debug.Assert(
|
|
!_hasPendingDestination
|
|
|| _pendingDestination.TeleportSequence == sequence,
|
|
"The transit's active sequence and the Aim-time destination's "
|
|
+ "own sequence must never diverge (A9).");
|
|
if (!_hasPendingDestination
|
|
|| !_transit.TryRegisterHostProjection(
|
|
_pendingRevealGeneration,
|
|
_pendingCell,
|
|
out RuntimeWorldHostProjectionToken hostToken))
|
|
{
|
|
PhysicsDiagnostics.LogTeleport(
|
|
"REFUSED", _pendingCell, "cause=host-token-unavailable");
|
|
// R8 residual fix (2026-08-05): same rationale as the
|
|
// stale-reveal refusal above — route through
|
|
// LogLocalTeleportArrival too, so ACDREAM_PROBE_LOCAL_TELEPORT
|
|
// alone is enough to see every App-side refusal cause.
|
|
PhysicsDiagnostics.LogLocalTeleportArrival(
|
|
cause: "host-token-unavailable",
|
|
placementStatus: "Refused",
|
|
portalGeneration: _pendingRevealGeneration,
|
|
teleportSequence: sequence,
|
|
destinationCell: _pendingCell,
|
|
resolvedCell: 0u,
|
|
hookTailRan: false,
|
|
leashArmed: false,
|
|
autorunCancelled: false);
|
|
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
|
}
|
|
|
|
RuntimeTeleportDestination destination = _pendingDestination;
|
|
var portal = new RuntimePortalPlacementAuthority(
|
|
Present: true,
|
|
RevealGeneration: _pendingRevealGeneration,
|
|
TeleportSequence: destination.TeleportSequence,
|
|
Projection: hostToken);
|
|
return _acceptedPositionDrive.TryExecuteAcceptedPortalArrival(
|
|
destination,
|
|
portal);
|
|
}
|
|
|
|
public void ResetSession()
|
|
{
|
|
ThrowIfDisposed();
|
|
ResetTransit(
|
|
clearSession: true,
|
|
resetCanonicalTransit: true);
|
|
}
|
|
|
|
public void ResetGenerationPresentation()
|
|
{
|
|
ThrowIfDisposed();
|
|
ResetTransit(
|
|
clearSession: true,
|
|
resetCanonicalTransit: false);
|
|
}
|
|
|
|
public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) =>
|
|
_presentation.ApplyViewPlane(projection);
|
|
|
|
public ICamera ApplyViewPlane(ICamera camera) =>
|
|
_presentation.ApplyViewPlane(camera);
|
|
|
|
public void DrawPortalViewport(int width, int height, Matrix4x4 projection) =>
|
|
_presentation.DrawPortalViewport(width, height, projection);
|
|
|
|
private void TryActivatePendingPresentation()
|
|
{
|
|
if (!_transit.HasPendingTeleportStart)
|
|
return;
|
|
|
|
long generation = _lifetimeGeneration;
|
|
if (!_mode.TryEnterPortalSpace()
|
|
|| _lifetimeGeneration != generation
|
|
|| !_transit.HasPendingTeleportStart)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_holdSeconds = 0f;
|
|
_presentation.Begin(_mode.Projection);
|
|
if (_lifetimeGeneration != generation
|
|
|| !_transit.HasPendingTeleportStart)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!_transit.ActivateQueuedTeleport())
|
|
return;
|
|
TryAimAcceptedDestination();
|
|
Console.WriteLine(
|
|
$"live: teleport presentation started "
|
|
+ $"(seq={_transit.ActiveTeleportSequence})");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enter-world round (2026-08-17): the login half of retail's ONE
|
|
/// wormhole machine. Retail begins the identical TAS_TUNNEL animation for
|
|
/// initial login and for F751 teleports from the same
|
|
/// <c>gmSmartBoxUI::UseTime @ 0x004D6EAB</c> flag edge —
|
|
/// <c>SmartBox::teleport_in_progress @ 0x00451C20</c> goes high the
|
|
/// moment the login player object exists with
|
|
/// <c>position_update_complete == 0</c>, no F751 required. acdream's
|
|
/// canonical login edge is Runtime's login reveal generation
|
|
/// (<see cref="RuntimeWorldTransitState.BeginLoginReveal"/>, begun on the
|
|
/// first accepted local-player position on every entry route: direct
|
|
/// auto-select, character-select Enter, and enter-after-create).
|
|
///
|
|
/// <para>
|
|
/// Activation retries every Tick until
|
|
/// <see cref="ILocalPlayerTeleportModeOperations.TryEnterPortalSpace"/>
|
|
/// succeeds — the same retry shape the F751 arm uses — which requires the
|
|
/// Runtime first-entry conductor's published movement controller.
|
|
/// Entering portal space cancels the player-mode auto-entry (its first
|
|
/// statement), so this arm owns the reveal's
|
|
/// EnterWorld/LoginComplete/Complete suffix exactly like the teleport
|
|
/// pump owns its own; the update-frame order (teleport phase before
|
|
/// auto-entry) guarantees this claim happens before auto-entry could
|
|
/// fire.
|
|
/// </para>
|
|
/// </summary>
|
|
private void TryActivateLoginPresentation()
|
|
{
|
|
if (_transit.HasPendingTeleportStart || _transit.IsTeleportActive)
|
|
return;
|
|
|
|
RuntimePortalSnapshot snapshot = _transit.Snapshot;
|
|
if (snapshot.Kind != RuntimePortalKind.Login
|
|
|| snapshot.Generation == 0
|
|
|| snapshot.Completed
|
|
|| snapshot.Cancelled
|
|
|| _loginRevealGeneration == snapshot.Generation)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Quiet pre-gate: until the first-entry conductor PUBLISHES the
|
|
// movement controller, portal-space entry cannot succeed (and the
|
|
// full entry path would log a refusal every tick). Retry silently.
|
|
if (_mode.Controller is not { CanExecuteLiveMovement: true })
|
|
return;
|
|
|
|
long generation = _lifetimeGeneration;
|
|
if (!_mode.TryEnterPortalSpaceForLogin()
|
|
|| _lifetimeGeneration != generation
|
|
|| _mode.Controller is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Re-read after the mode entry: TryEnterPortalSpaceForLogin can run
|
|
// arbitrary presentation attach work.
|
|
snapshot = _transit.Snapshot;
|
|
if (snapshot.Kind != RuntimePortalKind.Login
|
|
|| snapshot.Generation == 0
|
|
|| snapshot.Completed
|
|
|| snapshot.Cancelled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Enter-click round (2026-08-17): a click-armed tunnel is ADOPTED,
|
|
// not restarted — the presentation is already running (sequencer in
|
|
// its Tunnel hold, tunnel scene visible, enter cue already played at
|
|
// the click), so re-Begin here would restart the sequencer and
|
|
// double-fire the begin-edge events. The hold clock also carries
|
|
// over: the user's wait began at the click.
|
|
bool adoptedArmedTunnel = _loginTunnelArmed;
|
|
_loginTunnelArmed = false;
|
|
_loginRevealGeneration = snapshot.Generation;
|
|
_loginPresentationActive = true;
|
|
if (!adoptedArmedTunnel)
|
|
{
|
|
_loginHoldSeconds = 0f;
|
|
_presentation.Begin(_mode.Projection);
|
|
}
|
|
Console.WriteLine(
|
|
$"live: login portal-space presentation started "
|
|
+ $"(gen={snapshot.Generation} "
|
|
+ $"cell=0x{snapshot.Readiness.DestinationCell:X8} "
|
|
+ $"adoptedArmedTunnel={(adoptedArmedTunnel ? 1 : 0)})");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-frame pump for the login presentation — the login mirror of the
|
|
/// teleport pump in <see cref="Tick"/>. Differences, each anchored in
|
|
/// retail: there is no Place edge to drive (the first-entry conductor
|
|
/// committed the canonical placement before this presentation could
|
|
/// begin — retail's <c>SmartBox::UseTime @ 0x00455483</c> likewise only
|
|
/// flips <c>position_update_complete</c>, it does not place), and
|
|
/// LoginComplete rides <see cref="TeleportAnimEvent.FireLoginComplete"/>
|
|
/// at the WorldFadeIn end (<c>gmSmartBoxUI::UseTime @ 0x004D745D</c> →
|
|
/// <c>CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90</c>)
|
|
/// instead of at raw first placement.
|
|
/// </summary>
|
|
private void TickLoginPresentation(float deltaSeconds)
|
|
{
|
|
TryActivateLoginPresentation();
|
|
|
|
RuntimePortalSnapshot snapshot = _transit.Snapshot;
|
|
bool revealActive = snapshot.Kind == RuntimePortalKind.Login
|
|
&& snapshot.Generation != 0
|
|
&& !snapshot.Completed
|
|
&& !snapshot.Cancelled;
|
|
if (!revealActive || _loginRevealGeneration != snapshot.Generation)
|
|
{
|
|
if (_loginPresentationActive)
|
|
{
|
|
// The reveal this presentation was serving ended underneath
|
|
// it (cancel, supersession, or session reset that did not
|
|
// route through this controller's own reset). Drop the claim
|
|
// and retire the visuals; a successor reveal re-activates
|
|
// through TryActivateLoginPresentation above.
|
|
_loginRevealGeneration = 0;
|
|
_loginPresentationActive = false;
|
|
_loginTunnelArmed = false;
|
|
_loginHoldSeconds = 0f;
|
|
_presentation.Reset();
|
|
}
|
|
else if (_loginTunnelArmed)
|
|
{
|
|
// Enter-click round (2026-08-17): the pre-reveal armed
|
|
// window — from the Enter click until the Runtime login
|
|
// reveal begins (CreatePlayer + first accepted position) and
|
|
// the activation above claims it. Keeps the tunnel animating
|
|
// across the server round-trip; disarms if the enter
|
|
// transaction fell back to character select.
|
|
TickArmedLoginTunnel(deltaSeconds);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!_loginPresentationActive)
|
|
return;
|
|
|
|
long generation = _lifetimeGeneration;
|
|
long revealGeneration = snapshot.Generation;
|
|
uint destinationCell = snapshot.Readiness.DestinationCell;
|
|
|
|
bool originReady = !_streaming.IsRecenterPending;
|
|
bool worldReady = _loginPlacementCompleted
|
|
&& originReady
|
|
&& _worldReveal.Evaluate(destinationCell).IsReady;
|
|
if (!IsCurrentLoginLifetime(generation, revealGeneration))
|
|
return;
|
|
|
|
if (!worldReady)
|
|
_loginHoldSeconds += deltaSeconds;
|
|
_presentation.SetWaitCue(
|
|
!worldReady
|
|
&& _worldReveal.ObserveWait(
|
|
TimeSpan.FromSeconds(_loginHoldSeconds)));
|
|
|
|
var (_, events) = _presentation.Tick(deltaSeconds, worldReady);
|
|
if (!IsCurrentLoginLifetime(generation, revealGeneration))
|
|
return;
|
|
|
|
foreach (TeleportAnimEvent teleportEvent in events)
|
|
{
|
|
switch (teleportEvent)
|
|
{
|
|
case TeleportAnimEvent.PlayEnterSound:
|
|
// Sound_UI_EnterPortal as the animation begins —
|
|
// BeginTeleportAnimation @ 0x004D638E, identical for the
|
|
// login entry. Logged (once per login) as the audio-start
|
|
// evidence line for connected gates.
|
|
Console.WriteLine(
|
|
"live: login portal-space enter cue "
|
|
+ "(Sound_UI_EnterPortal)");
|
|
_presentation.PlayEnterCue();
|
|
if (!IsCurrentLoginLifetime(generation, revealGeneration))
|
|
return;
|
|
break;
|
|
case TeleportAnimEvent.EnterTunnel:
|
|
_presentation.EnterTunnel();
|
|
if (!IsCurrentLoginLifetime(generation, revealGeneration))
|
|
return;
|
|
break;
|
|
case TeleportAnimEvent.Place:
|
|
// No login PLACEMENT: the canonical initial placement is
|
|
// the first-entry conductor's, already committed (the
|
|
// worldReady latch above requires it). Retail's login
|
|
// analogue only flips position_update_complete
|
|
// (SmartBox::UseTime @ 0x00455483) — but that same
|
|
// blocking-ends edge is where retail RESUMES
|
|
// CObjectMaint/CPhysics (@ 0x00455410), while the tunnel
|
|
// is still in front. Acknowledge the login
|
|
// materialization/simulation release here, the exact
|
|
// login mirror of the teleport pump's
|
|
// ObserveMaterialized — without it the world generation
|
|
// stayed unavailable until Complete, so the whole
|
|
// WorldFadeIn second presented an EMPTY world frame (the
|
|
// 2026-08-17 gate's exit-edge void).
|
|
_worldReveal.ObserveLoginMaterialized(revealGeneration);
|
|
if (!IsCurrentLoginLifetime(generation, revealGeneration))
|
|
return;
|
|
break;
|
|
case TeleportAnimEvent.PlayExitSound:
|
|
// Release destination cell blocking at the exact
|
|
// portal/world viewport swap — same edge as the teleport
|
|
// pump (gmSmartBoxUI::UseTime @ 0x004D6E30), with
|
|
// Sound_UI_ExitPortal @ 0x004D7405.
|
|
_worldReveal.RevealWorldViewport();
|
|
if (!IsCurrentLoginLifetime(generation, revealGeneration))
|
|
return;
|
|
_presentation.PlayExitCue();
|
|
if (!IsCurrentLoginLifetime(generation, revealGeneration))
|
|
return;
|
|
_presentation.ExitTunnel();
|
|
if (!IsCurrentLoginLifetime(generation, revealGeneration))
|
|
return;
|
|
break;
|
|
case TeleportAnimEvent.FireLoginComplete:
|
|
_mode.EnterWorld();
|
|
if (!IsCurrentLoginLifetime(generation, revealGeneration))
|
|
return;
|
|
_session.SendLoginComplete();
|
|
if (!IsCurrentLoginLifetime(generation, revealGeneration))
|
|
return;
|
|
_worldReveal.Complete();
|
|
_loginRevealGeneration = 0;
|
|
_loginPresentationActive = false;
|
|
_loginHoldSeconds = 0f;
|
|
Console.WriteLine(
|
|
"live: login portal-space presentation complete");
|
|
return;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
_presentation.TickTunnel(deltaSeconds);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enter-click round (2026-08-17): the armed pre-reveal pump. The
|
|
/// sequencer holds in its Tunnel state (worldReady pinned false — no
|
|
/// reveal exists to be ready), the tunnel scene animates, and the hold
|
|
/// clock accumulates from the click. Disarms when the Runtime
|
|
/// character-selection lifecycle regresses out of the enter transaction
|
|
/// (rejected EnterWorld → AwaitingSelection, or a session teardown →
|
|
/// Inactive/Connecting): the character-select screen is in front again
|
|
/// and retail shows no tunnel there.
|
|
/// </summary>
|
|
private void TickArmedLoginTunnel(float deltaSeconds)
|
|
{
|
|
RuntimeCharacterSelectionLifecycle lifecycle =
|
|
_loginLifecycle.SelectionLifecycle;
|
|
if (lifecycle is not (
|
|
RuntimeCharacterSelectionLifecycle.EnteringWorld
|
|
or RuntimeCharacterSelectionLifecycle.InWorld))
|
|
{
|
|
_loginTunnelArmed = false;
|
|
_loginHoldSeconds = 0f;
|
|
_presentation.Reset();
|
|
Console.WriteLine(
|
|
$"live: login tunnel disarmed (lifecycle={lifecycle})");
|
|
return;
|
|
}
|
|
|
|
long generation = _lifetimeGeneration;
|
|
_loginHoldSeconds += deltaSeconds;
|
|
var (_, events) = _presentation.Tick(deltaSeconds, worldReady: false);
|
|
if (_lifetimeGeneration != generation || !_loginTunnelArmed)
|
|
return;
|
|
if (!ProcessArmedLoginTunnelEvents(events, generation))
|
|
return;
|
|
_presentation.TickTunnel(deltaSeconds);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The login pump's currency check — the login mirror of
|
|
/// <see cref="IsCurrentLifetime(long, ushort)"/>: same controller
|
|
/// lifetime, still no active teleport (an F751 supersedes the login
|
|
/// presentation), and the transit snapshot still carries the exact
|
|
/// claimed login reveal generation.
|
|
/// </summary>
|
|
private bool IsCurrentLoginLifetime(
|
|
long lifetimeGeneration,
|
|
long revealGeneration) =>
|
|
_lifetimeGeneration == lifetimeGeneration
|
|
&& !_transit.IsTeleportActive
|
|
&& _loginRevealGeneration == revealGeneration
|
|
&& _transit.Snapshot.Generation == revealGeneration;
|
|
|
|
private void TryAimAcceptedDestination()
|
|
{
|
|
if (!_transit.TryGetAcceptedTeleportDestination(
|
|
out RuntimeTeleportDestination destination))
|
|
{
|
|
return;
|
|
}
|
|
|
|
long generation = _lifetimeGeneration;
|
|
ushort sequence = _transit.ActiveTeleportSequence;
|
|
PlayerMovementController? controller = _mode.Controller;
|
|
if (controller is null)
|
|
{
|
|
if (!_mode.TryEnterPortalSpace()
|
|
|| !IsCurrentLifetime(generation, sequence))
|
|
{
|
|
return;
|
|
}
|
|
|
|
controller = _mode.Controller;
|
|
if (controller is null)
|
|
return;
|
|
}
|
|
|
|
if (!AimDestination(destination, controller, generation, sequence))
|
|
return;
|
|
}
|
|
|
|
private bool AimDestination(
|
|
RuntimeTeleportDestination destination,
|
|
PlayerMovementController controller,
|
|
long generation,
|
|
ushort sequence)
|
|
{
|
|
Position position = destination.Position;
|
|
int landblockX = (int)((position.ObjCellId >> 24) & 0xFFu);
|
|
int landblockY = (int)((position.ObjCellId >> 16) & 0xFFu);
|
|
uint streamingOriginLandblockId = StreamingRegion.EncodeLandblockId(
|
|
_streaming.CenterX,
|
|
_streaming.CenterY);
|
|
var origin = new Vector3(
|
|
(landblockX - _streaming.CenterX) * 192f,
|
|
(landblockY - _streaming.CenterY) * 192f,
|
|
0f);
|
|
Vector3 translated = position.Frame.Origin + origin;
|
|
|
|
TeleportLandblockTransition transition = TeleportLandblockTransition.Classify(
|
|
controller.CellId,
|
|
position.ObjCellId,
|
|
streamingOriginLandblockId);
|
|
int oldX = (int)((transition.SourceLandblockId >> 24) & 0xFFu);
|
|
int oldY = (int)((transition.SourceLandblockId >> 16) & 0xFFu);
|
|
|
|
Console.WriteLine(
|
|
$"live: teleport arrival - old lb=({oldX},{oldY}) "
|
|
+ $"new lb=({landblockX},{landblockY}) "
|
|
+ $"dist={Vector3.Distance(translated, controller.Position):F1}");
|
|
|
|
// Retail SmartBox enters blocking_for_cells before old-world object,
|
|
// physics, landscape, and ambient owners may advance again. Begin the
|
|
// reveal generation before a recenter can start detaching that world.
|
|
if (!_worldReveal.TryBeginPortal(
|
|
sequence,
|
|
position.ObjCellId,
|
|
out long revealGeneration))
|
|
{
|
|
return false;
|
|
}
|
|
_pendingRevealGeneration = revealGeneration;
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return false;
|
|
|
|
if (transition.ChangesStreamingCenter)
|
|
{
|
|
bool isSealedDungeon = _streaming.IsSealedDungeon(
|
|
position.ObjCellId);
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return false;
|
|
|
|
_streaming.BeginRecenter(
|
|
landblockX,
|
|
landblockY,
|
|
isSealedDungeon);
|
|
if (!IsCurrentLifetime(generation, sequence))
|
|
return false;
|
|
}
|
|
|
|
// C4 route 3: the App-frame-translated `translated`/`worldPosition`
|
|
// vector is no longer carried past this point — the canonical
|
|
// portal arm resolves the placement through Runtime's OWN world
|
|
// frame (resolveWorldOffsetFromRuntimeFrame: true), using the
|
|
// cell-local `destination` Position captured HERE rather than an
|
|
// App-translated snapshot (trap T4). It must be captured here and
|
|
// NOT re-read from the transit at the Place edge:
|
|
// _worldReveal.TryBeginPortal (above) drives
|
|
// RuntimeWorldTransitState.TryBeginPortalReveal, which atomically
|
|
// CONSUMES the transit's one accepted-destination slot the instant
|
|
// it claims this reveal generation — a later
|
|
// TryGetAcceptedTeleportDestination call always finds it empty.
|
|
_pendingRotation = position.Frame.Orientation;
|
|
_pendingCell = position.ObjCellId;
|
|
_pendingDestination = destination;
|
|
_hasPendingDestination = true;
|
|
_holdSeconds = 0f;
|
|
PhysicsDiagnostics.LogTeleport(
|
|
"AIM",
|
|
position.ObjCellId,
|
|
$"seq={destination.TeleportSequence} lb={landblockX},{landblockY} "
|
|
+ $"indoor={((position.ObjCellId & 0xFFFFu) >= 0x0100u)} "
|
|
+ $"playerCross={transition.CrossesLandblock} "
|
|
+ $"centerChange={transition.ChangesStreamingCenter}");
|
|
return true;
|
|
}
|
|
|
|
private long ResetTransit(
|
|
bool clearSession,
|
|
bool resetCanonicalTransit = false)
|
|
{
|
|
long generation = checked(++_lifetimeGeneration);
|
|
|
|
_pendingCell = 0u;
|
|
_pendingRotation = Quaternion.Identity;
|
|
_pendingRevealGeneration = 0;
|
|
_pendingDestination = default;
|
|
_hasPendingDestination = false;
|
|
_placementCommitted = false;
|
|
_awaitingDeferredWake = false;
|
|
_holdSeconds = 0f;
|
|
// Enter-world round (2026-08-17): an F751 arriving mid-login-tunnel
|
|
// (clearSession: false) withdraws the login presentation's claim —
|
|
// the portal pump supersedes it and owns the single LoginComplete,
|
|
// exactly as retail's one teleportInProgress flag stays high across
|
|
// both and sends once at the eventual WorldFadeIn end. The
|
|
// placement-completed latch is a session fact: it survives the
|
|
// teleport-scoped reset and clears only with the session.
|
|
_loginRevealGeneration = 0;
|
|
_loginPresentationActive = false;
|
|
_loginTunnelArmed = false;
|
|
_loginHoldSeconds = 0f;
|
|
if (clearSession)
|
|
_loginPlacementCompleted = false;
|
|
|
|
_streaming.ResetRecenter(clearSession);
|
|
if (_lifetimeGeneration != generation)
|
|
return generation;
|
|
|
|
if (clearSession)
|
|
{
|
|
if (resetCanonicalTransit)
|
|
_worldReveal.ResetSession();
|
|
else
|
|
_worldReveal.ResetHostSession();
|
|
}
|
|
else
|
|
{
|
|
_transit.EndTeleport();
|
|
_worldReveal.Cancel();
|
|
}
|
|
if (_lifetimeGeneration != generation)
|
|
return generation;
|
|
|
|
_presentation.Reset();
|
|
if (_lifetimeGeneration != generation)
|
|
return generation;
|
|
|
|
return generation;
|
|
}
|
|
|
|
private bool IsCurrentLifetime(long generation, ushort sequence) =>
|
|
_lifetimeGeneration == generation
|
|
&& _transit.IsTeleportActive
|
|
&& _transit.ActiveTeleportSequence == sequence;
|
|
|
|
private void ThrowIfDisposed() =>
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_presentation.Dispose();
|
|
_disposed = true;
|
|
}
|
|
}
|