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."); + } +}