Merge campaign-hover-ui-round: tunnel-from-click, the in-world logoff, source-level escape normalization
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Tunnel arms at the Enter click (user-directed improvement over retail's
black CreatePlayer gap; AD-109). The full retail logoff: 0xF653 with the
3s hold (+20s PK), server-driven LogOut motion with input disabled,
the wormhole entered in reverse order (no exit cue), live return to
character select on the kept connection with the pushed roster —
second Enter round-trips (AD-110 filed, AD-74 RETIRED: Options'
Exit-to-Character-Selection is now real). Escape normalization moved to
retail's own placement — StringTableMetaLanguage::UnescapeString ported
at the string source, five consumer patches retired, the 4,365-string
escape population sweep-pinned (AD-111 records the wire-domain
appraisal exception).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 14:22:04 +02:00
commit 2ae1e3971c
35 changed files with 2441 additions and 162 deletions

File diff suppressed because one or more lines are too long

View file

@ -56,6 +56,11 @@ internal sealed record InteractionRetainedUiDependencies(
HostQuiescenceGate HostQuiescence,
RetainedUiInputCaptureSlot RetainedInputCapture,
InputDispatcher? InputDispatcher,
/// <summary>Logout round (2026-08-17): the construction-order teleport
/// bridge — the retained UI is composed before the teleport controller,
/// so the end-character-session binding reaches the wormhole owner
/// through this deferred sink.</summary>
AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink TeleportSink,
// Campaign OP slice OP8: the portable keybinds.json path
// (ApplicationPathSet.KeyBindingsFile) — the Configure Keyboard screen's
// Save button writes here, same file GameWindow's startup load reads.
@ -751,7 +756,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
() => late.Session.LinkStatus,
d.ClientTime,
() => late.Session.CurrentSession?.RequestLinkStatusPing(),
d.Window.Close),
// Logout round (2026-08-17): the in-world logoff flow
// (retail EndCharacterSession — animation + reverse
// wormhole + return to character select), forwarded
// through the construction-order teleport-sink bridge.
EndCharacterSession: d.TeleportSink.RequestLogout,
// Exit Game keeps the app-exit: window close runs the
// graceful-shutdown logoff in WorldSession.Dispose.
ExitGame: d.Window.Close),
Toolbar: new ToolbarRuntimeBindings(
d.Inventory.Objects,
d.Inventory.Shortcuts,

View file

@ -1020,7 +1020,21 @@ internal sealed class SessionPlayerCompositionPhase
presentation,
// C4 route 3: the portal arm shares route 2's Runtime
// 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),
// Logout round (2026-08-17): the logout arm's Runtime seams
// (wire begin, confirmation, return-to-selection
// transaction, PK hold fact, interpreter disable).
new RuntimeLocalPlayerLogoutOperations(
d.Runtime,
d.PlayerController,
liveSessionSource,
d.Inventory.Objects,
d.PlayerIdentity));
LocalPlayerTeleportController CreateLocalTeleportWithTunnel(
PortalTunnelPresentation portalTunnel)

View file

@ -47,6 +47,15 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
public MovementInput Capture()
{
// Logout round (2026-08-17): retail disables the command interpreter
// the moment the logoff request goes on the wire
// (CPlayerSystem::RequestLogOff @ 0x00562E6D ->
// CommandInterpreter::HandleLogOff @ 0x006B3330 -> Disable) — held
// keys stop producing movement while the server-broadcast LogOut
// motion plays.
if (_movement.CommandInterpreterDisabled)
return default;
// Devtools owns the whole gameplay keyboard while active, including
// a latched autorun. Retained chat owns physical key state only;
// retail's autorun latch continues until an explicit cancel action.

View file

@ -188,7 +188,13 @@ internal sealed class LiveSessionRuntimeFactory
SetChatIdentity: _domain.Communication.Chat.SetLocalPlayerGuid,
MarkPersistent: _world.WorldState.MarkPersistent,
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(
SetActiveCharacter: _interaction.Settings.SetActiveCharacter,
RestoreLayout: () =>

View file

@ -1397,6 +1397,7 @@ public sealed class GameWindow :
_hostQuiescence,
_retainedInputCapture,
hostInputCamera.InputDispatcher,
_localPlayerTeleportSink,
_applicationPaths.KeyBindingsFile,
_runtimeSettings,
_runtime,

View file

@ -15,9 +15,14 @@ internal static class RenderPresentationDiagnostics
/// classification for the login wormhole edges. When set, every completed
/// 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>
/// line is written on every classification transition. The gate contract
/// is retail's: the sequence over a login must contain NO <c>void</c>
/// entry on either edge (black → tunnel → world, each swap atomic).
/// line is written on every classification transition. The gate contract:
/// the sequence over a login must contain NO <c>void</c> entry on either
/// 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.
/// </summary>
public static bool ProbeLoginFrames { get; } =

View file

@ -160,8 +160,18 @@ internal sealed class LocalPlayerTeleportRenderStateSource
/// backdrop had no retail counterpart and presented as the gate's
/// entry-edge VOID (2026-08-17). Both flags flip on the update thread
/// (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.
///
/// <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>
public bool IsPortalViewportVisible =>
_teleport.IsPortalViewportVisible || _login.IsWaitingForLogin;

View file

@ -40,6 +40,36 @@ internal interface ILocalPlayerTeleportNetworkSink
/// </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 &amp; CONTACT</c>
/// branch @ 0x004EA445).
/// </summary>
void RequestLogout();
void ResetSession();
void ResetGenerationPresentation();
@ -83,6 +113,10 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
public void OnLocalPlayerFirstEntryCompleted() =>
Required().OnLocalPlayerFirstEntryCompleted();
public void ArmLoginTunnel() => Required().ArmLoginTunnel();
public void RequestLogout() => Required().RequestLogout();
public void ResetSession() => Required().ResetSession();
public void ResetGenerationPresentation() =>
@ -141,6 +175,129 @@ 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
{
@ -337,6 +494,21 @@ 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);
@ -370,6 +542,12 @@ internal sealed class LocalPlayerTeleportPresentation
_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)
{
@ -499,6 +677,18 @@ internal sealed class LocalPlayerTeleportController
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
@ -511,6 +701,27 @@ internal sealed class LocalPlayerTeleportController
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,
@ -521,7 +732,9 @@ internal sealed class LocalPlayerTeleportController
ILocalPlayerTeleportPlacement placement,
ILocalPlayerTeleportSession session,
ILocalPlayerTeleportPresentation presentation,
RuntimeAcceptedPositionDriveController acceptedPositionDrive)
RuntimeAcceptedPositionDriveController acceptedPositionDrive,
ILocalPlayerLoginLifecycleSource loginLifecycle,
ILocalPlayerLogoutOperations logout)
{
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
_input = input ?? throw new ArgumentNullException(nameof(input));
@ -534,6 +747,9 @@ internal sealed class LocalPlayerTeleportController
_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;
@ -571,6 +787,16 @@ internal sealed class LocalPlayerTeleportController
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))
@ -610,9 +836,286 @@ internal sealed class LocalPlayerTeleportController
_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)
@ -1066,14 +1569,26 @@ internal sealed class LocalPlayerTeleportController
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;
_loginHoldSeconds = 0f;
_presentation.Begin(_mode.Projection);
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})");
+ $"cell=0x{snapshot.Readiness.DestinationCell:X8} "
+ $"adoptedArmedTunnel={(adoptedArmedTunnel ? 1 : 0)})");
}
/// <summary>
@ -1108,9 +1623,20 @@ internal sealed class LocalPlayerTeleportController
// 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;
}
@ -1216,6 +1742,42 @@ internal sealed class LocalPlayerTeleportController
_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
@ -1368,6 +1930,7 @@ internal sealed class LocalPlayerTeleportController
// teleport-scoped reset and clears only with the session.
_loginRevealGeneration = 0;
_loginPresentationActive = false;
_loginTunnelArmed = false;
_loginHoldSeconds = 0f;
if (clearSession)
_loginPlacementCompleted = false;

View file

@ -19,17 +19,16 @@ namespace AcDream.App.UI.Layout;
/// <para>
/// The description pages used to bypass this entirely: they assigned a raw
/// <c>LinesProvider</c> lambda returning ONE unwrapped <see cref="AcDream.App.UI.UiText.Line"/>
/// per composed string, with no escape-normalize and no word-wrap. Two
/// concrete symptoms this caused: literal two-character <c>"\n"</c>
/// escapes rendered as backslash-n instead of a real line break (the DAT
/// stores that literal escape — <c>DatWidgetFactory.BuildText</c>'s own
/// authored-string path already normalizes it for single-element authored
/// captions; this helper reproduces the SAME normalize for
/// runtime-composed multi-segment text), and — for the Town page
/// specifically — an unwrapped single line meant the town-specific SUFFIX
/// of the composed string rendered far outside the box's clipped viewport,
/// so switching towns looked like "the text never changes" even though the
/// underlying string genuinely did (only its INVISIBLE tail differed).
/// per composed string, with no word-wrap. Historical symptom (Batch C):
/// for the Town page an unwrapped single line meant the town-specific
/// SUFFIX of the composed string rendered far outside the box's clipped
/// viewport, so switching towns looked like "the text never changes" even
/// though the underlying string genuinely did (only its INVISIBLE tail
/// differed). Escape decoding (the DAT's literal two-character <c>"\n"</c>,
/// Batch C's other symptom) has since moved to the string source
/// (<see cref="DatStringResolver"/> → <see cref="RetailStringEscapes"/>,
/// the 2026-08-17 systemic round) — segments reach this composer with real
/// line breaks already in place.
/// </para>
/// </summary>
internal static class DatRichText
@ -41,7 +40,7 @@ internal static class DatRichText
public readonly record struct Segment(string? Text, Vector4 Color);
/// <summary>
/// Escape-normalizes and word-wraps every segment (independently, so
/// Word-wraps every segment (independently, so
/// each segment's wrapped lines keep ITS OWN color), then concatenates
/// the results in order. No separator is inserted between segments —
/// retail's own composition calls concatenate directly
@ -76,15 +75,12 @@ internal static class DatRichText
if (string.IsNullOrEmpty(segment.Text))
continue;
// The installed DAT stores the LITERAL two-character escape
// "\n" (0x5C 0x6E), not a real line break — same normalize
// DatWidgetFactory.BuildText's authored-string path already
// applies for single-element authored captions.
string normalized = segment.Text
.Replace("\\n", "\n")
.Replace("\r", string.Empty);
foreach (string wrapped in UiText.WrapWords(normalized, measure, maximumWidth))
// Escape decoding (the DAT's literal two-character "\n") happens
// at the string source (DatStringResolver → RetailStringEscapes,
// 2026-08-17 systemic round — retail's own placement), so
// segments arrive with real line breaks; WrapWords preserves
// them and drops any stray CR itself.
foreach (string wrapped in UiText.WrapWords(segment.Text, measure, maximumWidth))
lines.Add(new UiText.Line(wrapped, segment.Color));
}

View file

@ -9,9 +9,23 @@ namespace AcDream.App.UI.Layout;
/// The caller owns synchronization around <see cref="DatCollection"/> reads.
/// </summary>
/// <remarks>
/// <para>
/// Retail reference: <c>StringInfo::GetString</c> and
/// <c>compute_str_hash @ 0x00413110</c>. A StringInfo's token selects one
/// localized string variant; ordinary UI labels use token zero.
/// </para>
/// <para>
/// Every resolution decodes the DAT's two-character escapes
/// (<c>\n</c>, <c>\t</c>, <c>\r</c>, <c>\q</c>, and the metalanguage
/// self-escapes) HERE, at the source — retail's own placement: every public
/// <c>StringInfo</c> resolution ends in
/// <c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c>
/// (<c>StringInfo::InqString @ 0x0042E490</c>,
/// <c>StringInfo::GetLiteralValue @ 0x0042CA50</c>). Consumers receive
/// already-decoded text and must not re-decode — see
/// <see cref="RetailStringEscapes"/>' remarks for the double-decode hazard
/// (the 2026-08-17 systemic round that retired the per-consumer copies).
/// </para>
/// </remarks>
public sealed class DatStringResolver
{
@ -41,7 +55,9 @@ public sealed class DatStringResolver
return null;
int index = token >= 0 && token < entry.Strings.Count ? token : 0;
return entry.Strings[index].Value;
// StringInfo::InqString @ 0x0042E490's unconditional tail: the stored
// string is escaped; the resolved string is decoded.
return RetailStringEscapes.Unescape(entry.Strings[index].Value);
}
/// <summary>Returns every literal token for one retail StringInfo entry.</summary>
@ -57,7 +73,9 @@ public sealed class DatStringResolver
return table is not null
&& table.Strings.TryGetValue(stringId, out var entry)
&& entry.Strings.Count != 0
? entry.Strings.Select(value => value.Value).ToArray()
? entry.Strings
.Select(value => RetailStringEscapes.Unescape(value.Value))
.ToArray()
: null;
}
@ -105,14 +123,21 @@ public sealed class DatStringResolver
{
composed.Append(entry.Strings[i].Value);
// Variables are stored as the pre-computed name hashes (the same
// compute_str_hash space PlayerVariable lives in).
// compute_str_hash space PlayerVariable lives in). Each value is
// escaped on insert — retail's AddVariable_String @ 0x0042E6C0
// stores every variable through SetLiteralValue(escape=1)
// @ 0x0042C980 → EscapeString — so the final whole-string
// unescape below returns variable content verbatim while
// decoding the authored fragments' escapes.
if (i < entry.Variables.Count
&& variables.TryGetValue(entry.Variables[i], out string? value))
{
composed.Append(value);
composed.Append(RetailStringEscapes.Escape(value));
}
}
return composed.ToString();
// StringInfo::InqString @ 0x0042E490's unconditional tail, same as
// Resolve above: composed text decodes its escapes at the source.
return RetailStringEscapes.Unescape(composed.ToString());
}
/// <summary>

View file

@ -856,9 +856,11 @@ public static class DatWidgetFactory
{
// 2026-08-13 social gate: authored strings can carry embedded
// newlines (the fellowship empty-state is three sentences over
// '\n's). Gate round 2: the DAT stores the LITERAL two-character
// escape "\n" (0x5C 0x6E — probe-verified: the dump printed
// backslash-n, not a line break), so normalize the escape first.
// '\n's). The DAT stores those as the LITERAL two-character
// escape "\n" (0x5C 0x6E — probe-verified), decoded at the
// string SOURCE since the 2026-08-17 systemic round
// (DatStringResolver → RetailStringEscapes; retail's own
// placement) — `authored` arrives with REAL line breaks here.
// Gate round 3: retail additionally WORD-WRAPS each authored line
// within the element extent (its GlyphList draw — the same wrap
// the confirmation dialog view already uses), so a multiline
@ -868,10 +870,7 @@ public static class DatWidgetFactory
// re-wrapping them is a client-wide behavior change no gate has
// asked for). Providers re-read DefaultColor/width/font per call
// (NOT captured eagerly) so state-driven changes keep tracking.
string normalized = authored
.Replace("\\n", "\n")
.Replace("\r", string.Empty);
if (normalized.Contains('\n'))
if (authored.Contains('\n'))
{
float cachedWidth = float.NaN;
UiDatFont? cachedFont = null;
@ -897,7 +896,7 @@ public static class DatWidgetFactory
? font.MeasureWidth
: static value => value.Length * 8f;
cachedLines = [.. UiText
.WrapWords(normalized, measure, maximumWidth)
.WrapWords(authored, measure, maximumWidth)
.Select(line => new UiText.Line(line, t.DefaultColor))];
}
return cachedLines;
@ -906,7 +905,7 @@ public static class DatWidgetFactory
else
{
t.LinesProvider = () =>
[new UiText.Line(normalized, t.DefaultColor)];
[new UiText.Line(authored, t.DefaultColor)];
}
}
@ -921,7 +920,7 @@ public static class DatWidgetFactory
|| !state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
|| stateCaption.Kind != UiPropertyKind.StringInfo)
continue;
if (NormalizeEscapes(stringResolve?.Invoke(stateCaption.StringInfoValue))
if (stringResolve?.Invoke(stateCaption.StringInfoValue)
is { Length: > 0 } text)
(stateStrings ??= new Dictionary<uint, string>())[stateId] = text;
}
@ -1220,6 +1219,20 @@ public static class DatWidgetFactory
.OrderBy(child => child.ReadOrder)
.ToArray();
/// <summary>
/// Resolves the effective authored caption (dat property <c>0x17</c>)
/// for a widget. Escape decoding is NOT done here: since the 2026-08-17
/// systemic round the string SOURCE (<see cref="DatStringResolver"/> →
/// <see cref="RetailStringEscapes"/>, retail's own placement — every
/// <c>StringInfo</c> resolution ends in
/// <c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c>) hands
/// every consumer already-decoded text. That supersedes R2-2 (Campaign
/// CC gate round 1 Batch E)'s consumer-level normalize, which covered
/// only the P0x17 resolutions in THIS file and missed sibling consumers
/// (the exit-world confirmation dialog, gate round 2) — the exact class
/// of bug source placement closes. Re-decoding here would corrupt an
/// authored <c>\\n</c> (escaped backslash then 'n') into a line break.
/// </summary>
private static string? ResolveAuthoredString(
ElementInfo info,
Func<UiStringInfoValue, string?>? stringResolve)
@ -1228,42 +1241,16 @@ public static class DatWidgetFactory
|| !info.TryGetEffectiveProperty(0x17u, out var property)
|| property.Kind != UiPropertyKind.StringInfo)
return null;
string? resolved = stringResolve(property.StringInfoValue);
// R2-2 (Campaign CC gate round 1 Batch E): the DAT stores the LITERAL
// two-character escape "\n" (0x5C 0x6E), not a real line break — same
// fact BuildText's own authored-string path already normalized for
// (see that call site's own comment). Centralizing the normalize
// HERE, at the single choke point every P0x17 caption resolution in
// this file goes through (BuildText, BuildButton's own caption AND
// its lifted-child caption, BuildButton's coexisting ValueLabel,
// BuildCheckbox), closes the exact class of bug R2-2 found: a caption
// like the Profession credits button's own "Attribute\n Credits"
// rendered the literal backslash-n because BuildButton never
// normalized while BuildText did. BuildText's own subsequent
// Replace("\\n","\n") is now a harmless no-op (idempotent) — left in
// place rather than removed, since it costs nothing and documents the
// same fact locally.
return NormalizeEscapes(resolved);
return stringResolve(property.StringInfoValue);
}
/// <summary>
/// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize
/// <see cref="ResolveAuthoredString"/> applies, pulled out so the
/// per-STATE authored-caption loop below (which resolves a state's own
/// <c>0x17</c> directly, bypassing the effective-property resolution
/// <see cref="ResolveAuthoredString"/> wraps) gets the SAME normalize
/// instead of a second, easily-forgotten copy.
/// </summary>
private static string? NormalizeEscapes(string? raw) =>
raw?.Replace("\\n", "\n").Replace("\r", string.Empty);
/// <summary>
/// #409 (client-wide retail tooltip system): resolves the already-
/// extracted <see cref="ElementInfo.TooltipText"/> (dat property
/// <c>0x49</c>) through <paramref name="stringResolve"/>, applying the
/// SAME escape normalization every other authored <c>StringInfo</c>
/// (captions, <c>0x17</c>) gets at this one choke point. Null when the
/// element authors no tooltip text or no resolver is available.
/// <c>0x49</c>) through <paramref name="stringResolve"/>. Arrives
/// escape-decoded from the string source, like every authored
/// <c>StringInfo</c> (see <see cref="ResolveAuthoredString"/>). Null
/// when the element authors no tooltip text or no resolver is available.
/// </summary>
internal static string? ResolveTooltipText(
ElementInfo info,
@ -1271,6 +1258,6 @@ public static class DatWidgetFactory
{
if (stringResolve is null || info.TooltipText is not { } tooltipText)
return null;
return NormalizeEscapes(stringResolve(tooltipText));
return stringResolve(tooltipText);
}
}

View file

@ -23,8 +23,13 @@ internal static class IndicatorDetailText
?? value.Length * 8f;
var lines = new List<UiText.Line>();
string normalized = text.Replace("\\n", "\n", StringComparison.Ordinal);
foreach (string paragraph in normalized.Split('\n'))
// DAT-resolved bodies (vitae, link status, effects) arrive with
// real line breaks — escapes decode at the string source
// (DatStringResolver → RetailStringEscapes, 2026-08-17 systemic
// round). Wire-sourced text (the appraisal inscription) renders
// verbatim, exactly like retail's ItemExamineUI::AddItemInfo
// @ 0x004AC050 → UIElement_Text::AppendTextWithFont direct append.
foreach (string paragraph in text.Split('\n'))
{
if (paragraph.Length == 0)
{

View file

@ -157,6 +157,14 @@ internal static class ItemAppraisalTextLayout
}
Vector4 color = ResolveColor(target, fragment.Style);
// WIRE-domain normalize — deliberately NOT the DAT source
// decode (DatStringResolver → RetailStringEscapes, 2026-08-17
// systemic round): appraisal fragments are server strings
// (long description, use text, inscription), which never pass
// the DAT string source, so this is not a duplicate path. It
// accommodates literal "\n" sequences in ACE's database
// strings; server strings with REAL line breaks flow through
// the Split below either way.
string normalized = fragment.Text.Replace(
"\\n",
"\n",

View file

@ -0,0 +1,140 @@
using System.Text;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Exact port of retail's string-table escape codec
/// (<c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c> /
/// <c>EscapeString @ 0x0067BBC0</c> and their character tables
/// <c>GetUnEscapedChar @ 0x0067B750</c> / <c>GetEscapedChar @ 0x0067B6C0</c>).
/// </summary>
/// <remarks>
/// <para>
/// PLACEMENT (the systemic 2026-08-17 normalization round): retail decodes
/// escapes at the string SOURCE, not per-widget. Every public
/// <c>StringInfo</c> resolution runs the unescape unconditionally before any
/// consumer sees the text — <c>StringInfo::InqString @ 0x0042E490</c> tail
/// and <c>StringInfo::GetLiteralValue @ 0x0042CA50</c> both end in
/// <c>UnescapeString</c>. The write side is the inverse:
/// <c>StringInfo::SetLiteralValue @ 0x0042C980</c> runs <c>EscapeString</c>
/// when storing plain text (and <c>StringInfo::AddVariable_String
/// @ 0x0042E6C0</c> always stores variables that way), so stored text is
/// escaped, resolved text is decoded, and variable content round-trips
/// verbatim. acdream's equivalent source is <see cref="DatStringResolver"/>;
/// widgets and controllers receive already-decoded strings and must not
/// re-decode (a second pass corrupts an authored <c>\\n</c> — escaped
/// backslash then 'n' — into a line break).
/// </para>
/// <para>
/// The escape set (byte-verified against the PDB-paired 2013 binary; the
/// metalanguage character-set literal at file offset 0x3FE178 is the ten
/// characters <c>[]!{}#\|^$</c>):
/// <c>\n</c> → LF (0x0A), <c>\t</c> → TAB (0x09), <c>\r</c> → CR (0x0D),
/// <c>\q</c> → '"' (0x22), and a backslash before any of the ten
/// metalanguage characters yields that character itself. A backslash before
/// anything else is NOT an escape — retail copies it through verbatim
/// (<c>GetUnEscapedChar</c> returns 0 and <c>UnescapeString</c>'s
/// else-branch keeps the current character).
/// </para>
/// </remarks>
public static class RetailStringEscapes
{
/// <summary>The ten metalanguage-significant characters that escape to
/// themselves. Byte-decoded from the retail binary (see class remarks) —
/// the same literal both character tables test with <c>wcschr</c>.</summary>
private const string MetaCharacters = "[]!{}#\\|^$";
/// <summary>
/// <c>StringTableMetaLanguage::GetUnEscapedChar @ 0x0067B750</c>: the
/// character an escape pair <c>\</c>+<paramref name="value"/> decodes
/// to, or <c>'\0'</c> when the pair is not an escape.
/// </summary>
internal static char GetUnEscapedChar(char value) => value switch
{
'n' => '\n',
'q' => '"',
'r' => '\r',
't' => '\t',
not '\0' when MetaCharacters.Contains(value) => value,
_ => '\0',
};
/// <summary>
/// <c>StringTableMetaLanguage::GetEscapedChar @ 0x0067B6C0</c>: the
/// character that follows the backslash when <paramref name="value"/>
/// must be stored escaped, or <c>'\0'</c> when it is stored verbatim.
/// </summary>
internal static char GetEscapedChar(char value) => value switch
{
'\t' => 't',
'\n' => 'n',
'\r' => 'r',
'"' => 'q',
not '\0' when MetaCharacters.Contains(value) => value,
_ => '\0',
};
/// <summary>
/// <c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c>: decodes
/// every two-character escape pair; all other characters (including a
/// backslash that does not start a recognized pair, and a trailing
/// backslash) copy through verbatim.
/// </summary>
public static string Unescape(string value)
{
ArgumentNullException.ThrowIfNull(value);
// Fast path: a string with no backslash cannot contain an escape.
int first = value.IndexOf('\\');
if (first < 0)
return value;
var result = new StringBuilder(value.Length);
for (int i = 0; i < value.Length; i++)
{
char current = value[i];
// Retail reads the character AFTER the candidate backslash (the
// terminator — never an escape — when at the end of the buffer).
char next = i + 1 < value.Length ? value[i + 1] : '\0';
char unescaped = GetUnEscapedChar(next);
if (current == '\\' && unescaped != '\0')
{
result.Append(unescaped);
i++; // consume the pair
}
else if (current != '\0')
{
result.Append(current);
}
}
return result.ToString();
}
/// <summary>
/// <c>StringTableMetaLanguage::EscapeString @ 0x0067BBC0</c>: the exact
/// inverse — every character with a <see cref="GetEscapedChar"/> mapping
/// is stored as <c>\</c> + that mapping; everything else verbatim.
/// <c>Unescape(Escape(x)) == x</c> for every <paramref name="value"/> —
/// the round-trip retail relies on for template variables.
/// </summary>
public static string Escape(string value)
{
ArgumentNullException.ThrowIfNull(value);
StringBuilder? result = null;
for (int i = 0; i < value.Length; i++)
{
char current = value[i];
char escaped = GetEscapedChar(current);
if (escaped != '\0')
{
result ??= new StringBuilder(value.Length + 4)
.Append(value, 0, i);
result.Append('\\').Append(escaped);
}
else if (current != '\0')
{
result?.Append(current);
}
}
return result?.ToString() ?? value;
}
}

View file

@ -117,7 +117,15 @@ public sealed record IndicatorRuntimeBindings(
Func<LinkStatusSnapshot> LinkStatus,
Func<double> CurrentTime,
Action RequestLinkStatusPing,
Action EndCharacterSession);
/// <summary>Logout round (2026-08-17): the IN-WORLD character logoff —
/// retail's end-character-session flow (log-off animation, reverse
/// wormhole, return to character select on the live connection). Was
/// the window-close action before the logout flow existed.</summary>
Action EndCharacterSession,
/// <summary>The app-exit action (window close → the graceful-shutdown
/// logoff in WorldSession.Dispose) — the Options panel's Exit Game
/// button, retail's m_shouldQuitOnLogout arm.</summary>
Action ExitGame);
public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects,
@ -2461,18 +2469,52 @@ public sealed class RetailUiRuntime : IDisposable
private void RequestEndCharacterSession()
{
ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted =>
// Logout round (2026-08-17): retail funnels BOTH end-session
// surfaces (the indicator bar's control and the Options panel's
// Exit to Character Selection) into gmGamePlayUI's ONE
// m_doEndSession drain, so the grounded three-way branch applies
// here too — the original OP3 port carried it only on the Options
// button.
ShowConfirmation(
ResolveEndCharacterSessionConfirmMessage(),
accepted =>
{
if (accepted)
EndCharacterSessionWithRetailGates();
});
}
/// <summary>
/// The shared confirmed-Yes drain — <c>gmGamePlayUI::UseTime
/// @ 0x004EA3A0</c>'s exact three-way branch (see
/// <see cref="RequestExitToCharacterSelection"/>'s doc for the
/// pseudocode): grounded → <c>CPlayerSystem::LogOffCharacter(0)</c>;
/// airborne → the mid-air refusal; no player → silent no-op.
/// </summary>
private void EndCharacterSessionWithRetailGates()
{
switch (_bindings.Options.IsGrounded())
{
if (accepted)
case true:
_bindings.Indicators.EndCharacterSession();
});
break;
case false:
_bindings.Options.DisplaySystemMessage(
ClientTextRefusals.CantLogOffMidAir);
break;
case null:
break;
}
}
/// <summary>
/// Campaign OP slice OP3: the Options panel's Exit to Character
/// Selection button (element <c>0x10000203</c>) — D6's "behaves as Exit
/// Game" adaptation (register row, same commit), PLUS retail's
/// confirmation dialog and mid-air refusal, which DO port exactly.
/// Selection button (element <c>0x10000203</c>), with retail's
/// confirmation dialog and mid-air refusal. Logout round (2026-08-17):
/// the D6 "behaves as Exit Game" adaptation (AD-76) is RETIRED — the
/// grounded confirmed exit now runs the real in-world logoff flow
/// (log-off animation, reverse wormhole, return to character select on
/// the live connection).
/// <c>gmGamePlayUI::UseTime @0x004EA3A0</c>'s drain, its EXACT three-way
/// branch (review-fix round, 2026-08-11 — the original port collapsed
/// this to a two-way `if/else` that fired the refusal outside player
@ -2490,26 +2532,18 @@ public sealed class RetailUiRuntime : IDisposable
/// </summary>
private void RequestExitToCharacterSelection()
{
ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted =>
{
if (!accepted) return;
switch (_bindings.Options.IsGrounded())
// Logout round (2026-08-17): the D6 "behaves as Exit Game"
// adaptation (AD-76, retired with this change) is gone — the
// confirmed, grounded exit now runs the REAL retail flow through
// EndCharacterSessionWithRetailGates: log-off animation, reverse
// wormhole, return to character select on the live connection.
ShowConfirmation(
ResolveEndCharacterSessionConfirmMessage(),
accepted =>
{
case true:
_bindings.Indicators.EndCharacterSession();
break;
case false:
_bindings.Options.DisplaySystemMessage(ClientTextRefusals.CantLogOffMidAir);
break;
case null:
// Retail's `else if (smartbox->player)` gate: outside
// player mode (or with no live controller) there is no
// player object for UseTime to test at all, so neither
// the airborne refusal nor the logoff itself ever runs.
break;
}
});
if (accepted)
EndCharacterSessionWithRetailGates();
});
}
/// <summary>
@ -2575,7 +2609,10 @@ public sealed class RetailUiRuntime : IDisposable
var callbacks = new Layout.OptionsPanelController.Callbacks(
Toggle: () => ToggleWindow(WindowNames.Options),
RequestExitToCharacterSelection: RequestExitToCharacterSelection,
ExitGame: _bindings.Indicators.EndCharacterSession,
// Logout round (2026-08-17): Exit Game keeps the app-exit
// (window close → graceful-shutdown logoff); the in-world
// return-to-charselect flow lives on EndCharacterSession.
ExitGame: _bindings.Indicators.ExitGame,
UseMouseTurningSettings: ApplyMouseTurningSettingsMacro,
DisplaySystemMessage: _bindings.Options.DisplaySystemMessage,
AfterApply: () => _bindings.Options.CommandBus().Publish(
@ -2973,10 +3010,10 @@ public sealed class RetailUiRuntime : IDisposable
});
if (text is null) return 0u; // no invented English
// The authored text stores its blank line as a literal
// "\n\n" two-character escape (live-probed) — same
// convention DatWidgetFactory/IndicatorDetailText already
// unescape for other DAT-authored strings.
text = text.Replace("\\n", "\n", StringComparison.Ordinal);
// "\n\n" two-character escape (live-probed), decoded at
// the string source (DatStringResolver →
// RetailStringEscapes, 2026-08-17 systemic round) —
// `text` arrives with real line breaks.
try
{
return DialogFactory.MakeWait(text, queueKey: 0x10000001u);
@ -4331,13 +4368,13 @@ public sealed class RetailUiRuntime : IDisposable
{
lock (_bindings.Assets.DatLock)
{
return NormalizeRetailNewlines(strings.ResolveTemplate(
stringTableId,
"ID_CharacterManagement_DeleteCharacterConfirmation",
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = characterName,
})!);
return strings.ResolveTemplate(
stringTableId,
"ID_CharacterManagement_DeleteCharacterConfirmation",
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = characterName,
})!;
}
}
@ -4353,16 +4390,16 @@ public sealed class RetailUiRuntime : IDisposable
confirmExit));
}
// Escape decoding (the DAT's literal two-character "\n" and friends)
// happens at the string source since the 2026-08-17 systemic round —
// DatStringResolver → RetailStringEscapes, retail's own placement — so
// this is a plain key-hash resolve. The former NormalizeRetailNewlines
// consumer copy is retired (double-decoding corrupts an authored "\\n").
private static string? ResolveCharacterManagementString(
DatStringResolver strings,
uint tableId,
string key) =>
strings.Resolve(tableId, DatStringResolver.ComputeHash(key)) is { } value
? NormalizeRetailNewlines(value)
: null;
private static string NormalizeRetailNewlines(string value) =>
value.Replace("\\n", "\n", StringComparison.Ordinal);
strings.Resolve(tableId, DatStringResolver.ComputeHash(key));
private void ConfigureCharacterCreation()
{

View file

@ -1139,6 +1139,77 @@ public sealed class WorldSession : IDisposable
EnsureNetReceiveLoopStarted();
}
/// <summary>
/// The server's opcode-only <c>0xF653</c> logoff confirmation has been
/// observed since the last <see cref="RequestCharacterLogOff"/>. Latched
/// by the inbound datagram path regardless of world-event dispatch, so
/// both the graceful-shutdown wait and the in-world logoff presentation
/// read the same fact.
/// </summary>
public bool IsCharacterLogOffConfirmed =>
Volatile.Read(ref _characterLogOffConfirmed) != 0;
/// <summary>
/// Logout round (2026-08-17): the IN-WORLD character-logoff request —
/// retail's <c>CPlayerSystem::RequestLogOff @ 0x00562DD0</c> →
/// <c>Proto_UI::LogOffCharacter @ 0x00546A20</c> (opcode <c>0xF653</c> +
/// active character id), sent the moment the exit confirmation is
/// accepted, ~3 s BEFORE the client's own wormhole presentation begins.
/// Non-blocking: the server's confirmation arrives through the ordinary
/// <see cref="Tick"/> pump and is observed via
/// <see cref="IsCharacterLogOffConfirmed"/> (retail's inbound dispatch
/// case for the echo runs <c>CPlayerSystem::ExecuteLogOff @ 0x0055D780</c>).
/// The graceful-shutdown path in <see cref="Dispose"/> is unchanged and
/// independent; after <see cref="ReturnToCharacterSelect"/> its
/// <c>BuildShutdownPlan</c> no longer requests a second logoff (state is
/// not <see cref="State.InWorld"/>).
/// </summary>
public void RequestCharacterLogOff()
{
if (CurrentState != State.InWorld || _activeCharacterId == 0)
{
throw new InvalidOperationException(
"character logoff requires an in-world session with an "
+ "active character");
}
Interlocked.Exchange(ref _characterLogOffConfirmed, 0);
SendGameMessage(CharacterLogOff.BuildRequestBody(_activeCharacterId));
}
/// <summary>
/// Logout round (2026-08-17): the world half of retail's
/// <c>CPlayerSystem::ExecuteLogOff @ 0x0055D780</c> →
/// <c>ClientNet::ExitWorldDisconnect @ 0x00541E00</c> — return this LIVE
/// session to character select WITHOUT touching the transport. Retail
/// keeps the logon connection (ExitWorldDisconnect removes every
/// connection EXCEPT <c>logonRecID_</c>; against ACE the logon and world
/// connection are the same one) and resets the outbound event counter
/// (<c>Proto_UI::SetEventCounter(0) @ 0x00541E79</c>); ACE mirrors it
/// server-side — <c>Session.SendFinalLogOffMessages</c> leaves the
/// session in <c>AuthConnected</c> and <c>InitSessionForWorldLogin</c>
/// resets <c>GameEventSequence</c> on the next world entry
/// (ACE Session.cs:249-278, CharacterHandler.cs:258). A second
/// <see cref="EnterWorld(int,TimeSpan?)"/> then runs the same
/// InCharacterSelect → EnteringWorld transition the enter-rejection
/// retry path already exercises.
/// </summary>
public void ReturnToCharacterSelect()
{
if (CurrentState is not (State.InWorld or State.EnteringWorld))
{
throw new InvalidOperationException(
"return-to-character-select requires an in-world session");
}
_activeCharacterId = 0;
// Proto_UI::SetEventCounter(0) @ 0x00541E79: the client's outbound
// game-action sequence restarts for the next world session.
_gameActionSequence = 0;
Interlocked.Exchange(ref _characterLogOffConfirmed, 0);
Transition(State.InCharacterSelect);
}
/// <summary>
/// Send CharacterEnterWorldRequest and CharacterEnterWorld for
/// <see cref="Characters"/>[<paramref name="characterIndex"/>].

View file

@ -95,6 +95,7 @@ public sealed class RuntimeLocalPlayerMovementState
private RuntimeLocalPlayerPhysicsPublicationState? _physicsPublication;
private bool _autoRunActive;
private bool _hasCommandInput;
private bool _commandInterpreterDisabled;
private MovementInput _commandInput;
private bool _disposed;
private long _revision;
@ -378,13 +379,39 @@ public sealed class RuntimeLocalPlayerMovementState
return _controller?.PrepareForAttackRequest() == true;
}
/// <summary>
/// Logout round (2026-08-17): retail's
/// <c>CommandInterpreter::HandleLogOff @ 0x006B3330</c> →
/// <c>Disable()</c>, fired from <c>CPlayerSystem::RequestLogOff
/// @ 0x00562E6D</c> the moment the logoff request goes on the wire.
/// While disabled the graphical input source produces NO movement
/// intent (the server-broadcast LogOut motion animates the player) and
/// the autorun latch is cancelled. Session-scoped: cleared by
/// <see cref="ResetInputIntent"/>/<see cref="ResetSession"/> (the
/// generation reset the return-to-character-select transaction runs).
/// </summary>
public bool CommandInterpreterDisabled => _commandInterpreterDisabled;
public void DisableCommandInterpreter()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_commandInterpreterDisabled)
return;
_commandInterpreterDisabled = true;
_autoRunActive = false;
_hasCommandInput = false;
_commandInput = default;
Interlocked.Increment(ref _revision);
}
public void ResetInputIntent()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_autoRunActive && !_hasCommandInput)
if (!_autoRunActive && !_hasCommandInput && !_commandInterpreterDisabled)
return;
_autoRunActive = false;
_hasCommandInput = false;
_commandInterpreterDisabled = false;
_commandInput = default;
Interlocked.Increment(ref _revision);
}
@ -402,10 +429,12 @@ public sealed class RuntimeLocalPlayerMovementState
bool changed =
_autoRunActive
|| _hasCommandInput
|| _commandInterpreterDisabled
|| _controller is not null
|| _preparingMotionOwner is not null;
_autoRunActive = false;
_hasCommandInput = false;
_commandInterpreterDisabled = false;
_commandInput = default;
if (_controller is not null)
{

View file

@ -235,6 +235,17 @@ public interface ILiveSessionOperations
session.SendCharacterCreation(accountName, request, skillAdvancementClasses);
void Tick(WorldSession session);
void DisposeSession(WorldSession session);
/// <summary>Logout round (2026-08-17): the in-world 0xF653 request —
/// <see cref="WorldSession.RequestCharacterLogOff"/>.</summary>
void RequestCharacterLogOff(WorldSession session) =>
session.RequestCharacterLogOff();
/// <summary>Logout round (2026-08-17): the live-connection return to
/// character select — <see cref="WorldSession.ReturnToCharacterSelect"/>.
/// </summary>
void ReturnToCharacterSelect(WorldSession session) =>
session.ReturnToCharacterSelect();
}
internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
@ -1155,6 +1166,198 @@ public sealed class LiveSessionController
}
}
/// <summary>
/// Logout round (2026-08-17): retail's
/// <c>CPlayerSystem::LogOffCharacter(force=0) @ 0x00563520</c> —
/// <c>CPlayerModule::SaveToServer</c> FIRST (the pre-logoff flush hook,
/// @ 0x00563528), then <c>RequestLogOff @ 0x00562DD0</c>'s 0xF653 wire
/// send. No teardown happens here: the ~3 s hold, the wormhole
/// presentation, and the confirmation ride
/// <c>RuntimeWorldTransitState</c>'s logout lifecycle App-side; the
/// world teardown is <see cref="CompleteCharacterLogOff"/>.
/// </summary>
public RuntimeCommandResult BeginCharacterLogOff(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeGenerationToken current = new(_generation);
if (expectedGeneration != current)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.StaleGeneration,
current);
}
if (_disposed
|| _disposeRequested
|| _scope is null
|| !_inWorld
|| _operationDepth != 0)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.Inactive,
current);
}
SessionScope scope = _scope;
InvokePreLogoffFlush(scope.Session);
try
{
_operations.RequestCharacterLogOff(scope.Session);
}
catch (Exception error)
{
Console.Error.WriteLine(
$"live: character-logoff request failed: {error.Message}");
return new RuntimeCommandResult(
RuntimeCommandStatus.Rejected,
current);
}
return new RuntimeCommandResult(
RuntimeCommandStatus.Accepted,
current);
}
}
/// <summary>
/// Logout round (2026-08-17): the return-to-character-select transaction
/// — retail's <c>ExecuteLogOff @ 0x0055D780</c> (world teardown, logon
/// connection kept, event counter reset via
/// <c>Proto_UI::SetEventCounter(0) @ 0x00541E79</c>) composed with the
/// character-select re-show its fresh CharacterList drives
/// (<c>gmGamePlayUI::Update @ 0x004E9CD0</c> →
/// <c>QueueUIMode(0x1000000a)</c>). Invoked by the graphical host AFTER
/// the logout presentation retired and the server's 0xF653 echo landed.
/// Structurally it is <c>ReconnectCore</c> minus the transport swap: the
/// retiring world generation's routes are disposed, the host resets that
/// generation, the SAME live <see cref="WorldSession"/> flips back to
/// character select, and a fresh generation re-binds routes and re-applies
/// the roster ACE pushed alongside the logoff echo
/// (<c>Session.SendFinalLogOffMessages</c> — 0xF653 + CharacterList +
/// ServerName; the session stays <c>AuthConnected</c>). Any failure
/// degrades to the full <c>StopCore</c> teardown rather than leaving a
/// half-reset session.
/// </summary>
public RuntimeCommandResult CompleteCharacterLogOff(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeGenerationToken current = new(_generation);
if (expectedGeneration != current)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.StaleGeneration,
current);
}
if (_disposed
|| _disposeRequested
|| _scope is null
|| _retiredScope is not null
|| !_inWorld)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.Inactive,
current);
}
if (_operationDepth != 0)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.Rejected,
current);
}
return RunTopLevel(CompleteCharacterLogOffCore);
}
}
private RuntimeCommandResult CompleteCharacterLogOffCore()
{
SessionScope scope = _scope!;
ILiveSessionLifecycleHost host = scope.Host;
WorldSession session = scope.Session;
RuntimeGenerationToken retiring = scope.Generation;
try
{
// 1. Retire the world generation's routes: outbound commands
// become inert before inbound subscriptions detach (the same
// ordering LiveSessionBinding's own teardown guarantees). The
// transport is deliberately untouched — retail keeps the
// logon connection at character select.
scope.Binding?.Dispose();
scope.CharacterSelectionBinding?.Dispose();
// 2. Detach, then reset the retiring world generation (the
// DrainTeardown stage 2→3 ordering, without stage 1's
// transport disposal).
if (scope.HostAttached)
{
host.DetachSession(session);
scope.HostAttached = false;
}
host.ResetSessionState(retiring);
// 3. Core.Net: InWorld → InCharacterSelect + retail's outbound
// event-counter reset.
_operations.ReturnToCharacterSelect(session);
// 4. Fresh generation for character select and the next world.
ulong generation = ++_generation;
var activeGeneration = new RuntimeGenerationToken(generation);
_inWorld = false;
_activeSelection = null;
CharacterSelectionState.Reset(activeGeneration);
CharacterCreationState.Reset(activeGeneration);
_createsSinceCharacterList = 0;
CharacterSelectionState.Begin(activeGeneration);
CharacterCreationState.Begin(activeGeneration);
// 5. New scope over the SAME session and host; commands stay
// inert until the next EnterWorld activates them, exactly
// like the awaiting-selection connect flow.
var newScope = new SessionScope(session, host, activeGeneration);
_scope = newScope;
LiveSessionBinding binding = host.BindSession(session);
newScope.Binding = binding;
newScope.HostAttached = true;
newScope.CharacterSelectionBinding = BindCharacterSelection(
newScope,
generation);
// 6. Roster + world name from the session's post-logoff caches
// (ACE pushed both in the SAME batch as the 0xF653 echo).
CharacterList.Parsed? characters =
_operations.GetCharacters(session);
if (characters is not null)
{
_createsSinceCharacterList = 0;
LiveSessionRosterReport roster = BuildRosterReport(characters);
CharacterSelectionState.ApplyRoster(roster);
host.ReportRoster(roster);
}
if (_operations.GetServerInfo(session) is { } serverInfo)
CharacterSelectionState.ApplyWorldName(serverInfo.WorldName);
Console.WriteLine(
"live: character logoff complete — returned to character "
+ "select (session connected)");
return new RuntimeCommandResult(
RuntimeCommandStatus.Accepted,
activeGeneration);
}
catch (Exception error)
{
Console.Error.WriteLine(
"live: return-to-character-select failed; stopping session: "
+ error.Message);
_ = StopAfterFailure(error);
return new RuntimeCommandResult(
RuntimeCommandStatus.Rejected,
new RuntimeGenerationToken(_generation));
}
}
private RuntimeCommandResult EnterSelectedCore() =>
EnterHighlightedCore(static (operations, session, character, _) =>
operations.EnterWorld(session, character.ActiveIndex));

View file

@ -14,7 +14,16 @@ public sealed record LiveSessionSelectionBindings(
Action<uint> SetChatIdentity,
Action<uint> MarkPersistent,
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(
Action<string> SetActiveCharacter,
@ -252,6 +261,9 @@ public sealed class LiveSessionHost
_selection.MarkPersistent(id);
_selection.SetVanishProbeIdentity(id);
_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)

View file

@ -11,7 +11,12 @@ public readonly record struct RuntimeWorldTransitOwnershipSnapshot(
int ActiveRevealCount,
int PendingDestinationReadinessCount,
int HostProjectionCount,
int PendingHostAcknowledgementCount)
int PendingHostAcknowledgementCount,
/// <summary>Logout round (2026-08-17): 1 while a character-logoff
/// lifecycle (request/hold/presentation/confirmation) is in flight.
/// Defaulted so pre-existing positional constructions read
/// identically.</summary>
int ActiveLogoutCount = 0)
{
public bool IsSessionIdle =>
BufferedTeleportDestinationCount == 0
@ -21,7 +26,45 @@ public readonly record struct RuntimeWorldTransitOwnershipSnapshot(
&& ActiveRevealCount == 0
&& PendingDestinationReadinessCount == 0
&& HostProjectionCount == 0
&& PendingHostAcknowledgementCount == 0;
&& PendingHostAcknowledgementCount == 0
&& ActiveLogoutCount == 0;
}
/// <summary>
/// Logout round (2026-08-17): the canonical stages of retail's ONE
/// character-logoff flow, owned Runtime-side exactly like the login/portal
/// reveal lifecycles (the App host projects presentation only).
/// </summary>
/// <remarks>
/// Retail:
/// <list type="bullet">
/// <item><description><see cref="Requested"/> —
/// <c>CPlayerSystem::RequestLogOff @ 0x00562DD0</c>: the 0xF653 request is
/// on the wire, <c>logOffRequested = 1</c>, <c>logOffRequestTime =
/// now + 3.0</c> (+20.0 more when <c>IsPlayerKiller()</c> — 0x00562E3E /
/// 0x00562E67), and the command interpreter is disabled
/// (<c>CommandInterpreter::HandleLogOff @ 0x006B3330</c> → Disable). The
/// server-broadcast LogOut motion plays during this hold.</description></item>
/// <item><description><see cref="PresentationActive"/> —
/// <c>gmSmartBoxUI::UseTime @ 0x004D6E7D</c>: the hold elapsed;
/// <c>BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @ 0x004D6E83</c> (which
/// plays the enter cue @ 0x004D638E), <c>SetTeleportInProgress(1)</c>,
/// <c>SetLogOffStarted</c>. The wormhole then runs WorldFadeOut →
/// TunnelFadeIn → Tunnel and HOLDS (the char-select swap preempts retail's
/// TunnelContinue/FadeOut tail, so no exit cue plays on logout).</description></item>
/// <item><description><see cref="Confirmed"/> — the server's opcode-only
/// 0xF653 echo (ACE <c>SendFinalLogOffMessages</c>, Session.cs:249):
/// retail's inbound dispatch case 3 (@ 0x0055C963) runs
/// <c>CPlayerSystem::ExecuteLogOff @ 0x0055D780</c>. The fresh
/// CharacterList in the same batch drives the character-select return.</description></item>
/// </list>
/// </remarks>
public enum RuntimeLogoutStage
{
None,
Requested,
PresentationActive,
Confirmed,
}
/// <summary>
@ -66,6 +109,22 @@ public sealed class RuntimeWorldTransitState
public static readonly TimeSpan RetailWaitCueDelay =
TimeSpan.FromSeconds(5);
/// <summary>
/// Retail's logoff presentation hold: <c>logOffRequestTime =
/// Timer::cur_time + 3.0</c> (<c>CPlayerSystem::RequestLogOff
/// @ 0x00562E3E</c>). The server-broadcast LogOut motion plays on the
/// player during this window; the wormhole begins when it elapses.
/// </summary>
public const double RetailLogoutHoldSeconds = 3.0;
/// <summary>
/// The additional player-killer hold: <c>+ 20.0</c> when the local
/// player's weenie reports <c>IsPlayerKiller()</c>
/// (<c>CPlayerSystem::RequestLogOff @ 0x00562E4E-0x00562E67</c>),
/// mirroring ACE's own server-side <c>pk_timer</c> logoff queue.
/// </summary>
public const double RetailPlayerKillerAdditionalHoldSeconds = 20.0;
private readonly Action<string> _log;
private readonly Dictionary<ushort, RuntimeTeleportDestination>
_bufferedDestinations = [];
@ -82,6 +141,9 @@ public sealed class RuntimeWorldTransitState
private bool _destinationAccepted;
private bool _hasAcceptedDestination;
private RuntimeTeleportDestination _acceptedDestination;
private RuntimeLogoutStage _logoutStage;
private double _logoutHoldElapsedSeconds;
private double _logoutHoldRequiredSeconds;
public RuntimeWorldTransitState(Action<string>? log = null)
{
@ -124,7 +186,124 @@ public sealed class RuntimeWorldTransitState
revealActive ? 1 : 0,
revealActive && !_snapshot.IsReady ? 1 : 0,
_hostProjections.Count,
pendingHostAcknowledgements);
pendingHostAcknowledgements,
_logoutStage != RuntimeLogoutStage.None ? 1 : 0);
}
// ── Logout lifecycle (2026-08-17) — see RuntimeLogoutStage's remarks
// for the retail derivation. ─────────────────────────────────────────
public RuntimeLogoutStage LogoutStage => _logoutStage;
public bool IsLogoutActive => _logoutStage != RuntimeLogoutStage.None;
/// <summary>
/// The Yes-click edge: latches retail's <c>logOffRequested</c> +
/// <c>logOffRequestTime</c> pair (<c>CPlayerSystem::RequestLogOff
/// @ 0x00562DD0</c>). Refuses while a teleport or logout lifecycle is
/// already in flight — the upstream grounded gate
/// (<c>gmGamePlayUI::UseTime @ 0x004EA445</c>) already refuses mid-air,
/// which covers portal transit for the player-driven path; this guard
/// keeps the invariant structural.
/// </summary>
public bool TryBeginLogoutRequest(bool isPlayerKiller)
{
if (_logoutStage != RuntimeLogoutStage.None
|| _teleportActive
|| _hasPendingTeleportStart)
{
LogRejected(
"logout-request-refused",
$"stage={_logoutStage} teleportActive={_teleportActive} "
+ $"pendingStart={_hasPendingTeleportStart}");
return false;
}
_logoutStage = RuntimeLogoutStage.Requested;
_logoutHoldElapsedSeconds = 0d;
_logoutHoldRequiredSeconds = RetailLogoutHoldSeconds
+ (isPlayerKiller ? RetailPlayerKillerAdditionalHoldSeconds : 0d);
SafeLog(
$"[world-reveal] event=logout-requested "
+ $"holdSeconds={_logoutHoldRequiredSeconds:F1} "
+ $"pk={(isPlayerKiller ? 1 : 0)}");
return true;
}
/// <summary>
/// Rolls back a request whose 0xF653 wire send refused — nothing is on
/// the wire, so no logout lifecycle may remain armed. Only legal from
/// <see cref="RuntimeLogoutStage.Requested"/>.
/// </summary>
public bool CancelLogoutRequest()
{
if (_logoutStage != RuntimeLogoutStage.Requested)
return false;
_logoutStage = RuntimeLogoutStage.None;
_logoutHoldElapsedSeconds = 0d;
_logoutHoldRequiredSeconds = 0d;
SafeLog("[world-reveal] event=logout-request-cancelled");
return true;
}
/// <summary>
/// Advances the request hold (retail's <c>logOffRequestTime</c> compare
/// at <c>gmSmartBoxUI::UseTime @ 0x004D6E6E</c>). Returns true exactly
/// once — on the tick the hold elapses — moving the lifecycle to
/// <see cref="RuntimeLogoutStage.PresentationActive"/>; the host begins
/// the wormhole (WorldFadeOut entry, enter cue) on that edge.
/// </summary>
public bool AdvanceLogoutHold(double deltaSeconds)
{
if (_logoutStage != RuntimeLogoutStage.Requested || deltaSeconds < 0d)
return false;
_logoutHoldElapsedSeconds += deltaSeconds;
if (_logoutHoldElapsedSeconds < _logoutHoldRequiredSeconds)
return false;
_logoutStage = RuntimeLogoutStage.PresentationActive;
SafeLog("[world-reveal] event=logout-presentation-begin");
return true;
}
/// <summary>
/// The server's opcode-only 0xF653 echo landed — retail's
/// <c>ExecuteLogOff</c> edge. Legal from either pre-confirmation stage:
/// ACE's confirmation timing (≥6 s after the request, and only once the
/// player left the landblock) normally lands mid-tunnel, but nothing
/// forbids it landing during the hold.
/// </summary>
public bool AcknowledgeLogoutConfirmed()
{
if (_logoutStage is not (
RuntimeLogoutStage.Requested
or RuntimeLogoutStage.PresentationActive))
{
return false;
}
_logoutStage = RuntimeLogoutStage.Confirmed;
SafeLog("[world-reveal] event=logout-confirmed");
return true;
}
/// <summary>
/// The character-select handoff: the host retired the presentation and
/// is about to run the return-to-selection session transaction. Clears
/// the lifecycle so the world generation reset inside that transaction
/// sees a converged transit owner.
/// </summary>
public bool CompleteLogout()
{
if (_logoutStage != RuntimeLogoutStage.Confirmed)
return false;
_logoutStage = RuntimeLogoutStage.None;
_logoutHoldElapsedSeconds = 0d;
_logoutHoldRequiredSeconds = 0d;
SafeLog("[world-reveal] event=logout-complete");
return true;
}
/// <summary>
@ -836,6 +1015,9 @@ public sealed class RuntimeWorldTransitState
_hasLastTeleportStart = false;
_lastTeleportStartSequence = 0;
_bufferedDestinations.Clear();
_logoutStage = RuntimeLogoutStage.None;
_logoutHoldElapsedSeconds = 0d;
_logoutHoldRequiredSeconds = 0d;
}
private bool TryGetHostRecord(

View file

@ -238,6 +238,8 @@ public sealed class InteractionRetainedUiCompositionTests
HostQuiescence: null!,
RetainedInputCapture: null!,
InputDispatcher: null,
TeleportSink:
new AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink(),
KeyBindingsFilePath: "keybinds.json",
Settings: null!,
Runtime: runtime,

View file

@ -1827,6 +1827,10 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
public void OnLocalPlayerFirstEntryCompleted() { }
public void ArmLoginTunnel() { }
public void RequestLogout() { }
public void ResetSession() { }
public void ResetGenerationPresentation() { }

View file

@ -1061,6 +1061,10 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
public void OnLocalPlayerFirstEntryCompleted() { }
public void ArmLoginTunnel() { }
public void RequestLogout() { }
public void ResetSession() { }
public void ResetGenerationPresentation() { }

View file

@ -920,6 +920,28 @@ public sealed class LocalPlayerTeleportControllerTests
/// </summary>
public bool WorldReady;
/// <summary>
/// Enter-click round (2026-08-17): the Runtime character-selection
/// lifecycle the click-armed tunnel projects, resolved per call.
/// Defaults to EnteringWorld (an enter transaction in flight — the
/// state at every real arm site); armed-tunnel tests regress it to
/// AwaitingSelection to drive the disarm.
/// </summary>
public RuntimeCharacterSelectionLifecycle SelectionLifecycle
{
get => LoginLifecycle.SelectionLifecycle;
set => LoginLifecycle.SelectionLifecycle = value;
}
public readonly FakeLoginLifecycleSource LoginLifecycle = new();
/// <summary>
/// Logout round (2026-08-17): the logout pump's Runtime seams —
/// mutable wire/confirmation/transaction outcomes so tests drive the
/// full request → hold → wormhole → confirmation → handoff flow.
/// </summary>
public readonly FakeLogoutOperations Logout = new();
public Harness(
int centerX = 0x20,
int centerY = 0x21,
@ -1048,7 +1070,9 @@ public sealed class LocalPlayerTeleportControllerTests
Placement,
Session,
Presentation,
AcceptedPositionDrive);
AcceptedPositionDrive,
LoginLifecycle,
Logout);
}
/// <summary>
@ -1458,6 +1482,7 @@ public sealed class LocalPlayerTeleportControllerTests
{
public List<uint> Starts { get; } = [];
public int FirstEntryCompletions;
public int LoginTunnelArms;
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
public void OfferDestination(
RuntimeTeleportDestination destination,
@ -1465,6 +1490,9 @@ public sealed class LocalPlayerTeleportControllerTests
{
}
public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++;
public void ArmLoginTunnel() => LoginTunnelArms++;
public int LogoutRequests;
public void RequestLogout() => LogoutRequests++;
public void ResetSession()
{
}
@ -1718,11 +1746,338 @@ public sealed class LocalPlayerTeleportControllerTests
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);
}
// ── Logout round (2026-08-17): the logout arm of the wormhole machine.
//
// Retail: CPlayerSystem::RequestLogOff @ 0x00562DD0 (chat + 0xF653 +
// 3 s hold (+20 PK) + interpreter Disable) →
// gmSmartBoxUI::UseTime @ 0x004D6E64 hold-elapsed →
// BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @ 0x004D6E83 (enter cue,
// @ 0x004D638E) → TunnelFadeIn → Tunnel hold → inbound 0xF653 echo →
// ExecuteLogOff @ 0x0055D780 + CharacterList-driven char-select swap.
// No exit cue on logout (the swap preempts the tail) — AD-110.
[Fact]
public void LogoutRequest_SendsWireThenHoldsThreeSeconds_ThenBeginsWormhole()
{
var order = new List<string>();
var harness = new Harness(worldReady: true, order: order);
Assert.True(harness.Controller.TryRequestLogout());
Assert.Equal(1, harness.Logout.BeginCalls);
Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage);
Assert.Equal(1, harness.Input.EndCount);
// The 3 s hold: no presentation, the server-broadcast LogOut motion
// is playing in-world.
harness.Controller.Tick(1.0f);
harness.Controller.Tick(1.0f);
Assert.DoesNotContain("presentation-begin-logout", order);
Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage);
// Hold elapses → the wormhole begins at WorldFadeOut with the enter
// cue (BeginTeleportAnimation plays it unconditionally).
harness.Presentation.Enqueue(TeleportAnimEvent.PlayEnterSound);
harness.Controller.Tick(1.05f);
Assert.Contains("presentation-begin-logout", order);
Assert.Equal(
RuntimeLogoutStage.PresentationActive,
harness.Transit.LogoutStage);
Assert.Equal(["enter"], harness.Presentation.Cues);
// The tunnel edge arrives on its own sequencer event.
harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel);
harness.Controller.Tick(0.016f);
Assert.True(harness.Presentation.IsPortalViewportVisible);
Assert.All(
harness.Presentation.WorldReadyValues,
value => Assert.False(value));
}
[Fact]
public void LogoutConfirmation_RunsTheHandoffOnceAndCompletesTheLifecycle()
{
var order = new List<string>();
var harness = new Harness(worldReady: true, order: order);
Assert.True(harness.Controller.TryRequestLogout());
harness.Presentation.Enqueue(
TeleportAnimEvent.PlayEnterSound,
TeleportAnimEvent.EnterTunnel);
harness.Controller.Tick(3.05f);
Assert.True(harness.Presentation.IsPortalViewportVisible);
// The server's opcode-only 0xF653 echo lands (ACE sends it >= 6 s
// after the request, tunnel always up by then): the next tick
// acknowledges and runs the handoff IMMEDIATELY — retail's
// ExecuteLogOff-on-echo (AD-110's composed edge).
harness.Logout.IsCharacterLogOffConfirmed = true;
harness.Logout.OnComplete = () =>
harness.Controller.ResetGenerationPresentation();
harness.Controller.Tick(0.016f);
Assert.Equal(1, harness.Logout.CompleteCalls);
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
// The transaction's world reset retired the presentation.
Assert.Contains("presentation-reset", order);
Assert.False(harness.Presentation.IsPortalViewportVisible);
// No exit cue on logout — the swap preempts the tail.
Assert.Equal(["enter"], harness.Presentation.Cues);
// The retired lifecycle stays quiet.
harness.Controller.Tick(0.016f);
Assert.Equal(1, harness.Logout.CompleteCalls);
}
[Fact]
public void LogoutConfirmationBeforeHoldEnd_SkipsTheWormholeEntirely()
{
// Retail's ExecuteLogOff clears logOffRequested — a confirmation
// landing before the hold elapses cancels the pending wormhole and
// the CharacterList swap happens directly (unreachable against
// ACE's >= 6 s floor, but the machine is total).
var order = new List<string>();
var harness = new Harness(worldReady: true, order: order);
Assert.True(harness.Controller.TryRequestLogout());
harness.Logout.IsCharacterLogOffConfirmed = true;
harness.Logout.OnComplete = () =>
harness.Controller.ResetGenerationPresentation();
harness.Controller.Tick(0.016f);
Assert.Equal(1, harness.Logout.CompleteCalls);
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
Assert.DoesNotContain("presentation-begin-logout", order);
Assert.Empty(harness.Presentation.Cues);
}
[Fact]
public void LogoutRequest_RefusedWireRollsTheLifecycleBack()
{
var harness = new Harness(worldReady: true);
harness.Logout.BeginResult = false;
Assert.False(harness.Controller.TryRequestLogout());
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
Assert.Equal(1, harness.Logout.BeginCalls);
}
[Fact]
public void LogoutRequest_UsesThePlayerKillerHold()
{
var harness = new Harness(worldReady: true);
harness.Logout.IsLocalPlayerKiller = true;
Assert.True(harness.Controller.TryRequestLogout());
// 3 s is NOT enough for a PK — retail adds +20 s
// (RequestLogOff @ 0x00562E67).
harness.Controller.Tick(3.5f);
Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage);
harness.Controller.Tick(19.6f);
Assert.Equal(
RuntimeLogoutStage.PresentationActive,
harness.Transit.LogoutStage);
}
[Fact]
public void TeleportStart_IsIgnoredWhileLogoutIsActive()
{
var harness = new Harness(worldReady: true);
Assert.True(harness.Controller.TryRequestLogout());
harness.Controller.OnTeleportStarted(7);
Assert.False(harness.Controller.IsActive);
Assert.False(harness.Transit.HasPendingTeleportStart);
Assert.Equal(RuntimeLogoutStage.Requested, harness.Transit.LogoutStage);
}
[Fact]
public void LogoutRequest_RefusedDuringTeleportOrLogin()
{
var harness = new Harness(worldReady: true);
harness.Controller.OnTeleportStarted(3);
Assert.False(harness.Controller.TryRequestLogout());
Assert.Equal(0, harness.Logout.BeginCalls);
var loginHarness = new Harness(worldReady: false);
loginHarness.Presentation.Enqueue(
TeleportAnimEvent.PlayEnterSound,
TeleportAnimEvent.EnterTunnel);
loginHarness.Controller.ArmLoginTunnel();
Assert.False(loginHarness.Controller.TryRequestLogout());
Assert.Equal(0, loginHarness.Logout.BeginCalls);
}
private sealed class StubLoginState : IRenderLoginStateSource
{
public bool IsWaitingForLogin { get; set; }
}
private sealed class FakeLoginLifecycleSource
: ILocalPlayerLoginLifecycleSource
{
public RuntimeCharacterSelectionLifecycle SelectionLifecycle
{
get;
set;
} = RuntimeCharacterSelectionLifecycle.EnteringWorld;
}
private sealed class FakeLogoutOperations : ILocalPlayerLogoutOperations
{
public bool IsLocalPlayerKiller { get; set; }
public bool BeginResult = true;
public int BeginCalls;
public bool IsCharacterLogOffConfirmed { get; set; }
public bool CompleteResult = true;
public int CompleteCalls;
public Action? OnComplete;
public bool BeginCharacterLogOff()
{
BeginCalls++;
return BeginResult;
}
public bool CompleteCharacterLogOff()
{
CompleteCalls++;
OnComplete?.Invoke();
return CompleteResult;
}
}
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
{
private readonly List<string> _order;
@ -1747,6 +2102,12 @@ public sealed class LocalPlayerTeleportControllerTests
_order.Add("presentation-begin");
}
public void BeginLogout(Matrix4x4 projection)
{
BeginProjection = projection;
_order.Add("presentation-begin-logout");
}
public (TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
Tick(float deltaSeconds, bool worldReady)
{

View file

@ -1904,18 +1904,21 @@ public sealed class CharacterCreationUiControllerTests
/// <summary>
/// GF-2: the composed description routes through the shared rich-text
/// helper — header segments (palette index 1) render in a DIFFERENT
/// color than body segments (index 0), and each segment's own escape
/// sequence is normalized. The fixture's description element carries
/// no authored <c>FontColorPalette</c>, so this also exercises
/// <see cref="DatRichText.PaletteColor"/>'s fallback (green header /
/// white body).
/// color than body segments (index 0), and an authored line break
/// splits into stacked lines. The harness resolver models
/// <c>DatStringResolver</c>'s post-decode output (the DAT's literal
/// "\n" escape decodes AT THE SOURCE since the 2026-08-17 systemic
/// round), so the fixture feeds a REAL '\n'. The fixture's description
/// element carries no authored <c>FontColorPalette</c>, so this also
/// exercises <see cref="DatRichText.PaletteColor"/>'s fallback (green
/// header / white body).
/// </summary>
[Fact]
public void HeritageDescription_ComposesGreenHeaderAndWhiteBodySegments()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills_Header"] = "Trained Starting Skills:";
environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills"] = "Line one\\nLine two";
environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills"] = "Line one\nLine two";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
BumpRevisionAndTick(environment);
@ -1924,7 +1927,7 @@ public sealed class CharacterCreationUiControllerTests
var lines = description.LinesProvider().ToList();
Assert.Contains(lines, l => l.Text == "Trained Starting Skills:" && l.Color == new Vector4(0f, 1f, 0f, 1f));
// The literal "\n" escape in the body segment must become TWO
// The source-decoded line break in the body segment must become TWO
// separate lines, not render as a literal backslash-n.
Assert.Contains(lines, l => l.Text == "Line one" && l.Color == Vector4.One);
Assert.Contains(lines, l => l.Text == "Line two" && l.Color == Vector4.One);

View file

@ -138,10 +138,12 @@ public sealed class CharacterManagementLiveDatTests
// Finding 1: MakeConfirmExitDialog@0x004ed250's text
// (compute_str_hash("ID_CharacterManagement_ConfirmExit"), table
// enum 0x10000002 -> 0x23000002). The raw DAT string carries a
// literal two-character "\n" escape (this test's Resolve() helper
// does not normalize it — RetailUiRuntime does, via
// NormalizeRetailNewlines, before handing it to the controller).
Assert.Equal("Are you sure you want to leave?\\n", Resolve(strings, table,
// literal two-character "\n" escape; since the 2026-08-17 systemic
// round DatStringResolver decodes it AT THE SOURCE (retail's own
// placement — StringInfo::InqString @ 0x0042E490's UnescapeString
// tail), so Resolve returns a REAL line break and no consumer
// normalizes again.
Assert.Equal("Are you sure you want to leave?\n", Resolve(strings, table,
"ID_CharacterManagement_ConfirmExit"));
string confirmation = Assert.IsType<string>(strings.ResolveTemplate(
table,

View file

@ -5,8 +5,10 @@ namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CC gate round 1 Batch C: unit tests for the shared
/// escape-normalize + word-wrap + per-segment-color helper feeding
/// GF-2/GF-3/GF-11a and the Summary how-to text (Commit 3).
/// word-wrap + per-segment-color helper feeding GF-2/GF-3/GF-11a and the
/// Summary how-to text (Commit 3). Escape decoding moved to the string
/// source in the 2026-08-17 systemic round (DatStringResolver →
/// RetailStringEscapes) — segments reach Compose with real line breaks.
/// </summary>
public class DatRichTextTests
{
@ -16,17 +18,24 @@ public class DatRichTextTests
private static UiText MakeTarget(float width) =>
new() { Width = width, Height = 200f };
/// <summary>Segments arrive source-decoded (real '\n'); Compose keeps
/// the authored break as a line split. A literal backslash-n pair in a
/// segment must stay VERBATIM — re-decoding here is the double-decode
/// hazard the 2026-08-17 round retired.</summary>
[Fact]
public void Compose_NormalizesLiteralBackslashNEscape()
public void Compose_SplitsOnRealNewlines_AndKeepsLiteralPairsVerbatim()
{
UiText target = MakeTarget(1000f); // wide enough that nothing wraps
var segments = new[] { new DatRichText.Segment("line one\\nline two", White) };
var segments = new[]
{
new DatRichText.Segment("line one\nliteral \\n stays", White),
};
var lines = DatRichText.Compose(target, segments);
Assert.Equal(2, lines.Count);
Assert.Equal("line one", lines[0].Text);
Assert.Equal("line two", lines[1].Text);
Assert.Equal("literal \\n stays", lines[1].Text);
}
[Fact]

View file

@ -0,0 +1,136 @@
using System.IO;
using System.Text;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter.Options;
using StringTable = DatReaderWriter.DBObjs.StringTable;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// 2026-08-17 systemic escape round: installed-DAT sweep of EVERY string
/// table for literal escape content, plus the source-normalization contract
/// (<see cref="DatStringResolver"/> resolutions must equal
/// <see cref="RetailStringEscapes.Unescape"/> of the raw stored text —
/// retail's <c>StringInfo::InqString @ 0x0042E490</c> placement). This is
/// the measurement companion to the per-consumer normalize retirement: it
/// proves the escape class genuinely exists in shipping data and prints
/// which tables carry it.
/// </summary>
public sealed class DatStringEscapeSweepTests
{
[InstalledDatFact]
public void EveryInstalledStringResolvesSourceDecoded()
{
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
var resolver = new DatStringResolver(dats);
int tables = 0;
int strings = 0;
int withNewlineEscape = 0;
int withTabEscape = 0;
int withCrEscape = 0;
int withQuoteEscape = 0;
int withMetaSelfEscape = 0;
int withUnknownPair = 0;
int withRealCr = 0;
var perTableNewlines = new SortedDictionary<uint, int>();
var examples = new List<string>();
string? userReportedExitText = null;
foreach (uint tableId in dats.GetAllIdsOfType<StringTable>().Order())
{
StringTable? table = dats.Get<StringTable>(tableId);
if (table is null)
continue;
tables++;
foreach ((uint stringId, var entry) in table.Strings)
{
for (int token = 0; token < entry.Strings.Count; token++)
{
string raw = entry.Strings[token].Value;
strings++;
bool newline = false, unknown = false, meta = false;
for (int i = 0; i < raw.Length - 1; i++)
{
if (raw[i] != '\\')
continue;
char next = raw[i + 1];
char decoded = RetailStringEscapes.GetUnEscapedChar(next);
switch (decoded)
{
case '\n': newline = true; break;
case '\t': withTabEscape++; break;
case '\r': withCrEscape++; break;
case '"': withQuoteEscape++; break;
case '\0': unknown = true; break;
default: meta = true; break;
}
i++; // the pair is consumed either way it decodes
}
if (newline)
{
withNewlineEscape++;
perTableNewlines[tableId] =
perTableNewlines.GetValueOrDefault(tableId) + 1;
if (examples.Count < 12)
examples.Add(
$"0x{tableId:X8}/0x{stringId:X8}: \"{Truncate(raw)}\"");
}
if (meta) withMetaSelfEscape++;
if (unknown) withUnknownPair++;
if (raw.Contains('\r')) withRealCr++;
// The source contract: what consumers receive from the
// resolver is EXACTLY the retail unescape of the stored
// text — nothing more (no consumer re-decode is owed),
// nothing less (no escape survives to render literally).
Assert.Equal(
RetailStringEscapes.Unescape(raw),
resolver.Resolve(tableId, stringId, token));
if (raw.Contains("exit your character", StringComparison.OrdinalIgnoreCase))
userReportedExitText =
$"0x{tableId:X8}/0x{stringId:X8}: \"{raw}\"";
}
}
}
var summary = new StringBuilder()
.AppendLine("[escape-sweep] installed-DAT string-table inventory:")
.AppendLine($" tables={tables} strings={strings}")
.AppendLine($" strings with literal \\n escape: {withNewlineEscape}")
.AppendLine($" \\t pairs: {withTabEscape}; \\r pairs: {withCrEscape}; \\q pairs: {withQuoteEscape}")
.AppendLine($" strings with metalanguage self-escapes: {withMetaSelfEscape}")
.AppendLine($" strings with unrecognized backslash pairs (kept verbatim): {withUnknownPair}")
.AppendLine($" strings containing a REAL CR character: {withRealCr}")
.AppendLine(" \\n-escape counts per table: "
+ string.Join(", ", perTableNewlines.Select(
static pair => $"0x{pair.Key:X8}={pair.Value}")))
.AppendLine(" examples:");
foreach (string example in examples)
summary.AppendLine($" {example}");
summary.AppendLine(userReportedExitText is null
? " user-reported exit-world text: NOT found by content scan"
: $" user-reported exit-world text: {userReportedExitText}");
Console.WriteLine(summary.ToString());
// The escape class must genuinely exist in shipping data — if this
// ever goes to zero the sweep (and the source decode) is measuring
// nothing and needs re-examination, not silent success.
Assert.True(
withNewlineEscape > 0,
"expected at least one installed string carrying the literal \\n escape");
}
private static string Truncate(string value) =>
(value.Length <= 90 ? value : value[..90] + "…")
.Replace("\r", "<CR>").Replace("\n", "<LF>");
}

View file

@ -85,6 +85,72 @@ public sealed class DatStringResolverTemplateTests
new Dictionary<uint, string>()));
}
/// <summary>
/// The 2026-08-17 systemic escape round: resolution decodes the DAT's
/// literal two-character escapes AT THE SOURCE — retail's own placement
/// (<c>StringInfo::InqString @ 0x0042E490</c>'s unconditional
/// <c>UnescapeString</c> tail). Consumers receive real line breaks; no
/// per-consumer normalize remains.
/// </summary>
[Fact]
public void ResolveDecodesEscapesAtTheSource()
{
var resolver = MakeResolver(
"ID_Confirm_Exit",
fragments: [
"This will exit your character from the game world.\\n\\nAre you sure?",
],
variables: []);
Assert.Equal(
"This will exit your character from the game world.\n\nAre you sure?",
resolver.Resolve(
TableId, DatStringResolver.ComputeHash("ID_Confirm_Exit")));
}
[Fact]
public void ResolveAllDecodesEveryVariant()
{
var resolver = MakeResolver(
"ID_Variants",
fragments: ["one\\nline", "two\\tcol"],
variables: []);
Assert.Equal(
["one\nline", "two\tcol"],
resolver.ResolveAll(
TableId, DatStringResolver.ComputeHash("ID_Variants")));
}
/// <summary>
/// Template composition decodes the authored fragments' escapes while
/// variable content round-trips VERBATIM — retail escapes each variable
/// on insert (<c>AddVariable_String @ 0x0042E6C0</c> →
/// <c>SetLiteralValue(escape=1) @ 0x0042C980</c>) and unescapes the
/// composed whole once, so a player name containing escape-significant
/// characters can never be corrupted by the final decode.
/// </summary>
[Fact]
public void ResolveTemplateDecodesFragmentsAndKeepsVariablesVerbatim()
{
var resolver = MakeResolver(
"ID_Delete_Confirmation",
fragments: ["Delete ", "?\\nType 'DELETE' to confirm."],
variables: [DatStringResolver.PlayerVariable]);
Assert.Equal(
"Delete Odd\\nName?\nType 'DELETE' to confirm.",
resolver.ResolveTemplate(
TableId,
"ID_Delete_Confirmation",
new Dictionary<uint, string>
{
// A pathological name carrying a REAL backslash before
// an 'n' — must come out verbatim, not as a line break.
[DatStringResolver.PlayerVariable] = "Odd\\nName",
}));
}
[Fact]
public void UnknownKeyResolvesNull()
{

View file

@ -1144,8 +1144,10 @@ public class DatWidgetFactoryTests
}
/// <summary>
/// 2026-08-13 social gate round 3: a MULTILINE authored string (literal
/// backslash-n escapes in the DAT) word-wraps each authored line to the
/// 2026-08-13 social gate round 3: a MULTILINE authored string (a real
/// '\n' — the DAT's literal backslash-n escape decodes at the string
/// source since the 2026-08-17 systemic round, so the resolver seam
/// hands this factory decoded text) word-wraps each authored line to the
/// widget's live width — retail's GlyphList draw, the same wrap the
/// confirmation dialog view uses. The fellowship empty-state was
/// rendering its three authored lines as three clipped runs.
@ -1165,7 +1167,7 @@ public class DatWidgetFactoryTests
// Width=100 fits 12 characters per wrapped line.
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: _ => "one two three four five\\nsix"));
stringResolve: _ => "one two three four five\nsix"));
var lines = text.LinesProvider!();
Assert.True(lines.Count >= 3);

View file

@ -0,0 +1,103 @@
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Conformance for <see cref="RetailStringEscapes"/> — the exact port of
/// retail's string-table escape codec
/// (<c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c> /
/// <c>EscapeString @ 0x0067BBC0</c>, character tables
/// <c>GetUnEscapedChar @ 0x0067B750</c> / <c>GetEscapedChar @ 0x0067B6C0</c>).
/// The metalanguage character set is byte-verified against the PDB-paired
/// 2013 binary (file offset 0x3FE178: <c>[]!{}#\|^$</c>).
/// </summary>
public sealed class RetailStringEscapesTests
{
[Theory]
[InlineData("line one\\nline two", "line one\nline two")]
[InlineData("a\\tb", "a\tb")]
[InlineData("a\\rb", "a\rb")]
[InlineData("say \\qhi\\q", "say \"hi\"")]
public void Unescape_DecodesTheFourCharacterEscapes(
string raw, string expected)
=> Assert.Equal(expected, RetailStringEscapes.Unescape(raw));
[Theory]
[InlineData("\\[", "[")]
[InlineData("\\]", "]")]
[InlineData("\\!", "!")]
[InlineData("\\{", "{")]
[InlineData("\\}", "}")]
[InlineData("\\#", "#")]
[InlineData("\\\\", "\\")]
[InlineData("\\|", "|")]
[InlineData("\\^", "^")]
[InlineData("\\$", "$")]
public void Unescape_DecodesEveryMetalanguageSelfEscape(
string raw, string expected)
=> Assert.Equal(expected, RetailStringEscapes.Unescape(raw));
/// <summary>
/// GetUnEscapedChar returns 0 for anything else — retail keeps the
/// backslash verbatim (UnescapeString's else-branch), including a
/// trailing backslash whose "next" character is the terminator.
/// </summary>
[Theory]
[InlineData("\\z", "\\z")]
[InlineData("C:\\path\\dir", "C:\\path\\dir")]
[InlineData("ends with \\", "ends with \\")]
[InlineData("\\N upper is not an escape", "\\N upper is not an escape")]
public void Unescape_KeepsUnrecognizedPairsVerbatim(
string raw, string expected)
=> Assert.Equal(expected, RetailStringEscapes.Unescape(raw));
/// <summary>
/// The double-decode hazard the 2026-08-17 systemic round exists to
/// close: an authored escaped backslash before an 'n' decodes ONCE to
/// the literal two characters backslash+n — a second decode pass (the
/// retired per-consumer copies) would corrupt it into a line break.
/// </summary>
[Fact]
public void Unescape_EscapedBackslashBeforeN_YieldsLiteralPair()
=> Assert.Equal("\\n", RetailStringEscapes.Unescape("\\\\n"));
[Fact]
public void Unescape_EmptyString_IsEmpty()
=> Assert.Equal(string.Empty, RetailStringEscapes.Unescape(string.Empty));
/// <summary>No backslash → no allocation: the same instance returns.</summary>
[Fact]
public void Unescape_NoEscapes_ReturnsTheSameInstance()
{
const string plain = "Please Wait";
Assert.Same(plain, RetailStringEscapes.Unescape(plain));
}
[Theory]
[InlineData("line one\nline two", "line one\\nline two")]
[InlineData("a\tb", "a\\tb")]
[InlineData("a\rb", "a\\rb")]
[InlineData("say \"hi\"", "say \\qhi\\q")]
[InlineData("[x]", "\\[x\\]")]
[InlineData("back\\slash", "back\\\\slash")]
[InlineData("plain", "plain")]
public void Escape_IsTheStorageInverse(string plain, string expected)
=> Assert.Equal(expected, RetailStringEscapes.Escape(plain));
/// <summary>
/// Retail's template-variable round trip
/// (<c>AddVariable_String @ 0x0042E6C0</c> escapes on insert;
/// <c>InqString @ 0x0042E490</c> unescapes the composed whole): variable
/// content must come out verbatim.
/// </summary>
[Theory]
[InlineData("plain name")]
[InlineData("Odd\\Name")]
[InlineData("multi\nline")]
[InlineData("tabs\tand \"quotes\"")]
[InlineData("[]!{}#\\|^$")]
[InlineData("")]
public void UnescapeOfEscape_RoundTripsVerbatim(string value)
=> Assert.Equal(value, RetailStringEscapes.Unescape(
RetailStringEscapes.Escape(value)));
}

