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/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj
new file mode 100644
index 00000000..89be4968
--- /dev/null
+++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj
@@ -0,0 +1,12 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ 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..7ee3ed95
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs
@@ -0,0 +1,120 @@
+using System.Diagnostics;
+
+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();
+
+ /// 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 class SystemChildProcess : ILauncherChildProcess
+{
+ 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 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..c6749fdf
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs
@@ -0,0 +1,146 @@
+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 ILauncherChildProcess? _process;
+
+ public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
+ {
+ _factory = factory ?? new SystemChildProcessFactory();
+ }
+
+ public LauncherSessionState State { get; private set; } = LauncherSessionState.Starting;
+
+ /// Set once reaches
+ /// ; null before then.
+ ///
+ public int? ExitCode { get; private set; }
+
+ /// 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);
+
+ try
+ {
+ process.Start();
+
+ 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;
+ }
+
+ throw;
+ }
+
+ SetState(LauncherSessionState.Running);
+ }
+
+ ///
+ /// Requests a graceful stop (CloseMainWindow), falling back to Kill
+ /// if the process has not exited within .
+ /// A no-op if was never called or the process has
+ /// already exited.
+ ///
+ public void Stop(TimeSpan timeout)
+ {
+ ILauncherChildProcess? process;
+ lock (_gate)
+ {
+ process = _process;
+ }
+
+ if (process is null || process.HasExited)
+ {
+ return;
+ }
+
+ process.CloseMainWindow();
+ if (!process.WaitForExit(timeout) && !process.HasExited)
+ {
+ process.Kill();
+ }
+ }
+
+ private void OnProcessExited(object? sender, EventArgs e)
+ {
+ ILauncherChildProcess? process;
+ lock (_gate)
+ {
+ process = _process;
+ }
+
+ ExitCode = process is { HasExited: true } ? process.ExitCode : null;
+ SetState(LauncherSessionState.Exited);
+ }
+
+ private void SetState(LauncherSessionState state)
+ {
+ lock (_gate)
+ {
+ State = state;
+ }
+
+ StateChanged?.Invoke(this, state);
+ }
+
+ 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..9bba0226
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs
@@ -0,0 +1,167 @@
+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 sessionDirectory = Path.Combine(
+ paths.CacheDirectory,
+ "launcher",
+ "sessions",
+ sessionId);
+ string configFilePath = Path.Combine(sessionDirectory, "session.json");
+ string statusFilePath = Path.Combine(sessionDirectory, "status.jsonl");
+
+ 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
+ {
+ Paths = new SessionPathOverrides(),
+ 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 SessionCharacterSelector BuildSelector(CharacterProfile character)
+ {
+ if (CharacterIdFormat.TryParse(character.Id, out uint id))
+ {
+ 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..fca7c970
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs
@@ -0,0 +1,134 @@
+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
+{
+ public SessionPathOverrides Paths { get; init; } = new();
+
+ 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;
+
+ 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..55570cd5
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs
@@ -0,0 +1,32 @@
+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);
+
+ 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))
+ 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..2fac9f0b
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs
@@ -0,0 +1,395 @@
+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;
+
+ 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()
+ {
+ 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, restricts the final file to
+ /// owner read/write (0600) per Campaign LA's plaintext-credential
+ /// decision (spec §5, decisions log).
+ ///
+ public void Save()
+ {
+ string? directory = Path.GetDirectoryName(FilePath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ string tempPath = FilePath + ".tmp";
+ using (FileStream stream = File.Create(tempPath))
+ {
+ JsonSerializer.Serialize(stream, Document, SerializerOptions);
+ }
+
+ File.Move(tempPath, FilePath, overwrite: true);
+
+ if (OperatingSystem.IsLinux())
+ {
+ File.SetUnixFileMode(
+ FilePath,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ }
+
+ // --- 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);
+ CharacterProfile? existing = profile.Characters.Find(
+ character => string.Equals(
+ character.Id,
+ idText,
+ StringComparison.OrdinalIgnoreCase));
+
+ // Defensive fallback for a hand-edited file where a character
+ // row was added with a name but no id yet.
+ existing ??= profile.Characters.Find(
+ character => character.Id is null
+ && 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..b216392b
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs
@@ -0,0 +1,81 @@
+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,
+ int 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 well-formed status line whose e value (or overall envelope
+/// shape) 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; }
+}
diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs
new file mode 100644
index 00000000..17495b7c
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs
@@ -0,0 +1,247 @@
+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 line whose e is not one of the eight known
+/// values, or whose payload doesn't match that event's expected shape,
+/// or that isn't valid JSON at all, degrades to a typed
+/// rather than an exception — a
+/// launcher must keep tailing a session's status stream even against a
+/// host running a newer/older wire version.
+///
+///
+public static class StatusEventParser
+{
+ public static StatusEvent Parse(string line)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(line);
+
+ try
+ {
+ using JsonDocument document = JsonDocument.Parse(line);
+ JsonElement root = document.RootElement;
+
+ int v = GetInt32OrDefault(root, "v");
+ string e = GetStringOrDefault(root, "e");
+ DateTimeOffset t = GetDateTimeOffsetOrDefault(root, "t");
+ string sessionId = GetStringOrDefault(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),
+ _ =>
+ new UnknownStatusEvent
+ {
+ V = v,
+ E = e,
+ T = t,
+ SessionId = sessionId,
+ RawJson = line,
+ },
+ };
+ }
+ catch (Exception)
+ {
+ // JsonException (malformed JSON), FormatException (a
+ // required-field miss inside a Parse* helper) — all degrade
+ // the same way: never throw out of the tailer.
+ return new UnknownStatusEvent
+ {
+ V = 0,
+ E = string.Empty,
+ T = default,
+ SessionId = string.Empty,
+ RawJson = line,
+ };
+ }
+ }
+
+ 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");
+ int secondsGreyedOut = RequireInt32(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
+ && DateTimeOffset.TryParse(
+ element.GetString(),
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.None,
+ out DateTimeOffset value)
+ ? 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 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..69b81852
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs
@@ -0,0 +1,118 @@
+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 or nothing new/complete has
+ /// arrived since the last poll.
+ ///
+ public IReadOnlyList ReadNewEvents()
+ {
+ 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/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
new file mode 100644
index 00000000..df12ea29
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs
@@ -0,0 +1,246 @@
+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 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)
+ : ILauncherChildProcessFactory
+ {
+ public FakeChildProcess? LastCreated { get; private set; }
+
+ public ILauncherChildProcess Create(LauncherProcessSpec spec)
+ {
+ LastCreated = new FakeChildProcess(spec, exitsWithinStopTimeout);
+ return LastCreated;
+ }
+ }
+
+ private sealed class FakeChildProcess(LauncherProcessSpec spec, bool exitsWithinStopTimeout)
+ : ILauncherChildProcess
+ {
+ private readonly RecordingTextWriter _standardInput = 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 KillCallCount { get; private set; }
+
+ public bool HasExited { get; private set; }
+
+ public int ExitCode { get; private set; }
+
+ public TextWriter StandardInput => _standardInput;
+
+ public event EventHandler? Exited;
+
+ public void Start() => Started = true;
+
+ public bool CloseMainWindow()
+ {
+ CloseMainWindowCallCount++;
+ return true;
+ }
+
+ public void Kill()
+ {
+ KillCallCount++;
+ HasExited = true;
+ ExitCode = -1;
+ Exited?.Invoke(this, EventArgs.Empty);
+ }
+
+ public bool WaitForExit(TimeSpan timeout)
+ {
+ if (!exitsWithinStopTimeout)
+ return false;
+
+ HasExited = true;
+ ExitCode = 0;
+ Exited?.Invoke(this, EventArgs.Empty);
+ return true;
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+
+ private sealed class RecordingTextWriter : StringWriter
+ {
+ public bool IsClosed { get; private set; }
+
+ protected override void Dispose(bool disposing)
+ {
+ IsClosed = true;
+ base.Dispose(disposing);
+ }
+ }
+}
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..49d8615b
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs
@@ -0,0 +1,269 @@
+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 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 ProcessContentCarriesInstallRecordAndPathsIsAlwaysPresent()
+ {
+ 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();
+ AssertKeys(process, "paths", "content");
+
+ // Paths is always present as an object; every member is omitted
+ // when unset (hosts resolve their own default ApplicationPathSet).
+ Assert.Empty(process["paths"]!.AsObject());
+
+ 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 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..fdade48b
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs
@@ -0,0 +1,43 @@
+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("5000000A", 0x5000000Au)]
+ public void TryParseAcceptsWithAndWithoutPrefixAndCase(string text, uint expected)
+ {
+ Assert.True(CharacterIdFormat.TryParse(text, out uint id));
+ Assert.Equal(expected, id);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not-hex")]
+ public void TryParseRejectsNullEmptyOrNonHex(string? text)
+ {
+ 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..db6a672a
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs
@@ -0,0 +1,287 @@
+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);
+ }
+}
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..9b967ea9
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs
@@ -0,0 +1,146 @@
+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 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..ef98becd
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs
@@ -0,0 +1,125 @@
+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(0, list.Characters[0].SecondsGreyedOut);
+ Assert.Equal(1342177291u, list.Characters[1].Id);
+ Assert.Equal(1, 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);
+ }
+
+ [Fact]
+ public void KnownEValueWithMissingRequiredFieldSurfacesAsUnknownEventRatherThanThrowing()
+ {
+ // characterList without "characters" — a shape mismatch, not
+ // just an unrecognized e value.
+ var e = StatusEventParser.Parse(
+ """{"v":1,"e":"characterList","t":"2026-08-14T12:00:09Z","sessionId":"s1","accountName":"a","slotCount":6}""");
+
+ Assert.IsType(e);
+ }
+}
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..f908ec06
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs
@@ -0,0 +1,172 @@
+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 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 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);
+ }
+}