Added Android Launcher and Disk Extration

This commit is contained in:
iChris4 committed 2026-09-17 18:32:04 +02:00
1 parent 1346e53cd6
commit 2f85207c39
74 files changed
+6153 -70

No files matched your search

+7
View File
@@ -220,6 +220,13 @@ Copy-Directory (Join-Path $repoRoot 'projects\mkwii') (Join-Path $workspace 'pro
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'LocalBuild.ps1') -Destination (Join-Path $workspace 'LocalBuild.ps1')
# LocalBuild.ps1 dot-sources the canonical configure flags from this sibling.
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1') -Destination (Join-Path $workspace 'NativeBuildFlags.ps1')
# --build-quest compiles the player's translation for the Meta Quest app with these; they are not
# part of any toolkit fingerprint, so shipping them never invalidates a PC product.
$workspaceAndroid = Join-Path $workspace 'android'
New-Item -ItemType Directory -Force $workspaceAndroid | Out-Null
foreach ($script in 'QuestGameKit.psm1', 'Build-QuestGame.ps1') {
Copy-Item -LiteralPath (Join-Path $repoRoot "android\$script") -Destination (Join-Path $workspaceAndroid $script)
}
[IO.Directory]::CreateDirectory((Join-Path $workspace 'Dependencies')) | Out-Null
foreach ($name in $requiredDependencies) { Copy-Directory (Join-Path $dependencySources $name) (Join-Path $workspace "Dependencies\$name") }
@@ -15,7 +15,8 @@ internal enum AppMode
RepairProducts,
Version,
InfoJson,
EmitPayloadIdentities
EmitPayloadIdentities,
BuildQuest
}
/// <summary>
@@ -34,6 +35,7 @@ internal sealed record ModeRules
public bool RequiresInstallDirectory { get; init; }
public bool RequiresPayloadRoot { get; init; }
public bool AcceptsPortable { get; init; }
public bool AcceptsQuestBuild { get; init; }
}
internal sealed class CommandLine
@@ -47,6 +49,9 @@ internal sealed class CommandLine
public bool ProgressJson { get; private set; }
public string? PayloadRootPath { get; private set; }
public bool Portable { get; private set; }
public string? QuestApkPath { get; private set; }
public string? OutputPath { get; private set; }
public bool IncludeGameFiles { get; private set; }
public static bool WantsProgressJson(string[] args) =>
args.Any(argument => argument.Equals("--progress-json", StringComparison.OrdinalIgnoreCase));
@@ -88,6 +93,10 @@ internal sealed class CommandLine
result.RetroWfcPayloadMode = RetroWfcPayloadMode.Skipped;
break;
case "--install-dir": result.InstallDirectory = RequireValue(args, ref i); break;
case "--build-quest": result.Mode = AppMode.BuildQuest; break;
case "--quest-apk": result.QuestApkPath = RequireValue(args, ref i); break;
case "--output": result.OutputPath = RequireValue(args, ref i); break;
case "--include-game-files": result.IncludeGameFiles = true; break;
default:
throw new ArgumentException($"Unknown CLI option: {args[i]}");
}
@@ -136,6 +145,11 @@ internal sealed class CommandLine
[AppMode.EmitPayloadIdentities] = new ModeRules
{
Flag = "--emit-payload-identities", RequiresPayloadRoot = true
},
[AppMode.BuildQuest] = new ModeRules
{
Flag = "--build-quest", AcceptsProgressJson = true, RequiresInstallDirectory = true,
AcceptsQuestBuild = true
}
};
@@ -153,6 +167,11 @@ internal sealed class CommandLine
Reject(ProgressJson && !rules.AcceptsProgressJson, "--progress-json");
Reject(PayloadRootPath is not null && !rules.RequiresPayloadRoot, "--payload-root");
Reject(Portable && !rules.AcceptsPortable, "--portable");
Reject(QuestApkPath is not null && !rules.AcceptsQuestBuild, "--quest-apk");
Reject(OutputPath is not null && !rules.AcceptsQuestBuild, "--output");
Reject(IncludeGameFiles && !rules.AcceptsQuestBuild, "--include-game-files");
if (rules.AcceptsQuestBuild && (string.IsNullOrWhiteSpace(QuestApkPath) || string.IsNullOrWhiteSpace(OutputPath)))
throw new ArgumentException($"--quest-apk and --output are required with {rules.Flag}.");
Reject(RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable && !rules.AcceptsPayloadMode,
"A Retro-WFC payload option");
@@ -16,6 +16,8 @@ internal static class ConsoleCommands
Console.Out.WriteLine(" --check-products [--install-dir <dir>] [--retro-dir <folder>] [--progress-json]");
Console.Out.WriteLine(" --repair-products --install-dir <dir> --retro-dir <folder> " +
"(--download-retro-wfc-payload | --skip-retro-wfc-payload) [--progress-json]");
Console.Out.WriteLine(" --build-quest --install-dir <dir> --quest-apk <apk> --output <file.wcgame> " +
"[--include-game-files] [--progress-json]");
Console.Out.WriteLine(" --launch-retro | --launch-base");
Console.Out.WriteLine(" --uninstall --install-dir <dir>");
Console.Out.WriteLine(" --version");
@@ -33,7 +35,7 @@ internal static class ConsoleCommands
{
Console.Out.WriteLine(JsonSerializer.Serialize(new
{
productId = ProductInfo.Id, version = ProductInfo.Version, openxrD3D12 = true
productId = ProductInfo.Id, version = ProductInfo.Version, openxrD3D12 = true, questBuild = true
}));
return 0;
}
@@ -263,6 +265,53 @@ internal static class ConsoleCommands
}
}
/// <summary>
/// Builds the player's game for the Meta Quest app from this installation's translation and writes it
/// as a .wcgame (see <see cref="QuestBuildService"/>). Holds the installation's operation lock, so it
/// never races a repair of the translation it compiles.
/// </summary>
public static async Task<int> BuildQuest(CommandLine command, CancellationToken cancellationToken = default)
{
var logPath = Path.Combine(Path.GetTempPath(), "WiiCompiled-quest-build.log");
var logLock = new object();
void Log(string message)
{
lock (logLock)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] {message}{Environment.NewLine}");
}
}
File.WriteAllText(logPath, $"{ProductInfo.Name} Quest build {ProductInfo.Version}{Environment.NewLine}");
var ndjson = command.ProgressJson ? new NdjsonInstallReporter(Log) : null;
IInstallReporter reporter = ndjson is null ? new ConsoleInstallReporter(Log) : ndjson;
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);
var result = await new QuestBuildService(reporter).BuildAsync(installation,
Path.GetFullPath(command.QuestApkPath!), Path.GetFullPath(command.OutputPath!),
command.IncludeGameFiles, cancellationToken);
if (ndjson is null)
Console.Out.WriteLine($"Quest game package: {result.PackagePath} ({result.SizeBytes / 1_000_000} MB)");
ndjson?.QuestPackage(result);
ndjson?.Success(installation.Root);
return 0;
}
catch (Exception ex) when (ndjson is not null)
{
ndjson.Failure(ex is OperationCanceledException ? "Operation canceled." : ex.Message);
Console.Error.WriteLine(ex);
return 1;
}
finally
{
ndjson?.EnsureFinished("The Quest build stopped before it reached a result.");
}
}
/// <summary>
/// The <c>products</c> record exactly as the v1 contract defines it: a typed status and a
/// human-readable detail per product, plus one aggregate. Nothing else is derived, because
@@ -16,6 +16,10 @@ internal static class InstallStages
public const string BuildBase = "build-base";
public const string BuildRetro = "build-retro";
public const string Publish = "publish";
public const string QuestKit = "quest-kit";
public const string QuestToolchain = "quest-toolchain";
public const string QuestBuild = "quest-build";
public const string QuestPackage = "quest-package";
}
/// <summary>
@@ -93,6 +97,23 @@ internal sealed class NdjsonInstallReporter : IInstallReporter
_log?.Invoke("FAILED: " + error);
}
/// <summary>
/// <c>--build-quest</c>'s product: where the game package is and what it was built against. Written
/// once, before the terminal result line.
/// </summary>
public void QuestPackage(QuestBuildService.Result result)
{
lock (_gate)
{
if (_finished) return;
WriteLine(new
{
type = "quest-package", path = result.PackagePath, kitFingerprint = result.KitFingerprint,
includesGameFiles = result.IncludesGameFiles, sizeBytes = result.SizeBytes
});
}
}
/// <summary>
/// The terminal result line is the caller's only completion signal, so no exit path may skip it.
/// Callers invoke this from a finally block; it is a no-op once a result was already written.
@@ -58,6 +58,12 @@ internal static class Program
return ConsoleCommands.CheckProducts(command, cancellationSignal.Token);
}
if (command.Mode == AppMode.BuildQuest)
{
using var cancellationSignal = CancellationSignal.ObserveEnvironment();
return ConsoleCommands.BuildQuest(command, cancellationSignal.Token).GetAwaiter().GetResult();
}
if (command.Mode is AppMode.LaunchBase or AppMode.LaunchRetro)
return GameLaunchService.LaunchAsync(
command.Mode == AppMode.LaunchBase ? BuildProfile.Base : BuildProfile.RetroRewind)
@@ -0,0 +1,306 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// <c>--build-quest</c>: builds the player's game for the WiiCompiled Meta Quest app from this
/// installation's own translation, so the Quest app, like this installer, never ships game code.
///
/// The Quest app's APK carries a game kit (its prebuilt runtime plus <c>kit.json</c>, the recipe; see
/// android/QuestGameKit.psm1). This service extracts that kit, provides the Android NDK compiler it
/// was built with (downloaded from Google once, never redistributed), and runs the workspace's
/// <c>android\Build-QuestGame.ps1</c>, which compiles the translation against the kit and writes a
/// <c>.wcgame</c> the headset imports. With game files included, the package also carries the
/// extracted disc, so the headset needs nothing else.
/// </summary>
internal sealed class QuestBuildService
{
/// <summary>The NDK the game kit is built with (android/app/build.gradle.kts, ndkVersion).</summary>
internal static class Ndk
{
public const string Revision = "29.0.14206865";
public const string ArchiveName = "android-ndk-r29-windows.zip";
public const string Url = "https://dl.google.com/android/repository/" + ArchiveName;
// Google's repository manifest (repository2-3.xml) publishes size and SHA-1 only.
public const long Size = 833850862;
public const string Sha1 = "ab3bb30fbb9e6903666d60c55d11e78b04e07472";
public const string LlvmPrefix = "android-ndk-r29/toolchains/llvm/prebuilt/windows-x86_64/";
public const string LicenseUrl = "https://developer.android.com/studio/terms";
}
/// <summary>Developers point this at an installed NDK's toolchains\llvm\prebuilt\windows-x86_64.</summary>
public const string ToolchainOverrideVariable = "WIICOMPILED_QUEST_NDK_TOOLCHAIN";
private const string QuestDirectoryName = "QuestBuild";
private static readonly Regex NinjaProgress = new(@"^\[(\d+)/(\d+)\]", RegexOptions.Compiled);
private static readonly Regex OtherArchitectureInclude = new(
@"^sysroot/usr/include/(arm-linux-androideabi|i686-linux-android|x86_64-linux-android|riscv64-linux-android)/",
RegexOptions.Compiled);
private readonly IInstallReporter _reporter;
public QuestBuildService(IInstallReporter reporter) => _reporter = reporter;
internal sealed record Result(string PackagePath, string KitFingerprint, bool IncludesGameFiles, long SizeBytes);
internal sealed record Toolchain(string ClangCxx, string ClangC, string Sysroot);
public async Task<Result> BuildAsync(Installation installation, string apkPath, string outputPath,
bool includeGameFiles, CancellationToken cancellationToken)
{
_reporter.Progress(InstallStages.Validate, "Checking the installation and the Quest app...", 1);
var workspace = installation.WorkspaceDirectory;
var generated = Path.Combine(workspace, "generated");
var script = Path.Combine(workspace, "android", "Build-QuestGame.ps1");
var manifest = Path.Combine(workspace, "projects", "mkwii", "recomp.yml");
var ninja = Path.Combine(installation.ToolkitDirectory, "Ninja", "ninja.exe");
if (!File.Exists(Path.Combine(generated, "build_shards", "shards.cmake")))
throw new InvalidOperationException(
"This installation has no translated game to build from. Repair WiiCompiled, then try again.");
if (!File.Exists(script) || !File.Exists(manifest) || !File.Exists(ninja))
throw new InvalidOperationException(
"This version of WiiCompiled cannot build for Quest. Update it to the release that matches the Quest app.");
if (!File.Exists(apkPath))
throw new FileNotFoundException("The Quest app (APK) was not found.", apkPath);
if (includeGameFiles && !IsGameData(installation.GameDataDirectory))
throw new InvalidOperationException(
"This installation's game files are missing or incomplete. Repair WiiCompiled, or build without game files.");
var questRoot = Path.Combine(installation.Root, QuestDirectoryName);
var kitDirectory = Path.Combine(questRoot, "kit");
_reporter.Progress(InstallStages.QuestKit, "Reading the game kit from the Quest app...", 2);
var kitFingerprint = ExtractKit(apkPath, kitDirectory, cancellationToken);
_reporter.Diagnostic($"Quest game kit {kitFingerprint}");
var toolchain = await EnsureToolchainAsync(questRoot, cancellationToken);
var temporaryOutput = outputPath + ".partial";
if (File.Exists(temporaryOutput)) File.Delete(temporaryOutput);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
var arguments = new List<string>
{
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script,
"-Generated", generated, "-Kit", kitDirectory, "-Manifest", manifest,
"-BuildDir", Path.Combine(questRoot, "build"), "-Output", temporaryOutput,
"-ClangCxx", toolchain.ClangCxx, "-ClangC", toolchain.ClangC, "-Sysroot", toolchain.Sysroot,
"-Ninja", ninja, "-BuiltBy", $"WiiCompiled Setup {ProductInfo.Version}"
};
if (includeGameFiles)
{
arguments.Add("-Data");
arguments.Add(installation.GameDataDirectory);
}
_reporter.Progress(InstallStages.QuestBuild, "Preparing the Quest build...", 40);
var powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),
"WindowsPowerShell", "v1.0", "powershell.exe");
try
{
var run = await ProcessRunner.RunAsync(powershell, arguments, ObserveBuild, cancellationToken,
start => start.WorkingDirectory = workspace, capture: false,
ex => _reporter.Diagnostic("The cancelled Quest build could not be stopped immediately: " + ex.Message));
if (run.ExitCode != 0)
throw new InvalidOperationException(
"Building the game for Quest failed. The setup log has the compiler output.");
if (!File.Exists(temporaryOutput))
throw new InvalidDataException("The Quest build finished without producing a game package.");
File.Move(temporaryOutput, outputPath, overwrite: true);
}
catch
{
// A package with game files is several GB; a failed or cancelled one must not stay behind.
TryDelete(temporaryOutput);
throw;
}
return new Result(outputPath, kitFingerprint, includeGameFiles, new FileInfo(outputPath).Length);
}
private void TryDelete(string path)
{
try
{
if (File.Exists(path)) File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_reporter.Diagnostic($"Could not remove {path}: {ex.Message}");
}
}
private void ObserveBuild(string line)
{
if (string.IsNullOrWhiteSpace(line)) return;
_reporter.Diagnostic(line);
var match = NinjaProgress.Match(line);
if (match.Success && int.TryParse(match.Groups[1].Value, out var done) &&
int.TryParse(match.Groups[2].Value, out var total) && total > 0)
{
_reporter.Progress(InstallStages.QuestBuild, "Compiling the game for Quest...", 40 + 54 * done / total);
}
else if (line.Contains("MKWCBUILD:STEP:quest-package", StringComparison.Ordinal))
{
_reporter.Progress(InstallStages.QuestPackage, "Packaging the game for Quest...", 95);
}
}
private static bool IsGameData(string directory) =>
Directory.Exists(Path.Combine(directory, "files")) &&
File.Exists(Path.Combine(directory, "sys", "fst.bin")) &&
File.Exists(Path.Combine(directory, "sys", "main.dol"));
/// <summary>Extracts <c>assets/game_kit</c> from the Quest app's APK and returns its fingerprint.</summary>
internal static string ExtractKit(string apkPath, string destination, CancellationToken cancellationToken)
{
const string prefix = "assets/game_kit/";
if (Directory.Exists(destination)) Directory.Delete(destination, recursive: true);
Directory.CreateDirectory(destination);
var root = Path.GetFullPath(destination) + Path.DirectorySeparatorChar;
using (var apk = ZipFile.OpenRead(apkPath))
{
foreach (var entry in apk.Entries)
{
cancellationToken.ThrowIfCancellationRequested();
if (!entry.FullName.StartsWith(prefix, StringComparison.Ordinal) || entry.FullName.EndsWith('/'))
continue;
var target = Path.GetFullPath(Path.Combine(destination, entry.FullName[prefix.Length..]));
if (!target.StartsWith(root, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"The Quest app contains an invalid game kit path: {entry.FullName}");
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
entry.ExtractToFile(target, overwrite: true);
}
}
var kitJson = Path.Combine(destination, "kit.json");
if (!File.Exists(kitJson))
throw new InvalidDataException(
"This APK is not a WiiCompiled Quest app with a game kit. Choose the Quest app installed on the headset.");
using var document = JsonDocument.Parse(File.ReadAllText(kitJson));
return document.RootElement.TryGetProperty("fingerprint", out var fingerprint) &&
fingerprint.ValueKind == JsonValueKind.String
? fingerprint.GetString()!
: throw new InvalidDataException("The Quest app's game kit has no fingerprint.");
}
private async Task<Toolchain> EnsureToolchainAsync(string questRoot, CancellationToken cancellationToken)
{
var configured = Environment.GetEnvironmentVariable(ToolchainOverrideVariable);
if (!string.IsNullOrWhiteSpace(configured))
{
_reporter.Diagnostic($"Using the NDK toolchain at {configured} ({ToolchainOverrideVariable})");
return ToolchainIn(configured);
}
var llvm = Path.Combine(questRoot, "android-ndk-" + Ndk.Revision);
var marker = Path.Combine(llvm, ".complete");
if (File.Exists(marker) && File.ReadAllText(marker).Trim() == Ndk.Sha1)
return ToolchainIn(llvm);
var downloads = Path.Combine(questRoot, "downloads");
Directory.CreateDirectory(downloads);
var archive = Path.Combine(downloads, Ndk.ArchiveName);
if (!File.Exists(archive) || new FileInfo(archive).Length != Ndk.Size || Sha1Of(archive) != Ndk.Sha1)
await DownloadNdkAsync(archive, cancellationToken);
_reporter.Progress(InstallStages.QuestToolchain, "Unpacking the Android compiler...", 34);
var staging = llvm + ".extracting";
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
using (var zip = ZipFile.OpenRead(archive))
{
foreach (var entry in zip.Entries)
{
cancellationToken.ThrowIfCancellationRequested();
var relative = SelectToolchainEntry(entry.FullName);
if (relative is null) continue;
var target = Path.Combine(staging, relative.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
entry.ExtractToFile(target, overwrite: true);
}
}
_ = ToolchainIn(staging);
if (Directory.Exists(llvm)) Directory.Delete(llvm, recursive: true);
Directory.Move(staging, llvm);
File.WriteAllText(marker, Ndk.Sha1);
File.Delete(archive);
return ToolchainIn(llvm);
}
private async Task DownloadNdkAsync(string archive, CancellationToken cancellationToken)
{
_reporter.Progress(InstallStages.QuestToolchain,
$"Downloading the Android NDK compiler from Google (about {Ndk.Size / 1_000_000} MB, Android SDK License: {Ndk.LicenseUrl})...", 5);
var partial = archive + ".partial";
using (var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan })
using (var response = await http.GetAsync(Ndk.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
{
response.EnsureSuccessStatusCode();
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destination = File.Create(partial);
using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1);
var buffer = new byte[1 << 20];
long received = 0;
var lastPercent = -1;
while (true)
{
var read = await source.ReadAsync(buffer, cancellationToken);
if (read == 0) break;
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
sha1.AppendData(buffer, 0, read);
received += read;
var percent = 5 + (int)(28 * Math.Min(received, Ndk.Size) / Ndk.Size);
if (percent != lastPercent)
{
lastPercent = percent;
_reporter.Progress(InstallStages.QuestToolchain,
$"Downloading the Android NDK compiler ({received / 1_000_000} of {Ndk.Size / 1_000_000} MB)...", percent);
}
}
var digest = Convert.ToHexString(sha1.GetHashAndReset()).ToLowerInvariant();
if (received != Ndk.Size || digest != Ndk.Sha1)
throw new InvalidDataException(
$"The Android NDK download is damaged (size {received}, SHA-1 {digest}). Try again.");
}
File.Move(partial, archive, overwrite: true);
}
/// <summary>
/// The files of the NDK's Windows toolchain a game build needs, as their path below the LLVM
/// directory, or null: clang and lld with their DLLs, clang's own headers and the aarch64
/// runtime pieces, and the aarch64 half of the sysroot. About 230 MB of the 2.5 GB NDK.
/// </summary>
internal static string? SelectToolchainEntry(string entryName)
{
if (!entryName.StartsWith(Ndk.LlvmPrefix, StringComparison.Ordinal)) return null;
var relative = entryName[Ndk.LlvmPrefix.Length..];
if (relative.Length == 0 || relative.EndsWith('/')) return null;
var keep = relative is "bin/clang.exe" or "bin/clang++.exe" or "bin/ld.lld.exe"
or "bin/libwinpthread-1.dll" or "bin/libxml2.dll" ||
Regex.IsMatch(relative, @"^lib/clang/[^/]+/include/") ||
Regex.IsMatch(relative, @"^lib/clang/[^/]+/lib/linux/libclang_rt\.builtins-aarch64-android\.a$") ||
Regex.IsMatch(relative, @"^lib/clang/[^/]+/lib/linux/aarch64/(libunwind|libatomic)\.a$") ||
(relative.StartsWith("sysroot/usr/include/", StringComparison.Ordinal) &&
!OtherArchitectureInclude.IsMatch(relative)) ||
Regex.IsMatch(relative, @"^sysroot/usr/lib/aarch64-linux-android/([^/]+|29/.+)$");
return keep ? relative : null;
}
private static Toolchain ToolchainIn(string llvmDirectory)
{
var toolchain = new Toolchain(
Path.Combine(llvmDirectory, "bin", "clang++.exe"),
Path.Combine(llvmDirectory, "bin", "clang.exe"),
Path.Combine(llvmDirectory, "sysroot"));
if (!File.Exists(toolchain.ClangCxx) || !File.Exists(toolchain.ClangC) || !Directory.Exists(toolchain.Sysroot))
throw new InvalidDataException($"The Android NDK toolchain at {llvmDirectory} is incomplete.");
return toolchain;
}
private static string Sha1Of(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA1.HashData(stream)).ToLowerInvariant();
}
}
@@ -98,6 +98,9 @@ internal static class SelfTests
Test("Toolkit fingerprint", TestToolkitFingerprint, failures);
Test("Retro Rewind inspection state machine", TestCodePulStateMachine, failures);
Test("Products report contract", TestProductsReport, failures);
Test("Quest build command line", TestQuestBuildCommandLine, failures);
Test("Quest toolchain selection", TestQuestToolchainSelection, failures);
Test("Quest game kit extraction", TestQuestKitExtraction, failures);
foreach (var failure in failures) Console.Error.WriteLine("FAILED " + failure);
return failures.Count == 0 ? 0 : 1;
}
@@ -292,6 +295,105 @@ internal static class SelfTests
}
}
private static void TestQuestBuildCommandLine()
{
var build = CommandLine.Parse([
"--build-quest", "--install-dir", "C:\\Games\\MKW", "--quest-apk", "quest.apk",
"--output", "D:\\MarioKartWii.wcgame", "--include-game-files", "--progress-json"
]);
if (build.Mode != AppMode.BuildQuest || build.QuestApkPath != "quest.apk" ||
build.OutputPath != "D:\\MarioKartWii.wcgame" || !build.IncludeGameFiles || !build.ProgressJson)
throw new Exception("The Quest build contract did not parse.");
void Rejects(string description, params string[] args)
{
try
{
CommandLine.Parse(args);
throw new Exception(description + " was accepted.");
}
catch (ArgumentException) { }
}
Rejects("A Quest build without the app", "--build-quest", "--install-dir", "i", "--output", "o.wcgame");
Rejects("A Quest build without an output", "--build-quest", "--install-dir", "i", "--quest-apk", "a.apk");
Rejects("A Quest build without an installation", "--build-quest", "--quest-apk", "a.apk", "--output", "o");
Rejects("--quest-apk outside a Quest build", "--launch-base", "--quest-apk", "a.apk");
Rejects("--include-game-files outside a Quest build", "--check-products", "--include-game-files");
}
private static void TestQuestToolchainSelection()
{
var prefix = QuestBuildService.Ndk.LlvmPrefix;
(string Entry, string? Expected)[] cases =
[
(prefix + "bin/clang.exe", "bin/clang.exe"),
(prefix + "bin/ld.lld.exe", "bin/ld.lld.exe"),
(prefix + "bin/libxml2.dll", "bin/libxml2.dll"),
(prefix + "bin/clang-tidy.exe", null),
(prefix + "lib/clang/21/include/arm_neon.h", "lib/clang/21/include/arm_neon.h"),
(prefix + "lib/clang/21/lib/linux/libclang_rt.builtins-aarch64-android.a",
"lib/clang/21/lib/linux/libclang_rt.builtins-aarch64-android.a"),
(prefix + "lib/clang/21/lib/linux/libclang_rt.builtins-x86_64-android.a", null),
(prefix + "lib/clang/21/lib/linux/aarch64/libunwind.a", "lib/clang/21/lib/linux/aarch64/libunwind.a"),
(prefix + "sysroot/usr/include/stdio.h", "sysroot/usr/include/stdio.h"),
(prefix + "sysroot/usr/include/aarch64-linux-android/asm/unistd.h",
"sysroot/usr/include/aarch64-linux-android/asm/unistd.h"),
(prefix + "sysroot/usr/include/x86_64-linux-android/asm/unistd.h", null),
(prefix + "sysroot/usr/lib/aarch64-linux-android/libc++_shared.so",
"sysroot/usr/lib/aarch64-linux-android/libc++_shared.so"),
(prefix + "sysroot/usr/lib/aarch64-linux-android/29/crtbegin_so.o",
"sysroot/usr/lib/aarch64-linux-android/29/crtbegin_so.o"),
(prefix + "sysroot/usr/lib/aarch64-linux-android/30/crtbegin_so.o", null),
(prefix + "sysroot/usr/lib/arm-linux-androideabi/libc++_shared.so", null),
("android-ndk-r29/source.properties", null),
];
foreach (var (entry, expected) in cases)
{
var actual = QuestBuildService.SelectToolchainEntry(entry);
if (actual != expected)
throw new Exception($"{entry} selected as {actual ?? "(skipped)"}, expected {expected ?? "(skipped)"}.");
}
}
private static void TestQuestKitExtraction()
{
var temp = Path.Combine(Path.GetTempPath(), "mkwc-questkit-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temp);
try
{
var apk = Path.Combine(temp, "app.apk");
using (var zip = System.IO.Compression.ZipFile.Open(apk, System.IO.Compression.ZipArchiveMode.Create))
{
void Add(string name, string text)
{
using var writer = new StreamWriter(zip.CreateEntry(name).Open());
writer.Write(text);
}
Add("assets/game_kit/kit.json", "{\"schema\":1,\"fingerprint\":\"abc123\"}");
Add("assets/game_kit/include/memory.h", "#pragma once");
Add("assets/runtime_resources/dsp_coef.bin", "not the kit");
Add("lib/arm64-v8a/libSDL3.so", "not the kit");
}
var kit = Path.Combine(temp, "kit");
if (QuestBuildService.ExtractKit(apk, kit, CancellationToken.None) != "abc123")
throw new Exception("The kit fingerprint was not read.");
if (!File.Exists(Path.Combine(kit, "include", "memory.h")) ||
Directory.GetFiles(kit, "*", SearchOption.AllDirectories).Length != 2)
throw new Exception("The kit extraction took the wrong files.");
var notQuest = Path.Combine(temp, "other.apk");
using (var zip = System.IO.Compression.ZipFile.Open(notQuest, System.IO.Compression.ZipArchiveMode.Create))
zip.CreateEntry("lib/arm64-v8a/libmain.so");
try
{
QuestBuildService.ExtractKit(notQuest, kit, CancellationToken.None);
throw new Exception("An APK without a game kit was accepted.");
}
catch (InvalidDataException) { }
}
finally { Directory.Delete(temp, recursive: true); }
}
private static void TestCommandLineContract()
{
void Rejects(string description, params string[] args)