View file

@ -553,16 +553,20 @@ public class UiButtonTests
Assert.True(confinedWidth < button.Width, "the confined width must be narrower than the full button");
}
// ── R2-2 escape-normalize ────────────────────────────────────────────
// ── R2-2 authored caption (source-decoded) ───────────────────────────
/// <summary>
/// R2-2: BuildButton's own P0x17 caption escape-normalizes the same way
/// BuildText's authored-string path always has — the DAT stores the
/// LITERAL two-character escape "\n" (0x5C 0x6E), and the Profession
/// credits button's own authored caption is exactly this shape.
/// R2-2's successor contract (2026-08-17 systemic round): the DAT's
/// LITERAL two-character escape "\n" (0x5C 0x6E — the Profession
/// credits button's own authored caption is exactly this shape) decodes
/// at the string SOURCE (DatStringResolver → RetailStringEscapes,
/// retail's own placement), so the resolver seam hands BuildButton a
/// caption with a REAL line break — and the factory passes it through
/// verbatim, with no second decode that would corrupt an authored
/// backslash pair.
/// </summary>
[Fact]
public void BuildButton_OwnCaption_NormalizesLiteralBackslashNEscape()
public void BuildButton_OwnCaption_PassesSourceDecodedTextThrough()
{
uint stringId = 444u;
var info = new ElementInfo { Type = 1, Width = 150, Height = 50 };
@ -575,11 +579,14 @@ public class UiButtonTests
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
// The raw resolved string carries the LITERAL two characters
// '\' and 'n', matching what the installed DAT actually stores.
stringResolve: value => value.StringId == stringId ? "Attribute\\n Credits" : null));
// The resolver seam models DatStringResolver's post-decode
// output: a REAL '\n', plus a literal backslash pair that a
// stray second decode would corrupt into a line break.
stringResolve: value => value.StringId == stringId
? "Attribute\n Credits \\not-an-escape"
: null));
Assert.Equal("Attribute\n Credits", button.Label);
Assert.Equal("Attribute\n Credits \\not-an-escape", button.Label);
}
private static UiButton ButtonWithStates(params string[] states)

