mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 02:00:14 +02:00
Added Android Launcher and Disk Extration
This commit is contained in:
1 parent
1346e53cd6
commit
2f85207c39
74 files changed
+6153
-70
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user