diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md index 1857822..823313f 100644 --- a/DISTRIBUTION.md +++ b/DISTRIBUTION.md @@ -24,7 +24,8 @@ dotnet test translator/Translator.sln -c Release ``` `--version` still prints a plain version. `--info-json` additionally reports `productId` as -`wiicompiled-openxr-vr`, `version`, and `openxrD3D12: true`. VR refuses installation, repair, +`wiicompiled-openxr-vr`, `version`, `openxrD3D12: true`, and `questBuild: true` when the setup +supports `--build-quest` (see `docs/quest-port.md`). VR refuses installation, repair, launch, or uninstall of a normal or unidentified installation. Nonportable VR installations use `WiiCompiledOpenXRVR` application data and a distinct uninstall registration and shortcut. diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index 0f37c08..b1d5b90 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -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") } diff --git a/Launcher/WiiCompiled.Setup.Windows/CommandLine.cs b/Launcher/WiiCompiled.Setup.Windows/CommandLine.cs index 4d2ffd6..7f862c5 100644 --- a/Launcher/WiiCompiled.Setup.Windows/CommandLine.cs +++ b/Launcher/WiiCompiled.Setup.Windows/CommandLine.cs @@ -15,7 +15,8 @@ internal enum AppMode RepairProducts, Version, InfoJson, - EmitPayloadIdentities + EmitPayloadIdentities, + BuildQuest } /// @@ -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"); diff --git a/Launcher/WiiCompiled.Setup.Windows/ConsoleCommands.cs b/Launcher/WiiCompiled.Setup.Windows/ConsoleCommands.cs index ba2bf7f..4727859 100644 --- a/Launcher/WiiCompiled.Setup.Windows/ConsoleCommands.cs +++ b/Launcher/WiiCompiled.Setup.Windows/ConsoleCommands.cs @@ -16,6 +16,8 @@ internal static class ConsoleCommands Console.Out.WriteLine(" --check-products [--install-dir ] [--retro-dir ] [--progress-json]"); Console.Out.WriteLine(" --repair-products --install-dir --retro-dir " + "(--download-retro-wfc-payload | --skip-retro-wfc-payload) [--progress-json]"); + Console.Out.WriteLine(" --build-quest --install-dir --quest-apk --output " + + "[--include-game-files] [--progress-json]"); Console.Out.WriteLine(" --launch-retro | --launch-base"); Console.Out.WriteLine(" --uninstall --install-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 } } + /// + /// Builds the player's game for the Meta Quest app from this installation's translation and writes it + /// as a .wcgame (see ). Holds the installation's operation lock, so it + /// never races a repair of the translation it compiles. + /// + public static async Task 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."); + } + } + /// /// The products 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 diff --git a/Launcher/WiiCompiled.Setup.Windows/InstallProgress.cs b/Launcher/WiiCompiled.Setup.Windows/InstallProgress.cs index 2bacbce..681b0e2 100644 --- a/Launcher/WiiCompiled.Setup.Windows/InstallProgress.cs +++ b/Launcher/WiiCompiled.Setup.Windows/InstallProgress.cs @@ -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"; } /// @@ -93,6 +97,23 @@ internal sealed class NdjsonInstallReporter : IInstallReporter _log?.Invoke("FAILED: " + error); } + /// + /// --build-quest's product: where the game package is and what it was built against. Written + /// once, before the terminal result line. + /// + 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 + }); + } + } + /// /// 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. diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs index d273177..24e8d2d 100644 --- a/Launcher/WiiCompiled.Setup.Windows/Program.cs +++ b/Launcher/WiiCompiled.Setup.Windows/Program.cs @@ -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) diff --git a/Launcher/WiiCompiled.Setup.Windows/QuestBuildService.cs b/Launcher/WiiCompiled.Setup.Windows/QuestBuildService.cs new file mode 100644 index 0000000..eeb5e97 --- /dev/null +++ b/Launcher/WiiCompiled.Setup.Windows/QuestBuildService.cs @@ -0,0 +1,306 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace WiiCompiled.Setup.Windows; + +/// +/// --build-quest: 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 kit.json, 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 +/// android\Build-QuestGame.ps1, which compiles the translation against the kit and writes a +/// .wcgame the headset imports. With game files included, the package also carries the +/// extracted disc, so the headset needs nothing else. +/// +internal sealed class QuestBuildService +{ + /// The NDK the game kit is built with (android/app/build.gradle.kts, ndkVersion). + 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"; + } + + /// Developers point this at an installed NDK's toolchains\llvm\prebuilt\windows-x86_64. + 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 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 + { + "-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")); + + /// Extracts assets/game_kit from the Quest app's APK and returns its fingerprint. + 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 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); + } + + /// + /// 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. + /// + 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(); + } +} diff --git a/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs b/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs index 5ff387f..f29f0c6 100644 --- a/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs +++ b/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs @@ -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) diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 1693dec..2badf0b 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -141,6 +141,19 @@ trees themselves (fetched by `Launcher/Prepare-Dependencies.ps1`) so end-user bu | OpenXR-SDK | 1.1.61 | Apache-2.0 | | | nodtool (disc image extraction) | v2.0.0-alpha.10 | MIT OR Apache-2.0 | | +The Meta Quest APK builds nod itself into `libnod_jni.so` (`android/nod-jni`) for its disc image +extraction, and that library also carries the compression code nod reads WIA and RVZ images with. +The Rust crates pulled in by `android/nod-jni/Cargo.lock` are all MIT, Apache-2.0, BSD, Zlib, +Unicode-3.0, BSL-1.0 or Unlicense licensed. + +| Component (Quest APK) | Version | License | Upstream | +| --- | --- | --- | --- | +| nod | v2.0.0-alpha.10 | MIT OR Apache-2.0 | | +| Zstandard (via zstd-sys) | 1.5.7 | BSD-3-Clause | | +| XZ Utils liblzma (via liblzma-sys) | liblzma-sys 0.4.9 | 0BSD | | +| bzip2 (via bzip2-sys) | 1.0.8 | bzip2 license (BSD-style) | | +| jni-rs | 0.21.1 | MIT OR Apache-2.0 | | + ### Dual-licensed components - elections made by this project - **FreeType** is offered under the FreeType License (FTL) or GPL-2.0. **This project elects the @@ -174,6 +187,12 @@ unmodified, with their license texts, in the installer's `licenses/` folder. > supply it on request for the exact version shipped in any given release. Pins live in > `Launcher/Prepare-PortableTools.ps1` and `Launcher/NativeBuildFlags.ps1`. +## Downloaded on the user's machine, never redistributed + +| Component | License | Upstream | +| --- | --- | --- | +| Android NDK r29 for Windows (clang, lld, sysroot), fetched by `--build-quest` from Google with a pinned SHA-1 | Android Software Development Kit License Agreement | | + --- ## Development-only dependencies diff --git a/android/Build-Quest.ps1 b/android/Build-Quest.ps1 index 457c6e4..f951e6d 100644 --- a/android/Build-Quest.ps1 +++ b/android/Build-Quest.ps1 @@ -87,10 +87,21 @@ $apk = Get-ChildItem -Path $apkDir -Filter '*.apk' | Select-Object -First 1 if (-not $apk) { throw "No APK under $apkDir" } Write-Host "APK: $($apk.FullName)" +# The base APK must be distributable: no translated game inside, only the game kit. +if ($Flavor -eq 'base') { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($apk.FullName) + try { + $gameLibraries = @($archive.Entries | Where-Object { $_.FullName -match '^lib/[^/]+/libmain[^/]*\.so$' }) + $hasKit = @($archive.Entries | Where-Object { $_.FullName -eq 'assets/game_kit/kit.json' }).Count -gt 0 + } finally { $archive.Dispose() } + if ($gameLibraries.Count -gt 0) { throw "The APK contains a translated game library: $($gameLibraries.FullName -join ', ')" } + if (-not $hasKit) { throw 'The APK has no game kit (assets/game_kit/kit.json)' } +} + if ($Install) { $adb = Join-Path $sdkRoot 'platform-tools\adb.exe' & $adb install -r $apk.FullName if ($LASTEXITCODE -ne 0) { throw "adb install failed ($LASTEXITCODE)" } - Write-Host 'Installed. Push the extracted disc with:' - Write-Host ' adb push DATA /sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA' + Write-Host 'Installed. Build and push the game with android/Build-QuestGame.ps1 -Install.' } diff --git a/android/Build-QuestGame.ps1 b/android/Build-QuestGame.ps1 new file mode 100644 index 0000000..eba3fc2 --- /dev/null +++ b/android/Build-QuestGame.ps1 @@ -0,0 +1,105 @@ +# Builds the player's Quest game on a PC: compiles the translator's output for their own disc +# against the Quest game kit (see QuestGameKit.psm1) and packages libmain.so as a .wcgame the +# headset imports ("Import from computer" in the launcher). +# +# Two callers. A developer runs it from the checkout, where the toolchain defaults to the Android +# SDK's NDK and ninja: +# +# powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 [-Generated ] [-Kit ] +# [-Data ] [-Output ] [-Install] +# +# WiiCompiled Setup's --build-quest runs the copy staged in an installation's BuildWorkspace\android, +# naming everything explicitly: the kit it extracted from the Quest app, the NDK toolchain it +# downloaded, the toolkit's ninja and the workspace's recomp.yml. +# +# -Generated is the translator output (the dev workspace's generated/ by default). -Kit is the game +# kit the Quest app carries; it defaults to the one the last APK build exported. -Data adds the +# extracted disc (the directory holding sys/ and files/) so the headset needs nothing else. +# -Install pushes the package into the app's Import folder over adb, where the launcher picks it +# up the next time it opens. +[CmdletBinding()] +param( + [string]$Generated = '', + [string]$Kit = '', + [string]$Output = '', + [string]$Data = '', + [string]$BuildDir = '', + [string]$Manifest = '', + [string]$ClangCxx = '', + [string]$ClangC = '', + [string]$Sysroot = '', + [string]$Ninja = '', + [string]$BuiltBy = 'android/Build-QuestGame.ps1', + [int]$TranslatedJobs = 0, + [switch]$Install +) +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'QuestGameKit.psm1') -Force + +$root = $PSScriptRoot +$workspace = (Resolve-Path (Join-Path $root '..')).Path +if (-not $Generated) { $Generated = Join-Path $workspace '.scratch\vr-build-workspace\BuildWorkspace\generated' } +if (-not $Manifest) { $Manifest = Join-Path $workspace 'projects\mkwii\recomp.yml' } +if (-not $BuildDir) { $BuildDir = Join-Path $root 'app\build\questGame' } +if (-not $Kit) { + # The kit the last APK build packaged (the exportBase*QuestGameKit tasks), so the game + # matches the app it will be imported into. + $Kit = Get-ChildItem -Path (Join-Path $root 'app\build\generated') -Filter kit.json -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Directory.Name -eq 'game_kit' } | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 | ForEach-Object { $_.DirectoryName } +} +if (-not $Kit -or -not (Test-Path (Join-Path $Kit 'kit.json'))) { throw 'No game kit; run Build-Quest.ps1 first or pass -Kit' } +if (-not (Test-Path (Join-Path $Generated 'build_shards\shards.cmake'))) { throw "No translated graph at $Generated" } + +$sdkRoot = if ($env:ANDROID_HOME) { $env:ANDROID_HOME } elseif ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android\Sdk' } +if (-not $ClangCxx -or -not $ClangC -or -not $Sysroot) { + $llvm = Join-Path $sdkRoot 'ndk\29.0.14206865\toolchains\llvm\prebuilt\windows-x86_64' + if (-not $ClangCxx) { $ClangCxx = Join-Path $llvm 'bin\clang++.exe' } + if (-not $ClangC) { $ClangC = Join-Path $llvm 'bin\clang.exe' } + if (-not $Sysroot) { $Sysroot = Join-Path $llvm 'sysroot' } +} +foreach ($tool in $ClangCxx, $ClangC) { if (-not (Test-Path $tool)) { throw "Compiler not found: $tool" } } +if (-not $Ninja) { + $Ninja = Get-ChildItem -Path (Join-Path $sdkRoot 'cmake') -Filter ninja.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName + if (-not $Ninja) { $Ninja = (Get-Command ninja.exe -ErrorAction SilentlyContinue).Source } +} +if (-not $Ninja -or -not (Test-Path $Ninja)) { throw 'ninja.exe not found' } + +# Translated shards need roughly 1.5 GB each at -O2; leave a margin for the rest of the machine. +if ($TranslatedJobs -le 0) { + $memoryGb = [math]::Floor((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB) + $TranslatedJobs = [math]::Max(1, [math]::Min([Environment]::ProcessorCount, [math]::Floor(($memoryGb - 4) / 1.5))) +} + +# The disc this translation was made from: recomp.yml's pins, which the translator enforced. +$pins = [IO.File]::ReadAllText($Manifest) +$gameId = [regex]::Match($pins, '\n\s*game_id:\s*(\w+)').Groups[1].Value +$dolSha = [regex]::Match($pins, '(?s)\n\s*dol:.*?sha256:\s*([0-9a-fA-F]{64})').Groups[1].Value +$relSha = [regex]::Match($pins, '(?s)\n\s*rel:.*?sha256:\s*([0-9a-fA-F]{64})').Groups[1].Value +if (-not $gameId -or -not $dolSha -or -not $relSha) { throw "Cannot read the disc pins from $Manifest" } + +$stopwatch = [Diagnostics.Stopwatch]::StartNew() +$library = Invoke-QuestGameBuild -KitDir $Kit -GeneratedDir $Generated -BuildDir $BuildDir ` + -ClangCxx $ClangCxx -ClangC $ClangC -Sysroot $Sysroot -Ninja $Ninja -TranslatedJobs $TranslatedJobs +Write-Host ("Built {0} in {1:N1} min" -f $library, $stopwatch.Elapsed.TotalMinutes) + +if (-not $Output) { $Output = Join-Path $BuildDir 'MarioKartWii.wcgame' } +Write-Host 'MKWCBUILD:STEP:quest-package Packaging the game for Quest' +$game = New-QuestGamePackage -Library $library -DataDir $Data -KitDir $Kit -GameId $gameId -DolSha256 $dolSha -RelSha256 $relSha ` + -BuiltBy $BuiltBy -OutputPath $Output +Write-Host "Game package: $Output (kit $($game.kitFingerprint))" + +if ($Install) { + $adb = Join-Path $sdkRoot 'platform-tools\adb.exe' + $import = '/sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/Import' + # The launcher creates Import itself and must own it to remove imported packages (see + # Run-Quest.ps1 on adb-created directories), so only push into one that exists. + $exists = ((& $adb shell "test -d '$import' && echo yes") | Out-String).Trim() + if ($exists -ne 'yes') { throw 'The launcher has not created its Import folder yet; open the app once on the headset first.' } + $remote = "$import/$([IO.Path]::GetFileName($Output))" + & $adb push $Output $remote + if ($LASTEXITCODE -ne 0) { throw "adb push failed ($LASTEXITCODE)" } + # The folder belongs to the app; only the pushed file is the shell user's to open up. + & $adb shell "chmod a+r '$remote'" + Write-Host 'Pushed; the launcher imports it the next time it opens.' +} diff --git a/android/Export-QuestGameKit.ps1 b/android/Export-QuestGameKit.ps1 new file mode 100644 index 0000000..f895b93 --- /dev/null +++ b/android/Export-QuestGameKit.ps1 @@ -0,0 +1,16 @@ +# Exports the Quest game kit (see QuestGameKit.psm1) from a configured and built Android CMake +# tree. android/app/build.gradle.kts runs this after the native build and packages the result as +# the APK's game_kit assets. +# +# powershell -ExecutionPolicy Bypass -File android/Export-QuestGameKit.ps1 -CMakeBinaryDir -OutputDir -LlvmStrip +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$CMakeBinaryDir, + [Parameter(Mandatory)] [string]$OutputDir, + [Parameter(Mandatory)] [string]$LlvmStrip +) +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'QuestGameKit.psm1') -Force +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$fingerprint = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip +Write-Host "Quest game kit $fingerprint -> $OutputDir" diff --git a/android/QuestGameKit.psm1 b/android/QuestGameKit.psm1 new file mode 100644 index 0000000..de295f2 --- /dev/null +++ b/android/QuestGameKit.psm1 @@ -0,0 +1,424 @@ +# The Quest game kit: everything libmain.so links except the translated game, plus the recipe +# that compiles and links the player's own translation against it. +# +# The APK never contains translated Mario Kart code. Its assets carry this kit instead, and the +# player's libmain.so is built from their own disc, either on the headset (the launcher's +# on-device builder) or on a PC (Build-QuestGame.ps1, the PC launcher's "Build for Quest"), then +# loaded from the app's private storage. Both builders replay one recipe, kit.json, which is +# exported from CMake's own build graph so its flags and link order cannot drift from a normal +# build: +# +# * the link line of mkw_quest_kit_probe (runtime/cmake/PublicProducts.cmake), which is +# WiiCompiled without the game, becomes kit.json's link inputs, with {game:*} markers where +# the game's objects sat in WiiCompiled's own link; +# * the compile commands CMake recorded for one source of each generated kind (translated shard, +# registration shard, disc-generated runtime source, blob assembly) become kit.json's +# compile flags, with include paths reduced to the kit's copy of runtime/include. +# +# Placeholders in kit.json: {kit} the extracted kit, {sysroot} the NDK sysroot the compiler +# uses, {workspace} the directory holding the translator's generated/ tree. +# +# Windows PowerShell 5.1 compatible. + +Set-StrictMode -Version Latest + +$script:KitSchema = 1 +$script:GameSchema = 1 + +function ConvertTo-ForwardPath([string]$Path) { + return [IO.Path]::GetFullPath($Path).Replace('\', '/').TrimEnd('/') +} + +function ConvertFrom-NinjaPath([string]$Text) { + return $Text.Replace('$:', ':').Replace('$ ', ' ').Replace('$$', '$') +} + +# Splits a CMake compile command. CMake quotes only arguments that need it and escapes inner +# quotes as \" (for example -DIMGUI_USER_CONFIG=\"aurora/imgui_config.h\"). +function Split-CommandLine([string]$Command) { + $arguments = New-Object System.Collections.Generic.List[string] + $current = New-Object System.Text.StringBuilder + $inQuotes = $false + $pending = $false + for ($i = 0; $i -lt $Command.Length; $i++) { + $c = $Command[$i] + if ($c -eq '\' -and $i + 1 -lt $Command.Length -and $Command[$i + 1] -eq '"') { + [void]$current.Append('"'); $i++; $pending = $true; continue + } + if ($c -eq '"') { $inQuotes = -not $inQuotes; $pending = $true; continue } + if (-not $inQuotes -and [char]::IsWhiteSpace($c)) { + if ($pending) { $arguments.Add($current.ToString()); [void]$current.Clear(); $pending = $false } + continue + } + [void]$current.Append($c); $pending = $true + } + if ($pending) { $arguments.Add($current.ToString()) } + return ,$arguments.ToArray() +} + +# One argument for a response file or a Windows command line (CommandLineToArgvW rules, which +# clang's GNU response-file tokenizer reads the same way for these arguments). +function ConvertTo-QuotedArgument([string]$Argument) { + if ($Argument -notmatch '[\s"]') { return $Argument } + return '"' + $Argument.Replace('"', '\"') + '"' +} + +function Get-Sha256Hex([string]$Path) { + $sha = [Security.Cryptography.SHA256]::Create() + try { + $stream = [IO.File]::OpenRead($Path) + try { $bytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() } + } finally { $sha.Dispose() } + return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '') +} + +function Get-StringSha256Hex([string]$Text) { + $sha = [Security.Cryptography.SHA256]::Create() + try { $bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Text)) } finally { $sha.Dispose() } + return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '') +} + +# Identity of a runtime/include tree. Translated code compiles against these headers, so a game is +# only built from a translation whose runtime headers are the kit's own. +function Get-RuntimeIncludeFingerprint([string]$Directory) { + $root = (Resolve-Path $Directory).Path.TrimEnd('\', '/') + $lines = Get-ChildItem -Recurse -File $root | Sort-Object FullName | ForEach-Object { + $_.FullName.Substring($root.Length + 1).Replace('\', '/') + ' ' + (Get-Sha256Hex $_.FullName) + } + return Get-StringSha256Hex ($lines -join "`n") +} + +# compile_commands.json as {file, command} objects. Windows PowerShell's ConvertFrom-Json refuses +# documents over 2 MB, so 5.1 uses the serializer underneath it with the limit lifted. +function Read-CompileCommands([string]$Path) { + $text = [IO.File]::ReadAllText($Path) + if ($PSVersionTable.PSVersion.Major -ge 6) { + return @($text | ConvertFrom-Json | ForEach-Object { [pscustomobject]@{ file = $_.file; command = $_.command } }) + } + Add-Type -AssemblyName System.Web.Extensions + $serializer = New-Object System.Web.Script.Serialization.JavaScriptSerializer + $serializer.MaxJsonLength = [int]::MaxValue + return @($serializer.DeserializeObject($text) | ForEach-Object { [pscustomobject]@{ file = $_['file']; command = $_['command'] } }) +} + +# Compile flags of one recorded command, reduced to what a generated source needs anywhere. +function ConvertTo-KitCompileFlags { + param([string[]]$Arguments, [string]$RuntimeInclude, [string]$Workspace) + $flags = New-Object System.Collections.Generic.List[string] + for ($i = 1; $i -lt $Arguments.Length; $i++) { + $a = $Arguments[$i] + switch -Regex -CaseSensitive ($a) { + '^(-o|-c|-MT|-MF)$' { $i++; continue } + '^(-MD|-MMD|-g|-Winvalid-pch)$' { continue } + '^-isystem$' { $i++; continue } + '^--sysroot=' { $flags.Add('--sysroot={sysroot}'); continue } + '^-Xclang$' { + if ($i + 3 -lt $Arguments.Length -and $Arguments[$i + 1] -eq '-include-pch') { $i += 3; continue } + if ($i + 3 -lt $Arguments.Length -and $Arguments[$i + 1] -eq '-include') { + # CMake's cmake_pch.hxx only includes the precompiled header; builders parse it + # directly (a PCH is only valid for the compiler that wrote it). + $i += 3; $flags.Add('-include'); $flags.Add('mkw_pch.h'); continue + } + $flags.Add($a); continue + } + '^-I' { + $path = ConvertTo-ForwardPath $a.Substring(2) + if ($path -eq $RuntimeInclude) { $flags.Add('-I{kit}/include') } + elseif ($path -eq $Workspace) { $flags.Add('-I{workspace}') } + continue + } + default { $flags.Add($a) } + } + } + return ,$flags.ToArray() +} + +function Export-QuestGameKit { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$CMakeBinaryDir, + [Parameter(Mandatory)] [string]$OutputDir, + [Parameter(Mandatory)] [string]$RepoRoot, + [Parameter(Mandatory)] [string]$LlvmStrip + ) + $ErrorActionPreference = 'Stop' + $binary = ConvertTo-ForwardPath $CMakeBinaryDir + $runtimeInclude = ConvertTo-ForwardPath (Join-Path $RepoRoot 'runtime/include') + + # The probe's link edge: explicit inputs are the objects, LINK_LIBRARIES the archives. + $ninja = [IO.File]::ReadAllText("$binary/build.ninja") + $edge = [regex]::Match($ninja, '(?m)^build (\S*libmkw_quest_kit_probe\.so): (\S+) ([^\r\n]*)\r?\n((?: [^\r\n]*\r?\n)+)') + if (-not $edge.Success) { throw "No mkw_quest_kit_probe link edge in $binary/build.ninja" } + $explicit = ($edge.Groups[3].Value.Replace('$ ', '') -split ' \|')[0] + $objects = @($explicit -split ' ' | Where-Object { $_ } | ForEach-Object { ConvertFrom-NinjaPath $_.Replace('', '$ ') }) + $vars = @{} + foreach ($line in ($edge.Groups[4].Value -split "\r?\n")) { + if ($line -match '^ (\w+) = (.*)$') { $vars[$Matches[1]] = $Matches[2] } + } + $rule = [regex]::Match($ninja, "(?ms)^rule $([regex]::Escape($edge.Groups[2].Value))\r?\n.*?command = ([^\r\n]*)") + if (-not $rule.Success) { + $rules = [IO.File]::ReadAllText("$binary/CMakeFiles/rules.ninja") + $rule = [regex]::Match($rules, "(?ms)^rule $([regex]::Escape($edge.Groups[2].Value))\r?\n.*?command = ([^\r\n]*)") + } + $target = [regex]::Match($rule.Groups[1].Value, '--target=(\S+)').Groups[1].Value + if (-not $target) { throw "Cannot read the link target triple from rule $($edge.Groups[2].Value)" } + + # Compile flags per generated kind, from CMake's own commands. + $commands = Read-CompileCommands "$binary/compile_commands.json" + function Find-Command([string]$Pattern) { + $entry = @($commands | Where-Object { $_.file.Replace('\', '/') -match $Pattern }) + if ($entry.Count -eq 0) { throw "compile_commands.json has no source matching $Pattern" } + return $entry[0] + } + $translatedEntry = Find-Command '/build_shards/base_common/[^/]+\.cpp$' + $workspace = ConvertTo-ForwardPath ($translatedEntry.file.Replace('\', '/') -replace '/generated/build_shards/.*$', '') + $flagsFor = { + param($Entry) + ConvertTo-KitCompileFlags -Arguments (Split-CommandLine $Entry.command) -RuntimeInclude $runtimeInclude -Workspace $workspace + } + $compile = [ordered]@{ + translated = & $flagsFor $translatedEntry + product = & $flagsFor (Find-Command '/build_shards/base_registration/[^/]+\.cpp$') + runtime = & $flagsFor (Find-Command '/generated/data_sections_init\.cpp$') + asm = & $flagsFor (Find-Command 'data_sections_init_blobs[^/]*\.S$') + } + + if (Test-Path $OutputDir) { Remove-Item -Recurse -Force $OutputDir } + $objectsDir = Join-Path $OutputDir 'objects' + $libsDir = Join-Path $OutputDir 'libs' + New-Item -ItemType Directory -Force $objectsDir, $libsDir | Out-Null + Copy-Item -Recurse (Join-Path $RepoRoot 'runtime/include') (Join-Path $OutputDir 'include') + + $copied = @{} + $copy = { + param([string]$Source, [string]$Directory) + $full = ConvertTo-ForwardPath $(if ([IO.Path]::IsPathRooted($Source)) { $Source } else { "$binary/$Source" }) + if ($copied.ContainsKey($full)) { return $copied[$full] } + $script:kitIndex++ + $name = '{0:D3}_{1}' -f $script:kitIndex, [IO.Path]::GetFileName($full) + $destination = Join-Path (Join-Path $OutputDir $Directory) $name + & $LlvmStrip --strip-debug -o $destination $full + if ($LASTEXITCODE -ne 0) { throw "llvm-strip failed on $full" } + $copied[$full] = "{kit}/$Directory/$name" + return $copied[$full] + } + $script:kitIndex = 0 + + # Link inputs in the probe's order, with the game's slots marked where WiiCompiled has them. + $inputs = New-Object System.Collections.Generic.List[string] + $lastRuntime = -1 + for ($i = 0; $i -lt $objects.Count; $i++) { + if ($objects[$i] -match '/mkw_runtime_common\.dir/') { $lastRuntime = $i } + } + for ($i = 0; $i -lt $objects.Count; $i++) { + $inputs.Add((& $copy $objects[$i] 'objects')) + if ($i -eq $lastRuntime) { $inputs.Add('{game:runtime}') } + if ($objects[$i] -match '/base_product\.cpp\.o$') { $inputs.Add('{game:product}') } + } + if (-not $inputs.Contains('{game:runtime}') -or -not $inputs.Contains('{game:product}')) { + throw 'The probe link line lacks the runtime or product objects the game slots follow' + } + foreach ($token in ((ConvertFrom-NinjaPath $vars['LINK_LIBRARIES']) -split ' ' | Where-Object { $_ })) { + if ($token.StartsWith('-')) { $inputs.Add($token); continue } + $path = $token.Replace('\', '/') + if ($path -match '/sysroot/(.+)$') { $inputs.Add("{sysroot}/$($Matches[1])") } + else { $inputs.Add((& $copy $token 'libs')) } + if ($path -match '/libmkw_platform\.a$') { $inputs.Add('{game:translated}') } + } + if (-not $inputs.Contains('{game:translated}')) { throw 'The probe link line lacks libmkw_platform.a' } + + $linkFlags = New-Object System.Collections.Generic.List[string] + $linkFlags.Add("--target=$target"); $linkFlags.Add('--sysroot={sysroot}'); $linkFlags.Add('-fPIC') + foreach ($name in 'LANGUAGE_COMPILE_FLAGS', 'ARCH_FLAGS', 'LINK_FLAGS') { + if (-not $vars.ContainsKey($name)) { continue } + $tokens = Split-CommandLine (ConvertFrom-NinjaPath $vars[$name]) + for ($i = 0; $i -lt $tokens.Length; $i++) { + if ($tokens[$i] -eq '-g' -or $tokens[$i] -eq '-Wl,--unresolved-symbols=ignore-all') { continue } + if ($tokens[$i] -eq '-Xlinker' -and $i + 1 -lt $tokens.Length -and $tokens[$i + 1].StartsWith('--dependency-file')) { $i++; continue } + $linkFlags.Add($tokens[$i]) + } + } + $linkFlags.Add('-Wl,-soname,libmain.so') + + $recipe = [ordered]@{ + schema = $script:KitSchema + product = 'base' + output = 'libmain.so' + target = $target + runtimeIncludeFingerprint = Get-RuntimeIncludeFingerprint (Join-Path $OutputDir 'include') + compile = $compile + link = [ordered]@{ flags = $linkFlags.ToArray(); inputs = $inputs.ToArray() } + } + $recipeJson = $recipe | ConvertTo-Json -Depth 8 -Compress + $files = Get-ChildItem -Recurse -File $OutputDir | Sort-Object FullName | ForEach-Object { + $relative = $_.FullName.Substring((Resolve-Path $OutputDir).Path.Length).Replace('\', '/') + "$relative $(Get-Sha256Hex $_.FullName)" + } + $recipe.fingerprint = Get-StringSha256Hex ($recipeJson + "`n" + ($files -join "`n")) + [IO.File]::WriteAllText((Join-Path $OutputDir 'kit.json'), ($recipe | ConvertTo-Json -Depth 8), (New-Object Text.UTF8Encoding $false)) + return $recipe.fingerprint +} + +# The generated source lists emit-build-shards wrote into shards.cmake. +function Read-ShardList { + param([string]$ShardsCmake, [string]$Name) + $text = [IO.File]::ReadAllText($ShardsCmake) + $match = [regex]::Match($text, "(?s)set\($Name\s*(.*?)\)") + if (-not $match.Success) { return @() } + return @([regex]::Matches($match.Groups[1].Value, '"([^"]+)"') | ForEach-Object { $_.Groups[1].Value.Replace('\', '/') }) +} + +function Invoke-QuestGameBuild { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$KitDir, + [Parameter(Mandatory)] [string]$GeneratedDir, + [Parameter(Mandatory)] [string]$BuildDir, + [Parameter(Mandatory)] [string]$ClangCxx, + [Parameter(Mandatory)] [string]$ClangC, + [Parameter(Mandatory)] [string]$Sysroot, + [Parameter(Mandatory)] [string]$Ninja, + [Parameter(Mandatory)] [int]$TranslatedJobs + ) + $ErrorActionPreference = 'Stop' + $kit = ConvertTo-ForwardPath $KitDir + $generated = ConvertTo-ForwardPath $GeneratedDir + $workspace = ConvertTo-ForwardPath (Split-Path -Parent $generated) + $recipe = [IO.File]::ReadAllText("$kit/kit.json") | ConvertFrom-Json + if ($recipe.schema -ne $script:KitSchema) { throw "Unsupported game kit schema $($recipe.schema)" } + # The translation must come from the same release as the kit: its code is compiled against the + # kit's runtime headers and linked with the kit's runtime objects. + $workspaceInclude = Join-Path $workspace 'runtime/include' + if (Test-Path $workspaceInclude) { + if ((Get-RuntimeIncludeFingerprint $workspaceInclude) -ne $recipe.runtimeIncludeFingerprint) { + throw ('The Quest app and the translation on this PC come from different WiiCompiled releases ' + + '(their runtime headers differ). Update both to the same release, then build again.') + } + } + New-Item -ItemType Directory -Force $BuildDir | Out-Null + $build = ConvertTo-ForwardPath $BuildDir + $expand = { param([string]$s) $s.Replace('{kit}', $kit).Replace('{sysroot}', (ConvertTo-ForwardPath $Sysroot)).Replace('{workspace}', $workspace) } + + foreach ($kind in 'translated', 'product', 'runtime', 'asm') { + $lines = @($recipe.compile.$kind | ForEach-Object { ConvertTo-QuotedArgument (& $expand $_) }) + [IO.File]::WriteAllText("$build/$kind.rsp", ($lines -join "`n"), (New-Object Text.UTF8Encoding $false)) + } + $linkLines = @($recipe.link.flags | ForEach-Object { ConvertTo-QuotedArgument (& $expand $_) }) + [IO.File]::WriteAllText("$build/link.rsp", ($linkLines -join "`n"), (New-Object Text.UTF8Encoding $false)) + + # The Windows installer generates the blob assembly for PE/COFF; rewrite it for ELF exactly + # as runtime/cmake/PublicProducts.cmake does. + $blob = [IO.File]::ReadAllText("$generated/data_sections_init_blobs.S") + $elf = $blob.Replace('.section .rdata,"dr"', '.section .rodata,"a",@progbits') + if ($elf -ne $blob) { $elf += "`n.section .note.GNU-stack,`"`",@progbits`n" } + [IO.File]::WriteAllText("$build/data_sections_init_blobs_android.S", $elf, (New-Object Text.UTF8Encoding $false)) + + $shards = "$generated/build_shards/shards.cmake" + $sources = [ordered]@{ + runtime = @("$generated/data_sections_init.cpp", "$generated/guest_symbol_table.cpp") + runtimeAsm = @("$build/data_sections_init_blobs_android.S") + product = @(Read-ShardList $shards 'MKW_BASE_REGISTRATION_SOURCES') + translated = @((Read-ShardList $shards 'MKW_BASE_COMMON_SHARDS') + (Read-ShardList $shards 'MKW_BASE_PORTABLE_SENSITIVE_SHARDS')) + } + if ($sources.translated.Count -eq 0) { throw "No translated shards in $shards" } + + $ninjaText = New-Object System.Text.StringBuilder + $escape = { param([string]$p) $p.Replace('$', '$$').Replace(':', '$:').Replace(' ', '$ ') } + [void]$ninjaText.AppendLine('ninja_required_version = 1.10') + [void]$ninjaText.AppendLine("pool translated`n depth = $TranslatedJobs") + [void]$ninjaText.AppendLine("rule cxx`n command = $(ConvertTo-QuotedArgument $ClangCxx) @`$flags -c `$in -o `$out`n description = Compiling `$in") + [void]$ninjaText.AppendLine("rule asm`n command = $(ConvertTo-QuotedArgument $ClangC) @`$flags -c `$in -o `$out`n description = Assembling `$in") + [void]$ninjaText.AppendLine("rule link`n command = $(ConvertTo-QuotedArgument $ClangCxx) @`$flags -o `$out @`$out.rsp`n rspfile = `$out.rsp`n rspfile_content = `$inputs`n description = Linking `$out") + $objectsOf = @{ runtime = @(); product = @(); translated = @() } + $seen = @{} + foreach ($kind in 'runtime', 'runtimeAsm', 'product', 'translated') { + foreach ($source in $sources[$kind]) { + $stem = [IO.Path]::GetFileNameWithoutExtension($source) + $object = "obj/$stem.o" + if ($seen.ContainsKey($object)) { throw "Two generated sources share the object name $object" } + $seen[$object] = $true + $rule = if ($kind -eq 'runtimeAsm') { 'asm' } else { 'cxx' } + $flags = switch ($kind) { 'runtimeAsm' { 'asm.rsp' } default { "$kind.rsp" } } + [void]$ninjaText.AppendLine("build $(& $escape $object): $rule $(& $escape $source)`n flags = $(& $escape "$build/$flags")") + if ($kind -eq 'translated') { [void]$ninjaText.AppendLine(' pool = translated') } + $slot = if ($kind -eq 'runtimeAsm') { 'runtime' } else { $kind } + $objectsOf[$slot] += "$build/$object" + } + } + $linkInputs = New-Object System.Collections.Generic.List[string] + foreach ($linkInput in $recipe.link.inputs) { + switch ($linkInput) { + '{game:runtime}' { $objectsOf.runtime | ForEach-Object { $linkInputs.Add($_) } } + '{game:product}' { $objectsOf.product | ForEach-Object { $linkInputs.Add($_) } } + '{game:translated}' { + # Archive semantics, as libmkw_base_shared.a has in a CMake build. + $linkInputs.Add('-Wl,--start-lib') + $objectsOf.translated | ForEach-Object { $linkInputs.Add($_) } + $linkInputs.Add('-Wl,--end-lib') + } + default { $linkInputs.Add((& $expand $linkInput)) } + } + } + $allObjects = @($objectsOf.runtime + $objectsOf.product + $objectsOf.translated | ForEach-Object { & $escape $_.Substring($build.Length + 1) }) + $rspInputs = ($linkInputs | ForEach-Object { ConvertTo-QuotedArgument $_ }) -join ' ' + [void]$ninjaText.AppendLine("build libmain.so: link $($allObjects -join ' ')`n flags = $(& $escape "$build/link.rsp")`n inputs = $($rspInputs.Replace('$', '$$'))") + [IO.File]::WriteAllText("$build/build.ninja", $ninjaText.ToString(), (New-Object Text.UTF8Encoding $false)) + + # Ninja's progress goes to the console, not into this function's return value; its [n/N] + # lines are what WiiCompiled Setup turns into build progress. + Write-Host 'MKWCBUILD:STEP:quest-compile Compiling the game for Quest' + & $Ninja -C $build | Out-Host + if ($LASTEXITCODE -ne 0) { throw "Game build failed (ninja exit $LASTEXITCODE)" } + return "$build/libmain.so" +} + +# A .wcgame: the built library and what it was built from, for the headset to import. With +# -DataDir it also carries the extracted disc (DATA/...), written without compression, so a player who +# builds on a PC copies one file to the headset and never needs the disc image there. +function New-QuestGamePackage { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Library, + [string]$DataDir = '', + [Parameter(Mandatory)] [string]$KitDir, + [Parameter(Mandatory)] [string]$GameId, + [Parameter(Mandatory)] [string]$DolSha256, + [Parameter(Mandatory)] [string]$RelSha256, + [Parameter(Mandatory)] [string]$BuiltBy, + [Parameter(Mandatory)] [string]$OutputPath + ) + $ErrorActionPreference = 'Stop' + $recipe = [IO.File]::ReadAllText((Join-Path $KitDir 'kit.json')) | ConvertFrom-Json + $game = [ordered]@{ + schema = $script:GameSchema + profile = $recipe.product + gameId = $GameId + dolSha256 = $DolSha256 + relSha256 = $RelSha256 + kitFingerprint = $recipe.fingerprint + library = $recipe.output + librarySha256 = Get-Sha256Hex $Library + includesData = [bool]$DataDir + builtBy = $BuiltBy + builtAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + } + Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem + if (Test-Path $OutputPath) { Remove-Item -Force $OutputPath } + $zip = [IO.Compression.ZipFile]::Open($OutputPath, 'Create') + try { + $entry = $zip.CreateEntry('game.json') + $writer = New-Object IO.StreamWriter($entry.Open(), (New-Object Text.UTF8Encoding $false)) + try { $writer.Write(($game | ConvertTo-Json)) } finally { $writer.Dispose() } + [void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Library, $recipe.output, 'Optimal') + if ($DataDir) { + $dataRoot = (Resolve-Path $DataDir).Path.TrimEnd('\', '/') + foreach ($file in Get-ChildItem -Recurse -File $dataRoot) { + $name = 'DATA/' + $file.FullName.Substring($dataRoot.Length + 1).Replace('\', '/') + [void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $file.FullName, $name, 'NoCompression') + } + } + } finally { $zip.Dispose() } + return $game +} + +Export-ModuleMember -Function Export-QuestGameKit, Invoke-QuestGameBuild, New-QuestGamePackage diff --git a/android/README.md b/android/README.md index 4f0bc2e..9d3dd66 100644 --- a/android/README.md +++ b/android/README.md @@ -3,7 +3,8 @@ Standalone Android/OpenXR build of the Mario Kart Wii recompilation for Quest 2, Quest 3, Quest 3S and Quest Pro. The full design, build walkthrough and current status live in [docs/quest-port.md](../docs/quest-port.md); this directory only -holds the Gradle project and its two helper scripts. +holds the Gradle project, its helper scripts, the game kit tooling +(`QuestGameKit.psm1`, `Build-QuestGame.ps1`) and `nod-jni`. ```powershell powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # stages the SDL3 3.4.4 AAR once @@ -14,10 +15,23 @@ powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Flavor retroRe The translated game (the translator's `generated/` tree for **your own** PAL `RMCP01` disc) is passed with `-Generated `; it defaults to the installer workspace next to this checkout. No game data is ever part of the -APK: push the extracted disc afterwards to +APK, and neither is any translated game code: the base APK carries a game kit, +and your `libmain.so` is built from your own disc and imported +(`Build-QuestGame.ps1 -Install`, or **Import from computer** with its `.wcgame`). +Copy your disc image to the headset (for example `adb push MarioKart.iso +/sdcard/Download/`) and press **Select disc image** in the launcher, which checks +and extracts it with nod, as the PC installer does. Or push an already extracted +disc to ``` /sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA ``` `Config.toml`, saves and logs live in the same `WiiCompiledOpenXRVR` directory. + +The app opens on a 2D launcher panel modelled on the PC launcher (WheelWizard VR): +**Home** starts the game in the headset and says when DATA is missing, and +**Settings** edits `Config.toml` (VR camera, render scale, virtual screen, +resolution, controllers, audio). The game itself is `QuestActivity`, in its own +`:game` process; `adb shell am start -n org.wiicompiled.quest/.QuestActivity` +still starts it directly. diff --git a/android/Run-Quest.ps1 b/android/Run-Quest.ps1 index 47670d3..6cf351f 100644 --- a/android/Run-Quest.ps1 +++ b/android/Run-Quest.ps1 @@ -1,5 +1,6 @@ # Installs the built APK on the connected Quest, places the game data, launches -# the activity and collects diagnostics for one session. +# the game activity directly (skipping the launcher panel) and collects +# diagnostics for one session. # # powershell -ExecutionPolicy Bypass -File android/Run-Quest.ps1 [-Apk ] [-Data ] # [-Seconds 60] [-NoLaunch] [-SkipInstall] @@ -96,8 +97,9 @@ $logcat = Join-Path $runDir 'logcat.txt' & $adb logcat -d -v time | Out-File -FilePath $logcat -Encoding utf8 Write-Host "logcat: $logcat" -$running = ((& $adb shell pidof $package) | Out-String).Trim() -if ($running) { Write-Host "Process still running (pid $running)" } else { Write-Warning 'Process is not running' } +# The game runs in its own process; the package's main process is the launcher panel. +$running = ((& $adb shell pidof "${package}:game") | Out-String).Trim() +if ($running) { Write-Host "Game process still running (pid $running)" } else { Write-Warning 'Game process is not running' } $latest = ((& $adb shell "ls -t '$appDir/Logs' 2>/dev/null | head -1") | Out-String).Trim() if ($latest) { diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ddbc40d..b9eb5c7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,4 @@ +import javax.inject.Inject import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -26,6 +27,62 @@ val prepareRuntimeResources by tasks.registering(Copy::class) { into(runtimeResources) } +// The disc the launcher accepts when it extracts the player's image: the game ID and +// the clean PAL main.dol / StaticR.rel hashes, read from the same recomp.yml pins the +// PC installer's payload manifest uses (Launcher/Build-Installer.ps1). +val discPins: Map = run { + val manifest = File(mkwRepoRoot, "projects/mkwii/recomp.yml").readText() + fun pin(pattern: String): String = + Regex(pattern, RegexOption.DOT_MATCHES_ALL).find(manifest)?.groupValues?.get(1) + ?: throw GradleException("projects/mkwii/recomp.yml has no match for $pattern") + mapOf( + "DISC_GAME_ID" to pin("""\n\s*game_id:\s*(\w+)"""), + "DISC_DOL_SHA256" to pin("""\n\s*dol:.*?sha256:\s*([0-9a-fA-F]{64})"""), + "DISC_REL_SHA256" to pin("""\n\s*rel:.*?sha256:\s*([0-9a-fA-F]{64})"""), + ) +} + +// android/nod-jni: nod, the disc image library the PC installer runs as nodtool, +// cross-compiled with cargo for the launcher's "Select disc image". Needs a Rust +// toolchain with the aarch64-linux-android target (see docs/quest-port.md). +val nodJniDir = rootProject.file("nod-jni") +val nodJniTargetDir = layout.buildDirectory.dir("nod-jni") +val nodJniLibs = layout.buildDirectory.dir("generated/jniLibs/nodJni") +val buildNodJni by tasks.registering(Exec::class) { + inputs.dir(File(nodJniDir, "src")) + inputs.files(File(nodJniDir, "Cargo.toml"), File(nodJniDir, "Cargo.lock")) + outputs.file(nodJniTargetDir.map { it.file("aarch64-linux-android/release/libnod_jni.so") }) + workingDir = nodJniDir + val windows = System.getProperty("os.name").startsWith("Windows") + val cargoHome = System.getenv("CARGO_HOME")?.let(::File) ?: File(System.getProperty("user.home"), ".cargo") + val cargo = providers.gradleProperty("cargo").orNull + ?: File(cargoHome, if (windows) "bin/cargo.exe" else "bin/cargo").takeIf { it.isFile }?.path + ?: "cargo" + commandLine(cargo, "build", "--release", "--locked", "--target", "aarch64-linux-android") + doFirst { + val host = when { + windows -> "windows-x86_64" + System.getProperty("os.name").startsWith("Mac") -> "darwin-x86_64" + else -> "linux-x86_64" + } + val bin = File(androidComponents.sdkComponents.ndkDirectory.get().asFile, "toolchains/llvm/prebuilt/$host/bin") + val clang = File(bin, "aarch64-linux-android29-clang" + if (windows) ".cmd" else "").path + environment("CARGO_TARGET_DIR", nodJniTargetDir.get().asFile.path) + environment("CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER", clang) + environment("CC_aarch64_linux_android", clang) + environment("AR_aarch64_linux_android", File(bin, "llvm-ar" + if (windows) ".exe" else "").path) + } +} +val stageNodJni by tasks.registering(Copy::class) { + from(buildNodJni) { include("**/libnod_jni.so") } + eachFile { path = "arm64-v8a/$name" } + includeEmptyDirs = false + into(nodJniLibs) +} +tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach { + dependsOn(stageNodJni) +} + android { namespace = "org.wiicompiled.quest" compileSdk = 36 @@ -38,6 +95,11 @@ android { targetSdk = 34 versionCode = 1 versionName = "0.1.0-quest" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + for ((name, value) in discPins) { + buildConfigField("String", name, "\"$value\"") + } ndk { abiFilters += "arm64-v8a" @@ -65,7 +127,10 @@ android { dimension = "profile" buildConfigField("String", "MAIN_LIBRARY", "\"main\"") buildConfigField("String", "PROFILE", "\"base\"") - externalNativeBuild { cmake { targets += "WiiCompiled" } } + // The APK carries the game kit, not the game: the player's libmain.so is built from + // their own disc and loaded from private storage (see android/QuestGameKit.psm1). + buildConfigField("boolean", "EXTERNAL_GAME", "true") + externalNativeBuild { cmake { targets += "mkw_quest_kit_probe" } } } create("retroRewind") { dimension = "profile" @@ -73,6 +138,9 @@ android { versionNameSuffix = "-retro-rewind" buildConfigField("String", "MAIN_LIBRARY", "\"main_retro_rewind\"") buildConfigField("String", "PROFILE", "\"retro_rewind\"") + // Not moved to the game kit yet: this flavour still bundles its translated library, + // so a Retro Rewind APK is for local use only. + buildConfigField("boolean", "EXTERNAL_GAME", "false") externalNativeBuild { cmake { targets += "RetroRewind" } } } } @@ -92,6 +160,7 @@ android { sourceSets.named("main") { assets.srcDir(runtimeResources) + jniLibs.srcDir(nodJniLibs) } compileOptions { @@ -101,6 +170,8 @@ android { packaging { jniLibs { useLegacyPackaging = false + // Built only to produce the game kit; its objects travel as assets/game_kit. + excludes += "**/libmkw_quest_kit_probe.so" } } lint { @@ -112,6 +183,71 @@ tasks.named("preBuild") { dependsOn(prepareRuntimeResources) } +/** + * Exports the game kit (android/QuestGameKit.psm1) from the CMake tree this variant's native + * build just produced, into the variant's assets as game_kit/. The tree is found through the + * probe library: AGP gives the .cxx directory and the obj directory the same configuration hash. + */ +abstract class ExportQuestGameKit : DefaultTask() { + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @get:Internal + abstract val appDir: DirectoryProperty + + @get:Internal + abstract val script: RegularFileProperty + + @get:Internal + abstract val llvmStrip: RegularFileProperty + + @get:Inject + abstract val execOperations: ExecOperations + + init { + // The kit's inputs are CMake's outputs, which Gradle does not track; exporting takes seconds. + outputs.upToDateWhen { false } + } + + @TaskAction + fun export() { + val app = appDir.get().asFile + val probe = File(app, "build/intermediates/cxx").walkTopDown() + .filter { it.name == "libmkw_quest_kit_probe.so" && it.parentFile.name == "arm64-v8a" } + .maxByOrNull { it.lastModified() } + ?: throw GradleException("No libmkw_quest_kit_probe.so; the native build did not produce the game kit probe") + val configuration = probe.parentFile.parentFile.parentFile // / + val binaryDir = File(app, ".cxx/${configuration.parentFile.name}/${configuration.name}/arm64-v8a") + if (!File(binaryDir, "build.ninja").isFile) throw GradleException("No CMake tree at $binaryDir") + val kitDir = File(outputDir.get().asFile, "game_kit") + execOperations.exec { + commandLine( + "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.get().asFile.path, + "-CMakeBinaryDir", binaryDir.path, "-OutputDir", kitDir.path, "-LlvmStrip", llvmStrip.get().asFile.path, + ) + } + } +} + +androidComponents { + onVariants(selector().withFlavor("profile" to "base")) { variant -> + val capitalized = variant.name.replaceFirstChar { it.uppercase() } + // AGP packages every library in the CMake output directory, so a game library left over + // from a build that still linked the game would otherwise ship in the APK. + variant.packaging.jniLibs.excludes.add("**/libmain*.so") + val export = tasks.register("export${capitalized}QuestGameKit") { + dependsOn("merge${capitalized}NativeLibs") + appDir.set(layout.projectDirectory) + script.set(rootProject.layout.projectDirectory.file("Export-QuestGameKit.ps1")) + val host = if (System.getProperty("os.name").startsWith("Windows")) "windows-x86_64" else "linux-x86_64" + llvmStrip.set(sdkComponents.ndkDirectory.map { + it.file("toolchains/llvm/prebuilt/$host/bin/llvm-strip" + if (host.startsWith("windows")) ".exe" else "") + }) + } + variant.sources.assets?.addGeneratedSourceDirectory(export, ExportQuestGameKit::outputDir) + } +} + kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) @@ -121,4 +257,7 @@ kotlin { dependencies { // SDLActivity and libSDL3.so; the AAR is downloaded by Prepare-QuestDependencies.ps1. implementation(files("libs/SDL3-3.4.4.aar")) + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test.ext:junit:1.2.1") } diff --git a/android/app/src/androidTest/java/org/wiicompiled/quest/PrivateCodeExecutionTest.kt b/android/app/src/androidTest/java/org/wiicompiled/quest/PrivateCodeExecutionTest.kt new file mode 100644 index 0000000..5bb6382 --- /dev/null +++ b/android/app/src/androidTest/java/org/wiicompiled/quest/PrivateCodeExecutionTest.kt @@ -0,0 +1,65 @@ +package org.wiicompiled.quest + +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import java.util.concurrent.TimeUnit +import java.util.zip.ZipFile +import org.junit.Test +import org.junit.runner.RunWith + +/** + * What an app targeting API 29+ may run from its own private storage, which is + * where an on-device build puts the compiler and the compiled game. + * + * Run with `adb shell am instrument`, never `gradlew connectedAndroidTest`: that + * task uninstalls the app afterwards, and with it the player's extracted DATA. + */ +@RunWith(AndroidJUnit4::class) +class PrivateCodeExecutionTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val probe = File(context.filesDir, "probe").apply { mkdirs() } + + @Test + fun loadsALibraryFromPrivateStorage() { + val library = File(probe, "libnod_jni_copy.so") + ZipFile(context.applicationInfo.sourceDir).use { apk -> + apk.getInputStream(apk.getEntry("lib/arm64-v8a/libnod_jni.so")).use { input -> + library.outputStream().use { input.copyTo(it) } + } + } + report("System.load from filesDir") { System.load(library.absolutePath); "loaded" } + } + + @Test + fun runsABinaryFromPrivateStorage() { + val binary = File(probe, "toybox") + File("/system/bin/toybox").inputStream().use { input -> binary.outputStream().use { input.copyTo(it) } } + binary.setExecutable(true) + report("exec from filesDir") { run(binary.absolutePath, "echo", "direct") } + report("exec through /system/bin/linker64") { run("/system/bin/linker64", binary.absolutePath, "echo", "linker") } + } + + private fun run(vararg command: String): String { + val process = ProcessBuilder(*command).redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().readText().trim() + process.waitFor(30, TimeUnit.SECONDS) + return "exit ${process.exitValue()}: $output" + } + + private fun report(what: String, probe: () -> String) { + val outcome = try { + probe() + } catch (t: Throwable) { + "FAILED ${t.javaClass.simpleName}: ${t.message}" + } + Log.i(TAG, "$what -> $outcome") + InstrumentationRegistry.getInstrumentation().sendStatus(0, android.os.Bundle().apply { putString("stream", "$what -> $outcome\n") }) + } + + private companion object { + const val TAG = "WiiCompiledProbe" + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d471f74..11536b6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + + @@ -38,6 +42,35 @@ + + + + + + + + + + + + + + - + diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt index 0a3aef7..b371532 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -49,8 +49,9 @@ endif() add_subdirectory("${MKW_REPO_ROOT}/runtime" wiicompiled-runtime) -# App-owned JNI glue lives in each product library SDLActivity loads. -foreach(product IN ITEMS WiiCompiled RetroRewind) +# App-owned JNI glue lives in each product library SDLActivity loads, and in the game kit +# probe (runtime/cmake/PublicProducts.cmake) whose objects the player's game is linked with. +foreach(product IN ITEMS WiiCompiled RetroRewind mkw_quest_kit_probe) if(TARGET ${product}) target_sources(${product} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/quest_surface_jni.cpp") target_include_directories(${product} PRIVATE "${MKW_REPO_ROOT}/aurora-main/include") diff --git a/android/app/src/main/java/org/wiicompiled/quest/GameLibrary.kt b/android/app/src/main/java/org/wiicompiled/quest/GameLibrary.kt new file mode 100644 index 0000000..15030b6 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/GameLibrary.kt @@ -0,0 +1,54 @@ +package org.wiicompiled.quest + +import android.content.Context +import java.io.File +import org.json.JSONObject + +/** + * The player's compiled game: libmain.so built from their own disc against the game kit this + * APK carries (assets/game_kit, see android/QuestGameKit.psm1), plus the game.json it came with. + * + * It lives in internal private storage, the one place Android lets an app load native code it + * did not install (execution from shared or external storage is blocked). A library built for + * another kit, meaning another app version's runtime, is Stale and must be rebuilt: it would link + * against runtime objects this APK no longer has. + */ +object GameLibrary { + + enum class Status { Missing, Stale, Ready } + + const val LIBRARY_NAME = "libmain.so" + const val MANIFEST_NAME = "game.json" + + @Volatile + private var cachedKitFingerprint: String? = null + + fun directory(context: Context): File = File(context.filesDir, "game/${BuildConfig.PROFILE}") + + fun library(context: Context): File = File(directory(context), LIBRARY_NAME) + + fun manifest(context: Context): JSONObject? = File(directory(context), MANIFEST_NAME) + .takeIf { it.isFile } + ?.let { runCatching { JSONObject(it.readText()) }.getOrNull() } + + /** The fingerprint of the kit in this APK's assets, which every game must be built against. */ + fun kitFingerprint(context: Context): String? { + cachedKitFingerprint?.let { return it } + val fingerprint = runCatching { + context.assets.open("game_kit/kit.json").use { JSONObject(it.reader().readText()).getString("fingerprint") } + }.getOrNull() + cachedKitFingerprint = fingerprint + return fingerprint + } + + fun status(context: Context): Status { + if (!BuildConfig.EXTERNAL_GAME) { + return Status.Ready + } + val manifest = manifest(context) + if (manifest == null || !library(context).isFile) { + return Status.Missing + } + return if (manifest.optString("kitFingerprint") == kitFingerprint(context)) Status.Ready else Status.Stale + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt b/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt new file mode 100644 index 0000000..a059c72 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/GameStorage.kt @@ -0,0 +1,82 @@ +package org.wiicompiled.quest + +import android.content.Context +import java.io.File + +/** + * Where the game's user files live on the headset. The launcher and the game + * activity run in different processes and both resolve every path here, so + * they cannot disagree about which Config.toml or DATA directory is meant. + * + * Everything sits under Android/data//files/WiiCompiledOpenXRVR, the + * one directory a player can reach over adb: the extracted disc (DATA), + * Config.toml, NAND saves and per-run logs. + */ +object GameStorage { + + // Must match kApplicationDirectoryName in runtime/include/runtime_config.h. + const val APP_DIRECTORY = "WiiCompiledOpenXRVR" + const val DISC_DIRECTORY = "DATA" + + enum class DiscStatus { Missing, Incomplete, Ready } + + fun dataRoot(context: Context): File = context.getExternalFilesDir(null) ?: context.filesDir + + fun gameRoot(context: Context): File = File(dataRoot(context), APP_DIRECTORY) + + fun discDirectory(context: Context): File = File(gameRoot(context), DISC_DIRECTORY) + + fun configFile(context: Context): File = File(gameRoot(context), "Config.toml") + + fun logsDirectory(context: Context): File = File(gameRoot(context), "Logs") + + /** Game packages dropped here (adb push, Build-QuestGame.ps1 -Install) are imported when the launcher opens. */ + fun importDirectory(context: Context): File = File(gameRoot(context), "Import") + + /** + * Whether DATA holds what the runtime's DVD layer accepts (IsDvdDataRoot in + * runtime/src/hle/storage/dvd.cpp): the whole extracted partition, not just + * its files/ folder. Incomplete also covers a tree adb left unreadable to + * the app, which looks the same from here. + */ + fun discStatus(context: Context): DiscStatus { + val disc = discDirectory(context) + if (!disc.isDirectory) { + return DiscStatus.Missing + } + val usable = File(disc, "files").isDirectory && File(disc, "sys/fst.bin").isFile + return if (usable) DiscStatus.Ready else DiscStatus.Incomplete + } + + /** + * Creates the game directory and a first Config.toml with VR on and the + * disc path filled in. An existing file is left alone: the launcher, the + * in-headset settings panel and the player all edit it in place. + */ + fun prepare(context: Context): File { + val gameRoot = gameRoot(context) + gameRoot.mkdirs() + // Created by the app so it owns it and can remove imported packages; adb only adds files. + importDirectory(context).mkdirs() + val config = configFile(context) + if (!config.isFile) { + val disc = discDirectory(context).absolutePath + config.writeText( + """ + # WiiCompiled Quest configuration. Edit with the launcher, the in-game panel or adb pull/push. + [paths] + dvd_root = "$disc" + + [video] + widescreen = true + resolution_multiplier = 1.0 + + [vr] + enabled = true + render_scale = 1.0 + """.trimIndent() + "\n", + ) + } + return gameRoot + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/QuestActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/QuestActivity.kt index 393cf41..370a037 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/QuestActivity.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/QuestActivity.kt @@ -3,6 +3,9 @@ package org.wiicompiled.quest import android.app.AlertDialog import android.content.Context import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Process import android.system.Os import android.util.Log import java.io.File @@ -11,7 +14,9 @@ import org.libsdl.app.SDLActivity import org.libsdl.app.SDLSurface /** - * The one activity of the standalone Quest build. + * The game: the immersive activity that runs the native runtime and the player's + * own compiled game. The app opens on [org.wiicompiled.quest.launcher.LauncherActivity], + * whose Play button starts this one. * * SDLActivity owns the surface, the Java-side event pump and the JNI plumbing * the OpenXR loader needs (the runtime fetches the JavaVM and this activity @@ -19,44 +24,78 @@ import org.libsdl.app.SDLSurface * adds is everything the native runtime cannot discover on its own: * * - the data directory the player fills with the extracted disc - * (Android/data//files/WiiCompiled/DATA) and where Config.toml, - * saves and logs live; - * - the bundled read-only runtime resources, unpacked from the APK once; - * - a Config.toml with VR enabled and the disc path filled in. + * (Android/data//files/WiiCompiledOpenXRVR/DATA) and where + * Config.toml, saves and logs live ([GameStorage]); + * - the bundled read-only runtime resources, unpacked from the APK once. * * The paths are handed over through the environment before SDLActivity's own * onCreate loads the native libraries, because the runtime resolves them from * static initialisers where SDL's JNI helpers are not yet usable. + * + * This activity lives in its own `:game` process, and that process ends with + * it. Neither SDL nor the runtime can start a second time in one process: + * SDLActivity calls System.exit on a re-created activity once SDL_main has run, + * and guest memory, fibers and the OpenXR device are process-wide. A fresh + * process per session also keeps the launcher alive when the game exits. */ class QuestActivity : SDLActivity() { - override fun getLibraries(): Array = arrayOf("SDL3", BuildConfig.MAIN_LIBRARY) + override fun getLibraries(): Array = + if (BuildConfig.EXTERNAL_GAME) arrayOf("SDL3") else arrayOf("SDL3", BuildConfig.MAIN_LIBRARY) + + /** + * The game is not part of the APK: it is the libmain.so the player built from their own disc + * ([GameLibrary]), loaded from private storage. Its libSDL3.so, libpng16.so and + * libc++_shared.so dependencies resolve against the ones this APK installed. + */ + override fun loadLibraries() { + super.loadLibraries() + if (BuildConfig.EXTERNAL_GAME) { + // SDLActivity reports a failed load in its own error dialog and never starts the game. + if (GameLibrary.status(this) != GameLibrary.Status.Ready) { + throw UnsatisfiedLinkError(getString(R.string.game_not_installed)) + } + System.load(GameLibrary.library(this).absolutePath) + } + } + + override fun getMainSharedObject(): String = + if (BuildConfig.EXTERNAL_GAME) GameLibrary.library(this).absolutePath else super.getMainSharedObject() override fun createSDLSurface(context: Context): SDLSurface = QuestSurface(context) override fun onCreate(savedInstanceState: Bundle?) { - val dataRoot = (getExternalFilesDir(null) ?: filesDir) - val gameRoot = File(dataRoot, APP_DIRECTORY) - gameRoot.mkdirs() + GameStorage.prepare(this) val resources = unpackRuntimeResources() - Os.setenv("MKW_ANDROID_DATA_DIR", dataRoot.absolutePath, true) + Os.setenv("MKW_ANDROID_DATA_DIR", GameStorage.dataRoot(this).absolutePath, true) Os.setenv("MKW_ANDROID_RESOURCES_DIR", resources.absolutePath, true) - ensureConfig(gameRoot) super.onCreate(savedInstanceState) - if (!File(gameRoot, DISC_DIRECTORY).isDirectory && !mBrokenLibraries) { - Log.w(TAG, "No extracted disc found under ${gameRoot.absolutePath}") + // The launcher does not offer Play without DATA; this covers a direct + // start, such as adb am start. + if (GameStorage.discStatus(this) == GameStorage.DiscStatus.Missing && !mBrokenLibraries) { + val disc = GameStorage.discDirectory(this) + Log.w(TAG, "No extracted disc found under ${disc.parentFile?.absolutePath}") AlertDialog.Builder(this) .setTitle(getString(R.string.missing_game_data_title)) - .setMessage(getString(R.string.missing_game_data_message, File(gameRoot, DISC_DIRECTORY).absolutePath)) + .setMessage(getString(R.string.missing_game_data_message, disc.absolutePath)) .setCancelable(false) .setPositiveButton(android.R.string.ok) { _, _ -> finish() } .show() } } + override fun onDestroy() { + // SDLActivity asks SDL_main to quit and waits up to a second for it. + super.onDestroy() + // Ended from the main looper so the activity manager has already + // recorded this destruction and returns to the launcher. + Log.i(TAG, "Game activity destroyed; ending the game process") + Handler(Looper.getMainLooper()).post { Process.killProcess(Process.myPid()) } + } + /** * Copies runtime_resources/ from the APK assets into private storage the * first time this build runs. A stamp file keyed on the version code keeps @@ -92,37 +131,7 @@ class QuestActivity : SDLActivity() { } } - /** - * Writes a first Config.toml pointing at the disc directory with VR on. - * An existing file is left alone: the settings overlay edits it in place. - */ - private fun ensureConfig(gameRoot: File) { - val config = File(gameRoot, "Config.toml") - if (config.isFile) { - return - } - val disc = File(gameRoot, DISC_DIRECTORY).absolutePath - config.writeText( - """ - # WiiCompiled Quest configuration. Edit with adb pull/push or the in-game overlay. - [paths] - dvd_root = "$disc" - - [video] - widescreen = true - resolution_multiplier = 1.0 - - [vr] - enabled = true - render_scale = 1.0 - """.trimIndent() + "\n", - ) - } - - companion object { - private const val TAG = "WiiCompiledQuest" - // Must match kApplicationDirectoryName in runtime/include/runtime_config.h. - private const val APP_DIRECTORY = "WiiCompiledOpenXRVR" - private const val DISC_DIRECTORY = "DATA" + private companion object { + const val TAG = "WiiCompiledQuest" } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/ConfigStore.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/ConfigStore.kt new file mode 100644 index 0000000..479b2b3 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/ConfigStore.kt @@ -0,0 +1,51 @@ +package org.wiicompiled.quest.launcher + +import android.util.Log +import java.io.File +import java.io.IOException + +/** + * Config.toml on disk. Every edit re-reads the file first, so a value the + * in-headset panel wrote meanwhile is never overwritten with a stale copy. + */ +class ConfigStore(val file: File) { + + /** The current file, empty when it does not exist yet, or null when it cannot be read. */ + fun load(): TomlConfig? { + if (!file.isFile) { + return TomlConfig.parse("") + } + return try { + TomlConfig.parse(file.readText()) + } catch (e: IOException) { + Log.w(TAG, "Cannot read ${file.absolutePath}", e) + null + } + } + + /** Applies [edit] to a fresh read and writes the result back. */ + fun update(edit: (TomlConfig) -> Unit): Boolean { + // A file that exists but cannot be read must not be replaced by one + // holding only this edit. + val config = load() ?: return false + edit(config) + val text = config.text() + return try { + file.parentFile?.mkdirs() + val temporary = File(file.parentFile, "${file.name}.tmp") + temporary.writeText(text) + if (!temporary.renameTo(file)) { + temporary.delete() + file.writeText(text) + } + true + } catch (e: IOException) { + Log.w(TAG, "Cannot write ${file.absolutePath}", e) + false + } + } + + private companion object { + const val TAG = "WiiCompiledLauncher" + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/DiscChecks.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/DiscChecks.kt new file mode 100644 index 0000000..1697539 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/DiscChecks.kt @@ -0,0 +1,74 @@ +package org.wiicompiled.quest.launcher + +import java.security.MessageDigest +import java.util.Locale + +/** + * The PC installer's acceptance rules for a player's disc image, in one place: + * Launcher/WiiCompiled.Setup.Windows/InputValidation.cs (formats, game ID) and + * InstallerEngine.ValidateExtractedGame (the clean PAL main.dol and StaticR.rel). + * The pins come from projects/mkwii/recomp.yml through BuildConfig. + */ +class DiscChecks( + private val gameId: String, + private val dolSha256: String, + private val relSha256: String, +) { + + data class Header(val gameId: String, val title: String, val extractedBytes: Long) + + /** Null when [displayName] is a format nod reads, otherwise the message to show. */ + fun extensionError(displayName: String?): String? { + val extension = displayName?.substringAfterLast('.', "")?.lowercase(Locale.ROOT).orEmpty() + return if (extension in SUPPORTED_EXTENSIONS) null else FORMAT_MESSAGE + } + + fun compatibilityError(header: Header): String? { + if (header.gameId.equals(gameId, ignoreCase = true)) { + return null + } + return "This build supports Mario Kart Wii PAL ($gameId). The selected image is " + + "${header.gameId} (${header.title}, ${region(header.gameId)})." + } + + fun revisionError(dol: ByteArray, rel: ByteArray): String? { + if (sha256(dol).equals(dolSha256, ignoreCase = true) && sha256(rel).equals(relSha256, ignoreCase = true)) { + return null + } + return "The disc is $gameId but does not match the supported clean PAL revision. " + + "Patched or otherwise modified game code cannot be installed safely." + } + + companion object { + const val DOL_PATH = "sys/main.dol" + const val REL_PATH = "files/rel/StaticR.rel" + const val FST_PATH = "sys/fst.bin" + + private val SUPPORTED_EXTENSIONS = setOf("iso", "gcm", "gcz", "ciso", "wbfs", "wia", "rvz") + private const val FORMAT_MESSAGE = + "Select a complete Wii disc image in ISO, GCM, GCZ, CISO, WBFS, WIA, or RVZ format." + + /** NodDisc.header's "\n\n<bytes>". */ + fun parseHeader(text: String): Header { + val lines = text.split('\n') + return Header( + gameId = lines.getOrElse(0) { "" }, + title = lines.getOrElse(1) { "" }, + extractedBytes = lines.getOrNull(2)?.toLongOrNull() ?: 0L, + ) + } + + fun region(gameId: String): String = when (gameId.getOrNull(3)) { + 'P' -> "PAL" + 'E' -> "NTSC-U" + 'J' -> "NTSC-J" + 'K' -> "Korea" + 'W' -> "Taiwan" + null -> "Unknown" + else -> gameId[3].toString() + } + + fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/DiscExtraction.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/DiscExtraction.kt new file mode 100644 index 0000000..92f32fc --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/DiscExtraction.kt @@ -0,0 +1,65 @@ +package org.wiicompiled.quest.launcher + +import android.content.Context +import android.os.StatFs +import android.util.Log +import java.io.File +import java.io.IOException +import org.wiicompiled.quest.GameStorage + +/** + * The player's disc image becomes DATA, the way the PC installer does it + * (Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs): read the header and require RMCP01, + * check main.dol and StaticR.rel against the clean PAL pins, extract the data partition with nod, + * and check the extracted files again before they replace DATA. + * + * The image is checked before anything is written, so a wrong disc fails in seconds rather than + * after the extraction. + */ +object DiscExtraction { + + private const val TAG = "WiiCompiledLauncher" + private const val STAGING_DIRECTORY = "DATA.extracting" + // Headroom beyond the files themselves: FUSE and directory overhead. + private const val FREE_SPACE_MARGIN = 256L * 1024 * 1024 + + /** Null on success, otherwise the message to show. */ + fun run(context: Context, fd: Int, name: String?, progress: GameSetup.Progress, finishing: () -> Unit): String? { + val checks = GameFiles.checks + checks.extensionError(name)?.let { return it } + + val header = try { + DiscChecks.parseHeader(NodDisc.header(fd)) + } catch (e: IOException) { + return "This disc image could not be read. ${e.message.orEmpty()}".trim() + } + checks.compatibilityError(header)?.let { return it } + checks.revisionError(NodDisc.readFile(fd, DiscChecks.DOL_PATH), NodDisc.readFile(fd, DiscChecks.REL_PATH)) + ?.let { return it } + + val gameRoot = GameStorage.gameRoot(context) + gameRoot.mkdirs() + val staging = File(gameRoot, STAGING_DIRECTORY) + staging.deleteRecursively() + try { + val needed = header.extractedBytes + FREE_SPACE_MARGIN + val available = StatFs(gameRoot.absolutePath).availableBytes + if (available < needed) { + return "Not enough free space: the game files need ${GameFiles.gigabytes(needed)}, and ${GameFiles.gigabytes(available)} is free." + } + + staging.mkdirs() + progress.update(0, header.extractedBytes) + NodDisc.extract(fd, staging.absolutePath) { done, total -> progress.update(done, total) } + + finishing() + GameFiles.validateData(staging)?.let { return it } + GameFiles.replace(GameStorage.discDirectory(context), staging)?.let { return it } + Log.i(TAG, "Game data extracted into ${GameStorage.discDirectory(context).absolutePath}") + return null + } finally { + // Gone already after a successful run, which moved it to DATA. + staging.deleteRecursively() + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/GameFiles.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameFiles.kt new file mode 100644 index 0000000..08fdf9f --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameFiles.kt @@ -0,0 +1,50 @@ +package org.wiicompiled.quest.launcher + +import android.util.Log +import java.io.File +import org.wiicompiled.quest.BuildConfig + +/** Checks and swaps shared by every [GameSetup] task. */ +object GameFiles { + + private const val TAG = "WiiCompiledLauncher" + + val checks = DiscChecks(BuildConfig.DISC_GAME_ID, BuildConfig.DISC_DOL_SHA256, BuildConfig.DISC_REL_SHA256) + + /** + * InstallerEngine.ValidateExtractedGame (the PC installer): the files the runtime needs, with + * the pinned hashes. Null when [root] is a usable DATA folder. + */ + fun validateData(root: File): String? { + val dol = File(root, DiscChecks.DOL_PATH) + val rel = File(root, DiscChecks.REL_PATH) + if (!dol.isFile || !rel.isFile || !File(root, DiscChecks.FST_PATH).isFile) { + return "The game files are missing required Mario Kart Wii files." + } + return checks.revisionError(dol.readBytes(), rel.readBytes()) + } + + /** + * Moves [staging] to [destination], keeping the old destination until the new one is in + * place. Null on success, otherwise the message to show. + */ + fun replace(destination: File, staging: File): String? { + val replaced = File(destination.parentFile, "${destination.name}.replaced") + replaced.deleteRecursively() + if (destination.exists() && !destination.renameTo(replaced)) { + return "The existing ${destination.name} folder could not be replaced. Remove it, for example with " + + "adb shell rm -r ${destination.absolutePath}, and try again." + } + if (!staging.renameTo(destination)) { + replaced.renameTo(destination) + return "The new files could not be moved into ${destination.absolutePath}." + } + // The new copy is in place; an old one that will not delete only costs space. + if (replaced.exists() && !replaced.deleteRecursively()) { + Log.w(TAG, "Could not remove ${replaced.absolutePath}") + } + return null + } + + fun gigabytes(bytes: Long) = "%.1f GB".format(bytes / 1_000_000_000.0) +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/GamePackageImport.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/GamePackageImport.kt new file mode 100644 index 0000000..e9dbaea --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/GamePackageImport.kt @@ -0,0 +1,163 @@ +package org.wiicompiled.quest.launcher + +import android.content.Context +import android.os.ParcelFileDescriptor +import android.os.StatFs +import android.util.Log +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FilterInputStream +import java.io.InputStream +import java.io.InterruptedIOException +import java.security.MessageDigest +import java.util.zip.ZipInputStream +import org.json.JSONObject +import org.wiicompiled.quest.BuildConfig +import org.wiicompiled.quest.GameLibrary +import org.wiicompiled.quest.GameStorage + +/** + * A .wcgame built on a PC (android/Build-QuestGame.ps1, the PC launcher's "Build for Quest") + * becomes this headset's game: libmain.so and game.json, and DATA too when the package carries + * the extracted disc. + * + * The package is only accepted for the disc and app it was built for: game.json must name the + * pinned game and hashes, the kit this APK carries, and a library whose hash matches the bytes + * in the package. Both parts are staged and checked before either replaces what is installed. + */ +object GamePackageImport { + + private const val TAG = "WiiCompiledLauncher" + private const val SCHEMA = 1 + private const val DATA_PREFIX = "DATA/" + private const val FREE_SPACE_MARGIN = 256L * 1024 * 1024 + + /** Null on success, otherwise the message to show. */ + fun run(context: Context, descriptor: ParcelFileDescriptor, progress: GameSetup.Progress, finishing: () -> Unit): String? { + val total = descriptor.statSize + val gameRoot = GameStorage.gameRoot(context) + val available = StatFs(gameRoot.absolutePath).availableBytes + if (total > 0 && available < total + FREE_SPACE_MARGIN) { + return "Not enough free space: importing needs ${GameFiles.gigabytes(total + FREE_SPACE_MARGIN)}, and ${GameFiles.gigabytes(available)} is free." + } + + val libraryDir = GameLibrary.directory(context) + val libraryStaging = File(libraryDir.parentFile, "${libraryDir.name}.importing") + val dataStaging = File(gameRoot, "DATA.importing") + libraryStaging.deleteRecursively() + dataStaging.deleteRecursively() + try { + libraryStaging.mkdirs() + var manifestText: String? = null + var librarySha256: String? = null + var hasData = false + + val counted = CountingInputStream(FileInputStream(descriptor.fileDescriptor)) + ZipInputStream(BufferedInputStream(counted, 1 shl 20)).use { zip -> + val buffer = ByteArray(1 shl 20) + var lastReport = 0L + while (true) { + val entry = zip.nextEntry ?: break + val name = entry.name + if (entry.isDirectory) continue + if (name.startsWith("/") || name.split('/').any { it == ".." }) { + return "The game file contains an invalid path: $name" + } + when { + name == GameLibrary.MANIFEST_NAME -> manifestText = zip.readBytes().toString(Charsets.UTF_8) + name == GameLibrary.LIBRARY_NAME -> { + val digest = MessageDigest.getInstance("SHA-256") + File(libraryStaging, GameLibrary.LIBRARY_NAME).outputStream().use { out -> + while (true) { + val read = zip.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + out.write(buffer, 0, read) + } + } + librarySha256 = digest.digest().joinToString("") { "%02x".format(it) } + } + name.startsWith(DATA_PREFIX) -> { + hasData = true + val target = File(dataStaging, name.removePrefix(DATA_PREFIX)) + target.parentFile?.mkdirs() + target.outputStream().use { out -> + while (true) { + val read = zip.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + if (counted.count - lastReport >= 8L shl 20) { + lastReport = counted.count + if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled") + } + } + } + } + } + if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled") + } + } + + finishing() + val manifestJson = manifestText?.let { runCatching { JSONObject(it) }.getOrNull() } + ?: return "This is not a WiiCompiled game file (game.json is missing)." + manifestError(context, manifestJson, librarySha256)?.let { return it } + if (hasData) { + GameFiles.validateData(dataStaging)?.let { return it } + } else if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) { + return "This game file has no game files (DATA). Build it again with the disc files included, or select your disc image first." + } + + File(libraryStaging, GameLibrary.MANIFEST_NAME).writeText(manifestText!!) + if (hasData) { + GameFiles.replace(GameStorage.discDirectory(context), dataStaging)?.let { return it } + } + libraryDir.parentFile?.mkdirs() + GameFiles.replace(libraryDir, libraryStaging)?.let { return it } + Log.i(TAG, "Imported the game built by ${manifestJson.optString("builtBy")} at ${manifestJson.optString("builtAt")}") + return null + } finally { + libraryStaging.deleteRecursively() + dataStaging.deleteRecursively() + } + } + + private fun manifestError(context: Context, manifest: JSONObject, librarySha256: String?): String? { + if (manifest.optInt("schema") != SCHEMA) { + return "This game file comes from a newer or older launcher (format ${manifest.optInt("schema")}). Build it again with the current PC launcher." + } + if (manifest.optString("profile") != BuildConfig.PROFILE) { + return "This game file is for ${manifest.optString("profile")}, but this app is ${BuildConfig.PROFILE}." + } + if (!manifest.optString("gameId").equals(BuildConfig.DISC_GAME_ID, ignoreCase = true) || + !manifest.optString("dolSha256").equals(BuildConfig.DISC_DOL_SHA256, ignoreCase = true) || + !manifest.optString("relSha256").equals(BuildConfig.DISC_REL_SHA256, ignoreCase = true) + ) { + return "This game file was not built from the supported clean PAL Mario Kart Wii (${BuildConfig.DISC_GAME_ID})." + } + if (manifest.optString("kitFingerprint") != GameLibrary.kitFingerprint(context)) { + return "This game file was built for a different version of this app. Build it again against the version installed on the headset." + } + val expected = manifest.optString("librarySha256") + if (librarySha256 == null) { + return "This game file has no game library (libmain.so)." + } + if (!expected.equals(librarySha256, ignoreCase = true)) { + return "The game library in this file is damaged (its checksum does not match). Copy the file again." + } + return null + } + + private class CountingInputStream(input: InputStream) : FilterInputStream(input) { + @Volatile + var count = 0L + private set + + override fun read(): Int = super.read().also { if (it >= 0) count++ } + + override fun read(b: ByteArray, off: Int, len: Int): Int = super.read(b, off, len).also { if (it > 0) count += it } + + override fun skip(n: Long): Long = super.skip(n).also { count += it } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetup.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetup.kt new file mode 100644 index 0000000..3676c02 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetup.kt @@ -0,0 +1,123 @@ +package org.wiicompiled.quest.launcher + +import android.content.Context +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.util.Log +import java.io.File +import java.io.InterruptedIOException + +/** + * The long-running steps that make the game playable from what the player owns, one at a time, + * process-wide. [GameSetupService] runs them in the foreground and the launcher watches [state]. + * + * - [Task.ExtractDisc]: the player's disc image becomes DATA ([DiscExtraction]). + * - [Task.ImportPackage]: a .wcgame built on a PC becomes the game library, and DATA too when + * the package carries it ([GamePackageImport]). + * + * Every task stages its output next to the destination and swaps it in only after it has been + * checked, so a failed, cancelled or killed run never costs working files. + */ +object GameSetup { + + enum class Task { ExtractDisc, ImportPackage } + + sealed interface State { + data object Idle : State + data class Checking(val task: Task) : State + data class Working(val task: Task, val done: Long, val total: Long) : State + data class Finishing(val task: Task) : State + data class Done(val task: Task) : State + data class Failed(val task: Task, val message: String) : State + data class Cancelled(val task: Task) : State + } + + /** What a task step reports; returning false from [update] asks it to stop. */ + fun interface Progress { + fun update(done: Long, total: Long): Boolean + } + + private const val TAG = "WiiCompiledLauncher" + + private val main = Handler(Looper.getMainLooper()) + private val listeners = mutableSetOf<(State) -> Unit>() + + @Volatile + var state: State = State.Idle + private set + + @Volatile + private var cancelRequested = false + + val isRunning: Boolean + get() = state is State.Checking || state is State.Working || state is State.Finishing + + /** Listeners are called on the main thread, with the current state first. */ + fun addListener(listener: (State) -> Unit) { + listeners += listener + listener(state) + } + + fun removeListener(listener: (State) -> Unit) { + listeners -= listener + } + + fun cancel() { + cancelRequested = true + } + + /** Claims the single slot for [task]; false when another task is running. */ + fun begin(task: Task): Boolean { + synchronized(this) { + if (isRunning) return false + cancelRequested = false + publish(State.Checking(task)) + return true + } + } + + /** The whole task, on a worker thread. Always ends in Done, Failed or Cancelled. */ + fun run(context: Context, task: Task, uri: Uri, deleteSource: Boolean) { + val progress = Progress { done, total -> + publish(State.Working(task, done, total)) + !cancelRequested + } + val finishing = { publish(State.Finishing(task)) } + try { + val failure = context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + when (task) { + Task.ExtractDisc -> DiscExtraction.run(context, descriptor.fd, displayName(context, uri), progress, finishing) + Task.ImportPackage -> GamePackageImport.run(context, descriptor, progress, finishing) + } + } ?: "The selected file could not be opened." + if (failure != null) { + publish(State.Failed(task, failure)) + return + } + if (deleteSource && uri.scheme == "file") { + uri.path?.let { File(it).delete() } + } + publish(State.Done(task)) + } catch (e: InterruptedIOException) { + publish(State.Cancelled(task)) + } catch (e: Exception) { + Log.e(TAG, "$task failed", e) + publish(State.Failed(task, e.message ?: e.toString())) + } + } + + fun displayName(context: Context, uri: Uri): String? = + runCatching { + context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + }.getOrNull() ?: uri.lastPathSegment + + private fun publish(next: State) { + state = next + main.post { + for (listener in listeners.toList()) listener(next) + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetupService.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetupService.kt new file mode 100644 index 0000000..cbede93 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameSetupService.kt @@ -0,0 +1,136 @@ +package org.wiicompiled.quest.launcher + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import android.os.IBinder +import android.os.PowerManager +import android.os.SystemClock +import android.util.Log +import kotlin.concurrent.thread +import org.wiicompiled.quest.R + +/** + * Runs a [GameSetup] task as a foreground service. Extracting a disc or importing a game takes + * minutes, and a panel app's process is otherwise fair game once the panel is closed; taking the + * headset off also sleeps the CPU, hence the partial wake lock. + */ +class GameSetupService : Service() { + + private var lastNotified = 0L + private val listener: (GameSetup.State) -> Unit = { state -> + val now = SystemClock.elapsedRealtime() + if (state !is GameSetup.State.Working || now - lastNotified >= NOTIFY_INTERVAL_MS) { + lastNotified = now + getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notification(state)) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // startForegroundService obliges the service to go foreground even when it has nothing + // to do, or Android 12+ ends the app. + createChannel() + val task = intent?.getStringExtra(EXTRA_TASK)?.let { name -> GameSetup.Task.entries.firstOrNull { it.name == name } } + ?: GameSetup.Task.ExtractDisc + startForeground(NOTIFICATION_ID, notification(GameSetup.State.Checking(task)), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + val uri = intent?.data + if (uri == null || !GameSetup.begin(task)) { + // A task already going keeps the service, and ends it itself. + if (!GameSetup.isRunning) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf(startId) + } + return START_NOT_STICKY + } + GameSetup.addListener(listener) + val deleteSource = intent.getBooleanExtra(EXTRA_DELETE_SOURCE, false) + + val wakeLock = getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WiiCompiled:GameSetup") + wakeLock.acquire(WAKE_LOCK_TIMEOUT_MS) + thread(name = "GameSetup") { + try { + GameSetup.run(applicationContext, task, uri, deleteSource) + } finally { + releaseReadPermission(uri) + if (wakeLock.isHeld) wakeLock.release() + mainExecutor.execute { + GameSetup.removeListener(listener) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + } + return START_NOT_STICKY + } + + private fun releaseReadPermission(uri: Uri) { + if (uri.scheme != "content") return + try { + contentResolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: SecurityException) { + // Never persisted: the provider does not offer persistable grants. + } + } + + private fun createChannel() { + val manager = getSystemService(NotificationManager::class.java) ?: return + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, getString(R.string.disc_setup_channel), NotificationManager.IMPORTANCE_LOW), + ) + } + + private fun notification(state: GameSetup.State): Notification { + val builder = Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_wheel) + .setContentTitle(getString(R.string.disc_setup_notification_title)) + .setOngoing(true) + .setOnlyAlertOnce(true) + if (state is GameSetup.State.Working) { + val percent = if (state.total > 0) (state.done * 100 / state.total).toInt() else 0 + val text = if (state.task == GameSetup.Task.ImportPackage) R.string.disc_setup_importing else R.string.disc_setup_extracting + builder.setContentText(getString(text, percent)).setProgress(100, percent, false) + } else { + builder.setContentText(getString(R.string.disc_setup_checking)).setProgress(0, 0, true) + } + return builder.build() + } + + companion object { + private const val TAG = "WiiCompiledLauncher" + private const val CHANNEL_ID = "disc_setup" + private const val NOTIFICATION_ID = 1 + private const val NOTIFY_INTERVAL_MS = 1000L + private const val EXTRA_TASK = "task" + private const val EXTRA_DELETE_SOURCE = "deleteSource" + // Longer than any real task; only a hung run would reach it. + private const val WAKE_LOCK_TIMEOUT_MS = 60L * 60 * 1000 + + /** + * Starts [task] on [uri]: a document the player picked, or a file in the Import folder, + * which [deleteSource] removes once it has been imported. + */ + fun start(context: Context, task: GameSetup.Task, uri: Uri, deleteSource: Boolean = false) { + if (uri.scheme == "content") { + try { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: SecurityException) { + Log.i(TAG, "No persistable read grant for $uri; relying on the picker's grant") + } + } + val intent = Intent(context, GameSetupService::class.java) + .setData(uri) + .putExtra(EXTRA_TASK, task.name) + .putExtra(EXTRA_DELETE_SOURCE, deleteSource) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.startForegroundService(intent) + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt new file mode 100644 index 0000000..30a90e9 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt @@ -0,0 +1,407 @@ +package org.wiicompiled.quest.launcher + +import android.app.Activity +import android.app.ActivityManager +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.res.ColorStateList +import android.net.Uri +import android.os.Bundle +import android.text.format.Formatter +import android.util.Log +import android.view.View +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import org.wiicompiled.quest.BuildConfig +import org.wiicompiled.quest.GameLibrary +import org.wiicompiled.quest.GameStorage +import org.wiicompiled.quest.QuestActivity +import org.wiicompiled.quest.R + +/** + * The app's entry point on the headset: a 2D panel modelled on the PC launcher (WheelWizard VR), + * with a Home page that sets up and starts the game and a Settings page that edits Config.toml. + * + * The APK carries no game code. Playing needs two things the player owns: the game files (DATA, + * extracted from their disc image here or on a PC) and the game itself (libmain.so, built from + * their disc on a PC and brought over with Import from computer). Home's main button is always + * the next of those steps, and Play once both are there. + * + * The game runs as [QuestActivity], an immersive activity in its own `:game` process. SDL and the + * runtime cannot start twice in one process, so that process ends with every game session, and + * this panel stays behind to relaunch it. + */ +class LauncherActivity : Activity() { + + private enum class Page { Home, Settings } + + /** What Home's main and secondary buttons do. */ + private enum class Action { Play, Resume, SelectDisc, ImportGame } + + private lateinit var navHome: View + private lateinit var navSettings: View + private lateinit var homePage: View + private lateinit var settingsView: View + private lateinit var trails: WheelTrailsView + private lateinit var playButton: View + private lateinit var playIcon: ImageView + private lateinit var playText: TextView + private lateinit var progress: ProgressBar + private lateinit var homeStatus: TextView + private lateinit var secondary: TextView + private lateinit var cancel: View + private lateinit var dataBanner: View + private lateinit var dataBannerIcon: ImageView + private lateinit var dataBannerText: TextView + private lateinit var settings: SettingsPage + + private var page = Page.Home + private var launching = false + private var trailsAway = true + private var setupKind: Class<*>? = null + private var mainAction = Action.Play + private var secondaryAction: Action? = null + + private val setupListener: (GameSetup.State) -> Unit = { state -> + when { + page == Page.Home -> refreshHome() + // Rows only change when a task starts or ends, not with every progress step. + state.javaClass != setupKind -> settings.refresh() + } + setupKind = state.javaClass + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_launcher) + try { + GameStorage.prepare(this) + } catch (e: Exception) { + Log.w(TAG, "Cannot prepare the game directory", e) + } + + navHome = findViewById(R.id.nav_home) + navSettings = findViewById(R.id.nav_settings) + homePage = findViewById(R.id.page_home) + settingsView = findViewById(R.id.page_settings) + trails = findViewById(R.id.home_trails) + playButton = findViewById(R.id.home_play) + playIcon = findViewById(R.id.home_play_icon) + playText = findViewById(R.id.home_play_text) + progress = findViewById(R.id.home_progress) + homeStatus = findViewById(R.id.home_status) + secondary = findViewById(R.id.home_secondary) + cancel = findViewById(R.id.home_cancel) + dataBanner = findViewById(R.id.home_data_banner) + dataBannerIcon = findViewById(R.id.home_data_banner_icon) + dataBannerText = findViewById(R.id.home_data_banner_text) + + findViewById<TextView>(R.id.home_title).setText( + if (BuildConfig.PROFILE == "retro_rewind") R.string.home_title_retro_rewind else R.string.home_title_base, + ) + findViewById<TextView>(R.id.launcher_version).text = getString(R.string.launcher_version, BuildConfig.VERSION_NAME) + + settings = SettingsPage( + this, + settingsView, + ConfigStore(GameStorage.configFile(this)), + gameRunning = ::isGameRunning, + selectDiscImage = ::selectDiscImage, + importGame = ::importGame, + ) + savedInstanceState?.getString(KEY_TAB)?.let { name -> + SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select) + } + + navHome.setOnClickListener { showPage(Page.Home) } + navSettings.setOnClickListener { showPage(Page.Settings) } + playButton.setOnClickListener { perform(mainAction) } + secondary.setOnClickListener { secondaryAction?.let(::perform) } + cancel.setOnClickListener { GameSetup.cancel() } + + val restored = savedInstanceState?.getString(KEY_PAGE)?.let { name -> Page.entries.firstOrNull { it.name == name } } + showPage(restored ?: Page.Home) + } + + override fun onResume() { + super.onResume() + launching = false + importDroppedPackage() + setupKind = GameSetup.state.javaClass + refresh() + GameSetup.addListener(setupListener) + // The game's process can take a moment to go away after its activity + // closes, which would still read as running. + val runningAtResume = isGameRunning() + window.decorView.postDelayed({ + if (!isFinishing && isGameRunning() != runningAtResume) refresh() + }, PROCESS_EXIT_GRACE_MS) + } + + override fun onPause() { + GameSetup.removeListener(setupListener) + super.onPause() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString(KEY_PAGE, page.name) + outState.putString(KEY_TAB, settings.tab.name) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + val uri = data?.data ?: return + if (resultCode != RESULT_OK) return + val task = when (requestCode) { + REQUEST_DISC_IMAGE -> GameSetup.Task.ExtractDisc + REQUEST_GAME_PACKAGE -> GameSetup.Task.ImportPackage + else -> return + } + GameSetupService.start(this, task, uri) + // Progress and the outcome are shown on Home, wherever the picker was opened from. + showPage(Page.Home) + } + + private fun showPage(target: Page) { + page = target + navHome.isSelected = target == Page.Home + navSettings.isSelected = target == Page.Settings + homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE + settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE + if (target == Page.Home) { + trailsAway = true + } + refresh() + } + + private fun refresh() { + when (page) { + Page.Home -> refreshHome() + Page.Settings -> settings.refresh() + } + } + + private fun refreshHome() { + val disc = GameStorage.discDirectory(this).absolutePath + val discStatus = GameStorage.discStatus(this) + val gameStatus = GameLibrary.status(this) + val running = isGameRunning() + val setup = GameSetup.state + val settingUp = GameSetup.isRunning + val importing = setupTask(setup) == GameSetup.Task.ImportPackage + + mainAction = when { + running -> Action.Resume + gameStatus != GameLibrary.Status.Ready -> Action.ImportGame + discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc + else -> Action.Play + } + // Without anything yet, a player with only a headset starts from their disc image. + secondaryAction = when { + settingUp || running -> null + mainAction == Action.ImportGame && discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc + mainAction == Action.SelectDisc -> Action.ImportGame + else -> null + } + + playButton.isEnabled = !settingUp + playIcon.setImageResource(if (!settingUp && (mainAction == Action.Play || mainAction == Action.Resume)) R.drawable.ic_play else R.drawable.ic_disc) + playText.text = when { + setup is GameSetup.State.Checking -> getString(if (importing) R.string.home_checking_package else R.string.home_checking) + setup is GameSetup.State.Working -> getString(if (importing) R.string.home_importing else R.string.home_extracting, percent(setup)) + setup is GameSetup.State.Finishing -> getString(R.string.home_finishing) + else -> getString(label(mainAction)) + } + secondary.visibility = if (secondaryAction != null) View.VISIBLE else View.GONE + secondaryAction?.let { secondary.setText(label(it)) } + + progress.visibility = if (settingUp) View.VISIBLE else View.GONE + progress.isIndeterminate = setup !is GameSetup.State.Working + if (setup is GameSetup.State.Working && setup.total > 0) { + progress.progress = (setup.done * progress.max / setup.total).toInt() + } + cancel.visibility = if (settingUp && setup !is GameSetup.State.Finishing) View.VISIBLE else View.GONE + + homeStatus.text = when { + setup is GameSetup.State.Working -> getString( + R.string.home_extract_progress, + Formatter.formatShortFileSize(this, setup.done), + Formatter.formatShortFileSize(this, setup.total), + ) + setup is GameSetup.State.Checking -> getString(if (importing) R.string.home_checking_package_status else R.string.home_checking_status) + settingUp || setup is GameSetup.State.Failed -> "" + setup is GameSetup.State.Cancelled -> getString(R.string.home_setup_cancelled) + running -> getString(R.string.home_running) + mainAction == Action.Play && setup is GameSetup.State.Done -> getString(R.string.home_setup_done) + mainAction == Action.Play -> getString(R.string.home_put_on_headset) + else -> "" + } + + when { + settingUp -> showBanner(null) + setup is GameSetup.State.Failed -> showBanner(getString(R.string.home_setup_failed, setup.message), warning = true) + discStatus == GameStorage.DiscStatus.Incomplete -> showBanner(getString(R.string.home_data_incomplete, disc), warning = true) + gameStatus == GameLibrary.Status.Stale -> showBanner(getString(R.string.home_game_stale), warning = true) + gameStatus == GameLibrary.Status.Missing && discStatus == GameStorage.DiscStatus.Missing -> + showBanner(getString(R.string.home_setup_intro), warning = false) + gameStatus == GameLibrary.Status.Missing -> showBanner(getString(R.string.home_game_missing), warning = false) + discStatus == GameStorage.DiscStatus.Missing -> showBanner(getString(R.string.home_data_missing, disc), warning = false) + else -> showBanner(null) + } + + if (trailsAway && !launching) { + trailsAway = false + trails.enter() + } + } + + private fun label(action: Action): Int = when (action) { + Action.Play -> R.string.home_play + Action.Resume -> R.string.home_resume + Action.SelectDisc -> R.string.home_select_disc + Action.ImportGame -> R.string.home_import + } + + private fun perform(action: Action) { + if (GameSetup.isRunning) return + when (action) { + Action.Play, Action.Resume -> play() + Action.SelectDisc -> selectDiscImage() + Action.ImportGame -> importGame() + } + } + + private fun showBanner(text: String?, warning: Boolean = false) { + if (text == null) { + dataBanner.visibility = View.GONE + return + } + dataBannerText.text = text + dataBanner.setBackgroundResource(if (warning) R.drawable.bg_banner_warning else R.drawable.bg_banner_info) + dataBannerIcon.setImageResource(if (warning) R.drawable.ic_warning else R.drawable.ic_disc) + dataBannerIcon.imageTintList = ColorStateList.valueOf(getColor(if (warning) R.color.warning_500 else R.color.primary_400)) + dataBanner.visibility = View.VISIBLE + } + + /** Opens the document picker for a disc image, asking first when it would replace DATA. */ + private fun selectDiscImage() { + if (GameSetup.isRunning) return + if (GameStorage.discStatus(this) == GameStorage.DiscStatus.Missing) { + openPicker(REQUEST_DISC_IMAGE) + return + } + confirm(R.string.home_replace_title, R.string.home_replace_message, R.string.home_select_disc) { + openPicker(REQUEST_DISC_IMAGE) + } + } + + /** Opens the document picker for a .wcgame, asking first when it would replace the game. */ + private fun importGame() { + if (GameSetup.isRunning) return + if (GameLibrary.status(this) != GameLibrary.Status.Ready) { + openPicker(REQUEST_GAME_PACKAGE) + return + } + confirm(R.string.home_replace_game_title, R.string.home_replace_game_message, R.string.home_import) { + openPicker(REQUEST_GAME_PACKAGE) + } + } + + private fun confirm(title: Int, message: Int, positive: Int, onConfirm: () -> Unit) { + AlertDialog.Builder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(positive) { _, _ -> onConfirm() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun openPicker(requestCode: Int) { + // Disc images and game files have no reliable MIME type, so every file is offered + // and the task checks the name and contents. + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*") + try { + @Suppress("DEPRECATION") + startActivityForResult(intent, requestCode) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "No document picker", e) + Toast.makeText(this, R.string.home_no_picker, Toast.LENGTH_LONG).show() + } + } + + /** + * Imports the newest .wcgame in the Import folder, where Build-QuestGame.ps1 -Install and adb + * put them. Each package is tried once, whether it imports or not, until it changes: a file + * adb pushed belongs to the shell user, so the app cannot always delete it afterwards. + */ + private fun importDroppedPackage() { + if (GameSetup.isRunning) return + val dropped = GameStorage.importDirectory(this) + .listFiles { file -> file.isFile && file.name.endsWith(".wcgame", ignoreCase = true) } + ?.maxByOrNull { it.lastModified() } + ?: return + val identity = "${dropped.absolutePath}:${dropped.length()}:${dropped.lastModified()}" + val preferences = getSharedPreferences(PREFERENCES, MODE_PRIVATE) + if (preferences.getString(KEY_LAST_DROPPED_IMPORT, null) == identity) return + preferences.edit().putString(KEY_LAST_DROPPED_IMPORT, identity).apply() + Log.i(TAG, "Importing dropped game package ${dropped.absolutePath}") + GameSetupService.start(this, GameSetup.Task.ImportPackage, Uri.fromFile(dropped), deleteSource = true) + } + + private fun play() { + if (launching) { + return + } + launching = true + trailsAway = true + trails.leave { + try { + startActivity(Intent(this, QuestActivity::class.java)) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "Cannot start the game activity", e) + Toast.makeText(this, R.string.home_launch_failed, Toast.LENGTH_LONG).show() + launching = false + refreshHome() + } + } + } + + private fun isGameRunning(): Boolean { + val manager = getSystemService(ActivityManager::class.java) ?: return false + val gameProcess = "$packageName$GAME_PROCESS_SUFFIX" + return manager.runningAppProcesses.orEmpty().any { it.processName == gameProcess } + } + + private fun setupTask(state: GameSetup.State): GameSetup.Task? = when (state) { + is GameSetup.State.Checking -> state.task + is GameSetup.State.Working -> state.task + is GameSetup.State.Finishing -> state.task + is GameSetup.State.Done -> state.task + is GameSetup.State.Failed -> state.task + is GameSetup.State.Cancelled -> state.task + GameSetup.State.Idle -> null + } + + private fun percent(state: GameSetup.State.Working): Int = + if (state.total > 0) (state.done * 100 / state.total).toInt() else 0 + + private companion object { + const val TAG = "WiiCompiledLauncher" + // Must match QuestActivity's android:process in AndroidManifest.xml. + const val GAME_PROCESS_SUFFIX = ":game" + const val KEY_PAGE = "page" + const val KEY_TAB = "settingsTab" + const val PROCESS_EXIT_GRACE_MS = 1000L + const val REQUEST_DISC_IMAGE = 1 + const val REQUEST_GAME_PACKAGE = 2 + const val PREFERENCES = "launcher" + const val KEY_LAST_DROPPED_IMPORT = "lastDroppedImport" + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/NodDisc.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/NodDisc.kt new file mode 100644 index 0000000..b62b2aa --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/NodDisc.kt @@ -0,0 +1,37 @@ +package org.wiicompiled.quest.launcher + +import java.io.IOException +import java.io.InterruptedIOException + +/** + * nod, the disc image library the PC installer runs as nodtool, through + * android/nod-jni. Every call takes the file descriptor of an open disc image + * (ISO, WBFS, RVZ, WIA, CISO, GCZ) and reads the Wii data partition from it. + * All three block, so callers run them off the main thread. + */ +object NodDisc { + + init { + System.loadLibrary("nod_jni") + } + + fun interface Listener { + /** Bytes written so far out of [total]; return false to cancel. */ + fun update(done: Long, total: Long): Boolean + } + + /** "<game id>\n<title>\n<bytes an extraction writes>", see [DiscChecks.parseHeader]. */ + @Throws(IOException::class) + external fun header(fd: Int): String + + /** `sys/main.dol` or a path below `files/` of the data partition. */ + @Throws(IOException::class) + external fun readFile(fd: Int, path: String): ByteArray + + /** + * Writes the data partition to [outDir] in nodtool extract's layout. + * Throws [InterruptedIOException] when [listener] cancels. + */ + @Throws(IOException::class) + external fun extract(fd: Int, outDir: String, listener: Listener) +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt new file mode 100644 index 0000000..c7dfedb --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/SettingsPage.kt @@ -0,0 +1,636 @@ +package org.wiicompiled.quest.launcher + +import android.app.Activity +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.SeekBar +import android.widget.Spinner +import android.widget.Switch +import android.widget.TextView +import android.widget.Toast +import kotlin.math.roundToInt +import org.wiicompiled.quest.BuildConfig +import org.wiicompiled.quest.GameLibrary +import org.wiicompiled.quest.GameStorage +import org.wiicompiled.quest.R + +/** + * The launcher's Settings page: tabs of setting rows, each bound to one or two + * Config.toml keys. + * + * Every row reads its value the way runtime/include/runtime_config.h parses it, + * with the same default and the same accepted range, so what is shown is what + * the game will use. Nothing is written until the player changes a row, and + * each change is one line edit of a fresh read of the file. + */ +class SettingsPage( + private val activity: Activity, + root: View, + private val store: ConfigStore, + private val gameRunning: () -> Boolean, + private val selectDiscImage: () -> Unit, + private val importGame: () -> Unit, +) { + + enum class Tab(val label: Int) { + Vr(R.string.settings_tab_vr), + Graphics(R.string.settings_tab_graphics), + Controls(R.string.settings_tab_controls), + Audio(R.string.settings_tab_audio), + About(R.string.settings_tab_about), + } + + private class Dependent(val row: View, val control: View, val enabledIf: (TomlConfig) -> Boolean) + + private val tabStrip: LinearLayout = root.findViewById(R.id.settings_tabs) + private val rows: LinearLayout = root.findViewById(R.id.settings_rows) + private val scroll: ScrollView = root.findViewById(R.id.settings_scroll) + private val dependents = mutableListOf<Dependent>() + private val tabViews = mutableMapOf<Tab, TextView>() + + /** The file as read for the rows on screen. */ + private var current = TomlConfig.parse("") + + var tab: Tab = Tab.Vr + private set + + init { + for (entry in Tab.entries) { + val view = TextView(activity, null, 0, R.style.Launcher_Tab).apply { + setText(entry.label) + setOnClickListener { select(entry) } + } + tabStrip.addView(view, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, dp(44))) + tabViews[entry] = view + } + tabViews.getValue(tab).isSelected = true + } + + fun select(entry: Tab) { + if (entry != tab) { + tabViews.getValue(tab).isSelected = false + tab = entry + tabViews.getValue(tab).isSelected = true + scroll.scrollTo(0, 0) + } + refresh() + } + + /** Rebuilds the open tab from the file, which the headset panel may have changed. */ + fun refresh() { + current = store.load() ?: TomlConfig.parse("") + rows.removeAllViews() + dependents.clear() + if (gameRunning()) { + banner(R.drawable.bg_banner_warning, R.string.settings_running_note) + } else { + banner(R.drawable.bg_banner_info, R.string.settings_apply_note) + } + when (tab) { + Tab.Vr -> buildVr() + Tab.Graphics -> buildGraphics() + Tab.Controls -> buildControls() + Tab.Audio -> buildAudio() + Tab.About -> buildAbout() + } + updateDependents(current) + } + + private fun buildVr() { + val firstPerson = { c: TomlConfig -> c.bool("vr", "first_person") ?: false } + section(R.string.section_vr_camera) { + choice( + R.string.vr_camera, R.string.vr_camera_helper, + listOf(R.string.vr_camera_chase, R.string.vr_camera_first_person), + read = { if (firstPerson(it)) 1 else 0 }, + write = { c, index -> c.setBool("vr", "first_person", index == 1) }, + ) + choice( + R.string.vr_rotation, R.string.vr_rotation_helper, + listOf(R.string.vr_rotation_yaw, R.string.vr_rotation_yaw_pitch, R.string.vr_rotation_full), + read = { stringIndex(it, "vr", "first_person_rotation", ROTATIONS) }, + write = { c, index -> c.setString("vr", "first_person_rotation", ROTATIONS[index]) }, + enabledIf = firstPerson, + ) + // One setting in two keys, presented as the in-headset panel's two + // tick boxes are: nothing, the driver (model 0), or every model. + choice( + R.string.vr_hide, R.string.vr_hide_helper, + listOf(R.string.vr_hide_nothing, R.string.vr_hide_driver, R.string.vr_hide_driver_and_kart), + read = { + val hide = it.bool("vr", "first_person_hide_driver") ?: true + val model = it.integer("vr", "first_person_hidden_model")?.takeIf { m -> m in -1L..31L } ?: 0L + when { + !hide -> 0 + model < 0 -> 2 + else -> 1 + } + }, + write = { c, index -> + c.setBool("vr", "first_person_hide_driver", index != 0) + if (index != 0) { + c.setInteger("vr", "first_person_hidden_model", if (index == 1) 0L else -1L) + } + }, + enabledIf = firstPerson, + ) + slider( + R.string.vr_lean_back, R.string.vr_lean_back_helper, -45.0, 45.0, 1.0, + read = { number(it, "vr", "lean_back_degrees", -45.0, 45.0, 0.0) }, + format = { "%.0f°".format(it) }, + write = { c, value -> c.setFloat("vr", "lean_back_degrees", value) }, + ) + } + section(R.string.section_vr_headset) { + slider( + R.string.vr_render_scale, R.string.vr_render_scale_helper, 0.25, 2.0, 0.05, + read = { number(it, "vr", "render_scale", 0.25, 2.0, 1.0) }, + format = { "%.2f×".format(it) }, + write = { c, value -> c.setFloat("vr", "render_scale", value) }, + ) + choice( + R.string.vr_interpolation, R.string.vr_interpolation_helper, + listOf(activity.getString(R.string.vr_interpolation_off), activity.getString(R.string.vr_interpolation_auto), "72 FPS", "90 FPS", "120 FPS"), + read = { INTERPOLATION_FPS.indexOf(vrInterpolationFps(it)) }, + write = { c, index -> c.setInteger("vr", "frame_interpolation_fps", INTERPOLATION_FPS[index]) }, + ) + } + section(R.string.section_vr_screen) { + toggle( + R.string.vr_hud_screen, R.string.vr_hud_screen_helper, + read = { it.bool("vr", "hud_virtual_screen") ?: true }, + write = { c, value -> c.setBool("vr", "hud_virtual_screen", value) }, + ) + slider( + R.string.vr_hud_distance, R.string.vr_hud_distance_helper, 0.5, 5.0, 0.1, + read = { number(it, "vr", "hud_distance_meters", 0.25, 10.0, 2.0) }, + format = { "%.1f m".format(it) }, + write = { c, value -> c.setFloat("vr", "hud_distance_meters", value) }, + ) + slider( + R.string.vr_hud_width, R.string.vr_hud_width_helper, 1.0, 6.0, 0.1, + read = { number(it, "vr", "hud_width_meters", 0.25, 20.0, 2.4) }, + format = { "%.1f m".format(it) }, + write = { c, value -> c.setFloat("vr", "hud_width_meters", value) }, + ) + } + } + + private fun buildGraphics() { + section(R.string.section_graphics) { + choice( + R.string.graphics_resolution, R.string.graphics_resolution_helper, + RESOLUTIONS.map { if (it == 1.0) activity.getString(R.string.graphics_resolution_native) else "${TomlConfig.formatFloat(it).removeSuffix(".0")}x" }, + read = { RESOLUTIONS.indexOf(resolution(it)) }, + write = { c, index -> c.setFloat("video", "resolution_multiplier", RESOLUTIONS[index]) }, + custom = { if (resolution(it) == 0.0) "Auto" else "${TomlConfig.formatFloat(resolution(it)).removeSuffix(".0")}x" }, + ) + toggle( + R.string.graphics_widescreen, R.string.graphics_widescreen_helper, + read = { it.bool("video", "widescreen") ?: true }, + write = { c, value -> c.setBool("video", "widescreen", value) }, + ) + toggle( + R.string.graphics_bloom, R.string.graphics_bloom_helper, + read = { (disabledPostProcessing(it) and BLOOM_PATH) != 0L }, + write = { c, value -> c.setInteger("video", "disabled_post_processing_paths", if (value) BLOOM_PATH else 0L) }, + ) + toggle( + R.string.graphics_skip_unready, R.string.graphics_skip_unready_helper, + read = { it.bool("video", "skip_unready_pipelines") ?: true }, + write = { c, value -> c.setBool("video", "skip_unready_pipelines", value) }, + ) + } + } + + private fun buildControls() { + section(R.string.section_controls) { + choice( + R.string.controls_mode, R.string.controls_mode_helper, + listOf(R.string.controls_mode_wii_remote, R.string.controls_mode_gamepad), + read = { stringIndex(it, "vr", "controller_mode", CONTROLLER_MODES) }, + write = { c, index -> c.setString("vr", "controller_mode", CONTROLLER_MODES[index]) }, + ) + toggle( + R.string.controls_rumble, R.string.controls_rumble_helper, + read = { it.bool("controller", "rumble") ?: true }, + write = { c, value -> c.setBool("controller", "rumble", value) }, + ) + } + section(R.string.section_controls_mapping) { + info(R.string.controls_map_a, activity.getString(R.string.controls_map_a_value)) + info(R.string.controls_map_b, activity.getString(R.string.controls_map_b_value)) + info(R.string.controls_map_12, activity.getString(R.string.controls_map_12_value)) + info(R.string.controls_map_minus_plus, activity.getString(R.string.controls_map_minus_plus_value)) + info(R.string.controls_map_stick, activity.getString(R.string.controls_map_stick_value)) + info(R.string.controls_map_z, activity.getString(R.string.controls_map_z_value)) + info(R.string.controls_map_c, activity.getString(R.string.controls_map_c_value)) + info(R.string.controls_map_home, activity.getString(R.string.controls_map_home_value)) + info(R.string.controls_map_pointer, activity.getString(R.string.controls_map_pointer_value)) + info(R.string.controls_map_panel, activity.getString(R.string.controls_map_panel_value)) + } + } + + private fun buildAudio() { + section(R.string.section_audio) { + for ((label, key) in VOLUMES) { + slider( + label, null, 0.0, 100.0, 1.0, + read = { number(it, "audio", key, 0.0, 1.0, 1.0) * 100.0 }, + format = { "%.0f%%".format(it) }, + write = { c, value -> c.setFloat("audio", key, value / 100.0) }, + ) + } + toggle( + R.string.audio_muted, R.string.audio_muted_helper, + read = { it.bool("audio", "muted") ?: false }, + write = { c, value -> c.setBool("audio", "muted", value) }, + ) + } + } + + private fun buildAbout() { + section(R.string.section_about_app) { + info(R.string.about_version, activity.getString(R.string.about_version_value, BuildConfig.VERSION_NAME, BuildConfig.PROFILE)) + } + section(R.string.section_about_storage) { + val disc = GameStorage.discDirectory(activity).absolutePath + val status = when (GameStorage.discStatus(activity)) { + GameStorage.DiscStatus.Ready -> R.string.about_game_data_ready + GameStorage.DiscStatus.Incomplete -> R.string.about_game_data_incomplete + GameStorage.DiscStatus.Missing -> R.string.about_game_data_missing + } + info(R.string.about_game_data, activity.getString(status, disc), stacked = true) + action(R.string.about_extract, R.string.about_extract_helper, R.string.home_select_disc, enabled = !GameSetup.isRunning) { + selectDiscImage() + } + val manifest = GameLibrary.manifest(activity) + val gameText = when (GameLibrary.status(activity)) { + GameLibrary.Status.Ready -> manifest?.let { + activity.getString(R.string.about_game_ready, it.optString("builtBy"), it.optString("builtAt")) + } ?: activity.getString(R.string.about_game_missing) + GameLibrary.Status.Stale -> activity.getString(R.string.about_game_stale) + GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing) + } + info(R.string.about_game, gameText, stacked = true) + action(R.string.home_import, R.string.about_import_helper, R.string.home_import, enabled = !GameSetup.isRunning) { + importGame() + } + info(R.string.about_config, store.file.absolutePath, stacked = true) + info(R.string.about_logs, GameStorage.logsDirectory(activity).absolutePath, stacked = true) + } + section(R.string.section_about_diagnostics) { + toggle( + R.string.about_openxr_logging, R.string.about_openxr_logging_helper, + read = { it.bool("diagnostics", "openxr_logging") ?: false }, + write = { c, value -> c.setBool("diagnostics", "openxr_logging", value) }, + ) + } + } + + // Row construction + + private inner class Section { + private val config = current + val views = mutableListOf<View>() + + fun toggle( + title: Int, + helper: Int?, + read: (TomlConfig) -> Boolean, + write: (TomlConfig, Boolean) -> Unit, + enabledIf: ((TomlConfig) -> Boolean)? = null, + ) { + val control = Switch(activity).apply { + isChecked = read(config) + thumbTintList = checkedColors(R.color.neutral_50, R.color.neutral_300) + trackTintList = checkedColors(R.color.primary_400, R.color.neutral_600) + setOnCheckedChangeListener { _, checked -> commit { write(it, checked) } } + } + val row = row(title, helper, control) + row.setOnClickListener { if (control.isEnabled) control.toggle() } + add(row, control, enabledIf) + } + + /** [read] returns the option index, or -1 when the file holds a value no option names. */ + fun choice( + title: Int, + helper: Int?, + labels: List<Any>, + read: (TomlConfig) -> Int, + write: (TomlConfig, Int) -> Unit, + enabledIf: ((TomlConfig) -> Boolean)? = null, + custom: ((TomlConfig) -> String)? = null, + ) { + val names = labels.map { if (it is Int) activity.getString(it) else it.toString() }.toMutableList() + var shown = read(config) + if (shown < 0) { + // A value only a hand edit can set: show it rather than pretend + // it is one of the options, and keep it until the player picks one. + names += activity.getString(R.string.settings_custom_value, custom?.invoke(config) ?: "?") + shown = names.size - 1 + } + val spinner = Spinner(activity, Spinner.MODE_DROPDOWN).apply { + background = drawable(R.drawable.bg_dropdown) + setPopupBackgroundDrawable(drawable(R.drawable.bg_dropdown_popup)) + dropDownVerticalOffset = dp(4) + adapter = ArrayAdapter(activity, R.layout.item_dropdown, names).apply { + setDropDownViewResource(R.layout.item_dropdown_popup) + } + setSelection(shown, false) + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + if (position == shown || position >= labels.size) { + return + } + shown = position + commit { write(it, position) } + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + } + layoutParams = LinearLayout.LayoutParams(dp(210), LinearLayout.LayoutParams.WRAP_CONTENT) + } + add(row(title, helper, spinner), spinner, enabledIf) + } + + fun slider( + title: Int, + helper: Int?, + min: Double, + max: Double, + step: Double, + read: (TomlConfig) -> Double, + format: (Double) -> String, + write: (TomlConfig, Double) -> Unit, + enabledIf: ((TomlConfig) -> Boolean)? = null, + ) { + val initial = read(config) + var committed = initial + val valueText = TextView(activity).apply { + text = format(initial) + setTextColor(activity.getColor(R.color.neutral_100)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + gravity = Gravity.END or Gravity.CENTER_VERTICAL + layoutParams = LinearLayout.LayoutParams(dp(64), LinearLayout.LayoutParams.WRAP_CONTENT) + } + val steps = ((max - min) / step).roundToInt() + val seekBar = SeekBar(activity).apply { + this.max = steps + progress = ((initial.coerceIn(min, max) - min) / step).roundToInt() + progressTintList = ColorStateList.valueOf(activity.getColor(R.color.primary_400)) + thumbTintList = ColorStateList.valueOf(activity.getColor(R.color.primary_300)) + progressBackgroundTintList = ColorStateList.valueOf(activity.getColor(R.color.neutral_600)) + layoutParams = LinearLayout.LayoutParams(dp(210), LinearLayout.LayoutParams.WRAP_CONTENT) + } + // Rounded so 0.25 + 15 * 0.05 is 1.0 again and an untouched value is not rewritten. + fun valueAt(progress: Int) = (min + progress * step).let { Math.round(it * 1e6) / 1e6 } + fun commitValue(value: Double) { + if (value != committed) { + committed = value + commit { write(it, value) } + } + } + seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + private var tracking = false + + override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) { + if (!fromUser) return + valueText.text = format(valueAt(progress)) + // Dragging writes once, on release; a key or controller step writes at once. + if (!tracking) commitValue(valueAt(progress)) + } + + override fun onStartTrackingTouch(bar: SeekBar) { + tracking = true + } + + override fun onStopTrackingTouch(bar: SeekBar) { + tracking = false + commitValue(valueAt(bar.progress)) + } + }) + val control = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(seekBar) + addView(valueText) + } + add(row(title, helper, control), seekBar, enabledIf) + } + + fun action(title: Int, helper: Int?, button: Int, enabled: Boolean, onClick: () -> Unit) { + val control = TextView(activity).apply { + setText(button) + setTextColor(activity.getColor(R.color.neutral_100)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + gravity = Gravity.CENTER + background = drawable(R.drawable.bg_button_secondary) + setPadding(dp(16), 0, dp(16), 0) + minHeight = dp(40) + isEnabled = enabled + alpha = if (enabled) 1f else 0.45f + setOnClickListener { onClick() } + } + add(row(title, helper, control), null, null) + } + + /** A read-only line; [stacked] puts a long value such as a path under its title. */ + fun info(title: Int, value: String, stacked: Boolean = false) { + val row: LinearLayout + if (stacked) { + row = row(title, null, null) + val texts = row.getChildAt(0) as LinearLayout + texts.addView(helperText(value, selectable = true)) + } else { + val valueText = TextView(activity).apply { + text = value + setTextColor(activity.getColor(R.color.neutral_300)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + } + row = row(title, null, valueText) + row.minimumHeight = dp(44) + } + add(row, null, null) + } + + private fun add(row: View, control: View?, enabledIf: ((TomlConfig) -> Boolean)?) { + views += row + if (control != null && enabledIf != null) { + dependents += Dependent(row, control, enabledIf) + } + } + } + + private fun section(label: Int, build: Section.() -> Unit) { + rows.addView( + TextView(activity, null, 0, R.style.Launcher_SectionLabel).apply { + setText(label) + }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + topMargin = dp(18) + bottomMargin = dp(6) + }, + ) + val section = Section().apply(build) + section.views.forEachIndexed { index, view -> + val last = section.views.size - 1 + view.background = drawable( + when { + last == 0 -> R.drawable.bg_row_single + index == 0 -> R.drawable.bg_row_top + index == last -> R.drawable.bg_row_bottom + else -> R.drawable.bg_row_middle + }, + ) + rows.addView( + view, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + if (index > 0) topMargin = dp(3) + }, + ) + } + } + + private fun row(title: Int, helper: Int?, control: View?): LinearLayout { + val texts = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + addView( + TextView(activity).apply { + setText(title) + setTextColor(activity.getColor(R.color.neutral_100)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) + }, + ) + if (helper != null) { + addView(helperText(activity.getString(helper), selectable = false)) + } + } + return LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(58) + setPadding(dp(16), dp(10), dp(14), dp(10)) + addView(texts, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply { marginEnd = dp(16) }) + if (control != null) { + addView(control) + } + } + } + + private fun helperText(text: String, selectable: Boolean) = TextView(activity).apply { + this.text = text + setTextColor(activity.getColor(R.color.neutral_400)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 12.5f) + setTextIsSelectable(selectable) + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + topMargin = dp(2) + } + } + + private fun banner(background: Int, text: Int) { + val warning = background == R.drawable.bg_banner_warning + val content = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + this.background = drawable(background) + setPadding(dp(12), dp(10), dp(14), dp(10)) + if (warning) { + addView( + ImageView(activity).apply { + setImageResource(R.drawable.ic_warning) + imageTintList = ColorStateList.valueOf(activity.getColor(R.color.warning_500)) + }, + LinearLayout.LayoutParams(dp(20), dp(20)).apply { marginEnd = dp(12) }, + ) + } + addView( + TextView(activity).apply { + setText(text) + setTextColor(activity.getColor(R.color.neutral_300)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) + }, + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f), + ) + } + rows.addView(content) + } + + private fun commit(edit: (TomlConfig) -> Unit) { + var edited: TomlConfig? = null + val saved = store.update { + edit(it) + edited = it + } + if (!saved) { + Toast.makeText(activity, R.string.settings_write_failed, Toast.LENGTH_LONG).show() + } + edited?.let(::updateDependents) + } + + private fun updateDependents(config: TomlConfig) { + for (dependent in dependents) { + val enabled = dependent.enabledIf(config) + dependent.control.isEnabled = enabled + dependent.row.alpha = if (enabled) 1f else 0.45f + } + } + + private fun checkedColors(checked: Int, unchecked: Int) = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(activity.getColor(checked), activity.getColor(unchecked)), + ) + + private fun drawable(id: Int): Drawable = activity.getDrawable(id)!! + + private fun dp(value: Int): Int = (value * activity.resources.displayMetrics.density).roundToInt() + + private companion object { + val ROTATIONS = listOf("yaw", "yaw_pitch", "full") + val CONTROLLER_MODES = listOf("wii_remote", "gamepad") + val INTERPOLATION_FPS = listOf(0L, 1L, 72L, 90L, 120L) + val RESOLUTIONS = listOf(1.0, 1.5, 2.0, 3.0, 4.0) + val SUPPORTED_RESOLUTIONS = listOf(0.0, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0) + const val BLOOM_PATH = 0x10L + const val UINT32_MAX = 0xFFFF_FFFFL + val VOLUMES = listOf( + R.string.audio_master to "volume", + R.string.audio_music to "music_volume", + R.string.audio_sound_effects to "sound_effects_volume", + R.string.audio_voices to "voices_volume", + R.string.audio_ui to "ui_volume", + ) + + /** A value outside what the runtime accepts reads as the runtime's default. */ + fun number(config: TomlConfig, section: String, key: String, min: Double, max: Double, default: Double): Double = + config.number(section, key)?.takeIf { it in min..max } ?: default + + /** Unrecognised strings fall back to the first (default) option, as in the runtime. */ + fun stringIndex(config: TomlConfig, section: String, key: String, values: List<String>): Int = + values.indexOf(config.string(section, key)).coerceAtLeast(0) + + fun resolution(config: TomlConfig): Double = + config.number("video", "resolution_multiplier")?.takeIf { it in SUPPORTED_RESOLUTIONS } ?: 1.0 + + /** RuntimeUserConfig's disabledPostProcessingPaths: only the bloom bit is accepted. */ + fun disabledPostProcessing(config: TomlConfig): Long = + config.integer("video", "disabled_post_processing_paths") + ?.takeIf { it in 0L..UINT32_MAX && (it and BLOOM_PATH.inv()) == 0L } ?: BLOOM_PATH + + /** vr.frame_interpolation_fps, with the legacy frame_interpolation switch and NormalizeFrameInterpolationFps. */ + fun vrInterpolationFps(config: TomlConfig): Long { + val value = config.integer("vr", "frame_interpolation_fps")?.takeIf { it in 0L..UINT32_MAX } + ?: config.bool("vr", "frame_interpolation")?.let { if (it) 1L else 0L } + ?: 0L + return if (value in INTERPOLATION_FPS) value else 0L + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/TomlConfig.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/TomlConfig.kt new file mode 100644 index 0000000..0ed6e00 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/TomlConfig.kt @@ -0,0 +1,227 @@ +package org.wiicompiled.quest.launcher + +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.Locale + +/** + * The runtime's Config.toml held as lines, read and edited one flat + * `key = value` at a time so every comment, unknown key and path the runtime, + * the in-headset settings panel or the player put there survives. + * + * Edits follow RuntimeConfigFile::WriteSetting (runtime/include/runtime_config.h) + * line for line: a present key is replaced where it stands, a missing key goes + * after its section's last non-blank line, and a missing section is appended. + * The typed readers accept only what the runtime's toml11 lookups accept, so a + * value the game would ignore reads as absent here too and shows its default. + */ +class TomlConfig private constructor(private val lines: MutableList<String>) { + + private class Section(val header: Int, val end: Int) + + fun text(): String = if (lines.isEmpty()) "" else lines.joinToString("\n", postfix = "\n") + + /** The value of [section].[key] as written, without a trailing comment. */ + fun literal(section: String, key: String): String? { + val found = findSection(section) ?: return null + val index = findKey(found, key) ?: return null + val code = removeComment(lines[index]) + return code.substring(code.indexOf('=') + 1).trim().ifEmpty { null } + } + + fun bool(section: String, key: String): Boolean? = when (literal(section, key)) { + "true" -> true + "false" -> false + else -> null + } + + /** An integer literal; the runtime's integer keys reject floats. */ + fun integer(section: String, key: String): Long? { + val literal = literal(section, key) ?: return null + return if (INTEGER.matches(literal)) literal.replace("_", "").toLongOrNull() else null + } + + /** An integer or float literal, as FindConfigFloat accepts either. */ + fun number(section: String, key: String): Double? { + val literal = literal(section, key) ?: return null + if (!INTEGER.matches(literal) && !FLOAT.matches(literal)) { + return null + } + return literal.replace("_", "").toDoubleOrNull()?.takeIf { it.isFinite() } + } + + fun string(section: String, key: String): String? = literal(section, key)?.let(::unquote) + + fun set(section: String, key: String, literal: String) { + val replacement = "$key = $literal" + val found = findSection(section) + if (found == null) { + if (lines.isNotEmpty() && lines.last().isNotEmpty()) { + lines.add("") + } + lines.add("[$section]") + lines.add(replacement) + return + } + val index = findKey(found, key) + if (index != null) { + lines[index] = replacement + return + } + // Keep the key above the blank line that separates this section from + // the next header, where a reader would take it for the next section's. + var insertAt = found.end + while (insertAt > found.header + 1 && lines[insertAt - 1].trim().isEmpty()) { + insertAt-- + } + lines.add(insertAt, replacement) + } + + fun setBool(section: String, key: String, value: Boolean) = set(section, key, value.toString()) + + fun setInteger(section: String, key: String, value: Long) = set(section, key, value.toString()) + + fun setFloat(section: String, key: String, value: Double) = set(section, key, formatFloat(value)) + + fun setString(section: String, key: String, value: String) = set(section, key, quote(value)) + + private fun findSection(section: String): Section? { + var header = -1 + for (i in lines.indices) { + val name = headerName(lines[i]) ?: continue + if (header >= 0) { + return Section(header, i) + } + if (name == section) { + header = i + } + } + return if (header >= 0) Section(header, lines.size) else null + } + + private fun findKey(section: Section, key: String): Int? { + for (i in section.header + 1 until section.end) { + val code = removeComment(lines[i]).trim() + val equals = code.indexOf('=') + if (equals >= 0 && code.substring(0, equals).trim() == key) { + return i + } + } + return null + } + + companion object { + private val INTEGER = Regex("[+-]?\\d(_?\\d)*") + private val FLOAT = Regex("[+-]?\\d(_?\\d)*(\\.\\d(_?\\d)*)?([eE][+-]?\\d(_?\\d)*)?") + + fun parse(text: String): TomlConfig { + val lines = text.split('\n').map { it.removeSuffix("\r") }.toMutableList() + if (lines.last().isEmpty()) { + lines.removeAt(lines.size - 1) + } + return TomlConfig(lines) + } + + /** The line up to a `#` that is not inside a quoted string. */ + fun removeComment(line: String): String { + var inSingle = false + var inDouble = false + var escaped = false + for (i in line.indices) { + val ch = line[i] + if (inDouble && ch == '\\' && !escaped) { + escaped = true + continue + } + when { + ch == '\'' && !inDouble -> inSingle = !inSingle + ch == '"' && !inSingle && !escaped -> inDouble = !inDouble + ch == '#' && !inSingle && !inDouble -> return line.substring(0, i) + } + escaped = false + } + return line + } + + private fun headerName(line: String): String? { + val code = removeComment(line).trim() + if (code.length < 2 || code.first() != '[' || code.last() != ']') { + return null + } + return code.substring(1, code.length - 1).trim() + } + + /** Three decimals at most, and always a decimal point so TOML reads a float. */ + fun formatFloat(value: Double): String { + val text = BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + return if (text.contains('.')) text else "$text.0" + } + + fun quote(value: String): String = buildString { + append('"') + for (ch in value) { + when (ch) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> if (ch < ' ' || ch == '') append(String.format(Locale.ROOT, "\\u%04x", ch.code)) else append(ch) + } + } + append('"') + } + + /** A TOML basic or literal string's content, or null for anything else. */ + fun unquote(literal: String): String? { + if (literal.length >= 2 && literal.first() == '\'' && literal.last() == '\'') { + val content = literal.substring(1, literal.length - 1) + return if (content.contains('\'')) null else content + } + if (literal.length < 2 || literal.first() != '"' || literal.last() != '"') { + return null + } + val result = StringBuilder() + var i = 1 + val end = literal.length - 1 + while (i < end) { + val ch = literal[i] + if (ch == '"') { + return null + } + if (ch != '\\') { + result.append(ch) + i++ + continue + } + if (i + 1 >= end) { + return null + } + when (val escape = literal[i + 1]) { + 'b' -> result.append('\b') + 't' -> result.append('\t') + 'n' -> result.append('\n') + 'f' -> result.append(' ') + 'r' -> result.append('\r') + '"' -> result.append('"') + '\\' -> result.append('\\') + 'u', 'U' -> { + val digits = if (escape == 'u') 4 else 8 + if (i + 2 + digits > end) { + return null + } + val code = literal.substring(i + 2, i + 2 + digits).toIntOrNull(16) ?: return null + if (!Character.isValidCodePoint(code)) { + return null + } + result.appendCodePoint(code) + i += digits + } + else -> return null + } + i += 2 + } + return result.toString() + } + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/WheelTrailsView.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/WheelTrailsView.kt new file mode 100644 index 0000000..71f73a5 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/WheelTrailsView.kt @@ -0,0 +1,140 @@ +package org.wiicompiled.quest.launcher + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import org.wiicompiled.quest.R + +/** + * The Home page's backdrop: WheelWizard's wheel trails, broad diagonal bands + * that each end in a wheel. They roll in when the page opens and roll away + * when Play is pressed. + * + * A trail is anchored at its wheel and its band runs from there off the edge. + * Rolling moves the wheel along the band, so an entering or leaving trail + * always comes from or goes to the edge it is attached to. + */ +class WheelTrailsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : View(context, attrs) { + + private class Trail( + val fromRight: Boolean, + val xDp: Float, + val fromBottom: Boolean, + val yDp: Float, + /** Clockwise from straight down, the direction the band runs. */ + val angle: Float, + val band: Int, + val wheel: Int, + ) + + private val trails = listOf( + Trail(false, 30f, false, -20f, 45f, R.color.primary_400, R.color.primary_700), + Trail(false, 210f, false, 10f, 45f, R.color.primary_600, R.color.primary_800), + Trail(false, 230f, false, 210f, 45f, R.color.primary_200, R.color.primary_600), + Trail(true, 230f, true, 60f, -35f, R.color.primary_400, R.color.primary_700), + Trail(true, 40f, true, 10f, -35f, R.color.primary_600, R.color.primary_800), + ) + + private val density = resources.displayMetrics.density + private val bandWidth = 137f * density + private val bandLength = 3000f * density + private val bandPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val wheelPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val rect = RectF() + + /** How far every wheel sits from its anchor along its band, in pixels. */ + private var offset = 0f + private var rotationDegrees = 0f + private var animator: ValueAnimator? = null + + /** Rolls the trails in from the edges. */ + fun enter() { + animate(from = 320f * density, to = 0f, spin = 560f, durationMs = 650L, leaving = false, done = null) + } + + /** Rolls the trails off the edges, then runs [done]. */ + fun leave(done: () -> Unit) { + animate(from = offset, to = 1400f * density, spin = 360f, durationMs = 450L, leaving = true, done = done) + } + + private fun animate(from: Float, to: Float, spin: Float, durationMs: Long, leaving: Boolean, done: (() -> Unit)?) { + animator?.cancel() + val startRotation = if (leaving) rotationDegrees else -200f + animator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = durationMs + interpolator = if (leaving) AccelerateInterpolator(1.6f) else DecelerateInterpolator(2f) + addUpdateListener { + val t = it.animatedValue as Float + offset = from + (to - from) * t + rotationDegrees = startRotation + spin * t + invalidate() + } + if (done != null) { + addListener(object : android.animation.AnimatorListenerAdapter() { + private var cancelled = false + override fun onAnimationCancel(animation: android.animation.Animator) { + cancelled = true + } + + override fun onAnimationEnd(animation: android.animation.Animator) { + if (!cancelled) done() + } + }) + } + start() + } + } + + override fun onDetachedFromWindow() { + animator?.cancel() + super.onDetachedFromWindow() + } + + override fun onDraw(canvas: Canvas) { + for (trail in trails) { + val x = if (trail.fromRight) width - trail.xDp * density else trail.xDp * density + val y = if (trail.fromBottom) height - trail.yDp * density else trail.yDp * density + canvas.save() + canvas.translate(x, y) + canvas.rotate(trail.angle) + canvas.translate(0f, offset) + drawTrail(canvas, trail) + canvas.restore() + } + } + + private fun drawTrail(canvas: Canvas, trail: Trail) { + val half = bandWidth / 2f + bandPaint.color = context.getColor(trail.band) + wheelPaint.color = context.getColor(trail.wheel) + + rect.set(-half, -half, half, bandLength) + canvas.drawRoundRect(rect, half, half, bandPaint) + + // Tyre, rim and hub, the same wheel as ic_wheel at band scale. + canvas.rotate(rotationDegrees) + val unit = bandWidth / 24f * 0.85f + canvas.drawCircle(0f, 0f, 11f * unit, wheelPaint) + canvas.drawCircle(0f, 0f, 8f * unit, bandPaint) + canvas.drawCircle(0f, 0f, 6.5f * unit, wheelPaint) + for (spoke in 0 until 5) { + val radians = Math.toRadians(-90.0 + 72.0 * spoke) + canvas.drawCircle( + (3.9f * unit * Math.cos(radians)).toFloat(), + (3.9f * unit * Math.sin(radians)).toFloat(), + 1.45f * unit, + bandPaint, + ) + } + canvas.drawCircle(0f, 0f, 0.9f * unit, bandPaint) + } +} diff --git a/android/app/src/main/res/color/nav_content.xml b/android/app/src/main/res/color/nav_content.xml new file mode 100644 index 0000000..1d840da --- /dev/null +++ b/android/app/src/main/res/color/nav_content.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="@color/primary_400" android:state_selected="true" /> + <item android:color="@color/neutral_50" android:state_hovered="true" /> + <item android:color="@color/neutral_300" /> +</selector> diff --git a/android/app/src/main/res/color/play_button_content.xml b/android/app/src/main/res/color/play_button_content.xml new file mode 100644 index 0000000..ee7ca2c --- /dev/null +++ b/android/app/src/main/res/color/play_button_content.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="@color/neutral_400" android:state_enabled="false" /> + <item android:color="@color/neutral_50" /> +</selector> diff --git a/android/app/src/main/res/color/tab_text.xml b/android/app/src/main/res/color/tab_text.xml new file mode 100644 index 0000000..9f5d052 --- /dev/null +++ b/android/app/src/main/res/color/tab_text.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="@color/neutral_50" android:state_selected="true" /> + <item android:color="@color/neutral_200" android:state_hovered="true" /> + <item android:color="@color/neutral_400" /> +</selector> diff --git a/android/app/src/main/res/drawable/bg_banner_info.xml b/android/app/src/main/res/drawable/bg_banner_info.xml new file mode 100644 index 0000000..22b38ca --- /dev/null +++ b/android/app/src/main/res/drawable/bg_banner_info.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/neutral_900" /> + <stroke android:width="1dp" android:color="@color/neutral_700" /> + <corners android:radius="8dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/bg_banner_warning.xml b/android/app/src/main/res/drawable/bg_banner_warning.xml new file mode 100644 index 0000000..8f63a21 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_banner_warning.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/neutral_800" /> + <stroke android:width="1dp" android:color="@color/warning_500" /> + <corners android:radius="8dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/bg_button_secondary.xml b/android/app/src/main/res/drawable/bg_button_secondary.xml new file mode 100644 index 0000000..f1da5d6 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_button_secondary.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_enabled="false"> + <shape android:shape="rectangle"> + <solid android:color="@color/neutral_800" /> + <corners android:radius="6dp" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/neutral_600" /> + <corners android:radius="6dp" /> + </shape> + </item> + <item android:state_hovered="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/neutral_700" /> + <corners android:radius="6dp" /> + </shape> + </item> + <item> + <shape android:shape="rectangle"> + <solid android:color="@color/neutral_800" /> + <stroke android:width="1dp" android:color="@color/neutral_700" /> + <corners android:radius="6dp" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/bg_content.xml b/android/app/src/main/res/drawable/bg_content.xml new file mode 100644 index 0000000..7428793 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_content.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/neutral_950" /> + <corners android:topLeftRadius="14dp" android:bottomLeftRadius="14dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/bg_dropdown.xml b/android/app/src/main/res/drawable/bg_dropdown.xml new file mode 100644 index 0000000..7b46874 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_dropdown.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item> + <selector> + <item android:state_hovered="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/neutral_700" /> + <corners android:radius="6dp" /> + </shape> + </item> + <item> + <shape android:shape="rectangle"> + <solid android:color="@color/neutral_800" /> + <corners android:radius="6dp" /> + </shape> + </item> + </selector> + </item> + <item + android:width="16dp" + android:height="16dp" + android:end="10dp" + android:drawable="@drawable/ic_chevron_down" + android:gravity="end|center_vertical" /> +</layer-list> diff --git a/android/app/src/main/res/drawable/bg_dropdown_popup.xml b/android/app/src/main/res/drawable/bg_dropdown_popup.xml new file mode 100644 index 0000000..8ed8c60 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_dropdown_popup.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/neutral_800" /> + <stroke android:width="1dp" android:color="@color/neutral_700" /> + <corners android:radius="6dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/bg_home_card.xml b/android/app/src/main/res/drawable/bg_home_card.xml new file mode 100644 index 0000000..d87f140 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_home_card.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The tall card behind the game title: a half-circle top on a 250dp width. --> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/neutral_900" /> + <corners android:topLeftRadius="125dp" android:topRightRadius="125dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/bg_nav_item.xml b/android/app/src/main/res/drawable/bg_nav_item.xml new file mode 100644 index 0000000..534563b --- /dev/null +++ b/android/app/src/main/res/drawable/bg_nav_item.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Sidebar entry: the open page takes the content's colour, as in WheelWizard's menu. --> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_selected="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/neutral_950" /> + <corners android:radius="8dp" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/neutral_800" /> + <corners android:radius="8dp" /> + </shape> + </item> + <item android:state_hovered="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/neutral_800" /> + <corners android:radius="8dp" /> + </shape> + </item> + <item android:drawable="@android:color/transparent" /> +</selector> diff --git a/android/app/src/main/res/drawable/bg_pill.xml b/android/app/src/main/res/drawable/bg_pill.xml new file mode 100644 index 0000000..37eb263 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_pill.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <stroke android:width="1dp" android:color="@color/neutral_600" /> + <corners android:radius="999dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/bg_play_button.xml b/android/app/src/main/res/drawable/bg_play_button.xml new file mode 100644 index 0000000..0e2ae01 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_play_button.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_enabled="false"> + <shape android:shape="rectangle"> + <solid android:color="@color/neutral_700" /> + <corners android:radius="6dp" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/primary_800" /> + <corners android:radius="6dp" /> + </shape> + </item> + <item android:state_hovered="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/primary_600" /> + <corners android:radius="6dp" /> + </shape> + </item> + <item> + <shape android:shape="rectangle"> + <solid android:color="@color/primary_700" /> + <corners android:radius="6dp" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/bg_row_bottom.xml b/android/app/src/main/res/drawable/bg_row_bottom.xml new file mode 100644 index 0000000..6c2b406 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_row_bottom.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/neutral_900" /> + <corners android:bottomLeftRadius="8dp" android:bottomRightRadius="8dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/bg_row_middle.xml b/android/app/src/main/res/drawable/bg_row_middle.xml new file mode 100644 index 0000000..d9b787e --- /dev/null +++ b/android/app/src/main/res/drawable/bg_row_middle.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/neutral_900" /> +</shape> diff --git a/android/app/src/main/res/drawable/bg_row_single.xml b/android/app/src/main/res/drawable/bg_row_single.xml new file mode 100644 index 0000000..056369f --- /dev/null +++ b/android/app/src/main/res/drawable/bg_row_single.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/neutral_900" /> + <corners android:radius="8dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/bg_row_top.xml b/android/app/src/main/res/drawable/bg_row_top.xml new file mode 100644 index 0000000..66c511b --- /dev/null +++ b/android/app/src/main/res/drawable/bg_row_top.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/neutral_900" /> + <corners android:topLeftRadius="8dp" android:topRightRadius="8dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/bg_tab.xml b/android/app/src/main/res/drawable/bg_tab.xml new file mode 100644 index 0000000..cd5fb6a --- /dev/null +++ b/android/app/src/main/res/drawable/bg_tab.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Settings tab: an underline marks the open tab. --> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_selected="true"> + <layer-list> + <item android:gravity="bottom" android:height="3dp"> + <shape android:shape="rectangle"> + <solid android:color="@color/primary_400" /> + <corners android:radius="2dp" /> + </shape> + </item> + </layer-list> + </item> + <item android:state_hovered="true"> + <layer-list> + <item android:gravity="bottom" android:height="3dp"> + <shape android:shape="rectangle"> + <solid android:color="@color/neutral_600" /> + <corners android:radius="2dp" /> + </shape> + </item> + </layer-list> + </item> + <item android:drawable="@android:color/transparent" /> +</selector> diff --git a/android/app/src/main/res/drawable/ic_chevron_down.xml b/android/app/src/main/res/drawable/ic_chevron_down.xml new file mode 100644 index 0000000..0ee683b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_chevron_down.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:strokeColor="@color/neutral_300" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:strokeWidth="2.5" + android:pathData="M6,9L12,15L18,9" /> +</vector> diff --git a/android/app/src/main/res/drawable/ic_disc.xml b/android/app/src/main/res/drawable/ic_disc.xml new file mode 100644 index 0000000..f40dd59 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_disc.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- An optical disc with its centre hole and a read-side highlight. Drawn for this app. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FFFFFFFF" + android:fillType="evenOdd" + android:pathData="M1.5,12a10.5,10.5 0 1,0 21,0a10.5,10.5 0 1,0 -21,0z + M9.5,12a2.5,2.5 0 1,0 5,0a2.5,2.5 0 1,0 -5,0z + M12,4.5A7.5,7.5 0 0,0 4.5,12H6A6,6 0 0,1 12,6z" /> +</vector> diff --git a/android/app/src/main/res/drawable/ic_home.xml b/android/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 0000000..5ac3239 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FFFFFFFF" + android:strokeColor="#FFFFFFFF" + android:strokeLineJoin="round" + android:strokeWidth="1.5" + android:pathData="M12,3L2.5,11.5H5V20.5H10V15H14V20.5H19V11.5H21.5Z" /> +</vector> diff --git a/android/app/src/main/res/drawable/ic_play.xml b/android/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..07fd5ea --- /dev/null +++ b/android/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FFFFFFFF" + android:strokeColor="#FFFFFFFF" + android:strokeLineJoin="round" + android:strokeWidth="2.5" + android:pathData="M7,4.5L19.5,12L7,19.5Z" /> +</vector> diff --git a/android/app/src/main/res/drawable/ic_sliders.xml b/android/app/src/main/res/drawable/ic_sliders.xml new file mode 100644 index 0000000..84b495f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_sliders.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:strokeColor="#FFFFFFFF" + android:strokeLineCap="round" + android:strokeWidth="2" + android:pathData="M3.5,6H20.5M3.5,12H20.5M3.5,18H20.5" /> + <path + android:fillColor="#FFFFFFFF" + android:pathData="M12.4,6a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0z + M5.9,12a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0z + M10.4,18a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0z" /> +</vector> diff --git a/android/app/src/main/res/drawable/ic_warning.xml b/android/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..7e52fcc --- /dev/null +++ b/android/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FFFFFFFF" + android:fillType="evenOdd" + android:pathData="M12,2.5L22.5,20.5H1.5Z M11,9H13V15H11Z M11,16.5H13V18.5H11Z" /> +</vector> diff --git a/android/app/src/main/res/drawable/ic_wheel.xml b/android/app/src/main/res/drawable/ic_wheel.xml new file mode 100644 index 0000000..25539f7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_wheel.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- A kart wheel: tyre, rim with five holes, hub. Drawn for this app. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FFFFFFFF" + android:fillType="evenOdd" + android:pathData="M1,12a11,11 0 1,0 22,0a11,11 0 1,0 -22,0z M4,12a8,8 0 1,0 16,0a8,8 0 1,0 -16,0z" /> + <path + android:fillColor="#FFFFFFFF" + android:fillType="evenOdd" + android:pathData="M5.5,12a6.5,6.5 0 1,0 13,0a6.5,6.5 0 1,0 -13,0z + M10.55,8.1a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z + M14.26,10.8a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z + M12.84,15.16a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z + M8.26,15.16a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z + M6.84,10.8a1.45,1.45 0 1,0 2.9,0a1.45,1.45 0 1,0 -2.9,0z + M11.1,12a0.9,0.9 0 1,0 1.8,0a0.9,0.9 0 1,0 -1.8,0z" /> +</vector> diff --git a/android/app/src/main/res/layout/activity_launcher.xml b/android/app/src/main/res/layout/activity_launcher.xml new file mode 100644 index 0000000..3c9b71a --- /dev/null +++ b/android/app/src/main/res/layout/activity_launcher.xml @@ -0,0 +1,113 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The launcher panel: WheelWizard's layout, a sidebar of pages beside the open page. --> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/neutral_900" + android:orientation="horizontal"> + + <LinearLayout + android:layout_width="210dp" + android:layout_height="match_parent" + android:orientation="vertical" + android:paddingStart="10dp" + android:paddingTop="12dp" + android:paddingEnd="10dp" + android:paddingBottom="12dp"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingStart="4dp" + android:paddingBottom="20dp"> + + <ImageView + android:layout_width="26dp" + android:layout_height="26dp" + android:importantForAccessibility="no" + android:src="@drawable/ic_wheel" + android:tint="@color/neutral_400" /> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="10dp" + android:text="@string/app_name" + android:textColor="@color/neutral_300" + android:textSize="16sp" /> + </LinearLayout> + + <TextView + style="@style/Launcher.SectionLabel" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="4dp" + android:layout_marginBottom="6dp" + android:text="@string/launcher_section_general" /> + + <LinearLayout + android:id="@+id/nav_home" + style="@style/Launcher.NavItem"> + + <ImageView + style="@style/Launcher.NavIcon" + android:importantForAccessibility="no" + android:src="@drawable/ic_home" /> + + <TextView + style="@style/Launcher.NavLabel" + android:text="@string/launcher_nav_home" /> + </LinearLayout> + + <LinearLayout + android:id="@+id/nav_settings" + style="@style/Launcher.NavItem"> + + <ImageView + style="@style/Launcher.NavIcon" + android:importantForAccessibility="no" + android:src="@drawable/ic_sliders" /> + + <TextView + style="@style/Launcher.NavLabel" + android:text="@string/launcher_nav_settings" /> + </LinearLayout> + + <Space + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" /> + + <TextView + android:id="@+id/launcher_version" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="4dp" + android:background="@drawable/bg_pill" + android:paddingStart="12dp" + android:paddingTop="4dp" + android:paddingEnd="12dp" + android:paddingBottom="4dp" + android:textColor="@color/neutral_300" + android:textSize="13sp" /> + </LinearLayout> + + <FrameLayout + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="1" + android:background="@drawable/bg_content" + android:clipToOutline="true"> + + <include + android:id="@+id/page_home" + layout="@layout/page_home" /> + + <include + android:id="@+id/page_settings" + layout="@layout/page_settings" + android:visibility="gone" /> + </FrameLayout> +</LinearLayout> diff --git a/android/app/src/main/res/layout/item_dropdown.xml b/android/app/src/main/res/layout/item_dropdown.xml new file mode 100644 index 0000000..6e7e724 --- /dev/null +++ b/android/app/src/main/res/layout/item_dropdown.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<TextView xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="40dp" + android:ellipsize="end" + android:gravity="center_vertical" + android:maxLines="1" + android:paddingStart="12dp" + android:paddingEnd="34dp" + android:textColor="@color/neutral_100" + android:textSize="14sp" /> diff --git a/android/app/src/main/res/layout/item_dropdown_popup.xml b/android/app/src/main/res/layout/item_dropdown_popup.xml new file mode 100644 index 0000000..6d00734 --- /dev/null +++ b/android/app/src/main/res/layout/item_dropdown_popup.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<TextView xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="44dp" + android:background="@drawable/bg_nav_item" + android:gravity="center_vertical" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:textColor="@color/neutral_100" + android:textSize="14sp" /> diff --git a/android/app/src/main/res/layout/page_home.xml b/android/app/src/main/res/layout/page_home.xml new file mode 100644 index 0000000..38669bb --- /dev/null +++ b/android/app/src/main/res/layout/page_home.xml @@ -0,0 +1,203 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipToPadding="false" + android:orientation="vertical" + android:paddingStart="24dp" + android:paddingEnd="24dp"> + + <TextView + style="@style/Launcher.PageTitle" + android:layout_width="wrap_content" + android:layout_height="64dp" + android:gravity="bottom" + android:paddingBottom="10dp" + android:text="@string/launcher_nav_home" /> + + <View + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="@color/neutral_700" /> + + <LinearLayout + android:id="@+id/home_data_banner" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="12dp" + android:background="@drawable/bg_banner_warning" + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingStart="12dp" + android:paddingTop="10dp" + android:paddingEnd="14dp" + android:paddingBottom="10dp" + android:visibility="gone"> + + <ImageView + android:id="@+id/home_data_banner_icon" + android:layout_width="22dp" + android:layout_height="22dp" + android:importantForAccessibility="no" + android:src="@drawable/ic_warning" + android:tint="@color/warning_500" /> + + <TextView + android:id="@+id/home_data_banner_text" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="12dp" + android:layout_weight="1" + android:textColor="@color/neutral_200" + android:textIsSelectable="true" + android:textSize="13sp" /> + </LinearLayout> + + <FrameLayout + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_marginStart="-24dp" + android:layout_marginEnd="-24dp" + android:layout_weight="1" + android:clipChildren="true"> + + <org.wiicompiled.quest.launcher.WheelTrailsView + android:id="@+id/home_trails" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + + <LinearLayout + android:layout_width="250dp" + android:layout_height="match_parent" + android:layout_gravity="center_horizontal" + android:layout_marginTop="28dp" + android:background="@drawable/bg_home_card" + android:gravity="center_horizontal" + android:orientation="vertical" + android:paddingStart="10dp" + android:paddingEnd="10dp"> + + <ImageView + android:layout_width="200dp" + android:layout_height="200dp" + android:layout_marginTop="25dp" + android:importantForAccessibility="no" + android:src="@drawable/ic_wheel" + android:tint="@color/neutral_600" /> + + <Space + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" /> + + <TextView + android:id="@+id/home_title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:fontFamily="sans-serif-medium" + android:gravity="center" + android:textColor="@color/neutral_300" + android:textSize="24sp" /> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="6dp" + android:gravity="center" + android:text="@string/home_backend" + android:textColor="@color/neutral_400" + android:textSize="15sp" /> + + <LinearLayout + android:id="@+id/home_play" + android:layout_width="wrap_content" + android:layout_height="48dp" + android:layout_marginTop="22dp" + android:background="@drawable/bg_play_button" + android:clickable="true" + android:focusable="true" + android:gravity="center" + android:minWidth="190dp" + android:orientation="horizontal" + android:paddingStart="20dp" + android:paddingEnd="24dp"> + + <ImageView + android:id="@+id/home_play_icon" + android:layout_width="20dp" + android:layout_height="20dp" + android:duplicateParentState="true" + android:importantForAccessibility="no" + android:src="@drawable/ic_play" + android:tint="@color/play_button_content" /> + + <TextView + android:id="@+id/home_play_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="12dp" + android:duplicateParentState="true" + android:fontFamily="sans-serif-medium" + android:text="@string/home_play" + android:textColor="@color/play_button_content" + android:textSize="17sp" /> + </LinearLayout> + + <TextView + android:id="@+id/home_secondary" + android:layout_width="wrap_content" + android:layout_height="40dp" + android:layout_marginTop="8dp" + android:background="@drawable/bg_button_secondary" + android:clickable="true" + android:focusable="true" + android:gravity="center" + android:minWidth="190dp" + android:paddingStart="16dp" + android:paddingEnd="16dp" + android:textColor="@color/neutral_100" + android:textSize="15sp" + android:visibility="gone" /> + + <ProgressBar + android:id="@+id/home_progress" + style="@android:style/Widget.Material.ProgressBar.Horizontal" + android:layout_width="190dp" + android:layout_height="wrap_content" + android:layout_marginTop="14dp" + android:max="1000" + android:progressBackgroundTint="@color/neutral_700" + android:progressTint="@color/primary_400" + android:visibility="gone" /> + + <TextView + android:id="@+id/home_status" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="12dp" + android:gravity="center" + android:minLines="2" + android:textColor="@color/neutral_400" + android:textSize="13sp" /> + + <TextView + android:id="@+id/home_cancel" + android:layout_width="wrap_content" + android:layout_height="36dp" + android:background="@drawable/bg_nav_item" + android:clickable="true" + android:focusable="true" + android:gravity="center" + android:paddingStart="16dp" + android:paddingEnd="16dp" + android:text="@string/home_cancel" + android:textColor="@color/neutral_200" + android:textSize="14sp" + android:visibility="gone" /> + + <Space + android:layout_width="match_parent" + android:layout_height="40dp" /> + </LinearLayout> + </FrameLayout> +</LinearLayout> diff --git a/android/app/src/main/res/layout/page_settings.xml b/android/app/src/main/res/layout/page_settings.xml new file mode 100644 index 0000000..6b9940b --- /dev/null +++ b/android/app/src/main/res/layout/page_settings.xml @@ -0,0 +1,51 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + android:paddingStart="24dp" + android:paddingEnd="24dp"> + + <TextView + style="@style/Launcher.PageTitle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="18dp" + android:text="@string/launcher_nav_settings" /> + + <HorizontalScrollView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + android:scrollbars="none"> + + <LinearLayout + android:id="@+id/settings_tabs" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="-14dp" + android:orientation="horizontal" /> + </HorizontalScrollView> + + <View + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="@color/neutral_700" /> + + <ScrollView + android:id="@+id/settings_scroll" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:fadeScrollbars="false" + android:scrollbarStyle="outsideOverlay"> + + <LinearLayout + android:id="@+id/settings_rows" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:paddingTop="14dp" + android:paddingBottom="28dp" /> + </ScrollView> +</LinearLayout> diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index ba2528e..e9c6959 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,4 +1,29 @@ <?xml version="1.0" encoding="utf-8"?> <resources> <color name="ic_launcher_background">#1A237E</color> + + <!-- The launcher uses WheelWizard's palette (Views/Styles/Resources/Constants.axaml) + so the Quest and PC launchers read as the same product. --> + <color name="neutral_50">#F6F7F9</color> + <color name="neutral_100">#EDEDF1</color> + <color name="neutral_200">#D7D9E0</color> + <color name="neutral_300">#B4B8C5</color> + <color name="neutral_400">#8B91A5</color> + <color name="neutral_500">#6C7389</color> + <color name="neutral_600">#575D72</color> + <color name="neutral_700">#474B5D</color> + <color name="neutral_800">#3E424E</color> + <color name="neutral_900">#363944</color> + <color name="neutral_950">#24252D</color> + + <color name="primary_200">#8EFFE2</color> + <color name="primary_300">#34EAC5</color> + <color name="primary_400">#01CBA5</color> + <color name="primary_600">#00A488</color> + <color name="primary_700">#03826E</color> + <color name="primary_800">#08675A</color> + + <color name="warning_500">#FFC800</color> + <color name="warning_600">#D19200</color> + <color name="danger_500">#FE2048</color> </resources> diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 01f2cfc..1f94aac 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -3,4 +3,172 @@ <string name="app_name">WiiCompiled VR</string> <string name="missing_game_data_title">Game data not found</string> <string name="missing_game_data_message">Copy the extracted DATA directory of your own PAL RMCP01 Mario Kart Wii disc to:\n\n%1$s\n\nFor example with adb: adb push DATA /sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA\n\nThen start the app again.</string> + + <!-- Launcher shell --> + <string name="launcher_section_general">General</string> + <string name="launcher_nav_home">Home</string> + <string name="launcher_nav_settings">Settings</string> + <string name="launcher_version">v%1$s</string> + + <!-- Home --> + <string name="home_title_base">Mario Kart Wii</string> + <string name="home_title_retro_rewind">Retro Rewind</string> + <string name="home_backend">WiiCompiled OpenXR VR</string> + <string name="home_play">Play</string> + <string name="home_resume">Resume</string> + <string name="home_select_disc">Select disc image</string> + <string name="home_running">The game is still running in the background.</string> + <string name="home_put_on_headset">The game opens in the headset.</string> + <string name="home_data_missing">The game is ready, but its game files (DATA) are missing. Select your own PAL (RMCP01) disc image to extract them, or import a game file from your computer that includes them.\n\nThe game files go to:\n%1$s</string> + <string name="home_import">Import from computer</string> + <string name="home_setup_intro">Nothing from Mario Kart Wii comes with this app: the game is built from your own PAL (RMCP01) disc.\n\nWith a computer: in the WiiCompiled VR launcher, use Build for Quest with your disc image, copy the .wcgame file it makes to this headset, then press Import from computer.\n\nOn this headset: copy your disc image (ISO, WBFS, RVZ…) here, for example into Download, and press Select disc image.</string> + <string name="home_game_missing">The game files are ready. The game itself is built from your disc: in the WiiCompiled VR launcher on your computer, use Build for Quest, copy the .wcgame file to this headset, then press Import from computer.</string> + <string name="home_game_stale">This app was updated, so the game must be built again for it: use Build for Quest in the WiiCompiled VR launcher again, then Import from computer.</string> + <string name="home_checking_package">Checking game file…</string> + <string name="home_checking_package_status">Checking the game file was built from your disc for this version of the app.</string> + <string name="home_importing">Importing %1$d%%</string> + <string name="home_replace_game_title">Replace the game?</string> + <string name="home_replace_game_message">The game installed now stays until the new game file has been checked and completely imported.</string> + <string name="home_data_incomplete">DATA is there but incomplete or unreadable. Select your disc image to extract it again, or copy the whole extracted partition (sys/ and files/, not just files/) to:\n%1$s</string> + <string name="home_checking">Checking disc…</string> + <string name="home_extracting">Extracting %1$d%%</string> + <string name="home_finishing">Finishing…</string> + <string name="home_extract_progress">%1$s of %2$s</string> + <string name="home_checking_status">Reading the disc image and checking it is the clean PAL game.</string> + <string name="home_cancel">Cancel</string> + <string name="home_setup_done">The game files are ready.</string> + <string name="home_setup_cancelled">Extraction cancelled.</string> + <string name="home_setup_failed">The game files could not be set up.\n%1$s</string> + <string name="home_no_picker">This headset has no file picker to choose a disc image with.</string> + <string name="home_replace_title">Replace the game files?</string> + <string name="home_replace_message">Your current DATA folder stays until the new disc image has been checked and completely extracted.</string> + <string name="home_launch_failed">The game could not be started.</string> + + <!-- Settings shell --> + <string name="settings_tab_vr">VR</string> + <string name="settings_tab_graphics">Graphics</string> + <string name="settings_tab_controls">Controls</string> + <string name="settings_tab_audio">Audio</string> + <string name="settings_tab_about">About</string> + <string name="settings_apply_note">Changes apply the next time the game starts. While playing, click both thumbsticks together to open the in-headset settings.</string> + <string name="settings_running_note">The game is running, so these changes wait until it is closed and started again.</string> + <string name="settings_write_failed">Could not save Config.toml.</string> + <string name="settings_custom_value">Custom (%1$s)</string> + + <!-- VR tab --> + <string name="section_vr_camera">Camera</string> + <string name="vr_camera">Camera</string> + <string name="vr_camera_helper">Ride behind the kart like the game, or sit in the driver\'s seat.</string> + <string name="vr_camera_chase">Chase camera</string> + <string name="vr_camera_first_person">First person</string> + <string name="vr_rotation">Follow kart rotation</string> + <string name="vr_rotation_helper">In first person, follow turning only (the comfortable choice), turning and climbing, or all kart rotation including banking.</string> + <string name="vr_rotation_yaw">Turning only</string> + <string name="vr_rotation_yaw_pitch">Turning + pitch</string> + <string name="vr_rotation_full">Full rotation</string> + <string name="vr_hide">Hide in first person</string> + <string name="vr_hide_helper">Your driver sits where your eyes are. Other racers are never hidden.</string> + <string name="vr_hide_nothing">Nothing</string> + <string name="vr_hide_driver">Driver</string> + <string name="vr_hide_driver_and_kart">Driver and kart</string> + <string name="vr_lean_back">Lean back angle</string> + <string name="vr_lean_back_helper">Tilts the race view back for playing reclined. 0 applies no tilt.</string> + + <string name="section_vr_headset">Headset</string> + <string name="vr_render_scale">Headset render scale</string> + <string name="vr_render_scale_helper">Scales the eye resolution the headset recommends. Higher is sharper and costs GPU time; lower it if races stutter.</string> + <string name="vr_interpolation">VR frame interpolation</string> + <string name="vr_interpolation_helper">Experimental. Renders extra frames between game frames at the headset\'s rate. Needs GPU headroom and adds one game frame of latency.</string> + <string name="vr_interpolation_off">Off</string> + <string name="vr_interpolation_auto">Auto</string> + + <string name="section_vr_screen">Virtual screen</string> + <string name="vr_hud_screen">Race HUD on the virtual screen</string> + <string name="vr_hud_screen_helper">Puts the minimap, position and item roulette on a screen in front of you instead of stretching them across the view.</string> + <string name="vr_hud_distance">Screen distance</string> + <string name="vr_hud_distance_helper">How far the menu screen and race HUD sit in front of you.</string> + <string name="vr_hud_width">Screen width</string> + <string name="vr_hud_width_helper">How wide the menu screen and race HUD are.</string> + + <!-- Graphics tab --> + <string name="section_graphics">Rendering</string> + <string name="graphics_resolution">Internal resolution</string> + <string name="graphics_resolution_helper">The game\'s own render size. Native keeps the headset at its best frame rate.</string> + <string name="graphics_resolution_native">Native (1x)</string> + <string name="graphics_widescreen">Widescreen</string> + <string name="graphics_widescreen_helper">16:9 menus and races instead of 4:3.</string> + <string name="graphics_bloom">Disable bloom</string> + <string name="graphics_bloom_helper">Bloom\'s bright glow reads poorly in a headset, so it starts off.</string> + <string name="graphics_skip_unready">Prevent shader stutters</string> + <string name="graphics_skip_unready_helper">Skips a draw for a moment while its shader compiles instead of pausing the game.</string> + + <!-- Controls tab --> + <string name="section_controls">Controllers</string> + <string name="controls_mode">VR controllers</string> + <string name="controls_mode_helper">What the Touch controllers are to the game.</string> + <string name="controls_mode_wii_remote">Wii Remote + Nunchuk</string> + <string name="controls_mode_gamepad">Gamepad</string> + <string name="controls_rumble">Vibration</string> + <string name="controls_rumble_helper">The game\'s rumble vibrates the controllers.</string> + <string name="section_controls_mapping">Wii Remote + Nunchuk buttons</string> + <string name="controls_map_a">A</string> + <string name="controls_map_a_value">Right A</string> + <string name="controls_map_b">B</string> + <string name="controls_map_b_value">Right trigger</string> + <string name="controls_map_12">1 / 2</string> + <string name="controls_map_12_value">Right stick up / down</string> + <string name="controls_map_minus_plus">− / +</string> + <string name="controls_map_minus_plus_value">Right stick left / right</string> + <string name="controls_map_stick">Nunchuk stick</string> + <string name="controls_map_stick_value">Left stick</string> + <string name="controls_map_z">Z</string> + <string name="controls_map_z_value">Left trigger</string> + <string name="controls_map_c">C</string> + <string name="controls_map_c_value">Left grip</string> + <string name="controls_map_home">HOME</string> + <string name="controls_map_home_value">Left menu</string> + <string name="controls_map_pointer">Pointer and motion</string> + <string name="controls_map_pointer_value">Aim and move the right controller</string> + <string name="controls_map_panel">Settings panel</string> + <string name="controls_map_panel_value">Click both thumbsticks</string> + + <!-- Audio tab --> + <string name="section_audio">Volume</string> + <string name="audio_master">Master</string> + <string name="audio_music">Music</string> + <string name="audio_sound_effects">Sound effects</string> + <string name="audio_voices">Voices</string> + <string name="audio_ui">Menu sounds</string> + <string name="audio_muted">Mute</string> + <string name="audio_muted_helper">Silences the game without changing the volumes above.</string> + + <!-- About tab --> + <string name="section_about_app">App</string> + <string name="about_version">Version</string> + <string name="about_version_value">%1$s (%2$s)</string> + <string name="section_about_storage">Files</string> + <string name="about_game_data">Game data</string> + <string name="about_game_data_ready">Found: %1$s</string> + <string name="about_game_data_incomplete">Incomplete or unreadable: %1$s</string> + <string name="about_game_data_missing">Missing: %1$s</string> + <string name="about_config">Configuration</string> + <string name="about_logs">Logs</string> + <string name="about_extract">Extract from disc image</string> + <string name="about_extract_helper">Replaces DATA with the files of your own PAL disc image.</string> + <string name="about_game">Game</string> + <string name="about_game_ready">Built by %1$s on %2$s</string> + <string name="about_game_missing">Not installed yet</string> + <string name="about_game_stale">Built for another version of this app; build it again</string> + <string name="about_import_helper">Installs a .wcgame made by Build for Quest in the WiiCompiled VR launcher on a computer.</string> + <string name="section_about_diagnostics">Diagnostics</string> + <string name="about_openxr_logging">OpenXR diagnostic logging</string> + <string name="about_openxr_logging_helper">Writes VR frame timing to the run log once per second: late, skipped and black frames, tracking loss. Turn it on to report stutter, and off again afterwards.</string> + + <!-- Disc setup notification --> + <string name="disc_setup_channel">Game file setup</string> + <string name="disc_setup_notification_title">Setting up the game files</string> + <string name="disc_setup_checking">Checking the disc image…</string> + <string name="disc_setup_extracting">Extracting: %1$d%%</string> + <string name="disc_setup_importing">Importing: %1$d%%</string> + <string name="game_not_installed">The game is not installed. Open WiiCompiled VR and set it up from Home.</string> </resources> diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 3c450f5..e9fee49 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -6,4 +6,81 @@ <item name="android:windowBackground">@android:color/black</item> <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> </style> + + <!-- The 2D launcher panel: dark Material widgets tinted with the launcher palette. --> + <style name="Theme.WiiCompiled.Launcher" parent="android:Theme.Material.NoActionBar"> + <item name="android:windowBackground">@color/neutral_900</item> + <item name="android:statusBarColor">@color/neutral_900</item> + <item name="android:navigationBarColor">@color/neutral_900</item> + <item name="android:colorPrimary">@color/neutral_900</item> + <item name="android:colorAccent">@color/primary_400</item> + <item name="android:colorControlNormal">@color/neutral_400</item> + <item name="android:textColorPrimary">@color/neutral_100</item> + <item name="android:textColorSecondary">@color/neutral_400</item> + <item name="android:colorBackground">@color/neutral_950</item> + <item name="android:alertDialogTheme">@style/Theme.WiiCompiled.Launcher.Dialog</item> + </style> + + <style name="Theme.WiiCompiled.Launcher.Dialog" parent="android:Theme.Material.Dialog.Alert"> + <item name="android:colorAccent">@color/primary_400</item> + <item name="android:colorBackground">@color/neutral_900</item> + </style> + + <!-- Namespace for the launcher's view styles below. --> + <style name="Launcher" /> + + <style name="Launcher.PageTitle"> + <item name="android:textColor">@color/neutral_300</item> + <item name="android:textSize">26sp</item> + <item name="android:fontFamily">sans-serif-medium</item> + </style> + + <style name="Launcher.SectionLabel"> + <item name="android:textColor">@color/neutral_300</item> + <item name="android:textSize">14sp</item> + <item name="android:fontFamily">sans-serif-medium</item> + </style> + + <style name="Launcher.NavItem"> + <item name="android:layout_width">match_parent</item> + <item name="android:layout_height">48dp</item> + <item name="android:orientation">horizontal</item> + <item name="android:gravity">center_vertical</item> + <item name="android:paddingStart">12dp</item> + <item name="android:paddingEnd">12dp</item> + <item name="android:layout_marginBottom">2dp</item> + <item name="android:background">@drawable/bg_nav_item</item> + <item name="android:clickable">true</item> + <item name="android:focusable">true</item> + </style> + + <style name="Launcher.NavIcon"> + <item name="android:layout_width">24dp</item> + <item name="android:layout_height">24dp</item> + <item name="android:tint">@color/nav_content</item> + <item name="android:duplicateParentState">true</item> + </style> + + <style name="Launcher.NavLabel"> + <item name="android:layout_width">wrap_content</item> + <item name="android:layout_height">wrap_content</item> + <item name="android:layout_marginStart">14dp</item> + <item name="android:textColor">@color/nav_content</item> + <item name="android:textSize">19sp</item> + <item name="android:duplicateParentState">true</item> + </style> + + <style name="Launcher.Tab"> + <item name="android:layout_width">wrap_content</item> + <item name="android:layout_height">44dp</item> + <item name="android:gravity">center</item> + <item name="android:paddingStart">14dp</item> + <item name="android:paddingEnd">14dp</item> + <item name="android:textSize">15sp</item> + <item name="android:fontFamily">sans-serif-medium</item> + <item name="android:textColor">@color/tab_text</item> + <item name="android:background">@drawable/bg_tab</item> + <item name="android:clickable">true</item> + <item name="android:focusable">true</item> + </style> </resources> diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/DiscChecksTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/DiscChecksTest.kt new file mode 100644 index 0000000..f1d22c1 --- /dev/null +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/DiscChecksTest.kt @@ -0,0 +1,55 @@ +package org.wiicompiled.quest.launcher + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** The Quest launcher must accept and reject disc images exactly as the PC installer does. */ +class DiscChecksTest { + + private val dol = "clean dol".toByteArray() + private val rel = "clean rel".toByteArray() + private val checks = DiscChecks("RMCP01", DiscChecks.sha256(dol), DiscChecks.sha256(rel)) + + @Test + fun acceptsTheFormatsNodReads() { + for (name in listOf("mkw.iso", "MKW.ISO", "game.rvz", "game.wbfs", "a.b.wia", "x.ciso", "x.gcz", "x.gcm")) { + assertNull(name, checks.extensionError(name)) + } + for (name in listOf("mkw.zip", "DATA", "mkw.iso.part", null)) { + assertNotNull(name, checks.extensionError(name)) + } + } + + @Test + fun parsesTheNativeHeader() { + val header = DiscChecks.parseHeader("RMCP01\nMARIO KART Wii\n2891234567") + assertEquals(DiscChecks.Header("RMCP01", "MARIO KART Wii", 2_891_234_567L), header) + assertEquals(0L, DiscChecks.parseHeader("RMCP01").extractedBytes) + } + + @Test + fun rejectsOtherRegionsWithTheInstallerMessage() { + assertNull(checks.compatibilityError(DiscChecks.Header("rmcp01", "MARIO KART Wii", 1))) + assertEquals( + "This build supports Mario Kart Wii PAL (RMCP01). The selected image is RMCE01 (MARIO KART Wii, NTSC-U).", + checks.compatibilityError(DiscChecks.Header("RMCE01", "MARIO KART Wii", 1)), + ) + } + + @Test + fun requiresTheCleanRevision() { + assertNull(checks.revisionError(dol, rel)) + assertNotNull(checks.revisionError("patched".toByteArray(), rel)) + assertNotNull(checks.revisionError(dol, "patched".toByteArray())) + } + + @Test + fun hashesAsLowercaseHex() { + assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + DiscChecks.sha256("abc".toByteArray()), + ) + } +} diff --git a/android/app/src/test/java/org/wiicompiled/quest/launcher/TomlConfigTest.kt b/android/app/src/test/java/org/wiicompiled/quest/launcher/TomlConfigTest.kt new file mode 100644 index 0000000..c831867 --- /dev/null +++ b/android/app/src/test/java/org/wiicompiled/quest/launcher/TomlConfigTest.kt @@ -0,0 +1,127 @@ +package org.wiicompiled.quest.launcher + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The launcher and the runtime edit the same Config.toml, so the line editor + * must place keys exactly where RuntimeConfigFile::WriteSetting does and read + * only what the runtime's toml11 lookups accept. + */ +class TomlConfigTest { + + private val sample = """ + # WiiCompiled Quest configuration. + [paths] + dvd_root = "/storage/emulated/0/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA" + + [video] + widescreen = true # set by the activity + resolution_multiplier = 1.0 + + [vr] + enabled = true + render_scale = 1.0 + """.trimIndent() + "\n" + + @Test + fun untouchedFileRoundTrips() { + assertEquals(sample, TomlConfig.parse(sample).text()) + val crlf = sample.replace("\n", "\r\n") + assertEquals(sample, TomlConfig.parse(crlf).text()) + } + + @Test + fun replacesAKeyInPlace() { + val config = TomlConfig.parse(sample) + config.setFloat("vr", "render_scale", 0.75) + assertEquals(sample.replace("render_scale = 1.0", "render_scale = 0.75"), config.text()) + } + + @Test + fun insertsAMissingKeyAboveTheBlankLineBeforeTheNextSection() { + val config = TomlConfig.parse(sample) + config.setBool("video", "skip_unready_pipelines", false) + val expected = sample.replace( + "resolution_multiplier = 1.0\n\n[vr]", + "resolution_multiplier = 1.0\nskip_unready_pipelines = false\n\n[vr]", + ) + assertEquals(expected, config.text()) + } + + @Test + fun appendsAMissingSection() { + val config = TomlConfig.parse(sample) + config.setFloat("audio", "volume", 0.5) + assertEquals("$sample\n[audio]\nvolume = 0.5\n", config.text()) + } + + @Test + fun createsAFileFromNothing() { + val config = TomlConfig.parse("") + config.setString("vr", "controller_mode", "gamepad") + assertEquals("[vr]\ncontroller_mode = \"gamepad\"\n", config.text()) + } + + @Test + fun findsKeysOnlyInTheirOwnSection() { + val config = TomlConfig.parse("[video]\nenabled = false\n\n[vr]\nenabled = true\n") + assertEquals(false, config.bool("video", "enabled")) + assertEquals(true, config.bool("vr", "enabled")) + assertNull(config.bool("audio", "enabled")) + } + + @Test + fun ignoresCommentsButNotHashesInsideStrings() { + val config = TomlConfig.parse( + "[paths] # headers may carry comments\n" + + "dvd_root = \"/data/#1/DATA\" # a comment\n" + + "# enabled = true\n", + ) + assertEquals("/data/#1/DATA", config.string("paths", "dvd_root")) + assertNull(config.literal("paths", "enabled")) + } + + @Test + fun readsNumbersTheWayTheRuntimeDoes() { + val config = TomlConfig.parse( + "[vr]\n" + + "render_scale = 1\n" + + "hud_width_meters = 2.4\n" + + "lean_back_degrees = -1_0.5\n" + + "frame_interpolation_fps = 72\n" + + "first_person_hidden_model = 72.0\n" + + "world_units_per_meter = 1.0f\n" + + "hud_distance_meters = \"2.0\"\n", + ) + // FindConfigFloat accepts integers as well as floats. + assertEquals(1.0, config.number("vr", "render_scale")!!, 0.0) + assertEquals(2.4, config.number("vr", "hud_width_meters")!!, 0.0) + assertEquals(-10.5, config.number("vr", "lean_back_degrees")!!, 0.0) + // Integer keys reject floats; anything that is not a TOML number is absent. + assertEquals(72L, config.integer("vr", "frame_interpolation_fps")) + assertNull(config.integer("vr", "first_person_hidden_model")) + assertNull(config.number("vr", "world_units_per_meter")) + assertNull(config.number("vr", "hud_distance_meters")) + } + + @Test + fun formatsFloatsSoTomlReadsAFloat() { + assertEquals("1.0", TomlConfig.formatFloat(1.0)) + assertEquals("0.05", TomlConfig.formatFloat(0.05)) + assertEquals("1.0", TomlConfig.formatFloat(0.25 + 15 * 0.05)) + assertEquals("-12.0", TomlConfig.formatFloat(-12.0)) + assertEquals("0.0", TomlConfig.formatFloat(-0.0)) + } + + @Test + fun quotesAndUnquotesStrings() { + val value = "C:\\Games\\\"Kart\"\ttab" + assertEquals(value, TomlConfig.unquote(TomlConfig.quote(value))) + assertEquals("yaw", TomlConfig.unquote("'yaw'")) + assertEquals("é", TomlConfig.unquote("\"\\u00e9\"")) + assertNull(TomlConfig.unquote("yaw")) + assertNull(TomlConfig.unquote("\"bad\\q\"")) + } +} diff --git a/android/nod-jni/Cargo.lock b/android/nod-jni/Cargo.lock new file mode 100644 index 0000000..a0a8ab4 --- /dev/null +++ b/android/nod-jni/Cargo.lock @@ -0,0 +1,817 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "higher-kinded-types" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e690f8474c6c5d8ff99656fcbc195a215acc3949481a8b0b3351c838972dc776" +dependencies = [ + "macro_rules_attribute", + "never-say-never", + "paste", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "liblzma-sys" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7e3581f367a78d7b7e7ae948d023310556f0cfc13156c2e4e00e25616492b9" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "never-say-never" +version = "6.6.666" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5a574dadd7941adeaa71823ecba5e28331b8313fb2e1c6a5c7e5981ea53ad6" + +[[package]] +name = "nod" +version = "2.0.0-alpha.10" +source = "git+https://github.com/encounter/nod?tag=v2.0.0-alpha.10#dc18d2ff129f05228b8510ea092d8b24c290a49a" +dependencies = [ + "adler2", + "aes", + "base16ct", + "block-padding", + "bytes", + "bzip2-sys", + "cbc", + "crc32fast", + "crossbeam-channel", + "crossbeam-utils", + "crypto-common", + "digest", + "dyn-clone", + "encoding_rs", + "itertools", + "liblzma-sys", + "libz-sys", + "lru", + "md-5", + "polonius-the-crab", + "sha1", + "simple_moving_average", + "thiserror 2.0.20", + "tracing", + "xxhash-rust", + "zerocopy", + "zstd-sys", +] + +[[package]] +name = "nod-jni" +version = "0.1.0" +dependencies = [ + "jni", + "nod", + "zerocopy", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polonius-the-crab" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec242d7eccbb2fd8b3b5b6e3cf89f94a91a800f469005b44d154359609f8af72" +dependencies = [ + "higher-kinded-types", + "never-say-never", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simple_moving_average" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4b144ad185430cd033299e2c93e465d5a7e65fbb858593dc57181fa13cd310" +dependencies = [ + "num-traits", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/android/nod-jni/Cargo.toml b/android/nod-jni/Cargo.toml new file mode 100644 index 0000000..47b019e --- /dev/null +++ b/android/nod-jni/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "nod-jni" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "GPL-3.0-only" +publish = false +description = "JNI bridge from the Quest launcher to nod, the disc image library behind the PC installer's nodtool." + +[lib] +name = "nod_jni" +crate-type = ["cdylib"] + +# Built on its own, never as part of an outer workspace. +[workspace] + +[dependencies] +jni = "0.21" +# The same nod release as Launcher/WiiCompiled.Setup.Common/NodToolProvider.cs, so the +# Quest and PC installers extract a disc identically. alpha.10 is only published on GitHub. +nod = { git = "https://github.com/encounter/nod", tag = "v2.0.0-alpha.10", default-features = false, features = [ + "compress-bzip2-static", + "compress-lzma-static", + "compress-zlib-static", + "compress-zstd-static", + "threading", +] } +zerocopy = "0.8" + +[profile.release] +codegen-units = 1 +lto = "thin" +strip = true diff --git a/android/nod-jni/src/lib.rs b/android/nod-jni/src/lib.rs new file mode 100644 index 0000000..64933a4 --- /dev/null +++ b/android/nod-jni/src/lib.rs @@ -0,0 +1,300 @@ +//! JNI bridge from the Quest launcher (`org.wiicompiled.quest.launcher.NodDisc`) to nod. +//! +//! The PC installer validates and extracts the player's disc with nodtool. This library gives +//! the Quest launcher the same three operations on a file descriptor from Android's document +//! picker: read the disc header, read one file of the data partition (so the launcher can hash +//! main.dol and StaticR.rel before a long extraction), and extract the data partition. +//! +//! The extraction is nodtool's `extract` command (nodtool/src/cmd/extract.rs at +//! v2.0.0-alpha.10, MIT OR Apache-2.0) with progress reporting and cancellation added, so both +//! installers write the same `sys/`, `files/`, `disc/` and `*.bin` layout that the runtime's DVD +//! layer reads. + +use std::{ + fs::{self, File}, + io::{self, BufRead, Read, Write}, + os::{ + fd::{BorrowedFd, RawFd}, + unix::fs::FileExt, + }, + panic::{AssertUnwindSafe, catch_unwind}, + path::Path, + sync::Arc, + time::{Duration, Instant}, +}; + +use jni::{ + JNIEnv, + objects::{JObject, JString, JValue}, + sys::{jbyteArray, jint, jstring}, +}; +use nod::{ + common::PartitionKind, + disc::fst::Fst, + read::{DiscOptions, DiscReader, DiscStream, PartitionMeta, PartitionOptions, PartitionReader}, +}; +use zerocopy::IntoBytes; + +/// Thrown instead of IOException when the listener asked to stop. +const CANCELLED_EXCEPTION: &str = "java/io/InterruptedIOException"; +const PROGRESS_INTERVAL: Duration = Duration::from_millis(200); + +enum Failure { + Error(String), + Cancelled, +} + +impl<E: std::fmt::Display> From<E> for Failure { + fn from(error: E) -> Self { Failure::Error(error.to_string()) } +} + +type Outcome<T> = Result<T, Failure>; + +/// A disc image read with pread through its own duplicate of the picker's descriptor. pread +/// keeps no file position, so nod's preloader threads can each hold a clone. +#[derive(Clone)] +struct FdStream { + file: Arc<File>, + len: u64, +} + +impl DiscStream for FdStream { + fn read_exact_at(&mut self, buf: &mut [u8], offset: u64) -> io::Result<()> { + self.file.read_exact_at(buf, offset) + } + + fn stream_len(&mut self) -> io::Result<u64> { Ok(self.len) } +} + +fn open_disc(fd: RawFd) -> Outcome<DiscReader> { + // SAFETY: the caller keeps the ParcelFileDescriptor open for the duration of the JNI call, + // and only a duplicate outlives this borrow. + let owned = unsafe { BorrowedFd::borrow_raw(fd) }.try_clone_to_owned()?; + let file = File::from(owned); + let len = file.metadata()?.len(); + let options = DiscOptions { preloader_threads: 4, ..Default::default() }; + Ok(DiscReader::new_stream(Box::new(FdStream { file: Arc::new(file), len }), &options)?) +} + +fn open_data_partition(disc: &DiscReader) -> Outcome<Box<dyn PartitionReader>> { + Ok(disc.open_partition_kind(PartitionKind::Data, &PartitionOptions::default())?) +} + +/// Bytes an extraction writes: the system files plus every file in the FST. +fn extracted_size(disc: &DiscReader, meta: &PartitionMeta) -> Outcome<u64> { + let mut total = (meta.raw_boot.len() + + meta.raw_bi2.len() + + meta.raw_apploader.len() + + meta.raw_fst.len() + + meta.raw_dol.len()) as u64; + if disc.header().is_wii() { + total += 0x100 + disc.region().map_or(0, |region| region.len()) as u64; + for part in [&meta.raw_ticket, &meta.raw_tmd, &meta.raw_cert_chain] { + total += part.as_ref().map_or(0, |bytes| bytes.len()) as u64; + } + total += meta.raw_h3_table.as_ref().map_or(0, |bytes| bytes.len()) as u64; + } + let fst = Fst::new(&meta.raw_fst)?; + for (_, node, _) in fst.iter() { + if node.is_file() { + total += u64::from(node.length()); + } + } + Ok(total) +} + +/// "<game id>\n<title>\n<bytes an extraction writes>" +fn header(fd: RawFd) -> Outcome<String> { + let disc = open_disc(fd)?; + let mut partition = open_data_partition(&disc)?; + let meta = partition.meta()?; + let total = extracted_size(&disc, &meta)?; + let header = disc.header(); + Ok(format!("{}\n{}\n{}", header.game_id_str(), header.game_title_str(), total)) +} + +/// `sys/main.dol`, or a path below `files/`, of the data partition. +fn read_file(fd: RawFd, path: &str) -> Outcome<Vec<u8>> { + let disc = open_disc(fd)?; + let mut partition = open_data_partition(&disc)?; + let meta = partition.meta()?; + if path == "sys/main.dol" { + return Ok(meta.raw_dol.to_vec()); + } + let Some(relative) = path.strip_prefix("files/") else { + return Err(Failure::Error(format!("Unsupported disc path {path}"))); + }; + let fst = Fst::new(&meta.raw_fst)?; + let Some((_, node)) = fst.find(relative) else { + return Err(Failure::Error(format!("{path} is not on this disc"))); + }; + let mut bytes = Vec::with_capacity(node.length() as usize); + partition.open_file(node)?.read_to_end(&mut bytes)?; + Ok(bytes) +} + +struct Progress<'a> { + done: u64, + total: u64, + last: Instant, + report: &'a mut dyn FnMut(u64, u64) -> Outcome<bool>, +} + +impl Progress<'_> { + fn advance(&mut self, bytes: usize) -> Outcome<()> { + self.done += bytes as u64; + if self.last.elapsed() >= PROGRESS_INTERVAL { + self.last = Instant::now(); + self.check()?; + } + Ok(()) + } + + fn check(&mut self) -> Outcome<()> { + if (self.report)(self.done, self.total)? { Ok(()) } else { Err(Failure::Cancelled) } + } +} + +fn extract(fd: RawFd, out_dir: &Path, report: &mut dyn FnMut(u64, u64) -> Outcome<bool>) -> Outcome<()> { + let disc = open_disc(fd)?; + let mut partition = open_data_partition(&disc)?; + let meta = partition.meta()?; + let total = extracted_size(&disc, &meta)?; + let mut progress = Progress { done: 0, total, last: Instant::now(), report }; + progress.check()?; + + extract_sys_files(&disc, &meta, out_dir, &mut progress)?; + + let files_dir = out_dir.join("files"); + fs::create_dir_all(&files_dir)?; + let is_wii = disc.header().is_wii(); + let fst = Fst::new(&meta.raw_fst)?; + for (_, node, path) in fst.iter() { + let target = files_dir.join(&path); + if node.is_dir() { + fs::create_dir_all(&target).map_err(|e| format!("Creating directory {path}: {e}"))?; + continue; + } + let mut file = File::create(&target).map_err(|e| format!("Creating file {path}: {e}"))?; + let mut reader = partition.open_file(node).map_err(|e| { + format!("Opening {path} on the disc (offset {}, size {}): {e}", node.offset(is_wii), node.length()) + })?; + loop { + let buf = reader.fill_buf().map_err(|e| format!("Reading {path}: {e}"))?; + let len = buf.len(); + if len == 0 { + break; + } + file.write_all(buf).map_err(|e| format!("Writing {path}: {e}"))?; + reader.consume(len); + progress.advance(len)?; + } + file.flush().map_err(|e| format!("Writing {path}: {e}"))?; + } + progress.check() +} + +fn extract_sys_files(disc: &DiscReader, meta: &PartitionMeta, out_dir: &Path, progress: &mut Progress) -> Outcome<()> { + let sys_dir = out_dir.join("sys"); + fs::create_dir_all(&sys_dir)?; + write_file(meta.raw_boot.as_ref(), &sys_dir.join("boot.bin"), progress)?; + write_file(meta.raw_bi2.as_ref(), &sys_dir.join("bi2.bin"), progress)?; + write_file(meta.raw_apploader.as_ref(), &sys_dir.join("apploader.img"), progress)?; + write_file(meta.raw_fst.as_ref(), &sys_dir.join("fst.bin"), progress)?; + write_file(meta.raw_dol.as_ref(), &sys_dir.join("main.dol"), progress)?; + + let header = disc.header(); + if header.is_wii() { + let disc_dir = out_dir.join("disc"); + fs::create_dir_all(&disc_dir)?; + write_file(&header.as_bytes()[..0x100], &disc_dir.join("header.bin"), progress)?; + if let Some(region) = disc.region() { + write_file(region, &disc_dir.join("region.bin"), progress)?; + } + if let Some(ticket) = meta.raw_ticket.as_deref() { + write_file(ticket, &out_dir.join("ticket.bin"), progress)?; + } + if let Some(tmd) = meta.raw_tmd.as_deref() { + write_file(tmd, &out_dir.join("tmd.bin"), progress)?; + } + if let Some(cert_chain) = meta.raw_cert_chain.as_deref() { + write_file(cert_chain, &out_dir.join("cert.bin"), progress)?; + } + if let Some(h3_table) = meta.raw_h3_table.as_deref() { + write_file(h3_table, &out_dir.join("h3.bin"), progress)?; + } + } + Ok(()) +} + +fn write_file(bytes: &[u8], path: &Path, progress: &mut Progress) -> Outcome<()> { + fs::write(path, bytes).map_err(|e| format!("Writing {}: {e}", path.display()))?; + progress.advance(bytes.len()) +} + +/// Runs `body`, turning a failure or a panic into a pending Java exception. +fn guarded<T>(env: &mut JNIEnv, fallback: T, body: impl FnOnce(&mut JNIEnv) -> Outcome<T>) -> T { + match catch_unwind(AssertUnwindSafe(|| body(env))) { + Ok(Ok(value)) => value, + Ok(Err(failure)) => { + // A Java exception raised inside a callback is already pending; keep it. + if !env.exception_check().unwrap_or(false) { + let _ = match failure { + Failure::Error(message) => env.throw_new("java/io/IOException", message), + Failure::Cancelled => env.throw_new(CANCELLED_EXCEPTION, "Extraction cancelled"), + }; + } + fallback + } + Err(_) => { + let _ = env.throw_new("java/io/IOException", "nod-jni panicked; see logcat"); + fallback + } + } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_wiicompiled_quest_launcher_NodDisc_header<'local>( + mut env: JNIEnv<'local>, + _this: JObject<'local>, + fd: jint, +) -> jstring { + guarded(&mut env, std::ptr::null_mut(), |env| Ok(env.new_string(header(fd)?)?.into_raw())) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_wiicompiled_quest_launcher_NodDisc_readFile<'local>( + mut env: JNIEnv<'local>, + _this: JObject<'local>, + fd: jint, + path: JString<'local>, +) -> jbyteArray { + guarded(&mut env, std::ptr::null_mut(), |env| { + let path: String = env.get_string(&path)?.into(); + let bytes = read_file(fd, &path)?; + Ok(env.byte_array_from_slice(&bytes)?.into_raw()) + }) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_wiicompiled_quest_launcher_NodDisc_extract<'local>( + mut env: JNIEnv<'local>, + _this: JObject<'local>, + fd: jint, + out_dir: JString<'local>, + listener: JObject<'local>, +) { + guarded(&mut env, (), |env| { + let out_dir: String = env.get_string(&out_dir)?.into(); + let mut report = |done: u64, total: u64| -> Outcome<bool> { + let result = env.call_method( + &listener, + "update", + "(JJ)Z", + &[JValue::Long(done as i64), JValue::Long(total as i64)], + ); + Ok(result?.z()?) + }; + extract(fd, Path::new(&out_dir), &mut report) + }) +} diff --git a/docs/quest-port.md b/docs/quest-port.md index 87f162f..de9a12d 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -138,6 +138,157 @@ suggested for `oculus/touch_controller` and `khr/simple_controller`. - Time conversion for frame interpolation uses `XR_KHR_convert_timespec_time` (CLOCK_MONOTONIC, the clock behind `steady_clock` on Bionic). +### Launcher and game process + +The app opens on `LauncherActivity` (`android/app/src/main/java/org/wiicompiled/quest/launcher/`), +a 2D Horizon OS panel modelled on the PC launcher, WheelWizard VR, and using its +palette. **Home** has the Play button and reports a missing or incomplete `DATA` +(the check is the runtime's own `IsDvdDataRoot`: `files/` and `sys/fst.bin`). +**Settings** edits `Config.toml` in tabs: VR (camera, rotation, driver hiding, +lean back, render scale, VR interpolation, virtual screen size and distance), +Graphics (resolution, widescreen, bloom, shader stutter), Controls (controller +mode, vibration, the Wii Remote mapping), Audio, and About (paths, OpenXR +logging). The launch-time geometry (`render_scale`, `hud_distance_meters`, +`hud_width_meters`) is only reachable here, not from the in-headset panel. + +The launcher follows the runtime's rules exactly. `TomlConfig` edits one line +the way `RuntimeConfigFile::WriteSetting` does, and every edit re-reads the file, +so values the in-headset panel wrote are kept. Each row reads its key with the +runtime's default and accepted range. `TomlConfigTest` (`gradlew +:app:testBaseDebugUnitTest`) covers the editor. + +`QuestActivity`, the immersive game, is started from Play, like DolphinXR's +`EmulationActivity`: `com.oculus.intent.category.VR` without `LAUNCHER`. It runs +in its own `:game` process, and its `onDestroy` ends that process. This is +required, not tidiness: + +- SDLActivity calls `System.exit(0)` when an activity is created again after + `SDL_main` has returned. In one shared process, the second Play would kill + the launcher along with the game. +- Guest memory, fibers, static configuration and the OpenXR Vulkan device all + belong to the process. DolphinXR crashed when it opened a second Vulkan VR + session in the same process. + +Every session therefore starts in a fresh process, and the launcher loads no +game code. While the `:game` process is alive, Home shows *Resume* and Settings +warns that changes wait for a restart. `adb shell am start -n +org.wiicompiled.quest/.QuestActivity` still starts the game directly, which is +what `Run-Quest.ps1` does. + +### Extracting the player's disc image + +Without `DATA`, Home's main button is **Select disc image** (About has the same +action for replacing `DATA`). The player picks their own image with Android's +document picker, and the launcher extracts it the way the PC installer does +(`Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs`). Both use nod +v2.0.0-alpha.10, so both write the same layout: + +1. The file name must be a format nod reads: ISO, GCM, GCZ, CISO, WBFS, WIA or RVZ. +2. The header must be the pinned game ID, and `main.dol` and `StaticR.rel`, + read straight from the image, must match the pinned SHA-256s. Gradle + reads all three pins from `projects/mkwii/recomp.yml` into `BuildConfig`, so + a wrong disc fails in seconds, before anything is written. +3. The data partition is extracted into `DATA.extracting` next to `DATA`, after a + free-space check. +4. The extracted files are checked against the same pins, as + `ValidateExtractedGame` does. Only then is an existing `DATA` renamed away, + the new one moved in, and the old one deleted. A failed, cancelled or killed + run never costs a working `DATA`. + +nod is the one piece of native code the launcher loads: `android/nod-jni` is +a Rust `cdylib` with three JNI calls (header, read one file, extract). The +extraction is nodtool's `extract` command, plus progress reporting and +cancellation. It reads the picker's file descriptor with `pread`, so nod's +preloader threads each hold their own clone. Wii partition decryption needs +the common key, and that key lives in the nod crate fetched at build time, not +in this repository. The PC installer is the same way: it downloads nodtool. + +`GameSetupService` runs the job as a `dataSync` foreground service with a +partial wake lock. A multi-minute extraction then survives the panel being +closed and the headset being taken off. `DiscChecksTest` covers the acceptance +rules and their messages. + +### No game code in the APK: the game kit + +Like the PC installer, which compiles the translated game on the player's machine, the base +APK contains no translated Mario Kart code. It carries a **game kit** (`assets/game_kit`, about +105 MB before compression). The kit is everything `libmain.so` links except the game: the +runtime, aurora, Dawn and the other dependencies, prebuilt and stripped, plus `kit.json`, the +recipe that compiles and links a player's own translation against them. The player's +`libmain.so` is built from their disc and loaded from internal private storage (`GameLibrary`), +the only place Android lets an app load native code it did not install. + +The kit is exported from CMake's own build graph, so its flags cannot drift from a normal build: + +- `runtime/cmake/PublicProducts.cmake` defines `mkw_quest_kit_probe` on Android: WiiCompiled + `WITHOUT_GAME` (no base shards, and the runtime objects `$<FILTER>`ed of the two + disc-generated sources, which skip the unity build on Android for that reason), linked with the + game's symbols unresolved. The base flavour builds this probe instead of the product. +- `android/QuestGameKit.psm1` (`Export-QuestGameKit`, run by the `exportBase*QuestGameKit` + Gradle tasks) turns the probe's ninja link edge into `kit.json`'s ordered link inputs. It adds + `{game:runtime}`, `{game:product}` and `{game:translated}` markers where WiiCompiled had those + objects, and translated shards link inside `--start-lib/--end-lib` with archive semantics as + `libmkw_base_shared.a` did. It takes the compile flags CMake recorded for one source of each + generated kind. The fingerprint hashes the recipe and every file. +- `Invoke-QuestGameBuild` replays the recipe with ninja. On the development PC, a library built + this way had the same 61,975 defined and 785 undefined dynamic symbols, 29,995 translated + functions, `NEEDED` list and soname as the CMake-built one, in 2.4 minutes. + +`Build-Quest.ps1` refuses a base APK that contains any `libmain*.so` or lacks the kit, and the +base variant excludes `**/libmain*.so` from packaging, since AGP packages every library left in +the CMake output directory. The `func_8…` symbols the kit's runtime objects define are +hand-written HLE overrides (`PPC_NATIVE_OVERRIDE_*` in `hle_stubs.h`), not translated code. +The Retro Rewind flavour has not moved to the kit yet and still bundles its library. + +### Game packages (.wcgame) and Import from computer + +A `.wcgame` is a zip holding `game.json`, `libmain.so` and optionally `DATA/…` (the extracted +disc, written without compression). `game.json` records the profile, game ID, `main.dol` and +`StaticR.rel` pins, the kit fingerprint, the library's SHA-256 and who built it. +`android/Build-QuestGame.ps1` builds one on a PC from the translator's output and the kit the +last APK build exported, and `-Data` includes the game files. `-Install` pushes it into the app's +`Import` folder. The launcher creates that folder itself so it owns it, and imports the newest +package the next time it opens, once per package. + +Players get the same build from WheelWizard VR: Settings → WiiCompiled → Meta Quest → **Build**. +WheelWizard asks for the Quest app's APK, whether to include the game files and where to save the +package, then runs the installed setup: + +``` +WiiCompiled-Setup.exe --build-quest --install-dir <install> --quest-apk <app.apk> --output <file.wcgame> [--include-game-files] --progress-json +``` + +Setup extracts `assets/game_kit` from the APK into `<install>\QuestBuild\kit`, so the game always +matches the app it goes to. It then runs the installation's staged copy of `Build-QuestGame.ps1` +over its own `BuildWorkspace\generated`, `recomp.yml`, the toolkit's ninja and, with +`--include-game-files`, `GameAssets\DATA`. The Android compiler is not part of the toolkit: the +first build downloads Google's `android-ndk-r29-windows.zip` (834 MB, SHA-1 pinned in +`QuestBuildService.Ndk`, under the Android SDK License, so it is never redistributed). It keeps +only the ~230 MB that a build needs (clang, lld, clang's headers, the aarch64 runtime libraries and +sysroot) in `<install>\QuestBuild\android-ndk-29.0.14206865`, and deletes the archive. +`WIICOMPILED_QUEST_NDK_TOOLCHAIN` points it at an existing NDK LLVM directory instead. Besides +`progress` and the terminal `result`, the stream carries one +`{"type":"quest-package","path","kitFingerprint","includesGameFiles","sizeBytes"}` line. A setup +that supports all this says `"questBuild": true` in `--info-json`, and WheelWizard asks older ones +to update. The package is written as `<file>.partial` and renamed at the end; a failed or +cancelled build removes it. The kit's `runtimeIncludeFingerprint` must match the installation's +`runtime/include`, so an installation only builds for the Quest app from the same release. + +`GamePackageImport` (Home's **Import from computer**, or the Import folder) stages the library +and any `DATA` next to their destinations. It accepts them only if `game.json` names this +profile, the pinned disc, this APK's kit fingerprint, and a library hash matching the bytes, and +if the game files pass the same checks as an extraction. A package built for another app version +is refused, and an installed game whose kit fingerprint no longer matches the APK shows as stale +on Home. + +Home's main button is always the next step: **Import from computer** until a game is installed +(with **Select disc image** beside it while there are no game files), **Select disc image** when +only the game files are missing, and **Play** once both are present. Building the game on the +headset itself is the next step of this work. The translator (a self-contained +`linux-bionic-arm64` .NET build, which needs heap pointer tagging disabled) and Termux's clang +21.1.8 both ran on a Quest 3. A base translation took 6.7 minutes with a 3.1 GB memory peak, and +the heaviest shard compiled in 65 seconds with a 480 MB peak. + ### Build system - `runtime/CMakeLists.txt` recognises `CMAKE_SYSTEM_NAME=Android` on arm64 as @@ -158,24 +309,37 @@ suggested for `oculus/touch_controller` and `khr/simple_controller`. - `android/`: the Gradle project. `app/src/main/cpp/CMakeLists.txt` adds the repository's `runtime/` as a subdirectory with those Android choices; flavours `base` and `retroRewind` pick the product target and library name. +- `android/nod-jni`: Gradle's `buildNodJni` task runs `cargo build --release + --locked --target aarch64-linux-android` with the NDK's clang as linker and C + compiler. `stageNodJni` puts `libnod_jni.so` into the APK's `arm64-v8a` + libraries. `-Pcargo=<path>` overrides the cargo binary. ## Building Prerequisites on the Windows host (all already present on the machine this was developed on): JDK 17, Android SDK with platform 34+, NDK `29.0.14206865`, -SDK CMake `3.22.1`, `adb`; a translated graph for your own disc (the installer's -`BuildWorkspace/generated`, produced by the normal Windows pipeline). +SDK CMake `3.22.1`, `adb`, Rust 1.85+ with `rustup target add aarch64-linux-android`; +a translated graph for your own disc (the installer's `BuildWorkspace/generated`, +produced by the normal Windows pipeline). ```powershell powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # SDL3 3.4.4 AAR into android/app/libs -powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # base game, debug-signed +powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app and its game kit, debug-signed +powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Install # your game, against that kit, into Import (or WheelWizard VR's Build for Quest) powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Flavor retroRewind # Retro Rewind (needs translate-mod output) -adb push DATA /sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA +adb push MarioKart.iso /sdcard/Download/ # then Select disc image in the launcher ``` +Instead of the disc image, an already extracted partition can be pushed to +`/sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA`, or included in the +game package with `Build-QuestGame.ps1 -Data <dir>`. A game package only fits the APK whose kit +it was built against. After a native or runtime change, run both scripts again; after a +Kotlin-only change the kit fingerprint stays the same and the installed game keeps working. + `Config.toml`, saves and per-run logs live next to `DATA` under -`WiiCompiledOpenXRVR`; the activity writes a first `Config.toml` with -`[vr] enabled = true` and `paths.dvd_root` set. Logs: `adb logcat -s SDL WiiCompiledQuest` +`WiiCompiledOpenXRVR`; the launcher (or the game activity, when started +directly) writes a first `Config.toml` with `[vr] enabled = true` and +`paths.dvd_root` set. Logs: `adb logcat -s SDL WiiCompiledQuest WiiCompiledLauncher` plus the `Logs/<product>_<stamp>_pid<pid>/console.log` folder the runtime writes. A CMake-only cross-compile of the native runtime (no game) is the quick diff --git a/runtime/cmake/PublicProducts.cmake b/runtime/cmake/PublicProducts.cmake index 8388a12..7a218a9 100644 --- a/runtime/cmake/PublicProducts.cmake +++ b/runtime/cmake/PublicProducts.cmake @@ -161,6 +161,14 @@ set_source_files_properties(${MKW_PPC_SEMANTIC_RUNTIME_SOURCES} PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON SKIP_PRECOMPILE_HEADERS ON COMPILE_OPTIONS "${MKW_TRANSLATED_PPC_FP_OPTIONS}") +if(MKW_PLATFORM_ANDROID) + # The Quest app ships the runtime prebuilt and builds only the game from the player's disc + # (android/app/src/main/cpp/CMakeLists.txt, the game kit). These two are generated from that + # disc, so they must stay objects of their own rather than disappear into a unity batch the + # kit has to ship. + set_source_files_properties("${DATA_INIT_FILE}" "${GUEST_SYMBOL_TABLE_FILE}" PROPERTIES + SKIP_UNITY_BUILD_INCLUSION ON) +endif() set_target_properties(mkw_runtime_common PROPERTIES UNITY_BUILD ON UNITY_BUILD_MODE GROUP) target_precompile_headers(mkw_runtime_common PRIVATE "${MKW_RUNTIME_SOURCE_DIR}/include/mkw_pch.h") mkw_apply_common_compile_options(mkw_runtime_common) @@ -230,8 +238,16 @@ if(MKW_HAVE_RETRO_REWIND) target_precompile_headers(mkw_retro_rewind_functions REUSE_FROM mkw_base_shared) endif() +# WITHOUT_GAME configures everything a product links except the translated game: the base +# shards and the runtime objects generated from the disc. The Quest game kit is built that way. function(mkw_configure_product target) - target_sources(${target} PRIVATE $<TARGET_OBJECTS:mkw_runtime_common>) + cmake_parse_arguments(PARSE_ARGV 1 MKW_PRODUCT "WITHOUT_GAME" "" "") + if(MKW_PRODUCT_WITHOUT_GAME) + target_sources(${target} PRIVATE + "$<FILTER:$<TARGET_OBJECTS:mkw_runtime_common>,EXCLUDE,data_sections_init|guest_symbol_table>") + else() + target_sources(${target} PRIVATE $<TARGET_OBJECTS:mkw_runtime_common>) + endif() # Startup CPU check. Must stay a separate object library so it keeps the # plain baseline ISA while everything around it is built for x86-64-v3. if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") @@ -253,8 +269,13 @@ function(mkw_configure_product target) # The dispatch-table and registration shards compile inside the product target itself and # include the same fat translated headers; bound them by the same pool. mkw_bound_translated_compiles(${target}) - target_link_libraries(${target} PRIVATE - mkw_platform mkw_base_shared mkw::pugixml mkw::toml11 mkw::cryptopp) + if(MKW_PRODUCT_WITHOUT_GAME) + target_link_libraries(${target} PRIVATE + mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp) + else() + target_link_libraries(${target} PRIVATE + mkw_platform mkw_base_shared mkw::pugixml mkw::toml11 mkw::cryptopp) + endif() target_link_libraries(${target} PRIVATE aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx) @@ -366,6 +387,18 @@ if(TARGET mkw_base_sensitive) target_sources(WiiCompiled PRIVATE $<TARGET_OBJECTS:mkw_base_sensitive>) endif() +if(MKW_PLATFORM_ANDROID) + # The Quest game kit. The APK never contains the translated game: the player builds + # libmain.so from their own disc, on the headset or on a PC, and the app loads it from + # private storage. This probe is WiiCompiled without the game, linked with the game's + # symbols left unresolved. Building it builds every input the kit ships, and its link line + # is the recipe android/QuestGameKit.psm1 exports and the game builders replay. + add_library(mkw_quest_kit_probe SHARED "${MKW_BASE_PRODUCT_SOURCE}") + mkw_configure_product(mkw_quest_kit_probe WITHOUT_GAME) + target_precompile_headers(mkw_quest_kit_probe REUSE_FROM WiiCompiled) + target_link_options(mkw_quest_kit_probe PRIVATE "-Wl,--unresolved-symbols=ignore-all") +endif() + if(MKW_HAVE_RETRO_REWIND) if(MKW_PLATFORM_ANDROID) add_library(RetroRewind SHARED "${MKW_RETRO_REWIND_PRODUCT_SOURCE}" ${MKW_RETRO_REGISTRATION_SOURCES}) @@ -407,7 +440,7 @@ endif() set(MKW_ALL_BUILD_TARGETS mkw_runtime_common mkw_base_shared mkw_base_sensitive mkw_retro_sensitive - mkw_retro_rewind_functions WiiCompiled RetroRewind) + mkw_retro_rewind_functions WiiCompiled RetroRewind mkw_quest_kit_probe) foreach(target IN LISTS MKW_ALL_BUILD_TARGETS) if(TARGET ${target} AND MKW_BASELINE_ARCH_FLAG) target_compile_options(${target} PRIVATE ${MKW_BASELINE_ARCH_FLAG})