From e3c4028b504ae8abcb0f9801da8e0262c73553d7 Mon Sep 17 00:00:00 2001
From: theofficialgman <28281419+theofficialgman@users.noreply.github.com>
Date: Tue, 25 Aug 2026 22:48:00 -0400
Subject: [PATCH] partial linux setup/install scripting
---
.../WiiCompiled.Setup.Linux/BuildRunner.cs | 75 ++++++
.../WiiCompiled.Setup.Linux/DesktopEntry.cs | 40 +++
Launcher/WiiCompiled.Setup.Linux/DiscTool.cs | 95 +++++++
.../InstallProgress.cs | 209 ++++++++++++++
Launcher/WiiCompiled.Setup.Linux/JsonState.cs | 33 +++
Launcher/WiiCompiled.Setup.Linux/Models.cs | 31 +++
Launcher/WiiCompiled.Setup.Linux/Program.cs | 254 ++++++++++++++++++
.../ProjectManifest.cs | 70 +++++
.../WiiCompiled.Setup.Linux.csproj | 17 ++
.../WorkspaceLocator.cs | 27 ++
10 files changed, 851 insertions(+)
create mode 100644 Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs
create mode 100644 Launcher/WiiCompiled.Setup.Linux/DesktopEntry.cs
create mode 100644 Launcher/WiiCompiled.Setup.Linux/DiscTool.cs
create mode 100644 Launcher/WiiCompiled.Setup.Linux/InstallProgress.cs
create mode 100644 Launcher/WiiCompiled.Setup.Linux/JsonState.cs
create mode 100644 Launcher/WiiCompiled.Setup.Linux/Models.cs
create mode 100644 Launcher/WiiCompiled.Setup.Linux/Program.cs
create mode 100644 Launcher/WiiCompiled.Setup.Linux/ProjectManifest.cs
create mode 100644 Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj
create mode 100644 Launcher/WiiCompiled.Setup.Linux/WorkspaceLocator.cs
diff --git a/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs b/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs
new file mode 100644
index 0000000..11516f3
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs
@@ -0,0 +1,75 @@
+using System.Diagnostics;
+
+namespace WiiCompiled.Setup.Linux;
+
+///
+/// Invokes Launcher/local-build.sh and turns its stdout into progress reports. Replaces
+/// LocalBuildService.cs's hardcoded Windows PowerShell 5.1 invocation - there is no PowerShell
+/// dependency here at all, just bash.
+///
+internal static class BuildRunner
+{
+ public static async Task RunAsync(
+ string workspace, string profile, string outputDir, string? baseOutputDir,
+ string? retroRewindPackageDir, string? retroWfcOfflineDir, bool skipRetroWfcPayload,
+ bool forceCleanBuild, IInstallReporter reporter, CancellationToken cancellationToken)
+ {
+ var script = Path.Combine(workspace, "Launcher", "local-build.sh");
+ if (!File.Exists(script)) throw new FileNotFoundException("local-build.sh is missing", script);
+
+ var startInfo = new ProcessStartInfo("bash")
+ {
+ WorkingDirectory = workspace,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+ startInfo.ArgumentList.Add(script);
+ startInfo.ArgumentList.Add("--profile"); startInfo.ArgumentList.Add(profile);
+ startInfo.ArgumentList.Add("--output-dir"); startInfo.ArgumentList.Add(outputDir);
+ if (!string.IsNullOrEmpty(baseOutputDir))
+ {
+ startInfo.ArgumentList.Add("--base-output-dir"); startInfo.ArgumentList.Add(baseOutputDir);
+ }
+ if (!string.IsNullOrEmpty(retroRewindPackageDir))
+ {
+ startInfo.ArgumentList.Add("--retro-rewind-package-dir"); startInfo.ArgumentList.Add(retroRewindPackageDir);
+ }
+ if (!string.IsNullOrEmpty(retroWfcOfflineDir))
+ {
+ startInfo.ArgumentList.Add("--retro-wfc-offline-dir"); startInfo.ArgumentList.Add(retroWfcOfflineDir);
+ }
+ if (skipRetroWfcPayload) startInfo.ArgumentList.Add("--skip-retro-wfc-payload");
+ if (forceCleanBuild) startInfo.ArgumentList.Add("--force-clean-build");
+
+ using var process = new Process { StartInfo = startInfo };
+ var window = new BuildProgressWindow(reporter, InstallStages.Build, start: 6, end: 96);
+
+ process.OutputDataReceived += (_, e) => { if (e.Data is not null) window.Observe(e.Data); };
+ process.ErrorDataReceived += (_, e) => { if (e.Data is not null) reporter.Diagnostic(e.Data); };
+
+ process.Start();
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ try
+ {
+ await process.WaitForExitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ KillProcessTree(process);
+ throw;
+ }
+
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException($"local-build.sh failed (exit {process.ExitCode}). See diagnostics above.");
+ }
+ }
+
+ private static void KillProcessTree(Process process)
+ {
+ try { process.Kill(entireProcessTree: true); } catch { /* best-effort */ }
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup.Linux/DesktopEntry.cs b/Launcher/WiiCompiled.Setup.Linux/DesktopEntry.cs
new file mode 100644
index 0000000..6ae6426
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup.Linux/DesktopEntry.cs
@@ -0,0 +1,40 @@
+namespace WiiCompiled.Setup.Linux;
+
+///
+/// freedesktop.org .desktop application-menu entries. Replaces ShellIntegration.cs's registry
+/// uninstall entry (no Linux analogue for an unpackaged tool - Windows already skips that step for
+/// portable installs, this just applies that same behavior universally) and .lnk shortcuts.
+///
+internal static class DesktopEntry
+{
+ private static string ApplicationsDirectory =>
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "applications");
+
+ private static string PathFor(string profile) =>
+ Path.Combine(ApplicationsDirectory, $"wiicompiled-{profile}.desktop");
+
+ public static void Create(string profile, string displayName, string exePath)
+ {
+ // exePath is the installed native runtime binary itself (e.g.
+ // .../Install/Base/WiiCompiled) - each profile already gets its own .desktop file here,
+ // so there is no need to route through the setup tool's own launch-base/launch-retro
+ // subcommand dispatch first. Unquoted: the Desktop Entry spec's Exec grammar doesn't take
+ // a bare '"'-wrapped path, and none is needed here anyway - the only part of this path
+ // that varies is the username, which Unix forbids containing whitespace.
+ Directory.CreateDirectory(ApplicationsDirectory);
+ var contents =
+ "[Desktop Entry]\n" +
+ "Type=Application\n" +
+ $"Name={displayName}\n" +
+ $"Exec={exePath}\n" +
+ "Categories=Game;\n" +
+ "Terminal=false\n";
+ File.WriteAllText(PathFor(profile), contents);
+ }
+
+ public static void Remove(string profile)
+ {
+ var path = PathFor(profile);
+ if (File.Exists(path)) File.Delete(path);
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup.Linux/DiscTool.cs b/Launcher/WiiCompiled.Setup.Linux/DiscTool.cs
new file mode 100644
index 0000000..d130fda
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup.Linux/DiscTool.cs
@@ -0,0 +1,95 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+
+namespace WiiCompiled.Setup.Linux;
+
+///
+/// Validates and extracts the user's own Mario Kart Wii disc via the system-installed
+/// `dolphin-tool` (required prerequisite, per the project's scoping decision to depend on system
+/// tools rather than bundling one) - the Linux analogue of the Windows installer shelling out to a
+/// bundled DolphinTool.exe.
+///
+internal static class DiscTool
+{
+ public static async Task ValidateAndExtractAsync(
+ string isoPath, ProjectManifest manifest, string assetsDirectory,
+ IInstallReporter reporter, CancellationToken cancellationToken)
+ {
+ reporter.Progress(InstallStages.ExtractDisc, "Reading the disc header", 2);
+ var headerJson = await RunAsync(["header", "-i", isoPath, "-j"], cancellationToken);
+ using var header = JsonDocument.Parse(headerJson);
+ var gameId = header.RootElement.GetProperty("game_id").GetString();
+ if (!string.Equals(gameId, manifest.GameId, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"This disc is '{gameId}', not the expected '{manifest.GameId}' (Mario Kart Wii, region {manifest.Region}). " +
+ "Only your own legally-owned copy of that exact game/region can be used.");
+ }
+
+ 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
+ {
+ await RunAsync(["extract", "-i", isoPath, "-g", "-o", scratch, "-q"], cancellationToken);
+
+ var dolPath = Path.Combine(scratch, "DATA", "sys", "main.dol");
+ var relPath = Path.Combine(scratch, "DATA", "files", "rel", "StaticR.rel");
+ if (!File.Exists(dolPath)) throw new FileNotFoundException("dolphin-tool did not produce main.dol", dolPath);
+ if (!File.Exists(relPath)) throw new FileNotFoundException("dolphin-tool 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);
+ }
+ finally
+ {
+ try { Directory.Delete(scratch, recursive: true); } catch { /* best-effort cleanup */ }
+ }
+ }
+
+ private static string Sha256Of(string path)
+ {
+ using var stream = File.OpenRead(path);
+ return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
+ }
+
+ private static async Task RunAsync(string[] arguments, CancellationToken cancellationToken)
+ {
+ var startInfo = new System.Diagnostics.ProcessStartInfo("dolphin-tool")
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+ foreach (var argument in arguments) startInfo.ArgumentList.Add(argument);
+
+ using var process = System.Diagnostics.Process.Start(startInfo)
+ ?? throw new InvalidOperationException("Failed to start dolphin-tool. Is it installed and on PATH?");
+ var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken);
+ var stderr = await process.StandardError.ReadToEndAsync(cancellationToken);
+ await process.WaitForExitAsync(cancellationToken);
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException(
+ $"dolphin-tool {string.Join(' ', arguments)} failed (exit {process.ExitCode}): {stderr}");
+ }
+ return stdout;
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup.Linux/InstallProgress.cs b/Launcher/WiiCompiled.Setup.Linux/InstallProgress.cs
new file mode 100644
index 0000000..e816e62
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup.Linux/InstallProgress.cs
@@ -0,0 +1,209 @@
+using System.Text.Json;
+
+namespace WiiCompiled.Setup.Linux;
+
+// Ported near-verbatim from Launcher/WiiCompiled.Setup/InstallProgress.cs: this whole file is
+// platform-neutral (System.Text.Json + Console only), so the NDJSON --progress-json wire protocol
+// stays byte-for-byte the same shape a future GUI already speaks on Windows.
+
+///
+/// Stable stage identifiers reported by --progress-json. Kept intentionally small for this
+/// lean Linux installer (no toolkit-extraction/publish-transaction stages, since there is no
+/// bundled toolkit or staged workspace copy here - see the plan's "operate on a git checkout"
+/// scoping decision).
+///
+internal static class InstallStages
+{
+ public const string Validate = "validate";
+ public const string ExtractDisc = "extract-disc";
+ public const string Build = "build";
+ public const string Shortcuts = "shortcuts";
+}
+
+///
+/// Where an installation reports what it is doing. Progress is coarse and monotonic; raw translator
+/// and compiler output is a diagnostic, never progress, because it is unbounded and machine-hostile.
+///
+internal interface IInstallReporter
+{
+ void Progress(string stage, string message, int percent);
+ void Diagnostic(string line);
+}
+
+///
+/// The --progress-json protocol: one JSON object per line on stdout, nothing else on stdout,
+/// diagnostics on stderr. The terminal result line is written exactly once.
+///
+internal sealed class NdjsonInstallReporter : IInstallReporter
+{
+ private static readonly JsonSerializerOptions Options = new() { WriteIndented = false };
+ private readonly object _gate = new();
+ private int _lastPercent;
+ private bool _finished;
+
+ public void Progress(string stage, string message, int percent)
+ {
+ lock (_gate)
+ {
+ if (_finished) return;
+ // Percentages are clamped monotonic: a caller's progress bar must never walk backwards
+ // because a later stage happened to estimate a lower number.
+ _lastPercent = Math.Clamp(Math.Max(percent, _lastPercent), 0, 99);
+ WriteLine(new { type = "progress", stage, message, percent = _lastPercent });
+ }
+ }
+
+ public void Diagnostic(string line) => Console.Error.WriteLine(line);
+
+ public void Success(string installDirectory)
+ {
+ lock (_gate)
+ {
+ if (_finished) return;
+ _finished = true;
+ WriteLine(new { type = "result", success = true, version = ProductInfo.Version, installDir = installDirectory });
+ }
+ }
+
+ public void Failure(string error)
+ {
+ lock (_gate)
+ {
+ if (_finished) return;
+ _finished = true;
+ WriteLine(new { type = "result", success = false, error });
+ }
+ }
+
+ ///
+ /// The terminal result line is the caller's only completion signal, so no exit path may skip it.
+ /// Callers invoke this from a finally block; it is a no-op once a result was already written.
+ ///
+ public void EnsureFinished(string errorIfUnfinished) => Failure(errorIfUnfinished);
+
+ private static void WriteLine(object value)
+ {
+ Console.Out.WriteLine(JsonSerializer.Serialize(value, Options));
+ Console.Out.Flush();
+ }
+}
+
+/// Plain-text console reporting for a run without --progress-json.
+internal sealed class ConsoleInstallReporter : IInstallReporter
+{
+ public void Progress(string stage, string message, int percent) =>
+ Console.Out.WriteLine($"[{percent,3}%] {message}");
+
+ public void Diagnostic(string line) => Console.Out.WriteLine(line);
+}
+
+///
+/// Build step identifiers from local-build.sh's MKWCBUILD:STEP:<id> lines - the id is
+/// the contract, matched against Launcher/local-build.sh's log_step() call sites.
+///
+internal static class BuildStepIds
+{
+ public const string BuildTranslator = "build-translator";
+ public const string ReuseBaseTranslation = "reuse-base-translation";
+ public const string RetranslateBase = "retranslate-base";
+ public const string TranslateBase = "translate-base";
+ public const string EmitBaseManifest = "emit-base-manifest";
+ public const string TranslateMod = "translate-mod";
+ public const string GenerateDataInit = "generate-data-init";
+ public const string EmitBuildShards = "emit-build-shards";
+ public const string ConfigureNative = "configure-native";
+ public const string Compile = "compile";
+}
+
+///
+/// Maps one local-build.sh run onto a slice of the overall percentage. local-build.sh announces
+/// every step it starts with an MKWCBUILD: prefix, so the slice can advance on real events
+/// instead of on a timer.
+///
+internal sealed class BuildProgressWindow
+{
+ private const string Marker = "MKWCBUILD:";
+ private const string StepMarker = "STEP:";
+
+ /// The fraction the compile step reaches; beyond it, compiler output is a heartbeat.
+ private const double CompileFraction = 0.58;
+
+ private static readonly (string Id, double Fraction, string Message)[] Steps =
+ [
+ (BuildStepIds.BuildTranslator, 0.04, "Building the translator"),
+ (BuildStepIds.ReuseBaseTranslation, 0.30, "Reusing the completed base translation"),
+ (BuildStepIds.RetranslateBase, 0.08, "The base translation is stale; retranslating it"),
+ (BuildStepIds.TranslateBase, 0.10, "Translating Mario Kart Wii"),
+ (BuildStepIds.EmitBaseManifest, 0.34, "Creating the translation manifest"),
+ (BuildStepIds.TranslateMod, 0.38, "Translating the Retro Rewind Code.pul"),
+ (BuildStepIds.GenerateDataInit, 0.44, "Generating game data initialization"),
+ (BuildStepIds.EmitBuildShards, 0.48, "Preparing the native build"),
+ (BuildStepIds.ConfigureNative, 0.52, "Configuring the compiler"),
+ (BuildStepIds.Compile, CompileFraction, "Compiling the game. This is the longest step"),
+ ];
+
+ private readonly IInstallReporter _reporter;
+ private readonly string _stage;
+ private readonly int _start;
+ private readonly int _end;
+ private double _fraction;
+ private string _message = "Preparing the local build";
+ private int _reportedPercent = -1;
+
+ public BuildProgressWindow(IInstallReporter reporter, string stage, int start, int end)
+ {
+ _reporter = reporter;
+ _stage = stage;
+ _start = start;
+ _end = end;
+ }
+
+ public void Observe(string line)
+ {
+ var index = line.IndexOf(Marker, StringComparison.Ordinal);
+ if (index >= 0)
+ {
+ var text = line[(index + Marker.Length)..].Trim();
+ if (text.StartsWith(StepMarker, StringComparison.Ordinal))
+ {
+ var identifier = text[StepMarker.Length..];
+ var end = identifier.IndexOf(' ');
+ if (end >= 0) identifier = identifier[..end];
+ foreach (var (id, fraction, message) in Steps)
+ {
+ if (!id.Equals(identifier, StringComparison.Ordinal)) continue;
+ if (fraction > _fraction)
+ {
+ _fraction = fraction;
+ _message = message;
+ Emit();
+ }
+ return;
+ }
+ }
+ }
+
+ // Anything else - a plain MKWCBUILD note, or raw tool output - stays a diagnostic and only
+ // feeds the heartbeat below.
+ _reporter.Diagnostic(line);
+ // Compilation announces itself once and then emits thousands of compiler lines. Treat that
+ // output as a heartbeat so the slice keeps creeping forward, but only publish a progress
+ // line when the rounded percentage actually changes.
+ if (_fraction >= CompileFraction)
+ {
+ _fraction = Math.Min(0.97, _fraction + 0.0015);
+ Emit();
+ }
+ }
+
+ private void Emit()
+ {
+ var percent = Interpolate(_fraction);
+ if (percent == _reportedPercent) return;
+ _reportedPercent = percent;
+ _reporter.Progress(_stage, _message, percent);
+ }
+
+ private int Interpolate(double fraction) =>
+ (int)Math.Round(_start + (_end - _start) * Math.Clamp(fraction, 0, 1));
+}
diff --git a/Launcher/WiiCompiled.Setup.Linux/JsonState.cs b/Launcher/WiiCompiled.Setup.Linux/JsonState.cs
new file mode 100644
index 0000000..c068d91
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup.Linux/JsonState.cs
@@ -0,0 +1,33 @@
+using System.Text.Json;
+
+namespace WiiCompiled.Setup.Linux;
+
+/// Reads and atomically writes the small JSON state documents this tool keeps.
+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 re-run install", not into a crash.
+ return null;
+ }
+ }
+
+ public static void Write(string path, T value)
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
+ var tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N");
+ File.WriteAllText(tempPath, JsonSerializer.Serialize(value, WriteOptions));
+ File.Move(tempPath, path, overwrite: true);
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup.Linux/Models.cs b/Launcher/WiiCompiled.Setup.Linux/Models.cs
new file mode 100644
index 0000000..b9c9671
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup.Linux/Models.cs
@@ -0,0 +1,31 @@
+namespace WiiCompiled.Setup.Linux;
+
+internal static class ProductInfo
+{
+ public const string Name = "WiiCompiled";
+ public const string Version = "0.2.22";
+}
+
+/// One installed product's record inside install-state.json.
+internal sealed class ProductInstallRecord
+{
+ public string Profile { get; set; } = "";
+ public string InstallDirectory { get; set; } = "";
+ public string ExecutableName { get; set; } = "";
+ public string DolSha256 { get; set; } = "";
+ public string RelSha256 { get; set; } = "";
+ public string BuiltUtc { get; set; } = "";
+}
+
+///
+/// The whole flat state document this tool keeps at ~/.local/share/WiiCompiled/install-state.json.
+/// Deliberately not a fingerprint tree: local-build.sh already does its own incremental-rebuild
+/// caching, so this only needs to remember where things were installed and what they were built
+/// against, not decide when to rebuild.
+///
+internal sealed class InstallState
+{
+ public int SchemaVersion { get; set; } = 1;
+ public string Workspace { get; set; } = "";
+ public List Products { get; set; } = new();
+}
diff --git a/Launcher/WiiCompiled.Setup.Linux/Program.cs b/Launcher/WiiCompiled.Setup.Linux/Program.cs
new file mode 100644
index 0000000..4c0dcad
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup.Linux/Program.cs
@@ -0,0 +1,254 @@
+using System.Security.Cryptography;
+
+namespace WiiCompiled.Setup.Linux;
+
+internal static class Program
+{
+ private static async Task Main(string[] args)
+ {
+ if (args.Length == 0 || args[0] is "-h" or "--help") { PrintUsage(); return 0; }
+ if (args[0] == "--version") { Console.WriteLine(ProductInfo.Version); return 0; }
+
+ using var cts = new CancellationTokenSource();
+ // Replaces CancellationSignal.cs's named-EventWaitHandle IPC (Windows-only): SIGINT/SIGTERM
+ // are the portable, standard way for a parent (Wheel Wizard or a shell) to cancel this
+ // process and the build it spawned.
+ using var sigint = System.Runtime.InteropServices.PosixSignalRegistration.Create(
+ System.Runtime.InteropServices.PosixSignal.SIGINT, context => { context.Cancel = true; cts.Cancel(); });
+ using var sigterm = System.Runtime.InteropServices.PosixSignalRegistration.Create(
+ System.Runtime.InteropServices.PosixSignal.SIGTERM, context => { context.Cancel = true; cts.Cancel(); });
+ return await RunAsync(args, cts);
+ }
+
+ private static async Task RunAsync(string[] args, CancellationTokenSource cts)
+ {
+ var command = args[0];
+ var rest = args[1..];
+ var flags = ParseFlags(rest);
+ var progressJson = flags.ContainsKey("progress-json");
+ IInstallReporter reporter = progressJson ? new NdjsonInstallReporter() : new ConsoleInstallReporter();
+
+ try
+ {
+ switch (command)
+ {
+ case "install":
+ await InstallAsync(flags, reporter, cts.Token);
+ break;
+ case "uninstall":
+ Uninstall(flags);
+ break;
+ case "launch-base":
+ return Launch("base", flags);
+ case "launch-retro":
+ return Launch("retro-rewind", flags);
+ case "check-products":
+ CheckProducts();
+ break;
+ default:
+ Console.Error.WriteLine($"Unknown command: {command}");
+ PrintUsage();
+ return 1;
+ }
+ (reporter as NdjsonInstallReporter)?.Success(flags.GetValueOrDefault("install-dir") ?? "");
+ return 0;
+ }
+ catch (OperationCanceledException)
+ {
+ Console.Error.WriteLine("Cancelled.");
+ (reporter as NdjsonInstallReporter)?.Failure("cancelled");
+ return 130;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"error: {ex.Message}");
+ (reporter as NdjsonInstallReporter)?.Failure(ex.Message);
+ return 1;
+ }
+ }
+
+ 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 workspace = flags.GetValueOrDefault("workspace") ?? WorkspaceLocator.FindFrom(AppContext.BaseDirectory);
+ var manifest = ProjectManifest.Load(Path.Combine(workspace, "projects", "mkwii", "recomp.yml"));
+ var assetsDir = Path.Combine(workspace, "Assets");
+
+ reporter.Progress(InstallStages.Validate, "Checking prerequisites", 1);
+ if (flags.TryGetValue("game", out var isoPath) && !string.IsNullOrEmpty(isoPath))
+ {
+ await DiscTool.ValidateAndExtractAsync(isoPath, manifest, assetsDir, reporter, token);
+ }
+ else
+ {
+ var dol = Path.Combine(assetsDir, "main.dol");
+ var rel = Path.Combine(assetsDir, "StaticR.rel");
+ if (!File.Exists(dol) || !File.Exists(rel))
+ {
+ throw new InvalidOperationException(
+ "No --game ISO was given and Assets/main.dol + Assets/StaticR.rel are not already present. " +
+ "Either pass --game , or extract them yourself first (see translator/README.md).");
+ }
+ }
+
+ 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);
+
+ 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"),
+ flags.ContainsKey("force-clean-build"),
+ reporter, token);
+
+ reporter.Progress(InstallStages.Shortcuts, "Creating shortcuts", 98);
+ var dolSha = Sha256Of(Path.Combine(assetsDir, "main.dol"));
+ var relSha = Sha256Of(Path.Combine(assetsDir, "StaticR.rel"));
+
+ foreach (var p in profiles)
+ {
+ var dir = p == "base" ? (baseInstallDir ?? installDir) : installDir;
+ var exeName = p == "base" ? "WiiCompiled" : "RetroRewind";
+ var displayName = p == "base" ? "WiiCompiled (base game)" : "WiiCompiled (Retro Rewind)";
+ state.Products.RemoveAll(r => r.Profile == p);
+ state.Products.Add(new ProductInstallRecord
+ {
+ Profile = p,
+ InstallDirectory = dir,
+ ExecutableName = exeName,
+ DolSha256 = dolSha,
+ RelSha256 = relSha,
+ BuiltUtc = DateTime.UtcNow.ToString("O"),
+ });
+ DesktopEntry.Create(p, displayName, Path.Combine(dir, exeName));
+ }
+ JsonState.Write(StatePath, state);
+ reporter.Progress(InstallStages.Shortcuts, "Install complete", 99);
+ }
+
+ private static void Uninstall(Dictionary flags)
+ {
+ var profile = flags.GetValueOrDefault("profile") ?? "all";
+ 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)
+ {
+ if (Directory.Exists(record.InstallDirectory))
+ {
+ Directory.Delete(record.InstallDirectory, recursive: true);
+ }
+ DesktopEntry.Remove(record.Profile);
+ state.Products.Remove(record);
+ Console.WriteLine($"Removed {record.Profile} from {record.InstallDirectory}");
+ }
+ JsonState.Write(StatePath, state);
+ }
+
+ private static int Launch(string profile, Dictionary flags)
+ {
+ var state = JsonState.TryRead(StatePath);
+ var record = state?.Products.FirstOrDefault(r => r.Profile == profile);
+ if (record is null)
+ {
+ var installHint = profile == "retro-rewind"
+ ? "install --retro-dir {--download-retro-wfc-payload | --skip-retro-wfc-payload}"
+ : $"install --profile {profile}";
+ Console.Error.WriteLine($"{profile} is not installed. Run '{installHint}' first.");
+ return 1;
+ }
+ var exePath = Path.Combine(record.InstallDirectory, record.ExecutableName);
+ if (!File.Exists(exePath))
+ {
+ Console.Error.WriteLine($"Installed executable is missing: {exePath}. Run 'install --profile {profile}' again.");
+ return 1;
+ }
+ var startInfo = new System.Diagnostics.ProcessStartInfo(exePath)
+ {
+ WorkingDirectory = record.InstallDirectory,
+ UseShellExecute = false,
+ };
+ using var process = System.Diagnostics.Process.Start(startInfo);
+ process?.WaitForExit();
+ return process?.ExitCode ?? 1;
+ }
+
+ private static void CheckProducts()
+ {
+ var state = JsonState.TryRead(StatePath);
+ if (state is null || state.Products.Count == 0)
+ {
+ Console.WriteLine("Nothing installed.");
+ return;
+ }
+ var assetsDir = Path.Combine(state.Workspace, "Assets");
+ var currentDol = Sha256IfExists(Path.Combine(assetsDir, "main.dol"));
+ var currentRel = Sha256IfExists(Path.Combine(assetsDir, "StaticR.rel"));
+ foreach (var record in state.Products)
+ {
+ var exePath = Path.Combine(record.InstallDirectory, record.ExecutableName);
+ var present = File.Exists(exePath);
+ var stale = present && (currentDol != record.DolSha256 || currentRel != record.RelSha256);
+ var status = !present ? "MISSING" : stale ? "STALE (game assets changed since last build)" : "current";
+ Console.WriteLine($"{record.Profile,-14} {status,-45} {record.InstallDirectory}");
+ }
+ }
+
+ private static string Sha256Of(string path)
+ {
+ using var stream = File.OpenRead(path);
+ return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
+ }
+
+ private static string? Sha256IfExists(string path) => File.Exists(path) ? Sha256Of(path) : null;
+
+ private static string StatePath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WiiCompiled", "install-state.json");
+
+ private static string DefaultInstallDir(string profile) => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WiiCompiled", "Install",
+ profile == "base" ? "Base" : "RetroRewind");
+
+ private static Dictionary ParseFlags(string[] args)
+ {
+ var flags = new Dictionary();
+ for (var i = 0; i < args.Length; i++)
+ {
+ var arg = args[i];
+ if (!arg.StartsWith("--", StringComparison.Ordinal)) continue;
+ var name = arg[2..];
+ if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
+ {
+ flags[name] = args[++i];
+ }
+ else
+ {
+ flags[name] = null; // boolean flag
+ }
+ }
+ return flags;
+ }
+
+ private static void PrintUsage()
+ {
+ 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]
+ [--force-clean-build] [--progress-json] [--workspace DIR]
+ uninstall [--profile {base|retro-rewind|both|all}]
+ launch-base
+ launch-retro
+ check-products
+ --version
+ """);
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup.Linux/ProjectManifest.cs b/Launcher/WiiCompiled.Setup.Linux/ProjectManifest.cs
new file mode 100644
index 0000000..43b9a21
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup.Linux/ProjectManifest.cs
@@ -0,0 +1,70 @@
+using System.Text.RegularExpressions;
+
+namespace WiiCompiled.Setup.Linux;
+
+///
+/// The handful of facts this tool needs out of projects/mkwii/recomp.yml. Parsed literally line by
+/// line - the same approach Launcher/NativeBuildFlags.ps1's Get-MkwProjectPins and
+/// Launcher/local-build.sh already use - rather than pulling in a YAML library, since the manifest
+/// is machine-written with a fixed shape.
+///
+internal sealed class ProjectManifest
+{
+ public required string GameId { get; init; }
+ public required string Region { get; init; }
+ public required string DolSha256 { get; init; }
+ public required string RelSha256 { get; init; }
+
+ public static ProjectManifest Load(string path)
+ {
+ if (!File.Exists(path)) throw new FileNotFoundException("Translation project file is missing", path);
+
+ string? gameId = null, region = null, dolSha = null, relSha = null;
+ string section = "";
+ string inputKey = "";
+
+ foreach (var raw in File.ReadLines(path))
+ {
+ var line = Regex.Replace(raw, "#.*$", "");
+ if (string.IsNullOrWhiteSpace(line)) continue;
+
+ var sectionMatch = Regex.Match(line, "^([A-Za-z0-9_]+):");
+ if (sectionMatch.Success)
+ {
+ section = sectionMatch.Groups[1].Value;
+ inputKey = "";
+ continue;
+ }
+
+ if (section == "inputs")
+ {
+ var keyMatch = Regex.Match(line, @"^\s{2}([A-Za-z0-9_]+):\s*$");
+ if (keyMatch.Success) { inputKey = keyMatch.Groups[1].Value; continue; }
+
+ var shaMatch = Regex.Match(line, @"^\s*sha256:\s*([0-9a-fA-F]{64})\s*$");
+ if (shaMatch.Success)
+ {
+ var value = shaMatch.Groups[1].Value.ToLowerInvariant();
+ if (inputKey == "dol") dolSha = value;
+ else if (inputKey == "rel") relSha = value;
+ }
+ }
+ else if (section == "project")
+ {
+ var idMatch = Regex.Match(line, @"^\s*game_id:\s*(\S+)\s*$");
+ if (idMatch.Success) gameId = idMatch.Groups[1].Value;
+
+ var regionMatch = Regex.Match(line, @"^\s*region:\s*(\S+)\s*$");
+ if (regionMatch.Success) region = regionMatch.Groups[1].Value;
+ }
+ }
+
+ if (gameId is null || region is null || dolSha is null || relSha is null)
+ {
+ throw new InvalidDataException(
+ $"{path} does not pin game_id/region/dol.sha256/rel.sha256; the project file is not the shape this tool expects.");
+ }
+
+ return new ProjectManifest { GameId = gameId, Region = region, DolSha256 = dolSha, RelSha256 = relSha };
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj
new file mode 100644
index 0000000..0b19ca5
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj
@@ -0,0 +1,17 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ WiiCompiled.Setup.Linux
+ WiiCompiled.Setup.Linux
+ 0.2.22
+ patchzy
+ WiiCompiled
+ Command-line installer and launcher for WiiCompiled on Linux
+ embedded
+ en
+ true
+
+
diff --git a/Launcher/WiiCompiled.Setup.Linux/WorkspaceLocator.cs b/Launcher/WiiCompiled.Setup.Linux/WorkspaceLocator.cs
new file mode 100644
index 0000000..99d9a93
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup.Linux/WorkspaceLocator.cs
@@ -0,0 +1,27 @@
+namespace WiiCompiled.Setup.Linux;
+
+///
+/// Finds the repo checkout this tool is running from by walking up from its own directory looking
+/// for Launcher/local-build.sh - this tool operates directly on a git checkout (no bundled/staged
+/// workspace copy), so there is no installed "Toolkit" layout to anchor on the way the Windows
+/// installer's Installation.cs does.
+///
+internal static class WorkspaceLocator
+{
+ private const int MaxSearchDepth = 6;
+
+ public static string FindFrom(string startDirectory)
+ {
+ var current = new DirectoryInfo(startDirectory);
+ for (var level = 0; level <= MaxSearchDepth && current is not null; level++, current = current.Parent)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "Launcher", "local-build.sh")))
+ {
+ return current.FullName;
+ }
+ }
+ throw new InvalidOperationException(
+ "Could not find the WiiCompiled repository (looked for Launcher/local-build.sh walking up " +
+ $"from {startDirectory}). Pass --workspace explicitly.");
+ }
+}