feat(launcher): add verified first-run installer

This commit is contained in:
Erik 2026-08-14 20:06:37 +02:00
parent 60f627998c
commit ff6ebb6a6a
28 changed files with 3259 additions and 125 deletions

View file

@ -8,6 +8,7 @@ on:
- "src/AcDream.Platform/**"
- "src/AcDream.Launcher.Core/**"
- "src/AcDream.Launcher/**"
- "src/AcDream.Bake/**"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
@ -19,6 +20,7 @@ on:
- "tests/AcDream.Platform.Tests/**"
- "tests/AcDream.Launcher.Core.Tests/**"
- "tests/AcDream.Launcher.Tests/**"
- "tests/AcDream.Bake.Tests/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
@ -36,6 +38,7 @@ on:
- "src/AcDream.Platform/**"
- "src/AcDream.Launcher.Core/**"
- "src/AcDream.Launcher/**"
- "src/AcDream.Bake/**"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
@ -47,6 +50,7 @@ on:
- "tests/AcDream.Platform.Tests/**"
- "tests/AcDream.Launcher.Core.Tests/**"
- "tests/AcDream.Launcher.Tests/**"
- "tests/AcDream.Bake.Tests/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
@ -80,7 +84,7 @@ jobs:
dotnet-version: "10.0.x"
# No apt step here on purpose. This job's whole claim is that the closure
# below is presentation-free: it builds Plugin.Abstractions, Core,
# below is presentation-free: it builds Bake, Plugin.Abstractions, Core,
# Core.Net, Content, Runtime and Headless, runs their tests, and invokes
# the Headless CLI. Nothing in it opens a display, links GL, or calls
# xvfb-run, so an "install the graphical smoke dependencies" step here was
@ -94,6 +98,7 @@ jobs:
$projects = @(
"src/AcDream.Platform/AcDream.Platform.csproj",
"src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj",
"src/AcDream.Bake/AcDream.Bake.csproj",
"src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj",
"src/AcDream.Core/AcDream.Core.csproj",
"src/AcDream.Core.Net/AcDream.Core.Net.csproj",
@ -116,6 +121,7 @@ jobs:
$projects = @(
"tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj",
"tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj",
"tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj",
"tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj",
"tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj",
"tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj",

View file

@ -290,10 +290,15 @@ src/
Status/ -> incremental host-status parsing/tailing
Orchestration/ -> immutable UI snapshots, typed actions,
capability gates, and running-session lifetime
Installation/ -> portable four-DAT validation, Windows retail
path discovery, versioned JSONL bake-process
orchestration, and atomic SHA/size/tool-version
install-record verification and recovery
-> references Platform only; no Avalonia or game-host dependency
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
ViewModels/ -> thin MVVM projection over Launcher.Core
ViewModels/ -> thin MVVM projection over Launcher.Core,
including the first-run DAT/bake wizard
-> references Launcher.Core only (Platform transitively); it never owns
a second profile, process, status, or credential state graph
-> Linux launcher/probe/headless flows remain portable; graphical-client

View file

@ -0,0 +1,125 @@
using System.Globalization;
namespace AcDream.Bake;
internal sealed record BakeCommandLineOptions(
string DatDirectory,
string OutputPath,
HashSet<uint>? IdFilter,
HashSet<uint>? LandblockFilter,
int Threads,
bool ProgressJson);
internal static class BakeCommandLine
{
internal const string Usage =
"usage: acdream-bake --dat-dir <path> [--out <file>] "
+ "[--ids 0xId,0xId,...] [--landblocks 0xId,...] "
+ "[--threads <n>] [--progress-json]";
public static bool TryParse(
IReadOnlyList<string> args,
TextWriter error,
out BakeCommandLineOptions? options)
{
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(error);
string? datDirectory = null;
string? outputPath = null;
HashSet<uint>? idFilter = null;
HashSet<uint>? landblockFilter = null;
int threads = Environment.ProcessorCount;
bool progressJson = false;
for (int i = 0; i < args.Count; i++)
{
switch (args[i])
{
case "--dat-dir":
datDirectory = Value(args, ref i);
break;
case "--out":
outputPath = Value(args, ref i);
break;
case "--ids":
idFilter = ParseHexList(Value(args, ref i), error);
break;
case "--landblocks":
landblockFilter = ParseHexList(Value(args, ref i), error);
break;
case "--threads":
if (int.TryParse(
Value(args, ref i),
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int parsedThreads)
&& parsedThreads > 0)
{
threads = parsedThreads;
}
break;
case "--progress-json":
progressJson = true;
break;
default:
error.WriteLine($"unrecognized argument: {args[i]}");
options = null;
return false;
}
}
if (string.IsNullOrWhiteSpace(datDirectory))
{
error.WriteLine(Usage);
options = null;
return false;
}
outputPath ??= Path.Combine(datDirectory, "acdream.pak");
options = new BakeCommandLineOptions(
datDirectory,
outputPath,
idFilter,
landblockFilter,
threads,
progressJson);
return true;
}
private static string? Value(IReadOnlyList<string> args, ref int index) =>
index + 1 < args.Count ? args[++index] : null;
private static HashSet<uint> ParseHexList(string? raw, TextWriter error)
{
var result = new HashSet<uint>();
if (string.IsNullOrWhiteSpace(raw))
{
return result;
}
foreach (string token in raw.Split(
',',
StringSplitOptions.RemoveEmptyEntries
| StringSplitOptions.TrimEntries))
{
string hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? token[2..]
: token;
if (uint.TryParse(
hex,
NumberStyles.HexNumber,
CultureInfo.InvariantCulture,
out uint value))
{
result.Add(value);
}
else
{
error.WriteLine($"warning: could not parse id '{token}' - skipped");
}
}
return result;
}
}

View file

@ -0,0 +1,101 @@
using System.Text.Json;
namespace AcDream.Bake;
public interface IBakeProgressSink
{
void Started(uint bakeToolVersion, string outputPath);
void Progress(
string phase,
long completed,
long total,
int failures,
double elapsedSeconds,
double etaSeconds,
long privateBytes,
long managedBytes);
void Completed(uint bakeToolVersion, long outputBytes, int failures);
void Error(string message);
}
/// <summary>
/// Version-1 JSON-lines machine channel enabled only by
/// <c>--progress-json</c>. Ordinary human console lines remain unchanged and
/// share stdout; consumers identify these records by shape instead of
/// scraping human prose.
/// </summary>
public sealed class BakeProgressJsonWriter(TextWriter output) : IBakeProgressSink
{
public const int CurrentVersion = 1;
private readonly TextWriter _output = output
?? throw new ArgumentNullException(nameof(output));
private readonly object _gate = new();
public void Started(uint bakeToolVersion, string outputPath) =>
Write(new
{
v = CurrentVersion,
e = "started",
t = DateTimeOffset.UtcNow,
bakeToolVersion,
outputPath,
});
public void Progress(
string phase,
long completed,
long total,
int failures,
double elapsedSeconds,
double etaSeconds,
long privateBytes,
long managedBytes) =>
Write(new
{
v = CurrentVersion,
e = "progress",
t = DateTimeOffset.UtcNow,
phase,
completed,
total,
failures,
elapsedSeconds,
etaSeconds,
privateBytes,
managedBytes,
});
public void Completed(uint bakeToolVersion, long outputBytes, int failures) =>
Write(new
{
v = CurrentVersion,
e = "completed",
t = DateTimeOffset.UtcNow,
bakeToolVersion,
outputBytes,
failures,
});
public void Error(string message) =>
Write(new
{
v = CurrentVersion,
e = "error",
t = DateTimeOffset.UtcNow,
message,
});
private void Write<T>(T value)
{
string line = JsonSerializer.Serialize(value);
lock (_gate)
{
_output.WriteLine(line);
_output.Flush();
}
}
}

View file

@ -0,0 +1,34 @@
namespace AcDream.Bake;
internal static class BakeProgressReporter
{
public static void Write(
TextWriter humanOutput,
IBakeProgressSink? machineOutput,
string phase,
long completed,
int total,
int failures,
TimeSpan elapsed,
double etaSeconds,
long privateBytes,
long managedBytes)
{
ArgumentNullException.ThrowIfNull(humanOutput);
humanOutput.WriteLine(
$"[{elapsed:hh\\:mm\\:ss}] extracted {completed:N0}/{total:N0}, "
+ $"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, "
+ $"ETA={etaSeconds:F0}s, "
+ $"private={privateBytes / 1024.0 / 1024.0:F0}MB, "
+ $"managed={managedBytes / 1024.0 / 1024.0:F0}MB");
machineOutput?.Progress(
phase,
completed,
total,
failures,
elapsed.TotalSeconds,
etaSeconds,
privateBytes,
managedBytes);
}
}

View file

@ -22,6 +22,7 @@ public sealed record BakeOptions
public HashSet<uint>? LandblockFilter { get; init; }
public int Threads { get; init; } = System.Environment.ProcessorCount;
public CancellationToken CancellationToken { get; init; }
public IBakeProgressSink? Progress { get; init; }
}
/// <summary>Compact result used by the full-scale gate and deterministic tests.</summary>
@ -89,6 +90,7 @@ public static class BakeRunner
throw new ArgumentOutOfRangeException(nameof(options), "thread count must be positive");
options.CancellationToken.ThrowIfCancellationRequested();
options.Progress?.Started(PakFormat.CurrentBakeToolVersion, options.OutPath);
var totalStopwatch = Stopwatch.StartNew();
var report = BakeOutputTransaction.WriteValidateAndPublish(
options.OutPath,
@ -112,6 +114,10 @@ public static class BakeRunner
};
PrintSummary(report, options.OutPath);
options.Progress?.Completed(
report.Header.BakeToolVersion,
report.OutputBytes,
report.Failures);
return report;
}
@ -273,6 +279,8 @@ public static class BakeRunner
failures.Count,
stopwatch.Elapsed,
lastProgressReport,
options.Progress,
"mesh",
batchStart + BatchSize >= ordinaryWork.Count &&
envCatalog.UniqueGeometryCount == 0);
}
@ -372,6 +380,8 @@ public static class BakeRunner
failures.Count,
stopwatch.Elapsed,
lastProgressReport,
options.Progress,
"mesh",
batchStart + BatchSize >= envCatalog.Groups.Count);
}
@ -571,6 +581,8 @@ public static class BakeRunner
failures.Count,
collisionStopwatch.Elapsed,
lastProgressReport,
options.Progress,
"collision",
final: false);
}
@ -757,6 +769,8 @@ public static class BakeRunner
failures.Count,
collisionStopwatch.Elapsed,
lastProgressReport,
options.Progress,
"collision",
final: false);
}
}
@ -769,6 +783,8 @@ public static class BakeRunner
failures.Count,
collisionStopwatch.Elapsed,
lastProgressReport,
options.Progress,
"collision",
final: true);
writer.Finish();
@ -910,6 +926,8 @@ public static class BakeRunner
int failures,
TimeSpan elapsed,
Stopwatch lastProgressReport,
IBakeProgressSink? progress,
string phase,
bool final)
{
if (!final && lastProgressReport.Elapsed.TotalSeconds < 5)
@ -920,11 +938,18 @@ public static class BakeRunner
using var process = Process.GetCurrentProcess();
process.Refresh();
long managedHeap = GC.GetGCMemoryInfo().HeapSizeBytes;
Console.WriteLine(
$"[{elapsed:hh\\:mm\\:ss}] extracted {done:N0}/{total:N0}, " +
$"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, " +
$"ETA={etaSeconds:F0}s, private={process.PrivateMemorySize64 / 1024.0 / 1024.0:F0}MB, " +
$"managed={managedHeap / 1024.0 / 1024.0:F0}MB");
long privateBytes = process.PrivateMemorySize64;
BakeProgressReporter.Write(
Console.Out,
progress,
phase,
done,
total,
failures,
elapsed,
etaSeconds,
privateBytes,
managedHeap);
lastProgressReport.Restart();
}

View file

@ -1,7 +1,3 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AcDream.Bake;
// acdream-bake: offline CLI producing a versioned pak file containing every
@ -13,66 +9,35 @@ using AcDream.Bake;
//
// Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5.
string? datDir = null;
string? outPath = null;
HashSet<uint>? idFilter = null;
HashSet<uint>? landblockFilter = null;
int threads = Environment.ProcessorCount;
for (int i = 0; i < args.Length; i++) {
switch (args[i]) {
case "--dat-dir":
datDir = args.ElementAtOrDefault(++i);
break;
case "--out":
outPath = args.ElementAtOrDefault(++i);
break;
case "--ids":
idFilter = ParseHexList(args.ElementAtOrDefault(++i));
break;
case "--landblocks":
landblockFilter = ParseHexList(args.ElementAtOrDefault(++i));
break;
case "--threads":
if (int.TryParse(args.ElementAtOrDefault(++i), out var t) && t > 0) threads = t;
break;
default:
Console.Error.WriteLine($"unrecognized argument: {args[i]}");
return 2;
}
}
if (string.IsNullOrWhiteSpace(datDir)) {
Console.Error.WriteLine("usage: acdream-bake --dat-dir <path> [--out <file>] [--ids 0xId,0xId,...] [--landblocks 0xId,...] [--threads <n>]");
if (!BakeCommandLine.TryParse(args, Console.Error, out BakeCommandLineOptions? command))
{
return 2;
}
if (!Directory.Exists(datDir)) {
Console.Error.WriteLine($"error: directory not found: {datDir}");
if (!Directory.Exists(command!.DatDirectory))
{
Console.Error.WriteLine($"error: directory not found: {command.DatDirectory}");
return 2;
}
outPath ??= Path.Combine(datDir, "acdream.pak");
return BakeRunner.Run(new BakeOptions {
DatDir = datDir,
OutPath = outPath,
IdFilter = idFilter,
LandblockFilter = landblockFilter,
Threads = threads,
IBakeProgressSink? progress = command.ProgressJson
? new BakeProgressJsonWriter(Console.Out)
: null;
try
{
return BakeRunner.Run(new BakeOptions
{
DatDir = command.DatDirectory,
OutPath = command.OutputPath,
IdFilter = command.IdFilter,
LandblockFilter = command.LandblockFilter,
Threads = command.Threads,
Progress = progress,
});
static HashSet<uint> ParseHexList(string? raw) {
var result = new HashSet<uint>();
if (string.IsNullOrWhiteSpace(raw)) return result;
foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) {
var hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? token[2..] : token;
if (uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var value)) {
result.Add(value);
}
else {
Console.Error.WriteLine($"warning: could not parse id '{token}' — skipped");
}
}
return result;
catch (Exception exception)
{
progress?.Error(exception.Message);
Console.Error.WriteLine($"error: {exception.Message}");
return 1;
}

View file

@ -0,0 +1,172 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
namespace AcDream.Launcher.Core.Installation;
/// <summary>The exact child-process contract for one launcher bake.</summary>
public sealed record BakeProcessRequest(
string ExecutablePath,
string DatDirectory,
string OutputPath,
int Threads)
{
public IReadOnlyList<string> Arguments =>
[
"--dat-dir",
DatDirectory,
"--out",
OutputPath,
"--threads",
Threads.ToString(CultureInfo.InvariantCulture),
"--progress-json",
];
}
public sealed record BakeProcessResult(int ExitCode, string StandardError);
/// <summary>
/// Injectable child seam. Stdout is delivered as arbitrary chunks so the
/// versioned JSONL parser, rather than line-oriented process plumbing, owns
/// partial-record behavior.
/// </summary>
public interface IBakeProcessRunner
{
Task<BakeProcessResult> RunAsync(
BakeProcessRequest request,
Action<string> onStandardOutput,
CancellationToken cancellationToken = default);
}
public sealed class SystemBakeProcessRunner : IBakeProcessRunner
{
private const int BufferSize = 4096;
private const int MaximumCapturedErrorCharacters = 32 * 1024;
public async Task<BakeProcessResult> RunAsync(
BakeProcessRequest request,
Action<string> onStandardOutput,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(onStandardOutput);
if (request.Threads <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(request),
"Bake thread count must be positive.");
}
cancellationToken.ThrowIfCancellationRequested();
var startInfo = new ProcessStartInfo
{
FileName = request.ExecutablePath,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
foreach (string argument in request.Arguments)
{
startInfo.ArgumentList.Add(argument);
}
using var process = new Process { StartInfo = startInfo };
if (!process.Start())
{
throw new InvalidOperationException("The bake process could not be started.");
}
// The bake consumes no credential or other stdin input.
process.StandardInput.Close();
var standardError = new StringBuilder();
Task stdoutPump = PumpAsync(
process.StandardOutput,
onStandardOutput,
CancellationToken.None);
Task stderrPump = PumpAsync(
process.StandardError,
chunk => AppendBounded(standardError, chunk),
CancellationToken.None);
using CancellationTokenRegistration cancellation = cancellationToken.Register(
static state =>
{
var child = (Process)state!;
try
{
if (!child.HasExited)
{
child.Kill(entireProcessTree: true);
}
}
catch
{
// The cancellation token remains authoritative. Races with
// natural exit or handle teardown do not replace it.
}
},
process);
try
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
await Task.WhenAll(stdoutPump, stderrPump).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return new BakeProcessResult(process.ExitCode, standardError.ToString());
}
catch (OperationCanceledException)
{
try
{
using var cleanupTimeout = new CancellationTokenSource(
TimeSpan.FromSeconds(5));
await process.WaitForExitAsync(cleanupTimeout.Token)
.ConfigureAwait(false);
await Task.WhenAll(stdoutPump, stderrPump)
.WaitAsync(cleanupTimeout.Token)
.ConfigureAwait(false);
}
catch
{
// Preserve cancellation. The process kill registration above
// already made the best effort to terminate the tree.
}
throw;
}
}
private static async Task PumpAsync(
TextReader reader,
Action<string> sink,
CancellationToken cancellationToken)
{
char[] buffer = new char[BufferSize];
while (true)
{
int read = await reader.ReadAsync(buffer, cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
return;
}
sink(new string(buffer, 0, read));
}
}
private static void AppendBounded(StringBuilder destination, string chunk)
{
int remaining = MaximumCapturedErrorCharacters - destination.Length;
if (remaining <= 0)
{
return;
}
destination.Append(chunk.AsSpan(0, Math.Min(remaining, chunk.Length)));
}
}

View file

@ -0,0 +1,54 @@
namespace AcDream.Launcher.Core.Installation;
/// <summary>
/// Versioned machine-readable output from <c>acdream-bake
/// --progress-json</c>. Human output shares stdout but remains a distinct
/// event so the installer never derives state by scraping prose.
/// </summary>
public abstract record BakeProgressEvent(int Version, string EventName);
public sealed record BakeStartedEvent(
int Version,
uint BakeToolVersion,
string? OutputPath)
: BakeProgressEvent(Version, "started");
public sealed record BakeWorkProgressEvent(
int Version,
string Phase,
long Completed,
long Total,
int Failures,
double ElapsedSeconds,
double EtaSeconds)
: BakeProgressEvent(Version, "progress");
public sealed record BakeCompletedEvent(
int Version,
uint BakeToolVersion,
long OutputBytes,
int Failures)
: BakeProgressEvent(Version, "completed");
public sealed record BakeErrorEvent(int Version, string Message)
: BakeProgressEvent(Version, "error");
public sealed record UnknownBakeProgressEvent(
int Version,
string EventName,
string RawLine)
: BakeProgressEvent(Version, EventName);
public sealed record FutureBakeProgressEvent(
int Version,
string EventName,
string RawLine)
: BakeProgressEvent(Version, EventName);
public sealed record MalformedBakeProgressEvent(
string RawLine,
string Reason)
: BakeProgressEvent(0, "malformed");
public sealed record BakeHumanOutputEvent(string Text)
: BakeProgressEvent(0, "human");

View file

@ -0,0 +1,230 @@
using System.Text;
using System.Text.Json;
namespace AcDream.Launcher.Core.Installation;
/// <summary>
/// Incremental JSONL parser tolerant of arbitrary stream chunk boundaries.
/// Unknown event names and future protocol versions stay observable without
/// failing the bake; malformed known payloads are explicit typed events.
/// </summary>
public sealed class BakeProgressJsonlParser
{
public const int CurrentVersion = 1;
private readonly StringBuilder _pending = new();
public IReadOnlyList<BakeProgressEvent> Append(string chunk)
{
ArgumentNullException.ThrowIfNull(chunk);
_pending.Append(chunk);
return Drain(completeFinalLine: false);
}
public IReadOnlyList<BakeProgressEvent> Complete() =>
Drain(completeFinalLine: true);
public static BakeProgressEvent ParseLine(string line)
{
ArgumentNullException.ThrowIfNull(line);
string trimmed = line.Trim();
if (trimmed.Length == 0)
{
return new BakeHumanOutputEvent(string.Empty);
}
if (trimmed[0] != '{')
{
return new BakeHumanOutputEvent(line.TrimEnd('\r'));
}
try
{
using JsonDocument document = JsonDocument.Parse(trimmed);
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !TryGetInt32(root, "v", out int version)
|| !TryGetString(root, "e", out string? eventName))
{
return Malformed(line, "JSON progress requires integer 'v' and string 'e'.");
}
if (version != CurrentVersion)
{
return new FutureBakeProgressEvent(version, eventName!, line);
}
return eventName switch
{
"started" => ParseStarted(root, version, line),
"progress" => ParseProgress(root, version, line),
"completed" => ParseCompleted(root, version, line),
"error" => ParseError(root, version, line),
_ => new UnknownBakeProgressEvent(version, eventName!, line),
};
}
catch (JsonException ex)
{
return Malformed(line, ex.Message);
}
}
private IReadOnlyList<BakeProgressEvent> Drain(bool completeFinalLine)
{
var events = new List<BakeProgressEvent>();
while (true)
{
int newline = IndexOfNewline(_pending);
if (newline < 0)
{
break;
}
string line = _pending.ToString(0, newline);
_pending.Remove(0, newline + 1);
events.Add(ParseLine(line));
}
if (completeFinalLine && _pending.Length > 0)
{
string line = _pending.ToString();
_pending.Clear();
events.Add(ParseLine(line));
}
return events;
}
private static int IndexOfNewline(StringBuilder value)
{
for (int i = 0; i < value.Length; i++)
{
if (value[i] == '\n')
{
return i;
}
}
return -1;
}
private static BakeProgressEvent ParseStarted(
JsonElement root,
int version,
string raw)
{
if (!TryGetUInt32(root, "bakeToolVersion", out uint bakeToolVersion)
|| bakeToolVersion == 0)
{
return Malformed(raw, "started requires a positive bakeToolVersion.");
}
_ = TryGetString(root, "outputPath", out string? outputPath);
return new BakeStartedEvent(version, bakeToolVersion, outputPath);
}
private static BakeProgressEvent ParseProgress(
JsonElement root,
int version,
string raw)
{
if (!TryGetString(root, "phase", out string? phase)
|| !TryGetInt64(root, "completed", out long completed)
|| !TryGetInt64(root, "total", out long total)
|| !TryGetInt32(root, "failures", out int failures)
|| !TryGetDouble(root, "elapsedSeconds", out double elapsedSeconds)
|| !TryGetDouble(root, "etaSeconds", out double etaSeconds)
|| completed < 0
|| total < 0
|| completed > total
|| failures < 0
|| elapsedSeconds < 0
|| etaSeconds < 0)
{
return Malformed(raw, "progress payload has missing or invalid fields.");
}
return new BakeWorkProgressEvent(
version,
phase!,
completed,
total,
failures,
elapsedSeconds,
etaSeconds);
}
private static BakeProgressEvent ParseCompleted(
JsonElement root,
int version,
string raw)
{
if (!TryGetUInt32(root, "bakeToolVersion", out uint bakeToolVersion)
|| !TryGetInt64(root, "outputBytes", out long outputBytes)
|| !TryGetInt32(root, "failures", out int failures)
|| bakeToolVersion == 0
|| outputBytes <= 0
|| failures < 0)
{
return Malformed(raw, "completed payload has missing or invalid fields.");
}
return new BakeCompletedEvent(
version,
bakeToolVersion,
outputBytes,
failures);
}
private static BakeProgressEvent ParseError(
JsonElement root,
int version,
string raw) =>
TryGetString(root, "message", out string? message)
&& !string.IsNullOrWhiteSpace(message)
? new BakeErrorEvent(version, message)
: Malformed(raw, "error requires a non-empty message.");
private static MalformedBakeProgressEvent Malformed(string raw, string reason) =>
new(raw, reason);
private static bool TryGetString(
JsonElement root,
string name,
out string? value)
{
value = null;
return root.TryGetProperty(name, out JsonElement element)
&& element.ValueKind == JsonValueKind.String
&& (value = element.GetString()) is not null;
}
private static bool TryGetInt32(JsonElement root, string name, out int value)
{
value = default;
return root.TryGetProperty(name, out JsonElement element)
&& element.TryGetInt32(out value);
}
private static bool TryGetUInt32(JsonElement root, string name, out uint value)
{
value = default;
return root.TryGetProperty(name, out JsonElement element)
&& element.TryGetUInt32(out value);
}
private static bool TryGetInt64(JsonElement root, string name, out long value)
{
value = default;
return root.TryGetProperty(name, out JsonElement element)
&& element.TryGetInt64(out value);
}
private static bool TryGetDouble(JsonElement root, string name, out double value)
{
value = default;
return root.TryGetProperty(name, out JsonElement element)
&& element.TryGetDouble(out value)
&& double.IsFinite(value);
}
}

View file

@ -0,0 +1,138 @@
namespace AcDream.Launcher.Core.Installation;
/// <summary>
/// Portable validation and Windows-only discovery for the four retail data
/// archives consumed by <c>acdream-bake</c>. Discovery is intentionally only
/// a list of conventional paths; validation is the same filesystem operation
/// on Windows and Linux, including for a manually entered path.
/// </summary>
public sealed class DatDirectoryLocator
{
public static IReadOnlyList<string> RequiredFileNames { get; } =
Array.AsReadOnly<string>(
[
"client_portal.dat",
"client_cell_1.dat",
"client_highres.dat",
"client_local_English.dat",
]);
private readonly bool _isWindows;
private readonly string[] _windowsCandidates;
private readonly Func<string, bool> _directoryExists;
private readonly Func<string, bool> _fileExists;
public DatDirectoryLocator(
bool? isWindows = null,
IEnumerable<string>? windowsCandidates = null,
Func<string, bool>? directoryExists = null,
Func<string, bool>? fileExists = null)
{
_isWindows = isWindows ?? OperatingSystem.IsWindows();
_windowsCandidates = (windowsCandidates ?? DefaultWindowsCandidates())
.Where(path => !string.IsNullOrWhiteSpace(path))
.Select(Path.GetFullPath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
_directoryExists = directoryExists ?? Directory.Exists;
_fileExists = fileExists ?? File.Exists;
}
/// <summary>
/// Returns conventional Windows locations that actually exist, in
/// preference order. An existing but incomplete directory remains in the
/// result so the wizard can explain exactly which DATs are missing.
/// Linux returns an empty list and relies on the manual picker/path field.
/// </summary>
public IReadOnlyList<DatDirectoryValidation> Detect()
{
if (!_isWindows)
{
return [];
}
return _windowsCandidates
.Where(_directoryExists)
.Select(Validate)
.ToArray();
}
public DatDirectoryValidation Validate(string? directory)
{
if (string.IsNullOrWhiteSpace(directory))
{
return DatDirectoryValidation.Invalid(
directory ?? string.Empty,
"Choose the folder containing the retail DAT files.",
RequiredFileNames);
}
string fullPath;
try
{
fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
}
catch (Exception ex) when (ex is ArgumentException
or NotSupportedException
or PathTooLongException)
{
return DatDirectoryValidation.Invalid(
directory,
"The DAT directory path is not valid.",
RequiredFileNames);
}
if (!_directoryExists(fullPath))
{
return DatDirectoryValidation.Invalid(
fullPath,
"The DAT directory does not exist.",
RequiredFileNames);
}
string[] missing = RequiredFileNames
.Where(fileName => !_fileExists(Path.Combine(fullPath, fileName)))
.ToArray();
return missing.Length == 0
? DatDirectoryValidation.Valid(fullPath)
: DatDirectoryValidation.Invalid(
fullPath,
"The selected directory is missing required retail DAT files.",
missing);
}
private static IEnumerable<string> DefaultWindowsCandidates()
{
string userProfile = Environment.GetFolderPath(
Environment.SpecialFolder.UserProfile);
if (!string.IsNullOrWhiteSpace(userProfile))
{
yield return Path.Combine(
userProfile,
"Documents",
"Asheron's Call");
}
yield return @"C:\Turbine\Asheron's Call";
}
}
public sealed record DatDirectoryValidation(
string Directory,
bool IsValid,
string Message,
IReadOnlyList<string> MissingFileNames)
{
internal static DatDirectoryValidation Valid(string directory) =>
new(
directory,
true,
"All four required retail DAT files were found.",
[]);
internal static DatDirectoryValidation Invalid(
string directory,
string message,
IReadOnlyList<string> missingFileNames) =>
new(directory, false, message, missingFileNames);
}

View file

@ -0,0 +1,337 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Launching;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Installation;
public enum InstallRecordVerificationState
{
Missing,
Verified,
Invalid,
}
public sealed record InstallRecordVerification(
InstallRecordVerificationState State,
LauncherInstallRecord? Record,
string Status)
{
public bool IsVerified => State == InstallRecordVerificationState.Verified;
}
/// <summary>
/// Versioned install-record persistence and startup verification. The record
/// is atomically replaced only after a complete package has been hashed; a
/// crash during a reinstall can recover the prior verified pak from the
/// adjacent backup before launch is enabled.
/// </summary>
public sealed class LauncherInstallRecordStore
{
public const uint CurrentBakeToolVersion = 4;
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
};
private readonly ApplicationPathSet _paths;
private readonly DatDirectoryLocator _datDirectories;
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
public LauncherInstallRecordStore(
ApplicationPathSet paths,
DatDirectoryLocator? datDirectories = null,
Func<string, CancellationToken, Task<string>>? computeSha256 = null)
{
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
_datDirectories = datDirectories ?? new DatDirectoryLocator();
_computeSha256 = computeSha256
?? ((path, cancellationToken) =>
FileIntegrity.ComputeSha256HexAsync(path, cancellationToken));
}
public string RecordPath => Path.Combine(_paths.DataDirectory, "install.json");
public string PreparedAssetPath => Path.Combine(
_paths.DataDirectory,
"pak",
"acdream.pak");
public static string GetBackupPath(string preparedAssetPath) =>
preparedAssetPath + ".previous-install";
public async Task<InstallRecordVerification> LoadAndVerifyAsync(
CancellationToken cancellationToken = default)
{
if (!File.Exists(RecordPath))
{
return new InstallRecordVerification(
InstallRecordVerificationState.Missing,
null,
"Client content is not installed. Complete the first-run setup.");
}
LauncherInstallRecord? record;
try
{
await using FileStream stream = new(
RecordPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
record = await JsonSerializer.DeserializeAsync<LauncherInstallRecord>(
stream,
SerializerOptions,
cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or JsonException
or NotSupportedException)
{
return Invalid($"The install record could not be read: {ex.Message}");
}
if (record is null)
{
return Invalid("The install record is empty.");
}
string? contractError = ValidateRecordContract(record);
if (contractError is not null)
{
return Invalid(contractError);
}
string backupPath = GetBackupPath(record.PreparedAssetPath);
FileVerification current = await VerifyFileAsync(
record.PreparedAssetPath,
record,
cancellationToken)
.ConfigureAwait(false);
if (current.IsValid)
{
TryDelete(backupPath);
return Verified(record);
}
// A process crash may occur after the old verified package was moved
// aside but before the replacement record was published. Verify the
// backup against the still-current record before restoring it.
FileVerification backup = await VerifyFileAsync(
backupPath,
record,
cancellationToken)
.ConfigureAwait(false);
if (backup.IsValid)
{
try
{
Directory.CreateDirectory(
Path.GetDirectoryName(record.PreparedAssetPath)
?? throw new InvalidOperationException(
"The prepared asset path has no parent directory."));
File.Move(backupPath, record.PreparedAssetPath, overwrite: true);
return Verified(record, "Recovered and verified the previous client content.");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return Invalid(
$"The previous verified package could not be restored: {ex.Message}");
}
}
return Invalid(current.Status);
}
public async Task SaveAtomicallyAsync(
LauncherInstallRecord record,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
string? contractError = ValidateRecordContract(record);
if (contractError is not null)
{
throw new InvalidDataException(contractError);
}
string directory = Path.GetDirectoryName(RecordPath)
?? throw new InvalidOperationException(
"The install record has no parent directory.");
Directory.CreateDirectory(directory);
string temporaryPath = Path.Combine(
directory,
$".{Path.GetFileName(RecordPath)}.{Guid.NewGuid():N}.tmp");
try
{
await using (FileStream stream = new(
temporaryPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 4096,
options: FileOptions.Asynchronous | FileOptions.WriteThrough))
{
await JsonSerializer.SerializeAsync(
stream,
record,
SerializerOptions,
cancellationToken)
.ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
stream.Flush(flushToDisk: true);
}
cancellationToken.ThrowIfCancellationRequested();
File.Move(temporaryPath, RecordPath, overwrite: true);
}
finally
{
TryDelete(temporaryPath);
}
}
private string? ValidateRecordContract(LauncherInstallRecord record)
{
if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
{
return $"Install record version {record.Version} is not supported.";
}
if (!record.HasIntegrityMetadata)
{
return "The install record is missing SHA-256, size, or bake-tool metadata.";
}
if (record.BakeToolVersion != CurrentBakeToolVersion)
{
return $"Bake tool version {record.BakeToolVersion} is not supported; "
+ $"version {CurrentBakeToolVersion} is required.";
}
if (!IsSha256(record.PreparedAssetSha256))
{
return "The install record contains an invalid SHA-256 digest.";
}
string canonicalPreparedPath = Path.GetFullPath(PreparedAssetPath);
string recordedPreparedPath;
try
{
recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath);
}
catch (Exception ex) when (ex is ArgumentException
or NotSupportedException
or PathTooLongException)
{
return $"The prepared asset path is invalid: {ex.Message}";
}
if (!PathsEqual(recordedPreparedPath, canonicalPreparedPath))
{
return "The install record does not point to the launcher's canonical "
+ "DataDirectory/pak/acdream.pak path.";
}
DatDirectoryValidation datValidation =
_datDirectories.Validate(record.DatDirectory);
return datValidation.IsValid
? null
: datValidation.Message + FormatMissing(datValidation.MissingFileNames);
}
private async Task<FileVerification> VerifyFileAsync(
string path,
LauncherInstallRecord record,
CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return new FileVerification(false, "The prepared package is missing.");
}
try
{
long length = new FileInfo(path).Length;
if (length != record.PreparedAssetSize)
{
return new FileVerification(
false,
$"The prepared package size changed (expected "
+ $"{record.PreparedAssetSize}, found {length}).");
}
string sha256 = await _computeSha256(path, cancellationToken)
.ConfigureAwait(false);
return FileIntegrity.Matches(sha256, record.PreparedAssetSha256)
? new FileVerification(true, "Client content verified.")
: new FileVerification(
false,
"The prepared package SHA-256 does not match the install record.");
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return new FileVerification(
false,
$"The prepared package could not be verified: {ex.Message}");
}
}
private static InstallRecordVerification Verified(
LauncherInstallRecord record,
string status = "Client content SHA-256, size, and bake-tool version verified.") =>
new(InstallRecordVerificationState.Verified, record, status);
private static InstallRecordVerification Invalid(string status) =>
new(InstallRecordVerificationState.Invalid, null, status);
private static bool IsSha256(string value) =>
value.Length == 64 && value.All(Uri.IsHexDigit);
private static string FormatMissing(IReadOnlyList<string> missing) =>
missing.Count == 0
? string.Empty
: " Missing: " + string.Join(", ", missing) + ".";
private static bool PathsEqual(string left, string right) =>
string.Equals(
Path.TrimEndingDirectorySeparator(left),
Path.TrimEndingDirectorySeparator(right),
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
internal static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// A stale temp/backup is never treated as a published record. The
// next startup verification retries cleanup/recovery.
}
}
private sealed record FileVerification(bool IsValid, string Status);
}

View file

@ -0,0 +1,466 @@
using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Launching;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Installation;
public enum LauncherInstallPhase
{
Idle,
ValidatingDatFiles,
PreparingOutput,
BakingMeshes,
BakingCollision,
VerifyingPackage,
SavingRecord,
Completed,
Cancelled,
Failed,
}
public sealed record LauncherInstallProgress(
LauncherInstallPhase Phase,
string Status,
long Completed = 0,
long Total = 0,
int Failures = 0,
double EtaSeconds = 0)
{
public double Fraction => Total > 0
? Math.Clamp((double)Completed / Total, 0, 1)
: 0;
}
public sealed record LauncherInstallResult(LauncherInstallRecord Record);
public sealed class LauncherInstallException : Exception
{
public LauncherInstallException(string message)
: base(message)
{
}
public LauncherInstallException(string message, Exception innerException)
: base(message, innerException)
{
}
}
public interface ILauncherInstaller
{
IReadOnlyList<DatDirectoryValidation> DetectDatDirectories();
DatDirectoryValidation ValidateDatDirectory(string? directory);
Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default);
Task<LauncherInstallResult> InstallAsync(
string datDirectory,
int threads,
IProgress<LauncherInstallProgress>? progress = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// BCL-only first-run transaction. It invokes the GL-free bake executable as
/// a child, consumes only its versioned JSONL records, verifies the published
/// pak, and atomically records the install. A prior verified package is moved
/// to an adjacent recovery slot and restored on every failure/cancellation
/// path, so a fake or crashed child cannot replace it with partial output.
/// </summary>
public sealed class LauncherInstaller : ILauncherInstaller
{
private readonly string _bakeExecutablePath;
private readonly DatDirectoryLocator _datDirectories;
private readonly LauncherInstallRecordStore _recordStore;
private readonly IBakeProcessRunner _processRunner;
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
private readonly SemaphoreSlim _installGate = new(1, 1);
private LauncherInstallRecord? _verifiedRecord;
public LauncherInstaller(
ApplicationPathSet paths,
string bakeExecutablePath,
DatDirectoryLocator? datDirectories = null,
LauncherInstallRecordStore? recordStore = null,
IBakeProcessRunner? processRunner = null,
Func<string, CancellationToken, Task<string>>? computeSha256 = null)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(bakeExecutablePath);
_bakeExecutablePath = Path.GetFullPath(bakeExecutablePath);
_datDirectories = datDirectories ?? new DatDirectoryLocator();
_computeSha256 = computeSha256
?? ((path, cancellationToken) =>
FileIntegrity.ComputeSha256HexAsync(path, cancellationToken));
_recordStore = recordStore
?? new LauncherInstallRecordStore(
paths,
_datDirectories,
_computeSha256);
_processRunner = processRunner ?? new SystemBakeProcessRunner();
}
public IReadOnlyList<DatDirectoryValidation> DetectDatDirectories() =>
_datDirectories.Detect();
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
_datDirectories.Validate(directory);
public async Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default)
{
InstallRecordVerification verification = await _recordStore
.LoadAndVerifyAsync(cancellationToken)
.ConfigureAwait(false);
_verifiedRecord = verification.Record;
return verification;
}
public async Task<LauncherInstallResult> InstallAsync(
string datDirectory,
int threads,
IProgress<LauncherInstallProgress>? progress = null,
CancellationToken cancellationToken = default)
{
if (threads <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(threads),
"Bake thread count must be positive.");
}
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await InstallCoreAsync(
datDirectory,
threads,
progress,
cancellationToken)
.ConfigureAwait(false);
}
finally
{
_installGate.Release();
}
}
private async Task<LauncherInstallResult> InstallCoreAsync(
string datDirectory,
int threads,
IProgress<LauncherInstallProgress>? progress,
CancellationToken cancellationToken)
{
Report(
progress,
LauncherInstallPhase.ValidatingDatFiles,
"Validating the four retail DAT files...");
DatDirectoryValidation validation = _datDirectories.Validate(datDirectory);
if (!validation.IsValid)
{
string message = validation.Message
+ FormatMissing(validation.MissingFileNames);
Report(progress, LauncherInstallPhase.Failed, message);
throw new LauncherInstallException(message);
}
if (!File.Exists(_bakeExecutablePath))
{
string message =
$"The co-deployed bake tool is missing at '{_bakeExecutablePath}'.";
Report(progress, LauncherInstallPhase.Failed, message);
throw new LauncherInstallException(message);
}
string outputPath = _recordStore.PreparedAssetPath;
string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath);
if (_verifiedRecord is null)
{
InstallRecordVerification existing = await _recordStore
.LoadAndVerifyAsync(cancellationToken)
.ConfigureAwait(false);
_verifiedRecord = existing.Record;
}
Directory.CreateDirectory(
Path.GetDirectoryName(outputPath)
?? throw new InvalidOperationException(
"The prepared package path has no parent directory."));
Report(
progress,
LauncherInstallPhase.PreparingOutput,
"Preparing the atomic package transaction...");
bool previousPreserved = PreservePreviousPackage(outputPath, backupPath);
if (!previousPreserved)
{
LauncherInstallRecordStore.TryDelete(backupPath);
}
var parser = new BakeProgressJsonlParser();
BakeStartedEvent? started = null;
BakeCompletedEvent? completed = null;
string? protocolError = null;
string? childError = null;
void Observe(BakeProgressEvent progressEvent)
{
switch (progressEvent)
{
case BakeStartedEvent value:
started = value;
break;
case BakeWorkProgressEvent value:
LauncherInstallPhase phase = value.Phase switch
{
"mesh" => LauncherInstallPhase.BakingMeshes,
"collision" => LauncherInstallPhase.BakingCollision,
_ => LauncherInstallPhase.BakingMeshes,
};
Report(
progress,
phase,
$"Baking {value.Phase} assets: "
+ $"{value.Completed:N0}/{value.Total:N0}; "
+ $"failures: {value.Failures:N0}",
value.Completed,
value.Total,
value.Failures,
value.EtaSeconds);
break;
case BakeCompletedEvent value:
completed = value;
break;
case BakeErrorEvent value:
childError = value.Message;
Report(
progress,
LauncherInstallPhase.Failed,
$"Bake tool error: {value.Message}");
break;
case MalformedBakeProgressEvent value:
protocolError ??= value.Reason;
Report(
progress,
LauncherInstallPhase.Failed,
$"Malformed bake progress: {value.Reason}");
break;
// Human lines are deliberately ignored, and unknown event
// kinds are forward-compatible. A future protocol version
// cannot satisfy the required v1 started/completed pair.
}
}
try
{
cancellationToken.ThrowIfCancellationRequested();
var request = new BakeProcessRequest(
_bakeExecutablePath,
validation.Directory,
outputPath,
threads);
BakeProcessResult processResult = await _processRunner.RunAsync(
request,
chunk =>
{
foreach (BakeProgressEvent progressEvent in parser.Append(chunk))
{
Observe(progressEvent);
}
},
cancellationToken)
.ConfigureAwait(false);
foreach (BakeProgressEvent progressEvent in parser.Complete())
{
Observe(progressEvent);
}
cancellationToken.ThrowIfCancellationRequested();
if (processResult.ExitCode != 0)
{
throw new LauncherInstallException(
BuildChildFailure(
processResult.ExitCode,
childError,
processResult.StandardError));
}
if (!string.IsNullOrWhiteSpace(childError))
{
throw new LauncherInstallException(
$"The bake tool reported an error: {childError}");
}
if (protocolError is not null)
{
throw new LauncherInstallException(
$"The bake tool emitted malformed JSON progress: {protocolError}");
}
if (started is null || completed is null)
{
throw new LauncherInstallException(
"The bake tool exited without the required v1 started/completed "
+ "progress records.");
}
if (started.BakeToolVersion != completed.BakeToolVersion
|| completed.BakeToolVersion
!= LauncherInstallRecordStore.CurrentBakeToolVersion)
{
throw new LauncherInstallException(
$"The bake tool reported version {completed.BakeToolVersion}; "
+ $"version {LauncherInstallRecordStore.CurrentBakeToolVersion} "
+ "is required.");
}
if (completed.Failures != 0)
{
throw new LauncherInstallException(
$"The bake completed with {completed.Failures:N0} failed assets.");
}
if (!File.Exists(outputPath))
{
throw new LauncherInstallException(
"The bake tool reported success but did not publish acdream.pak.");
}
long size = new FileInfo(outputPath).Length;
if (size <= 0 || size != completed.OutputBytes)
{
throw new LauncherInstallException(
"The published package size does not match the bake completion record.");
}
Report(
progress,
LauncherInstallPhase.VerifyingPackage,
"Computing the prepared package SHA-256...");
string sha256 = await _computeSha256(outputPath, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var record = new LauncherInstallRecord(
validation.Directory,
outputPath,
sha256,
size,
completed.BakeToolVersion);
Report(
progress,
LauncherInstallPhase.SavingRecord,
"Saving the verified install record...");
await _recordStore.SaveAtomicallyAsync(record, cancellationToken)
.ConfigureAwait(false);
_verifiedRecord = record;
LauncherInstallRecordStore.TryDelete(backupPath);
Report(
progress,
LauncherInstallPhase.Completed,
"Client content installed and verified.",
completed: 1,
total: 1);
return new LauncherInstallResult(record);
}
catch (OperationCanceledException)
{
RestorePreviousPackage(outputPath, backupPath, previousPreserved);
Report(
progress,
LauncherInstallPhase.Cancelled,
"Installation cancelled; no new install record was published.");
throw;
}
catch (Exception ex)
{
RestorePreviousPackage(outputPath, backupPath, previousPreserved);
Report(
progress,
LauncherInstallPhase.Failed,
$"Installation failed: {ex.Message}");
if (ex is LauncherInstallException)
{
throw;
}
throw new LauncherInstallException("Installation failed.", ex);
}
}
private bool PreservePreviousPackage(string outputPath, string backupPath)
{
LauncherInstallRecord? previous = _verifiedRecord;
if (previous is null
|| !PathsEqual(previous.PreparedAssetPath, outputPath)
|| !File.Exists(outputPath))
{
return false;
}
File.Move(outputPath, backupPath, overwrite: true);
return true;
}
private static void RestorePreviousPackage(
string outputPath,
string backupPath,
bool previousPreserved)
{
if (previousPreserved && File.Exists(backupPath))
{
File.Move(backupPath, outputPath, overwrite: true);
return;
}
LauncherInstallRecordStore.TryDelete(outputPath);
LauncherInstallRecordStore.TryDelete(backupPath);
}
private static string BuildChildFailure(
int exitCode,
string? jsonError,
string standardError)
{
string detail = !string.IsNullOrWhiteSpace(jsonError)
? jsonError
: standardError.Trim();
return detail.Length == 0
? $"The bake tool exited with code {exitCode}."
: $"The bake tool exited with code {exitCode}: {detail}";
}
private static void Report(
IProgress<LauncherInstallProgress>? progress,
LauncherInstallPhase phase,
string status,
long completed = 0,
long total = 0,
int failures = 0,
double etaSeconds = 0) =>
progress?.Report(new LauncherInstallProgress(
phase,
status,
completed,
total,
failures,
etaSeconds));
private static string FormatMissing(IReadOnlyList<string> missing) =>
missing.Count == 0
? string.Empty
: " Missing: " + string.Join(", ", missing) + ".";
private static bool PathsEqual(string left, string right) =>
string.Equals(
Path.GetFullPath(left),
Path.GetFullPath(right),
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
}