View file

@ -169,6 +169,30 @@ public sealed class LiveSessionControllerTests
}
DisposeCounts[session] = DisposeCounts.GetValueOrDefault(session) + 1;
}
// Logout round (2026-08-17): the in-world logoff pair — recorded
// here so the return-to-selection transaction is testable without a
// live in-world WorldSession state machine.
public int RequestCharacterLogOffCount { get; private set; }
public int ReturnToCharacterSelectCount { get; private set; }
public bool ThrowOnRequestCharacterLogOff { get; set; }
public bool ThrowOnReturnToCharacterSelect { get; set; }
public void RequestCharacterLogOff(WorldSession session)
{
calls.Add("request-character-logoff");
RequestCharacterLogOffCount++;
if (ThrowOnRequestCharacterLogOff)
throw new InvalidOperationException("logoff request failure");
}
public void ReturnToCharacterSelect(WorldSession session)
{
calls.Add("return-to-character-select");
ReturnToCharacterSelectCount++;
if (ThrowOnReturnToCharacterSelect)
throw new InvalidOperationException("return failure");
}
}
private sealed class TestHost(List<string> calls) : ILiveSessionLifecycleHost
@ -1619,6 +1643,144 @@ public sealed class LiveSessionControllerTests
Assert.Single(operations.DisposeCounts);
}
// ── Logout round (2026-08-17): the in-world logoff transaction pair —
// retail CPlayerSystem::LogOffCharacter(0) @ 0x00563520 (flush-first
// 0xF653 request) and ExecuteLogOff @ 0x0055D780 composed with the
// CharacterList-driven character-select re-show (AD-110). ─────────────
[Fact]
public void BeginCharacterLogOff_FlushesFirstThenSendsTheRequest()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.ConfigurePreLogoffFlush(_ => calls.Add("flush"));
Assert.Equal(
LiveSessionStartStatus.Connected,
controller.Start(LiveOptions(), host).Status);
RuntimeCommandResult result =
controller.BeginCharacterLogOff(controller.Generation);
Assert.True(result.Accepted);
Assert.Equal(1, operations.RequestCharacterLogOffCount);
// SaveToServer BEFORE the wire request — LogOffCharacter @ 0x00563528.
Assert.True(
calls.IndexOf("flush") < calls.IndexOf("request-character-logoff"));
// No teardown of any kind at request time (the single reset on
// record is Start's own initial host reset).
Assert.True(controller.IsInWorld);
Assert.Equal(1, host.ResetCount);
}
[Fact]
public void BeginCharacterLogOff_RefusesOutsideTheWorld()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Assert.Equal(
RuntimeCommandStatus.Inactive,
controller.BeginCharacterLogOff(controller.Generation).Status);
Assert.Equal(0, operations.RequestCharacterLogOffCount);
Assert.Equal(
LiveSessionStartStatus.Connected,
controller.Start(LiveOptions(), host).Status);
Assert.Equal(
RuntimeCommandStatus.StaleGeneration,
controller.BeginCharacterLogOff(default).Status);
}
[Fact]
public void CompleteCharacterLogOff_ReturnsToSelectionOnTheLiveSession()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Assert.Equal(
LiveSessionStartStatus.Connected,
controller.Start(LiveOptions(), host).Status);
WorldSession session = operations.Sessions[0];
RuntimeGenerationToken worldGeneration = controller.Generation;
calls.Clear();
RuntimeCommandResult result =
controller.CompleteCharacterLogOff(worldGeneration);
Assert.True(result.Accepted);
// The retiring world generation's routes died first, then the host
// reset THAT generation, then the same live session flipped back —
// no transport disposal anywhere.
Assert.Equal(
[
"deactivate", "detach-events", "detach-session", "reset",
"return-to-character-select", "bind", "roster",
],
calls);
// ResetGenerations[0] is Start's own initial host reset; the
// transaction's reset targets exactly the retiring world generation.
Assert.Equal(2, host.ResetCount);
Assert.Equal(worldGeneration, host.ResetGenerations[^1]);
Assert.Empty(operations.DisposeCounts);
Assert.False(controller.IsInWorld);
Assert.Same(session, controller.CurrentSession);
Assert.NotEqual(worldGeneration, controller.Generation);
Assert.Equal(result.Generation, controller.Generation);
// The fresh generation owns an AwaitingSelection roster re-applied
// from the session cache ACE's post-logoff CharacterList filled.
RuntimeCharacterSelectionSnapshot snapshot =
controller.CharacterSelectionState.View.Snapshot;
Assert.Equal(
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
snapshot.Lifecycle);
Assert.Equal(controller.Generation, snapshot.Generation);
Assert.Equal(2, host.Rosters.Count);
// The round trip: a second Enter works on the SAME session.
RuntimeCommandResult enter = controller.Enter(controller.Generation);
Assert.True(enter.Accepted);
Assert.True(controller.IsInWorld);
Assert.Equal(2, operations.EnterWorldCount);
Assert.True(host.CommandBuses[^1].Active);
}
[Fact]
public void CompleteCharacterLogOff_RefusalsAndFailureDegradeToStop()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Assert.Equal(
RuntimeCommandStatus.Inactive,
controller.CompleteCharacterLogOff(controller.Generation).Status);
Assert.Equal(
LiveSessionStartStatus.Connected,
controller.Start(LiveOptions(), host).Status);
Assert.Equal(
RuntimeCommandStatus.StaleGeneration,
controller.CompleteCharacterLogOff(default).Status);
// A mid-transaction failure must not leave a half-reset session:
// the transaction degrades to the full StopCore teardown.
operations.ThrowOnReturnToCharacterSelect = true;
WorldSession session = operations.Sessions[0];
RuntimeCommandResult result =
controller.CompleteCharacterLogOff(controller.Generation);
Assert.Equal(RuntimeCommandStatus.Rejected, result.Status);
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[session]);
}
private static LiveSessionConnectOptions LiveOptions(
bool live = true,
string? user = "user",

View file

@ -1050,6 +1050,104 @@ public sealed class RuntimeWorldTransitStateTests
state.ResetSession();
}
// ── Logout round (2026-08-17): the character-logoff lifecycle —
// retail CPlayerSystem::RequestLogOff @ 0x00562DD0 (3 s hold, +20 PK),
// gmSmartBoxUI::UseTime @ 0x004D6E64 (hold-elapsed presentation begin),
// ExecuteLogOff @ 0x0055D780 (the 0xF653 echo), and the char-select
// handoff. ────────────────────────────────────────────────────────────
[Fact]
public void LogoutLifecycle_RequestHoldPresentationConfirmComplete()
{
var state = new RuntimeWorldTransitState();
Assert.Equal(RuntimeLogoutStage.None, state.LogoutStage);
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false));
Assert.Equal(RuntimeLogoutStage.Requested, state.LogoutStage);
Assert.Equal(1, state.CaptureOwnership().ActiveLogoutCount);
Assert.False(state.CaptureOwnership().IsSessionIdle);
// A second request while one is in flight refuses.
Assert.False(state.TryBeginLogoutRequest(isPlayerKiller: false));
// The retail 3.0 s hold: not elapsed at 2.9, elapsed at 3.0+.
Assert.False(state.AdvanceLogoutHold(2.9d));
Assert.Equal(RuntimeLogoutStage.Requested, state.LogoutStage);
Assert.True(state.AdvanceLogoutHold(0.2d));
Assert.Equal(
RuntimeLogoutStage.PresentationActive,
state.LogoutStage);
// The begin edge fires exactly once.
Assert.False(state.AdvanceLogoutHold(1.0d));
Assert.True(state.AcknowledgeLogoutConfirmed());
Assert.Equal(RuntimeLogoutStage.Confirmed, state.LogoutStage);
Assert.False(state.AcknowledgeLogoutConfirmed());
Assert.True(state.CompleteLogout());
Assert.Equal(RuntimeLogoutStage.None, state.LogoutStage);
Assert.False(state.CompleteLogout());
Assert.True(state.CaptureOwnership().IsSessionIdle);
}
[Fact]
public void LogoutHold_PlayerKillerAddsTwentySeconds()
{
var state = new RuntimeWorldTransitState();
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: true));
// 3 s is not enough for a PK (RequestLogOff @ 0x00562E67: +20.0).
Assert.False(state.AdvanceLogoutHold(3.5d));
Assert.False(state.AdvanceLogoutHold(19.0d));
Assert.True(state.AdvanceLogoutHold(0.6d));
Assert.Equal(
RuntimeLogoutStage.PresentationActive,
state.LogoutStage);
}
[Fact]
public void LogoutConfirmation_LegalFromTheRequestHold()
{
// ACE's >= 6 s confirmation floor makes this unreachable live, but
// the machine is total: a confirmation during the hold cancels the
// pending wormhole (retail ExecuteLogOff clears logOffRequested).
var state = new RuntimeWorldTransitState();
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false));
Assert.True(state.AcknowledgeLogoutConfirmed());
Assert.Equal(RuntimeLogoutStage.Confirmed, state.LogoutStage);
// The hold no longer advances a confirmed lifecycle.
Assert.False(state.AdvanceLogoutHold(10.0d));
Assert.True(state.CompleteLogout());
}
[Fact]
public void LogoutRequest_RefusedDuringTeleportAndCancelRollsBack()
{
var state = new RuntimeWorldTransitState();
_ = BeginPortal(state, OutdoorCell, sequence: 3);
Assert.False(state.TryBeginLogoutRequest(isPlayerKiller: false));
var idle = new RuntimeWorldTransitState();
Assert.True(idle.TryBeginLogoutRequest(isPlayerKiller: false));
Assert.True(idle.CancelLogoutRequest());
Assert.Equal(RuntimeLogoutStage.None, idle.LogoutStage);
Assert.True(idle.CaptureOwnership().IsSessionIdle);
// Cancel is only legal from Requested.
Assert.False(idle.CancelLogoutRequest());
}
[Fact]
public void LogoutLifecycle_ClearsOnSessionReset()
{
var state = new RuntimeWorldTransitState();
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false));
state.ResetSession();
Assert.Equal(RuntimeLogoutStage.None, state.LogoutStage);
Assert.True(state.CaptureOwnership().IsSessionIdle);
// A fresh session can log out again.
Assert.True(state.TryBeginLogoutRequest(isPlayerKiller: false));
}
private static long BeginPortal(
RuntimeWorldTransitState state,
uint cell,