Added Android Launcher and Disk Extration

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

No files matched your search

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