Added Support for both Base and Retro Rewind in the launher

This commit is contained in:
iChris4 committed 2026-09-17 22:46:15 +02:00
1 parent 58c0890983
commit 321db74a0f
33 files changed
+681 -325

No files matched your search

+5 -1
View File
@@ -25,7 +25,11 @@ dotnet test translator/Translator.sln -c Release
`--version` still prints a plain version. `--info-json` additionally reports `productId` as `--version` still prints a plain version. `--info-json` additionally reports `productId` as
`wiicompiled-openxr-vr`, `version`, `openxrD3D12: true`, and `questBuild: true` when the setup `wiicompiled-openxr-vr`, `version`, `openxrD3D12: true`, and `questBuild: true` when the setup
supports `--build-quest` (see `docs/quest-port.md`). VR refuses installation, repair, supports `--build-quest` (see `docs/quest-port.md`). From 0.2.39 every release declares it, and
`Verify-Release.ps1` refuses one whose payload lacks the `BuildWorkspace/android` scripts that
build runs, so a release that claims Quest support can always deliver it. WheelWizard offers
Build for Quest only once the installed setup reports `questBuild`, and asks the player to update
otherwise. VR refuses installation, repair,
launch, or uninstall of a normal or unidentified installation. Nonportable VR installations use launch, or uninstall of a normal or unidentified installation. Nonportable VR installations use
`WiiCompiledOpenXRVR` application data and a distinct uninstall registration and shortcut. `WiiCompiledOpenXRVR` application data and a distinct uninstall registration and shortcut.
+1 -1
View File
@@ -1,5 +1,5 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.2.38</Version> <Version>0.2.39</Version>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+9
View File
@@ -9,6 +9,9 @@ if ($SetupPath) {
$info = (& $SetupPath --info-json | Out-String) | ConvertFrom-Json $info = (& $SetupPath --info-json | Out-String) | ConvertFrom-Json
if ($LASTEXITCODE -ne 0 -or $info.productId -cne 'wiicompiled-openxr-vr' -or if ($LASTEXITCODE -ne 0 -or $info.productId -cne 'wiicompiled-openxr-vr' -or
$info.version -cne $version -or $info.openxrD3D12 -ne $true) { throw 'Setup VR identity mismatch.' } $info.version -cne $version -or $info.openxrD3D12 -ne $true) { throw 'Setup VR identity mismatch.' }
# WheelWizard offers Build for Quest only to a setup that declares it, so a release that says
# so must also carry what the build runs.
if ($info.questBuild -ne $true) { throw 'Setup does not declare Quest build support.' }
# Inspect the appended payload as well: a correctly versioned host can still wrap stale tools. # Inspect the appended payload as well: a correctly versioned host can still wrap stale tools.
Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression
$stream = [IO.File]::OpenRead((Resolve-Path -LiteralPath $SetupPath)) $stream = [IO.File]::OpenRead((Resolve-Path -LiteralPath $SetupPath))
@@ -43,6 +46,12 @@ if ($SetupPath) {
if ($manifest.ProductId -cne $info.productId -or $manifest.ProductVersion -cne $version) { if ($manifest.ProductId -cne $info.productId -or $manifest.ProductVersion -cne $version) {
throw 'Payload identity/version does not match the executable.' throw 'Payload identity/version does not match the executable.'
} }
# The scripts --build-quest runs live in the installed workspace, not the toolkit.
foreach ($script in 'QuestGameKit.psm1', 'Build-QuestGame.ps1') {
if (-not $zip.GetEntry("BuildWorkspace/android/$script")) {
throw "The payload has no BuildWorkspace/android/$script, so --build-quest cannot run."
}
}
} finally { $zip.Dispose() } } finally { $zip.Dispose() }
} finally { $payload.Dispose(); $stream.Dispose() } } finally { $payload.Dispose(); $stream.Dispose() }
} }
@@ -52,6 +52,9 @@ internal sealed class CommandLine
public string? QuestApkPath { get; private set; } public string? QuestApkPath { get; private set; }
public string? OutputPath { get; private set; } public string? OutputPath { get; private set; }
public bool IncludeGameFiles { get; private set; } public bool IncludeGameFiles { get; private set; }
/// <summary>Which game the Quest app is to build: "base" (the default) or "retro_rewind".</summary>
public string QuestProduct { get; private set; } = "base";
public bool IncludeModContent { get; private set; }
public static bool WantsProgressJson(string[] args) => public static bool WantsProgressJson(string[] args) =>
args.Any(argument => argument.Equals("--progress-json", StringComparison.OrdinalIgnoreCase)); args.Any(argument => argument.Equals("--progress-json", StringComparison.OrdinalIgnoreCase));
@@ -97,6 +100,8 @@ internal sealed class CommandLine
case "--quest-apk": result.QuestApkPath = RequireValue(args, ref i); break; case "--quest-apk": result.QuestApkPath = RequireValue(args, ref i); break;
case "--output": result.OutputPath = RequireValue(args, ref i); break; case "--output": result.OutputPath = RequireValue(args, ref i); break;
case "--include-game-files": result.IncludeGameFiles = true; break; case "--include-game-files": result.IncludeGameFiles = true; break;
case "--quest-product": result.QuestProduct = RequireValue(args, ref i); break;
case "--include-mod-content": result.IncludeModContent = true; break;
default: default:
throw new ArgumentException($"Unknown CLI option: {args[i]}"); throw new ArgumentException($"Unknown CLI option: {args[i]}");
} }
@@ -149,7 +154,7 @@ internal sealed class CommandLine
[AppMode.BuildQuest] = new ModeRules [AppMode.BuildQuest] = new ModeRules
{ {
Flag = "--build-quest", AcceptsProgressJson = true, RequiresInstallDirectory = true, Flag = "--build-quest", AcceptsProgressJson = true, RequiresInstallDirectory = true,
AcceptsQuestBuild = true AcceptsQuestBuild = true, AcceptsRetroDirectory = true
} }
}; };
@@ -170,6 +175,13 @@ internal sealed class CommandLine
Reject(QuestApkPath is not null && !rules.AcceptsQuestBuild, "--quest-apk"); Reject(QuestApkPath is not null && !rules.AcceptsQuestBuild, "--quest-apk");
Reject(OutputPath is not null && !rules.AcceptsQuestBuild, "--output"); Reject(OutputPath is not null && !rules.AcceptsQuestBuild, "--output");
Reject(IncludeGameFiles && !rules.AcceptsQuestBuild, "--include-game-files"); Reject(IncludeGameFiles && !rules.AcceptsQuestBuild, "--include-game-files");
Reject(IncludeModContent && !rules.AcceptsQuestBuild, "--include-mod-content");
if (rules.AcceptsQuestBuild && QuestProduct is not ("base" or "retro_rewind"))
throw new ArgumentException("--quest-product must be base or retro_rewind.");
if (IncludeModContent && QuestProduct != "retro_rewind")
throw new ArgumentException("--include-mod-content needs --quest-product retro_rewind.");
if (IncludeModContent && string.IsNullOrWhiteSpace(RetroDirectoryPath))
throw new ArgumentException("--include-mod-content needs --retro-dir.");
if (rules.AcceptsQuestBuild && (string.IsNullOrWhiteSpace(QuestApkPath) || string.IsNullOrWhiteSpace(OutputPath))) if (rules.AcceptsQuestBuild && (string.IsNullOrWhiteSpace(QuestApkPath) || string.IsNullOrWhiteSpace(OutputPath)))
throw new ArgumentException($"--quest-apk and --output are required with {rules.Flag}."); throw new ArgumentException($"--quest-apk and --output are required with {rules.Flag}.");
Reject(RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable && !rules.AcceptsPayloadMode, Reject(RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable && !rules.AcceptsPayloadMode,
@@ -17,7 +17,8 @@ internal static class ConsoleCommands
Console.Out.WriteLine(" --repair-products --install-dir <dir> --retro-dir <folder> " + Console.Out.WriteLine(" --repair-products --install-dir <dir> --retro-dir <folder> " +
"(--download-retro-wfc-payload | --skip-retro-wfc-payload) [--progress-json]"); "(--download-retro-wfc-payload | --skip-retro-wfc-payload) [--progress-json]");
Console.Out.WriteLine(" --build-quest --install-dir <dir> --quest-apk <apk> --output <file.wcgame> " + Console.Out.WriteLine(" --build-quest --install-dir <dir> --quest-apk <apk> --output <file.wcgame> " +
"[--include-game-files] [--progress-json]"); "[--quest-product base|retro_rewind] [--include-game-files] " +
"[--retro-dir <folder> --include-mod-content] [--progress-json]");
Console.Out.WriteLine(" --launch-retro | --launch-base"); Console.Out.WriteLine(" --launch-retro | --launch-base");
Console.Out.WriteLine(" --uninstall --install-dir <dir>"); Console.Out.WriteLine(" --uninstall --install-dir <dir>");
Console.Out.WriteLine(" --version"); Console.Out.WriteLine(" --version");
@@ -293,7 +294,8 @@ internal static class ConsoleCommands
using var operationLock = InstallOperationLock.Acquire(installation.Root, reporter); using var operationLock = InstallOperationLock.Acquire(installation.Root, reporter);
var result = await new QuestBuildService(reporter).BuildAsync(installation, var result = await new QuestBuildService(reporter).BuildAsync(installation,
Path.GetFullPath(command.QuestApkPath!), Path.GetFullPath(command.OutputPath!), Path.GetFullPath(command.QuestApkPath!), Path.GetFullPath(command.OutputPath!),
command.IncludeGameFiles, cancellationToken); command.IncludeGameFiles, cancellationToken, command.QuestProduct,
command.IncludeModContent ? Path.GetFullPath(command.RetroDirectoryPath!) : null);
if (ndjson is null) if (ndjson is null)
Console.Out.WriteLine($"Quest game package: {result.PackagePath} ({result.SizeBytes / 1_000_000} MB)"); Console.Out.WriteLine($"Quest game package: {result.PackagePath} ({result.SizeBytes / 1_000_000} MB)");
ndjson?.QuestPackage(result); ndjson?.QuestPackage(result);
@@ -49,7 +49,8 @@ internal sealed class QuestBuildService
internal sealed record Toolchain(string ClangCxx, string ClangC, string Sysroot); internal sealed record Toolchain(string ClangCxx, string ClangC, string Sysroot);
public async Task<Result> BuildAsync(Installation installation, string apkPath, string outputPath, public async Task<Result> BuildAsync(Installation installation, string apkPath, string outputPath,
bool includeGameFiles, CancellationToken cancellationToken) bool includeGameFiles, CancellationToken cancellationToken, string product = "base",
string? modContentDirectory = null)
{ {
_reporter.Progress(InstallStages.Validate, "Checking the installation and the Quest app...", 1); _reporter.Progress(InstallStages.Validate, "Checking the installation and the Quest app...", 1);
var workspace = installation.WorkspaceDirectory; var workspace = installation.WorkspaceDirectory;
@@ -73,14 +74,24 @@ internal sealed class QuestBuildService
var kitDirectory = Path.Combine(questRoot, "kit"); var kitDirectory = Path.Combine(questRoot, "kit");
_reporter.Progress(InstallStages.QuestKit, "Reading the game kit from the Quest app...", 2); _reporter.Progress(InstallStages.QuestKit, "Reading the game kit from the Quest app...", 2);
var kit = ExtractKit(apkPath, kitDirectory, cancellationToken); var kit = ExtractKit(apkPath, kitDirectory, cancellationToken);
_reporter.Diagnostic($"Quest game kit {kit.Fingerprint} ({kit.Product})"); _reporter.Diagnostic($"Quest game kit {kit.Fingerprint} [{string.Join(", ", kit.Products)}]");
// A Retro Rewind app needs this installation's Retro Rewind translation, which only exists if (!kit.Products.Contains(product))
// once the mod has been built here at least once.
if (kit.Product == "retro_rewind" && !HasRetroRewindShards(generated))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"This Quest app is the Retro Rewind one, and this installation has no Retro Rewind " + $"The Quest app cannot play {product}: its game kit carries {string.Join(", ", kit.Products)}.");
"translation. Install or repair Retro Rewind, then build for Quest again."); }
// Retro Rewind needs this installation's own Retro Rewind translation, which only exists
// once the mod has been built here at least once.
if (product == "retro_rewind" && !HasRetroRewindShards(generated))
{
throw new InvalidOperationException(
"This installation has no Retro Rewind translation. Install or repair Retro Rewind, " +
"then build for Quest again.");
}
if (modContentDirectory is not null && !File.Exists(Path.Combine(modContentDirectory, "Binaries", "Code.pul")))
{
throw new InvalidOperationException(
"The Retro Rewind content to put in the game file is missing its Binaries/Code.pul.");
} }
var toolchain = await EnsureToolchainAsync(questRoot, cancellationToken); var toolchain = await EnsureToolchainAsync(questRoot, cancellationToken);
@@ -94,13 +105,18 @@ internal sealed class QuestBuildService
"-Generated", generated, "-Kit", kitDirectory, "-Manifest", manifest, "-Generated", generated, "-Kit", kitDirectory, "-Manifest", manifest,
"-BuildDir", Path.Combine(questRoot, "build"), "-Output", temporaryOutput, "-BuildDir", Path.Combine(questRoot, "build"), "-Output", temporaryOutput,
"-ClangCxx", toolchain.ClangCxx, "-ClangC", toolchain.ClangC, "-Sysroot", toolchain.Sysroot, "-ClangCxx", toolchain.ClangCxx, "-ClangC", toolchain.ClangC, "-Sysroot", toolchain.Sysroot,
"-Ninja", ninja, "-BuiltBy", $"WiiCompiled Setup {ProductInfo.Version}" "-Ninja", ninja, "-BuiltBy", $"WiiCompiled Setup {ProductInfo.Version}", "-Product", product
}; };
if (includeGameFiles) if (includeGameFiles)
{ {
arguments.Add("-Data"); arguments.Add("-Data");
arguments.Add(installation.GameDataDirectory); arguments.Add(installation.GameDataDirectory);
} }
if (modContentDirectory is not null)
{
arguments.Add("-Mod");
arguments.Add(modContentDirectory);
}
_reporter.Progress(InstallStages.QuestBuild, "Preparing the Quest build...", 40); _reporter.Progress(InstallStages.QuestBuild, "Preparing the Quest build...", 40);
var powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), var powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),
@@ -168,8 +184,8 @@ internal sealed class QuestBuildService
File.Exists(Path.Combine(directory, "sys", "fst.bin")) && File.Exists(Path.Combine(directory, "sys", "fst.bin")) &&
File.Exists(Path.Combine(directory, "sys", "main.dol")); File.Exists(Path.Combine(directory, "sys", "main.dol"));
/// <summary>What the extracted kit says it builds: its fingerprint and product ("base" or "retro_rewind").</summary> /// <summary>What the extracted kit says it builds: its fingerprint and the products it carries.</summary>
internal sealed record Kit(string Fingerprint, string Product); internal sealed record Kit(string Fingerprint, IReadOnlyCollection<string> Products);
/// <summary>Extracts <c>assets/game_kit</c> from the Quest app's APK and reads what it is for.</summary> /// <summary>Extracts <c>assets/game_kit</c> from the Quest app's APK and reads what it is for.</summary>
internal static Kit ExtractKit(string apkPath, string destination, CancellationToken cancellationToken) internal static Kit ExtractKit(string apkPath, string destination, CancellationToken cancellationToken)
@@ -203,11 +219,11 @@ internal sealed class QuestBuildService
{ {
throw new InvalidDataException("The Quest app's game kit has no fingerprint."); throw new InvalidDataException("The Quest app's game kit has no fingerprint.");
} }
var product = document.RootElement.TryGetProperty("product", out var value) && var products = document.RootElement.TryGetProperty("products", out var value) &&
value.ValueKind == JsonValueKind.String value.ValueKind == JsonValueKind.Object
? value.GetString()! ? value.EnumerateObject().Select(property => property.Name).ToArray()
: "base"; : throw new InvalidDataException("The Quest app's game kit lists no games it can build.");
return new Kit(fingerprint.GetString()!, product); return new Kit(fingerprint.GetString()!, products);
} }
private async Task<Toolchain> EnsureToolchainAsync(string questRoot, CancellationToken cancellationToken) private async Task<Toolchain> EnsureToolchainAsync(string questRoot, CancellationToken cancellationToken)
@@ -302,9 +302,18 @@ internal static class SelfTests
"--output", "D:\\MarioKartWii.wcgame", "--include-game-files", "--progress-json" "--output", "D:\\MarioKartWii.wcgame", "--include-game-files", "--progress-json"
]); ]);
if (build.Mode != AppMode.BuildQuest || build.QuestApkPath != "quest.apk" || if (build.Mode != AppMode.BuildQuest || build.QuestApkPath != "quest.apk" ||
build.OutputPath != "D:\\MarioKartWii.wcgame" || !build.IncludeGameFiles || !build.ProgressJson) build.OutputPath != "D:\\MarioKartWii.wcgame" || !build.IncludeGameFiles || !build.ProgressJson ||
build.QuestProduct != "base" || build.IncludeModContent)
throw new Exception("The Quest build contract did not parse."); throw new Exception("The Quest build contract did not parse.");
var retro = CommandLine.Parse([
"--build-quest", "--install-dir", "C:\\Games\\MKW", "--quest-apk", "quest.apk",
"--output", "D:\\RetroRewind.wcgame", "--quest-product", "retro_rewind",
"--retro-dir", "D:\\RetroRewind6", "--include-mod-content"
]);
if (retro.QuestProduct != "retro_rewind" || !retro.IncludeModContent || retro.RetroDirectoryPath != "D:\\RetroRewind6")
throw new Exception("The Retro Rewind Quest build contract did not parse.");
void Rejects(string description, params string[] args) void Rejects(string description, params string[] args)
{ {
try try
@@ -319,6 +328,12 @@ internal static class SelfTests
Rejects("A Quest build without an installation", "--build-quest", "--quest-apk", "a.apk", "--output", "o"); 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("--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"); Rejects("--include-game-files outside a Quest build", "--check-products", "--include-game-files");
Rejects("An unknown Quest product", "--build-quest", "--install-dir", "i", "--quest-apk", "a.apk",
"--output", "o.wcgame", "--quest-product", "mario-kart-8");
Rejects("The mod content of a base game", "--build-quest", "--install-dir", "i", "--quest-apk", "a.apk",
"--output", "o.wcgame", "--retro-dir", "r", "--include-mod-content");
Rejects("Mod content without its source", "--build-quest", "--install-dir", "i", "--quest-apk", "a.apk",
"--output", "o.wcgame", "--quest-product", "retro_rewind", "--include-mod-content");
} }
private static void TestQuestToolchainSelection() private static void TestQuestToolchainSelection()
@@ -369,15 +384,16 @@ internal static class SelfTests
using var writer = new StreamWriter(zip.CreateEntry(name).Open()); using var writer = new StreamWriter(zip.CreateEntry(name).Open());
writer.Write(text); writer.Write(text);
} }
Add("assets/game_kit/kit.json", "{\"schema\":2,\"product\":\"retro_rewind\",\"fingerprint\":\"abc123\"}"); Add("assets/game_kit/kit.json", "{\"schema\":3,\"fingerprint\":\"abc123\",\"products\":{\"base\":{},\"retro_rewind\":{}}}");
Add("assets/game_kit/include/memory.h", "#pragma once"); Add("assets/game_kit/include/memory.h", "#pragma once");
Add("assets/runtime_resources/dsp_coef.bin", "not the kit"); Add("assets/runtime_resources/dsp_coef.bin", "not the kit");
Add("lib/arm64-v8a/libSDL3.so", "not the kit"); Add("lib/arm64-v8a/libSDL3.so", "not the kit");
} }
var kit = Path.Combine(temp, "kit"); var kit = Path.Combine(temp, "kit");
var extracted = QuestBuildService.ExtractKit(apk, kit, CancellationToken.None); var extracted = QuestBuildService.ExtractKit(apk, kit, CancellationToken.None);
if (extracted.Fingerprint != "abc123" || extracted.Product != "retro_rewind") if (extracted.Fingerprint != "abc123" || !extracted.Products.Contains("base") ||
throw new Exception("The kit fingerprint or product was not read."); !extracted.Products.Contains("retro_rewind"))
throw new Exception("The kit fingerprint or its games were not read.");
if (!File.Exists(Path.Combine(kit, "include", "memory.h")) || if (!File.Exists(Path.Combine(kit, "include", "memory.h")) ||
Directory.GetFiles(kit, "*", SearchOption.AllDirectories).Length != 2) Directory.GetFiles(kit, "*", SearchOption.AllDirectories).Length != 2)
throw new Exception("The kit extraction took the wrong files."); throw new Exception("The kit extraction took the wrong files.");
-2
View File
@@ -1,2 +0,0 @@
64fdd492f6ce71fd223b49f493e0809d9a7f9ed45ebaa3605f626eb77282dbe4 WiiCompiled-Setup.exe
b319b86d46bea6ebfa52965c65706976d6004abc01914ff82a2b73c71de84def WheelWizardVRWindows.exe
@@ -1 +0,0 @@
946bb530c9deea52f397689d637a4e0c10070d9c1980d7d02c4acc78f8fb7e5d WheelWizardVRWindows.exe
@@ -1 +0,0 @@
46ab8a1d1c6d8af551cdd45808397a0eb4fbbc96bb97e25d7a5a70d6ccde01bf WheelWizardVRWindows.exe
+10 -13
View File
@@ -1,8 +1,11 @@
# Builds the standalone Meta Quest APK on a Windows host. # Builds the standalone Meta Quest APK on a Windows host.
# #
# powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 [-Generated <dir>] [-Flavor base|retroRewind] # powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 [-Generated <dir>]
# [-Configuration debug|release] [-Install] # [-Configuration debug|release] [-Install]
# #
# One app offers both games: the APK carries a kit for the base game, and for Retro Rewind too
# when -Generated holds a translation that includes the mod.
#
# -Generated names the translator output for the disc you own: the directory # -Generated names the translator output for the disc you own: the directory
# holding data_sections_init.cpp, RuntimeConfig.h and build_shards/shards.cmake. # holding data_sections_init.cpp, RuntimeConfig.h and build_shards/shards.cmake.
# It defaults to the installer's build workspace next to this checkout. The # It defaults to the installer's build workspace next to this checkout. The
@@ -17,7 +20,6 @@ param(
[string]$Generated = '', [string]$Generated = '',
[string]$Dependencies = '', [string]$Dependencies = '',
[string]$CMakeDir = '', [string]$CMakeDir = '',
[ValidateSet('base', 'retroRewind')] [string]$Flavor = 'base',
[ValidateSet('debug', 'release')] [string]$Configuration = 'debug', [ValidateSet('debug', 'release')] [string]$Configuration = 'debug',
[switch]$Install [switch]$Install
) )
@@ -71,7 +73,7 @@ if (-not $Dependencies) {
} }
$variant = (Get-Culture).TextInfo.ToTitleCase($Configuration) $variant = (Get-Culture).TextInfo.ToTitleCase($Configuration)
$task = "app:assemble$((Get-Culture).TextInfo.ToTitleCase($Flavor))$variant" $task = "app:assemble$variant"
$gradleArgs = @( $gradleArgs = @(
'--project-dir', $root, '--project-dir', $root,
"-PmkwGeneratedDir=$Generated" "-PmkwGeneratedDir=$Generated"
@@ -82,13 +84,13 @@ Write-Host "gradlew $($gradleArgs -join ' ')"
& (Join-Path $root 'gradlew.bat') @gradleArgs & (Join-Path $root 'gradlew.bat') @gradleArgs
if ($LASTEXITCODE -ne 0) { throw "Gradle failed ($LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "Gradle failed ($LASTEXITCODE)" }
$apkDir = Join-Path $root "app\build\outputs\apk\$Flavor\$Configuration" $apkDir = Join-Path $root "app\build\outputs\apk\$Configuration"
$apk = Get-ChildItem -Path $apkDir -Filter '*.apk' | Select-Object -First 1 $apk = Get-ChildItem -Path $apkDir -Filter '*.apk' | Select-Object -First 1
if (-not $apk) { throw "No APK under $apkDir" } if (-not $apk) { throw "No APK under $apkDir" }
Write-Host "APK: $($apk.FullName)" Write-Host "APK: $($apk.FullName)"
# Every APK must be distributable: no translated game inside, only the game kit. The toolchain # The APK must be distributable: no translated game inside, only the game kit and the toolchain
# that builds one on the headset travels in the base APK only. # that builds one on the headset.
Add-Type -AssemblyName System.IO.Compression.FileSystem Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [IO.Compression.ZipFile]::OpenRead($apk.FullName) $archive = [IO.Compression.ZipFile]::OpenRead($apk.FullName)
try { try {
@@ -98,16 +100,11 @@ try {
} finally { $archive.Dispose() } } finally { $archive.Dispose() }
if ($gameLibraries.Count -gt 0) { throw "The APK contains a translated game library: $($gameLibraries.FullName -join ', ')" } 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 (-not $hasKit) { throw 'The APK has no game kit (assets/game_kit/kit.json)' }
if ($Flavor -eq 'base' -and -not $hasToolchain) { throw 'The APK has no build toolchain (assets/quest_toolchain/files.zip)' } if (-not $hasToolchain) { throw 'The APK has no build toolchain (assets/quest_toolchain/files.zip)' }
if ($Flavor -ne 'base' -and $hasToolchain) { throw 'Only the base APK carries the on-headset build toolchain' }
if ($Install) { if ($Install) {
$adb = Join-Path $sdkRoot 'platform-tools\adb.exe' $adb = Join-Path $sdkRoot 'platform-tools\adb.exe'
& $adb install -r $apk.FullName & $adb install -r $apk.FullName
if ($LASTEXITCODE -ne 0) { throw "adb install failed ($LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "adb install failed ($LASTEXITCODE)" }
if ($Flavor -eq 'base') { Write-Host 'Installed. Build a game with Build on this Quest in the launcher, or on this PC with android/Build-QuestGame.ps1 -Install.'
Write-Host 'Installed. Build the game with Build on this Quest in the launcher, or on this PC with android/Build-QuestGame.ps1 -Install.'
} else {
Write-Host "Installed. Build its game on this PC with android/Build-QuestGame.ps1 -Flavor $Flavor -Install."
}
} }
+14 -15
View File
@@ -13,9 +13,10 @@
# downloaded, the toolkit's ninja and the workspace's recomp.yml. # 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 # -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 of -Flavor (base or # kit the Quest app carries; it defaults to the one the last APK build exported. -Product picks the
# retroRewind, whose translation must include the mod) exported. -Data adds the # game: base, or retro_rewind (whose translation must include the mod). -Data adds the extracted
# extracted disc (the directory holding sys/ and files/) so the headset needs nothing else. # disc (the directory holding sys/ and files/) and -Mod the RetroRewind6 pack, so the headset needs
# nothing else.
# -Install pushes the package into the app's Import folder over adb, where the launcher picks it # -Install pushes the package into the app's Import folder over adb, where the launcher picks it
# up the next time it opens. # up the next time it opens.
[CmdletBinding()] [CmdletBinding()]
@@ -31,7 +32,8 @@ param(
[string]$Sysroot = '', [string]$Sysroot = '',
[string]$Ninja = '', [string]$Ninja = '',
[string]$BuiltBy = 'android/Build-QuestGame.ps1', [string]$BuiltBy = 'android/Build-QuestGame.ps1',
[ValidateSet('base', 'retroRewind')] [string]$Flavor = 'base', [ValidateSet('base', 'retro_rewind')] [string]$Product = 'base',
[string]$Mod = '',
[int]$TranslatedJobs = 0, [int]$TranslatedJobs = 0,
[switch]$Install [switch]$Install
) )
@@ -42,17 +44,15 @@ $root = $PSScriptRoot
$workspace = (Resolve-Path (Join-Path $root '..')).Path $workspace = (Resolve-Path (Join-Path $root '..')).Path
if (-not $Generated) { $Generated = Join-Path $workspace '.scratch\vr-build-workspace\BuildWorkspace\generated' } 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 $Manifest) { $Manifest = Join-Path $workspace 'projects\mkwii\recomp.yml' }
if (-not $BuildDir) { $BuildDir = Join-Path $root "app\build\questGame\$Flavor" } if (-not $BuildDir) { $BuildDir = Join-Path $root "app\build\questGame\$Product" }
if (-not $Kit) { if (-not $Kit) {
# The kit this flavour's last APK build packaged (its export<Flavor>*QuestGameKit task), so the # The kit the last APK build packaged, so the game matches the app it will be imported into.
# game matches the app it will be imported into.
$exportTask = 'export' + $Flavor.Substring(0, 1).ToUpper() + $Flavor.Substring(1)
$Kit = Get-ChildItem -Path (Join-Path $root 'app\build\generated') -Filter kit.json -Recurse -ErrorAction SilentlyContinue | $Kit = Get-ChildItem -Path (Join-Path $root 'app\build\generated') -Filter kit.json -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Directory.Name -eq 'game_kit' -and $_.FullName -like "*$exportTask*" } | Where-Object { $_.Directory.Name -eq 'game_kit' } |
Sort-Object LastWriteTime -Descending | Select-Object -First 1 | ForEach-Object { $_.DirectoryName } Sort-Object LastWriteTime -Descending | Select-Object -First 1 | ForEach-Object { $_.DirectoryName }
} }
if (-not $Kit -or -not (Test-Path (Join-Path $Kit 'kit.json'))) { if (-not $Kit -or -not (Test-Path (Join-Path $Kit 'kit.json'))) {
throw "No $Flavor game kit; run Build-Quest.ps1 -Flavor $Flavor first, or pass -Kit" 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" } if (-not (Test-Path (Join-Path $Generated 'build_shards\shards.cmake'))) { throw "No translated graph at $Generated" }
@@ -85,19 +85,18 @@ if (-not $gameId -or -not $dolSha -or -not $relSha) { throw "Cannot read the dis
$stopwatch = [Diagnostics.Stopwatch]::StartNew() $stopwatch = [Diagnostics.Stopwatch]::StartNew()
$library = Invoke-QuestGameBuild -KitDir $Kit -GeneratedDir $Generated -BuildDir $BuildDir ` $library = Invoke-QuestGameBuild -KitDir $Kit -GeneratedDir $Generated -BuildDir $BuildDir `
-ClangCxx $ClangCxx -ClangC $ClangC -Sysroot $Sysroot -Ninja $Ninja -TranslatedJobs $TranslatedJobs -ClangCxx $ClangCxx -ClangC $ClangC -Sysroot $Sysroot -Ninja $Ninja -TranslatedJobs $TranslatedJobs -Product $Product
Write-Host ("Built {0} in {1:N1} min" -f $library, $stopwatch.Elapsed.TotalMinutes) Write-Host ("Built {0} in {1:N1} min" -f $library, $stopwatch.Elapsed.TotalMinutes)
if (-not $Output) { $Output = Join-Path $BuildDir 'MarioKartWii.wcgame' } if (-not $Output) { $Output = Join-Path $BuildDir 'MarioKartWii.wcgame' }
Write-Host 'MKWCBUILD:STEP:quest-package Packaging the game for Quest' 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 ` $game = New-QuestGamePackage -Library $library -DataDir $Data -ModDir $Mod -KitDir $Kit -GameId $gameId -DolSha256 $dolSha -RelSha256 $relSha `
-BuiltBy $BuiltBy -OutputPath $Output -BuiltBy $BuiltBy -OutputPath $Output -Product $Product
Write-Host "Game package: $Output (kit $($game.kitFingerprint))" Write-Host "Game package: $Output (kit $($game.kitFingerprint))"
if ($Install) { if ($Install) {
$adb = Join-Path $sdkRoot 'platform-tools\adb.exe' $adb = Join-Path $sdkRoot 'platform-tools\adb.exe'
$package = if ($Flavor -eq 'retroRewind') { 'org.wiicompiled.quest.retrorewind' } else { 'org.wiicompiled.quest' } $import = '/sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/Import'
$import = "/sdcard/Android/data/$package/files/WiiCompiledOpenXRVR/Import"
# The launcher creates Import itself and must own it to remove imported packages (see # 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. # 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() $exists = ((& $adb shell "test -d '$import' && echo yes") | Out-String).Trim()
+6 -6
View File
@@ -1,17 +1,17 @@
# Exports the Quest game kit (see QuestGameKit.psm1) from a configured and built Android CMake # 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 # tree. android/app/build.gradle.kts runs this after the native build and packages the result as
# the APK's game_kit assets, once per app flavour. # the APK's game_kit assets. One kit carries a recipe per product the tree built a probe for:
# the base game always, and Retro Rewind when the translation includes the mod.
# #
# powershell -ExecutionPolicy Bypass -File android/Export-QuestGameKit.ps1 -CMakeBinaryDir <dir> -OutputDir <dir> -LlvmStrip <llvm-strip> [-Product base|retro_rewind] # powershell -ExecutionPolicy Bypass -File android/Export-QuestGameKit.ps1 -CMakeBinaryDir <dir> -OutputDir <dir> -LlvmStrip <llvm-strip>
[CmdletBinding()] [CmdletBinding()]
param( param(
[Parameter(Mandatory)] [string]$CMakeBinaryDir, [Parameter(Mandatory)] [string]$CMakeBinaryDir,
[Parameter(Mandatory)] [string]$OutputDir, [Parameter(Mandatory)] [string]$OutputDir,
[Parameter(Mandatory)] [string]$LlvmStrip, [Parameter(Mandatory)] [string]$LlvmStrip
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'QuestGameKit.psm1') -Force Import-Module (Join-Path $PSScriptRoot 'QuestGameKit.psm1') -Force
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$fingerprint = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip -Product $Product $kit = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip
Write-Host "Quest game kit ($Product) $fingerprint -> $OutputDir" Write-Host "Quest game kit $($kit.Fingerprint) [$($kit.Products -join ', ')] -> $OutputDir"
+84 -61
View File
@@ -24,7 +24,7 @@
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$script:KitSchema = 2 $script:KitSchema = 3
$script:GameSchema = 1 $script:GameSchema = 1
# What each app flavour's kit is made of. Slots name the game's objects in the probe's link line, # What each app flavour's kit is made of. Slots name the game's objects in the probe's link line,
@@ -268,34 +268,15 @@ function Export-QuestGameKit {
[Parameter(Mandatory)] [string]$CMakeBinaryDir, [Parameter(Mandatory)] [string]$CMakeBinaryDir,
[Parameter(Mandatory)] [string]$OutputDir, [Parameter(Mandatory)] [string]$OutputDir,
[Parameter(Mandatory)] [string]$RepoRoot, [Parameter(Mandatory)] [string]$RepoRoot,
[Parameter(Mandatory)] [string]$LlvmStrip, [Parameter(Mandatory)] [string]$LlvmStrip
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$binary = ConvertTo-ForwardPath $CMakeBinaryDir $binary = ConvertTo-ForwardPath $CMakeBinaryDir
$runtimeInclude = ConvertTo-ForwardPath (Join-Path $RepoRoot 'runtime/include') $runtimeInclude = ConvertTo-ForwardPath (Join-Path $RepoRoot 'runtime/include')
$kitProfile = $script:KitProfiles[$Product]
# The probe's link edge: explicit inputs are the objects, LINK_LIBRARIES the archives.
$ninja = [IO.File]::ReadAllText("$binary/build.ninja") $ninja = [IO.File]::ReadAllText("$binary/build.ninja")
$edge = [regex]::Match($ninja, "(?m)^build (\S*$([regex]::Escape($kitProfile.Probe))\.so): (\S+) ([^\r\n]*)\r?\n((?: [^\r\n]*\r?\n)+)")
if (-not $edge.Success) { throw "No $($kitProfile.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" $commands = Read-CompileCommands "$binary/compile_commands.json"
$rules = if (Test-Path "$binary/CMakeFiles/rules.ninja") { [IO.File]::ReadAllText("$binary/CMakeFiles/rules.ninja") } else { '' }
function Find-Command([string]$Pattern) { function Find-Command([string]$Pattern) {
$entry = @($commands | Where-Object { $_.file.Replace('\', '/') -match $Pattern }) $entry = @($commands | Where-Object { $_.file.Replace('\', '/') -match $Pattern })
if ($entry.Count -eq 0) { throw "compile_commands.json has no source matching $Pattern" } if ($entry.Count -eq 0) { throw "compile_commands.json has no source matching $Pattern" }
@@ -307,20 +288,15 @@ function Export-QuestGameKit {
param($Entry) param($Entry)
ConvertTo-KitCompileFlags -Arguments (Split-CommandLine $Entry.command) -RuntimeInclude $runtimeInclude -Workspace $workspace ConvertTo-KitCompileFlags -Arguments (Split-CommandLine $Entry.command) -RuntimeInclude $runtimeInclude -Workspace $workspace
} }
$compile = [ordered]@{
translated = & $flagsFor $translatedEntry
product = & $flagsFor (Find-Command $kitProfile.RegistrationPattern)
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 } if (Test-Path $OutputDir) { Remove-Item -Recurse -Force $OutputDir }
$objectsDir = Join-Path $OutputDir 'objects' New-Item -ItemType Directory -Force (Join-Path $OutputDir 'objects'), (Join-Path $OutputDir 'libs') | Out-Null
$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') Copy-Item -Recurse (Join-Path $RepoRoot 'runtime/include') (Join-Path $OutputDir 'include')
# One copy of every object and archive, however many products link it: the two probes differ
# only in their own product object.
$copied = @{} $copied = @{}
$script:kitIndex = 0
$copy = { $copy = {
param([string]$Source, [string]$Directory) param([string]$Source, [string]$Directory)
$full = ConvertTo-ForwardPath $(if ([IO.Path]::IsPathRooted($Source)) { $Source } else { "$binary/$Source" }) $full = ConvertTo-ForwardPath $(if ([IO.Path]::IsPathRooted($Source)) { $Source } else { "$binary/$Source" })
@@ -333,9 +309,32 @@ function Export-QuestGameKit {
$copied[$full] = "{kit}/$Directory/$name" $copied[$full] = "{kit}/$Directory/$name"
return $copied[$full] return $copied[$full]
} }
$script:kitIndex = 0
# Link inputs in the probe's order, with the game's slots marked where WiiCompiled has them. # One recipe per product this CMake tree built a probe for. The base one must be there; Retro
# Rewind's exists only once translate-mod and emit-build-shards have run.
$products = [ordered]@{}
foreach ($product in 'base', 'retro_rewind') {
$kitProfile = $script:KitProfiles[$product]
$edge = [regex]::Match($ninja, "(?m)^build (\S*$([regex]::Escape($kitProfile.Probe))\.so): (\S+) ([^\r\n]*)\r?\n((?: [^\r\n]*\r?\n)+)")
if (-not $edge.Success) {
if ($product -eq 'base') { throw "No $($kitProfile.Probe) link edge in $binary/build.ninja" }
Write-Host "No $($kitProfile.Probe) in this build; the kit carries the base game only."
continue
}
$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) {
$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)" }
# Link inputs in the probe's order, with the game's slots marked where the product has them.
$inputs = New-Object System.Collections.Generic.List[string] $inputs = New-Object System.Collections.Generic.List[string]
$lastRuntime = -1 $lastRuntime = -1
for ($i = 0; $i -lt $objects.Count; $i++) { for ($i = 0; $i -lt $objects.Count; $i++) {
@@ -352,7 +351,7 @@ function Export-QuestGameKit {
} }
foreach ($slot in $kitProfile.Slots) { foreach ($slot in $kitProfile.Slots) {
if ($slot -ne 'translated' -and -not $inputs.Contains("{game:$slot}")) { if ($slot -ne 'translated' -and -not $inputs.Contains("{game:$slot}")) {
throw "The probe link line lacks the objects the {game:$slot} slot follows" throw "The $product probe link line lacks the objects the {game:$slot} slot follows"
} }
} }
foreach ($token in ((ConvertFrom-NinjaPath $vars['LINK_LIBRARIES']) -split ' ' | Where-Object { $_ })) { foreach ($token in ((ConvertFrom-NinjaPath $vars['LINK_LIBRARIES']) -split ' ' | Where-Object { $_ })) {
@@ -362,7 +361,7 @@ function Export-QuestGameKit {
else { $inputs.Add((& $copy $token 'libs')) } else { $inputs.Add((& $copy $token 'libs')) }
if ($path -match '/libmkw_platform\.a$') { $inputs.Add('{game:translated}') } 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' } if (-not $inputs.Contains('{game:translated}')) { throw "The $product probe link line lacks libmkw_platform.a" }
$linkFlags = New-Object System.Collections.Generic.List[string] $linkFlags = New-Object System.Collections.Generic.List[string]
$linkFlags.Add("--target=$target"); $linkFlags.Add('--sysroot={sysroot}'); $linkFlags.Add('-fPIC') $linkFlags.Add("--target=$target"); $linkFlags.Add('--sysroot={sysroot}'); $linkFlags.Add('-fPIC')
@@ -376,8 +375,25 @@ function Export-QuestGameKit {
} }
} }
$linkFlags.Add('-Wl,-soname,libmain.so') $linkFlags.Add('-Wl,-soname,libmain.so')
$lld = Get-KitLldArguments -ClangCxx (Join-Path (Split-Path -Parent $LlvmStrip) 'clang++.exe') -KitDir $OutputDir `
$products[$product] = [ordered]@{
output = 'libmain.so'
target = $target
compile = [ordered]@{
translated = & $flagsFor $translatedEntry
product = & $flagsFor (Find-Command $kitProfile.RegistrationPattern)
runtime = & $flagsFor (Find-Command '/generated/data_sections_init\.cpp$')
asm = & $flagsFor (Find-Command 'data_sections_init_blobs[^/]*\.S$')
}
sources = $kitProfile.Sources
link = [ordered]@{
flags = $linkFlags.ToArray()
inputs = $inputs.ToArray()
lld = Get-KitLldArguments -ClangCxx (Join-Path (Split-Path -Parent $LlvmStrip) 'clang++.exe') -KitDir $OutputDir `
-Flags $linkFlags.ToArray() -Inputs $inputs.ToArray() -Slots $kitProfile.Slots -Flags $linkFlags.ToArray() -Inputs $inputs.ToArray() -Slots $kitProfile.Slots
}
}
}
# What the headset's translator needs besides the disc: the game manifest and symbol map, and # What the headset's translator needs besides the disc: the game manifest and symbol map, and
# the runtime sources it indexes for native registrations, which must be the ones compiled into # the runtime sources it indexes for native registrations, which must be the ones compiled into
@@ -389,13 +405,8 @@ function Export-QuestGameKit {
$recipe = [ordered]@{ $recipe = [ordered]@{
schema = $script:KitSchema schema = $script:KitSchema
product = $Product
output = 'libmain.so'
target = $target
runtimeIncludeFingerprint = Get-RuntimeIncludeFingerprint (Join-Path $OutputDir 'include') runtimeIncludeFingerprint = Get-RuntimeIncludeFingerprint (Join-Path $OutputDir 'include')
compile = $compile products = $products
sources = $kitProfile.Sources
link = [ordered]@{ flags = $linkFlags.ToArray(); inputs = $inputs.ToArray(); lld = $lld }
} }
$recipeJson = $recipe | ConvertTo-Json -Depth 8 -Compress $recipeJson = $recipe | ConvertTo-Json -Depth 8 -Compress
$files = Get-RelativeFiles $OutputDir | Sort-Object FullName | ForEach-Object { $files = Get-RelativeFiles $OutputDir | Sort-Object FullName | ForEach-Object {
@@ -403,7 +414,7 @@ function Export-QuestGameKit {
} }
$recipe.fingerprint = Get-StringSha256Hex ($recipeJson + "`n" + ($files -join "`n")) $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)) [IO.File]::WriteAllText((Join-Path $OutputDir 'kit.json'), ($recipe | ConvertTo-Json -Depth 8), (New-Object Text.UTF8Encoding $false))
return $recipe.fingerprint return [pscustomobject]@{ Fingerprint = $recipe.fingerprint; Products = @($products.Keys) }
} }
# The generated source lists emit-build-shards wrote into shards.cmake. # The generated source lists emit-build-shards wrote into shards.cmake.
@@ -425,7 +436,8 @@ function Invoke-QuestGameBuild {
[Parameter(Mandatory)] [string]$ClangC, [Parameter(Mandatory)] [string]$ClangC,
[Parameter(Mandatory)] [string]$Sysroot, [Parameter(Mandatory)] [string]$Sysroot,
[Parameter(Mandatory)] [string]$Ninja, [Parameter(Mandatory)] [string]$Ninja,
[Parameter(Mandatory)] [int]$TranslatedJobs [Parameter(Mandatory)] [int]$TranslatedJobs,
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$kit = ConvertTo-ForwardPath $KitDir $kit = ConvertTo-ForwardPath $KitDir
@@ -433,6 +445,10 @@ function Invoke-QuestGameBuild {
$workspace = ConvertTo-ForwardPath (Split-Path -Parent $generated) $workspace = ConvertTo-ForwardPath (Split-Path -Parent $generated)
$recipe = [IO.File]::ReadAllText("$kit/kit.json") | ConvertFrom-Json $recipe = [IO.File]::ReadAllText("$kit/kit.json") | ConvertFrom-Json
if ($recipe.schema -ne $script:KitSchema) { throw "Unsupported game kit schema $($recipe.schema)" } if ($recipe.schema -ne $script:KitSchema) { throw "Unsupported game kit schema $($recipe.schema)" }
if (-not $recipe.products.PSObject.Properties.Name.Contains($Product)) {
throw "This Quest app's game kit cannot build $Product (it has: $($recipe.products.PSObject.Properties.Name -join ', '))"
}
$productRecipe = $recipe.products.$Product
# The translation must come from the same release as the kit: its code is compiled against the # 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. # kit's runtime headers and linked with the kit's runtime objects.
$workspaceInclude = Join-Path $workspace 'runtime/include' $workspaceInclude = Join-Path $workspace 'runtime/include'
@@ -447,10 +463,10 @@ function Invoke-QuestGameBuild {
$expand = { param([string]$s) $s.Replace('{kit}', $kit).Replace('{sysroot}', (ConvertTo-ForwardPath $Sysroot)).Replace('{workspace}', $workspace) } $expand = { param([string]$s) $s.Replace('{kit}', $kit).Replace('{sysroot}', (ConvertTo-ForwardPath $Sysroot)).Replace('{workspace}', $workspace) }
foreach ($kind in 'translated', 'product', 'runtime', 'asm') { foreach ($kind in 'translated', 'product', 'runtime', 'asm') {
$lines = @($recipe.compile.$kind | ForEach-Object { ConvertTo-QuotedArgument (& $expand $_) }) $lines = @($productRecipe.compile.$kind | ForEach-Object { ConvertTo-QuotedArgument (& $expand $_) })
[IO.File]::WriteAllText("$build/$kind.rsp", ($lines -join "`n"), (New-Object Text.UTF8Encoding $false)) [IO.File]::WriteAllText("$build/$kind.rsp", ($lines -join "`n"), (New-Object Text.UTF8Encoding $false))
} }
$linkLines = @($recipe.link.flags | ForEach-Object { ConvertTo-QuotedArgument (& $expand $_) }) $linkLines = @($productRecipe.link.flags | ForEach-Object { ConvertTo-QuotedArgument (& $expand $_) })
[IO.File]::WriteAllText("$build/link.rsp", ($linkLines -join "`n"), (New-Object Text.UTF8Encoding $false)) [IO.File]::WriteAllText("$build/link.rsp", ($linkLines -join "`n"), (New-Object Text.UTF8Encoding $false))
# The Windows installer generates blob assembly for PE/COFF; every one of them is rewritten for # The Windows installer generates blob assembly for PE/COFF; every one of them is rewritten for
@@ -469,9 +485,9 @@ function Invoke-QuestGameBuild {
# file. # file.
$shards = "$generated/build_shards/shards.cmake" $shards = "$generated/build_shards/shards.cmake"
$sources = [ordered]@{} $sources = [ordered]@{}
foreach ($slot in $recipe.sources.PSObject.Properties.Name) { foreach ($slot in $productRecipe.sources.PSObject.Properties.Name) {
$slotSources = New-Object System.Collections.Generic.List[string] $slotSources = New-Object System.Collections.Generic.List[string]
foreach ($entry in $recipe.sources.$slot) { foreach ($entry in $productRecipe.sources.$slot) {
if ($entry.StartsWith('@')) { if ($entry.StartsWith('@')) {
Read-ShardList $shards $entry.Substring(1) | ForEach-Object { $slotSources.Add($_) } Read-ShardList $shards $entry.Substring(1) | ForEach-Object { $slotSources.Add($_) }
} else { } else {
@@ -482,7 +498,7 @@ function Invoke-QuestGameBuild {
} }
if ($sources.translated.Count -eq 0) { throw "No translated shards in $shards" } if ($sources.translated.Count -eq 0) { throw "No translated shards in $shards" }
foreach ($slot in $sources.Keys) { foreach ($slot in $sources.Keys) {
if ($sources[$slot].Count -eq 0) { throw "The $slot sources of a $($recipe.product) game are missing from $shards" } if ($sources[$slot].Count -eq 0) { throw "The $slot sources of a $Product game are missing from $shards" }
} }
$ninjaText = New-Object System.Text.StringBuilder $ninjaText = New-Object System.Text.StringBuilder
@@ -511,7 +527,7 @@ function Invoke-QuestGameBuild {
} }
} }
$linkInputs = New-Object System.Collections.Generic.List[string] $linkInputs = New-Object System.Collections.Generic.List[string]
foreach ($linkInput in $recipe.link.inputs) { foreach ($linkInput in $productRecipe.link.inputs) {
$slot = [regex]::Match($linkInput, '^\{game:(\w+)\}$').Groups[1].Value $slot = [regex]::Match($linkInput, '^\{game:(\w+)\}$').Groups[1].Value
if (-not $slot) { $linkInputs.Add((& $expand $linkInput)); continue } if (-not $slot) { $linkInputs.Add((& $expand $linkInput)); continue }
if (-not $objectsOf.ContainsKey($slot)) { throw "The kit recipe links a {game:$slot} slot it names no sources for" } if (-not $objectsOf.ContainsKey($slot)) { throw "The kit recipe links a {game:$slot} slot it names no sources for" }
@@ -534,33 +550,38 @@ function Invoke-QuestGameBuild {
return "$build/libmain.so" return "$build/libmain.so"
} }
# A .wcgame: the built library and what it was built from, for the headset to import. With # A .wcgame: the built library and what it was built from, for the headset to import. It can also
# -DataDir it also carries the extracted disc (DATA/...), written without compression, so a player who # carry, written without compression, the extracted disc (-DataDir, as DATA/...) and the Retro
# builds on a PC copies one file to the headset and never needs the disc image there. # Rewind pack (-ModDir, as MOD/...), so a player who builds on a PC copies one file to the headset
# and needs nothing else there.
function New-QuestGamePackage { function New-QuestGamePackage {
[CmdletBinding()] [CmdletBinding()]
param( param(
[Parameter(Mandatory)] [string]$Library, [Parameter(Mandatory)] [string]$Library,
[string]$DataDir = '', [string]$DataDir = '',
[string]$ModDir = '',
[Parameter(Mandatory)] [string]$KitDir, [Parameter(Mandatory)] [string]$KitDir,
[Parameter(Mandatory)] [string]$GameId, [Parameter(Mandatory)] [string]$GameId,
[Parameter(Mandatory)] [string]$DolSha256, [Parameter(Mandatory)] [string]$DolSha256,
[Parameter(Mandatory)] [string]$RelSha256, [Parameter(Mandatory)] [string]$RelSha256,
[Parameter(Mandatory)] [string]$BuiltBy, [Parameter(Mandatory)] [string]$BuiltBy,
[Parameter(Mandatory)] [string]$OutputPath [Parameter(Mandatory)] [string]$OutputPath,
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$recipe = [IO.File]::ReadAllText((Join-Path $KitDir 'kit.json')) | ConvertFrom-Json $recipe = [IO.File]::ReadAllText((Join-Path $KitDir 'kit.json')) | ConvertFrom-Json
if ($ModDir -and $Product -ne 'retro_rewind') { throw 'Only a Retro Rewind game carries the mod pack.' }
$game = [ordered]@{ $game = [ordered]@{
schema = $script:GameSchema schema = $script:GameSchema
profile = $recipe.product profile = $Product
gameId = $GameId gameId = $GameId
dolSha256 = $DolSha256 dolSha256 = $DolSha256
relSha256 = $RelSha256 relSha256 = $RelSha256
kitFingerprint = $recipe.fingerprint kitFingerprint = $recipe.fingerprint
library = $recipe.output library = $recipe.products.$Product.output
librarySha256 = Get-Sha256Hex $Library librarySha256 = Get-Sha256Hex $Library
includesData = [bool]$DataDir includesData = [bool]$DataDir
includesMod = [bool]$ModDir
builtBy = $BuiltBy builtBy = $BuiltBy
builtAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') builtAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
} }
@@ -572,10 +593,12 @@ function New-QuestGamePackage {
$entry = $zip.CreateEntry('game.json') $entry = $zip.CreateEntry('game.json')
$writer = New-Object IO.StreamWriter($entry.Open(), (New-Object Text.UTF8Encoding $false)) $writer = New-Object IO.StreamWriter($entry.Open(), (New-Object Text.UTF8Encoding $false))
try { $writer.Write(($game | ConvertTo-Json)) } finally { $writer.Dispose() } try { $writer.Write(($game | ConvertTo-Json)) } finally { $writer.Dispose() }
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Library, $recipe.output, 'Optimal') [void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Library, $game.library, 'Optimal')
if ($DataDir) { foreach ($tree in @{ Prefix = 'DATA'; Directory = $DataDir }, @{ Prefix = 'MOD'; Directory = $ModDir }) {
foreach ($file in Get-RelativeFiles $DataDir) { if (-not $tree.Directory) { continue }
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $file.FullName, "DATA/$($file.Relative)", 'NoCompression') foreach ($file in Get-RelativeFiles $tree.Directory) {
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$zip, $file.FullName, "$($tree.Prefix)/$($file.Relative)", 'NoCompression')
} }
} }
} finally { $zip.Dispose() } } finally { $zip.Dispose() }
+20 -11
View File
@@ -9,17 +9,25 @@ holds the Gradle project, its helper scripts, the game kit tooling
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # stages the SDL3 3.4.4 AAR once powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # stages the SDL3 3.4.4 AAR once
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # base game, debug-signed, installs over adb powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, debug-signed, installs over adb
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Flavor retroRewind # Retro Rewind product
``` ```
The translated game (the translator's `generated/` tree for **your own** One app offers both games, with a toggle on Home: Mario Kart Wii and, when your
PAL `RMCP01` disc) is passed with `-Generated <dir>`; it defaults to the translation includes the mod, Retro Rewind. The translated game (the translator's
installer workspace next to this checkout. No game data is ever part of the `generated/` tree for **your own** PAL `RMCP01` disc) is passed with
APK, and neither is any translated game code: the base APK carries a game kit, `-Generated <dir>`; it defaults to the installer workspace next to this checkout.
and your `libmain.so` is built from your own disc, either on the headset No game data is ever part of the APK, and neither is any translated game code:
(**Build on this Quest**, about half an hour) or on a PC and imported the APK carries one game kit holding a recipe per game, and your `libmain.so` is
(`Build-QuestGame.ps1 -Install`, or **Import from computer** with its `.wcgame`). built from your own disc, either on the headset (**Build on this Quest**, about
half an hour) or on a PC and imported:
```powershell
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product base -Install
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install
```
`-Mod` puts the Retro Rewind pack in the package, which is how the headset gets
it; **Import from computer** takes the same `.wcgame` from the Import folder.
Copy your disc image to the headset (for example `adb push MarioKart.iso 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 /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 and extracts it with nod, as the PC installer does. Or push an already extracted
@@ -32,8 +40,9 @@ disc to
`Config.toml`, saves and logs live in the same `WiiCompiledOpenXRVR` directory. `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): 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 **Home** picks the game with a toggle, starts it in the headset and says what is
still missing (the disc files, the game itself, or Retro Rewind's pack), and
**Settings** edits `Config.toml` (VR camera, render scale, virtual screen, **Settings** edits `Config.toml` (VR camera, render scale, virtual screen,
resolution, controllers, audio). The game itself is `QuestActivity`, in its own resolution, controllers, audio) and lists both games. The game itself is `QuestActivity`, in its own
`:game` process; `adb shell am start -n org.wiicompiled.quest/.QuestActivity` `:game` process; `adb shell am start -n org.wiicompiled.quest/.QuestActivity`
still starts it directly. still starts it directly.
+22 -43
View File
@@ -42,6 +42,13 @@ val discPins: Map<String, String> = run {
) )
} }
// Whether the translation this build is pointed at includes the Retro Rewind mod, which decides
// whether the kit can carry that product (emit-build-shards records it in shards.cmake).
val hasRetroRewindShards: Boolean = run {
val shards = File(mkwGeneratedDir ?: return@run false, "build_shards/shards.cmake")
shards.isFile && shards.readText().contains("set(MKW_HAVE_RETRO_REWIND_SHARDS ON)")
}
// android/nod-jni: nod, the disc image library the PC installer runs as nodtool, // 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 // 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). // toolchain with the aarch64-linux-android target (see docs/quest-port.md).
@@ -100,12 +107,23 @@ android {
for ((name, value) in discPins) { for ((name, value) in discPins) {
buildConfigField("String", name, "\"$value\"") buildConfigField("String", name, "\"$value\"")
} }
// The APK carries game kits, not games: the player's libmain.so is built from their own
// disc and loaded from private storage (see android/QuestGameKit.psm1). One app offers
// both products, like the PC launcher, and the launcher's toggle picks which one to play.
buildConfigField("boolean", "EXTERNAL_GAME", "true")
buildConfigField("boolean", "ON_DEVICE_BUILD", "true")
ndk { ndk {
abiFilters += "arm64-v8a" abiFilters += "arm64-v8a"
} }
externalNativeBuild { externalNativeBuild {
cmake { cmake {
// One probe per product the translation can build. Retro Rewind's exists only
// once translate-mod and emit-build-shards have run over the mod.
targets += "mkw_quest_kit_probe"
if (hasRetroRewindShards) {
targets += "mkw_quest_kit_probe_retro"
}
arguments += listOf( arguments += listOf(
"-DANDROID_STL=c++_shared", "-DANDROID_STL=c++_shared",
"-DANDROID_PLATFORM=android-29", "-DANDROID_PLATFORM=android-29",
@@ -121,33 +139,6 @@ android {
} }
} }
flavorDimensions += "profile"
productFlavors {
create("base") {
dimension = "profile"
buildConfigField("String", "MAIN_LIBRARY", "\"main\"")
buildConfigField("String", "PROFILE", "\"base\"")
// 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")
// And it carries the toolchain that builds it on the headset (Prepare-QuestToolchain.ps1).
buildConfigField("boolean", "ON_DEVICE_BUILD", "true")
externalNativeBuild { cmake { targets += "mkw_quest_kit_probe" } }
}
create("retroRewind") {
dimension = "profile"
applicationIdSuffix = ".retrorewind"
versionNameSuffix = "-retro-rewind"
buildConfigField("String", "MAIN_LIBRARY", "\"main_retro_rewind\"")
buildConfigField("String", "PROFILE", "\"retro_rewind\"")
buildConfigField("boolean", "EXTERNAL_GAME", "true")
// Retro Rewind is built on a PC and imported: building it on the headset would also
// need the mod's Code.pul and its content there, which the launcher cannot fetch yet.
buildConfigField("boolean", "ON_DEVICE_BUILD", "false")
externalNativeBuild { cmake { targets += "mkw_quest_kit_probe_retro" } }
}
}
externalNativeBuild { externalNativeBuild {
cmake { cmake {
// aurora-main needs CMake 3.25+, newer than the SDK's bundled 3.22.1; // aurora-main needs CMake 3.25+, newer than the SDK's bundled 3.22.1;
@@ -204,10 +195,6 @@ abstract class ExportQuestGameKit : DefaultTask() {
@get:Internal @get:Internal
abstract val llvmStrip: RegularFileProperty abstract val llvmStrip: RegularFileProperty
/** "base" or "retro_rewind": which product's kit this flavour carries. */
@get:Internal
abstract val product: Property<String>
@get:Inject @get:Inject
abstract val execOperations: ExecOperations abstract val execOperations: ExecOperations
@@ -219,11 +206,10 @@ abstract class ExportQuestGameKit : DefaultTask() {
@TaskAction @TaskAction
fun export() { fun export() {
val app = appDir.get().asFile val app = appDir.get().asFile
val library = if (product.get() == "base") "libmkw_quest_kit_probe.so" else "libmkw_quest_kit_probe_retro.so"
val probe = File(app, "build/intermediates/cxx").walkTopDown() val probe = File(app, "build/intermediates/cxx").walkTopDown()
.filter { it.name == library && it.parentFile.name == "arm64-v8a" } .filter { it.name == "libmkw_quest_kit_probe.so" && it.parentFile.name == "arm64-v8a" }
.maxByOrNull { it.lastModified() } .maxByOrNull { it.lastModified() }
?: throw GradleException("No $library; the native build did not produce the game kit probe") ?: 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 configuration = probe.parentFile.parentFile.parentFile // <BuildType>/<hash>
val binaryDir = File(app, ".cxx/${configuration.parentFile.name}/${configuration.name}/arm64-v8a") 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") if (!File(binaryDir, "build.ninja").isFile) throw GradleException("No CMake tree at $binaryDir")
@@ -232,7 +218,6 @@ abstract class ExportQuestGameKit : DefaultTask() {
commandLine( commandLine(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.get().asFile.path, "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.get().asFile.path,
"-CMakeBinaryDir", binaryDir.path, "-OutputDir", kitDir.path, "-LlvmStrip", llvmStrip.get().asFile.path, "-CMakeBinaryDir", binaryDir.path, "-OutputDir", kitDir.path, "-LlvmStrip", llvmStrip.get().asFile.path,
"-Product", product.get(),
) )
} }
} }
@@ -275,24 +260,19 @@ androidComponents {
onVariants { variant -> onVariants { variant ->
val host = if (System.getProperty("os.name").startsWith("Windows")) "windows-x86_64" else "linux-x86_64" val host = if (System.getProperty("os.name").startsWith("Windows")) "windows-x86_64" else "linux-x86_64"
val capitalized = variant.name.replaceFirstChar { it.uppercase() } val capitalized = variant.name.replaceFirstChar { it.uppercase() }
// Both flavours ship a kit instead of the game. AGP packages every library in the CMake // The APK ships kits instead of games. AGP packages every library in the CMake output
// output directory, so a game library left over from an earlier build would otherwise // directory, so a game library left over from an earlier build would otherwise travel in it.
// travel in the APK.
variant.packaging.jniLibs.excludes.add("**/libmain*.so") variant.packaging.jniLibs.excludes.add("**/libmain*.so")
val export = tasks.register<ExportQuestGameKit>("export${capitalized}QuestGameKit") { val export = tasks.register<ExportQuestGameKit>("export${capitalized}QuestGameKit") {
dependsOn("merge${capitalized}NativeLibs") dependsOn("merge${capitalized}NativeLibs")
appDir.set(layout.projectDirectory) appDir.set(layout.projectDirectory)
script.set(rootProject.layout.projectDirectory.file("Export-QuestGameKit.ps1")) script.set(rootProject.layout.projectDirectory.file("Export-QuestGameKit.ps1"))
product.set(if (variant.flavorName == "retroRewind") "retro_rewind" else "base")
llvmStrip.set(sdkComponents.ndkDirectory.map { llvmStrip.set(sdkComponents.ndkDirectory.map {
it.file("toolchains/llvm/prebuilt/$host/bin/llvm-strip" + if (host.startsWith("windows")) ".exe" else "") it.file("toolchains/llvm/prebuilt/$host/bin/llvm-strip" + if (host.startsWith("windows")) ".exe" else "")
}) })
} }
variant.sources.assets?.addGeneratedSourceDirectory(export, ExportQuestGameKit::outputDir) variant.sources.assets?.addGeneratedSourceDirectory(export, ExportQuestGameKit::outputDir)
// Only the base flavour builds the game on the headset, so only it carries the toolchain:
// Retro Rewind also needs the mod's own content and translation, which is a PC build for now.
if (variant.flavorName != "retroRewind") {
val prepareToolchain = tasks.register<PrepareQuestToolchain>("prepare${capitalized}QuestToolchain") { val prepareToolchain = tasks.register<PrepareQuestToolchain>("prepare${capitalized}QuestToolchain") {
script.set(rootProject.layout.projectDirectory.file("Prepare-QuestToolchain.ps1")) script.set(rootProject.layout.projectDirectory.file("Prepare-QuestToolchain.ps1"))
sources.from(script, rootProject.layout.projectDirectory.dir("toolchain")) sources.from(script, rootProject.layout.projectDirectory.dir("toolchain"))
@@ -303,7 +283,6 @@ androidComponents {
variant.sources.assets?.addGeneratedSourceDirectory(prepareToolchain, PrepareQuestToolchain::outputDir) variant.sources.assets?.addGeneratedSourceDirectory(prepareToolchain, PrepareQuestToolchain::outputDir)
} }
} }
}
kotlin { kotlin {
compilerOptions { compilerOptions {
@@ -5,8 +5,9 @@ import java.io.File
import org.json.JSONObject import org.json.JSONObject
/** /**
* The player's compiled game: libmain.so built from their own disc against the game kit this * A player's compiled game: libmain.so built from their own disc against the game kit this APK
* APK carries (assets/game_kit, see android/QuestGameKit.psm1), plus the game.json it came with. * carries (assets/game_kit, see android/QuestGameKit.psm1), plus the game.json it came with. One
* per [GameProfile], so the unmodded game and Retro Rewind can both be installed.
* *
* It lives in internal private storage, the one place Android lets an app load native code it * 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 * did not install (execution from shared or external storage is blocked). A library built for
@@ -23,11 +24,14 @@ object GameLibrary {
@Volatile @Volatile
private var cachedKitFingerprint: String? = null private var cachedKitFingerprint: String? = null
fun directory(context: Context): File = File(context.filesDir, "game/${BuildConfig.PROFILE}") fun directory(context: Context, profile: GameProfile = GameProfile.selected(context)): File =
File(context.filesDir, "game/${profile.id}")
fun library(context: Context): File = File(directory(context), LIBRARY_NAME) fun library(context: Context, profile: GameProfile = GameProfile.selected(context)): File =
File(directory(context, profile), LIBRARY_NAME)
fun manifest(context: Context): JSONObject? = File(directory(context), MANIFEST_NAME) fun manifest(context: Context, profile: GameProfile = GameProfile.selected(context)): JSONObject? =
File(directory(context, profile), MANIFEST_NAME)
.takeIf { it.isFile } .takeIf { it.isFile }
?.let { runCatching { JSONObject(it.readText()) }.getOrNull() } ?.let { runCatching { JSONObject(it.readText()) }.getOrNull() }
@@ -41,12 +45,9 @@ object GameLibrary {
return fingerprint return fingerprint
} }
fun status(context: Context): Status { fun status(context: Context, profile: GameProfile = GameProfile.selected(context)): Status {
if (!BuildConfig.EXTERNAL_GAME) { val manifest = manifest(context, profile)
return Status.Ready if (manifest == null || !library(context, profile).isFile) {
}
val manifest = manifest(context)
if (manifest == null || !library(context).isFile) {
return Status.Missing return Status.Missing
} }
return if (manifest.optString("kitFingerprint") == kitFingerprint(context)) Status.Ready else Status.Stale return if (manifest.optString("kitFingerprint") == kitFingerprint(context)) Status.Ready else Status.Stale
@@ -0,0 +1,45 @@
package org.wiicompiled.quest
import android.content.Context
import java.io.File
import org.json.JSONObject
/**
* The games this app can play, as the PC launcher offers them: the unmodded game and Retro Rewind.
*
* One APK carries a kit for each (`assets/game_kit`, see android/QuestGameKit.psm1) and the player
* keeps a built game per profile in private storage, so switching between them is a toggle rather
* than a second app. The choice is a file, not a preference, because the launcher and the game run
* in different processes and the game reads it while starting.
*/
enum class GameProfile(val id: String, val title: Int, val modPack: Boolean) {
Base("base", R.string.home_title_base, modPack = false),
RetroRewind("retro_rewind", R.string.home_title_retro_rewind, modPack = true);
companion object {
private const val SELECTION_FILE = "selected-game"
fun of(id: String?): GameProfile? = entries.firstOrNull { it.id == id }
/** The profiles this APK's kit can build and load, in launcher order. */
fun available(context: Context): List<GameProfile> {
val products = runCatching {
context.assets.open("game_kit/kit.json").use { input ->
JSONObject(input.reader().readText()).getJSONObject("products").keys().asSequence().toSet()
}
}.getOrDefault(setOf(Base.id))
return entries.filter { it.id in products }.ifEmpty { listOf(Base) }
}
/** The profile the player last chose, or the first available one. */
fun selected(context: Context): GameProfile {
val available = available(context)
val stored = runCatching { File(context.filesDir, SELECTION_FILE).readText().trim() }.getOrNull()
return of(stored)?.takeIf { it in available } ?: available.first()
}
fun select(context: Context, profile: GameProfile) {
File(context.filesDir, SELECTION_FILE).writeText(profile.id)
}
}
}
@@ -2,6 +2,7 @@ package org.wiicompiled.quest
import android.content.Context import android.content.Context
import java.io.File import java.io.File
import org.wiicompiled.quest.launcher.TomlConfig
/** /**
* Where the game's user files live on the headset. The launcher and the game * Where the game's user files live on the headset. The launcher and the game
@@ -30,9 +31,12 @@ object GameStorage {
fun modDirectory(context: Context): File = File(gameRoot(context), MOD_DIRECTORY) fun modDirectory(context: Context): File = File(gameRoot(context), MOD_DIRECTORY)
/** Whether this app needs the Retro Rewind pack and has it. Always true for the base game. */ /** The pack's own Code.pul, which is what a modded game and a mod translation both need. */
fun modContentReady(context: Context): Boolean = fun modCodePul(context: Context): File = File(modDirectory(context), "Binaries/Code.pul")
BuildConfig.PROFILE != "retro_rewind" || File(modDirectory(context), "Binaries/Code.pul").isFile
/** Whether the pack a profile needs is there. Always true for the unmodded game. */
fun modContentReady(context: Context, profile: GameProfile): Boolean =
!profile.modPack || modCodePul(context).isFile
fun configFile(context: Context): File = File(gameRoot(context), "Config.toml") fun configFile(context: Context): File = File(gameRoot(context), "Config.toml")
@@ -58,7 +62,8 @@ object GameStorage {
/** /**
* Creates the game directory and a first Config.toml with VR on and the * 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 * disc path filled in. An existing file keeps every setting it has, and only
* gains the Retro Rewind pack path when it predates it: the launcher, the
* in-headset settings panel and the player all edit it in place. * in-headset settings panel and the player all edit it in place.
*/ */
fun prepare(context: Context): File { fun prepare(context: Context): File {
@@ -75,10 +80,9 @@ object GameStorage {
"[paths]", "[paths]",
"dvd_root = \"${discDirectory(context).absolutePath}\"", "dvd_root = \"${discDirectory(context).absolutePath}\"",
) )
// The modded product reads its pack from retro_rewind_root, which sits next to DATA here. // The modded product reads its pack from retro_rewind_root, which sits next to DATA
if (BuildConfig.PROFILE == "retro_rewind") { // here. Harmless for the unmodded game, and one config serves both.
lines += "retro_rewind_root = \"${modDirectory(context).absolutePath}\"" lines += "retro_rewind_root = \"${modDirectory(context).absolutePath}\""
}
lines += listOf( lines += listOf(
"", "",
"[video]", "[video]",
@@ -90,6 +94,14 @@ object GameStorage {
"render_scale = 1.0", "render_scale = 1.0",
) )
config.writeText(lines.joinToString("\n", postfix = "\n")) config.writeText(lines.joinToString("\n", postfix = "\n"))
} else {
// A config written before this app offered Retro Rewind names no pack, and the modded
// game would find none. Only that one line is added; the rest is the player's.
val toml = TomlConfig.parse(config.readText())
if (toml.string("paths", "retro_rewind_root").isNullOrBlank()) {
toml.setString("paths", "retro_rewind_root", modDirectory(context).absolutePath)
config.writeText(toml.text())
}
} }
return gameRoot return gameRoot
} }
@@ -40,27 +40,26 @@ import org.libsdl.app.SDLSurface
*/ */
class QuestActivity : SDLActivity() { class QuestActivity : SDLActivity() {
override fun getLibraries(): Array<String> = /** The game the launcher's toggle last selected; read once, since a session plays one game. */
if (BuildConfig.EXTERNAL_GAME) arrayOf("SDL3") else arrayOf("SDL3", BuildConfig.MAIN_LIBRARY) private val profile: GameProfile by lazy { GameProfile.selected(this) }
override fun getLibraries(): Array<String> = arrayOf("SDL3")
/** /**
* The game is not part of the APK: it is the libmain.so the player built from their own disc * 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 * ([GameLibrary]) for the selected profile, loaded from private storage. Its libSDL3.so,
* libc++_shared.so dependencies resolve against the ones this APK installed. * libpng16.so and libc++_shared.so dependencies resolve against the ones this APK installed.
*/ */
override fun loadLibraries() { override fun loadLibraries() {
super.loadLibraries() super.loadLibraries()
if (BuildConfig.EXTERNAL_GAME) {
// SDLActivity reports a failed load in its own error dialog and never starts the game. // SDLActivity reports a failed load in its own error dialog and never starts the game.
if (GameLibrary.status(this) != GameLibrary.Status.Ready) { if (GameLibrary.status(this, profile) != GameLibrary.Status.Ready) {
throw UnsatisfiedLinkError(getString(R.string.game_not_installed)) throw UnsatisfiedLinkError(getString(R.string.game_not_installed))
} }
System.load(GameLibrary.library(this).absolutePath) System.load(GameLibrary.library(this, profile).absolutePath)
}
} }
override fun getMainSharedObject(): String = override fun getMainSharedObject(): String = GameLibrary.library(this, profile).absolutePath
if (BuildConfig.EXTERNAL_GAME) GameLibrary.library(this).absolutePath else super.getMainSharedObject()
override fun createSDLSurface(context: Context): SDLSurface = QuestSurface(context) override fun createSDLSurface(context: Context): SDLSurface = QuestSurface(context)
@@ -60,6 +60,14 @@ object BuildRecipe {
Regex("""\n\s*entry_points:\s*\n\s*-\s*(0x[0-9A-Fa-f]{8})""").find(manifest)?.groupValues?.get(1) Regex("""\n\s*entry_points:\s*\n\s*-\s*(0x[0-9A-Fa-f]{8})""").find(manifest)?.groupValues?.get(1)
?: throw IllegalArgumentException("recomp.yml names no translation entry point") ?: throw IllegalArgumentException("recomp.yml names no translation entry point")
/**
* Where recomp.yml's Retro Rewind profile expects the mod, relative to the workspace. A base
* translation only accounts for a mod whose Code.pul is there.
*/
fun modRoot(manifest: String): String =
Regex("""\n\s*mod_root:\s*(\S+)""").find(manifest)?.groupValues?.get(1)
?: throw IllegalArgumentException("recomp.yml names no Retro Rewind mod_root")
/** /**
* The blob assembly for ELF, as runtime/cmake/PublicProducts.cmake rewrites a Windows-generated * The blob assembly for ELF, as runtime/cmake/PublicProducts.cmake rewrites a Windows-generated
* one. The headset's translator already writes ELF sections, so this normally changes nothing. * one. The headset's translator already writes ELF sections, so this normally changes nothing.
@@ -24,6 +24,7 @@ import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.wiicompiled.quest.BuildConfig import org.wiicompiled.quest.BuildConfig
import org.wiicompiled.quest.GameLibrary import org.wiicompiled.quest.GameLibrary
import org.wiicompiled.quest.GameProfile
import org.wiicompiled.quest.GameStorage import org.wiicompiled.quest.GameStorage
/** /**
@@ -53,7 +54,7 @@ object GameBuild {
} }
private const val TAG = "WiiCompiledLauncher" private const val TAG = "WiiCompiledLauncher"
private const val KIT_SCHEMA = 2 private const val KIT_SCHEMA = 3
private const val TOOLCHAIN_ASSETS = "quest_toolchain" private const val TOOLCHAIN_ASSETS = "quest_toolchain"
private const val KIT_ASSETS = "game_kit" private const val KIT_ASSETS = "game_kit"
private const val MARKER = ".complete" private const val MARKER = ".complete"
@@ -65,8 +66,8 @@ object GameBuild {
private const val EXPECTED_TRANSLATION_SECONDS = 600.0 private const val EXPECTED_TRANSLATION_SECONDS = 600.0
private const val COMPILE_MEMORY_BYTES = 700L * 1024 * 1024 private const val COMPILE_MEMORY_BYTES = 700L * 1024 * 1024
/** Null on success, otherwise the message to show. */ /** Builds [profile]'s game. Null on success, otherwise the message to show. */
fun run(context: Context, reporter: Reporter, cancelled: () -> Boolean, finishing: () -> Unit): String? { fun run(context: Context, profile: GameProfile, reporter: Reporter, cancelled: () -> Boolean, finishing: () -> Unit): String? {
if (!BuildConfig.ON_DEVICE_BUILD) { if (!BuildConfig.ON_DEVICE_BUILD) {
return "This app cannot build its game on the headset. Build it on a computer and use Import from computer." return "This app cannot build its game on the headset. Build it on a computer and use Import from computer."
} }
@@ -74,6 +75,10 @@ object GameBuild {
if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) { if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) {
return "The game files (DATA) are needed to build the game. Select your disc image first." return "The game files (DATA) are needed to build the game. Select your disc image first."
} }
if (!GameStorage.modContentReady(context, profile)) {
return "Retro Rewind needs its pack before it can be built. Import a Retro Rewind game file that " +
"carries it, or copy the RetroRewind6 folder to ${GameStorage.modDirectory(context).absolutePath}."
}
GameFiles.validateData(disc)?.let { return it } GameFiles.validateData(disc)?.let { return it }
val root = File(context.filesDir, "build") val root = File(context.filesDir, "build")
root.mkdirs() root.mkdirs()
@@ -85,7 +90,7 @@ object GameBuild {
val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val log = BuildLog(File(GameStorage.logsDirectory(context), "build_$stamp.log")) val log = BuildLog(File(GameStorage.logsDirectory(context), "build_$stamp.log"))
try { try {
return Build(context, root, disc, reporter, cancelled, finishing, log).run() return Build(context, profile, root, disc, reporter, cancelled, finishing, log).run()
} catch (e: InterruptedIOException) { } catch (e: InterruptedIOException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
@@ -99,6 +104,7 @@ object GameBuild {
private class Build( private class Build(
val context: Context, val context: Context,
val profile: GameProfile,
val root: File, val root: File,
val disc: File, val disc: File,
val reporter: Reporter, val reporter: Reporter,
@@ -109,24 +115,26 @@ object GameBuild {
val toolchain = File(root, "toolchain") val toolchain = File(root, "toolchain")
val ndk = File(root, "ndk") val ndk = File(root, "ndk")
val kit = File(root, "kit") val kit = File(root, "kit")
val work = File(root, GameLibrary.directory(context).name) val work = File(root, profile.id)
val workspace = File(work, "ws") val workspace = File(work, "ws")
val objects = File(work, "obj") val objects = File(work, "obj")
val temporary = File(root, "tmp") val temporary = File(root, "tmp")
val failure = AtomicReference<String?>(null) val failure = AtomicReference<String?>(null)
fun run(): String? { fun run(): String? {
log.line("WiiCompiled Quest ${BuildConfig.VERSION_NAME} building ${BuildConfig.PROFILE} on this headset") log.line("WiiCompiled Quest ${BuildConfig.VERSION_NAME} building ${profile.id} on this headset")
report(0, Step.Prepare) report(0, Step.Prepare)
val toolchainManifest = JSONObject(context.assets.open("$TOOLCHAIN_ASSETS/toolchain.json").reader().use { it.readText() }) val toolchainManifest = JSONObject(context.assets.open("$TOOLCHAIN_ASSETS/toolchain.json").reader().use { it.readText() })
unpackToolchain(toolchainManifest) unpackToolchain(toolchainManifest)
report(30, Step.Prepare) report(30, Step.Prepare)
val kitFingerprint = GameLibrary.kitFingerprint(context) ?: return "This app carries no game kit." val kitFingerprint = GameLibrary.kitFingerprint(context) ?: return "This app carries no game kit."
unpackKit(kitFingerprint) unpackKit(kitFingerprint)
val recipe = JSONObject(File(kit, "kit.json").readText()) val kitJson = JSONObject(File(kit, "kit.json").readText())
if (recipe.optInt("schema") != KIT_SCHEMA) { if (kitJson.optInt("schema") != KIT_SCHEMA) {
return "This app's game kit is version ${recipe.optInt("schema")}, which this builder does not know." return "This app's game kit is version ${kitJson.optInt("schema")}, which this builder does not know."
} }
val recipe = kitJson.getJSONObject("products").optJSONObject(profile.id)
?: return "This app's game kit cannot build ${profile.id}."
report(40, Step.Prepare) report(40, Step.Prepare)
downloadNdk(JSONObject(context.assets.open("$TOOLCHAIN_ASSETS/${toolchainManifest.getString("ndk")}").reader().use { it.readText() })) downloadNdk(JSONObject(context.assets.open("$TOOLCHAIN_ASSETS/${toolchainManifest.getString("ndk")}").reader().use { it.readText() }))
@@ -141,8 +149,9 @@ object GameBuild {
log, log,
cancelled = { cancelled() || failure.get() != null }, cancelled = { cancelled() || failure.get() != null },
) )
translate(tools, "$kitFingerprint ${toolchainManifest.getString("fingerprint")}")?.let { return it } val buildIdentity = "$kitFingerprint ${toolchainManifest.getString("fingerprint")}"
val objectsBySlot = compile(tools, recipe, toolchainManifest.getString("fingerprint")) translate(tools, buildIdentity)?.let { return it }
val objectsBySlot = compile(tools, recipe, buildIdentity)
failure.get()?.let { return it } failure.get()?.let { return it }
val library = link(tools, recipe, objectsBySlot) ?: return failure.get() ?: "Linking the game failed." val library = link(tools, recipe, objectsBySlot) ?: return failure.get() ?: "Linking the game failed."
@@ -271,7 +280,10 @@ object GameBuild {
fun translate(tools: ToolProcess, identity: String): String? { fun translate(tools: ToolProcess, identity: String): String? {
val generated = File(workspace, "generated") val generated = File(workspace, "generated")
val provenance = File(generated, "translation-provenance.txt") val provenance = File(generated, "translation-provenance.txt")
val expected = "$identity ${BuildConfig.DISC_DOL_SHA256} ${BuildConfig.DISC_REL_SHA256}" // A modded game needs a base translation that knows this Code.pul, so the pack's own
// identity is part of what the stored translation is reused for.
val modIdentity = if (profile.modPack) BuildRecipe.hex(sha256(GameStorage.modCodePul(context))) else ""
val expected = "$identity ${profile.id} ${BuildConfig.DISC_DOL_SHA256} ${BuildConfig.DISC_REL_SHA256} $modIdentity"
val shards = File(generated, "build_shards/shards.cmake") val shards = File(generated, "build_shards/shards.cmake")
if (provenance.isFile && provenance.readText() == expected && shards.isFile) { if (provenance.isFile && provenance.readText() == expected && shards.isFile) {
log.line("Reusing the translation of an earlier build") log.line("Reusing the translation of an earlier build")
@@ -286,9 +298,17 @@ object GameBuild {
File(disc, DiscChecks.DOL_PATH).copyTo(File(workspace, "Assets/main.dol"), overwrite = true) File(disc, DiscChecks.DOL_PATH).copyTo(File(workspace, "Assets/main.dol"), overwrite = true)
File(disc, DiscChecks.REL_PATH).copyTo(File(workspace, "Assets/StaticR.rel"), overwrite = true) File(disc, DiscChecks.REL_PATH).copyTo(File(workspace, "Assets/StaticR.rel"), overwrite = true)
val manifest = "projects/mkwii/recomp.yml" val manifest = "projects/mkwii/recomp.yml"
val entryPoint = BuildRecipe.entryPoint(File(workspace, manifest).readText()) val manifestText = File(workspace, manifest).readText()
val entryPoint = BuildRecipe.entryPoint(manifestText)
// The base translation only knows a mod's patches when the profile's Code.pul sits
// where recomp.yml's mod_root says, so the pack's copy is staged there first.
val steps = if (profile.modPack) 4 else 3
if (profile.modPack) {
val modRoot = File(workspace, BuildRecipe.modRoot(manifestText))
GameStorage.modCodePul(context).copyTo(File(modRoot, "Binaries/Code.pul"), overwrite = true)
}
report(50, Step.Translate, 1, 3) report(50, Step.Translate, 1, steps)
val timing = Regex("""\(t=([0-9.]+)s\)""") val timing = Regex("""\(t=([0-9.]+)s\)""")
translator( translator(
tools, "translating the game", tools, "translating the game",
@@ -299,22 +319,66 @@ object GameBuild {
) { line -> ) { line ->
timing.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { seconds -> timing.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { seconds ->
val fraction = minOf(seconds / EXPECTED_TRANSLATION_SECONDS, 0.97) val fraction = minOf(seconds / EXPECTED_TRANSLATION_SECONDS, 0.97)
reporter.update(50 + (320 * fraction).toInt(), Step.Translate, 1, 3) reporter.update(50 + (300 * fraction).toInt(), Step.Translate, 1, steps)
} }
}?.let { return it } }?.let { return it }
report(370, Step.Translate, 2, 3)
translator(tools, "generating the game data", "generate-data-init", "--project", manifest, "--target-os", "android")?.let { return it } // Retro Rewind's own code: its Code.pul translated against the base translation, as
report(385, Step.Translate, 3, 3) // Launcher/LocalBuild.ps1 does on a PC. Online play needs a payload this cannot fetch.
val modOutput = "build/mods/retro_rewind_full_cpp"
if (profile.modPack) {
report(350, Step.Translate, 2, steps)
translator( translator(
tools, "preparing the build", tools, "creating the base manifest",
"emit-base-manifest", "--project", manifest, "--out", "build/base",
"--functions-dir", "generated/functions",
"--translation-output-metadata", "generated/base_translation_output.json", "--region", "P",
)?.let { return it }
translator(
tools, "translating Retro Rewind",
"translate-mod", "--project", manifest, "--profile", "retro-rewind",
"--base-manifest", "build/base/mkwii_base_manifest.json",
"--base-translation-output-metadata", "generated/base_translation_output.json",
"--code-pul", GameStorage.modCodePul(context).absolutePath,
"--mod-root", GameStorage.modDirectory(context).absolutePath,
"--mod-name", "Retro Rewind", "--region", "P", "--out", modOutput,
"--prefer-cached-inputs", "--emit-cpp", "--skip-retro-wfc",
"--threads", TRANSLATOR_THREADS.toString(),
)?.let { return it }
}
report(370, Step.Translate, steps - 1, steps)
translator(tools, "generating the game data", "generate-data-init", "--project", manifest, "--target-os", "android")?.let { return it }
report(385, Step.Translate, steps, steps)
val shardArguments = mutableListOf(
"emit-build-shards", "--project", manifest, "--base-metadata", "generated/base_translation_output.json", "emit-build-shards", "--project", manifest, "--base-metadata", "generated/base_translation_output.json",
"--base-functions-dir", "generated/functions", "--native-source-dir", "runtime/src", "--out", "generated/build_shards", "--base-functions-dir", "generated/functions", "--native-source-dir", "runtime/src", "--out", "generated/build_shards",
)?.let { return it } )
if (profile.modPack) {
shardArguments += listOf(
"--resolved-profile", "$modOutput/resolved_dispatch_profile.json",
"--retro-cpp-dir", "$modOutput/cpp",
)
}
translator(tools, "preparing the build", *shardArguments.toTypedArray())?.let { return it }
provenance.writeText(expected) provenance.writeText(expected)
report(400, Step.Translate, 3, 3) report(400, Step.Translate, steps, steps)
return null return null
} }
fun sha256(file: File): ByteArray {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(1 shl 20)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest()
}
fun translator(tools: ToolProcess, what: String, vararg arguments: String, onLine: (String) -> Unit = {}): String? { fun translator(tools: ToolProcess, what: String, vararg arguments: String, onLine: (String) -> Unit = {}): String? {
log.line("== translator ${arguments.first()}") log.line("== translator ${arguments.first()}")
val result = tools.run( val result = tools.run(
@@ -332,11 +396,12 @@ object GameBuild {
} }
/** Compiles every generated source; returns their objects by link slot (runtime, product, translated). */ /** Compiles every generated source; returns their objects by link slot (runtime, product, translated). */
fun compile(tools: ToolProcess, recipe: JSONObject, toolchainFingerprint: String): Map<String, List<String>> { // The identity is the kit's fingerprint and the toolchain's: the kit is fingerprinted as a
// whole, and each game already compiles in its own work directory.
fun compile(tools: ToolProcess, recipe: JSONObject, identity: String): Map<String, List<String>> {
val generated = File(workspace, "generated") val generated = File(workspace, "generated")
val shards = File(generated, "build_shards/shards.cmake").readText() val shards = File(generated, "build_shards/shards.cmake").readText()
val stamp = File(objects, ".stamp") val stamp = File(objects, ".stamp")
val identity = "${recipe.getString("fingerprint")} $toolchainFingerprint"
if (stamp.takeIf { it.isFile }?.readText() != identity) { if (stamp.takeIf { it.isFile }?.readText() != identity) {
objects.deleteRecursively() objects.deleteRecursively()
objects.mkdirs() objects.mkdirs()
@@ -481,7 +546,7 @@ object GameBuild {
} }
fun install(library: File, kitFingerprint: String): String? { fun install(library: File, kitFingerprint: String): String? {
val destination = GameLibrary.directory(context) val destination = GameLibrary.directory(context, profile)
val staging = File(destination.parentFile, "${destination.name}.building") val staging = File(destination.parentFile, "${destination.name}.building")
staging.deleteRecursively() staging.deleteRecursively()
staging.mkdirs() staging.mkdirs()
@@ -500,7 +565,7 @@ object GameBuild {
val builtAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date()) val builtAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date())
val manifest = JSONObject() val manifest = JSONObject()
.put("schema", 1) .put("schema", 1)
.put("profile", BuildConfig.PROFILE) .put("profile", profile.id)
.put("gameId", BuildConfig.DISC_GAME_ID) .put("gameId", BuildConfig.DISC_GAME_ID)
.put("dolSha256", BuildConfig.DISC_DOL_SHA256) .put("dolSha256", BuildConfig.DISC_DOL_SHA256)
.put("relSha256", BuildConfig.DISC_REL_SHA256) .put("relSha256", BuildConfig.DISC_REL_SHA256)
@@ -15,22 +15,25 @@ import java.util.zip.ZipInputStream
import org.json.JSONObject import org.json.JSONObject
import org.wiicompiled.quest.BuildConfig import org.wiicompiled.quest.BuildConfig
import org.wiicompiled.quest.GameLibrary import org.wiicompiled.quest.GameLibrary
import org.wiicompiled.quest.GameProfile
import org.wiicompiled.quest.GameStorage import org.wiicompiled.quest.GameStorage
/** /**
* A .wcgame built on a PC (android/Build-QuestGame.ps1, the PC launcher's "Build for Quest") * 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 * becomes one of this headset's games: libmain.so and game.json, plus DATA when the package
* the extracted disc. * carries the extracted disc and the Retro Rewind pack when it carries that.
* *
* The package is only accepted for the disc and app it was built for: game.json must name the * The package says which game it is (`profile`), so importing one installs that game and leaves
* pinned game and hashes, the kit this APK carries, and a library whose hash matches the bytes * the other alone. It is only accepted for the disc and app it was built for: game.json must name
* in the package. Both parts are staged and checked before either replaces what is installed. * the pinned game and hashes, the kit this APK carries, and a library whose hash matches the bytes
* in the package. Every part is staged and checked before any of it replaces what is installed.
*/ */
object GamePackageImport { object GamePackageImport {
private const val TAG = "WiiCompiledLauncher" private const val TAG = "WiiCompiledLauncher"
private const val SCHEMA = 1 private const val SCHEMA = 1
private const val DATA_PREFIX = "DATA/" private const val DATA_PREFIX = "DATA/"
private const val MOD_PREFIX = "MOD/"
private const val FREE_SPACE_MARGIN = 256L * 1024 * 1024 private const val FREE_SPACE_MARGIN = 256L * 1024 * 1024
/** Null on success, otherwise the message to show. */ /** Null on success, otherwise the message to show. */
@@ -42,21 +45,37 @@ object GamePackageImport {
return "Not enough free space: importing needs ${GameFiles.gigabytes(total + FREE_SPACE_MARGIN)}, and ${GameFiles.gigabytes(available)} is free." return "Not enough free space: importing needs ${GameFiles.gigabytes(total + FREE_SPACE_MARGIN)}, and ${GameFiles.gigabytes(available)} is free."
} }
val libraryDir = GameLibrary.directory(context) // The game's destination is only known once game.json has been read, so the library is
val libraryStaging = File(libraryDir.parentFile, "${libraryDir.name}.importing") // staged under a name of its own first.
val libraryStaging = File(context.filesDir, "game/.importing")
val dataStaging = File(gameRoot, "DATA.importing") val dataStaging = File(gameRoot, "DATA.importing")
libraryStaging.deleteRecursively() val modStaging = File(gameRoot, "${GameStorage.MOD_DIRECTORY}.importing")
dataStaging.deleteRecursively() for (staging in listOf(libraryStaging, dataStaging, modStaging)) staging.deleteRecursively()
try { try {
libraryStaging.mkdirs() libraryStaging.mkdirs()
var manifestText: String? = null var manifestText: String? = null
var librarySha256: String? = null var librarySha256: String? = null
var hasData = false var hasData = false
var hasMod = false
val counted = CountingInputStream(FileInputStream(descriptor.fileDescriptor)) val counted = CountingInputStream(FileInputStream(descriptor.fileDescriptor))
ZipInputStream(BufferedInputStream(counted, 1 shl 20)).use { zip -> ZipInputStream(BufferedInputStream(counted, 1 shl 20)).use { zip ->
val buffer = ByteArray(1 shl 20) val buffer = ByteArray(1 shl 20)
var lastReport = 0L var lastReport = 0L
fun extract(target: File) {
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")
}
}
}
}
while (true) { while (true) {
val entry = zip.nextEntry ?: break val entry = zip.nextEntry ?: break
val name = entry.name val name = entry.name
@@ -80,19 +99,11 @@ object GamePackageImport {
} }
name.startsWith(DATA_PREFIX) -> { name.startsWith(DATA_PREFIX) -> {
hasData = true hasData = true
val target = File(dataStaging, name.removePrefix(DATA_PREFIX)) extract(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")
}
}
} }
name.startsWith(MOD_PREFIX) -> {
hasMod = true
extract(File(modStaging, name.removePrefix(MOD_PREFIX)))
} }
} }
if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled") if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled")
@@ -102,24 +113,34 @@ object GamePackageImport {
finishing() finishing()
val manifestJson = manifestText?.let { runCatching { JSONObject(it) }.getOrNull() } val manifestJson = manifestText?.let { runCatching { JSONObject(it) }.getOrNull() }
?: return "This is not a WiiCompiled game file (game.json is missing)." ?: return "This is not a WiiCompiled game file (game.json is missing)."
val profile = GameProfile.of(manifestJson.optString("profile"))
?.takeIf { it in GameProfile.available(context) }
?: return "This game file is for ${manifestJson.optString("profile")}, which this app does not carry a kit for."
manifestError(context, manifestJson, librarySha256)?.let { return it } manifestError(context, manifestJson, librarySha256)?.let { return it }
if (hasData) { if (hasData) {
GameFiles.validateData(dataStaging)?.let { return it } GameFiles.validateData(dataStaging)?.let { return it }
} else if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) { } 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." return "This game file has no game files (DATA). Build it again with the disc files included, or select your disc image first."
} }
if (hasMod && !File(modStaging, "Binaries/Code.pul").isFile) {
return "The Retro Rewind pack in this game file is incomplete (no Binaries/Code.pul)."
}
File(libraryStaging, GameLibrary.MANIFEST_NAME).writeText(manifestText!!) File(libraryStaging, GameLibrary.MANIFEST_NAME).writeText(manifestText!!)
if (hasData) { if (hasData) {
GameFiles.replace(GameStorage.discDirectory(context), dataStaging)?.let { return it } GameFiles.replace(GameStorage.discDirectory(context), dataStaging)?.let { return it }
} }
if (hasMod) {
GameFiles.replace(GameStorage.modDirectory(context), modStaging)?.let { return it }
}
val libraryDir = GameLibrary.directory(context, profile)
libraryDir.parentFile?.mkdirs() libraryDir.parentFile?.mkdirs()
GameFiles.replace(libraryDir, libraryStaging)?.let { return it } GameFiles.replace(libraryDir, libraryStaging)?.let { return it }
Log.i(TAG, "Imported the game built by ${manifestJson.optString("builtBy")} at ${manifestJson.optString("builtAt")}") Log.i(TAG, "Imported ${profile.id} built by ${manifestJson.optString("builtBy")} at ${manifestJson.optString("builtAt")}")
GameProfile.select(context, profile)
return null return null
} finally { } finally {
libraryStaging.deleteRecursively() for (staging in listOf(libraryStaging, dataStaging, modStaging)) staging.deleteRecursively()
dataStaging.deleteRecursively()
} }
} }
@@ -127,9 +148,6 @@ object GamePackageImport {
if (manifest.optInt("schema") != SCHEMA) { 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." 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) || if (!manifest.optString("gameId").equals(BuildConfig.DISC_GAME_ID, ignoreCase = true) ||
!manifest.optString("dolSha256").equals(BuildConfig.DISC_DOL_SHA256, ignoreCase = true) || !manifest.optString("dolSha256").equals(BuildConfig.DISC_DOL_SHA256, ignoreCase = true) ||
!manifest.optString("relSha256").equals(BuildConfig.DISC_REL_SHA256, ignoreCase = true) !manifest.optString("relSha256").equals(BuildConfig.DISC_REL_SHA256, ignoreCase = true)
@@ -7,6 +7,7 @@ import android.os.Looper
import android.util.Log import android.util.Log
import java.io.File import java.io.File
import java.io.InterruptedIOException import java.io.InterruptedIOException
import org.wiicompiled.quest.GameProfile
/** /**
* The long-running steps that make the game playable from what the player owns, one at a time, * The long-running steps that make the game playable from what the player owns, one at a time,
@@ -87,7 +88,7 @@ object GameSetup {
} }
/** The whole task, on a worker thread. Always ends in Done, Failed or Cancelled. [uri] is null only for a build. */ /** The whole task, on a worker thread. Always ends in Done, Failed or Cancelled. [uri] is null only for a build. */
fun run(context: Context, task: Task, uri: Uri?, deleteSource: Boolean) { fun run(context: Context, task: Task, uri: Uri?, deleteSource: Boolean, profile: GameProfile = GameProfile.selected(context)) {
val progress = Progress { done, total -> val progress = Progress { done, total ->
publish(State.Working(task, done, total)) publish(State.Working(task, done, total))
!cancelRequested !cancelRequested
@@ -99,7 +100,7 @@ object GameSetup {
publish(State.Working(task, permille.toLong(), 1000, step, done, total)) publish(State.Working(task, permille.toLong(), 1000, step, done, total))
!cancelRequested !cancelRequested
} }
GameBuild.run(context, reporter, cancelled = { cancelRequested }, finishing) GameBuild.run(context, profile, reporter, cancelled = { cancelRequested }, finishing)
} else { } else {
uri?.let { context.contentResolver.openFileDescriptor(it, "r") }?.use { descriptor -> uri?.let { context.contentResolver.openFileDescriptor(it, "r") }?.use { descriptor ->
when (task) { when (task) {
@@ -13,6 +13,7 @@ import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
import kotlin.concurrent.thread import kotlin.concurrent.thread
import org.wiicompiled.quest.GameProfile
import org.wiicompiled.quest.R import org.wiicompiled.quest.R
/** /**
@@ -51,13 +52,14 @@ class GameSetupService : Service() {
} }
GameSetup.addListener(listener) GameSetup.addListener(listener)
val deleteSource = intent?.getBooleanExtra(EXTRA_DELETE_SOURCE, false) ?: false val deleteSource = intent?.getBooleanExtra(EXTRA_DELETE_SOURCE, false) ?: false
val profile = GameProfile.of(intent?.getStringExtra(EXTRA_PROFILE)) ?: GameProfile.selected(this)
val wakeLock = getSystemService(PowerManager::class.java) val wakeLock = getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WiiCompiled:GameSetup") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WiiCompiled:GameSetup")
wakeLock.acquire(if (task == GameSetup.Task.BuildGame) BUILD_WAKE_LOCK_TIMEOUT_MS else WAKE_LOCK_TIMEOUT_MS) wakeLock.acquire(if (task == GameSetup.Task.BuildGame) BUILD_WAKE_LOCK_TIMEOUT_MS else WAKE_LOCK_TIMEOUT_MS)
thread(name = "GameSetup") { thread(name = "GameSetup") {
try { try {
GameSetup.run(applicationContext, task, uri, deleteSource) GameSetup.run(applicationContext, task, uri, deleteSource, profile)
} finally { } finally {
uri?.let(::releaseReadPermission) uri?.let(::releaseReadPermission)
if (wakeLock.isHeld) wakeLock.release() if (wakeLock.isHeld) wakeLock.release()
@@ -115,21 +117,24 @@ class GameSetupService : Service() {
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val NOTIFY_INTERVAL_MS = 1000L private const val NOTIFY_INTERVAL_MS = 1000L
private const val EXTRA_TASK = "task" private const val EXTRA_TASK = "task"
private const val EXTRA_PROFILE = "profile"
private const val EXTRA_DELETE_SOURCE = "deleteSource" private const val EXTRA_DELETE_SOURCE = "deleteSource"
// Longer than any real task; only a hung run would reach it. // Longer than any real task; only a hung run would reach it.
private const val WAKE_LOCK_TIMEOUT_MS = 60L * 60 * 1000 private const val WAKE_LOCK_TIMEOUT_MS = 60L * 60 * 1000
private const val BUILD_WAKE_LOCK_TIMEOUT_MS = 3L * 60 * 60 * 1000 private const val BUILD_WAKE_LOCK_TIMEOUT_MS = 3L * 60 * 60 * 1000
/** Builds [profile]'s game from DATA on this headset ([GameBuild]). */
fun startBuild(context: Context, profile: GameProfile) {
val intent = Intent(context, GameSetupService::class.java)
.putExtra(EXTRA_TASK, GameSetup.Task.BuildGame.name)
.putExtra(EXTRA_PROFILE, profile.id)
context.startForegroundService(intent)
}
/** /**
* Starts [task] on [uri]: a document the player picked, or a file in the Import folder, * Starts [task] on [uri]: a document the player picked, or a file in the Import folder,
* which [deleteSource] removes once it has been imported. * which [deleteSource] removes once it has been imported.
*/ */
/** Builds the game from DATA on this headset ([GameBuild]). */
fun startBuild(context: Context) {
val intent = Intent(context, GameSetupService::class.java).putExtra(EXTRA_TASK, GameSetup.Task.BuildGame.name)
context.startForegroundService(intent)
}
fun start(context: Context, task: GameSetup.Task, uri: Uri, deleteSource: Boolean = false) { fun start(context: Context, task: GameSetup.Task, uri: Uri, deleteSource: Boolean = false) {
if (uri.scheme == "content") { if (uri.scheme == "content") {
try { try {
@@ -11,12 +11,15 @@ import android.os.Bundle
import android.text.format.Formatter import android.text.format.Formatter
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import org.wiicompiled.quest.BuildConfig import org.wiicompiled.quest.BuildConfig
import org.wiicompiled.quest.GameLibrary import org.wiicompiled.quest.GameLibrary
import org.wiicompiled.quest.GameProfile
import org.wiicompiled.quest.GameStorage import org.wiicompiled.quest.GameStorage
import org.wiicompiled.quest.QuestActivity import org.wiicompiled.quest.QuestActivity
import org.wiicompiled.quest.R import org.wiicompiled.quest.R
@@ -56,8 +59,14 @@ class LauncherActivity : Activity() {
private lateinit var dataBanner: View private lateinit var dataBanner: View
private lateinit var dataBannerIcon: ImageView private lateinit var dataBannerIcon: ImageView
private lateinit var dataBannerText: TextView private lateinit var dataBannerText: TextView
private lateinit var homeTitle: TextView
private lateinit var gameToggle: LinearLayout
private lateinit var settings: SettingsPage private lateinit var settings: SettingsPage
/** The games this APK carries a kit for, and the one the player picked. */
private val profiles: List<GameProfile> by lazy { GameProfile.available(this) }
private var profile = GameProfile.Base
private var page = Page.Home private var page = Page.Home
private var launching = false private var launching = false
private var trailsAway = true private var trailsAway = true
@@ -99,9 +108,9 @@ class LauncherActivity : Activity() {
dataBannerIcon = findViewById(R.id.home_data_banner_icon) dataBannerIcon = findViewById(R.id.home_data_banner_icon)
dataBannerText = findViewById(R.id.home_data_banner_text) dataBannerText = findViewById(R.id.home_data_banner_text)
findViewById<TextView>(R.id.home_title).setText( homeTitle = findViewById(R.id.home_title)
if (BuildConfig.PROFILE == "retro_rewind") R.string.home_title_retro_rewind else R.string.home_title_base, gameToggle = findViewById(R.id.home_game_toggle)
) buildGameToggle()
findViewById<TextView>(R.id.launcher_version).text = getString(R.string.launcher_version, BuildConfig.VERSION_NAME) findViewById<TextView>(R.id.launcher_version).text = getString(R.string.launcher_version, BuildConfig.VERSION_NAME)
settings = SettingsPage( settings = SettingsPage(
@@ -131,13 +140,15 @@ class LauncherActivity : Activity() {
!GameSetup.isRunning && GameStorage.discStatus(this) == GameStorage.DiscStatus.Ready !GameSetup.isRunning && GameStorage.discStatus(this) == GameStorage.DiscStatus.Ready
) { ) {
Log.i(TAG, "Starting a game build requested over adb") Log.i(TAG, "Starting a game build requested over adb")
GameSetupService.startBuild(this) GameSetupService.startBuild(this, GameProfile.selected(this))
} }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
launching = false launching = false
// An import can install the other game and select it, so the toggle follows the file.
profile = GameProfile.selected(this)
importDroppedPackage() importDroppedPackage()
setupKind = GameSetup.state.javaClass setupKind = GameSetup.state.javaClass
refresh() refresh()
@@ -198,7 +209,11 @@ class LauncherActivity : Activity() {
private fun refreshHome() { private fun refreshHome() {
val disc = GameStorage.discDirectory(this).absolutePath val disc = GameStorage.discDirectory(this).absolutePath
val discStatus = GameStorage.discStatus(this) val discStatus = GameStorage.discStatus(this)
val gameStatus = GameLibrary.status(this) val gameStatus = GameLibrary.status(this, profile)
homeTitle.setText(profile.title)
for ((index, entry) in profiles.withIndex()) {
gameToggle.getChildAt(index).isSelected = entry == profile
}
val running = isGameRunning() val running = isGameRunning()
val setup = GameSetup.state val setup = GameSetup.state
val settingUp = GameSetup.isRunning val settingUp = GameSetup.isRunning
@@ -211,7 +226,7 @@ class LauncherActivity : Activity() {
mainAction = when { mainAction = when {
running -> Action.Resume running -> Action.Resume
discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc
gameStatus != GameLibrary.Status.Ready -> if (BuildConfig.ON_DEVICE_BUILD) Action.BuildGame else Action.ImportGame gameStatus != GameLibrary.Status.Ready -> Action.BuildGame
else -> Action.Play else -> Action.Play
} }
secondaryAction = when { secondaryAction = when {
@@ -280,14 +295,12 @@ class LauncherActivity : Activity() {
setup is GameSetup.State.Failed && building -> showBanner(getString(R.string.home_build_failed, setup.message), warning = true) setup is GameSetup.State.Failed && building -> showBanner(getString(R.string.home_build_failed, setup.message), warning = true)
setup is GameSetup.State.Failed -> showBanner(getString(R.string.home_setup_failed, setup.message), warning = true) 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) discStatus == GameStorage.DiscStatus.Incomplete -> showBanner(getString(R.string.home_data_incomplete, disc), warning = true)
!GameStorage.modContentReady(this) -> !GameStorage.modContentReady(this, profile) ->
showBanner(getString(R.string.home_mod_missing, GameStorage.modDirectory(this).absolutePath), warning = true) showBanner(getString(R.string.home_mod_missing, GameStorage.modDirectory(this).absolutePath), warning = true)
gameStatus == GameLibrary.Status.Stale -> gameStatus == GameLibrary.Status.Stale -> showBanner(getString(R.string.home_game_stale), warning = true)
showBanner(getString(if (BuildConfig.ON_DEVICE_BUILD) R.string.home_game_stale else R.string.home_game_stale_pc), warning = true)
gameStatus == GameLibrary.Status.Missing && discStatus == GameStorage.DiscStatus.Missing -> gameStatus == GameLibrary.Status.Missing && discStatus == GameStorage.DiscStatus.Missing ->
showBanner(getString(if (BuildConfig.ON_DEVICE_BUILD) R.string.home_setup_intro else R.string.home_setup_intro_pc), warning = false) showBanner(getString(R.string.home_setup_intro), warning = false)
gameStatus == GameLibrary.Status.Missing -> gameStatus == GameLibrary.Status.Missing -> showBanner(getString(R.string.home_game_missing), warning = false)
showBanner(getString(if (BuildConfig.ON_DEVICE_BUILD) R.string.home_game_missing else R.string.home_game_missing_pc), warning = false)
discStatus == GameStorage.DiscStatus.Missing -> showBanner(getString(R.string.home_data_missing, disc), warning = false) discStatus == GameStorage.DiscStatus.Missing -> showBanner(getString(R.string.home_data_missing, disc), warning = false)
else -> showBanner(null) else -> showBanner(null)
} }
@@ -298,6 +311,40 @@ class LauncherActivity : Activity() {
} }
} }
/**
* Home's game switch, shown only when this APK carries more than one kit. Each game keeps its
* own built library, so switching is immediate; only the panel's state changes.
*/
private fun buildGameToggle() {
for (entry in profiles) {
val button = TextView(this).apply {
setText(entry.title)
textSize = 14f
setTextColor(getColorStateList(R.color.game_toggle_text))
setBackgroundResource(R.drawable.bg_game_toggle)
gravity = android.view.Gravity.CENTER
// One line: the pill has a fixed height, so a wrapped "Retro Rewind" loses half of itself.
isSingleLine = true
setPadding(10.dp(), 0, 10.dp(), 0)
isClickable = true
isFocusable = true
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, 38.dp())
setOnClickListener { selectProfile(entry) }
}
gameToggle.addView(button)
}
gameToggle.visibility = if (profiles.size > 1) View.VISIBLE else View.GONE
}
private fun Int.dp(): Int = (this * resources.displayMetrics.density).toInt()
private fun selectProfile(target: GameProfile) {
if (target == profile || GameSetup.isRunning) return
profile = target
GameProfile.select(this, target)
refresh()
}
private fun label(action: Action): Int = when (action) { private fun label(action: Action): Int = when (action) {
Action.Play -> R.string.home_play Action.Play -> R.string.home_play
Action.Resume -> R.string.home_resume Action.Resume -> R.string.home_resume
@@ -325,12 +372,20 @@ class LauncherActivity : Activity() {
GameBuild.Step.Prepare, null -> getString(R.string.build_step_prepare) GameBuild.Step.Prepare, null -> getString(R.string.build_step_prepare)
} }
/** Builds the game on this headset from DATA, after saying what that takes. */ /** Builds the selected game on this headset from DATA, after saying what that takes. */
private fun buildGame() { private fun buildGame() {
if (!BuildConfig.ON_DEVICE_BUILD || GameSetup.isRunning || GameStorage.discStatus(this) != GameStorage.DiscStatus.Ready) return if (GameSetup.isRunning || GameStorage.discStatus(this) != GameStorage.DiscStatus.Ready) return
val message = if (GameLibrary.status(this) == GameLibrary.Status.Ready) R.string.home_build_replace_message else R.string.home_build_message if (!GameStorage.modContentReady(this, profile)) {
confirm(R.string.home_mod_needed_title, R.string.home_mod_needed_message, R.string.home_import) { importGame() }
return
}
val message = if (GameLibrary.status(this, profile) == GameLibrary.Status.Ready) {
R.string.home_build_replace_message
} else {
R.string.home_build_message
}
confirm(R.string.home_build_title, message, R.string.home_build) { confirm(R.string.home_build_title, message, R.string.home_build) {
GameSetupService.startBuild(this) GameSetupService.startBuild(this, profile)
showPage(Page.Home) showPage(Page.Home)
} }
} }
@@ -362,7 +417,7 @@ class LauncherActivity : Activity() {
/** Opens the document picker for a .wcgame, asking first when it would replace the game. */ /** Opens the document picker for a .wcgame, asking first when it would replace the game. */
private fun importGame() { private fun importGame() {
if (GameSetup.isRunning) return if (GameSetup.isRunning) return
if (GameLibrary.status(this) != GameLibrary.Status.Ready) { if (GameLibrary.status(this, profile) != GameLibrary.Status.Ready) {
openPicker(REQUEST_GAME_PACKAGE) openPicker(REQUEST_GAME_PACKAGE)
return return
} }
@@ -19,6 +19,7 @@ import android.widget.Toast
import kotlin.math.roundToInt import kotlin.math.roundToInt
import org.wiicompiled.quest.BuildConfig import org.wiicompiled.quest.BuildConfig
import org.wiicompiled.quest.GameLibrary import org.wiicompiled.quest.GameLibrary
import org.wiicompiled.quest.GameProfile
import org.wiicompiled.quest.GameStorage import org.wiicompiled.quest.GameStorage
import org.wiicompiled.quest.R import org.wiicompiled.quest.R
@@ -260,7 +261,7 @@ class SettingsPage(
private fun buildAbout() { private fun buildAbout() {
section(R.string.section_about_app) { section(R.string.section_about_app) {
info(R.string.about_version, activity.getString(R.string.about_version_value, BuildConfig.VERSION_NAME, BuildConfig.PROFILE)) info(R.string.about_version, BuildConfig.VERSION_NAME)
} }
section(R.string.section_about_storage) { section(R.string.section_about_storage) {
val disc = GameStorage.discDirectory(activity).absolutePath val disc = GameStorage.discDirectory(activity).absolutePath
@@ -273,15 +274,18 @@ class SettingsPage(
action(R.string.about_extract, R.string.about_extract_helper, R.string.home_select_disc, enabled = !GameSetup.isRunning) { action(R.string.about_extract, R.string.about_extract_helper, R.string.home_select_disc, enabled = !GameSetup.isRunning) {
selectDiscImage() selectDiscImage()
} }
val manifest = GameLibrary.manifest(activity) // One row per game this app carries a kit for, so both are visible at once.
val gameText = when (GameLibrary.status(activity)) { for (profile in GameProfile.available(activity)) {
val manifest = GameLibrary.manifest(activity, profile)
val gameText = when (GameLibrary.status(activity, profile)) {
GameLibrary.Status.Ready -> manifest?.let { GameLibrary.Status.Ready -> manifest?.let {
activity.getString(R.string.about_game_ready, it.optString("builtBy"), it.optString("builtAt")) activity.getString(R.string.about_game_ready, it.optString("builtBy"), it.optString("builtAt"))
} ?: activity.getString(R.string.about_game_missing) } ?: activity.getString(R.string.about_game_missing)
GameLibrary.Status.Stale -> activity.getString(R.string.about_game_stale) GameLibrary.Status.Stale -> activity.getString(R.string.about_game_stale)
GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing) GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing)
} }
info(R.string.about_game, gameText, stacked = true) info(profile.title, gameText, stacked = true)
}
if (BuildConfig.ON_DEVICE_BUILD) { if (BuildConfig.ON_DEVICE_BUILD) {
action( action(
R.string.home_build, R.string.about_build_helper, R.string.home_build, R.string.home_build, R.string.about_build_helper, R.string.home_build,
@@ -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_950" android:state_selected="true" />
<item android:color="@color/neutral_100" android:state_hovered="true" />
<item android:color="@color/neutral_300" />
</selector>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- One side of Home's game toggle: the selected game is filled, as WheelWizard's game switch is. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="@color/primary_400" />
<corners android:radius="999dp" />
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_700" />
<corners android:radius="999dp" />
</shape>
</item>
<item android:state_hovered="true">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_800" />
<corners android:radius="999dp" />
</shape>
</item>
<item android:drawable="@android:color/transparent" />
</selector>
@@ -90,6 +90,16 @@
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" /> android:layout_weight="1" />
<LinearLayout
android:id="@+id/home_game_toggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:background="@drawable/bg_pill"
android:orientation="horizontal"
android:padding="3dp"
android:visibility="gone" />
<TextView <TextView
android:id="@+id/home_title" android:id="@+id/home_title"
android:layout_width="match_parent" android:layout_width="match_parent"
+4 -6
View File
@@ -21,12 +21,11 @@
<string name="home_put_on_headset">The game opens in the headset.</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_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_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\nOn this headset: copy your disc image (ISO, WBFS, RVZ…) here, for example into Download, press Select disc image, then Build on this Quest.\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.</string> <string name="home_setup_intro">Nothing from Mario Kart Wii comes with this app: each game is built from your own PAL (RMCP01) disc.\n\nOn this headset: copy your disc image (ISO, WBFS, RVZ…) here, for example into Download, press Select disc image, then Build on this Quest.\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. Retro Rewind also needs its pack, which a Retro Rewind game file can carry.</string>
<string name="home_game_missing">The game files are ready. Now build the game from them: Build on this Quest takes about 30–40 minutes on a Quest 3. With a computer, use Build for Quest in the WiiCompiled VR launcher instead, copy the .wcgame file to this headset and press Import from computer.</string> <string name="home_game_missing">The game files are ready. Now build this game from them: Build on this Quest takes about 30–40 minutes on a Quest 3. With a computer, use Build for Quest in the WiiCompiled VR launcher instead, copy the .wcgame file to this headset and press Import from computer.</string>
<string name="home_game_stale">This app was updated, so the game must be built again for it: press Build on this Quest, or use Build for Quest in the WiiCompiled VR launcher again and Import from computer.</string> <string name="home_game_stale">This app was updated, so the game must be built again for it: press Build on this Quest, or use Build for Quest in the WiiCompiled VR launcher again and Import from computer.</string>
<string name="home_setup_intro_pc">Nothing from Mario Kart Wii or Retro Rewind comes with this app: the game is built from your own PAL (RMCP01) disc.\n\nCopy your disc image (ISO, WBFS, RVZ…) here, for example into Download, and press Select disc image. Then, on a computer, use Build for Quest in the WiiCompiled VR launcher with this app’s APK, copy the .wcgame file it makes to this headset and press Import from computer.</string> <string name="home_mod_needed_title">Retro Rewind needs its pack</string>
<string name="home_game_missing_pc">The game files are ready. The game itself is built on a computer: in the WiiCompiled VR launcher, use Build for Quest with this app’s APK, copy the .wcgame file to this headset, then press Import from computer.</string> <string name="home_mod_needed_message">Building Retro Rewind needs its pack (the RetroRewind6 folder) on this headset. Import a Retro Rewind game file built with the pack included, or copy the folder next to DATA yourself.</string>
<string name="home_game_stale_pc">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_build">Build on this Quest</string> <string name="home_build">Build on this Quest</string>
<string name="home_build_preparing">Preparing…</string> <string name="home_build_preparing">Preparing…</string>
<string name="home_building">Building %1$d%%</string> <string name="home_building">Building %1$d%%</string>
@@ -165,7 +164,6 @@
<!-- About tab --> <!-- About tab -->
<string name="section_about_app">App</string> <string name="section_about_app">App</string>
<string name="about_version">Version</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="section_about_storage">Files</string>
<string name="about_game_data">Game data</string> <string name="about_game_data">Game data</string>
<string name="about_game_data_ready">Found: %1$s</string> <string name="about_game_data_ready">Found: %1$s</string>
@@ -11,6 +11,9 @@ import org.junit.Test
*/ */
class TomlConfigTest { class TomlConfigTest {
private val dvdRoot =
"dvd_root = \"/storage/emulated/0/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA\""
private val sample = """ private val sample = """
# WiiCompiled Quest configuration. # WiiCompiled Quest configuration.
[paths] [paths]
@@ -50,6 +53,21 @@ class TomlConfigTest {
assertEquals(expected, config.text()) assertEquals(expected, config.text())
} }
@Test
fun addsTheRetroRewindPackPathToAConfigThatPredatesIt() {
// What GameStorage.prepare does to an install made before the app offered Retro Rewind:
// the pack path joins [paths], and nothing else moves.
val config = TomlConfig.parse(sample)
assertNull(config.string("paths", "retro_rewind_root"))
val pack = "/storage/emulated/0/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/RetroRewind6"
config.setString("paths", "retro_rewind_root", pack)
assertEquals(
sample.replace("$dvdRoot\n\n[video]", "$dvdRoot\nretro_rewind_root = \"$pack\"\n\n[video]"),
config.text(),
)
assertEquals(pack, TomlConfig.parse(config.text()).string("paths", "retro_rewind_root"))
}
@Test @Test
fun appendsAMissingSection() { fun appendsAMissingSection() {
val config = TomlConfig.parse(sample) val config = TomlConfig.parse(sample)
+58 -32
View File
@@ -223,8 +223,8 @@ The kit is exported from CMake's own build graph, so its flags cannot drift from
- `runtime/cmake/PublicProducts.cmake` defines `mkw_quest_kit_probe` on Android: WiiCompiled - `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 `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 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. game's symbols unresolved. The app builds this probe instead of the product.
- `android/QuestGameKit.psm1` (`Export-QuestGameKit`, run by the `exportBase*QuestGameKit` - `android/QuestGameKit.psm1` (`Export-QuestGameKit`, run by the `export*QuestGameKit`
Gradle tasks) turns the probe's ninja link edge into `kit.json`'s ordered link inputs. It adds 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 `{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 objects, and translated shards link inside `--start-lib/--end-lib` with archive semantics as
@@ -234,40 +234,56 @@ The kit is exported from CMake's own build graph, so its flags cannot drift from
this way had the same 61,975 defined and 785 undefined dynamic symbols, 29,995 translated 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. functions, `NEEDED` list and soname as the CMake-built one, in 2.4 minutes.
`Build-Quest.ps1` refuses an APK that contains any `libmain*.so` or lacks the kit, and every `Build-Quest.ps1` refuses an APK that contains any `libmain*.so` or lacks the kit, and packaging
variant excludes `**/libmain*.so` and the probes from packaging, since AGP packages every library excludes `**/libmain*.so` and `**/libmkw_quest_kit_probe*.so`, since AGP packages every library
left in the CMake output directory. The `func_8…` symbols the kit's runtime objects define are 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. hand-written HLE overrides (`PPC_NATIVE_OVERRIDE_*` in `hle_stubs.h`), not translated code.
**Retro Rewind has its own kit** (`mkw_quest_kit_probe_retro`, RetroRewind without any translated **Retro Rewind rides in the same app, on its own kit** (`mkw_quest_kit_probe_retro`, RetroRewind
code), so that APK ships no game either. It needs one more link slot than the base flavour, because without any translated code), so the APK ships neither game. The mod needs one more link slot than
the modded product links more kinds of translated code: `{game:runtime}` (the disc-generated the base game, because the modded product links more kinds of translated code: `{game:runtime}`
sources), `{game:product}` (the mod's registration and dispatch shards), `{game:mod}` (the mod's own (the disc-generated sources), `{game:product}` (the mod's registration and dispatch shards),
shards, the profile-sensitive base shards it replaces, and the mod's data patches) and `{game:mod}` (the mod's own shards, the profile-sensitive base shards it replaces, and the mod's
`{game:translated}` (the shared base shards, with the archive semantics `libmkw_base_shared.a` has). data patches) and `{game:translated}` (the shared base shards, with the archive semantics
Rather than teach each builder which flavour it is building, `kit.json` now carries a `sources` `libmkw_base_shared.a` has). Rather than teach each builder which game it is building, `kit.json`
map naming the `shards.cmake` list behind each slot, and the builders just follow it (kit schema 2). carries a `sources` map naming the `shards.cmake` list behind each slot, and the builders just
Its game is built on a PC for now: building it on the headset would also need the mod's `Code.pul` follow it.
and its 2 GB pack there, so only the base APK carries the on-device toolchain
(`BuildConfig.ON_DEVICE_BUILD`). The Retro Rewind app writes `[paths] retro_rewind_root` into its One APK, one kit: `kit.json` (schema 3) holds a `products` map with a `base` entry and, when the
first `Config.toml` and Home says so when that pack is missing. translation includes the mod, a `retro_rewind` entry, each with its own compile flags, link line,
slots and `fingerprint`; `runtimeIncludeFingerprint` and the kit's own `fingerprint` stay at the
top level. Everything that builds a game names the product it wants: `Build-QuestGame.ps1
-Product base|retro_rewind`, `Setup --quest-product`, and on the headset `GameProfile`. Each game
lives in its own directory under `files/game/<profile>`, so both can be installed at once and the
launcher's toggle switches between them; the selected one is remembered in `filesDir/selected-game`
(a plain file, because the launcher and the `:game` process do not share preferences).
Retro Rewind also needs its 2 GB pack on the headset. The app writes `[paths] retro_rewind_root`
into `Config.toml`, Home says so when the pack is missing, and a `.wcgame` can carry it (below).
Building the mod on the headset additionally needs the mod's `Code.pul`, which arrives with that
pack, so **Build on this Quest** offers Retro Rewind only once the pack is installed.
### Game packages (.wcgame) and Import from computer ### Game packages (.wcgame) and Import from computer
A `.wcgame` is a zip holding `game.json`, `libmain.so` and optionally `DATA/…` (the extracted 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 disc) and `MOD/…` (the RetroRewind6 pack), both written without compression. `game.json` records
`StaticR.rel` pins, the kit fingerprint, the library's SHA-256 and who built it. the profile, game ID, `main.dol` and `StaticR.rel` pins, the kit fingerprint, the library's
`android/Build-QuestGame.ps1` builds one on a PC from the translator's output and the kit the SHA-256, whether the package carries game files and mod content, and who built it.
last APK build exported, and `-Data` includes the game files. `-Install` pushes it into the app's `android/Build-QuestGame.ps1 -Product base|retro_rewind` builds one on a PC from the translator's
`Import` folder. The launcher creates that folder itself so it owns it, and imports the newest output and the kit the last APK build exported; `-Data` includes the game files and `-Mod
package the next time it opens, once per package. <RetroRewind6>` the pack. `-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. An import selects the game it just installed.
Players get the same build from WheelWizard VR: Settings → WiiCompiled → Meta Quest → **Build**. 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 WheelWizard asks which game, whether to include the game files, for Retro Rewind whether to
package, then runs the installed setup: include its pack, for the Quest app's APK 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 WiiCompiled-Setup.exe --build-quest --install-dir <install> --quest-apk <app.apk> --output <file.wcgame>
--quest-product base|retro_rewind [--include-game-files]
[--retro-dir <RetroRewind6> --include-mod-content] --progress-json
``` ```
Setup extracts `assets/game_kit` from the APK into `<install>\QuestBuild\kit`, so the game always Setup extracts `assets/game_kit` from the APK into `<install>\QuestBuild\kit`, so the game always
@@ -375,10 +391,11 @@ Android facts this design rests on, all measured on a Quest 3:
also learned `--target-os windows|macos|linux|android` for also learned `--target-os windows|macos|linux|android` for
`generate-data-init` and `translate-mod`, for pipelines that generate on `generate-data-init` and `translate-mod`, for pipelines that generate on
another host. another host.
- `android/`: the Gradle project. `app/src/main/cpp/CMakeLists.txt` adds the - `android/`: the Gradle project, one app with no flavours.
repository's `runtime/` as a subdirectory with those Android choices; `app/src/main/cpp/CMakeLists.txt` adds the repository's `runtime/` as a
flavours `base` and `retroRewind` each build their own game kit probe, and only `base` carries subdirectory with those Android choices and builds both game kit probes
the on-headset toolchain. (the Retro Rewind one only when the translation includes the mod), which
`exportDebugQuestGameKit` turns into the single kit the app carries.
- `android/nod-jni`: Gradle's `buildNodJni` task runs `cargo build --release - `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 --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` compiler. `stageNodJni` puts `libnod_jni.so` into the APK's `arm64-v8a`
@@ -389,7 +406,7 @@ Android facts this design rests on, all measured on a Quest 3:
Prerequisites on the Windows host (all already present on the machine this 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`, was developed on): JDK 17, Android SDK with platform 34+, NDK `29.0.14206865`,
SDK CMake `3.22.1`, `adb`, Rust 1.85+ with `rustup target add aarch64-linux-android`, SDK CMake `3.22.1`, `adb`, Rust 1.85+ with `rustup target add aarch64-linux-android`,
the .NET 10 SDK (for the headset's translator; the base APK build downloads ~70 MB of Termux the .NET 10 SDK (for the headset's translator; the APK build downloads ~70 MB of Termux
packages into `android/.dependencies` the first time); a translated graph for your own disc (the packages into `android/.dependencies` the first time); a translated graph for your own disc (the
installer's `BuildWorkspace/generated`, produced by the normal Windows pipeline). installer's `BuildWorkspace/generated`, produced by the normal Windows pipeline).
@@ -397,8 +414,7 @@ installer's `BuildWorkspace/generated`, produced by the normal Windows pipeline)
powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # SDL3 3.4.4 AAR into android/app/libs 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 # the app, its game kit and toolchain, debug-signed powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, its game kit and toolchain, 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-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 app and its kit (needs translate-mod output) powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install # the mod and its pack (needs translate-mod output)
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Flavor retroRewind -Data <dir> -Install # its game, with the disc files
adb push MarioKart.iso /sdcard/Download/ # then Select disc image in the launcher adb push MarioKart.iso /sdcard/Download/ # then Select disc image in the launcher
``` ```
@@ -468,6 +484,16 @@ What has been verified on the development machine (September 2026):
the base library does not have. Packaged with the disc files (2.55 GB), imported on the Quest 3 in the base library does not have. Packaged with the disc files (2.55 GB), imported on the Quest 3 in
under two minutes, and the mod runs: its own title screen, "Press the A Button", and the licence under two minutes, and the mod runs: its own title screen, "Press the A Button", and the licence
menu, with the pack read from `retro_rewind_root`. menu, with the pack read from `retro_rewind_root`.
- **Device, 2026-09-17: one app, both games.** The merged APK (121 MB) carries one kit with a `base`
and a `retro_rewind` recipe, no `libmain*.so` and no probe. Both games were built on the PC from
that one kit (base in 0.1 min from cached objects, Retro Rewind in 3.3 min) and imported on a
Quest 3: the base package in 3 s, the Retro Rewind one — 2.0 GB, carrying the pack — in 93 s,
which installed `RetroRewind6` and selected the game it had just installed. Both live side by
side in `files/game/<profile>`. Retro Rewind ran first (its title screen), then Home's toggle
switched to Mario Kart Wii, which ran from the same app. Two bugs this found: the on-device
builder read a per-product `fingerprint` that schema 3 keeps at the kit's top level, and a
`Config.toml` written before this app offered Retro Rewind named no pack, so the mod would have
found none — `GameStorage.prepare` now adds that one line to an existing config.
Bring-up fixes that only a device could reveal: Bring-up fixes that only a device could reveal: