diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml
index 801f3476..757827b9 100644
--- a/.github/workflows/headless-portability.yml
+++ b/.github/workflows/headless-portability.yml
@@ -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",
@@ -166,17 +172,34 @@ jobs:
dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- - name: Publish the self-contained Linux launcher
- if: runner.os == 'Linux'
+ - name: Publish the self-contained launcher distribution
shell: pwsh
run: |
+ $rid = if ($IsWindows) { "win-x64" } else { "linux-x64" }
dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj `
-c Release `
- -r linux-x64 `
- -o artifacts/acdream-launcher-linux-x64
+ -r $rid `
+ -o "artifacts/acdream-launcher-$rid"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- - name: Verify self-contained property and artifact execution
+ - name: Verify self-contained Windows launcher and bake artifacts
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: |
+ $root = "artifacts/acdream-launcher-win-x64"
+ if (-not (Test-Path -LiteralPath "$root/acdream-launcher.exe" -PathType Leaf)) { throw "launcher executable missing" }
+ if (-not (Test-Path -LiteralPath "$root/acdream-bake.exe" -PathType Leaf)) { throw "bake executable missing" }
+ if (Test-Path -LiteralPath "$root/acdream-launcher.dll") { throw "launcher is not single-file" }
+ if (Test-Path -LiteralPath "$root/acdream-bake.dll") { throw "bake is not single-file" }
+ $env:DOTNET_ROOT = "Z:\definitely-not-installed"
+ $env:DOTNET_ROOT_X64 = "Z:\definitely-not-installed"
+ $env:DOTNET_MULTILEVEL_LOOKUP = "0"
+ & "$root/acdream-launcher.exe" --verify-publish
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ & "$root/acdream-bake.exe" --help
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ - name: Verify self-contained Linux launcher and bake artifacts
if: runner.os == 'Linux'
shell: bash
run: |
@@ -189,11 +212,17 @@ jobs:
-getProperty:SelfContained | tr -d '\r\n ')
test "$self_contained" = true
test -x "$root/acdream-launcher"
+ test -x "$root/acdream-bake"
test ! -f "$root/acdream-launcher.dll"
+ test ! -f "$root/acdream-bake.dll"
DOTNET_ROOT=/definitely-not-installed \
DOTNET_ROOT_X64=/definitely-not-installed \
DOTNET_MULTILEVEL_LOOKUP=0 \
"$root/acdream-launcher" --verify-publish
+ DOTNET_ROOT=/definitely-not-installed \
+ DOTNET_ROOT_X64=/definitely-not-installed \
+ DOTNET_MULTILEVEL_LOOKUP=0 \
+ "$root/acdream-bake" --help
linux-graphical:
runs-on: ubuntu-latest
diff --git a/AcDream.slnx b/AcDream.slnx
index 50f27be6..6080bb7f 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -27,6 +27,7 @@
+
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 2f0f0f5a..2fcef60c 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -301,6 +301,9 @@ src/
AcDream.Platform/ BCL-only portable path contract (Campaign LA LA0)
ApplicationPathSet.cs -> shared XDG/Windows config, data, cache,
plugin, screenshot, and diagnostic paths
+ BakePublicationGuardPaths.cs
+ -> shared launcher/Bake environment nonce and
+ adjacent publication lock/token naming contract
-> zero project/package references (guarded by
tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs);
Runtime and App reference it directly; Headless reaches it
@@ -313,12 +316,28 @@ 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; one
+ OS-handle lease serializes recovery/install per
+ DataDirectory; a second OS-held publication
+ lock plus durable per-transaction nonce makes
+ late orphan Bake children irrevocably stale
+ before recovery, while already-authorized
+ promotion completes before recovery; only exact
+ adjacent
+ `..acdream-bake..tmp` files are
+ transaction-owned crash residue
-> 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
+ -> every per-RID publish composes the separately published self-contained
+ `acdream-bake` executable beside the launcher without a project edge
-> Linux launcher/probe/headless flows remain portable; graphical-client
actions are explicitly disabled until Modern Runtime Slice L resumes
diff --git a/src/AcDream.Bake/AcDream.Bake.csproj b/src/AcDream.Bake/AcDream.Bake.csproj
index 9b04f741..3557add4 100644
--- a/src/AcDream.Bake/AcDream.Bake.csproj
+++ b/src/AcDream.Bake/AcDream.Bake.csproj
@@ -12,6 +12,7 @@
+
@@ -24,6 +25,7 @@
+
diff --git a/src/AcDream.Bake/BakeCommandLine.cs b/src/AcDream.Bake/BakeCommandLine.cs
new file mode 100644
index 00000000..ffdfb615
--- /dev/null
+++ b/src/AcDream.Bake/BakeCommandLine.cs
@@ -0,0 +1,133 @@
+using System.Globalization;
+
+namespace AcDream.Bake;
+
+internal sealed record BakeCommandLineOptions(
+ string DatDirectory,
+ string OutputPath,
+ HashSet? IdFilter,
+ HashSet? LandblockFilter,
+ int Threads,
+ bool ProgressJson);
+
+internal static class BakeCommandLine
+{
+ internal const string Usage =
+ "usage: acdream-bake --dat-dir [--out ] "
+ + "[--ids 0xId,0xId,...] [--landblocks 0xId,...] "
+ + "[--threads ] [--progress-json]\n"
+ + " acdream-bake --help";
+
+ public static bool IsHelpRequest(IReadOnlyList args)
+ {
+ ArgumentNullException.ThrowIfNull(args);
+ return args.Count == 1
+ && args[0] is "--help" or "-h";
+ }
+
+ public static bool TryParse(
+ IReadOnlyList args,
+ TextWriter error,
+ out BakeCommandLineOptions? options)
+ {
+ ArgumentNullException.ThrowIfNull(args);
+ ArgumentNullException.ThrowIfNull(error);
+
+ string? datDirectory = null;
+ string? outputPath = null;
+ HashSet? idFilter = null;
+ HashSet? 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 args, ref int index) =>
+ index + 1 < args.Count ? args[++index] : null;
+
+ private static HashSet ParseHexList(string? raw, TextWriter error)
+ {
+ var result = new HashSet();
+ 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;
+ }
+}
diff --git a/src/AcDream.Bake/BakeOutputTransaction.cs b/src/AcDream.Bake/BakeOutputTransaction.cs
index 0bc58e16..34f5cf16 100644
--- a/src/AcDream.Bake/BakeOutputTransaction.cs
+++ b/src/AcDream.Bake/BakeOutputTransaction.cs
@@ -9,11 +9,28 @@ namespace AcDream.Bake;
///
public static class BakeOutputTransaction
{
+ internal const string StagingMarker = ".acdream-bake.";
+
public static TResult WriteValidateAndPublish(
string destinationPath,
Func writeTemporary,
Action validateTemporary,
CancellationToken cancellationToken = default)
+ => WriteValidateAndPublish(
+ destinationPath,
+ writeTemporary,
+ validateTemporary,
+ beforePublicationLock: null,
+ beforePromotion: null,
+ cancellationToken);
+
+ internal static TResult WriteValidateAndPublish(
+ string destinationPath,
+ Func writeTemporary,
+ Action validateTemporary,
+ Action? beforePublicationLock,
+ Action? beforePromotion,
+ CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
ArgumentNullException.ThrowIfNull(writeTemporary);
@@ -25,9 +42,7 @@ public static class BakeOutputTransaction
throw new InvalidOperationException("destination has no parent directory");
Directory.CreateDirectory(directory);
- string temporaryPath = Path.Combine(
- directory,
- $".{Path.GetFileName(fullDestination)}.{Guid.NewGuid():N}.tmp");
+ string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid());
try
{
@@ -36,6 +51,14 @@ public static class BakeOutputTransaction
cancellationToken.ThrowIfCancellationRequested();
validateTemporary(temporaryPath, result);
cancellationToken.ThrowIfCancellationRequested();
+ beforePublicationLock?.Invoke();
+ using IDisposable? publication =
+ BakePublicationGuard.AcquireIfRequested(
+ fullDestination,
+ cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ beforePromotion?.Invoke();
+ cancellationToken.ThrowIfCancellationRequested();
// Same-volume MoveFileEx/rename is the publication primitive.
// File.Replace additionally performs destination metadata/backup
@@ -60,4 +83,21 @@ public static class BakeOutputTransaction
}
}
}
+
+ ///
+ /// Exact adjacent staging-name contract shared, by documentation and
+ /// conformance tests, with Launcher.Core. Keeping this tiny contract in
+ /// each BCL-facing assembly avoids an otherwise inverted project edge.
+ ///
+ internal static string CreateStagingPath(string destinationPath, Guid transactionId)
+ {
+ string fullDestination = Path.GetFullPath(destinationPath);
+ string directory = Path.GetDirectoryName(fullDestination)
+ ?? throw new InvalidOperationException(
+ "destination has no parent directory");
+ return Path.Combine(
+ directory,
+ $".{Path.GetFileName(fullDestination)}{StagingMarker}"
+ + $"{transactionId:N}.tmp");
+ }
}
diff --git a/src/AcDream.Bake/BakeProgressJsonWriter.cs b/src/AcDream.Bake/BakeProgressJsonWriter.cs
new file mode 100644
index 00000000..8dd7ccfa
--- /dev/null
+++ b/src/AcDream.Bake/BakeProgressJsonWriter.cs
@@ -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);
+}
+
+///
+/// Version-1 JSON-lines machine channel enabled only by
+/// --progress-json. Ordinary human console lines remain unchanged and
+/// share stdout; consumers identify these records by shape instead of
+/// scraping human prose.
+///
+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 value)
+ {
+ string line = JsonSerializer.Serialize(value);
+ lock (_gate)
+ {
+ _output.WriteLine(line);
+ _output.Flush();
+ }
+ }
+}
diff --git a/src/AcDream.Bake/BakeProgressReporter.cs b/src/AcDream.Bake/BakeProgressReporter.cs
new file mode 100644
index 00000000..954fddac
--- /dev/null
+++ b/src/AcDream.Bake/BakeProgressReporter.cs
@@ -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);
+ }
+}
diff --git a/src/AcDream.Bake/BakePublicationGuard.cs b/src/AcDream.Bake/BakePublicationGuard.cs
new file mode 100644
index 00000000..0b35a811
--- /dev/null
+++ b/src/AcDream.Bake/BakePublicationGuard.cs
@@ -0,0 +1,80 @@
+using AcDream.Platform;
+
+namespace AcDream.Bake;
+
+///
+/// Optional launcher authorization checked immediately before atomic
+/// publication. Standalone Bake runs have no nonce environment variable and
+/// retain the original unguarded behavior.
+///
+internal static class BakePublicationGuard
+{
+ private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
+
+ internal static IDisposable? AcquireIfRequested(
+ string outputPath,
+ CancellationToken cancellationToken)
+ {
+ string? nonce = Environment.GetEnvironmentVariable(
+ BakePublicationGuardPaths.NonceEnvironmentVariable);
+ if (nonce is null)
+ {
+ return null;
+ }
+
+ if (!BakePublicationGuardPaths.IsValidNonce(nonce))
+ {
+ throw new InvalidOperationException(
+ "The launcher bake publication nonce is invalid.");
+ }
+
+ string lockPath = BakePublicationGuardPaths.GetPublishLockPath(
+ outputPath);
+ Directory.CreateDirectory(
+ Path.GetDirectoryName(lockPath)
+ ?? throw new InvalidOperationException(
+ "The bake publication lock has no parent directory."));
+
+ FileStream? lease = null;
+ while (lease is null)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ lease = new FileStream(
+ lockPath,
+ FileMode.OpenOrCreate,
+ FileAccess.ReadWrite,
+ FileShare.None,
+ bufferSize: 1,
+ options: FileOptions.None);
+ }
+ catch (IOException)
+ {
+ cancellationToken.WaitHandle.WaitOne(RetryDelay);
+ }
+ }
+
+ try
+ {
+ string authorizationPath =
+ BakePublicationGuardPaths.GetAuthorizationPath(outputPath);
+ string authorized = File.Exists(authorizationPath)
+ ? File.ReadAllText(authorizationPath)
+ : string.Empty;
+ if (!string.Equals(authorized, nonce, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ "This bake process is no longer authorized to publish its output.");
+ }
+
+ return lease;
+ }
+ catch
+ {
+ lease.Dispose();
+ throw;
+ }
+ }
+
+}
diff --git a/src/AcDream.Bake/BakeRunner.cs b/src/AcDream.Bake/BakeRunner.cs
index 9ea6f1bb..6b60a2ea 100644
--- a/src/AcDream.Bake/BakeRunner.cs
+++ b/src/AcDream.Bake/BakeRunner.cs
@@ -22,6 +22,7 @@ public sealed record BakeOptions
public HashSet? LandblockFilter { get; init; }
public int Threads { get; init; } = System.Environment.ProcessorCount;
public CancellationToken CancellationToken { get; init; }
+ public IBakeProgressSink? Progress { get; init; }
}
/// Compact result used by the full-scale gate and deterministic tests.
@@ -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();
}
diff --git a/src/AcDream.Bake/Program.cs b/src/AcDream.Bake/Program.cs
index 2d7ee8c0..5aba455e 100644
--- a/src/AcDream.Bake/Program.cs
+++ b/src/AcDream.Bake/Program.cs
@@ -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,41 @@ using AcDream.Bake;
//
// Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5.
-string? datDir = null;
-string? outPath = null;
-HashSet? idFilter = null;
-HashSet? 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 (BakeCommandLine.IsHelpRequest(args))
+{
+ Console.Out.WriteLine(BakeCommandLine.Usage);
+ return 0;
}
-if (string.IsNullOrWhiteSpace(datDir)) {
- Console.Error.WriteLine("usage: acdream-bake --dat-dir [--out ] [--ids 0xId,0xId,...] [--landblocks 0xId,...] [--threads ]");
+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,
-});
-
-static HashSet ParseHexList(string? raw) {
- var result = new HashSet();
- 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;
+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,
+ });
+}
+catch (Exception exception)
+{
+ progress?.Error(exception.Message);
+ Console.Error.WriteLine($"error: {exception.Message}");
+ return 1;
}
diff --git a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj
index 85dc49f0..df9c9ad1 100644
--- a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj
+++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj
@@ -14,6 +14,7 @@
+
diff --git a/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs
new file mode 100644
index 00000000..cefa567e
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs
@@ -0,0 +1,72 @@
+namespace AcDream.Launcher.Core.Installation;
+
+///
+/// Exact adjacent temporary-file contract emitted by AcDream.Bake's
+/// BakeOutputTransaction. This class intentionally has no Bake project
+/// dependency: both sides pin the same documented format with conformance
+/// tests so Launcher.Core remains BCL-only.
+///
+internal static class BakeOutputStagingContract
+{
+ internal const string StagingMarker = ".acdream-bake.";
+ private const string Suffix = ".tmp";
+
+ internal static string CreateStagingPath(
+ string destinationPath,
+ Guid transactionId)
+ {
+ string fullDestination = Path.GetFullPath(destinationPath);
+ string directory = Path.GetDirectoryName(fullDestination)
+ ?? throw new InvalidOperationException(
+ "The prepared package path has no parent directory.");
+ return Path.Combine(
+ directory,
+ $".{Path.GetFileName(fullDestination)}{StagingMarker}"
+ + $"{transactionId:N}{Suffix}");
+ }
+
+ internal static bool IsOwnedStagingFileName(
+ string fileName,
+ string destinationFileName)
+ {
+ string prefix = $".{destinationFileName}{StagingMarker}";
+ if (!fileName.StartsWith(prefix, StringComparison.Ordinal)
+ || !fileName.EndsWith(Suffix, StringComparison.Ordinal)
+ || fileName.Length != prefix.Length + 32 + Suffix.Length)
+ {
+ return false;
+ }
+
+ ReadOnlySpan transaction = fileName.AsSpan(prefix.Length, 32);
+ return Guid.TryParseExact(transaction, "N", out _);
+ }
+
+ internal static void DeleteOwnedStagingFiles(string destinationPath)
+ {
+ string fullDestination = Path.GetFullPath(destinationPath);
+ string? directory = Path.GetDirectoryName(fullDestination);
+ if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
+ {
+ return;
+ }
+
+ string destinationFileName = Path.GetFileName(fullDestination);
+ try
+ {
+ foreach (string candidate in Directory.EnumerateFiles(directory))
+ {
+ if (IsOwnedStagingFileName(
+ Path.GetFileName(candidate),
+ destinationFileName))
+ {
+ LauncherInstallRecordStore.TryDelete(candidate);
+ }
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Best effort: these files are never launchable. A later startup
+ // retries exact-name cleanup under the publication lock.
+ }
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs
new file mode 100644
index 00000000..24fbc48c
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs
@@ -0,0 +1,198 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.Text;
+using AcDream.Platform;
+
+namespace AcDream.Launcher.Core.Installation;
+
+/// The exact child-process contract for one launcher bake.
+public sealed record BakeProcessRequest(
+ string ExecutablePath,
+ string DatDirectory,
+ string OutputPath,
+ int Threads,
+ string? PublicationNonce = null)
+{
+ public IReadOnlyList Arguments =>
+ [
+ "--dat-dir",
+ DatDirectory,
+ "--out",
+ OutputPath,
+ "--threads",
+ Threads.ToString(CultureInfo.InvariantCulture),
+ "--progress-json",
+ ];
+}
+
+public sealed record BakeProcessResult(int ExitCode, string StandardError);
+
+///
+/// Injectable child seam. Stdout is delivered as arbitrary chunks so the
+/// versioned JSONL parser, rather than line-oriented process plumbing, owns
+/// partial-record behavior.
+///
+public interface IBakeProcessRunner
+{
+ Task RunAsync(
+ BakeProcessRequest request,
+ Action onStandardOutput,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class SystemBakeProcessRunner : IBakeProcessRunner
+{
+ private const int BufferSize = 4096;
+ private const int MaximumCapturedErrorCharacters = 32 * 1024;
+
+ public async Task RunAsync(
+ BakeProcessRequest request,
+ Action 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();
+
+ ProcessStartInfo startInfo = CreateStartInfo(request);
+
+ 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;
+ }
+ }
+
+ internal static ProcessStartInfo CreateStartInfo(BakeProcessRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ 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);
+ }
+ startInfo.Environment.Remove(
+ BakePublicationGuardPaths.NonceEnvironmentVariable);
+ if (request.PublicationNonce is not null)
+ {
+ if (!BakePublicationGuardPaths.IsValidNonce(
+ request.PublicationNonce))
+ {
+ throw new ArgumentException(
+ "The bake publication nonce is invalid.",
+ nameof(request));
+ }
+
+ startInfo.Environment[
+ BakePublicationGuardPaths.NonceEnvironmentVariable] =
+ request.PublicationNonce;
+ }
+
+ return startInfo;
+ }
+
+ private static async Task PumpAsync(
+ TextReader reader,
+ Action 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)));
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs
new file mode 100644
index 00000000..608c602e
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs
@@ -0,0 +1,54 @@
+namespace AcDream.Launcher.Core.Installation;
+
+///
+/// Versioned machine-readable output from acdream-bake
+/// --progress-json. Human output shares stdout but remains a distinct
+/// event so the installer never derives state by scraping prose.
+///
+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");
diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs
new file mode 100644
index 00000000..cc4cafb5
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs
@@ -0,0 +1,230 @@
+using System.Text;
+using System.Text.Json;
+
+namespace AcDream.Launcher.Core.Installation;
+
+///
+/// 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.
+///
+public sealed class BakeProgressJsonlParser
+{
+ public const int CurrentVersion = 1;
+
+ private readonly StringBuilder _pending = new();
+
+ public IReadOnlyList Append(string chunk)
+ {
+ ArgumentNullException.ThrowIfNull(chunk);
+ _pending.Append(chunk);
+ return Drain(completeFinalLine: false);
+ }
+
+ public IReadOnlyList 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 Drain(bool completeFinalLine)
+ {
+ var events = new List();
+ 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);
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs
new file mode 100644
index 00000000..65d5eb92
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs
@@ -0,0 +1,121 @@
+namespace AcDream.Launcher.Core.Installation;
+
+///
+/// Strict state machine for known v1 bake events. Human output, unknown v1
+/// events, and future versions are deliberately transparent; known v1 events
+/// cannot be reordered, duplicated, or appended after the first terminal.
+///
+internal sealed class BakeProgressProtocol
+{
+ private BakeProgressProtocolState _state;
+
+ internal BakeStartedEvent? Started { get; private set; }
+
+ internal BakeCompletedEvent? Completed { get; private set; }
+
+ internal BakeErrorEvent? Error { get; private set; }
+
+ internal string? Violation { get; private set; }
+
+ internal bool Observe(BakeProgressEvent progressEvent)
+ {
+ ArgumentNullException.ThrowIfNull(progressEvent);
+ if (Violation is not null)
+ {
+ return false;
+ }
+
+ switch (progressEvent)
+ {
+ case BakeHumanOutputEvent:
+ case UnknownBakeProgressEvent:
+ case FutureBakeProgressEvent:
+ return true;
+ case MalformedBakeProgressEvent malformed:
+ Reject($"Malformed bake progress: {malformed.Reason}");
+ return false;
+ case BakeStartedEvent started:
+ if (_state != BakeProgressProtocolState.AwaitingStarted)
+ {
+ Reject(_state == BakeProgressProtocolState.Running
+ ? "The bake protocol emitted more than one v1 started event."
+ : "The bake protocol emitted a known event after its terminal event.");
+ return false;
+ }
+
+ Started = started;
+ _state = BakeProgressProtocolState.Running;
+ return true;
+ case BakeWorkProgressEvent:
+ if (_state != BakeProgressProtocolState.Running)
+ {
+ Reject(KnownEventStateViolation("progress"));
+ return false;
+ }
+
+ return true;
+ case BakeCompletedEvent completed:
+ if (_state != BakeProgressProtocolState.Running)
+ {
+ Reject(KnownEventStateViolation("completed"));
+ return false;
+ }
+
+ Completed = completed;
+ _state = BakeProgressProtocolState.Completed;
+ return true;
+ case BakeErrorEvent error:
+ if (_state != BakeProgressProtocolState.Running)
+ {
+ Reject(KnownEventStateViolation("error"));
+ return false;
+ }
+
+ Error = error;
+ _state = BakeProgressProtocolState.Error;
+ return true;
+ default:
+ Reject("The bake protocol emitted an unsupported known event.");
+ return false;
+ }
+ }
+
+ internal void CompleteInput()
+ {
+ if (Violation is not null)
+ {
+ return;
+ }
+
+ if (_state == BakeProgressProtocolState.AwaitingStarted)
+ {
+ Reject("The bake protocol did not emit a v1 started event first.");
+ }
+ else if (_state == BakeProgressProtocolState.Running)
+ {
+ Reject("The bake protocol ended without exactly one terminal event.");
+ }
+ }
+
+ private string KnownEventStateViolation(string eventName) => _state switch
+ {
+ BakeProgressProtocolState.AwaitingStarted =>
+ $"The bake protocol emitted v1 {eventName} before v1 started.",
+ BakeProgressProtocolState.Running =>
+ $"The bake protocol emitted an invalid v1 {eventName} event.",
+ _ => "The bake protocol emitted a known event after its terminal event.",
+ };
+
+ private void Reject(string message)
+ {
+ Violation ??= message;
+ }
+
+ private enum BakeProgressProtocolState
+ {
+ AwaitingStarted,
+ Running,
+ Completed,
+ Error,
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs b/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs
new file mode 100644
index 00000000..a3f409c6
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs
@@ -0,0 +1,120 @@
+using AcDream.Platform;
+
+namespace AcDream.Launcher.Core.Installation;
+
+///
+/// Launcher half of the environment-only Bake publication guard. Paths are
+/// derived from the canonical output path, while a durable GUID nonce grants
+/// one child permission to promote its already-validated adjacent staging
+/// file. Every token mutation happens while the stable publication lock is
+/// held.
+///
+internal static class BakePublicationGuardContract
+{
+ private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
+
+ internal static async ValueTask AcquireAsync(
+ string outputPath,
+ CancellationToken cancellationToken = default)
+ {
+ string lockPath = BakePublicationGuardPaths.GetPublishLockPath(
+ outputPath);
+ Directory.CreateDirectory(
+ Path.GetDirectoryName(lockPath)
+ ?? throw new InvalidOperationException(
+ "The bake publication lock has no parent directory."));
+
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ return new PublicationLease(new FileStream(
+ lockPath,
+ FileMode.OpenOrCreate,
+ FileAccess.ReadWrite,
+ FileShare.None,
+ bufferSize: 1,
+ options: FileOptions.None));
+ }
+ catch (IOException)
+ {
+ await Task.Delay(RetryDelay, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ }
+
+ internal static void Authorize(
+ string outputPath,
+ string nonce,
+ PublicationLease lease)
+ {
+ ArgumentNullException.ThrowIfNull(lease);
+ if (!BakePublicationGuardPaths.IsValidNonce(nonce))
+ {
+ throw new ArgumentException(
+ "The bake publication nonce must be a lowercase GUID in N format.",
+ nameof(nonce));
+ }
+
+ string authorizationPath =
+ BakePublicationGuardPaths.GetAuthorizationPath(outputPath);
+ using var stream = new FileStream(
+ authorizationPath,
+ FileMode.Create,
+ FileAccess.Write,
+ FileShare.None,
+ bufferSize: 4096,
+ options: FileOptions.WriteThrough);
+ using var writer = new StreamWriter(stream, leaveOpen: true);
+ writer.Write(nonce);
+ writer.Flush();
+ stream.Flush(flushToDisk: true);
+ }
+
+ internal static void Invalidate(
+ string outputPath,
+ PublicationLease lease,
+ string? onlyIfNonceMatches = null)
+ {
+ ArgumentNullException.ThrowIfNull(lease);
+ string authorizationPath =
+ BakePublicationGuardPaths.GetAuthorizationPath(outputPath);
+ if (!File.Exists(authorizationPath))
+ {
+ return;
+ }
+
+ if (onlyIfNonceMatches is not null)
+ {
+ string current = File.ReadAllText(authorizationPath);
+
+ if (!string.Equals(
+ current,
+ onlyIfNonceMatches,
+ StringComparison.Ordinal))
+ {
+ return;
+ }
+ }
+
+ File.Delete(authorizationPath);
+ }
+
+ internal sealed class PublicationLease : IAsyncDisposable
+ {
+ private readonly FileStream _stream;
+
+ internal PublicationLease(FileStream stream)
+ {
+ _stream = stream;
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _stream.Dispose();
+ return ValueTask.CompletedTask;
+ }
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs b/src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs
new file mode 100644
index 00000000..72096a4f
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs
@@ -0,0 +1,138 @@
+namespace AcDream.Launcher.Core.Installation;
+
+///
+/// Portable validation and Windows-only discovery for the four retail data
+/// archives consumed by acdream-bake. 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.
+///
+public sealed class DatDirectoryLocator
+{
+ public static IReadOnlyList RequiredFileNames { get; } =
+ Array.AsReadOnly(
+ [
+ "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 _directoryExists;
+ private readonly Func _fileExists;
+
+ public DatDirectoryLocator(
+ bool? isWindows = null,
+ IEnumerable? windowsCandidates = null,
+ Func? directoryExists = null,
+ Func? 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public IReadOnlyList 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 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 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 missingFileNames) =>
+ new(directory, false, message, missingFileNames);
+}
diff --git a/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs b/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs
new file mode 100644
index 00000000..1fc7ec8e
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs
@@ -0,0 +1,60 @@
+namespace AcDream.Launcher.Core.Installation;
+
+///
+/// Cross-process ownership for every mutation or recovery of one launcher
+/// DataDirectory. The persistent lock pathname is harmless; exclusivity is
+/// owned by the open OS handle and therefore disappears if the process dies.
+///
+internal sealed class InstallerTransactionLease : IAsyncDisposable
+{
+ internal const string LockFileName = ".install.lock";
+ private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
+
+ private readonly FileStream _stream;
+
+ private InstallerTransactionLease(FileStream stream)
+ {
+ _stream = stream;
+ }
+
+ internal static string GetLockPath(string dataDirectory) =>
+ Path.Combine(Path.GetFullPath(dataDirectory), LockFileName);
+
+ internal static async ValueTask AcquireAsync(
+ string dataDirectory,
+ CancellationToken cancellationToken = default)
+ {
+ string lockPath = GetLockPath(dataDirectory);
+ Directory.CreateDirectory(
+ Path.GetDirectoryName(lockPath)
+ ?? throw new InvalidOperationException(
+ "The installer lock path has no parent directory."));
+
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ var stream = new FileStream(
+ lockPath,
+ FileMode.OpenOrCreate,
+ FileAccess.ReadWrite,
+ FileShare.None,
+ bufferSize: 1,
+ FileOptions.None);
+ return new InstallerTransactionLease(stream);
+ }
+ catch (IOException)
+ {
+ await Task.Delay(RetryDelay, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _stream.Dispose();
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs
new file mode 100644
index 00000000..f88dba2f
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs
@@ -0,0 +1,488 @@
+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;
+}
+
+///
+/// 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.
+///
+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> _computeSha256;
+
+ public LauncherInstallRecordStore(
+ ApplicationPathSet paths,
+ DatDirectoryLocator? datDirectories = null,
+ Func>? computeSha256 = null)
+ {
+ _paths = paths ?? throw new ArgumentNullException(nameof(paths));
+ _datDirectories = datDirectories ?? new DatDirectoryLocator();
+ _computeSha256 = computeSha256
+ ?? ((path, cancellationToken) =>
+ FileIntegrity.ComputeSha256HexAsync(path, cancellationToken));
+ }
+
+ public string DataDirectory => Path.GetFullPath(_paths.DataDirectory);
+
+ public string RecordPath => Path.Combine(DataDirectory, "install.json");
+
+ public string PreparedAssetPath => Path.Combine(
+ DataDirectory,
+ "pak",
+ "acdream.pak");
+
+ public static string GetBackupPath(string preparedAssetPath) =>
+ preparedAssetPath + ".previous-install";
+
+ public async Task LoadAndVerifyAsync(
+ CancellationToken cancellationToken = default)
+ {
+ await using InstallerTransactionLease lease =
+ await InstallerTransactionLease.AcquireAsync(
+ DataDirectory,
+ cancellationToken)
+ .ConfigureAwait(false);
+ return await LoadAndVerifyUnderLeaseAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ internal async Task LoadAndVerifyUnderLeaseAsync(
+ 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);
+ using JsonDocument document = await JsonDocument.ParseAsync(
+ stream,
+ cancellationToken: cancellationToken)
+ .ConfigureAwait(false);
+ JsonElement root = document.RootElement;
+ if (root.ValueKind != JsonValueKind.Object
+ || !root.TryGetProperty("version", out JsonElement version)
+ || !version.TryGetInt32(out _))
+ {
+ return Invalid(
+ "The install record must contain an explicit integer version.");
+ }
+
+ record = root.Deserialize(SerializerOptions);
+ }
+ 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,
+ requireCanonicalSerializedPaths: true);
+ 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)
+ {
+ await using InstallerTransactionLease lease =
+ await InstallerTransactionLease.AcquireAsync(
+ DataDirectory,
+ cancellationToken)
+ .ConfigureAwait(false);
+ await SaveAtomicallyUnderLeaseAsync(record, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ internal async Task SaveAtomicallyUnderLeaseAsync(
+ LauncherInstallRecord record,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ LauncherInstallRecord normalized = NormalizeForSave(record);
+ string? contractError = ValidateRecordContract(
+ normalized,
+ requireCanonicalSerializedPaths: true);
+ 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,
+ normalized,
+ 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,
+ bool requireCanonicalSerializedPaths)
+ {
+ 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.";
+ }
+
+ if (string.IsNullOrWhiteSpace(record.PreparedAssetPath))
+ {
+ return "The prepared asset path is missing.";
+ }
+
+ if (string.IsNullOrWhiteSpace(record.DatDirectory))
+ {
+ return "The DAT directory path is missing.";
+ }
+
+ string canonicalPreparedPath = Path.GetFullPath(PreparedAssetPath);
+ if (requireCanonicalSerializedPaths
+ && !Path.IsPathFullyQualified(record.PreparedAssetPath))
+ {
+ return "The prepared asset path must be absolute.";
+ }
+
+ 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.";
+ }
+
+ if (requireCanonicalSerializedPaths
+ && !CanonicalSpellingEquals(
+ record.PreparedAssetPath,
+ recordedPreparedPath,
+ trimEndingSeparator: false))
+ {
+ return "The prepared asset path is not canonical.";
+ }
+
+ if (requireCanonicalSerializedPaths
+ && !Path.IsPathFullyQualified(record.DatDirectory))
+ {
+ return "The DAT directory path must be absolute.";
+ }
+
+ DatDirectoryValidation datValidation =
+ _datDirectories.Validate(record.DatDirectory);
+ if (!datValidation.IsValid)
+ {
+ return datValidation.Message
+ + FormatMissing(datValidation.MissingFileNames);
+ }
+
+ return requireCanonicalSerializedPaths
+ && !CanonicalSpellingEquals(
+ record.DatDirectory,
+ datValidation.Directory,
+ trimEndingSeparator: true)
+ ? "The DAT directory path is not canonical."
+ : null;
+ }
+
+ private LauncherInstallRecord NormalizeForSave(LauncherInstallRecord record)
+ {
+ if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
+ {
+ throw new InvalidDataException(
+ $"Install record version {record.Version} is not supported.");
+ }
+
+ if (string.IsNullOrWhiteSpace(record.PreparedAssetPath))
+ {
+ throw new InvalidDataException("The prepared asset path is missing.");
+ }
+
+ DatDirectoryValidation datValidation =
+ _datDirectories.Validate(record.DatDirectory);
+ if (!datValidation.IsValid)
+ {
+ throw new InvalidDataException(
+ datValidation.Message
+ + FormatMissing(datValidation.MissingFileNames));
+ }
+
+ string recordedPreparedPath;
+ try
+ {
+ recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath);
+ }
+ catch (Exception ex) when (ex is ArgumentException
+ or NotSupportedException
+ or PathTooLongException)
+ {
+ throw new InvalidDataException(
+ $"The prepared asset path is invalid: {ex.Message}",
+ ex);
+ }
+
+ if (!PathsEqual(recordedPreparedPath, PreparedAssetPath))
+ {
+ throw new InvalidDataException(
+ "The install record does not point to the launcher's canonical "
+ + "DataDirectory/pak/acdream.pak path.");
+ }
+
+ return record with
+ {
+ Version = LauncherInstallRecord.CurrentRecordVersion,
+ DatDirectory = datValidation.Directory,
+ PreparedAssetPath = Path.GetFullPath(PreparedAssetPath),
+ };
+ }
+
+ private async Task 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 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);
+
+ private static bool CanonicalSpellingEquals(
+ string serialized,
+ string canonical,
+ bool trimEndingSeparator)
+ {
+ string candidate = trimEndingSeparator
+ ? Path.TrimEndingDirectorySeparator(serialized)
+ : serialized;
+ return string.Equals(
+ candidate,
+ canonical,
+ 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);
+}
diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs
new file mode 100644
index 00000000..f8de24a7
--- /dev/null
+++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs
@@ -0,0 +1,561 @@
+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 DetectDatDirectories();
+
+ DatDirectoryValidation ValidateDatDirectory(string? directory);
+
+ Task LoadExistingAsync(
+ CancellationToken cancellationToken = default);
+
+ Task InstallAsync(
+ string datDirectory,
+ int threads,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// 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.
+///
+public sealed class LauncherInstaller : ILauncherInstaller
+{
+ private readonly string _bakeExecutablePath;
+ private readonly DatDirectoryLocator _datDirectories;
+ private readonly LauncherInstallRecordStore _recordStore;
+ private readonly IBakeProcessRunner _processRunner;
+ private readonly Func> _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>? 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 DetectDatDirectories() =>
+ _datDirectories.Detect();
+
+ public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
+ _datDirectories.Validate(directory);
+
+ public async Task LoadExistingAsync(
+ CancellationToken cancellationToken = default)
+ {
+ await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await using InstallerTransactionLease lease =
+ await InstallerTransactionLease.AcquireAsync(
+ _recordStore.DataDirectory,
+ cancellationToken)
+ .ConfigureAwait(false);
+ InstallRecordVerification verification =
+ await RecoverExistingUnderPublicationGuardAsync(
+ cancellationToken)
+ .ConfigureAwait(false);
+ _verifiedRecord = verification.Record;
+ return verification;
+ }
+ finally
+ {
+ _installGate.Release();
+ }
+ }
+
+ public async Task InstallAsync(
+ string datDirectory,
+ int threads,
+ IProgress? 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
+ {
+ await using InstallerTransactionLease lease =
+ await InstallerTransactionLease.AcquireAsync(
+ _recordStore.DataDirectory,
+ cancellationToken)
+ .ConfigureAwait(false);
+ return await InstallCoreAsync(
+ datDirectory,
+ threads,
+ progress,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ _installGate.Release();
+ }
+ }
+
+ private async Task InstallCoreAsync(
+ string datDirectory,
+ int threads,
+ IProgress? 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);
+ InstallRecordVerification existing =
+ await RecoverExistingUnderPublicationGuardAsync(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();
+ var protocol = new BakeProgressProtocol();
+ string? publicationNonce = null;
+
+ void Observe(BakeProgressEvent progressEvent)
+ {
+ bool accepted = protocol.Observe(progressEvent);
+ switch (progressEvent)
+ {
+ case BakeWorkProgressEvent value when accepted:
+ 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 BakeErrorEvent value when accepted:
+ Report(
+ progress,
+ LauncherInstallPhase.Failed,
+ $"Bake tool error: {value.Message}");
+ break;
+ case MalformedBakeProgressEvent value:
+ 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();
+ publicationNonce = BakePublicationGuardPaths.CreateNonce();
+ await using (
+ BakePublicationGuardContract.PublicationLease publication =
+ await BakePublicationGuardContract.AcquireAsync(
+ outputPath,
+ cancellationToken)
+ .ConfigureAwait(false))
+ {
+ BakePublicationGuardContract.Authorize(
+ outputPath,
+ publicationNonce,
+ publication);
+ }
+
+ var request = new BakeProcessRequest(
+ _bakeExecutablePath,
+ validation.Directory,
+ outputPath,
+ threads,
+ publicationNonce);
+ 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);
+ }
+ protocol.CompleteInput();
+
+ cancellationToken.ThrowIfCancellationRequested();
+ if (protocol.Violation is not null)
+ {
+ throw new LauncherInstallException(protocol.Violation);
+ }
+
+ if (processResult.ExitCode != 0)
+ {
+ throw new LauncherInstallException(
+ BuildChildFailure(
+ processResult.ExitCode,
+ protocol.Error?.Message,
+ processResult.StandardError));
+ }
+
+ if (protocol.Error is not null)
+ {
+ throw new LauncherInstallException(
+ $"The bake tool reported an error: {protocol.Error.Message}");
+ }
+
+ BakeStartedEvent? started = protocol.Started;
+ BakeCompletedEvent? completed = protocol.Completed;
+ if (started is null || completed is null)
+ {
+ throw new LauncherInstallException(
+ "The bake protocol did not finish with a v1 completed event.");
+ }
+
+ 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.SaveAtomicallyUnderLeaseAsync(
+ record,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ _verifiedRecord = record;
+ await FinalizeSuccessfulPublicationAsync(
+ outputPath,
+ backupPath,
+ publicationNonce)
+ .ConfigureAwait(false);
+ Report(
+ progress,
+ LauncherInstallPhase.Completed,
+ "Client content installed and verified.",
+ completed: 1,
+ total: 1);
+ return new LauncherInstallResult(record);
+ }
+ catch (OperationCanceledException)
+ {
+ await FinalizeFailedPublicationAsync(
+ outputPath,
+ backupPath,
+ previousPreserved,
+ publicationNonce)
+ .ConfigureAwait(false);
+ Report(
+ progress,
+ LauncherInstallPhase.Cancelled,
+ "Installation cancelled; no new install record was published.");
+ throw;
+ }
+ catch (Exception ex)
+ {
+ await FinalizeFailedPublicationAsync(
+ outputPath,
+ backupPath,
+ previousPreserved,
+ publicationNonce)
+ .ConfigureAwait(false);
+ Report(
+ progress,
+ LauncherInstallPhase.Failed,
+ $"Installation failed: {ex.Message}");
+ if (ex is LauncherInstallException)
+ {
+ throw;
+ }
+
+ throw new LauncherInstallException("Installation failed.", ex);
+ }
+ }
+
+ private async Task
+ RecoverExistingUnderPublicationGuardAsync(
+ CancellationToken cancellationToken)
+ {
+ string outputPath = _recordStore.PreparedAssetPath;
+ await using BakePublicationGuardContract.PublicationLease publication =
+ await BakePublicationGuardContract.AcquireAsync(
+ outputPath,
+ cancellationToken)
+ .ConfigureAwait(false);
+ // Any child whose parent died before it acquired this lock is now
+ // irrevocably stale. A child already holding the lock must finish its
+ // promotion before recovery reaches this invalidation point.
+ BakePublicationGuardContract.Invalidate(outputPath, publication);
+ BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
+ return await _recordStore.LoadAndVerifyUnderLeaseAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ private static async Task FinalizeSuccessfulPublicationAsync(
+ string outputPath,
+ string backupPath,
+ string publicationNonce)
+ {
+ await using BakePublicationGuardContract.PublicationLease publication =
+ await BakePublicationGuardContract.AcquireAsync(
+ outputPath,
+ CancellationToken.None)
+ .ConfigureAwait(false);
+ BakePublicationGuardContract.Invalidate(
+ outputPath,
+ publication,
+ publicationNonce);
+ LauncherInstallRecordStore.TryDelete(backupPath);
+ BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
+ }
+
+ private static async Task FinalizeFailedPublicationAsync(
+ string outputPath,
+ string backupPath,
+ bool previousPreserved,
+ string? publicationNonce)
+ {
+ await using BakePublicationGuardContract.PublicationLease publication =
+ await BakePublicationGuardContract.AcquireAsync(
+ outputPath,
+ CancellationToken.None)
+ .ConfigureAwait(false);
+ BakePublicationGuardContract.Invalidate(
+ outputPath,
+ publication,
+ publicationNonce);
+ RestorePreviousPackage(outputPath, backupPath, previousPreserved);
+ BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
+ }
+
+ 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? 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 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);
+}
diff --git a/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs
index 327d406a..dab09b90 100644
--- a/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs
+++ b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs
@@ -27,6 +27,11 @@ public static class FileIntegrity
return Convert.ToHexStringLower(hash);
}
+ ///
+ /// Asynchronous, cancellable counterpart used while verifying a multi-
+ /// gigabyte prepared package. The file stays streamed and no buffer is
+ /// retained after the hash completes.
+ ///
public static async Task 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);
diff --git a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs
index 5d8e0c07..c89c279a 100644
--- a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs
+++ b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs
@@ -3,10 +3,24 @@ namespace AcDream.Launcher.Core.Launching;
///
/// The DAT/pak locations a completed install (LA9) records and every
/// session-config composition consumes for
-/// . SHA-256/version bookkeeping
-/// for the install record itself is LA9/LA10 scope; this slice only
-/// needs the two paths a session config requires.
+/// . 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.
///
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;
+}
diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
index 358dc4f9..c9a7a582 100644
--- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
+++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs
@@ -29,6 +29,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
private readonly List _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? sessionIdFactory = null)
+ Func? 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();
diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj
index a4c20260..63fa57ff 100644
--- a/src/AcDream.Launcher/AcDream.Launcher.csproj
+++ b/src/AcDream.Launcher/AcDream.Launcher.csproj
@@ -10,7 +10,8 @@
true
true
true
- true
+ true
+ true
@@ -22,4 +23,20 @@
+
+
+
+
+ <_BakePublishDirectory Condition="$([System.IO.Path]::IsPathRooted('$(PublishDir)'))">$(PublishDir)
+ <_BakePublishDirectory Condition="'$(_BakePublishDirectory)' == ''">$(MSBuildProjectDirectory)\$(PublishDir)
+
+
+
diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs
index 75dd93fe..2723ed1d 100644
--- a/src/AcDream.Launcher/App.axaml.cs
+++ b/src/AcDream.Launcher/App.axaml.cs
@@ -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();
diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml
index f85d1e8a..9af0408c 100644
--- a/src/AcDream.Launcher/MainWindow.axaml
+++ b/src/AcDream.Launcher/MainWindow.axaml
@@ -342,21 +342,88 @@
KeyDown="OnModalKeyDown"
AutomationProperties.Name="First-run setup modal dialog"
IsVisible="{Binding FirstRunWizardShell.IsOpen}">
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AcDream.Launcher/MainWindow.axaml.cs b/src/AcDream.Launcher/MainWindow.axaml.cs
index a8df2d9a..c84e70ce 100644
--- a/src/AcDream.Launcher/MainWindow.axaml.cs
+++ b/src/AcDream.Launcher/MainWindow.axaml.cs
@@ -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 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;
+ }
}
diff --git a/src/AcDream.Launcher/ViewModels/FirstRunInstallerViewModel.cs b/src/AcDream.Launcher/ViewModels/FirstRunInstallerViewModel.cs
new file mode 100644
index 00000000..1cbf8b1b
--- /dev/null
+++ b/src/AcDream.Launcher/ViewModels/FirstRunInstallerViewModel.cs
@@ -0,0 +1,384 @@
+using System.ComponentModel;
+using AcDream.Launcher.Core.Installation;
+using AcDream.Launcher.Core.Launching;
+
+namespace AcDream.Launcher.ViewModels;
+
+///
+/// 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.
+///
+public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
+{
+ private readonly ILauncherInstaller _installer;
+ private readonly IUiDispatcher _dispatcher;
+ private readonly Action _onInstalled;
+ private readonly Func _canOpen;
+ private readonly Func _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 onInstalled,
+ Func? canOpen = null,
+ Func? 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 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(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(Action callback) : IProgress
+ {
+ private readonly Action _callback = callback
+ ?? throw new ArgumentNullException(nameof(callback));
+
+ public void Report(T value) => _callback(value);
+ }
+}
+
+internal sealed class UnavailableLauncherInstaller : ILauncherInstaller
+{
+ public IReadOnlyList DetectDatDirectories() => [];
+
+ public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
+ new(
+ directory ?? string.Empty,
+ false,
+ "The installer service is unavailable in this host.",
+ DatDirectoryLocator.RequiredFileNames);
+
+ public Task LoadExistingAsync(
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new InstallRecordVerification(
+ InstallRecordVerificationState.Missing,
+ null,
+ "The installer service is unavailable in this host."));
+
+ public Task InstallAsync(
+ string datDirectory,
+ int threads,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default) =>
+ Task.FromException(
+ new LauncherInstallException(
+ "The installer service is unavailable in this host."));
+}
diff --git a/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs b/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
index 7c036bfa..879f2245 100644
--- a/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
+++ b/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
@@ -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();
diff --git a/src/AcDream.Platform/BakePublicationGuardPaths.cs b/src/AcDream.Platform/BakePublicationGuardPaths.cs
new file mode 100644
index 00000000..75f46ae3
--- /dev/null
+++ b/src/AcDream.Platform/BakePublicationGuardPaths.cs
@@ -0,0 +1,35 @@
+namespace AcDream.Platform;
+
+///
+/// Portable, versioned naming contract shared by the launcher parent and the
+/// independently published bake child. The durable token grants one child
+/// permission to publish while the adjacent OS-held lock serializes its final
+/// promotion with launcher recovery.
+///
+public static class BakePublicationGuardPaths
+{
+ public const string NonceEnvironmentVariable =
+ "ACDREAM_BAKE_PUBLISH_NONCE_V1";
+ public const string PublishLockSuffix = ".publish.lock";
+ public const string AuthorizationSuffix = ".publish-token";
+
+ public static string CreateNonce() => Guid.NewGuid().ToString("N");
+
+ public static bool IsValidNonce(string? nonce) =>
+ nonce is not null
+ && nonce.Length == 32
+ && Guid.TryParseExact(nonce, "N", out Guid parsed)
+ && string.Equals(parsed.ToString("N"), nonce, StringComparison.Ordinal);
+
+ public static string GetPublishLockPath(string outputPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
+ return Path.GetFullPath(outputPath) + PublishLockSuffix;
+ }
+
+ public static string GetAuthorizationPath(string outputPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
+ return Path.GetFullPath(outputPath) + AuthorizationSuffix;
+ }
+}
diff --git a/tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs b/tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs
index 4d397817..ea872021 100644
--- a/tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs
+++ b/tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs
@@ -23,6 +23,24 @@ public sealed class BakeOutputTransactionTests : IDisposable
}
}
+ [Fact]
+ public void StagingPathUsesTheDocumentedLauncherRecoveryContract()
+ {
+ string destination = Path.Combine(_directory, "pak", "acdream.pak");
+ Guid transaction = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef");
+
+ string staging = BakeOutputTransaction.CreateStagingPath(
+ destination,
+ transaction);
+
+ Assert.Equal(
+ Path.Combine(
+ _directory,
+ "pak",
+ ".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"),
+ staging);
+ }
+
[Fact]
public void Publish_ReplacesExistingDestinationOnlyAfterValidation()
{
diff --git a/tests/AcDream.Bake.Tests/BakeProgressCliTests.cs b/tests/AcDream.Bake.Tests/BakeProgressCliTests.cs
new file mode 100644
index 00000000..5f8e0658
--- /dev/null
+++ b/tests/AcDream.Bake.Tests/BakeProgressCliTests.cs
@@ -0,0 +1,115 @@
+using System.Text.Json;
+using AcDream.Content.Pak;
+
+namespace AcDream.Bake.Tests;
+
+public sealed class BakeProgressCliTests
+{
+ [Theory]
+ [InlineData("--help")]
+ [InlineData("-h")]
+ public void HelpIsAZeroDatArgumentProbe(string argument)
+ {
+ Assert.True(BakeCommandLine.IsHelpRequest([argument]));
+ Assert.False(BakeCommandLine.IsHelpRequest([argument, "extra"]));
+ Assert.Contains("--help", BakeCommandLine.Usage, StringComparison.Ordinal);
+ }
+
+ [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()));
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj
new file mode 100644
index 00000000..b64e0eaa
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj
@@ -0,0 +1,16 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ latest
+ false
+ true
+
+
+
+
+
+
+
diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs
new file mode 100644
index 00000000..c56f94cd
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs
@@ -0,0 +1,190 @@
+using System.Diagnostics;
+using System.Reflection;
+using AcDream.Bake;
+using AcDream.Launcher.Core.Installation;
+using AcDream.Platform;
+
+return args.FirstOrDefault() switch
+{
+ "hold-install-lease" => await HoldInstallLeaseAsync(args[1..]),
+ "orphan-parent" => await RunOrphanParentAsync(args[1..]),
+ "orphan-child" => RunOrphanChild(args[1..]),
+ _ => 2,
+};
+
+static async Task HoldInstallLeaseAsync(string[] arguments)
+{
+ if (arguments.Length != 3)
+ {
+ return 2;
+ }
+
+ string lockPath = Path.GetFullPath(arguments[0]);
+ string stagingPath = Path.GetFullPath(arguments[1]);
+ string readyPath = Path.GetFullPath(arguments[2]);
+ Directory.CreateDirectory(
+ Path.GetDirectoryName(lockPath)
+ ?? throw new InvalidOperationException("lock path has no parent"));
+ Directory.CreateDirectory(
+ Path.GetDirectoryName(stagingPath)
+ ?? throw new InvalidOperationException("staging path has no parent"));
+
+ using var lease = new FileStream(
+ lockPath,
+ FileMode.OpenOrCreate,
+ FileAccess.ReadWrite,
+ FileShare.None);
+ File.WriteAllText(stagingPath, "abandoned bake staging");
+ File.WriteAllText(readyPath, "ready");
+ await Task.Delay(Timeout.InfiniteTimeSpan);
+ return 0;
+}
+
+static async Task RunOrphanParentAsync(string[] arguments)
+{
+ if (arguments.Length != 8)
+ {
+ return 2;
+ }
+
+ string dataDirectory = Path.GetFullPath(arguments[0]);
+ string datDirectory = Path.GetFullPath(arguments[1]);
+ string bakeMarker = Path.GetFullPath(arguments[2]);
+ string schedule = arguments[3];
+ string childReadyPath = Path.GetFullPath(arguments[4]);
+ string childReleasePath = Path.GetFullPath(arguments[5]);
+ string childPidPath = Path.GetFullPath(arguments[6]);
+ string childExitPath = Path.GetFullPath(arguments[7]);
+ var paths = new ApplicationPathSet(
+ Path.Combine(dataDirectory, "fixture-config"),
+ dataDirectory,
+ Path.Combine(dataDirectory, "fixture-cache"),
+ null);
+ var runner = new OrphanBakeProcessRunner(
+ schedule,
+ childReadyPath,
+ childReleasePath,
+ childPidPath,
+ childExitPath);
+ var installer = new LauncherInstaller(
+ paths,
+ bakeMarker,
+ processRunner: runner);
+
+ try
+ {
+ await installer.InstallAsync(datDirectory, 1);
+ return 0;
+ }
+ catch
+ {
+ return 9;
+ }
+}
+
+static int RunOrphanChild(string[] arguments)
+{
+ if (arguments.Length != 5)
+ {
+ return 2;
+ }
+
+ string outputPath = Path.GetFullPath(arguments[0]);
+ string schedule = arguments[1];
+ string readyPath = Path.GetFullPath(arguments[2]);
+ string releasePath = Path.GetFullPath(arguments[3]);
+ string exitPath = Path.GetFullPath(arguments[4]);
+ Action barrier = () =>
+ {
+ File.WriteAllText(readyPath, schedule);
+ while (!File.Exists(releasePath))
+ {
+ Thread.Sleep(10);
+ }
+ };
+
+ int exitCode;
+ try
+ {
+ BakeOutputTransaction.WriteValidateAndPublish(
+ outputPath,
+ temporaryPath =>
+ {
+ File.WriteAllText(temporaryPath, "orphan replacement");
+ return 1;
+ },
+ (temporaryPath, _) =>
+ {
+ if (File.ReadAllText(temporaryPath) != "orphan replacement")
+ {
+ throw new InvalidDataException("staging content changed");
+ }
+ },
+ beforePublicationLock: schedule == "late" ? barrier : null,
+ beforePromotion: schedule == "holds" ? barrier : null,
+ CancellationToken.None);
+ exitCode = 0;
+ }
+ catch (Exception ex)
+ {
+ File.WriteAllText(exitPath + ".error", ex.Message);
+ exitCode = 17;
+ }
+
+ File.WriteAllText(exitPath, exitCode.ToString(
+ System.Globalization.CultureInfo.InvariantCulture));
+ return exitCode;
+}
+
+file sealed class OrphanBakeProcessRunner(
+ string schedule,
+ string childReadyPath,
+ string childReleasePath,
+ string childPidPath,
+ string childExitPath) : IBakeProcessRunner
+{
+ public async Task RunAsync(
+ BakeProcessRequest request,
+ Action onStandardOutput,
+ CancellationToken cancellationToken = default)
+ {
+ string dotnetHost = Environment.ProcessPath
+ ?? throw new InvalidOperationException("dotnet host path is unavailable");
+ string fixtureDll = Assembly.GetExecutingAssembly().Location;
+ var startInfo = new ProcessStartInfo(dotnetHost)
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ startInfo.ArgumentList.Add(fixtureDll);
+ startInfo.ArgumentList.Add("orphan-child");
+ startInfo.ArgumentList.Add(request.OutputPath);
+ startInfo.ArgumentList.Add(schedule);
+ startInfo.ArgumentList.Add(childReadyPath);
+ startInfo.ArgumentList.Add(childReleasePath);
+ startInfo.ArgumentList.Add(childExitPath);
+ startInfo.Environment.Remove(
+ BakePublicationGuardPaths.NonceEnvironmentVariable);
+ startInfo.Environment[
+ BakePublicationGuardPaths.NonceEnvironmentVariable] =
+ request.PublicationNonce
+ ?? throw new InvalidOperationException("publication nonce is missing");
+
+ using Process child = Process.Start(startInfo)
+ ?? throw new InvalidOperationException("orphan child did not start");
+ File.WriteAllText(
+ childPidPath,
+ child.Id.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ await child.WaitForExitAsync(cancellationToken);
+ if (child.ExitCode == 0)
+ {
+ long bytes = new FileInfo(request.OutputPath).Length;
+ onStandardOutput("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
+ onStandardOutput($"{{\"v\":1,\"e\":\"completed\","
+ + $"\"bakeToolVersion\":4,\"outputBytes\":{bytes},"
+ + "\"failures\":0}\n");
+ }
+
+ return new BakeProcessResult(child.ExitCode, "orphan fixture child");
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj
index 6f77e682..78fa3e4f 100644
--- a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj
+++ b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj
@@ -19,5 +19,11 @@
+
+
+ false
+ true
+
diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/BakeProcessRunnerTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProcessRunnerTests.cs
new file mode 100644
index 00000000..35dee17e
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProcessRunnerTests.cs
@@ -0,0 +1,46 @@
+using AcDream.Launcher.Core.Installation;
+using AcDream.Platform;
+
+namespace AcDream.Launcher.Core.Tests.Installation;
+
+public sealed class BakeProcessRunnerTests
+{
+ [Fact]
+ public void PublicationNonceIsEnvironmentOnlyAndVisibleArgumentsStayPinned()
+ {
+ string nonce = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef")
+ .ToString("N");
+ var request = new BakeProcessRequest(
+ "acdream-bake",
+ "retail-dats",
+ "data/pak/acdream.pak",
+ 7,
+ nonce);
+
+ System.Diagnostics.ProcessStartInfo startInfo =
+ SystemBakeProcessRunner.CreateStartInfo(request);
+
+ Assert.Equal(request.Arguments, startInfo.ArgumentList);
+ Assert.DoesNotContain(nonce, startInfo.ArgumentList);
+ Assert.Equal(
+ nonce,
+ startInfo.Environment[
+ BakePublicationGuardPaths.NonceEnvironmentVariable]);
+ }
+
+ [Fact]
+ public void UnguardedRequestExplicitlyRemovesInheritedAuthorization()
+ {
+ var request = new BakeProcessRequest(
+ "acdream-bake",
+ "retail-dats",
+ "data/pak/acdream.pak",
+ 1);
+
+ System.Diagnostics.ProcessStartInfo startInfo =
+ SystemBakeProcessRunner.CreateStartInfo(request);
+
+ Assert.False(startInfo.Environment.ContainsKey(
+ BakePublicationGuardPaths.NonceEnvironmentVariable));
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressJsonlParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressJsonlParserTests.cs
new file mode 100644
index 00000000..33c96033
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressJsonlParserTests.cs
@@ -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(Assert.Single(
+ parser.Append("human startup text\n{\"v\":1,\"e\":\"pro")));
+ IReadOnlyList events = parser.Append(
+ "gress\",\"phase\":\"mesh\",\"completed\":4,\"total\":10,"
+ + "\"failures\":0,\"elapsedSeconds\":5,\"etaSeconds\":7}\n");
+
+ Assert.Single(events);
+ BakeWorkProgressEvent progress =
+ Assert.IsType(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 first = parser.Append(
+ "{\"v\":1,\"e\":\"progress\",\"phase\":\"mesh\"}\n"
+ + "{not-json");
+ Assert.IsType(Assert.Single(first));
+
+ MalformedBakeProgressEvent final = Assert.IsType(
+ Assert.Single(parser.Complete()));
+ Assert.False(string.IsNullOrWhiteSpace(final.Reason));
+ }
+
+ [Fact]
+ public void UnknownKindsAndFutureVersionsRemainTypedAndFutureSafe()
+ {
+ var parser = new BakeProgressJsonlParser();
+ IReadOnlyList 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(events[0]);
+ FutureBakeProgressEvent future =
+ Assert.IsType(events[1]);
+ Assert.Equal(2, future.Version);
+ BakeStartedEvent started = Assert.IsType(events[2]);
+ Assert.Equal(4u, started.BakeToolVersion);
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressProtocolTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressProtocolTests.cs
new file mode 100644
index 00000000..438ced97
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressProtocolTests.cs
@@ -0,0 +1,109 @@
+using AcDream.Launcher.Core.Installation;
+
+namespace AcDream.Launcher.Core.Tests.Installation;
+
+public sealed class BakeProgressProtocolTests
+{
+ [Fact]
+ public void OneStartedProgressAndCompletedSequenceIsAccepted()
+ {
+ var protocol = new BakeProgressProtocol();
+
+ Assert.True(protocol.Observe(new BakeHumanOutputEvent("human")));
+ Assert.True(protocol.Observe(new UnknownBakeProgressEvent(
+ 1,
+ "newMetric",
+ "{}")));
+ Assert.True(protocol.Observe(new FutureBakeProgressEvent(
+ 2,
+ "started",
+ "{}")));
+ Assert.True(protocol.Observe(new BakeStartedEvent(1, 4, "pak")));
+ Assert.True(protocol.Observe(new BakeWorkProgressEvent(
+ 1,
+ "mesh",
+ 1,
+ 2,
+ 0,
+ 1,
+ 1)));
+ Assert.True(protocol.Observe(new BakeCompletedEvent(1, 4, 100, 0)));
+
+ protocol.CompleteInput();
+
+ Assert.Null(protocol.Violation);
+ Assert.NotNull(protocol.Started);
+ Assert.NotNull(protocol.Completed);
+ Assert.Null(protocol.Error);
+ }
+
+ [Theory]
+ [MemberData(nameof(InvalidKnownSequences))]
+ public void OutOfOrderDuplicateAndPostTerminalKnownEventsAreRejected(
+ BakeProgressEvent[] events)
+ {
+ var protocol = new BakeProgressProtocol();
+
+ foreach (BakeProgressEvent progressEvent in events)
+ {
+ protocol.Observe(progressEvent);
+ }
+
+ protocol.CompleteInput();
+
+ Assert.NotNull(protocol.Violation);
+ }
+
+ [Fact]
+ public void ErrorTerminalCannotBeOverwrittenByContradictoryCompletion()
+ {
+ var protocol = new BakeProgressProtocol();
+ var failure = new BakeErrorEvent(1, "first failure");
+
+ Assert.True(protocol.Observe(new BakeStartedEvent(1, 4, null)));
+ Assert.True(protocol.Observe(failure));
+ Assert.False(protocol.Observe(new BakeCompletedEvent(1, 4, 10, 0)));
+ protocol.CompleteInput();
+
+ Assert.Same(failure, protocol.Error);
+ Assert.Null(protocol.Completed);
+ Assert.Contains("after", protocol.Violation, StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static TheoryData InvalidKnownSequences => new()
+ {
+ new BakeProgressEvent[]
+ {
+ new BakeWorkProgressEvent(1, "mesh", 0, 1, 0, 0, 0),
+ },
+ new BakeProgressEvent[]
+ {
+ new BakeCompletedEvent(1, 4, 10, 0),
+ },
+ new BakeProgressEvent[]
+ {
+ new BakeErrorEvent(1, "before start"),
+ },
+ new BakeProgressEvent[]
+ {
+ new BakeStartedEvent(1, 4, null),
+ new BakeStartedEvent(1, 4, null),
+ },
+ new BakeProgressEvent[]
+ {
+ new BakeStartedEvent(1, 4, null),
+ new BakeCompletedEvent(1, 4, 10, 0),
+ new BakeCompletedEvent(1, 4, 10, 0),
+ },
+ new BakeProgressEvent[]
+ {
+ new BakeStartedEvent(1, 4, null),
+ new BakeCompletedEvent(1, 4, 10, 0),
+ new BakeWorkProgressEvent(1, "mesh", 1, 1, 0, 1, 0),
+ },
+ new BakeProgressEvent[]
+ {
+ new BakeStartedEvent(1, 4, null),
+ },
+ };
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/DatDirectoryLocatorTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/DatDirectoryLocatorTests.cs
new file mode 100644
index 00000000..b5991052
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Installation/DatDirectoryLocatorTests.cs
@@ -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 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");
+ }
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallRecordStoreTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallRecordStoreTests.cs
new file mode 100644
index 00000000..793dd519
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallRecordStoreTests.cs
@@ -0,0 +1,309 @@
+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;
+
+[CollectionDefinition(WorkingDirectoryCollection.Name, DisableParallelization = true)]
+public sealed class WorkingDirectoryCollection
+{
+ public const string Name = "Launcher install-record working directory";
+}
+
+[Collection(WorkingDirectoryCollection.Name)]
+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 MissingExplicitVersionIsRejectedBeforeAdmission()
+ {
+ var store = new LauncherInstallRecordStore(_paths);
+ Directory.CreateDirectory(_paths.DataDirectory);
+ await File.WriteAllTextAsync(
+ store.RecordPath,
+ JsonSerializer.Serialize(new
+ {
+ datDirectory = Path.GetFullPath(_dats),
+ preparedAssetPath = Path.GetFullPath(store.PreparedAssetPath),
+ preparedAssetSha256 = new string('a', 64),
+ preparedAssetSize = 12,
+ bakeToolVersion =
+ LauncherInstallRecordStore.CurrentBakeToolVersion,
+ }));
+
+ InstallRecordVerification verification = await store.LoadAndVerifyAsync();
+
+ Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
+ Assert.Contains("explicit", verification.Status, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task SaveNormalizesCanonicalAbsoluteDatAndPreparedPaths()
+ {
+ var store = new LauncherInstallRecordStore(_paths);
+ Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
+ await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
+ var info = new FileInfo(store.PreparedAssetPath);
+ var nonCanonical = new LauncherInstallRecord(
+ Path.Combine(_dats, "..", Path.GetFileName(_dats), "."),
+ Path.Combine(
+ Path.GetDirectoryName(store.PreparedAssetPath)!,
+ "..",
+ "pak",
+ Path.GetFileName(store.PreparedAssetPath)),
+ await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
+ info.Length,
+ LauncherInstallRecordStore.CurrentBakeToolVersion);
+
+ await store.SaveAtomicallyAsync(nonCanonical);
+
+ using JsonDocument document = JsonDocument.Parse(
+ await File.ReadAllTextAsync(store.RecordPath));
+ Assert.Equal(
+ Path.GetFullPath(_dats),
+ document.RootElement.GetProperty("datDirectory").GetString());
+ Assert.Equal(
+ Path.GetFullPath(store.PreparedAssetPath),
+ document.RootElement.GetProperty("preparedAssetPath").GetString());
+ Assert.Equal(
+ LauncherInstallRecord.CurrentRecordVersion,
+ document.RootElement.GetProperty("version").GetInt32());
+ Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
+ }
+
+ [Fact]
+ public async Task RelativeDatRecordCannotChangeMeaningWithWorkingDirectory()
+ {
+ var store = new LauncherInstallRecordStore(_paths);
+ Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
+ await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
+ string alternateWorkingDirectory = Path.Combine(_root, "alternate-cwd");
+ string alternateDats = Path.Combine(alternateWorkingDirectory, "retail-dats");
+ CreateCompleteDatDirectory(alternateDats);
+ var info = new FileInfo(store.PreparedAssetPath);
+ var relative = new LauncherInstallRecord(
+ "retail-dats",
+ Path.GetFullPath(store.PreparedAssetPath),
+ await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
+ info.Length,
+ LauncherInstallRecordStore.CurrentBakeToolVersion);
+ Directory.CreateDirectory(_paths.DataDirectory);
+ await File.WriteAllTextAsync(
+ store.RecordPath,
+ JsonSerializer.Serialize(relative, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ }));
+
+ string originalWorkingDirectory = Environment.CurrentDirectory;
+ try
+ {
+ Environment.CurrentDirectory = alternateWorkingDirectory;
+ InstallRecordVerification verification =
+ await store.LoadAndVerifyAsync();
+
+ Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
+ Assert.Contains("absolute", verification.Status, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ Environment.CurrentDirectory = originalWorkingDirectory;
+ }
+ }
+
+ [Fact]
+ public async Task OlderAbsoluteButNonCanonicalDatDocumentIsRejected()
+ {
+ var store = new LauncherInstallRecordStore(_paths);
+ Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
+ await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
+ var info = new FileInfo(store.PreparedAssetPath);
+ var nonCanonical = new LauncherInstallRecord(
+ Path.Combine(_dats, "..", Path.GetFileName(_dats)),
+ Path.GetFullPath(store.PreparedAssetPath),
+ await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
+ info.Length,
+ LauncherInstallRecordStore.CurrentBakeToolVersion);
+ Directory.CreateDirectory(_paths.DataDirectory);
+ await File.WriteAllTextAsync(
+ store.RecordPath,
+ JsonSerializer.Serialize(nonCanonical, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ }));
+
+ InstallRecordVerification verification = await store.LoadAndVerifyAsync();
+
+ Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
+ Assert.Contains("canonical", 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 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");
+ }
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs
new file mode 100644
index 00000000..4e48b069
--- /dev/null
+++ b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs
@@ -0,0 +1,793 @@
+using System.Diagnostics;
+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();
+
+ LauncherInstallResult result = await installer.InstallAsync(
+ _dats,
+ threads: 7,
+ new ImmediateProgress(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.True(BakePublicationGuardPaths.IsValidNonce(
+ observedRequest.PublicationNonce));
+ Assert.DoesNotContain(
+ observedRequest.PublicationNonce!,
+ 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);
+ Assert.False(File.Exists(
+ BakePublicationGuardPaths.GetAuthorizationPath(
+ result.Record.PreparedAssetPath)));
+
+ 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();
+
+ LauncherInstallException exception = await Assert.ThrowsAsync(
+ () => installer.InstallAsync(
+ _dats,
+ 2,
+ new ImmediateProgress(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 ContradictoryTerminalCannotReplaceFirstFailureOrPriorInstall()
+ {
+ (LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) =
+ await CreateInstallerWithPriorRecordAsync(
+ async (request, output, _) =>
+ {
+ await File.WriteAllTextAsync(request.OutputPath, "contradictory output");
+ long bytes = new FileInfo(request.OutputPath).Length;
+ output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
+ output("{\"v\":1,\"e\":\"error\",\"message\":\"first failure\"}\n");
+ output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4,"
+ + $"\"outputBytes\":{bytes},\"failures\":0}}\n");
+ return new BakeProcessResult(0, string.Empty);
+ });
+
+ LauncherInstallException exception =
+ await Assert.ThrowsAsync(
+ () => installer.InstallAsync(_dats, 2));
+
+ Assert.Contains("after", exception.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(
+ "previous verified package",
+ await File.ReadAllTextAsync(store.PreparedAssetPath));
+ Assert.Equal(old, (await store.LoadAndVerifyAsync()).Record);
+ }
+
+ [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();
+ using var cancellation = new CancellationTokenSource();
+
+ Task operation = installer.InstallAsync(
+ _dats,
+ 3,
+ new ImmediateProgress(progress.Add),
+ cancellation.Token);
+ await enteredChild.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ cancellation.Cancel();
+
+ await Assert.ThrowsAnyAsync(() => 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(
+ () => 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 operation = installer.InstallAsync(
+ _dats,
+ 2,
+ cancellationToken: cancellation.Token);
+ await hashEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ cancellation.Cancel();
+
+ await Assert.ThrowsAnyAsync(() => operation);
+ Assert.False(File.Exists(store.PreparedAssetPath));
+ Assert.False(File.Exists(store.RecordPath));
+ }
+
+ [Fact]
+ public async Task IndependentInstallersSerializeAndWaitingCancellationTouchesNothing()
+ {
+ var storeA = new LauncherInstallRecordStore(_paths);
+ LauncherInstallRecord old = await CreatePriorRecordAsync(storeA);
+ string backupPath = LauncherInstallRecordStore.GetBackupPath(
+ storeA.PreparedAssetPath);
+ var childEntered = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseChild = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var runnerA = new FakeBakeProcessRunner(async (request, _, _) =>
+ {
+ await File.WriteAllTextAsync(request.OutputPath, "installer A in progress");
+ childEntered.SetResult();
+ await releaseChild.Task;
+ return new BakeProcessResult(1, "fixture A failed");
+ });
+ bool runnerBEntered = false;
+ var runnerB = new FakeBakeProcessRunner((_, _, _) =>
+ {
+ runnerBEntered = true;
+ return Task.FromResult(new BakeProcessResult(1, "must not run"));
+ });
+ var installerA = new LauncherInstaller(
+ _paths,
+ _bakeExecutable,
+ recordStore: storeA,
+ processRunner: runnerA);
+ var installerB = new LauncherInstaller(
+ _paths,
+ _bakeExecutable,
+ recordStore: new LauncherInstallRecordStore(_paths),
+ processRunner: runnerB);
+
+ Task operationA =
+ installerA.InstallAsync(_dats, 1);
+ await childEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ try
+ {
+ using var cancellationB = new CancellationTokenSource();
+ Task operationB = installerB.InstallAsync(
+ _dats,
+ 1,
+ cancellationToken: cancellationB.Token);
+ await Task.Delay(150);
+ cancellationB.Cancel();
+
+ await Assert.ThrowsAnyAsync(() => operationB);
+ Assert.False(runnerBEntered);
+ Assert.Equal(
+ "installer A in progress",
+ await File.ReadAllTextAsync(storeA.PreparedAssetPath));
+ Assert.Equal(
+ "previous verified package",
+ await File.ReadAllTextAsync(backupPath));
+ }
+ finally
+ {
+ releaseChild.TrySetResult();
+ }
+
+ await Assert.ThrowsAsync(() => operationA);
+ Assert.Equal(
+ "previous verified package",
+ await File.ReadAllTextAsync(storeA.PreparedAssetPath));
+ Assert.False(File.Exists(backupPath));
+ Assert.Equal(old, (await storeA.LoadAndVerifyAsync()).Record);
+ }
+
+ [Fact]
+ public void StagingCleanupDeletesOnlyExactBakeTransactionNames()
+ {
+ var store = new LauncherInstallRecordStore(_paths);
+ string outputPath = store.PreparedAssetPath;
+ string directory = Path.GetDirectoryName(outputPath)!;
+ Directory.CreateDirectory(directory);
+ string owned = BakeOutputStagingContract.CreateStagingPath(
+ outputPath,
+ Guid.Parse("01234567-89ab-cdef-0123-456789abcdef"));
+ string canonical = outputPath;
+ string backup = LauncherInstallRecordStore.GetBackupPath(outputPath);
+ string oldPattern = Path.Combine(
+ directory,
+ $".{Path.GetFileName(outputPath)}.{Guid.NewGuid():N}.tmp");
+ string invalidTransaction = Path.Combine(
+ directory,
+ $".{Path.GetFileName(outputPath)}.acdream-bake.not-a-guid.tmp");
+ string unrelated = Path.Combine(directory, "unrelated.tmp");
+ Assert.Equal(
+ Path.Combine(
+ directory,
+ ".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"),
+ owned);
+ foreach (string path in new[]
+ {
+ owned,
+ canonical,
+ backup,
+ oldPattern,
+ invalidTransaction,
+ unrelated,
+ })
+ {
+ File.WriteAllText(path, Path.GetFileName(path));
+ }
+
+ BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
+
+ Assert.False(File.Exists(owned));
+ Assert.True(File.Exists(canonical));
+ Assert.True(File.Exists(backup));
+ Assert.True(File.Exists(oldPattern));
+ Assert.True(File.Exists(invalidTransaction));
+ Assert.True(File.Exists(unrelated));
+ }
+
+ [Fact]
+ public async Task KilledProcessReleasesLeaseAndRestartReclaimsOnlyBakeStaging()
+ {
+ var store = new LauncherInstallRecordStore(_paths);
+ LauncherInstallRecord old = await CreatePriorRecordAsync(store);
+ string staging = BakeOutputStagingContract.CreateStagingPath(
+ store.PreparedAssetPath,
+ Guid.Parse("fedcba98-7654-3210-fedc-ba9876543210"));
+ string ready = Path.Combine(_root, "fixture-ready");
+ string fixtureDll = GetInstallLeaseFixturePath();
+ Assert.True(File.Exists(fixtureDll), $"Missing fixture: {fixtureDll}");
+
+ var startInfo = new ProcessStartInfo("dotnet")
+ {
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ };
+ startInfo.ArgumentList.Add(fixtureDll);
+ startInfo.ArgumentList.Add("hold-install-lease");
+ startInfo.ArgumentList.Add(
+ InstallerTransactionLease.GetLockPath(store.DataDirectory));
+ startInfo.ArgumentList.Add(staging);
+ startInfo.ArgumentList.Add(ready);
+ using Process helper = Process.Start(startInfo)
+ ?? throw new InvalidOperationException("Could not start lease fixture.");
+ try
+ {
+ await WaitForFileAsync(ready, helper, TimeSpan.FromSeconds(10));
+ Assert.True(File.Exists(staging));
+
+ var blockedInstaller = new LauncherInstaller(
+ _paths,
+ _bakeExecutable,
+ recordStore: new LauncherInstallRecordStore(_paths));
+ using var blockedCancellation = new CancellationTokenSource(
+ TimeSpan.FromMilliseconds(200));
+ await Assert.ThrowsAnyAsync(
+ () => blockedInstaller.LoadExistingAsync(blockedCancellation.Token));
+ Assert.True(File.Exists(staging));
+ }
+ finally
+ {
+ if (!helper.HasExited)
+ {
+ helper.Kill(entireProcessTree: true);
+ }
+
+ await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
+ }
+
+ var restarted = new LauncherInstaller(
+ _paths,
+ _bakeExecutable,
+ recordStore: new LauncherInstallRecordStore(_paths));
+ InstallRecordVerification recovered = await restarted.LoadExistingAsync();
+
+ Assert.True(recovered.IsVerified);
+ Assert.Equal(old, recovered.Record);
+ Assert.False(File.Exists(staging));
+ Assert.Equal(
+ "previous verified package",
+ await File.ReadAllTextAsync(store.PreparedAssetPath));
+ }
+
+ [Theory]
+ [InlineData("holds", 0)]
+ [InlineData("late", 17)]
+ public async Task OrphanBakeCanNeverPublishAfterRestartRecovery(
+ string schedule,
+ int expectedChildExitCode)
+ {
+ var store = new LauncherInstallRecordStore(_paths);
+ LauncherInstallRecord old = await CreatePriorRecordAsync(store);
+ string recordBefore = await File.ReadAllTextAsync(store.RecordPath);
+ string control = Path.Combine(_root, "orphan-" + schedule);
+ Directory.CreateDirectory(control);
+ string ready = Path.Combine(control, "child-ready");
+ string release = Path.Combine(control, "child-release");
+ string childPid = Path.Combine(control, "child-pid");
+ string childExit = Path.Combine(control, "child-exit");
+ string fixtureDll = GetInstallLeaseFixturePath();
+ var startInfo = new ProcessStartInfo("dotnet")
+ {
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ };
+ foreach (string argument in new[]
+ {
+ fixtureDll,
+ "orphan-parent",
+ store.DataDirectory,
+ _dats,
+ _bakeExecutable,
+ schedule,
+ ready,
+ release,
+ childPid,
+ childExit,
+ })
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ using Process parent = Process.Start(startInfo)
+ ?? throw new InvalidOperationException("Could not start orphan parent.");
+ int orphanPid = 0;
+ try
+ {
+ await WaitForFileAsync(ready, parent, TimeSpan.FromSeconds(15));
+ orphanPid = int.Parse(
+ await File.ReadAllTextAsync(childPid),
+ System.Globalization.CultureInfo.InvariantCulture);
+ Assert.True(File.Exists(
+ LauncherInstallRecordStore.GetBackupPath(
+ store.PreparedAssetPath)));
+ Assert.True(File.Exists(
+ BakePublicationGuardPaths.GetAuthorizationPath(
+ store.PreparedAssetPath)));
+
+ parent.Kill(entireProcessTree: false);
+ await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
+
+ var restarted = new LauncherInstaller(
+ _paths,
+ _bakeExecutable,
+ recordStore: new LauncherInstallRecordStore(_paths));
+ Task recovery =
+ restarted.LoadExistingAsync();
+ InstallRecordVerification recovered;
+ if (schedule == "holds")
+ {
+ await Task.Delay(200);
+ Assert.False(recovery.IsCompleted);
+ File.WriteAllText(release, "release");
+ recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
+ }
+ else
+ {
+ recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
+ Assert.False(File.Exists(
+ BakePublicationGuardPaths.GetAuthorizationPath(
+ store.PreparedAssetPath)));
+ File.WriteAllText(release, "release");
+ }
+
+ Assert.True(recovered.IsVerified);
+ Assert.Equal(old, recovered.Record);
+ string canonicalAfterRecovery =
+ await File.ReadAllTextAsync(store.PreparedAssetPath);
+ string recordAfterRecovery =
+ await File.ReadAllTextAsync(store.RecordPath);
+ bool backupAfterRecovery = File.Exists(
+ LauncherInstallRecordStore.GetBackupPath(
+ store.PreparedAssetPath));
+
+ await WaitForFileAsync(childExit, TimeSpan.FromSeconds(15));
+ Assert.Equal(
+ expectedChildExitCode,
+ int.Parse(
+ await File.ReadAllTextAsync(childExit),
+ System.Globalization.CultureInfo.InvariantCulture));
+ if (schedule == "late")
+ {
+ Assert.Contains(
+ "no longer authorized",
+ await File.ReadAllTextAsync(childExit + ".error"),
+ StringComparison.OrdinalIgnoreCase);
+ }
+ await Task.Delay(200);
+
+ Assert.Equal(
+ canonicalAfterRecovery,
+ await File.ReadAllTextAsync(store.PreparedAssetPath));
+ Assert.Equal("previous verified package", canonicalAfterRecovery);
+ Assert.Equal(recordBefore, recordAfterRecovery);
+ Assert.Equal(recordAfterRecovery, await File.ReadAllTextAsync(store.RecordPath));
+ Assert.Equal(
+ backupAfterRecovery,
+ File.Exists(LauncherInstallRecordStore.GetBackupPath(
+ store.PreparedAssetPath)));
+ Assert.False(backupAfterRecovery);
+ Assert.False(File.Exists(
+ BakePublicationGuardPaths.GetAuthorizationPath(
+ store.PreparedAssetPath)));
+ }
+ finally
+ {
+ File.WriteAllText(release, "release");
+ if (!parent.HasExited)
+ {
+ parent.Kill(entireProcessTree: false);
+ await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
+ }
+
+ if (orphanPid != 0 && !File.Exists(childExit))
+ {
+ TryKill(orphanPid);
+ }
+ }
+ }
+
+ private async Task<(
+ LauncherInstaller Installer,
+ LauncherInstallRecordStore Store,
+ LauncherInstallRecord Old)> CreateInstallerWithPriorRecordAsync(
+ Func, CancellationToken, Task> 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 async Task CreatePriorRecordAsync(
+ LauncherInstallRecordStore store)
+ {
+ 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);
+ return old;
+ }
+
+ private static async Task WaitForFileAsync(
+ string path,
+ Process process,
+ TimeSpan timeout)
+ {
+ using var cancellation = new CancellationTokenSource(timeout);
+ while (!File.Exists(path))
+ {
+ if (process.HasExited)
+ {
+ throw new InvalidOperationException(
+ $"Lease fixture exited with {process.ExitCode}: "
+ + await process.StandardError.ReadToEndAsync());
+ }
+
+ await Task.Delay(25, cancellation.Token);
+ }
+ }
+
+ private static async Task WaitForFileAsync(string path, TimeSpan timeout)
+ {
+ using var cancellation = new CancellationTokenSource(timeout);
+ while (!File.Exists(path))
+ {
+ await Task.Delay(25, cancellation.Token);
+ }
+ }
+
+ private static void TryKill(int processId)
+ {
+ try
+ {
+ using Process process = Process.GetProcessById(processId);
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ process.WaitForExit(5_000);
+ }
+ }
+ catch
+ {
+ // The orphan normally exits by itself; cleanup tolerates the
+ // expected race with Process.GetProcessById.
+ }
+ }
+
+ private static string GetInstallLeaseFixturePath()
+ {
+ string root = FindRepositoryRoot();
+ string configuration = new DirectoryInfo(AppContext.BaseDirectory)
+ .Parent?.Name
+ ?? "Release";
+ return Path.Combine(
+ root,
+ "tests",
+ "AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder",
+ "bin",
+ configuration,
+ "net10.0",
+ "AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.dll");
+ }
+
+ private static string FindRepositoryRoot()
+ {
+ foreach (string start in new[]
+ {
+ AppContext.BaseDirectory,
+ Environment.CurrentDirectory,
+ })
+ {
+ for (var directory = new DirectoryInfo(start);
+ directory is not null;
+ directory = directory.Parent)
+ {
+ if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
+ {
+ return directory.FullName;
+ }
+ }
+ }
+
+ throw new DirectoryNotFoundException("Could not locate repository root.");
+ }
+
+ 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, CancellationToken, Task> handler)
+ : IBakeProcessRunner
+ {
+ private readonly Func<
+ BakeProcessRequest,
+ Action,
+ CancellationToken,
+ Task> _handler = handler;
+
+ public Task RunAsync(
+ BakeProcessRequest request,
+ Action onStandardOutput,
+ CancellationToken cancellationToken = default) =>
+ _handler(request, onStandardOutput, cancellationToken);
+ }
+
+ private sealed class ImmediateProgress(Action callback) : IProgress
+ {
+ public void Report(T value) => callback(value);
+ }
+}
diff --git a/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs b/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs
index b2dc593a..5a5379a9 100644
--- a/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs
+++ b/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs
@@ -71,6 +71,25 @@ public sealed class LauncherProjectBoundaryTests
Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile"));
}
+ [Fact]
+ public void RidPublishComposesBakeWithoutAProjectReference()
+ {
+ string project = File.ReadAllText(Path.Combine(
+ FindRepositoryRoot(),
+ "src",
+ "AcDream.Launcher",
+ "AcDream.Launcher.csproj"));
+
+ Assert.DoesNotContain(
+ "ProjectReference Include=\"..\\AcDream.Bake",
+ project,
+ StringComparison.Ordinal);
+ Assert.Contains("PublishCoDeployedBakeTool", project, StringComparison.Ordinal);
+ Assert.Contains("..\\AcDream.Bake\\AcDream.Bake.csproj", project, StringComparison.Ordinal);
+ Assert.Contains("SelfContained=true", project, StringComparison.Ordinal);
+ Assert.Contains("PublishSingleFile=true", project, StringComparison.Ordinal);
+ }
+
[Fact]
public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards()
{
@@ -110,12 +129,18 @@ 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);
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
+ Assert.Contains("acdream-bake.exe\" --help", workflow, StringComparison.Ordinal);
+ Assert.Contains("\"$root/acdream-bake\" --help", workflow, StringComparison.Ordinal);
+ Assert.Contains("test -x \"$root/acdream-bake\"", workflow, StringComparison.Ordinal);
Assert.Contains(
"test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless",
workflow,
diff --git a/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs b/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
index 93c5d69f..0f0048d9 100644
--- a/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
+++ b/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
@@ -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(
+ 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?,
+ CancellationToken,
+ Task>? InstallHandler { get; set; }
+
+ public FakeLauncherInstaller()
+ {
+ Record = new LauncherInstallRecord(
+ DetectedDirectory,
+ Path.GetFullPath("data/pak/acdream.pak"),
+ new string('a', 64),
+ 123,
+ LauncherInstallRecordStore.CurrentBakeToolVersion);
+ }
+
+ public IReadOnlyList 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 LoadExistingAsync(
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new InstallRecordVerification(
+ InstallRecordVerificationState.Missing,
+ null,
+ "Client content is not installed."));
+
+ public Task InstallAsync(
+ string datDirectory,
+ int threads,
+ IProgress? 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));
+ }
+ }
}