mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-09-11 17:31:29 -04:00
partial linux setup/install scripting
This commit is contained in:
@@ -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:<id></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();
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user