Files
wiicompiled/Launcher/WiiCompiled.Setup.Linux/DiscTool.cs
T
patchzyy 5e337e6ecf add ntsc-u and ntsc-j support
Co-authored-by: DarthMDev <mgracer48@yahoo.com>
Co-authored-by: rooklz <rookieb@muzo-works.com>
2026-10-21 16:01:27 +02:00

173 lines
9.3 KiB
C#

using System.Security.Cryptography;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Linux;
/// <summary>
/// Validates and extracts the user's own Mario Kart Wii disc via `nodtool` (see
/// WiiCompiled.Setup.Common/NodToolProvider.cs) - a prebuilt, MIT/Apache-2.0-licensed CLI from
/// encounter/nod, replacing the earlier dependency on a system-installed `dolphin-tool`
/// (GPL-2.0-or-later, and not reliably packaged standalone by every distro).
/// </summary>
internal static class DiscTool
{
/// <summary>Validates and extracts the disc, returning the supported release it is.</summary>
public static async Task<ProjectManifest> ValidateAndExtractAsync(
string isoPath, IReadOnlyList<ProjectManifest> releases, string assetsDirectory, string workspace,
string? nodToolBin, IInstallReporter reporter, CancellationToken cancellationToken)
{
var nodTool = nodToolBin ?? await NodToolProvider.ResolveAsync(workspace, cancellationToken);
// `nodtool info` only decodes the disc/partition headers (milliseconds); `nodtool extract`
// copies the whole data partition to disk (tens of seconds for a custom-track-heavy MKWii
// ISO). The game ID picks the release, so a wrong disc fails fast and an existing
// extraction is only reused for the same release.
reporter.Progress(InstallStages.ExtractDisc, "Reading the disc header", 2);
var info = NodToolInfoParser.Parse(await RunInfoAsync(nodTool, isoPath, cancellationToken));
var manifest = releases.FirstOrDefault(release =>
string.Equals(info.GameId, release.GameId, StringComparison.Ordinal))
?? throw new InvalidOperationException(
$"This disc is '{info.GameId}', which is not a supported Mario Kart Wii release " +
$"({ProjectManifest.Describe(releases)}). " +
"Only your own legally-owned copy of a supported game/region can be used.");
if (IsAlreadyExtracted(manifest, assetsDirectory))
{
reporter.Progress(InstallStages.ExtractDisc, "Reusing the verified extracted disc", 6);
return manifest;
}
// Extracted straight into Assets/DATA (kept, not a scratch dir) - the runtime reads course/
// texture/audio data from this directory live via [paths] dvd_root, not just at translation
// time, so it has to survive past this install (see Program.cs, which points dvd_root here).
reporter.Progress(InstallStages.ExtractDisc, "Extracting the disc image", 4);
var dataDir = Path.Combine(assetsDirectory, "DATA");
Directory.CreateDirectory(assetsDirectory);
File.Delete(Path.Combine(assetsDirectory, ExtractionMarker));
if (Directory.Exists(dataDir)) Directory.Delete(dataDir, recursive: true);
await RunExtractAsync(nodTool, isoPath, dataDir, cancellationToken);
var dolPath = Path.Combine(dataDir, "sys", "main.dol");
var relPath = Path.Combine(dataDir, "files", "rel", "StaticR.rel");
if (!File.Exists(dolPath)) throw new FileNotFoundException("nodtool did not produce main.dol", dolPath);
if (!File.Exists(relPath)) throw new FileNotFoundException("nodtool did not produce StaticR.rel", relPath);
var dolSha = Sha256Of(dolPath);
var relSha = Sha256Of(relPath);
if (!string.Equals(dolSha, manifest.DolSha256, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"main.dol sha256 mismatch: expected {manifest.DolSha256}, got {dolSha}. " +
"This disc revision does not match what this project's manifest is pinned to.");
}
if (!string.Equals(relSha, manifest.RelSha256, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"StaticR.rel sha256 mismatch: expected {manifest.RelSha256}, got {relSha}. " +
"This disc revision does not match what this project's manifest 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);
File.WriteAllText(Path.Combine(assetsDirectory, ExtractionMarker), ExtractionRecord(dolSha, dataDir));
reporter.Progress(InstallStages.ExtractDisc, "Disc validated and extracted", 6);
return manifest;
}
/// <summary>The release already extracted into Assets, chosen by its main.dol like local-build.sh.</summary>
public static ProjectManifest SelectExtracted(IReadOnlyList<ProjectManifest> releases, string assetsDirectory)
{
var dolSha = Sha256Of(Path.Combine(assetsDirectory, "main.dol"));
var manifest = releases.FirstOrDefault(release => string.Equals(dolSha, release.DolSha256, StringComparison.Ordinal))
?? throw new InvalidOperationException(
$"Assets/main.dol (sha256 {dolSha}) is not a supported clean Mario Kart Wii release " +
$"({ProjectManifest.Describe(releases)}).");
var relSha = Sha256Of(Path.Combine(assetsDirectory, "StaticR.rel"));
if (!string.Equals(relSha, manifest.RelSha256, StringComparison.Ordinal))
throw new InvalidOperationException(
$"Assets/StaticR.rel does not match the clean {manifest.GameId} release of Assets/main.dol.");
return manifest;
}
private static async Task<string> RunInfoAsync(string nodTool, string isoPath, CancellationToken cancellationToken)
{
var startInfo = new System.Diagnostics.ProcessStartInfo(nodTool)
{
ArgumentList = { "info", isoPath },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using var process = System.Diagnostics.Process.Start(startInfo)
?? throw new InvalidOperationException($"Failed to start {nodTool}.");
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken);
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"nodtool could not read this disc image (exit {process.ExitCode}): {stderr}{stdout}".Trim());
}
return stdout;
}
private const string ExtractionMarker = ".disc-extracted";
// Each release has one accepted revision, so a completed, verified extraction is identical to a fresh one.
private static bool IsAlreadyExtracted(ProjectManifest manifest, string assetsDirectory)
{
var marker = Path.Combine(assetsDirectory, ExtractionMarker);
var dol = Path.Combine(assetsDirectory, "main.dol");
var rel = Path.Combine(assetsDirectory, "StaticR.rel");
var dataDir = Path.Combine(assetsDirectory, "DATA");
if (!File.Exists(marker) || !File.Exists(dol) || !File.Exists(rel) ||
!Directory.Exists(Path.Combine(dataDir, "files")))
return false;
return string.Equals(File.ReadAllText(marker), ExtractionRecord(manifest.DolSha256, dataDir),
StringComparison.Ordinal) &&
string.Equals(Sha256Of(dol), manifest.DolSha256, StringComparison.Ordinal) &&
string.Equals(Sha256Of(rel), manifest.RelSha256, StringComparison.Ordinal);
}
// The DOL hash plus every extracted file's path and size, so a later deletion or truncation inside
// DATA forces a fresh extraction.
private static string ExtractionRecord(string dolSha, string dataDir)
{
var record = new System.Text.StringBuilder(dolSha).Append('\n');
foreach (var path in Directory.EnumerateFiles(dataDir, "*", SearchOption.AllDirectories)
.Select(path => Path.GetRelativePath(dataDir, path).Replace('\\', '/'))
.Order(StringComparer.Ordinal))
record.Append(new FileInfo(Path.Combine(dataDir, path)).Length).Append(' ').Append(path).Append('\n');
return record.ToString();
}
private static string Sha256Of(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static async Task RunExtractAsync(string nodTool, string isoPath, string outDir, CancellationToken cancellationToken)
{
var startInfo = new System.Diagnostics.ProcessStartInfo(nodTool)
{
ArgumentList = { "extract", isoPath, outDir, "-q" },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using var process = System.Diagnostics.Process.Start(startInfo)
?? throw new InvalidOperationException($"Failed to start {nodTool}.");
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken);
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"nodtool extract {isoPath} failed (exit {process.ExitCode}): {stderr}{stdout}");
}
}
}