View file

@ -27,6 +27,11 @@ public static class FileIntegrity
return Convert.ToHexStringLower(hash);
}
/// <summary>
/// Asynchronous, cancellable counterpart used while verifying a multi-
/// gigabyte prepared package. The file stays streamed and no buffer is
/// retained after the hash completes.
/// </summary>
public static async Task<string> ComputeSha256HexAsync(
string filePath,
CancellationToken cancellationToken = default)
@ -38,8 +43,8 @@ public static class FileIntegrity
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
useAsync: true);
bufferSize: 1024 * 1024,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken)
.ConfigureAwait(false);
return Convert.ToHexStringLower(hash);

View file

@ -3,10 +3,24 @@ 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.
/// <see cref="SessionContentDescriptor"/>. Integrity metadata is launcher-
/// local: it is verified before this record is admitted to the orchestrator
/// and is deliberately not copied into the host session-config contract.
/// </summary>
public sealed record LauncherInstallRecord(
string DatDirectory,
string PreparedAssetPath);
string PreparedAssetPath,
string PreparedAssetSha256 = "",
long PreparedAssetSize = 0,
uint BakeToolVersion = 0)
{
public const int CurrentRecordVersion = 1;
public int Version { get; init; } = CurrentRecordVersion;
public bool HasIntegrityMetadata =>
!string.IsNullOrEmpty(PreparedAssetSha256)
&& PreparedAssetSha256.Length == 64
&& PreparedAssetSize > 0
&& BakeToolVersion > 0;
}

View file

@ -29,6 +29,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
private readonly List<ManagedActivity> _activities = [];
private LauncherInstallRecord? _installRecord;
private string _installationStatus;
private bool _disposed;
public LauncherOrchestrator(
@ -40,7 +41,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
ILauncherSessionConfigService? configService = null,
ILauncherProcessSupervisorFactory? supervisorFactory = null,
IStatusEventSourceFactory? statusSourceFactory = null,
Func<string>? sessionIdFactory = null)
Func<string>? sessionIdFactory = null,
string? installationStatus = null)
{
_profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore));
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
@ -51,6 +53,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
_supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory();
_statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory();
_sessionIdFactory = sessionIdFactory ?? CreateSessionId;
_installationStatus = installationStatus
?? (installRecord is null
? FirstRunRequired
: "Client content paths are configured.");
}
public event EventHandler? StateChanged;
@ -85,9 +91,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
sessions,
_platform,
_installRecord is not null,
_installRecord is null
? FirstRunRequired
: "Client content paths are configured.");
_installationStatus);
}
}
@ -184,6 +188,9 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
{
ThrowIfDisposed();
_installRecord = installRecord;
_installationStatus = installRecord is null
? FirstRunRequired
: "Client content SHA-256, size, and bake-tool version verified.";
}
RaiseStateChanged();

View file

@ -1,3 +1,4 @@
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
@ -22,15 +23,40 @@ public sealed partial class App : Application
{
ApplicationPathSet paths = ApplicationPathSet.Resolve();
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
LauncherInstallRecord? install = ResolveDevelopmentInstallRecord();
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
var installer = new LauncherInstaller(
paths,
Path.Combine(
AppContext.BaseDirectory,
"acdream-bake" + executableSuffix));
InstallRecordVerification verification;
try
{
// Hashing the package before constructing the orchestrator is
// intentional: no launch action is enabled until the persisted
// size/SHA/tool-version record has been verified.
verification = installer.LoadExistingAsync()
.GetAwaiter()
.GetResult();
}
catch (Exception ex)
{
verification = new InstallRecordVerification(
InstallRecordVerificationState.Invalid,
null,
$"Client content verification failed: {ex.Message}");
}
_orchestrator = new LauncherOrchestrator(
profiles,
paths,
LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory),
install);
verification.Record,
installationStatus: verification.Status);
_viewModel = new LauncherWindowViewModel(
_orchestrator,
new AvaloniaUiDispatcher());
new AvaloniaUiDispatcher(),
installer);
_viewModel.Initialize();
desktop.MainWindow = new MainWindow
@ -43,19 +69,6 @@ public sealed partial class App : Application
base.OnFrameworkInitializationCompleted();
}
private static LauncherInstallRecord? ResolveDevelopmentInstallRecord()
{
// LA9 owns persisted install discovery. LA4 accepts the existing
// developer environment pair at this one composition root so the
// launch/probe UI can be exercised before the first-run body lands.
string? datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
string? preparedAssetPath = Environment.GetEnvironmentVariable("ACDREAM_PAK_PATH");
return !string.IsNullOrWhiteSpace(datDirectory)
&& !string.IsNullOrWhiteSpace(preparedAssetPath)
? new LauncherInstallRecord(datDirectory, preparedAssetPath)
: null;
}
private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
{
_viewModel?.Dispose();

View file

@ -342,21 +342,88 @@
KeyDown="OnModalKeyDown"
AutomationProperties.Name="First-run setup modal dialog"
IsVisible="{Binding FirstRunWizardShell.IsOpen}">
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
<Border Classes="card"
Width="680"
MaxHeight="680"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<ScrollViewer>
<StackPanel Spacing="12">
<TextBlock Text="{Binding FirstRunWizardShell.Title}" FontSize="24" FontWeight="Bold" />
<TextBlock Text="{Binding FirstRunWizardShell.Body}" TextWrapping="Wrap" />
<TextBlock Text="Retail DAT directory" FontWeight="SemiBold" />
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<TextBox x:Name="FirstRunDatDirectoryTextBox"
AutomationProperties.Name="Retail DAT directory"
IsEnabled="{Binding FirstRunWizardShell.CanEditInputs}"
PlaceholderText="Path containing client_portal.dat"
Text="{Binding FirstRunWizardShell.DatDirectory, Mode=TwoWay}" />
<Button Grid.Column="1"
Content="Browse..."
AutomationProperties.Name="Browse for retail DAT directory"
IsEnabled="{Binding FirstRunWizardShell.CanEditInputs}"
Click="OnBrowseDatDirectory" />
</Grid>
<StackPanel Spacing="3">
<TextBlock Text="{Binding FirstRunWizardShell.ValidationStatus}"
TextWrapping="Wrap" />
<TextBlock Text="{Binding FirstRunWizardShell.MissingFiles}"
Foreground="#FFB0A0"
IsVisible="{Binding FirstRunWizardShell.HasMissingFiles}"
TextWrapping="Wrap" />
</StackPanel>
<Grid ColumnDefinitions="160,*" ColumnSpacing="10">
<StackPanel Spacing="4">
<TextBlock Text="Bake worker threads" FontWeight="SemiBold" />
<TextBox AutomationProperties.Name="Bake worker threads"
IsEnabled="{Binding FirstRunWizardShell.CanEditInputs}"
Text="{Binding FirstRunWizardShell.ThreadsText, Mode=TwoWay}" />
</StackPanel>
<TextBlock Grid.Column="1"
VerticalAlignment="Bottom"
Classes="muted"
Text="{Binding FirstRunWizardShell.ThreadCountValidation}"
TextWrapping="Wrap" />
</Grid>
<Border Background="#24344B" Padding="10" CornerRadius="5">
<StackPanel Spacing="7">
<TextBlock Text="{Binding FirstRunWizardShell.Status}" TextWrapping="Wrap" />
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding FirstRunWizardShell.ProgressPercent}"
IsIndeterminate="{Binding FirstRunWizardShell.IsProgressIndeterminate}" />
<TextBlock Text="{Binding FirstRunWizardShell.Phase}"
Classes="muted" />
</StackPanel>
</Border>
<TextBlock Text="{Binding FirstRunWizardShell.Error}"
Foreground="#FF9A9A"
IsVisible="{Binding FirstRunWizardShell.HasError}"
TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="Validate"
AutomationProperties.Name="Validate retail DAT directory"
Command="{Binding FirstRunWizardShell.ValidateCommand}" />
<Button Content="Cancel bake"
AutomationProperties.Name="Cancel first-run bake"
Command="{Binding FirstRunWizardShell.CancelCommand}" />
<Button x:Name="FirstRunCloseButton"
Content="Close"
HorizontalAlignment="Right"
IsCancel="True"
IsDefault="True"
AutomationProperties.Name="Close first-run setup"
Command="{Binding FirstRunWizardShell.CloseCommand}" />
<Button Content="Build and install"
Classes="primary"
IsDefault="True"
AutomationProperties.Name="Build and install client content"
Command="{Binding FirstRunWizardShell.StartCommand}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Border>

View file

@ -2,7 +2,9 @@ using System.ComponentModel;
using AcDream.Launcher.ViewModels;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
namespace AcDream.Launcher;
@ -116,7 +118,7 @@ public sealed partial class MainWindow : Window
}
else if (viewModel.FirstRunWizardShell.IsOpen)
{
FirstRunCloseButton.Focus();
FirstRunDatDirectoryTextBox.Focus();
}
else if (viewModel.UpdatePromptShell.IsOpen)
{
@ -134,4 +136,33 @@ public sealed partial class MainWindow : Window
viewModel.CloseActiveModal();
e.Handled = true;
}
private async void OnBrowseDatDirectory(object? sender, RoutedEventArgs e)
{
if (DataContext is not LauncherWindowViewModel viewModel)
{
return;
}
try
{
IReadOnlyList<IStorageFolder> folders = await StorageProvider
.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose the retail Asheron's Call DAT directory",
AllowMultiple = false,
});
if (folders.Count > 0)
{
viewModel.FirstRunWizardShell.SelectDatDirectory(
folders[0].Path.LocalPath);
}
}
catch (Exception ex)
{
viewModel.FirstRunWizardShell.ReportPickerError(ex.Message);
}
e.Handled = true;
}
}

View file

@ -0,0 +1,384 @@
using System.ComponentModel;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Launching;
namespace AcDream.Launcher.ViewModels;
/// <summary>
/// Thin wizard projection over the BCL-only installer transaction. Filesystem,
/// child-process, hashing, recovery, and record publication all remain in
/// Launcher.Core; this type owns only editable fields and UI command state.
/// </summary>
public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
{
private readonly ILauncherInstaller _installer;
private readonly IUiDispatcher _dispatcher;
private readonly Action<LauncherInstallRecord> _onInstalled;
private readonly Func<bool> _canOpen;
private readonly Func<bool> _canStart;
private CancellationTokenSource? _cancellation;
private string _datDirectory = string.Empty;
private string _threadsText = Math.Max(1, Environment.ProcessorCount).ToString();
private string _status = "Choose the folder containing the retail DAT files.";
private string _validationStatus = "No DAT directory selected.";
private string _missingFiles = string.Empty;
private string? _error;
private LauncherInstallPhase _phase = LauncherInstallPhase.Idle;
private double _progressPercent;
private bool _isOpen;
private bool _isRunning;
private bool _isDatDirectoryValid;
private bool _disposed;
public FirstRunInstallerViewModel(
ILauncherInstaller installer,
IUiDispatcher dispatcher,
Action<LauncherInstallRecord> onInstalled,
Func<bool>? canOpen = null,
Func<bool>? canStart = null)
{
_installer = installer ?? throw new ArgumentNullException(nameof(installer));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_onInstalled = onInstalled ?? throw new ArgumentNullException(nameof(onInstalled));
_canOpen = canOpen ?? (() => true);
_canStart = canStart ?? (() => true);
OpenCommand = new RelayCommand(Open, () => !_disposed && _canOpen());
CloseCommand = new RelayCommand(Close, () => !IsRunning);
ValidateCommand = new RelayCommand(Validate, () => !IsRunning);
StartCommand = new AsyncRelayCommand(StartAsync, CanBeginInstall);
CancelCommand = new RelayCommand(
() => _cancellation?.Cancel(),
() => IsRunning && _cancellation is not null);
}
public string Title => "First-run setup";
public string Body =>
"Select the retail Asheron's Call DAT folder. acdream will validate "
+ "the four required files, build DataDirectory/pak/acdream.pak, and "
+ "verify its SHA-256 before enabling launch.";
public string DatDirectory
{
get => _datDirectory;
set
{
if (SetProperty(ref _datDirectory, value ?? string.Empty))
{
Validate();
}
}
}
public string ThreadsText
{
get => _threadsText;
set
{
if (SetProperty(ref _threadsText, value ?? string.Empty))
{
OnPropertyChanged(nameof(IsThreadCountValid));
OnPropertyChanged(nameof(ThreadCountValidation));
NotifyCommandStates();
}
}
}
public bool IsThreadCountValid =>
int.TryParse(ThreadsText, out int threads) && threads > 0;
public string ThreadCountValidation => IsThreadCountValid
? "Worker count is valid."
: "Threads must be a positive whole number.";
public string Status
{
get => _status;
private set => SetProperty(ref _status, value);
}
public string ValidationStatus
{
get => _validationStatus;
private set => SetProperty(ref _validationStatus, value);
}
public string MissingFiles
{
get => _missingFiles;
private set
{
if (SetProperty(ref _missingFiles, value))
{
OnPropertyChanged(nameof(HasMissingFiles));
}
}
}
public bool HasMissingFiles => MissingFiles.Length > 0;
public string? Error
{
get => _error;
private set
{
if (SetProperty(ref _error, value))
{
OnPropertyChanged(nameof(HasError));
}
}
}
public bool HasError => !string.IsNullOrWhiteSpace(Error);
public LauncherInstallPhase Phase
{
get => _phase;
private set => SetProperty(ref _phase, value);
}
public double ProgressPercent
{
get => _progressPercent;
private set => SetProperty(ref _progressPercent, value);
}
public bool IsProgressIndeterminate =>
IsRunning && ProgressPercent <= 0;
public bool CanEditInputs => !IsRunning;
public bool IsOpen
{
get => _isOpen;
private set => SetProperty(ref _isOpen, value);
}
public bool IsRunning
{
get => _isRunning;
private set
{
if (SetProperty(ref _isRunning, value))
{
OnPropertyChanged(nameof(IsProgressIndeterminate));
OnPropertyChanged(nameof(CanEditInputs));
NotifyCommandStates();
}
}
}
public bool IsDatDirectoryValid
{
get => _isDatDirectoryValid;
private set
{
if (SetProperty(ref _isDatDirectoryValid, value))
{
NotifyCommandStates();
}
}
}
public RelayCommand OpenCommand { get; }
public RelayCommand CloseCommand { get; }
public RelayCommand ValidateCommand { get; }
public AsyncRelayCommand StartCommand { get; }
public RelayCommand CancelCommand { get; }
public void SelectDatDirectory(string directory) => DatDirectory = directory;
public void ReportPickerError(string message)
{
Error = string.IsNullOrWhiteSpace(message)
? "The DAT directory picker failed."
: message;
}
public void NotifyCommandStates()
{
OpenCommand.NotifyCanExecuteChanged();
CloseCommand.NotifyCanExecuteChanged();
ValidateCommand.NotifyCanExecuteChanged();
StartCommand.NotifyCanExecuteChanged();
CancelCommand.NotifyCanExecuteChanged();
}
public void Close()
{
if (!IsRunning)
{
IsOpen = false;
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_cancellation?.Cancel();
_cancellation?.Dispose();
_cancellation = null;
NotifyCommandStates();
}
private void Open()
{
if (string.IsNullOrWhiteSpace(DatDirectory))
{
IReadOnlyList<DatDirectoryValidation> candidates =
_installer.DetectDatDirectories();
DatDirectoryValidation? preferred =
candidates.FirstOrDefault(candidate => candidate.IsValid)
?? candidates.FirstOrDefault();
if (preferred is not null)
{
_datDirectory = preferred.Directory;
OnPropertyChanged(nameof(DatDirectory));
}
}
Validate();
IsOpen = true;
}
private void Validate()
{
if (IsRunning)
{
return;
}
DatDirectoryValidation validation =
_installer.ValidateDatDirectory(DatDirectory);
IsDatDirectoryValid = validation.IsValid;
ValidationStatus = validation.Message;
MissingFiles = validation.MissingFileNames.Count == 0
? string.Empty
: "Missing: " + string.Join(", ", validation.MissingFileNames);
Error = null;
NotifyCommandStates();
}
private bool CanBeginInstall() =>
!_disposed
&& IsOpen
&& !IsRunning
&& IsDatDirectoryValid
&& IsThreadCountValid
&& _canStart();
private async Task StartAsync()
{
if (!int.TryParse(ThreadsText, out int threads) || threads <= 0)
{
return;
}
using var cancellation = new CancellationTokenSource();
_cancellation = cancellation;
IsRunning = true;
Error = null;
ProgressPercent = 0;
Phase = LauncherInstallPhase.ValidatingDatFiles;
Status = "Starting installation...";
var progress = new CallbackProgress<LauncherInstallProgress>(value =>
_dispatcher.Post(() => ApplyProgress(value)));
try
{
LauncherInstallResult result = await _installer.InstallAsync(
DatDirectory,
threads,
progress,
cancellation.Token)
.ConfigureAwait(true);
_onInstalled(result.Record);
Phase = LauncherInstallPhase.Completed;
ProgressPercent = 100;
Status = "Client content installed and verified. Launch is enabled.";
}
catch (OperationCanceledException)
{
Phase = LauncherInstallPhase.Cancelled;
Status = "Installation cancelled. The previous verified install was preserved.";
}
catch (Exception ex)
{
Phase = LauncherInstallPhase.Failed;
Error = string.IsNullOrWhiteSpace(ex.Message)
? "Installation failed."
: ex.Message;
Status = "Installation failed; no new install record was published.";
}
finally
{
if (ReferenceEquals(_cancellation, cancellation))
{
_cancellation = null;
}
IsRunning = false;
}
}
private void ApplyProgress(LauncherInstallProgress progress)
{
if (_disposed)
{
return;
}
Phase = progress.Phase;
Status = progress.Status;
ProgressPercent = progress.Total > 0
? progress.Fraction * 100
: 0;
OnPropertyChanged(nameof(IsProgressIndeterminate));
}
private sealed class CallbackProgress<T>(Action<T> callback) : IProgress<T>
{
private readonly Action<T> _callback = callback
?? throw new ArgumentNullException(nameof(callback));
public void Report(T value) => _callback(value);
}
}
internal sealed class UnavailableLauncherInstaller : ILauncherInstaller
{
public IReadOnlyList<DatDirectoryValidation> DetectDatDirectories() => [];
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
new(
directory ?? string.Empty,
false,
"The installer service is unavailable in this host.",
DatDirectoryLocator.RequiredFileNames);
public Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(new InstallRecordVerification(
InstallRecordVerificationState.Missing,
null,
"The installer service is unavailable in this host."));
public Task<LauncherInstallResult> InstallAsync(
string datDirectory,
int threads,
IProgress<LauncherInstallProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<LauncherInstallResult>(
new LauncherInstallException(
"The installer service is unavailable in this host."));
}

View file

@ -1,5 +1,7 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
@ -22,20 +24,20 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public LauncherWindowViewModel(
ILauncherOrchestrator orchestrator,
IUiDispatcher dispatcher)
IUiDispatcher dispatcher,
ILauncherInstaller? installer = null)
{
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_orchestrator.StateChanged += OnOrchestratorStateChanged;
EditorDialog = new ProfileEditorDialogViewModel();
FirstRunWizardShell = new LauncherShellViewModel(
"First-run setup",
"Choose and validate the retail DAT directory, build acdream.pak, "
+ "and record the installed client. The installer transaction and "
+ "progress body land in Campaign LA slice LA9.",
"Installer shell ready — implementation arrives in LA9.",
() => CanInteract);
FirstRunWizardShell = new FirstRunInstallerViewModel(
installer ?? new UnavailableLauncherInstaller(),
dispatcher,
OnInstallCompleted,
() => CanInteract,
() => !IsBusy && Sessions.All(session => !session.IsActive));
UpdatePromptShell = new LauncherShellViewModel(
"Client update",
"Review a signed release manifest, verify the downloaded archive, "
@ -88,7 +90,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public ProfileEditorDialogViewModel EditorDialog { get; }
public LauncherShellViewModel FirstRunWizardShell { get; }
public FirstRunInstallerViewModel FirstRunWizardShell { get; }
public LauncherShellViewModel UpdatePromptShell { get; }
@ -332,6 +334,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
UpdatePromptShell.PropertyChanged -= OnModalPropertyChanged;
FirstRunWizardShell.Dispose();
}
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
@ -346,7 +349,8 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
private void OnModalPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(ProfileEditorDialogViewModel.IsOpen)
&& e.PropertyName != nameof(LauncherShellViewModel.IsOpen))
&& e.PropertyName != nameof(LauncherShellViewModel.IsOpen)
&& e.PropertyName != nameof(FirstRunInstallerViewModel.IsOpen))
{
return;
}
@ -370,7 +374,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
}
else if (FirstRunWizardShell.IsOpen)
{
FirstRunWizardShell.IsOpen = false;
FirstRunWizardShell.Close();
}
else if (UpdatePromptShell.IsOpen)
{
@ -964,6 +968,14 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
: message;
}
private void OnInstallCompleted(LauncherInstallRecord record)
{
_orchestrator.SetInstallRecord(record);
OperationStatus = "Client content installed and verified.";
LastError = null;
RefreshFromCore();
}
private void NotifyCommandStates()
{
AddServerCommand.NotifyCanExecuteChanged();

View file

@ -0,0 +1,105 @@
using System.Text.Json;
using AcDream.Content.Pak;
namespace AcDream.Bake.Tests;
public sealed class BakeProgressCliTests
{
[Fact]
public void ProgressJsonFlagIsOptInAndDefaultOutputRemainsInTheDatDirectory()
{
using var errors = new StringWriter();
Assert.True(BakeCommandLine.TryParse(
["--dat-dir", "retail-dats"],
errors,
out BakeCommandLineOptions? defaults));
Assert.NotNull(defaults);
Assert.False(defaults.ProgressJson);
Assert.Equal(
Path.Combine("retail-dats", "acdream.pak"),
defaults.OutputPath);
Assert.True(BakeCommandLine.TryParse(
[
"--dat-dir", "retail-dats",
"--out", "prepared/acdream.pak",
"--threads", "7",
"--progress-json",
],
errors,
out BakeCommandLineOptions? machine));
Assert.NotNull(machine);
Assert.True(machine.ProgressJson);
Assert.Equal("prepared/acdream.pak", machine.OutputPath);
Assert.Equal(7, machine.Threads);
Assert.Contains("--progress-json", BakeCommandLine.Usage, StringComparison.Ordinal);
}
[Fact]
public void HumanFiveSecondLineIsAlwaysWrittenAndJsonIsOnlyWrittenWhenEnabled()
{
using var humanOnly = new StringWriter();
BakeProgressReporter.Write(
humanOnly,
machineOutput: null,
phase: "mesh",
completed: 1250,
total: 5000,
failures: 2,
elapsed: TimeSpan.FromSeconds(5),
etaSeconds: 15,
privateBytes: 64L * 1024 * 1024,
managedBytes: 16L * 1024 * 1024);
string defaultText = humanOnly.ToString();
Assert.Contains("[00:00:05] extracted", defaultText, StringComparison.Ordinal);
Assert.Contains("failures=2", defaultText, StringComparison.Ordinal);
Assert.DoesNotContain("\"v\":", defaultText, StringComparison.Ordinal);
using var combined = new StringWriter();
var json = new BakeProgressJsonWriter(combined);
BakeProgressReporter.Write(
combined,
json,
phase: "collision",
completed: 5,
total: 10,
failures: 0,
elapsed: TimeSpan.FromSeconds(10),
etaSeconds: 10,
privateBytes: 1,
managedBytes: 2);
string[] lines = combined.ToString().Split(
Environment.NewLine,
StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
Assert.StartsWith("[00:00:10] extracted", lines[0], StringComparison.Ordinal);
using JsonDocument document = JsonDocument.Parse(lines[1]);
Assert.Equal(1, document.RootElement.GetProperty("v").GetInt32());
Assert.Equal("progress", document.RootElement.GetProperty("e").GetString());
Assert.Equal("collision", document.RootElement.GetProperty("phase").GetString());
}
[Fact]
public void VersionedWriterCarriesCurrentBakeVersionOnTerminalRecords()
{
using var output = new StringWriter();
var writer = new BakeProgressJsonWriter(output);
writer.Started(PakFormat.CurrentBakeToolVersion, "prepared/acdream.pak");
writer.Completed(PakFormat.CurrentBakeToolVersion, 1234, failures: 0);
JsonElement[] events = output.ToString()
.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
.Select(line => JsonDocument.Parse(line).RootElement.Clone())
.ToArray();
Assert.Equal(["started", "completed"], events.Select(value =>
value.GetProperty("e").GetString()));
Assert.All(events, value => Assert.Equal(
PakFormat.CurrentBakeToolVersion,
value.GetProperty("bakeToolVersion").GetUInt32()));
}
}

View file

@ -0,0 +1,57 @@
using AcDream.Launcher.Core.Installation;
namespace AcDream.Launcher.Core.Tests.Installation;
public sealed class BakeProgressJsonlParserTests
{
[Fact]
public void PartialChunksAreBufferedUntilTheJsonLineIsComplete()
{
var parser = new BakeProgressJsonlParser();
Assert.IsType<BakeHumanOutputEvent>(Assert.Single(
parser.Append("human startup text\n{\"v\":1,\"e\":\"pro")));
IReadOnlyList<BakeProgressEvent> events = parser.Append(
"gress\",\"phase\":\"mesh\",\"completed\":4,\"total\":10,"
+ "\"failures\":0,\"elapsedSeconds\":5,\"etaSeconds\":7}\n");
Assert.Single(events);
BakeWorkProgressEvent progress =
Assert.IsType<BakeWorkProgressEvent>(events[0]);
Assert.Equal("mesh", progress.Phase);
Assert.Equal(4, progress.Completed);
Assert.Equal(10, progress.Total);
}
[Fact]
public void MalformedKnownPayloadAndTruncatedFinalLineNeverThrow()
{
var parser = new BakeProgressJsonlParser();
IReadOnlyList<BakeProgressEvent> first = parser.Append(
"{\"v\":1,\"e\":\"progress\",\"phase\":\"mesh\"}\n"
+ "{not-json");
Assert.IsType<MalformedBakeProgressEvent>(Assert.Single(first));
MalformedBakeProgressEvent final = Assert.IsType<MalformedBakeProgressEvent>(
Assert.Single(parser.Complete()));
Assert.False(string.IsNullOrWhiteSpace(final.Reason));
}
[Fact]
public void UnknownKindsAndFutureVersionsRemainTypedAndFutureSafe()
{
var parser = new BakeProgressJsonlParser();
IReadOnlyList<BakeProgressEvent> events = parser.Append(
"{\"v\":1,\"e\":\"newMetric\",\"value\":9}\n"
+ "{\"v\":2,\"e\":\"progress\",\"newShape\":true}\n"
+ "{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4,"
+ "\"outputPath\":\"pak\",\"futureField\":42}\n");
Assert.IsType<UnknownBakeProgressEvent>(events[0]);
FutureBakeProgressEvent future =
Assert.IsType<FutureBakeProgressEvent>(events[1]);
Assert.Equal(2, future.Version);
BakeStartedEvent started = Assert.IsType<BakeStartedEvent>(events[2]);
Assert.Equal(4u, started.BakeToolVersion);
}
}

View file

@ -0,0 +1,90 @@
using AcDream.Launcher.Core.Installation;
namespace AcDream.Launcher.Core.Tests.Installation;
public sealed class DatDirectoryLocatorTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-dat-locator-tests",
Guid.NewGuid().ToString("N"));
public DatDirectoryLocatorTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void PortableValidationRequiresTheFourExactDatFileNames()
{
string directory = Path.Combine(_root, "retail");
Directory.CreateDirectory(directory);
foreach (string fileName in DatDirectoryLocator.RequiredFileNames.Take(3))
{
File.WriteAllText(Path.Combine(directory, fileName), "fixture");
}
var locator = new DatDirectoryLocator(isWindows: false);
DatDirectoryValidation incomplete = locator.Validate(directory);
Assert.False(incomplete.IsValid);
Assert.Equal(["client_local_English.dat"], incomplete.MissingFileNames);
File.WriteAllText(
Path.Combine(directory, "client_local_English.dat"),
"fixture");
DatDirectoryValidation valid = locator.Validate(directory);
Assert.True(valid.IsValid);
Assert.Equal(Path.GetFullPath(directory), valid.Directory);
Assert.Empty(valid.MissingFileNames);
}
[Fact]
public void WindowsDetectionChecksBothConventionalLocationsInOrder()
{
string documents = Path.Combine(_root, "Documents", "Asheron's Call");
string turbine = Path.Combine(_root, "Turbine", "Asheron's Call");
CreateCompleteDatDirectory(documents);
Directory.CreateDirectory(turbine);
File.WriteAllText(Path.Combine(turbine, "client_portal.dat"), "fixture");
var locator = new DatDirectoryLocator(
isWindows: true,
windowsCandidates: [documents, turbine]);
IReadOnlyList<DatDirectoryValidation> detected = locator.Detect();
Assert.Equal(2, detected.Count);
Assert.Equal(Path.GetFullPath(documents), detected[0].Directory);
Assert.True(detected[0].IsValid);
Assert.Equal(Path.GetFullPath(turbine), detected[1].Directory);
Assert.False(detected[1].IsValid);
Assert.Equal(3, detected[1].MissingFileNames.Count);
}
[Fact]
public void LinuxHasNoWindowsAutoDetectionButManualValidationStillWorks()
{
string manual = Path.Combine(_root, "linux-dats");
CreateCompleteDatDirectory(manual);
var locator = new DatDirectoryLocator(
isWindows: false,
windowsCandidates: [manual]);
Assert.Empty(locator.Detect());
Assert.True(locator.Validate(manual).IsValid);
}
private static void CreateCompleteDatDirectory(string directory)
{
Directory.CreateDirectory(directory);
foreach (string fileName in DatDirectoryLocator.RequiredFileNames)
{
File.WriteAllText(Path.Combine(directory, fileName), "fixture");
}
}
}

View file

@ -0,0 +1,178 @@
using System.Text.Json;
using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Launching;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Installation;
public sealed class LauncherInstallRecordStoreTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-install-record-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
private readonly string _dats;
public LauncherInstallRecordStoreTests()
{
_paths = new ApplicationPathSet(
Path.Combine(_root, "config"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
null);
_dats = Path.Combine(_root, "retail-dats");
CreateCompleteDatDirectory(_dats);
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task AtomicRecordRoundTripVerifiesShaSizeAndBakeToolVersion()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
LauncherInstallRecord record = await CreateRecordAsync(store);
await store.SaveAtomicallyAsync(record);
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.True(verification.IsVerified);
Assert.Equal(record, verification.Record);
Assert.Contains("SHA-256", verification.Status, StringComparison.Ordinal);
Assert.Empty(Directory.EnumerateFiles(_paths.DataDirectory, ".install.json.*.tmp"));
}
[Fact]
public async Task SizeAndShaCorruptionDisableTheInstall()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "original");
LauncherInstallRecord record = await CreateRecordAsync(store);
await store.SaveAtomicallyAsync(record);
await File.WriteAllTextAsync(store.PreparedAssetPath, "different-size");
InstallRecordVerification size = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, size.State);
Assert.Contains("size changed", size.Status, StringComparison.OrdinalIgnoreCase);
await File.WriteAllTextAsync(store.PreparedAssetPath, "tampered");
var sameSizeRecord = record with
{
PreparedAssetSize = new FileInfo(store.PreparedAssetPath).Length,
PreparedAssetSha256 = new string('0', 64),
};
await store.SaveAtomicallyAsync(sameSizeRecord);
InstallRecordVerification sha = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, sha.State);
Assert.Contains("SHA-256", sha.Status, StringComparison.Ordinal);
}
[Fact]
public async Task StaleBakeToolVersionIsRejectedBeforeHashing()
{
int hashCalls = 0;
var store = new LauncherInstallRecordStore(
_paths,
computeSha256: (_, _) =>
{
hashCalls++;
return Task.FromResult(new string('a', 64));
});
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "package");
var stale = new LauncherInstallRecord(
_dats,
store.PreparedAssetPath,
new string('a', 64),
new FileInfo(store.PreparedAssetPath).Length,
LauncherInstallRecordStore.CurrentBakeToolVersion - 1);
Directory.CreateDirectory(_paths.DataDirectory);
await File.WriteAllTextAsync(
store.RecordPath,
JsonSerializer.Serialize(stale, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
Assert.Contains("Bake tool version", verification.Status, StringComparison.Ordinal);
Assert.Equal(0, hashCalls);
}
[Fact]
public async Task NullIntegrityMetadataIsReportedAsInvalidInsteadOfThrowing()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(_paths.DataDirectory);
await File.WriteAllTextAsync(
store.RecordPath,
JsonSerializer.Serialize(new
{
datDirectory = _dats,
preparedAssetPath = store.PreparedAssetPath,
preparedAssetSha256 = (string?)null,
preparedAssetSize = 12,
bakeToolVersion =
LauncherInstallRecordStore.CurrentBakeToolVersion,
version = LauncherInstallRecord.CurrentRecordVersion,
}));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
Assert.Contains("missing", verification.Status, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task StartupRecoversPriorVerifiedPackageAfterInterruptedReplacement()
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, "previous-good");
LauncherInstallRecord record = await CreateRecordAsync(store);
await store.SaveAtomicallyAsync(record);
string backup = LauncherInstallRecordStore.GetBackupPath(
store.PreparedAssetPath);
File.Move(store.PreparedAssetPath, backup);
await File.WriteAllTextAsync(store.PreparedAssetPath, "partial-new");
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.True(verification.IsVerified);
Assert.Equal("previous-good", await File.ReadAllTextAsync(store.PreparedAssetPath));
Assert.False(File.Exists(backup));
}
private async Task<LauncherInstallRecord> CreateRecordAsync(
LauncherInstallRecordStore store)
{
var info = new FileInfo(store.PreparedAssetPath);
return new LauncherInstallRecord(
Path.GetFullPath(_dats),
Path.GetFullPath(store.PreparedAssetPath),
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
info.Length,
LauncherInstallRecordStore.CurrentBakeToolVersion);
}
private static void CreateCompleteDatDirectory(string directory)
{
Directory.CreateDirectory(directory);
foreach (string fileName in DatDirectoryLocator.RequiredFileNames)
{
File.WriteAllText(Path.Combine(directory, fileName), "fixture");
}
}
}

View file

@ -0,0 +1,336 @@
using System.Text.Json.Nodes;
using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Profiles;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Installation;
public sealed class LauncherInstallerTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-installer-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
private readonly string _dats;
private readonly string _bakeExecutable;
public LauncherInstallerTests()
{
_paths = new ApplicationPathSet(
Path.Combine(_root, "config"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
null);
_dats = Path.Combine(_root, "retail-dats");
_bakeExecutable = Path.Combine(_root, "bin", "acdream-bake");
CreateCompleteDatDirectory(_dats);
Directory.CreateDirectory(Path.GetDirectoryName(_bakeExecutable)!);
File.WriteAllText(_bakeExecutable, "fake executable marker");
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task FakeChildProgressPublishesVerifiedRecordAndFeedsExactSessionContent()
{
BakeProcessRequest? observedRequest = null;
var runner = new FakeBakeProcessRunner(async (request, output, _) =>
{
observedRequest = request;
Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!);
await File.WriteAllTextAsync(request.OutputPath, "complete prepared package");
long bytes = new FileInfo(request.OutputPath).Length;
output("acdream-bake human header\n{\"v\":1,\"e\":\"star");
output("ted\",\"bakeToolVersion\":4,\"outputPath\":\"pak\"}\n");
output("{\"v\":1,\"e\":\"progress\",\"phase\":\"mesh\","
+ "\"completed\":25,\"total\":100,\"failures\":0,"
+ "\"elapsedSeconds\":5,\"etaSeconds\":15}\n");
output("{\"v\":1,\"e\":\"newMetric\",\"value\":1}\n");
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4,"
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
return new BakeProcessResult(0, string.Empty);
});
var installer = new LauncherInstaller(
_paths,
_bakeExecutable,
processRunner: runner);
var progress = new List<LauncherInstallProgress>();
LauncherInstallResult result = await installer.InstallAsync(
_dats,
threads: 7,
new ImmediateProgress<LauncherInstallProgress>(progress.Add));
Assert.NotNull(observedRequest);
Assert.Equal(Path.GetFullPath(_bakeExecutable), observedRequest.ExecutablePath);
Assert.Equal(Path.GetFullPath(_dats), observedRequest.DatDirectory);
Assert.Equal(
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"),
observedRequest.OutputPath);
Assert.Equal(
[
"--dat-dir", Path.GetFullPath(_dats),
"--out", Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"),
"--threads", "7",
"--progress-json",
],
observedRequest.Arguments);
Assert.Equal(LauncherInstallRecordStore.CurrentBakeToolVersion,
result.Record.BakeToolVersion);
Assert.Equal(new FileInfo(result.Record.PreparedAssetPath).Length,
result.Record.PreparedAssetSize);
Assert.Equal(
await FileIntegrity.ComputeSha256HexAsync(result.Record.PreparedAssetPath),
result.Record.PreparedAssetSha256);
Assert.Contains(progress, value => value.Phase == LauncherInstallPhase.BakingMeshes);
Assert.Equal(LauncherInstallPhase.Completed, progress[^1].Phase);
var store = new LauncherInstallRecordStore(_paths);
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.True(verification.IsVerified);
Assert.Equal(result.Record, verification.Record);
var server = new ServerProfile
{
Name = "Local ACE",
Host = "127.0.0.1",
Port = 9000,
};
var account = new AccountProfile
{
Account = "testaccount",
Password = "credential-never-serialized",
};
var character = new CharacterProfile
{
Name = "+Acdream",
Id = "0x5000000A",
LaunchMode = LaunchMode.Gui,
};
ComposedSessionConfig composed = SessionConfigComposer.Compose(
server,
account,
character,
result.Record,
_paths,
"installed-session");
JsonObject content = JsonNode.Parse(
SessionConfigComposer.Serialize(composed.Document))!
["process"]!["content"]!.AsObject();
Assert.Equal(result.Record.DatDirectory, (string?)content["datDirectory"]);
Assert.Equal(
result.Record.PreparedAssetPath,
(string?)content["preparedAssetPath"]);
Assert.DoesNotContain(
result.Record.PreparedAssetSha256,
SessionConfigComposer.Serialize(composed.Document),
StringComparison.Ordinal);
}
[Fact]
public async Task FailedChildRestoresPriorVerifiedPakAndRecord()
{
(LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) =
await CreateInstallerWithPriorRecordAsync(
async (request, output, _) =>
{
await File.WriteAllTextAsync(request.OutputPath, "partial replacement");
output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
output("{\"v\":1,\"e\":\"error\",\"message\":\"fixture failed\"}\n");
return new BakeProcessResult(9, "human failure detail");
},
loadExisting: false);
string recordBefore = await File.ReadAllTextAsync(store.RecordPath);
var progress = new List<LauncherInstallProgress>();
LauncherInstallException exception = await Assert.ThrowsAsync<LauncherInstallException>(
() => installer.InstallAsync(
_dats,
2,
new ImmediateProgress<LauncherInstallProgress>(progress.Add)));
Assert.Contains("fixture failed", exception.Message, StringComparison.Ordinal);
Assert.Equal("previous verified package", await File.ReadAllTextAsync(
store.PreparedAssetPath));
Assert.Equal(recordBefore, await File.ReadAllTextAsync(store.RecordPath));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.True(verification.IsVerified);
Assert.Equal(old, verification.Record);
Assert.Equal(LauncherInstallPhase.Failed, progress[^1].Phase);
}
[Fact]
public async Task CancellationRestoresPriorInstallAndNeverPublishesPartialOutput()
{
var enteredChild = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
(LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) =
await CreateInstallerWithPriorRecordAsync(
async (request, _, cancellationToken) =>
{
await File.WriteAllTextAsync(
request.OutputPath,
"partial replacement",
cancellationToken);
enteredChild.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return new BakeProcessResult(0, string.Empty);
});
var progress = new List<LauncherInstallProgress>();
using var cancellation = new CancellationTokenSource();
Task<LauncherInstallResult> operation = installer.InstallAsync(
_dats,
3,
new ImmediateProgress<LauncherInstallProgress>(progress.Add),
cancellation.Token);
await enteredChild.Task.WaitAsync(TimeSpan.FromSeconds(5));
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);
Assert.Equal("previous verified package", await File.ReadAllTextAsync(
store.PreparedAssetPath));
Assert.False(File.Exists(LauncherInstallRecordStore.GetBackupPath(
store.PreparedAssetPath)));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.True(verification.IsVerified);
Assert.Equal(old, verification.Record);
Assert.Equal(LauncherInstallPhase.Cancelled, progress[^1].Phase);
}
[Fact]
public async Task FailedFirstInstallRemovesPartialPakAndCreatesNoRecord()
{
var runner = new FakeBakeProcessRunner(async (request, _, _) =>
{
Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!);
await File.WriteAllTextAsync(request.OutputPath, "partial");
return new BakeProcessResult(1, "failed");
});
var installer = new LauncherInstaller(
_paths,
_bakeExecutable,
processRunner: runner);
var store = new LauncherInstallRecordStore(_paths);
await Assert.ThrowsAsync<LauncherInstallException>(
() => installer.InstallAsync(_dats, 1));
Assert.False(File.Exists(store.PreparedAssetPath));
Assert.False(File.Exists(store.RecordPath));
}
[Fact]
public async Task CancellationDuringHashRemovesUnrecordedPublishedPackage()
{
var hashEntered = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var runner = new FakeBakeProcessRunner(async (request, output, _) =>
{
Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!);
await File.WriteAllTextAsync(request.OutputPath, "complete but unverified");
long bytes = new FileInfo(request.OutputPath).Length;
output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4,"
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
return new BakeProcessResult(0, string.Empty);
});
var store = new LauncherInstallRecordStore(_paths);
var installer = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: store,
processRunner: runner,
computeSha256: async (_, cancellationToken) =>
{
hashEntered.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return new string('a', 64);
});
using var cancellation = new CancellationTokenSource();
Task<LauncherInstallResult> operation = installer.InstallAsync(
_dats,
2,
cancellationToken: cancellation.Token);
await hashEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);
Assert.False(File.Exists(store.PreparedAssetPath));
Assert.False(File.Exists(store.RecordPath));
}
private async Task<(
LauncherInstaller Installer,
LauncherInstallRecordStore Store,
LauncherInstallRecord Old)> CreateInstallerWithPriorRecordAsync(
Func<BakeProcessRequest, Action<string>, CancellationToken, Task<BakeProcessResult>> handler,
bool loadExisting = true)
{
var store = new LauncherInstallRecordStore(_paths);
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(
store.PreparedAssetPath,
"previous verified package");
var old = new LauncherInstallRecord(
Path.GetFullPath(_dats),
Path.GetFullPath(store.PreparedAssetPath),
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
new FileInfo(store.PreparedAssetPath).Length,
LauncherInstallRecordStore.CurrentBakeToolVersion);
await store.SaveAtomicallyAsync(old);
var installer = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: store,
processRunner: new FakeBakeProcessRunner(handler));
if (loadExisting)
{
Assert.True((await installer.LoadExistingAsync()).IsVerified);
}
return (installer, store, old);
}
private static void CreateCompleteDatDirectory(string directory)
{
Directory.CreateDirectory(directory);
foreach (string fileName in DatDirectoryLocator.RequiredFileNames)
{
File.WriteAllText(Path.Combine(directory, fileName), "fixture");
}
}
private sealed class FakeBakeProcessRunner(
Func<BakeProcessRequest, Action<string>, CancellationToken, Task<BakeProcessResult>> handler)
: IBakeProcessRunner
{
private readonly Func<
BakeProcessRequest,
Action<string>,
CancellationToken,
Task<BakeProcessResult>> _handler = handler;
public Task<BakeProcessResult> RunAsync(
BakeProcessRequest request,
Action<string> onStandardOutput,
CancellationToken cancellationToken = default) =>
_handler(request, onStandardOutput, cancellationToken);
}
private sealed class ImmediateProgress<T>(Action<T> callback) : IProgress<T>
{
public void Report(T value) => callback(value);
}
}

View file

@ -110,8 +110,11 @@ public sealed class LauncherProjectBoundaryTests
Assert.Contains("src/AcDream.Launcher/**", workflow, StringComparison.Ordinal);
Assert.Contains("tests/AcDream.Launcher.Tests/**", workflow, StringComparison.Ordinal);
Assert.Contains("src/AcDream.Bake/**", workflow, StringComparison.Ordinal);
Assert.Contains("tests/AcDream.Bake.Tests/**", workflow, StringComparison.Ordinal);
Assert.Contains("portable-launcher:", workflow, StringComparison.Ordinal);
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", workflow, StringComparison.Ordinal);
Assert.Contains("tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj", workflow, StringComparison.Ordinal);
Assert.Contains("-r linux-x64", workflow, StringComparison.Ordinal);
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);

View file

@ -1,6 +1,7 @@
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.ViewModels;
namespace AcDream.Launcher.Tests;
@ -32,7 +33,7 @@ public sealed class LauncherWindowViewModelTests
Assert.True(session.IsActive);
Assert.True(viewModel.IsFirstRunRequired);
Assert.Contains("LA9", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
Assert.Contains("LA10", viewModel.UpdatePromptShell.Body, StringComparison.Ordinal);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
@ -325,6 +326,92 @@ public sealed class LauncherWindowViewModelTests
Assert.False(viewModel.EditorDialog.IsOpen);
}
[Fact]
public async Task FirstRunWizardAutoDetectsValidatesAndPublishesVerifiedInstall()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var installer = new FakeLauncherInstaller();
using var viewModel = CreateInitialized(orchestrator, installer);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.Equal(installer.DetectedDirectory, viewModel.FirstRunWizardShell.DatDirectory);
Assert.True(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
Assert.True(viewModel.FirstRunWizardShell.StartCommand.CanExecute(null));
viewModel.FirstRunWizardShell.SelectDatDirectory("incomplete-manual-path");
Assert.False(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
viewModel.FirstRunWizardShell.SelectDatDirectory(installer.DetectedDirectory);
Assert.True(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
viewModel.FirstRunWizardShell.ThreadsText = "3";
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
Assert.Equal((installer.DetectedDirectory, 3), installer.InstallRequest);
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
Assert.False(viewModel.IsFirstRunRequired);
Assert.Equal(LauncherInstallPhase.Completed, viewModel.FirstRunWizardShell.Phase);
Assert.Equal(100, viewModel.FirstRunWizardShell.ProgressPercent);
Assert.False(viewModel.FirstRunWizardShell.HasError);
}
[Fact]
public async Task FirstRunWizardCancellationAndFailureRemainVisibleAndPublishNothing()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var entered = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var installer = new FakeLauncherInstaller
{
InstallHandler = async (_, _, progress, cancellationToken) =>
{
progress?.Report(new LauncherInstallProgress(
LauncherInstallPhase.BakingMeshes,
"Baking mesh assets.",
1,
10));
entered.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
throw new InvalidOperationException("unreachable");
},
};
using var viewModel = CreateInitialized(orchestrator, installer);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Task install = viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(viewModel.FirstRunWizardShell.CancelCommand.CanExecute(null));
Assert.False(viewModel.FirstRunWizardShell.CanEditInputs);
viewModel.FirstRunWizardShell.CancelCommand.Execute(null);
await install;
Assert.Equal(LauncherInstallPhase.Cancelled, viewModel.FirstRunWizardShell.Phase);
Assert.Null(orchestrator.InstalledRecord);
Assert.False(viewModel.FirstRunWizardShell.HasError);
installer.InstallHandler = (_, _, _, _) =>
Task.FromException<LauncherInstallResult>(
new LauncherInstallException("fixture bake failed"));
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
Assert.Equal(LauncherInstallPhase.Failed, viewModel.FirstRunWizardShell.Phase);
Assert.Contains(
"fixture bake failed",
viewModel.FirstRunWizardShell.Error ?? string.Empty,
StringComparison.Ordinal);
Assert.Null(orchestrator.InstalledRecord);
}
[Fact]
public async Task ActiveSessionStopAndFinishedSessionClearUseCoreOwnership()
{
@ -345,11 +432,13 @@ public sealed class LauncherWindowViewModelTests
}
private static LauncherWindowViewModel CreateInitialized(
FakeLauncherOrchestrator orchestrator)
FakeLauncherOrchestrator orchestrator,
ILauncherInstaller? installer = null)
{
var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher());
new ImmediateUiDispatcher(),
installer);
viewModel.Initialize();
return viewModel;
}
@ -425,14 +514,18 @@ public sealed class LauncherWindowViewModelTests
public string? StoppedSessionId { get; private set; }
public LauncherInstallRecord? InstalledRecord { get; private set; }
public void LoadProfiles() => LoadCalled = true;
public LauncherStateSnapshot GetSnapshot() => new(
[CreateServerSnapshot()],
[Session],
Platform,
IsInstallationReady: false,
InstallationStatus: "No installed client is configured.");
IsInstallationReady: InstalledRecord is not null,
InstallationStatus: InstalledRecord is null
? "No installed client is configured."
: "Client content verified.");
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
Platform.ForLaunchMode(mode);
@ -448,6 +541,8 @@ public sealed class LauncherWindowViewModelTests
public void SetInstallRecord(LauncherInstallRecord? installRecord)
{
InstalledRecord = installRecord;
StateChanged?.Invoke(this, EventArgs.Empty);
}
public void AddServer(string name, string host, int port) =>
@ -589,4 +684,83 @@ public sealed class LauncherWindowViewModelTests
ActivityStatus: "Connected."),
]);
}
private sealed class FakeLauncherInstaller : ILauncherInstaller
{
public string DetectedDirectory { get; } = Path.GetFullPath("retail-dats");
public LauncherInstallRecord Record { get; }
public (string DatDirectory, int Threads)? InstallRequest { get; private set; }
public Func<
string,
int,
IProgress<LauncherInstallProgress>?,
CancellationToken,
Task<LauncherInstallResult>>? InstallHandler { get; set; }
public FakeLauncherInstaller()
{
Record = new LauncherInstallRecord(
DetectedDirectory,
Path.GetFullPath("data/pak/acdream.pak"),
new string('a', 64),
123,
LauncherInstallRecordStore.CurrentBakeToolVersion);
}
public IReadOnlyList<DatDirectoryValidation> DetectDatDirectories() =>
[
ValidateDatDirectory(DetectedDirectory),
];
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
string.Equals(directory, DetectedDirectory, StringComparison.Ordinal)
? new DatDirectoryValidation(
DetectedDirectory,
true,
"All four required retail DAT files were found.",
[])
: new DatDirectoryValidation(
directory ?? string.Empty,
false,
"The DAT directory is incomplete.",
DatDirectoryLocator.RequiredFileNames);
public Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(new InstallRecordVerification(
InstallRecordVerificationState.Missing,
null,
"Client content is not installed."));
public Task<LauncherInstallResult> InstallAsync(
string datDirectory,
int threads,
IProgress<LauncherInstallProgress>? progress = null,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
InstallRequest = (datDirectory, threads);
if (InstallHandler is not null)
{
return InstallHandler(
datDirectory,
threads,
progress,
cancellationToken);
}
progress?.Report(new LauncherInstallProgress(
LauncherInstallPhase.BakingMeshes,
"Baking mesh assets.",
5,
10));
progress?.Report(new LauncherInstallProgress(
LauncherInstallPhase.VerifyingPackage,
"Verifying package."));
return Task.FromResult(new LauncherInstallResult(Record));
}
}
}