refactor WiiCompiled.Setup into WiiCompiled.Setup.Windows and add WiiCompiled.Setup.Common

the idea behind this is C# code that is OS agnostic can go in WiiCompiled.Setup.Common to be shared by any OS specific code (eg: WiiCompiled.Setup.Windows and WiiCompiled.Setup.Linux).
This commit is contained in:
theofficialgman
2026-08-26 20:10:20 -04:00
parent f09590aa5d
commit c0ed2bfbeb
48 changed files with 444 additions and 340 deletions
@@ -0,0 +1,75 @@
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Bridges a frontend-owned, named Windows event into the cancellation token used by setup.
/// </summary>
internal sealed class CancellationSignal : IDisposable
{
public const string EnvironmentVariable = "MKWCOMPILED_CANCEL_EVENT";
private readonly EventWaitHandle? _event;
private readonly CancellationTokenSource? _source;
private readonly RegisteredWaitHandle? _registration;
private CancellationSignal(EventWaitHandle? @event, CancellationTokenSource? source,
RegisteredWaitHandle? registration)
{
_event = @event;
_source = source;
_registration = registration;
}
public CancellationToken Token => _source?.Token ?? CancellationToken.None;
public static CancellationSignal ObserveEnvironment()
{
var eventName = Environment.GetEnvironmentVariable(EnvironmentVariable);
if (eventName is null)
return new CancellationSignal(null, null, null);
if (string.IsNullOrWhiteSpace(eventName))
throw new InvalidOperationException(
$"The cancellation event named by {EnvironmentVariable} is empty.");
EventWaitHandle signal;
try
{
signal = EventWaitHandle.OpenExisting(eventName.Trim());
}
catch (Exception ex) when (ex is WaitHandleCannotBeOpenedException or UnauthorizedAccessException
or IOException or ArgumentException)
{
throw new InvalidOperationException(
$"The cancellation event named by {EnvironmentVariable} could not be opened.", ex);
}
var source = new CancellationTokenSource();
RegisteredWaitHandle registration;
try
{
registration = ThreadPool.RegisterWaitForSingleObject(signal, static (state, _) =>
{
try { ((CancellationTokenSource)state!).Cancel(); }
catch (ObjectDisposedException) { }
}, source, Timeout.Infinite, executeOnlyOnce: true);
// Registering and checking explicitly closes the race where the event was already set
// before RegisterWaitForSingleObject installed its wait.
if (signal.WaitOne(0)) source.Cancel();
}
catch
{
source.Dispose();
signal.Dispose();
throw;
}
return new CancellationSignal(signal, source, registration);
}
public void Dispose()
{
_registration?.Unregister(null);
_event?.Dispose();
_source?.Dispose();
}
}
@@ -0,0 +1,191 @@
namespace WiiCompiled.Setup.Windows;
internal enum AppMode
{
Help,
SilentInstall,
VerifyInputs,
Uninstall,
SilentUninstall,
UninstallWorker,
SelfTest,
LaunchBase,
LaunchRetro,
CheckProducts,
RepairProducts,
Version,
EmitPayloadIdentities
}
/// <summary>
/// What each mode accepts. Every cross-flag rule below is expressed here once instead of as a
/// per-mode exception list, so adding a mode cannot silently inherit another mode's inputs.
/// </summary>
internal sealed record ModeRules
{
public string Flag { get; init; } = "this command";
public bool AcceptsProgressJson { get; init; }
public bool AcceptsGame { get; init; }
public bool RequiresGame { get; init; }
public bool AcceptsRetroDirectory { get; init; }
public bool RequiresRetroDirectory { get; init; }
public bool AcceptsPayloadMode { get; init; }
public bool RequiresInstallDirectory { get; init; }
public bool RequiresPayloadRoot { get; init; }
public bool AcceptsPortable { get; init; }
}
internal sealed class CommandLine
{
public AppMode Mode { get; private set; } = AppMode.Help;
public string? GamePath { get; private set; }
public string? RetroDirectoryPath { get; private set; }
public RetroWfcPayloadMode RetroWfcPayloadMode { get; private set; }
public string? InstallDirectory { get; private set; }
public bool Quiet { get; private set; }
public bool ProgressJson { get; private set; }
public string? PayloadRootPath { get; private set; }
public bool Portable { get; private set; }
public static bool WantsProgressJson(string[] args) =>
args.Any(argument => argument.Equals("--progress-json", StringComparison.OrdinalIgnoreCase));
public static CommandLine Parse(string[] args)
{
var result = new CommandLine();
for (var i = 0; i < args.Length; i++)
{
switch (args[i].ToLowerInvariant())
{
case "--version": result.Mode = AppMode.Version; break;
case "--silent": result.Mode = AppMode.SilentInstall; break;
case "--verify-inputs": result.Mode = AppMode.VerifyInputs; break;
case "--uninstall": result.Mode = AppMode.Uninstall; break;
case "--silent-uninstall": result.Mode = AppMode.SilentUninstall; result.Quiet = true; break;
case "--uninstall-worker": result.Mode = AppMode.UninstallWorker; break;
case "--self-test": result.Mode = AppMode.SelfTest; break;
case "--launch-base": result.Mode = AppMode.LaunchBase; break;
case "--launch-retro": result.Mode = AppMode.LaunchRetro; break;
case "--check-products": result.Mode = AppMode.CheckProducts; break;
case "--repair-products": result.Mode = AppMode.RepairProducts; break;
case "--portable": result.Portable = true; break;
case "--emit-payload-identities": result.Mode = AppMode.EmitPayloadIdentities; break;
case "--payload-root": result.PayloadRootPath = RequireValue(args, ref i); break;
case "--quiet": result.Quiet = true; break;
case "--progress-json": result.ProgressJson = true; break;
case "--game": result.GamePath = RequireValue(args, ref i); break;
case "--retro-dir": result.RetroDirectoryPath = RequireValue(args, ref i); break;
case "--download-retro-wfc-payload":
if (result.RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable)
throw new ArgumentException("Choose only one Retro-WFC payload mode.");
result.RetroWfcPayloadMode = RetroWfcPayloadMode.Online;
break;
case "--skip-retro-wfc-payload":
if (result.RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable)
throw new ArgumentException("Choose only one Retro-WFC payload mode.");
result.RetroWfcPayloadMode = RetroWfcPayloadMode.Skipped;
break;
case "--install-dir": result.InstallDirectory = RequireValue(args, ref i); break;
default:
throw new ArgumentException($"Unknown CLI option: {args[i]}");
}
}
result.Validate();
return result;
}
private static readonly Dictionary<AppMode, ModeRules> Rules = new()
{
[AppMode.SilentInstall] = new ModeRules
{
Flag = "--silent",
AcceptsProgressJson = true, AcceptsGame = true, RequiresGame = true,
AcceptsRetroDirectory = true, AcceptsPayloadMode = true,
AcceptsPortable = true
},
[AppMode.VerifyInputs] = new ModeRules
{
Flag = "--verify-inputs",
AcceptsProgressJson = true, AcceptsGame = true, RequiresGame = true,
AcceptsRetroDirectory = true
},
[AppMode.CheckProducts] = new ModeRules
{
Flag = "--check-products", AcceptsProgressJson = true, AcceptsRetroDirectory = true
},
[AppMode.RepairProducts] = new ModeRules
{
Flag = "--repair-products",
AcceptsProgressJson = true, AcceptsRetroDirectory = true, RequiresRetroDirectory = true,
AcceptsPayloadMode = true, RequiresInstallDirectory = true
},
[AppMode.LaunchBase] = new ModeRules { Flag = "--launch-base" },
[AppMode.LaunchRetro] = new ModeRules { Flag = "--launch-retro" },
[AppMode.Uninstall] = new ModeRules { Flag = "--uninstall", RequiresInstallDirectory = true },
[AppMode.SilentUninstall] = new ModeRules
{
Flag = "--silent-uninstall", RequiresInstallDirectory = true
},
[AppMode.UninstallWorker] = new ModeRules
{
Flag = "--uninstall-worker", RequiresInstallDirectory = true
},
[AppMode.EmitPayloadIdentities] = new ModeRules
{
Flag = "--emit-payload-identities", RequiresPayloadRoot = true
}
};
private void Validate()
{
var rules = Rules.GetValueOrDefault(Mode) ?? new ModeRules();
void Reject(bool present, string option)
{
if (present) throw new ArgumentException($"{option} is not valid with {rules.Flag}.");
}
Reject(GamePath is not null && !rules.AcceptsGame, "--game");
Reject(RetroDirectoryPath is not null && !rules.AcceptsRetroDirectory, "--retro-dir");
Reject(ProgressJson && !rules.AcceptsProgressJson, "--progress-json");
Reject(PayloadRootPath is not null && !rules.RequiresPayloadRoot, "--payload-root");
Reject(Portable && !rules.AcceptsPortable, "--portable");
Reject(RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable && !rules.AcceptsPayloadMode,
"A Retro-WFC payload option");
if (rules.RequiresGame && string.IsNullOrWhiteSpace(GamePath))
throw new ArgumentException("--game is required.");
if (rules.RequiresRetroDirectory && string.IsNullOrWhiteSpace(RetroDirectoryPath))
throw new ArgumentException(
$"--retro-dir is required with {rules.Flag}. Supply Wheel Wizard's canonical Retro Rewind folder.");
if (rules.RequiresPayloadRoot && string.IsNullOrWhiteSpace(PayloadRootPath))
throw new ArgumentException($"--payload-root is required with {rules.Flag}.");
// A portable installation has no default location so the caller must state where that root is.
if (Portable && string.IsNullOrWhiteSpace(InstallDirectory))
throw new ArgumentException(
"--install-dir is required with --portable. Its parent folder becomes the portable root.");
if (Mode == AppMode.SilentInstall && string.IsNullOrWhiteSpace(InstallDirectory))
InstallDirectory = ProductInfo.DefaultInstallDirectory;
if (rules.RequiresInstallDirectory && string.IsNullOrWhiteSpace(InstallDirectory))
throw new ArgumentException($"--install-dir is required with {rules.Flag}.");
// The Retro Rewind source and a payload decision are a pair. a base-only operation
// has no payload to decide.
if (rules.AcceptsPayloadMode && RetroDirectoryPath is not null &&
RetroWfcPayloadMode == RetroWfcPayloadMode.NotApplicable)
throw new ArgumentException(
"Retro Rewind requires --download-retro-wfc-payload or --skip-retro-wfc-payload.");
if (RetroDirectoryPath is null && RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable)
throw new ArgumentException(
"--retro-dir is required when selecting a Retro-WFC payload option.");
}
private static string RequireValue(string[] args, ref int index)
{
if (++index >= args.Length || string.IsNullOrWhiteSpace(args[index]))
throw new ArgumentException($"A value is required after {args[index - 1]}.");
return args[index];
}
}
@@ -0,0 +1,184 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
internal sealed record RetroRewindCompileInputs(
string RetroRewindRoot,
string CodePulSha256,
string CompileInputsSha256);
internal static class CompileInputsFingerprint
{
private const string FormatVersion = "mkwc-retro-compile-inputs-v3";
/// <summary>The exact directory name a build snapshot uses, because it is the mod root name
/// the translator turns into the product's relative DVD overlay root.</summary>
public const string PackageDirectoryName = "RetroRewind6";
public static RetroRewindCompileInputs Compute(string selectedDirectory,
CancellationToken cancellationToken = default)
{
var root = RetroRewindSource.ResolveRetroRewind6(selectedDirectory);
return new RetroRewindCompileInputs(root, ComputeCodePulSha256(root, cancellationToken),
ComputeCompileInputsSha256(root, cancellationToken));
}
public static string ComputeCodePulSha256(string retroRewindRoot,
CancellationToken cancellationToken = default)
{
var codePul = CodePulPath(Path.GetFullPath(retroRewindRoot));
if (!File.Exists(codePul)) throw MissingCodePul(retroRewindRoot);
cancellationToken.ThrowIfCancellationRequested();
return InputValidation.Sha256File(codePul);
}
/// Hashes only the translator inputs without relying on timestamps.
public static string ComputeCompileInputsSha256(string retroRewindRoot,
CancellationToken cancellationToken = default)
{
var root = Path.GetFullPath(retroRewindRoot);
if (!Directory.Exists(root))
throw new DirectoryNotFoundException($"The Retro Rewind folder is missing: {root}");
var codePul = CodePulPath(root);
if (!File.Exists(codePul)) throw MissingCodePul(root);
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
AppendField(hash, FormatVersion);
AppendFile(hash, root, codePul, required: true, cancellationToken);
AppendTopology(hash, "topology:mod-root", root);
AppendTopology(hash, "topology:files", Path.Combine(root, "files"));
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
}
/// <summary>
/// Copies just the compile inputs of a Wheel Wizard-owned Retro Rewind folder into
/// operation-owned scratch and proves the copy is a faithful snapshot. This is the Code.pul
/// </summary>
public static RetroRewindCompileInputs Snapshot(string selectedDirectory, string scratchDirectory,
CancellationToken cancellationToken = default)
{
var source = Compute(selectedDirectory, cancellationToken);
var destination = Path.Combine(Path.GetFullPath(scratchDirectory), PackageDirectoryName);
if (Directory.Exists(destination) || File.Exists(destination))
throw new IOException($"The compile-input snapshot destination already exists: {destination}");
CopyCompileInputs(source.RetroRewindRoot, destination, cancellationToken);
var snapshot = new RetroRewindCompileInputs(destination,
ComputeCodePulSha256(destination, cancellationToken),
ComputeCompileInputsSha256(destination, cancellationToken));
// A snapshot that hashes exactly like its source is that source; anything else means the
// folder was being written while it was read, which is the frontend's lease to hold.
if (!snapshot.CodePulSha256.Equals(source.CodePulSha256, StringComparison.OrdinalIgnoreCase) ||
!snapshot.CompileInputsSha256.Equals(source.CompileInputsSha256,
StringComparison.OrdinalIgnoreCase))
{
throw new IOException(
"The Retro Rewind compile inputs changed while they were being copied. " +
"Wait for its update to finish and retry.");
}
return snapshot;
}
private static void CopyCompileInputs(string root, string destination,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(destination);
CopyInput(root, destination, CodePulPath(root), required: true, cancellationToken);
ReproduceTopology(root, destination, "files", cancellationToken);
}
private static void CopyInput(string root, string destination, string path, bool required,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (!File.Exists(path))
{
if (required) throw MissingCompileInput(path);
return;
}
RejectLink(path);
var output = Path.Combine(destination, RelativePath(root, path)
.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(output)!);
File.Copy(path, output, overwrite: true);
}
private static void ReproduceTopology(string root, string destination, string relative,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var source = Path.Combine(root, relative);
if (!File.Exists(source) && !Directory.Exists(source)) return;
RejectLink(source);
var output = Path.Combine(destination, relative);
if (Directory.Exists(source)) Directory.CreateDirectory(output);
else File.Copy(source, output, overwrite: true);
}
private static void AppendFile(IncrementalHash hash, string root, string path, bool required,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var relative = RelativePath(root, path);
if (!File.Exists(path))
{
if (required) throw MissingCompileInput(path);
AppendField(hash, "file:" + relative);
AppendField(hash, "absent");
return;
}
RejectLink(path);
AppendField(hash, "file:" + relative);
AppendField(hash, InputValidation.Sha256File(path));
}
private static void AppendTopology(IncrementalHash hash, string name, string path)
{
AppendField(hash, name);
if (!File.Exists(path) && !Directory.Exists(path))
{
AppendField(hash, "absent");
return;
}
var attributes = File.GetAttributes(path);
var kind = Directory.Exists(path) ? "directory" : "file";
if ((attributes & FileAttributes.ReparsePoint) != 0) kind += "+link";
AppendField(hash, kind);
}
private static void AppendField(IncrementalHash hash, string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
Span<byte> length = stackalloc byte[sizeof(int)];
BinaryPrimitives.WriteInt32LittleEndian(length, bytes.Length);
hash.AppendData(length);
hash.AppendData(bytes);
}
private static string RelativePath(string root, string path) =>
Path.GetRelativePath(root, path).Replace('\\', '/');
private static string CodePulPath(string root) => Path.Combine(root, "Binaries", "Code.pul");
private static void RejectLink(string path)
{
if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
throw new InvalidDataException(
$"The Retro Rewind compile inputs contain a link instead of a regular entry: {path}");
}
private static InvalidDataException MissingCompileInput(string path) =>
new($"The Retro Rewind compile input is missing: {path}");
private static InvalidDataException MissingCodePul(string retroRewindRoot) =>
new($"The Retro Rewind folder is missing Binaries\\Code.pul: {retroRewindRoot}");
}
@@ -0,0 +1,329 @@
using System.Text.Json;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
internal static class ConsoleCommands
{
public static int Help()
{
Console.Out.WriteLine($"{ProductInfo.Name} command-line setup {ProductInfo.Version}");
Console.Out.WriteLine("Wheel Wizard is the graphical interface for installing and launching WiiCompiled.");
Console.Out.WriteLine();
Console.Out.WriteLine("Commands:");
Console.Out.WriteLine(" --silent --game <image> --install-dir <dir> [--retro-dir <folder>] [--portable]");
Console.Out.WriteLine(" --verify-inputs --game <image> [--retro-dir <folder>]");
Console.Out.WriteLine(" --check-products [--install-dir <dir>] [--retro-dir <folder>] [--progress-json]");
Console.Out.WriteLine(" --repair-products --install-dir <dir> --retro-dir <folder> " +
"(--download-retro-wfc-payload | --skip-retro-wfc-payload) [--progress-json]");
Console.Out.WriteLine(" --launch-retro | --launch-base");
Console.Out.WriteLine(" --uninstall --install-dir <dir>");
Console.Out.WriteLine(" --version");
return 0;
}
public static int Version()
{
Console.Out.WriteLine(ProductInfo.Version);
return 0;
}
public static int EmitPayloadIdentities(CommandLine command)
{
var root = Path.GetFullPath(command.PayloadRootPath!);
if (!Directory.Exists(InstalledLayout.Toolkit(root)) ||
!Directory.Exists(InstalledLayout.Workspace(root)))
{
throw new InvalidDataException($"{root} is not a staged payload root: expected " +
$"{InstalledLayout.ToolkitDirectoryName} and {InstalledLayout.WorkspaceDirectoryName} " +
"directories.");
}
var components = ToolkitFingerprint.ComputeComponents(root);
var identities = new PayloadIdentities(
components.Compile,
components.Translation,
components.NativeToolchain,
ToolkitFingerprint.ComputePackage(root),
ToolkitFingerprint.ComputeRuntimeAssets(root));
Console.Out.WriteLine(JsonSerializer.Serialize(identities));
return 0;
}
private sealed record PayloadIdentities(string ToolkitFingerprint, string TranslationFingerprint,
string NativeToolchainFingerprint, string ToolkitPackageFingerprint,
string RuntimeAssetsFingerprint);
public static int VerifyInputs(CommandLine command)
{
var reporter = command.ProgressJson ? new NdjsonInstallReporter() : null;
var temp = Path.Combine(Path.GetTempPath(), "mkwc-verify-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temp);
try
{
using var payload = PayloadArchive.OpenCurrent();
var manifest = payload.ReadManifest();
var tool = Path.Combine(temp, "nodtool.exe");
payload.ExtractEntry(InstalledLayout.ToolkitEntryPrefix + "nodtool.exe", tool);
payload.ExtractDirectory(InstalledLayout.ToolkitEntryPrefix + "Redist", temp);
reporter?.Progress(InstallStages.Validate, "Checking the Wii disc image...", 10);
var header = InputValidation.ReadDiscHeaderAsync(tool, command.GamePath!).GetAwaiter().GetResult();
InputValidation.EnsureCompatibleDisc(header, manifest);
if (command.RetroDirectoryPath is not null)
{
reporter?.Progress(InstallStages.Validate, "Checking the Retro Rewind folder...", 80);
_ = CompileInputsFingerprint.Compute(command.RetroDirectoryPath);
}
reporter?.Success(command.InstallDirectory is null
? ProductInfo.DefaultInstallDirectory
: Path.GetFullPath(command.InstallDirectory));
return 0;
}
catch (Exception ex) when (reporter is not null)
{
reporter.Failure(ex.Message);
Console.Error.WriteLine(ex);
return 1;
}
finally
{
reporter?.EnsureFinished("Input verification stopped before it reached a result.");
if (Directory.Exists(temp)) Directory.Delete(temp, recursive: true);
}
}
public static async Task<int> Install(CommandLine command, CancellationToken cancellationToken = default)
{
var logPath = Path.Combine(Path.GetTempPath(), "WiiCompiled-setup.log");
var logLock = new object();
void Log(string message)
{
lock (logLock)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] {message}{Environment.NewLine}");
}
}
File.WriteAllText(logPath, $"{ProductInfo.Name} setup {ProductInfo.Version}{Environment.NewLine}");
var ndjson = command.ProgressJson ? new NdjsonInstallReporter(Log) : null;
IInstallReporter reporter = ndjson ?? (IInstallReporter)new ConsoleInstallReporter(Log);
var installDirectory = Path.GetFullPath(command.InstallDirectory!);
try
{
var engine = new InstallerEngine(reporter);
await engine.InstallAsync(new InstallOptions
{
GamePath = command.GamePath!,
RetroDirectoryPath = command.RetroDirectoryPath,
RetroWfcPayloadMode = command.RetroWfcPayloadMode,
InstallDirectory = installDirectory,
Portable = command.Portable
}, cancellationToken);
ndjson?.Success(installDirectory);
return 0;
}
catch (Exception ex) when (ndjson is not null)
{
// The caller reads stdout as a protocol, so the failure has to arrive as the terminal
// result line; the exception detail belongs on stderr and in the setup log.
ndjson.Failure(ex.Message);
Console.Error.WriteLine(ex);
return 1;
}
finally
{
// Nothing - not a cancellation, not a process-level abort path - may leave the caller
// without the one line it waits for.
ndjson?.EnsureFinished("The installer stopped before it reached a result.");
}
}
/// <summary>Reports whether the installed products are current, without building, mutating, networking,
/// or walking the Retro Rewind asset tree. Exit code 2 means work is required, not a protocol failure.</summary>
public static int CheckProducts(CommandLine command,
CancellationToken cancellationToken = default)
{
var reporter = command.ProgressJson ? new NdjsonInstallReporter() : null;
var installation = new Installation(string.IsNullOrWhiteSpace(command.InstallDirectory)
? AppContext.BaseDirectory
: Path.GetFullPath(command.InstallDirectory));
try
{
// The lock also performs exact journal/scratch recovery. Acquire it for absent and
// partial roots too: a killed first install can have recoverable state even when the
// product executable/state file has not reached the live directory yet.
using var operationLock = InstallOperationLock.Acquire(installation.Root, reporter);
PortableInstallHealing.HealMovedInstall(installation, reporter);
var report = InspectProducts(installation, command.RetroDirectoryPath,
cancellationToken: cancellationToken);
Console.Out.WriteLine(command.ProgressJson
? SerializeProductsReport(report)
: $"base: {report.Base.Reason} {report.Base.Detail}".TrimEnd());
if (!command.ProgressJson)
Console.Out.WriteLine(
$"retro-rewind: {report.RetroRewind.Reason} {report.RetroRewind.Detail}".TrimEnd());
Console.Out.Flush();
reporter?.Success(installation.Root);
return report.RebuildRequired ? 2 : 0;
}
catch (Exception ex) when (reporter is not null)
{
reporter.Failure(ex.Message);
Console.Error.WriteLine(ex);
return 1;
}
finally
{
reporter?.EnsureFinished("The product check stopped before it reached a result.");
}
}
/// <summary>
/// Reconciles only the product work identified by a preceding health check. Every invocation
/// carries the canonical Retro Rewind folder and one payload option; the final inspection is
/// authoritative, so success is emitted only when every required product is current.
/// </summary>
public static async Task<int> RepairProducts(CommandLine command,
CancellationToken cancellationToken = default)
{
var logPath = Path.Combine(Path.GetTempPath(), "WiiCompiled-repair.log");
var logLock = new object();
void Log(string message)
{
lock (logLock)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] {message}{Environment.NewLine}");
}
}
var ndjson = command.ProgressJson ? new NdjsonInstallReporter(Log) : null;
IInstallReporter reporter = ndjson is null ? new ConsoleInstallReporter(Log) : ndjson;
var installDirectory = Path.GetFullPath(command.InstallDirectory!);
var installation = new Installation(installDirectory);
try
{
if (!installation.IsPresent)
throw new InvalidOperationException(
$"WiiCompiled is not installed at {installDirectory}.");
using var operationLock = InstallOperationLock.Acquire(installDirectory, reporter);
PortableInstallHealing.HealMovedInstall(installation, reporter);
cancellationToken.ThrowIfCancellationRequested();
var service = new ProductRepairService(installation, reporter);
using var scratch = InstallScratchSpace.CreateInsideInstall(installDirectory, reporter);
// Only compile inputs are copied out of the canonical installation, under the frontend's
// source lease. Its assets stay where they are and are read live at launch.
var canonicalRoot = RetroRewindSource.ResolveRetroRewind6(command.RetroDirectoryPath!);
var snapshot = CompileInputsFingerprint.Snapshot(canonicalRoot,
Path.Combine(scratch.Root, "compile-inputs"), cancellationToken);
var options = new ProductRepairService.ReconcileOptions
{
ScratchRoot = scratch.Root,
CanonicalRetroRewindRoot = canonicalRoot
};
var reconciliation = await service.RepairRetroAsync(snapshot, command.RetroWfcPayloadMode,
options, cancellationToken);
// ReconcileAsync is the completion barrier. Cancellation is observed throughout
// preparation and immediately before publication; a request racing with or following
// its uncancellable Publish/Commit must not turn committed state into a failure result.
// Reconciliation returns the exact payload-cache observation it proved or published, so
// terminal health does not hash the same payload a second time.
var after = InspectProducts(installation, snapshot,
reconciliation.CachedRetroWfcPayloadMatches);
if (after.RebuildRequired)
throw new InvalidOperationException(
"Product repair completed without producing a current installation: " +
after.ActionRequiredDetail);
ndjson?.Success(installDirectory);
return 0;
}
catch (Exception ex) when (ndjson is not null)
{
ndjson.Failure(ex is OperationCanceledException ? "Operation canceled." : ex.Message);
Console.Error.WriteLine(ex);
return 1;
}
finally
{
ndjson?.EnsureFinished("Product repair stopped before it reached a result.");
}
}
/// <summary>
/// The <c>products</c> record exactly as the v1 contract defines it: a typed status and a
/// human-readable detail per product, plus one aggregate. Nothing else is derived, because
/// nothing else is reported.
/// </summary>
internal sealed record ProductsReport(string InstallDirectory,
ProductState Base, ProductState RetroRewind)
{
public bool RebuildRequired => Base.ActionRequired || RetroRewind.ActionRequired;
public string ActionRequiredDetail => string.Join(" ",
new[] { Base, RetroRewind }
.Where(state => state.ActionRequired)
.Select(state => state.Detail)
.Where(detail => !string.IsNullOrWhiteSpace(detail)));
}
/// <summary>
/// Inspects both products against the canonical Retro Rewind installation: the caller's explicit
/// folder when it supplied one, otherwise the recorded <c>retro_rewind_root</c>.
/// </summary>
internal static ProductsReport InspectProducts(Installation installation,
string? canonicalRetroDirectory = null, bool? cachedRetroWfcPayloadMatches = null,
CancellationToken cancellationToken = default)
{
if (!installation.IsPresent) return AbsentReport(installation);
var canonical = installation.ResolveCanonicalCompileInputs(canonicalRetroDirectory,
out var canonicalError, cancellationToken);
return BuildReport(installation, canonical, canonicalError, cachedRetroWfcPayloadMatches);
}
/// <summary>
/// Builds the report from a compile-input identity the operation already observed under its
/// lock, so a repair does not hash the same canonical inputs at every decision boundary.
/// </summary>
internal static ProductsReport InspectProducts(Installation installation,
RetroRewindCompileInputs canonical, bool? cachedRetroWfcPayloadMatches = null) =>
installation.IsPresent
? BuildReport(installation, canonical, null, cachedRetroWfcPayloadMatches)
: AbsentReport(installation);
private static ProductsReport AbsentReport(Installation installation) =>
// Probing a path nothing was installed to is a valid answer. It must not create a directory
// or turn the frontend's first-install probe into a repair request.
new(installation.Root,
new ProductState(ProductStatus.Absent, "WiiCompiled is not installed here."),
new ProductState(ProductStatus.Absent, "Retro Rewind is not installed."));
private static ProductsReport BuildReport(Installation installation,
RetroRewindCompileInputs? canonical, string? canonicalError,
bool? cachedRetroWfcPayloadMatches)
{
var toolkitFingerprint = installation.ResolveToolkitFingerprint();
return new ProductsReport(installation.Root,
installation.CheckBase(toolkitFingerprint),
installation.CheckRetroRewind(toolkitFingerprint, canonical, canonicalError,
cachedRetroWfcPayloadMatches));
}
/// <summary>
/// Emits the stable <c>products</c> record. The frontend branches on <c>status</c> only, never
/// on the detail text, and an unrecognized status fails closed on its side.
/// </summary>
internal static string SerializeProductsReport(ProductsReport report) =>
System.Text.Json.JsonSerializer.Serialize(new
{
type = "products",
setupVersion = ProductInfo.Version,
installDir = report.InstallDirectory,
rebuildRequired = report.RebuildRequired,
@base = new { status = report.Base.Reason, detail = report.Base.Detail },
retroRewind = new { status = report.RetroRewind.Reason, detail = report.RetroRewind.Detail }
});
}
@@ -0,0 +1,64 @@
using System.Diagnostics;
namespace WiiCompiled.Setup.Windows;
internal static class GameLaunchService
{
public static Task<int> LaunchAsync(BuildProfile profile)
{
var installation = new Installation(AppContext.BaseDirectory);
using var operation = InstallOperationLock.Acquire(installation.Root);
// A portable installation the user moved is reconciled before anything is read from its
// recorded location. Launch never needs the discarded native build tree.
PortableInstallHealing.HealMovedInstall(installation,
new DelegatingInstallReporter(Console.Error.WriteLine));
EnsureProductIsCurrent(installation, profile);
return Task.FromResult(LaunchInstalledProduct(installation, profile));
}
private static void EnsureProductIsCurrent(Installation installation, BuildProfile profile)
{
var state = InspectProductForLaunch(installation, profile);
if (state.Status == ProductStatus.Current) return;
if (state.Status == ProductStatus.Absent)
throw new InvalidDataException(
"Retro Rewind is not installed. Install Retro Rewind through Wheel Wizard first.");
throw new InvalidDataException(string.IsNullOrWhiteSpace(state.Detail)
? "This product must be repaired through Wheel Wizard before it can be launched."
: state.Detail);
}
/// <summary>Validates the executable, inputs, build provenance, runtime assets, payload cache, and the
/// canonical Retro Rewind root, so a missing/invalid root fails launch instead of starting a vanilla-looking game.</summary>
internal static ProductState InspectProductForLaunch(Installation installation, BuildProfile profile)
{
var toolkitFingerprint = installation.ResolveToolkitFingerprint();
if (profile == BuildProfile.Base)
return installation.CheckBase(toolkitFingerprint);
var canonical = installation.ResolveCanonicalCompileInputs(null, out var canonicalError);
return installation.CheckRetroRewind(toolkitFingerprint, canonical, canonicalError);
}
private static int LaunchInstalledProduct(Installation installation, BuildProfile profile)
{
var runtimeRoot = profile == BuildProfile.Base ? installation.BaseDirectory : installation.RetroDirectory;
var runtime = profile == BuildProfile.Base ? installation.BaseExecutable : installation.RetroExecutable;
if (!File.Exists(runtime))
throw new FileNotFoundException("The installed recomp is missing. Run the installer again.", runtime);
if (!File.Exists(Path.Combine(installation.GameDataDirectory, "sys", "fst.bin")))
throw new InvalidDataException("The installed Mario Kart Wii game data is missing. Run the installer again.");
var info = new ProcessStartInfo
{
FileName = runtime,
WorkingDirectory = runtimeRoot,
UseShellExecute = false
};
using var process = Process.Start(info) ?? throw new InvalidOperationException("Could not start the recomp.");
process.WaitForExit();
return process.ExitCode;
}
}
@@ -0,0 +1,165 @@
using System.Diagnostics;
using System.Buffers.Binary;
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
internal static class InputValidation
{
private static readonly HashSet<string> SupportedDiscImageExtensions = new(
[".iso", ".gcm", ".gcz", ".ciso", ".wbfs", ".wia", ".rvz"],
StringComparer.OrdinalIgnoreCase);
public static void ValidateExtension(string gamePath)
{
if (!File.Exists(gamePath))
throw new FileNotFoundException("The selected game image does not exist.", gamePath);
var extension = Path.GetExtension(gamePath);
if (!SupportedDiscImageExtensions.Contains(extension))
throw new InvalidDataException(
"Select a complete Wii disc image in ISO, GCM, GCZ, CISO, WBFS, WIA, or RVZ format.");
}
public static async Task<DiscHeader> ReadDiscHeaderAsync(string nodTool, string gamePath,
CancellationToken cancellationToken = default)
{
ValidateExtension(gamePath);
var result = await ProcessRunner.RunAsync(nodTool,
["info", Path.GetFullPath(gamePath)], null, cancellationToken);
if (result.ExitCode != 0)
throw new InvalidDataException("nodtool could not read this disc image. " + result.CombinedOutput.Trim());
var info = NodToolInfoParser.Parse(result.StandardOutput);
return new DiscHeader
{
GameId = info.GameId,
InternalName = info.Title,
Region = RegionFromGameId(info.GameId),
Revision = info.Revision,
};
}
private static string RegionFromGameId(string gameId) => gameId.Length >= 4
? gameId[3] switch
{
'P' => "PAL",
'E' => "NTSC-U",
'J' => "NTSC-J",
'K' => "Korea",
'W' => "Taiwan",
_ => gameId[3].ToString(),
}
: "Unknown";
public static void EnsureCompatibleDisc(DiscHeader header, PayloadManifest manifest)
{
if (!header.GameId.Equals(manifest.ExpectedGameId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"This build supports Mario Kart Wii PAL ({manifest.ExpectedGameId}). " +
$"The selected image is {header.GameId} ({header.InternalName}, {header.Region}).");
}
}
// Thin forwarding wrappers: the actual download/RSA-verification logic lives in
// WiiCompiled.Setup.Common.RetroWfcPayload (shared with WiiCompiled.Setup.Linux) so there's one
// copy of it, not two. Kept under these names so every existing call site here
// (ProductRepairService.cs, LocalBuildService.cs, Installation.cs, SelfTests.cs) is unchanged.
public const string CurrentRetroWfcPayloadUri = RetroWfcPayload.CurrentRetroWfcPayloadUri;
public static string ValidateStagedRetroWfcPayloadDirectory(string stagedDirectory,
RSAParameters? signingKey = null) =>
RetroWfcPayload.ValidateStagedRetroWfcPayloadDirectory(stagedDirectory, signingKey);
public static string ResolveRetroWfcPayloadFile(string stagedDirectory,
RSAParameters? signingKey = null) =>
RetroWfcPayload.ResolveRetroWfcPayloadFile(stagedDirectory, signingKey);
public static string ComputeRetroWfcPayloadSha256(string stagedDirectory,
RSAParameters? signingKey = null) =>
RetroWfcPayload.ComputeRetroWfcPayloadSha256(stagedDirectory, signingKey);
public static void ValidateRetroWfcPayloadUri(string uriText) =>
RetroWfcPayload.ValidateRetroWfcPayloadUri(uriText);
public static Task<RetroWfcPayloadSnapshot> DownloadRetroWfcPayloadAsync(string uriText,
string destinationDirectory, CancellationToken cancellationToken) =>
RetroWfcPayload.DownloadRetroWfcPayloadAsync(uriText, destinationDirectory, cancellationToken);
internal static bool IsTransientRetroWfcDownloadFailure(Exception exception,
CancellationToken cancellationToken) =>
RetroWfcPayload.IsTransientRetroWfcDownloadFailure(exception, cancellationToken);
public static string Sha256File(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
}
internal sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError)
{
public string CombinedOutput => StandardOutput + Environment.NewLine + StandardError;
}
internal static class ProcessRunner
{
/// <summary>Runs a redirected child process to completion. <paramref name="configure"/> sets up a
/// working directory or scrubbed environment; <paramref name="capture"/> is off for callers that only
/// forward output live, so a build's output isn't buffered in memory for nobody to read.</summary>
public static async Task<ProcessResult> RunAsync(string executable, IReadOnlyList<string> arguments,
Action<string>? output, CancellationToken cancellationToken,
Action<ProcessStartInfo>? configure = null, bool capture = true,
Action<Exception>? onTerminationFailure = null)
{
var info = new ProcessStartInfo
{
FileName = executable,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
foreach (var argument in arguments) info.ArgumentList.Add(argument);
configure?.Invoke(info);
using var process = new Process { StartInfo = info, EnableRaisingEvents = true };
var stdout = new List<string>();
var stderr = new List<string>();
process.OutputDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stdout.Add(e.Data); output?.Invoke(e.Data); } };
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stderr.Add(e.Data); output?.Invoke(e.Data); } };
if (!process.Start()) throw new InvalidOperationException($"Could not start {executable}.");
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await WaitForExitAsync(process, cancellationToken, onTerminationFailure);
return new ProcessResult(process.ExitCode, string.Join(Environment.NewLine, stdout),
string.Join(Environment.NewLine, stderr));
}
public static async Task WaitForExitAsync(Process process, CancellationToken cancellationToken,
Action<Exception>? onTerminationFailure = null)
{
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
try
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
}
catch (Exception ex)
{
onTerminationFailure?.Invoke(ex);
}
await process.WaitForExitAsync(CancellationToken.None);
process.WaitForExit();
throw;
}
process.WaitForExit();
}
}
@@ -0,0 +1,87 @@
using System.Security.Cryptography;
using System.Text;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// A fail-fast, cross-process lock covering install, repair and launch operations for one install
/// root. The lock file lives beside the installation, so replacing the installation cannot replace
/// or release the lock that protects it.
/// </summary>
internal sealed class InstallOperationLock : IDisposable
{
private readonly FileStream _stream;
private InstallOperationLock(FileStream stream) => _stream = stream;
public string LockPath => _stream.Name;
public static InstallOperationLock Acquire(string installDirectory, IInstallReporter? reporter = null)
{
var root = InstallOperationPaths.NormalizeRoot(installDirectory);
var parent = InstallOperationPaths.GetParent(root);
Directory.CreateDirectory(parent);
var path = InstallOperationPaths.GetLockPath(root);
FileStream stream;
try
{
stream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None,
bufferSize: 1, FileOptions.WriteThrough);
}
catch (IOException ex)
{
throw new InvalidOperationException(
"Another WiiCompiled operation is already using this installation. " +
"Wait for the current game, install, or repair operation to finish.", ex);
}
try
{
InstallTransaction.Recover(root, reporter);
InstallScratchSpace.Recover(root, reporter);
return new InstallOperationLock(stream);
}
catch
{
stream.Dispose();
throw;
}
}
public void Dispose() => _stream.Dispose();
}
/// <summary>Deterministic paths shared by the operation lock and its recovery journal.</summary>
internal static class InstallOperationPaths
{
public static string NormalizeRoot(string installDirectory)
{
if (string.IsNullOrWhiteSpace(installDirectory))
throw new ArgumentException("The installation directory is required.", nameof(installDirectory));
return FileSystemUtilities.NormalizePath(installDirectory);
}
public static string GetParent(string normalizedRoot) =>
Directory.GetParent(normalizedRoot)?.FullName
?? throw new InvalidOperationException("The installation directory must have a parent directory.");
public static string GetScopeId(string normalizedRoot) =>
HashId(16, normalizedRoot.ToUpperInvariant());
public static string HashId(int hexLength, params string[] canonicalParts) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', canonicalParts))))
.ToLowerInvariant()[..hexLength];
public static string GetLockPath(string normalizedRoot) =>
Path.Combine(GetParent(normalizedRoot), $".mkwc-operation-{GetScopeId(normalizedRoot)}.lock");
public static string GetJournalPath(string normalizedRoot) =>
Path.Combine(GetParent(normalizedRoot), $".mkwc-transaction-{GetScopeId(normalizedRoot)}.json");
public static string GetWorkRoot(string normalizedRoot, Guid transactionId) =>
Path.Combine(GetParent(normalizedRoot),
$".mkwc-transaction-{GetScopeId(normalizedRoot)}-{transactionId:N}");
}
@@ -0,0 +1,235 @@
using System.Text.Json;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Stable stage identifiers reported by <c>--progress-json</c>. These are part of the public
/// WheelWizard contract (docs/WHEELWIZARD_CONTRACT.md): the strings may gain new members, but an
/// existing identifier never changes meaning.
/// </summary>
internal static class InstallStages
{
public const string Validate = "validate";
public const string ExtractToolkit = "extract-toolkit";
public const string ExtractDisc = "extract-disc";
public const string PrepareRetro = "prepare-retro";
public const string BuildBase = "build-base";
public const string BuildRetro = "build-retro";
public const string Publish = "publish";
}
/// <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>Reports to a caller-supplied sink; used by console and maintenance commands.</summary>
internal sealed class DelegatingInstallReporter : IInstallReporter
{
private readonly Action<string> _sink;
public DelegatingInstallReporter(Action<string> sink) => _sink = sink;
public void Progress(string stage, string message, int percent) => _sink(message);
public void Diagnostic(string line) => _sink(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 Action<string>? _log;
private readonly object _gate = new();
private int _lastPercent;
private bool _finished;
public NdjsonInstallReporter(Action<string>? log = null) => _log = log;
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 });
}
_log?.Invoke(message);
}
public void Diagnostic(string line)
{
Console.Error.WriteLine(line);
_log?.Invoke(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 });
}
_log?.Invoke("FAILED: " + 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 silent install without <c>--progress-json</c>.</summary>
internal sealed class ConsoleInstallReporter : IInstallReporter
{
private readonly Action<string>? _log;
public ConsoleInstallReporter(Action<string>? log = null) => _log = log;
public void Progress(string stage, string message, int percent)
{
Console.Out.WriteLine($"[{percent,3}%] {message}");
_log?.Invoke(message);
}
public void Diagnostic(string line)
{
Console.Out.WriteLine(line);
_log?.Invoke(line);
}
}
/// <summary>Build step identifiers from the bundled script's <c>MKWCBUILD:STEP:&lt;id&gt;</c> lines. The
/// id is the contract; LocalBuild.ps1 emits these exactly and Test-PinnedFacts.ps1 fails the release if
/// the two sets disagree, so progress never depends on matching reworded English prose.</summary>
internal static class BuildStepIds
{
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 translate-and-compile run onto a slice of the overall percentage. The bundled
/// build script 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.ReuseBaseTranslation, 0.30, "Reusing the completed base translation"),
(BuildStepIds.RetranslateBase, 0.05, "The base translation is stale; retranslating it"),
(BuildStepIds.TranslateBase, 0.08, "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 bundled 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 - an unknown step identifier from a newer script, 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,83 @@
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Owns one temporary directory for an install operation. The name carries the installation's scope
/// id, so the next locked operation can remove trees left by forced process termination without
/// touching scratch belonging to another installation.
/// </summary>
internal sealed class InstallScratchSpace : IDisposable
{
private readonly IInstallReporter? _reporter;
private bool _disposed;
private InstallScratchSpace(string root, IInstallReporter? reporter)
{
Root = root;
_reporter = reporter;
}
public string Root { get; }
/// <summary>Creates staging beside the installation, suitable even when the install is absent.</summary>
public static InstallScratchSpace CreateSibling(string installDirectory, IInstallReporter? reporter = null) =>
Create(InstallOperationPaths.GetParent(InstallOperationPaths.NormalizeRoot(installDirectory)),
installDirectory, reporter);
/// <summary>Creates build/update output inside an existing installation.</summary>
public static InstallScratchSpace CreateInsideInstall(string installDirectory,
IInstallReporter? reporter = null)
{
var installRoot = InstallOperationPaths.NormalizeRoot(installDirectory);
if (!Directory.Exists(installRoot))
throw new DirectoryNotFoundException(
"An in-install scratch directory requires an existing installation root.");
return Create(installRoot, installDirectory, reporter);
}
/// <summary>Deletes scratch left by an interrupted operation. Call only while locked.</summary>
public static void Recover(string installDirectory, IInstallReporter? reporter = null)
{
var installRoot = InstallOperationPaths.NormalizeRoot(installDirectory);
foreach (var container in new[] { InstallOperationPaths.GetParent(installRoot), installRoot })
{
if (!Directory.Exists(container)) continue;
foreach (var leftover in Directory.EnumerateDirectories(container, Pattern(installRoot)))
{
reporter?.Diagnostic("Removing scratch space from an interrupted operation: " + leftover);
FileSystemUtilities.DeleteDirectoryIfExists(leftover);
}
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try
{
FileSystemUtilities.DeleteDirectoryIfExists(Root);
}
catch (Exception ex)
{
// The next locked operation removes this exact tree. A transient AV/file-indexer handle
// must not turn an already committed installation into a reported failure.
_reporter?.Diagnostic("Scratch cleanup is pending and will be retried: " + ex.Message);
}
}
private static InstallScratchSpace Create(string container, string installDirectory,
IInstallReporter? reporter)
{
var installRoot = InstallOperationPaths.NormalizeRoot(installDirectory);
var root = Path.Combine(container,
$".mkwc-scratch-{InstallOperationPaths.GetScopeId(installRoot)}-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
reporter?.Diagnostic("Prepared exact update scratch space at " + root);
return new InstallScratchSpace(root, reporter);
}
private static string Pattern(string installRoot) =>
$".mkwc-scratch-{InstallOperationPaths.GetScopeId(installRoot)}-*";
}
@@ -0,0 +1,394 @@
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
internal enum InstallTransactionEntryKind
{
Directory,
File
}
/// <summary>An exact prepared-path to live-path replacement made by an install transaction.</summary>
internal sealed record InstallTransactionEntry(
string PreparedPath,
string DestinationPath,
InstallTransactionEntryKind Kind)
{
public static InstallTransactionEntry Directory(string preparedPath, string destinationPath) =>
new(preparedPath, destinationPath, InstallTransactionEntryKind.Directory);
public static InstallTransactionEntry File(string preparedPath, string destinationPath) =>
new(preparedPath, destinationPath, InstallTransactionEntryKind.File);
}
/// <summary>
/// Publishes prepared files/directories as one recoverable operation. Destinations are journaled before the
/// first move, so an interrupted operation rolls back on disposal or the next lock acquisition. Because every
/// move is a same-volume rename, rollback needs no per-entry record, just backup/destination/prepared-path presence.
/// </summary>
internal sealed class InstallTransaction : IDisposable
{
// v3 renamed the journal's version field from "Schema" to "SchemaVersion", so that every
// persisted document in an installation spells it the same way. A journal only exists between
// the first move of an update and its commit, and a document from another version has always
// been rejected rather than interpreted, so the rename needs no read compatibility - it only
// needs to be visible as a version change.
private const int SchemaVersion = 3;
private readonly string _journalPath;
private readonly IInstallReporter? _reporter;
private readonly TransactionJournal _journal;
private bool _finished;
private InstallTransaction(string journalPath, TransactionJournal journal, IInstallReporter? reporter)
{
_journalPath = journalPath;
_journal = journal;
_reporter = reporter;
}
public static InstallTransaction Begin(string installDirectory, IInstallReporter? reporter = null,
params InstallTransactionEntry[] entries)
{
var root = InstallOperationPaths.NormalizeRoot(installDirectory);
if (entries is null || entries.Length == 0)
throw new ArgumentException("At least one publish entry is required.", nameof(entries));
var journalPath = InstallOperationPaths.GetJournalPath(root);
if (File.Exists(journalPath))
throw new InvalidOperationException(
"This installation has an unfinished transaction. Acquire the installation operation lock " +
"so it can recover before beginning another transaction.");
var transactionId = Guid.NewGuid();
var workRoot = InstallOperationPaths.GetWorkRoot(root, transactionId);
var journal = new TransactionJournal
{
SchemaVersion = SchemaVersion,
TransactionId = transactionId,
InstallRoot = root,
WorkRoot = workRoot,
State = TransactionState.Prepared,
Entries = NormalizeEntries(root, workRoot, entries)
};
Directory.CreateDirectory(Path.Combine(workRoot, "backup"));
JsonState.Write(journalPath, journal);
return new InstallTransaction(journalPath, journal, reporter);
}
/// <summary>Moves every old destination aside and publishes every prepared entry.</summary>
public void Publish()
{
EnsureActive(TransactionState.Prepared);
try
{
foreach (var entry in _journal.Entries)
{
if (entry.OriginalExisted)
Move(entry.DestinationPath, entry.BackupPath, entry.Kind);
Move(entry.PreparedPath, entry.DestinationPath, entry.Kind);
}
_journal.State = TransactionState.Published;
WriteJournal();
}
catch (Exception publishFailure)
{
try
{
RollBack();
}
catch (Exception rollbackFailure)
{
throw new AggregateException(
"Publishing failed and the previous installation could not be fully restored. " +
"The recovery journal was retained for the next operation.",
publishFailure, rollbackFailure);
}
throw;
}
}
/// <summary>
/// Durably records the runtime configuration preimage before the caller mutates it. Recovery of
/// any uncommitted transaction restores this exact preimage; committed transactions retain the
/// new configuration. Call after <see cref="Publish"/> and before the first config write.
/// </summary>
public void RecordRuntimeConfigurationMutation(RuntimeConfigSnapshot snapshot)
{
EnsureActive(TransactionState.Published);
_journal.RuntimeConfigExisted = snapshot.Existed;
_journal.RuntimeConfigContents = snapshot.Contents;
_journal.RuntimeConfigMutationPlanned = true;
WriteJournal();
}
/// <summary>
/// Makes the published paths authoritative. Cleanup failure is non-fatal: the committed journal
/// makes the next lock acquisition finish deleting only this transaction's exact backup paths.
/// </summary>
public void Commit()
{
EnsureActive(TransactionState.Published);
_journal.State = TransactionState.Committed;
WriteJournal();
_finished = true;
TryCleanCommitted(_journalPath, _journal, _reporter);
}
public void Dispose()
{
if (_finished) return;
RollBack();
}
/// <summary>Recovers the one deterministic journal for an installation. Call only while locked.</summary>
public static void Recover(string installDirectory, IInstallReporter? reporter = null)
{
var root = InstallOperationPaths.NormalizeRoot(installDirectory);
var journalPath = InstallOperationPaths.GetJournalPath(root);
if (!File.Exists(journalPath)) return;
var journal = JsonState.TryRead<TransactionJournal>(journalPath)
?? throw new InvalidDataException(
$"The interrupted-install journal is unreadable: {journalPath}");
ValidateJournal(root, journal);
if (journal.State is TransactionState.Committed or TransactionState.RolledBack)
{
reporter?.Diagnostic("Finishing cleanup from the previous completed update operation...");
TryCleanCommitted(journalPath, journal, reporter);
if (File.Exists(journalPath))
throw new IOException("The previous committed update's exact backup could not be removed.");
return;
}
reporter?.Diagnostic("Restoring the installation after an interrupted update...");
new InstallTransaction(journalPath, journal, reporter).RollBack();
}
private void RollBack()
{
if (_finished) return;
ValidateJournal(InstallOperationPaths.NormalizeRoot(_journal.InstallRoot), _journal);
foreach (var entry in _journal.Entries.AsEnumerable().Reverse())
RollBackEntry(entry);
if (_journal.RuntimeConfigMutationPlanned)
{
// The journal records the installation root, which is what decides whether this
// installation's configuration lives in its portable root or in per-user data.
RuntimeConfiguration.Restore(
RuntimeConfiguration.ResolveConfigPath(_journal.InstallRoot),
new RuntimeConfigSnapshot(_journal.RuntimeConfigExisted,
_journal.RuntimeConfigContents ?? []));
}
_journal.State = TransactionState.RolledBack;
WriteJournal();
_finished = true;
FileSystemUtilities.DeleteDirectoryIfExists(_journal.WorkRoot);
File.Delete(_journalPath);
}
private static void RollBackEntry(TransactionJournalEntry entry)
{
var destinationExists = Exists(entry.DestinationPath, entry.Kind);
if (Exists(entry.BackupPath, entry.Kind))
{
if (destinationExists) Delete(entry.DestinationPath, entry.Kind);
Move(entry.BackupPath, entry.DestinationPath, entry.Kind);
return;
}
// No backup: either the original was never moved aside, or it has already been restored.
// Both leave the original in place, so only a vanished original is a failure.
if (entry.OriginalExisted)
{
if (!destinationExists)
throw new IOException($"The transaction backup is missing for {entry.DestinationPath}.");
return;
}
if (destinationExists && Exists(entry.PreparedPath, entry.Kind))
throw new IOException(
$"Both prepared and published paths exist for {entry.DestinationPath}; recovery is ambiguous.");
if (destinationExists) Delete(entry.DestinationPath, entry.Kind);
}
private static List<TransactionJournalEntry> NormalizeEntries(string root, string workRoot,
IReadOnlyList<InstallTransactionEntry> entries)
{
var normalized = new List<TransactionJournalEntry>(entries.Count);
for (var index = 0; index < entries.Count; index++)
{
var requested = entries[index];
var prepared = Path.GetFullPath(requested.PreparedPath);
var destination = Path.GetFullPath(requested.DestinationPath);
EnsureDestinationInScope(root, destination);
if (FileSystemUtilities.PathsOverlap(prepared, destination))
throw new InvalidOperationException("A prepared path and its destination must not overlap.");
if (!Exists(prepared, requested.Kind))
throw new FileNotFoundException("A prepared transaction entry is missing.", prepared);
if (ExistsAsOtherKind(prepared, requested.Kind))
throw new InvalidDataException($"The prepared path has the wrong entry type: {prepared}");
if (ExistsAsOtherKind(destination, requested.Kind))
throw new InvalidDataException($"The destination has the wrong entry type: {destination}");
foreach (var previous in normalized)
{
if (FileSystemUtilities.PathsOverlap(destination, previous.DestinationPath))
throw new InvalidOperationException("Transaction destinations must not overlap.");
if (FileSystemUtilities.PathsOverlap(prepared, previous.PreparedPath))
throw new InvalidOperationException("Transaction prepared paths must not overlap.");
if (FileSystemUtilities.PathsOverlap(prepared, previous.DestinationPath) ||
FileSystemUtilities.PathsOverlap(destination, previous.PreparedPath))
throw new InvalidOperationException("Transaction sources and destinations must not overlap.");
}
normalized.Add(new TransactionJournalEntry
{
PreparedPath = prepared,
DestinationPath = destination,
BackupPath = Path.Combine(workRoot, "backup", index.ToString("D4")),
Kind = requested.Kind,
OriginalExisted = Exists(destination, requested.Kind)
});
}
return normalized;
}
private static void ValidateJournal(string expectedRoot, TransactionJournal journal)
{
if (journal.SchemaVersion != SchemaVersion || journal.TransactionId == Guid.Empty)
throw new InvalidDataException("The interrupted-install journal has an unsupported format.");
var root = InstallOperationPaths.NormalizeRoot(journal.InstallRoot);
if (!root.Equals(expectedRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("The interrupted-install journal belongs to another installation.");
var expectedWorkRoot = InstallOperationPaths.GetWorkRoot(root, journal.TransactionId);
if (!Path.GetFullPath(journal.WorkRoot).Equals(expectedWorkRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("The interrupted-install journal has an invalid work directory.");
if (journal.Entries.Count == 0 || journal.Entries.Count > 64)
throw new InvalidDataException("The interrupted-install journal has an invalid entry count.");
for (var index = 0; index < journal.Entries.Count; index++)
{
var entry = journal.Entries[index];
EnsureDestinationInScope(root, Path.GetFullPath(entry.DestinationPath));
var expectedBackup = Path.Combine(expectedWorkRoot, "backup", index.ToString("D4"));
if (!Path.GetFullPath(entry.BackupPath).Equals(expectedBackup, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("The interrupted-install journal has an invalid backup path.");
}
}
private static void EnsureDestinationInScope(string root, string destination)
{
if (!FileSystemUtilities.PathContains(root, destination))
throw new InvalidOperationException("A transaction destination must be inside its installation root.");
}
private void EnsureActive(TransactionState expected)
{
if (_finished || _journal.State != expected)
throw new InvalidOperationException("The install transaction is not in the required state.");
}
private void WriteJournal() => JsonState.Write(_journalPath, _journal);
private static bool Exists(string path, InstallTransactionEntryKind kind) =>
kind == InstallTransactionEntryKind.Directory ? Directory.Exists(path) : File.Exists(path);
private static bool ExistsAsOtherKind(string path, InstallTransactionEntryKind kind) =>
kind == InstallTransactionEntryKind.Directory ? File.Exists(path) : Directory.Exists(path);
// Antivirus and indexers briefly hold handles inside freshly written trees; ride those out
// before treating a locked path as fatal.
private const int MoveAttempts = 10;
private static readonly TimeSpan MoveRetryDelay = TimeSpan.FromMilliseconds(500);
private static void Move(string source, string destination, InstallTransactionEntryKind kind)
{
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
for (var attempt = 1; ; attempt++)
{
try
{
if (kind == InstallTransactionEntryKind.Directory) Directory.Move(source, destination);
else File.Move(source, destination);
return;
}
catch (Exception ex) when (IsTransientLock(ex))
{
if (attempt == MoveAttempts)
throw new IOException($"Could not replace \"{destination}\": {ex.Message} " +
"Close any program using this folder and retry the update.", ex);
Thread.Sleep(MoveRetryDelay);
}
}
}
internal static bool IsTransientLock(Exception ex) =>
ex is IOException or UnauthorizedAccessException &&
(ex.HResult & 0xFFFF) is 5 or 32 or 33; // ACCESS_DENIED, SHARING_VIOLATION, LOCK_VIOLATION
private static void Delete(string path, InstallTransactionEntryKind kind)
{
if (kind == InstallTransactionEntryKind.Directory) FileSystemUtilities.DeleteDirectoryIfExists(path);
else File.Delete(path);
}
private static void TryCleanCommitted(string journalPath, TransactionJournal journal,
IInstallReporter? reporter)
{
try
{
FileSystemUtilities.DeleteDirectoryIfExists(journal.WorkRoot);
File.Delete(journalPath);
}
catch (Exception ex)
{
reporter?.Diagnostic(
"The update committed successfully, but its exact previous-version backup could not yet be " +
"removed: " + ex.Message);
}
}
private sealed class TransactionJournal
{
public TransactionJournal() { }
public int SchemaVersion { get; set; }
public Guid TransactionId { get; set; }
public string InstallRoot { get; set; } = "";
public string WorkRoot { get; set; } = "";
public TransactionState State { get; set; }
public List<TransactionJournalEntry> Entries { get; set; } = [];
public bool RuntimeConfigMutationPlanned { get; set; }
public bool RuntimeConfigExisted { get; set; }
public byte[]? RuntimeConfigContents { get; set; }
}
private sealed class TransactionJournalEntry
{
public TransactionJournalEntry() { }
public string PreparedPath { get; set; } = "";
public string DestinationPath { get; set; } = "";
public string BackupPath { get; set; } = "";
public InstallTransactionEntryKind Kind { get; set; }
public bool OriginalExisted { get; set; }
}
private enum TransactionState
{
Prepared,
Published,
Committed,
RolledBack
}
}
@@ -0,0 +1,593 @@
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>Provenance written by the bundled build script next to every product it produces.</summary>
internal sealed class LocalBuildProvenance
{
public int SchemaVersion { get; set; }
public string Profile { get; set; } = "";
public string DolSha256 { get; set; } = "";
public string RelSha256 { get; set; } = "";
public string? CodePulSha256 { get; set; }
public string? RetroWfcPayloadMode { get; set; }
public string? RetroWfcPayloadSha256 { get; set; }
public long? RetroWfcPayloadLength { get; set; }
public string Compiler { get; set; } = "";
public const string FileName = "local-build.json";
}
/// <summary>
/// The typed product status of the <c>--check-products</c> contract. These are the only nine answers
/// the backend gives; see docs/WHEELWIZARD_CONTRACT.md. A frontend branches on the status alone.
/// </summary>
internal enum ProductStatus
{
Absent,
/// <summary>The binary matches the installed toolkit and the canonical compile inputs.</summary>
Current,
/// <summary>The toolkit that produced the binary is no longer the installed toolkit.</summary>
ToolkitChanged,
/// <summary>The canonical Code.pul is not the one this binary statically embeds.</summary>
CodePulChanged,
/// <summary>Another translator-consumed Retro Rewind input changed.</summary>
CompileInputsChanged,
/// <summary>Only the cached Retro-WFC payload needs repair; no compilation.</summary>
PayloadChanged,
/// <summary>
/// The canonical Retro Rewind installation is missing or structurally invalid, so the product
/// can neither be trusted nor rebuilt until the frontend supplies it.
/// </summary>
InputsMissing,
/// <summary>
/// The product exists, but the installation cannot prove its provenance or provide the required
/// toolkit. A caller must repair the installation before launching it.
/// </summary>
Blocked,
/// <summary>The binary or the support files it needs are missing or unusable.</summary>
Broken
}
/// <summary>
/// The result of a non-mutating product inspection. Status and detail are exactly what the wire
/// carries: nothing is derived that is not also reported.
/// </summary>
internal sealed record ProductState(ProductStatus Status, string Detail)
{
/// <summary>The stable machine-readable status string of the command-line contract.</summary>
public string Reason => Status switch
{
ProductStatus.Absent => "absent",
ProductStatus.Current => "current",
ProductStatus.ToolkitChanged => "toolkit-changed",
ProductStatus.CodePulChanged => "code-pul-changed",
ProductStatus.CompileInputsChanged => "compile-inputs-changed",
ProductStatus.PayloadChanged => "payload-changed",
ProductStatus.InputsMissing => "inputs-missing",
ProductStatus.Blocked => "blocked",
_ => "broken"
};
/// <summary>Whether a caller must do something before this product may be launched.</summary>
public bool ActionRequired => Status is not ProductStatus.Current and not ProductStatus.Absent;
}
/// <summary>
/// The on-disk shape of an installation, plus the cheap checks a launch performs before starting a
/// binary. Everything here is O(1), a single file hash, or the compile-input identity of the
/// canonical Retro Rewind installation; the multi-gigabyte asset tree is never walked.
/// </summary>
internal sealed class Installation
{
public Installation(string root) => Root = FileSystemUtilities.NormalizePath(root);
public string Root { get; }
public string ToolkitDirectory => InstalledLayout.Toolkit(Root);
public string WorkspaceDirectory => InstalledLayout.Workspace(Root);
public string WorkspaceAssetsDirectory => Path.Combine(WorkspaceDirectory, "Assets");
public string WorkspaceRetroWfcPayload => Path.Combine(WorkspaceAssetsDirectory, "OfflinePayload");
public string BaseDirectory => Path.Combine(Root, "Base");
public string BaseExecutable => Path.Combine(BaseDirectory, "WiiCompiled.exe");
public string RetroDirectory => Path.Combine(Root, "RetroRewind");
public string RetroExecutable => Path.Combine(RetroDirectory, "RetroRewind.exe");
public string GameDataDirectory => Path.Combine(Root, "GameAssets", "DATA");
public string InstallStatePath => Path.Combine(Root, InstalledLayout.InstallStateFileName);
public string ToolkitStatePath => Path.Combine(Root, ToolkitState.FileName);
/// <summary>The portable root this installation lives in, or null for an ordinary installation.</summary>
public string? PortableRootDirectory => PortableRoot.TryFind(Root);
/// <summary>
/// The runtime configuration this installation reads: <c>&lt;portable root&gt;\UserData\Config.toml</c>
/// when portable, otherwise the shared per-user file.
/// </summary>
public string ConfigPath => RuntimeConfiguration.ResolveConfigPath(Root);
/// <summary>The install directory copy of setup that acts as the CLI launcher.</summary>
public string SetupCopyPath => Path.Combine(Root, ProductInfo.SetupCopyName);
private const string Toolkit = InstalledLayout.ToolkitDirectoryName + "\\";
private const string Workspace = InstalledLayout.WorkspaceDirectoryName + "\\";
private static readonly string[] RequiredToolkitFiles =
[
Toolkit + @"Translator\Translator.Cli.exe",
Toolkit + @"CMake\bin\cmake.exe",
Toolkit + @"Ninja\ninja.exe",
Toolkit + @"llvm-mingw\bin\clang-22.exe",
Toolkit + @"llvm-mingw\bin\ld.lld.exe",
Toolkit + @"llvm-mingw\bin\x86_64-w64-mingw32-clang.exe",
Toolkit + @"llvm-mingw\bin\x86_64-w64-mingw32-clang++.exe",
Toolkit + @"llvm-mingw\bin\x86_64-w64-mingw32-windres.exe",
Workspace + "LocalBuild.ps1",
Workspace + "NativeBuildFlags.ps1",
Workspace + @"projects\mkwii\recomp.yml",
Workspace + @"runtime\CMakeLists.txt",
Workspace + @"runtime\assets\dsp\dsp_coef.bin"
];
// These are the source trees and pinned dependency roots LocalBuild.ps1 consumes during every
// native configure/build. Presence is intentionally a cheap structural test, not another full
// toolkit hash on every status check. The dependency names come from the one shipped list, so a
// dependency added to the payload cannot be missed here.
private static readonly string[] RequiredNonEmptyToolkitDirectories =
[
Workspace + @"runtime\src",
Workspace + @"runtime\assets\wii",
Workspace + "aurora-main",
.. InstalledLayout.DependencyNames.Select(name => Workspace + @"Dependencies\" + name)
];
public bool Exists => File.Exists(InstallStatePath);
/// <summary>
/// Whether this directory holds an installation at all. A missing or empty directory is not a
/// broken installation, it is simply nothing - a frontend probing a path it has not installed to
/// yet must get "absent", not an error.
/// </summary>
public bool IsPresent => Directory.Exists(Root) &&
(Exists || File.Exists(BaseExecutable) || File.Exists(RetroExecutable));
/// <summary>
/// A cheap structural check for the programs and workspace inputs LocalBuild.ps1 actually
/// invokes. Product inspection intentionally does not rehash the full toolkit every time, but
/// it must not claim a product current when its compiler, linker, or native runtime is gone.
/// </summary>
public bool HasToolkit => RequiredToolkitFiles.All(relative => File.Exists(Path.Combine(Root, relative))) &&
RequiredNonEmptyToolkitDirectories.All(relative =>
IsNonEmptyRegularDirectory(Path.Combine(Root, relative)));
public bool HasRetroProduct => File.Exists(RetroExecutable);
public InstallState? ReadInstallState() => JsonState.TryRead<InstallState>(InstallStatePath);
internal ToolkitState? ReadToolkitState() => JsonState.TryRead<ToolkitState>(ToolkitStatePath);
/// <summary>
/// The identity of the installed toolkit. Inspection never writes or adopts state: a missing
/// toolkit provenance record must not silently make an existing product look trustworthy.
/// </summary>
public string ResolveToolkitFingerprint()
{
var state = ReadToolkitState();
if (state is { SchemaVersion: 2 } && !string.IsNullOrEmpty(state.ToolkitFingerprint) &&
!string.IsNullOrEmpty(state.ToolkitPackageFingerprint) && HasToolkit)
return state.ToolkitFingerprint;
// Do not derive a replacement identity from whatever happens to be on disk. A missing or
// unsupported toolkit-state.json has no authoritative chain to the installed products and
// is therefore blocked until setup repairs it.
return "";
}
/// <summary>The recorded package identity of the installed Toolkit directory, under the same
/// fail-closed rules as <see cref="ResolveToolkitFingerprint"/>.</summary>
public string ResolveToolkitPackageFingerprint()
{
var state = ReadToolkitState();
if (state is { SchemaVersion: 2 } && !string.IsNullOrEmpty(state.ToolkitFingerprint) &&
!string.IsNullOrEmpty(state.ToolkitPackageFingerprint) && HasToolkit)
return state.ToolkitPackageFingerprint;
return "";
}
public void WriteToolkitState(string fingerprint, string packageFingerprint, string releaseTag,
string runtimeAssetsFingerprint = "", string translationFingerprint = "",
string nativeToolchainFingerprint = "") =>
JsonState.Write(ToolkitStatePath, new ToolkitState
{
ToolkitFingerprint = fingerprint,
ToolkitPackageFingerprint = packageFingerprint,
RuntimeAssetsFingerprint = runtimeAssetsFingerprint,
TranslationFingerprint = translationFingerprint,
NativeToolchainFingerprint = nativeToolchainFingerprint,
ToolkitReleaseTag = releaseTag
});
/// <summary>
/// Reads the complete product provenance. A product without the current fingerprint schema is
/// intentionally untrusted rather than guessed from an older, partial provenance document.
/// </summary>
public ProductFingerprint? ReadProductFingerprint(string productDirectory, string toolkitFingerprint)
{
var recorded = JsonState.TryRead<ProductFingerprint>(
Path.Combine(productDirectory, ProductFingerprint.FileName));
return recorded is { SchemaVersion: 1 } ? recorded : null;
}
/// <summary>
/// The canonical Retro Rewind install to compare against: explicit <c>--retro-dir</c> if given, else the
/// recorded <c>retro_rewind_root</c>. Reads only compile inputs, so it stays cheap for every status check and launch.
/// </summary>
internal RetroRewindCompileInputs? ResolveCanonicalCompileInputs(string? explicitDirectory,
out string? error, CancellationToken cancellationToken = default)
{
error = null;
var selected = string.IsNullOrWhiteSpace(explicitDirectory)
? ConfiguredRetroRewindRoot
: explicitDirectory;
if (string.IsNullOrWhiteSpace(selected))
{
if (HasRetroProduct || ReadInstallState()?.RetroRewindInstalled == true)
error = "No canonical Retro Rewind folder is recorded for this installation. " +
"Repair it with Wheel Wizard's current Retro Rewind folder.";
return null;
}
try
{
return CompileInputsFingerprint.Compute(selected, cancellationToken);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException
or ArgumentException)
{
error = "The canonical Retro Rewind folder cannot be read: " + ex.Message;
return null;
}
}
/// <summary>The canonical Retro Rewind root the runtime reads its assets from, if one is set.</summary>
public string? ConfiguredRetroRewindRoot => RuntimeConfiguration.GetRetroRewindRoot(ConfigPath);
public ProductState CheckBase(string toolkitFingerprint)
{
var state = CheckBaseCore(toolkitFingerprint);
if (state.Status != ProductStatus.Current) return state;
var runtimeAssetsError = ValidateCopiedRuntimeAssets(BaseDirectory, "base", toolkitFingerprint);
return runtimeAssetsError is null ? state : new ProductState(ProductStatus.Broken, runtimeAssetsError);
}
/// <summary>
/// Everything about the base product except its copied support files. Repair inspects those
/// separately because they are republished from the installed workspace without recompiling.
/// </summary>
internal ProductState CheckBaseCore(string toolkitFingerprint)
{
if (!IsUsableExecutable(BaseExecutable))
return new ProductState(ProductStatus.Broken, "The base recomp executable is missing.");
if (!HasUsableToolkit(toolkitFingerprint))
return MissingToolkit();
var installState = ReadCurrentInstallState();
if (installState is null)
return new ProductState(ProductStatus.Blocked,
"The installation state is missing, unsupported, or belongs to another directory.");
var fingerprint = ReadProductFingerprint(BaseDirectory, toolkitFingerprint);
if (fingerprint is null)
return new ProductState(ProductStatus.Blocked,
"The base recomp has no current build provenance. Repair it with the current setup.");
if (!fingerprint.ToolkitFingerprint.Equals(toolkitFingerprint, StringComparison.Ordinal))
return new ProductState(ProductStatus.ToolkitChanged,
"The base recomp was produced by a different recompilation toolkit.");
var provenanceError = ValidateProductProvenance(BaseDirectory, "base", fingerprint, installState);
return provenanceError is null
? new ProductState(ProductStatus.Current, "")
: new ProductState(ProductStatus.Blocked, provenanceError);
}
/// <summary>
/// Classifies the Retro Rewind product against the canonical installation's compile inputs. The
/// asset tree is never inspected: the runtime reads it live, so an asset-only Retro Rewind
/// update leaves this product current.
/// </summary>
public ProductState CheckRetroRewind(string toolkitFingerprint,
RetroRewindCompileInputs? canonical, string? canonicalError = null,
bool? cachedRetroWfcPayloadMatches = null)
{
var state = CheckRetroRewindCore(toolkitFingerprint, canonical, canonicalError,
cachedRetroWfcPayloadMatches);
if (state.Status != ProductStatus.Current) return state;
var runtimeAssetsError = ValidateCopiedRuntimeAssets(RetroDirectory, "retro-rewind",
toolkitFingerprint);
return runtimeAssetsError is null ? state : new ProductState(ProductStatus.Broken, runtimeAssetsError);
}
internal ProductState CheckRetroRewindCore(string toolkitFingerprint,
RetroRewindCompileInputs? canonical, string? canonicalError = null,
bool? cachedRetroWfcPayloadMatches = null)
{
var installState = ReadCurrentInstallState();
var recordedAsInstalled = installState?.RetroRewindInstalled == true;
if ((HasRetroProduct || recordedAsInstalled) && !HasUsableToolkit(toolkitFingerprint))
return MissingToolkit();
if (!HasRetroProduct)
{
return recordedAsInstalled
? new ProductState(ProductStatus.Broken,
"Retro Rewind is recorded as installed, but its executable is missing.")
: new ProductState(ProductStatus.Absent, "Retro Rewind is not installed.");
}
if (canonicalError is not null)
return new ProductState(ProductStatus.InputsMissing, canonicalError);
var fingerprint = ReadProductFingerprint(RetroDirectory, toolkitFingerprint);
if (fingerprint is null)
return new ProductState(ProductStatus.Blocked,
"The Retro Rewind product has no current build provenance. " +
"Repair it with Wheel Wizard's current Retro Rewind folder.");
if (!fingerprint.ToolkitFingerprint.Equals(toolkitFingerprint, StringComparison.Ordinal))
return new ProductState(ProductStatus.ToolkitChanged,
"Retro Rewind was produced by a different recompilation toolkit.");
if (installState is null)
return new ProductState(ProductStatus.Blocked,
"The installation state is missing, unsupported, or belongs to another directory.");
var provenanceError = ValidateProductProvenance(RetroDirectory, "retro-rewind", fingerprint,
installState);
if (provenanceError is not null)
return new ProductState(ProductStatus.Blocked, provenanceError);
if (string.IsNullOrWhiteSpace(fingerprint.CodePulSha256) ||
string.IsNullOrWhiteSpace(fingerprint.RetroRewindCompileInputsSha256) ||
string.IsNullOrWhiteSpace(installState.RetroRewindCodePulSha256) ||
string.IsNullOrWhiteSpace(installState.RetroRewindCompileInputsSha256) ||
!Same(installState.RetroRewindCodePulSha256, fingerprint.CodePulSha256) ||
!Same(installState.RetroRewindCompileInputsSha256,
fingerprint.RetroRewindCompileInputsSha256))
return new ProductState(ProductStatus.Blocked,
"Retro Rewind's compile-input provenance is incomplete or inconsistent. " +
"Repair it with Wheel Wizard's current Retro Rewind folder.");
if (canonical is not null)
{
if (!Same(canonical.CodePulSha256, fingerprint.CodePulSha256))
return new ProductState(ProductStatus.CodePulChanged,
"The canonical Retro Rewind Code.pul changed since WiiCompiled was translated.");
if (!Same(canonical.CompileInputsSha256, fingerprint.RetroRewindCompileInputsSha256))
return new ProductState(ProductStatus.CompileInputsChanged,
"Translator-consumed Retro Rewind inputs changed since WiiCompiled was built.");
}
if (fingerprint.RetroWfcPayloadMode == "downloaded" &&
CheckRetroWfcPayloadCache(fingerprint, installState, cachedRetroWfcPayloadMatches)
is { } payloadError)
return payloadError;
return new ProductState(ProductStatus.Current, "");
}
/// <summary>
/// Compares the workspace payload cache with the identity this product embeds.
/// <paramref name="cachedMatches"/> carries an observation the operation already paid for:
/// true/false skip the hash, null means nothing is known yet.
/// </summary>
private ProductState? CheckRetroWfcPayloadCache(ProductFingerprint fingerprint, InstallState installState,
bool? cachedMatches)
{
if (string.IsNullOrWhiteSpace(fingerprint.RetroWfcPayloadSha256) ||
fingerprint.RetroWfcPayloadLength <= 0 ||
string.IsNullOrWhiteSpace(installState.RetroWfcPayloadSha256) ||
installState.RetroWfcPayloadLength <= 0)
return new ProductState(ProductStatus.Blocked,
"The Retro-WFC payload provenance is incomplete.");
if (cachedMatches == false)
return new ProductState(ProductStatus.PayloadChanged,
"The cached Retro-WFC payload is missing or does not match the current snapshot.");
if (cachedMatches == true) return null;
try
{
var payload = InputValidation.ResolveRetroWfcPayloadFile(WorkspaceRetroWfcPayload);
if (!InputValidation.Sha256File(payload).Equals(fingerprint.RetroWfcPayloadSha256,
StringComparison.OrdinalIgnoreCase) ||
new FileInfo(payload).Length != fingerprint.RetroWfcPayloadLength)
return new ProductState(ProductStatus.PayloadChanged,
"The cached Retro-WFC payload is not the one this product was built with.");
return null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
return new ProductState(ProductStatus.PayloadChanged,
"The cached Retro-WFC payload is missing or invalid: " + ex.Message);
}
}
private static bool Same(string left, string right) =>
left.Equals(right, StringComparison.OrdinalIgnoreCase);
internal bool ProductUsesGameInputs(string productDirectory, string toolkitFingerprint,
string expectedDolSha256, string expectedRelSha256)
{
var fingerprint = ReadProductFingerprint(productDirectory, toolkitFingerprint);
return fingerprint is not null &&
fingerprint.DolSha256.Equals(expectedDolSha256, StringComparison.OrdinalIgnoreCase) &&
fingerprint.RelSha256.Equals(expectedRelSha256, StringComparison.OrdinalIgnoreCase);
}
private bool HasUsableToolkit(string toolkitFingerprint) =>
HasToolkit && !string.IsNullOrWhiteSpace(toolkitFingerprint);
private static ProductState MissingToolkit() =>
new(ProductStatus.Blocked,
"The recompilation toolkit is missing or incomplete. Repair the setup before launching or updating products.");
private InstallState? ReadCurrentInstallState()
{
var state = ReadInstallState();
if (state is not { SchemaVersion: 1 } || string.IsNullOrWhiteSpace(state.InstallDir)) return null;
try
{
return FileSystemUtilities.PathsEqual(state.InstallDir, Root) ? state : null;
}
catch { return null; }
}
private string? ValidateProductProvenance(string productDirectory, string expectedProfile,
ProductFingerprint fingerprint, InstallState state)
{
if (!fingerprint.Profile.Equals(expectedProfile, StringComparison.Ordinal) ||
string.IsNullOrWhiteSpace(fingerprint.DolSha256) ||
string.IsNullOrWhiteSpace(fingerprint.RelSha256) ||
string.IsNullOrWhiteSpace(fingerprint.ExecutableSha256))
return $"The {expectedProfile} product provenance is incomplete.";
var executable = expectedProfile == "base" ? BaseExecutable : RetroExecutable;
if (!IsUsableExecutable(executable))
return $"The {expectedProfile} executable is missing or incomplete.";
try
{
if (!InputValidation.Sha256File(executable).Equals(fingerprint.ExecutableSha256,
StringComparison.OrdinalIgnoreCase))
return $"The {expectedProfile} executable no longer matches its recorded build identity.";
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return $"The {expectedProfile} executable cannot be verified: {ex.Message}";
}
var dol = Path.Combine(GameDataDirectory, "sys", "main.dol");
var rel = Path.Combine(GameDataDirectory, "files", "rel", "StaticR.rel");
var fst = Path.Combine(GameDataDirectory, "sys", "fst.bin");
if (!File.Exists(dol) || !File.Exists(rel) || !File.Exists(fst))
return "The installed game assets are incomplete. Apply setup again with the disc image.";
try
{
var dolSha = InputValidation.Sha256File(dol);
var relSha = InputValidation.Sha256File(rel);
if (!dolSha.Equals(fingerprint.DolSha256, StringComparison.OrdinalIgnoreCase) ||
!relSha.Equals(fingerprint.RelSha256, StringComparison.OrdinalIgnoreCase) ||
!dolSha.Equals(state.DolSha256, StringComparison.OrdinalIgnoreCase) ||
!relSha.Equals(state.RelSha256, StringComparison.OrdinalIgnoreCase))
return "The installed game inputs do not match the product provenance.";
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return "The installed game inputs cannot be verified: " + ex.Message;
}
var local = JsonState.TryRead<LocalBuildProvenance>(
Path.Combine(productDirectory, LocalBuildProvenance.FileName));
if (local is not { SchemaVersion: 1 } ||
!local.Profile.Equals(expectedProfile, StringComparison.Ordinal) ||
!local.DolSha256.Equals(fingerprint.DolSha256, StringComparison.OrdinalIgnoreCase) ||
!local.RelSha256.Equals(fingerprint.RelSha256, StringComparison.OrdinalIgnoreCase))
return $"The {expectedProfile} executable has invalid local-build provenance.";
if (expectedProfile == "retro-rewind" &&
(!string.Equals(local.RetroWfcPayloadMode, fingerprint.RetroWfcPayloadMode,
StringComparison.Ordinal) ||
!string.Equals(local.RetroWfcPayloadSha256 ?? "", fingerprint.RetroWfcPayloadSha256,
StringComparison.OrdinalIgnoreCase) ||
(local.RetroWfcPayloadLength ?? 0) != fingerprint.RetroWfcPayloadLength))
{
return "The Retro Rewind executable has invalid Retro-WFC payload provenance.";
}
return null;
}
/// <summary>
/// Runtime support files are copied beside products rather than embedded in the executable.
/// The installed toolkit-state is their authoritative release identity; products with missing
/// or altered copies cannot be declared launchable merely because their .exe still hashes.
/// </summary>
internal string? ValidateCopiedRuntimeAssets(string productDirectory, string expectedProfile,
string toolkitFingerprint)
{
var toolkitState = ReadToolkitState();
if (toolkitState is not { SchemaVersion: 2 } ||
!string.Equals(toolkitState.ToolkitFingerprint, toolkitFingerprint, StringComparison.Ordinal) ||
string.IsNullOrWhiteSpace(toolkitState.RuntimeAssetsFingerprint))
{
return "The installed runtime-asset provenance is missing or does not belong to the current toolkit.";
}
string? failure = null;
if (!ToolkitFingerprint.ProductRuntimeAssetsMatch(productDirectory,
toolkitState.RuntimeAssetsFingerprint, diagnostic: line => failure = line))
{
// An access-denied or half-deleted tree must not masquerade as an ordinary hash mismatch.
return failure ??
$"The {expectedProfile} product's copied runtime assets are missing, corrupt, or stale.";
}
return null;
}
private static bool IsUsableExecutable(string path)
{
try { return File.Exists(path) && new FileInfo(path).Length >= 64 * 1024; }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return false; }
}
private static bool IsNonEmptyRegularDirectory(string path)
{
try
{
if (!Directory.Exists(path)) return false;
var directory = new DirectoryInfo(path);
if ((directory.Attributes & FileAttributes.ReparsePoint) != 0) return false;
using var entries = Directory.EnumerateFileSystemEntries(path).GetEnumerator();
return entries.MoveNext();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return false;
}
}
/// <summary>The Retro-WFC payload choice this installation was built with.</summary>
public RetroWfcPayloadMode ResolveRetroWfcPayloadMode(string toolkitFingerprint)
{
var recorded = ReadProductFingerprint(RetroDirectory, toolkitFingerprint)?.RetroWfcPayloadMode;
recorded = string.IsNullOrEmpty(recorded) ? ReadInstallState()?.RetroWfcPayloadMode : recorded;
return recorded switch
{
"downloaded" => RetroWfcPayloadMode.Online,
"skipped" => RetroWfcPayloadMode.Skipped,
_ => RetroWfcPayloadMode.NotApplicable
};
}
public bool MatchesRetroWfcPayloadSnapshot(string toolkitFingerprint,
RetroWfcPayloadSnapshot snapshot)
{
var fingerprint = ReadProductFingerprint(RetroDirectory, toolkitFingerprint);
var state = ReadCurrentInstallState();
return fingerprint is { RetroWfcPayloadMode: "downloaded" } &&
state is { RetroWfcPayloadMode: "downloaded" } &&
string.Equals(fingerprint.RetroWfcPayloadSha256, snapshot.Sha256, StringComparison.Ordinal) &&
string.Equals(state.RetroWfcPayloadSha256, snapshot.Sha256, StringComparison.Ordinal);
}
public bool CachedRetroWfcPayloadMatches(RetroWfcPayloadSnapshot snapshot)
{
try
{
var payload = InputValidation.ResolveRetroWfcPayloadFile(WorkspaceRetroWfcPayload);
return new FileInfo(payload).Length == snapshot.ByteLength &&
InputValidation.Sha256File(payload).Equals(snapshot.Sha256, StringComparison.Ordinal);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
return false;
}
}
}
@@ -0,0 +1,54 @@
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Names of the installed/staged layout. Not cosmetic: payload and toolkit identities hash relative paths
/// that begin with these strings, so changing one (including casing) invalidates every shipped fingerprint.
/// Build-Installer.ps1 stages the payload with exactly these names.
/// </summary>
internal static class InstalledLayout
{
public const string ToolkitDirectoryName = "Toolkit";
public const string WorkspaceDirectoryName = "BuildWorkspace";
public const string InstallStateFileName = "install-state.json";
public const string PayloadManifestFileName = "payload-manifest.json";
/// <summary>Payload entry names use forward slashes; the archive is written by tar.</summary>
public const string ToolkitEntryPrefix = ToolkitDirectoryName + "/";
public const string WorkspaceEntryPrefix = WorkspaceDirectoryName + "/";
public static string Toolkit(string root) => Path.Combine(root, ToolkitDirectoryName);
public static string Workspace(string root) => Path.Combine(root, WorkspaceDirectoryName);
/// <summary>
/// The pinned offline dependency sources shipped under <c>BuildWorkspace\Dependencies</c>. This
/// is the one list: Build-Installer.ps1 stages exactly these directories and
/// Launcher/Test-PinnedFacts.ps1 fails the release build if its copy and this one disagree.
/// </summary>
public static readonly string[] DependencyNames =
[
"abseil-cpp", "cppwinrt", "dawn_prebuilt", "fmt", "freetype", "imgui", "native_prebuilt",
"png", "SDL", "sqlite3", "tracy", "xxhash", "zlib", "zstd"
];
}
/// <summary>
/// Source-owned files copied verbatim beside every product. One inventory drives identity
/// (<see cref="ToolkitFingerprint.ComputeRuntimeAssets"/>), verification, and publication. Renaming
/// an identity key below requires bumping the fingerprint's version salt.
/// </summary>
internal static class ProductRuntimeAssets
{
/// <summary>The bootstrap tree, copied from <c>runtime/assets/wii</c> into every product.</summary>
public const string SourceBootstrapDirectoryName = "wii";
public const string ProductBootstrapDirectoryName = "wii_bootstrap";
/// <summary>Single files, given as their path below <c>runtime/assets</c> and their product name.</summary>
public static readonly (string[] SourceRelativePath, string ProductFileName)[] Files =
[
(["dsp", "dsp_coef.bin"], "dsp_coef.bin"),
(["pipeline", "initial_pipeline_cache.db"], "initial_pipeline_cache.db")
];
public static string SourceFile(string sourceAssets, string[] relativePath) =>
Path.Combine([sourceAssets, .. relativePath]);
}
@@ -0,0 +1,527 @@
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
internal sealed class InstallerEngine
{
private readonly IInstallReporter _reporter;
public InstallerEngine(IInstallReporter reporter) => _reporter = reporter;
public async Task InstallAsync(InstallOptions options, CancellationToken cancellationToken = default)
{
var installDirectory = Path.GetFullPath(options.InstallDirectory);
ValidateInstallDirectory(installDirectory);
InputValidation.ValidateExtension(options.GamePath);
var parent = Directory.GetParent(installDirectory)?.FullName
?? throw new InvalidOperationException("The installation directory has no parent.");
Directory.CreateDirectory(parent);
using var operationLock = InstallOperationLock.Acquire(installDirectory, _reporter);
var existing = new Installation(installDirectory);
PortableInstallHealing.HealMovedInstall(existing, _reporter);
var previousState = existing.ReadInstallState();
using var scratch = Directory.Exists(installDirectory)
? InstallScratchSpace.CreateInsideInstall(installDirectory, _reporter)
: InstallScratchSpace.CreateSibling(installDirectory, _reporter);
var staging = scratch.Root;
const long payloadStagingAllowance = 2L * 1024 * 1024 * 1024;
FileSystemUtilities.EnsureFreeSpace(installDirectory, payloadStagingAllowance, "Setup");
using var payload = PayloadArchive.OpenCurrent();
var manifest = payload.ReadManifest();
var toolkit = InstalledLayout.Toolkit(staging);
var workspace = InstalledLayout.Workspace(staging);
var candidateToolkitFingerprint = manifest.ToolkitFingerprint;
var candidateToolkitPackageFingerprint = manifest.ToolkitPackageFingerprint;
var candidateRuntimeAssetsFingerprint = manifest.RuntimeAssetsFingerprint;
var installedToolkitFingerprint = existing.IsPresent
? existing.ResolveToolkitFingerprint()
: "";
var installedToolkitPackageFingerprint = existing.IsPresent
? existing.ResolveToolkitPackageFingerprint()
: "";
var reusableGameAssets = FindReusableGameAssets(existing, manifest);
var sameToolkit = existing.IsPresent && reusableGameAssets is not null &&
installedToolkitFingerprint.Equals(candidateToolkitFingerprint,
StringComparison.Ordinal);
var samePackageContent = existing.IsPresent &&
installedToolkitPackageFingerprint.Equals(
candidateToolkitPackageFingerprint, StringComparison.Ordinal);
var sameToolkitPackage = sameToolkit && samePackageContent;
var hadRetro = existing.HasRetroProduct || previousState?.RetroRewindInstalled == true;
var runtimeAssetsCurrent = sameToolkit && RuntimeAssetsAreCurrent(existing,
candidateRuntimeAssetsFingerprint, cancellationToken);
var installedNodTool = Path.Combine(existing.ToolkitDirectory, "nodtool.exe");
var extractToolkit = MustRefreshToolkit(sameToolkit, samePackageContent,
File.Exists(installedNodTool));
var extractWorkspace = !sameToolkit || !runtimeAssetsCurrent;
_reporter.Progress(InstallStages.ExtractToolkit,
"Inspecting the release's local recompilation toolkit...", 1);
if (extractToolkit) payload.ExtractDirectory(InstalledLayout.ToolkitDirectoryName, toolkit);
if (extractWorkspace)
{
payload.ExtractDirectory(InstalledLayout.WorkspaceDirectoryName, workspace);
WorkspaceTimestamps.MarkChangedFiles(existing.WorkspaceDirectory, workspace,
_reporter.Diagnostic, cancellationToken);
}
payload.ExtractEntry("host/WiiCompiled-Setup.exe",
Path.Combine(staging, ProductInfo.SetupCopyName));
payload.ExtractDirectory("licenses", Path.Combine(staging, "licenses"));
payload.ExtractEntry(InstalledLayout.PayloadManifestFileName,
Path.Combine(staging, InstalledLayout.PayloadManifestFileName));
var nodTool = extractToolkit ? Path.Combine(toolkit, "nodtool.exe") : installedNodTool;
_reporter.Progress(InstallStages.Validate, "Checking the Wii disc image...", 2);
var header = await InputValidation.ReadDiscHeaderAsync(nodTool, options.GamePath,
cancellationToken);
InputValidation.EnsureCompatibleDisc(header, manifest);
var canonicalRetroRoot = options.RetroDirectoryPath is null
? null
: RetroRewindSource.ResolveRetroRewind6(options.RetroDirectoryPath);
ValidateRetroOptions(canonicalRetroRoot, options.RetroWfcPayloadMode, manifest);
var retroCompileInputs = canonicalRetroRoot is null
? null
: SnapshotRetroRewindCompileInputs(canonicalRetroRoot,
Path.Combine(staging, "compile-inputs"), cancellationToken);
if (options.Portable)
{
var portableRoot = PortableRoot.Create(parent);
_reporter.Diagnostic($"Installing as a portable installation under {portableRoot}.");
}
if (sameToolkit)
{
var desiredInputDrift = ProductRepairService.FindDesiredInputDrift(existing,
candidateToolkitFingerprint, manifest.ExpectedDolSha256, manifest.ExpectedRelSha256);
if (desiredInputDrift.Retro && retroCompileInputs is null)
throw new InvalidOperationException(
"This release changes the clean game inputs used by the installed Retro Rewind product. " +
"Supply Wheel Wizard's current Retro Rewind folder explicitly so it can be rebuilt safely.");
var reconciliation = await ReconcileSameToolkitAsync(existing, retroCompileInputs,
canonicalRetroRoot, options.RetroWfcPayloadMode, Path.Combine(staging, "repair"),
manifest.ExpectedDolSha256, manifest.ExpectedRelSha256, cancellationToken);
var remainingCancellation = reconciliation.PublicationCommitted
? CancellationToken.None
: cancellationToken;
if (!runtimeAssetsCurrent)
runtimeAssetsCurrent = RuntimeAssetsAreCurrent(existing,
candidateRuntimeAssetsFingerprint, remainingCancellation);
remainingCancellation.ThrowIfCancellationRequested();
EnsureReconciliationCurrent(existing, candidateToolkitFingerprint, retroCompileInputs,
manifest.ExpectedDolSha256, manifest.ExpectedRelSha256,
reconciliation.CachedRetroWfcPayloadMatches);
var updatedState = BuildInstallState(existing.ReadInstallState(), installDirectory, manifest,
retroCompileInputs, canonicalRetroRoot, options.RetroWfcPayloadMode);
PrepareMetadata(staging, manifest, updatedState);
remainingCancellation.ThrowIfCancellationRequested();
var releaseEntries = new List<InstallTransactionEntry>();
if (!sameToolkitPackage)
AddComponent(releaseEntries, staging, installDirectory,
InstalledLayout.ToolkitDirectoryName);
if (!runtimeAssetsCurrent)
AddRuntimeAssetPublicationEntries(releaseEntries, staging, installDirectory,
updatedState.RetroRewindInstalled, candidateRuntimeAssetsFingerprint,
remainingCancellation);
Publish(staging, installDirectory, canonicalRetroRoot, updatedState,
releaseEntries, remainingCancellation);
return;
}
if (hadRetro && retroCompileInputs is null)
{
throw new InvalidOperationException(
"The installed Retro Rewind product must be produced again. Supply Wheel Wizard's " +
"current Retro Rewind folder explicitly so it can be rebuilt safely.");
}
EnsureSufficientRemainingDiskSpace(installDirectory,
needsExtractedDisc: reusableGameAssets is null, needsLocalBuild: true);
if (reusableGameAssets is null)
{
await ExtractGameAssetsAsync(nodTool, options.GamePath,
Path.Combine(staging, "GameAssets"), manifest, cancellationToken);
}
await PublishToolkitAndReconcileProductsAsync(existing, staging, workspace, manifest,
previousState, options, canonicalRetroRoot, retroCompileInputs,
publishGameAssets: reusableGameAssets is null, cancellationToken);
}
internal static bool MustRefreshToolkit(bool sameToolkit, bool samePackageContent,
bool nodToolPresent) =>
!sameToolkit || !samePackageContent || !nodToolPresent;
private static void AddComponent(List<InstallTransactionEntry> entries, string staging,
string installDirectory, string name) =>
entries.Add(InstallTransactionEntry.Directory(Path.Combine(staging, name),
Path.Combine(installDirectory, name)));
/// <summary>
/// Build-produced workspace directories (not shipped in a release) grafted from the installed workspace into
/// the staged one, carrying the base translation, native build dir, verified inputs, and Retro-WFC payload cache
/// across a toolkit update. Each is re-validated by the build, so stale caches degrade to a clean rebuild, never reuse.
/// </summary>
private static readonly string[] WorkspaceCacheDirectories =
["generated", "build", "native-build", "Assets", "PulsarPacks"];
private async Task PublishToolkitAndReconcileProductsAsync(Installation existing, string staging,
string stagedWorkspace, PayloadManifest manifest, InstallState? previousState,
InstallOptions options, string? canonicalRetroRoot,
RetroRewindCompileInputs? retroCompileInputs,
bool publishGameAssets, CancellationToken cancellationToken)
{
var installDirectory = existing.Root;
foreach (var cacheDirectory in WorkspaceCacheDirectories)
{
cancellationToken.ThrowIfCancellationRequested();
var source = Path.Combine(existing.WorkspaceDirectory, cacheDirectory);
if (!Directory.Exists(source)) continue;
var destination = Path.Combine(stagedWorkspace, cacheDirectory);
if (Directory.Exists(destination)) Directory.Delete(destination, recursive: true);
Directory.Move(source, destination);
}
var state = BuildInstallState(previousState, installDirectory, manifest,
compileInputs: null, canonicalRetroRoot, options.RetroWfcPayloadMode);
PrepareMetadata(staging, manifest, state);
cancellationToken.ThrowIfCancellationRequested();
var entries = new List<InstallTransactionEntry>();
AddComponent(entries, staging, installDirectory, InstalledLayout.ToolkitDirectoryName);
AddComponent(entries, staging, installDirectory, InstalledLayout.WorkspaceDirectoryName);
if (publishGameAssets) AddComponent(entries, staging, installDirectory, "GameAssets");
Publish(staging, installDirectory, canonicalRetroRoot, state,
entries, cancellationToken, progressPercent: 8, completionPercent: 10);
_reporter.Progress(InstallStages.BuildBase,
"Producing the installed products with the published toolkit...", 11);
var reconciliation = await ReconcileSameToolkitAsync(existing, retroCompileInputs,
canonicalRetroRoot, options.RetroWfcPayloadMode, repairScratch: null,
manifest.ExpectedDolSha256, manifest.ExpectedRelSha256, cancellationToken);
EnsureReconciliationCurrent(existing, manifest.ToolkitFingerprint, retroCompileInputs,
manifest.ExpectedDolSha256, manifest.ExpectedRelSha256,
reconciliation.CachedRetroWfcPayloadMatches);
}
private Task<ProductRepairService.ReconciliationResult> ReconcileSameToolkitAsync(Installation existing,
RetroRewindCompileInputs? retroCompileInputs, string? canonicalRetroRoot,
RetroWfcPayloadMode payloadMode, string? repairScratch, string expectedDolSha256,
string expectedRelSha256, CancellationToken cancellationToken)
{
var repair = new ProductRepairService(existing, _reporter);
var options = new ProductRepairService.ReconcileOptions
{
ScratchRoot = repairScratch,
ExpectedDolSha256 = expectedDolSha256,
ExpectedRelSha256 = expectedRelSha256,
CanonicalRetroRewindRoot = canonicalRetroRoot
};
return retroCompileInputs is null
? repair.RepairBaseAsync(options, cancellationToken)
: repair.RepairRetroAsync(retroCompileInputs, payloadMode, options, cancellationToken);
}
private static void EnsureReconciliationCurrent(Installation installation, string toolkitFingerprint,
RetroRewindCompileInputs? retroCompileInputs, string expectedDolSha256, string expectedRelSha256,
bool? cachedRetroWfcPayloadMatches)
{
var baseState = installation.CheckBase(toolkitFingerprint);
var retroState = installation.CheckRetroRewind(toolkitFingerprint, retroCompileInputs,
canonicalError: null, cachedRetroWfcPayloadMatches);
var desiredInputsCurrent = installation.ProductUsesGameInputs(installation.BaseDirectory,
toolkitFingerprint, expectedDolSha256, expectedRelSha256) &&
(!installation.HasRetroProduct ||
installation.ProductUsesGameInputs(installation.RetroDirectory,
toolkitFingerprint, expectedDolSha256, expectedRelSha256));
if (baseState.Status == ProductStatus.Current && !retroState.ActionRequired && desiredInputsCurrent) return;
var details = string.Join(" ", new[] { baseState, retroState }
.Where(state => state.ActionRequired)
.Select(state => state.Detail)
.Where(detail => !string.IsNullOrWhiteSpace(detail)));
throw new InvalidOperationException(
"Product reconciliation did not produce a current installation with the release's game inputs; " +
"release metadata was not advanced." +
(string.IsNullOrWhiteSpace(details) ? "" : " " + details));
}
/// <summary>
/// Publishes the caller's component entries together with the release metadata every install and
/// update advances, then applies the runtime configuration this installation owns.
/// </summary>
private void Publish(string staging, string installDirectory,
string? canonicalRetroRoot, InstallState state,
List<InstallTransactionEntry> entries, CancellationToken cancellationToken,
int progressPercent = 95, int completionPercent = 99)
{
entries.Add(InstallTransactionEntry.Directory(Path.Combine(staging, "licenses"),
Path.Combine(installDirectory, "licenses")));
entries.Add(InstallTransactionEntry.File(Path.Combine(staging, ProductInfo.SetupCopyName),
Path.Combine(installDirectory, ProductInfo.SetupCopyName)));
entries.Add(InstallTransactionEntry.File(
Path.Combine(staging, InstalledLayout.PayloadManifestFileName),
Path.Combine(installDirectory, InstalledLayout.PayloadManifestFileName)));
entries.Add(InstallTransactionEntry.File(Path.Combine(staging, ToolkitState.FileName),
Path.Combine(installDirectory, ToolkitState.FileName)));
entries.Add(InstallTransactionEntry.File(
Path.Combine(staging, InstalledLayout.InstallStateFileName),
Path.Combine(installDirectory, InstalledLayout.InstallStateFileName)));
cancellationToken.ThrowIfCancellationRequested();
RunningProductGuard.EnsureProductsNotRunning(installDirectory);
_reporter.Progress(InstallStages.Publish, "Publishing the completed installation...", progressPercent);
var configPath = RuntimeConfiguration.ResolveConfigPath(installDirectory);
var configSnapshot = RuntimeConfiguration.Capture(configPath);
using var transaction = InstallTransaction.Begin(installDirectory, _reporter, entries.ToArray());
transaction.Publish();
transaction.RecordRuntimeConfigurationMutation(configSnapshot);
RuntimeConfiguration.SetDvdRoot(configPath, Path.Combine(installDirectory, "GameAssets", "DATA"));
// The runtime scans this directory live for its asset overlay, so it is recorded on every
// operation that receives one and is never rewritten by a base-only operation.
if (canonicalRetroRoot is not null)
RuntimeConfiguration.SetRetroRewindRoot(configPath, canonicalRetroRoot);
transaction.Commit();
try
{
// A portable installation is owned by the folder it lives in, not by this machine. Adding
// a machine-wide uninstall entry for it would outlive the folder and, worse, would
// overwrite the entry of a normal installation on the same account.
if (PortableRoot.TryFind(installDirectory) is not null)
{
_reporter.Diagnostic(
"Portable installation: no Windows uninstall entry was registered. Remove the folder, " +
"or run the copied setup with --uninstall, to uninstall it.");
}
else
{
ShellIntegration.RegisterUninstaller(installDirectory, state.RetroRewindInstalled);
}
ShellIntegration.CreateShortcuts(installDirectory);
}
catch (Exception ex)
{
_reporter.Diagnostic("The installation succeeded, but Windows shell integration failed: " +
ex.Message);
}
_reporter.Progress(InstallStages.Publish, "Installation complete.", completionPercent);
}
private static void PrepareMetadata(string staging, PayloadManifest manifest, InstallState state)
{
JsonState.Write(Path.Combine(staging, ToolkitState.FileName), new ToolkitState
{
ToolkitFingerprint = manifest.ToolkitFingerprint,
ToolkitPackageFingerprint = manifest.ToolkitPackageFingerprint,
RuntimeAssetsFingerprint = manifest.RuntimeAssetsFingerprint,
TranslationFingerprint = manifest.TranslationFingerprint,
NativeToolchainFingerprint = manifest.NativeToolchainFingerprint,
ToolkitReleaseTag = manifest.ToolkitReleaseTag
});
JsonState.Write(Path.Combine(staging, InstalledLayout.InstallStateFileName), state);
}
private static InstallState BuildInstallState(InstallState? previousState, string installDirectory,
PayloadManifest manifest, RetroRewindCompileInputs? compileInputs, string? canonicalRetroRoot,
RetroWfcPayloadMode payloadMode,
RetroWfcPayloadSnapshot? retroWfcPayloadSnapshot = null)
{
// Preserve the pre-update object for runtime-configuration rollback/ownership decisions.
var state = new InstallState
{
SchemaVersion = 1,
SetupVersion = ProductInfo.Version,
ProductVersion = manifest.ProductVersion,
InstallDir = installDirectory,
InstalledUtc = previousState?.InstalledUtc ?? DateTime.UtcNow,
RetroRewindInstalled = previousState?.RetroRewindInstalled ?? false,
ToolkitReleaseTag = manifest.ToolkitReleaseTag,
DolSha256 = manifest.ExpectedDolSha256,
RelSha256 = manifest.ExpectedRelSha256,
RetroRewindCodePulSha256 = previousState?.RetroRewindCodePulSha256 ?? "",
RetroRewindCompileInputsSha256 = previousState?.RetroRewindCompileInputsSha256 ?? "",
RetroWfcPayloadMode = previousState?.RetroWfcPayloadMode ?? "",
RetroWfcPayloadSha256 = previousState?.RetroWfcPayloadSha256 ?? "",
RetroWfcPayloadLength = previousState?.RetroWfcPayloadLength ?? 0,
RetroRewindRoot = canonicalRetroRoot is null
? previousState?.RetroRewindRoot ?? ""
: Path.GetFullPath(canonicalRetroRoot)
};
if (compileInputs is not null)
{
state.RetroRewindInstalled = true;
state.RetroRewindCodePulSha256 = compileInputs.CodePulSha256;
state.RetroRewindCompileInputsSha256 = compileInputs.CompileInputsSha256;
state.RetroWfcPayloadMode = payloadMode == RetroWfcPayloadMode.Online
? "downloaded"
: "skipped";
state.RetroWfcPayloadSha256 = payloadMode == RetroWfcPayloadMode.Online
? retroWfcPayloadSnapshot?.Sha256 ?? state.RetroWfcPayloadSha256
: "";
state.RetroWfcPayloadLength = payloadMode == RetroWfcPayloadMode.Online
? retroWfcPayloadSnapshot?.ByteLength ?? state.RetroWfcPayloadLength
: 0;
if (payloadMode == RetroWfcPayloadMode.Online &&
(string.IsNullOrWhiteSpace(state.RetroWfcPayloadSha256) || state.RetroWfcPayloadLength <= 0))
throw new InvalidDataException("The downloaded Retro-WFC payload snapshot is missing.");
}
return state;
}
/// <summary>
/// Captures the canonical installation's compile inputs into operation-owned staging. Only
/// Code.pul and the bundled Pulsar sources are copied; the asset tree stays where Wheel Wizard
/// owns it and is read live by the runtime through <c>retro_rewind_root</c>.
/// </summary>
private RetroRewindCompileInputs SnapshotRetroRewindCompileInputs(string canonicalRetroRoot,
string destination, CancellationToken cancellationToken)
{
_reporter.Progress(InstallStages.Validate, "Snapshotting the Retro Rewind compile inputs...", 3);
return CompileInputsFingerprint.Snapshot(canonicalRetroRoot, destination, cancellationToken);
}
private bool RuntimeAssetsAreCurrent(Installation installation, string expectedFingerprint,
CancellationToken cancellationToken)
{
var installedSource = ToolkitFingerprint.TryComputeRuntimeAssets(installation.Root, cancellationToken,
_reporter.Diagnostic);
if (!expectedFingerprint.Equals(installedSource, StringComparison.Ordinal)) return false;
if (File.Exists(installation.BaseExecutable) &&
!ToolkitFingerprint.ProductRuntimeAssetsMatch(installation.BaseDirectory, expectedFingerprint,
cancellationToken, _reporter.Diagnostic))
return false;
return !installation.HasRetroProduct ||
ToolkitFingerprint.ProductRuntimeAssetsMatch(installation.RetroDirectory,
expectedFingerprint, cancellationToken, _reporter.Diagnostic);
}
private void AddRuntimeAssetPublicationEntries(List<InstallTransactionEntry> entries,
string staging, string installDirectory, bool includeRetro, string expectedFingerprint,
CancellationToken cancellationToken)
{
// The identity was computed from this exact operation-owned payload extraction, so the
// staged assets are published as they are; only repair has an installed source to re-verify.
var sourceAssets = Path.Combine(InstalledLayout.Workspace(staging), "runtime", "assets");
var products = (includeRetro ? new[] { "Base", "RetroRewind" } : new[] { "Base" })
.Select(product => (product, Path.Combine(installDirectory, product)));
RuntimeAssetPublication.AddEntries(entries, sourceAssets,
Path.Combine(staging, "runtime-asset-publication"), products, expectedFingerprint,
"publication", _reporter.Diagnostic, cancellationToken);
entries.Add(InstallTransactionEntry.Directory(sourceAssets,
Path.Combine(InstalledLayout.Workspace(installDirectory), "runtime", "assets")));
}
private static void ValidateRetroOptions(string? canonicalRetroRoot, RetroWfcPayloadMode mode,
PayloadManifest manifest)
{
if (canonicalRetroRoot is null && mode != RetroWfcPayloadMode.NotApplicable)
throw new InvalidOperationException(
"A Retro-WFC payload option requires the canonical Retro Rewind folder.");
if (canonicalRetroRoot is not null && mode == RetroWfcPayloadMode.NotApplicable)
throw new InvalidOperationException(
"Download the Retro-WFC payload, or explicitly skip the optional payload.");
if (canonicalRetroRoot is not null && mode == RetroWfcPayloadMode.Online)
InputValidation.ValidateRetroWfcPayloadUri(manifest.RetroWfcPayloadUri);
}
private string? FindReusableGameAssets(Installation existing, PayloadManifest manifest)
{
var dataRoot = existing.GameDataDirectory;
// No fst.bin simply means nothing was extracted here before; anything past that gate is an
// existing extraction that fails reuse, which the log must say before the expensive
// re-extraction quietly repairs it.
if (!File.Exists(Path.Combine(dataRoot, "sys", "fst.bin"))) return null;
try
{
ValidateExtractedGame(dataRoot, manifest);
return dataRoot;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
_reporter.Diagnostic(
"The installed game assets cannot be reused; the disc will be extracted again: " + ex.Message);
return null;
}
}
private async Task ExtractGameAssetsAsync(string nodTool, string gamePath, string destination,
PayloadManifest manifest, CancellationToken cancellationToken)
{
_reporter.Progress(InstallStages.ExtractDisc,
"Extracting the game disc. This is the longest preparation step...", 6);
// Extracted straight into a "DATA" subfolder so the on-disk layout matches what
// Installation.GameDataDirectory and every other reader of it already expect - nodtool
// itself has no such wrapper (it extracts sys/+files/ directly to whatever <outdir> is
// given), so this is purely destination-side, not a nodtool convention.
var dataRoot = Path.Combine(destination, "DATA");
var extraction = await ProcessRunner.RunAsync(nodTool,
["extract", Path.GetFullPath(gamePath), dataRoot, "-q"],
line => { if (!string.IsNullOrWhiteSpace(line)) _reporter.Diagnostic(line); },
cancellationToken);
if (extraction.ExitCode != 0)
throw new InvalidDataException("Game extraction failed. " + extraction.CombinedOutput.Trim());
ValidateExtractedGame(dataRoot, manifest);
}
private static void ValidateExtractedGame(string dataRoot, PayloadManifest manifest)
{
var dol = Path.Combine(dataRoot, "sys", "main.dol");
var rel = Path.Combine(dataRoot, "files", "rel", "StaticR.rel");
var fst = Path.Combine(dataRoot, "sys", "fst.bin");
if (!File.Exists(dol) || !File.Exists(rel) || !File.Exists(fst))
throw new InvalidDataException("The disc extraction is missing required Mario Kart Wii files.");
var dolHash = InputValidation.Sha256File(dol);
var relHash = InputValidation.Sha256File(rel);
if (!dolHash.Equals(manifest.ExpectedDolSha256, StringComparison.OrdinalIgnoreCase) ||
!relHash.Equals(manifest.ExpectedRelSha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
"The disc is RMCP01 but does not match the supported clean PAL revision. " +
"Patched or otherwise modified game code cannot be installed safely.");
}
}
private static void ValidateInstallDirectory(string path) =>
FileSystemUtilities.EnsureUsableLocation(path, "The installation folder");
private static void EnsureSufficientRemainingDiskSpace(string installDirectory,
bool needsExtractedDisc, bool needsLocalBuild)
{
const long extractedDiscAllowance = 5L * 1024 * 1024 * 1024;
const long localBuildAllowance = 14L * 1024 * 1024 * 1024;
const long safetyAllowance = 2L * 1024 * 1024 * 1024;
// This preflight runs after the release payload and the compile-input snapshot have already
// been staged. Count only work that can still allocate space from this point.
var required = checked((needsExtractedDisc ? extractedDiscAllowance : 0) +
(needsLocalBuild ? localBuildAllowance : 0) + safetyAllowance);
FileSystemUtilities.EnsureFreeSpace(installDirectory, required, "Setup");
}
}
@@ -0,0 +1,198 @@
using System.Diagnostics;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// <see cref="Both"/> runs one retro-aware translation and compiles the two products from a single
/// build graph, sharing every profile-neutral shard object. Building the legs separately re-emits
/// the shared base shards with retro-aware content in the second leg and recompiles all of them.
/// </summary>
internal enum BuildProfile { Base, RetroRewind, Both }
internal sealed class LocalBuildService
{
private readonly IInstallReporter _reporter;
public LocalBuildService(IInstallReporter reporter) => _reporter = reporter;
/// <summary>
/// Runs the bundled translate-and-compile script against <paramref name="installStaging"/>, which
/// holds a complete <c>Toolkit</c> and <c>BuildWorkspace</c> pair - either an installation being
/// staged or an existing installation being repaired in place.
/// </summary>
public async Task BuildAsync(string installStaging, BuildProfile profile, string outputDirectory,
RetroWfcPayloadMode retroWfcPayloadMode, string? retroWfcOfflinePayloadDirectory,
CancellationToken cancellationToken, ToolkitFingerprintComponents? toolkitComponents = null,
bool forceCleanBuild = false, BuildProgressWindow? progress = null,
string? retroRewindPackageDirectory = null, string? baseOutputDirectory = null)
{
var workspace = InstalledLayout.Workspace(installStaging);
var toolkit = InstalledLayout.Toolkit(installStaging);
var script = Path.Combine(workspace, "LocalBuild.ps1");
if (!File.Exists(script)) throw new FileNotFoundException("The local build script is missing.", script);
var buildsRetro = profile is BuildProfile.RetroRewind or BuildProfile.Both;
if (!buildsRetro && retroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable)
throw new ArgumentException("The base build cannot select a Retro-WFC payload.");
if (buildsRetro && retroWfcPayloadMode == RetroWfcPayloadMode.NotApplicable)
throw new ArgumentException("The Retro Rewind build must select or explicitly skip the Retro-WFC payload.");
if (retroRewindPackageDirectory is not null && !buildsRetro)
throw new ArgumentException("Only Retro Rewind can select an explicit package snapshot.");
if ((profile == BuildProfile.Both) != (baseOutputDirectory is not null))
throw new ArgumentException("A combined build takes exactly one base output directory.");
if (retroRewindPackageDirectory is not null)
retroRewindPackageDirectory = RetroRewindSource.ResolveRetroRewind6(
retroRewindPackageDirectory);
if (retroWfcPayloadMode == RetroWfcPayloadMode.Online)
retroWfcOfflinePayloadDirectory =
InputValidation.ValidateStagedRetroWfcPayloadDirectory(retroWfcOfflinePayloadDirectory!);
var arguments = new List<string>
{
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script,
"-Workspace", workspace, "-Toolkit", toolkit,
"-Profile", profile switch
{
BuildProfile.Base => "base",
BuildProfile.RetroRewind => "retro-rewind",
_ => "both"
},
"-OutputDirectory", outputDirectory
};
if (baseOutputDirectory is not null)
{
arguments.Add("-BaseOutputDirectory");
arguments.Add(baseOutputDirectory);
}
// The script is the single owner of every cache-reuse decision; these identities are the
// evidence it compares against the provenance it recorded with the caches themselves.
// Without them (or with -ForceCleanBuild) it degrades to a full clean translate and build.
if (toolkitComponents is not null)
{
arguments.Add("-TranslationFingerprint");
arguments.Add(toolkitComponents.Translation);
arguments.Add("-NativeToolchainFingerprint");
arguments.Add(toolkitComponents.NativeToolchain);
}
if (forceCleanBuild)
arguments.Add("-ForceCleanBuild");
if (retroRewindPackageDirectory is not null)
{
arguments.Add("-RetroRewindPackageDirectory");
arguments.Add(retroRewindPackageDirectory);
}
if (retroWfcPayloadMode == RetroWfcPayloadMode.Online)
{
arguments.Add("-RetroWfcOfflineDirectory");
arguments.Add(retroWfcOfflinePayloadDirectory!);
arguments.Add("-RetroWfcPayloadOrigin");
arguments.Add("downloaded");
}
else if (retroWfcPayloadMode == RetroWfcPayloadMode.Skipped)
{
arguments.Add("-SkipRetroWfcPayload");
}
// Scrubs the ambient VS environment so the bundled clang-mingw toolchain is the only one visible.
// The actual search path is set by LocalBuild.ps1's one canonical definition (Get-MkwToolchainPath,
// shared with Prepare-NativePrebuilt.ps1), not duplicated here, to avoid drift.
void ScrubEnvironment(ProcessStartInfo start)
{
start.WorkingDirectory = workspace;
foreach (var inherited in new[]
{ "INCLUDE", "LIB", "LIBPATH", "VSINSTALLDIR", "VCToolsInstallDir", "WindowsSdkDir" })
start.Environment.Remove(inherited);
}
void Observe(string line)
{
if (string.IsNullOrWhiteSpace(line)) return;
if (progress is not null) progress.Observe(line);
else _reporter.Diagnostic(line);
}
var build = await ProcessRunner.RunAsync(
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),
"WindowsPowerShell", "v1.0", "powershell.exe"),
arguments, Observe, cancellationToken, ScrubEnvironment, capture: false,
ex => _reporter.Diagnostic(
"The cancelled build process could not be terminated immediately: " + ex.Message));
if (build.ExitCode != 0)
throw new InvalidOperationException($"Local recompilation failed with exit code {build.ExitCode}. See the setup log for the failed translator or compiler command.");
var expectedOutputs = profile switch
{
BuildProfile.Base => [(outputDirectory, "WiiCompiled.exe")],
BuildProfile.RetroRewind => [(outputDirectory, "RetroRewind.exe")],
_ => new[] { (baseOutputDirectory!, "WiiCompiled.exe"), (outputDirectory, "RetroRewind.exe") }
};
foreach (var (directory, executableName) in expectedOutputs)
{
var executable = Path.Combine(directory, executableName);
if (!File.Exists(executable) || new FileInfo(executable).Length < 64 * 1024)
throw new InvalidDataException("The local build completed without producing a valid game executable.");
if (!File.Exists(Path.Combine(directory, "local-build.json")))
throw new InvalidDataException("The local build did not produce provenance information.");
}
}
/// <summary>
/// Records what this product was built from: <paramref name="toolkitFingerprint"/> ties it to the
/// translator/workspace, <paramref name="codePulSha256"/> to Retro Rewind's embedded Code.pul, so a
/// later launch or update can tell whether the binary is still correct.
/// </summary>
public static void WriteFingerprint(string outputDirectory, BuildProfile profile, string toolkitFingerprint,
string dolSha256, string relSha256, string codePulSha256, RetroWfcPayloadMode retroWfcPayloadMode,
string retroRewindCompileInputsSha256 = "", string retroWfcPayloadSha256 = "",
long retroWfcPayloadLength = 0)
{
var executable = Path.Combine(outputDirectory,
profile == BuildProfile.Base ? "WiiCompiled.exe" : "RetroRewind.exe");
if (!File.Exists(executable))
throw new FileNotFoundException("Cannot record build provenance because the compiled executable is missing.",
executable);
if (profile == BuildProfile.RetroRewind)
{
var local = JsonState.TryRead<LocalBuildProvenance>(
Path.Combine(outputDirectory, LocalBuildProvenance.FileName));
var expectedMode = retroWfcPayloadMode == RetroWfcPayloadMode.Online
? "downloaded"
: "skipped";
if (local is not { SchemaVersion: 1 } ||
!string.Equals(local.DolSha256, dolSha256, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(local.RelSha256, relSha256, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(local.CodePulSha256, codePulSha256,
StringComparison.OrdinalIgnoreCase) ||
!string.Equals(local.RetroWfcPayloadMode, expectedMode, StringComparison.Ordinal) ||
!string.Equals(local.RetroWfcPayloadSha256,
retroWfcPayloadMode == RetroWfcPayloadMode.Online ? retroWfcPayloadSha256 : null,
StringComparison.OrdinalIgnoreCase) ||
(local.RetroWfcPayloadLength ?? 0) !=
(retroWfcPayloadMode == RetroWfcPayloadMode.Online ? retroWfcPayloadLength : 0))
{
throw new InvalidDataException(
"The local Retro Rewind build did not consume the selected Retro-WFC payload snapshot.");
}
}
var fingerprint = new ProductFingerprint
{
Profile = profile == BuildProfile.Base ? "base" : "retro-rewind",
ToolkitFingerprint = toolkitFingerprint,
DolSha256 = dolSha256,
RelSha256 = relSha256,
ExecutableSha256 = InputValidation.Sha256File(executable),
CodePulSha256 = codePulSha256,
RetroRewindCompileInputsSha256 = retroRewindCompileInputsSha256,
RetroWfcPayloadMode = retroWfcPayloadMode switch
{
RetroWfcPayloadMode.Online => "downloaded",
RetroWfcPayloadMode.Skipped => "skipped",
_ => ""
},
RetroWfcPayloadSha256 = retroWfcPayloadSha256,
RetroWfcPayloadLength = retroWfcPayloadLength
};
JsonState.Write(Path.Combine(outputDirectory, ProductFingerprint.FileName), fingerprint);
}
}
@@ -0,0 +1,161 @@
using System.Text.Json.Serialization;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
internal enum RetroWfcPayloadMode
{
NotApplicable,
Online,
Skipped
}
internal sealed class InstallOptions
{
public required string GamePath { get; init; }
/// <summary>
/// The canonical Retro Rewind installation Wheel Wizard owns. Its resolved path is recorded as
/// <c>retro_rewind_root</c>; only its compile inputs are ever snapshotted.
/// </summary>
public string? RetroDirectoryPath { get; init; }
public RetroWfcPayloadMode RetroWfcPayloadMode { get; init; }
public required string InstallDirectory { get; init; }
/// <summary>
/// Establish a portable installation: the parent of <see cref="InstallDirectory"/> becomes the
/// portable root, gets the <c>portable.txt</c> marker and a <c>UserData</c> directory, and all
/// runtime user state and <c>[paths]</c> settings stay inside it.
/// </summary>
public bool Portable { get; init; }
public bool HasRetroRewind => RetroDirectoryPath is not null;
}
internal sealed class PayloadManifest
{
public int SchemaVersion { get; set; }
public string ProductVersion { get; set; } = "";
public string ExpectedGameId { get; set; } = "RMCP01";
public string ExpectedDolSha256 { get; set; } = "";
public string ExpectedRelSha256 { get; set; } = "";
public string ToolkitReleaseTag { get; set; } = "";
public string BuildModel { get; set; } = "";
public string RetroWfcPayloadUri { get; set; } = "";
/// <summary>
/// Content identities computed once at release-build time (<c>Build-Installer.ps1 --emit-payload-identities</c>)
/// so install doesn't re-hash gigabytes per user; compilation alone re-derives the toolkit identity from disk.
/// </summary>
public string ToolkitFingerprint { get; set; } = "";
public string ToolkitPackageFingerprint { get; set; } = "";
public string RuntimeAssetsFingerprint { get; set; } = "";
public string TranslationFingerprint { get; set; } = "";
public string NativeToolchainFingerprint { get; set; } = "";
}
internal sealed class DiscHeader
{
[JsonPropertyName("game_id")]
public string GameId { get; set; } = "";
[JsonPropertyName("internal_name")]
public string InternalName { get; set; } = "";
[JsonPropertyName("region")]
public string Region { get; set; } = "";
[JsonPropertyName("revision")]
public int Revision { get; set; }
}
internal sealed class InstallState
{
[JsonRequired]
public int SchemaVersion { get; set; } = 1;
/// <summary>
/// The setup version that produced this installation. This is the field WheelWizard compares
/// against the latest published <c>v*</c> release tag; see docs/WHEELWIZARD_CONTRACT.md.
/// </summary>
public string SetupVersion { get; set; } = ProductInfo.Version;
public string ProductVersion { get; set; } = ProductInfo.Version;
/// <summary>The directory this state describes, so a frontend can confirm what it found.</summary>
public string InstallDir { get; set; } = "";
public DateTime InstalledUtc { get; set; } = DateTime.UtcNow;
public bool RetroRewindInstalled { get; set; }
public string ToolkitReleaseTag { get; set; } = "";
public string DolSha256 { get; set; } = "";
public string RelSha256 { get; set; } = "";
public string RetroRewindCodePulSha256 { get; set; } = "";
public string RetroRewindCompileInputsSha256 { get; set; } = "";
public string RetroWfcPayloadMode { get; set; } = "";
public string RetroWfcPayloadSha256 { get; set; } = "";
public long RetroWfcPayloadLength { get; set; }
/// <summary>
/// The <c>RetroRewind6</c> directory this installation was pointed at. The runtime's actual copy is
/// <c>[paths] retro_rewind_root</c>; this mirror lets uninstall drop only the setting it owns.
/// </summary>
public string RetroRewindRoot { get; set; } = "";
}
/// <summary>
/// Written next to every locally produced product (<c>Base</c>, <c>RetroRewind</c>): the exact inputs
/// it was built from, so launch/update can decide without re-running anything whether it's still correct.
/// </summary>
internal sealed class ProductFingerprint
{
[JsonRequired]
public int SchemaVersion { get; set; } = 1;
public string Profile { get; set; } = "";
public string SetupVersion { get; set; } = ProductInfo.Version;
public string ToolkitFingerprint { get; set; } = "";
public string DolSha256 { get; set; } = "";
public string RelSha256 { get; set; } = "";
/// <summary>
/// Identity of the exact executable produced by this build. A valid input provenance is not
/// enough if a partial copy, third-party replacement, or disk corruption changed the product
/// after it was built.
/// </summary>
public string ExecutableSha256 { get; set; } = "";
public string CodePulSha256 { get; set; } = "";
/// <summary>
/// Identity of every translator-consumed Retro Rewind input this product was built from. An
/// asset-only change to the canonical installation does not alter it, which is exactly why an
/// asset-only Retro Rewind update needs no backend work at all.
/// </summary>
public string RetroRewindCompileInputsSha256 { get; set; } = "";
public string RetroWfcPayloadMode { get; set; } = "";
public string RetroWfcPayloadSha256 { get; set; } = "";
public long RetroWfcPayloadLength { get; set; }
public DateTimeOffset BuiltUtc { get; set; } = DateTimeOffset.UtcNow;
public const string FileName = "build-fingerprint.json";
}
/// <summary>
/// Written whenever the toolkit (translator + build workspace + compiler) is installed or replaced;
/// records compile, packaged-tool, and copied-runtime identities. Launch checks compare the compile
/// identity against each product's <see cref="ProductFingerprint.ToolkitFingerprint"/> in O(1).
/// </summary>
internal sealed class ToolkitState
{
[JsonRequired]
public int SchemaVersion { get; set; } = 2;
public string ToolkitFingerprint { get; set; } = "";
public string ToolkitPackageFingerprint { get; set; } = "";
/// <summary>Identity of the runtime assets copied verbatim beside each product.</summary>
public string RuntimeAssetsFingerprint { get; set; } = "";
public string TranslationFingerprint { get; set; } = "";
public string NativeToolchainFingerprint { get; set; } = "";
public string ToolkitReleaseTag { get; set; } = "";
public string SetupVersion { get; set; } = ProductInfo.Version;
public DateTimeOffset UpdatedUtc { get; set; } = DateTimeOffset.UtcNow;
public const string FileName = "toolkit-state.json";
}
@@ -0,0 +1,167 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
namespace WiiCompiled.Setup.Windows;
internal sealed class PayloadArchive : IDisposable
{
private static readonly byte[] FooterMagic = Encoding.ASCII.GetBytes("MKWCPAY1");
private static readonly DateTime NormalizedPayloadTimestampUtc =
new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private const int FooterSize = 24;
private readonly FileStream _executable;
private readonly SliceStream _payloadStream;
private readonly ZipArchive _zip;
private PayloadArchive(FileStream executable, SliceStream payloadStream, ZipArchive zip)
{
_executable = executable;
_payloadStream = payloadStream;
_zip = zip;
}
public static PayloadArchive OpenCurrent()
{
var path = Environment.ProcessPath ?? throw new InvalidOperationException("Cannot locate the setup executable.");
var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
try
{
if (stream.Length < FooterSize)
throw new InvalidDataException("This setup executable does not contain an installation payload.");
stream.Position = stream.Length - FooterSize;
using var reader = new BinaryReader(stream, Encoding.ASCII, leaveOpen: true);
var magic = reader.ReadBytes(FooterMagic.Length);
var offset = reader.ReadInt64();
var length = reader.ReadInt64();
if (!magic.SequenceEqual(FooterMagic) || offset < 0 || length <= 0 || offset + length != stream.Length - FooterSize)
throw new InvalidDataException("The installation payload is missing or damaged.");
var slice = new SliceStream(stream, offset, length);
var zip = new ZipArchive(slice, ZipArchiveMode.Read, leaveOpen: true);
return new PayloadArchive(stream, slice, zip);
}
catch
{
stream.Dispose();
throw;
}
}
public PayloadManifest ReadManifest()
{
var entry = FindEntry(InstalledLayout.PayloadManifestFileName)
?? throw new InvalidDataException("The payload manifest is missing.");
using var stream = entry.Open();
var manifest = JsonSerializer.Deserialize<PayloadManifest>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidDataException("The payload manifest is invalid.");
if (manifest.SchemaVersion != 2)
throw new InvalidDataException($"Unsupported payload schema {manifest.SchemaVersion}.");
// The payload identities are the release-computed replacement for hashing the whole
// toolkit on the user's machine; a payload without them was packaged incorrectly.
if (string.IsNullOrWhiteSpace(manifest.ToolkitFingerprint) ||
string.IsNullOrWhiteSpace(manifest.ToolkitPackageFingerprint) ||
string.IsNullOrWhiteSpace(manifest.RuntimeAssetsFingerprint))
throw new InvalidDataException("The payload manifest is missing its content identities.");
InputValidation.ValidateRetroWfcPayloadUri(manifest.RetroWfcPayloadUri);
return manifest;
}
public void ExtractEntry(string entryName, string destination)
{
var entry = FindEntry(entryName)
?? throw new InvalidDataException($"Payload entry is missing: {entryName}");
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
using (var input = entry.Open())
using (var output = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.None))
input.CopyTo(output);
NormalizeExtractedTimestamp(destination);
}
public void ExtractDirectory(string prefix, string destination)
{
prefix = NormalizeEntryName(prefix).TrimEnd('/') + "/";
var destinationRoot = Path.GetFullPath(destination).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
foreach (var entry in _zip.Entries)
{
var normalizedEntry = NormalizeEntryName(entry.FullName);
if (!normalizedEntry.StartsWith(prefix, StringComparison.Ordinal) || normalizedEntry.EndsWith('/'))
continue;
var relative = normalizedEntry[prefix.Length..].Replace('/', Path.DirectorySeparatorChar);
var outputPath = Path.GetFullPath(Path.Combine(destinationRoot, relative));
if (!outputPath.StartsWith(destinationRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"Unsafe payload path: {entry.FullName}");
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
entry.ExtractToFile(outputPath, overwrite: true);
NormalizeExtractedTimestamp(outputPath);
}
}
internal static void NormalizeExtractedTimestamp(string path) =>
File.SetLastWriteTimeUtc(path, NormalizedPayloadTimestampUtc);
private static string NormalizeEntryName(string value) => value.Replace('\\', '/').TrimStart('/');
private ZipArchiveEntry? FindEntry(string name)
{
var normalized = NormalizeEntryName(name);
return _zip.Entries.FirstOrDefault(entry =>
NormalizeEntryName(entry.FullName).Equals(normalized, StringComparison.Ordinal));
}
public void Dispose()
{
_zip.Dispose();
_payloadStream.Dispose();
_executable.Dispose();
}
private sealed class SliceStream : Stream
{
private readonly Stream _inner;
private readonly long _offset;
private readonly long _length;
private long _position;
public SliceStream(Stream inner, long offset, long length)
{
_inner = inner;
_offset = offset;
_length = length;
}
public override bool CanRead => true;
public override bool CanSeek => true;
public override bool CanWrite => false;
public override long Length => _length;
public override long Position { get => _position; set => Seek(value, SeekOrigin.Begin); }
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count)
{
if (_position >= _length) return 0;
count = (int)Math.Min(count, _length - _position);
lock (_inner)
{
_inner.Position = _offset + _position;
var read = _inner.Read(buffer, offset, count);
_position += read;
return read;
}
}
public override long Seek(long offset, SeekOrigin origin)
{
var next = origin switch
{
SeekOrigin.Begin => offset,
SeekOrigin.Current => _position + offset,
SeekOrigin.End => _length + offset,
_ => throw new ArgumentOutOfRangeException(nameof(origin))
};
if (next < 0 || next > _length) throw new IOException("Attempted to seek outside the payload.");
return _position = next;
}
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}
@@ -0,0 +1,54 @@
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// A portable root can be moved or renamed between operations. Every installed-host operation that
/// reads <c>install-state.json</c> passes through here first so exactly one place decides what a
/// moved installation means, and so a non-portable installation is never touched.
/// </summary>
internal static class PortableInstallHealing
{
/// <summary>
/// Reconciles a moved portable installation with its recorded location: the state file adopts the
/// directory it was actually found in, and the native build tree is discarded because its
/// CMake cache holds absolute paths from the old location. Returns whether anything was healed.
/// </summary>
public static bool HealMovedInstall(Installation installation, IInstallReporter? reporter = null)
{
// Guard: an ordinary installation that disagrees with its state file is a real problem for
// the operation to report, not something to silently rewrite.
if (PortableRoot.TryFind(installation.Root) is null) return false;
var state = installation.ReadInstallState();
if (state is not { SchemaVersion: 1 } || string.IsNullOrWhiteSpace(state.InstallDir)) return false;
string recorded;
try
{
recorded = FileSystemUtilities.NormalizePath(state.InstallDir);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
recorded = state.InstallDir;
}
if (recorded.Equals(installation.Root, StringComparison.OrdinalIgnoreCase)) return false;
var previous = state.InstallDir;
state.InstallDir = installation.Root;
JsonState.Write(installation.InstallStatePath, state);
// The configured native build directory bakes absolute source, toolchain, and output paths
// into CMakeCache.txt. After a move it is unusable and would fail the next configure rather
// than being reused, so it is removed and reconfigured from scratch on the next build.
var nativeBuild = Path.Combine(installation.WorkspaceDirectory, "native-build");
var hadNativeBuild = Directory.Exists(nativeBuild);
if (hadNativeBuild) FileSystemUtilities.DeleteDirectoryIfExists(nativeBuild);
reporter?.Diagnostic(
$"This portable installation moved from {previous} to {installation.Root}. " +
"The recorded location was updated" +
(hadNativeBuild ? " and the location-bound native build cache was discarded." : "."));
return true;
}
}
@@ -0,0 +1,668 @@
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Reconciles installed products against the canonical Retro Rewind install Wheel Wizard owns: the
/// toolkit compiles, the source only contributes inputs and its path (<c>retro_rewind_root</c>).
/// </summary>
internal sealed class ProductRepairService
{
internal enum RetroWfcPayloadAction { None, PublishCacheOnly, RebuildRetro }
/// <summary>
/// Inputs every reconciliation entry point shares. Everything here is optional context the
/// caller already owns under the operation lock; the entry point decides what work is possible.
/// </summary>
internal sealed record ReconcileOptions
{
/// <summary>Operation scratch the caller owns, or null to own one for this call.</summary>
public string? ScratchRoot { get; init; }
/// <summary>The release's authoritative clean-disc inputs, supplied together or not at all.</summary>
public string? ExpectedDolSha256 { get; init; }
public string? ExpectedRelSha256 { get; init; }
/// <summary>
/// The frontend's own canonical <c>RetroRewind6</c> directory, recorded as
/// <c>retro_rewind_root</c>. Required by every Retro Rewind operation: the compile-input
/// snapshot lives in operation scratch, so it can never be the durable value.
/// </summary>
public string? CanonicalRetroRewindRoot { get; init; }
}
internal sealed record ReconciliationResult(
RetroRewindCompileInputs? CompileInputs,
bool PublicationCommitted,
/// <summary>
/// true when this operation proved or published the online payload cache, null when it made
/// no observation and the next inspection must check for itself. false is never produced
/// here; product inspection reserves it for a cache already proven stale.
/// </summary>
bool? CachedRetroWfcPayloadMatches);
private readonly Installation _installation;
private readonly IInstallReporter _reporter;
public ProductRepairService(Installation installation, IInstallReporter reporter)
{
_installation = installation;
_reporter = reporter;
}
/// <summary>
/// Repairs the base product and any copy-only drift the installation can fix from its own
/// authoritative workspace. The caller must hold <see cref="InstallOperationLock"/>.
/// </summary>
public Task<ReconciliationResult> RepairBaseAsync(ReconcileOptions options,
CancellationToken cancellationToken = default) =>
ReconcileAsync(null, RetroWfcPayloadMode.NotApplicable, options, cancellationToken);
/// <summary>
/// Reconciles Retro Rewind against one immutable compile-input snapshot of the canonical
/// installation, recompiling only what those inputs actually change.
/// </summary>
public Task<ReconciliationResult> RepairRetroAsync(RetroRewindCompileInputs snapshot,
RetroWfcPayloadMode payloadMode, ReconcileOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(snapshot);
if (payloadMode == RetroWfcPayloadMode.NotApplicable)
throw new InvalidOperationException(
"A Retro Rewind operation requires a Retro-WFC payload mode.");
return ReconcileAsync(snapshot, payloadMode, options, cancellationToken);
}
private async Task<ReconciliationResult> ReconcileAsync(RetroRewindCompileInputs? snapshot,
RetroWfcPayloadMode requestedPayloadMode, ReconcileOptions options,
CancellationToken cancellationToken)
{
if (!_installation.IsPresent)
throw new InvalidOperationException("WiiCompiled is not installed here.");
if (!_installation.HasToolkit)
throw new InvalidOperationException(
"The installed recompilation toolkit is missing. Apply the current setup release before repairing products.");
RunningProductGuard.EnsureProductsNotRunning(_installation.Root);
var expectedDolSha256 = options.ExpectedDolSha256;
var expectedRelSha256 = options.ExpectedRelSha256;
if (string.IsNullOrWhiteSpace(expectedDolSha256) != string.IsNullOrWhiteSpace(expectedRelSha256))
throw new ArgumentException("Both authoritative game-input hashes must be supplied together.");
var toolkitFingerprint = _installation.ResolveToolkitFingerprint();
if (string.IsNullOrWhiteSpace(toolkitFingerprint))
throw new InvalidDataException("The installed recompilation toolkit cannot be identified safely.");
using var ownedScratch = options.ScratchRoot is null
? InstallScratchSpace.CreateInsideInstall(_installation.Root, _reporter)
: null;
var scratchRoot = options.ScratchRoot ?? ownedScratch!.Root;
Directory.CreateDirectory(scratchRoot);
RetroWfcPayloadSnapshot? payloadSnapshot = null;
if (requestedPayloadMode == RetroWfcPayloadMode.Online)
{
_reporter.Progress(InstallStages.PrepareRetro,
"Downloading the current shared Retro-WFC payload snapshot...", 3);
var payloadScratch = Path.Combine(scratchRoot, "retro-wfc-payload");
try
{
payloadSnapshot = await InputValidation.DownloadRetroWfcPayloadAsync(
InputValidation.CurrentRetroWfcPayloadUri, payloadScratch, cancellationToken);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested &&
ex is HttpRequestException or IOException or InvalidDataException
or InvalidOperationException or OperationCanceledException)
{
payloadSnapshot = RecoverInstalledRetroWfcPayload(toolkitFingerprint,
Path.Combine(scratchRoot, "retro-wfc-payload-recovered"), ex, cancellationToken);
}
}
_reporter.Progress(InstallStages.PrepareRetro,
"Checking which installed products are still current...", 3);
var downloadedPayloadMatchesProduct = payloadSnapshot is not null &&
_installation.MatchesRetroWfcPayloadSnapshot(
toolkitFingerprint, payloadSnapshot);
var cachedPayloadMatchesSnapshot = payloadSnapshot is null
? (bool?)null
: _installation.CachedRetroWfcPayloadMatches(payloadSnapshot);
var payloadAction = payloadSnapshot is null
? RetroWfcPayloadAction.None
: PlanRetroWfcPayloadReconciliation(downloadedPayloadMatchesProduct,
cachedPayloadMatchesSnapshot == true);
// The canonical root the runtime must read assets from. A base-only operation never changes
// it, so its recorded value stays exactly as the last Retro Rewind operation left it.
var canonicalRoot = snapshot is null
? null
: Path.GetFullPath(options.CanonicalRetroRewindRoot ?? throw new InvalidOperationException(
"A Retro Rewind operation must record the canonical Retro Rewind folder."));
var canonicalRootChanged = canonicalRoot is not null && !RecordedCanonicalRootMatches(canonicalRoot);
var baseState = _installation.CheckBaseCore(toolkitFingerprint);
var retroState = _installation.CheckRetroRewindCore(toolkitFingerprint, snapshot,
canonicalError: null, cachedPayloadMatchesSnapshot);
var desiredInputDrift = FindDesiredInputDrift(_installation, toolkitFingerprint,
expectedDolSha256, expectedRelSha256);
var rebuildBase = RequiresCompilation(baseState.Status) || desiredInputDrift.Base;
var rebuildRetro = false;
var syncRetroWfcPayload = false;
if (snapshot is null)
{
if (desiredInputDrift.Retro)
throw new InvalidOperationException(
"The release changes the clean game inputs used by the installed Retro Rewind product. " +
"Supply Wheel Wizard's current Retro Rewind folder explicitly so it can be rebuilt safely.");
if (retroState.ActionRequired)
throw new InvalidOperationException(
"Retro Rewind needs repair, but Wheel Wizard's current Retro Rewind folder was not supplied. " +
retroState.Detail);
}
else
{
rebuildRetro = desiredInputDrift.Retro || RequiresCompilation(retroState.Status) ||
retroState.Status == ProductStatus.Absent;
syncRetroWfcPayload = retroState.Status == ProductStatus.PayloadChanged;
if (payloadAction == RetroWfcPayloadAction.RebuildRetro)
{
rebuildRetro = true;
syncRetroWfcPayload = true;
}
else if (payloadAction == RetroWfcPayloadAction.PublishCacheOnly)
{
syncRetroWfcPayload = true;
}
var installedMode = _installation.ResolveRetroWfcPayloadMode(toolkitFingerprint);
if (_installation.HasRetroProduct && installedMode != RetroWfcPayloadMode.NotApplicable &&
installedMode != requestedPayloadMode)
{
rebuildRetro = true;
syncRetroWfcPayload = requestedPayloadMode == RetroWfcPayloadMode.Online;
}
if (rebuildRetro && requestedPayloadMode == RetroWfcPayloadMode.Online)
syncRetroWfcPayload = true;
}
// Copied support files are republished from the installed workspace, never recompiled. A
// rebuild already stages fresh copies, so only a product that is not being rebuilt needs it.
var syncBaseRuntimeAssets = !rebuildBase && File.Exists(_installation.BaseExecutable) &&
_installation.ValidateCopiedRuntimeAssets(_installation.BaseDirectory,
"base", toolkitFingerprint) is not null;
var syncRetroRuntimeAssets = !rebuildRetro && _installation.HasRetroProduct &&
_installation.ValidateCopiedRuntimeAssets(_installation.RetroDirectory,
"retro-rewind", toolkitFingerprint) is not null;
if (!rebuildBase && !rebuildRetro && !syncRetroWfcPayload && !syncBaseRuntimeAssets &&
!syncRetroRuntimeAssets && !canonicalRootChanged)
{
_reporter.Progress(InstallStages.Publish, "The installed products are already current.", 99);
return new ReconciliationResult(snapshot, PublicationCommitted: false,
VerifiedOnlinePayloadCache(toolkitFingerprint));
}
cancellationToken.ThrowIfCancellationRequested();
var builder = new LocalBuildService(_reporter);
string? baseOutput = null;
string? retroOutput = null;
if (rebuildBase || rebuildRetro)
{
EnsureSufficientRepairDiskSpace();
_reporter.Progress(InstallStages.PrepareRetro,
"Verifying the installed recompilation toolkit...", 4);
var toolkitComponents = VerifyToolkitForCompilation(toolkitFingerprint, cancellationToken);
RefreshTranslationAssets(expectedDolSha256, expectedRelSha256, cancellationToken);
// A broken or blocked product may mean the caches themselves are damaged, so nothing
// recorded on disk is allowed to contribute to the repaired product.
var forceCleanBuild =
(rebuildBase && baseState.Status is ProductStatus.Broken or ProductStatus.Blocked) ||
(rebuildRetro && retroState.Status is ProductStatus.Broken or ProductStatus.Blocked);
var (dolSha, relSha) = TranslationInputHashes();
if (rebuildBase && rebuildRetro)
{
// One retro-aware translation, one build graph, both products - the same reason the
// install path uses BuildProfile.Both: sequential legs would re-emit every shared
// base shard with retro-aware content and recompile all of them twice.
var compileInputs = snapshot ?? throw new InvalidOperationException(
"Retro Rewind recompilation requires a compile-input snapshot.");
baseOutput = Path.Combine(scratchRoot, "base-output");
retroOutput = Path.Combine(scratchRoot, "retro-output");
_reporter.Progress(InstallStages.BuildBase,
"Recompiling Mario Kart Wii and Retro Rewind with the installed toolkit...", 5);
await BuildWithCleanRetryAsync(forceCleanBuild, clean =>
builder.BuildAsync(_installation.Root, BuildProfile.Both, retroOutput,
requestedPayloadMode, payloadSnapshot?.Directory, cancellationToken,
toolkitComponents, clean,
progress: new BuildProgressWindow(_reporter, InstallStages.BuildBase, 5, 92),
retroRewindPackageDirectory: compileInputs.RetroRewindRoot,
baseOutputDirectory: baseOutput));
LocalBuildService.WriteFingerprint(baseOutput, BuildProfile.Base, toolkitFingerprint,
dolSha, relSha, "", RetroWfcPayloadMode.NotApplicable);
LocalBuildService.WriteFingerprint(retroOutput, BuildProfile.RetroRewind,
toolkitFingerprint, dolSha, relSha, compileInputs.CodePulSha256,
requestedPayloadMode, compileInputs.CompileInputsSha256,
payloadSnapshot?.Sha256 ?? "", payloadSnapshot?.ByteLength ?? 0);
}
else if (rebuildBase)
{
baseOutput = Path.Combine(scratchRoot, "base-output");
_reporter.Progress(InstallStages.BuildBase,
"Recompiling Mario Kart Wii with the installed toolkit...", 5);
await BuildWithCleanRetryAsync(forceCleanBuild, clean =>
builder.BuildAsync(_installation.Root, BuildProfile.Base, baseOutput,
RetroWfcPayloadMode.NotApplicable, null, cancellationToken,
toolkitComponents, clean,
progress: new BuildProgressWindow(_reporter, InstallStages.BuildBase, 5, 92)));
LocalBuildService.WriteFingerprint(baseOutput, BuildProfile.Base, toolkitFingerprint,
dolSha, relSha, "", RetroWfcPayloadMode.NotApplicable);
}
else
{
var compileInputs = snapshot ?? throw new InvalidOperationException(
"Retro Rewind recompilation requires a compile-input snapshot.");
retroOutput = Path.Combine(scratchRoot, "retro-output");
_reporter.Progress(InstallStages.BuildRetro,
"Recompiling Retro Rewind for the canonical Code.pul...", 5);
await BuildWithCleanRetryAsync(forceCleanBuild, clean =>
builder.BuildAsync(_installation.Root, BuildProfile.RetroRewind, retroOutput,
requestedPayloadMode, payloadSnapshot?.Directory, cancellationToken,
toolkitComponents, clean,
progress: new BuildProgressWindow(_reporter, InstallStages.BuildRetro, 5, 92),
retroRewindPackageDirectory: compileInputs.RetroRewindRoot));
LocalBuildService.WriteFingerprint(retroOutput, BuildProfile.RetroRewind,
toolkitFingerprint, dolSha, relSha, compileInputs.CodePulSha256,
requestedPayloadMode, compileInputs.CompileInputsSha256,
payloadSnapshot?.Sha256 ?? "", payloadSnapshot?.ByteLength ?? 0);
}
}
if (retroOutput is not null)
PreserveProductConfig(_installation.RetroDirectory, retroOutput);
if (baseOutput is not null)
PreserveProductConfig(_installation.BaseDirectory, baseOutput);
cancellationToken.ThrowIfCancellationRequested();
PublishProducts(new PublicationPlan
{
BaseOutput = baseOutput,
RetroOutput = retroOutput,
CompileInputs = snapshot,
CanonicalRetroRewindRoot = canonicalRoot,
SyncBaseRuntimeAssets = syncBaseRuntimeAssets,
SyncRetroRuntimeAssets = syncRetroRuntimeAssets,
SyncRetroWfcPayload = syncRetroWfcPayload,
ToolkitFingerprint = toolkitFingerprint,
ScratchRoot = scratchRoot,
RequestedPayloadMode = requestedPayloadMode,
PayloadSnapshot = payloadSnapshot,
ExpectedDolSha256 = expectedDolSha256,
ExpectedRelSha256 = expectedRelSha256
}, cancellationToken);
_reporter.Progress(InstallStages.Publish, "Product repair complete.", 99);
return new ReconciliationResult(snapshot, PublicationCommitted: true,
VerifiedOnlinePayloadCache(toolkitFingerprint));
}
private RetroWfcPayloadSnapshot RecoverInstalledRetroWfcPayload(string toolkitFingerprint,
string destinationDirectory, Exception downloadFailure, CancellationToken cancellationToken)
{
InvalidOperationException Unavailable(string reason) => new(
"The Retro-WFC payload service is unavailable" +
$" ({downloadFailure.Message.TrimEnd('.')}), and {reason}" +
" Try again once the service is reachable.", downloadFailure);
var fingerprint = _installation.ReadProductFingerprint(_installation.RetroDirectory,
toolkitFingerprint);
var state = _installation.ReadInstallState();
if (fingerprint is not { RetroWfcPayloadMode: "downloaded" } ||
state is not { RetroWfcPayloadMode: "downloaded" } ||
string.IsNullOrWhiteSpace(fingerprint.RetroWfcPayloadSha256) ||
!fingerprint.RetroWfcPayloadSha256.Equals(state.RetroWfcPayloadSha256,
StringComparison.Ordinal) ||
fingerprint.RetroWfcPayloadLength <= 0)
{
throw Unavailable("this installation has no verified payload snapshot to fall back to.");
}
string cachedFile;
try
{
cachedFile = InputValidation.ResolveRetroWfcPayloadFile(
_installation.WorkspaceRetroWfcPayload);
cancellationToken.ThrowIfCancellationRequested();
if (new FileInfo(cachedFile).Length != fingerprint.RetroWfcPayloadLength ||
!InputValidation.Sha256File(cachedFile).Equals(fingerprint.RetroWfcPayloadSha256,
StringComparison.Ordinal))
{
throw Unavailable("the installed payload snapshot no longer matches its recorded identity.");
}
var destination = Path.Combine(Path.GetFullPath(destinationDirectory), "binary",
"payload.RMCPD00.bin");
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.Copy(cachedFile, destination, overwrite: true);
var root = InputValidation.ValidateStagedRetroWfcPayloadDirectory(destinationDirectory);
_reporter.Diagnostic(
"The Retro-WFC payload service is unavailable; reusing the installation's verified " +
$"payload snapshot (sha256 {fingerprint.RetroWfcPayloadSha256[..12]}..., " +
$"{fingerprint.RetroWfcPayloadLength} bytes).");
return new RetroWfcPayloadSnapshot(root, fingerprint.RetroWfcPayloadSha256,
fingerprint.RetroWfcPayloadLength);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
throw Unavailable($"the installed payload snapshot could not be reused ({ex.Message.TrimEnd('.')}).");
}
}
/// <summary>The statuses that can only be resolved by producing the product again.</summary>
private static bool RequiresCompilation(ProductStatus status) =>
status is ProductStatus.ToolkitChanged or ProductStatus.CodePulChanged or
ProductStatus.CompileInputsChanged or ProductStatus.InputsMissing or
ProductStatus.Blocked or ProductStatus.Broken;
private bool? VerifiedOnlinePayloadCache(string toolkitFingerprint) =>
_installation.HasRetroProduct &&
_installation.ResolveRetroWfcPayloadMode(toolkitFingerprint) == RetroWfcPayloadMode.Online
? true
: null;
internal static RetroWfcPayloadAction PlanRetroWfcPayloadReconciliation(bool productMatchesSnapshot,
bool cacheMatchesSnapshot) =>
!productMatchesSnapshot ? RetroWfcPayloadAction.RebuildRetro
: cacheMatchesSnapshot ? RetroWfcPayloadAction.None
: RetroWfcPayloadAction.PublishCacheOnly;
private bool RecordedCanonicalRootMatches(string canonicalRoot)
{
var configured = _installation.ConfiguredRetroRewindRoot;
var recorded = _installation.ReadInstallState()?.RetroRewindRoot;
return configured is not null && recorded is not null &&
SamePath(configured, canonicalRoot) && SamePath(recorded, canonicalRoot);
}
private static bool SamePath(string left, string right)
{
try { return FileSystemUtilities.PathsEqual(left, right); }
catch { return false; }
}
/// <summary>Everything one reconciliation decided to publish as a single transaction.</summary>
private sealed record PublicationPlan
{
public string? BaseOutput { get; init; }
public string? RetroOutput { get; init; }
public RetroRewindCompileInputs? CompileInputs { get; init; }
public string? CanonicalRetroRewindRoot { get; init; }
public bool SyncBaseRuntimeAssets { get; init; }
public bool SyncRetroRuntimeAssets { get; init; }
public bool SyncRetroWfcPayload { get; init; }
public required string ToolkitFingerprint { get; init; }
public required string ScratchRoot { get; init; }
public RetroWfcPayloadMode RequestedPayloadMode { get; init; }
public RetroWfcPayloadSnapshot? PayloadSnapshot { get; init; }
public string? ExpectedDolSha256 { get; init; }
public string? ExpectedRelSha256 { get; init; }
}
private void PublishProducts(PublicationPlan plan, CancellationToken cancellationToken)
{
var entries = new List<InstallTransactionEntry>();
if (plan.BaseOutput is not null)
entries.Add(InstallTransactionEntry.Directory(plan.BaseOutput, _installation.BaseDirectory));
if (plan.RetroOutput is not null)
entries.Add(InstallTransactionEntry.Directory(plan.RetroOutput, _installation.RetroDirectory));
if (plan.SyncBaseRuntimeAssets || plan.SyncRetroRuntimeAssets)
AddRuntimeAssetPublicationEntries(entries, plan.ScratchRoot, plan.SyncBaseRuntimeAssets,
plan.SyncRetroRuntimeAssets, plan.ToolkitFingerprint, cancellationToken);
if (plan.SyncRetroWfcPayload)
{
if (plan.PayloadSnapshot is null)
throw new InvalidDataException("The downloaded Retro-WFC payload was not staged.");
entries.Add(InstallTransactionEntry.Directory(plan.PayloadSnapshot.Directory,
_installation.WorkspaceRetroWfcPayload));
}
if (plan.RetroOutput is not null && plan.RequestedPayloadMode == RetroWfcPayloadMode.Skipped)
{
var emptyPayload = Path.Combine(plan.ScratchRoot, "retro-wfc-payload-removed");
Directory.CreateDirectory(emptyPayload);
entries.Add(InstallTransactionEntry.Directory(emptyPayload,
_installation.WorkspaceRetroWfcPayload));
}
// Copied-runtime repair does not change product or input provenance. Publish only the
// authoritative support files in that case, leaving install-state byte-for-byte intact.
var publishState = plan.BaseOutput is not null || plan.RetroOutput is not null ||
plan.CanonicalRetroRewindRoot is not null;
if (publishState)
{
var statePath = Path.Combine(plan.ScratchRoot, InstalledLayout.InstallStateFileName);
JsonState.Write(statePath, BuildUpdatedInstallState(plan.ToolkitFingerprint,
plan.CompileInputs, plan.CanonicalRetroRewindRoot,
plan.RetroOutput is null ? null : plan.RequestedPayloadMode,
plan.RetroOutput is null ? null : plan.PayloadSnapshot, plan.ExpectedDolSha256,
plan.ExpectedRelSha256));
entries.Add(InstallTransactionEntry.File(statePath, _installation.InstallStatePath));
}
cancellationToken.ThrowIfCancellationRequested();
// The game may have been started during a long compile; publishing renames its directory.
RunningProductGuard.EnsureProductsNotRunning(_installation.Root);
var configPath = RuntimeConfiguration.ResolveConfigPath(_installation.Root);
var configSnapshot = RuntimeConfiguration.Capture(configPath);
using var transaction = InstallTransaction.Begin(_installation.Root, _reporter, entries.ToArray());
transaction.Publish();
if (plan.CanonicalRetroRewindRoot is not null)
{
transaction.RecordRuntimeConfigurationMutation(configSnapshot);
RuntimeConfiguration.SetRetroRewindRoot(configPath, plan.CanonicalRetroRewindRoot);
}
transaction.Commit();
}
private async Task BuildWithCleanRetryAsync(bool forceCleanBuild, Func<bool, Task> build)
{
try
{
await build(forceCleanBuild);
}
catch (InvalidOperationException ex) when (!forceCleanBuild)
{
_reporter.Diagnostic(
$"Incremental recompilation failed ({ex.Message}); retrying with a clean build.");
await build(true);
}
}
private void EnsureSufficientRepairDiskSpace()
{
const long localBuildAllowance = 14L * 1024 * 1024 * 1024;
const long safetyAllowance = 2L * 1024 * 1024 * 1024;
FileSystemUtilities.EnsureFreeSpace(_installation.Root,
localBuildAllowance + safetyAllowance, "Local recompilation");
}
internal ToolkitFingerprintComponents VerifyToolkitForCompilation(string authoritativeFingerprint,
CancellationToken cancellationToken = default)
{
var state = _installation.ReadToolkitState();
if (state is not { SchemaVersion: 2 } ||
string.IsNullOrWhiteSpace(state.ToolkitFingerprint) ||
!state.ToolkitFingerprint.Equals(authoritativeFingerprint, StringComparison.Ordinal))
{
throw new InvalidDataException(
"The installed toolkit provenance is missing or does not match this repair operation.");
}
var components = ToolkitFingerprint.ComputeComponents(_installation.Root, cancellationToken);
if (!components.Compile.Equals(authoritativeFingerprint, StringComparison.Ordinal))
{
throw new InvalidDataException(
"The installed recompilation toolkit was modified or is incomplete. Apply the current setup release before compiling products.");
}
return components;
}
private void AddRuntimeAssetPublicationEntries(List<InstallTransactionEntry> entries,
string scratchRoot, bool includeBase, bool includeRetro, string toolkitFingerprint,
CancellationToken cancellationToken)
{
var state = _installation.ReadToolkitState();
if (state is not { SchemaVersion: 2 } ||
!state.ToolkitFingerprint.Equals(toolkitFingerprint, StringComparison.Ordinal) ||
string.IsNullOrWhiteSpace(state.RuntimeAssetsFingerprint))
{
throw new InvalidDataException(
"The installed runtime-asset provenance is missing or does not belong to the current toolkit.");
}
var expectedFingerprint = state.RuntimeAssetsFingerprint;
var sourceFingerprint = ToolkitFingerprint.ComputeRuntimeAssets(_installation.Root, cancellationToken);
if (!sourceFingerprint.Equals(expectedFingerprint, StringComparison.Ordinal))
{
throw new InvalidDataException(
"The installed BuildWorkspace runtime assets do not match their authoritative toolkit provenance.");
}
var products = new[]
{
(Include: includeBase, Name: "Base", Destination: _installation.BaseDirectory),
(Include: includeRetro, Name: "RetroRewind", Destination: _installation.RetroDirectory)
}
.Where(product => product.Include)
.Select(product => (product.Name, product.Destination));
RuntimeAssetPublication.AddEntries(entries,
Path.Combine(_installation.WorkspaceDirectory, "runtime", "assets"),
Path.Combine(scratchRoot, "runtime-asset-repair"), products, expectedFingerprint,
"repair", _reporter.Diagnostic, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
var sourceAfter = ToolkitFingerprint.ComputeRuntimeAssets(_installation.Root, cancellationToken);
if (!sourceAfter.Equals(sourceFingerprint, StringComparison.Ordinal))
throw new IOException("The installed BuildWorkspace runtime assets changed while repair was being prepared.");
}
private InstallState BuildUpdatedInstallState(string toolkitFingerprint,
RetroRewindCompileInputs? compileInputs, string? canonicalRetroRewindRoot,
RetroWfcPayloadMode? payloadMode = null,
RetroWfcPayloadSnapshot? retroWfcPayloadSnapshot = null, string? expectedDolSha256 = null,
string? expectedRelSha256 = null)
{
var state = _installation.ReadInstallState() ?? new InstallState { InstallDir = _installation.Root };
state.InstallDir = _installation.Root;
state.SchemaVersion = 1;
if (string.IsNullOrWhiteSpace(state.SetupVersion)) state.SetupVersion = ProductInfo.Version;
if (string.IsNullOrWhiteSpace(state.ProductVersion)) state.ProductVersion = state.SetupVersion;
if (!string.IsNullOrWhiteSpace(expectedDolSha256)) state.DolSha256 = expectedDolSha256;
if (!string.IsNullOrWhiteSpace(expectedRelSha256)) state.RelSha256 = expectedRelSha256;
state.RetroRewindInstalled = compileInputs is not null || _installation.HasRetroProduct;
if (compileInputs is not null)
{
state.RetroRewindCodePulSha256 = compileInputs.CodePulSha256;
state.RetroRewindCompileInputsSha256 = compileInputs.CompileInputsSha256;
}
if (canonicalRetroRewindRoot is not null)
state.RetroRewindRoot = Path.GetFullPath(canonicalRetroRewindRoot);
if (payloadMode is not null)
{
state.RetroWfcPayloadMode = payloadMode == RetroWfcPayloadMode.Online
? "downloaded"
: "skipped";
state.RetroWfcPayloadSha256 = payloadMode == RetroWfcPayloadMode.Online
? retroWfcPayloadSnapshot?.Sha256 ?? throw new InvalidDataException(
"The downloaded Retro-WFC payload snapshot is missing.")
: "";
state.RetroWfcPayloadLength = payloadMode == RetroWfcPayloadMode.Online
? retroWfcPayloadSnapshot!.ByteLength
: 0;
}
else
{
var retro = _installation.ReadProductFingerprint(_installation.RetroDirectory,
toolkitFingerprint);
if (retro is not null && !string.IsNullOrWhiteSpace(retro.RetroWfcPayloadMode))
{
state.RetroWfcPayloadMode = retro.RetroWfcPayloadMode;
state.RetroWfcPayloadSha256 = retro.RetroWfcPayloadSha256;
state.RetroWfcPayloadLength = retro.RetroWfcPayloadLength;
}
}
return state;
}
internal static (bool Base, bool Retro) FindDesiredInputDrift(Installation installation,
string toolkitFingerprint, string? expectedDolSha256, string? expectedRelSha256)
{
if (string.IsNullOrWhiteSpace(expectedDolSha256) || string.IsNullOrWhiteSpace(expectedRelSha256))
return (false, false);
return (
File.Exists(installation.BaseExecutable) &&
!installation.ProductUsesGameInputs(installation.BaseDirectory, toolkitFingerprint,
expectedDolSha256, expectedRelSha256),
installation.HasRetroProduct &&
!installation.ProductUsesGameInputs(installation.RetroDirectory, toolkitFingerprint,
expectedDolSha256, expectedRelSha256));
}
private void RefreshTranslationAssets(string? expectedDolSha256, string? expectedRelSha256,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(expectedDolSha256) && string.IsNullOrWhiteSpace(expectedRelSha256))
{
var state = _installation.ReadInstallState();
expectedDolSha256 = state is { SchemaVersion: 1 } ? state.DolSha256 : null;
expectedRelSha256 = state is { SchemaVersion: 1 } ? state.RelSha256 : null;
}
if (string.IsNullOrWhiteSpace(expectedDolSha256) || string.IsNullOrWhiteSpace(expectedRelSha256))
throw new InvalidDataException(
"The installed game-input provenance is missing or unsupported. Apply the current setup with the disc image.");
Directory.CreateDirectory(_installation.WorkspaceAssetsDirectory);
foreach (var (name, source, expectedSha256) in new[]
{
("main.dol", Path.Combine(_installation.GameDataDirectory, "sys", "main.dol"), expectedDolSha256),
("StaticR.rel", Path.Combine(_installation.GameDataDirectory, "files", "rel", "StaticR.rel"),
expectedRelSha256)
})
{
cancellationToken.ThrowIfCancellationRequested();
if (!File.Exists(source))
throw new InvalidDataException(
$"The installed Mario Kart Wii {name} is missing. Apply setup again with the disc image.");
if (!InputValidation.Sha256File(source).Equals(expectedSha256, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException(
$"The installed Mario Kart Wii {name} does not match its recorded clean-disc identity. " +
"Apply the current setup with the disc image.");
var workspaceInput = Path.Combine(_installation.WorkspaceAssetsDirectory, name);
if (File.Exists(workspaceInput) &&
InputValidation.Sha256File(workspaceInput)
.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase))
continue;
File.Copy(source, workspaceInput, overwrite: true);
}
}
private (string DolSha, string RelSha) TranslationInputHashes() =>
(InputValidation.Sha256File(Path.Combine(_installation.WorkspaceAssetsDirectory, "main.dol")),
InputValidation.Sha256File(Path.Combine(_installation.WorkspaceAssetsDirectory, "StaticR.rel")));
private static void PreserveProductConfig(string currentProduct, string preparedProduct)
{
var config = Path.Combine(currentProduct, "config");
if (Directory.Exists(config))
FileSystemUtilities.CopyDirectory(config, Path.Combine(preparedProduct, "config"));
}
}
@@ -0,0 +1,136 @@
namespace WiiCompiled.Setup.Windows;
using System.Runtime.InteropServices;
internal static class Program
{
private static int Main(string[] args)
{
var progressJson = CommandLine.WantsProgressJson(args);
try
{
PlatformChecks.EnsureSupportedHost();
if (args.Length == 0 && GetConsoleProcessList(new uint[1], 1) == 1)
{
Console.Out.WriteLine(
"Mario Kart WiiCompiled is installed through Wheel Wizard - download it from " +
"https://github.com/TeamWheelWizard/WheelWizard");
Console.ReadKey(intercept: true);
return 0;
}
CommandLine command;
try
{
command = CommandLine.Parse(args);
}
catch (ArgumentException ex) when (args.Length > 0)
{
// A command line was supplied, so a caller is driving this process. Rejecting the
// command with a modal dialog would hang that caller forever.
if (progressJson) new NdjsonInstallReporter().Failure(ex.Message);
else Console.Error.WriteLine(ex.Message);
return 1;
}
if (command.Mode == AppMode.Version)
return ConsoleCommands.Version();
if (command.Mode == AppMode.Help)
return ConsoleCommands.Help();
if (command.Mode == AppMode.SelfTest)
return SelfTests.Run();
if (command.Mode == AppMode.VerifyInputs)
return ConsoleCommands.VerifyInputs(command);
if (command.Mode == AppMode.EmitPayloadIdentities)
return ConsoleCommands.EmitPayloadIdentities(command);
if (command.Mode == AppMode.CheckProducts)
{
using var cancellationSignal = CancellationSignal.ObserveEnvironment();
return ConsoleCommands.CheckProducts(command, cancellationSignal.Token);
}
if (command.Mode is AppMode.LaunchBase or AppMode.LaunchRetro)
return GameLaunchService.LaunchAsync(
command.Mode == AppMode.LaunchBase ? BuildProfile.Base : BuildProfile.RetroRewind)
.GetAwaiter().GetResult();
if (command.Mode is AppMode.SilentInstall or AppMode.RepairProducts)
{
using var cancellationSignal = CancellationSignal.ObserveEnvironment();
return command.Mode == AppMode.SilentInstall
? ConsoleCommands.Install(command, cancellationSignal.Token).GetAwaiter().GetResult()
: ConsoleCommands.RepairProducts(command, cancellationSignal.Token)
.GetAwaiter().GetResult();
}
if (command.Mode is AppMode.Uninstall or AppMode.SilentUninstall)
{
UninstallService.StartWorker(command.InstallDirectory!,
quiet: command.Mode == AppMode.SilentUninstall || command.Quiet);
return 0;
}
if (command.Mode == AppMode.UninstallWorker)
return UninstallService.RunWorker(command.InstallDirectory!, command.Quiet);
return ConsoleCommands.Help();
}
catch (Exception ex)
{
// A caller reading the NDJSON protocol must always receive a terminal result line, even
// when the failure happened before the installer was reached (a bad command line, for
// instance). ConsoleCommands already reports its own failures, so this only fires for
// errors raised outside it.
if (progressJson)
{
new NdjsonInstallReporter().Failure(ex.Message);
Console.Error.WriteLine(ex);
}
else
{
Console.Error.WriteLine(ex);
}
return 1;
}
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint GetConsoleProcessList(
[Out] uint[] processList,
uint processCount);
}
internal static class PlatformChecks
{
public static void EnsureSupportedHost()
{
if (!OperatingSystem.IsWindows() || !Environment.Is64BitOperatingSystem)
throw new PlatformNotSupportedException("WiiCompiled requires 64-bit Windows.");
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 18362))
throw new PlatformNotSupportedException("WiiCompiled requires Windows 10 version 1903 or newer.");
}
}
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
public const string Version = "0.2.24";
/// <summary>
/// The setup executable is copied into the installation under this name. It is the launcher and
/// launch entry point named by the Wheel Wizard contract, so the name is part of that interface.
/// </summary>
public const string SetupCopyName = "WiiCompiled-Setup.exe";
public const string UninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\WiiCompiled";
public static string DefaultInstallDirectory =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Programs", "WiiCompiled");
}
@@ -0,0 +1,48 @@
using System.Diagnostics;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Refuses to replace installed products while one of them is running: publishing renames the
/// product directories, which Windows rejects while a process runs from them.
/// </summary>
internal static class RunningProductGuard
{
public static void EnsureProductsNotRunning(string installDirectory)
{
var installation = new Installation(installDirectory);
var running = FindProcessesUnder(installation.BaseDirectory, installation.RetroDirectory);
if (running.Count == 0) return;
throw new InvalidOperationException(
$"Mario Kart Wii is still running ({string.Join(", ", running)}). " +
"Close the game, then retry the update.");
}
private static List<string> FindProcessesUnder(params string[] roots)
{
var names = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var process in Process.GetProcesses())
{
try
{
if (process.Id == Environment.ProcessId) continue;
var path = process.MainModule?.FileName;
if (path is null) continue;
if (roots.Any(root => FileSystemUtilities.PathContains(root, Path.GetFullPath(path))))
names.Add(Path.GetFileName(path));
}
catch
{
// Inaccessible processes (elevated, exited, protected) cannot run our products' exes
// from a user-writable install directory in any case that matters here.
}
finally
{
process.Dispose();
}
}
return names.ToList();
}
}
@@ -0,0 +1,48 @@
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// The one path by which a product receives its copied runtime assets, shared by install and repair.
/// Each product gets its own moved (not copied) entry, re-verified against the authoritative identity
/// in case the source changed mid-preparation.
/// </summary>
internal static class RuntimeAssetPublication
{
public static void AddEntries(List<InstallTransactionEntry> entries, string sourceAssets,
string preparedRoot, IEnumerable<(string Name, string Destination)> products,
string expectedFingerprint, string operationDescription, Action<string> diagnostic,
CancellationToken cancellationToken)
{
foreach (var (name, destination) in products)
{
cancellationToken.ThrowIfCancellationRequested();
var preparedProduct = Path.Combine(preparedRoot, name);
Directory.CreateDirectory(preparedProduct);
FileSystemUtilities.CopyDirectory(
Path.Combine(sourceAssets, ProductRuntimeAssets.SourceBootstrapDirectoryName),
Path.Combine(preparedProduct, ProductRuntimeAssets.ProductBootstrapDirectoryName),
cancellationToken);
foreach (var (relativePath, fileName) in ProductRuntimeAssets.Files)
{
cancellationToken.ThrowIfCancellationRequested();
File.Copy(ProductRuntimeAssets.SourceFile(sourceAssets, relativePath),
Path.Combine(preparedProduct, fileName));
}
if (!ToolkitFingerprint.ProductRuntimeAssetsMatch(preparedProduct, expectedFingerprint,
cancellationToken, diagnostic))
{
throw new IOException(
$"A copied product runtime asset changed while {operationDescription} was being prepared.");
}
entries.Add(InstallTransactionEntry.Directory(
Path.Combine(preparedProduct, ProductRuntimeAssets.ProductBootstrapDirectoryName),
Path.Combine(destination, ProductRuntimeAssets.ProductBootstrapDirectoryName)));
foreach (var (_, fileName) in ProductRuntimeAssets.Files)
entries.Add(InstallTransactionEntry.File(Path.Combine(preparedProduct, fileName),
Path.Combine(destination, fileName)));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
using Microsoft.Win32;
namespace WiiCompiled.Setup.Windows;
internal static class ShellIntegration
{
private const string ShortcutFileName = "wiicompiled (base) (beta).lnk";
public static void RegisterUninstaller(string installDirectory, bool retroInstalled)
{
using var key = Registry.CurrentUser.CreateSubKey(ProductInfo.UninstallKey, writable: true)
?? throw new InvalidOperationException("Could not register the uninstaller.");
var cli = Path.Combine(installDirectory, ProductInfo.SetupCopyName);
key.SetValue("DisplayName", ProductInfo.Name);
key.SetValue("DisplayVersion", ProductInfo.Version);
key.SetValue("Publisher", "WiiCompiled");
key.SetValue("InstallLocation", installDirectory);
key.SetValue("DisplayIcon", cli);
key.SetValue("UninstallString", $"\"{cli}\" --uninstall --install-dir \"{installDirectory}\"");
key.SetValue("QuietUninstallString", $"\"{cli}\" --silent-uninstall --install-dir \"{installDirectory}\"");
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
key.SetValue("EstimatedSize", EstimateSizeKb(installDirectory), RegistryValueKind.DWord);
key.SetValue("Comments", retroInstalled ? "Includes the Retro Rewind profile" : "WiiCompiled");
}
public static void UnregisterUninstaller() =>
Registry.CurrentUser.DeleteSubKeyTree(ProductInfo.UninstallKey, throwOnMissingSubKey: false);
/// <summary>Creates the desktop and Start Menu shortcuts that launch the base game.</summary>
public static void CreateShortcuts(string installDirectory)
{
var cli = Path.Combine(installDirectory, ProductInfo.SetupCopyName);
var shellType = Type.GetTypeFromProgID("WScript.Shell")
?? throw new InvalidOperationException("The Windows Script Host shell is unavailable.");
dynamic shell = Activator.CreateInstance(shellType)!;
foreach (var path in ShortcutPaths())
{
dynamic shortcut = shell.CreateShortcut(path);
shortcut.TargetPath = cli;
shortcut.Arguments = "--launch-base";
shortcut.WorkingDirectory = installDirectory;
shortcut.IconLocation = cli + ",0";
shortcut.Description = "Play Mario Kart Wii (base game)";
shortcut.Save();
}
}
public static void RemoveShortcuts() => RemoveShortcuts(ShortcutPaths());
internal static void RemoveShortcuts(IEnumerable<string> shortcutPaths)
{
var failures = new List<Exception>();
foreach (var path in shortcutPaths)
DeleteFileBestEffort(path, failures);
if (failures.Count != 0)
throw new AggregateException("One or more WiiCompiled shortcuts could not be removed.", failures);
}
private static string[] ShortcutPaths() =>
[
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), ShortcutFileName),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs", ShortcutFileName),
];
private static void DeleteFileBestEffort(string path, List<Exception> failures)
{
try
{
File.Delete(path);
}
catch (Exception ex)
{
failures.Add(new IOException($"Could not delete shortcut {path}: {ex.Message}", ex));
}
}
private static int EstimateSizeKb(string directory)
{
try
{
var bytes = Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
.Sum(path => new FileInfo(path).Length);
return (int)Math.Min(int.MaxValue, (bytes + 1023) / 1024);
}
catch
{
return 0;
}
}
}
@@ -0,0 +1,287 @@
using System.Security.Cryptography;
using System.Text;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Content identity of everything that decides what the locally produced executables contain.
/// First link of toolkit content -&gt; toolkit-state.json -&gt; per-product build-fingerprint.json:
/// unchanged skips retranslation, any change forces a rebuild. Content-based so re-tags are free.
/// </summary>
internal static class ToolkitFingerprint
{
// v6/native-toolchain v2 forces one clean rebuild past a Ninja stale-object bug.
// translation v2 added runtime/src, which the translator regex-scans for native overrides.
private const string Version = "mkwc-toolkit-compile-v6";
private const string TranslationVersion = "mkwc-toolkit-translation-v2";
private const string NativeToolchainVersion = "mkwc-toolkit-native-toolchain-v2";
private const string PackageVersion = "mkwc-toolkit-package-v1";
private const string RuntimeAssetsVersion = "mkwc-product-runtime-assets-v3";
private const string RuntimeAssetsDescription = "The product runtime assets";
private static readonly string[] SourceExtensions =
[".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".inl", ".s", ".asm",
".cmake", ".txt", ".yml", ".yaml", ".toml", ".json", ".ps1", ".in", ".def", ".patch"];
/// <summary>
/// Inputs that decide translator output: translator, translation project, runtime sources it
/// scans for native override registrations/effect contracts, and the build script's translator
/// command lines. Unchanged means the completed base translation can be reused instead of re-run.
/// </summary>
private static readonly string[] TranslationPrefixes =
[
InstalledLayout.ToolkitEntryPrefix + "Translator/",
InstalledLayout.WorkspaceEntryPrefix + "projects/",
InstalledLayout.WorkspaceEntryPrefix + "runtime/src/"
];
private static readonly string[] TranslationFiles =
[InstalledLayout.WorkspaceEntryPrefix + "LocalBuild.ps1"];
/// <summary>
/// Inputs that make compiled objects unsafe to reuse: the compiler/CMake/Ninja toolchain, the
/// shipped runtime DLLs, and the scripts owning configure flags. Source/dependency changes are
/// deliberately excluded, since CMake/Ninja already rebuild exactly the affected objects.
/// </summary>
private static readonly string[] NativeToolchainPrefixes =
[
InstalledLayout.ToolkitEntryPrefix + "llvm-mingw/",
InstalledLayout.ToolkitEntryPrefix + "CMake/",
InstalledLayout.ToolkitEntryPrefix + "Ninja/",
InstalledLayout.ToolkitEntryPrefix + "Redist/"
];
private static readonly string[] NativeToolchainFiles =
[
InstalledLayout.WorkspaceEntryPrefix + "LocalBuild.ps1",
InstalledLayout.WorkspaceEntryPrefix + "NativeBuildFlags.ps1"
];
/// <summary>
/// Fingerprint of the toolkit at <paramref name="root"/>: the staged payload root at
/// release-build time, or an install directory when a compile path re-verifies it. The install
/// path itself never computes this, it compares manifest identity against toolkit-state.json.
/// </summary>
public static string Compute(string root, CancellationToken cancellationToken = default) =>
ComputeComponents(root, cancellationToken).Compile;
/// <summary>
/// One walk, three identities: the compile identity plus its translation and native-toolchain
/// subsets. The subsets never gate correctness, compile identity alone decides if products are
/// current, they only decide how much completed work carries into the next build.
/// </summary>
public static ToolkitFingerprintComponents ComputeComponents(string root,
CancellationToken cancellationToken = default)
{
root = Path.GetFullPath(root);
var toolkit = InstalledLayout.Toolkit(root);
var workspace = InstalledLayout.Workspace(root);
var entries = new SortedDictionary<string, string>(StringComparer.Ordinal);
// nodtool validates/extracts the user disc but does not influence generated products.
// Everything else in Toolkit can affect translation, compilation, linking, or copied
// runtime support and therefore belongs to the compile identity.
AddDirectory(entries, root, toolkit, null,
cancellationToken,
file => !Path.GetFileName(file).Equals("nodtool.exe", StringComparison.OrdinalIgnoreCase));
AddFile(entries, root, Path.Combine(workspace, "LocalBuild.ps1"), cancellationToken);
AddFile(entries, root, Path.Combine(workspace, "NativeBuildFlags.ps1"), cancellationToken);
AddDirectory(entries, root, Path.Combine(workspace, "projects"), null, cancellationToken);
var runtime = Path.Combine(workspace, "runtime");
var runtimeAssets = Path.Combine(runtime, "assets");
// runtime/assets is copied beside the executable; it is deliberately tracked by the
// independent runtime-assets identity below so changing those bytes does not recompile code.
AddDirectory(entries, root, runtime, SourceExtensions, cancellationToken,
file => !FileSystemUtilities.PathContains(runtimeAssets, file));
AddDirectory(entries, root, Path.Combine(workspace, "aurora-main"), SourceExtensions,
cancellationToken);
// The shipped precompiled aurora archives and the pinned Dawn runtime are linked into the
// product, so a change there changes the binary just as surely as a translator change does.
// native_prebuilt's provenance.json is excluded: its Contents list already identifies every
// shipped byte, while its BuiltUtc stamp would make a bit-identical re-harvest look like a
// toolkit change and force a global user-side rebuild for nothing.
AddDirectory(entries, root, Path.Combine(workspace, "Dependencies"), null, cancellationToken,
file => !file.EndsWith(
Path.Combine("native_prebuilt", "provenance.json"), StringComparison.OrdinalIgnoreCase));
if (entries.Count == 0)
throw new InvalidDataException($"No toolkit files were found under {root}; the installation is incomplete.");
return new ToolkitFingerprintComponents(
BuildIdentity(Version, entries),
BuildSubsetIdentity(TranslationVersion, entries, TranslationPrefixes, TranslationFiles),
BuildSubsetIdentity(NativeToolchainVersion, entries, NativeToolchainPrefixes,
NativeToolchainFiles));
}
private static string BuildSubsetIdentity(string version, SortedDictionary<string, string> entries,
string[] prefixes, string[] files)
{
var subset = new SortedDictionary<string, string>(StringComparer.Ordinal);
foreach (var (relative, hash) in entries)
{
if (prefixes.Any(prefix => relative.StartsWith(prefix, StringComparison.Ordinal)) ||
files.Any(file => relative.Equals(file, StringComparison.Ordinal)))
subset[relative] = hash;
}
return BuildIdentity(version, subset);
}
/// <summary>
/// Content identity of the source-owned files copied verbatim beside every product. This is
/// intentionally independent of <see cref="Compute"/>: an asset-only release is published
/// transactionally without translating or compiling unchanged code.
/// </summary>
public static string ComputeRuntimeAssets(string root, CancellationToken cancellationToken = default)
{
root = Path.GetFullPath(root);
var assets = Path.Combine(InstalledLayout.Workspace(root), "runtime", "assets");
var entries = new SortedDictionary<string, string>(StringComparer.Ordinal);
AddMappedDirectory(entries, Path.Combine(assets, ProductRuntimeAssets.SourceBootstrapDirectoryName),
ProductRuntimeAssets.ProductBootstrapDirectoryName, cancellationToken);
foreach (var (relativePath, productFileName) in ProductRuntimeAssets.Files)
AddRequiredMappedFile(entries, ProductRuntimeAssets.SourceFile(assets, relativePath),
productFileName, cancellationToken);
return BuildIdentity(RuntimeAssetsVersion, entries);
}
public static string? TryComputeRuntimeAssets(string root, CancellationToken cancellationToken = default,
Action<string>? diagnostic = null)
{
try { return ComputeRuntimeAssets(root, cancellationToken); }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
diagnostic?.Invoke(
$"The workspace runtime assets under {root} could not be fingerprinted: {ex.Message}");
return null;
}
}
/// <summary>Checks the actual copied product files against a source runtime-assets identity.</summary>
public static bool ProductRuntimeAssetsMatch(string productDirectory, string expectedFingerprint,
CancellationToken cancellationToken = default, Action<string>? diagnostic = null)
{
try
{
var entries = new SortedDictionary<string, string>(StringComparer.Ordinal);
AddMappedDirectory(entries,
Path.Combine(productDirectory, ProductRuntimeAssets.ProductBootstrapDirectoryName),
ProductRuntimeAssets.ProductBootstrapDirectoryName, cancellationToken);
foreach (var (_, productFileName) in ProductRuntimeAssets.Files)
AddRequiredMappedFile(entries, Path.Combine(productDirectory, productFileName),
productFileName, cancellationToken);
return BuildIdentity(RuntimeAssetsVersion, entries).Equals(expectedFingerprint,
StringComparison.Ordinal);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
diagnostic?.Invoke(
$"The copied runtime assets under {productDirectory} could not be verified: {ex.Message}");
return false;
}
}
/// <summary>Identity of the shipped Toolkit directory, including installer-only tools.</summary>
public static string ComputePackage(string root, CancellationToken cancellationToken = default)
{
root = Path.GetFullPath(root);
var entries = new SortedDictionary<string, string>(StringComparer.Ordinal);
AddDirectory(entries, root, InstalledLayout.Toolkit(root), null, cancellationToken);
if (entries.Count == 0)
throw new InvalidDataException($"No toolkit package files were found under {root}.");
return BuildIdentity(PackageVersion, entries);
}
/// <summary>
/// The toolkit is ~26,000 mostly tiny files (llvm-mingw's headers/libraries dominate), so this
/// walk is bound by per-file open/read overhead, not hashing throughput, minutes rather than
/// seconds on a cold cache under on-access antivirus. Hashing concurrently keeps a compiling
/// repair from taking longer to prove toolkit integrity than to translate with it.
/// </summary>
private static void AddDirectory(SortedDictionary<string, string> entries, string root, string directory,
string[]? extensions, CancellationToken cancellationToken = default,
Func<string, bool>? include = null)
{
if (!Directory.Exists(directory)) return;
var selected = new List<string>();
foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories))
{
cancellationToken.ThrowIfCancellationRequested();
if (include is not null && !include(file)) continue;
if (extensions is not null &&
!extensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase))
continue;
selected.Add(file);
}
var hashed = new System.Collections.Concurrent.ConcurrentBag<(string Relative, string Hash)>();
Parallel.ForEach(selected,
new ParallelOptions
{
CancellationToken = cancellationToken,
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount)
},
file =>
{
if (!File.Exists(file)) return;
var relative = Path.GetRelativePath(root, file).Replace('\\', '/');
hashed.Add((relative, InputValidation.Sha256File(file)));
});
foreach (var (relative, hash) in hashed) entries[relative] = hash;
}
private static void AddFile(SortedDictionary<string, string> entries, string root, string file,
CancellationToken cancellationToken = default)
{
if (!File.Exists(file)) return;
cancellationToken.ThrowIfCancellationRequested();
var relative = Path.GetRelativePath(root, file).Replace('\\', '/');
entries[relative] = InputValidation.Sha256File(file);
}
private static void AddMappedDirectory(SortedDictionary<string, string> entries, string directory,
string identityRoot, CancellationToken cancellationToken)
{
if (!Directory.Exists(directory))
throw new InvalidDataException($"Required product runtime asset directory is missing: {directory}");
var tree = FileSystemUtilities.EnumerateRegularTree(directory, cancellationToken,
RuntimeAssetsDescription);
if (tree.Count == 0)
throw new InvalidDataException($"Required product runtime asset directory is empty: {directory}");
foreach (var entry in tree)
{
if (entry.IsEmptyDirectory)
entries[$"empty-directory:{identityRoot}/{entry.RelativePath}"] = "";
else if (!entry.IsDirectory)
entries[$"{identityRoot}/{entry.RelativePath}"] = InputValidation.Sha256File(entry.FullPath);
}
}
private static void AddRequiredMappedFile(SortedDictionary<string, string> entries, string file,
string identityPath, CancellationToken cancellationToken)
{
if (!File.Exists(file))
throw new InvalidDataException($"Required product runtime asset is missing: {file}");
FileSystemUtilities.RejectReparsePoint(new FileInfo(file), RuntimeAssetsDescription);
cancellationToken.ThrowIfCancellationRequested();
entries[identityPath] = InputValidation.Sha256File(file);
}
private static string BuildIdentity(string version, SortedDictionary<string, string> entries)
{
var builder = new StringBuilder(version).Append('\n');
foreach (var (relative, hash) in entries)
builder.Append(relative).Append('|').Append(hash).Append('\n');
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())))
.ToLowerInvariant();
}
}
/// <summary>
/// Compile identity plus the two subsets deciding cache reuse: <see cref="Compile"/> is the
/// authoritative freshness identity, <see cref="Translation"/> decides if the base translation is
/// still current, <see cref="NativeToolchain"/> decides if compiled objects may still be linked.
/// </summary>
internal sealed record ToolkitFingerprintComponents(string Compile, string Translation,
string NativeToolchain);
@@ -0,0 +1,113 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
internal static class UninstallService
{
public static void StartWorker(string installDirectory, bool quiet = false)
{
var source = Environment.ProcessPath ?? throw new InvalidOperationException("Cannot locate the uninstaller.");
var temporary = Path.Combine(Path.GetTempPath(), $"WiiCompiled-Uninstall-{Guid.NewGuid():N}.exe");
File.Copy(source, temporary, overwrite: true);
var info = new ProcessStartInfo
{
FileName = temporary,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
};
info.ArgumentList.Add("--uninstall-worker");
info.ArgumentList.Add("--install-dir");
info.ArgumentList.Add(Path.GetFullPath(installDirectory));
if (quiet) info.ArgumentList.Add("--quiet");
_ = Process.Start(info) ?? throw new InvalidOperationException("Could not start the uninstall worker.");
}
public static int RunWorker(string installDirectory, bool quiet)
{
installDirectory = Path.GetFullPath(installDirectory);
Thread.Sleep(750);
TryCleanup("remove shortcuts", quiet, ShellIntegration.RemoveShortcuts);
// A portable installation never registered an uninstall entry, and the single machine-wide
// key may belong to a normal installation on the same account. Removing it here would
// uninstall that one from Windows' point of view.
bool? isPortable = null;
TryCleanup("determine whether the installation is portable", quiet,
() => isPortable = PortableRoot.TryFind(installDirectory) is not null);
if (isPortable == false)
TryCleanup("remove the Add/Remove Programs entry", quiet,
ShellIntegration.UnregisterUninstaller);
var installation = new Installation(installDirectory);
string? configPath = null;
TryCleanup("locate the runtime configuration", quiet,
() => configPath = RuntimeConfiguration.ResolveConfigPath(installDirectory));
InstallState? state = null;
TryCleanup("read the installation state", quiet,
() => state = installation.ReadInstallState());
if (configPath is not null)
{
TryCleanup("remove the installed game path from the runtime configuration", quiet,
() => RuntimeConfiguration.RemoveDvdRootIfOwned(configPath,
Path.Combine(installDirectory, "GameAssets", "DATA")));
}
// Remove only this install's Retro Rewind setting; leave user-owned paths untouched.
if (configPath is not null && !string.IsNullOrEmpty(state?.RetroRewindRoot))
TryCleanup("remove the Retro Rewind path from the runtime configuration", quiet,
() => RuntimeConfiguration.RemoveRetroRewindRootIfOwned(configPath, state.RetroRewindRoot));
Exception? lastError = null;
for (var attempt = 0; attempt < 20; attempt++)
{
try
{
if (Directory.Exists(installDirectory)) Directory.Delete(installDirectory, recursive: true);
lastError = null;
break;
}
catch (Exception ex)
{
lastError = ex;
Thread.Sleep(250);
}
}
var current = Environment.ProcessPath;
if (current is not null) MoveFileEx(current, null, MoveFileFlags.DelayUntilReboot);
if (lastError is not null)
{
if (!quiet)
Console.Error.WriteLine("Uninstall could not remove every file. " + lastError.Message);
return 1;
}
if (!quiet)
Console.Out.WriteLine("WiiCompiled was removed from this computer.");
return 0;
}
private static void TryCleanup(string description, bool quiet, Action cleanup)
{
try
{
cleanup();
}
catch (Exception ex)
{
if (!quiet)
Console.Error.WriteLine($"Uninstall could not {description}; continuing. {ex.Message}");
}
}
[Flags]
private enum MoveFileFlags : uint { DelayUntilReboot = 0x4 }
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool MoveFileEx(string existingFileName, string? newFileName, MoveFileFlags flags);
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WiiCompiled.Setup</AssemblyName>
<RootNamespace>WiiCompiled.Setup.Windows</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.2.24</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Command-line installer and launcher for WiiCompiled</Description>
<DebugType>embedded</DebugType>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\WiiCompiled.Setup.Common\WiiCompiled.Setup.Common.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,76 @@
namespace WiiCompiled.Setup.Windows;
internal static class WorkspaceTimestamps
{
public static void MarkChangedFiles(string installedWorkspace, string stagedWorkspace,
Action<string> diagnostic, CancellationToken cancellationToken = default)
{
if (!Directory.Exists(installedWorkspace) || !Directory.Exists(stagedWorkspace)) return;
installedWorkspace = Path.GetFullPath(installedWorkspace);
stagedWorkspace = Path.GetFullPath(stagedWorkspace);
var stampUtc = DateTime.UtcNow;
var changed = 0;
var unchanged = 0;
foreach (var stagedFile in Directory.EnumerateFiles(stagedWorkspace, "*",
SearchOption.AllDirectories))
{
cancellationToken.ThrowIfCancellationRequested();
var relative = Path.GetRelativePath(stagedWorkspace, stagedFile);
var installedFile = Path.Combine(installedWorkspace, relative);
if (FileUnchanged(installedFile, stagedFile))
{
// Clang validates PCH dependency mtimes by equality, so an unchanged file must
// keep the exact timestamp the previous build recorded, not the normalized one.
InheritTimestamp(installedFile, stagedFile);
unchanged++;
continue;
}
File.SetLastWriteTimeUtc(stagedFile, stampUtc);
changed++;
}
diagnostic($"Marked {changed} changed workspace file(s) for recompilation; " +
$"{unchanged} unchanged file(s) keep the incremental build cache valid.");
}
private static void InheritTimestamp(string installedFile, string stagedFile)
{
try
{
File.SetLastWriteTimeUtc(stagedFile, File.GetLastWriteTimeUtc(installedFile));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
}
}
private static bool FileUnchanged(string installedFile, string stagedFile)
{
try
{
if (!File.Exists(installedFile)) return false;
using var installed = File.OpenRead(installedFile);
using var staged = File.OpenRead(stagedFile);
if (installed.Length != staged.Length) return false;
var installedBuffer = new byte[81920];
var stagedBuffer = new byte[81920];
while (true)
{
var installedRead = installed.ReadAtLeast(installedBuffer, installedBuffer.Length,
throwOnEndOfStream: false);
var stagedRead = staged.ReadAtLeast(stagedBuffer, stagedBuffer.Length,
throwOnEndOfStream: false);
if (installedRead != stagedRead) return false;
if (installedRead == 0) return true;
if (!installedBuffer.AsSpan(0, installedRead)
.SequenceEqual(stagedBuffer.AsSpan(0, stagedRead)))
return false;
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return false;
}
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="WiiCompiled.Setup" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>