mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-10-17 12:48:15 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dbee74218 |
@@ -0,0 +1,56 @@
|
||||
using System.Text;
|
||||
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>The disc identity read from an extracted game's <c>sys/boot.bin</c>.</summary>
|
||||
public sealed record ExtractedGameHeader(string GameId, int Revision, string InternalName);
|
||||
|
||||
/// <summary>
|
||||
/// A game Wheel Wizard already extracted, in the layout Dolphin's "Extract Entire Disc" writes:
|
||||
/// <c><root>/DATA/{sys,files}</c>. WiiCompiled reads it in place (<c>dvd_root</c>) instead of extracting a
|
||||
/// copy of its own, so it is only ever read, never written or deleted.
|
||||
/// </summary>
|
||||
public static class ExtractedGameDirectory
|
||||
{
|
||||
private const int BootSize = 0x440;
|
||||
private const uint WiiMagic = 0x5D1C9EA3;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>DATA</c> folder of an extracted game: <paramref name="path"/> may be the game root holding
|
||||
/// <c>DATA/</c>, or the <c>DATA</c> folder itself.
|
||||
/// </summary>
|
||||
public static string ResolveDataDirectory(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new ArgumentException("--game-dir needs the folder of the extracted game.");
|
||||
var full = FileSystemUtilities.NormalizePath(path);
|
||||
if (!Directory.Exists(full))
|
||||
throw new DirectoryNotFoundException($"The extracted game folder does not exist: {full}");
|
||||
|
||||
foreach (var candidate in new[] { Path.Combine(full, "DATA"), full })
|
||||
{
|
||||
if (File.Exists(Path.Combine(candidate, "sys", "main.dol")) && File.Exists(Path.Combine(candidate, "sys", "boot.bin")))
|
||||
return FileSystemUtilities.NormalizePath(candidate);
|
||||
}
|
||||
|
||||
throw new InvalidDataException(
|
||||
$"{full} is not an extracted Wii game: expected DATA/sys/main.dol and DATA/sys/boot.bin.");
|
||||
}
|
||||
|
||||
/// <summary>Reads the disc header from <c>sys/boot.bin</c>, which starts with the same bytes as the disc.</summary>
|
||||
public static ExtractedGameHeader ReadHeader(string dataDirectory)
|
||||
{
|
||||
var boot = File.ReadAllBytes(Path.Combine(dataDirectory, "sys", "boot.bin"));
|
||||
if (boot.Length < BootSize)
|
||||
throw new InvalidDataException("The extracted game's sys/boot.bin is incomplete.");
|
||||
var magic = (uint)(boot[0x18] << 24 | boot[0x19] << 16 | boot[0x1A] << 8 | boot[0x1B]);
|
||||
if (magic != WiiMagic)
|
||||
throw new InvalidDataException("The extracted game is not a Wii game.");
|
||||
|
||||
var gameId = Encoding.ASCII.GetString(boot, 0, 6).TrimEnd('\0', ' ');
|
||||
var nameBytes = boot.AsSpan(0x20, 0x40);
|
||||
var end = nameBytes.IndexOf((byte)0);
|
||||
var name = Encoding.ASCII.GetString(end < 0 ? nameBytes : nameBytes[..end]).Trim();
|
||||
return new ExtractedGameHeader(gameId, boot[7], name);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,54 @@ internal static class DiscTool
|
||||
reporter.Progress(InstallStages.ExtractDisc, "Disc validated and extracted", 6);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks a game Wheel Wizard already extracted against the pinned clean disc and copies only its two code
|
||||
/// files into <paramref name="assetsDirectory"/> for translation. The game itself stays where it is; the
|
||||
/// runtime reads it in place. Returns its <c>DATA</c> folder.
|
||||
/// </summary>
|
||||
public static string ValidateExtracted(string gameDirectory, ProjectManifest manifest, string assetsDirectory,
|
||||
IInstallReporter reporter)
|
||||
{
|
||||
reporter.Progress(InstallStages.ExtractDisc, "Checking the extracted game", 2);
|
||||
var dataDir = ExtractedGameDirectory.ResolveDataDirectory(gameDirectory);
|
||||
var header = ExtractedGameDirectory.ReadHeader(dataDir);
|
||||
if (!string.Equals(header.GameId, manifest.GameId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"This game is '{header.GameId}', not the expected '{manifest.GameId}' (Mario Kart Wii, region {manifest.Region}).");
|
||||
}
|
||||
|
||||
var dolPath = Path.Combine(dataDir, "sys", "main.dol");
|
||||
var relPath = Path.Combine(dataDir, "files", "rel", "StaticR.rel");
|
||||
if (!File.Exists(relPath)) throw new FileNotFoundException("The extracted game has no StaticR.rel", relPath);
|
||||
if (!string.Equals(Sha256Of(dolPath), manifest.DolSha256, StringComparison.Ordinal) ||
|
||||
!string.Equals(Sha256Of(relPath), manifest.RelSha256, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The extracted game's main.dol or StaticR.rel is not the clean game code this project is pinned to.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(assetsDirectory);
|
||||
File.Copy(dolPath, Path.Combine(assetsDirectory, "main.dol"), overwrite: true);
|
||||
File.Copy(relPath, Path.Combine(assetsDirectory, "StaticR.rel"), overwrite: true);
|
||||
reporter.Progress(InstallStages.ExtractDisc, "Extracted game validated", 6);
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
/// <summary>Removes the workspace's own extraction once the extracted game is read in place instead.</summary>
|
||||
public static void RemoveOwnExtraction(string assetsDirectory, IInstallReporter reporter)
|
||||
{
|
||||
var own = Path.Combine(assetsDirectory, "DATA");
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(own)) Directory.Delete(own, recursive: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
reporter.Diagnostic("The game copy WiiCompiled no longer uses could not be removed yet: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> RunInfoAsync(string nodTool, string isoPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo(nodTool)
|
||||
|
||||
@@ -32,4 +32,10 @@ internal sealed class InstallState
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
public string Workspace { get; set; } = "";
|
||||
public List<ProductInstallRecord> Products { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// The <c>DATA</c> folder of a game Wheel Wizard already extracted (<c>--game-dir</c>), read in place through
|
||||
/// <c>dvd_root</c>. Empty when the game was extracted into the workspace's own <c>Assets/DATA</c>.
|
||||
/// </summary>
|
||||
public string GameDataDir { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ internal static class Program
|
||||
// --workspace <cache> ahead of whatever the caller passed, so these can't assume position 0.
|
||||
if (args.Length == 0 || args.Contains("-h") || args.Contains("--help")) { PrintUsage(); return 0; }
|
||||
if (args.Contains("--version")) { Console.WriteLine(ProductInfo.Version); return 0; }
|
||||
// Lets Wheel Wizard tell what this AppImage supports before it asks; an older one rejects the flag.
|
||||
if (args.Contains("--capabilities"))
|
||||
{
|
||||
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(
|
||||
new { type = "capabilities", capabilities = new[] { "game-dir" } }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
// Replaces CancellationSignal.cs's named-EventWaitHandle IPC (Windows-only): SIGINT/SIGTERM
|
||||
@@ -50,6 +57,9 @@ internal static class Program
|
||||
case "check-products":
|
||||
CheckProducts();
|
||||
break;
|
||||
case "link-game-dir":
|
||||
LinkGameDirectory(flags, reporter);
|
||||
break;
|
||||
default:
|
||||
Console.Error.WriteLine($"Unknown command: {command}");
|
||||
PrintUsage();
|
||||
@@ -99,10 +109,23 @@ internal static class Program
|
||||
var assetsDir = Path.Combine(workspace, "Assets");
|
||||
|
||||
reporter.Progress(InstallStages.Validate, "Checking prerequisites", 1);
|
||||
if (flags.TryGetValue("game", out var isoPath) && !string.IsNullOrEmpty(isoPath))
|
||||
var gameDirectory = flags.GetValueOrDefault("game-dir");
|
||||
flags.TryGetValue("game", out var isoPath);
|
||||
if (!string.IsNullOrEmpty(isoPath) && !string.IsNullOrEmpty(gameDirectory))
|
||||
throw new ArgumentException("Pass either --game or --game-dir, not both.");
|
||||
|
||||
var previousState = JsonState.TryRead<InstallState>(StatePath);
|
||||
// Where the runtime reads the game: an extracted game it was given or already linked, else its own copy.
|
||||
var gameDataDir = previousState?.GameDataDir ?? "";
|
||||
if (!string.IsNullOrEmpty(gameDirectory))
|
||||
{
|
||||
gameDataDir = DiscTool.ValidateExtracted(gameDirectory, manifest, assetsDir, reporter);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(isoPath))
|
||||
{
|
||||
await DiscTool.ValidateAndExtractAsync(isoPath, manifest, assetsDir, workspace,
|
||||
flags.GetValueOrDefault("disc-tool-bin"), reporter, token);
|
||||
gameDataDir = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -116,8 +139,9 @@ internal static class Program
|
||||
}
|
||||
}
|
||||
|
||||
var state = JsonState.TryRead<InstallState>(StatePath) ?? new InstallState { Workspace = workspace };
|
||||
var state = previousState ?? new InstallState { Workspace = workspace };
|
||||
state.Workspace = workspace;
|
||||
state.GameDataDir = gameDataDir;
|
||||
|
||||
var profile = installsRetro ? "both" : "base";
|
||||
var profiles = installsRetro ? new[] { "base", "retro-rewind" } : new[] { "base" };
|
||||
@@ -218,11 +242,13 @@ internal static class Program
|
||||
// per-user Config.toml (RuntimeConfiguration.ResolveConfigPath's Windows-only portable-root
|
||||
// lookup has nothing to find here either way).
|
||||
var configPath = RuntimeConfiguration.ApplicationDataConfigPath;
|
||||
var dataDir = Path.Combine(assetsDir, "DATA");
|
||||
var dataDir = string.IsNullOrEmpty(gameDataDir) ? Path.Combine(assetsDir, "DATA") : gameDataDir;
|
||||
if (Directory.Exists(dataDir))
|
||||
{
|
||||
RuntimeConfiguration.SetDvdRoot(configPath, dataDir);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(gameDataDir))
|
||||
DiscTool.RemoveOwnExtraction(assetsDir, reporter);
|
||||
if (installsRetro)
|
||||
{
|
||||
RuntimeConfiguration.SetRetroRewindRoot(configPath, retroDir!);
|
||||
@@ -231,6 +257,39 @@ internal static class Program
|
||||
reporter.Progress(InstallStages.Shortcuts, "Install complete", 99);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points the installation at a game Wheel Wizard already extracted, without rebuilding: the game code is
|
||||
/// checked to be exactly what the installed products were built from, then <c>dvd_root</c> and the state name
|
||||
/// it and the workspace's own copy is removed.
|
||||
/// </summary>
|
||||
private static void LinkGameDirectory(Dictionary<string, string?> flags, IInstallReporter reporter)
|
||||
{
|
||||
var gameDirectory = flags.GetValueOrDefault("game-dir");
|
||||
if (string.IsNullOrEmpty(gameDirectory))
|
||||
throw new ArgumentException("link-game-dir needs --game-dir <folder>.");
|
||||
var state = JsonState.TryRead<InstallState>(StatePath);
|
||||
if (state is null || state.Products.Count == 0)
|
||||
throw new InvalidOperationException("WiiCompiled is not installed yet.");
|
||||
|
||||
var workspace = flags.GetValueOrDefault("workspace") ??
|
||||
(string.IsNullOrEmpty(state.Workspace) ? WorkspaceLocator.FindFrom(AppContext.BaseDirectory) : state.Workspace);
|
||||
var manifest = ProjectManifest.Load(Path.Combine(workspace, "projects", "mkwii", "recomp.yml"));
|
||||
var assetsDir = Path.Combine(workspace, "Assets");
|
||||
var dataDir = DiscTool.ValidateExtracted(gameDirectory, manifest, assetsDir, reporter);
|
||||
|
||||
var dolSha = Sha256Of(Path.Combine(dataDir, "sys", "main.dol"));
|
||||
var relSha = Sha256Of(Path.Combine(dataDir, "files", "rel", "StaticR.rel"));
|
||||
if (state.Products.Any(record => record.DolSha256 != dolSha || record.RelSha256 != relSha))
|
||||
throw new InvalidOperationException(
|
||||
"The installed products were built from different game code; install again with --game-dir instead.");
|
||||
|
||||
reporter.Progress(InstallStages.Shortcuts, "Pointing WiiCompiled at the extracted game", 70);
|
||||
RuntimeConfiguration.SetDvdRoot(RuntimeConfiguration.ApplicationDataConfigPath, dataDir);
|
||||
state.GameDataDir = dataDir;
|
||||
JsonState.Write(StatePath, state);
|
||||
DiscTool.RemoveOwnExtraction(assetsDir, reporter);
|
||||
}
|
||||
|
||||
private static void Uninstall()
|
||||
{
|
||||
// Matches Windows: UninstallService.cs removes the whole install directory unconditionally -
|
||||
@@ -351,7 +410,7 @@ internal static class Program
|
||||
Console.WriteLine("""
|
||||
Usage: wiicompiled-setup <command> [options]
|
||||
|
||||
install [--game ISO_PATH] [--install-dir DIR] [--retro-dir DIR
|
||||
install [--game ISO_PATH | --game-dir EXTRACTED_GAME] [--install-dir DIR] [--retro-dir DIR
|
||||
{--download-retro-wfc-payload | --skip-retro-wfc-payload}]
|
||||
[--force-clean-build] [--translator-bin PATH] [--disc-tool-bin PATH]
|
||||
[--cc PATH] [--cxx PATH] [--fuse-ld NAME_OR_PATH] [--cmake PATH] [--ninja PATH]
|
||||
@@ -360,7 +419,9 @@ internal static class Program
|
||||
launch-base
|
||||
launch-retro
|
||||
check-products
|
||||
link-game-dir --game-dir EXTRACTED_GAME [--progress-json]
|
||||
--version
|
||||
--capabilities
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ internal enum AppMode
|
||||
CheckProducts,
|
||||
RepairProducts,
|
||||
Version,
|
||||
EmitPayloadIdentities
|
||||
EmitPayloadIdentities,
|
||||
Capabilities,
|
||||
LinkGameDirectory
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -26,7 +28,12 @@ internal sealed record ModeRules
|
||||
public string Flag { get; init; } = "this command";
|
||||
public bool AcceptsProgressJson { get; init; }
|
||||
public bool AcceptsGame { get; init; }
|
||||
|
||||
/// <summary>The game is required, as either <c>--game <image></c> or <c>--game-dir <folder></c>.</summary>
|
||||
public bool RequiresGame { get; init; }
|
||||
|
||||
public bool AcceptsGameDirectory { get; init; }
|
||||
public bool RequiresGameDirectory { get; init; }
|
||||
public bool AcceptsRetroDirectory { get; init; }
|
||||
public bool RequiresRetroDirectory { get; init; }
|
||||
public bool AcceptsPayloadMode { get; init; }
|
||||
@@ -39,6 +46,7 @@ internal sealed class CommandLine
|
||||
{
|
||||
public AppMode Mode { get; private set; } = AppMode.Help;
|
||||
public string? GamePath { get; private set; }
|
||||
public string? GameDirectoryPath { get; private set; }
|
||||
public string? RetroDirectoryPath { get; private set; }
|
||||
public RetroWfcPayloadMode RetroWfcPayloadMode { get; private set; }
|
||||
public string? InstallDirectory { get; private set; }
|
||||
@@ -70,6 +78,9 @@ internal sealed class CommandLine
|
||||
case "--repair-products": result.Mode = AppMode.RepairProducts; break;
|
||||
case "--portable": result.Portable = true; break;
|
||||
case "--emit-payload-identities": result.Mode = AppMode.EmitPayloadIdentities; break;
|
||||
case "--capabilities": result.Mode = AppMode.Capabilities; break;
|
||||
case "--link-game-dir": result.Mode = AppMode.LinkGameDirectory; break;
|
||||
case "--game-dir": result.GameDirectoryPath = RequireValue(args, ref i); break;
|
||||
case "--payload-root": result.PayloadRootPath = RequireValue(args, ref i); break;
|
||||
case "--quiet": result.Quiet = true; break;
|
||||
case "--progress-json": result.ProgressJson = true; break;
|
||||
@@ -100,16 +111,22 @@ internal sealed class CommandLine
|
||||
[AppMode.SilentInstall] = new ModeRules
|
||||
{
|
||||
Flag = "--silent",
|
||||
AcceptsProgressJson = true, AcceptsGame = true, RequiresGame = true,
|
||||
AcceptsProgressJson = true, AcceptsGame = true, AcceptsGameDirectory = true, RequiresGame = true,
|
||||
AcceptsRetroDirectory = true, AcceptsPayloadMode = true,
|
||||
AcceptsPortable = true
|
||||
},
|
||||
[AppMode.VerifyInputs] = new ModeRules
|
||||
{
|
||||
Flag = "--verify-inputs",
|
||||
AcceptsProgressJson = true, AcceptsGame = true, RequiresGame = true,
|
||||
AcceptsProgressJson = true, AcceptsGame = true, AcceptsGameDirectory = true, RequiresGame = true,
|
||||
AcceptsRetroDirectory = true
|
||||
},
|
||||
[AppMode.LinkGameDirectory] = new ModeRules
|
||||
{
|
||||
Flag = "--link-game-dir",
|
||||
AcceptsProgressJson = true, AcceptsGameDirectory = true, RequiresGameDirectory = true,
|
||||
RequiresInstallDirectory = true
|
||||
},
|
||||
[AppMode.CheckProducts] = new ModeRules
|
||||
{
|
||||
Flag = "--check-products", AcceptsProgressJson = true, AcceptsRetroDirectory = true
|
||||
@@ -147,6 +164,9 @@ internal sealed class CommandLine
|
||||
}
|
||||
|
||||
Reject(GamePath is not null && !rules.AcceptsGame, "--game");
|
||||
Reject(GameDirectoryPath is not null && !rules.AcceptsGameDirectory, "--game-dir");
|
||||
if (GamePath is not null && GameDirectoryPath is not null)
|
||||
throw new ArgumentException("Pass either --game or --game-dir, not both.");
|
||||
Reject(RetroDirectoryPath is not null && !rules.AcceptsRetroDirectory, "--retro-dir");
|
||||
Reject(ProgressJson && !rules.AcceptsProgressJson, "--progress-json");
|
||||
Reject(PayloadRootPath is not null && !rules.RequiresPayloadRoot, "--payload-root");
|
||||
@@ -154,8 +174,10 @@ internal sealed class CommandLine
|
||||
Reject(RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable && !rules.AcceptsPayloadMode,
|
||||
"A Retro-WFC payload option");
|
||||
|
||||
if (rules.RequiresGame && string.IsNullOrWhiteSpace(GamePath))
|
||||
throw new ArgumentException("--game is required.");
|
||||
if (rules.RequiresGame && string.IsNullOrWhiteSpace(GamePath) && string.IsNullOrWhiteSpace(GameDirectoryPath))
|
||||
throw new ArgumentException("--game or --game-dir is required.");
|
||||
if (rules.RequiresGameDirectory && string.IsNullOrWhiteSpace(GameDirectoryPath))
|
||||
throw new ArgumentException($"--game-dir is required with {rules.Flag}.");
|
||||
if (rules.RequiresRetroDirectory && string.IsNullOrWhiteSpace(RetroDirectoryPath))
|
||||
throw new ArgumentException(
|
||||
$"--retro-dir is required with {rules.Flag}. Supply Wheel Wizard's canonical Retro Rewind folder.");
|
||||
|
||||
@@ -11,8 +11,10 @@ internal static class ConsoleCommands
|
||||
Console.Out.WriteLine("Wheel Wizard is the graphical interface for installing and launching WiiCompiled.");
|
||||
Console.Out.WriteLine();
|
||||
Console.Out.WriteLine("Commands:");
|
||||
Console.Out.WriteLine(" --silent --game <image> --install-dir <dir> [--retro-dir <folder>] [--portable]");
|
||||
Console.Out.WriteLine(" --verify-inputs --game <image> [--retro-dir <folder>]");
|
||||
Console.Out.WriteLine(" --silent (--game <image> | --game-dir <folder>) --install-dir <dir> [--retro-dir <folder>] [--portable]");
|
||||
Console.Out.WriteLine(" --verify-inputs (--game <image> | --game-dir <folder>) [--retro-dir <folder>]");
|
||||
Console.Out.WriteLine(" --link-game-dir --install-dir <dir> --game-dir <folder> [--progress-json]");
|
||||
Console.Out.WriteLine(" --capabilities");
|
||||
Console.Out.WriteLine(" --check-products [--install-dir <dir>] [--retro-dir <folder>] [--progress-json]");
|
||||
Console.Out.WriteLine(" --repair-products --install-dir <dir> --retro-dir <folder> " +
|
||||
"(--download-retro-wfc-payload | --skip-retro-wfc-payload) [--progress-json]");
|
||||
@@ -28,6 +30,80 @@ internal static class ConsoleCommands
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What this setup supports, so Wheel Wizard can tell before it asks. A setup from before this command rejects
|
||||
/// it as unknown, which the caller reads as supporting nothing new.
|
||||
/// </summary>
|
||||
public static int Capabilities()
|
||||
{
|
||||
Console.Out.WriteLine(JsonSerializer.Serialize(new { type = "capabilities", capabilities = new[] { "game-dir" } }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points an existing installation at a game Wheel Wizard already extracted: <c>dvd_root</c> and the install
|
||||
/// state name it, and the installation's own extracted copy is removed. Nothing is rebuilt, since the game
|
||||
/// code it was built from is checked to be the same.
|
||||
/// </summary>
|
||||
public static int LinkGameDirectory(CommandLine command)
|
||||
{
|
||||
var reporter = command.ProgressJson ? new NdjsonInstallReporter() : null;
|
||||
var installation = new Installation(Path.GetFullPath(command.InstallDirectory!));
|
||||
try
|
||||
{
|
||||
if (!installation.IsPresent)
|
||||
throw new InvalidOperationException($"WiiCompiled is not installed at {installation.Root}.");
|
||||
|
||||
using var operationLock = InstallOperationLock.Acquire(installation.Root, reporter);
|
||||
PortableInstallHealing.HealMovedInstall(installation, reporter);
|
||||
var state = installation.ReadInstallState() is { SchemaVersion: 1 } current
|
||||
? current
|
||||
: throw new InvalidDataException("The installation has no current install state. Apply setup again.");
|
||||
|
||||
reporter?.Progress(InstallStages.Validate, "Checking the extracted game...", 20);
|
||||
var dataDirectory = ExtractedGameDirectory.ResolveDataDirectory(command.GameDirectoryPath!);
|
||||
var header = InputValidation.ReadDiscHeaderFromDirectory(dataDirectory);
|
||||
var installedManifest = JsonState.TryRead<PayloadManifest>(
|
||||
Path.Combine(installation.Root, InstalledLayout.PayloadManifestFileName));
|
||||
if (installedManifest is not null)
|
||||
InputValidation.EnsureCompatibleDisc(header, installedManifest);
|
||||
// The products were built from exactly this game code; anything else needs a rebuild, not a link.
|
||||
foreach (var (file, expected) in new[]
|
||||
{
|
||||
(Path.Combine(dataDirectory, "sys", "main.dol"), state.DolSha256),
|
||||
(Path.Combine(dataDirectory, "files", "rel", "StaticR.rel"), state.RelSha256),
|
||||
})
|
||||
{
|
||||
if (!File.Exists(file) ||
|
||||
!InputValidation.Sha256File(file).Equals(expected, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException(
|
||||
$"{Path.GetFileName(file)} in the extracted game is not the clean game code WiiCompiled was built from.");
|
||||
}
|
||||
|
||||
RunningProductGuard.EnsureProductsNotRunning(installation.Root);
|
||||
reporter?.Progress(InstallStages.Publish, "Pointing WiiCompiled at the extracted game...", 70);
|
||||
RuntimeConfiguration.SetDvdRoot(installation.ConfigPath, dataDirectory);
|
||||
state.GameDataDir = dataDirectory;
|
||||
JsonState.Write(installation.InstallStatePath, state);
|
||||
InstallerEngine.RemoveOwnGameAssets(installation, reporter ?? (IInstallReporter)new ConsoleInstallReporter(_ => { }));
|
||||
|
||||
if (reporter is null)
|
||||
Console.Out.WriteLine($"WiiCompiled now reads the game from {dataDirectory}.");
|
||||
reporter?.Success(installation.Root);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex) when (reporter is not null)
|
||||
{
|
||||
reporter.Failure(ex.Message);
|
||||
Console.Error.WriteLine(ex);
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
reporter?.EnsureFinished("Linking the extracted game stopped before it reached a result.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static int EmitPayloadIdentities(CommandLine command)
|
||||
{
|
||||
@@ -68,7 +144,10 @@ internal static class ConsoleCommands
|
||||
payload.ExtractEntry(InstalledLayout.ToolkitEntryPrefix + "nodtool.exe", tool);
|
||||
payload.ExtractDirectory(InstalledLayout.ToolkitEntryPrefix + "Redist", temp);
|
||||
reporter?.Progress(InstallStages.Validate, "Checking the Wii disc image...", 10);
|
||||
var header = InputValidation.ReadDiscHeaderAsync(tool, command.GamePath!).GetAwaiter().GetResult();
|
||||
var header = command.GameDirectoryPath is null
|
||||
? InputValidation.ReadDiscHeaderAsync(tool, command.GamePath!).GetAwaiter().GetResult()
|
||||
: InputValidation.ReadDiscHeaderFromDirectory(
|
||||
ExtractedGameDirectory.ResolveDataDirectory(command.GameDirectoryPath));
|
||||
InputValidation.EnsureCompatibleDisc(header, manifest);
|
||||
|
||||
if (command.RetroDirectoryPath is not null)
|
||||
@@ -115,7 +194,8 @@ internal static class ConsoleCommands
|
||||
var engine = new InstallerEngine(reporter);
|
||||
await engine.InstallAsync(new InstallOptions
|
||||
{
|
||||
GamePath = command.GamePath!,
|
||||
GamePath = command.GamePath,
|
||||
GameDirectoryPath = command.GameDirectoryPath,
|
||||
RetroDirectoryPath = command.RetroDirectoryPath,
|
||||
RetroWfcPayloadMode = command.RetroWfcPayloadMode,
|
||||
InstallDirectory = installDirectory,
|
||||
|
||||
@@ -42,6 +42,19 @@ internal static class InputValidation
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The disc header of a game Wheel Wizard already extracted, read from its <c>sys/boot.bin</c>.</summary>
|
||||
public static DiscHeader ReadDiscHeaderFromDirectory(string dataDirectory)
|
||||
{
|
||||
var header = ExtractedGameDirectory.ReadHeader(dataDirectory);
|
||||
return new DiscHeader
|
||||
{
|
||||
GameId = header.GameId,
|
||||
InternalName = header.InternalName,
|
||||
Region = RegionFromGameId(header.GameId),
|
||||
Revision = header.Revision,
|
||||
};
|
||||
}
|
||||
|
||||
private static string RegionFromGameId(string gameId) => gameId.Length >= 4
|
||||
? gameId[3] switch
|
||||
{
|
||||
|
||||
@@ -99,7 +99,17 @@ internal sealed class Installation
|
||||
public string BaseExecutable => Path.Combine(BaseDirectory, "WiiCompiled.exe");
|
||||
public string RetroDirectory => Path.Combine(Root, "RetroRewind");
|
||||
public string RetroExecutable => Path.Combine(RetroDirectory, "RetroRewind.exe");
|
||||
public string GameDataDirectory => Path.Combine(Root, "GameAssets", "DATA");
|
||||
/// <summary>
|
||||
/// Where the game data is read from: Wheel Wizard's extracted game when the installation was pointed at it,
|
||||
/// otherwise the extraction this installation keeps itself.
|
||||
/// </summary>
|
||||
public string GameDataDirectory =>
|
||||
ReadInstallState() is { GameDataDir.Length: > 0 } state ? state.GameDataDir : OwnGameDataDirectory;
|
||||
|
||||
/// <summary>The installation's own extraction, which only an installation given a disc image has.</summary>
|
||||
public string OwnGameDataDirectory => Path.Combine(Root, "GameAssets", "DATA");
|
||||
|
||||
public string OwnGameAssetsDirectory => Path.Combine(Root, "GameAssets");
|
||||
public string InstallStatePath => Path.Combine(Root, InstalledLayout.InstallStateFileName);
|
||||
public string ToolkitStatePath => Path.Combine(Root, ToolkitState.FileName);
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ internal sealed class InstallerEngine
|
||||
var installDirectory = Path.GetFullPath(options.InstallDirectory);
|
||||
ValidateInstallDirectory(installDirectory);
|
||||
|
||||
InputValidation.ValidateExtension(options.GamePath);
|
||||
// A game Wheel Wizard already extracted is read in place; only a disc image is extracted here.
|
||||
var externalGameData = options.GameDirectoryPath is null
|
||||
? null
|
||||
: ExtractedGameDirectory.ResolveDataDirectory(options.GameDirectoryPath);
|
||||
if (externalGameData is null)
|
||||
InputValidation.ValidateExtension(options.GamePath ?? throw new ArgumentException("A game is required."));
|
||||
var parent = Directory.GetParent(installDirectory)?.FullName
|
||||
?? throw new InvalidOperationException("The installation directory has no parent.");
|
||||
Directory.CreateDirectory(parent);
|
||||
@@ -49,7 +54,8 @@ internal sealed class InstallerEngine
|
||||
var installedToolkitPackageFingerprint = existing.IsPresent
|
||||
? existing.ResolveToolkitPackageFingerprint()
|
||||
: "";
|
||||
var reusableGameAssets = FindReusableGameAssets(existing, manifest);
|
||||
var reusableGameAssets = externalGameData ?? FindReusableGameAssets(existing, manifest);
|
||||
var gameDataDir = GameDataDirFor(externalGameData, reusableGameAssets, previousState);
|
||||
|
||||
var sameToolkit = existing.IsPresent && reusableGameAssets is not null &&
|
||||
installedToolkitFingerprint.Equals(candidateToolkitFingerprint,
|
||||
@@ -84,9 +90,12 @@ internal sealed class InstallerEngine
|
||||
|
||||
var nodTool = extractToolkit ? Path.Combine(toolkit, "nodtool.exe") : installedNodTool;
|
||||
_reporter.Progress(InstallStages.Validate, "Checking the Wii disc image...", 2);
|
||||
var header = await InputValidation.ReadDiscHeaderAsync(nodTool, options.GamePath,
|
||||
cancellationToken);
|
||||
var header = externalGameData is null
|
||||
? await InputValidation.ReadDiscHeaderAsync(nodTool, options.GamePath!, cancellationToken)
|
||||
: InputValidation.ReadDiscHeaderFromDirectory(externalGameData);
|
||||
InputValidation.EnsureCompatibleDisc(header, manifest);
|
||||
if (externalGameData is not null)
|
||||
ValidateExtractedGame(externalGameData, manifest);
|
||||
var canonicalRetroRoot = options.RetroDirectoryPath is null
|
||||
? null
|
||||
: RetroRewindSource.ResolveRetroRewind6(options.RetroDirectoryPath);
|
||||
@@ -130,6 +139,7 @@ internal sealed class InstallerEngine
|
||||
|
||||
var updatedState = BuildInstallState(existing.ReadInstallState(), installDirectory, manifest,
|
||||
retroCompileInputs, canonicalRetroRoot, options.RetroWfcPayloadMode);
|
||||
updatedState.GameDataDir = gameDataDir;
|
||||
PrepareMetadata(staging, manifest, updatedState);
|
||||
remainingCancellation.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -158,16 +168,30 @@ internal sealed class InstallerEngine
|
||||
|
||||
if (reusableGameAssets is null)
|
||||
{
|
||||
await ExtractGameAssetsAsync(nodTool, options.GamePath,
|
||||
await ExtractGameAssetsAsync(nodTool, options.GamePath!,
|
||||
Path.Combine(staging, "GameAssets"), manifest, cancellationToken);
|
||||
}
|
||||
|
||||
await PublishToolkitAndReconcileProductsAsync(existing, staging, workspace, manifest,
|
||||
previousState, options, canonicalRetroRoot, retroCompileInputs,
|
||||
publishGameAssets: reusableGameAssets is null, createShortcuts: firstInstall,
|
||||
publishGameAssets: reusableGameAssets is null, gameDataDir, createShortcuts: firstInstall,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What <see cref="InstallState.GameDataDir"/> becomes: the extracted game this operation was given, or the
|
||||
/// one a previous operation linked when that is what is being reused; empty for the installation's own copy.
|
||||
/// </summary>
|
||||
private static string GameDataDirFor(string? externalGameData, string? reusableGameAssets, InstallState? previousState)
|
||||
{
|
||||
if (externalGameData is not null)
|
||||
return externalGameData;
|
||||
if (reusableGameAssets is not null && previousState is { GameDataDir.Length: > 0 } previous &&
|
||||
FileSystemUtilities.PathsEqual(reusableGameAssets, previous.GameDataDir))
|
||||
return previous.GameDataDir;
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
internal static bool MustRefreshToolkit(bool sameToolkit, bool samePackageContent,
|
||||
bool nodToolPresent) =>
|
||||
@@ -190,7 +214,7 @@ internal sealed class InstallerEngine
|
||||
string stagedWorkspace, PayloadManifest manifest, InstallState? previousState,
|
||||
InstallOptions options, string? canonicalRetroRoot,
|
||||
RetroRewindCompileInputs? retroCompileInputs,
|
||||
bool publishGameAssets, bool createShortcuts, CancellationToken cancellationToken)
|
||||
bool publishGameAssets, string gameDataDir, bool createShortcuts, CancellationToken cancellationToken)
|
||||
{
|
||||
var installDirectory = existing.Root;
|
||||
|
||||
@@ -208,6 +232,7 @@ internal sealed class InstallerEngine
|
||||
|
||||
var state = BuildInstallState(previousState, installDirectory, manifest,
|
||||
compileInputs: null, canonicalRetroRoot, options.RetroWfcPayloadMode);
|
||||
state.GameDataDir = gameDataDir;
|
||||
PrepareMetadata(staging, manifest, state);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -305,7 +330,9 @@ internal sealed class InstallerEngine
|
||||
using var transaction = InstallTransaction.Begin(installDirectory, _reporter, entries.ToArray());
|
||||
transaction.Publish();
|
||||
transaction.RecordRuntimeConfigurationMutation(configSnapshot);
|
||||
RuntimeConfiguration.SetDvdRoot(configPath, Path.Combine(installDirectory, "GameAssets", "DATA"));
|
||||
RuntimeConfiguration.SetDvdRoot(configPath, string.IsNullOrEmpty(state.GameDataDir)
|
||||
? Path.Combine(installDirectory, "GameAssets", "DATA")
|
||||
: state.GameDataDir);
|
||||
// The runtime scans this directory live for its asset overlay, so it is recorded on every
|
||||
// operation that receives one and is never rewritten by a base-only operation.
|
||||
if (canonicalRetroRoot is not null)
|
||||
@@ -336,9 +363,26 @@ internal sealed class InstallerEngine
|
||||
ex.Message);
|
||||
}
|
||||
|
||||
// Reading Wheel Wizard's extracted game, the installation no longer needs a copy of its own.
|
||||
if (!string.IsNullOrEmpty(state.GameDataDir))
|
||||
RemoveOwnGameAssets(new Installation(installDirectory), _reporter);
|
||||
|
||||
_reporter.Progress(InstallStages.Publish, "Installation complete.", completionPercent);
|
||||
}
|
||||
|
||||
/// <summary>Deletes the installation's own extraction; it is only ever this installation's own folder.</summary>
|
||||
internal static void RemoveOwnGameAssets(Installation installation, IInstallReporter reporter)
|
||||
{
|
||||
try
|
||||
{
|
||||
FileSystemUtilities.DeleteDirectoryIfExists(installation.OwnGameAssetsDirectory);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
reporter.Diagnostic("The game copy WiiCompiled no longer uses could not be removed yet: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrepareMetadata(string staging, PayloadManifest manifest, InstallState state)
|
||||
{
|
||||
JsonState.Write(Path.Combine(staging, ToolkitState.FileName), new ToolkitState
|
||||
|
||||
@@ -12,7 +12,14 @@ internal enum RetroWfcPayloadMode
|
||||
|
||||
internal sealed class InstallOptions
|
||||
{
|
||||
public required string GamePath { get; init; }
|
||||
/// <summary>A disc image to extract. Exactly one of this and <see cref="GameDirectoryPath"/> is set.</summary>
|
||||
public string? GamePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A game Wheel Wizard already extracted (its root, holding <c>DATA/</c>). It is read in place through
|
||||
/// <c>dvd_root</c>; nothing is extracted and no copy of it is kept in the installation.
|
||||
/// </summary>
|
||||
public string? GameDirectoryPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The canonical Retro Rewind installation Wheel Wizard owns. Its resolved path is recorded as
|
||||
@@ -102,6 +109,13 @@ internal sealed class InstallState
|
||||
/// <c>[paths] retro_rewind_root</c>; this mirror lets uninstall drop only the setting it owns.
|
||||
/// </summary>
|
||||
public string RetroRewindRoot { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The extracted game's <c>DATA</c> folder this installation reads in place (<c>--game-dir</c>). Empty for an
|
||||
/// installation that reads its own extraction in <c>GameAssets\DATA</c>. Wheel Wizard reads this to know
|
||||
/// whether WiiCompiled already uses its extracted game.
|
||||
/// </summary>
|
||||
public string GameDataDir { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -39,6 +39,12 @@ internal static class Program
|
||||
if (command.Mode == AppMode.Version)
|
||||
return ConsoleCommands.Version();
|
||||
|
||||
if (command.Mode == AppMode.Capabilities)
|
||||
return ConsoleCommands.Capabilities();
|
||||
|
||||
if (command.Mode == AppMode.LinkGameDirectory)
|
||||
return ConsoleCommands.LinkGameDirectory(command);
|
||||
|
||||
if (command.Mode == AppMode.Help)
|
||||
return ConsoleCommands.Help();
|
||||
|
||||
|
||||
@@ -53,6 +53,10 @@ internal static class SelfTests
|
||||
Test("Runtime configuration key matching", TestRuntimeConfigurationKeyMatching, failures);
|
||||
Test("Install location validation", TestInstallLocationValidation, failures);
|
||||
Test("Command line contract", TestCommandLineContract, failures);
|
||||
Test("Extracted game command line contract", TestGameDirectoryCommandLine, failures);
|
||||
Test("Extracted game directory", TestExtractedGameDirectory, failures);
|
||||
Test("Game data follows the install state", TestGameDataDirectoryFollowsState, failures);
|
||||
Test("Capabilities report", TestCapabilitiesReport, failures);
|
||||
Test("Portable root discovery", TestPortableRootDiscovery, failures);
|
||||
Test("Portable install validation failure leaves no marker",
|
||||
TestPortableValidationFailureLeavesNoMarker, failures);
|
||||
@@ -265,6 +269,139 @@ internal static class SelfTests
|
||||
}
|
||||
}
|
||||
|
||||
private static void TestGameDirectoryCommandLine()
|
||||
{
|
||||
void Rejects(string description, params string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
CommandLine.Parse(args);
|
||||
throw new Exception(description + " was accepted.");
|
||||
}
|
||||
catch (ArgumentException) { }
|
||||
}
|
||||
|
||||
var silent = CommandLine.Parse(["--silent", "--game-dir", "D:\\WW\\GAME", "--install-dir", "C:\\MKW", "--progress-json"]);
|
||||
if (silent.Mode != AppMode.SilentInstall || silent.GameDirectoryPath != "D:\\WW\\GAME" || silent.GamePath is not null)
|
||||
throw new Exception("An install from an extracted game did not parse.");
|
||||
|
||||
var link = CommandLine.Parse(["--link-game-dir", "--install-dir", "C:\\MKW", "--game-dir", "D:\\WW\\GAME", "--progress-json"]);
|
||||
if (link.Mode != AppMode.LinkGameDirectory || link.GameDirectoryPath != "D:\\WW\\GAME" || !link.ProgressJson)
|
||||
throw new Exception("The link command did not parse.");
|
||||
|
||||
if (CommandLine.Parse(["--capabilities"]).Mode != AppMode.Capabilities)
|
||||
throw new Exception("--capabilities was not recognised.");
|
||||
|
||||
Rejects("Both a disc image and an extracted game", "--silent", "--game", "g.iso", "--game-dir", "GAME", "--install-dir", "x");
|
||||
Rejects("An install with no game at all", "--silent", "--install-dir", "x");
|
||||
Rejects("A link without the extracted game", "--link-game-dir", "--install-dir", "x");
|
||||
Rejects("A link without an installation", "--link-game-dir", "--game-dir", "GAME");
|
||||
Rejects("A link from a disc image", "--link-game-dir", "--install-dir", "x", "--game-dir", "GAME", "--game", "g.iso");
|
||||
Rejects("--game-dir where nothing reads it", "--check-products", "--install-dir", "x", "--game-dir", "GAME");
|
||||
}
|
||||
|
||||
private static byte[] BootBin(string gameId)
|
||||
{
|
||||
var boot = new byte[0x440];
|
||||
System.Text.Encoding.ASCII.GetBytes(gameId).CopyTo(boot, 0);
|
||||
boot[7] = 2;
|
||||
BinaryPrimitives.WriteUInt32BigEndian(boot.AsSpan(0x18), 0x5D1C9EA3);
|
||||
System.Text.Encoding.ASCII.GetBytes("MARIO KART WII").CopyTo(boot, 0x20);
|
||||
return boot;
|
||||
}
|
||||
|
||||
private static void TestExtractedGameDirectory()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "mkwc-gamedir-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var sys = Path.Combine(root, "GAME", "DATA", "sys");
|
||||
Directory.CreateDirectory(sys);
|
||||
File.WriteAllBytes(Path.Combine(sys, "main.dol"), [1, 2, 3]);
|
||||
File.WriteAllBytes(Path.Combine(sys, "boot.bin"), BootBin("RMCP01"));
|
||||
|
||||
var fromRoot = ExtractedGameDirectory.ResolveDataDirectory(Path.Combine(root, "GAME"));
|
||||
var fromData = ExtractedGameDirectory.ResolveDataDirectory(Path.Combine(root, "GAME", "DATA"));
|
||||
if (!FileSystemUtilities.PathsEqual(fromRoot, Path.Combine(root, "GAME", "DATA")) ||
|
||||
!FileSystemUtilities.PathsEqual(fromData, fromRoot))
|
||||
throw new Exception("The DATA folder was not found from the game root or from itself.");
|
||||
|
||||
var header = InputValidation.ReadDiscHeaderFromDirectory(fromRoot);
|
||||
if (header.GameId != "RMCP01" || header.Revision != 2 || header.InternalName != "MARIO KART WII" ||
|
||||
header.Region != "PAL")
|
||||
throw new Exception("The extracted game's header was misread.");
|
||||
|
||||
try
|
||||
{
|
||||
ExtractedGameDirectory.ResolveDataDirectory(root);
|
||||
throw new Exception("A folder without a game was accepted.");
|
||||
}
|
||||
catch (InvalidDataException) { }
|
||||
|
||||
File.WriteAllBytes(Path.Combine(sys, "boot.bin"), new byte[0x440]);
|
||||
try
|
||||
{
|
||||
ExtractedGameDirectory.ReadHeader(fromRoot);
|
||||
throw new Exception("A boot.bin without the Wii magic was accepted.");
|
||||
}
|
||||
catch (InvalidDataException) { }
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TestGameDataDirectoryFollowsState()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "mkwc-gamedata-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(root);
|
||||
var installation = new Installation(root);
|
||||
if (!FileSystemUtilities.PathsEqual(installation.GameDataDirectory, installation.OwnGameDataDirectory))
|
||||
throw new Exception("An installation without state did not read its own game copy.");
|
||||
|
||||
var external = Path.Combine(root, "elsewhere", "GAME", "DATA");
|
||||
JsonState.Write(installation.InstallStatePath, new InstallState { InstallDir = root, GameDataDir = external });
|
||||
if (!FileSystemUtilities.PathsEqual(installation.GameDataDirectory, external))
|
||||
throw new Exception("A linked installation did not read the extracted game.");
|
||||
|
||||
// Removing the installation's own copy never reaches the extracted game it reads.
|
||||
Directory.CreateDirectory(Path.Combine(installation.OwnGameAssetsDirectory, "DATA"));
|
||||
Directory.CreateDirectory(external);
|
||||
File.WriteAllText(Path.Combine(external, "keep.bin"), "keep");
|
||||
InstallerEngine.RemoveOwnGameAssets(installation, new ConsoleInstallReporter(_ => { }));
|
||||
if (Directory.Exists(installation.OwnGameAssetsDirectory) || !File.Exists(Path.Combine(external, "keep.bin")))
|
||||
throw new Exception("Removing the own game copy removed the wrong folder.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TestCapabilitiesReport()
|
||||
{
|
||||
var output = new StringWriter();
|
||||
var original = Console.Out;
|
||||
Console.SetOut(output);
|
||||
try
|
||||
{
|
||||
if (ConsoleCommands.Capabilities() != 0)
|
||||
throw new Exception("--capabilities did not succeed.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(original);
|
||||
}
|
||||
|
||||
using var document = System.Text.Json.JsonDocument.Parse(output.ToString());
|
||||
if (document.RootElement.GetProperty("type").GetString() != "capabilities" ||
|
||||
!document.RootElement.GetProperty("capabilities").EnumerateArray().Any(item => item.GetString() == "game-dir"))
|
||||
throw new Exception("--capabilities did not report game-dir.");
|
||||
}
|
||||
|
||||
private static void TestCommandLineContract()
|
||||
{
|
||||
void Rejects(string description, params string[] args)
|
||||
|
||||
@@ -55,6 +55,10 @@ internal static class UninstallService
|
||||
TryCleanup("remove the installed game path from the runtime configuration", quiet,
|
||||
() => RuntimeConfiguration.RemoveDvdRootIfOwned(configPath,
|
||||
Path.Combine(installDirectory, "GameAssets", "DATA")));
|
||||
// A game Wheel Wizard extracted is only unregistered here; the folder itself is Wheel Wizard's, and stays.
|
||||
if (!string.IsNullOrEmpty(state?.GameDataDir))
|
||||
TryCleanup("remove the extracted game path from the runtime configuration", quiet,
|
||||
() => RuntimeConfiguration.RemoveDvdRootIfOwned(configPath, state.GameDataDir));
|
||||
}
|
||||
|
||||
// Remove only this install's Retro Rewind setting; leave user-owned paths untouched.
|
||||
|
||||
Reference in New Issue
Block a user