diff --git a/AcDream.slnx b/AcDream.slnx
index fa90475d..20f17f98 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -7,6 +7,7 @@
+
@@ -25,6 +26,7 @@
+
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index f39bc608..881ed834 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -24,6 +24,57 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
+## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host
+
+**Status:** OPEN
+**Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE
+account session stuck for several minutes — a documented project landmine;
+see CLAUDE.md "Logout-before-reconnect")
+**Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3)
+**Component:** Launcher.Core / process supervision
+
+**Description.** `LauncherProcessSupervisor.Stop` now attempts a graceful
+stop signal (`ILauncherChildProcess.TryRequestGracefulStop`) BEFORE
+`CloseMainWindow`. On Linux this sends `SIGINT` via a `libc` P/Invoke
+(`kill(pid, 2)`), which the K4-proven headless host already turns into an
+ACE-confirmed graceful logout. On Windows there is no equivalent today for a
+console process with no message-pump window: `CloseMainWindow` is a no-op
+for a console host (there is no `HWND` to target), and
+`GenerateConsoleCtrlEvent` cannot usefully target an arbitrary child process
+today — Windows delivers console control events to every process attached
+to the SAME console as the calling process, so an unscoped call would also
+signal the launcher itself (and anything else sharing that console), not
+just the intended child. `TryRequestGracefulStop` therefore returns `false`
+on Windows unconditionally, and `Stop` degrades straight to `CloseMainWindow`
+(still a no-op for a console child) and then the timeout-driven `Kill()` —
+exactly the hard-kill behavior this finding was written to describe, just
+with a documented (rather than silent) gap.
+
+**Known fix direction (not yet implemented).** Spawn the Windows child with
+the `CREATE_NEW_PROCESS_GROUP` creation flag (available via a native
+`CreateProcess` call or by setting it on the `ProcessStartInfo`/`Process`
+plumbing in `SystemChildProcess`) so the child gets its own console process
+group, detached from the launcher's own group. Then
+`TryRequestGracefulStop` on Windows calls
+`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)` —
+`CTRL_BREAK` (unlike `CTRL_C`) can target a specific process group ID and,
+unlike `CTRL_CLOSE`/`CTRL_LOGOFF`/`CTRL_SHUTDOWN`, is deliverable to a
+process that has installed no console-control handler at all (the default
+CRT handler treats it as a terminating signal, so `AcDream.Headless` doesn't
+strictly need new code to receive SOME form of shutdown from it) — though
+wiring a real `SetConsoleCtrlHandler` handler that routes `CTRL_BREAK` into
+the same graceful-shutdown path K4 already built for Linux SIGINT is the
+better long-term target, so a Windows headless launch gets the identical
+ACE-confirmed graceful logout instead of just "exits somehow."
+
+**Acceptance for closing this issue:** `SystemChildProcess` spawns Windows
+children with `CREATE_NEW_PROCESS_GROUP`; `TryRequestGracefulStop` sends
+`CTRL_BREAK_EVENT` to that child's process group on Windows; a live
+connected gate proves `AcDream.Headless` exits gracefully (ACE clears the
+session immediately, not after the ~3-minute stale-session window) when
+stopped via `LauncherProcessSupervisor.Stop` on Windows, matching the
+Linux SIGINT behavior.
+
## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click
**Status:** ROOT-CAUSED + FIXED — pending the user's visual re-gate of the
diff --git a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj
new file mode 100644
index 00000000..85dc49f0
--- /dev/null
+++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj
@@ -0,0 +1,21 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+
+ true
+
+
+
+
+
+
+
+
diff --git a/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs
new file mode 100644
index 00000000..327d406a
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs
@@ -0,0 +1,61 @@
+using System.Security.Cryptography;
+
+namespace AcDream.Launcher.Core.Integrity;
+
+///
+/// Streaming SHA-256 for pak/download verification, consumed by the
+/// install engine (LA9) and the updater (LA10). Kept minimal in this
+/// slice: hash a file and compare its hex digest.
+///
+public static class FileIntegrity
+{
+ ///
+ /// Computes the lower-case hex SHA-256 digest of a file, streaming it
+ /// from disk rather than loading it fully into memory (relevant for
+ /// the ~30 GB pak file LA9 verifies).
+ ///
+ public static string ComputeSha256Hex(string filePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
+
+ using FileStream stream = new(
+ filePath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read);
+ byte[] hash = SHA256.HashData(stream);
+ return Convert.ToHexStringLower(hash);
+ }
+
+ public static async Task ComputeSha256HexAsync(
+ string filePath,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
+
+ await using FileStream stream = new(
+ filePath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read,
+ bufferSize: 4096,
+ useAsync: true);
+ byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken)
+ .ConfigureAwait(false);
+ return Convert.ToHexStringLower(hash);
+ }
+
+ /// Case-insensitive hex comparison — callers may receive an
+ /// expected digest in either case from a manifest or a hand-typed
+ /// fixture.
+ public static bool Matches(string actualHex, string expectedHex)
+ {
+ ArgumentNullException.ThrowIfNull(actualHex);
+ ArgumentNullException.ThrowIfNull(expectedHex);
+ return string.Equals(actualHex, expectedHex, StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// Computes and compares in one call.
+ public static bool Verify(string filePath, string expectedHex) =>
+ Matches(ComputeSha256Hex(filePath), expectedHex);
+}
diff --git a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs
new file mode 100644
index 00000000..872c0130
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs
@@ -0,0 +1,174 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+
+namespace AcDream.Launcher.Core.Launching;
+
+///
+/// Thin seam over so
+/// 's lifecycle and Stop
+/// (CloseMainWindow, falling back to Kill after a timeout) state machine
+/// can be unit-tested against an in-memory fake without spawning a real
+/// OS process or depending on real window-message timing — both
+/// "injectable for tests" per Campaign LA spec §3.
+///
+public interface ILauncherChildProcess : IDisposable
+{
+ bool HasExited { get; }
+
+ int ExitCode { get; }
+
+ /// The child's redirected stdin. The supervisor writes the
+ /// account password here (followed by a newline) and then closes it —
+ /// never anywhere else.
+ TextWriter StandardInput { get; }
+
+ /// Fires exactly once, when the child process terminates
+ /// (mirrors with
+ /// EnableRaisingEvents on).
+ event EventHandler? Exited;
+
+ void Start();
+
+ ///
+ /// Attempts a graceful stop signal appropriate to the platform,
+ /// tried BEFORE (Campaign LA plan §LA3
+ /// review finding F3): a no-window console host (e.g.
+ /// AcDream.Headless) never has a main window for
+ /// to close, so without this step
+ /// always degraded
+ /// straight to a timeout + hard — and a hard kill
+ /// leaves the ACE account session stuck for several minutes (a
+ /// documented project landmine; see CLAUDE.md
+ /// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved
+ /// the headless host's SIGINT handler produces an ACE-confirmed
+ /// graceful logout). On Windows there is no reliable cross-console
+ /// mechanism for an arbitrary no-window child process today — see
+ /// docs/ISSUES.md for the tracked gap and fix direction; this
+ /// returns false there. Returns true only when the signal was
+ /// actually delivered; never throws.
+ ///
+ bool TryRequestGracefulStop();
+
+ /// Mirrors — requests
+ /// a graceful close via WM_CLOSE. Returns false for a console/no-
+ /// window process (never throws), matching the real API.
+ bool CloseMainWindow();
+
+ /// Mirrors with
+ /// entireProcessTree: true.
+ void Kill();
+
+ bool WaitForExit(TimeSpan timeout);
+}
+
+/// Creates instances from a
+/// .
+public interface ILauncherChildProcessFactory
+{
+ ILauncherChildProcess Create(LauncherProcessSpec spec);
+}
+
+/// Real-process implementation used in production.
+public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory
+{
+ public ILauncherChildProcess Create(LauncherProcessSpec spec) =>
+ new SystemChildProcess(spec);
+}
+
+internal sealed partial class SystemChildProcess : ILauncherChildProcess
+{
+ // SIGINT's numeric value (POSIX-stable across Linux distributions).
+ // K4/Slice K already proved the headless host's SIGINT handler
+ // produces an ACE-confirmed graceful logout.
+ private const int Sigint = 2;
+
+ [LibraryImport("libc", SetLastError = true)]
+ private static partial int kill(int pid, int sig);
+
+ private readonly Process _process;
+ private bool _raisingEnabled;
+
+ internal SystemChildProcess(LauncherProcessSpec spec)
+ {
+ ArgumentNullException.ThrowIfNull(spec);
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = spec.ExecutablePath,
+ RedirectStandardInput = true,
+ UseShellExecute = false,
+ };
+
+ foreach (string argument in spec.Arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ if (!string.IsNullOrEmpty(spec.WorkingDirectory))
+ {
+ startInfo.WorkingDirectory = spec.WorkingDirectory;
+ }
+
+ _process = new Process { StartInfo = startInfo };
+ }
+
+ public bool HasExited => _process.HasExited;
+
+ public int ExitCode => _process.ExitCode;
+
+ public TextWriter StandardInput => _process.StandardInput;
+
+ public event EventHandler? Exited;
+
+ public void Start()
+ {
+ _process.EnableRaisingEvents = true;
+ _process.Exited += OnExited;
+ _raisingEnabled = true;
+ _process.Start();
+ }
+
+ public bool TryRequestGracefulStop()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ // No reliable cross-console mechanism exists for an
+ // arbitrary no-window Windows child process — tracked gap,
+ // see docs/ISSUES.md.
+ return false;
+ }
+
+ try
+ {
+ return kill(_process.Id, Sigint) == 0;
+ }
+ catch
+ {
+ // Matches CloseMainWindow's "never throws" contract — the
+ // process may not have started yet, may have already exited
+ // (ESRCH), or the platform may lack libc under an unusual
+ // Linux runtime; any of these degrade to "signal not sent"
+ // rather than an exception out of Stop().
+ return false;
+ }
+ }
+
+ public bool CloseMainWindow() => _process.CloseMainWindow();
+
+ public void Kill() => _process.Kill(entireProcessTree: true);
+
+ public bool WaitForExit(TimeSpan timeout) => _process.WaitForExit(timeout);
+
+ public void Dispose()
+ {
+ if (_raisingEnabled)
+ {
+ _process.Exited -= OnExited;
+ }
+
+ _process.Dispose();
+ }
+
+ private void OnExited(object? sender, EventArgs e) =>
+ Exited?.Invoke(this, EventArgs.Empty);
+}
diff --git a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs
new file mode 100644
index 00000000..5d8e0c07
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs
@@ -0,0 +1,12 @@
+namespace AcDream.Launcher.Core.Launching;
+
+///
+/// The DAT/pak locations a completed install (LA9) records and every
+/// session-config composition consumes for
+/// . SHA-256/version bookkeeping
+/// for the install record itself is LA9/LA10 scope; this slice only
+/// needs the two paths a session config requires.
+///
+public sealed record LauncherInstallRecord(
+ string DatDirectory,
+ string PreparedAssetPath);
diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs
new file mode 100644
index 00000000..11b59dee
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs
@@ -0,0 +1,15 @@
+namespace AcDream.Launcher.Core.Launching;
+
+///
+/// What to spawn: the host executable path + argument list (both
+/// injectable per Campaign LA spec §3, e.g. AcDream.Headless --config
+/// <path> or AcDream.App --session-config <path>).
+/// Deliberately carries no credential field — the password is a separate
+/// transient parameter to
+/// that flows only to the child's stdin, never into this spec, an
+/// argument list, or a process environment.
+///
+public sealed record LauncherProcessSpec(
+ string ExecutablePath,
+ IReadOnlyList Arguments,
+ string? WorkingDirectory = null);
diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs
new file mode 100644
index 00000000..0f7fcac1
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs
@@ -0,0 +1,286 @@
+using System.Runtime.ExceptionServices;
+
+namespace AcDream.Launcher.Core.Launching;
+
+///
+/// Spawns a host process (App/Headless), feeds the account password to
+/// its stdin then closes it, and supervises its lifetime (Campaign LA
+/// spec §3/§6). One supervisor instance owns exactly one child process
+/// for its lifetime — start a new supervisor per launched session.
+///
+public sealed class LauncherProcessSupervisor : IDisposable
+{
+ private readonly ILauncherChildProcessFactory _factory;
+ private readonly object _gate = new();
+ private readonly Queue _pendingStateChanges = [];
+ private ILauncherChildProcess? _process;
+ private LauncherSessionState _state = LauncherSessionState.Starting;
+ private int? _exitCode;
+ private bool _publishingStateChanges;
+
+ public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
+ {
+ _factory = factory ?? new SystemChildProcessFactory();
+ }
+
+ public LauncherSessionState State
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _state;
+ }
+ }
+ }
+
+ /// Set once reaches
+ /// ; null before then.
+ ///
+ public int? ExitCode
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _exitCode;
+ }
+ }
+ }
+
+ /// Fires on every
+ /// transition, in order.
+ public event EventHandler? StateChanged;
+
+ ///
+ /// Spawns the child described by , writes
+ /// (if any) followed by a newline to its
+ /// stdin, then closes stdin. The password is never written anywhere
+ /// else — not into , not into an environment
+ /// variable, not logged.
+ ///
+ public void Start(LauncherProcessSpec spec, string? password)
+ {
+ ArgumentNullException.ThrowIfNull(spec);
+
+ ILauncherChildProcess process;
+ lock (_gate)
+ {
+ if (_process is not null)
+ {
+ throw new InvalidOperationException(
+ "This supervisor already owns a process; start a new "
+ + "supervisor per launched session.");
+ }
+
+ process = _factory.Create(spec);
+ process.Exited += OnProcessExited;
+ _process = process;
+ }
+
+ SetState(LauncherSessionState.Starting);
+
+ bool started = false;
+ try
+ {
+ process.Start();
+ started = true;
+
+ if (password is not null)
+ {
+ process.StandardInput.Write(password);
+ process.StandardInput.Write('\n');
+ process.StandardInput.Flush();
+ }
+
+ process.StandardInput.Close();
+ }
+ catch
+ {
+ lock (_gate)
+ {
+ process.Exited -= OnProcessExited;
+ _process = null;
+ }
+
+ // A failure after the child actually started (e.g. the stdin
+ // pipe breaks while feeding the password) must not leave a
+ // live, unsupervised, undisposable child running (Campaign LA
+ // plan §LA3 review finding F8) — kill the whole process tree
+ // and release the handle before propagating the original
+ // failure.
+ if (started)
+ {
+ try
+ {
+ process.Kill();
+ }
+ catch
+ {
+ // Best-effort — the ORIGINAL failure, rethrown below,
+ // is what the caller needs to see; a failed cleanup
+ // kill must not replace it.
+ }
+ }
+
+ process.Dispose();
+
+ throw;
+ }
+
+ SetState(LauncherSessionState.Running);
+ }
+
+ ///
+ /// Requests a graceful stop — first
+ /// (SIGINT
+ /// on Linux; a no-op on Windows today, see
+ /// 's docs),
+ /// then — falling
+ /// back to if the process has
+ /// not exited within . A no-op if
+ /// was never called or the process has already
+ /// exited.
+ ///
+ /// BLOCKS THE CALLING THREAD for up to
+ /// (via the real child's WaitForExit) — callers on a UI thread
+ /// must dispatch this off-thread rather than calling it directly (a
+ /// binding requirement for the LA4 Avalonia UI, which will call this
+ /// method from a "stop session" action).
+ ///
+ ///
+ public void Stop(TimeSpan timeout)
+ {
+ ILauncherChildProcess? process;
+ lock (_gate)
+ {
+ process = _process;
+ }
+
+ if (process is null || process.HasExited)
+ {
+ return;
+ }
+
+ process.TryRequestGracefulStop();
+ process.CloseMainWindow();
+ if (!process.WaitForExit(timeout) && !process.HasExited)
+ {
+ process.Kill();
+ }
+ }
+
+ private void OnProcessExited(object? sender, EventArgs e)
+ {
+ int? exitCode;
+ lock (_gate)
+ {
+ exitCode = _process is { HasExited: true } process
+ ? process.ExitCode
+ : null;
+ }
+
+ SetState(LauncherSessionState.Exited, exitCode);
+ }
+
+ ///
+ /// Applies a state transition, or silently ignores it (Campaign LA
+ /// plan §LA3 review finding F9): once reaches the
+ /// terminal , no later call
+ /// may move it anywhere else, and only
+ /// fires for a transition that was actually applied. This matters
+ /// because 's trailing
+ /// SetState(LauncherSessionState.Running) can race a
+ /// synchronous callback fired from
+ /// inside itself (a child that dies immediately)
+ /// — without this guard, "Running" would silently resurrect a
+ /// process that has already reported its exit.
+ ///
+ private void SetState(LauncherSessionState state, int? exitCode = null)
+ {
+ bool publish;
+ lock (_gate)
+ {
+ if (_state == LauncherSessionState.Exited)
+ {
+ return;
+ }
+
+ _state = state;
+ if (state == LauncherSessionState.Exited)
+ {
+ _exitCode = exitCode;
+ }
+
+ _pendingStateChanges.Enqueue(state);
+ publish = !_publishingStateChanges;
+ if (publish)
+ {
+ _publishingStateChanges = true;
+ }
+ }
+
+ if (publish)
+ {
+ PublishPendingStateChanges();
+ }
+ }
+
+ ///
+ /// Drains state notifications through one publisher. Transition storage
+ /// stays under , but user callbacks run outside it so
+ /// they may re-enter the supervisor or wait for another thread reading
+ /// state without deadlocking. A concurrent/re-entrant transition queues
+ /// behind the notification already in flight, preserving storage order in
+ /// the externally observed event stream.
+ ///
+ private void PublishPendingStateChanges()
+ {
+ Exception? firstException = null;
+ while (true)
+ {
+ LauncherSessionState state;
+ lock (_gate)
+ {
+ if (_pendingStateChanges.Count == 0)
+ {
+ _publishingStateChanges = false;
+ break;
+ }
+
+ state = _pendingStateChanges.Dequeue();
+ }
+
+ try
+ {
+ StateChanged?.Invoke(this, state);
+ }
+ catch (Exception ex)
+ {
+ // Preserve the previous propagation behavior, but finish
+ // publishing any transition already committed concurrently
+ // (especially terminal Exited) before rethrowing the first
+ // observer failure to the initiating caller.
+ firstException ??= ex;
+ }
+ }
+
+ if (firstException is not null)
+ {
+ ExceptionDispatchInfo.Capture(firstException).Throw();
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ if (_process is not null)
+ {
+ _process.Exited -= OnProcessExited;
+ _process.Dispose();
+ _process = null;
+ }
+ }
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs b/src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs
new file mode 100644
index 00000000..2f04fae0
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs
@@ -0,0 +1,21 @@
+namespace AcDream.Launcher.Core.Launching;
+
+/// Lifecycle of a launched host process, per Campaign LA spec §3
+/// ("supervise lifetime ... surface typed session state").
+public enum LauncherSessionState
+{
+ /// The child process has been created and the credential
+ /// handed off, but has not yet reached .
+ Starting,
+
+ /// The child process is spawned and its stdin has been
+ /// closed. Says nothing about game-level connection state — that
+ /// comes from the status stream (see
+ /// AcDream.Launcher.Core.Status).
+ Running,
+
+ /// The child process has exited. See
+ /// for the exit
+ /// code.
+ Exited,
+}
diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs
new file mode 100644
index 00000000..1b4349d2
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs
@@ -0,0 +1,242 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using AcDream.Launcher.Core.Profiles;
+using AcDream.Platform;
+
+namespace AcDream.Launcher.Core.Launching;
+
+/// The composed session-config document plus the two per-launch
+/// paths derived from the session id, per Campaign LA spec §6.
+public sealed record ComposedSessionConfig(
+ string SessionId,
+ string ConfigFilePath,
+ string StatusFilePath,
+ SessionConfigDocument Document);
+
+///
+/// Builds the per-launch from a
+/// profile character + install record (Campaign LA spec §6). Passwords
+/// NEVER appear in the composed document — the credential is always the
+/// standardInput provider; the launcher feeds the password to the
+/// child process's stdin separately ().
+///
+public static class SessionConfigComposer
+{
+ internal static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = true,
+ };
+
+ ///
+ /// Builds the document and the paths it would be written to under
+ /// <CacheDirectory>/launcher/sessions/<sessionId>/,
+ /// without touching disk. is caller-
+ /// supplied so composition stays a pure function of its inputs
+ /// (golden-file tests pass a fixed id).
+ ///
+ public static ComposedSessionConfig Compose(
+ ServerProfile server,
+ AccountProfile account,
+ CharacterProfile character,
+ LauncherInstallRecord install,
+ ApplicationPathSet paths,
+ string sessionId,
+ int? loginCommandDelayMs = null)
+ {
+ ArgumentNullException.ThrowIfNull(server);
+ ArgumentNullException.ThrowIfNull(account);
+ ArgumentNullException.ThrowIfNull(character);
+ ArgumentNullException.ThrowIfNull(install);
+ ArgumentNullException.ThrowIfNull(paths);
+ ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
+
+ (string configFilePath, string statusFilePath) = BuildSessionPaths(paths, sessionId);
+
+ SessionCharacterSelector? selector = character.LaunchMode == LaunchMode.GuiSelect
+ ? null
+ : BuildSelector(character);
+
+ SessionPolicyDescriptor? policy = character.LaunchMode == LaunchMode.Headless
+ ? new SessionPolicyDescriptor()
+ : null;
+
+ var descriptor = new SessionDescriptor
+ {
+ Id = sessionId,
+ Endpoint = new SessionEndpointDescriptor
+ {
+ Host = server.Host,
+ Port = server.Port,
+ },
+ Account = account.Account,
+ Character = selector,
+ Policy = policy,
+ Credential = new SessionCredentialDescriptor(),
+ Plugins = character.Plugins.Count > 0 ? [.. character.Plugins] : null,
+ LoginCommands = character.LoginCommands.Count > 0
+ ? [.. character.LoginCommands]
+ : null,
+ LoginCommandDelayMs = loginCommandDelayMs,
+ StatusFile = statusFilePath,
+ };
+
+ var document = new SessionConfigDocument
+ {
+ Process = new SessionProcessSettings
+ {
+ Content = new SessionContentDescriptor
+ {
+ DatDirectory = install.DatDirectory,
+ PreparedAssetPath = install.PreparedAssetPath,
+ },
+ },
+ Sessions = [descriptor],
+ };
+
+ return new ComposedSessionConfig(
+ sessionId,
+ configFilePath,
+ statusFilePath,
+ document);
+ }
+
+ ///
+ /// Builds a probe session-config document (Campaign LA plan §LA2/
+ /// §LA3 review finding F2): the session carries mode: "probe",
+ /// no character selector, and no policy — the host
+ /// reports the account's character roster over the status stream and
+ /// exits without entering the world. plugins/loginCommands
+ /// don't apply to a probe and are always omitted, exactly like an
+ /// empty configured set on a normal session.
+ ///
+ public static ComposedSessionConfig ComposeProbe(
+ ServerProfile server,
+ AccountProfile account,
+ LauncherInstallRecord install,
+ ApplicationPathSet paths,
+ string sessionId)
+ {
+ ArgumentNullException.ThrowIfNull(server);
+ ArgumentNullException.ThrowIfNull(account);
+ ArgumentNullException.ThrowIfNull(install);
+ ArgumentNullException.ThrowIfNull(paths);
+ ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
+
+ (string configFilePath, string statusFilePath) = BuildSessionPaths(paths, sessionId);
+
+ var descriptor = new SessionDescriptor
+ {
+ Id = sessionId,
+ Mode = "probe",
+ Endpoint = new SessionEndpointDescriptor
+ {
+ Host = server.Host,
+ Port = server.Port,
+ },
+ Account = account.Account,
+ Character = null,
+ Policy = null,
+ Credential = new SessionCredentialDescriptor(),
+ Plugins = null,
+ LoginCommands = null,
+ LoginCommandDelayMs = null,
+ StatusFile = statusFilePath,
+ };
+
+ var document = new SessionConfigDocument
+ {
+ Process = new SessionProcessSettings
+ {
+ Content = new SessionContentDescriptor
+ {
+ DatDirectory = install.DatDirectory,
+ PreparedAssetPath = install.PreparedAssetPath,
+ },
+ },
+ Sessions = [descriptor],
+ };
+
+ return new ComposedSessionConfig(
+ sessionId,
+ configFilePath,
+ statusFilePath,
+ document);
+ }
+
+ /// Composes and writes session.json to
+ /// , creating the
+ /// per-session directory. The status file itself is created by the
+ /// launched host, not the launcher.
+ public static ComposedSessionConfig ComposeAndWrite(
+ ServerProfile server,
+ AccountProfile account,
+ CharacterProfile character,
+ LauncherInstallRecord install,
+ ApplicationPathSet paths,
+ string sessionId,
+ int? loginCommandDelayMs = null)
+ {
+ ComposedSessionConfig composed = Compose(
+ server,
+ account,
+ character,
+ install,
+ paths,
+ sessionId,
+ loginCommandDelayMs);
+
+ string? directory = Path.GetDirectoryName(composed.ConfigFilePath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ using FileStream stream = File.Create(composed.ConfigFilePath);
+ JsonSerializer.Serialize(stream, composed.Document, SerializerOptions);
+
+ return composed;
+ }
+
+ /// Serializes the composed document exactly as
+ /// would write it — used by golden-file
+ /// tests that assert on the JSON text without touching disk.
+ public static string Serialize(SessionConfigDocument document) =>
+ JsonSerializer.Serialize(document, SerializerOptions);
+
+ private static (string ConfigFilePath, string StatusFilePath) BuildSessionPaths(
+ ApplicationPathSet paths,
+ string sessionId)
+ {
+ string sessionDirectory = Path.Combine(
+ paths.CacheDirectory,
+ "launcher",
+ "sessions",
+ sessionId);
+
+ return (
+ Path.Combine(sessionDirectory, "session.json"),
+ Path.Combine(sessionDirectory, "status.jsonl"));
+ }
+
+ private static SessionCharacterSelector BuildSelector(CharacterProfile character)
+ {
+ // A parsed id of 0 is not a usable selector — both host loaders
+ // (App/Headless) reject `id: 0` outright, so falling through to
+ // the name selector here is the only shape that reaches a real
+ // character (Campaign LA plan §LA3 review finding F10).
+ if (CharacterIdFormat.TryParse(character.Id, out uint id) && id != 0)
+ {
+ return new SessionCharacterSelector { Id = id };
+ }
+
+ if (!string.IsNullOrWhiteSpace(character.Name))
+ {
+ return new SessionCharacterSelector { Name = character.Name };
+ }
+
+ throw new InvalidOperationException(
+ "Character has neither a usable id nor a name to select by.");
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs
new file mode 100644
index 00000000..0f013f53
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs
@@ -0,0 +1,152 @@
+namespace AcDream.Launcher.Core.Launching;
+
+///
+/// The per-launch session-config document written to
+/// <CacheDirectory>/launcher/sessions/<sessionId>/session.json
+/// and consumed by AcDream.Headless --config / (LA1)
+/// AcDream.App --session-config.
+///
+///
+/// PINNED CONTRACT (Campaign LA plan §LA3): this is the Slice K1
+/// HeadlessConfiguration version-1 shape extended with optional
+/// launcher fields (plugins, loginCommands,
+/// loginCommandDelayMs, statusFile). Launcher.Core defines
+/// its own DTOs rather than referencing AcDream.Headless — the
+/// project reference set for this assembly is AcDream.Platform
+/// ONLY (no game-solution dependency; see LA3 acceptance).
+/// Serialized camelCase via , with
+/// null optional members omitted from the written JSON.
+///
+///
+public sealed class SessionConfigDocument
+{
+ public int Version { get; init; } = 1;
+
+ public SessionProcessSettings Process { get; init; } = new();
+
+ public List Sessions { get; init; } = [];
+}
+
+public sealed class SessionProcessSettings
+{
+ ///
+ /// PINNED CONTRACT (Campaign LA plan §LA3 review, finding F1): the
+ /// paths KEY is entirely OMITTED from the written JSON unless
+ /// a caller explicitly supplies overrides — never an empty object.
+ /// The App-side loader parses with strict
+ /// UnmappedMemberHandling.Disallow and has no paths
+ /// member of its own, so an emitted "paths":{} is a null-
+ /// omission artifact (the object's own members are all optional and
+ /// omit cleanly, but the containing property was never null itself)
+ /// that would fail every gui/guiSelect launch at config load.
+ ///
+ public SessionPathOverrides? Paths { get; init; }
+
+ public SessionContentDescriptor Content { get; init; } = new();
+}
+
+/// All three members are optional overrides; a host resolves
+/// its own default ApplicationPathSet when a member is
+/// omitted.
+public sealed class SessionPathOverrides
+{
+ public string? ConfigDirectory { get; init; }
+
+ public string? DataDirectory { get; init; }
+
+ public string? CacheDirectory { get; init; }
+}
+
+// NOTE: these DTOs are write-only (Launcher.Core composes and serializes
+// them; it never deserializes a session-config document back). Members
+// that the pinned contract calls "always present" therefore use plain
+// non-nullable defaults rather than C#'s `required` modifier — a
+// `required` member cannot be given a `= new()` default on a containing
+// type without a [SetsRequiredMembers] constructor, and correctness here
+// is enforced by SessionConfigComposer's tests, not the compiler.
+
+public sealed class SessionContentDescriptor
+{
+ public string DatDirectory { get; init; } = string.Empty;
+
+ public string PreparedAssetPath { get; init; } = string.Empty;
+}
+
+public sealed class SessionDescriptor
+{
+ public string Id { get; init; } = string.Empty;
+
+ /// Present only for a probe session ("probe",
+ /// Campaign LA plan §LA2/§LA3) — the host reports the account's
+ /// character roster and exits without entering the world. OMITTED
+ /// entirely for a normal gui/guiSelect/headless play session.
+ ///
+ public string? Mode { get; init; }
+
+ public SessionEndpointDescriptor Endpoint { get; init; } = new();
+
+ public string Account { get; init; } = string.Empty;
+
+ /// Exactly one of index/id/name when present. OMITTED
+ /// entirely for a guiSelect launch (retail character-select
+ /// screen instead of auto-enter).
+ public SessionCharacterSelector? Character { get; init; }
+
+ /// Present only for a headless launch (the idle
+ /// bot policy). Omitted for gui/guiSelect.
+ public SessionPolicyDescriptor? Policy { get; init; }
+
+ public SessionCredentialDescriptor Credential { get; init; } = new();
+
+ /// Omitted (never an empty array) when the character has no
+ /// configured plugin set.
+ public List? Plugins { get; init; }
+
+ /// Omitted (never an empty array) when the character has no
+ /// configured login commands.
+ public List? LoginCommands { get; init; }
+
+ /// Overrides the host's default 500 ms inter-command
+ /// delay when set; omitted otherwise.
+ public int? LoginCommandDelayMs { get; init; }
+
+ public string StatusFile { get; init; } = string.Empty;
+}
+
+public sealed class SessionEndpointDescriptor
+{
+ public string Host { get; init; } = string.Empty;
+
+ public int Port { get; init; }
+}
+
+/// Exactly one of //
+/// is set by .
+///
+public sealed class SessionCharacterSelector
+{
+ public int? Index { get; init; }
+
+ public uint? Id { get; init; }
+
+ public string? Name { get; init; }
+}
+
+/// Campaign LA composes exactly the idle bot policy (LA2)
+/// for headless launches — the launcher never asks for any other
+/// policy id.
+public sealed class SessionPolicyDescriptor
+{
+ public string Id { get; init; } = "idle";
+}
+
+/// Always the standardInput provider — the launcher pipes
+/// the account password to the child's stdin and never places it in the
+/// session-config document, process arguments, or environment (see
+/// ).
+public sealed class SessionCredentialDescriptor
+{
+ public string Provider { get; init; } = "standardInput";
+
+ public string Reference { get; init; } = "session";
+}
diff --git a/src/AcDream.Launcher.Core/Profiles/AccountProfile.cs b/src/AcDream.Launcher.Core/Profiles/AccountProfile.cs
new file mode 100644
index 00000000..03b3693c
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Profiles/AccountProfile.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+
+namespace AcDream.Launcher.Core.Profiles;
+
+///
+/// One account under a server, per Campaign LA spec §5. The password is
+/// plaintext by explicit user decision
+/// (claude-memory/project_launcher_direction.md) — never written
+/// anywhere except this file, never logged, never placed in a session
+/// config or process argument/environment (see
+/// ).
+///
+public sealed class AccountProfile
+{
+ [JsonRequired]
+ public string Account { get; set; } = string.Empty;
+
+ [JsonRequired]
+ public string Password { get; set; } = string.Empty;
+
+ public List Characters { get; set; } = [];
+}
diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs b/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs
new file mode 100644
index 00000000..573ceef0
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs
@@ -0,0 +1,43 @@
+using System.Globalization;
+
+namespace AcDream.Launcher.Core.Profiles;
+
+///
+/// Converts between the wire uint character GUID and the
+/// launcher-profile hex-string representation ("0x5000000A",
+/// matching the convention used throughout the project, e.g. the
+/// +Acdream test character's 0x5000000A in CLAUDE.md).
+///
+public static class CharacterIdFormat
+{
+ public static string ToHexString(uint id) =>
+ "0x" + id.ToString("X8", CultureInfo.InvariantCulture);
+
+ ///
+ /// Parses as a hex character id — the
+ /// 0x prefix (case-insensitive) is REQUIRED (Campaign LA plan
+ /// §LA3 review finding F10). Every all-digit id is ALSO a valid hex
+ /// number (e.g. "12345678"), so accepting a bare unprefixed
+ /// string as hex silently reinterprets a hand-typed decimal id and
+ /// selects the wrong character; requiring the prefix makes "this is
+ /// hex" an explicit, unambiguous signal instead of a guess.
+ ///
+ public static bool TryParse(string? text, out uint id)
+ {
+ id = 0;
+ if (string.IsNullOrWhiteSpace(text))
+ return false;
+
+ ReadOnlySpan span = text.AsSpan().Trim();
+ if (!span.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ span = span[2..];
+
+ return uint.TryParse(
+ span,
+ NumberStyles.HexNumber,
+ CultureInfo.InvariantCulture,
+ out id);
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs b/src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs
new file mode 100644
index 00000000..18827873
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs
@@ -0,0 +1,31 @@
+using System.Text.Json.Serialization;
+
+namespace AcDream.Launcher.Core.Profiles;
+
+///
+/// One character row under an account, per Campaign LA spec §5. The
+/// / pair is the launcher-maintained
+/// cache (fed by status-stream characterList events and roster
+/// probes via );
+/// //
+/// are user-owned settings that a roster merge must never clobber.
+///
+public sealed class CharacterProfile
+{
+ [JsonRequired]
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Hex-formatted character GUID (e.g. "0x5000000A"), matching
+ /// the convention used elsewhere in the project. Null only for a
+ /// hand-authored fixture/profile entry that has never been through a
+ /// roster merge.
+ ///
+ public string? Id { get; set; }
+
+ public LaunchMode LaunchMode { get; set; } = LaunchMode.GuiSelect;
+
+ public List Plugins { get; set; } = [];
+
+ public List LoginCommands { get; set; } = [];
+}
diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs b/src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs
new file mode 100644
index 00000000..91b3ea6b
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs
@@ -0,0 +1,19 @@
+namespace AcDream.Launcher.Core.Profiles;
+
+///
+/// One roster row as reported by a host's characterList status
+/// event or an on-demand probe launch (Campaign LA spec §3/§6). Mirrors
+/// the wire shape of AcDream.Core.Net.Messages.CharacterList.Character
+/// (uint Id, string Name, uint SecondsGreyedOut) — Launcher.Core
+/// does not reference Core.Net, so this is an independent, intentionally
+/// identical shape fed by the status-stream parser
+/// ().
+/// is carried for completeness but is
+/// NEVER persisted into — ACE reports a
+/// constant 1 during the pending-delete grace window (a boolean, not a
+/// countdown), and the profile schema (§5) has no field for it.
+///
+public readonly record struct CharacterRosterEntry(
+ uint Id,
+ string Name,
+ uint SecondsGreyedOut);
diff --git a/src/AcDream.Launcher.Core/Profiles/LaunchMode.cs b/src/AcDream.Launcher.Core/Profiles/LaunchMode.cs
new file mode 100644
index 00000000..c3c02633
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Profiles/LaunchMode.cs
@@ -0,0 +1,35 @@
+namespace AcDream.Launcher.Core.Profiles;
+
+///
+/// Per-character launch behaviour (Campaign LA spec §5). Stored on each
+/// and read by
+/// to
+/// decide the shape of the composed session-config document.
+///
+///
+/// Serialized as camelCase text ("gui"/"guiSelect"/
+/// "headless") via the explicit
+/// new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, ...)
+/// registered in 's serializer options
+/// — deliberately NOT a per-type [JsonConverter] attribute, which
+/// uses exact member-name casing ("Gui") regardless of the
+/// ambient PropertyNamingPolicy.
+///
+///
+public enum LaunchMode
+{
+ /// Launch the graphical client straight into the world as
+ /// this character.
+ Gui,
+
+ /// Launch the graphical client but stop at the retail
+ /// character-select screen — no character selector is sent. This is
+ /// the default for a character that has never had its launch mode set
+ /// explicitly.
+ GuiSelect,
+
+ /// Launch the no-window host running the idle bot
+ /// policy (enter world, run plugins/login commands, stay until
+ /// stopped).
+ Headless,
+}
diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs
new file mode 100644
index 00000000..771d3dc2
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs
@@ -0,0 +1,16 @@
+using System.Text.Json.Serialization;
+
+namespace AcDream.Launcher.Core.Profiles;
+
+///
+/// Root document for launcher-profiles.json (Campaign LA spec §5)
+/// — the launcher's ONLY credential/profile store. Loaded and saved by
+/// .
+///
+public sealed class LauncherProfileDocument
+{
+ [JsonRequired]
+ public int Version { get; set; } = LauncherProfileStore.CurrentVersion;
+
+ public List Servers { get; set; } = [];
+}
diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs
new file mode 100644
index 00000000..7bef5a6a
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs
@@ -0,0 +1,17 @@
+namespace AcDream.Launcher.Core.Profiles;
+
+/// Thrown for a malformed launcher-profiles.json document
+/// or an invalid CRUD operation against
+/// (unknown target, duplicate name, etc.).
+public sealed class LauncherProfileException : Exception
+{
+ public LauncherProfileException(string message)
+ : base(message)
+ {
+ }
+
+ public LauncherProfileException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs
new file mode 100644
index 00000000..25d30787
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs
@@ -0,0 +1,481 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using AcDream.Platform;
+
+namespace AcDream.Launcher.Core.Profiles;
+
+///
+/// Load/save/CRUD owner for launcher-profiles.json (Campaign LA
+/// spec §5) — the launcher's ONLY credential/profile store, and the
+/// binding surface the Avalonia UI (slice LA4) mutates directly.
+///
+///
+/// A store instance holds the current in-memory
+/// after ; every CRUD method mutates that document in
+/// place so callers can chain store.AddServer(...); store.Save();
+/// without re-threading a returned document through every call.
+///
+///
+public sealed class LauncherProfileStore
+{
+ internal const int CurrentVersion = 1;
+ internal const UnixFileMode OwnerOnlyFileMode =
+ UnixFileMode.UserRead | UnixFileMode.UserWrite;
+
+ private static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ AllowTrailingCommas = false,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = false,
+ ReadCommentHandling = JsonCommentHandling.Disallow,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ WriteIndented = true,
+ Converters =
+ {
+ new JsonStringEnumConverter(
+ JsonNamingPolicy.CamelCase,
+ allowIntegerValues: false),
+ },
+ };
+
+ public LauncherProfileStore(string filePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
+ FilePath = Path.GetFullPath(filePath);
+ Document = new LauncherProfileDocument();
+ }
+
+ /// Resolve the store at the canonical location under
+ ///
+ /// (%APPDATA%\acdream\launcher-profiles.json /
+ /// ~/.config/acdream/launcher-profiles.json).
+ public static LauncherProfileStore ForApplicationPaths(ApplicationPathSet paths)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ return new LauncherProfileStore(
+ Path.Combine(paths.ConfigDirectory, "launcher-profiles.json"));
+ }
+
+ public string FilePath { get; }
+
+ public LauncherProfileDocument Document { get; private set; }
+
+ ///
+ /// Loads from . A
+ /// missing file is not an error — it resolves to a fresh empty
+ /// document (version 1, no servers), matching a never-launched
+ /// installation. Returns true when a file was actually read.
+ ///
+ public bool Load()
+ {
+ // Opportunistic cleanup of a stale ".tmp" left behind by a Save()
+ // that crashed between creating the temp file and the atomic
+ // rename (Campaign LA plan §LA3 review finding F4) — a stray
+ // temp file carries the same plaintext credentials as the real
+ // store and should not linger.
+ DeleteStaleTempFile(FilePath + ".tmp");
+
+ if (!File.Exists(FilePath))
+ {
+ Document = new LauncherProfileDocument();
+ return false;
+ }
+
+ LauncherProfileDocument? document;
+ using (FileStream stream = File.OpenRead(FilePath))
+ {
+ try
+ {
+ document = JsonSerializer.Deserialize(
+ stream,
+ SerializerOptions);
+ }
+ catch (JsonException ex)
+ {
+ throw new LauncherProfileException(
+ $"'{FilePath}' is not a valid launcher profile document.",
+ ex);
+ }
+ }
+
+ if (document is null)
+ {
+ throw new LauncherProfileException($"'{FilePath}' is empty.");
+ }
+
+ if (document.Version != CurrentVersion)
+ {
+ throw new LauncherProfileException(
+ $"Unsupported launcher-profiles version {document.Version}; "
+ + $"expected {CurrentVersion}.");
+ }
+
+ Document = document;
+ return true;
+ }
+
+ ///
+ /// Persists to via a
+ /// write-then-atomic-rename so a crash mid-write never leaves a
+ /// truncated credentials file. On Linux, the temp file is created
+ /// atomically with owner read/write (0600) as its requested creation
+ /// mode — before its path is observable and before any plaintext
+ /// credential is serialized into it. The final path retains that mode
+ /// through the rename (Campaign LA's plaintext-credential decision,
+ /// spec §5, decisions log).
+ /// A failure between temp-file creation and the rename deletes the
+ /// stale temp file rather than leaving it behind.
+ ///
+ public void Save()
+ {
+ string? directory = Path.GetDirectoryName(FilePath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ string tempPath = FilePath + ".tmp";
+ DeleteStaleTempFile(tempPath);
+ try
+ {
+ using (FileStream stream = CreateCredentialTempFile(tempPath))
+ {
+ if (OperatingSystem.IsLinux())
+ {
+ // UnixCreateMode is subject to the process umask. It
+ // guarantees the file is never created with group/other
+ // access; normalize the owner bits while the still-empty
+ // file is open so the persisted contract is exactly 0600.
+ File.SetUnixFileMode(tempPath, OwnerOnlyFileMode);
+ }
+
+ JsonSerializer.Serialize(stream, Document, SerializerOptions);
+ }
+
+ File.Move(tempPath, FilePath, overwrite: true);
+ }
+ catch
+ {
+ DeleteStaleTempFile(tempPath);
+ throw;
+ }
+
+ if (OperatingSystem.IsLinux())
+ {
+ File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
+ }
+ }
+
+ ///
+ /// Builds the exact options used for the plaintext-credential temp
+ /// file. makes creation atomic and
+ /// refuses to follow an existing stale or raced path. On Linux,
+ /// supplies 0600 to
+ /// the OS create operation itself, eliminating the observable
+ /// create-then-chmod window. Windows leaves UnixCreateMode unset and
+ /// therefore retains its normal user-profile ACL behavior.
+ ///
+ internal static FileStreamOptions CreateCredentialTempFileOptions()
+ {
+ var options = new FileStreamOptions
+ {
+ Mode = FileMode.CreateNew,
+ Access = FileAccess.Write,
+ Share = FileShare.None,
+ };
+
+ if (OperatingSystem.IsLinux())
+ {
+ options.UnixCreateMode = OwnerOnlyFileMode;
+ }
+
+ return options;
+ }
+
+ internal static FileStream CreateCredentialTempFile(string tempPath) =>
+ new(tempPath, CreateCredentialTempFileOptions());
+
+ private static void DeleteStaleTempFile(string tempPath)
+ {
+ try
+ {
+ if (File.Exists(tempPath))
+ {
+ File.Delete(tempPath);
+ }
+ }
+ catch
+ {
+ // Best-effort cleanup only — the caller's own exception (a
+ // failed Save()) or the fresh Load() already in progress is
+ // what matters; a cleanup failure must not mask either.
+ }
+ }
+
+ // --- Server CRUD -----------------------------------------------
+
+ public ServerProfile AddServer(string name, string host, int port)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentException.ThrowIfNullOrWhiteSpace(host);
+ RequireValidPort(port);
+
+ if (FindServer(name) is not null)
+ {
+ throw new LauncherProfileException(
+ $"A server named '{name}' already exists.");
+ }
+
+ var server = new ServerProfile { Name = name, Host = host, Port = port };
+ Document.Servers.Add(server);
+ return server;
+ }
+
+ public void EditServer(
+ string name,
+ string? newName = null,
+ string? newHost = null,
+ int? newPort = null)
+ {
+ ServerProfile server = FindServerOrThrow(name);
+
+ if (newName is not null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(newName);
+ if (!string.Equals(newName, server.Name, StringComparison.Ordinal)
+ && FindServer(newName) is not null)
+ {
+ throw new LauncherProfileException(
+ $"A server named '{newName}' already exists.");
+ }
+
+ server.Name = newName;
+ }
+
+ if (newHost is not null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(newHost);
+ server.Host = newHost;
+ }
+
+ if (newPort is not null)
+ {
+ RequireValidPort(newPort.Value);
+ server.Port = newPort.Value;
+ }
+ }
+
+ public void RemoveServer(string name)
+ {
+ ServerProfile server = FindServerOrThrow(name);
+ Document.Servers.Remove(server);
+ }
+
+ // --- Account CRUD ------------------------------------------------
+
+ public AccountProfile AddAccount(string serverName, string account, string password)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(account);
+ ArgumentNullException.ThrowIfNull(password);
+ ServerProfile server = FindServerOrThrow(serverName);
+
+ if (FindAccount(server, account) is not null)
+ {
+ throw new LauncherProfileException(
+ $"Account '{account}' already exists on server '{serverName}'.");
+ }
+
+ var profile = new AccountProfile { Account = account, Password = password };
+ server.Accounts.Add(profile);
+ return profile;
+ }
+
+ public void EditAccount(
+ string serverName,
+ string account,
+ string? newAccount = null,
+ string? newPassword = null)
+ {
+ ServerProfile server = FindServerOrThrow(serverName);
+ AccountProfile profile = FindAccountOrThrow(server, account);
+
+ if (newAccount is not null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(newAccount);
+ if (!string.Equals(newAccount, profile.Account, StringComparison.Ordinal)
+ && FindAccount(server, newAccount) is not null)
+ {
+ throw new LauncherProfileException(
+ $"Account '{newAccount}' already exists on server '{serverName}'.");
+ }
+
+ profile.Account = newAccount;
+ }
+
+ if (newPassword is not null)
+ {
+ profile.Password = newPassword;
+ }
+ }
+
+ public void RemoveAccount(string serverName, string account)
+ {
+ ServerProfile server = FindServerOrThrow(serverName);
+ AccountProfile profile = FindAccountOrThrow(server, account);
+ server.Accounts.Remove(profile);
+ }
+
+ // --- Character settings (roster-driven add/remove; user-edited settings) ---
+
+ ///
+ /// Edits the user-owned settings of an existing character row. There
+ /// is no manual add/remove for characters — the roster (
+ /// ) is the only source of new rows, per
+ /// spec §5/§6.
+ ///
+ public void EditCharacter(
+ string serverName,
+ string account,
+ string characterName,
+ LaunchMode? launchMode = null,
+ IReadOnlyList? plugins = null,
+ IReadOnlyList? loginCommands = null)
+ {
+ ServerProfile server = FindServerOrThrow(serverName);
+ AccountProfile profile = FindAccountOrThrow(server, account);
+ CharacterProfile character = FindCharacterOrThrow(profile, characterName);
+
+ if (launchMode is not null)
+ {
+ character.LaunchMode = launchMode.Value;
+ }
+
+ if (plugins is not null)
+ {
+ character.Plugins = [.. plugins];
+ }
+
+ if (loginCommands is not null)
+ {
+ character.LoginCommands = [.. loginCommands];
+ }
+ }
+
+ ///
+ /// Folds a reported character roster into an account's
+ /// (Campaign LA spec §3/§5/
+ /// §6): every roster entry either updates the name of an existing
+ /// row (matched by ) while
+ /// PRESERVING that row's user settings (,
+ /// ,
+ /// ), or is inserted as a
+ /// new row with default settings (,
+ /// no plugins, no login commands). Existing rows absent from the
+ /// roster are RETAINED unchanged — they may simply be pending-delete
+ /// (ACE keeps deleted characters queryable during the grace window)
+ /// or the roster snapshot may be partial; this store never deletes a
+ /// character row on the caller's behalf.
+ ///
+ public void MergeRoster(
+ string serverName,
+ string account,
+ IReadOnlyList roster)
+ {
+ ArgumentNullException.ThrowIfNull(roster);
+ ServerProfile server = FindServerOrThrow(serverName);
+ AccountProfile profile = FindAccountOrThrow(server, account);
+
+ foreach (CharacterRosterEntry entry in roster)
+ {
+ string idText = CharacterIdFormat.ToHexString(entry.Id);
+
+ // Normalize BOTH sides through TryParse/ToHexString rather
+ // than a raw string compare (Campaign LA plan §LA3 review
+ // finding F10): a stored id that round-trips to the same
+ // uint (different case, or — before this fix — no "0x"
+ // prefix) must match even though its text isn't byte-
+ // identical to the canonical form this method itself always
+ // writes.
+ CharacterProfile? existing = profile.Characters.Find(
+ character => CharacterIdFormat.TryParse(character.Id, out uint existingId)
+ && existingId == entry.Id);
+
+ // Defensive fallback for a row whose id is missing OR
+ // unparseable (e.g. a hand-edited id with no "0x" prefix,
+ // which TryParse now rejects outright) — match by name
+ // instead so a later merge self-heals the id into the
+ // canonical form rather than creating a permanent duplicate
+ // row.
+ existing ??= profile.Characters.Find(
+ character => !CharacterIdFormat.TryParse(character.Id, out _)
+ && string.Equals(
+ character.Name,
+ entry.Name,
+ StringComparison.Ordinal));
+
+ if (existing is not null)
+ {
+ existing.Id = idText;
+ existing.Name = entry.Name;
+ continue;
+ }
+
+ profile.Characters.Add(new CharacterProfile
+ {
+ Id = idText,
+ Name = entry.Name,
+ LaunchMode = LaunchMode.GuiSelect,
+ Plugins = [],
+ LoginCommands = [],
+ });
+ }
+ }
+
+ // --- Lookups -------------------------------------------------------
+
+ private ServerProfile? FindServer(string name) =>
+ Document.Servers.Find(
+ server => string.Equals(server.Name, name, StringComparison.Ordinal));
+
+ private ServerProfile FindServerOrThrow(string name)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ return FindServer(name)
+ ?? throw new LauncherProfileException($"No server named '{name}'.");
+ }
+
+ private static AccountProfile? FindAccount(ServerProfile server, string account) =>
+ server.Accounts.Find(
+ candidate => string.Equals(candidate.Account, account, StringComparison.Ordinal));
+
+ private static AccountProfile FindAccountOrThrow(ServerProfile server, string account)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(account);
+ return FindAccount(server, account)
+ ?? throw new LauncherProfileException(
+ $"No account '{account}' on server '{server.Name}'.");
+ }
+
+ private static CharacterProfile FindCharacterOrThrow(
+ AccountProfile profile,
+ string characterName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
+ return profile.Characters.Find(
+ character => string.Equals(
+ character.Name,
+ characterName,
+ StringComparison.Ordinal))
+ ?? throw new LauncherProfileException(
+ $"No character '{characterName}' on account '{profile.Account}'.");
+ }
+
+ private static void RequireValidPort(int port)
+ {
+ if (port is < 1 or > 65535)
+ {
+ throw new LauncherProfileException(
+ $"Port {port} is outside the valid 1-65535 range.");
+ }
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Profiles/ServerProfile.cs b/src/AcDream.Launcher.Core/Profiles/ServerProfile.cs
new file mode 100644
index 00000000..3a5f0388
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Profiles/ServerProfile.cs
@@ -0,0 +1,19 @@
+using System.Text.Json.Serialization;
+
+namespace AcDream.Launcher.Core.Profiles;
+
+/// One server entry, per Campaign LA spec §5 (manual add — no
+/// published server-list import this campaign).
+public sealed class ServerProfile
+{
+ [JsonRequired]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonRequired]
+ public string Host { get; set; } = string.Empty;
+
+ [JsonRequired]
+ public int Port { get; set; }
+
+ public List Accounts { get; set; } = [];
+}
diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs
new file mode 100644
index 00000000..c090b6e3
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs
@@ -0,0 +1,96 @@
+namespace AcDream.Launcher.Core.Status;
+
+///
+/// One parsed line of a host's status.jsonl stream (Campaign LA
+/// spec §6). Every event carries the versioned envelope
+/// (v/e/t/sessionId) plus its own typed
+/// payload. See for the wire shape and
+/// for the
+/// incremental reader that produces these.
+///
+public abstract record StatusEvent
+{
+ public required int V { get; init; }
+
+ public required string E { get; init; }
+
+ public required DateTimeOffset T { get; init; }
+
+ public required string SessionId { get; init; }
+}
+
+public sealed record StartedStatusEvent : StatusEvent;
+
+public sealed record ConnectedStatusEvent : StatusEvent;
+
+public readonly record struct StatusCharacterEntry(
+ uint Id,
+ string Name,
+ uint SecondsGreyedOut);
+
+public sealed record CharacterListStatusEvent : StatusEvent
+{
+ public required string AccountName { get; init; }
+
+ public required int SlotCount { get; init; }
+
+ public required IReadOnlyList Characters { get; init; }
+}
+
+public sealed record EnteredWorldStatusEvent : StatusEvent
+{
+ public required uint CharacterId { get; init; }
+
+ public required string CharacterName { get; init; }
+}
+
+public sealed record PluginLoadedStatusEvent : StatusEvent
+{
+ public required string Plugin { get; init; }
+}
+
+public sealed record PluginFailedStatusEvent : StatusEvent
+{
+ public required string Plugin { get; init; }
+
+ public required string Error { get; init; }
+}
+
+public sealed record DisconnectedStatusEvent : StatusEvent
+{
+ public required string Reason { get; init; }
+}
+
+public sealed record ExitedStatusEvent : StatusEvent
+{
+ public required int Code { get; init; }
+
+ public required string Reason { get; init; }
+}
+
+///
+/// A JSON-object status line whose non-empty string e value this
+/// reader does not recognize. The tailer never throws on an unrecognized
+/// event — an older launcher reading a newer host's stream
+/// degrades to seeing rows instead of
+/// crashing.
+///
+public sealed record UnknownStatusEvent : StatusEvent
+{
+ public required string RawJson { get; init; }
+}
+
+///
+/// A complete JSON value that is not an object, an object without a
+/// usable event name, or a known event whose pinned v1 envelope/payload
+/// does not match its expected shape. Distinguished from
+/// (Campaign
+/// LA plan §LA3 review finding F12) so a launcher can tell "a newer/older
+/// host sent an event I've never heard of" apart from "a host I recognize
+/// sent me garbage for an event I do know" — the two cases call for
+/// different diagnostics. The tailer never throws for either case.
+///
+public sealed record MalformedStatusEvent : StatusEvent
+{
+ public required string Error { get; init; }
+}
diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs
new file mode 100644
index 00000000..f6811a60
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs
@@ -0,0 +1,391 @@
+using System.Text.Json;
+
+namespace AcDream.Launcher.Core.Status;
+
+///
+/// Parses one status.jsonl line (Campaign LA spec §6) into a typed
+/// . Wire shape: every line is a flat JSON
+/// object carrying the envelope (v, e, t,
+/// sessionId) alongside that event's own fields — e.g.
+/// {"v":1,"e":"characterList","t":"...","sessionId":"...",
+/// "accountName":"...","slotCount":6,"characters":[...]}.
+///
+///
+/// Never throws: a null/blank/malformed-JSON line, a complete JSON value
+/// with a non-object root, an unrecognized e value, or a recognized
+/// e whose envelope/payload does not match the pinned v1 shape all
+/// degrade to a typed event ( or
+/// ) rather than an exception. A launcher
+/// must keep tailing a session's status stream even against a host running a
+/// newer/older wire version or a host that writes a bad line.
+///
+///
+public static class StatusEventParser
+{
+ public static StatusEvent Parse(string line)
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ return UnknownEvent(line ?? string.Empty);
+ }
+
+ JsonDocument document;
+ try
+ {
+ document = JsonDocument.Parse(line);
+ }
+ catch (JsonException)
+ {
+ return UnknownEvent(line);
+ }
+
+ using (document)
+ {
+ JsonElement root = document.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return MalformedEvent(
+ v: 0,
+ e: string.Empty,
+ t: default,
+ sessionId: string.Empty,
+ "status event root is not a JSON object.");
+ }
+
+ if (!TryGetEventName(root, out string e, out string eventNameError))
+ {
+ return MalformedEvent(
+ GetInt32OrDefault(root, "v"),
+ e,
+ GetDateTimeOffsetOrDefault(root, "t"),
+ GetStringOrDefault(root, "sessionId"),
+ eventNameError);
+ }
+
+ // A genuinely unknown event name is the forward-compatibility
+ // case. Retain its best-effort envelope and raw JSON without
+ // imposing this launcher's known-event envelope/payload schema.
+ if (!IsKnownEventName(e))
+ {
+ return new UnknownStatusEvent
+ {
+ V = GetInt32OrDefault(root, "v"),
+ E = e,
+ T = GetDateTimeOffsetOrDefault(root, "t"),
+ SessionId = GetStringOrDefault(root, "sessionId"),
+ RawJson = line,
+ };
+ }
+
+ try
+ {
+ // The pinned v1 envelope applies to every known event,
+ // including payload-free started/connected rows. Defaulting
+ // malformed fields would turn corrupt or cross-version input
+ // into an apparently valid typed event.
+ int v = RequireVersionOne(root);
+ DateTimeOffset t = RequireUtcTimestamp(root, "t");
+ string sessionId = RequireNonEmptyString(root, "sessionId");
+
+ return e switch
+ {
+ "started" =>
+ new StartedStatusEvent { V = v, E = e, T = t, SessionId = sessionId },
+ "connected" =>
+ new ConnectedStatusEvent { V = v, E = e, T = t, SessionId = sessionId },
+ "characterList" =>
+ ParseCharacterList(root, v, e, t, sessionId),
+ "enteredWorld" =>
+ ParseEnteredWorld(root, v, e, t, sessionId),
+ "pluginLoaded" =>
+ ParsePluginLoaded(root, v, e, t, sessionId),
+ "pluginFailed" =>
+ ParsePluginFailed(root, v, e, t, sessionId),
+ "disconnected" =>
+ ParseDisconnected(root, v, e, t, sessionId),
+ "exited" =>
+ ParseExited(root, v, e, t, sessionId),
+ _ => throw new InvalidOperationException("known event dispatch is incomplete."),
+ };
+ }
+ catch (Exception ex) when (ex is FormatException or InvalidOperationException)
+ {
+ return MalformedEvent(
+ GetInt32OrDefault(root, "v"),
+ e,
+ GetDateTimeOffsetOrDefault(root, "t"),
+ GetStringOrDefault(root, "sessionId"),
+ ex.Message);
+ }
+ }
+ }
+
+ private static bool IsKnownEventName(string eventName) =>
+ eventName is
+ "started" or
+ "connected" or
+ "characterList" or
+ "enteredWorld" or
+ "pluginLoaded" or
+ "pluginFailed" or
+ "disconnected" or
+ "exited";
+
+ private static bool TryGetEventName(
+ JsonElement root,
+ out string eventName,
+ out string error)
+ {
+ if (!root.TryGetProperty("e", out JsonElement element))
+ {
+ eventName = string.Empty;
+ error = "status event is missing 'e'.";
+ return false;
+ }
+
+ if (element.ValueKind != JsonValueKind.String)
+ {
+ eventName = string.Empty;
+ error = "status event field 'e' is not a string.";
+ return false;
+ }
+
+ eventName = element.GetString() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(eventName))
+ {
+ error = "status event field 'e' is empty.";
+ return false;
+ }
+
+ error = string.Empty;
+ return true;
+ }
+
+ private static UnknownStatusEvent UnknownEvent(string rawLine) =>
+ new()
+ {
+ V = 0,
+ E = string.Empty,
+ T = default,
+ SessionId = string.Empty,
+ RawJson = rawLine,
+ };
+
+ private static MalformedStatusEvent MalformedEvent(
+ int v,
+ string e,
+ DateTimeOffset t,
+ string sessionId,
+ string error) =>
+ new()
+ {
+ V = v,
+ E = e,
+ T = t,
+ SessionId = sessionId,
+ Error = error,
+ };
+
+ private static StatusEvent ParseCharacterList(
+ JsonElement root,
+ int v,
+ string e,
+ DateTimeOffset t,
+ string sessionId)
+ {
+ string accountName = RequireString(root, "accountName");
+ int slotCount = RequireInt32(root, "slotCount");
+ JsonElement charactersElement = RequireProperty(root, "characters");
+
+ var characters = new List();
+ foreach (JsonElement item in charactersElement.EnumerateArray())
+ {
+ uint id = RequireUInt32(item, "id");
+ string name = RequireString(item, "name");
+ uint secondsGreyedOut = RequireUInt32(item, "secondsGreyedOut");
+ characters.Add(new StatusCharacterEntry(id, name, secondsGreyedOut));
+ }
+
+ return new CharacterListStatusEvent
+ {
+ V = v,
+ E = e,
+ T = t,
+ SessionId = sessionId,
+ AccountName = accountName,
+ SlotCount = slotCount,
+ Characters = characters,
+ };
+ }
+
+ private static StatusEvent ParseEnteredWorld(
+ JsonElement root,
+ int v,
+ string e,
+ DateTimeOffset t,
+ string sessionId) =>
+ new EnteredWorldStatusEvent
+ {
+ V = v,
+ E = e,
+ T = t,
+ SessionId = sessionId,
+ CharacterId = RequireUInt32(root, "characterId"),
+ CharacterName = RequireString(root, "characterName"),
+ };
+
+ private static StatusEvent ParsePluginLoaded(
+ JsonElement root,
+ int v,
+ string e,
+ DateTimeOffset t,
+ string sessionId) =>
+ new PluginLoadedStatusEvent
+ {
+ V = v,
+ E = e,
+ T = t,
+ SessionId = sessionId,
+ Plugin = RequireString(root, "plugin"),
+ };
+
+ private static StatusEvent ParsePluginFailed(
+ JsonElement root,
+ int v,
+ string e,
+ DateTimeOffset t,
+ string sessionId) =>
+ new PluginFailedStatusEvent
+ {
+ V = v,
+ E = e,
+ T = t,
+ SessionId = sessionId,
+ Plugin = RequireString(root, "plugin"),
+ Error = RequireString(root, "error"),
+ };
+
+ private static StatusEvent ParseDisconnected(
+ JsonElement root,
+ int v,
+ string e,
+ DateTimeOffset t,
+ string sessionId) =>
+ new DisconnectedStatusEvent
+ {
+ V = v,
+ E = e,
+ T = t,
+ SessionId = sessionId,
+ Reason = RequireString(root, "reason"),
+ };
+
+ private static StatusEvent ParseExited(
+ JsonElement root,
+ int v,
+ string e,
+ DateTimeOffset t,
+ string sessionId) =>
+ new ExitedStatusEvent
+ {
+ V = v,
+ E = e,
+ T = t,
+ SessionId = sessionId,
+ Code = RequireInt32(root, "code"),
+ Reason = RequireString(root, "reason"),
+ };
+
+ private static int GetInt32OrDefault(JsonElement root, string name) =>
+ root.TryGetProperty(name, out JsonElement element)
+ && element.ValueKind == JsonValueKind.Number
+ && element.TryGetInt32(out int value)
+ ? value
+ : 0;
+
+ private static string GetStringOrDefault(JsonElement root, string name) =>
+ root.TryGetProperty(name, out JsonElement element)
+ && element.ValueKind == JsonValueKind.String
+ ? element.GetString() ?? string.Empty
+ : string.Empty;
+
+ private static DateTimeOffset GetDateTimeOffsetOrDefault(
+ JsonElement root,
+ string name) =>
+ root.TryGetProperty(name, out JsonElement element)
+ && element.ValueKind == JsonValueKind.String
+ && element.TryGetDateTimeOffset(out DateTimeOffset value)
+ && value.Offset == TimeSpan.Zero
+ ? value
+ : default;
+
+ private static JsonElement RequireProperty(JsonElement root, string name) =>
+ root.TryGetProperty(name, out JsonElement element)
+ ? element
+ : throw new FormatException($"status event is missing '{name}'.");
+
+ private static string RequireString(JsonElement root, string name)
+ {
+ JsonElement element = RequireProperty(root, name);
+ return element.ValueKind == JsonValueKind.String
+ ? element.GetString() ?? string.Empty
+ : throw new FormatException($"status event field '{name}' is not a string.");
+ }
+
+ private static int RequireInt32(JsonElement root, string name)
+ {
+ JsonElement element = RequireProperty(root, name);
+ return element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out int value)
+ ? value
+ : throw new FormatException($"status event field '{name}' is not an integer.");
+ }
+
+ private static int RequireVersionOne(JsonElement root)
+ {
+ int version = RequireInt32(root, "v");
+ return version == 1
+ ? version
+ : throw new FormatException(
+ $"status event version is {version}; expected 1.");
+ }
+
+ private static string RequireNonEmptyString(JsonElement root, string name)
+ {
+ string value = RequireString(root, name);
+ return !string.IsNullOrWhiteSpace(value)
+ ? value
+ : throw new FormatException($"status event field '{name}' is empty.");
+ }
+
+ private static DateTimeOffset RequireUtcTimestamp(JsonElement root, string name)
+ {
+ JsonElement element = RequireProperty(root, name);
+ if (element.ValueKind != JsonValueKind.String)
+ {
+ throw new FormatException(
+ $"status event field '{name}' is not an ISO-8601 UTC string.");
+ }
+
+ string text = element.GetString() ?? string.Empty;
+ bool hasExplicitUtcOffset = text.EndsWith('Z')
+ || text.EndsWith("+00:00", StringComparison.Ordinal);
+ if (!hasExplicitUtcOffset
+ || !element.TryGetDateTimeOffset(out DateTimeOffset value)
+ || value.Offset != TimeSpan.Zero)
+ {
+ throw new FormatException(
+ $"status event field '{name}' is not an ISO-8601 UTC timestamp.");
+ }
+
+ return value;
+ }
+
+ private static uint RequireUInt32(JsonElement root, string name)
+ {
+ JsonElement element = RequireProperty(root, name);
+ return element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out uint value)
+ ? value
+ : throw new FormatException(
+ $"status event field '{name}' is not an unsigned integer.");
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs
new file mode 100644
index 00000000..a37b4075
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs
@@ -0,0 +1,139 @@
+using System.Text;
+
+namespace AcDream.Launcher.Core.Status;
+
+///
+/// Incremental reader over a host's status.jsonl file (Campaign LA
+/// spec §3/§6). Each call to returns the
+/// events that arrived since the previous call, tolerating:
+///
+/// - the file not existing yet (the launcher may start tailing
+/// before the host has written its first line — returns no events, not
+/// an error);
+/// - a partial last line (the host may be mid-write when polled —
+/// the tailer only advances its read position past the last confirmed
+/// '\n'; a still-incomplete tail is re-read, combined with
+/// whatever gets appended, on the next poll — never parsed while
+/// truncated).
+///
+/// One tailer instance owns one file's read position; construct a new
+/// one per session.
+///
+public sealed class StatusFileTailer
+{
+ private readonly string _path;
+ private long _position;
+
+ public StatusFileTailer(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ _path = path;
+ }
+
+ ///
+ /// Reads and parses every complete line appended to the file since
+ /// the last call. Returns an empty list (never null, never throws)
+ /// when the file doesn't exist yet, has been deleted/rotated between
+ /// the existence check and the open (a TOCTOU window — Campaign LA
+ /// plan §LA3 review finding F7), or nothing new/complete has arrived
+ /// since the last poll.
+ ///
+ public IReadOnlyList ReadNewEvents()
+ {
+ try
+ {
+ return ReadNewEventsCore();
+ }
+ catch (Exception ex) when (
+ ex is FileNotFoundException or DirectoryNotFoundException or IOException)
+ {
+ // The host process deleted/rotated the file (or its
+ // directory) between File.Exists and the open below, or
+ // another transient I/O condition hit mid-read — degrade to
+ // "nothing new this poll" rather than throwing out of a
+ // method documented never to throw; the next poll picks up
+ // wherever the file (or its replacement) actually is.
+ return [];
+ }
+ }
+
+ private IReadOnlyList ReadNewEventsCore()
+ {
+ if (!File.Exists(_path))
+ {
+ return [];
+ }
+
+ using var stream = new FileStream(
+ _path,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.ReadWrite | FileShare.Delete);
+
+ if (stream.Length < _position)
+ {
+ // The file was truncated/replaced under us (e.g. a fresh
+ // session reusing a stale path) — restart from the top
+ // rather than throwing or silently missing the new content.
+ _position = 0;
+ }
+
+ if (stream.Length == _position)
+ {
+ return [];
+ }
+
+ stream.Seek(_position, SeekOrigin.Begin);
+ int unreadByteCount = checked((int)(stream.Length - _position));
+ byte[] buffer = new byte[unreadByteCount];
+ int totalRead = 0;
+ while (totalRead < unreadByteCount)
+ {
+ int read = stream.Read(buffer, totalRead, unreadByteCount - totalRead);
+ if (read == 0)
+ {
+ break;
+ }
+
+ totalRead += read;
+ }
+
+ var events = new List();
+ int lineStart = 0;
+
+ // How far into `buffer` we've confirmed a complete line — this
+ // is where `_position` advances to. Bytes after this point (an
+ // in-progress line with no trailing '\n' yet) are simply left
+ // unread on disk; the next poll re-reads them from `_position`
+ // combined with whatever the writer appends in between. No
+ // separate in-memory carry-over buffer is needed.
+ int consumedThroughIndex = 0;
+
+ for (int i = 0; i < totalRead; i++)
+ {
+ if (buffer[i] != (byte)'\n')
+ {
+ continue;
+ }
+
+ int lineEnd = i;
+ if (lineEnd > lineStart && buffer[lineEnd - 1] == (byte)'\r')
+ {
+ lineEnd--;
+ }
+
+ if (lineEnd > lineStart)
+ {
+ string rawLine = Encoding.UTF8.GetString(buffer, lineStart, lineEnd - lineStart);
+ events.Add(StatusEventParser.Parse(rawLine));
+ }
+
+ lineStart = i + 1;
+ consumedThroughIndex = lineStart;
+ }
+
+ _position += consumedThroughIndex;
+
+ return events;
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj
new file mode 100644
index 00000000..6f77e682
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ latest
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/AcDream.Launcher.Core.Tests/Integrity/FileIntegrityTests.cs b/tests/AcDream.Launcher.Core.Tests/Integrity/FileIntegrityTests.cs
new file mode 100644
index 00000000..106106c7
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Integrity/FileIntegrityTests.cs
@@ -0,0 +1,98 @@
+using System.Security.Cryptography;
+using System.Text;
+using AcDream.Launcher.Core.Integrity;
+
+namespace AcDream.Launcher.Core.Tests.Integrity;
+
+public sealed class FileIntegrityTests : IDisposable
+{
+ private readonly string _root;
+
+ public FileIntegrityTests()
+ {
+ _root = Path.Combine(
+ Path.GetTempPath(),
+ "acdream-launcher-integrity-tests",
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_root);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void ComputeSha256HexMatchesTheFrameworkHasher()
+ {
+ string path = Path.Combine(_root, "file.bin");
+ byte[] content = Encoding.UTF8.GetBytes("acdream launcher integrity fixture");
+ File.WriteAllBytes(path, content);
+ string expected = Convert.ToHexStringLower(SHA256.HashData(content));
+
+ string actual = FileIntegrity.ComputeSha256Hex(path);
+
+ Assert.Equal(expected, actual);
+ }
+
+ [Fact]
+ public async Task ComputeSha256HexAsyncMatchesTheSyncResult()
+ {
+ string path = Path.Combine(_root, "file.bin");
+ File.WriteAllBytes(path, Encoding.UTF8.GetBytes("async path fixture"));
+
+ string sync = FileIntegrity.ComputeSha256Hex(path);
+ string asyncResult = await FileIntegrity.ComputeSha256HexAsync(path);
+
+ Assert.Equal(sync, asyncResult);
+ }
+
+ [Fact]
+ public void VerifySucceedsForAMatchingDigestRegardlessOfCase()
+ {
+ string path = Path.Combine(_root, "file.bin");
+ File.WriteAllBytes(path, Encoding.UTF8.GetBytes("case-insensitive fixture"));
+ string lower = FileIntegrity.ComputeSha256Hex(path);
+
+ Assert.True(FileIntegrity.Verify(path, lower));
+ Assert.True(FileIntegrity.Verify(path, lower.ToUpperInvariant()));
+ }
+
+ [Fact]
+ public void VerifyFailsForAMismatchedDigest()
+ {
+ string path = Path.Combine(_root, "file.bin");
+ File.WriteAllBytes(path, Encoding.UTF8.GetBytes("original content"));
+
+ Assert.False(FileIntegrity.Verify(path, new string('0', 64)));
+ }
+
+ [Fact]
+ public void DifferentContentProducesDifferentDigests()
+ {
+ string pathA = Path.Combine(_root, "a.bin");
+ string pathB = Path.Combine(_root, "b.bin");
+ File.WriteAllBytes(pathA, Encoding.UTF8.GetBytes("content A"));
+ File.WriteAllBytes(pathB, Encoding.UTF8.GetBytes("content B"));
+
+ Assert.NotEqual(
+ FileIntegrity.ComputeSha256Hex(pathA),
+ FileIntegrity.ComputeSha256Hex(pathB));
+ }
+
+ [Fact]
+ public void EmptyFileHashesToTheWellKnownSha256OfEmptyInput()
+ {
+ string path = Path.Combine(_root, "empty.bin");
+ File.WriteAllBytes(path, []);
+
+ string actual = FileIntegrity.ComputeSha256Hex(path);
+
+ Assert.Equal(
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ actual);
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/LauncherCoreDependencyBoundaryTests.cs b/tests/AcDream.Launcher.Core.Tests/LauncherCoreDependencyBoundaryTests.cs
new file mode 100644
index 00000000..8009b02c
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/LauncherCoreDependencyBoundaryTests.cs
@@ -0,0 +1,79 @@
+using System.Runtime.CompilerServices;
+using System.Xml.Linq;
+
+namespace AcDream.Launcher.Core.Tests;
+
+// Campaign LA plan §LA3 review finding F5: AcDream.Launcher.Core's entire
+// premise (spec §LA3, SessionConfigDocument.cs's "PINNED CONTRACT" remarks)
+// is being the BCL-plus-Platform-only assembly the external Avalonia
+// launcher (LA4) can reference without pulling in any game-solution
+// dependency. That contract is what this guard enforces — the csproj must
+// declare exactly one ProjectReference (AcDream.Platform) and zero
+// PackageReference entries, forever, in the same spirit as Platform's,
+// Runtime's, and Headless's dependency-boundary guards.
+public sealed class LauncherCoreDependencyBoundaryTests
+{
+ [Fact]
+ public void LauncherCoreProjectReferencesOnlyPlatformAndDeclaresNoPackages()
+ {
+ string repositoryRoot = FindRepositoryRoot();
+ string projectPath = Path.Combine(
+ repositoryRoot,
+ "src",
+ "AcDream.Launcher.Core",
+ "AcDream.Launcher.Core.csproj");
+ var project = XDocument.Load(projectPath);
+
+ var projectReferences = project.Descendants("ProjectReference")
+ .Select(element => element.Attribute("Include")?.Value)
+ // The csproj is authored with Windows-style "..\Foo\Foo.csproj"
+ // separators; Path.GetFileName only recognizes the platform's
+ // own separator, so on Linux it would return the whole
+ // relative path unchanged instead of just the filename.
+ // Normalizing to '/' first keeps this assertion
+ // platform-agnostic (this project's tests run under both
+ // native Windows and WSL — see Campaign LA plan §LA3 review
+ // finding F5's acceptance).
+ .Select(include => include is null
+ ? null
+ : Path.GetFileName(include.Replace('\\', '/')))
+ .ToList();
+
+ Assert.Equal(["AcDream.Platform.csproj"], projectReferences);
+ Assert.Empty(project.Descendants("PackageReference"));
+ }
+
+ private static string FindRepositoryRoot(
+ [CallerFilePath] string sourcePath = "")
+ {
+ string[] starts =
+ {
+ Path.GetDirectoryName(sourcePath) ?? string.Empty,
+ Directory.GetCurrentDirectory(),
+ AppContext.BaseDirectory,
+ };
+ foreach (string start in starts)
+ {
+ if (string.IsNullOrEmpty(start))
+ {
+ continue;
+ }
+
+ var directory = new DirectoryInfo(start);
+ while (directory is not null)
+ {
+ if (File.Exists(Path.Combine(
+ directory.FullName,
+ "AcDream.slnx")))
+ {
+ return directory.FullName;
+ }
+
+ directory = directory.Parent;
+ }
+ }
+
+ throw new DirectoryNotFoundException(
+ "Could not find AcDream.slnx above the source, working, or output directory.");
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
new file mode 100644
index 00000000..4ec1431e
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
@@ -0,0 +1,525 @@
+using System.Collections.Concurrent;
+using System.Threading;
+using AcDream.Launcher.Core.Launching;
+
+namespace AcDream.Launcher.Core.Tests.Launching;
+
+public sealed class LauncherProcessSupervisorTests
+{
+ [Fact]
+ public void StartWritesPasswordThenClosesStdinAndTransitionsToRunning()
+ {
+ var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
+ using var supervisor = new LauncherProcessSupervisor(factory);
+ var states = new List();
+ supervisor.StateChanged += (_, s) => states.Add(s);
+
+ supervisor.Start(Spec(), "S3cretPassw0rd!");
+
+ FakeChildProcess fake = factory.LastCreated!;
+ Assert.True(fake.Started);
+ Assert.Equal("S3cretPassw0rd!\n", fake.StandardInputText);
+ Assert.True(fake.StandardInputClosed);
+ Assert.Equal(LauncherSessionState.Running, supervisor.State);
+ Assert.Equal(
+ [LauncherSessionState.Starting, LauncherSessionState.Running],
+ states);
+ }
+
+ [Fact]
+ public void StartWithNullPasswordClosesStdinWithoutWriting()
+ {
+ var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
+ using var supervisor = new LauncherProcessSupervisor(factory);
+
+ supervisor.Start(Spec(), password: null);
+
+ FakeChildProcess fake = factory.LastCreated!;
+ Assert.Equal(string.Empty, fake.StandardInputText);
+ Assert.True(fake.StandardInputClosed);
+ }
+
+ [Fact]
+ public void StartTwiceOnTheSameSupervisorThrows()
+ {
+ var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
+ using var supervisor = new LauncherProcessSupervisor(factory);
+ supervisor.Start(Spec(), "pw");
+
+ Assert.Throws(() => supervisor.Start(Spec(), "pw"));
+ }
+
+ [Fact]
+ public void StopCallsCloseMainWindowAndSucceedsWithoutKillWhenTheProcessExitsInTime()
+ {
+ var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
+ using var supervisor = new LauncherProcessSupervisor(factory);
+ supervisor.Start(Spec(), "pw");
+
+ supervisor.Stop(TimeSpan.FromMilliseconds(50));
+
+ FakeChildProcess fake = factory.LastCreated!;
+ Assert.True(fake.CloseMainWindowCalled);
+ Assert.Equal(0, fake.KillCallCount);
+ Assert.Equal(LauncherSessionState.Exited, supervisor.State);
+ Assert.Equal(0, supervisor.ExitCode);
+ }
+
+ [Fact]
+ public void StopFallsBackToKillWhenTheProcessDoesNotExitWithinTheTimeout()
+ {
+ var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: false);
+ using var supervisor = new LauncherProcessSupervisor(factory);
+ supervisor.Start(Spec(), "pw");
+
+ supervisor.Stop(TimeSpan.FromMilliseconds(50));
+
+ FakeChildProcess fake = factory.LastCreated!;
+ Assert.True(fake.CloseMainWindowCalled);
+ Assert.Equal(1, fake.KillCallCount);
+ Assert.Equal(LauncherSessionState.Exited, supervisor.State);
+ }
+
+ [Fact]
+ public void StopIsANoOpBeforeStart()
+ {
+ var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
+ using var supervisor = new LauncherProcessSupervisor(factory);
+
+ supervisor.Stop(TimeSpan.FromMilliseconds(50));
+
+ Assert.Null(factory.LastCreated);
+ Assert.Equal(LauncherSessionState.Starting, supervisor.State);
+ }
+
+ [Fact]
+ public void StopIsANoOpAfterTheProcessHasAlreadyExited()
+ {
+ var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
+ using var supervisor = new LauncherProcessSupervisor(factory);
+ supervisor.Start(Spec(), "pw");
+ supervisor.Stop(TimeSpan.FromMilliseconds(50));
+ FakeChildProcess fake = factory.LastCreated!;
+ Assert.Equal(0, fake.KillCallCount);
+
+ supervisor.Stop(TimeSpan.FromMilliseconds(50));
+
+ // CloseMainWindow was called exactly once (the first Stop) —
+ // Stop after exit does not re-invoke the graceful/kill dance.
+ Assert.Equal(1, fake.CloseMainWindowCallCount);
+ Assert.Equal(0, fake.KillCallCount);
+ }
+
+ [Fact]
+ public void StopAttemptsTheGracefulStopSignalBeforeCloseMainWindow()
+ {
+ var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
+ using var supervisor = new LauncherProcessSupervisor(factory);
+ supervisor.Start(Spec(), "pw");
+
+ supervisor.Stop(TimeSpan.FromMilliseconds(50));
+
+ FakeChildProcess fake = factory.LastCreated!;
+ Assert.Equal(1, fake.TryRequestGracefulStopCallCount);
+ Assert.Equal(["gracefulStop", "closeMainWindow"], fake.CallOrder);
+ }
+
+ [Fact]
+ public void GracefulStopSignalSendsSigintToARealChildOnLinux()
+ {
+ // Review finding F3, proven end to end against the real
+ // SystemChildProcess: Stop() sends SIGINT before falling back to
+ // CloseMainWindow/Kill, and the trapped child exits gracefully
+ // with code 0 well within the timeout. A hard SIGKILL fallback
+ // (or a signal arriving before the shell's trap is even armed,
+ // which falls back to the shell's default SIGINT disposition —
+ // terminate with exit 128+2=130) would not produce this clean
+ // exit code, so ExitCode == 0 is a hermetic proof the graceful
+ // path is what actually stopped the child.
+ if (!OperatingSystem.IsLinux())
+ return;
+
+ string readyMarker = Path.Combine(
+ Path.GetTempPath(), "acdream-la3-sigint-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ using var supervisor = new LauncherProcessSupervisor();
+ var exited = new ManualResetEventSlim(false);
+ supervisor.StateChanged += (_, s) =>
+ {
+ if (s == LauncherSessionState.Exited)
+ exited.Set();
+ };
+
+ supervisor.Start(
+ new LauncherProcessSpec(
+ "/bin/bash",
+ [
+ "-c",
+ "trap 'kill $child 2>/dev/null; exit 0' INT; "
+ + "sleep 30 & child=$!; "
+ + $"touch '{readyMarker}'; "
+ + "wait $child",
+ ]),
+ password: null);
+
+ // Wait for the child to prove its SIGINT trap is armed AND
+ // its background `sleep` is tracked (touch runs after both,
+ // in program order) before sending the signal — otherwise
+ // this test would race the shell's own startup and
+ // intermittently observe the shell's default SIGINT
+ // disposition instead of the trap, or leave an untracked
+ // orphaned `sleep`.
+ DateTime readyDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
+ while (!File.Exists(readyMarker) && DateTime.UtcNow < readyDeadline)
+ {
+ Thread.Sleep(10);
+ }
+
+ Assert.True(
+ File.Exists(readyMarker),
+ "child did not signal trap-armed readiness in time");
+
+ supervisor.Stop(TimeSpan.FromSeconds(10));
+
+ Assert.True(exited.Wait(TimeSpan.FromSeconds(5)));
+ Assert.Equal(0, supervisor.ExitCode);
+ }
+ finally
+ {
+ try
+ {
+ File.Delete(readyMarker);
+ }
+ catch (IOException)
+ {
+ }
+ }
+ }
+
+ [Fact]
+ public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted()
+ {
+ var factory = new FakeChildProcessFactory(
+ exitsWithinStopTimeout: true,
+ throwOnStandardInputWrite: true);
+ using var supervisor = new LauncherProcessSupervisor(factory);
+
+ Assert.ThrowsAny(() => supervisor.Start(Spec(), "pw"));
+
+ FakeChildProcess fake = factory.LastCreated!;
+ Assert.True(fake.Started);
+ Assert.Equal(1, fake.KillCallCount);
+ Assert.True(fake.Disposed);
+ }
+
+ [Fact]
+ public void SetStateIsMonotonicAndIgnoresATransitionAfterExited()
+ {
+ // Simulates the child exiting synchronously from inside
+ // process.Start() itself (a child that dies immediately) — the
+ // trailing SetState(Running) at the end of Start() must not
+ // resurrect State from the terminal Exited it already reached,
+ // nor fire a spurious StateChanged(Running).
+ var factory = new FakeChildProcessFactory(
+ exitsWithinStopTimeout: true,
+ exitDuringStart: true);
+ using var supervisor = new LauncherProcessSupervisor(factory);
+ var states = new List();
+ supervisor.StateChanged += (_, s) => states.Add(s);
+
+ supervisor.Start(Spec(), "pw");
+
+ Assert.Equal(LauncherSessionState.Exited, supervisor.State);
+ Assert.Equal(
+ [LauncherSessionState.Starting, LauncherSessionState.Exited],
+ states);
+ }
+
+ [Fact]
+ public async Task ConcurrentRunningAndExitedPublicationsRemainMonotonicAndInOrder()
+ {
+ var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
+ using var supervisor = new LauncherProcessSupervisor(factory);
+ using var runningPublicationEntered = new ManualResetEventSlim(false);
+ using var releaseRunningPublication = new ManualResetEventSlim(false);
+ var states = new ConcurrentQueue();
+
+ supervisor.StateChanged += (_, state) =>
+ {
+ if (state == LauncherSessionState.Running)
+ {
+ runningPublicationEntered.Set();
+ Assert.True(
+ releaseRunningPublication.Wait(TimeSpan.FromSeconds(5)),
+ "test did not release the Running publication barrier");
+ }
+
+ states.Enqueue(state);
+ };
+
+ Task startTask = Task.Run(() => supervisor.Start(Spec(), "pw"));
+ try
+ {
+ Assert.True(
+ runningPublicationEntered.Wait(TimeSpan.FromSeconds(5)),
+ "Running publication did not reach the test barrier");
+
+ // Commit Exited while Running's observer is deliberately
+ // paused. Storage reaches the terminal state immediately, but
+ // publication must queue behind the earlier Running event.
+ factory.LastCreated!.ExitForTest(17);
+ Assert.Equal(LauncherSessionState.Exited, supervisor.State);
+ }
+ finally
+ {
+ releaseRunningPublication.Set();
+ }
+
+ await startTask.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal(
+ [
+ LauncherSessionState.Starting,
+ LauncherSessionState.Running,
+ LauncherSessionState.Exited,
+ ],
+ states);
+ Assert.Equal(17, supervisor.ExitCode);
+ }
+
+ [Fact]
+ public void StateChangedPublicationAllowsCrossThreadReadsAndReentrantExit()
+ {
+ var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
+ using var supervisor = new LauncherProcessSupervisor(factory);
+ var states = new List();
+
+ supervisor.StateChanged += (_, state) =>
+ {
+ states.Add(state);
+ if (state != LauncherSessionState.Running)
+ {
+ return;
+ }
+
+ // A publisher that invokes callbacks while holding the state
+ // gate deadlocks this cross-thread read. The callback also
+ // raises Exited re-entrantly; it must queue after Running rather
+ // than recurse out of order or deadlock.
+ Task readTask = Task.Run(() => supervisor.State);
+ Assert.True(readTask.Wait(TimeSpan.FromSeconds(5)));
+ Assert.Equal(LauncherSessionState.Running, readTask.Result);
+ factory.LastCreated!.ExitForTest(23);
+ };
+
+ supervisor.Start(Spec(), "pw");
+
+ Assert.Equal(
+ [
+ LauncherSessionState.Starting,
+ LauncherSessionState.Running,
+ LauncherSessionState.Exited,
+ ],
+ states);
+ Assert.Equal(LauncherSessionState.Exited, supervisor.State);
+ Assert.Equal(23, supervisor.ExitCode);
+ }
+
+ [Fact]
+ public void LauncherProcessSpecCarriesNoCredentialLikeMember()
+ {
+ // Defense in depth: the password must never be able to reach
+ // process arguments or environment (Campaign LA plan §LA3). This
+ // guards against a future field addition accidentally widening
+ // that surface.
+ System.Reflection.PropertyInfo[] properties =
+ typeof(LauncherProcessSpec).GetProperties();
+ Assert.DoesNotContain(
+ properties,
+ p => p.Name.Contains("password", StringComparison.OrdinalIgnoreCase)
+ || p.Name.Contains("credential", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void RealProcessSpawnFeedsStdinAndCapturesExitCode()
+ {
+ // The "trivial cross-platform fake child" (plan §LA3 acceptance):
+ // `dotnet --version` is guaranteed present (we're running under
+ // `dotnet test`) on both Windows and Linux/WSL, ignores stdin
+ // entirely, and reliably exits 0 — proving the REAL
+ // SystemChildProcessFactory spawn/stdin-feed/exit-code-capture
+ // path end to end without any OS-specific script branching.
+ string dotnet = FindDotnetExecutable();
+ using var supervisor = new LauncherProcessSupervisor();
+ var exited = new ManualResetEventSlim(false);
+ supervisor.StateChanged += (_, s) =>
+ {
+ if (s == LauncherSessionState.Exited)
+ exited.Set();
+ };
+
+ supervisor.Start(
+ new LauncherProcessSpec(dotnet, ["--version"]),
+ "unused-password-ignored-by-dotnet");
+
+ bool completed = exited.Wait(TimeSpan.FromSeconds(30));
+
+ Assert.True(completed, "the real dotnet --version child did not exit within 30s");
+ Assert.Equal(0, supervisor.ExitCode);
+ }
+
+ private static LauncherProcessSpec Spec() =>
+ new("fake-host", ["--session-config", "session.json"]);
+
+ private static string FindDotnetExecutable() =>
+ // PATH-based resolution: .NET Core's Process.Start searches PATH
+ // for a bare filename when UseShellExecute is false, on both
+ // Windows and Unix, and `dotnet` is guaranteed on PATH here
+ // because this test is itself running under `dotnet test`.
+ OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet";
+
+ private sealed class FakeChildProcessFactory(
+ bool exitsWithinStopTimeout,
+ bool exitDuringStart = false,
+ bool throwOnStandardInputWrite = false)
+ : ILauncherChildProcessFactory
+ {
+ public FakeChildProcess? LastCreated { get; private set; }
+
+ public ILauncherChildProcess Create(LauncherProcessSpec spec)
+ {
+ LastCreated = new FakeChildProcess(
+ spec,
+ exitsWithinStopTimeout,
+ exitDuringStart,
+ throwOnStandardInputWrite);
+ return LastCreated;
+ }
+ }
+
+ private sealed class FakeChildProcess(
+ LauncherProcessSpec spec,
+ bool exitsWithinStopTimeout,
+ bool exitDuringStart = false,
+ bool throwOnStandardInputWrite = false)
+ : ILauncherChildProcess
+ {
+ private readonly RecordingTextWriter _standardInput = new();
+ private readonly ThrowingTextWriter _throwingStandardInput = new();
+
+ public LauncherProcessSpec Spec { get; } = spec;
+
+ public bool Started { get; private set; }
+
+ public string StandardInputText => _standardInput.ToString();
+
+ public bool StandardInputClosed => _standardInput.IsClosed;
+
+ public bool CloseMainWindowCalled => CloseMainWindowCallCount > 0;
+
+ public int CloseMainWindowCallCount { get; private set; }
+
+ public int TryRequestGracefulStopCallCount { get; private set; }
+
+ public int KillCallCount { get; private set; }
+
+ public bool Disposed { get; private set; }
+
+ /// Records the order ,
+ /// , and were
+ /// actually invoked in — review finding F3's ordering guarantee.
+ ///
+ public List CallOrder { get; } = [];
+
+ public bool HasExited { get; private set; }
+
+ public int ExitCode { get; private set; }
+
+ public TextWriter StandardInput =>
+ throwOnStandardInputWrite ? _throwingStandardInput : _standardInput;
+
+ public event EventHandler? Exited;
+
+ public void Start()
+ {
+ Started = true;
+
+ if (exitDuringStart)
+ {
+ // Simulates a child that dies synchronously from inside
+ // Process.Start() itself (review finding F9's race).
+ ExitForTest(0);
+ }
+ }
+
+ public void ExitForTest(int exitCode)
+ {
+ if (HasExited)
+ {
+ return;
+ }
+
+ HasExited = true;
+ ExitCode = exitCode;
+ Exited?.Invoke(this, EventArgs.Empty);
+ }
+
+ public bool TryRequestGracefulStop()
+ {
+ TryRequestGracefulStopCallCount++;
+ CallOrder.Add("gracefulStop");
+ return false;
+ }
+
+ public bool CloseMainWindow()
+ {
+ CloseMainWindowCallCount++;
+ CallOrder.Add("closeMainWindow");
+ return true;
+ }
+
+ public void Kill()
+ {
+ KillCallCount++;
+ CallOrder.Add("kill");
+ ExitForTest(-1);
+ }
+
+ public bool WaitForExit(TimeSpan timeout)
+ {
+ if (!exitsWithinStopTimeout)
+ return false;
+
+ ExitForTest(0);
+ return true;
+ }
+
+ public void Dispose()
+ {
+ Disposed = true;
+ }
+ }
+
+ private sealed class RecordingTextWriter : StringWriter
+ {
+ public bool IsClosed { get; private set; }
+
+ protected override void Dispose(bool disposing)
+ {
+ IsClosed = true;
+ base.Dispose(disposing);
+ }
+ }
+
+ /// Simulates a broken stdin pipe (review finding F8): the
+ /// child process started successfully, but feeding it the password
+ /// fails.
+ private sealed class ThrowingTextWriter : StringWriter
+ {
+ public override void Write(string? value) =>
+ throw new IOException("simulated broken stdin pipe");
+
+ public override void Write(char value) =>
+ throw new IOException("simulated broken stdin pipe");
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs
new file mode 100644
index 00000000..26bd3c0a
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs
@@ -0,0 +1,398 @@
+using System.Text.Json.Nodes;
+using AcDream.Launcher.Core.Launching;
+using AcDream.Launcher.Core.Profiles;
+using AcDream.Platform;
+
+namespace AcDream.Launcher.Core.Tests.Launching;
+
+///
+/// Golden-shape tests for against the
+/// Campaign LA plan §LA3 pinned contract: exactly the listed keys, exact
+/// camelCase names, character/policy presence rules per launch mode, and
+/// (critically) no password anywhere in the document.
+///
+public sealed class SessionConfigComposerTests
+{
+ private static readonly ApplicationPathSet Paths = new(
+ ConfigDirectory: "/cfg/acdream",
+ DataDirectory: "/data/acdream",
+ CacheDirectory: "/cache/acdream",
+ LegacyConfigDirectory: null);
+
+ private static readonly LauncherInstallRecord Install = new(
+ DatDirectory: "/dats",
+ PreparedAssetPath: "/data/acdream/pak/acdream.pak");
+
+ private static ServerProfile Server() =>
+ new() { Name = "Local ACE", Host = "127.0.0.1", Port = 9000 };
+
+ private static AccountProfile Account() =>
+ new() { Account = "testaccount", Password = "S3cretPassw0rd!" };
+
+ private static CharacterProfile Character(LaunchMode mode, string? id = "0x5000000A") =>
+ new()
+ {
+ Name = "+Acdream",
+ Id = id,
+ LaunchMode = mode,
+ Plugins = ["ExamplePlugin"],
+ LoginCommands = ["/tell someone, hi"],
+ };
+
+ [Fact]
+ public void GuiModeIncludesCharacterSelectorAndOmitsPolicy()
+ {
+ ComposedSessionConfig composed = SessionConfigComposer.Compose(
+ Server(),
+ Account(),
+ Character(LaunchMode.Gui),
+ Install,
+ Paths,
+ sessionId: "session-gui");
+
+ JsonObject session = SingleSession(composed);
+
+ AssertKeys(
+ session,
+ "id", "endpoint", "account", "character", "credential",
+ "plugins", "loginCommands", "statusFile");
+
+ Assert.Equal("session-gui", (string?)session["id"]);
+ Assert.Equal("testaccount", (string?)session["account"]);
+ Assert.Equal(0x5000000Au, (uint?)session["character"]!["id"]);
+ Assert.Null(session["character"]!["name"]);
+ Assert.Null(session["character"]!["index"]);
+ Assert.Equal("standardInput", (string?)session["credential"]!["provider"]);
+ Assert.Equal("session", (string?)session["credential"]!["reference"]);
+ Assert.Equal(
+ new[] { "ExamplePlugin" },
+ session["plugins"]!.AsArray().Select(n => (string?)n));
+ Assert.Equal(
+ new[] { "/tell someone, hi" },
+ session["loginCommands"]!.AsArray().Select(n => (string?)n));
+ Assert.Equal(
+ Path.Combine(Paths.CacheDirectory, "launcher", "sessions", "session-gui", "status.jsonl"),
+ (string?)session["statusFile"]);
+ }
+
+ [Fact]
+ public void GuiSelectModeOmitsCharacterFieldEntirely()
+ {
+ ComposedSessionConfig composed = SessionConfigComposer.Compose(
+ Server(),
+ Account(),
+ Character(LaunchMode.GuiSelect),
+ Install,
+ Paths,
+ sessionId: "session-guiselect");
+
+ JsonObject session = SingleSession(composed);
+
+ AssertKeys(
+ session,
+ "id", "endpoint", "account", "credential",
+ "plugins", "loginCommands", "statusFile");
+ Assert.False(session.ContainsKey("character"));
+ Assert.False(session.ContainsKey("policy"));
+ }
+
+ [Fact]
+ public void HeadlessModeIncludesCharacterAndIdlePolicy()
+ {
+ ComposedSessionConfig composed = SessionConfigComposer.Compose(
+ Server(),
+ Account(),
+ Character(LaunchMode.Headless),
+ Install,
+ Paths,
+ sessionId: "session-headless",
+ loginCommandDelayMs: 750);
+
+ JsonObject session = SingleSession(composed);
+
+ AssertKeys(
+ session,
+ "id", "endpoint", "account", "character", "policy", "credential",
+ "plugins", "loginCommands", "loginCommandDelayMs", "statusFile");
+ Assert.Equal(0x5000000Au, (uint?)session["character"]!["id"]);
+ Assert.Equal("idle", (string?)session["policy"]!["id"]);
+ Assert.Equal(750, (int?)session["loginCommandDelayMs"]);
+ }
+
+ [Fact]
+ public void GuiModeFallsBackToNameSelectorWhenIdIsMissing()
+ {
+ ComposedSessionConfig composed = SessionConfigComposer.Compose(
+ Server(),
+ Account(),
+ Character(LaunchMode.Gui, id: null),
+ Install,
+ Paths,
+ sessionId: "session-gui-name");
+
+ JsonObject session = SingleSession(composed);
+ Assert.Null(session["character"]!["id"]);
+ Assert.Equal("+Acdream", (string?)session["character"]!["name"]);
+ }
+
+ [Fact]
+ public void GuiModeFallsBackToNameSelectorWhenIdIsAHandTypedDecimalWithoutThe0xPrefix()
+ {
+ // Review finding F10: an 8-digit all-decimal-digit string is ALSO
+ // a syntactically valid hex number. Without requiring the "0x"
+ // prefix, this used to silently reinterpret a hand-typed decimal
+ // id as hex and select the wrong character; it must now fall
+ // through to the name selector instead of guessing.
+ ComposedSessionConfig composed = SessionConfigComposer.Compose(
+ Server(),
+ Account(),
+ Character(LaunchMode.Gui, id: "12345678"),
+ Install,
+ Paths,
+ sessionId: "session-gui-decimal-id");
+
+ JsonObject session = SingleSession(composed);
+ Assert.Null(session["character"]!["id"]);
+ Assert.Equal("+Acdream", (string?)session["character"]!["name"]);
+ }
+
+ [Fact]
+ public void GuiModeFallsBackToNameSelectorWhenTheParsedIdIsZero()
+ {
+ // Review finding F10: both host loaders reject `id: 0` outright,
+ // so a parsed-but-zero id is not a usable selector either.
+ ComposedSessionConfig composed = SessionConfigComposer.Compose(
+ Server(),
+ Account(),
+ Character(LaunchMode.Gui, id: "0x00000000"),
+ Install,
+ Paths,
+ sessionId: "session-gui-zero-id");
+
+ JsonObject session = SingleSession(composed);
+ Assert.Null(session["character"]!["id"]);
+ Assert.Equal("+Acdream", (string?)session["character"]!["name"]);
+ }
+
+ [Fact]
+ public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays()
+ {
+ CharacterProfile character = Character(LaunchMode.Gui);
+ character.Plugins = [];
+ character.LoginCommands = [];
+
+ ComposedSessionConfig composed = SessionConfigComposer.Compose(
+ Server(),
+ Account(),
+ character,
+ Install,
+ Paths,
+ sessionId: "session-empty-lists");
+
+ JsonObject session = SingleSession(composed);
+ Assert.False(session.ContainsKey("plugins"));
+ Assert.False(session.ContainsKey("loginCommands"));
+ }
+
+ [Fact]
+ public void ProcessContentCarriesInstallRecordAndPathsIsOmittedByDefault()
+ {
+ ComposedSessionConfig composed = SessionConfigComposer.Compose(
+ Server(),
+ Account(),
+ Character(LaunchMode.Gui),
+ Install,
+ Paths,
+ sessionId: "session-content");
+
+ JsonObject root = ParseRoot(composed);
+ Assert.Equal(1, (int?)root["version"]);
+ JsonObject process = root["process"]!.AsObject();
+
+ // PINNED CONTRACT (review finding F1): process.paths is OMITTED
+ // entirely — not an empty object — unless a caller explicitly
+ // supplies overrides. The App-side loader parses with strict
+ // UnmappedMemberHandling.Disallow and has no `paths` member of
+ // its own, so an emitted "paths":{} would reject the whole
+ // document at config load for every gui/guiSelect launch.
+ AssertKeys(process, "content");
+ Assert.False(process.ContainsKey("paths"));
+
+ JsonObject content = process["content"]!.AsObject();
+ AssertKeys(content, "datDirectory", "preparedAssetPath");
+ Assert.Equal(Install.DatDirectory, (string?)content["datDirectory"]);
+ Assert.Equal(Install.PreparedAssetPath, (string?)content["preparedAssetPath"]);
+ }
+
+ [Fact]
+ public void NormalPlaySessionsOmitTheModeFieldEntirely()
+ {
+ foreach (LaunchMode mode in new[] { LaunchMode.Gui, LaunchMode.GuiSelect, LaunchMode.Headless })
+ {
+ ComposedSessionConfig composed = SessionConfigComposer.Compose(
+ Server(),
+ Account(),
+ Character(mode),
+ Install,
+ Paths,
+ sessionId: $"session-mode-omit-{mode}");
+
+ JsonObject session = SingleSession(composed);
+ Assert.False(session.ContainsKey("mode"));
+ }
+ }
+
+ [Fact]
+ public void ProbeModeSetsModeAndOmitsCharacterPolicyPluginsAndLoginCommands()
+ {
+ ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
+ Server(),
+ Account(),
+ Install,
+ Paths,
+ sessionId: "session-probe");
+
+ JsonObject session = SingleSession(composed);
+
+ AssertKeys(
+ session,
+ "id", "mode", "endpoint", "account", "credential", "statusFile");
+
+ Assert.Equal("session-probe", (string?)session["id"]);
+ Assert.Equal("probe", (string?)session["mode"]);
+ Assert.Equal("127.0.0.1", (string?)session["endpoint"]!["host"]);
+ Assert.Equal(9000, (int?)session["endpoint"]!["port"]);
+ Assert.Equal("testaccount", (string?)session["account"]);
+ Assert.Equal("standardInput", (string?)session["credential"]!["provider"]);
+ Assert.False(session.ContainsKey("character"));
+ Assert.False(session.ContainsKey("policy"));
+ Assert.False(session.ContainsKey("plugins"));
+ Assert.False(session.ContainsKey("loginCommands"));
+ Assert.False(session.ContainsKey("loginCommandDelayMs"));
+ Assert.Equal(
+ Path.Combine(
+ Paths.CacheDirectory, "launcher", "sessions", "session-probe", "status.jsonl"),
+ (string?)session["statusFile"]);
+ }
+
+ [Fact]
+ public void ProbeModeDocumentNeverContainsThePassword()
+ {
+ AccountProfile account = Account();
+
+ ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
+ Server(),
+ account,
+ Install,
+ Paths,
+ sessionId: "session-probe-pw");
+
+ string json = SessionConfigComposer.Serialize(composed.Document);
+ Assert.DoesNotContain(account.Password, json, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ProbeModeProcessSettingsMatchNormalComposition()
+ {
+ ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
+ Server(),
+ Account(),
+ Install,
+ Paths,
+ sessionId: "session-probe-content");
+
+ JsonObject root = ParseRoot(composed);
+ JsonObject process = root["process"]!.AsObject();
+ AssertKeys(process, "content");
+
+ JsonObject content = process["content"]!.AsObject();
+ Assert.Equal(Install.DatDirectory, (string?)content["datDirectory"]);
+ Assert.Equal(Install.PreparedAssetPath, (string?)content["preparedAssetPath"]);
+ }
+
+ [Fact]
+ public void ComposedDocumentNeverContainsThePassword()
+ {
+ AccountProfile account = Account();
+
+ foreach (LaunchMode mode in new[] { LaunchMode.Gui, LaunchMode.GuiSelect, LaunchMode.Headless })
+ {
+ ComposedSessionConfig composed = SessionConfigComposer.Compose(
+ Server(),
+ account,
+ Character(mode),
+ Install,
+ Paths,
+ sessionId: $"session-{mode}");
+
+ string json = SessionConfigComposer.Serialize(composed.Document);
+ Assert.DoesNotContain(account.Password, json, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public void ComposeAndWriteWritesSessionJsonUnderTheExpectedPath()
+ {
+ string root = Path.Combine(
+ Path.GetTempPath(),
+ "acdream-launcher-composer-tests",
+ Guid.NewGuid().ToString("N"));
+ try
+ {
+ var paths = new ApplicationPathSet(
+ Path.Combine(root, "cfg"),
+ Path.Combine(root, "data"),
+ Path.Combine(root, "cache"),
+ null);
+
+ ComposedSessionConfig composed = SessionConfigComposer.ComposeAndWrite(
+ Server(),
+ Account(),
+ Character(LaunchMode.Gui),
+ Install,
+ paths,
+ sessionId: "session-write");
+
+ string expectedPath = Path.Combine(
+ paths.CacheDirectory, "launcher", "sessions", "session-write", "session.json");
+ Assert.Equal(expectedPath, composed.ConfigFilePath);
+ Assert.True(File.Exists(expectedPath));
+
+ string text = File.ReadAllText(expectedPath);
+ Assert.DoesNotContain(Account().Password, text, StringComparison.Ordinal);
+ }
+ finally
+ {
+ if (Directory.Exists(root))
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+ }
+
+ private static JsonObject ParseRoot(ComposedSessionConfig composed)
+ {
+ string json = SessionConfigComposer.Serialize(composed.Document);
+ return JsonNode.Parse(json)!.AsObject();
+ }
+
+ private static JsonObject SingleSession(ComposedSessionConfig composed)
+ {
+ JsonObject root = ParseRoot(composed);
+ JsonArray sessions = root["sessions"]!.AsArray();
+ return Assert.Single(sessions)!.AsObject();
+ }
+
+ ///
+ /// Asserts the object's property set is EXACTLY the given keys — no
+ /// more, no fewer — without depending on reflection-based member
+ /// enumeration order (only the presence/absence of each pinned-
+ /// contract key is a guarantee this slice makes).
+ ///
+ private static void AssertKeys(JsonObject obj, params string[] expectedKeys)
+ {
+ var actual = new HashSet(obj.Select(kv => kv.Key), StringComparer.Ordinal);
+ var expected = new HashSet(expectedKeys, StringComparer.Ordinal);
+ Assert.Equal(expected, actual);
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs
new file mode 100644
index 00000000..089e0ea7
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs
@@ -0,0 +1,50 @@
+using AcDream.Launcher.Core.Profiles;
+
+namespace AcDream.Launcher.Core.Tests.Profiles;
+
+public sealed class CharacterIdFormatTests
+{
+ [Fact]
+ public void ToHexStringFormatsEightDigitUppercaseWithPrefix()
+ {
+ Assert.Equal("0x5000000A", CharacterIdFormat.ToHexString(0x5000000Au));
+ Assert.Equal("0x00000001", CharacterIdFormat.ToHexString(1u));
+ }
+
+ [Theory]
+ [InlineData("0x5000000A", 0x5000000Au)]
+ [InlineData("0x5000000a", 0x5000000Au)]
+ [InlineData("0X5000000A", 0x5000000Au)]
+ public void TryParseAcceptsThe0xPrefixCaseInsensitively(string text, uint expected)
+ {
+ Assert.True(CharacterIdFormat.TryParse(text, out uint id));
+ Assert.Equal(expected, id);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not-hex")]
+ [InlineData("5000000A")]
+ [InlineData("12345678")]
+ public void TryParseRejectsNullEmptyNonHexOrAnUnprefixedString(string? text)
+ {
+ // "5000000A"/"12345678" are all-hex-digit strings that would
+ // parse fine as hex WITHOUT the "0x" prefix — review finding F10
+ // requires the prefix precisely so a hand-typed decimal id (which
+ // is ALSO syntactically valid hex) is never silently
+ // misinterpreted as one.
+ Assert.False(CharacterIdFormat.TryParse(text, out uint id));
+ Assert.Equal(0u, id);
+ }
+
+ [Fact]
+ public void RoundTripsThroughToHexStringAndTryParse()
+ {
+ const uint original = 0x5000000Au;
+ string text = CharacterIdFormat.ToHexString(original);
+ Assert.True(CharacterIdFormat.TryParse(text, out uint parsed));
+ Assert.Equal(original, parsed);
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs
new file mode 100644
index 00000000..8d83c88d
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs
@@ -0,0 +1,362 @@
+using AcDream.Launcher.Core.Profiles;
+
+namespace AcDream.Launcher.Core.Tests.Profiles;
+
+public sealed class LauncherProfileStoreTests : IDisposable
+{
+ private readonly string _root;
+ private readonly string _filePath;
+
+ public LauncherProfileStoreTests()
+ {
+ _root = Path.Combine(
+ Path.GetTempPath(),
+ "acdream-launcher-profile-tests",
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_root);
+ _filePath = Path.Combine(_root, "launcher-profiles.json");
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void LoadOnMissingFileYieldsEmptyDocumentWithoutTouchingDisk()
+ {
+ var store = new LauncherProfileStore(_filePath);
+
+ bool loaded = store.Load();
+
+ Assert.False(loaded);
+ Assert.False(File.Exists(_filePath));
+ Assert.Equal(1, store.Document.Version);
+ Assert.Empty(store.Document.Servers);
+ }
+
+ [Fact]
+ public void AddServerThenSaveThenReloadRoundTrips()
+ {
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+
+ store.AddServer("Local ACE", "127.0.0.1", 9000);
+ store.Save();
+
+ Assert.True(File.Exists(_filePath));
+
+ var reloaded = new LauncherProfileStore(_filePath);
+ reloaded.Load();
+
+ ServerProfile server = Assert.Single(reloaded.Document.Servers);
+ Assert.Equal("Local ACE", server.Name);
+ Assert.Equal("127.0.0.1", server.Host);
+ Assert.Equal(9000, server.Port);
+ Assert.Empty(server.Accounts);
+ }
+
+ [Fact]
+ public void AddServerRejectsDuplicateName()
+ {
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+ store.AddServer("Local ACE", "127.0.0.1", 9000);
+
+ var ex = Assert.Throws(
+ () => store.AddServer("Local ACE", "127.0.0.1", 9001));
+ Assert.Contains("Local ACE", ex.Message);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(65536)]
+ [InlineData(-1)]
+ public void AddServerRejectsOutOfRangePort(int port)
+ {
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+
+ Assert.Throws(
+ () => store.AddServer("Local ACE", "127.0.0.1", port));
+ }
+
+ [Fact]
+ public void EditServerRenamesAndUpdatesHostAndPort()
+ {
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+ store.AddServer("Local ACE", "127.0.0.1", 9000);
+
+ store.EditServer("Local ACE", newName: "Home ACE", newHost: "10.0.0.5", newPort: 9001);
+
+ ServerProfile server = Assert.Single(store.Document.Servers);
+ Assert.Equal("Home ACE", server.Name);
+ Assert.Equal("10.0.0.5", server.Host);
+ Assert.Equal(9001, server.Port);
+ }
+
+ [Fact]
+ public void EditServerOnUnknownNameThrows()
+ {
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+
+ Assert.Throws(
+ () => store.EditServer("Nope", newHost: "1.2.3.4"));
+ }
+
+ [Fact]
+ public void RemoveServerRemovesIt()
+ {
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+ store.AddServer("Local ACE", "127.0.0.1", 9000);
+
+ store.RemoveServer("Local ACE");
+
+ Assert.Empty(store.Document.Servers);
+ }
+
+ [Fact]
+ public void AddEditRemoveAccountRoundTrip()
+ {
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+ store.AddServer("Local ACE", "127.0.0.1", 9000);
+
+ store.AddAccount("Local ACE", "testaccount", "testpassword");
+ AccountProfile account = Assert.Single(
+ store.Document.Servers.Single().Accounts);
+ Assert.Equal("testaccount", account.Account);
+ Assert.Equal("testpassword", account.Password);
+
+ store.EditAccount(
+ "Local ACE",
+ "testaccount",
+ newAccount: "renamed",
+ newPassword: "newpass");
+ account = Assert.Single(store.Document.Servers.Single().Accounts);
+ Assert.Equal("renamed", account.Account);
+ Assert.Equal("newpass", account.Password);
+
+ store.RemoveAccount("Local ACE", "renamed");
+ Assert.Empty(store.Document.Servers.Single().Accounts);
+ }
+
+ [Fact]
+ public void AddAccountRejectsDuplicateAccountOnSameServer()
+ {
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+ store.AddServer("Local ACE", "127.0.0.1", 9000);
+ store.AddAccount("Local ACE", "testaccount", "pw");
+
+ Assert.Throws(
+ () => store.AddAccount("Local ACE", "testaccount", "pw2"));
+ }
+
+ [Fact]
+ public void EditCharacterUpdatesLaunchModePluginsAndLoginCommandsOnly()
+ {
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+ store.AddServer("Local ACE", "127.0.0.1", 9000);
+ store.AddAccount("Local ACE", "testaccount", "pw");
+ store.MergeRoster(
+ "Local ACE",
+ "testaccount",
+ [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
+
+ store.EditCharacter(
+ "Local ACE",
+ "testaccount",
+ "+Acdream",
+ launchMode: LaunchMode.Headless,
+ plugins: ["ExamplePlugin"],
+ loginCommands: ["/tell someone, hi"]);
+
+ CharacterProfile character = Assert.Single(
+ store.Document.Servers.Single().Accounts.Single().Characters);
+ Assert.Equal(LaunchMode.Headless, character.LaunchMode);
+ Assert.Equal(["ExamplePlugin"], character.Plugins);
+ Assert.Equal(["/tell someone, hi"], character.LoginCommands);
+ Assert.Equal("0x5000000A", character.Id);
+ }
+
+ [Fact]
+ public void FullProfileWithServersAccountsAndCharactersRoundTripsThroughDisk()
+ {
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+ store.AddServer("Local ACE", "127.0.0.1", 9000);
+ store.AddAccount("Local ACE", "testaccount", "testpassword");
+ store.MergeRoster(
+ "Local ACE",
+ "testaccount",
+ [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
+ store.EditCharacter(
+ "Local ACE",
+ "testaccount",
+ "+Acdream",
+ launchMode: LaunchMode.Gui,
+ plugins: ["ExamplePlugin"],
+ loginCommands: ["/vt start"]);
+ store.Save();
+
+ // Direct proof of the on-disk enum casing — a round-trip alone
+ // could mask a PascalCase regression if the reader ever became
+ // case-insensitive on enum values.
+ string text = File.ReadAllText(_filePath);
+ Assert.Contains("\"launchMode\":\"gui\"", text.Replace(" ", string.Empty));
+
+ var reloaded = new LauncherProfileStore(_filePath);
+ reloaded.Load();
+
+ ServerProfile server = Assert.Single(reloaded.Document.Servers);
+ AccountProfile account = Assert.Single(server.Accounts);
+ CharacterProfile character = Assert.Single(account.Characters);
+ Assert.Equal("+Acdream", character.Name);
+ Assert.Equal("0x5000000A", character.Id);
+ Assert.Equal(LaunchMode.Gui, character.LaunchMode);
+ Assert.Equal(["ExamplePlugin"], character.Plugins);
+ Assert.Equal(["/vt start"], character.LoginCommands);
+ }
+
+ [Fact]
+ public void LoadRejectsUnsupportedVersion()
+ {
+ File.WriteAllText(_filePath, """{"version":2,"servers":[]}""");
+ var store = new LauncherProfileStore(_filePath);
+
+ Assert.Throws(() => store.Load());
+ }
+
+ [Fact]
+ public void LoadRejectsUnmappedMembersStrictly()
+ {
+ File.WriteAllText(
+ _filePath,
+ """{"version":1,"servers":[],"unexpectedField":true}""");
+ var store = new LauncherProfileStore(_filePath);
+
+ Assert.Throws(() => store.Load());
+ }
+
+ [Fact]
+ public void SaveWritesCamelCaseJson()
+ {
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+ store.AddServer("Local ACE", "127.0.0.1", 9000);
+ store.Save();
+
+ string text = File.ReadAllText(_filePath);
+ Assert.Contains("\"version\"", text);
+ Assert.Contains("\"servers\"", text);
+ Assert.Contains("\"host\"", text);
+ Assert.DoesNotContain("\"Version\"", text);
+ Assert.DoesNotContain("\"Servers\"", text);
+ }
+
+ [Fact]
+ public void SaveSetsOwnerOnlyPermissionsOnLinux()
+ {
+ // Linux-conditional: 0600 is a Linux-only hygiene step (spec §5,
+ // decisions log item "Windows profile-file permissions"). A no-op
+ // pass on Windows/macOS, matching the repo's established
+ // OperatingSystem.IsLinux() early-return pattern (e.g.
+ // HeadlessCredentialResolverTests.LinuxRejectsGroupOrOtherCredentialPermissions).
+ if (!OperatingSystem.IsLinux())
+ return;
+
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+ store.AddServer("Local ACE", "127.0.0.1", 9000);
+ store.AddAccount("Local ACE", "testaccount", "testpassword");
+ store.Save();
+
+ UnixFileMode mode = File.GetUnixFileMode(_filePath);
+ Assert.Equal(
+ UnixFileMode.UserRead | UnixFileMode.UserWrite,
+ mode);
+ }
+
+ [Fact]
+ public void TempCredentialCreationOptionsRequestAtomicPlatformCorrectCreation()
+ {
+ FileStreamOptions options =
+ LauncherProfileStore.CreateCredentialTempFileOptions();
+ Assert.Equal(FileMode.CreateNew, options.Mode);
+ Assert.Equal(FileAccess.Write, options.Access);
+ Assert.Equal(FileShare.None, options.Share);
+
+ if (OperatingSystem.IsLinux())
+ {
+ Assert.Equal(
+ LauncherProfileStore.OwnerOnlyFileMode,
+ options.UnixCreateMode);
+ }
+ else
+ {
+ Assert.Null(options.UnixCreateMode);
+ }
+ }
+
+ [Fact]
+ public void TempCredentialFileIsOwnerOnlyFromItsFirstObservableLinuxState()
+ {
+ // Deterministic proof of the exact production create path: inspect
+ // the file while the CreateNew handle is still open, before any
+ // serialization or post-create chmod can occur. This replaces the
+ // old timing-only poller, which could miss the vulnerable window.
+ if (!OperatingSystem.IsLinux())
+ return;
+
+ string tempPath = _filePath + ".tmp";
+ using FileStream stream = LauncherProfileStore.CreateCredentialTempFile(tempPath);
+
+ Assert.Equal(
+ LauncherProfileStore.OwnerOnlyFileMode,
+ File.GetUnixFileMode(tempPath));
+ }
+
+ [Fact]
+ public void SaveDeletesTheStaleTempFileWhenTheFinalRenameFails()
+ {
+ // Review finding F4: force the rename step to fail (the
+ // destination path names an existing DIRECTORY, which
+ // File.Move(..., overwrite: true) refuses to replace — Windows
+ // reports this as UnauthorizedAccessException, Linux as
+ // IOException, so the assertion below accepts either) and assert
+ // the temp file — which still carries the just-serialized
+ // plaintext credentials — doesn't linger on disk afterward.
+ Directory.CreateDirectory(_filePath);
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+ store.AddServer("Local ACE", "127.0.0.1", 9000);
+ store.AddAccount("Local ACE", "testaccount", "testpassword");
+
+ Assert.ThrowsAny(() => store.Save());
+
+ Assert.False(File.Exists(_filePath + ".tmp"));
+ }
+
+ [Fact]
+ public void LoadDeletesAStaleTempFileLeftBehindByACrashedSave()
+ {
+ // Review finding F4: a Save() that crashed between creating the
+ // temp file and the atomic rename leaves a ".tmp" carrying the
+ // same plaintext credentials as the real store. Load() cleans it
+ // up opportunistically the next time the store is opened.
+ File.WriteAllText(_filePath + ".tmp", """{"version":1,"servers":[]}""");
+
+ var store = new LauncherProfileStore(_filePath);
+ store.Load();
+
+ Assert.False(File.Exists(_filePath + ".tmp"));
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs
new file mode 100644
index 00000000..c151be08
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs
@@ -0,0 +1,172 @@
+using AcDream.Launcher.Core.Profiles;
+
+namespace AcDream.Launcher.Core.Tests.Profiles;
+
+///
+/// The roster-merge matrix (Campaign LA plan §LA3 acceptance): a new
+/// character, an existing character keeping its user settings, and a
+/// character absent from a later roster snapshot being retained
+/// (possibly pending-delete).
+///
+public sealed class RosterMergeTests
+{
+ private static LauncherProfileStore NewStoreWithServerAndAccount()
+ {
+ var store = new LauncherProfileStore(
+ Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json"));
+ store.Load();
+ store.AddServer("Local ACE", "127.0.0.1", 9000);
+ store.AddAccount("Local ACE", "testaccount", "testpassword");
+ return store;
+ }
+
+ [Fact]
+ public void FirstMergeAddsNewCharactersWithDefaultLaunchModeGuiSelect()
+ {
+ LauncherProfileStore store = NewStoreWithServerAndAccount();
+
+ store.MergeRoster(
+ "Local ACE",
+ "testaccount",
+ [
+ new CharacterRosterEntry(0x5000000A, "+Acdream", 0),
+ new CharacterRosterEntry(0x5000000B, "+Second", 0),
+ ]);
+
+ List characters =
+ store.Document.Servers.Single().Accounts.Single().Characters;
+ Assert.Equal(2, characters.Count);
+
+ CharacterProfile first = characters.Single(c => c.Name == "+Acdream");
+ Assert.Equal("0x5000000A", first.Id);
+ Assert.Equal(LaunchMode.GuiSelect, first.LaunchMode);
+ Assert.Empty(first.Plugins);
+ Assert.Empty(first.LoginCommands);
+
+ CharacterProfile second = characters.Single(c => c.Name == "+Second");
+ Assert.Equal("0x5000000B", second.Id);
+ Assert.Equal(LaunchMode.GuiSelect, second.LaunchMode);
+ }
+
+ [Fact]
+ public void SecondMergePreservesUserSettingsOnAnExistingCharacter()
+ {
+ LauncherProfileStore store = NewStoreWithServerAndAccount();
+ store.MergeRoster(
+ "Local ACE",
+ "testaccount",
+ [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
+ store.EditCharacter(
+ "Local ACE",
+ "testaccount",
+ "+Acdream",
+ launchMode: LaunchMode.Headless,
+ plugins: ["ExamplePlugin"],
+ loginCommands: ["/vt start"]);
+
+ // A later probe reports the same character again (same id), with
+ // a renamed display — settings must survive untouched.
+ store.MergeRoster(
+ "Local ACE",
+ "testaccount",
+ [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
+
+ CharacterProfile character = Assert.Single(
+ store.Document.Servers.Single().Accounts.Single().Characters);
+ Assert.Equal(LaunchMode.Headless, character.LaunchMode);
+ Assert.Equal(["ExamplePlugin"], character.Plugins);
+ Assert.Equal(["/vt start"], character.LoginCommands);
+ }
+
+ [Fact]
+ public void MergeUpdatesNameWhenIdMatchesButDisplayNameChanged()
+ {
+ LauncherProfileStore store = NewStoreWithServerAndAccount();
+ store.MergeRoster(
+ "Local ACE",
+ "testaccount",
+ [new CharacterRosterEntry(0x5000000A, "+OldName", 0)]);
+
+ store.MergeRoster(
+ "Local ACE",
+ "testaccount",
+ [new CharacterRosterEntry(0x5000000A, "+NewName", 0)]);
+
+ CharacterProfile character = Assert.Single(
+ store.Document.Servers.Single().Accounts.Single().Characters);
+ Assert.Equal("+NewName", character.Name);
+ Assert.Equal("0x5000000A", character.Id);
+ }
+
+ [Fact]
+ public void CharacterAbsentFromALaterRosterSnapshotIsRetained()
+ {
+ LauncherProfileStore store = NewStoreWithServerAndAccount();
+ store.MergeRoster(
+ "Local ACE",
+ "testaccount",
+ [
+ new CharacterRosterEntry(0x5000000A, "+Acdream", 0),
+ new CharacterRosterEntry(0x5000000B, "+PendingDelete", 1),
+ ]);
+
+ // A later probe's roster only reports one of the two — e.g. the
+ // other was deleted and is now in ACE's grace window / a
+ // partial snapshot. The store never removes rows on the
+ // caller's behalf.
+ store.MergeRoster(
+ "Local ACE",
+ "testaccount",
+ [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
+
+ List characters =
+ store.Document.Servers.Single().Accounts.Single().Characters;
+ Assert.Equal(2, characters.Count);
+ Assert.Contains(characters, c => c.Name == "+Acdream");
+ Assert.Contains(characters, c => c.Name == "+PendingDelete");
+ }
+
+ [Fact]
+ public void MergeNormalizesAnUnprefixedHexIdInsteadOfCreatingADuplicateRow()
+ {
+ // Review finding F10: a hand-edited row can carry an id without
+ // the "0x" prefix (e.g. copy-pasted from somewhere that dropped
+ // it). CharacterIdFormat.TryParse now REJECTS that string
+ // outright (it no longer guesses hex-without-a-prefix), so the
+ // old raw string-equality comparison against the roster's
+ // canonical "0x..." form would never match and would add a
+ // second row forever. The name-fallback match must still
+ // recognize this as the SAME character and self-heal its id.
+ LauncherProfileStore store = NewStoreWithServerAndAccount();
+ store.Document.Servers.Single().Accounts.Single().Characters.Add(
+ new CharacterProfile { Id = "5000000A", Name = "+Acdream" });
+
+ store.MergeRoster(
+ "Local ACE",
+ "testaccount",
+ [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
+
+ CharacterProfile character = Assert.Single(
+ store.Document.Servers.Single().Accounts.Single().Characters);
+ Assert.Equal("0x5000000A", character.Id);
+ Assert.Equal("+Acdream", character.Name);
+ }
+
+ [Fact]
+ public void MergeThrowsForUnknownServerOrAccount()
+ {
+ LauncherProfileStore store = NewStoreWithServerAndAccount();
+
+ Assert.Throws(
+ () => store.MergeRoster(
+ "Nope",
+ "testaccount",
+ [new CharacterRosterEntry(1, "x", 0)]));
+
+ Assert.Throws(
+ () => store.MergeRoster(
+ "Local ACE",
+ "nope",
+ [new CharacterRosterEntry(1, "x", 0)]));
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs
new file mode 100644
index 00000000..47f5e118
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs
@@ -0,0 +1,210 @@
+using AcDream.Launcher.Core.Status;
+
+namespace AcDream.Launcher.Core.Tests.Status;
+
+public sealed class StatusEventParserTests
+{
+ [Fact]
+ public void ParsesStarted()
+ {
+ var e = StatusEventParser.Parse(
+ """{"v":1,"e":"started","t":"2026-08-14T12:00:00Z","sessionId":"s1"}""");
+
+ var started = Assert.IsType(e);
+ Assert.Equal(1, started.V);
+ Assert.Equal("started", started.E);
+ Assert.Equal("s1", started.SessionId);
+ Assert.Equal(
+ DateTimeOffset.Parse("2026-08-14T12:00:00Z"),
+ started.T);
+ }
+
+ [Fact]
+ public void ParsesConnected()
+ {
+ var e = StatusEventParser.Parse(
+ """{"v":1,"e":"connected","t":"2026-08-14T12:00:01Z","sessionId":"s1"}""");
+ Assert.IsType(e);
+ }
+
+ [Fact]
+ public void ParsesCharacterListWithMultipleCharacters()
+ {
+ var e = StatusEventParser.Parse(
+ """
+ {"v":1,"e":"characterList","t":"2026-08-14T12:00:02Z","sessionId":"s1",
+ "accountName":"testaccount","slotCount":6,
+ "characters":[
+ {"id":1342177290,"name":"+Acdream","secondsGreyedOut":0},
+ {"id":1342177291,"name":"+Second","secondsGreyedOut":1}
+ ]}
+ """);
+
+ var list = Assert.IsType(e);
+ Assert.Equal("testaccount", list.AccountName);
+ Assert.Equal(6, list.SlotCount);
+ Assert.Equal(2, list.Characters.Count);
+ Assert.Equal(1342177290u, list.Characters[0].Id);
+ Assert.Equal("+Acdream", list.Characters[0].Name);
+ Assert.Equal(0u, list.Characters[0].SecondsGreyedOut);
+ Assert.Equal(1342177291u, list.Characters[1].Id);
+ Assert.Equal(1u, list.Characters[1].SecondsGreyedOut);
+ }
+
+ [Fact]
+ public void ParsesEnteredWorld()
+ {
+ var e = StatusEventParser.Parse(
+ """{"v":1,"e":"enteredWorld","t":"2026-08-14T12:00:03Z","sessionId":"s1","characterId":1342177290,"characterName":"+Acdream"}""");
+
+ var entered = Assert.IsType(e);
+ Assert.Equal(1342177290u, entered.CharacterId);
+ Assert.Equal("+Acdream", entered.CharacterName);
+ }
+
+ [Fact]
+ public void ParsesPluginLoadedAndPluginFailed()
+ {
+ var loaded = Assert.IsType(
+ StatusEventParser.Parse(
+ """{"v":1,"e":"pluginLoaded","t":"2026-08-14T12:00:04Z","sessionId":"s1","plugin":"ExamplePlugin"}"""));
+ Assert.Equal("ExamplePlugin", loaded.Plugin);
+
+ var failed = Assert.IsType(
+ StatusEventParser.Parse(
+ """{"v":1,"e":"pluginFailed","t":"2026-08-14T12:00:05Z","sessionId":"s1","plugin":"BadPlugin","error":"boom"}"""));
+ Assert.Equal("BadPlugin", failed.Plugin);
+ Assert.Equal("boom", failed.Error);
+ }
+
+ [Fact]
+ public void ParsesDisconnectedAndExited()
+ {
+ var disconnected = Assert.IsType(
+ StatusEventParser.Parse(
+ """{"v":1,"e":"disconnected","t":"2026-08-14T12:00:06Z","sessionId":"s1","reason":"serverClosed"}"""));
+ Assert.Equal("serverClosed", disconnected.Reason);
+
+ var exited = Assert.IsType(
+ StatusEventParser.Parse(
+ """{"v":1,"e":"exited","t":"2026-08-14T12:00:07Z","sessionId":"s1","code":0,"reason":"graceful"}"""));
+ Assert.Equal(0, exited.Code);
+ Assert.Equal("graceful", exited.Reason);
+ }
+
+ [Fact]
+ public void UnknownEValueSurfacesAsUnknownEventRatherThanThrowing()
+ {
+ var e = StatusEventParser.Parse(
+ """{"v":1,"e":"someFutureEvent","t":"2026-08-14T12:00:08Z","sessionId":"s1","extra":true}""");
+
+ var unknown = Assert.IsType(e);
+ Assert.Equal("someFutureEvent", unknown.E);
+ Assert.Equal("s1", unknown.SessionId);
+ Assert.Contains("someFutureEvent", unknown.RawJson);
+ }
+
+ [Fact]
+ public void MalformedJsonSurfacesAsUnknownEventRatherThanThrowing()
+ {
+ var e = StatusEventParser.Parse("{not json");
+
+ Assert.IsType(e);
+ }
+
+ [Theory]
+ [InlineData("[]")]
+ [InlineData("null")]
+ [InlineData("42")]
+ [InlineData("\"text\"")]
+ public void CompleteJsonWithANonObjectRootSurfacesAsMalformedEvent(string line)
+ {
+ var e = StatusEventParser.Parse(line);
+
+ var malformed = Assert.IsType(e);
+ Assert.Contains("root", malformed.Error, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("\t")]
+ public void WhitespaceOrEmptyLineSurfacesAsUnknownEventRatherThanThrowing(string line)
+ {
+ // Review finding F7: ArgumentException.ThrowIfNullOrWhiteSpace
+ // used to guard this method BEFORE the try/catch, so a
+ // whitespace-only line (e.g. a stray blank line the tailer
+ // happens to hand over) escaped as an uncaught exception instead
+ // of degrading like every other malformed-input case.
+ var e = StatusEventParser.Parse(line);
+
+ Assert.IsType(e);
+ }
+
+ [Fact]
+ public void NullLineSurfacesAsUnknownEventRatherThanThrowing()
+ {
+ var e = StatusEventParser.Parse(null!);
+
+ Assert.IsType(e);
+ }
+
+ [Theory]
+ [InlineData("{\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
+ [InlineData("{\"v\":\"1\",\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
+ [InlineData("{\"v\":2,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
+ [InlineData("{\"v\":1,\"e\":\"connected\",\"sessionId\":\"s1\"}")]
+ [InlineData("{\"v\":1,\"e\":\"started\",\"t\":42,\"sessionId\":\"s1\"}")]
+ [InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"not-a-time\",\"sessionId\":\"s1\"}")]
+ [InlineData("{\"v\":1,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00+02:00\",\"sessionId\":\"s1\"}")]
+ [InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\"}")]
+ [InlineData("{\"v\":1,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":42}")]
+ [InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"\"}")]
+ public void PayloadFreeKnownEventsRequireTheFullPinnedV1Envelope(string line)
+ {
+ var e = StatusEventParser.Parse(line);
+
+ var malformed = Assert.IsType(e);
+ Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
+ }
+
+ [Theory]
+ [InlineData("{\"v\":1,\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
+ [InlineData("{\"v\":1,\"e\":42,\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
+ public void MissingOrWrongKindEventNameSurfacesAsMalformedEvent(string line)
+ {
+ Assert.IsType(StatusEventParser.Parse(line));
+ }
+
+ [Fact]
+ public void KnownEValueWithMissingRequiredFieldSurfacesAsMalformedEventRatherThanThrowing()
+ {
+ // characterList without "characters" — a shape mismatch on a
+ // KNOWN event name. Review finding F12: this must be
+ // distinguishable from an unrecognized e value, so it now
+ // surfaces as MalformedStatusEvent rather than UnknownStatusEvent.
+ var e = StatusEventParser.Parse(
+ """{"v":1,"e":"characterList","t":"2026-08-14T12:00:09Z","sessionId":"s1","accountName":"a","slotCount":6}""");
+
+ var malformed = Assert.IsType(e);
+ Assert.Equal("characterList", malformed.E);
+ Assert.Equal("s1", malformed.SessionId);
+ Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
+ }
+
+ [Fact]
+ public void KnownEValueWithAFieldOfTheWrongJsonKindSurfacesAsMalformedEvent()
+ {
+ // "characters" present but not an array — this throws
+ // InvalidOperationException out of JsonElement.EnumerateArray()
+ // rather than the FormatException a missing/wrong-kind scalar
+ // field throws, so it exercises the parser's other malformed-
+ // payload catch path.
+ var e = StatusEventParser.Parse(
+ """{"v":1,"e":"characterList","t":"2026-08-14T12:00:10Z","sessionId":"s1","accountName":"a","slotCount":6,"characters":"not-an-array"}""");
+
+ var malformed = Assert.IsType(e);
+ Assert.Equal("characterList", malformed.E);
+ Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs
new file mode 100644
index 00000000..d2ad011c
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs
@@ -0,0 +1,214 @@
+using System.Text;
+using AcDream.Launcher.Core.Status;
+
+namespace AcDream.Launcher.Core.Tests.Status;
+
+public sealed class StatusFileTailerTests : IDisposable
+{
+ private readonly string _root;
+ private readonly string _path;
+
+ public StatusFileTailerTests()
+ {
+ _root = Path.Combine(
+ Path.GetTempPath(),
+ "acdream-launcher-tailer-tests",
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_root);
+ _path = Path.Combine(_root, "status.jsonl");
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void ReturnsNoEventsWhenTheFileDoesNotExistYet()
+ {
+ var tailer = new StatusFileTailer(_path);
+
+ IReadOnlyList events = tailer.ReadNewEvents();
+
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public void ReturnsNoEventsWhenNothingHasBeenAppendedSinceTheLastPoll()
+ {
+ AppendShared(Line("started", "s1"));
+ var tailer = new StatusFileTailer(_path);
+ Assert.Single(tailer.ReadNewEvents());
+
+ IReadOnlyList events = tailer.ReadNewEvents();
+
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public void ReadsMultipleCompleteLinesInOnePoll()
+ {
+ AppendShared(Line("started", "s1") + Line("connected", "s1"));
+ var tailer = new StatusFileTailer(_path);
+
+ IReadOnlyList events = tailer.ReadNewEvents();
+
+ Assert.Equal(2, events.Count);
+ Assert.IsType(events[0]);
+ Assert.IsType(events[1]);
+ }
+
+ [Fact]
+ public void ContinuesPastCompleteNonObjectJsonValuesToTheFollowingValidLine()
+ {
+ AppendShared(
+ "[]\nnull\n42\n\"text\"\n"
+ + Line("connected", "s1"));
+ var tailer = new StatusFileTailer(_path);
+
+ IReadOnlyList events = tailer.ReadNewEvents();
+
+ Assert.Equal(5, events.Count);
+ Assert.All(events.Take(4), e => Assert.IsType(e));
+ Assert.IsType(events[4]);
+ }
+
+ [Fact]
+ public void TolerateAPartialLastLineAndCompletesItOnALaterPoll()
+ {
+ string full = Line("started", "s1");
+ int splitAt = full.Length - 10; // cut mid-object, before the closing brace/newline
+ AppendShared(full[..splitAt]);
+ var tailer = new StatusFileTailer(_path);
+
+ IReadOnlyList firstPoll = tailer.ReadNewEvents();
+ Assert.Empty(firstPoll);
+
+ AppendShared(full[splitAt..]);
+ IReadOnlyList secondPoll = tailer.ReadNewEvents();
+
+ StatusEvent onlyEvent = Assert.Single(secondPoll);
+ Assert.IsType(onlyEvent);
+ }
+
+ [Fact]
+ public void APartialLineFollowedByAFullLineOnlyEmitsTheCompleteOne()
+ {
+ AppendShared(Line("started", "s1"));
+ string partial = """{"v":1,"e":"connected","t":"2026-08-14T12:00:00Z","sessionId":"s1"""; // no closing
+ AppendShared(partial);
+ var tailer = new StatusFileTailer(_path);
+
+ IReadOnlyList events = tailer.ReadNewEvents();
+
+ StatusEvent onlyEvent = Assert.Single(events);
+ Assert.IsType(onlyEvent);
+
+ // Completing the second line on a later poll produces exactly
+ // one more event, proving the partial bytes were retained (not
+ // dropped and not double-counted).
+ AppendShared("\"}\n");
+ IReadOnlyList secondPoll = tailer.ReadNewEvents();
+ StatusEvent completed = Assert.Single(secondPoll);
+ Assert.IsType(completed);
+ }
+
+ [Fact]
+ public void SkipsBlankLines()
+ {
+ AppendShared("\n" + Line("started", "s1") + "\n" + Line("connected", "s1"));
+ var tailer = new StatusFileTailer(_path);
+
+ IReadOnlyList events = tailer.ReadNewEvents();
+
+ Assert.Equal(2, events.Count);
+ }
+
+ [Fact]
+ public void ReadsWithAWriterHoldingTheFileOpenForAppend()
+ {
+ // Share-tolerant reads: the writer's handle stays open the whole
+ // time (FileShare.ReadWrite on both sides), matching a live host
+ // process appending status.jsonl while the launcher tails it.
+ using var writer = new FileStream(
+ _path,
+ FileMode.Create,
+ FileAccess.Write,
+ FileShare.ReadWrite | FileShare.Delete);
+ var tailer = new StatusFileTailer(_path);
+
+ byte[] first = Encoding.UTF8.GetBytes(Line("started", "s1"));
+ writer.Write(first, 0, first.Length);
+ writer.Flush();
+
+ IReadOnlyList firstPoll = tailer.ReadNewEvents();
+ Assert.Single(firstPoll);
+
+ byte[] second = Encoding.UTF8.GetBytes(Line("connected", "s1"));
+ writer.Write(second, 0, second.Length);
+ writer.Flush();
+
+ IReadOnlyList secondPoll = tailer.ReadNewEvents();
+ Assert.Single(secondPoll);
+ Assert.IsType(secondPoll[0]);
+ }
+
+ [Fact]
+ public void ReadNewEventsReturnsEmptyRatherThanThrowingOnASharingViolation()
+ {
+ // A deterministic proxy for the File.Exists -> new FileStream
+ // TOCTOU window (review finding F7): Windows enforces FileShare
+ // at the OS level, so holding an exclusive (FileShare.None)
+ // handle open while the tailer tries to open the same path
+ // reliably reproduces the IOException the tailer must now
+ // swallow instead of throwing out of a method documented never
+ // to throw. (.NET's FileStream doesn't apply mandatory locking
+ // on Linux by default, so this specific scenario isn't
+ // reproducible there — the fix itself is platform-agnostic, only
+ // this particular deterministic trigger is Windows-only.)
+ if (!OperatingSystem.IsWindows())
+ return;
+
+ AppendShared(Line("started", "s1"));
+ using var exclusiveHandle = new FileStream(
+ _path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
+
+ var tailer = new StatusFileTailer(_path);
+
+ IReadOnlyList events = tailer.ReadNewEvents();
+
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public void RestartsFromTheTopWhenTheFileIsTruncatedOrReplaced()
+ {
+ AppendShared(Line("started", "s1") + Line("connected", "s1"));
+ var tailer = new StatusFileTailer(_path);
+ Assert.Equal(2, tailer.ReadNewEvents().Count);
+
+ File.Delete(_path);
+ AppendShared(Line("started", "s2"));
+
+ IReadOnlyList events = tailer.ReadNewEvents();
+ StatusEvent onlyEvent = Assert.Single(events);
+ Assert.Equal("s2", onlyEvent.SessionId);
+ }
+
+ private static string Line(string e, string sessionId) =>
+ $$"""{"v":1,"e":"{{e}}","t":"2026-08-14T12:00:00Z","sessionId":"{{sessionId}}"}""" + "\n";
+
+ private void AppendShared(string text)
+ {
+ using var stream = new FileStream(
+ _path,
+ FileMode.Append,
+ FileAccess.Write,
+ FileShare.ReadWrite | FileShare.Delete);
+ byte[] bytes = Encoding.UTF8.GetBytes(text);
+ stream.Write(bytes, 0, bytes.Length);
+ }
+}