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

the idea behind this is C# code that is OS agnostic can go in WiiCompiled.Setup.Common to be shared by any OS specific code (eg: WiiCompiled.Setup.Windows and WiiCompiled.Setup.Linux).
This commit is contained in:
theofficialgman
2026-08-26 20:10:20 -04:00
parent f09590aa5d
commit c0ed2bfbeb
48 changed files with 444 additions and 340 deletions
@@ -0,0 +1,233 @@
namespace WiiCompiled.Setup.Common;
/// <summary>
/// One entry of an exact regular directory tree. Directory topology is part of the content
/// contract everywhere this walker is used: an empty directory can be a runtime-visible asset just
/// as a regular file can be, so it must not disappear from a content identity or a staged copy.
/// </summary>
public sealed record RegularTreeEntry(string RelativePath, string FullPath, bool IsDirectory,
bool IsEmptyDirectory, long Length);
public static class FileSystemUtilities
{
public static void CopyDirectory(string source, string destination,
CancellationToken cancellationToken = default)
{
Directory.CreateDirectory(destination);
foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories))
{
cancellationToken.ThrowIfCancellationRequested();
Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, directory)));
}
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
{
cancellationToken.ThrowIfCancellationRequested();
var output = Path.Combine(destination, Path.GetRelativePath(source, file));
Directory.CreateDirectory(Path.GetDirectoryName(output)!);
File.Copy(file, output, overwrite: true);
}
}
/// <summary>
/// Enumerates an exact tree without following links, sorted by forward-slash relative path.
/// Every directory appears exactly once and is flagged when it has no children at all.
/// </summary>
public static IReadOnlyList<RegularTreeEntry> EnumerateRegularTree(string root,
CancellationToken cancellationToken = default, string description = "The directory tree")
{
var rootInfo = new DirectoryInfo(Path.GetFullPath(root));
if (!rootInfo.Exists)
throw new DirectoryNotFoundException($"{description} is missing: {rootInfo.FullName}");
RejectReparsePoint(rootInfo, description);
var entries = new List<RegularTreeEntry>();
var pending = new Stack<DirectoryInfo>();
pending.Push(rootInfo);
while (pending.Count > 0)
{
cancellationToken.ThrowIfCancellationRequested();
var directory = pending.Pop();
var hasChild = false;
foreach (var entry in directory.EnumerateFileSystemInfos())
{
cancellationToken.ThrowIfCancellationRequested();
RejectReparsePoint(entry, description);
hasChild = true;
switch (entry)
{
case DirectoryInfo child:
pending.Push(child);
break;
case FileInfo file:
entries.Add(new RegularTreeEntry(Relative(rootInfo, file.FullName), file.FullName,
false, false, file.Length));
break;
default:
throw new InvalidDataException(
$"{description} contains an unsupported file-system entry: {entry.FullName}");
}
}
if (directory != rootInfo)
entries.Add(new RegularTreeEntry(Relative(rootInfo, directory.FullName), directory.FullName,
true, !hasChild, 0));
}
entries.Sort((left, right) => StringComparer.Ordinal.Compare(left.RelativePath, right.RelativePath));
return entries;
}
/// <summary>Removes exactly this directory, clearing attributes that would refuse deletion.</summary>
public static void DeleteDirectoryIfExists(string path)
{
if (!Directory.Exists(path)) return;
foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories))
{
try { File.SetAttributes(entry, FileAttributes.Normal); }
catch { }
}
try { File.SetAttributes(path, FileAttributes.Normal); }
catch { }
Directory.Delete(path, recursive: true);
}
/// <summary>
/// One comparable spelling of a path. Normalization happens before the trailing separator is
/// dropped, because <c>Path.GetFullPath("C:")</c> is the current directory on that drive rather
/// than the drive root.
/// </summary>
public static string NormalizePath(string path) =>
Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));
/// <summary>Whether two paths name the same location, after normalization.</summary>
public static bool PathsEqual(string left, string right) =>
NormalizePath(left).Equals(NormalizePath(right), StringComparison.OrdinalIgnoreCase);
/// <summary>Whether <paramref name="candidate"/> is <paramref name="container"/> or inside it, using a
/// normalized prefix check so <c>C:\Games2</c> doesn't match inside <c>C:\Games</c>.</summary>
public static bool PathContains(string container, string candidate)
{
container = NormalizePath(container);
candidate = NormalizePath(candidate);
return candidate.Equals(container, StringComparison.OrdinalIgnoreCase) ||
candidate.StartsWith(container + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase);
}
/// <summary>Whether two paths are equal or one contains the other, after normalization.</summary>
public static bool PathsOverlap(string left, string right) =>
PathContains(left, right) || PathContains(right, left);
/// <summary>Location rules shared by install directories and portable roots (both are trees setup may
/// replace and uninstall may delete). <paramref name="subject"/> only names what's being rejected.</summary>
public static void EnsureUsableLocation(string path, string subject)
{
if (string.IsNullOrWhiteSpace(path))
throw new InvalidOperationException($"{subject} is required.");
if (path.StartsWith(@"\\", StringComparison.Ordinal))
throw new InvalidOperationException($"{subject} must be on a local drive, not a network path.");
if (path.Length > MaximumLocationLength)
throw new InvalidOperationException(
$"{subject} path is too long. Choose a path shorter than {MaximumLocationLength} characters.");
var full = NormalizePath(path);
var drive = Path.GetPathRoot(full);
if (string.IsNullOrWhiteSpace(drive) ||
full.Equals(NormalizePath(drive), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"{subject} must be a subfolder, not the root of a drive. " +
"Choose a dedicated folder such as D:\\Games\\WiiCompiled\\Install.");
}
var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
if (!string.IsNullOrEmpty(windows) && PathContains(windows, full))
throw new InvalidOperationException($"{subject} cannot be inside the Windows directory.");
foreach (var folder in ReservedLocations)
{
var reserved = Environment.GetFolderPath(folder);
if (string.IsNullOrEmpty(reserved)) continue;
if (full.Equals(NormalizePath(reserved), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"{subject} cannot be {reserved}. Choose a dedicated folder.");
}
}
/// <summary>
/// The local build nests deep paths (llvm-mingw headers, CMake object directories) below the
/// installation, so the root itself has to stay well inside the classic path limit.
/// </summary>
private const int MaximumLocationLength = 180;
/// <summary>Well-known folders that must never <em>be</em> an installation or portable root.</summary>
private static readonly Environment.SpecialFolder[] ReservedLocations =
[
Environment.SpecialFolder.ProgramFiles,
Environment.SpecialFolder.ProgramFilesX86,
Environment.SpecialFolder.CommonProgramFiles,
Environment.SpecialFolder.System,
Environment.SpecialFolder.SystemX86,
Environment.SpecialFolder.UserProfile,
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolder.ApplicationData,
Environment.SpecialFolder.CommonApplicationData,
Environment.SpecialFolder.DesktopDirectory,
Environment.SpecialFolder.MyDocuments
];
/// <summary>
/// Writes a file so that a reader only ever sees the old contents or the new ones: the bytes go
/// to a sibling temporary and are renamed over the destination, which is atomic on NTFS.
/// Interrupting the write leaves the temporary behind, never a truncated document.
/// </summary>
public static void WriteAtomic(string path, byte[] contents)
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
var temporary = path + $".tmp-{Guid.NewGuid():N}";
try
{
File.WriteAllBytes(temporary, contents);
File.Move(temporary, path, overwrite: true);
}
finally
{
try { File.Delete(temporary); } catch { }
}
}
public static void WriteAtomic(string path, string contents) =>
WriteAtomic(path, System.Text.Encoding.UTF8.GetBytes(contents));
/// <summary>
/// Refuses an operation that cannot fit on the destination drive. The caller owns the allowance
/// it needs; this owns the one message and the one drive-readiness rule both paths report.
/// </summary>
public static void EnsureFreeSpace(string path, long required, string operationDescription)
{
var root = Path.GetPathRoot(path)
?? throw new InvalidOperationException(
"The installation directory is not on a local drive.");
var drive = new DriveInfo(root);
if (!drive.IsReady)
throw new IOException($"The destination drive {root} is not ready.");
if (drive.AvailableFreeSpace < required)
{
throw new IOException(
$"Not enough free space on {root}. {operationDescription} needs approximately " +
$"{FormatGiB(required)} free, but only {FormatGiB(drive.AvailableFreeSpace)} is available.");
}
}
private static string FormatGiB(long bytes) => $"{bytes / (1024d * 1024 * 1024):0.0} GiB";
public static void RejectReparsePoint(FileSystemInfo entry, string description)
{
if ((entry.Attributes & FileAttributes.ReparsePoint) != 0)
throw new InvalidDataException(
$"{description} contains a reparse point instead of a regular entry: {entry.FullName}");
}
private static string Relative(DirectoryInfo root, string fullPath) =>
Path.GetRelativePath(root.FullName, fullPath).Replace('\\', '/');
}
@@ -0,0 +1,33 @@
using System.Text.Json;
namespace WiiCompiled.Setup.Common;
/// <summary>Reads and atomically writes the small JSON state documents each installer keeps.</summary>
public static class JsonState
{
private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true };
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
public static T? TryRead<T>(string path) where T : class
{
try
{
return File.Exists(path) ? JsonSerializer.Deserialize<T>(File.ReadAllText(path), ReadOptions) : null;
}
catch
{
// A truncated or hand-edited state document must degrade into "unknown", which every
// caller already treats as "assume stale and rebuild", not into a crash.
return null;
}
}
public static void Write<T>(string path, T value)
{
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
var tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N");
File.WriteAllText(tempPath, JsonSerializer.Serialize(value, WriteOptions));
File.Move(tempPath, path, overwrite: true);
}
}
@@ -0,0 +1,38 @@
using System.Text.RegularExpressions;
namespace WiiCompiled.Setup.Common;
/// <summary>Disc metadata parsed from `nodtool info`'s stdout.</summary>
public sealed record NodToolDiscInfo(string GameId, string Title, int Revision);
/// <summary>
/// Parses the plain-text stdout of `nodtool info &lt;iso&gt;`. nodtool has no JSON output mode, but
/// prints one unconditional disc-level Title/Game ID/Disc-Revision block (via its own
/// `print_header`) before any per-partition breakdown - Wii discs also have differently-scoped
/// "Title"/"Game ID" lines per update/channel partition further down, so the first match of each
/// pattern is always the disc-level one both installers want.
/// </summary>
public static partial class NodToolInfoParser
{
public static NodToolDiscInfo Parse(string infoStdout)
{
var gameIdMatch = GameIdLine().Match(infoStdout);
if (!gameIdMatch.Success)
throw new InvalidOperationException("nodtool did not return disc metadata.");
var titleMatch = TitleLine().Match(infoStdout);
var revisionMatch = RevisionLine().Match(infoStdout);
return new NodToolDiscInfo(
GameId: gameIdMatch.Groups[1].Value,
Title: titleMatch.Success ? titleMatch.Groups[1].Value : "",
Revision: revisionMatch.Success ? int.Parse(revisionMatch.Groups[1].Value) : 0);
}
[GeneratedRegex(@"^Game ID: (\S+)", RegexOptions.Multiline)]
private static partial Regex GameIdLine();
[GeneratedRegex(@"^Title: (.+)$", RegexOptions.Multiline)]
private static partial Regex TitleLine();
[GeneratedRegex(@"^Disc \d+, Revision (\d+)", RegexOptions.Multiline)]
private static partial Regex RevisionLine();
}
@@ -0,0 +1,67 @@
using System.Runtime.InteropServices;
namespace WiiCompiled.Setup.Common;
/// <summary>
/// Resolves the `nodtool` binary both installers use for Wii disc validation/extraction (see
/// NodToolInfoParser.cs), replacing the earlier dependency on `dolphin-tool`/`DolphinTool.exe`. A
/// caller can supply one directly; otherwise this downloads the matching prebuilt release binary
/// from encounter/nod and caches it at Launcher/artifacts/nodtool[.exe].
///
/// Shared by: WiiCompiled.Setup.Linux/DiscTool.cs (falls back to this at end-user install time on
/// a plain git checkout), and WiiCompiled.Setup.Common.Cli (invoked once at packaging time by both
/// build-appimage.sh and Build-Installer.ps1 to acquire the copy each bundles).
/// </summary>
public static class NodToolProvider
{
public const string Version = "v2.0.0-alpha.10";
public static async Task<string> ResolveAsync(string workspace, CancellationToken cancellationToken)
{
var cacheName = OperatingSystem.IsWindows() ? "nodtool.exe" : "nodtool";
var cachePath = Path.Combine(workspace, "Launcher", "artifacts", cacheName);
if (File.Exists(cachePath)) return cachePath;
var url = $"https://github.com/encounter/nod/releases/download/{Version}/{AssetName()}";
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
var tempPath = cachePath + ".tmp";
using (var http = new HttpClient())
using (var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
{
response.EnsureSuccessStatusCode();
await using var fileStream = File.Create(tempPath);
await response.Content.CopyToAsync(fileStream, cancellationToken);
}
File.Move(tempPath, cachePath, overwrite: true);
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(cachePath,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
return cachePath;
}
private static string AssetName()
{
if (OperatingSystem.IsWindows())
{
return RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => "nodtool-windows-x86_64.exe",
Architecture.Arm64 => "nodtool-windows-arm64.exe",
Architecture.X86 => "nodtool-windows-x86.exe",
var other => throw new PlatformNotSupportedException($"No prebuilt nodtool release for Windows {other}"),
};
}
return RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => "nodtool-linux-x86_64",
Architecture.Arm64 => "nodtool-linux-aarch64",
Architecture.X86 => "nodtool-linux-i686",
var other => throw new PlatformNotSupportedException($"No prebuilt nodtool release for Linux {other}"),
};
}
}
@@ -0,0 +1,95 @@
namespace WiiCompiled.Setup.Common;
/// <summary>
/// A portable installation is a self-contained directory tree the user can move or carry on removable media:
/// <code>
/// &lt;root&gt;\portable.txt marker; its contents are irrelevant
/// &lt;root&gt;\Install\ the installation directory
/// &lt;root&gt;\UserData\ runtime user state (Config.toml, NAND, Cache, Logs)
/// </code>
/// The runtime finds the same root independently (<c>runtime/include/runtime_config.h</c>,
/// <c>PortableRootDirectory</c>); this class must keep the same marker name, layout, and depth bound.
/// Shared by both installers - Linux's CLI has no <c>--portable</c> flag, so it only ever calls
/// <see cref="TryFind"/>/<see cref="UserDataDirectory"/>/<see cref="Contains"/> (always missing,
/// since it never creates a marker file), not <see cref="Create"/>.
/// </summary>
public static class PortableRoot
{
public const string MarkerFileName = "portable.txt";
public const string UserDataDirectoryName = "UserData";
public const string InstallDirectoryName = "Install";
/// <summary>
/// Parents searched above the start directory. Kept small (installs sit 2 levels below root,
/// <c>&lt;root&gt;\Install\Base\game.exe</c>) so an unrelated marker far up a drive can't capture it.
/// </summary>
public const int MaximumSearchDepth = 4;
/// <summary>
/// The portable root <paramref name="startDirectory"/> belongs to, or null otherwise. Callers pass the
/// installation directory (not the running executable's location), since a downloaded setup runs from Downloads.
/// </summary>
public static string? TryFind(string startDirectory)
{
if (string.IsNullOrWhiteSpace(startDirectory)) return null;
string current;
try
{
// Normalize before trimming: "C:\" trimmed to "C:" is the current directory on that
// drive, not the drive root.
current = Normalize(startDirectory);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
return null;
}
for (var level = 0; level <= MaximumSearchDepth; level++)
{
if (File.Exists(Path.Combine(current, MarkerFileName))) return current;
var parent = Path.GetDirectoryName(current);
if (string.IsNullOrEmpty(parent) || parent.Equals(current, StringComparison.Ordinal)) break;
current = parent;
}
return null;
}
public static string UserDataDirectory(string root) => Path.Combine(root, UserDataDirectoryName);
/// <summary>Whether <paramref name="candidate"/> is <paramref name="root"/> or lives inside it.</summary>
public static bool Contains(string root, string candidate) =>
FileSystemUtilities.PathContains(root, candidate);
/// <summary>
/// Establishes the root an install is about to publish into: the marker and the user-state
/// directory must exist before anything resolves a configuration path, because that resolution is
/// what decides whether the installation writes portable or machine-wide settings.
/// </summary>
public static string Create(string root)
{
var full = Normalize(root);
EnsureUsableRoot(full);
Directory.CreateDirectory(full);
Directory.CreateDirectory(UserDataDirectory(full));
var marker = Path.Combine(full, MarkerFileName);
if (!File.Exists(marker))
{
File.WriteAllText(marker,
"WiiCompiled portable installation." + Environment.NewLine +
"This marker makes the runtime keep Config.toml, NAND, Cache, and Logs in UserData\\ " +
"beside it instead of in %LOCALAPPDATA%." + Environment.NewLine +
"Delete it to make this installation use per-user application data again." +
Environment.NewLine);
}
return full;
}
/// <summary>
/// A portable root is deleted wholesale by the user, so like an install directory it may never be a
/// drive root or well-known system location; both share <see cref="FileSystemUtilities.EnsureUsableLocation"/>.
/// </summary>
public static void EnsureUsableRoot(string root) =>
FileSystemUtilities.EnsureUsableLocation(root, "A portable installation root");
private static string Normalize(string path) => FileSystemUtilities.NormalizePath(path);
}
@@ -0,0 +1,60 @@
namespace WiiCompiled.Setup.Common;
/// <summary>
/// Resolves the one canonical Retro Rewind install. Wheel Wizard owns and passes it as
/// <c>--retro-dir</c>; each installer only resolves, reads, and records it, never packages or
/// copies it.
/// </summary>
public static class RetroRewindSource
{
/// <summary>
/// Resolves the <c>RetroRewind6</c> folder from a selection that may be the folder itself or a
/// parent containing exactly one <c>RetroRewind6/Binaries/Code.pul</c>.
/// </summary>
public static string ResolveRetroRewind6(string selected)
{
if (string.IsNullOrWhiteSpace(selected))
throw new InvalidDataException("Choose the canonical Retro Rewind folder.");
var root = Path.GetFullPath(selected);
if (!Directory.Exists(root))
throw new DirectoryNotFoundException($"The Retro Rewind folder does not exist: {root}");
var candidates = new List<string> { root, Path.Combine(root, "RetroRewind6") };
// A folder produced by unpacking the published distribution keeps an extra wrapper directory.
foreach (var child in Directory.EnumerateDirectories(root))
candidates.Add(Path.Combine(child, "RetroRewind6"));
var matches = candidates
.Where(candidate => File.Exists(Path.Combine(candidate, "Binaries", "Code.pul")))
.Select(Path.GetFullPath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return matches.Count switch
{
1 => ResolveFinalDirectory(matches[0]),
0 => throw new InvalidDataException(
$"{root} does not contain RetroRewind6\\Binaries\\Code.pul. " +
"Select the canonical Retro Rewind folder, or the folder that contains it."),
_ => throw new InvalidDataException(
$"{root} contains more than one RetroRewind6\\Binaries\\Code.pul. " +
"Select the exact Retro Rewind folder to use.")
};
}
/// <summary>
/// Must resolve a junction/symlink RetroRewind6 to its real directory before fingerprinting: the
/// compile-input identity records on-disk kind, and a snapshot copy is always a real directory, so an
/// unresolved link can never match its own copy and installs fail forever with a misleading error.
/// </summary>
private static string ResolveFinalDirectory(string path)
{
var directory = new DirectoryInfo(path);
if (directory.LinkTarget is null)
return path;
var target = directory.ResolveLinkTarget(returnFinalTarget: true)?.FullName;
if (target is null || !Directory.Exists(target))
throw new InvalidDataException($"The Retro Rewind folder is a link to a missing target: {path}");
return target;
}
}
@@ -0,0 +1,211 @@
using System.Buffers.Binary;
using System.Net;
using System.Security.Cryptography;
namespace WiiCompiled.Setup.Common;
/// <summary>
/// One validated, content-identified download in operation-owned scratch space. Callers use this
/// exact directory for both the update decision and any resulting build.
/// </summary>
public sealed record RetroWfcPayloadSnapshot(string Directory, string Sha256, long ByteLength);
/// <summary>
/// Downloads and verifies the Retro-WFC payload (a small, RSA-signed blob served from a single
/// fixed endpoint). Shared by both installers - moved here from WiiCompiled.Setup.Windows's
/// InputValidation.cs, which keeps every one of these method names as thin forwarding wrappers so
/// its many existing call sites (ProductRepairService.cs, LocalBuildService.cs, Installation.cs,
/// SelfTests.cs) needed no changes.
/// </summary>
public static class RetroWfcPayload
{
private const long MaximumRetroWfcPayloadBytes = 16L * 1024 * 1024;
// The payload is tens of kilobytes from a single fixed endpoint 30s is good.
private static readonly TimeSpan RetroWfcDownloadTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan RetroWfcRetryDelay = TimeSpan.FromSeconds(1);
public const string CurrentRetroWfcPayloadUri = "http://nas.play.rwfc.net/payload?g=RMCPD00";
private static readonly string RetroWfcOfflinePayloadFile =
Path.Combine("binary", "payload.RMCPD00.bin");
/// <summary>Retro-WFC production payload signing key (PROD PayloadPublicKey). Verifies
/// the payload past the 0x110 header; on-console stage1 pins the same key, so
/// rotating it upstream is a breaking release there too.</summary>
private static readonly byte[] RetroWfcPayloadSigningModulus = Convert.FromHexString(
"e6e6ce416f350422cbe26c36a67eba613dddcd27d79afd077dcc593e5319eaa6" +
"080293400033876d3dbdfda12c15f46ac8e4f5b40c56e7b5f67e91647d618cb9" +
"99c041581b86d103bd7723fceac03ad3ad5134bf611cd47dc527002596821e94" +
"1c9470938fea07238a84767323e4a610bd996465e59d04dae4febd915c96fc07" +
"39e4e818300829d78f3f2275e1f3fbd2507f1bde74f24a5285e61007b959a583" +
"b4820d75eca76680866efe5d79590b82c3577b796155899530e305b94b4ceef4" +
"428644b719df3d8540c9588f5bb02d83d3938255d1a1e073d3408163ff93a615" +
"a2106a03923a397aad6a29ebb43031ed06de1575c8ee2b54678fa059e025f455");
private const int RetroWfcPayloadSignedRegionOffset = 0x110;
private const int RetroWfcPayloadSignatureOffset = 0x10;
private const int RetroWfcPayloadMinimumBytes = 0x130;
public static string ValidateStagedRetroWfcPayloadDirectory(string stagedDirectory,
RSAParameters? signingKey = null)
{
if (string.IsNullOrWhiteSpace(stagedDirectory))
throw new InvalidDataException("The staged Retro-WFC payload directory is missing.");
var root = Path.GetFullPath(stagedDirectory);
var payload = Path.Combine(root, RetroWfcOfflinePayloadFile);
if (!File.Exists(payload))
throw new InvalidDataException(
"The staged Retro-WFC payload directory does not contain binary\\payload.RMCPD00.bin.");
ValidateRetroWfcPayloadFile(payload, signingKey);
return root;
}
public static string ResolveRetroWfcPayloadFile(string stagedDirectory,
RSAParameters? signingKey = null) =>
Path.Combine(ValidateStagedRetroWfcPayloadDirectory(stagedDirectory, signingKey),
RetroWfcOfflinePayloadFile);
public static string ComputeRetroWfcPayloadSha256(string stagedDirectory,
RSAParameters? signingKey = null) =>
Sha256File(ResolveRetroWfcPayloadFile(stagedDirectory, signingKey));
public static void ValidateRetroWfcPayloadUri(string uriText)
{
if (!string.Equals(uriText, CurrentRetroWfcPayloadUri, StringComparison.Ordinal))
throw new InvalidDataException("The installer does not define the fixed Retro-WFC payload endpoint.");
}
public static async Task<RetroWfcPayloadSnapshot> DownloadRetroWfcPayloadAsync(string uriText,
string destinationDirectory,
CancellationToken cancellationToken)
{
ValidateRetroWfcPayloadUri(uriText);
var uri = new Uri(uriText, UriKind.Absolute);
var root = Path.GetFullPath(destinationDirectory);
var destination = Path.Combine(root, RetroWfcOfflinePayloadFile);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
using var handler = new HttpClientHandler { AllowAutoRedirect = false };
using var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
for (var attempt = 1; attempt <= 2; attempt++)
{
try
{
return await DownloadRetroWfcPayloadAttemptAsync(client, uri, root, destination,
cancellationToken);
}
catch (Exception ex) when (attempt == 1 &&
IsTransientRetroWfcDownloadFailure(ex, cancellationToken))
{
await Task.Delay(RetroWfcRetryDelay, cancellationToken);
}
}
throw new InvalidOperationException("The Retro-WFC download retry loop ended unexpectedly.");
}
private static async Task<RetroWfcPayloadSnapshot> DownloadRetroWfcPayloadAttemptAsync(
HttpClient client, Uri uri, string root, string destination, CancellationToken cancellationToken)
{
var temporary = destination + $".tmp-{Guid.NewGuid():N}";
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(RetroWfcDownloadTimeout);
var attemptToken = timeout.Token;
try
{
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, attemptToken);
if ((int)response.StatusCode is >= 300 and < 400)
throw new InvalidDataException(
"The fixed Retro-WFC payload endpoint redirected; refusing to fetch from a different target.");
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength is > MaximumRetroWfcPayloadBytes)
throw new InvalidDataException("The Retro-WFC payload is unexpectedly large.");
await using var input = await response.Content.ReadAsStreamAsync(attemptToken);
long total = 0;
string actualSha256;
await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write,
FileShare.None, 64 * 1024, FileOptions.Asynchronous))
{
var buffer = new byte[64 * 1024];
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
while (true)
{
var read = await input.ReadAsync(buffer, attemptToken);
if (read == 0) break;
total = checked(total + read);
if (total > MaximumRetroWfcPayloadBytes)
throw new InvalidDataException("The Retro-WFC payload is unexpectedly large.");
hash.AppendData(buffer, 0, read);
await output.WriteAsync(buffer.AsMemory(0, read), attemptToken);
}
actualSha256 = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
}
ValidateRetroWfcPayloadFile(temporary);
File.Move(temporary, destination, overwrite: true);
return new RetroWfcPayloadSnapshot(root, actualSha256, total);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException(
$"The Retro-WFC payload download did not finish within {RetroWfcDownloadTimeout.TotalMinutes:0} minutes.",
ex);
}
finally
{
try { File.Delete(temporary); } catch { }
}
}
public static bool IsTransientRetroWfcDownloadFailure(Exception exception,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested) return false;
if (exception is TimeoutException) return true;
if (exception is not HttpRequestException request) return false;
return request.StatusCode is null or HttpStatusCode.RequestTimeout or
HttpStatusCode.TooManyRequests ||
request.StatusCode is { } status && (int)status >= 500;
}
private static void ValidateRetroWfcPayloadFile(string payload, RSAParameters? signingKey = null)
{
byte[] image;
using (var stream = File.OpenRead(payload))
{
if (stream.Length > MaximumRetroWfcPayloadBytes)
throw new InvalidDataException("The Retro-WFC payload is unexpectedly large.");
if (stream.Length < RetroWfcPayloadMinimumBytes)
throw new InvalidDataException("The Retro-WFC payload has an invalid header.");
image = new byte[stream.Length];
stream.ReadExactly(image);
}
if (!image.AsSpan(0, 12).SequenceEqual("WWFC/Payload"u8))
throw new InvalidDataException("The Retro-WFC payload has an invalid header.");
var declaredSize = BinaryPrimitives.ReadUInt32BigEndian(image.AsSpan(0x0C));
if (declaredSize != image.Length)
throw new InvalidDataException(
$"The Retro-WFC payload declares {declaredSize} bytes but contains {image.Length}.");
using var rsa = RSA.Create();
rsa.ImportParameters(signingKey ?? new RSAParameters
{
Modulus = RetroWfcPayloadSigningModulus,
Exponent = [0x01, 0x00, 0x01],
});
if (!rsa.VerifyData(
image.AsSpan(RetroWfcPayloadSignedRegionOffset),
image.AsSpan(RetroWfcPayloadSignatureOffset,
RetroWfcPayloadSignedRegionOffset - RetroWfcPayloadSignatureOffset),
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
throw new InvalidDataException(
"The Retro-WFC payload is not signed by the pinned Retro-WFC signing key.");
}
private static string Sha256File(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
}
@@ -0,0 +1,298 @@
using System.Text;
namespace WiiCompiled.Setup.Common;
public sealed record RuntimeConfigSnapshot(bool Existed, byte[] Contents);
/// <summary>
/// Reads and writes the runtime's <c>Config.toml</c>. Every entry point takes the file it operates on
/// since its location depends on the installation (portable <c>UserData</c> vs. per-user app data);
/// callers obtain it once via <see cref="ResolveConfigPath"/>. Shared by both installers - Linux
/// never creates a <see cref="PortableRoot.MarkerFileName"/> marker file, so
/// <see cref="ResolveConfigPath"/>/<see cref="FormatPathValue"/>'s portable-root lookups always miss
/// there and this degrades to the same plain per-user-app-data, always-absolute-path behavior a
/// non-portable Windows install already gets.
/// </summary>
public static class RuntimeConfiguration
{
public const string ConfigFileName = "Config.toml";
/// <summary>
/// Ordinal, matching the runtime parser/writer (<c>runtime_config.h</c>) exactly. Case-insensitive
/// matching here would let setup rewrite a <c>[Paths]</c>/<c>Dvd_Root</c> spelling the runtime ignores.
/// </summary>
private const StringComparison KeyComparison = StringComparison.Ordinal;
/// <summary>The per-user configuration an ordinary (non-portable) installation shares.</summary>
public static string ApplicationDataConfigPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"WiiCompiled", ConfigFileName);
/// <summary>
/// The configuration file that governs <paramref name="installDirectory"/>. This mirrors
/// <c>RuntimeConfigFile::ResolveConfigPath</c> in the runtime: portable roots win, everything
/// else is per-user application data.
/// </summary>
public static string ResolveConfigPath(string? installDirectory)
{
if (!string.IsNullOrWhiteSpace(installDirectory) &&
PortableRoot.TryFind(installDirectory) is { } portableRoot)
return Path.Combine(PortableRoot.UserDataDirectory(portableRoot), ConfigFileName);
return ApplicationDataConfigPath;
}
public static RuntimeConfigSnapshot Capture(string configPath) =>
File.Exists(configPath)
? new RuntimeConfigSnapshot(true, File.ReadAllBytes(configPath))
: new RuntimeConfigSnapshot(false, []);
public static void Restore(string configPath, RuntimeConfigSnapshot snapshot)
{
if (snapshot.Existed)
FileSystemUtilities.WriteAtomic(configPath, snapshot.Contents);
else
File.Delete(configPath);
}
public static void SetDvdRoot(string configPath, string dvdRoot) =>
SetPath(configPath, "dvd_root", dvdRoot);
/// <summary>
/// Records the one canonical Retro Rewind installation Wheel Wizard owns. The runtime builds its
/// asset overlay by scanning this directory live at launch, so an asset-only Retro Rewind update
/// needs no backend work: the next launch simply reads the new files.
/// </summary>
public static void SetRetroRewindRoot(string configPath, string retroRewindRoot) =>
SetPath(configPath, "retro_rewind_root", retroRewindRoot);
/// <summary>The canonical Retro Rewind root, or null when no installation has recorded one.</summary>
public static string? GetRetroRewindRoot(string configPath) =>
GetResolvedPath(configPath, "retro_rewind_root");
public static void RemoveRetroRewindRootIfOwned(string configPath, string retroRewindRoot) =>
RemovePathIfOwned(configPath, "retro_rewind_root", retroRewindRoot);
public static void RemoveDvdRootIfOwned(string configPath, string dvdRoot) =>
RemovePathIfOwned(configPath, "dvd_root", dvdRoot);
/// <summary>The raw stored text of a <c>[paths]</c> key, exactly as the file holds it.</summary>
public static string? GetPath(string configPath, string key) =>
TryUnquoteToml(GetRawValue(configPath, "paths", key) ?? "", out var value) ? value : null;
/// <summary>
/// A <c>[paths]</c> value as the runtime will actually use it. The runtime resolves every relative
/// <c>[paths]</c> value against the directory holding <c>Config.toml</c> (never the working
/// directory), so the host must resolve it the same way before comparing or reading it.
/// </summary>
public static string? GetResolvedPath(string configPath, string key)
{
var stored = GetPath(configPath, key);
return string.IsNullOrWhiteSpace(stored) ? null : ResolveAgainstConfig(configPath, stored);
}
public static string ConfigDirectory(string configPath) =>
Path.GetDirectoryName(Path.GetFullPath(configPath))
?? throw new InvalidOperationException($"{configPath} has no containing directory.");
public static string ResolveAgainstConfig(string configPath, string value) =>
Path.GetFullPath(value, ConfigDirectory(configPath));
private static void SetPath(string configPath, string key, string value)
{
var lines = ReadLinesOrDefault(configPath);
SetSectionValue(lines, "paths", key, QuoteToml(FormatPathValue(configPath, value)));
WriteLines(configPath, lines);
}
/// <summary>
/// Paths inside the portable root are stored relative to the config directory (forward slashes,
/// needing no TOML escaping) so the tree keeps working if the user moves or renames the root.
/// Everything else, including all non-portable paths, is stored absolute.
/// </summary>
private static string FormatPathValue(string configPath, string value)
{
var full = Path.GetFullPath(value);
var configDirectory = ConfigDirectory(configPath);
if (PortableRoot.TryFind(configDirectory) is not { } root || !PortableRoot.Contains(root, full))
return full;
var relative = Path.GetRelativePath(configDirectory, full);
return Path.IsPathRooted(relative)
? full
: relative.Replace(Path.DirectorySeparatorChar, '/');
}
private static void RemovePathIfOwned(string configPath, string key, string expectedValue)
{
if (!File.Exists(configPath)) return;
var lines = File.ReadAllLines(configPath).ToList();
var inPaths = false;
var changed = false;
for (var index = 0; index < lines.Count; index++)
{
var trimmed = RemoveComment(lines[index]).Trim();
if (IsSection(trimmed))
{
inPaths = trimmed.Equals("[paths]", KeyComparison);
continue;
}
if (!inPaths || trimmed.Length == 0) continue;
var equals = trimmed.IndexOf('=');
if (equals < 0 || !trimmed[..equals].Trim().Equals(key, KeyComparison))
continue;
if (!TryUnquoteToml(trimmed[(equals + 1)..].Trim(), out var configured)) return;
// A portable installation stores this relative, so ownership is decided on the path the
// runtime would actually resolve, not on the stored text.
if (!ResolveAgainstConfig(configPath, configured)
.Equals(Path.GetFullPath(expectedValue), StringComparison.OrdinalIgnoreCase))
return;
lines.RemoveAt(index);
changed = true;
break;
}
if (changed) WriteLines(configPath, lines);
}
/// <summary>The raw TOML literal stored for a key, or null when the section or key is absent.</summary>
public static string? GetRawValue(string configPath, string section, string key)
{
if (!File.Exists(configPath)) return null;
var header = $"[{section}]";
var inSection = false;
foreach (var line in File.ReadLines(configPath))
{
var trimmed = RemoveComment(line).Trim();
if (IsSection(trimmed))
{
inSection = trimmed.Equals(header, KeyComparison);
continue;
}
if (!inSection || trimmed.Length == 0) continue;
var equals = trimmed.IndexOf('=');
if (equals < 0 || !trimmed[..equals].Trim().Equals(key, KeyComparison)) continue;
return trimmed[(equals + 1)..].Trim();
}
return null;
}
/// <summary>
/// Replaces one key in one section, preserving comments, ordering, and every unrelated setting -
/// this file belongs to the user and to the in-game settings bar as much as to setup. A
/// commented-out key is deliberately not matched, matching the runtime's own writer.
/// </summary>
private static void SetSectionValue(List<string> lines, string section, string key, string value)
{
var header = $"[{section}]";
var sectionStart = -1;
var sectionEnd = lines.Count;
for (var index = 0; index < lines.Count; index++)
{
var trimmed = RemoveComment(lines[index]).Trim();
if (!IsSection(trimmed)) continue;
if (sectionStart >= 0)
{
sectionEnd = index;
break;
}
if (trimmed.Equals(header, KeyComparison)) sectionStart = index;
}
if (sectionStart < 0)
{
if (lines.Count > 0 && lines[^1].Length != 0) lines.Add("");
lines.Add(header);
lines.Add($"{key} = {value}");
return;
}
for (var index = sectionStart + 1; index < sectionEnd; index++)
{
var trimmed = RemoveComment(lines[index]).Trim();
if (trimmed.Length == 0) continue;
var equals = trimmed.IndexOf('=');
if (equals >= 0 && trimmed[..equals].Trim().Equals(key, KeyComparison))
{
lines[index] = $"{key} = {value}";
return;
}
}
// Append after the section's last real line, not after the blank line that separates it from
// the next header, so a generated file stays readable for the user who also edits it by hand.
var insertAt = sectionEnd;
while (insertAt > sectionStart + 1 && lines[insertAt - 1].Trim().Length == 0) insertAt--;
lines.Insert(insertAt, $"{key} = {value}");
}
private static List<string> ReadLinesOrDefault(string configPath) =>
File.Exists(configPath) ? File.ReadAllLines(configPath).ToList() : DefaultConfigLines();
private static void WriteLines(string configPath, List<string> lines) =>
FileSystemUtilities.WriteAtomic(configPath,
Encoding.UTF8.GetBytes(string.Join(Environment.NewLine, lines) + Environment.NewLine));
private static List<string> DefaultConfigLines() =>
[
"# WiiCompiled user configuration",
"",
"[paths]"
];
private static bool IsSection(string value) =>
value.Length >= 2 && value[0] == '[' && value[^1] == ']';
/// <summary>
/// Line-for-line port of <c>RemoveComment</c> in <c>runtime_config.h</c>, including its backslash
/// escaping inside basic strings: without it a Windows path ending in <c>\\"</c> reads as
/// re-opening the quote, and the two implementations disagree about which <c>#</c> starts a comment.
/// </summary>
private static string RemoveComment(string line)
{
var inSingle = false;
var inDouble = false;
var escaped = false;
for (var index = 0; index < line.Length; index++)
{
var character = line[index];
if (inDouble && character == '\\' && !escaped)
{
escaped = true;
continue;
}
if (character == '\'' && !inDouble) inSingle = !inSingle;
else if (character == '"' && !inSingle && !escaped) inDouble = !inDouble;
else if (character == '#' && !inSingle && !inDouble) return line[..index];
escaped = false;
}
return line;
}
private static string QuoteToml(string value) =>
"\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
public static bool TryUnquoteToml(string value, out string result)
{
result = "";
if (value.Length < 2) return false;
// Literal strings carry no escapes; the runtime's parser treats them the same way.
if (value[0] == '\'' && value[^1] == '\'')
{
result = value[1..^1];
return true;
}
if (value[0] != '"' || value[^1] != '"') return false;
var builder = new StringBuilder(value.Length - 2);
for (var index = 1; index < value.Length - 1; index++)
{
if (value[index] == '\\' && index + 1 < value.Length - 1)
{
var escaped = value[++index];
builder.Append(escaped switch { '\\' => '\\', '"' => '"', _ => escaped });
}
else
{
builder.Append(value[index]);
}
}
result = builder.ToString();
return true;
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>WiiCompiled.Setup.Common</RootNamespace>
<AssemblyName>WiiCompiled.Setup.Common</AssemblyName>
<Version>0.2.22</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers</Description>
<DebugType>embedded</DebugType>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
</Project>