Files
wiicompiled/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs
T
theofficialgman c0ed2bfbeb 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).
2026-08-29 12:06:58 -04:00

1507 lines
84 KiB
C#

using System.Buffers.Binary;
using System.Security.Cryptography;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
internal static class SelfTests
{
public static int Run()
{
var failures = new List<string>();
Test("ISO extension", () => TestAcceptedExtension(".iso"), failures);
Test("GCM extension", () => TestAcceptedExtension(".gcm"), failures);
Test("GCZ extension", () => TestAcceptedExtension(".gcz"), failures);
Test("CISO extension", () => TestAcceptedExtension(".ciso"), failures);
Test("WBFS extension", () => TestAcceptedExtension(".wbfs"), failures);
Test("WIA extension", () => TestAcceptedExtension(".wia"), failures);
Test("RVZ extension", () => TestAcceptedExtension(".RVZ"), failures);
Test("Unsupported extension", () =>
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".nfs");
File.WriteAllBytes(path, [0]);
try
{
try { InputValidation.ValidateExtension(path); throw new Exception("NFS was accepted."); }
catch (InvalidDataException) { }
}
finally { File.Delete(path); }
}, failures);
Test("Disc compatibility", () =>
InputValidation.EnsureCompatibleDisc(new DiscHeader { GameId = "RMCP01" },
new PayloadManifest { ExpectedGameId = "RMCP01" }), failures);
Test("Disc rejection", () =>
{
try
{
InputValidation.EnsureCompatibleDisc(new DiscHeader { GameId = "RMCJ01" },
new PayloadManifest { ExpectedGameId = "RMCP01" });
throw new Exception("Wrong region was accepted.");
}
catch (InvalidDataException) { }
}, failures);
Test("Payload timestamp normalization", TestPayloadTimestampNormalization, failures);
Test("Workspace timestamp reconciliation", TestWorkspaceTimestampReconciliation, failures);
Test("Toolkit accompanies the workspace", TestToolkitRefreshDecision, failures);
Test("Retro-WFC payload snapshot reconciliation", TestRetroWfcPayloadSnapshotReconciliation, failures);
Test("Retro-WFC transient retry policy", TestRetroWfcTransientRetryPolicy, failures);
Test("Shared runtime configuration", () => TestPathSettingRoundTrip("dvd_root",
RuntimeConfiguration.SetDvdRoot, RuntimeConfiguration.RemoveDvdRootIfOwned), failures);
Test("Canonical Retro Rewind configuration", () => TestPathSettingRoundTrip("retro_rewind_root",
RuntimeConfiguration.SetRetroRewindRoot, RuntimeConfiguration.RemoveRetroRewindRootIfOwned),
failures);
Test("Runtime configuration key matching", TestRuntimeConfigurationKeyMatching, failures);
Test("Install location validation", TestInstallLocationValidation, failures);
Test("Command line contract", TestCommandLineContract, failures);
Test("Portable root discovery", TestPortableRootDiscovery, failures);
Test("Portable install validation failure leaves no marker",
TestPortableValidationFailureLeavesNoMarker, failures);
Test("Portable configuration path resolution", TestPortableConfigPathResolution, failures);
Test("Portable relative path settings", TestPortableRelativePathSettings, failures);
Test("Portable move healing", TestPortableMoveHealing, failures);
Test("Install transaction rollback", TestInstallTransactionRollback, failures);
Test("Install move transient-lock policy", TestInstallMoveTransientLockPolicy, failures);
Test("Install scratch recovery", TestInstallScratchRecovery, failures);
Test("Shortcut cleanup is best effort", TestShortcutCleanupIsBestEffort, failures);
Test("Canonical Retro Rewind resolution", TestCanonicalRetroRewindResolution, failures);
Test("Retro Rewind compile-inputs golden vector",
TestCompileInputsFingerprintGoldenVector, failures);
Test("Retro Rewind compile-inputs fingerprint", TestCompileInputsFingerprint, failures);
Test("Retro Rewind compile-inputs snapshot", TestCompileInputsSnapshot, failures);
Test("Toolkit fingerprint", TestToolkitFingerprint, failures);
Test("Retro Rewind inspection state machine", TestCodePulStateMachine, failures);
Test("Products report contract", TestProductsReport, failures);
foreach (var failure in failures) Console.Error.WriteLine("FAILED " + failure);
return failures.Count == 0 ? 0 : 1;
}
private static void TestAcceptedExtension(string extension)
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + extension);
File.WriteAllBytes(path, [0]);
try { InputValidation.ValidateExtension(path); } finally { File.Delete(path); }
}
private static void TestPayloadTimestampNormalization()
{
var path = Path.Combine(Path.GetTempPath(), "mkwc-timestamp-" + Guid.NewGuid().ToString("N"));
try
{
File.WriteAllText(path, "timestamp");
File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddDays(1));
PayloadArchive.NormalizeExtractedTimestamp(path);
if (File.GetLastWriteTimeUtc(path).Year != 2000)
throw new Exception("Extracted payload timestamps were not normalized.");
}
finally
{
File.Delete(path);
}
}
private static void TestWorkspaceTimestampReconciliation()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-wsstamp-" + Guid.NewGuid().ToString("N"));
try
{
var installed = Path.Combine(root, "Install", "BuildWorkspace");
var staged = Path.Combine(root, "staging", "BuildWorkspace");
Directory.CreateDirectory(Path.Combine(installed, "runtime", "src"));
Directory.CreateDirectory(Path.Combine(staged, "runtime", "src"));
File.WriteAllText(Path.Combine(installed, "runtime", "src", "same.cpp"), "int same();");
File.WriteAllText(Path.Combine(staged, "runtime", "src", "same.cpp"), "int same();");
var installedSameStamp = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
File.SetLastWriteTimeUtc(Path.Combine(installed, "runtime", "src", "same.cpp"),
installedSameStamp);
File.WriteAllText(Path.Combine(installed, "runtime", "src", "edited.cpp"), "int old();");
File.WriteAllText(Path.Combine(staged, "runtime", "src", "edited.cpp"), "int new_();");
File.WriteAllText(Path.Combine(staged, "runtime", "src", "added.cpp"), "int added();");
foreach (var file in Directory.EnumerateFiles(staged, "*", SearchOption.AllDirectories))
PayloadArchive.NormalizeExtractedTimestamp(file);
WorkspaceTimestamps.MarkChangedFiles(installed, staged, _ => { });
if (File.GetLastWriteTimeUtc(Path.Combine(staged, "runtime", "src", "same.cpp")) !=
installedSameStamp)
throw new Exception("An unchanged workspace file did not inherit the installed " +
"timestamp; clang would reject the previous build's PCHs.");
if (File.GetLastWriteTimeUtc(Path.Combine(staged, "runtime", "src", "edited.cpp")).Year == 2000)
throw new Exception("A changed workspace file kept its normalized timestamp; " +
"Ninja would never recompile it.");
if (File.GetLastWriteTimeUtc(Path.Combine(staged, "runtime", "src", "added.cpp")).Year == 2000)
throw new Exception("A new workspace file kept its normalized timestamp.");
// A fresh installation has no workspace to compare against; the pass must be a no-op.
WorkspaceTimestamps.MarkChangedFiles(Path.Combine(root, "absent"), staged, _ => { });
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static void TestToolkitRefreshDecision()
{
if (!InstallerEngine.MustRefreshToolkit(sameToolkit: false, samePackageContent: true,
nodToolPresent: true))
throw new Exception("A republished workspace kept the installed toolkit; the shipped " +
"translator and project file could come from different releases.");
if (!InstallerEngine.MustRefreshToolkit(sameToolkit: true, samePackageContent: false,
nodToolPresent: true))
throw new Exception("Changed toolkit package content was not extracted.");
if (!InstallerEngine.MustRefreshToolkit(sameToolkit: true, samePackageContent: true,
nodToolPresent: false))
throw new Exception("A missing nodtool.exe did not force toolkit extraction.");
if (InstallerEngine.MustRefreshToolkit(sameToolkit: true, samePackageContent: true,
nodToolPresent: true))
throw new Exception("An unchanged toolkit was needlessly re-extracted.");
}
/// <summary>
/// Every <c>[paths]</c> key follows the same contract: writing preserves other settings, reads
/// back resolved, and uninstall removes it only while it still points at the recorded location.
/// </summary>
private static void TestPathSettingRoundTrip(string key, Action<string, string> set,
Action<string, string> removeIfOwned)
{
var root = Path.Combine(Path.GetTempPath(), $"mkwc-{key}-" + Guid.NewGuid().ToString("N"));
var config = Path.Combine(root, "Config.toml");
var owned = Path.Combine(root, "Owned Location");
try
{
Directory.CreateDirectory(owned);
File.WriteAllText(config,
"[video]\nwidescreen = true\n\n[paths]\nunrelated_root = \"D:\\\\Elsewhere\"\n");
set(config, owned);
var updated = File.ReadAllText(config);
if (!updated.Contains("widescreen = true", StringComparison.Ordinal) ||
!updated.Contains("unrelated_root = \"D:\\\\Elsewhere\"", StringComparison.Ordinal) ||
!updated.Contains(key + " = ", StringComparison.Ordinal))
throw new Exception($"Writing {key} discarded existing runtime settings.");
var read = RuntimeConfiguration.GetResolvedPath(config, key);
if (read is null || !read.Equals(Path.GetFullPath(owned), StringComparison.OrdinalIgnoreCase))
throw new Exception($"The configured {key} did not round-trip.");
removeIfOwned(config, Path.Combine(root, "Someone Else"));
if (RuntimeConfiguration.GetPath(config, key) is null)
throw new Exception($"A {key} owned by someone else was removed.");
removeIfOwned(config, owned);
if (RuntimeConfiguration.GetPath(config, key) is not null)
throw new Exception($"Uninstall did not remove its owned {key} setting.");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// TOML keys are case-sensitive and the runtime compares them exactly, so the host must match
/// case too, or it silently writes a key the runtime never reads.
/// </summary>
private static void TestRuntimeConfigurationKeyMatching()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-tomlcase-" + Guid.NewGuid().ToString("N"));
var config = Path.Combine(root, RuntimeConfiguration.ConfigFileName);
var owned = Path.Combine(root, "Owned Location");
try
{
Directory.CreateDirectory(owned);
File.WriteAllText(config, "[Paths]\r\nDvd_Root = \"D:\\\\Wrong\"\r\n");
RuntimeConfiguration.SetDvdRoot(config, owned);
if (!File.ReadAllText(config).Contains("Dvd_Root = \"D:\\\\Wrong\"", StringComparison.Ordinal))
throw new Exception("A differently-cased key the runtime ignores was rewritten.");
var written = RuntimeConfiguration.GetResolvedPath(config, "dvd_root");
if (written is null || !written.Equals(Path.GetFullPath(owned), StringComparison.OrdinalIgnoreCase))
throw new Exception("dvd_root was not written as the exactly-cased key the runtime reads.");
// An escaped quote does not end the value, so the '#' after it is data rather than the
// start of a comment - exactly what RemoveComment in runtime_config.h decides.
File.WriteAllText(config, "[paths]\r\ndvd_root = \"a\\\"#b\"\r\n");
if (RuntimeConfiguration.GetPath(config, "dvd_root") != "a\"#b")
throw new Exception("A '#' inside an escaped quoted value was treated as a comment.");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// One location rule set covers both an installation directory and a portable root, so the
/// rejections a portable install performs are the ones an ordinary install performs too.
/// </summary>
private static void TestInstallLocationValidation()
{
void Rejects(string description, string path)
{
try
{
FileSystemUtilities.EnsureUsableLocation(path, "The installation folder");
throw new Exception(description + " was accepted as an install location.");
}
catch (InvalidOperationException) { }
}
FileSystemUtilities.EnsureUsableLocation("C:\\Games\\WiiCompiled\\Install", "The installation folder");
Rejects("A network path", "\\\\server\\share\\WiiCompiled");
Rejects("An over-long path", "C:\\" + new string('w', 200));
Rejects("A drive root", "C:\\");
Rejects("A folder inside Windows",
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "WiiCompiled"));
Rejects("The user profile itself",
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
// The two entry points must not have separate opinions about any of the above.
foreach (var rejected in new[] { "\\\\server\\share\\WiiCompiled", "C:\\" })
{
try
{
PortableRoot.EnsureUsableRoot(rejected);
throw new Exception($"{rejected} was accepted as a portable root.");
}
catch (InvalidOperationException) { }
}
}
private static void TestCommandLineContract()
{
void Rejects(string description, params string[] args)
{
try
{
CommandLine.Parse(args);
throw new Exception(description + " was accepted.");
}
catch (ArgumentException) { }
}
var version = CommandLine.Parse(["--version"]);
if (version.Mode != AppMode.Version) throw new Exception("--version was not recognised.");
var silent = CommandLine.Parse([
"--silent", "--game", "game.iso", "--install-dir", "C:\\Games\\MKW",
"--progress-json", "--retro-dir", "D:\\RetroRewind", "--skip-retro-wfc-payload"
]);
if (silent.Mode != AppMode.SilentInstall || !silent.ProgressJson ||
silent.RetroDirectoryPath != "D:\\RetroRewind" ||
silent.RetroWfcPayloadMode != RetroWfcPayloadMode.Skipped)
throw new Exception("The silent install contract did not parse.");
var launch = CommandLine.Parse(["--launch-base"]);
if (launch.Mode != AppMode.LaunchBase)
throw new Exception("The launch contract did not parse.");
Rejects("runtime argument forwarding", "--launch-base", "--", "--windowed");
if (CommandLine.Parse(["--launch-retro"]).Mode != AppMode.LaunchRetro)
throw new Exception("--launch-retro was not recognised.");
var check = CommandLine.Parse([
"--check-products", "--install-dir", "C:\\Games\\MKW", "--retro-dir", "D:\\RetroRewind",
"--progress-json"
]);
if (check.Mode != AppMode.CheckProducts || check.RetroDirectoryPath != "D:\\RetroRewind" ||
check.RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable)
throw new Exception("The product check contract did not parse.");
var retroRepair = CommandLine.Parse([
"--repair-products", "--install-dir", "C:\\Games\\MKW", "--retro-dir", "D:\\RetroRewind",
"--download-retro-wfc-payload", "--progress-json"
]);
if (retroRepair.Mode != AppMode.RepairProducts ||
retroRepair.RetroDirectoryPath != "D:\\RetroRewind" ||
retroRepair.RetroWfcPayloadMode != RetroWfcPayloadMode.Online)
throw new Exception("The Retro Rewind repair contract did not parse.");
Rejects("An unknown Retro Rewind source flag", "--silent", "--game", "g.iso", "--retro-zip",
"a.zip", "--skip-retro-wfc-payload");
Rejects("--progress-json outside an install", "--launch-base", "--progress-json");
// The NAND flag is gone: Wheel Wizard writes paths.nand_root into Config.toml itself.
Rejects("The removed NAND flag", "--silent", "--game", "g.iso", "--nand-dir", "D:\\Wii");
Rejects("A payload option without the canonical Retro Rewind folder", "--silent", "--game",
"g.iso", "--skip-retro-wfc-payload");
Rejects("A Retro Rewind folder without a payload decision", "--silent", "--game", "g.iso",
"--retro-dir", "b");
Rejects("Repair without an installation", "--repair-products", "--retro-dir", "rr",
"--skip-retro-wfc-payload", "--progress-json");
Rejects("Repair without the canonical Retro Rewind folder", "--repair-products",
"--install-dir", "fixed", "--progress-json");
Rejects("Retro Rewind repair without a payload decision", "--repair-products", "--install-dir", "fixed",
"--retro-dir", "rr", "--progress-json");
Rejects("A repair payload option without the canonical folder", "--repair-products",
"--install-dir", "fixed", "--skip-retro-wfc-payload", "--progress-json");
var portable = CommandLine.Parse([
"--silent", "--game", "game.iso", "--install-dir", "D:\\Games\\Recomp\\Install",
"--portable", "--progress-json"
]);
if (portable.Mode != AppMode.SilentInstall || !portable.Portable)
throw new Exception("The portable install contract did not parse.");
Rejects("--portable without an install directory", "--silent", "--game", "g.iso", "--portable");
Rejects("--portable outside an install", "--check-products", "--install-dir", "fixed", "--portable");
// The config verbs are gone: WheelWizard reads and writes Config.toml directly now.
Rejects("The removed configuration read verb", "--get-config");
Rejects("The removed configuration write verb", "--set-config", "--install-dir", "fixed");
Rejects("A bare setting assignment", "--check-products", "video.show_fps=true");
}
/// <summary>
/// The marker walk-up is the one thing both languages must agree on. The depth bound keeps an
/// unrelated marker high up a drive from capturing an ordinary installation.
/// </summary>
private static void TestPortableRootDiscovery()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-portable-" + Guid.NewGuid().ToString("N"));
try
{
var install = Path.Combine(root, "Install");
var deep = Path.Combine(install, "Base", "config", "a", "b", "c");
Directory.CreateDirectory(deep);
if (PortableRoot.TryFind(install) is not null)
throw new Exception("A directory without the marker was treated as portable.");
File.WriteAllText(Path.Combine(root, PortableRoot.MarkerFileName), "portable");
if (PortableRoot.TryFind(root) != root)
throw new Exception("The marker directory itself was not recognised as the root.");
if (PortableRoot.TryFind(install) != root)
throw new Exception("The install directory did not resolve to its portable root.");
if (PortableRoot.TryFind(Path.Combine(install, "Base", "config", "a")) != root)
throw new Exception("A path within the depth bound did not resolve to its portable root.");
if (PortableRoot.TryFind(deep) is not null)
throw new Exception("The portable search walked further up than its depth bound.");
if (!PortableRoot.Contains(root, Path.Combine(install, "GameAssets", "DATA")) ||
PortableRoot.Contains(root, Path.Combine(Path.GetTempPath(), "elsewhere")))
throw new Exception("Portable containment did not classify paths correctly.");
try
{
PortableRoot.EnsureUsableRoot(Path.GetPathRoot(root)!);
throw new Exception("A drive root was accepted as a portable root.");
}
catch (InvalidOperationException) { }
try
{
PortableRoot.EnsureUsableRoot(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
throw new Exception("A well-known system folder was accepted as a portable root.");
}
catch (InvalidOperationException) { }
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// A rejected game image must not turn the destination's parent into a portable root. Otherwise
/// the marker walk-up would redirect unrelated later installs into this root's UserData.
/// </summary>
private static void TestPortableValidationFailureLeavesNoMarker()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-portable-rejected-" + Guid.NewGuid().ToString("N"));
var game = Path.Combine(root, "not-a-disc-image.wad");
try
{
Directory.CreateDirectory(root);
File.WriteAllBytes(game, [0]);
var options = new InstallOptions
{
GamePath = game,
InstallDirectory = Path.Combine(root, PortableRoot.InstallDirectoryName),
Portable = true
};
try
{
new InstallerEngine(new DelegatingInstallReporter(_ => { }))
.InstallAsync(options).GetAwaiter().GetResult();
throw new Exception("An unsupported game image was accepted.");
}
catch (InvalidDataException) { }
if (File.Exists(Path.Combine(root, PortableRoot.MarkerFileName)))
throw new Exception("A failed portable install left its marker behind.");
if (Directory.Exists(PortableRoot.UserDataDirectory(root)))
throw new Exception("A failed portable install left its UserData directory behind.");
if (PortableRoot.TryFind(options.InstallDirectory) is not null)
throw new Exception("A failed portable install captured later installs under its parent.");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static void TestShortcutCleanupIsBestEffort()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-shortcuts-" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(root);
// File.Delete cannot delete a directory. This deterministically forces the first cleanup
// to fail while the remaining independently guarded targets must still be attempted.
var firstShortcut = Path.Combine(root, "first.lnk");
Directory.CreateDirectory(firstShortcut);
var secondShortcut = Path.Combine(root, "second.lnk");
File.WriteAllText(secondShortcut, "shortcut");
try
{
ShellIntegration.RemoveShortcuts([firstShortcut, secondShortcut]);
throw new Exception("A shortcut deletion failure was not reported.");
}
catch (AggregateException) { }
if (File.Exists(secondShortcut))
throw new Exception("One failed shortcut deletion prevented the next file deletion.");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// The configuration location is a property of the installation, not of this process: an install
/// under a portable root uses that root's UserData, everything else the shared per-user file.
/// </summary>
private static void TestPortableConfigPathResolution()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-portable-config-" + Guid.NewGuid().ToString("N"));
try
{
var install = Path.Combine(root, "Install");
Directory.CreateDirectory(install);
if (!RuntimeConfiguration.ResolveConfigPath(install)
.Equals(RuntimeConfiguration.ApplicationDataConfigPath, StringComparison.OrdinalIgnoreCase))
throw new Exception("A non-portable installation did not use per-user application data.");
var created = PortableRoot.Create(root);
if (!Directory.Exists(PortableRoot.UserDataDirectory(created)) ||
!File.Exists(Path.Combine(created, PortableRoot.MarkerFileName)))
throw new Exception("Creating a portable root did not produce its marker and user data.");
var expected = Path.Combine(root, PortableRoot.UserDataDirectoryName, "Config.toml");
if (!RuntimeConfiguration.ResolveConfigPath(install)
.Equals(expected, StringComparison.OrdinalIgnoreCase))
throw new Exception("A portable installation did not use its own UserData configuration.");
if (!new Installation(install).ConfigPath.Equals(expected, StringComparison.OrdinalIgnoreCase))
throw new Exception("The installation did not report its portable configuration path.");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// A portable installation stores every path inside its root relative to the configuration
/// directory, so the whole tree survives being moved. Ownership decisions must therefore compare
/// resolved paths, not stored text.
/// </summary>
private static void TestPortableRelativePathSettings()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-portable-paths-" + Guid.NewGuid().ToString("N"));
try
{
PortableRoot.Create(root);
var install = Path.Combine(root, "Install");
var config = Path.Combine(root, PortableRoot.UserDataDirectoryName, "Config.toml");
var dvdRoot = Path.Combine(install, "GameAssets", "DATA");
var outsideRetro = Path.Combine(Path.GetTempPath(), "mkwc-outside-" + Guid.NewGuid().ToString("N"));
var insideRetro = Path.Combine(root, "RetroRewind6");
RuntimeConfiguration.SetDvdRoot(config, dvdRoot);
var stored = RuntimeConfiguration.GetPath(config, "dvd_root");
if (stored is null || Path.IsPathRooted(stored))
throw new Exception("A path inside the portable root was stored absolute.");
if (stored.Contains('\\', StringComparison.Ordinal))
throw new Exception("A relative path was not stored with the canonical forward slashes.");
if (!RuntimeConfiguration.GetResolvedPath(config, "dvd_root")!
.Equals(Path.GetFullPath(dvdRoot), StringComparison.OrdinalIgnoreCase))
throw new Exception("A relative dvd_root did not resolve against the configuration directory.");
RuntimeConfiguration.SetRetroRewindRoot(config, insideRetro);
if (Path.IsPathRooted(RuntimeConfiguration.GetPath(config, "retro_rewind_root")!))
throw new Exception("A Retro Rewind root inside the portable root was stored absolute.");
RuntimeConfiguration.SetRetroRewindRoot(config, outsideRetro);
if (!Path.IsPathRooted(RuntimeConfiguration.GetPath(config, "retro_rewind_root")!))
throw new Exception("A Retro Rewind root outside the portable root was stored relative.");
RuntimeConfiguration.RemoveDvdRootIfOwned(config, Path.Combine(install, "Elsewhere"));
if (RuntimeConfiguration.GetPath(config, "dvd_root") is null)
throw new Exception("A relative dvd_root owned by someone else was removed.");
RuntimeConfiguration.RemoveDvdRootIfOwned(config, dvdRoot);
if (RuntimeConfiguration.GetPath(config, "dvd_root") is not null)
throw new Exception("A relative dvd_root this installation owns was not removed.");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// A portable root can be moved. Healing adopts the directory the installation was actually found
/// in and discards the location-bound native build cache - and must never touch a non-portable
/// installation, where the same disagreement is a real problem to report.
/// </summary>
private static void TestPortableMoveHealing()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-move-" + Guid.NewGuid().ToString("N"));
try
{
var install = Path.Combine(root, "Install");
var nativeBuild = Path.Combine(install, "BuildWorkspace", "native-build");
Directory.CreateDirectory(nativeBuild);
File.WriteAllText(Path.Combine(nativeBuild, "CMakeCache.txt"),
"CMAKE_HOME_DIRECTORY:INTERNAL=D:/Old/Install/BuildWorkspace/runtime");
var statePath = Path.Combine(install, "install-state.json");
JsonState.Write(statePath, new InstallState { InstallDir = "D:\\Old\\Install" });
var plain = new Installation(install);
if (PortableInstallHealing.HealMovedInstall(plain))
throw new Exception("A non-portable installation was healed.");
if (!Directory.Exists(nativeBuild))
throw new Exception("A non-portable installation lost its native build directory.");
PortableRoot.Create(root);
var portable = new Installation(install);
if (!PortableInstallHealing.HealMovedInstall(portable))
throw new Exception("A moved portable installation was not healed.");
var healed = portable.ReadInstallState();
if (healed is null || !healed.InstallDir.Equals(portable.Root, StringComparison.OrdinalIgnoreCase))
throw new Exception("Healing did not adopt the actual installation directory.");
if (Directory.Exists(nativeBuild))
throw new Exception("Healing kept the location-bound native build cache.");
if (PortableInstallHealing.HealMovedInstall(portable))
throw new Exception("An installation already at its recorded location was healed again.");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static void TestCanonicalRetroRewindResolution()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-retro-" + Guid.NewGuid().ToString("N"));
try
{
var package = Path.Combine(root, "wrapper", "RetroRewind6");
Directory.CreateDirectory(Path.Combine(package, "Binaries"));
File.WriteAllText(Path.Combine(package, "Binaries", "Code.pul"), "pretend-kamek");
File.WriteAllText(Path.Combine(package, "version.txt"), "1.2.3");
var expected = InputValidation.Sha256File(Path.Combine(package, "Binaries", "Code.pul"));
// The canonical folder may be named directly, or reached through the one permitted
// wrapper level; all three selections resolve to exactly the same installation.
foreach (var selection in new[] { package, Path.Combine(root, "wrapper"), root })
{
var inputs = CompileInputsFingerprint.Compute(selection);
if (!inputs.RetroRewindRoot.Equals(Path.GetFullPath(package),
StringComparison.OrdinalIgnoreCase))
throw new Exception($"Selecting {selection} resolved to {inputs.RetroRewindRoot}.");
if (inputs.CodePulSha256 != expected)
throw new Exception($"The Code.pul hash was wrong when selecting {selection}.");
}
try
{
_ = RetroRewindSource.ResolveRetroRewind6(Path.Combine(package, "Binaries"));
throw new Exception("A folder without Code.pul was accepted.");
}
catch (InvalidDataException) { }
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static void TestRetroWfcPayloadSnapshotReconciliation()
{
// The endpoint is pinned in exactly one place; this test must not restate it.
const string endpoint = InputValidation.CurrentRetroWfcPayloadUri;
const string shaB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
var root = Path.Combine(Path.GetTempPath(), "mkwc-wfc-snapshot-" + Guid.NewGuid().ToString("N"));
try
{
InputValidation.ValidateRetroWfcPayloadUri(endpoint);
try
{
InputValidation.ValidateRetroWfcPayloadUri(endpoint + "&extra=1");
throw new Exception("A non-canonical Retro-WFC endpoint was accepted.");
}
catch (InvalidDataException) { }
var installation = new Installation(root);
Directory.CreateDirectory(installation.RetroDirectory);
var payloadRoot = Path.Combine(root, "download");
var payloadPath = Path.Combine(payloadRoot, "binary", "payload.RMCPD00.bin");
Directory.CreateDirectory(Path.GetDirectoryName(payloadPath)!);
using var signingRsa = RSA.Create(2048);
var signingKey = signingRsa.ExportParameters(includePrivateParameters: false);
var bytes = new byte[0x140];
"WWFC/Payload"u8.CopyTo(bytes);
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(0x0C), (uint)bytes.Length);
bytes[^1] = 0x5A;
signingRsa.SignData(bytes.AsSpan(0x110), HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1).CopyTo(bytes.AsSpan(0x10));
File.WriteAllBytes(payloadPath, bytes);
_ = InputValidation.ValidateStagedRetroWfcPayloadDirectory(payloadRoot, signingKey);
try
{
_ = InputValidation.ValidateStagedRetroWfcPayloadDirectory(payloadRoot);
throw new Exception("A payload signed by a foreign key passed the pinned production key.");
}
catch (InvalidDataException) { }
var shaA = InputValidation.ComputeRetroWfcPayloadSha256(payloadRoot, signingKey);
var snapshot = new RetroWfcPayloadSnapshot(payloadRoot, shaA, bytes.Length);
JsonState.Write(Path.Combine(installation.RetroDirectory, ProductFingerprint.FileName),
new ProductFingerprint
{
Profile = "retro-rewind", ToolkitFingerprint = "toolkit",
RetroWfcPayloadMode = "downloaded", RetroWfcPayloadSha256 = shaA,
RetroWfcPayloadLength = bytes.Length
});
JsonState.Write(installation.InstallStatePath, new InstallState
{
InstallDir = root, RetroWfcPayloadMode = "downloaded",
RetroWfcPayloadSha256 = shaA, RetroWfcPayloadLength = bytes.Length
});
if (!installation.MatchesRetroWfcPayloadSnapshot("toolkit", snapshot))
throw new Exception("The same downloaded payload snapshot did not produce a no-op.");
if (ProductRepairService.PlanRetroWfcPayloadReconciliation(productMatchesSnapshot: true,
cacheMatchesSnapshot: true) != ProductRepairService.RetroWfcPayloadAction.None)
throw new Exception("The same downloaded snapshot did not remain a no-op.");
if (ProductRepairService.PlanRetroWfcPayloadReconciliation(productMatchesSnapshot: true,
cacheMatchesSnapshot: false) !=
ProductRepairService.RetroWfcPayloadAction.PublishCacheOnly)
throw new Exception("A damaged local payload cache requested an unnecessary recompile.");
var changedSnapshot = snapshot with { Sha256 = shaB };
if (installation.MatchesRetroWfcPayloadSnapshot("toolkit", changedSnapshot))
throw new Exception("A changed downloaded payload snapshot did not require recompilation.");
if (ProductRepairService.PlanRetroWfcPayloadReconciliation(productMatchesSnapshot: false,
cacheMatchesSnapshot: false) != ProductRepairService.RetroWfcPayloadAction.RebuildRetro)
throw new Exception("A changed payload did not rebuild Retro Rewind.");
bytes[0x0F]++;
File.WriteAllBytes(payloadPath, bytes);
try
{
_ = InputValidation.ValidateStagedRetroWfcPayloadDirectory(payloadRoot, signingKey);
throw new Exception("A payload with a malformed declared length was accepted.");
}
catch (InvalidDataException) { }
bytes[0x0F]--;
bytes[^1] ^= 1;
File.WriteAllBytes(payloadPath, bytes);
try
{
_ = InputValidation.ValidateStagedRetroWfcPayloadDirectory(payloadRoot, signingKey);
throw new Exception("A payload with tampered signed content was accepted.");
}
catch (InvalidDataException) { }
bytes[^1] ^= 1;
bytes[0] = 0;
File.WriteAllBytes(payloadPath, bytes);
try
{
_ = InputValidation.ValidateStagedRetroWfcPayloadDirectory(payloadRoot, signingKey);
throw new Exception("A payload with a malformed header was accepted.");
}
catch (InvalidDataException) { }
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static void TestInstallMoveTransientLockPolicy()
{
if (!InstallTransaction.IsTransientLock(new IOException("in use", unchecked((int)0x80070020))))
throw new Exception("A sharing violation would not be retried.");
if (!InstallTransaction.IsTransientLock(new IOException("locked", unchecked((int)0x80070021))))
throw new Exception("A lock violation would not be retried.");
if (!InstallTransaction.IsTransientLock(new UnauthorizedAccessException("denied")))
throw new Exception("A transient access-denied would not be retried.");
if (InstallTransaction.IsTransientLock(new IOException("disk full", unchecked((int)0x80070070))))
throw new Exception("A permanent I/O failure would be retried.");
if (InstallTransaction.IsTransientLock(new InvalidDataException("bad")))
throw new Exception("A non-I/O failure would be retried.");
}
private static void TestRetroWfcTransientRetryPolicy()
{
if (!InputValidation.IsTransientRetroWfcDownloadFailure(
new HttpRequestException("Connection reset"), CancellationToken.None))
throw new Exception("A transport failure would not receive its one retry.");
if (!InputValidation.IsTransientRetroWfcDownloadFailure(
new HttpRequestException("Service unavailable", null,
System.Net.HttpStatusCode.ServiceUnavailable), CancellationToken.None))
throw new Exception("An HTTP 5xx response would not receive its one retry.");
if (!InputValidation.IsTransientRetroWfcDownloadFailure(
new TimeoutException("Timed out"), CancellationToken.None))
throw new Exception("A download timeout would not receive its one retry.");
if (InputValidation.IsTransientRetroWfcDownloadFailure(
new HttpRequestException("Not found", null, System.Net.HttpStatusCode.NotFound),
CancellationToken.None))
throw new Exception("A permanent HTTP 4xx response would be retried.");
if (InputValidation.IsTransientRetroWfcDownloadFailure(
new InvalidDataException("Malformed payload"), CancellationToken.None))
throw new Exception("A malformed payload would be retried.");
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
if (InputValidation.IsTransientRetroWfcDownloadFailure(
new TimeoutException("Cancelled"), cancelled.Token))
throw new Exception("Caller cancellation would be retried.");
}
private static void TestInstallTransactionRollback()
{
var parent = Path.Combine(Path.GetTempPath(),
"mkwc-transaction-self-test-" + Guid.NewGuid().ToString("N"));
var installRoot = Path.Combine(parent, "install");
var liveComponent = Path.Combine(installRoot, "Component");
var preparedComponent = Path.Combine(parent, "prepared-component");
var preparedFile = Path.Combine(parent, "prepared-added.json");
var addedFile = Path.Combine(installRoot, "added.json");
try
{
Directory.CreateDirectory(Path.Combine(liveComponent, "nested"));
File.WriteAllText(Path.Combine(liveComponent, "nested", "original.txt"), "original-bytes");
File.WriteAllText(Path.Combine(installRoot, "untouched.txt"), "untouched-bytes");
Directory.CreateDirectory(preparedComponent);
File.WriteAllText(Path.Combine(preparedComponent, "replacement.txt"), "replacement-bytes");
File.WriteAllText(preparedFile, "new-live-file");
using (var transaction = InstallTransaction.Begin(installRoot, null,
InstallTransactionEntry.Directory(preparedComponent, liveComponent),
InstallTransactionEntry.File(preparedFile, addedFile)))
{
transaction.Publish();
if (!File.Exists(Path.Combine(liveComponent, "replacement.txt")) ||
File.Exists(Path.Combine(liveComponent, "nested", "original.txt")) ||
File.ReadAllText(addedFile) != "new-live-file")
throw new Exception("The transaction did not publish its prepared replacements.");
// Deliberately omit Commit. Dispose must restore the exact pre-transaction tree.
}
var original = Path.Combine(liveComponent, "nested", "original.txt");
if (!File.Exists(original) || File.ReadAllText(original) != "original-bytes" ||
File.Exists(Path.Combine(liveComponent, "replacement.txt")) || File.Exists(addedFile) ||
Directory.Exists(preparedComponent) || File.Exists(preparedFile) ||
File.ReadAllText(Path.Combine(installRoot, "untouched.txt")) != "untouched-bytes")
throw new Exception("Disposing an uncommitted transaction did not restore the exact original tree.");
if (File.Exists(InstallOperationPaths.GetJournalPath(installRoot)))
throw new Exception("A rolled-back transaction left its journal behind.");
}
finally
{
if (Directory.Exists(parent)) Directory.Delete(parent, recursive: true);
}
}
private static void TestInstallScratchRecovery()
{
var parent = Path.Combine(Path.GetTempPath(),
"mkwc-scratch-self-test-" + Guid.NewGuid().ToString("N"));
var installRoot = Path.Combine(parent, "install");
var otherInstallRoot = Path.Combine(parent, "other-install");
InstallScratchSpace? scratch = null;
InstallScratchSpace? otherScratch = null;
try
{
Directory.CreateDirectory(installRoot);
Directory.CreateDirectory(otherInstallRoot);
scratch = InstallScratchSpace.CreateSibling(installRoot);
File.WriteAllText(Path.Combine(scratch.Root, "owned.txt"), "owned");
// Scratch is keyed by the installation's scope id, so a neighbouring installation's
// scratch lives beside it under a different prefix and must survive this recovery.
otherScratch = InstallScratchSpace.CreateSibling(otherInstallRoot);
var sentinel = Path.Combine(otherScratch.Root, "preserve.txt");
File.WriteAllText(sentinel, "preserve");
using (InstallOperationLock.Acquire(installRoot))
{
if (Directory.Exists(scratch.Root))
throw new Exception("Lock acquisition did not recover this installation's scratch space.");
if (!File.Exists(sentinel) || File.ReadAllText(sentinel) != "preserve")
throw new Exception("Scratch recovery removed another installation's scratch space.");
}
}
finally
{
scratch?.Dispose();
otherScratch?.Dispose();
if (Directory.Exists(parent)) Directory.Delete(parent, recursive: true);
}
}
private static void TestCompileInputsFingerprint()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-compile-inputs-" + Guid.NewGuid().ToString("N"));
try
{
var package = Path.Combine(root, "RetroRewind6");
Directory.CreateDirectory(Path.Combine(package, "Binaries"));
Directory.CreateDirectory(Path.Combine(package, "RaceAssets"));
File.WriteAllText(Path.Combine(package, "Binaries", "Code.pul"), "code-A");
File.WriteAllText(Path.Combine(package, "RaceAssets", "tracks.txt"), "assets-A");
var first = CompileInputsFingerprint.Compute(package);
var throughParent = CompileInputsFingerprint.Compute(root);
if (first.CodePulSha256 != throughParent.CodePulSha256 ||
first.CompileInputsSha256 != throughParent.CompileInputsSha256)
throw new Exception("The compile identity changed when selected through its parent folder.");
if (first.CompileInputsSha256 != CompileInputsFingerprint.ComputeCompileInputsSha256(package))
throw new Exception("The compile-input hash is not deterministic.");
// The runtime reads assets live from the canonical installation, so an asset-only Retro
// Rewind update must not disturb any identity the backend keeps.
File.WriteAllText(Path.Combine(package, "RaceAssets", "tracks.txt"), "assets-B");
Directory.CreateDirectory(Path.Combine(package, "RaceAssets", "empty-runtime-directory"));
var assetsChanged = CompileInputsFingerprint.Compute(package);
if (assetsChanged.CodePulSha256 != first.CodePulSha256 ||
assetsChanged.CompileInputsSha256 != first.CompileInputsSha256)
throw new Exception("An asset-only Retro Rewind update changed a compile identity.");
// The registered mod directories affect compilation even when empty.
var files = Path.Combine(package, "files");
Directory.CreateDirectory(files);
var filesTopology = CompileInputsFingerprint.Compute(package);
if (filesTopology.CompileInputsSha256 == assetsChanged.CompileInputsSha256)
throw new Exception("The top-level files directory topology was not part of compile identity.");
File.WriteAllText(Path.Combine(files, "runtime-asset.bin"), "asset");
if (CompileInputsFingerprint.Compute(package).CompileInputsSha256 != filesTopology.CompileInputsSha256)
throw new Exception("A normal files asset incorrectly triggered a recompile.");
// Only Code.pul and the recorded file layout should trigger recompilation.
File.WriteAllText(Path.Combine(package, "unrelated.cpp"), "// ignored");
var straySource = CompileInputsFingerprint.Compute(package);
if (straySource.CodePulSha256 != filesTopology.CodePulSha256 ||
straySource.CompileInputsSha256 != filesTopology.CompileInputsSha256)
throw new Exception("A stray source file changed a compile identity.");
var codePulPath = Path.Combine(package, "Binaries", "Code.pul");
var linkTarget = Path.Combine(package, "Binaries", "Code.pul.copy");
File.WriteAllText(linkTarget, "linked");
File.Delete(codePulPath);
if (TryCreateFileLink(codePulPath, linkTarget))
{
try
{
_ = CompileInputsFingerprint.Compute(package);
throw new Exception("A linked compile input was accepted into the compile identity.");
}
catch (InvalidDataException) { }
finally
{
File.Delete(codePulPath);
}
}
File.WriteAllText(codePulPath, "code-B");
var codeChanged = CompileInputsFingerprint.Compute(package);
if (codeChanged.CodePulSha256 == assetsChanged.CodePulSha256 ||
codeChanged.CompileInputsSha256 == assetsChanged.CompileInputsSha256)
throw new Exception("A Code.pul update did not change both compile identities.");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// A build snapshot must copy megabytes of compile inputs, not the multi-gigabyte asset tree,
/// and must be provably the same inputs the canonical installation had when it was read.
/// </summary>
private static void TestCompileInputsSnapshot()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-snapshot-" + Guid.NewGuid().ToString("N"));
try
{
var package = Path.Combine(root, "RetroRewind6");
Directory.CreateDirectory(Path.Combine(package, "Binaries"));
Directory.CreateDirectory(Path.Combine(package, "files", "Race", "Course"));
Directory.CreateDirectory(Path.Combine(package, "PulsarEngine", "MarioKartWii"));
File.WriteAllText(Path.Combine(package, "Binaries", "Code.pul"), "code-A");
File.WriteAllText(Path.Combine(package, "files", "Race", "Course", "beginner_course.szs"),
"a pretend multi-gigabyte asset");
File.WriteAllText(Path.Combine(package, "PulsarEngine", "MarioKartWii", "patch.cpp"), "int patch;");
var source = CompileInputsFingerprint.Compute(package);
var snapshot = CompileInputsFingerprint.Snapshot(package, Path.Combine(root, "scratch"));
if (snapshot.CodePulSha256 != source.CodePulSha256 ||
snapshot.CompileInputsSha256 != source.CompileInputsSha256)
throw new Exception("The compile-input snapshot is not identical to its source.");
// The mod root name becomes the product's relative DVD overlay root, so it is contractual.
if (Path.GetFileName(snapshot.RetroRewindRoot) != "RetroRewind6")
throw new Exception("The compile-input snapshot is not named RetroRewind6.");
if (!File.Exists(Path.Combine(snapshot.RetroRewindRoot, "Binaries", "Code.pul")))
throw new Exception("The compile-input snapshot is missing the Code.pul.");
if (File.Exists(Path.Combine(snapshot.RetroRewindRoot, "PulsarEngine", "MarioKartWii", "patch.cpp")))
throw new Exception("The compile-input snapshot copied Pulsar sources no build consumes.");
if (File.Exists(Path.Combine(snapshot.RetroRewindRoot, "files", "Race", "Course",
"beginner_course.szs")))
throw new Exception("The compile-input snapshot copied the runtime asset tree.");
if (!Directory.Exists(Path.Combine(snapshot.RetroRewindRoot, "files")))
throw new Exception("The compile-input snapshot lost the compile-relevant files topology.");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static void TestCompileInputsFingerprintGoldenVector()
{
const string expected = GoldenCompileInputsDigest;
var root = Path.Combine(Path.GetTempPath(), "mkwc-vector-" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(Path.Combine(root, "Binaries"));
Directory.CreateDirectory(Path.Combine(root, "tracks"));
Directory.CreateDirectory(Path.Combine(root, "empty", "nested"));
File.WriteAllBytes(Path.Combine(root, "Binaries", "Code.pul"), [0x00, 0x01, 0x02, 0xff]);
File.WriteAllBytes(Path.Combine(root, "readme.txt"),
System.Text.Encoding.UTF8.GetBytes("retro-rewind\n"));
File.WriteAllBytes(Path.Combine(root, "tracks", "course.bin"), []);
var actual = CompileInputsFingerprint.ComputeCompileInputsSha256(root);
if (!actual.Equals(expected, StringComparison.Ordinal))
throw new Exception($"Compile-inputs golden vector changed: {actual} != {expected}.");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// The compile-input digest decides whether an installed product must be recompiled, so it is
/// pinned here: changing the hashed set or its encoding must be a deliberate edit of this value.
/// </summary>
private const string GoldenCompileInputsDigest =
"19b2ead4f83f030aac110b82ac801605fa81b4ed86f7be6a1b2bb9dd20147184";
private static void TestToolkitFingerprint()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-toolkit-" + Guid.NewGuid().ToString("N"));
try
{
CreateFakeToolkit(root);
var first = ToolkitFingerprint.Compute(root);
if (first != ToolkitFingerprint.Compute(root))
throw new Exception("The toolkit fingerprint is not stable.");
// A file that has nothing to do with generated code must not invalidate every install.
File.WriteAllText(Path.Combine(root, "Toolkit", "nodtool.exe"), "irrelevant");
if (ToolkitFingerprint.Compute(root) != first)
throw new Exception("An unrelated toolkit file changed the fingerprint.");
File.WriteAllText(Path.Combine(root, "Toolkit", "Translator", "Translator.Cli.exe"), "translator-v2");
var afterTranslator = ToolkitFingerprint.Compute(root);
if (afterTranslator == first)
throw new Exception("A new translator did not change the fingerprint.");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "src", "main.cpp"), "int main2();");
var afterRuntimeSource = ToolkitFingerprint.Compute(root);
if (afterRuntimeSource == afterTranslator)
throw new Exception("A runtime source change did not change the fingerprint.");
var patch = Path.Combine(root, "BuildWorkspace", "aurora-main", "cmake", "patches",
"sdl3.patch");
Directory.CreateDirectory(Path.GetDirectoryName(patch)!);
File.WriteAllText(patch, "patch-v1");
var afterPatch = ToolkitFingerprint.Compute(root);
if (afterPatch == afterRuntimeSource)
throw new Exception("An Aurora patch consumed by the provider did not change compile identity.");
var installation = new Installation(root);
installation.WriteToolkitState(afterPatch, ToolkitFingerprint.ComputePackage(root),
"wiicompiled-v1", ToolkitFingerprint.ComputeRuntimeAssets(root));
var repair = new ProductRepairService(installation, new DelegatingInstallReporter(_ => { }));
repair.VerifyToolkitForCompilation(afterPatch);
void ExpectModifiedToolkitRejected(string description)
{
try
{
repair.VerifyToolkitForCompilation(afterPatch);
throw new Exception(description);
}
catch (InvalidDataException) { }
}
File.WriteAllText(patch, "patch-v2");
ExpectModifiedToolkitRejected("A modified mandatory Aurora patch was accepted for compilation.");
File.Delete(patch);
ExpectModifiedToolkitRejected("A deleted mandatory Aurora patch was accepted for compilation.");
File.WriteAllText(patch, "patch-v1");
repair.VerifyToolkitForCompilation(afterPatch);
var runtimeAssets = ToolkitFingerprint.ComputeRuntimeAssets(root);
var pipeline = Path.Combine(root, "BuildWorkspace", "runtime", "assets", "pipeline",
"initial_pipeline_cache.db");
File.WriteAllText(pipeline, "pipeline-v2");
if (ToolkitFingerprint.Compute(root) != afterPatch)
throw new Exception("A copied-only runtime asset needlessly changed compile identity.");
var changedRuntimeAssets = ToolkitFingerprint.ComputeRuntimeAssets(root);
if (changedRuntimeAssets == runtimeAssets)
throw new Exception("A copied runtime asset did not change its publication identity.");
var sourceEmptyDirectory = Path.Combine(root, "BuildWorkspace", "runtime", "assets", "wii",
"empty-runtime-directory");
Directory.CreateDirectory(sourceEmptyDirectory);
var topologyChangedRuntimeAssets = ToolkitFingerprint.ComputeRuntimeAssets(root);
if (topologyChangedRuntimeAssets == changedRuntimeAssets)
throw new Exception("An empty copied runtime-asset directory was omitted from its identity.");
// Cache-reuse subsets: translator or runtime/src changes (the translator regex-scans it
// for native overrides and effect contracts) invalidate translation but not toolchain,
// since CMake/Ninja rebuild exactly the affected objects; a compiler change invalidates
// only toolchain; a native_prebuilt provenance rewrite invalidates neither.
var components = ToolkitFingerprint.ComputeComponents(root);
if (components.Compile != afterPatch)
throw new Exception("ComputeComponents disagrees with the compile identity.");
File.WriteAllText(Path.Combine(root, "Toolkit", "Translator", "Translator.Cli.exe"),
"translator-v3");
var afterNewTranslator = ToolkitFingerprint.ComputeComponents(root);
if (afterNewTranslator.Translation == components.Translation)
throw new Exception("A new translator did not change the translation identity.");
if (afterNewTranslator.NativeToolchain != components.NativeToolchain)
throw new Exception("A new translator needlessly changed the native toolchain identity.");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "src", "main.cpp"),
"int main3();");
var afterNewRuntime = ToolkitFingerprint.ComputeComponents(root);
if (afterNewRuntime.Translation == afterNewTranslator.Translation)
throw new Exception("A runtime source change did not change the translation identity.");
if (afterNewRuntime.NativeToolchain != afterNewTranslator.NativeToolchain)
throw new Exception("A runtime source change needlessly changed the native toolchain identity.");
if (afterNewRuntime.Compile == afterNewTranslator.Compile)
throw new Exception("A runtime source change did not change the compile identity.");
File.WriteAllText(Path.Combine(root, "Toolkit", "llvm-mingw", "bin", "clang-22.exe"),
"clang-v2");
var afterNewCompiler = ToolkitFingerprint.ComputeComponents(root);
if (afterNewCompiler.NativeToolchain == afterNewRuntime.NativeToolchain)
throw new Exception("A new compiler did not change the native toolchain identity.");
if (afterNewCompiler.Translation != afterNewRuntime.Translation)
throw new Exception("A new compiler needlessly changed the translation identity.");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "Dependencies", "native_prebuilt",
"provenance.json"), "{\"BuiltUtc\":\"rewritten\"}");
if (ToolkitFingerprint.Compute(root) != afterNewCompiler.Compile)
throw new Exception(
"A native_prebuilt provenance rewrite needlessly changed the compile identity.");
var runtimeLink = Path.Combine(root, "BuildWorkspace", "runtime", "assets", "wii", "linked-assets");
if (TryCreateDirectoryLink(runtimeLink,
Path.Combine(root, "BuildWorkspace", "runtime", "assets", "wii", "shared2")))
{
try
{
try
{
_ = ToolkitFingerprint.ComputeRuntimeAssets(root);
throw new Exception("A runtime-asset reparse point was accepted into its identity.");
}
catch (InvalidDataException) { }
}
finally
{
Directory.Delete(runtimeLink);
}
}
var product = Path.Combine(root, "product");
Directory.CreateDirectory(product);
FileSystemUtilities.CopyDirectory(
Path.Combine(root, "BuildWorkspace", "runtime", "assets", "wii"),
Path.Combine(product, "wii_bootstrap"));
File.Copy(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "dsp", "dsp_coef.bin"),
Path.Combine(product, "dsp_coef.bin"));
File.Copy(pipeline, Path.Combine(product, "initial_pipeline_cache.db"));
if (!ToolkitFingerprint.ProductRuntimeAssetsMatch(product, topologyChangedRuntimeAssets))
throw new Exception("Copied product assets did not match their normalized source identity.");
Directory.Delete(Path.Combine(product, "wii_bootstrap", "empty-runtime-directory"));
if (ToolkitFingerprint.ProductRuntimeAssetsMatch(product, topologyChangedRuntimeAssets))
throw new Exception("A missing copied runtime-asset directory was not detected.");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static void CreateFakeToolkit(string root)
{
Directory.CreateDirectory(Path.Combine(root, "Toolkit", "Translator"));
Directory.CreateDirectory(Path.Combine(root, "Toolkit", "CMake", "bin"));
Directory.CreateDirectory(Path.Combine(root, "Toolkit", "Ninja"));
Directory.CreateDirectory(Path.Combine(root, "Toolkit", "llvm-mingw", "bin"));
Directory.CreateDirectory(Path.Combine(root, "BuildWorkspace", "projects", "mkwii"));
Directory.CreateDirectory(Path.Combine(root, "BuildWorkspace", "runtime", "src"));
Directory.CreateDirectory(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "wii", "shared2",
"wc24"));
Directory.CreateDirectory(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "dsp"));
Directory.CreateDirectory(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "pipeline"));
var aurora = Path.Combine(root, "BuildWorkspace", "aurora-main");
Directory.CreateDirectory(aurora);
File.WriteAllText(Path.Combine(aurora, "README.md"), "aurora source");
foreach (var dependency in InstalledLayout.DependencyNames)
{
var directory = Path.Combine(root, "BuildWorkspace", "Dependencies", dependency);
Directory.CreateDirectory(directory);
File.WriteAllText(Path.Combine(directory, ".mkwc-layout"), dependency);
}
File.WriteAllText(Path.Combine(root, "Toolkit", "Translator", "Translator.Cli.exe"), "translator-v1");
File.WriteAllText(Path.Combine(root, "Toolkit", "CMake", "bin", "cmake.exe"), "cmake");
File.WriteAllText(Path.Combine(root, "Toolkit", "Ninja", "ninja.exe"), "ninja");
foreach (var executable in new[]
{
"clang-22.exe", "ld.lld.exe", "x86_64-w64-mingw32-clang.exe",
"x86_64-w64-mingw32-clang++.exe", "x86_64-w64-mingw32-windres.exe"
})
File.WriteAllText(Path.Combine(root, "Toolkit", "llvm-mingw", "bin", executable), executable);
File.WriteAllText(Path.Combine(root, "Toolkit", "nodtool.exe"), "tool");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "LocalBuild.ps1"), "# build");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "NativeBuildFlags.ps1"), "# flags");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "projects", "mkwii", "recomp.yml"), "profiles: {}");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "CMakeLists.txt"), "cmake_minimum_required(VERSION 3.20)");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "src", "main.cpp"), "int main();");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "wii", "shared2", "wc24",
"bootstrap.bin"), "bootstrap");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "dsp", "dsp_coef.bin"),
"dsp");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "pipeline",
"initial_pipeline_cache.db"), "pipeline");
}
private static void CopyFakeRuntimeAssets(string root, string productDirectory)
{
var source = Path.Combine(root, "BuildWorkspace", "runtime", "assets");
FileSystemUtilities.CopyDirectory(Path.Combine(source, "wii"),
Path.Combine(productDirectory, "wii_bootstrap"));
File.Copy(Path.Combine(source, "dsp", "dsp_coef.bin"),
Path.Combine(productDirectory, "dsp_coef.bin"), overwrite: true);
File.Copy(Path.Combine(source, "pipeline", "initial_pipeline_cache.db"),
Path.Combine(productDirectory, "initial_pipeline_cache.db"), overwrite: true);
}
private static bool TryCreateFileLink(string linkPath, string targetPath)
{
try
{
File.CreateSymbolicLink(linkPath, targetPath);
return true;
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or NotSupportedException)
{
return false;
}
}
private static bool TryCreateDirectoryLink(string linkPath, string targetPath)
{
try
{
Directory.CreateSymbolicLink(linkPath, targetPath);
return true;
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or NotSupportedException)
{
// Developer Mode / symlink permission is not guaranteed on a user's Windows system.
// The identity check is still covered whenever the platform can create a link.
return false;
}
}
/// <summary>
/// The full inspection state machine against the canonical Retro Rewind installation. The
/// installation itself is never copied here, exactly as in production: only its compile inputs
/// are ever read, and asset-only changes to it must leave the product current.
/// </summary>
private static void TestCodePulStateMachine()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-install-" + Guid.NewGuid().ToString("N"));
try
{
CreateFakeToolkit(root);
var installation = new Installation(root);
var fingerprint = ToolkitFingerprint.Compute(root);
installation.WriteToolkitState(fingerprint, ToolkitFingerprint.ComputePackage(root),
"wiicompiled-v1", ToolkitFingerprint.ComputeRuntimeAssets(root));
// Wheel Wizard's own Retro Rewind installation, deliberately outside the install root.
var canonical = Path.Combine(root, "canonical", "RetroRewind6");
Directory.CreateDirectory(Path.Combine(canonical, "Binaries"));
Directory.CreateDirectory(Path.Combine(canonical, "PulsarEngine"));
File.WriteAllText(Path.Combine(canonical, "Binaries", "Code.pul"), "code-pul-A");
File.WriteAllText(Path.Combine(canonical, "PulsarEngine", "patch.cpp"), "int patch;");
File.WriteAllText(Path.Combine(canonical, "asset.txt"), "asset-A");
Directory.CreateDirectory(installation.BaseDirectory);
Directory.CreateDirectory(installation.RetroDirectory);
File.WriteAllBytes(installation.BaseExecutable, new byte[64 * 1024]);
File.WriteAllBytes(installation.RetroExecutable, new byte[64 * 1024]);
CopyFakeRuntimeAssets(root, installation.BaseDirectory);
CopyFakeRuntimeAssets(root, installation.RetroDirectory);
var gameSys = Path.Combine(installation.GameDataDirectory, "sys");
var gameRel = Path.Combine(installation.GameDataDirectory, "files", "rel");
Directory.CreateDirectory(gameSys);
Directory.CreateDirectory(gameRel);
File.WriteAllText(Path.Combine(gameSys, "main.dol"), "dol");
File.WriteAllText(Path.Combine(gameSys, "fst.bin"), "fst");
File.WriteAllText(Path.Combine(gameRel, "StaticR.rel"), "rel");
var dolSha = InputValidation.Sha256File(Path.Combine(gameSys, "main.dol"));
var relSha = InputValidation.Sha256File(Path.Combine(gameRel, "StaticR.rel"));
var inputsA = CompileInputsFingerprint.Compute(canonical);
LocalBuildService.WriteFingerprint(installation.BaseDirectory, BuildProfile.Base, fingerprint,
dolSha, relSha, "", RetroWfcPayloadMode.NotApplicable);
JsonState.Write(Path.Combine(installation.BaseDirectory, LocalBuildProvenance.FileName),
new LocalBuildProvenance { SchemaVersion = 1, Profile = "base", DolSha256 = dolSha,
RelSha256 = relSha });
JsonState.Write(Path.Combine(installation.RetroDirectory, ProductFingerprint.FileName),
new ProductFingerprint
{
Profile = "retro-rewind", ToolkitFingerprint = fingerprint, DolSha256 = dolSha, RelSha256 = relSha,
ExecutableSha256 = InputValidation.Sha256File(installation.RetroExecutable),
CodePulSha256 = inputsA.CodePulSha256,
RetroRewindCompileInputsSha256 = inputsA.CompileInputsSha256,
RetroWfcPayloadMode = "skipped"
});
JsonState.Write(Path.Combine(installation.RetroDirectory, LocalBuildProvenance.FileName),
new LocalBuildProvenance { SchemaVersion = 1, Profile = "retro-rewind", DolSha256 = dolSha,
RelSha256 = relSha, CodePulSha256 = inputsA.CodePulSha256,
RetroWfcPayloadMode = "skipped" });
JsonState.Write(installation.InstallStatePath, new InstallState
{
InstallDir = root, RetroRewindInstalled = true,
DolSha256 = dolSha, RelSha256 = relSha,
RetroRewindCodePulSha256 = inputsA.CodePulSha256,
RetroRewindCompileInputsSha256 = inputsA.CompileInputsSha256,
RetroWfcPayloadMode = "skipped",
RetroRewindRoot = canonical
});
ProductState Inspect() =>
installation.CheckRetroRewind(installation.ResolveToolkitFingerprint(),
CompileInputsFingerprint.Compute(canonical));
void Expect(ProductStatus expected, string description)
{
var inspected = Inspect();
if (inspected.Status != expected)
throw new Exception(
$"{description}: expected {expected}, got {inspected.Status}: {inspected.Detail}");
}
Expect(ProductStatus.Current, "A freshly built Retro Rewind");
// The whole point of the canonical-source model: assets change constantly and the
// runtime reads them live, so they must never make the backend rebuild anything.
File.WriteAllText(Path.Combine(canonical, "asset.txt"), "asset-B");
Directory.CreateDirectory(Path.Combine(canonical, "new-course-directory"));
Expect(ProductStatus.Current, "An asset-only Retro Rewind update");
var unreadableCanonical = installation.CheckRetroRewind(fingerprint, null,
"The canonical Retro Rewind folder cannot be read: missing.");
if (unreadableCanonical.Status != ProductStatus.InputsMissing ||
!unreadableCanonical.ActionRequired)
throw new Exception("A missing canonical Retro Rewind folder did not fail closed.");
var unchangedInputs = ProductRepairService.FindDesiredInputDrift(installation, fingerprint,
dolSha, relSha);
if (unchangedInputs.Base || unchangedInputs.Retro)
throw new Exception("Current authoritative game inputs requested an unnecessary rebuild.");
var changedInputs = ProductRepairService.FindDesiredInputDrift(installation, fingerprint,
new string('a', 64), relSha);
if (!changedInputs.Base || !changedInputs.Retro)
throw new Exception("Desired DOL drift did not force both installed products to rebuild.");
File.Delete(installation.ToolkitStatePath);
if (!string.IsNullOrEmpty(installation.ResolveToolkitFingerprint()) ||
Inspect().Status != ProductStatus.Blocked)
throw new Exception("A missing toolkit-state.json was inferred instead of failing closed.");
installation.WriteToolkitState(fingerprint, ToolkitFingerprint.ComputePackage(root),
"wiicompiled-v1", ToolkitFingerprint.ComputeRuntimeAssets(root));
Expect(ProductStatus.Current, "A restored toolkit-state.json");
var cmake = Path.Combine(root, "Toolkit", "CMake", "bin", "cmake.exe");
File.Delete(cmake);
if (!string.IsNullOrEmpty(installation.ResolveToolkitFingerprint()) ||
Inspect().Status != ProductStatus.Blocked)
throw new Exception("A missing bundled build tool did not block product inspection.");
File.WriteAllText(cmake, "cmake");
Expect(ProductStatus.Current, "A restored bundled build tool");
var auroraMarker = Path.Combine(root, "BuildWorkspace", "aurora-main", "README.md");
File.Delete(auroraMarker);
if (!string.IsNullOrEmpty(installation.ResolveToolkitFingerprint()) ||
Inspect().Status != ProductStatus.Blocked)
throw new Exception("A missing Aurora source tree did not block product inspection early.");
File.WriteAllText(auroraMarker, "aurora source");
var dependencyMarker = Path.Combine(root, "BuildWorkspace", "Dependencies", "fmt", ".mkwc-layout");
File.Delete(dependencyMarker);
if (!string.IsNullOrEmpty(installation.ResolveToolkitFingerprint()) ||
Inspect().Status != ProductStatus.Blocked)
throw new Exception("An empty required dependency tree did not block product inspection early.");
File.WriteAllText(dependencyMarker, "fmt");
Expect(ProductStatus.Current, "A restored required toolkit layout");
var baseDsp = Path.Combine(installation.BaseDirectory, "dsp_coef.bin");
File.Delete(baseDsp);
var missingBaseRuntimeAssets = installation.CheckBase(installation.ResolveToolkitFingerprint());
if (missingBaseRuntimeAssets.Status != ProductStatus.Broken ||
installation.CheckBaseCore(installation.ResolveToolkitFingerprint()).Status !=
ProductStatus.Current)
throw new Exception("Missing copied base runtime assets were not reported as broken.");
var retroPipeline = Path.Combine(installation.RetroDirectory, "initial_pipeline_cache.db");
File.Delete(retroPipeline);
if (Inspect().Status != ProductStatus.Broken)
throw new Exception("Missing copied Retro Rewind runtime assets were not reported as broken.");
// Copied support files are republished from the installed workspace: repairing them must
// never require the translator or the compiler.
using (InstallOperationLock.Acquire(root))
{
var repair = new ProductRepairService(installation, new DelegatingInstallReporter(_ => { }));
_ = repair.RepairBaseAsync(new ProductRepairService.ReconcileOptions())
.GetAwaiter().GetResult();
}
if (!File.Exists(baseDsp) ||
installation.CheckBase(installation.ResolveToolkitFingerprint()).Status != ProductStatus.Current)
throw new Exception("Runtime-only repair did not restore the base support assets and health.");
if (!File.Exists(retroPipeline))
throw new Exception("Runtime-only repair did not restore the Retro Rewind support assets.");
Expect(ProductStatus.Current, "Runtime-only Retro Rewind repair");
// Pulsar sources are no longer compile inputs; only the Code.pul built from them is.
File.WriteAllText(Path.Combine(canonical, "PulsarEngine", "patch.cpp"), "int patched;");
Expect(ProductStatus.Current, "A changed Pulsar source without a Code.pul change");
File.WriteAllText(Path.Combine(canonical, "PulsarEngine", "patch.cpp"), "int patch;");
File.WriteAllText(Path.Combine(canonical, "Binaries", "Code.pul"), "code-pul-B");
Expect(ProductStatus.CodePulChanged, "A changed canonical Code.pul");
File.WriteAllText(Path.Combine(canonical, "Binaries", "Code.pul"), "code-pul-A");
Expect(ProductStatus.Current, "A restored canonical Code.pul");
var alteredExecutable = File.ReadAllBytes(installation.RetroExecutable);
alteredExecutable[0] = 1;
File.WriteAllBytes(installation.RetroExecutable, alteredExecutable);
var tamperedExecutable = Inspect();
if (tamperedExecutable.Status != ProductStatus.Blocked || !tamperedExecutable.ActionRequired)
throw new Exception("A modified Retro Rewind executable did not fail closed.");
File.WriteAllBytes(installation.RetroExecutable, new byte[64 * 1024]);
Expect(ProductStatus.Current, "A restored Retro Rewind executable");
File.Delete(installation.RetroExecutable);
Expect(ProductStatus.Broken, "A missing Retro Rewind executable");
File.WriteAllBytes(installation.RetroExecutable, new byte[64 * 1024]);
File.WriteAllText(Path.Combine(root, "Toolkit", "Translator", "Translator.Cli.exe"), "translator-v2");
installation.WriteToolkitState(ToolkitFingerprint.Compute(root),
ToolkitFingerprint.ComputePackage(root), "wiicompiled-v2",
ToolkitFingerprint.ComputeRuntimeAssets(root));
Expect(ProductStatus.ToolkitChanged, "A replaced translator");
if (installation.CheckBase(installation.ResolveToolkitFingerprint()).Status != ProductStatus.ToolkitChanged)
throw new Exception("A replaced translator did not invalidate the base product.");
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// The frontend matches the products line's keys case-sensitively and branches on the typed
/// status alone, so the exact v1 shape is pinned here rather than left to a serializer default.
/// </summary>
private static void TestProductsReport()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-products-" + Guid.NewGuid().ToString("N"));
try
{
// A path nothing was installed to must answer "absent", not fail and not ask for a rebuild.
var missing = ConsoleCommands.InspectProducts(new Installation(root));
if (missing.RebuildRequired || missing.Base.Reason != "absent" ||
missing.RetroRewind.Reason != "absent")
throw new Exception("A missing installation directory was not reported as absent.");
if (Directory.Exists(root))
throw new Exception("Inspecting a missing installation created the directory.");
Directory.CreateDirectory(root);
var empty = ConsoleCommands.InspectProducts(new Installation(root));
if (empty.RebuildRequired || empty.Base.Reason != "absent" || empty.RetroRewind.Reason != "absent")
throw new Exception("An empty installation directory was not reported as absent.");
if (File.Exists(Path.Combine(root, ToolkitState.FileName)))
throw new Exception("Inspecting an empty installation wrote installation state.");
var json = ConsoleCommands.SerializeProductsReport(empty);
foreach (var key in new[]
{
"\"type\":\"products\"", "\"setupVersion\":", "\"installDir\":",
"\"rebuildRequired\":false", "\"base\":{\"status\":\"absent\",\"detail\":",
"\"retroRewind\":{\"status\":\"absent\",\"detail\":"
})
if (!json.Contains(key, StringComparison.Ordinal))
throw new Exception($"The products line is missing {key}: {json}");
// v1 reports a status and a detail per product and nothing else; a frontend must not be
// able to read a second, possibly disagreeing, opinion out of the same record.
foreach (var removed in new[] { "\"health\"", "\"action\"", "\"compileRequired\"",
"\"packageSyncRequired\"", "\"actionRequired\"" })
if (json.Contains(removed, StringComparison.Ordinal))
throw new Exception($"The products line still carries {removed}: {json}");
foreach (var wrongCase in new[] { "\"Base\"", "\"RetroRewind\"", "\"SetupVersion\"",
"\"InstallDir\"", "\"RebuildRequired\"", "\"Status\"", "\"Detail\"" })
if (json.Contains(wrongCase, StringComparison.Ordinal))
throw new Exception($"The products line used PascalCase for {wrongCase}: {json}");
foreach (var status in new[]
{
ProductStatus.Absent, ProductStatus.Current, ProductStatus.ToolkitChanged,
ProductStatus.CodePulChanged, ProductStatus.CompileInputsChanged,
ProductStatus.PayloadChanged, ProductStatus.InputsMissing,
ProductStatus.Blocked, ProductStatus.Broken
})
{
var reason = new ProductState(status, "").Reason;
if (!new[]
{
"absent", "current", "toolkit-changed", "code-pul-changed",
"compile-inputs-changed", "payload-changed", "inputs-missing", "blocked",
"broken"
}.Contains(reason, StringComparer.Ordinal))
throw new Exception($"{status} reports the undeclared status {reason}.");
}
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static void Test(string name, Action action, List<string> failures)
{
try { action(); }
catch (Exception ex) { failures.Add($"{name}: {ex.Message}"); }
}
}