feat(ui): AD-109 — arm the login wormhole at the char-select Enter click
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>
This commit is contained in:
parent
fdc4fd496d
commit
2bc81480d4
10 changed files with 437 additions and 13 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1020,7 +1020,12 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
presentation,
|
presentation,
|
||||||
// C4 route 3: the portal arm shares route 2's Runtime
|
// C4 route 3: the portal arm shares route 2's Runtime
|
||||||
// SetPosition drive controller.
|
// SetPosition drive controller.
|
||||||
acceptedPositionDrive);
|
acceptedPositionDrive,
|
||||||
|
// Enter-click round (2026-08-17): resolved per call — the
|
||||||
|
// armed pre-reveal tunnel projects the Runtime
|
||||||
|
// character-selection lifecycle to disarm on a rejected
|
||||||
|
// EnterWorld (see the controller's field doc).
|
||||||
|
new RuntimeLoginLifecycleSource(d.Runtime));
|
||||||
|
|
||||||
LocalPlayerTeleportController CreateLocalTeleportWithTunnel(
|
LocalPlayerTeleportController CreateLocalTeleportWithTunnel(
|
||||||
PortalTunnelPresentation portalTunnel)
|
PortalTunnelPresentation portalTunnel)
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,13 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
SetChatIdentity: _domain.Communication.Chat.SetLocalPlayerGuid,
|
SetChatIdentity: _domain.Communication.Chat.SetLocalPlayerGuid,
|
||||||
MarkPersistent: _world.WorldState.MarkPersistent,
|
MarkPersistent: _world.WorldState.MarkPersistent,
|
||||||
SetVanishProbeIdentity: id => EntityVanishProbe.PlayerGuid = id,
|
SetVanishProbeIdentity: id => EntityVanishProbe.PlayerGuid = id,
|
||||||
ClearCombat: _domain.Actions.Combat.Clear),
|
ClearCombat: _domain.Actions.Combat.Clear,
|
||||||
|
// Enter-click round (2026-08-17): the login wormhole arms at
|
||||||
|
// the selected-character edge — before the EnterWorld wire
|
||||||
|
// send on every entry route — so the tunnel covers the whole
|
||||||
|
// server round-trip (registered deviation; retail shows
|
||||||
|
// black until CreatePlayer).
|
||||||
|
ArmLoginTunnel: _world.Teleport.ArmLoginTunnel),
|
||||||
EnteredWorld: new(
|
EnteredWorld: new(
|
||||||
SetActiveCharacter: _interaction.Settings.SetActiveCharacter,
|
SetActiveCharacter: _interaction.Settings.SetActiveCharacter,
|
||||||
RestoreLayout: () =>
|
RestoreLayout: () =>
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,14 @@ internal static class RenderPresentationDiagnostics
|
||||||
/// classification for the login wormhole edges. When set, every completed
|
/// classification for the login wormhole edges. When set, every completed
|
||||||
/// render frame is classified by WHAT PRESENTED — <c>world</c> /
|
/// render frame is classified by WHAT PRESENTED — <c>world</c> /
|
||||||
/// <c>tunnel</c> / <c>black</c> / <c>void</c> — and a <c>[login-frames]</c>
|
/// <c>tunnel</c> / <c>black</c> / <c>void</c> — and a <c>[login-frames]</c>
|
||||||
/// line is written on every classification transition. The gate contract
|
/// line is written on every classification transition. The gate contract:
|
||||||
/// is retail's: the sequence over a login must contain NO <c>void</c>
|
/// the sequence over a login must contain NO <c>void</c> entry on either
|
||||||
/// entry on either edge (black → tunnel → world, each swap atomic).
|
/// edge, and — since the enter-click round's click-armed tunnel
|
||||||
|
/// (registered deviation from retail's pre-CreatePlayer black) — no
|
||||||
|
/// <c>black</c> entry between the Enter click and the world either: the
|
||||||
|
/// sequence from the click is tunnel → world, each swap atomic.
|
||||||
|
/// (<c>black</c> remains legal BEFORE the click — it is the
|
||||||
|
/// character-select screen's own backdrop.)
|
||||||
/// Not a user setting; not in <c>RuntimeOptions</c>; not persisted.
|
/// Not a user setting; not in <c>RuntimeOptions</c>; not persisted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool ProbeLoginFrames { get; } =
|
public static bool ProbeLoginFrames { get; } =
|
||||||
|
|
|
||||||
|
|
@ -160,8 +160,18 @@ internal sealed class LocalPlayerTeleportRenderStateSource
|
||||||
/// backdrop had no retail counterpart and presented as the gate's
|
/// backdrop had no retail counterpart and presented as the gate's
|
||||||
/// entry-edge VOID (2026-08-17). Both flags flip on the update thread
|
/// entry-edge VOID (2026-08-17). Both flags flip on the update thread
|
||||||
/// (the login activation tick flips ChaseModeEverEntered AND makes the
|
/// (the login activation tick flips ChaseModeEverEntered AND makes the
|
||||||
/// tunnel visible before the next render), so the black → tunnel → world
|
/// tunnel visible before the next render), so the cover → tunnel → world
|
||||||
/// sequence swaps atomically per frame.
|
/// sequence swaps atomically per frame.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Enter-click round (2026-08-17): with the click-armed login tunnel
|
||||||
|
/// (registered user-directed deviation — see
|
||||||
|
/// <c>ILocalPlayerTeleportNetworkSink.ArmLoginTunnel</c>) the tunnel
|
||||||
|
/// scene becomes visible AT the Enter click, so retail's bare-black
|
||||||
|
/// CreatePlayer window normally never presents; the second arm remains
|
||||||
|
/// the char-select backdrop and the fallback for any unarmed pre-world
|
||||||
|
/// frame.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsPortalViewportVisible =>
|
public bool IsPortalViewportVisible =>
|
||||||
_teleport.IsPortalViewportVisible || _login.IsWaitingForLogin;
|
_teleport.IsPortalViewportVisible || _login.IsWaitingForLogin;
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,24 @@ internal interface ILocalPlayerTeleportNetworkSink
|
||||||
/// </summary>
|
/// </summary>
|
||||||
void OnLocalPlayerFirstEntryCompleted();
|
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();
|
||||||
|
|
||||||
void ResetSession();
|
void ResetSession();
|
||||||
|
|
||||||
void ResetGenerationPresentation();
|
void ResetGenerationPresentation();
|
||||||
|
|
@ -83,6 +101,8 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
|
||||||
public void OnLocalPlayerFirstEntryCompleted() =>
|
public void OnLocalPlayerFirstEntryCompleted() =>
|
||||||
Required().OnLocalPlayerFirstEntryCompleted();
|
Required().OnLocalPlayerFirstEntryCompleted();
|
||||||
|
|
||||||
|
public void ArmLoginTunnel() => Required().ArmLoginTunnel();
|
||||||
|
|
||||||
public void ResetSession() => Required().ResetSession();
|
public void ResetSession() => Required().ResetSession();
|
||||||
|
|
||||||
public void ResetGenerationPresentation() =>
|
public void ResetGenerationPresentation() =>
|
||||||
|
|
@ -141,6 +161,30 @@ internal interface ILocalPlayerTeleportAuthority
|
||||||
bool IsFreshStart(ushort sequence);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed class LiveLocalPlayerTeleportAuthority
|
internal sealed class LiveLocalPlayerTeleportAuthority
|
||||||
: ILocalPlayerTeleportAuthority
|
: ILocalPlayerTeleportAuthority
|
||||||
{
|
{
|
||||||
|
|
@ -499,6 +543,18 @@ internal sealed class LocalPlayerTeleportController
|
||||||
private long _loginRevealGeneration;
|
private long _loginRevealGeneration;
|
||||||
private bool _loginPresentationActive;
|
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>
|
/// <summary>
|
||||||
/// Latched by <see cref="OnLocalPlayerFirstEntryCompleted"/> — the
|
/// Latched by <see cref="OnLocalPlayerFirstEntryCompleted"/> — the
|
||||||
/// first-entry conductor's canonical initial placement committed. The
|
/// first-entry conductor's canonical initial placement committed. The
|
||||||
|
|
@ -511,6 +567,18 @@ internal sealed class LocalPlayerTeleportController
|
||||||
private bool _loginPlacementCompleted;
|
private bool _loginPlacementCompleted;
|
||||||
private float _loginHoldSeconds;
|
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;
|
||||||
|
|
||||||
public LocalPlayerTeleportController(
|
public LocalPlayerTeleportController(
|
||||||
ILocalPlayerTeleportAuthority authority,
|
ILocalPlayerTeleportAuthority authority,
|
||||||
ILocalPlayerTeleportInputLifetime input,
|
ILocalPlayerTeleportInputLifetime input,
|
||||||
|
|
@ -521,7 +589,8 @@ internal sealed class LocalPlayerTeleportController
|
||||||
ILocalPlayerTeleportPlacement placement,
|
ILocalPlayerTeleportPlacement placement,
|
||||||
ILocalPlayerTeleportSession session,
|
ILocalPlayerTeleportSession session,
|
||||||
ILocalPlayerTeleportPresentation presentation,
|
ILocalPlayerTeleportPresentation presentation,
|
||||||
RuntimeAcceptedPositionDriveController acceptedPositionDrive)
|
RuntimeAcceptedPositionDriveController acceptedPositionDrive,
|
||||||
|
ILocalPlayerLoginLifecycleSource loginLifecycle)
|
||||||
{
|
{
|
||||||
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
|
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
|
||||||
_input = input ?? throw new ArgumentNullException(nameof(input));
|
_input = input ?? throw new ArgumentNullException(nameof(input));
|
||||||
|
|
@ -534,6 +603,8 @@ internal sealed class LocalPlayerTeleportController
|
||||||
_presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
|
_presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
|
||||||
_acceptedPositionDrive = acceptedPositionDrive
|
_acceptedPositionDrive = acceptedPositionDrive
|
||||||
?? throw new ArgumentNullException(nameof(acceptedPositionDrive));
|
?? throw new ArgumentNullException(nameof(acceptedPositionDrive));
|
||||||
|
_loginLifecycle = loginLifecycle
|
||||||
|
?? throw new ArgumentNullException(nameof(loginLifecycle));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsActive => _transit.IsTeleportActive;
|
public bool IsActive => _transit.IsTeleportActive;
|
||||||
|
|
@ -610,6 +681,93 @@ internal sealed class LocalPlayerTeleportController
|
||||||
_loginPlacementCompleted = true;
|
_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;
|
||||||
|
}
|
||||||
|
|
||||||
public void Tick(float deltaSeconds)
|
public void Tick(float deltaSeconds)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
|
@ -1066,14 +1224,26 @@ internal sealed class LocalPlayerTeleportController
|
||||||
return;
|
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;
|
_loginRevealGeneration = snapshot.Generation;
|
||||||
_loginPresentationActive = true;
|
_loginPresentationActive = true;
|
||||||
_loginHoldSeconds = 0f;
|
if (!adoptedArmedTunnel)
|
||||||
_presentation.Begin(_mode.Projection);
|
{
|
||||||
|
_loginHoldSeconds = 0f;
|
||||||
|
_presentation.Begin(_mode.Projection);
|
||||||
|
}
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"live: login portal-space presentation started "
|
$"live: login portal-space presentation started "
|
||||||
+ $"(gen={snapshot.Generation} "
|
+ $"(gen={snapshot.Generation} "
|
||||||
+ $"cell=0x{snapshot.Readiness.DestinationCell:X8})");
|
+ $"cell=0x{snapshot.Readiness.DestinationCell:X8} "
|
||||||
|
+ $"adoptedArmedTunnel={(adoptedArmedTunnel ? 1 : 0)})");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -1108,9 +1278,20 @@ internal sealed class LocalPlayerTeleportController
|
||||||
// through TryActivateLoginPresentation above.
|
// through TryActivateLoginPresentation above.
|
||||||
_loginRevealGeneration = 0;
|
_loginRevealGeneration = 0;
|
||||||
_loginPresentationActive = false;
|
_loginPresentationActive = false;
|
||||||
|
_loginTunnelArmed = false;
|
||||||
_loginHoldSeconds = 0f;
|
_loginHoldSeconds = 0f;
|
||||||
_presentation.Reset();
|
_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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1216,6 +1397,42 @@ internal sealed class LocalPlayerTeleportController
|
||||||
_presentation.TickTunnel(deltaSeconds);
|
_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>
|
/// <summary>
|
||||||
/// The login pump's currency check — the login mirror of
|
/// The login pump's currency check — the login mirror of
|
||||||
/// <see cref="IsCurrentLifetime(long, ushort)"/>: same controller
|
/// <see cref="IsCurrentLifetime(long, ushort)"/>: same controller
|
||||||
|
|
@ -1368,6 +1585,7 @@ internal sealed class LocalPlayerTeleportController
|
||||||
// teleport-scoped reset and clears only with the session.
|
// teleport-scoped reset and clears only with the session.
|
||||||
_loginRevealGeneration = 0;
|
_loginRevealGeneration = 0;
|
||||||
_loginPresentationActive = false;
|
_loginPresentationActive = false;
|
||||||
|
_loginTunnelArmed = false;
|
||||||
_loginHoldSeconds = 0f;
|
_loginHoldSeconds = 0f;
|
||||||
if (clearSession)
|
if (clearSession)
|
||||||
_loginPlacementCompleted = false;
|
_loginPlacementCompleted = false;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,16 @@ public sealed record LiveSessionSelectionBindings(
|
||||||
Action<uint> SetChatIdentity,
|
Action<uint> SetChatIdentity,
|
||||||
Action<uint> MarkPersistent,
|
Action<uint> MarkPersistent,
|
||||||
Action<uint> SetVanishProbeIdentity,
|
Action<uint> SetVanishProbeIdentity,
|
||||||
Action ClearCombat);
|
Action ClearCombat,
|
||||||
|
/// <summary>Enter-click round (2026-08-17): arms the graphical host's
|
||||||
|
/// login-wormhole presentation at the selected-character edge — the one
|
||||||
|
/// host callback every entry route (direct connect, roster Enter,
|
||||||
|
/// enter-after-create) fires immediately BEFORE the EnterWorld wire
|
||||||
|
/// send, so the tunnel covers the whole server round-trip (registered
|
||||||
|
/// user-directed deviation from retail's pre-CreatePlayer black).
|
||||||
|
/// Default no-op preserves headless and existing construction sites.
|
||||||
|
/// </summary>
|
||||||
|
Action? ArmLoginTunnel = null);
|
||||||
|
|
||||||
public sealed record LiveSessionEnteredWorldBindings(
|
public sealed record LiveSessionEnteredWorldBindings(
|
||||||
Action<string> SetActiveCharacter,
|
Action<string> SetActiveCharacter,
|
||||||
|
|
@ -252,6 +261,9 @@ public sealed class LiveSessionHost
|
||||||
_selection.MarkPersistent(id);
|
_selection.MarkPersistent(id);
|
||||||
_selection.SetVanishProbeIdentity(id);
|
_selection.SetVanishProbeIdentity(id);
|
||||||
_selection.ClearCombat();
|
_selection.ClearCombat();
|
||||||
|
// Enter-click round (2026-08-17): LAST, after identity wiring — the
|
||||||
|
// armed tunnel's own logging can then already attribute the session.
|
||||||
|
_selection.ArmLoginTunnel?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
|
private void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
|
||||||
|
|
|
||||||
|
|
@ -1827,6 +1827,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
||||||
|
|
||||||
public void OnLocalPlayerFirstEntryCompleted() { }
|
public void OnLocalPlayerFirstEntryCompleted() { }
|
||||||
|
|
||||||
|
public void ArmLoginTunnel() { }
|
||||||
|
|
||||||
public void ResetSession() { }
|
public void ResetSession() { }
|
||||||
|
|
||||||
public void ResetGenerationPresentation() { }
|
public void ResetGenerationPresentation() { }
|
||||||
|
|
|
||||||
|
|
@ -1061,6 +1061,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
|
||||||
|
|
||||||
public void OnLocalPlayerFirstEntryCompleted() { }
|
public void OnLocalPlayerFirstEntryCompleted() { }
|
||||||
|
|
||||||
|
public void ArmLoginTunnel() { }
|
||||||
|
|
||||||
public void ResetSession() { }
|
public void ResetSession() { }
|
||||||
|
|
||||||
public void ResetGenerationPresentation() { }
|
public void ResetGenerationPresentation() { }
|
||||||
|
|
|
||||||
|
|
@ -920,6 +920,21 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool WorldReady;
|
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(
|
public Harness(
|
||||||
int centerX = 0x20,
|
int centerX = 0x20,
|
||||||
int centerY = 0x21,
|
int centerY = 0x21,
|
||||||
|
|
@ -1048,7 +1063,8 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
Placement,
|
Placement,
|
||||||
Session,
|
Session,
|
||||||
Presentation,
|
Presentation,
|
||||||
AcceptedPositionDrive);
|
AcceptedPositionDrive,
|
||||||
|
LoginLifecycle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -1458,6 +1474,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
{
|
{
|
||||||
public List<uint> Starts { get; } = [];
|
public List<uint> Starts { get; } = [];
|
||||||
public int FirstEntryCompletions;
|
public int FirstEntryCompletions;
|
||||||
|
public int LoginTunnelArms;
|
||||||
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
|
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
|
||||||
public void OfferDestination(
|
public void OfferDestination(
|
||||||
RuntimeTeleportDestination destination,
|
RuntimeTeleportDestination destination,
|
||||||
|
|
@ -1465,6 +1482,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++;
|
public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++;
|
||||||
|
public void ArmLoginTunnel() => LoginTunnelArms++;
|
||||||
public void ResetSession()
|
public void ResetSession()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
@ -1718,11 +1736,156 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
Assert.False(source.IsPortalViewportVisible);
|
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
|
private sealed class StubLoginState : IRenderLoginStateSource
|
||||||
{
|
{
|
||||||
public bool IsWaitingForLogin { get; set; }
|
public bool IsWaitingForLogin { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FakeLoginLifecycleSource
|
||||||
|
: ILocalPlayerLoginLifecycleSource
|
||||||
|
{
|
||||||
|
public RuntimeCharacterSelectionLifecycle SelectionLifecycle
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
} = RuntimeCharacterSelectionLifecycle.EnteringWorld;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
|
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
|
||||||
{
|
{
|
||||||
private readonly List<string> _order;
|
private readonly List<string> _order;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue