merge: Campaign LA LA3 - Launcher.Core review-closed

This commit is contained in:
Erik 2026-08-14 17:11:25 +02:00
commit 7749545dc4
33 changed files with 4477 additions and 0 deletions

View file

@ -7,6 +7,7 @@
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
<Project Path="src/AcDream.Headless/AcDream.Headless.csproj" />
<Project Path="src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj" />
<Project Path="src/AcDream.Platform/AcDream.Platform.csproj" />
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
@ -25,6 +26,7 @@
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />

View file

@ -24,6 +24,57 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host
**Status:** OPEN
**Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE
account session stuck for several minutes — a documented project landmine;
see CLAUDE.md "Logout-before-reconnect")
**Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3)
**Component:** Launcher.Core / process supervision
**Description.** `LauncherProcessSupervisor.Stop` now attempts a graceful
stop signal (`ILauncherChildProcess.TryRequestGracefulStop`) BEFORE
`CloseMainWindow`. On Linux this sends `SIGINT` via a `libc` P/Invoke
(`kill(pid, 2)`), which the K4-proven headless host already turns into an
ACE-confirmed graceful logout. On Windows there is no equivalent today for a
console process with no message-pump window: `CloseMainWindow` is a no-op
for a console host (there is no `HWND` to target), and
`GenerateConsoleCtrlEvent` cannot usefully target an arbitrary child process
today — Windows delivers console control events to every process attached
to the SAME console as the calling process, so an unscoped call would also
signal the launcher itself (and anything else sharing that console), not
just the intended child. `TryRequestGracefulStop` therefore returns `false`
on Windows unconditionally, and `Stop` degrades straight to `CloseMainWindow`
(still a no-op for a console child) and then the timeout-driven `Kill()`
exactly the hard-kill behavior this finding was written to describe, just
with a documented (rather than silent) gap.
**Known fix direction (not yet implemented).** Spawn the Windows child with
the `CREATE_NEW_PROCESS_GROUP` creation flag (available via a native
`CreateProcess` call or by setting it on the `ProcessStartInfo`/`Process`
plumbing in `SystemChildProcess`) so the child gets its own console process
group, detached from the launcher's own group. Then
`TryRequestGracefulStop` on Windows calls
`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)`
`CTRL_BREAK` (unlike `CTRL_C`) can target a specific process group ID and,
unlike `CTRL_CLOSE`/`CTRL_LOGOFF`/`CTRL_SHUTDOWN`, is deliverable to a
process that has installed no console-control handler at all (the default
CRT handler treats it as a terminating signal, so `AcDream.Headless` doesn't
strictly need new code to receive SOME form of shutdown from it) — though
wiring a real `SetConsoleCtrlHandler` handler that routes `CTRL_BREAK` into
the same graceful-shutdown path K4 already built for Linux SIGINT is the
better long-term target, so a Windows headless launch gets the identical
ACE-confirmed graceful logout instead of just "exits somehow."
**Acceptance for closing this issue:** `SystemChildProcess` spawns Windows
children with `CREATE_NEW_PROCESS_GROUP`; `TryRequestGracefulStop` sends
`CTRL_BREAK_EVENT` to that child's process group on Windows; a live
connected gate proves `AcDream.Headless` exits gracefully (ACE clears the
session immediately, not after the ~3-minute stale-session window) when
stopped via `LauncherProcessSupervisor.Stop` on Windows, matching the
Linux SIGINT behavior.
## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click
**Status:** ROOT-CAUSED + FIXED — pending the user's visual re-gate of the

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Required by the LibraryImportAttribute source generator (F3's
Linux SIGINT P/Invoke in Launching/ILauncherChildProcess.cs):
the generated marshalling stub calls through an unmanaged
function pointer, which the compiler only allows in an unsafe
context. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,61 @@
using System.Security.Cryptography;
namespace AcDream.Launcher.Core.Integrity;
/// <summary>
/// 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.
/// </summary>
public static class FileIntegrity
{
/// <summary>
/// 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&#160;GB pak file LA9 verifies).
/// </summary>
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<string> 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);
}
/// <summary>Case-insensitive hex comparison — callers may receive an
/// expected digest in either case from a manifest or a hand-typed
/// fixture.</summary>
public static bool Matches(string actualHex, string expectedHex)
{
ArgumentNullException.ThrowIfNull(actualHex);
ArgumentNullException.ThrowIfNull(expectedHex);
return string.Equals(actualHex, expectedHex, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Computes and compares in one call.</summary>
public static bool Verify(string filePath, string expectedHex) =>
Matches(ComputeSha256Hex(filePath), expectedHex);
}

View file

@ -0,0 +1,174 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace AcDream.Launcher.Core.Launching;
/// <summary>
/// Thin seam over <see cref="System.Diagnostics.Process"/> so
/// <see cref="LauncherProcessSupervisor"/>'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.
/// </summary>
public interface ILauncherChildProcess : IDisposable
{
bool HasExited { get; }
int ExitCode { get; }
/// <summary>The child's redirected stdin. The supervisor writes the
/// account password here (followed by a newline) and then closes it —
/// never anywhere else.</summary>
TextWriter StandardInput { get; }
/// <summary>Fires exactly once, when the child process terminates
/// (mirrors <see cref="Process.Exited"/> with
/// <c>EnableRaisingEvents</c> on).</summary>
event EventHandler? Exited;
void Start();
/// <summary>
/// Attempts a graceful stop signal appropriate to the platform,
/// tried BEFORE <see cref="CloseMainWindow"/> (Campaign LA plan §LA3
/// review finding F3): a no-window console host (e.g.
/// <c>AcDream.Headless</c>) never has a main window for
/// <see cref="CloseMainWindow"/> to close, so without this step
/// <see cref="LauncherProcessSupervisor.Stop"/> always degraded
/// straight to a timeout + hard <see cref="Kill"/> — and a hard kill
/// leaves the ACE account session stuck for several minutes (a
/// documented project landmine; see CLAUDE.md
/// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved
/// the headless host's SIGINT handler produces an ACE-confirmed
/// graceful logout). On Windows there is no reliable cross-console
/// mechanism for an arbitrary no-window child process today — see
/// <c>docs/ISSUES.md</c> for the tracked gap and fix direction; this
/// returns false there. Returns true only when the signal was
/// actually delivered; never throws.
/// </summary>
bool TryRequestGracefulStop();
/// <summary>Mirrors <see cref="Process.CloseMainWindow"/> — requests
/// a graceful close via WM_CLOSE. Returns false for a console/no-
/// window process (never throws), matching the real API.</summary>
bool CloseMainWindow();
/// <summary>Mirrors <see cref="Process.Kill(bool)"/> with
/// <c>entireProcessTree: true</c>.</summary>
void Kill();
bool WaitForExit(TimeSpan timeout);
}
/// <summary>Creates <see cref="ILauncherChildProcess"/> instances from a
/// <see cref="LauncherProcessSpec"/>.</summary>
public interface ILauncherChildProcessFactory
{
ILauncherChildProcess Create(LauncherProcessSpec spec);
}
/// <summary>Real-process implementation used in production.</summary>
public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory
{
public ILauncherChildProcess Create(LauncherProcessSpec spec) =>
new SystemChildProcess(spec);
}
internal sealed partial class SystemChildProcess : ILauncherChildProcess
{
// SIGINT's numeric value (POSIX-stable across Linux distributions).
// K4/Slice K already proved the headless host's SIGINT handler
// produces an ACE-confirmed graceful logout.
private const int Sigint = 2;
[LibraryImport("libc", SetLastError = true)]
private static partial int kill(int pid, int sig);
private readonly Process _process;
private bool _raisingEnabled;
internal SystemChildProcess(LauncherProcessSpec spec)
{
ArgumentNullException.ThrowIfNull(spec);
var startInfo = new ProcessStartInfo
{
FileName = spec.ExecutablePath,
RedirectStandardInput = true,
UseShellExecute = false,
};
foreach (string argument in spec.Arguments)
{
startInfo.ArgumentList.Add(argument);
}
if (!string.IsNullOrEmpty(spec.WorkingDirectory))
{
startInfo.WorkingDirectory = spec.WorkingDirectory;
}
_process = new Process { StartInfo = startInfo };
}
public bool HasExited => _process.HasExited;
public int ExitCode => _process.ExitCode;
public TextWriter StandardInput => _process.StandardInput;
public event EventHandler? Exited;
public void Start()
{
_process.EnableRaisingEvents = true;
_process.Exited += OnExited;
_raisingEnabled = true;
_process.Start();
}
public bool TryRequestGracefulStop()
{
if (!OperatingSystem.IsLinux())
{
// No reliable cross-console mechanism exists for an
// arbitrary no-window Windows child process — tracked gap,
// see docs/ISSUES.md.
return false;
}
try
{
return kill(_process.Id, Sigint) == 0;
}
catch
{
// Matches CloseMainWindow's "never throws" contract — the
// process may not have started yet, may have already exited
// (ESRCH), or the platform may lack libc under an unusual
// Linux runtime; any of these degrade to "signal not sent"
// rather than an exception out of Stop().
return false;
}
}
public bool CloseMainWindow() => _process.CloseMainWindow();
public void Kill() => _process.Kill(entireProcessTree: true);
public bool WaitForExit(TimeSpan timeout) => _process.WaitForExit(timeout);
public void Dispose()
{
if (_raisingEnabled)
{
_process.Exited -= OnExited;
}
_process.Dispose();
}
private void OnExited(object? sender, EventArgs e) =>
Exited?.Invoke(this, EventArgs.Empty);
}

View file

@ -0,0 +1,12 @@
namespace AcDream.Launcher.Core.Launching;
/// <summary>
/// The DAT/pak locations a completed install (LA9) records and every
/// session-config composition consumes for
/// <see cref="SessionContentDescriptor"/>. SHA-256/version bookkeeping
/// for the install record itself is LA9/LA10 scope; this slice only
/// needs the two paths a session config requires.
/// </summary>
public sealed record LauncherInstallRecord(
string DatDirectory,
string PreparedAssetPath);

View file

@ -0,0 +1,15 @@
namespace AcDream.Launcher.Core.Launching;
/// <summary>
/// What to spawn: the host executable path + argument list (both
/// injectable per Campaign LA spec §3, e.g. <c>AcDream.Headless --config
/// &lt;path&gt;</c> or <c>AcDream.App --session-config &lt;path&gt;</c>).
/// Deliberately carries no credential field — the password is a separate
/// transient parameter to <see cref="LauncherProcessSupervisor.Start"/>
/// that flows only to the child's stdin, never into this spec, an
/// argument list, or a process environment.
/// </summary>
public sealed record LauncherProcessSpec(
string ExecutablePath,
IReadOnlyList<string> Arguments,
string? WorkingDirectory = null);

View file

@ -0,0 +1,286 @@
using System.Runtime.ExceptionServices;
namespace AcDream.Launcher.Core.Launching;
/// <summary>
/// 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.
/// </summary>
public sealed class LauncherProcessSupervisor : IDisposable
{
private readonly ILauncherChildProcessFactory _factory;
private readonly object _gate = new();
private readonly Queue<LauncherSessionState> _pendingStateChanges = [];
private ILauncherChildProcess? _process;
private LauncherSessionState _state = LauncherSessionState.Starting;
private int? _exitCode;
private bool _publishingStateChanges;
public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
{
_factory = factory ?? new SystemChildProcessFactory();
}
public LauncherSessionState State
{
get
{
lock (_gate)
{
return _state;
}
}
}
/// <summary>Set once <see cref="State"/> reaches
/// <see cref="LauncherSessionState.Exited"/>; null before then.
/// </summary>
public int? ExitCode
{
get
{
lock (_gate)
{
return _exitCode;
}
}
}
/// <summary>Fires on every <see cref="LauncherSessionState"/>
/// transition, in order.</summary>
public event EventHandler<LauncherSessionState>? StateChanged;
/// <summary>
/// Spawns the child described by <paramref name="spec"/>, writes
/// <paramref name="password"/> (if any) followed by a newline to its
/// stdin, then closes stdin. The password is never written anywhere
/// else — not into <paramref name="spec"/>, not into an environment
/// variable, not logged.
/// </summary>
public void Start(LauncherProcessSpec spec, string? password)
{
ArgumentNullException.ThrowIfNull(spec);
ILauncherChildProcess process;
lock (_gate)
{
if (_process is not null)
{
throw new InvalidOperationException(
"This supervisor already owns a process; start a new "
+ "supervisor per launched session.");
}
process = _factory.Create(spec);
process.Exited += OnProcessExited;
_process = process;
}
SetState(LauncherSessionState.Starting);
bool started = false;
try
{
process.Start();
started = true;
if (password is not null)
{
process.StandardInput.Write(password);
process.StandardInput.Write('\n');
process.StandardInput.Flush();
}
process.StandardInput.Close();
}
catch
{
lock (_gate)
{
process.Exited -= OnProcessExited;
_process = null;
}
// A failure after the child actually started (e.g. the stdin
// pipe breaks while feeding the password) must not leave a
// live, unsupervised, undisposable child running (Campaign LA
// plan §LA3 review finding F8) — kill the whole process tree
// and release the handle before propagating the original
// failure.
if (started)
{
try
{
process.Kill();
}
catch
{
// Best-effort — the ORIGINAL failure, rethrown below,
// is what the caller needs to see; a failed cleanup
// kill must not replace it.
}
}
process.Dispose();
throw;
}
SetState(LauncherSessionState.Running);
}
/// <summary>
/// Requests a graceful stop — first
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/> (SIGINT
/// on Linux; a no-op on Windows today, see
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/>'s docs),
/// then <see cref="ILauncherChildProcess.CloseMainWindow"/> — falling
/// back to <see cref="ILauncherChildProcess.Kill"/> if the process has
/// not exited within <paramref name="timeout"/>. A no-op if
/// <see cref="Start"/> was never called or the process has already
/// exited.
/// <para>
/// BLOCKS THE CALLING THREAD for up to <paramref name="timeout"/>
/// (via the real child's <c>WaitForExit</c>) — callers on a UI thread
/// must dispatch this off-thread rather than calling it directly (a
/// binding requirement for the LA4 Avalonia UI, which will call this
/// method from a "stop session" action).
/// </para>
/// </summary>
public void Stop(TimeSpan timeout)
{
ILauncherChildProcess? process;
lock (_gate)
{
process = _process;
}
if (process is null || process.HasExited)
{
return;
}
process.TryRequestGracefulStop();
process.CloseMainWindow();
if (!process.WaitForExit(timeout) && !process.HasExited)
{
process.Kill();
}
}
private void OnProcessExited(object? sender, EventArgs e)
{
int? exitCode;
lock (_gate)
{
exitCode = _process is { HasExited: true } process
? process.ExitCode
: null;
}
SetState(LauncherSessionState.Exited, exitCode);
}
/// <summary>
/// Applies a state transition, or silently ignores it (Campaign LA
/// plan §LA3 review finding F9): once <see cref="State"/> reaches the
/// terminal <see cref="LauncherSessionState.Exited"/>, no later call
/// may move it anywhere else, and <see cref="StateChanged"/> only
/// fires for a transition that was actually applied. This matters
/// because <see cref="Start"/>'s trailing
/// <c>SetState(LauncherSessionState.Running)</c> can race a
/// synchronous <see cref="OnProcessExited"/> callback fired from
/// inside <see cref="Start"/> itself (a child that dies immediately)
/// — without this guard, "Running" would silently resurrect a
/// process that has already reported its exit.
/// </summary>
private void SetState(LauncherSessionState state, int? exitCode = null)
{
bool publish;
lock (_gate)
{
if (_state == LauncherSessionState.Exited)
{
return;
}
_state = state;
if (state == LauncherSessionState.Exited)
{
_exitCode = exitCode;
}
_pendingStateChanges.Enqueue(state);
publish = !_publishingStateChanges;
if (publish)
{
_publishingStateChanges = true;
}
}
if (publish)
{
PublishPendingStateChanges();
}
}
/// <summary>
/// Drains state notifications through one publisher. Transition storage
/// stays under <see cref="_gate"/>, but user callbacks run outside it so
/// they may re-enter the supervisor or wait for another thread reading
/// state without deadlocking. A concurrent/re-entrant transition queues
/// behind the notification already in flight, preserving storage order in
/// the externally observed event stream.
/// </summary>
private void PublishPendingStateChanges()
{
Exception? firstException = null;
while (true)
{
LauncherSessionState state;
lock (_gate)
{
if (_pendingStateChanges.Count == 0)
{
_publishingStateChanges = false;
break;
}
state = _pendingStateChanges.Dequeue();
}
try
{
StateChanged?.Invoke(this, state);
}
catch (Exception ex)
{
// Preserve the previous propagation behavior, but finish
// publishing any transition already committed concurrently
// (especially terminal Exited) before rethrowing the first
// observer failure to the initiating caller.
firstException ??= ex;
}
}
if (firstException is not null)
{
ExceptionDispatchInfo.Capture(firstException).Throw();
}
}
public void Dispose()
{
lock (_gate)
{
if (_process is not null)
{
_process.Exited -= OnProcessExited;
_process.Dispose();
_process = null;
}
}
}
}

View file

@ -0,0 +1,21 @@
namespace AcDream.Launcher.Core.Launching;
/// <summary>Lifecycle of a launched host process, per Campaign LA spec §3
/// ("supervise lifetime ... surface typed session state").</summary>
public enum LauncherSessionState
{
/// <summary>The child process has been created and the credential
/// handed off, but has not yet reached <see cref="Running"/>.</summary>
Starting,
/// <summary>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
/// <c>AcDream.Launcher.Core.Status</c>).</summary>
Running,
/// <summary>The child process has exited. See
/// <see cref="LauncherProcessSupervisor.ExitCode"/> for the exit
/// code.</summary>
Exited,
}

View file

@ -0,0 +1,242 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.Launcher.Core.Profiles;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Launching;
/// <summary>The composed session-config document plus the two per-launch
/// paths derived from the session id, per Campaign LA spec §6.</summary>
public sealed record ComposedSessionConfig(
string SessionId,
string ConfigFilePath,
string StatusFilePath,
SessionConfigDocument Document);
/// <summary>
/// Builds the per-launch <see cref="SessionConfigDocument"/> from a
/// profile character + install record (Campaign LA spec §6). Passwords
/// NEVER appear in the composed document — the credential is always the
/// <c>standardInput</c> provider; the launcher feeds the password to the
/// child process's stdin separately (<see cref="LauncherProcessSupervisor"/>).
/// </summary>
public static class SessionConfigComposer
{
internal static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
};
/// <summary>
/// Builds the document and the paths it would be written to under
/// <c>&lt;CacheDirectory&gt;/launcher/sessions/&lt;sessionId&gt;/</c>,
/// without touching disk. <paramref name="sessionId"/> is caller-
/// supplied so composition stays a pure function of its inputs
/// (golden-file tests pass a fixed id).
/// </summary>
public static ComposedSessionConfig Compose(
ServerProfile server,
AccountProfile account,
CharacterProfile character,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId,
int? loginCommandDelayMs = null)
{
ArgumentNullException.ThrowIfNull(server);
ArgumentNullException.ThrowIfNull(account);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(install);
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
(string configFilePath, string statusFilePath) = BuildSessionPaths(paths, sessionId);
SessionCharacterSelector? selector = character.LaunchMode == LaunchMode.GuiSelect
? null
: BuildSelector(character);
SessionPolicyDescriptor? policy = character.LaunchMode == LaunchMode.Headless
? new SessionPolicyDescriptor()
: null;
var descriptor = new SessionDescriptor
{
Id = sessionId,
Endpoint = new SessionEndpointDescriptor
{
Host = server.Host,
Port = server.Port,
},
Account = account.Account,
Character = selector,
Policy = policy,
Credential = new SessionCredentialDescriptor(),
Plugins = character.Plugins.Count > 0 ? [.. character.Plugins] : null,
LoginCommands = character.LoginCommands.Count > 0
? [.. character.LoginCommands]
: null,
LoginCommandDelayMs = loginCommandDelayMs,
StatusFile = statusFilePath,
};
var document = new SessionConfigDocument
{
Process = new SessionProcessSettings
{
Content = new SessionContentDescriptor
{
DatDirectory = install.DatDirectory,
PreparedAssetPath = install.PreparedAssetPath,
},
},
Sessions = [descriptor],
};
return new ComposedSessionConfig(
sessionId,
configFilePath,
statusFilePath,
document);
}
/// <summary>
/// Builds a probe session-config document (Campaign LA plan §LA2/
/// §LA3 review finding F2): the session carries <c>mode: "probe"</c>,
/// no <c>character</c> selector, and no <c>policy</c> — the host
/// reports the account's character roster over the status stream and
/// exits without entering the world. <c>plugins</c>/<c>loginCommands</c>
/// don't apply to a probe and are always omitted, exactly like an
/// empty configured set on a normal session.
/// </summary>
public static ComposedSessionConfig ComposeProbe(
ServerProfile server,
AccountProfile account,
LauncherInstallRecord install,
ApplicationPathSet paths,
string sessionId)
{
ArgumentNullException.ThrowIfNull(server);
ArgumentNullException.ThrowIfNull(account);
ArgumentNullException.ThrowIfNull(install);
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
(string configFilePath, string statusFilePath) = BuildSessionPaths(paths, sessionId);
var descriptor = new SessionDescriptor
{
Id = sessionId,
Mode = "probe",
Endpoint = new SessionEndpointDescriptor
{
Host = server.Host,
Port = server.Port,
},
Account = account.Account,
Character = null,
Policy = null,
Credential = new SessionCredentialDescriptor(),
Plugins = null,
LoginCommands = null,
LoginCommandDelayMs = null,
StatusFile = statusFilePath,
};
var document = new SessionConfigDocument
{
Process = new SessionProcessSettings
{
Content = new SessionContentDescriptor
{
DatDirectory = install.DatDirectory,
PreparedAssetPath = install.PreparedAssetPath,
},
},
Sessions = [descriptor],
};
return new ComposedSessionConfig(
sessionId,
configFilePath,
statusFilePath,
document);
}
/// <summary>Composes and writes <c>session.json</c> to
/// <see cref="ComposedSessionConfig.ConfigFilePath"/>, creating the
/// per-session directory. The status file itself is created by the
/// launched host, not the launcher.</summary>
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;
}
/// <summary>Serializes the composed document exactly as
/// <see cref="ComposeAndWrite"/> would write it — used by golden-file
/// tests that assert on the JSON text without touching disk.</summary>
public static string Serialize(SessionConfigDocument document) =>
JsonSerializer.Serialize(document, SerializerOptions);
private static (string ConfigFilePath, string StatusFilePath) BuildSessionPaths(
ApplicationPathSet paths,
string sessionId)
{
string sessionDirectory = Path.Combine(
paths.CacheDirectory,
"launcher",
"sessions",
sessionId);
return (
Path.Combine(sessionDirectory, "session.json"),
Path.Combine(sessionDirectory, "status.jsonl"));
}
private static SessionCharacterSelector BuildSelector(CharacterProfile character)
{
// A parsed id of 0 is not a usable selector — both host loaders
// (App/Headless) reject `id: 0` outright, so falling through to
// the name selector here is the only shape that reaches a real
// character (Campaign LA plan §LA3 review finding F10).
if (CharacterIdFormat.TryParse(character.Id, out uint id) && id != 0)
{
return new SessionCharacterSelector { Id = id };
}
if (!string.IsNullOrWhiteSpace(character.Name))
{
return new SessionCharacterSelector { Name = character.Name };
}
throw new InvalidOperationException(
"Character has neither a usable id nor a name to select by.");
}
}

View file

@ -0,0 +1,152 @@
namespace AcDream.Launcher.Core.Launching;
/// <summary>
/// The per-launch session-config document written to
/// <c>&lt;CacheDirectory&gt;/launcher/sessions/&lt;sessionId&gt;/session.json</c>
/// and consumed by <c>AcDream.Headless --config</c> / (LA1)
/// <c>AcDream.App --session-config</c>.
///
/// <para>
/// PINNED CONTRACT (Campaign LA plan §LA3): this is the Slice K1
/// <c>HeadlessConfiguration</c> version-1 shape extended with optional
/// launcher fields (<c>plugins</c>, <c>loginCommands</c>,
/// <c>loginCommandDelayMs</c>, <c>statusFile</c>). Launcher.Core defines
/// its own DTOs rather than referencing <c>AcDream.Headless</c> — the
/// project reference set for this assembly is <c>AcDream.Platform</c>
/// ONLY (no game-solution dependency; see LA3 acceptance).
/// Serialized camelCase via <see cref="SessionConfigComposer"/>, with
/// null optional members omitted from the written JSON.
/// </para>
/// </summary>
public sealed class SessionConfigDocument
{
public int Version { get; init; } = 1;
public SessionProcessSettings Process { get; init; } = new();
public List<SessionDescriptor> Sessions { get; init; } = [];
}
public sealed class SessionProcessSettings
{
/// <summary>
/// PINNED CONTRACT (Campaign LA plan §LA3 review, finding F1): the
/// <c>paths</c> KEY is entirely OMITTED from the written JSON unless
/// a caller explicitly supplies overrides — never an empty object.
/// The App-side loader parses with strict
/// <c>UnmappedMemberHandling.Disallow</c> and has no <c>paths</c>
/// member of its own, so an emitted <c>"paths":{}</c> is a null-
/// omission artifact (the object's own members are all optional and
/// omit cleanly, but the containing property was never null itself)
/// that would fail every gui/guiSelect launch at config load.
/// </summary>
public SessionPathOverrides? Paths { get; init; }
public SessionContentDescriptor Content { get; init; } = new();
}
/// <summary>All three members are optional overrides; a host resolves
/// its own default <c>ApplicationPathSet</c> when a member is
/// omitted.</summary>
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;
/// <summary>Present only for a probe session (<c>"probe"</c>,
/// Campaign LA plan §LA2/§LA3) — the host reports the account's
/// character roster and exits without entering the world. OMITTED
/// entirely for a normal gui/guiSelect/headless play session.
/// </summary>
public string? Mode { get; init; }
public SessionEndpointDescriptor Endpoint { get; init; } = new();
public string Account { get; init; } = string.Empty;
/// <summary>Exactly one of index/id/name when present. OMITTED
/// entirely for a <c>guiSelect</c> launch (retail character-select
/// screen instead of auto-enter).</summary>
public SessionCharacterSelector? Character { get; init; }
/// <summary>Present only for a <c>headless</c> launch (the <c>idle</c>
/// bot policy). Omitted for <c>gui</c>/<c>guiSelect</c>.</summary>
public SessionPolicyDescriptor? Policy { get; init; }
public SessionCredentialDescriptor Credential { get; init; } = new();
/// <summary>Omitted (never an empty array) when the character has no
/// configured plugin set.</summary>
public List<string>? Plugins { get; init; }
/// <summary>Omitted (never an empty array) when the character has no
/// configured login commands.</summary>
public List<string>? LoginCommands { get; init; }
/// <summary>Overrides the host's default 500&#160;ms inter-command
/// delay when set; omitted otherwise.</summary>
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; }
}
/// <summary>Exactly one of <see cref="Index"/>/<see cref="Id"/>/
/// <see cref="Name"/> is set by <see cref="SessionConfigComposer"/>.
/// </summary>
public sealed class SessionCharacterSelector
{
public int? Index { get; init; }
public uint? Id { get; init; }
public string? Name { get; init; }
}
/// <summary>Campaign LA composes exactly the <c>idle</c> bot policy (LA2)
/// for headless launches — the launcher never asks for any other
/// policy id.</summary>
public sealed class SessionPolicyDescriptor
{
public string Id { get; init; } = "idle";
}
/// <summary>Always the <c>standardInput</c> 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
/// <see cref="LauncherProcessSupervisor"/>).</summary>
public sealed class SessionCredentialDescriptor
{
public string Provider { get; init; } = "standardInput";
public string Reference { get; init; } = "session";
}

View file

@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace AcDream.Launcher.Core.Profiles;
/// <summary>
/// One account under a server, per Campaign LA spec §5. The password is
/// plaintext by explicit user decision
/// (<c>claude-memory/project_launcher_direction.md</c>) — never written
/// anywhere except this file, never logged, never placed in a session
/// config or process argument/environment (see
/// <see cref="AcDream.Launcher.Core.Launching.SessionConfigComposer"/>).
/// </summary>
public sealed class AccountProfile
{
[JsonRequired]
public string Account { get; set; } = string.Empty;
[JsonRequired]
public string Password { get; set; } = string.Empty;
public List<CharacterProfile> Characters { get; set; } = [];
}

View file

@ -0,0 +1,43 @@
using System.Globalization;
namespace AcDream.Launcher.Core.Profiles;
/// <summary>
/// Converts between the wire <c>uint</c> character GUID and the
/// launcher-profile hex-string representation (<c>"0x5000000A"</c>,
/// matching the convention used throughout the project, e.g. the
/// <c>+Acdream</c> test character's <c>0x5000000A</c> in CLAUDE.md).
/// </summary>
public static class CharacterIdFormat
{
public static string ToHexString(uint id) =>
"0x" + id.ToString("X8", CultureInfo.InvariantCulture);
/// <summary>
/// Parses <paramref name="text"/> as a hex character id — the
/// <c>0x</c> prefix (case-insensitive) is REQUIRED (Campaign LA plan
/// §LA3 review finding F10). Every all-digit id is ALSO a valid hex
/// number (e.g. <c>"12345678"</c>), so accepting a bare unprefixed
/// string as hex silently reinterprets a hand-typed decimal id and
/// selects the wrong character; requiring the prefix makes "this is
/// hex" an explicit, unambiguous signal instead of a guess.
/// </summary>
public static bool TryParse(string? text, out uint id)
{
id = 0;
if (string.IsNullOrWhiteSpace(text))
return false;
ReadOnlySpan<char> span = text.AsSpan().Trim();
if (!span.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return false;
span = span[2..];
return uint.TryParse(
span,
NumberStyles.HexNumber,
CultureInfo.InvariantCulture,
out id);
}
}

View file

@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace AcDream.Launcher.Core.Profiles;
/// <summary>
/// One character row under an account, per Campaign LA spec §5. The
/// <see cref="Id"/>/<see cref="Name"/> pair is the launcher-maintained
/// cache (fed by status-stream <c>characterList</c> events and roster
/// probes via <see cref="LauncherProfileStore.MergeRoster"/>);
/// <see cref="LaunchMode"/>/<see cref="Plugins"/>/<see cref="LoginCommands"/>
/// are user-owned settings that a roster merge must never clobber.
/// </summary>
public sealed class CharacterProfile
{
[JsonRequired]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Hex-formatted character GUID (e.g. <c>"0x5000000A"</c>), 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.
/// </summary>
public string? Id { get; set; }
public LaunchMode LaunchMode { get; set; } = LaunchMode.GuiSelect;
public List<string> Plugins { get; set; } = [];
public List<string> LoginCommands { get; set; } = [];
}

View file

@ -0,0 +1,19 @@
namespace AcDream.Launcher.Core.Profiles;
/// <summary>
/// One roster row as reported by a host's <c>characterList</c> status
/// event or an on-demand probe launch (Campaign LA spec §3/§6). Mirrors
/// the wire shape of <c>AcDream.Core.Net.Messages.CharacterList.Character</c>
/// (<c>uint Id, string Name, uint SecondsGreyedOut</c>) — Launcher.Core
/// does not reference Core.Net, so this is an independent, intentionally
/// identical shape fed by the status-stream parser
/// (<see cref="AcDream.Launcher.Core.Status.CharacterListStatusEvent"/>).
/// <see cref="SecondsGreyedOut"/> is carried for completeness but is
/// NEVER persisted into <see cref="CharacterProfile"/> — 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.
/// </summary>
public readonly record struct CharacterRosterEntry(
uint Id,
string Name,
uint SecondsGreyedOut);

View file

@ -0,0 +1,35 @@
namespace AcDream.Launcher.Core.Profiles;
/// <summary>
/// Per-character launch behaviour (Campaign LA spec §5). Stored on each
/// <see cref="CharacterProfile"/> and read by
/// <see cref="AcDream.Launcher.Core.Launching.SessionConfigComposer"/> to
/// decide the shape of the composed session-config document.
///
/// <para>
/// Serialized as camelCase text (<c>"gui"</c>/<c>"guiSelect"</c>/
/// <c>"headless"</c>) via the explicit
/// <c>new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, ...)</c>
/// registered in <see cref="LauncherProfileStore"/>'s serializer options
/// — deliberately NOT a per-type <c>[JsonConverter]</c> attribute, which
/// uses exact member-name casing (<c>"Gui"</c>) regardless of the
/// ambient <c>PropertyNamingPolicy</c>.
/// </para>
/// </summary>
public enum LaunchMode
{
/// <summary>Launch the graphical client straight into the world as
/// this character.</summary>
Gui,
/// <summary>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.</summary>
GuiSelect,
/// <summary>Launch the no-window host running the <c>idle</c> bot
/// policy (enter world, run plugins/login commands, stay until
/// stopped).</summary>
Headless,
}

View file

@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace AcDream.Launcher.Core.Profiles;
/// <summary>
/// Root document for <c>launcher-profiles.json</c> (Campaign LA spec §5)
/// — the launcher's ONLY credential/profile store. Loaded and saved by
/// <see cref="LauncherProfileStore"/>.
/// </summary>
public sealed class LauncherProfileDocument
{
[JsonRequired]
public int Version { get; set; } = LauncherProfileStore.CurrentVersion;
public List<ServerProfile> Servers { get; set; } = [];
}

View file

@ -0,0 +1,17 @@
namespace AcDream.Launcher.Core.Profiles;
/// <summary>Thrown for a malformed <c>launcher-profiles.json</c> document
/// or an invalid CRUD operation against <see cref="LauncherProfileStore"/>
/// (unknown target, duplicate name, etc.).</summary>
public sealed class LauncherProfileException : Exception
{
public LauncherProfileException(string message)
: base(message)
{
}
public LauncherProfileException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,481 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Profiles;
/// <summary>
/// Load/save/CRUD owner for <c>launcher-profiles.json</c> (Campaign LA
/// spec §5) — the launcher's ONLY credential/profile store, and the
/// binding surface the Avalonia UI (slice LA4) mutates directly.
///
/// <para>
/// A store instance holds the current in-memory <see cref="Document"/>
/// after <see cref="Load"/>; every CRUD method mutates that document in
/// place so callers can chain <c>store.AddServer(...); store.Save();</c>
/// without re-threading a returned document through every call.
/// </para>
/// </summary>
public sealed class LauncherProfileStore
{
internal const int CurrentVersion = 1;
internal const UnixFileMode OwnerOnlyFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite;
private static readonly JsonSerializerOptions SerializerOptions = new()
{
AllowTrailingCommas = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = false,
ReadCommentHandling = JsonCommentHandling.Disallow,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
WriteIndented = true,
Converters =
{
new JsonStringEnumConverter(
JsonNamingPolicy.CamelCase,
allowIntegerValues: false),
},
};
public LauncherProfileStore(string filePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
FilePath = Path.GetFullPath(filePath);
Document = new LauncherProfileDocument();
}
/// <summary>Resolve the store at the canonical location under
/// <see cref="ApplicationPathSet.ConfigDirectory"/>
/// (<c>%APPDATA%\acdream\launcher-profiles.json</c> /
/// <c>~/.config/acdream/launcher-profiles.json</c>).</summary>
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; }
/// <summary>
/// Loads <see cref="Document"/> from <see cref="FilePath"/>. 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.
/// </summary>
public bool Load()
{
// Opportunistic cleanup of a stale ".tmp" left behind by a Save()
// that crashed between creating the temp file and the atomic
// rename (Campaign LA plan §LA3 review finding F4) — a stray
// temp file carries the same plaintext credentials as the real
// store and should not linger.
DeleteStaleTempFile(FilePath + ".tmp");
if (!File.Exists(FilePath))
{
Document = new LauncherProfileDocument();
return false;
}
LauncherProfileDocument? document;
using (FileStream stream = File.OpenRead(FilePath))
{
try
{
document = JsonSerializer.Deserialize<LauncherProfileDocument>(
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;
}
/// <summary>
/// Persists <see cref="Document"/> to <see cref="FilePath"/> via a
/// write-then-atomic-rename so a crash mid-write never leaves a
/// truncated credentials file. On Linux, the temp file is created
/// atomically with owner read/write (0600) as its requested creation
/// mode — before its path is observable and before any plaintext
/// credential is serialized into it. The final path retains that mode
/// through the rename (Campaign LA's plaintext-credential decision,
/// spec §5, decisions log).
/// A failure between temp-file creation and the rename deletes the
/// stale temp file rather than leaving it behind.
/// </summary>
public void Save()
{
string? directory = Path.GetDirectoryName(FilePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
string tempPath = FilePath + ".tmp";
DeleteStaleTempFile(tempPath);
try
{
using (FileStream stream = CreateCredentialTempFile(tempPath))
{
if (OperatingSystem.IsLinux())
{
// UnixCreateMode is subject to the process umask. It
// guarantees the file is never created with group/other
// access; normalize the owner bits while the still-empty
// file is open so the persisted contract is exactly 0600.
File.SetUnixFileMode(tempPath, OwnerOnlyFileMode);
}
JsonSerializer.Serialize(stream, Document, SerializerOptions);
}
File.Move(tempPath, FilePath, overwrite: true);
}
catch
{
DeleteStaleTempFile(tempPath);
throw;
}
if (OperatingSystem.IsLinux())
{
File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
}
}
/// <summary>
/// Builds the exact options used for the plaintext-credential temp
/// file. <see cref="FileMode.CreateNew"/> makes creation atomic and
/// refuses to follow an existing stale or raced path. On Linux,
/// <see cref="FileStreamOptions.UnixCreateMode"/> supplies 0600 to
/// the OS create operation itself, eliminating the observable
/// create-then-chmod window. Windows leaves UnixCreateMode unset and
/// therefore retains its normal user-profile ACL behavior.
/// </summary>
internal static FileStreamOptions CreateCredentialTempFileOptions()
{
var options = new FileStreamOptions
{
Mode = FileMode.CreateNew,
Access = FileAccess.Write,
Share = FileShare.None,
};
if (OperatingSystem.IsLinux())
{
options.UnixCreateMode = OwnerOnlyFileMode;
}
return options;
}
internal static FileStream CreateCredentialTempFile(string tempPath) =>
new(tempPath, CreateCredentialTempFileOptions());
private static void DeleteStaleTempFile(string tempPath)
{
try
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}
catch
{
// Best-effort cleanup only — the caller's own exception (a
// failed Save()) or the fresh Load() already in progress is
// what matters; a cleanup failure must not mask either.
}
}
// --- Server CRUD -----------------------------------------------
public ServerProfile AddServer(string name, string host, int port)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(host);
RequireValidPort(port);
if (FindServer(name) is not null)
{
throw new LauncherProfileException(
$"A server named '{name}' already exists.");
}
var server = new ServerProfile { Name = name, Host = host, Port = port };
Document.Servers.Add(server);
return server;
}
public void EditServer(
string name,
string? newName = null,
string? newHost = null,
int? newPort = null)
{
ServerProfile server = FindServerOrThrow(name);
if (newName is not null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
if (!string.Equals(newName, server.Name, StringComparison.Ordinal)
&& FindServer(newName) is not null)
{
throw new LauncherProfileException(
$"A server named '{newName}' already exists.");
}
server.Name = newName;
}
if (newHost is not null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(newHost);
server.Host = newHost;
}
if (newPort is not null)
{
RequireValidPort(newPort.Value);
server.Port = newPort.Value;
}
}
public void RemoveServer(string name)
{
ServerProfile server = FindServerOrThrow(name);
Document.Servers.Remove(server);
}
// --- Account CRUD ------------------------------------------------
public AccountProfile AddAccount(string serverName, string account, string password)
{
ArgumentException.ThrowIfNullOrWhiteSpace(account);
ArgumentNullException.ThrowIfNull(password);
ServerProfile server = FindServerOrThrow(serverName);
if (FindAccount(server, account) is not null)
{
throw new LauncherProfileException(
$"Account '{account}' already exists on server '{serverName}'.");
}
var profile = new AccountProfile { Account = account, Password = password };
server.Accounts.Add(profile);
return profile;
}
public void EditAccount(
string serverName,
string account,
string? newAccount = null,
string? newPassword = null)
{
ServerProfile server = FindServerOrThrow(serverName);
AccountProfile profile = FindAccountOrThrow(server, account);
if (newAccount is not null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(newAccount);
if (!string.Equals(newAccount, profile.Account, StringComparison.Ordinal)
&& FindAccount(server, newAccount) is not null)
{
throw new LauncherProfileException(
$"Account '{newAccount}' already exists on server '{serverName}'.");
}
profile.Account = newAccount;
}
if (newPassword is not null)
{
profile.Password = newPassword;
}
}
public void RemoveAccount(string serverName, string account)
{
ServerProfile server = FindServerOrThrow(serverName);
AccountProfile profile = FindAccountOrThrow(server, account);
server.Accounts.Remove(profile);
}
// --- Character settings (roster-driven add/remove; user-edited settings) ---
/// <summary>
/// Edits the user-owned settings of an existing character row. There
/// is no manual add/remove for characters — the roster (
/// <see cref="MergeRoster"/>) is the only source of new rows, per
/// spec §5/§6.
/// </summary>
public void EditCharacter(
string serverName,
string account,
string characterName,
LaunchMode? launchMode = null,
IReadOnlyList<string>? plugins = null,
IReadOnlyList<string>? 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];
}
}
/// <summary>
/// Folds a reported character roster into an account's
/// <see cref="AccountProfile.Characters"/> (Campaign LA spec §3/§5/
/// §6): every roster entry either updates the name of an existing
/// row (matched by <see cref="CharacterProfile.Id"/>) while
/// PRESERVING that row's user settings (<see cref="LaunchMode"/>,
/// <see cref="CharacterProfile.Plugins"/>,
/// <see cref="CharacterProfile.LoginCommands"/>), or is inserted as a
/// new row with default settings (<see cref="LaunchMode.GuiSelect"/>,
/// 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.
/// </summary>
public void MergeRoster(
string serverName,
string account,
IReadOnlyList<CharacterRosterEntry> roster)
{
ArgumentNullException.ThrowIfNull(roster);
ServerProfile server = FindServerOrThrow(serverName);
AccountProfile profile = FindAccountOrThrow(server, account);
foreach (CharacterRosterEntry entry in roster)
{
string idText = CharacterIdFormat.ToHexString(entry.Id);
// Normalize BOTH sides through TryParse/ToHexString rather
// than a raw string compare (Campaign LA plan §LA3 review
// finding F10): a stored id that round-trips to the same
// uint (different case, or — before this fix — no "0x"
// prefix) must match even though its text isn't byte-
// identical to the canonical form this method itself always
// writes.
CharacterProfile? existing = profile.Characters.Find(
character => CharacterIdFormat.TryParse(character.Id, out uint existingId)
&& existingId == entry.Id);
// Defensive fallback for a row whose id is missing OR
// unparseable (e.g. a hand-edited id with no "0x" prefix,
// which TryParse now rejects outright) — match by name
// instead so a later merge self-heals the id into the
// canonical form rather than creating a permanent duplicate
// row.
existing ??= profile.Characters.Find(
character => !CharacterIdFormat.TryParse(character.Id, out _)
&& string.Equals(
character.Name,
entry.Name,
StringComparison.Ordinal));
if (existing is not null)
{
existing.Id = idText;
existing.Name = entry.Name;
continue;
}
profile.Characters.Add(new CharacterProfile
{
Id = idText,
Name = entry.Name,
LaunchMode = LaunchMode.GuiSelect,
Plugins = [],
LoginCommands = [],
});
}
}
// --- Lookups -------------------------------------------------------
private ServerProfile? FindServer(string name) =>
Document.Servers.Find(
server => string.Equals(server.Name, name, StringComparison.Ordinal));
private ServerProfile FindServerOrThrow(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
return FindServer(name)
?? throw new LauncherProfileException($"No server named '{name}'.");
}
private static AccountProfile? FindAccount(ServerProfile server, string account) =>
server.Accounts.Find(
candidate => string.Equals(candidate.Account, account, StringComparison.Ordinal));
private static AccountProfile FindAccountOrThrow(ServerProfile server, string account)
{
ArgumentException.ThrowIfNullOrWhiteSpace(account);
return FindAccount(server, account)
?? throw new LauncherProfileException(
$"No account '{account}' on server '{server.Name}'.");
}
private static CharacterProfile FindCharacterOrThrow(
AccountProfile profile,
string characterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
return profile.Characters.Find(
character => string.Equals(
character.Name,
characterName,
StringComparison.Ordinal))
?? throw new LauncherProfileException(
$"No character '{characterName}' on account '{profile.Account}'.");
}
private static void RequireValidPort(int port)
{
if (port is < 1 or > 65535)
{
throw new LauncherProfileException(
$"Port {port} is outside the valid 1-65535 range.");
}
}
}

View file

@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace AcDream.Launcher.Core.Profiles;
/// <summary>One server entry, per Campaign LA spec §5 (manual add — no
/// published server-list import this campaign).</summary>
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<AccountProfile> Accounts { get; set; } = [];
}

View file

@ -0,0 +1,96 @@
namespace AcDream.Launcher.Core.Status;
/// <summary>
/// One parsed line of a host's <c>status.jsonl</c> stream (Campaign LA
/// spec §6). Every event carries the versioned envelope
/// (<c>v</c>/<c>e</c>/<c>t</c>/<c>sessionId</c>) plus its own typed
/// payload. See <see cref="StatusEventParser"/> for the wire shape and
/// <see cref="AcDream.Launcher.Core.Status.StatusFileTailer"/> for the
/// incremental reader that produces these.
/// </summary>
public abstract record StatusEvent
{
public required int V { get; init; }
public required string E { get; init; }
public required DateTimeOffset T { get; init; }
public required string SessionId { get; init; }
}
public sealed record StartedStatusEvent : StatusEvent;
public sealed record ConnectedStatusEvent : StatusEvent;
public readonly record struct StatusCharacterEntry(
uint Id,
string Name,
uint SecondsGreyedOut);
public sealed record CharacterListStatusEvent : StatusEvent
{
public required string AccountName { get; init; }
public required int SlotCount { get; init; }
public required IReadOnlyList<StatusCharacterEntry> 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; }
}
/// <summary>
/// A JSON-object status line whose non-empty string <c>e</c> value this
/// reader does not recognize. The tailer never throws on an unrecognized
/// event — an older launcher reading a newer host's stream
/// degrades to seeing <see cref="UnknownStatusEvent"/> rows instead of
/// crashing.
/// </summary>
public sealed record UnknownStatusEvent : StatusEvent
{
public required string RawJson { get; init; }
}
/// <summary>
/// A complete JSON value that is not an object, an object without a
/// usable event name, or a known event whose pinned v1 envelope/payload
/// does not match its expected shape. Distinguished from
/// <see cref="UnknownStatusEvent"/> (Campaign
/// LA plan §LA3 review finding F12) so a launcher can tell "a newer/older
/// host sent an event I've never heard of" apart from "a host I recognize
/// sent me garbage for an event I do know" — the two cases call for
/// different diagnostics. The tailer never throws for either case.
/// </summary>
public sealed record MalformedStatusEvent : StatusEvent
{
public required string Error { get; init; }
}

View file

@ -0,0 +1,391 @@
using System.Text.Json;
namespace AcDream.Launcher.Core.Status;
/// <summary>
/// Parses one <c>status.jsonl</c> line (Campaign LA spec §6) into a typed
/// <see cref="StatusEvent"/>. Wire shape: every line is a flat JSON
/// object carrying the envelope (<c>v</c>, <c>e</c>, <c>t</c>,
/// <c>sessionId</c>) alongside that event's own fields — e.g.
/// <c>{"v":1,"e":"characterList","t":"...","sessionId":"...",
/// "accountName":"...","slotCount":6,"characters":[...]}</c>.
///
/// <para>
/// Never throws: a null/blank/malformed-JSON line, a complete JSON value
/// with a non-object root, an unrecognized <c>e</c> value, or a recognized
/// <c>e</c> whose envelope/payload does not match the pinned v1 shape all
/// degrade to a typed event (<see cref="UnknownStatusEvent"/> or
/// <see cref="MalformedStatusEvent"/>) rather than an exception. A launcher
/// must keep tailing a session's status stream even against a host running a
/// newer/older wire version or a host that writes a bad line.
/// </para>
/// </summary>
public static class StatusEventParser
{
public static StatusEvent Parse(string line)
{
if (string.IsNullOrWhiteSpace(line))
{
return UnknownEvent(line ?? string.Empty);
}
JsonDocument document;
try
{
document = JsonDocument.Parse(line);
}
catch (JsonException)
{
return UnknownEvent(line);
}
using (document)
{
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return MalformedEvent(
v: 0,
e: string.Empty,
t: default,
sessionId: string.Empty,
"status event root is not a JSON object.");
}
if (!TryGetEventName(root, out string e, out string eventNameError))
{
return MalformedEvent(
GetInt32OrDefault(root, "v"),
e,
GetDateTimeOffsetOrDefault(root, "t"),
GetStringOrDefault(root, "sessionId"),
eventNameError);
}
// A genuinely unknown event name is the forward-compatibility
// case. Retain its best-effort envelope and raw JSON without
// imposing this launcher's known-event envelope/payload schema.
if (!IsKnownEventName(e))
{
return new UnknownStatusEvent
{
V = GetInt32OrDefault(root, "v"),
E = e,
T = GetDateTimeOffsetOrDefault(root, "t"),
SessionId = GetStringOrDefault(root, "sessionId"),
RawJson = line,
};
}
try
{
// The pinned v1 envelope applies to every known event,
// including payload-free started/connected rows. Defaulting
// malformed fields would turn corrupt or cross-version input
// into an apparently valid typed event.
int v = RequireVersionOne(root);
DateTimeOffset t = RequireUtcTimestamp(root, "t");
string sessionId = RequireNonEmptyString(root, "sessionId");
return e switch
{
"started" =>
new StartedStatusEvent { V = v, E = e, T = t, SessionId = sessionId },
"connected" =>
new ConnectedStatusEvent { V = v, E = e, T = t, SessionId = sessionId },
"characterList" =>
ParseCharacterList(root, v, e, t, sessionId),
"enteredWorld" =>
ParseEnteredWorld(root, v, e, t, sessionId),
"pluginLoaded" =>
ParsePluginLoaded(root, v, e, t, sessionId),
"pluginFailed" =>
ParsePluginFailed(root, v, e, t, sessionId),
"disconnected" =>
ParseDisconnected(root, v, e, t, sessionId),
"exited" =>
ParseExited(root, v, e, t, sessionId),
_ => throw new InvalidOperationException("known event dispatch is incomplete."),
};
}
catch (Exception ex) when (ex is FormatException or InvalidOperationException)
{
return MalformedEvent(
GetInt32OrDefault(root, "v"),
e,
GetDateTimeOffsetOrDefault(root, "t"),
GetStringOrDefault(root, "sessionId"),
ex.Message);
}
}
}
private static bool IsKnownEventName(string eventName) =>
eventName is
"started" or
"connected" or
"characterList" or
"enteredWorld" or
"pluginLoaded" or
"pluginFailed" or
"disconnected" or
"exited";
private static bool TryGetEventName(
JsonElement root,
out string eventName,
out string error)
{
if (!root.TryGetProperty("e", out JsonElement element))
{
eventName = string.Empty;
error = "status event is missing 'e'.";
return false;
}
if (element.ValueKind != JsonValueKind.String)
{
eventName = string.Empty;
error = "status event field 'e' is not a string.";
return false;
}
eventName = element.GetString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(eventName))
{
error = "status event field 'e' is empty.";
return false;
}
error = string.Empty;
return true;
}
private static UnknownStatusEvent UnknownEvent(string rawLine) =>
new()
{
V = 0,
E = string.Empty,
T = default,
SessionId = string.Empty,
RawJson = rawLine,
};
private static MalformedStatusEvent MalformedEvent(
int v,
string e,
DateTimeOffset t,
string sessionId,
string error) =>
new()
{
V = v,
E = e,
T = t,
SessionId = sessionId,
Error = error,
};
private static StatusEvent ParseCharacterList(
JsonElement root,
int v,
string e,
DateTimeOffset t,
string sessionId)
{
string accountName = RequireString(root, "accountName");
int slotCount = RequireInt32(root, "slotCount");
JsonElement charactersElement = RequireProperty(root, "characters");
var characters = new List<StatusCharacterEntry>();
foreach (JsonElement item in charactersElement.EnumerateArray())
{
uint id = RequireUInt32(item, "id");
string name = RequireString(item, "name");
uint secondsGreyedOut = RequireUInt32(item, "secondsGreyedOut");
characters.Add(new StatusCharacterEntry(id, name, secondsGreyedOut));
}
return new CharacterListStatusEvent
{
V = v,
E = e,
T = t,
SessionId = sessionId,
AccountName = accountName,
SlotCount = slotCount,
Characters = characters,
};
}
private static StatusEvent ParseEnteredWorld(
JsonElement root,
int v,
string e,
DateTimeOffset t,
string sessionId) =>
new EnteredWorldStatusEvent
{
V = v,
E = e,
T = t,
SessionId = sessionId,
CharacterId = RequireUInt32(root, "characterId"),
CharacterName = RequireString(root, "characterName"),
};
private static StatusEvent ParsePluginLoaded(
JsonElement root,
int v,
string e,
DateTimeOffset t,
string sessionId) =>
new PluginLoadedStatusEvent
{
V = v,
E = e,
T = t,
SessionId = sessionId,
Plugin = RequireString(root, "plugin"),
};
private static StatusEvent ParsePluginFailed(
JsonElement root,
int v,
string e,
DateTimeOffset t,
string sessionId) =>
new PluginFailedStatusEvent
{
V = v,
E = e,
T = t,
SessionId = sessionId,
Plugin = RequireString(root, "plugin"),
Error = RequireString(root, "error"),
};
private static StatusEvent ParseDisconnected(
JsonElement root,
int v,
string e,
DateTimeOffset t,
string sessionId) =>
new DisconnectedStatusEvent
{
V = v,
E = e,
T = t,
SessionId = sessionId,
Reason = RequireString(root, "reason"),
};
private static StatusEvent ParseExited(
JsonElement root,
int v,
string e,
DateTimeOffset t,
string sessionId) =>
new ExitedStatusEvent
{
V = v,
E = e,
T = t,
SessionId = sessionId,
Code = RequireInt32(root, "code"),
Reason = RequireString(root, "reason"),
};
private static int GetInt32OrDefault(JsonElement root, string name) =>
root.TryGetProperty(name, out JsonElement element)
&& element.ValueKind == JsonValueKind.Number
&& element.TryGetInt32(out int value)
? value
: 0;
private static string GetStringOrDefault(JsonElement root, string name) =>
root.TryGetProperty(name, out JsonElement element)
&& element.ValueKind == JsonValueKind.String
? element.GetString() ?? string.Empty
: string.Empty;
private static DateTimeOffset GetDateTimeOffsetOrDefault(
JsonElement root,
string name) =>
root.TryGetProperty(name, out JsonElement element)
&& element.ValueKind == JsonValueKind.String
&& element.TryGetDateTimeOffset(out DateTimeOffset value)
&& value.Offset == TimeSpan.Zero
? value
: default;
private static JsonElement RequireProperty(JsonElement root, string name) =>
root.TryGetProperty(name, out JsonElement element)
? element
: throw new FormatException($"status event is missing '{name}'.");
private static string RequireString(JsonElement root, string name)
{
JsonElement element = RequireProperty(root, name);
return element.ValueKind == JsonValueKind.String
? element.GetString() ?? string.Empty
: throw new FormatException($"status event field '{name}' is not a string.");
}
private static int RequireInt32(JsonElement root, string name)
{
JsonElement element = RequireProperty(root, name);
return element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out int value)
? value
: throw new FormatException($"status event field '{name}' is not an integer.");
}
private static int RequireVersionOne(JsonElement root)
{
int version = RequireInt32(root, "v");
return version == 1
? version
: throw new FormatException(
$"status event version is {version}; expected 1.");
}
private static string RequireNonEmptyString(JsonElement root, string name)
{
string value = RequireString(root, name);
return !string.IsNullOrWhiteSpace(value)
? value
: throw new FormatException($"status event field '{name}' is empty.");
}
private static DateTimeOffset RequireUtcTimestamp(JsonElement root, string name)
{
JsonElement element = RequireProperty(root, name);
if (element.ValueKind != JsonValueKind.String)
{
throw new FormatException(
$"status event field '{name}' is not an ISO-8601 UTC string.");
}
string text = element.GetString() ?? string.Empty;
bool hasExplicitUtcOffset = text.EndsWith('Z')
|| text.EndsWith("+00:00", StringComparison.Ordinal);
if (!hasExplicitUtcOffset
|| !element.TryGetDateTimeOffset(out DateTimeOffset value)
|| value.Offset != TimeSpan.Zero)
{
throw new FormatException(
$"status event field '{name}' is not an ISO-8601 UTC timestamp.");
}
return value;
}
private static uint RequireUInt32(JsonElement root, string name)
{
JsonElement element = RequireProperty(root, name);
return element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out uint value)
? value
: throw new FormatException(
$"status event field '{name}' is not an unsigned integer.");
}
}

View file

@ -0,0 +1,139 @@
using System.Text;
namespace AcDream.Launcher.Core.Status;
/// <summary>
/// Incremental reader over a host's <c>status.jsonl</c> file (Campaign LA
/// spec §3/§6). Each call to <see cref="ReadNewEvents"/> returns the
/// events that arrived since the previous call, tolerating:
/// <list type="bullet">
/// <item>the file not existing yet (the launcher may start tailing
/// before the host has written its first line — returns no events, not
/// an error);</item>
/// <item>a partial last line (the host may be mid-write when polled —
/// the tailer only advances its read position past the last confirmed
/// <c>'\n'</c>; a still-incomplete tail is re-read, combined with
/// whatever gets appended, on the next poll — never parsed while
/// truncated).</item>
/// </list>
/// One tailer instance owns one file's read position; construct a new
/// one per session.
/// </summary>
public sealed class StatusFileTailer
{
private readonly string _path;
private long _position;
public StatusFileTailer(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
_path = path;
}
/// <summary>
/// Reads and parses every complete line appended to the file since
/// the last call. Returns an empty list (never null, never throws)
/// when the file doesn't exist yet, has been deleted/rotated between
/// the existence check and the open (a TOCTOU window — Campaign LA
/// plan §LA3 review finding F7), or nothing new/complete has arrived
/// since the last poll.
/// </summary>
public IReadOnlyList<StatusEvent> ReadNewEvents()
{
try
{
return ReadNewEventsCore();
}
catch (Exception ex) when (
ex is FileNotFoundException or DirectoryNotFoundException or IOException)
{
// The host process deleted/rotated the file (or its
// directory) between File.Exists and the open below, or
// another transient I/O condition hit mid-read — degrade to
// "nothing new this poll" rather than throwing out of a
// method documented never to throw; the next poll picks up
// wherever the file (or its replacement) actually is.
return [];
}
}
private IReadOnlyList<StatusEvent> ReadNewEventsCore()
{
if (!File.Exists(_path))
{
return [];
}
using var stream = new FileStream(
_path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
if (stream.Length < _position)
{
// The file was truncated/replaced under us (e.g. a fresh
// session reusing a stale path) — restart from the top
// rather than throwing or silently missing the new content.
_position = 0;
}
if (stream.Length == _position)
{
return [];
}
stream.Seek(_position, SeekOrigin.Begin);
int unreadByteCount = checked((int)(stream.Length - _position));
byte[] buffer = new byte[unreadByteCount];
int totalRead = 0;
while (totalRead < unreadByteCount)
{
int read = stream.Read(buffer, totalRead, unreadByteCount - totalRead);
if (read == 0)
{
break;
}
totalRead += read;
}
var events = new List<StatusEvent>();
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;
}
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
<ProjectReference Include="..\..\src\AcDream.Platform\AcDream.Platform.csproj" />
</ItemGroup>
</Project>

View file

@ -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);
}
}

View file

@ -0,0 +1,79 @@
using System.Runtime.CompilerServices;
using System.Xml.Linq;
namespace AcDream.Launcher.Core.Tests;
// Campaign LA plan §LA3 review finding F5: AcDream.Launcher.Core's entire
// premise (spec §LA3, SessionConfigDocument.cs's "PINNED CONTRACT" remarks)
// is being the BCL-plus-Platform-only assembly the external Avalonia
// launcher (LA4) can reference without pulling in any game-solution
// dependency. That contract is what this guard enforces — the csproj must
// declare exactly one ProjectReference (AcDream.Platform) and zero
// PackageReference entries, forever, in the same spirit as Platform's,
// Runtime's, and Headless's dependency-boundary guards.
public sealed class LauncherCoreDependencyBoundaryTests
{
[Fact]
public void LauncherCoreProjectReferencesOnlyPlatformAndDeclaresNoPackages()
{
string repositoryRoot = FindRepositoryRoot();
string projectPath = Path.Combine(
repositoryRoot,
"src",
"AcDream.Launcher.Core",
"AcDream.Launcher.Core.csproj");
var project = XDocument.Load(projectPath);
var projectReferences = project.Descendants("ProjectReference")
.Select(element => element.Attribute("Include")?.Value)
// The csproj is authored with Windows-style "..\Foo\Foo.csproj"
// separators; Path.GetFileName only recognizes the platform's
// own separator, so on Linux it would return the whole
// relative path unchanged instead of just the filename.
// Normalizing to '/' first keeps this assertion
// platform-agnostic (this project's tests run under both
// native Windows and WSL — see Campaign LA plan §LA3 review
// finding F5's acceptance).
.Select(include => include is null
? null
: Path.GetFileName(include.Replace('\\', '/')))
.ToList();
Assert.Equal(["AcDream.Platform.csproj"], projectReferences);
Assert.Empty(project.Descendants("PackageReference"));
}
private static string FindRepositoryRoot(
[CallerFilePath] string sourcePath = "")
{
string[] starts =
{
Path.GetDirectoryName(sourcePath) ?? string.Empty,
Directory.GetCurrentDirectory(),
AppContext.BaseDirectory,
};
foreach (string start in starts)
{
if (string.IsNullOrEmpty(start))
{
continue;
}
var directory = new DirectoryInfo(start);
while (directory is not null)
{
if (File.Exists(Path.Combine(
directory.FullName,
"AcDream.slnx")))
{
return directory.FullName;
}
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException(
"Could not find AcDream.slnx above the source, working, or output directory.");
}
}

View file

@ -0,0 +1,525 @@
using System.Collections.Concurrent;
using System.Threading;
using AcDream.Launcher.Core.Launching;
namespace AcDream.Launcher.Core.Tests.Launching;
public sealed class LauncherProcessSupervisorTests
{
[Fact]
public void StartWritesPasswordThenClosesStdinAndTransitionsToRunning()
{
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
using var supervisor = new LauncherProcessSupervisor(factory);
var states = new List<LauncherSessionState>();
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<InvalidOperationException>(() => supervisor.Start(Spec(), "pw"));
}
[Fact]
public void StopCallsCloseMainWindowAndSucceedsWithoutKillWhenTheProcessExitsInTime()
{
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
using var supervisor = new LauncherProcessSupervisor(factory);
supervisor.Start(Spec(), "pw");
supervisor.Stop(TimeSpan.FromMilliseconds(50));
FakeChildProcess fake = factory.LastCreated!;
Assert.True(fake.CloseMainWindowCalled);
Assert.Equal(0, fake.KillCallCount);
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
Assert.Equal(0, supervisor.ExitCode);
}
[Fact]
public void StopFallsBackToKillWhenTheProcessDoesNotExitWithinTheTimeout()
{
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: false);
using var supervisor = new LauncherProcessSupervisor(factory);
supervisor.Start(Spec(), "pw");
supervisor.Stop(TimeSpan.FromMilliseconds(50));
FakeChildProcess fake = factory.LastCreated!;
Assert.True(fake.CloseMainWindowCalled);
Assert.Equal(1, fake.KillCallCount);
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
}
[Fact]
public void StopIsANoOpBeforeStart()
{
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
using var supervisor = new LauncherProcessSupervisor(factory);
supervisor.Stop(TimeSpan.FromMilliseconds(50));
Assert.Null(factory.LastCreated);
Assert.Equal(LauncherSessionState.Starting, supervisor.State);
}
[Fact]
public void StopIsANoOpAfterTheProcessHasAlreadyExited()
{
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
using var supervisor = new LauncherProcessSupervisor(factory);
supervisor.Start(Spec(), "pw");
supervisor.Stop(TimeSpan.FromMilliseconds(50));
FakeChildProcess fake = factory.LastCreated!;
Assert.Equal(0, fake.KillCallCount);
supervisor.Stop(TimeSpan.FromMilliseconds(50));
// CloseMainWindow was called exactly once (the first Stop) —
// Stop after exit does not re-invoke the graceful/kill dance.
Assert.Equal(1, fake.CloseMainWindowCallCount);
Assert.Equal(0, fake.KillCallCount);
}
[Fact]
public void StopAttemptsTheGracefulStopSignalBeforeCloseMainWindow()
{
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
using var supervisor = new LauncherProcessSupervisor(factory);
supervisor.Start(Spec(), "pw");
supervisor.Stop(TimeSpan.FromMilliseconds(50));
FakeChildProcess fake = factory.LastCreated!;
Assert.Equal(1, fake.TryRequestGracefulStopCallCount);
Assert.Equal(["gracefulStop", "closeMainWindow"], fake.CallOrder);
}
[Fact]
public void GracefulStopSignalSendsSigintToARealChildOnLinux()
{
// Review finding F3, proven end to end against the real
// SystemChildProcess: Stop() sends SIGINT before falling back to
// CloseMainWindow/Kill, and the trapped child exits gracefully
// with code 0 well within the timeout. A hard SIGKILL fallback
// (or a signal arriving before the shell's trap is even armed,
// which falls back to the shell's default SIGINT disposition —
// terminate with exit 128+2=130) would not produce this clean
// exit code, so ExitCode == 0 is a hermetic proof the graceful
// path is what actually stopped the child.
if (!OperatingSystem.IsLinux())
return;
string readyMarker = Path.Combine(
Path.GetTempPath(), "acdream-la3-sigint-" + Guid.NewGuid().ToString("N"));
try
{
using var supervisor = new LauncherProcessSupervisor();
var exited = new ManualResetEventSlim(false);
supervisor.StateChanged += (_, s) =>
{
if (s == LauncherSessionState.Exited)
exited.Set();
};
supervisor.Start(
new LauncherProcessSpec(
"/bin/bash",
[
"-c",
"trap 'kill $child 2>/dev/null; exit 0' INT; "
+ "sleep 30 & child=$!; "
+ $"touch '{readyMarker}'; "
+ "wait $child",
]),
password: null);
// Wait for the child to prove its SIGINT trap is armed AND
// its background `sleep` is tracked (touch runs after both,
// in program order) before sending the signal — otherwise
// this test would race the shell's own startup and
// intermittently observe the shell's default SIGINT
// disposition instead of the trap, or leave an untracked
// orphaned `sleep`.
DateTime readyDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (!File.Exists(readyMarker) && DateTime.UtcNow < readyDeadline)
{
Thread.Sleep(10);
}
Assert.True(
File.Exists(readyMarker),
"child did not signal trap-armed readiness in time");
supervisor.Stop(TimeSpan.FromSeconds(10));
Assert.True(exited.Wait(TimeSpan.FromSeconds(5)));
Assert.Equal(0, supervisor.ExitCode);
}
finally
{
try
{
File.Delete(readyMarker);
}
catch (IOException)
{
}
}
}
[Fact]
public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted()
{
var factory = new FakeChildProcessFactory(
exitsWithinStopTimeout: true,
throwOnStandardInputWrite: true);
using var supervisor = new LauncherProcessSupervisor(factory);
Assert.ThrowsAny<Exception>(() => supervisor.Start(Spec(), "pw"));
FakeChildProcess fake = factory.LastCreated!;
Assert.True(fake.Started);
Assert.Equal(1, fake.KillCallCount);
Assert.True(fake.Disposed);
}
[Fact]
public void SetStateIsMonotonicAndIgnoresATransitionAfterExited()
{
// Simulates the child exiting synchronously from inside
// process.Start() itself (a child that dies immediately) — the
// trailing SetState(Running) at the end of Start() must not
// resurrect State from the terminal Exited it already reached,
// nor fire a spurious StateChanged(Running).
var factory = new FakeChildProcessFactory(
exitsWithinStopTimeout: true,
exitDuringStart: true);
using var supervisor = new LauncherProcessSupervisor(factory);
var states = new List<LauncherSessionState>();
supervisor.StateChanged += (_, s) => states.Add(s);
supervisor.Start(Spec(), "pw");
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
Assert.Equal(
[LauncherSessionState.Starting, LauncherSessionState.Exited],
states);
}
[Fact]
public async Task ConcurrentRunningAndExitedPublicationsRemainMonotonicAndInOrder()
{
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
using var supervisor = new LauncherProcessSupervisor(factory);
using var runningPublicationEntered = new ManualResetEventSlim(false);
using var releaseRunningPublication = new ManualResetEventSlim(false);
var states = new ConcurrentQueue<LauncherSessionState>();
supervisor.StateChanged += (_, state) =>
{
if (state == LauncherSessionState.Running)
{
runningPublicationEntered.Set();
Assert.True(
releaseRunningPublication.Wait(TimeSpan.FromSeconds(5)),
"test did not release the Running publication barrier");
}
states.Enqueue(state);
};
Task startTask = Task.Run(() => supervisor.Start(Spec(), "pw"));
try
{
Assert.True(
runningPublicationEntered.Wait(TimeSpan.FromSeconds(5)),
"Running publication did not reach the test barrier");
// Commit Exited while Running's observer is deliberately
// paused. Storage reaches the terminal state immediately, but
// publication must queue behind the earlier Running event.
factory.LastCreated!.ExitForTest(17);
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
}
finally
{
releaseRunningPublication.Set();
}
await startTask.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(
[
LauncherSessionState.Starting,
LauncherSessionState.Running,
LauncherSessionState.Exited,
],
states);
Assert.Equal(17, supervisor.ExitCode);
}
[Fact]
public void StateChangedPublicationAllowsCrossThreadReadsAndReentrantExit()
{
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
using var supervisor = new LauncherProcessSupervisor(factory);
var states = new List<LauncherSessionState>();
supervisor.StateChanged += (_, state) =>
{
states.Add(state);
if (state != LauncherSessionState.Running)
{
return;
}
// A publisher that invokes callbacks while holding the state
// gate deadlocks this cross-thread read. The callback also
// raises Exited re-entrantly; it must queue after Running rather
// than recurse out of order or deadlock.
Task<LauncherSessionState> readTask = Task.Run(() => supervisor.State);
Assert.True(readTask.Wait(TimeSpan.FromSeconds(5)));
Assert.Equal(LauncherSessionState.Running, readTask.Result);
factory.LastCreated!.ExitForTest(23);
};
supervisor.Start(Spec(), "pw");
Assert.Equal(
[
LauncherSessionState.Starting,
LauncherSessionState.Running,
LauncherSessionState.Exited,
],
states);
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
Assert.Equal(23, supervisor.ExitCode);
}
[Fact]
public void LauncherProcessSpecCarriesNoCredentialLikeMember()
{
// Defense in depth: the password must never be able to reach
// process arguments or environment (Campaign LA plan §LA3). This
// guards against a future field addition accidentally widening
// that surface.
System.Reflection.PropertyInfo[] properties =
typeof(LauncherProcessSpec).GetProperties();
Assert.DoesNotContain(
properties,
p => p.Name.Contains("password", StringComparison.OrdinalIgnoreCase)
|| p.Name.Contains("credential", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void RealProcessSpawnFeedsStdinAndCapturesExitCode()
{
// The "trivial cross-platform fake child" (plan §LA3 acceptance):
// `dotnet --version` is guaranteed present (we're running under
// `dotnet test`) on both Windows and Linux/WSL, ignores stdin
// entirely, and reliably exits 0 — proving the REAL
// SystemChildProcessFactory spawn/stdin-feed/exit-code-capture
// path end to end without any OS-specific script branching.
string dotnet = FindDotnetExecutable();
using var supervisor = new LauncherProcessSupervisor();
var exited = new ManualResetEventSlim(false);
supervisor.StateChanged += (_, s) =>
{
if (s == LauncherSessionState.Exited)
exited.Set();
};
supervisor.Start(
new LauncherProcessSpec(dotnet, ["--version"]),
"unused-password-ignored-by-dotnet");
bool completed = exited.Wait(TimeSpan.FromSeconds(30));
Assert.True(completed, "the real dotnet --version child did not exit within 30s");
Assert.Equal(0, supervisor.ExitCode);
}
private static LauncherProcessSpec Spec() =>
new("fake-host", ["--session-config", "session.json"]);
private static string FindDotnetExecutable() =>
// PATH-based resolution: .NET Core's Process.Start searches PATH
// for a bare filename when UseShellExecute is false, on both
// Windows and Unix, and `dotnet` is guaranteed on PATH here
// because this test is itself running under `dotnet test`.
OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet";
private sealed class FakeChildProcessFactory(
bool exitsWithinStopTimeout,
bool exitDuringStart = false,
bool throwOnStandardInputWrite = false)
: ILauncherChildProcessFactory
{
public FakeChildProcess? LastCreated { get; private set; }
public ILauncherChildProcess Create(LauncherProcessSpec spec)
{
LastCreated = new FakeChildProcess(
spec,
exitsWithinStopTimeout,
exitDuringStart,
throwOnStandardInputWrite);
return LastCreated;
}
}
private sealed class FakeChildProcess(
LauncherProcessSpec spec,
bool exitsWithinStopTimeout,
bool exitDuringStart = false,
bool throwOnStandardInputWrite = false)
: ILauncherChildProcess
{
private readonly RecordingTextWriter _standardInput = new();
private readonly ThrowingTextWriter _throwingStandardInput = new();
public LauncherProcessSpec Spec { get; } = spec;
public bool Started { get; private set; }
public string StandardInputText => _standardInput.ToString();
public bool StandardInputClosed => _standardInput.IsClosed;
public bool CloseMainWindowCalled => CloseMainWindowCallCount > 0;
public int CloseMainWindowCallCount { get; private set; }
public int TryRequestGracefulStopCallCount { get; private set; }
public int KillCallCount { get; private set; }
public bool Disposed { get; private set; }
/// <summary>Records the order <see cref="TryRequestGracefulStop"/>,
/// <see cref="CloseMainWindow"/>, and <see cref="Kill"/> were
/// actually invoked in — review finding F3's ordering guarantee.
/// </summary>
public List<string> CallOrder { get; } = [];
public bool HasExited { get; private set; }
public int ExitCode { get; private set; }
public TextWriter StandardInput =>
throwOnStandardInputWrite ? _throwingStandardInput : _standardInput;
public event EventHandler? Exited;
public void Start()
{
Started = true;
if (exitDuringStart)
{
// Simulates a child that dies synchronously from inside
// Process.Start() itself (review finding F9's race).
ExitForTest(0);
}
}
public void ExitForTest(int exitCode)
{
if (HasExited)
{
return;
}
HasExited = true;
ExitCode = exitCode;
Exited?.Invoke(this, EventArgs.Empty);
}
public bool TryRequestGracefulStop()
{
TryRequestGracefulStopCallCount++;
CallOrder.Add("gracefulStop");
return false;
}
public bool CloseMainWindow()
{
CloseMainWindowCallCount++;
CallOrder.Add("closeMainWindow");
return true;
}
public void Kill()
{
KillCallCount++;
CallOrder.Add("kill");
ExitForTest(-1);
}
public bool WaitForExit(TimeSpan timeout)
{
if (!exitsWithinStopTimeout)
return false;
ExitForTest(0);
return true;
}
public void Dispose()
{
Disposed = true;
}
}
private sealed class RecordingTextWriter : StringWriter
{
public bool IsClosed { get; private set; }
protected override void Dispose(bool disposing)
{
IsClosed = true;
base.Dispose(disposing);
}
}
/// <summary>Simulates a broken stdin pipe (review finding F8): the
/// child process started successfully, but feeding it the password
/// fails.</summary>
private sealed class ThrowingTextWriter : StringWriter
{
public override void Write(string? value) =>
throw new IOException("simulated broken stdin pipe");
public override void Write(char value) =>
throw new IOException("simulated broken stdin pipe");
}
}

View file

@ -0,0 +1,398 @@
using System.Text.Json.Nodes;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Profiles;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Launching;
/// <summary>
/// Golden-shape tests for <see cref="SessionConfigComposer"/> 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.
/// </summary>
public sealed class SessionConfigComposerTests
{
private static readonly ApplicationPathSet Paths = new(
ConfigDirectory: "/cfg/acdream",
DataDirectory: "/data/acdream",
CacheDirectory: "/cache/acdream",
LegacyConfigDirectory: null);
private static readonly LauncherInstallRecord Install = new(
DatDirectory: "/dats",
PreparedAssetPath: "/data/acdream/pak/acdream.pak");
private static ServerProfile Server() =>
new() { Name = "Local ACE", Host = "127.0.0.1", Port = 9000 };
private static AccountProfile Account() =>
new() { Account = "testaccount", Password = "S3cretPassw0rd!" };
private static CharacterProfile Character(LaunchMode mode, string? id = "0x5000000A") =>
new()
{
Name = "+Acdream",
Id = id,
LaunchMode = mode,
Plugins = ["ExamplePlugin"],
LoginCommands = ["/tell someone, hi"],
};
[Fact]
public void GuiModeIncludesCharacterSelectorAndOmitsPolicy()
{
ComposedSessionConfig composed = SessionConfigComposer.Compose(
Server(),
Account(),
Character(LaunchMode.Gui),
Install,
Paths,
sessionId: "session-gui");
JsonObject session = SingleSession(composed);
AssertKeys(
session,
"id", "endpoint", "account", "character", "credential",
"plugins", "loginCommands", "statusFile");
Assert.Equal("session-gui", (string?)session["id"]);
Assert.Equal("testaccount", (string?)session["account"]);
Assert.Equal(0x5000000Au, (uint?)session["character"]!["id"]);
Assert.Null(session["character"]!["name"]);
Assert.Null(session["character"]!["index"]);
Assert.Equal("standardInput", (string?)session["credential"]!["provider"]);
Assert.Equal("session", (string?)session["credential"]!["reference"]);
Assert.Equal(
new[] { "ExamplePlugin" },
session["plugins"]!.AsArray().Select(n => (string?)n));
Assert.Equal(
new[] { "/tell someone, hi" },
session["loginCommands"]!.AsArray().Select(n => (string?)n));
Assert.Equal(
Path.Combine(Paths.CacheDirectory, "launcher", "sessions", "session-gui", "status.jsonl"),
(string?)session["statusFile"]);
}
[Fact]
public void GuiSelectModeOmitsCharacterFieldEntirely()
{
ComposedSessionConfig composed = SessionConfigComposer.Compose(
Server(),
Account(),
Character(LaunchMode.GuiSelect),
Install,
Paths,
sessionId: "session-guiselect");
JsonObject session = SingleSession(composed);
AssertKeys(
session,
"id", "endpoint", "account", "credential",
"plugins", "loginCommands", "statusFile");
Assert.False(session.ContainsKey("character"));
Assert.False(session.ContainsKey("policy"));
}
[Fact]
public void HeadlessModeIncludesCharacterAndIdlePolicy()
{
ComposedSessionConfig composed = SessionConfigComposer.Compose(
Server(),
Account(),
Character(LaunchMode.Headless),
Install,
Paths,
sessionId: "session-headless",
loginCommandDelayMs: 750);
JsonObject session = SingleSession(composed);
AssertKeys(
session,
"id", "endpoint", "account", "character", "policy", "credential",
"plugins", "loginCommands", "loginCommandDelayMs", "statusFile");
Assert.Equal(0x5000000Au, (uint?)session["character"]!["id"]);
Assert.Equal("idle", (string?)session["policy"]!["id"]);
Assert.Equal(750, (int?)session["loginCommandDelayMs"]);
}
[Fact]
public void GuiModeFallsBackToNameSelectorWhenIdIsMissing()
{
ComposedSessionConfig composed = SessionConfigComposer.Compose(
Server(),
Account(),
Character(LaunchMode.Gui, id: null),
Install,
Paths,
sessionId: "session-gui-name");
JsonObject session = SingleSession(composed);
Assert.Null(session["character"]!["id"]);
Assert.Equal("+Acdream", (string?)session["character"]!["name"]);
}
[Fact]
public void GuiModeFallsBackToNameSelectorWhenIdIsAHandTypedDecimalWithoutThe0xPrefix()
{
// Review finding F10: an 8-digit all-decimal-digit string is ALSO
// a syntactically valid hex number. Without requiring the "0x"
// prefix, this used to silently reinterpret a hand-typed decimal
// id as hex and select the wrong character; it must now fall
// through to the name selector instead of guessing.
ComposedSessionConfig composed = SessionConfigComposer.Compose(
Server(),
Account(),
Character(LaunchMode.Gui, id: "12345678"),
Install,
Paths,
sessionId: "session-gui-decimal-id");
JsonObject session = SingleSession(composed);
Assert.Null(session["character"]!["id"]);
Assert.Equal("+Acdream", (string?)session["character"]!["name"]);
}
[Fact]
public void GuiModeFallsBackToNameSelectorWhenTheParsedIdIsZero()
{
// Review finding F10: both host loaders reject `id: 0` outright,
// so a parsed-but-zero id is not a usable selector either.
ComposedSessionConfig composed = SessionConfigComposer.Compose(
Server(),
Account(),
Character(LaunchMode.Gui, id: "0x00000000"),
Install,
Paths,
sessionId: "session-gui-zero-id");
JsonObject session = SingleSession(composed);
Assert.Null(session["character"]!["id"]);
Assert.Equal("+Acdream", (string?)session["character"]!["name"]);
}
[Fact]
public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays()
{
CharacterProfile character = Character(LaunchMode.Gui);
character.Plugins = [];
character.LoginCommands = [];
ComposedSessionConfig composed = SessionConfigComposer.Compose(
Server(),
Account(),
character,
Install,
Paths,
sessionId: "session-empty-lists");
JsonObject session = SingleSession(composed);
Assert.False(session.ContainsKey("plugins"));
Assert.False(session.ContainsKey("loginCommands"));
}
[Fact]
public void ProcessContentCarriesInstallRecordAndPathsIsOmittedByDefault()
{
ComposedSessionConfig composed = SessionConfigComposer.Compose(
Server(),
Account(),
Character(LaunchMode.Gui),
Install,
Paths,
sessionId: "session-content");
JsonObject root = ParseRoot(composed);
Assert.Equal(1, (int?)root["version"]);
JsonObject process = root["process"]!.AsObject();
// PINNED CONTRACT (review finding F1): process.paths is OMITTED
// entirely — not an empty object — unless a caller explicitly
// supplies overrides. The App-side loader parses with strict
// UnmappedMemberHandling.Disallow and has no `paths` member of
// its own, so an emitted "paths":{} would reject the whole
// document at config load for every gui/guiSelect launch.
AssertKeys(process, "content");
Assert.False(process.ContainsKey("paths"));
JsonObject content = process["content"]!.AsObject();
AssertKeys(content, "datDirectory", "preparedAssetPath");
Assert.Equal(Install.DatDirectory, (string?)content["datDirectory"]);
Assert.Equal(Install.PreparedAssetPath, (string?)content["preparedAssetPath"]);
}
[Fact]
public void NormalPlaySessionsOmitTheModeFieldEntirely()
{
foreach (LaunchMode mode in new[] { LaunchMode.Gui, LaunchMode.GuiSelect, LaunchMode.Headless })
{
ComposedSessionConfig composed = SessionConfigComposer.Compose(
Server(),
Account(),
Character(mode),
Install,
Paths,
sessionId: $"session-mode-omit-{mode}");
JsonObject session = SingleSession(composed);
Assert.False(session.ContainsKey("mode"));
}
}
[Fact]
public void ProbeModeSetsModeAndOmitsCharacterPolicyPluginsAndLoginCommands()
{
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
Server(),
Account(),
Install,
Paths,
sessionId: "session-probe");
JsonObject session = SingleSession(composed);
AssertKeys(
session,
"id", "mode", "endpoint", "account", "credential", "statusFile");
Assert.Equal("session-probe", (string?)session["id"]);
Assert.Equal("probe", (string?)session["mode"]);
Assert.Equal("127.0.0.1", (string?)session["endpoint"]!["host"]);
Assert.Equal(9000, (int?)session["endpoint"]!["port"]);
Assert.Equal("testaccount", (string?)session["account"]);
Assert.Equal("standardInput", (string?)session["credential"]!["provider"]);
Assert.False(session.ContainsKey("character"));
Assert.False(session.ContainsKey("policy"));
Assert.False(session.ContainsKey("plugins"));
Assert.False(session.ContainsKey("loginCommands"));
Assert.False(session.ContainsKey("loginCommandDelayMs"));
Assert.Equal(
Path.Combine(
Paths.CacheDirectory, "launcher", "sessions", "session-probe", "status.jsonl"),
(string?)session["statusFile"]);
}
[Fact]
public void ProbeModeDocumentNeverContainsThePassword()
{
AccountProfile account = Account();
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
Server(),
account,
Install,
Paths,
sessionId: "session-probe-pw");
string json = SessionConfigComposer.Serialize(composed.Document);
Assert.DoesNotContain(account.Password, json, StringComparison.Ordinal);
}
[Fact]
public void ProbeModeProcessSettingsMatchNormalComposition()
{
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
Server(),
Account(),
Install,
Paths,
sessionId: "session-probe-content");
JsonObject root = ParseRoot(composed);
JsonObject process = root["process"]!.AsObject();
AssertKeys(process, "content");
JsonObject content = process["content"]!.AsObject();
Assert.Equal(Install.DatDirectory, (string?)content["datDirectory"]);
Assert.Equal(Install.PreparedAssetPath, (string?)content["preparedAssetPath"]);
}
[Fact]
public void ComposedDocumentNeverContainsThePassword()
{
AccountProfile account = Account();
foreach (LaunchMode mode in new[] { LaunchMode.Gui, LaunchMode.GuiSelect, LaunchMode.Headless })
{
ComposedSessionConfig composed = SessionConfigComposer.Compose(
Server(),
account,
Character(mode),
Install,
Paths,
sessionId: $"session-{mode}");
string json = SessionConfigComposer.Serialize(composed.Document);
Assert.DoesNotContain(account.Password, json, StringComparison.Ordinal);
}
}
[Fact]
public void ComposeAndWriteWritesSessionJsonUnderTheExpectedPath()
{
string root = Path.Combine(
Path.GetTempPath(),
"acdream-launcher-composer-tests",
Guid.NewGuid().ToString("N"));
try
{
var paths = new ApplicationPathSet(
Path.Combine(root, "cfg"),
Path.Combine(root, "data"),
Path.Combine(root, "cache"),
null);
ComposedSessionConfig composed = SessionConfigComposer.ComposeAndWrite(
Server(),
Account(),
Character(LaunchMode.Gui),
Install,
paths,
sessionId: "session-write");
string expectedPath = Path.Combine(
paths.CacheDirectory, "launcher", "sessions", "session-write", "session.json");
Assert.Equal(expectedPath, composed.ConfigFilePath);
Assert.True(File.Exists(expectedPath));
string text = File.ReadAllText(expectedPath);
Assert.DoesNotContain(Account().Password, text, StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
}
private static JsonObject ParseRoot(ComposedSessionConfig composed)
{
string json = SessionConfigComposer.Serialize(composed.Document);
return JsonNode.Parse(json)!.AsObject();
}
private static JsonObject SingleSession(ComposedSessionConfig composed)
{
JsonObject root = ParseRoot(composed);
JsonArray sessions = root["sessions"]!.AsArray();
return Assert.Single(sessions)!.AsObject();
}
/// <summary>
/// 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).
/// </summary>
private static void AssertKeys(JsonObject obj, params string[] expectedKeys)
{
var actual = new HashSet<string>(obj.Select(kv => kv.Key), StringComparer.Ordinal);
var expected = new HashSet<string>(expectedKeys, StringComparer.Ordinal);
Assert.Equal(expected, actual);
}
}

View file

@ -0,0 +1,50 @@
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Tests.Profiles;
public sealed class CharacterIdFormatTests
{
[Fact]
public void ToHexStringFormatsEightDigitUppercaseWithPrefix()
{
Assert.Equal("0x5000000A", CharacterIdFormat.ToHexString(0x5000000Au));
Assert.Equal("0x00000001", CharacterIdFormat.ToHexString(1u));
}
[Theory]
[InlineData("0x5000000A", 0x5000000Au)]
[InlineData("0x5000000a", 0x5000000Au)]
[InlineData("0X5000000A", 0x5000000Au)]
public void TryParseAcceptsThe0xPrefixCaseInsensitively(string text, uint expected)
{
Assert.True(CharacterIdFormat.TryParse(text, out uint id));
Assert.Equal(expected, id);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not-hex")]
[InlineData("5000000A")]
[InlineData("12345678")]
public void TryParseRejectsNullEmptyNonHexOrAnUnprefixedString(string? text)
{
// "5000000A"/"12345678" are all-hex-digit strings that would
// parse fine as hex WITHOUT the "0x" prefix — review finding F10
// requires the prefix precisely so a hand-typed decimal id (which
// is ALSO syntactically valid hex) is never silently
// misinterpreted as one.
Assert.False(CharacterIdFormat.TryParse(text, out uint id));
Assert.Equal(0u, id);
}
[Fact]
public void RoundTripsThroughToHexStringAndTryParse()
{
const uint original = 0x5000000Au;
string text = CharacterIdFormat.ToHexString(original);
Assert.True(CharacterIdFormat.TryParse(text, out uint parsed));
Assert.Equal(original, parsed);
}
}

View file

@ -0,0 +1,362 @@
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Tests.Profiles;
public sealed class LauncherProfileStoreTests : IDisposable
{
private readonly string _root;
private readonly string _filePath;
public LauncherProfileStoreTests()
{
_root = Path.Combine(
Path.GetTempPath(),
"acdream-launcher-profile-tests",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
_filePath = Path.Combine(_root, "launcher-profiles.json");
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void LoadOnMissingFileYieldsEmptyDocumentWithoutTouchingDisk()
{
var store = new LauncherProfileStore(_filePath);
bool loaded = store.Load();
Assert.False(loaded);
Assert.False(File.Exists(_filePath));
Assert.Equal(1, store.Document.Version);
Assert.Empty(store.Document.Servers);
}
[Fact]
public void AddServerThenSaveThenReloadRoundTrips()
{
var store = new LauncherProfileStore(_filePath);
store.Load();
store.AddServer("Local ACE", "127.0.0.1", 9000);
store.Save();
Assert.True(File.Exists(_filePath));
var reloaded = new LauncherProfileStore(_filePath);
reloaded.Load();
ServerProfile server = Assert.Single(reloaded.Document.Servers);
Assert.Equal("Local ACE", server.Name);
Assert.Equal("127.0.0.1", server.Host);
Assert.Equal(9000, server.Port);
Assert.Empty(server.Accounts);
}
[Fact]
public void AddServerRejectsDuplicateName()
{
var store = new LauncherProfileStore(_filePath);
store.Load();
store.AddServer("Local ACE", "127.0.0.1", 9000);
var ex = Assert.Throws<LauncherProfileException>(
() => 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<LauncherProfileException>(
() => 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<LauncherProfileException>(
() => 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<LauncherProfileException>(
() => 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<LauncherProfileException>(() => store.Load());
}
[Fact]
public void LoadRejectsUnmappedMembersStrictly()
{
File.WriteAllText(
_filePath,
"""{"version":1,"servers":[],"unexpectedField":true}""");
var store = new LauncherProfileStore(_filePath);
Assert.Throws<LauncherProfileException>(() => store.Load());
}
[Fact]
public void SaveWritesCamelCaseJson()
{
var store = new LauncherProfileStore(_filePath);
store.Load();
store.AddServer("Local ACE", "127.0.0.1", 9000);
store.Save();
string text = File.ReadAllText(_filePath);
Assert.Contains("\"version\"", text);
Assert.Contains("\"servers\"", text);
Assert.Contains("\"host\"", text);
Assert.DoesNotContain("\"Version\"", text);
Assert.DoesNotContain("\"Servers\"", text);
}
[Fact]
public void SaveSetsOwnerOnlyPermissionsOnLinux()
{
// Linux-conditional: 0600 is a Linux-only hygiene step (spec §5,
// decisions log item "Windows profile-file permissions"). A no-op
// pass on Windows/macOS, matching the repo's established
// OperatingSystem.IsLinux() early-return pattern (e.g.
// HeadlessCredentialResolverTests.LinuxRejectsGroupOrOtherCredentialPermissions).
if (!OperatingSystem.IsLinux())
return;
var store = new LauncherProfileStore(_filePath);
store.Load();
store.AddServer("Local ACE", "127.0.0.1", 9000);
store.AddAccount("Local ACE", "testaccount", "testpassword");
store.Save();
UnixFileMode mode = File.GetUnixFileMode(_filePath);
Assert.Equal(
UnixFileMode.UserRead | UnixFileMode.UserWrite,
mode);
}
[Fact]
public void TempCredentialCreationOptionsRequestAtomicPlatformCorrectCreation()
{
FileStreamOptions options =
LauncherProfileStore.CreateCredentialTempFileOptions();
Assert.Equal(FileMode.CreateNew, options.Mode);
Assert.Equal(FileAccess.Write, options.Access);
Assert.Equal(FileShare.None, options.Share);
if (OperatingSystem.IsLinux())
{
Assert.Equal(
LauncherProfileStore.OwnerOnlyFileMode,
options.UnixCreateMode);
}
else
{
Assert.Null(options.UnixCreateMode);
}
}
[Fact]
public void TempCredentialFileIsOwnerOnlyFromItsFirstObservableLinuxState()
{
// Deterministic proof of the exact production create path: inspect
// the file while the CreateNew handle is still open, before any
// serialization or post-create chmod can occur. This replaces the
// old timing-only poller, which could miss the vulnerable window.
if (!OperatingSystem.IsLinux())
return;
string tempPath = _filePath + ".tmp";
using FileStream stream = LauncherProfileStore.CreateCredentialTempFile(tempPath);
Assert.Equal(
LauncherProfileStore.OwnerOnlyFileMode,
File.GetUnixFileMode(tempPath));
}
[Fact]
public void SaveDeletesTheStaleTempFileWhenTheFinalRenameFails()
{
// Review finding F4: force the rename step to fail (the
// destination path names an existing DIRECTORY, which
// File.Move(..., overwrite: true) refuses to replace — Windows
// reports this as UnauthorizedAccessException, Linux as
// IOException, so the assertion below accepts either) and assert
// the temp file — which still carries the just-serialized
// plaintext credentials — doesn't linger on disk afterward.
Directory.CreateDirectory(_filePath);
var store = new LauncherProfileStore(_filePath);
store.Load();
store.AddServer("Local ACE", "127.0.0.1", 9000);
store.AddAccount("Local ACE", "testaccount", "testpassword");
Assert.ThrowsAny<Exception>(() => store.Save());
Assert.False(File.Exists(_filePath + ".tmp"));
}
[Fact]
public void LoadDeletesAStaleTempFileLeftBehindByACrashedSave()
{
// Review finding F4: a Save() that crashed between creating the
// temp file and the atomic rename leaves a ".tmp" carrying the
// same plaintext credentials as the real store. Load() cleans it
// up opportunistically the next time the store is opened.
File.WriteAllText(_filePath + ".tmp", """{"version":1,"servers":[]}""");
var store = new LauncherProfileStore(_filePath);
store.Load();
Assert.False(File.Exists(_filePath + ".tmp"));
}
}

View file

@ -0,0 +1,172 @@
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Tests.Profiles;
/// <summary>
/// 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).
/// </summary>
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<CharacterProfile> 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<CharacterProfile> characters =
store.Document.Servers.Single().Accounts.Single().Characters;
Assert.Equal(2, characters.Count);
Assert.Contains(characters, c => c.Name == "+Acdream");
Assert.Contains(characters, c => c.Name == "+PendingDelete");
}
[Fact]
public void MergeNormalizesAnUnprefixedHexIdInsteadOfCreatingADuplicateRow()
{
// Review finding F10: a hand-edited row can carry an id without
// the "0x" prefix (e.g. copy-pasted from somewhere that dropped
// it). CharacterIdFormat.TryParse now REJECTS that string
// outright (it no longer guesses hex-without-a-prefix), so the
// old raw string-equality comparison against the roster's
// canonical "0x..." form would never match and would add a
// second row forever. The name-fallback match must still
// recognize this as the SAME character and self-heal its id.
LauncherProfileStore store = NewStoreWithServerAndAccount();
store.Document.Servers.Single().Accounts.Single().Characters.Add(
new CharacterProfile { Id = "5000000A", Name = "+Acdream" });
store.MergeRoster(
"Local ACE",
"testaccount",
[new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
CharacterProfile character = Assert.Single(
store.Document.Servers.Single().Accounts.Single().Characters);
Assert.Equal("0x5000000A", character.Id);
Assert.Equal("+Acdream", character.Name);
}
[Fact]
public void MergeThrowsForUnknownServerOrAccount()
{
LauncherProfileStore store = NewStoreWithServerAndAccount();
Assert.Throws<LauncherProfileException>(
() => store.MergeRoster(
"Nope",
"testaccount",
[new CharacterRosterEntry(1, "x", 0)]));
Assert.Throws<LauncherProfileException>(
() => store.MergeRoster(
"Local ACE",
"nope",
[new CharacterRosterEntry(1, "x", 0)]));
}
}

View file

@ -0,0 +1,210 @@
using AcDream.Launcher.Core.Status;
namespace AcDream.Launcher.Core.Tests.Status;
public sealed class StatusEventParserTests
{
[Fact]
public void ParsesStarted()
{
var e = StatusEventParser.Parse(
"""{"v":1,"e":"started","t":"2026-08-14T12:00:00Z","sessionId":"s1"}""");
var started = Assert.IsType<StartedStatusEvent>(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<ConnectedStatusEvent>(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<CharacterListStatusEvent>(e);
Assert.Equal("testaccount", list.AccountName);
Assert.Equal(6, list.SlotCount);
Assert.Equal(2, list.Characters.Count);
Assert.Equal(1342177290u, list.Characters[0].Id);
Assert.Equal("+Acdream", list.Characters[0].Name);
Assert.Equal(0u, list.Characters[0].SecondsGreyedOut);
Assert.Equal(1342177291u, list.Characters[1].Id);
Assert.Equal(1u, list.Characters[1].SecondsGreyedOut);
}
[Fact]
public void ParsesEnteredWorld()
{
var e = StatusEventParser.Parse(
"""{"v":1,"e":"enteredWorld","t":"2026-08-14T12:00:03Z","sessionId":"s1","characterId":1342177290,"characterName":"+Acdream"}""");
var entered = Assert.IsType<EnteredWorldStatusEvent>(e);
Assert.Equal(1342177290u, entered.CharacterId);
Assert.Equal("+Acdream", entered.CharacterName);
}
[Fact]
public void ParsesPluginLoadedAndPluginFailed()
{
var loaded = Assert.IsType<PluginLoadedStatusEvent>(
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<PluginFailedStatusEvent>(
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<DisconnectedStatusEvent>(
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<ExitedStatusEvent>(
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<UnknownStatusEvent>(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<UnknownStatusEvent>(e);
}
[Theory]
[InlineData("[]")]
[InlineData("null")]
[InlineData("42")]
[InlineData("\"text\"")]
public void CompleteJsonWithANonObjectRootSurfacesAsMalformedEvent(string line)
{
var e = StatusEventParser.Parse(line);
var malformed = Assert.IsType<MalformedStatusEvent>(e);
Assert.Contains("root", malformed.Error, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void WhitespaceOrEmptyLineSurfacesAsUnknownEventRatherThanThrowing(string line)
{
// Review finding F7: ArgumentException.ThrowIfNullOrWhiteSpace
// used to guard this method BEFORE the try/catch, so a
// whitespace-only line (e.g. a stray blank line the tailer
// happens to hand over) escaped as an uncaught exception instead
// of degrading like every other malformed-input case.
var e = StatusEventParser.Parse(line);
Assert.IsType<UnknownStatusEvent>(e);
}
[Fact]
public void NullLineSurfacesAsUnknownEventRatherThanThrowing()
{
var e = StatusEventParser.Parse(null!);
Assert.IsType<UnknownStatusEvent>(e);
}
[Theory]
[InlineData("{\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":\"1\",\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":2,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":1,\"e\":\"connected\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":1,\"e\":\"started\",\"t\":42,\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"not-a-time\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":1,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00+02:00\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\"}")]
[InlineData("{\"v\":1,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":42}")]
[InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"\"}")]
public void PayloadFreeKnownEventsRequireTheFullPinnedV1Envelope(string line)
{
var e = StatusEventParser.Parse(line);
var malformed = Assert.IsType<MalformedStatusEvent>(e);
Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
}
[Theory]
[InlineData("{\"v\":1,\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":1,\"e\":42,\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
public void MissingOrWrongKindEventNameSurfacesAsMalformedEvent(string line)
{
Assert.IsType<MalformedStatusEvent>(StatusEventParser.Parse(line));
}
[Fact]
public void KnownEValueWithMissingRequiredFieldSurfacesAsMalformedEventRatherThanThrowing()
{
// characterList without "characters" — a shape mismatch on a
// KNOWN event name. Review finding F12: this must be
// distinguishable from an unrecognized e value, so it now
// surfaces as MalformedStatusEvent rather than UnknownStatusEvent.
var e = StatusEventParser.Parse(
"""{"v":1,"e":"characterList","t":"2026-08-14T12:00:09Z","sessionId":"s1","accountName":"a","slotCount":6}""");
var malformed = Assert.IsType<MalformedStatusEvent>(e);
Assert.Equal("characterList", malformed.E);
Assert.Equal("s1", malformed.SessionId);
Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
}
[Fact]
public void KnownEValueWithAFieldOfTheWrongJsonKindSurfacesAsMalformedEvent()
{
// "characters" present but not an array — this throws
// InvalidOperationException out of JsonElement.EnumerateArray()
// rather than the FormatException a missing/wrong-kind scalar
// field throws, so it exercises the parser's other malformed-
// payload catch path.
var e = StatusEventParser.Parse(
"""{"v":1,"e":"characterList","t":"2026-08-14T12:00:10Z","sessionId":"s1","accountName":"a","slotCount":6,"characters":"not-an-array"}""");
var malformed = Assert.IsType<MalformedStatusEvent>(e);
Assert.Equal("characterList", malformed.E);
Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
}
}

View file

@ -0,0 +1,214 @@
using System.Text;
using AcDream.Launcher.Core.Status;
namespace AcDream.Launcher.Core.Tests.Status;
public sealed class StatusFileTailerTests : IDisposable
{
private readonly string _root;
private readonly string _path;
public StatusFileTailerTests()
{
_root = Path.Combine(
Path.GetTempPath(),
"acdream-launcher-tailer-tests",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
_path = Path.Combine(_root, "status.jsonl");
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void ReturnsNoEventsWhenTheFileDoesNotExistYet()
{
var tailer = new StatusFileTailer(_path);
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
Assert.Empty(events);
}
[Fact]
public void ReturnsNoEventsWhenNothingHasBeenAppendedSinceTheLastPoll()
{
AppendShared(Line("started", "s1"));
var tailer = new StatusFileTailer(_path);
Assert.Single(tailer.ReadNewEvents());
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
Assert.Empty(events);
}
[Fact]
public void ReadsMultipleCompleteLinesInOnePoll()
{
AppendShared(Line("started", "s1") + Line("connected", "s1"));
var tailer = new StatusFileTailer(_path);
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
Assert.Equal(2, events.Count);
Assert.IsType<StartedStatusEvent>(events[0]);
Assert.IsType<ConnectedStatusEvent>(events[1]);
}
[Fact]
public void ContinuesPastCompleteNonObjectJsonValuesToTheFollowingValidLine()
{
AppendShared(
"[]\nnull\n42\n\"text\"\n"
+ Line("connected", "s1"));
var tailer = new StatusFileTailer(_path);
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
Assert.Equal(5, events.Count);
Assert.All(events.Take(4), e => Assert.IsType<MalformedStatusEvent>(e));
Assert.IsType<ConnectedStatusEvent>(events[4]);
}
[Fact]
public void TolerateAPartialLastLineAndCompletesItOnALaterPoll()
{
string full = Line("started", "s1");
int splitAt = full.Length - 10; // cut mid-object, before the closing brace/newline
AppendShared(full[..splitAt]);
var tailer = new StatusFileTailer(_path);
IReadOnlyList<StatusEvent> firstPoll = tailer.ReadNewEvents();
Assert.Empty(firstPoll);
AppendShared(full[splitAt..]);
IReadOnlyList<StatusEvent> secondPoll = tailer.ReadNewEvents();
StatusEvent onlyEvent = Assert.Single(secondPoll);
Assert.IsType<StartedStatusEvent>(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<StatusEvent> events = tailer.ReadNewEvents();
StatusEvent onlyEvent = Assert.Single(events);
Assert.IsType<StartedStatusEvent>(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<StatusEvent> secondPoll = tailer.ReadNewEvents();
StatusEvent completed = Assert.Single(secondPoll);
Assert.IsType<ConnectedStatusEvent>(completed);
}
[Fact]
public void SkipsBlankLines()
{
AppendShared("\n" + Line("started", "s1") + "\n" + Line("connected", "s1"));
var tailer = new StatusFileTailer(_path);
IReadOnlyList<StatusEvent> 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<StatusEvent> firstPoll = tailer.ReadNewEvents();
Assert.Single(firstPoll);
byte[] second = Encoding.UTF8.GetBytes(Line("connected", "s1"));
writer.Write(second, 0, second.Length);
writer.Flush();
IReadOnlyList<StatusEvent> secondPoll = tailer.ReadNewEvents();
Assert.Single(secondPoll);
Assert.IsType<ConnectedStatusEvent>(secondPoll[0]);
}
[Fact]
public void ReadNewEventsReturnsEmptyRatherThanThrowingOnASharingViolation()
{
// A deterministic proxy for the File.Exists -> new FileStream
// TOCTOU window (review finding F7): Windows enforces FileShare
// at the OS level, so holding an exclusive (FileShare.None)
// handle open while the tailer tries to open the same path
// reliably reproduces the IOException the tailer must now
// swallow instead of throwing out of a method documented never
// to throw. (.NET's FileStream doesn't apply mandatory locking
// on Linux by default, so this specific scenario isn't
// reproducible there — the fix itself is platform-agnostic, only
// this particular deterministic trigger is Windows-only.)
if (!OperatingSystem.IsWindows())
return;
AppendShared(Line("started", "s1"));
using var exclusiveHandle = new FileStream(
_path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
var tailer = new StatusFileTailer(_path);
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
Assert.Empty(events);
}
[Fact]
public void RestartsFromTheTopWhenTheFileIsTruncatedOrReplaced()
{
AppendShared(Line("started", "s1") + Line("connected", "s1"));
var tailer = new StatusFileTailer(_path);
Assert.Equal(2, tailer.ReadNewEvents().Count);
File.Delete(_path);
AppendShared(Line("started", "s2"));
IReadOnlyList<StatusEvent> 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);
}
}