diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index d3d033c..bc954a3 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -21,7 +21,7 @@ $dependencySources = [IO.Path]::GetFullPath((Join-Path $repoRoot $DependencySour $workRoot = Join-Path $PSScriptRoot 'artifacts\installer-build' $publish = Join-Path $workRoot 'publish' $payloadRoot = Join-Path $workRoot 'payload' -$setupProject = Join-Path $PSScriptRoot 'WiiCompiled.Setup\WiiCompiled.Setup.csproj' +$setupProject = Join-Path $PSScriptRoot 'WiiCompiled.Setup.Windows\WiiCompiled.Setup.Windows.csproj' $translatorProject = Join-Path $repoRoot 'translator\src\Translator.Cli\Translator.Cli.csproj' $projectFile = Join-Path $repoRoot 'projects\mkwii\recomp.yml' @@ -163,11 +163,11 @@ $translator = Join-Path $publish 'translator\Translator.Cli.exe' Assert-File $setupHost 'Published setup host' Assert-File $translator 'Self-contained translator' -# Resolved via the shared WiiCompiled.NodTool.Cli helper (also used by build-appimage.sh on Linux) -# rather than a separate download/version-pin copy here: it downloads and caches the same way +# Resolved via the shared WiiCompiled.Setup.Common.Cli helper (also used by build-appimage.sh on +# Linux) rather than a separate download/version-pin copy here: it downloads and caches the same way # NodToolProvider.cs always does (Launcher/artifacts/nodtool.exe), replacing the old manual # -DolphinToolPath handoff with an automated, pinned acquisition step. -$nodToolCliProject = Join-Path $PSScriptRoot 'WiiCompiled.NodTool.Cli' +$nodToolCliProject = Join-Path $PSScriptRoot 'WiiCompiled.Setup.Common.Cli' $nodTool = (& dotnet run --project $nodToolCliProject -c Release -- --workspace $repoRoot | Select-Object -Last 1) if ($LASTEXITCODE -ne 0) { throw "nodtool resolution failed with exit code $LASTEXITCODE." } Assert-File $nodTool 'Resolved nodtool' diff --git a/Launcher/Test-PinnedFacts.ps1 b/Launcher/Test-PinnedFacts.ps1 index 4d97c9c..aa16cc8 100644 --- a/Launcher/Test-PinnedFacts.ps1 +++ b/Launcher/Test-PinnedFacts.ps1 @@ -15,7 +15,7 @@ if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { } $repoRoot = [IO.Path]::GetFullPath($RepositoryRoot) $launcher = Join-Path $repoRoot 'Launcher' -$setup = Join-Path $launcher 'WiiCompiled.Setup' +$setup = Join-Path $launcher 'WiiCompiled.Setup.Windows' $failures = [Collections.Generic.List[string]]::new() function Add-Failure([string]$Message) { $failures.Add($Message) } diff --git a/Launcher/WiiCompiled.NodTool.Cli/Program.cs b/Launcher/WiiCompiled.Setup.Common.Cli/Program.cs similarity index 79% rename from Launcher/WiiCompiled.NodTool.Cli/Program.cs rename to Launcher/WiiCompiled.Setup.Common.Cli/Program.cs index e5315d1..8fa8bd9 100644 --- a/Launcher/WiiCompiled.NodTool.Cli/Program.cs +++ b/Launcher/WiiCompiled.Setup.Common.Cli/Program.cs @@ -1,11 +1,11 @@ -using WiiCompiled.NodTool; +using WiiCompiled.Setup.Common; // A packaging-time-only helper - never shipped, never run by an end user. Both // Launcher/build-appimage.sh and Launcher/Build-Installer.ps1 invoke this to obtain the nodtool // binary they bundle, so there is exactly one place (NodToolProvider) that knows the pinned // version/URL/platform-asset mapping, instead of a separate copy per packaging script. // -// Usage: WiiCompiled.NodTool.Cli --workspace +// Usage: WiiCompiled.Setup.Common.Cli --workspace // Prints the resolved nodtool path to stdout. string? workspace = null; @@ -19,7 +19,7 @@ for (var i = 0; i < args.Length; i++) if (workspace is null) { - Console.Error.WriteLine("Usage: WiiCompiled.NodTool.Cli --workspace "); + Console.Error.WriteLine("Usage: WiiCompiled.Setup.Common.Cli --workspace "); return 1; } diff --git a/Launcher/WiiCompiled.NodTool.Cli/WiiCompiled.NodTool.Cli.csproj b/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj similarity index 73% rename from Launcher/WiiCompiled.NodTool.Cli/WiiCompiled.NodTool.Cli.csproj rename to Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj index 0f7e534..b22d680 100644 --- a/Launcher/WiiCompiled.NodTool.Cli/WiiCompiled.NodTool.Cli.csproj +++ b/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj @@ -4,8 +4,8 @@ net8.0 enable enable - WiiCompiled.NodTool.Cli - WiiCompiled.NodTool.Cli + WiiCompiled.Setup.Common.Cli + WiiCompiled.Setup.Common.Cli 0.2.22 patchzy WiiCompiled @@ -14,6 +14,6 @@ en - + diff --git a/Launcher/WiiCompiled.Setup/FileSystemUtilities.cs b/Launcher/WiiCompiled.Setup.Common/FileSystemUtilities.cs similarity index 98% rename from Launcher/WiiCompiled.Setup/FileSystemUtilities.cs rename to Launcher/WiiCompiled.Setup.Common/FileSystemUtilities.cs index 1b662f9..cf3d530 100644 --- a/Launcher/WiiCompiled.Setup/FileSystemUtilities.cs +++ b/Launcher/WiiCompiled.Setup.Common/FileSystemUtilities.cs @@ -1,14 +1,14 @@ -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Common; /// /// One entry of an exact regular directory tree. Directory topology is part of the content /// contract everywhere this walker is used: an empty directory can be a runtime-visible asset just /// as a regular file can be, so it must not disappear from a content identity or a staged copy. /// -internal sealed record RegularTreeEntry(string RelativePath, string FullPath, bool IsDirectory, +public sealed record RegularTreeEntry(string RelativePath, string FullPath, bool IsDirectory, bool IsEmptyDirectory, long Length); -internal static class FileSystemUtilities +public static class FileSystemUtilities { public static void CopyDirectory(string source, string destination, CancellationToken cancellationToken = default) diff --git a/Launcher/WiiCompiled.Setup.Linux/JsonState.cs b/Launcher/WiiCompiled.Setup.Common/JsonState.cs similarity index 84% rename from Launcher/WiiCompiled.Setup.Linux/JsonState.cs rename to Launcher/WiiCompiled.Setup.Common/JsonState.cs index c068d91..ce719b6 100644 --- a/Launcher/WiiCompiled.Setup.Linux/JsonState.cs +++ b/Launcher/WiiCompiled.Setup.Common/JsonState.cs @@ -1,9 +1,9 @@ using System.Text.Json; -namespace WiiCompiled.Setup.Linux; +namespace WiiCompiled.Setup.Common; -/// Reads and atomically writes the small JSON state documents this tool keeps. -internal static class JsonState +/// Reads and atomically writes the small JSON state documents each installer keeps. +public static class JsonState { private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true }; private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; @@ -17,7 +17,7 @@ internal static class JsonState catch { // A truncated or hand-edited state document must degrade into "unknown", which every - // caller already treats as "assume stale and re-run install", not into a crash. + // caller already treats as "assume stale and rebuild", not into a crash. return null; } } diff --git a/Launcher/WiiCompiled.NodTool/NodToolInfoParser.cs b/Launcher/WiiCompiled.Setup.Common/NodToolInfoParser.cs similarity index 97% rename from Launcher/WiiCompiled.NodTool/NodToolInfoParser.cs rename to Launcher/WiiCompiled.Setup.Common/NodToolInfoParser.cs index 0c8a8cd..fb77aab 100644 --- a/Launcher/WiiCompiled.NodTool/NodToolInfoParser.cs +++ b/Launcher/WiiCompiled.Setup.Common/NodToolInfoParser.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace WiiCompiled.NodTool; +namespace WiiCompiled.Setup.Common; /// Disc metadata parsed from `nodtool info`'s stdout. public sealed record NodToolDiscInfo(string GameId, string Title, int Revision); diff --git a/Launcher/WiiCompiled.NodTool/NodToolProvider.cs b/Launcher/WiiCompiled.Setup.Common/NodToolProvider.cs similarity index 95% rename from Launcher/WiiCompiled.NodTool/NodToolProvider.cs rename to Launcher/WiiCompiled.Setup.Common/NodToolProvider.cs index 2ab6f93..f587695 100644 --- a/Launcher/WiiCompiled.NodTool/NodToolProvider.cs +++ b/Launcher/WiiCompiled.Setup.Common/NodToolProvider.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -namespace WiiCompiled.NodTool; +namespace WiiCompiled.Setup.Common; /// /// Resolves the `nodtool` binary both installers use for Wii disc validation/extraction (see @@ -9,7 +9,7 @@ namespace WiiCompiled.NodTool; /// from encounter/nod and caches it at Launcher/artifacts/nodtool[.exe]. /// /// Shared by: WiiCompiled.Setup.Linux/DiscTool.cs (falls back to this at end-user install time on -/// a plain git checkout), and WiiCompiled.NodTool.Cli (invoked once at packaging time by both +/// a plain git checkout), and WiiCompiled.Setup.Common.Cli (invoked once at packaging time by both /// build-appimage.sh and Build-Installer.ps1 to acquire the copy each bundles). /// public static class NodToolProvider diff --git a/Launcher/WiiCompiled.Setup/PortableRoot.cs b/Launcher/WiiCompiled.Setup.Common/PortableRoot.cs similarity index 59% rename from Launcher/WiiCompiled.Setup/PortableRoot.cs rename to Launcher/WiiCompiled.Setup.Common/PortableRoot.cs index eab3dcd..8344b5f 100644 --- a/Launcher/WiiCompiled.Setup/PortableRoot.cs +++ b/Launcher/WiiCompiled.Setup.Common/PortableRoot.cs @@ -1,4 +1,4 @@ -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Common; /// /// A portable installation is a self-contained directory tree the user can move or carry on removable media: @@ -9,8 +9,11 @@ namespace WiiCompiled.Setup; /// /// The runtime finds the same root independently (runtime/include/runtime_config.h, /// PortableRootDirectory); this class must keep the same marker name, layout, and depth bound. +/// Shared by both installers - Linux's CLI has no --portable flag, so it only ever calls +/// // (always missing, +/// since it never creates a marker file), not . /// -internal static class PortableRoot +public static class PortableRoot { public const string MarkerFileName = "portable.txt"; public const string UserDataDirectoryName = "UserData"; @@ -72,7 +75,7 @@ internal static class PortableRoot if (!File.Exists(marker)) { File.WriteAllText(marker, - $"{ProductInfo.Name} portable installation." + Environment.NewLine + + "WiiCompiled portable installation." + Environment.NewLine + "This marker makes the runtime keep Config.toml, NAND, Cache, and Logs in UserData\\ " + "beside it instead of in %LOCALAPPDATA%." + Environment.NewLine + "Delete it to make this installation use per-user application data again." + @@ -90,54 +93,3 @@ internal static class PortableRoot private static string Normalize(string path) => FileSystemUtilities.NormalizePath(path); } - -/// -/// A portable root can be moved or renamed between operations. Every installed-host operation that -/// reads install-state.json passes through here first so exactly one place decides what a -/// moved installation means, and so a non-portable installation is never touched. -/// -internal static class PortableInstallHealing -{ - /// - /// Reconciles a moved portable installation with its recorded location: the state file adopts the - /// directory it was actually found in, and the native build tree is discarded because its - /// CMake cache holds absolute paths from the old location. Returns whether anything was healed. - /// - public static bool HealMovedInstall(Installation installation, IInstallReporter? reporter = null) - { - // Guard: an ordinary installation that disagrees with its state file is a real problem for - // the operation to report, not something to silently rewrite. - if (PortableRoot.TryFind(installation.Root) is null) return false; - - var state = installation.ReadInstallState(); - if (state is not { SchemaVersion: 1 } || string.IsNullOrWhiteSpace(state.InstallDir)) return false; - - string recorded; - try - { - recorded = FileSystemUtilities.NormalizePath(state.InstallDir); - } - catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) - { - recorded = state.InstallDir; - } - if (recorded.Equals(installation.Root, StringComparison.OrdinalIgnoreCase)) return false; - - var previous = state.InstallDir; - state.InstallDir = installation.Root; - JsonState.Write(installation.InstallStatePath, state); - - // The configured native build directory bakes absolute source, toolchain, and output paths - // into CMakeCache.txt. After a move it is unusable and would fail the next configure rather - // than being reused, so it is removed and reconfigured from scratch on the next build. - var nativeBuild = Path.Combine(installation.WorkspaceDirectory, "native-build"); - var hadNativeBuild = Directory.Exists(nativeBuild); - if (hadNativeBuild) FileSystemUtilities.DeleteDirectoryIfExists(nativeBuild); - - reporter?.Diagnostic( - $"This portable installation moved from {previous} to {installation.Root}. " + - "The recorded location was updated" + - (hadNativeBuild ? " and the location-bound native build cache was discarded." : ".")); - return true; - } -} diff --git a/Launcher/WiiCompiled.Setup/RetroRewindSource.cs b/Launcher/WiiCompiled.Setup.Common/RetroRewindSource.cs similarity index 91% rename from Launcher/WiiCompiled.Setup/RetroRewindSource.cs rename to Launcher/WiiCompiled.Setup.Common/RetroRewindSource.cs index 75ea9b8..c74205e 100644 --- a/Launcher/WiiCompiled.Setup/RetroRewindSource.cs +++ b/Launcher/WiiCompiled.Setup.Common/RetroRewindSource.cs @@ -1,16 +1,17 @@ -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Common; /// /// Resolves the one canonical Retro Rewind install. Wheel Wizard owns and passes it as -/// --retro-dir; the backend only resolves, reads, and records it, never packages or copies it. +/// --retro-dir; each installer only resolves, reads, and records it, never packages or +/// copies it. /// -internal static class RetroRewindSource +public static class RetroRewindSource { /// /// Resolves the RetroRewind6 folder from a selection that may be the folder itself or a /// parent containing exactly one RetroRewind6/Binaries/Code.pul. /// - internal static string ResolveRetroRewind6(string selected) + public static string ResolveRetroRewind6(string selected) { if (string.IsNullOrWhiteSpace(selected)) throw new InvalidDataException("Choose the canonical Retro Rewind folder."); diff --git a/Launcher/WiiCompiled.Setup/InputValidation.cs b/Launcher/WiiCompiled.Setup.Common/RetroWfcPayload.cs similarity index 64% rename from Launcher/WiiCompiled.Setup/InputValidation.cs rename to Launcher/WiiCompiled.Setup.Common/RetroWfcPayload.cs index 68ae52f..f100171 100644 --- a/Launcher/WiiCompiled.Setup/InputValidation.cs +++ b/Launcher/WiiCompiled.Setup.Common/RetroWfcPayload.cs @@ -1,21 +1,28 @@ -using System.Diagnostics; using System.Buffers.Binary; using System.Net; using System.Security.Cryptography; -using System.Text.Json; -using WiiCompiled.NodTool; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Common; -internal static class InputValidation +/// +/// One validated, content-identified download in operation-owned scratch space. Callers use this +/// exact directory for both the update decision and any resulting build. +/// +public sealed record RetroWfcPayloadSnapshot(string Directory, string Sha256, long ByteLength); + +/// +/// Downloads and verifies the Retro-WFC payload (a small, RSA-signed blob served from a single +/// fixed endpoint). Shared by both installers - moved here from WiiCompiled.Setup.Windows's +/// InputValidation.cs, which keeps every one of these method names as thin forwarding wrappers so +/// its many existing call sites (ProductRepairService.cs, LocalBuildService.cs, Installation.cs, +/// SelfTests.cs) needed no changes. +/// +public static class RetroWfcPayload { private const long MaximumRetroWfcPayloadBytes = 16L * 1024 * 1024; // The payload is tens of kilobytes from a single fixed endpoint 30s is good. private static readonly TimeSpan RetroWfcDownloadTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan RetroWfcRetryDelay = TimeSpan.FromSeconds(1); - private static readonly HashSet SupportedDiscImageExtensions = new( - [".iso", ".gcm", ".gcz", ".ciso", ".wbfs", ".wia", ".rvz"], - StringComparer.OrdinalIgnoreCase); public const string CurrentRetroWfcPayloadUri = "http://nas.play.rwfc.net/payload?g=RMCPD00"; private static readonly string RetroWfcOfflinePayloadFile = @@ -38,57 +45,6 @@ internal static class InputValidation private const int RetroWfcPayloadSignatureOffset = 0x10; private const int RetroWfcPayloadMinimumBytes = 0x130; - public static void ValidateExtension(string gamePath) - { - if (!File.Exists(gamePath)) - throw new FileNotFoundException("The selected game image does not exist.", gamePath); - var extension = Path.GetExtension(gamePath); - if (!SupportedDiscImageExtensions.Contains(extension)) - throw new InvalidDataException( - "Select a complete Wii disc image in ISO, GCM, GCZ, CISO, WBFS, WIA, or RVZ format."); - } - - public static async Task ReadDiscHeaderAsync(string nodTool, string gamePath, - CancellationToken cancellationToken = default) - { - ValidateExtension(gamePath); - var result = await ProcessRunner.RunAsync(nodTool, - ["info", Path.GetFullPath(gamePath)], null, cancellationToken); - if (result.ExitCode != 0) - throw new InvalidDataException("nodtool could not read this disc image. " + result.CombinedOutput.Trim()); - - var info = NodToolInfoParser.Parse(result.StandardOutput); - return new DiscHeader - { - GameId = info.GameId, - InternalName = info.Title, - Region = RegionFromGameId(info.GameId), - Revision = info.Revision, - }; - } - - private static string RegionFromGameId(string gameId) => gameId.Length >= 4 - ? gameId[3] switch - { - 'P' => "PAL", - 'E' => "NTSC-U", - 'J' => "NTSC-J", - 'K' => "Korea", - 'W' => "Taiwan", - _ => gameId[3].ToString(), - } - : "Unknown"; - - public static void EnsureCompatibleDisc(DiscHeader header, PayloadManifest manifest) - { - if (!header.GameId.Equals(manifest.ExpectedGameId, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidDataException( - $"This build supports Mario Kart Wii PAL ({manifest.ExpectedGameId}). " + - $"The selected image is {header.GameId} ({header.InternalName}, {header.Region})."); - } - } - public static string ValidateStagedRetroWfcPayloadDirectory(string stagedDirectory, RSAParameters? signingKey = null) { @@ -201,7 +157,7 @@ internal static class InputValidation } } - internal static bool IsTransientRetroWfcDownloadFailure(Exception exception, + public static bool IsTransientRetroWfcDownloadFailure(Exception exception, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) return false; @@ -247,73 +203,9 @@ internal static class InputValidation "The Retro-WFC payload is not signed by the pinned Retro-WFC signing key."); } - public static string Sha256File(string path) + private static string Sha256File(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); } } - -internal sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError) -{ - public string CombinedOutput => StandardOutput + Environment.NewLine + StandardError; -} - -internal static class ProcessRunner -{ - /// Runs a redirected child process to completion. sets up a - /// working directory or scrubbed environment; is off for callers that only - /// forward output live, so a build's output isn't buffered in memory for nobody to read. - public static async Task RunAsync(string executable, IReadOnlyList arguments, - Action? output, CancellationToken cancellationToken, - Action? configure = null, bool capture = true, - Action? onTerminationFailure = null) - { - var info = new ProcessStartInfo - { - FileName = executable, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - foreach (var argument in arguments) info.ArgumentList.Add(argument); - configure?.Invoke(info); - - using var process = new Process { StartInfo = info, EnableRaisingEvents = true }; - var stdout = new List(); - var stderr = new List(); - process.OutputDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stdout.Add(e.Data); output?.Invoke(e.Data); } }; - process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stderr.Add(e.Data); output?.Invoke(e.Data); } }; - if (!process.Start()) throw new InvalidOperationException($"Could not start {executable}."); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - await WaitForExitAsync(process, cancellationToken, onTerminationFailure); - return new ProcessResult(process.ExitCode, string.Join(Environment.NewLine, stdout), - string.Join(Environment.NewLine, stderr)); - } - - public static async Task WaitForExitAsync(Process process, CancellationToken cancellationToken, - Action? onTerminationFailure = null) - { - try - { - await process.WaitForExitAsync(cancellationToken); - } - catch (OperationCanceledException) - { - try - { - if (!process.HasExited) process.Kill(entireProcessTree: true); - } - catch (Exception ex) - { - onTerminationFailure?.Invoke(ex); - } - await process.WaitForExitAsync(CancellationToken.None); - process.WaitForExit(); - throw; - } - process.WaitForExit(); - } -} diff --git a/Launcher/WiiCompiled.Setup/RuntimeConfiguration.cs b/Launcher/WiiCompiled.Setup.Common/RuntimeConfiguration.cs similarity index 89% rename from Launcher/WiiCompiled.Setup/RuntimeConfiguration.cs rename to Launcher/WiiCompiled.Setup.Common/RuntimeConfiguration.cs index cc7a546..9a4f88a 100644 --- a/Launcher/WiiCompiled.Setup/RuntimeConfiguration.cs +++ b/Launcher/WiiCompiled.Setup.Common/RuntimeConfiguration.cs @@ -1,16 +1,19 @@ -using System.Globalization; using System.Text; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Common; -internal sealed record RuntimeConfigSnapshot(bool Existed, byte[] Contents); +public sealed record RuntimeConfigSnapshot(bool Existed, byte[] Contents); /// /// Reads and writes the runtime's Config.toml. Every entry point takes the file it operates on /// since its location depends on the installation (portable UserData vs. per-user app data); -/// callers obtain it once via . +/// callers obtain it once via . Shared by both installers - Linux +/// never creates a marker file, so +/// /'s portable-root lookups always miss +/// there and this degrades to the same plain per-user-app-data, always-absolute-path behavior a +/// non-portable Windows install already gets. /// -internal static class RuntimeConfiguration +public static class RuntimeConfiguration { public const string ConfigFileName = "Config.toml"; @@ -51,7 +54,7 @@ internal static class RuntimeConfiguration File.Delete(configPath); } - internal static void SetDvdRoot(string configPath, string dvdRoot) => + public static void SetDvdRoot(string configPath, string dvdRoot) => SetPath(configPath, "dvd_root", dvdRoot); /// @@ -59,21 +62,21 @@ internal static class RuntimeConfiguration /// asset overlay by scanning this directory live at launch, so an asset-only Retro Rewind update /// needs no backend work: the next launch simply reads the new files. /// - internal static void SetRetroRewindRoot(string configPath, string retroRewindRoot) => + public static void SetRetroRewindRoot(string configPath, string retroRewindRoot) => SetPath(configPath, "retro_rewind_root", retroRewindRoot); /// The canonical Retro Rewind root, or null when no installation has recorded one. public static string? GetRetroRewindRoot(string configPath) => GetResolvedPath(configPath, "retro_rewind_root"); - internal static void RemoveRetroRewindRootIfOwned(string configPath, string retroRewindRoot) => + public static void RemoveRetroRewindRootIfOwned(string configPath, string retroRewindRoot) => RemovePathIfOwned(configPath, "retro_rewind_root", retroRewindRoot); - internal static void RemoveDvdRootIfOwned(string configPath, string dvdRoot) => + public static void RemoveDvdRootIfOwned(string configPath, string dvdRoot) => RemovePathIfOwned(configPath, "dvd_root", dvdRoot); /// The raw stored text of a [paths] key, exactly as the file holds it. - internal static string? GetPath(string configPath, string key) => + public static string? GetPath(string configPath, string key) => TryUnquoteToml(GetRawValue(configPath, "paths", key) ?? "", out var value) ? value : null; /// @@ -81,17 +84,17 @@ internal static class RuntimeConfiguration /// [paths] value against the directory holding Config.toml (never the working /// directory), so the host must resolve it the same way before comparing or reading it. /// - internal static string? GetResolvedPath(string configPath, string key) + public static string? GetResolvedPath(string configPath, string key) { var stored = GetPath(configPath, key); return string.IsNullOrWhiteSpace(stored) ? null : ResolveAgainstConfig(configPath, stored); } - internal static string ConfigDirectory(string configPath) => + public static string ConfigDirectory(string configPath) => Path.GetDirectoryName(Path.GetFullPath(configPath)) ?? throw new InvalidOperationException($"{configPath} has no containing directory."); - internal static string ResolveAgainstConfig(string configPath, string value) => + public static string ResolveAgainstConfig(string configPath, string value) => Path.GetFullPath(value, ConfigDirectory(configPath)); private static void SetPath(string configPath, string key, string value) @@ -150,7 +153,7 @@ internal static class RuntimeConfiguration } /// The raw TOML literal stored for a key, or null when the section or key is absent. - internal static string? GetRawValue(string configPath, string section, string key) + public static string? GetRawValue(string configPath, string section, string key) { if (!File.Exists(configPath)) return null; var header = $"[{section}]"; @@ -265,7 +268,7 @@ internal static class RuntimeConfiguration private static string QuoteToml(string value) => "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; - internal static bool TryUnquoteToml(string value, out string result) + public static bool TryUnquoteToml(string value, out string result) { result = ""; if (value.Length < 2) return false; @@ -292,5 +295,4 @@ internal static class RuntimeConfiguration result = builder.ToString(); return true; } - } diff --git a/Launcher/WiiCompiled.NodTool/WiiCompiled.NodTool.csproj b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj similarity index 63% rename from Launcher/WiiCompiled.NodTool/WiiCompiled.NodTool.csproj rename to Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj index 967ee2c..91fbef7 100644 --- a/Launcher/WiiCompiled.NodTool/WiiCompiled.NodTool.csproj +++ b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj @@ -3,12 +3,12 @@ net8.0 enable enable - WiiCompiled.NodTool - WiiCompiled.NodTool + WiiCompiled.Setup.Common + WiiCompiled.Setup.Common 0.2.22 patchzy WiiCompiled - Shared nodtool acquisition/parsing logic used by both the Windows and Linux installers + Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers embedded en diff --git a/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs b/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs index c0c8124..677b757 100644 --- a/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs +++ b/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs @@ -11,7 +11,7 @@ internal static class BuildRunner { public static async Task RunAsync( string workspace, string profile, string outputDir, string? baseOutputDir, - string? retroRewindPackageDir, string? retroWfcOfflineDir, bool skipRetroWfcPayload, + string? retroDir, string? retroWfcOfflineDir, bool skipRetroWfcPayload, bool forceCleanBuild, string? translatorBin, IInstallReporter reporter, CancellationToken cancellationToken) { var script = Path.Combine(workspace, "Launcher", "local-build.sh"); @@ -31,9 +31,11 @@ internal static class BuildRunner { startInfo.ArgumentList.Add("--base-output-dir"); startInfo.ArgumentList.Add(baseOutputDir); } - if (!string.IsNullOrEmpty(retroRewindPackageDir)) + if (!string.IsNullOrEmpty(retroDir)) { - startInfo.ArgumentList.Add("--retro-rewind-package-dir"); startInfo.ArgumentList.Add(retroRewindPackageDir); + // Still forwarded to local-build.sh under its own internal name - + // --retro-rewind-package-dir - matching LocalBuild.ps1's own -RetroRewindPackageDirectory. + startInfo.ArgumentList.Add("--retro-rewind-package-dir"); startInfo.ArgumentList.Add(retroDir); } if (!string.IsNullOrEmpty(retroWfcOfflineDir)) { diff --git a/Launcher/WiiCompiled.Setup.Linux/DiscTool.cs b/Launcher/WiiCompiled.Setup.Linux/DiscTool.cs index 8c88296..fad7213 100644 --- a/Launcher/WiiCompiled.Setup.Linux/DiscTool.cs +++ b/Launcher/WiiCompiled.Setup.Linux/DiscTool.cs @@ -1,11 +1,11 @@ using System.Security.Cryptography; -using WiiCompiled.NodTool; +using WiiCompiled.Setup.Common; namespace WiiCompiled.Setup.Linux; /// /// Validates and extracts the user's own Mario Kart Wii disc via `nodtool` (see -/// WiiCompiled.NodTool/NodToolProvider.cs) - a prebuilt, MIT/Apache-2.0-licensed CLI from +/// WiiCompiled.Setup.Common/NodToolProvider.cs) - a prebuilt, MIT/Apache-2.0-licensed CLI from /// encounter/nod, replacing the earlier dependency on a system-installed `dolphin-tool` /// (GPL-2.0-or-later, and not reliably packaged standalone by every distro). /// @@ -30,42 +30,38 @@ internal static class DiscTool "Only your own legally-owned copy of that exact game/region can be used."); } + // Extracted straight into Assets/DATA (kept, not a scratch dir) - the runtime reads course/ + // texture/audio data from this directory live via [paths] dvd_root, not just at translation + // time, so it has to survive past this install (see Program.cs, which points dvd_root here). reporter.Progress(InstallStages.ExtractDisc, "Extracting the disc image", 4); - var scratch = Path.Combine(Path.GetTempPath(), "wiicompiled-disc-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(scratch); - try + var dataDir = Path.Combine(assetsDirectory, "DATA"); + if (Directory.Exists(dataDir)) Directory.Delete(dataDir, recursive: true); + await RunExtractAsync(nodTool, isoPath, dataDir, cancellationToken); + + var dolPath = Path.Combine(dataDir, "sys", "main.dol"); + var relPath = Path.Combine(dataDir, "files", "rel", "StaticR.rel"); + if (!File.Exists(dolPath)) throw new FileNotFoundException("nodtool did not produce main.dol", dolPath); + if (!File.Exists(relPath)) throw new FileNotFoundException("nodtool did not produce StaticR.rel", relPath); + + var dolSha = Sha256Of(dolPath); + var relSha = Sha256Of(relPath); + if (!string.Equals(dolSha, manifest.DolSha256, StringComparison.Ordinal)) { - await RunExtractAsync(nodTool, isoPath, scratch, cancellationToken); - - var dolPath = Path.Combine(scratch, "sys", "main.dol"); - var relPath = Path.Combine(scratch, "files", "rel", "StaticR.rel"); - if (!File.Exists(dolPath)) throw new FileNotFoundException("nodtool did not produce main.dol", dolPath); - if (!File.Exists(relPath)) throw new FileNotFoundException("nodtool did not produce StaticR.rel", relPath); - - var dolSha = Sha256Of(dolPath); - var relSha = Sha256Of(relPath); - if (!string.Equals(dolSha, manifest.DolSha256, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"main.dol sha256 mismatch: expected {manifest.DolSha256}, got {dolSha}. " + - "This disc revision does not match what this project's manifest is pinned to."); - } - if (!string.Equals(relSha, manifest.RelSha256, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"StaticR.rel sha256 mismatch: expected {manifest.RelSha256}, got {relSha}. " + - "This disc revision does not match what this project's manifest is pinned to."); - } - - Directory.CreateDirectory(assetsDirectory); - File.Copy(dolPath, Path.Combine(assetsDirectory, "main.dol"), overwrite: true); - File.Copy(relPath, Path.Combine(assetsDirectory, "StaticR.rel"), overwrite: true); - reporter.Progress(InstallStages.ExtractDisc, "Disc validated and extracted", 6); + throw new InvalidOperationException( + $"main.dol sha256 mismatch: expected {manifest.DolSha256}, got {dolSha}. " + + "This disc revision does not match what this project's manifest is pinned to."); } - finally + if (!string.Equals(relSha, manifest.RelSha256, StringComparison.Ordinal)) { - try { Directory.Delete(scratch, recursive: true); } catch { /* best-effort cleanup */ } + throw new InvalidOperationException( + $"StaticR.rel sha256 mismatch: expected {manifest.RelSha256}, got {relSha}. " + + "This disc revision does not match what this project's manifest is pinned to."); } + + Directory.CreateDirectory(assetsDirectory); + File.Copy(dolPath, Path.Combine(assetsDirectory, "main.dol"), overwrite: true); + File.Copy(relPath, Path.Combine(assetsDirectory, "StaticR.rel"), overwrite: true); + reporter.Progress(InstallStages.ExtractDisc, "Disc validated and extracted", 6); } private static async Task RunInfoAsync(string nodTool, string isoPath, CancellationToken cancellationToken) diff --git a/Launcher/WiiCompiled.Setup.Linux/Program.cs b/Launcher/WiiCompiled.Setup.Linux/Program.cs index 9c8e9ed..dd68523 100644 --- a/Launcher/WiiCompiled.Setup.Linux/Program.cs +++ b/Launcher/WiiCompiled.Setup.Linux/Program.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using WiiCompiled.Setup.Common; namespace WiiCompiled.Setup.Linux; @@ -40,7 +41,7 @@ internal static class Program await InstallAsync(flags, reporter, cts.Token); break; case "uninstall": - Uninstall(flags); + Uninstall(); break; case "launch-base": return Launch("base", flags); @@ -73,9 +74,25 @@ internal static class Program private static async Task InstallAsync(Dictionary flags, IInstallReporter reporter, CancellationToken token) { - var profile = flags.GetValueOrDefault("profile") ?? "base"; - if (profile is not ("base" or "retro-rewind" or "both")) - throw new ArgumentException("--profile must be base, retro-rewind, or both"); + var retroDir = flags.GetValueOrDefault("retro-dir"); + var installsRetro = !string.IsNullOrEmpty(retroDir); + var downloadPayload = flags.ContainsKey("download-retro-wfc-payload"); + var skipPayload = flags.ContainsKey("skip-retro-wfc-payload"); + if (installsRetro) + { + if (downloadPayload == skipPayload) + throw new ArgumentException( + "Choose exactly one Retro-WFC mode: --download-retro-wfc-payload or --skip-retro-wfc-payload."); + } + else if (downloadPayload || skipPayload) + { + throw new ArgumentException("A Retro-WFC payload option is valid only with --retro-dir."); + } + + // Canonicalizes to the exact RetroRewind6 folder (accepting a parent folder or a symlink), + // the same validation Windows applies via this same shared method - local-build.sh's own + // check further down is a simpler backstop, not the primary validation anymore. + if (installsRetro) retroDir = RetroRewindSource.ResolveRetroRewind6(retroDir!); var workspace = flags.GetValueOrDefault("workspace") ?? WorkspaceLocator.FindFrom(AppContext.BaseDirectory); var manifest = ProjectManifest.Load(Path.Combine(workspace, "projects", "mkwii", "recomp.yml")); @@ -102,15 +119,35 @@ internal static class Program var state = JsonState.TryRead(StatePath) ?? new InstallState { Workspace = workspace }; state.Workspace = workspace; - var profiles = profile == "both" ? new[] { "base", "retro-rewind" } : new[] { profile }; - var baseInstallDir = profile == "both" ? DefaultInstallDir("base") : null; - var installDir = flags.GetValueOrDefault("install-dir") ?? DefaultInstallDir(profile == "both" ? "retro-rewind" : profile); + var profile = installsRetro ? "both" : "base"; + var profiles = installsRetro ? new[] { "base", "retro-rewind" } : new[] { "base" }; + var baseInstallDir = installsRetro ? DefaultInstallDir("base") : null; + var installDir = flags.GetValueOrDefault("install-dir") ?? DefaultInstallDir(installsRetro ? "retro-rewind" : "base"); + + string? retroWfcOfflineDir = null; + if (downloadPayload) + { + // Reused if a previous install already downloaded and it's still valid - matches + // Windows's own reuse-if-valid behavior instead of re-downloading on every install. + var cacheDir = Path.Combine(workspace, "generated", "retro-wfc-payload"); + reporter.Progress(InstallStages.Validate, "Preparing the Retro-WFC payload", 1); + try + { + RetroWfcPayload.ValidateStagedRetroWfcPayloadDirectory(cacheDir); + } + catch (InvalidDataException) + { + await RetroWfcPayload.DownloadRetroWfcPayloadAsync( + RetroWfcPayload.CurrentRetroWfcPayloadUri, cacheDir, token); + } + retroWfcOfflineDir = cacheDir; + } await BuildRunner.RunAsync( workspace, profile, installDir, baseInstallDir, - flags.GetValueOrDefault("retro-rewind-package-dir"), - flags.GetValueOrDefault("retro-wfc-offline-dir"), - flags.ContainsKey("skip-retro-wfc-payload"), + retroDir, + retroWfcOfflineDir, + skipPayload, flags.ContainsKey("force-clean-build"), flags.GetValueOrDefault("translator-bin"), reporter, token); @@ -137,15 +174,32 @@ internal static class Program DesktopEntry.Create(p, displayName, Path.Combine(dir, exeName)); } JsonState.Write(StatePath, state); + + // The runtime reads course/texture/audio data live from dvd_root at every launch, not just + // at translation time - without this the game fatally errors the instant it needs any file + // that isn't main.dol/StaticR.rel. Linux has no --portable flag, so this is always the + // per-user Config.toml (RuntimeConfiguration.ResolveConfigPath's Windows-only portable-root + // lookup has nothing to find here either way). + var configPath = RuntimeConfiguration.ApplicationDataConfigPath; + var dataDir = Path.Combine(assetsDir, "DATA"); + if (Directory.Exists(dataDir)) + { + RuntimeConfiguration.SetDvdRoot(configPath, dataDir); + } + if (installsRetro) + { + RuntimeConfiguration.SetRetroRewindRoot(configPath, retroDir!); + } + reporter.Progress(InstallStages.Shortcuts, "Install complete", 99); } - private static void Uninstall(Dictionary flags) + private static void Uninstall() { - var profile = flags.GetValueOrDefault("profile") ?? "all"; + // Matches Windows: UninstallService.cs removes the whole install directory unconditionally - + // there is no partial-product uninstall on either platform. var state = JsonState.TryRead(StatePath) ?? new InstallState(); - var toRemove = profile == "all" ? state.Products.ToList() : state.Products.Where(r => r.Profile == profile).ToList(); - foreach (var record in toRemove) + foreach (var record in state.Products.ToList()) { if (Directory.Exists(record.InstallDirectory)) { @@ -260,11 +314,11 @@ internal static class Program Console.WriteLine(""" Usage: wiicompiled-setup [options] - install --profile {base|retro-rewind|both} [--game ISO_PATH] [--install-dir DIR] - [--retro-rewind-package-dir DIR] [--retro-wfc-offline-dir DIR | --skip-retro-wfc-payload] + install [--game ISO_PATH] [--install-dir DIR] [--retro-dir DIR + {--download-retro-wfc-payload | --skip-retro-wfc-payload}] [--force-clean-build] [--translator-bin PATH] [--disc-tool-bin PATH] [--progress-json] [--workspace DIR] - uninstall [--profile {base|retro-rewind|both|all}] + uninstall launch-base launch-retro check-products diff --git a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj index 59a7df6..7424382 100644 --- a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj +++ b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj @@ -15,6 +15,6 @@ true - + diff --git a/Launcher/WiiCompiled.Setup/CancellationSignal.cs b/Launcher/WiiCompiled.Setup.Windows/CancellationSignal.cs similarity index 98% rename from Launcher/WiiCompiled.Setup/CancellationSignal.cs rename to Launcher/WiiCompiled.Setup.Windows/CancellationSignal.cs index dd4848b..c915aea 100644 --- a/Launcher/WiiCompiled.Setup/CancellationSignal.cs +++ b/Launcher/WiiCompiled.Setup.Windows/CancellationSignal.cs @@ -1,4 +1,4 @@ -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; /// /// Bridges a frontend-owned, named Windows event into the cancellation token used by setup. diff --git a/Launcher/WiiCompiled.Setup/CommandLine.cs b/Launcher/WiiCompiled.Setup.Windows/CommandLine.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/CommandLine.cs rename to Launcher/WiiCompiled.Setup.Windows/CommandLine.cs index ef573d6..14cd664 100644 --- a/Launcher/WiiCompiled.Setup/CommandLine.cs +++ b/Launcher/WiiCompiled.Setup.Windows/CommandLine.cs @@ -1,4 +1,4 @@ -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; internal enum AppMode { diff --git a/Launcher/WiiCompiled.Setup/CompileInputsFingerprint.cs b/Launcher/WiiCompiled.Setup.Windows/CompileInputsFingerprint.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/CompileInputsFingerprint.cs rename to Launcher/WiiCompiled.Setup.Windows/CompileInputsFingerprint.cs index 8b8dc0a..db69b6d 100644 --- a/Launcher/WiiCompiled.Setup/CompileInputsFingerprint.cs +++ b/Launcher/WiiCompiled.Setup.Windows/CompileInputsFingerprint.cs @@ -1,8 +1,9 @@ using System.Buffers.Binary; using System.Security.Cryptography; using System.Text; +using WiiCompiled.Setup.Common; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; internal sealed record RetroRewindCompileInputs( string RetroRewindRoot, diff --git a/Launcher/WiiCompiled.Setup/ConsoleCommands.cs b/Launcher/WiiCompiled.Setup.Windows/ConsoleCommands.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/ConsoleCommands.cs rename to Launcher/WiiCompiled.Setup.Windows/ConsoleCommands.cs index a269a1c..fbe9a16 100644 --- a/Launcher/WiiCompiled.Setup/ConsoleCommands.cs +++ b/Launcher/WiiCompiled.Setup.Windows/ConsoleCommands.cs @@ -1,6 +1,7 @@ using System.Text.Json; +using WiiCompiled.Setup.Common; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; internal static class ConsoleCommands { diff --git a/Launcher/WiiCompiled.Setup/GameLaunchService.cs b/Launcher/WiiCompiled.Setup.Windows/GameLaunchService.cs similarity index 98% rename from Launcher/WiiCompiled.Setup/GameLaunchService.cs rename to Launcher/WiiCompiled.Setup.Windows/GameLaunchService.cs index 58d10d0..6bdf7d2 100644 --- a/Launcher/WiiCompiled.Setup/GameLaunchService.cs +++ b/Launcher/WiiCompiled.Setup.Windows/GameLaunchService.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; internal static class GameLaunchService { diff --git a/Launcher/WiiCompiled.Setup.Windows/InputValidation.cs b/Launcher/WiiCompiled.Setup.Windows/InputValidation.cs new file mode 100644 index 0000000..5ca7558 --- /dev/null +++ b/Launcher/WiiCompiled.Setup.Windows/InputValidation.cs @@ -0,0 +1,165 @@ +using System.Diagnostics; +using System.Buffers.Binary; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using WiiCompiled.Setup.Common; + +namespace WiiCompiled.Setup.Windows; + +internal static class InputValidation +{ + private static readonly HashSet SupportedDiscImageExtensions = new( + [".iso", ".gcm", ".gcz", ".ciso", ".wbfs", ".wia", ".rvz"], + StringComparer.OrdinalIgnoreCase); + + public static void ValidateExtension(string gamePath) + { + if (!File.Exists(gamePath)) + throw new FileNotFoundException("The selected game image does not exist.", gamePath); + var extension = Path.GetExtension(gamePath); + if (!SupportedDiscImageExtensions.Contains(extension)) + throw new InvalidDataException( + "Select a complete Wii disc image in ISO, GCM, GCZ, CISO, WBFS, WIA, or RVZ format."); + } + + public static async Task ReadDiscHeaderAsync(string nodTool, string gamePath, + CancellationToken cancellationToken = default) + { + ValidateExtension(gamePath); + var result = await ProcessRunner.RunAsync(nodTool, + ["info", Path.GetFullPath(gamePath)], null, cancellationToken); + if (result.ExitCode != 0) + throw new InvalidDataException("nodtool could not read this disc image. " + result.CombinedOutput.Trim()); + + var info = NodToolInfoParser.Parse(result.StandardOutput); + return new DiscHeader + { + GameId = info.GameId, + InternalName = info.Title, + Region = RegionFromGameId(info.GameId), + Revision = info.Revision, + }; + } + + private static string RegionFromGameId(string gameId) => gameId.Length >= 4 + ? gameId[3] switch + { + 'P' => "PAL", + 'E' => "NTSC-U", + 'J' => "NTSC-J", + 'K' => "Korea", + 'W' => "Taiwan", + _ => gameId[3].ToString(), + } + : "Unknown"; + + public static void EnsureCompatibleDisc(DiscHeader header, PayloadManifest manifest) + { + if (!header.GameId.Equals(manifest.ExpectedGameId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"This build supports Mario Kart Wii PAL ({manifest.ExpectedGameId}). " + + $"The selected image is {header.GameId} ({header.InternalName}, {header.Region})."); + } + } + + // Thin forwarding wrappers: the actual download/RSA-verification logic lives in + // WiiCompiled.Setup.Common.RetroWfcPayload (shared with WiiCompiled.Setup.Linux) so there's one + // copy of it, not two. Kept under these names so every existing call site here + // (ProductRepairService.cs, LocalBuildService.cs, Installation.cs, SelfTests.cs) is unchanged. + public const string CurrentRetroWfcPayloadUri = RetroWfcPayload.CurrentRetroWfcPayloadUri; + + public static string ValidateStagedRetroWfcPayloadDirectory(string stagedDirectory, + RSAParameters? signingKey = null) => + RetroWfcPayload.ValidateStagedRetroWfcPayloadDirectory(stagedDirectory, signingKey); + + public static string ResolveRetroWfcPayloadFile(string stagedDirectory, + RSAParameters? signingKey = null) => + RetroWfcPayload.ResolveRetroWfcPayloadFile(stagedDirectory, signingKey); + + public static string ComputeRetroWfcPayloadSha256(string stagedDirectory, + RSAParameters? signingKey = null) => + RetroWfcPayload.ComputeRetroWfcPayloadSha256(stagedDirectory, signingKey); + + public static void ValidateRetroWfcPayloadUri(string uriText) => + RetroWfcPayload.ValidateRetroWfcPayloadUri(uriText); + + public static Task DownloadRetroWfcPayloadAsync(string uriText, + string destinationDirectory, CancellationToken cancellationToken) => + RetroWfcPayload.DownloadRetroWfcPayloadAsync(uriText, destinationDirectory, cancellationToken); + + internal static bool IsTransientRetroWfcDownloadFailure(Exception exception, + CancellationToken cancellationToken) => + RetroWfcPayload.IsTransientRetroWfcDownloadFailure(exception, cancellationToken); + + public static string Sha256File(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } +} + +internal sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError) +{ + public string CombinedOutput => StandardOutput + Environment.NewLine + StandardError; +} + +internal static class ProcessRunner +{ + /// Runs a redirected child process to completion. sets up a + /// working directory or scrubbed environment; is off for callers that only + /// forward output live, so a build's output isn't buffered in memory for nobody to read. + public static async Task RunAsync(string executable, IReadOnlyList arguments, + Action? output, CancellationToken cancellationToken, + Action? configure = null, bool capture = true, + Action? onTerminationFailure = null) + { + var info = new ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + foreach (var argument in arguments) info.ArgumentList.Add(argument); + configure?.Invoke(info); + + using var process = new Process { StartInfo = info, EnableRaisingEvents = true }; + var stdout = new List(); + var stderr = new List(); + process.OutputDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stdout.Add(e.Data); output?.Invoke(e.Data); } }; + process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stderr.Add(e.Data); output?.Invoke(e.Data); } }; + if (!process.Start()) throw new InvalidOperationException($"Could not start {executable}."); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + await WaitForExitAsync(process, cancellationToken, onTerminationFailure); + return new ProcessResult(process.ExitCode, string.Join(Environment.NewLine, stdout), + string.Join(Environment.NewLine, stderr)); + } + + public static async Task WaitForExitAsync(Process process, CancellationToken cancellationToken, + Action? onTerminationFailure = null) + { + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + try + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + } + catch (Exception ex) + { + onTerminationFailure?.Invoke(ex); + } + await process.WaitForExitAsync(CancellationToken.None); + process.WaitForExit(); + throw; + } + process.WaitForExit(); + } +} diff --git a/Launcher/WiiCompiled.Setup/InstallOperationLock.cs b/Launcher/WiiCompiled.Setup.Windows/InstallOperationLock.cs similarity index 97% rename from Launcher/WiiCompiled.Setup/InstallOperationLock.cs rename to Launcher/WiiCompiled.Setup.Windows/InstallOperationLock.cs index 44f1c78..035a442 100644 --- a/Launcher/WiiCompiled.Setup/InstallOperationLock.cs +++ b/Launcher/WiiCompiled.Setup.Windows/InstallOperationLock.cs @@ -1,7 +1,8 @@ using System.Security.Cryptography; using System.Text; +using WiiCompiled.Setup.Common; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; /// /// A fail-fast, cross-process lock covering install, repair and launch operations for one install diff --git a/Launcher/WiiCompiled.Setup/InstallProgress.cs b/Launcher/WiiCompiled.Setup.Windows/InstallProgress.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/InstallProgress.cs rename to Launcher/WiiCompiled.Setup.Windows/InstallProgress.cs index ac1e1fb..2bacbce 100644 --- a/Launcher/WiiCompiled.Setup/InstallProgress.cs +++ b/Launcher/WiiCompiled.Setup.Windows/InstallProgress.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; /// /// Stable stage identifiers reported by --progress-json. These are part of the public diff --git a/Launcher/WiiCompiled.Setup/InstallScratchSpace.cs b/Launcher/WiiCompiled.Setup.Windows/InstallScratchSpace.cs similarity index 98% rename from Launcher/WiiCompiled.Setup/InstallScratchSpace.cs rename to Launcher/WiiCompiled.Setup.Windows/InstallScratchSpace.cs index 49aa053..4169fbd 100644 --- a/Launcher/WiiCompiled.Setup/InstallScratchSpace.cs +++ b/Launcher/WiiCompiled.Setup.Windows/InstallScratchSpace.cs @@ -1,4 +1,6 @@ -namespace WiiCompiled.Setup; +using WiiCompiled.Setup.Common; + +namespace WiiCompiled.Setup.Windows; /// /// Owns one temporary directory for an install operation. The name carries the installation's scope diff --git a/Launcher/WiiCompiled.Setup/InstallTransaction.cs b/Launcher/WiiCompiled.Setup.Windows/InstallTransaction.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/InstallTransaction.cs rename to Launcher/WiiCompiled.Setup.Windows/InstallTransaction.cs index 4ee7d4b..8efb95e 100644 --- a/Launcher/WiiCompiled.Setup/InstallTransaction.cs +++ b/Launcher/WiiCompiled.Setup.Windows/InstallTransaction.cs @@ -1,4 +1,6 @@ -namespace WiiCompiled.Setup; +using WiiCompiled.Setup.Common; + +namespace WiiCompiled.Setup.Windows; internal enum InstallTransactionEntryKind { diff --git a/Launcher/WiiCompiled.Setup/Installation.cs b/Launcher/WiiCompiled.Setup.Windows/Installation.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/Installation.cs rename to Launcher/WiiCompiled.Setup.Windows/Installation.cs index 0b621ff..de600c9 100644 --- a/Launcher/WiiCompiled.Setup/Installation.cs +++ b/Launcher/WiiCompiled.Setup.Windows/Installation.cs @@ -1,4 +1,6 @@ -namespace WiiCompiled.Setup; +using WiiCompiled.Setup.Common; + +namespace WiiCompiled.Setup.Windows; /// Provenance written by the bundled build script next to every product it produces. internal sealed class LocalBuildProvenance diff --git a/Launcher/WiiCompiled.Setup/InstalledLayout.cs b/Launcher/WiiCompiled.Setup.Windows/InstalledLayout.cs similarity index 98% rename from Launcher/WiiCompiled.Setup/InstalledLayout.cs rename to Launcher/WiiCompiled.Setup.Windows/InstalledLayout.cs index 73d8efb..1372e03 100644 --- a/Launcher/WiiCompiled.Setup/InstalledLayout.cs +++ b/Launcher/WiiCompiled.Setup.Windows/InstalledLayout.cs @@ -1,4 +1,4 @@ -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; /// /// Names of the installed/staged layout. Not cosmetic: payload and toolkit identities hash relative paths diff --git a/Launcher/WiiCompiled.Setup/InstallerEngine.cs b/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/InstallerEngine.cs rename to Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs index f5cb13c..fce86f6 100644 --- a/Launcher/WiiCompiled.Setup/InstallerEngine.cs +++ b/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs @@ -1,4 +1,6 @@ -namespace WiiCompiled.Setup; +using WiiCompiled.Setup.Common; + +namespace WiiCompiled.Setup.Windows; internal sealed class InstallerEngine { diff --git a/Launcher/WiiCompiled.Setup/LocalBuildService.cs b/Launcher/WiiCompiled.Setup.Windows/LocalBuildService.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/LocalBuildService.cs rename to Launcher/WiiCompiled.Setup.Windows/LocalBuildService.cs index 439c594..1347c1e 100644 --- a/Launcher/WiiCompiled.Setup/LocalBuildService.cs +++ b/Launcher/WiiCompiled.Setup.Windows/LocalBuildService.cs @@ -1,6 +1,7 @@ using System.Diagnostics; +using WiiCompiled.Setup.Common; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; /// /// runs one retro-aware translation and compiles the two products from a single diff --git a/Launcher/WiiCompiled.Setup/Models.cs b/Launcher/WiiCompiled.Setup.Windows/Models.cs similarity index 95% rename from Launcher/WiiCompiled.Setup/Models.cs rename to Launcher/WiiCompiled.Setup.Windows/Models.cs index 83eb2cc..e62b261 100644 --- a/Launcher/WiiCompiled.Setup/Models.cs +++ b/Launcher/WiiCompiled.Setup.Windows/Models.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; +using WiiCompiled.Setup.Common; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; internal enum RetroWfcPayloadMode { @@ -54,12 +55,6 @@ internal sealed class PayloadManifest public string NativeToolchainFingerprint { get; set; } = ""; } -/// -/// One validated, content-identified download in operation-owned scratch space. Callers use this -/// exact directory for both the update decision and any resulting build. -/// -internal sealed record RetroWfcPayloadSnapshot(string Directory, string Sha256, long ByteLength); - internal sealed class DiscHeader { [JsonPropertyName("game_id")] diff --git a/Launcher/WiiCompiled.Setup/PayloadArchive.cs b/Launcher/WiiCompiled.Setup.Windows/PayloadArchive.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/PayloadArchive.cs rename to Launcher/WiiCompiled.Setup.Windows/PayloadArchive.cs index 3fb9055..d85f1fa 100644 --- a/Launcher/WiiCompiled.Setup/PayloadArchive.cs +++ b/Launcher/WiiCompiled.Setup.Windows/PayloadArchive.cs @@ -2,7 +2,7 @@ using System.IO.Compression; using System.Text; using System.Text.Json; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; internal sealed class PayloadArchive : IDisposable { diff --git a/Launcher/WiiCompiled.Setup.Windows/PortableInstallHealing.cs b/Launcher/WiiCompiled.Setup.Windows/PortableInstallHealing.cs new file mode 100644 index 0000000..786f369 --- /dev/null +++ b/Launcher/WiiCompiled.Setup.Windows/PortableInstallHealing.cs @@ -0,0 +1,54 @@ +using WiiCompiled.Setup.Common; + +namespace WiiCompiled.Setup.Windows; + +/// +/// A portable root can be moved or renamed between operations. Every installed-host operation that +/// reads install-state.json passes through here first so exactly one place decides what a +/// moved installation means, and so a non-portable installation is never touched. +/// +internal static class PortableInstallHealing +{ + /// + /// Reconciles a moved portable installation with its recorded location: the state file adopts the + /// directory it was actually found in, and the native build tree is discarded because its + /// CMake cache holds absolute paths from the old location. Returns whether anything was healed. + /// + public static bool HealMovedInstall(Installation installation, IInstallReporter? reporter = null) + { + // Guard: an ordinary installation that disagrees with its state file is a real problem for + // the operation to report, not something to silently rewrite. + if (PortableRoot.TryFind(installation.Root) is null) return false; + + var state = installation.ReadInstallState(); + if (state is not { SchemaVersion: 1 } || string.IsNullOrWhiteSpace(state.InstallDir)) return false; + + string recorded; + try + { + recorded = FileSystemUtilities.NormalizePath(state.InstallDir); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + recorded = state.InstallDir; + } + if (recorded.Equals(installation.Root, StringComparison.OrdinalIgnoreCase)) return false; + + var previous = state.InstallDir; + state.InstallDir = installation.Root; + JsonState.Write(installation.InstallStatePath, state); + + // The configured native build directory bakes absolute source, toolchain, and output paths + // into CMakeCache.txt. After a move it is unusable and would fail the next configure rather + // than being reused, so it is removed and reconfigured from scratch on the next build. + var nativeBuild = Path.Combine(installation.WorkspaceDirectory, "native-build"); + var hadNativeBuild = Directory.Exists(nativeBuild); + if (hadNativeBuild) FileSystemUtilities.DeleteDirectoryIfExists(nativeBuild); + + reporter?.Diagnostic( + $"This portable installation moved from {previous} to {installation.Root}. " + + "The recorded location was updated" + + (hadNativeBuild ? " and the location-bound native build cache was discarded." : ".")); + return true; + } +} diff --git a/Launcher/WiiCompiled.Setup/ProductRepairService.cs b/Launcher/WiiCompiled.Setup.Windows/ProductRepairService.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/ProductRepairService.cs rename to Launcher/WiiCompiled.Setup.Windows/ProductRepairService.cs index f1a75ca..6ff4d94 100644 --- a/Launcher/WiiCompiled.Setup/ProductRepairService.cs +++ b/Launcher/WiiCompiled.Setup.Windows/ProductRepairService.cs @@ -1,4 +1,6 @@ -namespace WiiCompiled.Setup; +using WiiCompiled.Setup.Common; + +namespace WiiCompiled.Setup.Windows; /// /// Reconciles installed products against the canonical Retro Rewind install Wheel Wizard owns: the diff --git a/Launcher/WiiCompiled.Setup/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/Program.cs rename to Launcher/WiiCompiled.Setup.Windows/Program.cs index 7858340..ac062a4 100644 --- a/Launcher/WiiCompiled.Setup/Program.cs +++ b/Launcher/WiiCompiled.Setup.Windows/Program.cs @@ -1,4 +1,4 @@ -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; using System.Runtime.InteropServices; diff --git a/Launcher/WiiCompiled.Setup/RunningProductGuard.cs b/Launcher/WiiCompiled.Setup.Windows/RunningProductGuard.cs similarity index 96% rename from Launcher/WiiCompiled.Setup/RunningProductGuard.cs rename to Launcher/WiiCompiled.Setup.Windows/RunningProductGuard.cs index 00b902d..cdfd77b 100644 --- a/Launcher/WiiCompiled.Setup/RunningProductGuard.cs +++ b/Launcher/WiiCompiled.Setup.Windows/RunningProductGuard.cs @@ -1,6 +1,7 @@ using System.Diagnostics; +using WiiCompiled.Setup.Common; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; /// /// Refuses to replace installed products while one of them is running: publishing renames the diff --git a/Launcher/WiiCompiled.Setup/RuntimeAssetPublication.cs b/Launcher/WiiCompiled.Setup.Windows/RuntimeAssetPublication.cs similarity index 97% rename from Launcher/WiiCompiled.Setup/RuntimeAssetPublication.cs rename to Launcher/WiiCompiled.Setup.Windows/RuntimeAssetPublication.cs index beb4186..9a9ad03 100644 --- a/Launcher/WiiCompiled.Setup/RuntimeAssetPublication.cs +++ b/Launcher/WiiCompiled.Setup.Windows/RuntimeAssetPublication.cs @@ -1,4 +1,6 @@ -namespace WiiCompiled.Setup; +using WiiCompiled.Setup.Common; + +namespace WiiCompiled.Setup.Windows; /// /// The one path by which a product receives its copied runtime assets, shared by install and repair. diff --git a/Launcher/WiiCompiled.Setup/SelfTests.cs b/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/SelfTests.cs rename to Launcher/WiiCompiled.Setup.Windows/SelfTests.cs index fc97c0b..21956a7 100644 --- a/Launcher/WiiCompiled.Setup/SelfTests.cs +++ b/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs @@ -1,7 +1,8 @@ using System.Buffers.Binary; using System.Security.Cryptography; +using WiiCompiled.Setup.Common; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; internal static class SelfTests { diff --git a/Launcher/WiiCompiled.Setup/ShellIntegration.cs b/Launcher/WiiCompiled.Setup.Windows/ShellIntegration.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/ShellIntegration.cs rename to Launcher/WiiCompiled.Setup.Windows/ShellIntegration.cs index 1746848..a17e1f4 100644 --- a/Launcher/WiiCompiled.Setup/ShellIntegration.cs +++ b/Launcher/WiiCompiled.Setup.Windows/ShellIntegration.cs @@ -1,6 +1,6 @@ using Microsoft.Win32; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; internal static class ShellIntegration { diff --git a/Launcher/WiiCompiled.Setup/ToolkitFingerprint.cs b/Launcher/WiiCompiled.Setup.Windows/ToolkitFingerprint.cs similarity index 99% rename from Launcher/WiiCompiled.Setup/ToolkitFingerprint.cs rename to Launcher/WiiCompiled.Setup.Windows/ToolkitFingerprint.cs index 8976210..ed1c952 100644 --- a/Launcher/WiiCompiled.Setup/ToolkitFingerprint.cs +++ b/Launcher/WiiCompiled.Setup.Windows/ToolkitFingerprint.cs @@ -1,7 +1,8 @@ using System.Security.Cryptography; using System.Text; +using WiiCompiled.Setup.Common; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; /// /// Content identity of everything that decides what the locally produced executables contain. diff --git a/Launcher/WiiCompiled.Setup/UninstallService.cs b/Launcher/WiiCompiled.Setup.Windows/UninstallService.cs similarity index 98% rename from Launcher/WiiCompiled.Setup/UninstallService.cs rename to Launcher/WiiCompiled.Setup.Windows/UninstallService.cs index 25ac2a4..490f174 100644 --- a/Launcher/WiiCompiled.Setup/UninstallService.cs +++ b/Launcher/WiiCompiled.Setup.Windows/UninstallService.cs @@ -1,7 +1,8 @@ using System.Diagnostics; using System.Runtime.InteropServices; +using WiiCompiled.Setup.Common; -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; internal static class UninstallService { diff --git a/Launcher/WiiCompiled.Setup/WiiCompiled.Setup.csproj b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj similarity index 81% rename from Launcher/WiiCompiled.Setup/WiiCompiled.Setup.csproj rename to Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj index d598994..5dbb356 100644 --- a/Launcher/WiiCompiled.Setup/WiiCompiled.Setup.csproj +++ b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj @@ -5,7 +5,7 @@ enable enable WiiCompiled.Setup - WiiCompiled.Setup + WiiCompiled.Setup.Windows app.manifest 0.2.24 patchzy @@ -15,6 +15,6 @@ en - + diff --git a/Launcher/WiiCompiled.Setup/WorkspaceTimestamps.cs b/Launcher/WiiCompiled.Setup.Windows/WorkspaceTimestamps.cs similarity index 98% rename from Launcher/WiiCompiled.Setup/WorkspaceTimestamps.cs rename to Launcher/WiiCompiled.Setup.Windows/WorkspaceTimestamps.cs index ae99f12..bbcabdd 100644 --- a/Launcher/WiiCompiled.Setup/WorkspaceTimestamps.cs +++ b/Launcher/WiiCompiled.Setup.Windows/WorkspaceTimestamps.cs @@ -1,4 +1,4 @@ -namespace WiiCompiled.Setup; +namespace WiiCompiled.Setup.Windows; internal static class WorkspaceTimestamps diff --git a/Launcher/WiiCompiled.Setup/app.manifest b/Launcher/WiiCompiled.Setup.Windows/app.manifest similarity index 100% rename from Launcher/WiiCompiled.Setup/app.manifest rename to Launcher/WiiCompiled.Setup.Windows/app.manifest diff --git a/Launcher/WiiCompiled.Setup/JsonState.cs b/Launcher/WiiCompiled.Setup/JsonState.cs deleted file mode 100644 index 5b51fc6..0000000 --- a/Launcher/WiiCompiled.Setup/JsonState.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Text.Json; - -namespace WiiCompiled.Setup; - -/// Reads and atomically writes the small JSON state documents kept inside an installation. -internal static class JsonState -{ - private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true }; - private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; - - public static T? TryRead(string path) where T : class - { - try - { - return File.Exists(path) ? JsonSerializer.Deserialize(File.ReadAllText(path), ReadOptions) : null; - } - catch - { - // A truncated or hand-edited state document must degrade into "unknown", which every - // caller already treats as "assume stale and rebuild", not into a failed launch. - return null; - } - } - - public static void Write(string path, T value) => - FileSystemUtilities.WriteAtomic(path, JsonSerializer.Serialize(value, WriteOptions)); -} diff --git a/Launcher/build-appimage.sh b/Launcher/build-appimage.sh index 6db0e79..fe56749 100644 --- a/Launcher/build-appimage.sh +++ b/Launcher/build-appimage.sh @@ -61,12 +61,12 @@ dotnet publish "$workspace/translator/src/Translator.Cli" -c Release -r linux-x6 cp "$translator_publish_tmp/Translator.Cli" "$appdir/usr/bin/translator-cli" chmod +x "$appdir/usr/bin/translator-cli" -# Resolved via the shared WiiCompiled.NodTool.Cli helper (also used by Build-Installer.ps1 on +# Resolved via the shared WiiCompiled.Setup.Common.Cli helper (also used by Build-Installer.ps1 on # Windows) rather than a second curl/version-pin copy here: it downloads and caches the same way # NodToolProvider.cs always does (Launcher/artifacts/nodtool), so there is exactly one place that # knows the nodtool version/URL/platform-asset mapping. echo "Resolving nodtool..." -nodtool_path=$(dotnet run --project "$workspace/Launcher/WiiCompiled.NodTool.Cli" -c Release -- \ +nodtool_path=$(dotnet run --project "$workspace/Launcher/WiiCompiled.Setup.Common.Cli" -c Release -- \ --workspace "$workspace" | tail -n1) cp "$nodtool_path" "$appdir/usr/bin/nodtool" chmod +x "$appdir/usr/bin/nodtool" diff --git a/Launcher/local-build.sh b/Launcher/local-build.sh index eb7cc06..ac869c9 100755 --- a/Launcher/local-build.sh +++ b/Launcher/local-build.sh @@ -52,7 +52,6 @@ output_dir="" base_output_dir="" retro_rewind_package_dir="" retro_wfc_offline_dir="" -retro_wfc_payload_origin=offline skip_retro_wfc_payload=0 force_clean_build=0 parallel_override=0 @@ -93,7 +92,6 @@ while [[ $# -gt 0 ]]; do --base-output-dir) base_output_dir=$2; shift 2 ;; --retro-rewind-package-dir) retro_rewind_package_dir=$2; shift 2 ;; --retro-wfc-offline-dir) retro_wfc_offline_dir=$2; shift 2 ;; - --retro-wfc-payload-origin) retro_wfc_payload_origin=$2; shift 2 ;; --skip-retro-wfc-payload) skip_retro_wfc_payload=1; shift ;; --force-clean-build) force_clean_build=1; shift ;; --parallel) parallel_override=$2; shift 2 ;;