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
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:
commit
2ae1e3971c
35 changed files with 2441 additions and 162 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
|
|
@ -1397,6 +1397,7 @@ public sealed class GameWindow :
|
|||
_hostQuiescence,
|
||||
_retainedInputCapture,
|
||||
hostInputCamera.InputDispatcher,
|
||||
_localPlayerTeleportSink,
|
||||
_applicationPaths.KeyBindingsFile,
|
||||
_runtimeSettings,
|
||||
_runtime,
|
||||
|
|
|
|||
|
|
@ -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; } =
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 & 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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
140
src/AcDream.App/UI/Layout/RetailStringEscapes.cs
Normal file
140
src/AcDream.App/UI/Layout/RetailStringEscapes.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"/>].
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1827,6 +1827,10 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
|
||||
public void OnLocalPlayerFirstEntryCompleted() { }
|
||||
|
||||
public void ArmLoginTunnel() { }
|
||||
|
||||
public void RequestLogout() { }
|
||||
|
||||
public void ResetSession() { }
|
||||
|
||||
public void ResetGenerationPresentation() { }
|
||||
|
|
|
|||
|
|
@ -1061,6 +1061,10 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
|
|||
|
||||
public void OnLocalPlayerFirstEntryCompleted() { }
|
||||
|
||||
public void ArmLoginTunnel() { }
|
||||
|
||||
public void RequestLogout() { }
|
||||
|
||||
public void ResetSession() { }
|
||||
|
||||
public void ResetGenerationPresentation() { }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
136
tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs
Normal file
136
tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs
Normal 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>");
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
103
tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs
Normal file
103
tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs
Normal 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)));
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue