partial linux setup/install scripting

This commit is contained in:
theofficialgman
2026-08-25 22:48:00 -04:00
parent 48c4df342f
commit e3c4028b50
10 changed files with 851 additions and 0 deletions
@@ -0,0 +1,75 @@
using System.Diagnostics;
namespace WiiCompiled.Setup.Linux;
/// <summary>
/// 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.
/// </summary>
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 */ }
}
}
@@ -0,0 +1,40 @@
namespace WiiCompiled.Setup.Linux;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -0,0 +1,95 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace WiiCompiled.Setup.Linux;
/// <summary>
/// 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.
/// </summary>
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<string> 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;
}
}
@@ -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.
/// <summary>
/// Stable stage identifiers reported by <c>--progress-json</c>. 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).
/// </summary>
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";
}
/// <summary>
/// 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.
/// </summary>
internal interface IInstallReporter
{
void Progress(string stage, string message, int percent);
void Diagnostic(string line);
}
/// <summary>
/// The <c>--progress-json</c> protocol: one JSON object per line on stdout, nothing else on stdout,
/// diagnostics on stderr. The terminal <c>result</c> line is written exactly once.
/// </summary>
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 });
}
}
/// <summary>
/// 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.
/// </summary>
public void EnsureFinished(string errorIfUnfinished) => Failure(errorIfUnfinished);
private static void WriteLine(object value)
{
Console.Out.WriteLine(JsonSerializer.Serialize(value, Options));
Console.Out.Flush();
}
}
/// <summary>Plain-text console reporting for a run without <c>--progress-json</c>.</summary>
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);
}
/// <summary>
/// Build step identifiers from local-build.sh's <c>MKWCBUILD:STEP:&lt;id&gt;</c> lines - the id is
/// the contract, matched against Launcher/local-build.sh's log_step() call sites.
/// </summary>
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";
}
/// <summary>
/// Maps one local-build.sh run onto a slice of the overall percentage. local-build.sh announces
/// every step it starts with an <c>MKWCBUILD:</c> prefix, so the slice can advance on real events
/// instead of on a timer.
/// </summary>
internal sealed class BuildProgressWindow
{
private const string Marker = "MKWCBUILD:";
private const string StepMarker = "STEP:";
/// <summary>The fraction the compile step reaches; beyond it, compiler output is a heartbeat.</summary>
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));
}
@@ -0,0 +1,33 @@
using System.Text.Json;
namespace WiiCompiled.Setup.Linux;
/// <summary>Reads and atomically writes the small JSON state documents this tool keeps.</summary>
internal static class JsonState
{
private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true };
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
public static T? TryRead<T>(string path) where T : class
{
try
{
return File.Exists(path) ? JsonSerializer.Deserialize<T>(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<T>(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);
}
}
@@ -0,0 +1,31 @@
namespace WiiCompiled.Setup.Linux;
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
public const string Version = "0.2.22";
}
/// <summary>One installed product's record inside install-state.json.</summary>
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; } = "";
}
/// <summary>
/// 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.
/// </summary>
internal sealed class InstallState
{
public int SchemaVersion { get; set; } = 1;
public string Workspace { get; set; } = "";
public List<ProductInstallRecord> Products { get; set; } = new();
}
+254
View File
@@ -0,0 +1,254 @@
using System.Security.Cryptography;
namespace WiiCompiled.Setup.Linux;
internal static class Program
{
private static async Task<int> 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<int> 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<string, string?> 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 <path-to-iso>, or extract them yourself first (see translator/README.md).");
}
}
var state = JsonState.TryRead<InstallState>(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<string, string?> flags)
{
var profile = flags.GetValueOrDefault("profile") ?? "all";
var state = JsonState.TryRead<InstallState>(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<string, string?> flags)
{
var state = JsonState.TryRead<InstallState>(StatePath);
var record = state?.Products.FirstOrDefault(r => r.Profile == profile);
if (record is null)
{
var installHint = profile == "retro-rewind"
? "install --retro-dir <RetroRewind6> {--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<InstallState>(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<string, string?> ParseFlags(string[] args)
{
var flags = new Dictionary<string, string?>();
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 <command> [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
""");
}
}
@@ -0,0 +1,70 @@
using System.Text.RegularExpressions;
namespace WiiCompiled.Setup.Linux;
/// <summary>
/// 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.
/// </summary>
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 };
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WiiCompiled.Setup.Linux</AssemblyName>
<RootNamespace>WiiCompiled.Setup.Linux</RootNamespace>
<Version>0.2.22</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Command-line installer and launcher for WiiCompiled on Linux</Description>
<DebugType>embedded</DebugType>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
@@ -0,0 +1,27 @@
namespace WiiCompiled.Setup.Linux;
/// <summary>
/// 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.
/// </summary>
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 <path-to-checkout> explicitly.");
}
}