feat(world): the login wormhole — every world entry runs retail's portal-space presentation with sound (TS-28 narrowed)

Retail runs the SAME TAS_TUNNEL wormhole at initial login as at an F751
teleport, with no F751 involved: SmartBox::teleport_in_progress
@0x00451C20 returns 1 the moment the login player exists with
position_update_complete == 0, gmSmartBoxUI::UseTime @0x004D6EAB
edge-detects it into BeginTeleportAnimation(TAS_TUNNEL) @0x004D6EC9
(playing Sound_UI_EnterPortal @0x004D638E), SmartBox::UseTime
@0x00455483 ends the hold once destination cells stop blocking,
Sound_UI_ExitPortal plays at the viewport swap @0x004D7405, and
LoginComplete goes out at the WorldFadeIn end @0x004D745D ->
CPlayerSystem::SendLoginCompleteNotification @0x00562E90 (ACE's own
GameActionLoginComplete comment names this contract: 'called when the
client player exits portal space. It includes initial login'). acdream
skipped all of it at login — every entry route (direct auto-select,
character-select Enter, enter-after-create) dropped onto the sky-only
'waiting for login' backdrop until the world reveal completed.

The fix engages the EXISTING F751 presentation machinery on Runtime's
login reveal — no duplicated presentation code, no timers:

- LocalPlayerTeleportController gains a login arm keyed off the
  Runtime-owned login reveal generation (RuntimeWorldTransitState
  .BeginLoginReveal, begun on the first accepted local-player position
  on every entry route). It drives the same TeleportAnimSequencer/
  PortalTunnelPresentation lifecycle and the same enter/exit cues; the
  Place edge is a no-op at login (the first-entry conductor already
  committed the canonical placement — retail's analogue only flips
  position_update_complete), and FireLoginComplete now performs
  EnterWorld + the single LoginComplete send + reveal Complete, exactly
  like the F751 pump. worldReady is latched on BOTH canonical first
  placement (OnLocalPlayerFirstEntryCompleted, the repointed
  GraphicalSessionEventRoute completion callback that used to send
  LoginComplete immediately) AND destination reveal readiness.
  ActiveDestinationCell now also reports the login destination so the
  render frame's reveal-preparation arm keeps running after portal-space
  entry flips ChaseModeEverEntered.
- PlayerModeController.TryEnterPortalSpaceForLogin performs the
  player-mode presentation attach (the same BuildControllerAndCamera the
  post-reveal auto-entry used to run) before flipping into portal space
  — at login no player-mode entry has happened yet. TryEnterPortalSpace
  itself now refuses (retryable) on a constructed-but-unpublished
  Runtime controller via the documented CanExecuteLiveMovement skip
  predicate instead of faulting — the first connected run crashed on
  exactly that pre-publication State write.
- HouseQuery stays at first-entry completion (retail: tail-called from
  CPlayerSystem::InitializePlayer @0x00563570, an object-arrival edge,
  not a tunnel edge).
- An F751 arriving mid-login-tunnel withdraws the login claim and hands
  the presentation to the portal pump, which owns the single
  LoginComplete — matching retail's one teleportInProgress flag.

TS-28 narrowed: the graphical host now runs the full login wormhole;
the residual is headless-only (no presentation; placement-edge send).

Live gates (testaccount2/+Horan vs local ACE, Release): the
character-select Enter route and the --session-config direct auto-select
route both play the wormhole with Sound_UI_EnterPortal at animation
begin, hold with retail's 'In Portal Space - Please Wait...' notice
until readiness, fade out with the view-plane warp, send LoginComplete
at the WorldFadeIn end, and materialize in Holtburg; ACE-confirmed
graceful logout. Tests: App 5493/3 skips (baseline 5490 + 3 new login
tests), Runtime 1744/0, full solution green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 10:51:33 +02:00
parent 302d90209d
commit 5ca1d47d7a
7 changed files with 533 additions and 16 deletions

View file

@ -141,13 +141,39 @@ internal sealed class PlayerModeController :
if (Controller is null && !TryEnter("teleport"))
return false;
if (Controller is not { } controller)
// Enter-world round (2026-08-17): the Runtime first-entry conductor
// can have CONSTRUCTED the controller without publishing it yet
// (CandidatePreparing/Sealed/Dormant). A State write in that window
// throws (EnsurePublishedForRuntimeOperation); CanExecuteLiveMovement
// is the documented skip-instead-of-fault predicate for exactly this
// pre-publication login window. Refuse — every caller retries on its
// own tick cadence.
if (Controller is not { CanExecuteLiveMovement: true } controller)
return false;
controller.State = PlayerState.PortalSpace;
return true;
}
/// <summary>
/// Enter-world round (2026-08-17): the login tunnel's portal-space entry.
/// The login wormhole begins before any player-mode entry has happened
/// (retail's camera set simply follows its always-present player), so
/// this first performs the SAME presentation attach the post-reveal
/// auto-entry used to run (<see cref="TryEnter"/> — chase camera, shadow,
/// animation sinks, approach lifetime), then flips into portal space.
/// <see cref="TryEnter"/> itself refuses until the Runtime first-entry
/// conductor commits, so pre-publication calls fail closed and retry.
/// </summary>
public bool TryEnterPortalSpaceForLogin()
{
_autoEntry?.Cancel();
if (!_mode.IsPlayerMode && !TryEnter("login"))
return false;
return TryEnterPortalSpace();
}
public void EnterWorld()
{
if (Controller is { } controller)

View file

@ -337,16 +337,25 @@ internal sealed class LiveSessionRuntimeFactory
_world.FirstEntryDrive,
_ =>
{
session.SendGameAction(GameActionLoginComplete.Build());
// Enter-world round (2026-08-17): the graphical host no
// longer sends LoginComplete here. Retail sends 0xA1 at the
// END of the login wormhole (gmSmartBoxUI::UseTime
// @0x004D745D -> CPlayerSystem::SendLoginCompleteNotification
// @0x00562E90), and the login portal-space presentation now
// runs on this host, so the send rides its
// FireLoginComplete edge (LocalPlayerTeleportController's
// login pump) — the same edge the F751 pump already uses.
// This notification is the presentation's worldReady latch:
// the canonical local-player first placement committed
// (retail's position_update_complete=1 analogue,
// SmartBox::UseTime @0x00455483).
_world.Teleport.OnLocalPlayerFirstEntryCompleted();
// Night-round review F2: CM_House::Event_QueryHouse @0x006aaa00
// is tail-called, unconditionally, from the end of
// CPlayerSystem::InitializePlayer @0x00563570 — the SAME
// once-per-session function AttemptSendLoginCompleteNotification
// lives in (guarded by player_initialized), right after that
// notification. This is the graphical host's direct
// (non-portal) first-entry completion edge — the exact
// analogue. Portal-space re-entries (LocalPlayerTeleportController)
// do NOT resend it, matching retail's single-shot guard.
// CPlayerSystem::InitializePlayer @0x00563570 (guarded by
// player_initialized, once per session) — an object-arrival
// edge, NOT a tunnel edge, so it stays at first-entry
// completion rather than moving with LoginComplete.
session.SendHouseQuery();
},
_world.AcceptedPositionDrive,

View file

@ -25,6 +25,21 @@ internal interface ILocalPlayerTeleportNetworkSink
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();
void ResetSession();
void ResetGenerationPresentation();
@ -65,6 +80,9 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
bool teleportTimestampAdvanced) =>
Required().OfferDestination(destination, teleportTimestampAdvanced);
public void OnLocalPlayerFirstEntryCompleted() =>
Required().OnLocalPlayerFirstEntryCompleted();
public void ResetSession() => Required().ResetSession();
public void ResetGenerationPresentation() =>
@ -102,6 +120,19 @@ 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();
}
@ -449,6 +480,37 @@ internal sealed class LocalPlayerTeleportController
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>
/// 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;
public LocalPlayerTeleportController(
ILocalPlayerTeleportAuthority authority,
ILocalPlayerTeleportInputLifetime input,
@ -476,8 +538,35 @@ internal sealed class LocalPlayerTeleportController
public bool IsActive => _transit.IsTeleportActive;
public bool IsPortalViewportVisible => _presentation.IsPortalViewportVisible;
public uint ActiveDestinationCell =>
_transit.IsTeleportActive ? _pendingCell : 0u;
/// <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)
{
@ -515,13 +604,22 @@ internal sealed class LocalPlayerTeleportController
TryAimAcceptedDestination();
}
public void OnLocalPlayerFirstEntryCompleted()
{
ThrowIfDisposed();
_loginPlacementCompleted = true;
}
public void Tick(float deltaSeconds)
{
ThrowIfDisposed();
TryActivatePendingPresentation();
TryAimAcceptedDestination();
if (!_transit.IsTeleportActive)
{
TickLoginPresentation(deltaSeconds);
return;
}
long generation = _lifetimeGeneration;
ushort sequence = _transit.ActiveTeleportSequence;
@ -902,6 +1000,225 @@ internal sealed class LocalPlayerTeleportController
+ $"(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;
}
_loginRevealGeneration = snapshot.Generation;
_loginPresentationActive = true;
_loginHoldSeconds = 0f;
_presentation.Begin(_mode.Projection);
Console.WriteLine(
$"live: login portal-space presentation started "
+ $"(gen={snapshot.Generation} "
+ $"cell=0x{snapshot.Readiness.DestinationCell:X8})");
}
/// <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;
_loginHoldSeconds = 0f;
_presentation.Reset();
}
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 Place edge: 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).
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>
/// 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(
@ -1030,6 +1347,18 @@ internal sealed class LocalPlayerTeleportController
_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;
_loginHoldSeconds = 0f;
if (clearSession)
_loginPlacementCompleted = false;
_streaming.ResetRecenter(clearSession);
if (_lifetimeGeneration != generation)