Merge campaign-enter-portal into the round branch: login portal-space presentation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 11:21:46 +02:00
commit 2f8c046aba
9 changed files with 568 additions and 17 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)

View file

@ -580,6 +580,13 @@ internal sealed class CharacterManagementUiController : IDisposable
// remains authoritative and closes it on InWorld/error/reset.
EnsureEnterWait();
RuntimeCommandResult result = _bindings.Enter();
// Enter-world round (2026-08-17): once-per-click outcome line — a
// refused Enter was previously indistinguishable from a click that
// never dispatched (both silent), which cost a full connected-gate
// round to tell apart.
Console.WriteLine(result.Accepted
? "[UI] character enter accepted"
: $"[UI] character enter rejected status={result.Status}");
if (!result.Accepted)
CloseContext(ref _enterWaitContext, suppressCallback: true);
InvalidateAndTick();

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
using System.Text;
using AcDream.Core.Items;
@ -163,7 +164,9 @@ public sealed class RetailUiAutomationProbe
{
var target = FindByDatElementId(datElementId);
if (target is null) return Fail($"element 0x{datElementId:X8} not found");
_root.OnMouseMove((int)target.CenterX, (int)target.CenterY);
// Same canvas→window conversion as ClickAt — see CanvasToWindow.
(int x, int y) = CanvasToWindow((int)target.CenterX, (int)target.CenterY);
_root.OnMouseMove(x, y);
return true;
}
@ -298,8 +301,30 @@ public sealed class RetailUiAutomationProbe
}
}
/// <summary>
/// Enter-world round (2026-08-17): element coordinates from the probe's
/// tree walk are CANVAS coordinates, but <see cref="UiRoot.OnMouseMove"/>/
/// <c>OnMouseDown</c>/<c>OnMouseUp</c> take WINDOW coordinates and map
/// window→canvas internally (<c>UiRoot.MapWindowToCanvas</c>). The two
/// spaces are identical on every ordinary screen (no
/// <see cref="UiRoot.FixedCanvasSize"/>), which is why every prior probe
/// gate passed — the character-select/chargen screens are the first
/// STRETCHED canvases this apparatus drove, and there the mismatch sent
/// every synthetic click to canvas·(canvas/window), i.e. nowhere near the
/// target element. Element-derived pointer paths therefore convert
/// canvas→window here before touching the root.
/// </summary>
private (int x, int y) CanvasToWindow(int x, int y)
{
Vector2 scale = _root.CanvasScale;
return scale == Vector2.One
? (x, y)
: ((int)MathF.Round(x * scale.X), (int)MathF.Round(y * scale.Y));
}
private void ClickAt(int x, int y)
{
(x, y) = CanvasToWindow(x, y);
Advance(16);
_root.OnMouseMove(x, y);
_root.OnMouseDown(UiMouseButton.Left, x, y);
@ -310,6 +335,8 @@ public sealed class RetailUiAutomationProbe
private void DragAt(int startX, int startY, int endX, int endY)
{
(startX, startY) = CanvasToWindow(startX, startY);
(endX, endY) = CanvasToWindow(endX, endY);
Advance(16);
_root.OnMouseMove(startX, startY);
_root.OnMouseDown(UiMouseButton.Left, startX, startY);