mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-09-11 09:25:05 -04:00
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:
@@ -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:<id></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><portable root>\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 -> toolkit-state.json -> 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>
|
||||
Reference in New Issue
Block a user