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
+733 -377

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
`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
`WiiCompiledOpenXRVR` application data and a distinct uninstall registration and shortcut.
+1 -1
View File
@@ -1,5 +1,5 @@
<Project>
<PropertyGroup>
<Version>0.2.38</Version>
<Version>0.2.39</Version>
</PropertyGroup>
</Project>
+9
View File
@@ -9,6 +9,9 @@ if ($SetupPath) {
$info = (& $SetupPath --info-json | Out-String) | ConvertFrom-Json
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.' }
# 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.
Add-Type -AssemblyName System.IO.Compression
$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) {
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 { $payload.Dispose(); $stream.Dispose() }
}
@@ -52,6 +52,9 @@ internal sealed class CommandLine
public string? QuestApkPath { get; private set; }
public string? OutputPath { 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) =>
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 "--output": result.OutputPath = RequireValue(args, ref i); 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:
throw new ArgumentException($"Unknown CLI option: {args[i]}");
}
@@ -149,7 +154,7 @@ internal sealed class CommandLine
[AppMode.BuildQuest] = new ModeRules
{
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(OutputPath is not null && !rules.AcceptsQuestBuild, "--output");
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)))
throw new ArgumentException($"--quest-apk and --output are required with {rules.Flag}.");
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> " +
"(--download-retro-wfc-payload | --skip-retro-wfc-payload) [--progress-json]");
Console.Out.WriteLine(" --build-quest --install-dir <dir> --quest-apk <apk> --output <file.wcgame> " +
"[--include-game-files] [--progress-json]");
"[--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(" --uninstall --install-dir <dir>");
Console.Out.WriteLine(" --version");
@@ -293,7 +294,8 @@ internal static class ConsoleCommands
using var operationLock = InstallOperationLock.Acquire(installation.Root, reporter);
var result = await new QuestBuildService(reporter).BuildAsync(installation,
Path.GetFullPath(command.QuestApkPath!), Path.GetFullPath(command.OutputPath!),
command.IncludeGameFiles, cancellationToken);
command.IncludeGameFiles, cancellationToken, command.QuestProduct,
command.IncludeModContent ? Path.GetFullPath(command.RetroDirectoryPath!) : null);
if (ndjson is null)
Console.Out.WriteLine($"Quest game package: {result.PackagePath} ({result.SizeBytes / 1_000_000} MB)");
ndjson?.QuestPackage(result);
@@ -49,7 +49,8 @@ internal sealed class QuestBuildService
internal sealed record Toolchain(string ClangCxx, string ClangC, string Sysroot);
public async Task<Result> BuildAsync(Installation installation, string apkPath, string outputPath,
bool includeGameFiles, CancellationToken cancellationToken)
bool includeGameFiles, CancellationToken cancellationToken, string product = "base",
string? modContentDirectory = null)
{
_reporter.Progress(InstallStages.Validate, "Checking the installation and the Quest app...", 1);
var workspace = installation.WorkspaceDirectory;
@@ -73,14 +74,24 @@ internal sealed class QuestBuildService
var kitDirectory = Path.Combine(questRoot, "kit");
_reporter.Progress(InstallStages.QuestKit, "Reading the game kit from the Quest app...", 2);
var kit = ExtractKit(apkPath, kitDirectory, cancellationToken);
_reporter.Diagnostic($"Quest game kit {kit.Fingerprint} ({kit.Product})");
// A Retro Rewind app needs this installation's Retro Rewind translation, which only exists
// once the mod has been built here at least once.
if (kit.Product == "retro_rewind" && !HasRetroRewindShards(generated))
_reporter.Diagnostic($"Quest game kit {kit.Fingerprint} [{string.Join(", ", kit.Products)}]");
if (!kit.Products.Contains(product))
{
throw new InvalidOperationException(
"This Quest app is the Retro Rewind one, and this installation has no Retro Rewind " +
"translation. Install or repair Retro Rewind, then build for Quest again.");
$"The Quest app cannot play {product}: its game kit carries {string.Join(", ", kit.Products)}.");
}
// 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);
@@ -94,13 +105,18 @@ internal sealed class QuestBuildService
"-Generated", generated, "-Kit", kitDirectory, "-Manifest", manifest,
"-BuildDir", Path.Combine(questRoot, "build"), "-Output", temporaryOutput,
"-ClangCxx", toolchain.ClangCxx, "-ClangC", toolchain.ClangC, "-Sysroot", toolchain.Sysroot,
"-Ninja", ninja, "-BuiltBy", $"WiiCompiled Setup {ProductInfo.Version}"
"-Ninja", ninja, "-BuiltBy", $"WiiCompiled Setup {ProductInfo.Version}", "-Product", product
};
if (includeGameFiles)
{
arguments.Add("-Data");
arguments.Add(installation.GameDataDirectory);
}
if (modContentDirectory is not null)
{
arguments.Add("-Mod");
arguments.Add(modContentDirectory);
}
_reporter.Progress(InstallStages.QuestBuild, "Preparing the Quest build...", 40);
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", "main.dol"));
/// <summary>What the extracted kit says it builds: its fingerprint and product ("base" or "retro_rewind").</summary>
internal sealed record Kit(string Fingerprint, string Product);
/// <summary>What the extracted kit says it builds: its fingerprint and the products it carries.</summary>
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>
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.");
}
var product = document.RootElement.TryGetProperty("product", out var value) &&
value.ValueKind == JsonValueKind.String
? value.GetString()!
: "base";
return new Kit(fingerprint.GetString()!, product);
var products = document.RootElement.TryGetProperty("products", out var value) &&
value.ValueKind == JsonValueKind.Object
? value.EnumerateObject().Select(property => property.Name).ToArray()
: throw new InvalidDataException("The Quest app's game kit lists no games it can build.");
return new Kit(fingerprint.GetString()!, products);
}
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"
]);
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.");
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)
{
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("--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("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()
@@ -369,15 +384,16 @@ internal static class SelfTests
using var writer = new StreamWriter(zip.CreateEntry(name).Open());
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/runtime_resources/dsp_coef.bin", "not the kit");
Add("lib/arm64-v8a/libSDL3.so", "not the kit");
}
var kit = Path.Combine(temp, "kit");
var extracted = QuestBuildService.ExtractKit(apk, kit, CancellationToken.None);
if (extracted.Fingerprint != "abc123" || extracted.Product != "retro_rewind")
throw new Exception("The kit fingerprint or product was not read.");
if (extracted.Fingerprint != "abc123" || !extracted.Products.Contains("base") ||
!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")) ||
Directory.GetFiles(kit, "*", SearchOption.AllDirectories).Length != 2)
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.
#
# 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]
#
# 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
# 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
@@ -17,7 +20,6 @@ param(
[string]$Generated = '',
[string]$Dependencies = '',
[string]$CMakeDir = '',
[ValidateSet('base', 'retroRewind')] [string]$Flavor = 'base',
[ValidateSet('debug', 'release')] [string]$Configuration = 'debug',
[switch]$Install
)
@@ -71,7 +73,7 @@ if (-not $Dependencies) {
}
$variant = (Get-Culture).TextInfo.ToTitleCase($Configuration)
$task = "app:assemble$((Get-Culture).TextInfo.ToTitleCase($Flavor))$variant"
$task = "app:assemble$variant"
$gradleArgs = @(
'--project-dir', $root,
"-PmkwGeneratedDir=$Generated"
@@ -82,13 +84,13 @@ Write-Host "gradlew $($gradleArgs -join ' ')"
& (Join-Path $root 'gradlew.bat') @gradleArgs
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
if (-not $apk) { throw "No APK under $apkDir" }
Write-Host "APK: $($apk.FullName)"
# Every APK must be distributable: no translated game inside, only the game kit. The toolchain
# that builds one on the headset travels in the base APK only.
# The APK must be distributable: no translated game inside, only the game kit and the toolchain
# that builds one on the headset.
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [IO.Compression.ZipFile]::OpenRead($apk.FullName)
try {
@@ -98,16 +100,11 @@ try {
} finally { $archive.Dispose() }
if ($gameLibraries.Count -gt 0) { throw "The APK contains a translated game library: $($gameLibraries.FullName -join ', ')" }
if (-not $hasKit) { throw 'The APK has no game kit (assets/game_kit/kit.json)' }
if ($Flavor -eq 'base' -and -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 (-not $hasToolchain) { throw 'The APK has no build toolchain (assets/quest_toolchain/files.zip)' }
if ($Install) {
$adb = Join-Path $sdkRoot 'platform-tools\adb.exe'
& $adb install -r $apk.FullName
if ($LASTEXITCODE -ne 0) { throw "adb install failed ($LASTEXITCODE)" }
if ($Flavor -eq 'base') {
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."
}
Write-Host 'Installed. Build a game with Build on this Quest in the launcher, or on this PC with android/Build-QuestGame.ps1 -Install.'
}
+14 -15
View File
@@ -13,9 +13,10 @@
# downloaded, the toolkit's ninja and the workspace's recomp.yml.
#
# -Generated is the translator output (the dev workspace's generated/ by default). -Kit is the game
# kit the Quest app carries; it defaults to the one the last APK build of -Flavor (base or
# retroRewind, whose translation must include the mod) exported. -Data adds the
# extracted disc (the directory holding sys/ and files/) so the headset needs nothing else.
# kit the Quest app carries; it defaults to the one the last APK build exported. -Product picks the
# game: base, or retro_rewind (whose translation must include the mod). -Data adds the extracted
# 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
# up the next time it opens.
[CmdletBinding()]
@@ -31,7 +32,8 @@ param(
[string]$Sysroot = '',
[string]$Ninja = '',
[string]$BuiltBy = 'android/Build-QuestGame.ps1',
[ValidateSet('base', 'retroRewind')] [string]$Flavor = 'base',
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base',
[string]$Mod = '',
[int]$TranslatedJobs = 0,
[switch]$Install
)
@@ -42,17 +44,15 @@ $root = $PSScriptRoot
$workspace = (Resolve-Path (Join-Path $root '..')).Path
if (-not $Generated) { $Generated = Join-Path $workspace '.scratch\vr-build-workspace\BuildWorkspace\generated' }
if (-not $Manifest) { $Manifest = Join-Path $workspace 'projects\mkwii\recomp.yml' }
if (-not $BuildDir) { $BuildDir = Join-Path $root "app\build\questGame\$Flavor" }
if (-not $BuildDir) { $BuildDir = Join-Path $root "app\build\questGame\$Product" }
if (-not $Kit) {
# The kit this flavour's last APK build packaged (its export<Flavor>*QuestGameKit task), so the
# game matches the app it will be imported into.
$exportTask = 'export' + $Flavor.Substring(0, 1).ToUpper() + $Flavor.Substring(1)
# The kit the last APK build packaged, so the game matches the app it will be imported into.
$Kit = Get-ChildItem -Path (Join-Path $root 'app\build\generated') -Filter kit.json -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Directory.Name -eq 'game_kit' -and $_.FullName -like "*$exportTask*" } |
Where-Object { $_.Directory.Name -eq 'game_kit' } |
Sort-Object LastWriteTime -Descending | Select-Object -First 1 | ForEach-Object { $_.DirectoryName }
}
if (-not $Kit -or -not (Test-Path (Join-Path $Kit 'kit.json'))) {
throw "No $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" }
@@ -85,19 +85,18 @@ if (-not $gameId -or -not $dolSha -or -not $relSha) { throw "Cannot read the dis
$stopwatch = [Diagnostics.Stopwatch]::StartNew()
$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)
if (-not $Output) { $Output = Join-Path $BuildDir 'MarioKartWii.wcgame' }
Write-Host 'MKWCBUILD:STEP:quest-package Packaging the game for Quest'
$game = New-QuestGamePackage -Library $library -DataDir $Data -KitDir $Kit -GameId $gameId -DolSha256 $dolSha -RelSha256 $relSha `
-BuiltBy $BuiltBy -OutputPath $Output
$game = New-QuestGamePackage -Library $library -DataDir $Data -ModDir $Mod -KitDir $Kit -GameId $gameId -DolSha256 $dolSha -RelSha256 $relSha `
-BuiltBy $BuiltBy -OutputPath $Output -Product $Product
Write-Host "Game package: $Output (kit $($game.kitFingerprint))"
if ($Install) {
$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/$package/files/WiiCompiledOpenXRVR/Import"
$import = '/sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/Import'
# The launcher creates Import itself and must own it to remove imported packages (see
# Run-Quest.ps1 on adb-created directories), so only push into one that exists.
$exists = ((& $adb shell "test -d '$import' && echo yes") | Out-String).Trim()
+6 -6
View File
@@ -1,17 +1,17 @@
# Exports the Quest game kit (see QuestGameKit.psm1) from a configured and built Android CMake
# tree. android/app/build.gradle.kts runs this after the native build and packages the result as
# the APK's game_kit assets, 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()]
param(
[Parameter(Mandatory)] [string]$CMakeBinaryDir,
[Parameter(Mandatory)] [string]$OutputDir,
[Parameter(Mandatory)] [string]$LlvmStrip,
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
[Parameter(Mandatory)] [string]$LlvmStrip
)
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'QuestGameKit.psm1') -Force
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$fingerprint = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip -Product $Product
Write-Host "Quest game kit ($Product) $fingerprint -> $OutputDir"
$kit = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip
Write-Host "Quest game kit $($kit.Fingerprint) [$($kit.Products -join ', ')] -> $OutputDir"
+118 -95
View File
@@ -24,7 +24,7 @@
Set-StrictMode -Version Latest
$script:KitSchema = 2
$script:KitSchema = 3
$script:GameSchema = 1
# 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]$OutputDir,
[Parameter(Mandatory)] [string]$RepoRoot,
[Parameter(Mandatory)] [string]$LlvmStrip,
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
[Parameter(Mandatory)] [string]$LlvmStrip
)
$ErrorActionPreference = 'Stop'
$binary = ConvertTo-ForwardPath $CMakeBinaryDir
$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")
$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"
$rules = if (Test-Path "$binary/CMakeFiles/rules.ninja") { [IO.File]::ReadAllText("$binary/CMakeFiles/rules.ninja") } else { '' }
function Find-Command([string]$Pattern) {
$entry = @($commands | Where-Object { $_.file.Replace('\', '/') -match $Pattern })
if ($entry.Count -eq 0) { throw "compile_commands.json has no source matching $Pattern" }
@@ -307,20 +288,15 @@ function Export-QuestGameKit {
param($Entry)
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 }
$objectsDir = Join-Path $OutputDir 'objects'
$libsDir = Join-Path $OutputDir 'libs'
New-Item -ItemType Directory -Force $objectsDir, $libsDir | Out-Null
New-Item -ItemType Directory -Force (Join-Path $OutputDir 'objects'), (Join-Path $OutputDir 'libs') | Out-Null
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 = @{}
$script:kitIndex = 0
$copy = {
param([string]$Source, [string]$Directory)
$full = ConvertTo-ForwardPath $(if ([IO.Path]::IsPathRooted($Source)) { $Source } else { "$binary/$Source" })
@@ -333,51 +309,91 @@ function Export-QuestGameKit {
$copied[$full] = "{kit}/$Directory/$name"
return $copied[$full]
}
$script:kitIndex = 0
# Link inputs in the probe's order, with the game's slots marked where WiiCompiled has them.
$inputs = New-Object System.Collections.Generic.List[string]
$lastRuntime = -1
for ($i = 0; $i -lt $objects.Count; $i++) {
if ($objects[$i] -match '/mkw_runtime_common\.dir/') { $lastRuntime = $i }
}
for ($i = 0; $i -lt $objects.Count; $i++) {
$inputs.Add((& $copy $objects[$i] 'objects'))
if ($objects[$i] -match $kitProfile.ProductObjectPattern) { $inputs.Add('{game:product}') }
if ($i -eq $lastRuntime) {
$inputs.Add('{game:runtime}')
# Retro Rewind links the mod's own objects after the runtime's, as its CMake target does.
if ($kitProfile.Slots -contains 'mod') { $inputs.Add('{game:mod}') }
# 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
}
}
foreach ($slot in $kitProfile.Slots) {
if ($slot -ne 'translated' -and -not $inputs.Contains("{game:$slot}")) {
throw "The probe link line lacks the objects the {game:$slot} slot follows"
$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] }
}
}
foreach ($token in ((ConvertFrom-NinjaPath $vars['LINK_LIBRARIES']) -split ' ' | Where-Object { $_ })) {
if ($token.StartsWith('-')) { $inputs.Add($token); continue }
$path = $token.Replace('\', '/')
if ($path -match '/sysroot/(.+)$') { $inputs.Add("{sysroot}/$($Matches[1])") }
else { $inputs.Add((& $copy $token 'libs')) }
if ($path -match '/libmkw_platform\.a$') { $inputs.Add('{game:translated}') }
}
if (-not $inputs.Contains('{game:translated}')) { throw 'The probe link line lacks libmkw_platform.a' }
$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)" }
$linkFlags = New-Object System.Collections.Generic.List[string]
$linkFlags.Add("--target=$target"); $linkFlags.Add('--sysroot={sysroot}'); $linkFlags.Add('-fPIC')
foreach ($name in 'LANGUAGE_COMPILE_FLAGS', 'ARCH_FLAGS', 'LINK_FLAGS') {
if (-not $vars.ContainsKey($name)) { continue }
$tokens = Split-CommandLine (ConvertFrom-NinjaPath $vars[$name])
for ($i = 0; $i -lt $tokens.Length; $i++) {
if ($tokens[$i] -eq '-g' -or $tokens[$i] -eq '-Wl,--unresolved-symbols=ignore-all') { continue }
if ($tokens[$i] -eq '-Xlinker' -and $i + 1 -lt $tokens.Length -and $tokens[$i + 1].StartsWith('--dependency-file')) { $i++; continue }
$linkFlags.Add($tokens[$i])
# 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]
$lastRuntime = -1
for ($i = 0; $i -lt $objects.Count; $i++) {
if ($objects[$i] -match '/mkw_runtime_common\.dir/') { $lastRuntime = $i }
}
for ($i = 0; $i -lt $objects.Count; $i++) {
$inputs.Add((& $copy $objects[$i] 'objects'))
if ($objects[$i] -match $kitProfile.ProductObjectPattern) { $inputs.Add('{game:product}') }
if ($i -eq $lastRuntime) {
$inputs.Add('{game:runtime}')
# Retro Rewind links the mod's own objects after the runtime's, as its CMake target does.
if ($kitProfile.Slots -contains 'mod') { $inputs.Add('{game:mod}') }
}
}
foreach ($slot in $kitProfile.Slots) {
if ($slot -ne 'translated' -and -not $inputs.Contains("{game:$slot}")) {
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 { $_ })) {
if ($token.StartsWith('-')) { $inputs.Add($token); continue }
$path = $token.Replace('\', '/')
if ($path -match '/sysroot/(.+)$') { $inputs.Add("{sysroot}/$($Matches[1])") }
else { $inputs.Add((& $copy $token 'libs')) }
if ($path -match '/libmkw_platform\.a$') { $inputs.Add('{game:translated}') }
}
if (-not $inputs.Contains('{game:translated}')) { throw "The $product probe link line lacks libmkw_platform.a" }
$linkFlags = New-Object System.Collections.Generic.List[string]
$linkFlags.Add("--target=$target"); $linkFlags.Add('--sysroot={sysroot}'); $linkFlags.Add('-fPIC')
foreach ($name in 'LANGUAGE_COMPILE_FLAGS', 'ARCH_FLAGS', 'LINK_FLAGS') {
if (-not $vars.ContainsKey($name)) { continue }
$tokens = Split-CommandLine (ConvertFrom-NinjaPath $vars[$name])
for ($i = 0; $i -lt $tokens.Length; $i++) {
if ($tokens[$i] -eq '-g' -or $tokens[$i] -eq '-Wl,--unresolved-symbols=ignore-all') { continue }
if ($tokens[$i] -eq '-Xlinker' -and $i + 1 -lt $tokens.Length -and $tokens[$i + 1].StartsWith('--dependency-file')) { $i++; continue }
$linkFlags.Add($tokens[$i])
}
}
$linkFlags.Add('-Wl,-soname,libmain.so')
$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
}
}
}
$linkFlags.Add('-Wl,-soname,libmain.so')
$lld = Get-KitLldArguments -ClangCxx (Join-Path (Split-Path -Parent $LlvmStrip) 'clang++.exe') -KitDir $OutputDir `
-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
# the runtime sources it indexes for native registrations, which must be the ones compiled into
@@ -389,13 +405,8 @@ function Export-QuestGameKit {
$recipe = [ordered]@{
schema = $script:KitSchema
product = $Product
output = 'libmain.so'
target = $target
runtimeIncludeFingerprint = Get-RuntimeIncludeFingerprint (Join-Path $OutputDir 'include')
compile = $compile
sources = $kitProfile.Sources
link = [ordered]@{ flags = $linkFlags.ToArray(); inputs = $inputs.ToArray(); lld = $lld }
products = $products
}
$recipeJson = $recipe | ConvertTo-Json -Depth 8 -Compress
$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"))
[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.
@@ -425,7 +436,8 @@ function Invoke-QuestGameBuild {
[Parameter(Mandatory)] [string]$ClangC,
[Parameter(Mandatory)] [string]$Sysroot,
[Parameter(Mandatory)] [string]$Ninja,
[Parameter(Mandatory)] [int]$TranslatedJobs
[Parameter(Mandatory)] [int]$TranslatedJobs,
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
)
$ErrorActionPreference = 'Stop'
$kit = ConvertTo-ForwardPath $KitDir
@@ -433,6 +445,10 @@ function Invoke-QuestGameBuild {
$workspace = ConvertTo-ForwardPath (Split-Path -Parent $generated)
$recipe = [IO.File]::ReadAllText("$kit/kit.json") | ConvertFrom-Json
if ($recipe.schema -ne $script:KitSchema) { throw "Unsupported game kit schema $($recipe.schema)" }
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
# kit's runtime headers and linked with the kit's runtime objects.
$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) }
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))
}
$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))
# The Windows installer generates blob assembly for PE/COFF; every one of them is rewritten for
@@ -469,9 +485,9 @@ function Invoke-QuestGameBuild {
# file.
$shards = "$generated/build_shards/shards.cmake"
$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]
foreach ($entry in $recipe.sources.$slot) {
foreach ($entry in $productRecipe.sources.$slot) {
if ($entry.StartsWith('@')) {
Read-ShardList $shards $entry.Substring(1) | ForEach-Object { $slotSources.Add($_) }
} else {
@@ -482,7 +498,7 @@ function Invoke-QuestGameBuild {
}
if ($sources.translated.Count -eq 0) { throw "No translated shards in $shards" }
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
@@ -511,7 +527,7 @@ function Invoke-QuestGameBuild {
}
}
$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
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" }
@@ -534,33 +550,38 @@ function Invoke-QuestGameBuild {
return "$build/libmain.so"
}
# A .wcgame: the built library and what it was built from, for the headset to import. With
# -DataDir it also carries the extracted disc (DATA/...), written without compression, so a player who
# builds on a PC copies one file to the headset and never needs the disc image there.
# A .wcgame: the built library and what it was built from, for the headset to import. It can also
# carry, written without compression, the extracted disc (-DataDir, as DATA/...) and the Retro
# 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 {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$Library,
[string]$DataDir = '',
[string]$ModDir = '',
[Parameter(Mandatory)] [string]$KitDir,
[Parameter(Mandatory)] [string]$GameId,
[Parameter(Mandatory)] [string]$DolSha256,
[Parameter(Mandatory)] [string]$RelSha256,
[Parameter(Mandatory)] [string]$BuiltBy,
[Parameter(Mandatory)] [string]$OutputPath
[Parameter(Mandatory)] [string]$OutputPath,
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
)
$ErrorActionPreference = 'Stop'
$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]@{
schema = $script:GameSchema
profile = $recipe.product
profile = $Product
gameId = $GameId
dolSha256 = $DolSha256
relSha256 = $RelSha256
kitFingerprint = $recipe.fingerprint
library = $recipe.output
library = $recipe.products.$Product.output
librarySha256 = Get-Sha256Hex $Library
includesData = [bool]$DataDir
includesMod = [bool]$ModDir
builtBy = $BuiltBy
builtAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
}
@@ -572,10 +593,12 @@ function New-QuestGamePackage {
$entry = $zip.CreateEntry('game.json')
$writer = New-Object IO.StreamWriter($entry.Open(), (New-Object Text.UTF8Encoding $false))
try { $writer.Write(($game | ConvertTo-Json)) } finally { $writer.Dispose() }
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Library, $recipe.output, 'Optimal')
if ($DataDir) {
foreach ($file in Get-RelativeFiles $DataDir) {
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $file.FullName, "DATA/$($file.Relative)", 'NoCompression')
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Library, $game.library, 'Optimal')
foreach ($tree in @{ Prefix = 'DATA'; Directory = $DataDir }, @{ Prefix = 'MOD'; Directory = $ModDir }) {
if (-not $tree.Directory) { continue }
foreach ($file in Get-RelativeFiles $tree.Directory) {
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$zip, $file.FullName, "$($tree.Prefix)/$($file.Relative)", 'NoCompression')
}
}
} finally { $zip.Dispose() }
+20 -11
View File
@@ -9,17 +9,25 @@ holds the Gradle project, its helper scripts, the game kit tooling
```powershell
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 -Flavor retroRewind # Retro Rewind product
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, debug-signed, installs over adb
```
The translated game (the translator's `generated/` tree for **your own**
PAL `RMCP01` disc) is passed with `-Generated <dir>`; it defaults to the
installer workspace next to this checkout. No game data is ever part of the
APK, and neither is any translated game code: the base APK carries a game kit,
and your `libmain.so` is built from your own disc, either on the headset
(**Build on this Quest**, about half an hour) or on a PC and imported
(`Build-QuestGame.ps1 -Install`, or **Import from computer** with its `.wcgame`).
One app offers both games, with a toggle on Home: Mario Kart Wii and, when your
translation includes the mod, Retro Rewind. The translated game (the translator's
`generated/` tree for **your own** PAL `RMCP01` disc) is passed with
`-Generated <dir>`; it defaults to the installer workspace next to this checkout.
No game data is ever part of the APK, and neither is any translated game code:
the APK carries one game kit holding a recipe per game, and your `libmain.so` is
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
/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
@@ -32,8 +40,9 @@ disc to
`Config.toml`, saves and logs live in the same `WiiCompiledOpenXRVR` directory.
The app opens on a 2D launcher panel modelled on the PC launcher (WheelWizard VR):
**Home** starts the game in the headset and says when DATA is missing, and
**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,
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`
still starts it directly.
+29 -50
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,
// 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).
@@ -100,12 +107,23 @@ android {
for ((name, value) in discPins) {
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 {
abiFilters += "arm64-v8a"
}
externalNativeBuild {
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(
"-DANDROID_STL=c++_shared",
"-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 {
cmake {
// 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
abstract val llvmStrip: RegularFileProperty
/** "base" or "retro_rewind": which product's kit this flavour carries. */
@get:Internal
abstract val product: Property<String>
@get:Inject
abstract val execOperations: ExecOperations
@@ -219,11 +206,10 @@ abstract class ExportQuestGameKit : DefaultTask() {
@TaskAction
fun export() {
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()
.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() }
?: 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 binaryDir = File(app, ".cxx/${configuration.parentFile.name}/${configuration.name}/arm64-v8a")
if (!File(binaryDir, "build.ninja").isFile) throw GradleException("No CMake tree at $binaryDir")
@@ -232,7 +218,6 @@ abstract class ExportQuestGameKit : DefaultTask() {
commandLine(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.get().asFile.path,
"-CMakeBinaryDir", binaryDir.path, "-OutputDir", kitDir.path, "-LlvmStrip", llvmStrip.get().asFile.path,
"-Product", product.get(),
)
}
}
@@ -275,33 +260,27 @@ androidComponents {
onVariants { variant ->
val host = if (System.getProperty("os.name").startsWith("Windows")) "windows-x86_64" else "linux-x86_64"
val capitalized = variant.name.replaceFirstChar { it.uppercase() }
// Both flavours ship a kit instead of the game. AGP packages every library in the CMake
// output directory, so a game library left over from an earlier build would otherwise
// travel in the APK.
// The APK ships kits instead of games. AGP packages every library in the CMake output
// directory, so a game library left over from an earlier build would otherwise travel in it.
variant.packaging.jniLibs.excludes.add("**/libmain*.so")
val export = tasks.register<ExportQuestGameKit>("export${capitalized}QuestGameKit") {
dependsOn("merge${capitalized}NativeLibs")
appDir.set(layout.projectDirectory)
script.set(rootProject.layout.projectDirectory.file("Export-QuestGameKit.ps1"))
product.set(if (variant.flavorName == "retroRewind") "retro_rewind" else "base")
llvmStrip.set(sdkComponents.ndkDirectory.map {
it.file("toolchains/llvm/prebuilt/$host/bin/llvm-strip" + if (host.startsWith("windows")) ".exe" else "")
})
}
variant.sources.assets?.addGeneratedSourceDirectory(export, ExportQuestGameKit::outputDir)
// 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") {
script.set(rootProject.layout.projectDirectory.file("Prepare-QuestToolchain.ps1"))
sources.from(script, rootProject.layout.projectDirectory.dir("toolchain"))
sources.from(fileTree(File(mkwRepoRoot, "translator/src")) { exclude("**/bin/**", "**/obj/**") })
sources.from(fileTree(File(mkwRepoRoot, "Launcher/WiiCompiled.Setup.Common")) { exclude("**/bin/**", "**/obj/**") })
ndkLlvm.set(sdkComponents.ndkDirectory.map { it.dir("toolchains/llvm/prebuilt/$host") })
}
variant.sources.assets?.addGeneratedSourceDirectory(prepareToolchain, PrepareQuestToolchain::outputDir)
val prepareToolchain = tasks.register<PrepareQuestToolchain>("prepare${capitalized}QuestToolchain") {
script.set(rootProject.layout.projectDirectory.file("Prepare-QuestToolchain.ps1"))
sources.from(script, rootProject.layout.projectDirectory.dir("toolchain"))
sources.from(fileTree(File(mkwRepoRoot, "translator/src")) { exclude("**/bin/**", "**/obj/**") })
sources.from(fileTree(File(mkwRepoRoot, "Launcher/WiiCompiled.Setup.Common")) { exclude("**/bin/**", "**/obj/**") })
ndkLlvm.set(sdkComponents.ndkDirectory.map { it.dir("toolchains/llvm/prebuilt/$host") })
}
variant.sources.assets?.addGeneratedSourceDirectory(prepareToolchain, PrepareQuestToolchain::outputDir)
}
}
@@ -5,8 +5,9 @@ import java.io.File
import org.json.JSONObject
/**
* The player's compiled game: libmain.so built from their own disc against the game kit this
* APK carries (assets/game_kit, see android/QuestGameKit.psm1), plus the game.json it came with.
* A player's compiled game: libmain.so built from their own disc against the game kit this APK
* carries (assets/game_kit, see android/QuestGameKit.psm1), plus the game.json it came with. 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
* did not install (execution from shared or external storage is blocked). A library built for
@@ -23,13 +24,16 @@ object GameLibrary {
@Volatile
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)
.takeIf { it.isFile }
?.let { runCatching { JSONObject(it.readText()) }.getOrNull() }
fun manifest(context: Context, profile: GameProfile = GameProfile.selected(context)): JSONObject? =
File(directory(context, profile), MANIFEST_NAME)
.takeIf { it.isFile }
?.let { runCatching { JSONObject(it.readText()) }.getOrNull() }
/** The fingerprint of the kit in this APK's assets, which every game must be built against. */
fun kitFingerprint(context: Context): String? {
@@ -41,12 +45,9 @@ object GameLibrary {
return fingerprint
}
fun status(context: Context): Status {
if (!BuildConfig.EXTERNAL_GAME) {
return Status.Ready
}
val manifest = manifest(context)
if (manifest == null || !library(context).isFile) {
fun status(context: Context, profile: GameProfile = GameProfile.selected(context)): Status {
val manifest = manifest(context, profile)
if (manifest == null || !library(context, profile).isFile) {
return Status.Missing
}
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 java.io.File
import org.wiicompiled.quest.launcher.TomlConfig
/**
* 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)
/** Whether this app needs the Retro Rewind pack and has it. Always true for the base game. */
fun modContentReady(context: Context): Boolean =
BuildConfig.PROFILE != "retro_rewind" || File(modDirectory(context), "Binaries/Code.pul").isFile
/** The pack's own Code.pul, which is what a modded game and a mod translation both need. */
fun modCodePul(context: Context): File = File(modDirectory(context), "Binaries/Code.pul")
/** 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")
@@ -58,7 +62,8 @@ object GameStorage {
/**
* 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.
*/
fun prepare(context: Context): File {
@@ -75,10 +80,9 @@ object GameStorage {
"[paths]",
"dvd_root = \"${discDirectory(context).absolutePath}\"",
)
// The modded product reads its pack from retro_rewind_root, which sits next to DATA here.
if (BuildConfig.PROFILE == "retro_rewind") {
lines += "retro_rewind_root = \"${modDirectory(context).absolutePath}\""
}
// The modded product reads its pack from retro_rewind_root, which sits next to DATA
// here. Harmless for the unmodded game, and one config serves both.
lines += "retro_rewind_root = \"${modDirectory(context).absolutePath}\""
lines += listOf(
"",
"[video]",
@@ -90,6 +94,14 @@ object GameStorage {
"render_scale = 1.0",
)
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
}
@@ -40,27 +40,26 @@ import org.libsdl.app.SDLSurface
*/
class QuestActivity : SDLActivity() {
override fun getLibraries(): Array<String> =
if (BuildConfig.EXTERNAL_GAME) arrayOf("SDL3") else arrayOf("SDL3", BuildConfig.MAIN_LIBRARY)
/** The game the launcher's toggle last selected; read once, since a session plays one game. */
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
* ([GameLibrary]), loaded from private storage. Its libSDL3.so, libpng16.so and
* libc++_shared.so dependencies resolve against the ones this APK installed.
* ([GameLibrary]) for the selected profile, loaded from private storage. Its libSDL3.so,
* libpng16.so and libc++_shared.so dependencies resolve against the ones this APK installed.
*/
override fun loadLibraries() {
super.loadLibraries()
if (BuildConfig.EXTERNAL_GAME) {
// SDLActivity reports a failed load in its own error dialog and never starts the game.
if (GameLibrary.status(this) != GameLibrary.Status.Ready) {
throw UnsatisfiedLinkError(getString(R.string.game_not_installed))
}
System.load(GameLibrary.library(this).absolutePath)
// SDLActivity reports a failed load in its own error dialog and never starts the game.
if (GameLibrary.status(this, profile) != GameLibrary.Status.Ready) {
throw UnsatisfiedLinkError(getString(R.string.game_not_installed))
}
System.load(GameLibrary.library(this, profile).absolutePath)
}
override fun getMainSharedObject(): String =
if (BuildConfig.EXTERNAL_GAME) GameLibrary.library(this).absolutePath else super.getMainSharedObject()
override fun getMainSharedObject(): String = GameLibrary.library(this, profile).absolutePath
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)
?: 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
* 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.wiicompiled.quest.BuildConfig
import org.wiicompiled.quest.GameLibrary
import org.wiicompiled.quest.GameProfile
import org.wiicompiled.quest.GameStorage
/**
@@ -53,7 +54,7 @@ object GameBuild {
}
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 KIT_ASSETS = "game_kit"
private const val MARKER = ".complete"
@@ -65,8 +66,8 @@ object GameBuild {
private const val EXPECTED_TRANSLATION_SECONDS = 600.0
private const val COMPILE_MEMORY_BYTES = 700L * 1024 * 1024
/** Null on success, otherwise the message to show. */
fun run(context: Context, reporter: Reporter, cancelled: () -> Boolean, finishing: () -> Unit): String? {
/** Builds [profile]'s game. Null on success, otherwise the message to show. */
fun run(context: Context, profile: GameProfile, reporter: Reporter, cancelled: () -> Boolean, finishing: () -> Unit): String? {
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."
}
@@ -74,6 +75,10 @@ object GameBuild {
if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) {
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 }
val root = File(context.filesDir, "build")
root.mkdirs()
@@ -85,7 +90,7 @@ object GameBuild {
val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val log = BuildLog(File(GameStorage.logsDirectory(context), "build_$stamp.log"))
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) {
throw e
} catch (e: Exception) {
@@ -99,6 +104,7 @@ object GameBuild {
private class Build(
val context: Context,
val profile: GameProfile,
val root: File,
val disc: File,
val reporter: Reporter,
@@ -109,24 +115,26 @@ object GameBuild {
val toolchain = File(root, "toolchain")
val ndk = File(root, "ndk")
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 objects = File(work, "obj")
val temporary = File(root, "tmp")
val failure = AtomicReference<String?>(null)
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)
val toolchainManifest = JSONObject(context.assets.open("$TOOLCHAIN_ASSETS/toolchain.json").reader().use { it.readText() })
unpackToolchain(toolchainManifest)
report(30, Step.Prepare)
val kitFingerprint = GameLibrary.kitFingerprint(context) ?: return "This app carries no game kit."
unpackKit(kitFingerprint)
val recipe = JSONObject(File(kit, "kit.json").readText())
if (recipe.optInt("schema") != KIT_SCHEMA) {
return "This app's game kit is version ${recipe.optInt("schema")}, which this builder does not know."
val kitJson = JSONObject(File(kit, "kit.json").readText())
if (kitJson.optInt("schema") != KIT_SCHEMA) {
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)
downloadNdk(JSONObject(context.assets.open("$TOOLCHAIN_ASSETS/${toolchainManifest.getString("ndk")}").reader().use { it.readText() }))
@@ -141,8 +149,9 @@ object GameBuild {
log,
cancelled = { cancelled() || failure.get() != null },
)
translate(tools, "$kitFingerprint ${toolchainManifest.getString("fingerprint")}")?.let { return it }
val objectsBySlot = compile(tools, recipe, toolchainManifest.getString("fingerprint"))
val buildIdentity = "$kitFingerprint ${toolchainManifest.getString("fingerprint")}"
translate(tools, buildIdentity)?.let { return it }
val objectsBySlot = compile(tools, recipe, buildIdentity)
failure.get()?.let { return it }
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? {
val generated = File(workspace, "generated")
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")
if (provenance.isFile && provenance.readText() == expected && shards.isFile) {
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.REL_PATH).copyTo(File(workspace, "Assets/StaticR.rel"), overwrite = true)
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\)""")
translator(
tools, "translating the game",
@@ -299,22 +319,66 @@ object GameBuild {
) { line ->
timing.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { seconds ->
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 }
report(370, Step.Translate, 2, 3)
// Retro Rewind's own code: its Code.pul translated against the base translation, as
// 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(
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, 3, 3)
translator(
tools, "preparing the build",
report(385, Step.Translate, steps, steps)
val shardArguments = mutableListOf(
"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",
)?.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)
report(400, Step.Translate, 3, 3)
report(400, Step.Translate, steps, steps)
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? {
log.line("== translator ${arguments.first()}")
val result = tools.run(
@@ -332,11 +396,12 @@ object GameBuild {
}
/** 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 shards = File(generated, "build_shards/shards.cmake").readText()
val stamp = File(objects, ".stamp")
val identity = "${recipe.getString("fingerprint")} $toolchainFingerprint"
if (stamp.takeIf { it.isFile }?.readText() != identity) {
objects.deleteRecursively()
objects.mkdirs()
@@ -481,7 +546,7 @@ object GameBuild {
}
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")
staging.deleteRecursively()
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 manifest = JSONObject()
.put("schema", 1)
.put("profile", BuildConfig.PROFILE)
.put("profile", profile.id)
.put("gameId", BuildConfig.DISC_GAME_ID)
.put("dolSha256", BuildConfig.DISC_DOL_SHA256)
.put("relSha256", BuildConfig.DISC_REL_SHA256)
@@ -15,22 +15,25 @@ import java.util.zip.ZipInputStream
import org.json.JSONObject
import org.wiicompiled.quest.BuildConfig
import org.wiicompiled.quest.GameLibrary
import org.wiicompiled.quest.GameProfile
import org.wiicompiled.quest.GameStorage
/**
* A .wcgame built on a PC (android/Build-QuestGame.ps1, the PC launcher's "Build for Quest")
* becomes this headset's game: libmain.so and game.json, and DATA too when the package carries
* the extracted disc.
* becomes one of this headset's games: libmain.so and game.json, plus DATA when the package
* 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
* pinned game and hashes, the kit this APK carries, and a library whose hash matches the bytes
* in the package. Both parts are staged and checked before either replaces what is installed.
* The package says which game it is (`profile`), so importing one installs that game and leaves
* the other alone. It is only accepted for the disc and app it was built for: game.json must name
* the pinned game and hashes, the kit this APK carries, and a library whose hash matches the bytes
* in the package. Every part is staged and checked before any of it replaces what is installed.
*/
object GamePackageImport {
private const val TAG = "WiiCompiledLauncher"
private const val SCHEMA = 1
private const val DATA_PREFIX = "DATA/"
private const val MOD_PREFIX = "MOD/"
private const val FREE_SPACE_MARGIN = 256L * 1024 * 1024
/** 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."
}
val libraryDir = GameLibrary.directory(context)
val libraryStaging = File(libraryDir.parentFile, "${libraryDir.name}.importing")
// The game's destination is only known once game.json has been read, so the library is
// staged under a name of its own first.
val libraryStaging = File(context.filesDir, "game/.importing")
val dataStaging = File(gameRoot, "DATA.importing")
libraryStaging.deleteRecursively()
dataStaging.deleteRecursively()
val modStaging = File(gameRoot, "${GameStorage.MOD_DIRECTORY}.importing")
for (staging in listOf(libraryStaging, dataStaging, modStaging)) staging.deleteRecursively()
try {
libraryStaging.mkdirs()
var manifestText: String? = null
var librarySha256: String? = null
var hasData = false
var hasMod = false
val counted = CountingInputStream(FileInputStream(descriptor.fileDescriptor))
ZipInputStream(BufferedInputStream(counted, 1 shl 20)).use { zip ->
val buffer = ByteArray(1 shl 20)
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) {
val entry = zip.nextEntry ?: break
val name = entry.name
@@ -80,19 +99,11 @@ object GamePackageImport {
}
name.startsWith(DATA_PREFIX) -> {
hasData = true
val target = File(dataStaging, name.removePrefix(DATA_PREFIX))
target.parentFile?.mkdirs()
target.outputStream().use { out ->
while (true) {
val read = zip.read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
if (counted.count - lastReport >= 8L shl 20) {
lastReport = counted.count
if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled")
}
}
}
extract(File(dataStaging, name.removePrefix(DATA_PREFIX)))
}
name.startsWith(MOD_PREFIX) -> {
hasMod = true
extract(File(modStaging, name.removePrefix(MOD_PREFIX)))
}
}
if (!progress.update(counted.count, total)) throw InterruptedIOException("Import cancelled")
@@ -102,24 +113,34 @@ object GamePackageImport {
finishing()
val manifestJson = manifestText?.let { runCatching { JSONObject(it) }.getOrNull() }
?: 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 }
if (hasData) {
GameFiles.validateData(dataStaging)?.let { return it }
} else if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) {
return "This game file has no game files (DATA). Build it again with the disc files included, or select your disc image first."
}
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!!)
if (hasData) {
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()
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
} finally {
libraryStaging.deleteRecursively()
dataStaging.deleteRecursively()
for (staging in listOf(libraryStaging, dataStaging, modStaging)) staging.deleteRecursively()
}
}
@@ -127,9 +148,6 @@ object GamePackageImport {
if (manifest.optInt("schema") != SCHEMA) {
return "This game file comes from a newer or older launcher (format ${manifest.optInt("schema")}). Build it again with the current PC launcher."
}
if (manifest.optString("profile") != BuildConfig.PROFILE) {
return "This game file is for ${manifest.optString("profile")}, but this app is ${BuildConfig.PROFILE}."
}
if (!manifest.optString("gameId").equals(BuildConfig.DISC_GAME_ID, ignoreCase = true) ||
!manifest.optString("dolSha256").equals(BuildConfig.DISC_DOL_SHA256, ignoreCase = true) ||
!manifest.optString("relSha256").equals(BuildConfig.DISC_REL_SHA256, ignoreCase = true)
@@ -7,6 +7,7 @@ import android.os.Looper
import android.util.Log
import java.io.File
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,
@@ -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. */
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 ->
publish(State.Working(task, done, total))
!cancelRequested
@@ -99,7 +100,7 @@ object GameSetup {
publish(State.Working(task, permille.toLong(), 1000, step, done, total))
!cancelRequested
}
GameBuild.run(context, reporter, cancelled = { cancelRequested }, finishing)
GameBuild.run(context, profile, reporter, cancelled = { cancelRequested }, finishing)
} else {
uri?.let { context.contentResolver.openFileDescriptor(it, "r") }?.use { descriptor ->
when (task) {
@@ -13,6 +13,7 @@ import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import kotlin.concurrent.thread
import org.wiicompiled.quest.GameProfile
import org.wiicompiled.quest.R
/**
@@ -51,13 +52,14 @@ class GameSetupService : Service() {
}
GameSetup.addListener(listener)
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)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WiiCompiled:GameSetup")
wakeLock.acquire(if (task == GameSetup.Task.BuildGame) BUILD_WAKE_LOCK_TIMEOUT_MS else WAKE_LOCK_TIMEOUT_MS)
thread(name = "GameSetup") {
try {
GameSetup.run(applicationContext, task, uri, deleteSource)
GameSetup.run(applicationContext, task, uri, deleteSource, profile)
} finally {
uri?.let(::releaseReadPermission)
if (wakeLock.isHeld) wakeLock.release()
@@ -115,21 +117,24 @@ class GameSetupService : Service() {
private const val NOTIFICATION_ID = 1
private const val NOTIFY_INTERVAL_MS = 1000L
private const val EXTRA_TASK = "task"
private const val EXTRA_PROFILE = "profile"
private const val EXTRA_DELETE_SOURCE = "deleteSource"
// Longer than any real task; only a hung run would reach it.
private const val WAKE_LOCK_TIMEOUT_MS = 60L * 60 * 1000
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,
* 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) {
if (uri.scheme == "content") {
try {
@@ -11,12 +11,15 @@ import android.os.Bundle
import android.text.format.Formatter
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import org.wiicompiled.quest.BuildConfig
import org.wiicompiled.quest.GameLibrary
import org.wiicompiled.quest.GameProfile
import org.wiicompiled.quest.GameStorage
import org.wiicompiled.quest.QuestActivity
import org.wiicompiled.quest.R
@@ -56,8 +59,14 @@ class LauncherActivity : Activity() {
private lateinit var dataBanner: View
private lateinit var dataBannerIcon: ImageView
private lateinit var dataBannerText: TextView
private lateinit var homeTitle: TextView
private lateinit var gameToggle: LinearLayout
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 launching = false
private var trailsAway = true
@@ -99,9 +108,9 @@ class LauncherActivity : Activity() {
dataBannerIcon = findViewById(R.id.home_data_banner_icon)
dataBannerText = findViewById(R.id.home_data_banner_text)
findViewById<TextView>(R.id.home_title).setText(
if (BuildConfig.PROFILE == "retro_rewind") R.string.home_title_retro_rewind else R.string.home_title_base,
)
homeTitle = findViewById(R.id.home_title)
gameToggle = findViewById(R.id.home_game_toggle)
buildGameToggle()
findViewById<TextView>(R.id.launcher_version).text = getString(R.string.launcher_version, BuildConfig.VERSION_NAME)
settings = SettingsPage(
@@ -131,13 +140,15 @@ class LauncherActivity : Activity() {
!GameSetup.isRunning && GameStorage.discStatus(this) == GameStorage.DiscStatus.Ready
) {
Log.i(TAG, "Starting a game build requested over adb")
GameSetupService.startBuild(this)
GameSetupService.startBuild(this, GameProfile.selected(this))
}
}
override fun onResume() {
super.onResume()
launching = false
// An import can install the other game and select it, so the toggle follows the file.
profile = GameProfile.selected(this)
importDroppedPackage()
setupKind = GameSetup.state.javaClass
refresh()
@@ -198,7 +209,11 @@ class LauncherActivity : Activity() {
private fun refreshHome() {
val disc = GameStorage.discDirectory(this).absolutePath
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 setup = GameSetup.state
val settingUp = GameSetup.isRunning
@@ -211,7 +226,7 @@ class LauncherActivity : Activity() {
mainAction = when {
running -> Action.Resume
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
}
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 -> 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)
!GameStorage.modContentReady(this) ->
!GameStorage.modContentReady(this, profile) ->
showBanner(getString(R.string.home_mod_missing, GameStorage.modDirectory(this).absolutePath), warning = true)
gameStatus == GameLibrary.Status.Stale ->
showBanner(getString(if (BuildConfig.ON_DEVICE_BUILD) R.string.home_game_stale else R.string.home_game_stale_pc), warning = true)
gameStatus == GameLibrary.Status.Stale -> showBanner(getString(R.string.home_game_stale), warning = true)
gameStatus == GameLibrary.Status.Missing && discStatus == GameStorage.DiscStatus.Missing ->
showBanner(getString(if (BuildConfig.ON_DEVICE_BUILD) R.string.home_setup_intro else R.string.home_setup_intro_pc), warning = false)
gameStatus == GameLibrary.Status.Missing ->
showBanner(getString(if (BuildConfig.ON_DEVICE_BUILD) R.string.home_game_missing else R.string.home_game_missing_pc), warning = false)
showBanner(getString(R.string.home_setup_intro), warning = false)
gameStatus == GameLibrary.Status.Missing -> showBanner(getString(R.string.home_game_missing), warning = false)
discStatus == GameStorage.DiscStatus.Missing -> showBanner(getString(R.string.home_data_missing, disc), warning = false)
else -> showBanner(null)
}
@@ -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) {
Action.Play -> R.string.home_play
Action.Resume -> R.string.home_resume
@@ -325,12 +372,20 @@ class LauncherActivity : Activity() {
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() {
if (!BuildConfig.ON_DEVICE_BUILD || 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 (GameSetup.isRunning || GameStorage.discStatus(this) != GameStorage.DiscStatus.Ready) return
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) {
GameSetupService.startBuild(this)
GameSetupService.startBuild(this, profile)
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. */
private fun importGame() {
if (GameSetup.isRunning) return
if (GameLibrary.status(this) != GameLibrary.Status.Ready) {
if (GameLibrary.status(this, profile) != GameLibrary.Status.Ready) {
openPicker(REQUEST_GAME_PACKAGE)
return
}
@@ -19,6 +19,7 @@ import android.widget.Toast
import kotlin.math.roundToInt
import org.wiicompiled.quest.BuildConfig
import org.wiicompiled.quest.GameLibrary
import org.wiicompiled.quest.GameProfile
import org.wiicompiled.quest.GameStorage
import org.wiicompiled.quest.R
@@ -260,7 +261,7 @@ class SettingsPage(
private fun buildAbout() {
section(R.string.section_about_app) {
info(R.string.about_version, activity.getString(R.string.about_version_value, BuildConfig.VERSION_NAME, BuildConfig.PROFILE))
info(R.string.about_version, BuildConfig.VERSION_NAME)
}
section(R.string.section_about_storage) {
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) {
selectDiscImage()
}
val manifest = GameLibrary.manifest(activity)
val gameText = when (GameLibrary.status(activity)) {
GameLibrary.Status.Ready -> manifest?.let {
activity.getString(R.string.about_game_ready, it.optString("builtBy"), it.optString("builtAt"))
} ?: activity.getString(R.string.about_game_missing)
GameLibrary.Status.Stale -> activity.getString(R.string.about_game_stale)
GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing)
// One row per game this app carries a kit for, so both are visible at once.
for (profile in GameProfile.available(activity)) {
val manifest = GameLibrary.manifest(activity, profile)
val gameText = when (GameLibrary.status(activity, profile)) {
GameLibrary.Status.Ready -> manifest?.let {
activity.getString(R.string.about_game_ready, it.optString("builtBy"), it.optString("builtAt"))
} ?: activity.getString(R.string.about_game_missing)
GameLibrary.Status.Stale -> activity.getString(R.string.about_game_stale)
GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing)
}
info(profile.title, gameText, stacked = true)
}
info(R.string.about_game, gameText, stacked = true)
if (BuildConfig.ON_DEVICE_BUILD) {
action(
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_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
android:id="@+id/home_title"
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_data_missing">The game is ready, but its game files (DATA) are missing. Select your own PAL (RMCP01) disc image to extract them, or import a game file from your computer that includes them.\n\nThe game files go to:\n%1$s</string>
<string name="home_import">Import from computer</string>
<string name="home_setup_intro">Nothing from Mario Kart Wii comes with this app: the game is built from your own PAL (RMCP01) disc.\n\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_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_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 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_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_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_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_mod_needed_title">Retro Rewind needs its pack</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_build">Build on this Quest</string>
<string name="home_build_preparing">Preparing…</string>
<string name="home_building">Building %1$d%%</string>
@@ -165,7 +164,6 @@
<!-- About tab -->
<string name="section_about_app">App</string>
<string name="about_version">Version</string>
<string name="about_version_value">%1$s (%2$s)</string>
<string name="section_about_storage">Files</string>
<string name="about_game_data">Game data</string>
<string name="about_game_data_ready">Found: %1$s</string>
@@ -11,6 +11,9 @@ import org.junit.Test
*/
class TomlConfigTest {
private val dvdRoot =
"dvd_root = \"/storage/emulated/0/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA\""
private val sample = """
# WiiCompiled Quest configuration.
[paths]
@@ -50,6 +53,21 @@ class TomlConfigTest {
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
fun appendsAMissingSection() {
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
`WITHOUT_GAME` (no base shards, and the runtime objects `$<FILTER>`ed of the two
disc-generated sources, which skip the unity build on Android for that reason), linked with the
game's symbols unresolved. The base flavour builds this probe instead of the product.
- `android/QuestGameKit.psm1` (`Export-QuestGameKit`, run by the `exportBase*QuestGameKit`
game's symbols unresolved. The app builds this probe instead of the product.
- `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
`{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
@@ -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
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
variant excludes `**/libmain*.so` and the probes from packaging, since AGP packages every library
`Build-Quest.ps1` refuses an APK that contains any `libmain*.so` or lacks the kit, and packaging
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
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
code), so that APK ships no game either. It needs one more link slot than the base flavour, because
the modded product links more kinds of translated code: `{game:runtime}` (the disc-generated
sources), `{game:product}` (the mod's registration and dispatch shards), `{game:mod}` (the mod's own
shards, the profile-sensitive base shards it replaces, and the mod's data patches) and
`{game:translated}` (the shared base shards, with the archive semantics `libmkw_base_shared.a` has).
Rather than teach each builder which flavour it is building, `kit.json` now carries a `sources`
map naming the `shards.cmake` list behind each slot, and the builders just follow it (kit schema 2).
Its game is built on a PC for now: building it on the headset would also need the mod's `Code.pul`
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
first `Config.toml` and Home says so when that pack is missing.
**Retro Rewind rides in the same app, on its own kit** (`mkw_quest_kit_probe_retro`, RetroRewind
without any translated code), so the APK ships neither game. The mod needs one more link slot than
the base game, because the modded product links more kinds of translated code: `{game:runtime}`
(the disc-generated sources), `{game:product}` (the mod's registration and dispatch shards),
`{game:mod}` (the mod's own shards, the profile-sensitive base shards it replaces, and the mod's
data patches) and `{game:translated}` (the shared base shards, with the archive semantics
`libmkw_base_shared.a` has). Rather than teach each builder which game it is building, `kit.json`
carries a `sources` map naming the `shards.cmake` list behind each slot, and the builders just
follow it.
One APK, one kit: `kit.json` (schema 3) holds a `products` map with a `base` entry and, when the
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
A `.wcgame` is a zip holding `game.json`, `libmain.so` and optionally `DATA/…` (the extracted
disc, written without compression). `game.json` records the profile, game ID, `main.dol` and
`StaticR.rel` pins, the kit fingerprint, the library's SHA-256 and who built it.
`android/Build-QuestGame.ps1` builds one on a PC from the translator's output and the kit the
last APK build exported, and `-Data` includes the game files. `-Install` pushes it into the app's
`Import` folder. The launcher creates that folder itself so it owns it, and imports the newest
package the next time it opens, once per package.
disc) and `MOD/…` (the RetroRewind6 pack), both written without compression. `game.json` records
the profile, game ID, `main.dol` and `StaticR.rel` pins, the kit fingerprint, the library's
SHA-256, whether the package carries game files and mod content, and who built it.
`android/Build-QuestGame.ps1 -Product base|retro_rewind` builds one on a PC from the translator's
output and the kit the last APK build exported; `-Data` includes the game files and `-Mod
<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**.
WheelWizard asks for the Quest app's APK, whether to include the game files and where to save the
package, then runs the installed setup:
WheelWizard asks which game, whether to include the game files, for Retro Rewind whether to
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
@@ -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
`generate-data-init` and `translate-mod`, for pipelines that generate on
another host.
- `android/`: the Gradle project. `app/src/main/cpp/CMakeLists.txt` adds the
repository's `runtime/` as a subdirectory with those Android choices;
flavours `base` and `retroRewind` each build their own game kit probe, and only `base` carries
the on-headset toolchain.
- `android/`: the Gradle project, one app with no flavours.
`app/src/main/cpp/CMakeLists.txt` adds the repository's `runtime/` as a
subdirectory with those Android choices and builds both game kit probes
(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
--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`
@@ -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
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`,
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
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/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-Quest.ps1 -Flavor retroRewind # Retro Rewind app and its kit (needs translate-mod output)
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Flavor retroRewind -Data <dir> -Install # its game, with the disc files
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install # the mod and its pack (needs translate-mod output)
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
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`.
- **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: