diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
index 3a298710..727c340e 100644
--- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
+++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
@@ -1023,30 +1023,44 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
Keyboard: new KeyboardRuntimeBindings(
d.InputDispatcher,
d.KeyBindingsFilePath),
- CharacterSelection: d.Options.LiveCharacterSelector is null
- ? new CharacterSelectionRuntimeBindings(
- () => late.GameRuntime.CharacterSelection,
- late.GameRuntime.CharacterSelectionHighlight,
- late.GameRuntime.CharacterSelectionEnter,
- late.GameRuntime.CharacterSelectionRequestDelete,
- late.GameRuntime.CharacterSelectionConfirmDelete,
- late.GameRuntime.CharacterSelectionRestore,
- late.GameRuntime.CharacterSelectionCancel,
- // Campaign LA gate round 2 finding 1: the SAME
- // window-close path GameplayInputCommandController's
- // Escape fallback uses (IGameplayWindowCommands.Close
- // /GameplayWindowCommands wrap this same d.Window.Close
- // delegate) — no separate exit path.
- d.Window.Close)
- : null,
+ // LU10: built UNCONDITIONALLY, including when the launcher
+ // supplied a character selector. The selector only decides how
+ // this session STARTS — straight into the world instead of
+ // pausing at the select screen. It must not decide whether the
+ // select screen EXISTS, because the player can come back to it:
+ // the toolbar's X (IndicatorBarController's
+ // EndCharacterSessionButtonId 0x100000FA) runs retail's
+ // EndCharacterSession, and LiveSessionController's logout
+ // transaction ends by resetting the world generation and
+ // calling CharacterSelectionState.Begin — i.e. it hands control
+ // to exactly these bindings. Gating them on the selector meant a
+ // launcher-started session logged out into a client with
+ // nowhere to land.
+ CharacterSelection: new CharacterSelectionRuntimeBindings(
+ () => late.GameRuntime.CharacterSelection,
+ late.GameRuntime.CharacterSelectionHighlight,
+ late.GameRuntime.CharacterSelectionEnter,
+ late.GameRuntime.CharacterSelectionRequestDelete,
+ late.GameRuntime.CharacterSelectionConfirmDelete,
+ late.GameRuntime.CharacterSelectionRestore,
+ late.GameRuntime.CharacterSelectionCancel,
+ // Campaign LA gate round 2 finding 1: the SAME
+ // window-close path GameplayInputCommandController's
+ // Escape fallback uses (IGameplayWindowCommands.Close
+ // /GameplayWindowCommands wrap this same d.Window.Close
+ // delegate) — no separate exit path.
+ d.Window.Close),
// Campaign CC slice CC4: same late-bound generation-capturing
// seam as CharacterSelection above. RequestExit here is a
// plain presentation action (closing the chargen screen and
// letting character-management's own Tick keep re-drawing
// itself underneath — see CharacterCreationUiController's
// OnExit doc), NOT a Runtime command or a window-close.
- CharacterCreation: d.Options.LiveCharacterSelector is null
- ? new CharacterCreationRuntimeBindings(
+ // LU10: unconditional for the same reason as CharacterSelection
+ // above — a player who logs out back to the select screen can
+ // create a character from there, so the screen behind it must
+ // exist regardless of how this session started.
+ CharacterCreation: new CharacterCreationRuntimeBindings(
() => late.GameRuntime.CharacterCreation,
late.GameRuntime.CharacterCreationSelectHeritage,
late.GameRuntime.CharacterCreationSelectGender,
@@ -1076,8 +1090,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance,
RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing,
GetSkillScore: chargenSkillScoreResolver.Resolve,
- OpenOnStart: d.Options.OpenCharacterCreationOnStart)
- : null);
+ OpenOnStart: d.Options.OpenCharacterCreationOnStart));
RetailUiRuntime runtime = lease.Mount(
() => RetailUiRuntime.CreateUninitialized(bindings));
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
index f7923808..d52acae7 100644
--- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
+++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
@@ -15,6 +15,11 @@ namespace AcDream.Launcher.Core.Orchestration;
///
public sealed class LauncherOrchestrator : ILauncherOrchestrator
{
+ /// How long the server may keep an account logged in after a
+ /// client dies without sending a logout. Observed against ACE at three
+ /// minutes and more; see GetReconnectHoldLocked.
+ private static readonly TimeSpan ServerSessionHold = TimeSpan.FromMinutes(3);
+
private const string FirstRunRequired =
"Client content is not configured. Complete the first-run setup before launching.";
@@ -142,14 +147,62 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
ThrowIfDisposed();
_ = FindAccountLocked(serverName, accountName);
ManagedActivity? active = FindActiveActivityLocked(serverName, accountName);
- return active is null
- ? LauncherCapability.Available
- : LauncherCapability.Unavailable(
+ if (active is not null)
+ {
+ return LauncherCapability.Unavailable(
$"Stop the running {active.Kind.ToString().ToLowerInvariant()} "
+ "for this account before starting another activity.");
+ }
+
+ return GetReconnectHoldLocked(serverName, accountName);
}
}
+ ///
+ /// LU9. A client that shuts itself down sends the server a logout and the
+ /// account frees in a few seconds. One that is killed or crashes sends
+ /// nothing, and the server keeps the account logged in until its own
+ /// timeout — observed at three minutes and more against ACE. Logging in
+ /// during that window does not queue or retry; it fails with a bare
+ /// "CharacterList not received", which reads as "the launcher is broken"
+ /// rather than "the server is still holding your last session".
+ ///
+ /// So the launcher holds the account itself for that window and says
+ /// how long is left. This is the server's constraint made visible, not a
+ /// retry or a grace period hiding one: the moment the hold expires the
+ /// account is offered again, and a graceful exit never starts one.
+ ///
+ private LauncherCapability GetReconnectHoldLocked(
+ string serverName,
+ string accountName)
+ {
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ ManagedActivity? ungraceful = _activities
+ .Where(activity =>
+ activity.IsTerminal
+ && !activity.ExitedGracefully
+ && activity.TerminalAt is not null
+ && string.Equals(activity.ServerName, serverName, StringComparison.Ordinal)
+ && string.Equals(activity.AccountName, accountName, StringComparison.Ordinal))
+ .OrderByDescending(activity => activity.TerminalAt)
+ .FirstOrDefault();
+ if (ungraceful?.TerminalAt is not { } terminalAt)
+ {
+ return LauncherCapability.Available;
+ }
+
+ TimeSpan remaining = ServerSessionHold - (now - terminalAt);
+ if (remaining <= TimeSpan.Zero)
+ {
+ return LauncherCapability.Available;
+ }
+
+ int seconds = (int)Math.Ceiling(remaining.TotalSeconds);
+ return LauncherCapability.Unavailable(
+ $"The last session for this account did not log out cleanly, so the "
+ + $"server may still be holding it. Try again in {seconds} s.");
+ }
+
public LauncherCapability GetProbeCapability(string serverName, string accountName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
@@ -838,6 +891,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
{
case ExitedStatusEvent exited:
activity.ExitCode ??= exited.Code;
+ activity.HostReportedExit = true;
+ activity.ExitReason ??= exited.Reason;
activity.HostTerminalStatus ??=
$"Exited: {exited.Reason} (code {exited.Code}).";
if (activity.State == LauncherActivityState.Exited)
@@ -904,6 +959,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
case ExitedStatusEvent exited:
activity.State = LauncherActivityState.Exited;
activity.ExitCode = exited.Code;
+ activity.HostReportedExit = true;
+ activity.ExitReason = exited.Reason;
activity.HostTerminalStatus =
$"Exited: {exited.Reason} (code {exited.Code}).";
activity.Status = activity.HostTerminalStatus;
@@ -1289,7 +1346,26 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public DateTimeOffset CreatedAt { get; }
- public LauncherActivityState State { get; set; } = LauncherActivityState.Starting;
+ private LauncherActivityState _state = LauncherActivityState.Starting;
+
+ ///
+ /// LU9: stamps on the FIRST transition into a
+ /// terminal state. Every terminal path in this file assigns through
+ /// here, so the server-side hold cannot be started from one site and
+ /// forgotten at another.
+ ///
+ public LauncherActivityState State
+ {
+ get => _state;
+ set
+ {
+ _state = value;
+ if (IsTerminal)
+ {
+ TerminalAt ??= DateTimeOffset.UtcNow;
+ }
+ }
+ }
public string Status { get; set; }
@@ -1299,6 +1375,21 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public string? HostTerminalStatus { get; set; }
+ /// LU9: the host's own terminal reason token, set only when
+ /// the host actually reported "exited" — so null means it never ran
+ /// its own teardown.
+ public string? ExitReason { get; set; }
+
+ /// LU9: the host reported its own terminal event, as opposed
+ /// to the launcher merely observing the process disappear.
+ public bool HostReportedExit { get; set; }
+
+ /// LU9: when this activity first became terminal, which is
+ /// when the server-side hold starts counting.
+ public DateTimeOffset? TerminalAt { get; set; }
+
+ public bool ExitedGracefully => HostReportedExit && ExitCode == 0;
+
public ILauncherProcessSupervisor? Supervisor { get; set; }
public EventHandler? SupervisorStateHandler { get; set; }
@@ -1332,7 +1423,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
Status,
ExitCode,
Error,
- CreatedAt);
+ CreatedAt,
+ ExitReason,
+ // Graceful means the HOST ran its own shutdown and exited
+ // clean, which is exactly the path that sends the server a
+ // logout. A killed or crashed child never reports "exited"
+ // and never exits zero, so it cannot be mistaken for one.
+ ExitedGracefully);
}
private sealed class StartRequest(
diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs
index 918e1065..e71d0847 100644
--- a/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs
+++ b/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs
@@ -61,7 +61,17 @@ public sealed record LauncherSessionSnapshot(
string Status,
int? ExitCode,
string? Error,
- DateTimeOffset CreatedAt)
+ DateTimeOffset CreatedAt,
+ /// LU9: the host's own machine-readable terminal reason
+ /// ("process-exit", "connection-error", "probe", ...), or null when the
+ /// host never got to report one — which itself means it did not shut
+ /// itself down.
+ string? ExitReason = null,
+ /// LU9: the host ran its OWN teardown and exited zero, so it
+ /// sent the server a logout. When this is false after a terminal state,
+ /// the server may still be holding the session — see
+ /// 's reconnect hold.
+ bool ExitedGracefully = false)
{
public bool IsActive => State is not (
LauncherActivityState.Exited
diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml
index da056eca..7cdb3031 100644
--- a/src/AcDream.Launcher/MainWindow.axaml
+++ b/src/AcDream.Launcher/MainWindow.axaml
@@ -227,14 +227,27 @@
-
+
-
+
+
+
+
+
+
+
+
diff --git a/src/AcDream.Launcher/ViewModels/LauncherSessionRowViewModel.cs b/src/AcDream.Launcher/ViewModels/LauncherSessionRowViewModel.cs
index be7c832d..711ce7c5 100644
--- a/src/AcDream.Launcher/ViewModels/LauncherSessionRowViewModel.cs
+++ b/src/AcDream.Launcher/ViewModels/LauncherSessionRowViewModel.cs
@@ -28,7 +28,7 @@ public sealed class LauncherSessionRowViewModel
Server = snapshot.ServerName;
Character = DescribeCharacter(snapshot);
State = DescribeState(snapshot);
- Status = snapshot.Status;
+ Status = DescribeStatus(snapshot);
Error = snapshot.Error;
IsActive = snapshot.IsActive;
StopCommand = new AsyncRelayCommand(
@@ -64,6 +64,52 @@ public sealed class LauncherSessionRowViewModel
public void NotifyCommandState() => StopCommand.NotifyCanExecuteChanged();
+ ///
+ /// LU9. A finished session used to report the host's own machine tokens —
+ /// "Exited: process-exit (code 0)", "Exited: connection-error (code 5)" —
+ /// which say nothing to a player about the thing they actually care about:
+ /// whether the client logged out properly, and therefore whether they can
+ /// log straight back in.
+ ///
+ /// While a session is alive the host's own status line is the most
+ /// informative thing available, so it is kept. Once it is over, the row
+ /// answers that question in a sentence.
+ ///
+ private static string DescribeStatus(LauncherSessionSnapshot snapshot)
+ {
+ if (snapshot.IsActive)
+ {
+ return snapshot.Status;
+ }
+
+ if (snapshot.State == LauncherActivityState.Cancelled)
+ {
+ return "Cancelled before it started.";
+ }
+
+ if (snapshot.ExitedGracefully)
+ {
+ return snapshot.Kind == LauncherActivityKind.Probe
+ ? "Finished reading characters."
+ : "Exited gracefully — logged out cleanly.";
+ }
+
+ // Not graceful: the host never reported its own exit, or reported a
+ // failure. The server may still be holding the account, and saying so
+ // here is what stops the next login failure being a mystery.
+ string detail = snapshot.ExitReason switch
+ {
+ "connection-error" => "Could not reach the server",
+ "credential-error" => "The account or password was rejected",
+ "configuration-error" => "The session configuration was rejected",
+ "usage-error" => "The client rejected how it was started",
+ null or "" => "Crashed",
+ _ => "Stopped unexpectedly",
+ };
+
+ return $"{detail} — the server may hold this account for a few minutes.";
+ }
+
private static string DescribeCharacter(LauncherSessionSnapshot snapshot)
{
if (snapshot.Kind == LauncherActivityKind.Probe)
diff --git a/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs b/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
index a793e152..bb64af0a 100644
--- a/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
+++ b/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
@@ -11,6 +11,16 @@ namespace AcDream.Launcher.ViewModels;
public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
{
private readonly ILauncherOrchestrator _orchestrator;
+ ///
+ /// LU9: how long Stop waits for the client to log out BEFORE killing it.
+ /// This used to be five seconds, which is not enough for a graphical
+ /// client to send its logout, wait for the server to acknowledge, and tear
+ /// down a mapped 28 GB world — so pressing Stop routinely ended in a kill,
+ /// the server kept holding the account, and the next login failed. The
+ /// kill is still there; it is now genuinely a last resort.
+ ///
+ private static readonly TimeSpan GracefulStopTimeout = TimeSpan.FromSeconds(30);
+
private readonly IUiDispatcher _dispatcher;
private readonly ILauncherInstaller _installer;
private LauncherStateSnapshot? _snapshot;
@@ -787,12 +797,12 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
_operationCancellation = cancellation;
IsBusy = true;
LastError = null;
- OperationStatus = "Stopping session…";
+ OperationStatus = "Logging out…";
try
{
await _orchestrator.StopSessionAsync(
sessionId,
- TimeSpan.FromSeconds(5),
+ GracefulStopTimeout,
cancellation.Token)
.ConfigureAwait(true);
OperationStatus = "Stop requested.";
diff --git a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs
index e1b566d3..2167540c 100644
--- a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs
@@ -183,11 +183,19 @@ public sealed class SessionPlayerCompositionTests
call => call.Target.DeclaringType == typeof(CharacterList)
&& call.Target.Name == nameof(CharacterList.TrySelectFirstAvailable));
+ // LU10: the retained UI must NOT branch on the selector. The selector
+ // decides how a session STARTS (above — straight into the world instead
+ // of pausing at the select screen); it must not decide whether the
+ // select screen EXISTS. Retail's EndCharacterSession — the toolbar X —
+ // ends by resetting the world generation and calling
+ // CharacterSelectionState.Begin, so a launcher-started session that
+ // logs out lands on exactly these bindings. While they were gated on
+ // the selector, it landed on nothing.
MethodInfo retainedUi = typeof(RetailInteractionRetainedUiCompositionFactory)
.GetMethod(nameof(
RetailInteractionRetainedUiCompositionFactory.CreateRetainedUi))!;
IReadOnlyList uiCalls = CompiledCallGraph.Read(retainedUi);
- Assert.Contains(
+ Assert.DoesNotContain(
uiCalls,
call => call.Target.DeclaringType == typeof(RuntimeOptions)
&& call.Target.Name == "get_LiveCharacterSelector");
@@ -196,6 +204,11 @@ public sealed class SessionPlayerCompositionTests
call => call.Target.DeclaringType
== typeof(CharacterSelectionRuntimeBindings)
&& call.Target.IsConstructor);
+ Assert.Contains(
+ uiCalls,
+ call => call.Target.DeclaringType
+ == typeof(AcDream.App.UI.Layout.CharacterCreationRuntimeBindings)
+ && call.Target.IsConstructor);
}
private sealed class RetryBinding(