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