Added Retro Rewind Quest Support

This commit is contained in:
iChris4 committed 2026-09-17 21:36:00 +02:00
1 parent 0bf2675db1
commit 58c0890983
18 files changed
+378 -168

No files matched your search

@@ -72,8 +72,16 @@ internal sealed class QuestBuildService
var questRoot = Path.Combine(installation.Root, QuestDirectoryName);
var kitDirectory = Path.Combine(questRoot, "kit");
_reporter.Progress(InstallStages.QuestKit, "Reading the game kit from the Quest app...", 2);
var kitFingerprint = ExtractKit(apkPath, kitDirectory, cancellationToken);
_reporter.Diagnostic($"Quest game kit {kitFingerprint}");
var 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))
{
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.");
}
var toolchain = await EnsureToolchainAsync(questRoot, cancellationToken);
@@ -116,7 +124,7 @@ internal sealed class QuestBuildService
TryDelete(temporaryOutput);
throw;
}
return new Result(outputPath, kitFingerprint, includeGameFiles, new FileInfo(outputPath).Length);
return new Result(outputPath, kit.Fingerprint, includeGameFiles, new FileInfo(outputPath).Length);
}
private void TryDelete(string path)
@@ -147,13 +155,24 @@ internal sealed class QuestBuildService
}
}
/// <summary>Whether emit-build-shards recorded a Retro Rewind translation in this workspace.</summary>
private static bool HasRetroRewindShards(string generated)
{
var shards = Path.Combine(generated, "build_shards", "shards.cmake");
return File.Exists(shards) &&
File.ReadAllText(shards).Contains("set(MKW_HAVE_RETRO_REWIND_SHARDS ON)", StringComparison.Ordinal);
}
private static bool IsGameData(string directory) =>
Directory.Exists(Path.Combine(directory, "files")) &&
File.Exists(Path.Combine(directory, "sys", "fst.bin")) &&
File.Exists(Path.Combine(directory, "sys", "main.dol"));
/// <summary>Extracts <c>assets/game_kit</c> from the Quest app's APK and returns its fingerprint.</summary>
internal static string ExtractKit(string apkPath, string destination, CancellationToken cancellationToken)
/// <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>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)
{
const string prefix = "assets/game_kit/";
if (Directory.Exists(destination)) Directory.Delete(destination, recursive: true);
@@ -179,10 +198,16 @@ internal sealed class QuestBuildService
throw new InvalidDataException(
"This APK is not a WiiCompiled Quest app with a game kit. Choose the Quest app installed on the headset.");
using var document = JsonDocument.Parse(File.ReadAllText(kitJson));
return document.RootElement.TryGetProperty("fingerprint", out var fingerprint) &&
fingerprint.ValueKind == JsonValueKind.String
? fingerprint.GetString()!
: throw new InvalidDataException("The Quest app's game kit has no fingerprint.");
if (!document.RootElement.TryGetProperty("fingerprint", out var fingerprint) ||
fingerprint.ValueKind != JsonValueKind.String)
{
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);
}
private async Task<Toolchain> EnsureToolchainAsync(string questRoot, CancellationToken cancellationToken)
@@ -369,14 +369,15 @@ internal static class SelfTests
using var writer = new StreamWriter(zip.CreateEntry(name).Open());
writer.Write(text);
}
Add("assets/game_kit/kit.json", "{\"schema\":1,\"fingerprint\":\"abc123\"}");
Add("assets/game_kit/kit.json", "{\"schema\":2,\"product\":\"retro_rewind\",\"fingerprint\":\"abc123\"}");
Add("assets/game_kit/include/memory.h", "#pragma once");
Add("assets/runtime_resources/dsp_coef.bin", "not the kit");
Add("lib/arm64-v8a/libSDL3.so", "not the kit");
}
var kit = Path.Combine(temp, "kit");
if (QuestBuildService.ExtractKit(apk, kit, CancellationToken.None) != "abc123")
throw new Exception("The kit fingerprint was not read.");
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 (!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.");
+18 -14
View File
@@ -87,23 +87,27 @@ $apk = Get-ChildItem -Path $apkDir -Filter '*.apk' | Select-Object -First 1
if (-not $apk) { throw "No APK under $apkDir" }
Write-Host "APK: $($apk.FullName)"
# The base APK must be distributable: no translated game inside, only the game kit.
if ($Flavor -eq 'base') {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [IO.Compression.ZipFile]::OpenRead($apk.FullName)
try {
$gameLibraries = @($archive.Entries | Where-Object { $_.FullName -match '^lib/[^/]+/libmain[^/]*\.so$' })
$hasKit = @($archive.Entries | Where-Object { $_.FullName -eq 'assets/game_kit/kit.json' }).Count -gt 0
$hasToolchain = @($archive.Entries | Where-Object { $_.FullName -eq 'assets/quest_toolchain/files.zip' }).Count -gt 0
} finally { $archive.Dispose() }
if ($gameLibraries.Count -gt 0) { throw "The APK contains a translated game library: $($gameLibraries.FullName -join ', ')" }
if (-not $hasKit) { throw 'The APK has no game kit (assets/game_kit/kit.json)' }
if (-not $hasToolchain) { throw 'The APK has no build toolchain (assets/quest_toolchain/files.zip)' }
}
# 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.
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [IO.Compression.ZipFile]::OpenRead($apk.FullName)
try {
$gameLibraries = @($archive.Entries | Where-Object { $_.FullName -match '^lib/[^/]+/libmain[^/]*\.so$' })
$hasKit = @($archive.Entries | Where-Object { $_.FullName -eq 'assets/game_kit/kit.json' }).Count -gt 0
$hasToolchain = @($archive.Entries | Where-Object { $_.FullName -eq 'assets/quest_toolchain/files.zip' }).Count -gt 0
} finally { $archive.Dispose() }
if ($gameLibraries.Count -gt 0) { throw "The APK contains a translated game library: $($gameLibraries.FullName -join ', ')" }
if (-not $hasKit) { throw 'The APK has no game kit (assets/game_kit/kit.json)' }
if ($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 ($Install) {
$adb = Join-Path $sdkRoot 'platform-tools\adb.exe'
& $adb install -r $apk.FullName
if ($LASTEXITCODE -ne 0) { throw "adb install failed ($LASTEXITCODE)" }
Write-Host 'Installed. Build the game with Build on this Quest in the launcher, or on this PC with android/Build-QuestGame.ps1 -Install.'
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."
}
}
+13 -7
View File
@@ -13,7 +13,8 @@
# downloaded, the toolkit's ninja and the workspace's recomp.yml.
#
# -Generated is the translator output (the dev workspace's generated/ by default). -Kit is the game
# kit the Quest app carries; it defaults to the one the last APK build exported. -Data adds the
# 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.
# -Install pushes the package into the app's Import folder over adb, where the launcher picks it
# up the next time it opens.
@@ -30,6 +31,7 @@ param(
[string]$Sysroot = '',
[string]$Ninja = '',
[string]$BuiltBy = 'android/Build-QuestGame.ps1',
[ValidateSet('base', 'retroRewind')] [string]$Flavor = 'base',
[int]$TranslatedJobs = 0,
[switch]$Install
)
@@ -40,15 +42,18 @@ $root = $PSScriptRoot
$workspace = (Resolve-Path (Join-Path $root '..')).Path
if (-not $Generated) { $Generated = Join-Path $workspace '.scratch\vr-build-workspace\BuildWorkspace\generated' }
if (-not $Manifest) { $Manifest = Join-Path $workspace 'projects\mkwii\recomp.yml' }
if (-not $BuildDir) { $BuildDir = Join-Path $root 'app\build\questGame' }
if (-not $BuildDir) { $BuildDir = Join-Path $root "app\build\questGame\$Flavor" }
if (-not $Kit) {
# The kit the last APK build packaged (the exportBase*QuestGameKit tasks), so the game
# matches the app it will be imported into.
# 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)
$Kit = Get-ChildItem -Path (Join-Path $root 'app\build\generated') -Filter kit.json -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Directory.Name -eq 'game_kit' } |
Where-Object { $_.Directory.Name -eq 'game_kit' -and $_.FullName -like "*$exportTask*" } |
Sort-Object LastWriteTime -Descending | Select-Object -First 1 | ForEach-Object { $_.DirectoryName }
}
if (-not $Kit -or -not (Test-Path (Join-Path $Kit 'kit.json'))) { throw 'No game kit; run Build-Quest.ps1 first or pass -Kit' }
if (-not $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"
}
if (-not (Test-Path (Join-Path $Generated 'build_shards\shards.cmake'))) { throw "No translated graph at $Generated" }
$sdkRoot = if ($env:ANDROID_HOME) { $env:ANDROID_HOME } elseif ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android\Sdk' }
@@ -91,7 +96,8 @@ Write-Host "Game package: $Output (kit $($game.kitFingerprint))"
if ($Install) {
$adb = Join-Path $sdkRoot 'platform-tools\adb.exe'
$import = '/sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/Import'
$package = if ($Flavor -eq 'retroRewind') { 'org.wiicompiled.quest.retrorewind' } else { 'org.wiicompiled.quest' }
$import = "/sdcard/Android/data/$package/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 -5
View File
@@ -1,16 +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.
# the APK's game_kit assets, once per app flavour.
#
# powershell -ExecutionPolicy Bypass -File android/Export-QuestGameKit.ps1 -CMakeBinaryDir <dir> -OutputDir <dir> -LlvmStrip <llvm-strip>
# powershell -ExecutionPolicy Bypass -File android/Export-QuestGameKit.ps1 -CMakeBinaryDir <dir> -OutputDir <dir> -LlvmStrip <llvm-strip> [-Product base|retro_rewind]
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$CMakeBinaryDir,
[Parameter(Mandatory)] [string]$OutputDir,
[Parameter(Mandatory)] [string]$LlvmStrip
[Parameter(Mandatory)] [string]$LlvmStrip,
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
)
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'QuestGameKit.psm1') -Force
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$fingerprint = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip
Write-Host "Quest game kit $fingerprint -> $OutputDir"
$fingerprint = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip -Product $Product
Write-Host "Quest game kit ($Product) $fingerprint -> $OutputDir"
+5
View File
@@ -271,6 +271,11 @@ function Publish-Translator([string]$Destination, [string]$Work) {
& (Join-Path $env:SystemRoot 'System32\robocopy.exe') (Join-Path $repo $tree) (Join-Path $stage $tree) /E /XD bin obj /NFL /NDL /NJH /NJS /NP | Out-Null
if ($LASTEXITCODE -ge 8) { throw "Staging $tree failed (robocopy $LASTEXITCODE)" }
}
# Build output the checkout may hold for another target framework would be picked up here and
# make the retargeted publish resolve the wrong references.
Get-ChildItem -Recurse -Directory $stage |
Where-Object { $_.Name -eq 'bin' -or $_.Name -eq 'obj' } |
ForEach-Object { if (Test-Path $_.FullName) { Remove-Item -Recurse -Force $_.FullName } }
foreach ($project in Get-ChildItem -Recurse -Filter *.csproj $stage) {
$text = [IO.File]::ReadAllText($project.FullName)
$retargeted = $text.Replace('<TargetFramework>net8.0</TargetFramework>', '<TargetFramework>net10.0</TargetFramework>')
+113 -54
View File
@@ -8,12 +8,14 @@
# exported from CMake's own build graph so its flags and link order cannot drift from a normal
# build:
#
# * the link line of mkw_quest_kit_probe (runtime/cmake/PublicProducts.cmake), which is
# WiiCompiled without the game, becomes kit.json's link inputs, with {game:*} markers where
# the game's objects sat in WiiCompiled's own link;
# * the link line of the flavour's probe (runtime/cmake/PublicProducts.cmake), which is that
# product without any translated code, becomes kit.json's link inputs, with {game:*} markers
# where the game's objects sat in the product's own link;
# * the compile commands CMake recorded for one source of each generated kind (translated shard,
# registration shard, disc-generated runtime source, blob assembly) become kit.json's
# compile flags, with include paths reduced to the kit's copy of runtime/include.
# compile flags, with include paths reduced to the kit's copy of runtime/include;
# * kit.json's "sources" says which of the translator's build_shards lists fill each slot, so a
# builder never has to know which flavour it is building.
#
# Placeholders in kit.json: {kit} the extracted kit, {sysroot} the NDK sysroot the compiler
# uses, {workspace} the directory holding the translator's generated/ tree.
@@ -22,9 +24,42 @@
Set-StrictMode -Version Latest
$script:KitSchema = 1
$script:KitSchema = 2
$script:GameSchema = 1
# What each app flavour's kit is made of. Slots name the game's objects in the probe's link line,
# and Sources says which of the translator's build_shards lists ("@NAME") or generated files fill
# each of them, in link order. Retro Rewind adds a slot: the mod's own shards and the
# profile-sensitive base shards it replaces the base flavour's with, linked as objects rather
# than through the base archive.
$script:KitProfiles = @{
base = @{
Probe = 'libmkw_quest_kit_probe'
ProductObjectPattern = '/base_product\.cpp\.o$'
RegistrationPattern = '/build_shards/base_registration/[^/]+\.cpp$'
Slots = @('runtime', 'product', 'translated')
Sources = [ordered]@{
runtime = @('{workspace}/generated/data_sections_init.cpp', '{workspace}/generated/guest_symbol_table.cpp',
'{workspace}/generated/data_sections_init_blobs.S')
product = @('@MKW_BASE_REGISTRATION_SOURCES')
translated = @('@MKW_BASE_COMMON_SHARDS', '@MKW_BASE_PORTABLE_SENSITIVE_SHARDS')
}
}
retro_rewind = @{
Probe = 'libmkw_quest_kit_probe_retro'
ProductObjectPattern = '/retro_rewind_product\.cpp\.o$'
RegistrationPattern = '/build_shards/retro_rewind_registration/[^/]+\.cpp$'
Slots = @('runtime', 'product', 'mod', 'translated')
Sources = [ordered]@{
runtime = @('{workspace}/generated/data_sections_init.cpp', '{workspace}/generated/guest_symbol_table.cpp',
'{workspace}/generated/data_sections_init_blobs.S')
product = @('@MKW_RETRO_REGISTRATION_SOURCES')
mod = @('@MKW_RETRO_PORTABLE_SENSITIVE_SHARDS', '@MKW_RETRO_MOD_SHARDS', '@MKW_RETRO_EXTRA_SOURCES')
translated = @('@MKW_BASE_COMMON_SHARDS')
}
}
}
function ConvertTo-ForwardPath([string]$Path) {
return [IO.Path]::GetFullPath($Path).Replace('\', '/').TrimEnd('/')
}
@@ -165,7 +200,7 @@ function Split-DriverCommand([string]$Line) {
# sysroot/ and lib/clang/), {output}, and {game:runtime}, {game:product}, {game:translated}, each
# replaced by its object files (the translated ones already sit inside --start-lib/--end-lib).
function Get-KitLldArguments {
param([string]$ClangCxx, [string]$KitDir, [string[]]$Flags, [string[]]$Inputs)
param([string]$ClangCxx, [string]$KitDir, [string[]]$Flags, [string[]]$Inputs, [string[]]$Slots)
$kit = ConvertTo-ForwardPath $KitDir
$ndk = ConvertTo-ForwardPath (Join-Path (Split-Path -Parent $ClangCxx) '..')
# Forward slashes throughout: clang reads response files with GNU quoting, where '\' escapes.
@@ -173,7 +208,7 @@ function Get-KitLldArguments {
New-Item -ItemType Directory $probe | Out-Null
try {
$markers = [ordered]@{}
foreach ($slot in 'runtime', 'product', 'translated') {
foreach ($slot in $Slots) {
$markers[$slot] = ConvertTo-ForwardPath (Join-Path $probe "game_$slot.o")
[IO.File]::WriteAllBytes($markers[$slot], [byte[]]@())
}
@@ -182,12 +217,12 @@ function Get-KitLldArguments {
foreach ($flag in $Flags) { $arguments.Add((& $real $flag)) }
$arguments.Add('-o'); $arguments.Add("$probe/libmain.so")
foreach ($linkInput in $Inputs) {
switch ($linkInput) {
'{game:runtime}' { $arguments.Add($markers.runtime) }
'{game:product}' { $arguments.Add($markers.product) }
'{game:translated}' { $arguments.Add('-Wl,--start-lib'); $arguments.Add($markers.translated); $arguments.Add('-Wl,--end-lib') }
default { $arguments.Add((& $real $linkInput)) }
}
$slot = [regex]::Match($linkInput, '^\{game:(\w+)\}$').Groups[1].Value
if (-not $slot) { $arguments.Add((& $real $linkInput)); continue }
# Only the base archive's slot keeps archive semantics; the rest are plain objects.
if ($slot -eq 'translated') { $arguments.Add('-Wl,--start-lib') }
$arguments.Add($markers[$slot])
if ($slot -eq 'translated') { $arguments.Add('-Wl,--end-lib') }
}
$rsp = Join-Path $probe 'link.rsp'
[IO.File]::WriteAllText($rsp, (($arguments | ForEach-Object { ConvertTo-QuotedArgument $_ }) -join "`n"), (New-Object Text.UTF8Encoding $false))
@@ -218,7 +253,7 @@ function Get-KitLldArguments {
if ($argument -match '[A-Za-z]:/') { throw "The kit link names a path outside the kit and the NDK: $argument" }
$lld.Add($argument)
}
foreach ($required in '{output}', '{game:runtime}', '{game:product}', '{game:translated}') {
foreach ($required in @('{output}') + @($Slots | ForEach-Object { "{game:$_}" })) {
if (-not $lld.Contains($required)) { throw "The expanded kit link lacks $required" }
}
return , $lld.ToArray()
@@ -233,16 +268,18 @@ function Export-QuestGameKit {
[Parameter(Mandatory)] [string]$CMakeBinaryDir,
[Parameter(Mandatory)] [string]$OutputDir,
[Parameter(Mandatory)] [string]$RepoRoot,
[Parameter(Mandatory)] [string]$LlvmStrip
[Parameter(Mandatory)] [string]$LlvmStrip,
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base'
)
$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*libmkw_quest_kit_probe\.so): (\S+) ([^\r\n]*)\r?\n((?: [^\r\n]*\r?\n)+)')
if (-not $edge.Success) { throw "No mkw_quest_kit_probe link edge in $binary/build.ninja" }
$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 = @{}
@@ -272,7 +309,7 @@ function Export-QuestGameKit {
}
$compile = [ordered]@{
translated = & $flagsFor $translatedEntry
product = & $flagsFor (Find-Command '/build_shards/base_registration/[^/]+\.cpp$')
product = & $flagsFor (Find-Command $kitProfile.RegistrationPattern)
runtime = & $flagsFor (Find-Command '/generated/data_sections_init\.cpp$')
asm = & $flagsFor (Find-Command 'data_sections_init_blobs[^/]*\.S$')
}
@@ -306,11 +343,17 @@ function Export-QuestGameKit {
}
for ($i = 0; $i -lt $objects.Count; $i++) {
$inputs.Add((& $copy $objects[$i] 'objects'))
if ($i -eq $lastRuntime) { $inputs.Add('{game:runtime}') }
if ($objects[$i] -match '/base_product\.cpp\.o$') { $inputs.Add('{game:product}') }
if ($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}') }
}
}
if (-not $inputs.Contains('{game:runtime}') -or -not $inputs.Contains('{game:product}')) {
throw 'The probe link line lacks the runtime or product objects the game slots follow'
foreach ($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"
}
}
foreach ($token in ((ConvertFrom-NinjaPath $vars['LINK_LIBRARIES']) -split ' ' | Where-Object { $_ })) {
if ($token.StartsWith('-')) { $inputs.Add($token); continue }
@@ -334,7 +377,7 @@ function Export-QuestGameKit {
}
$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()
-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
@@ -346,11 +389,12 @@ function Export-QuestGameKit {
$recipe = [ordered]@{
schema = $script:KitSchema
product = 'base'
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 }
}
$recipeJson = $recipe | ConvertTo-Json -Depth 8 -Compress
@@ -409,21 +453,37 @@ function Invoke-QuestGameBuild {
$linkLines = @($recipe.link.flags | ForEach-Object { ConvertTo-QuotedArgument (& $expand $_) })
[IO.File]::WriteAllText("$build/link.rsp", ($linkLines -join "`n"), (New-Object Text.UTF8Encoding $false))
# The Windows installer generates the blob assembly for PE/COFF; rewrite it for ELF exactly
# as runtime/cmake/PublicProducts.cmake does.
$blob = [IO.File]::ReadAllText("$generated/data_sections_init_blobs.S")
$elf = $blob.Replace('.section .rdata,"dr"', '.section .rodata,"a",@progbits')
if ($elf -ne $blob) { $elf += "`n.section .note.GNU-stack,`"`",@progbits`n" }
[IO.File]::WriteAllText("$build/data_sections_init_blobs_android.S", $elf, (New-Object Text.UTF8Encoding $false))
# The Windows installer generates blob assembly for PE/COFF; every one of them is rewritten for
# ELF exactly as runtime/cmake/PublicProducts.cmake does, into the build directory.
$toElf = {
param([string]$Source)
$text = [IO.File]::ReadAllText($Source)
$elf = $text.Replace('.section .rdata,"dr"', '.section .rodata,"a",@progbits')
if ($elf -ne $text) { $elf += "`n.section .note.GNU-stack,`"`",@progbits`n" }
$rewritten = "$build/$([IO.Path]::GetFileNameWithoutExtension($Source))_android.S"
[IO.File]::WriteAllText($rewritten, $elf, (New-Object Text.UTF8Encoding $false))
return $rewritten
}
# Each slot's sources, as the recipe names them: a build_shards list ("@NAME") or one generated
# file.
$shards = "$generated/build_shards/shards.cmake"
$sources = [ordered]@{
runtime = @("$generated/data_sections_init.cpp", "$generated/guest_symbol_table.cpp")
runtimeAsm = @("$build/data_sections_init_blobs_android.S")
product = @(Read-ShardList $shards 'MKW_BASE_REGISTRATION_SOURCES')
translated = @((Read-ShardList $shards 'MKW_BASE_COMMON_SHARDS') + (Read-ShardList $shards 'MKW_BASE_PORTABLE_SENSITIVE_SHARDS'))
$sources = [ordered]@{}
foreach ($slot in $recipe.sources.PSObject.Properties.Name) {
$slotSources = New-Object System.Collections.Generic.List[string]
foreach ($entry in $recipe.sources.$slot) {
if ($entry.StartsWith('@')) {
Read-ShardList $shards $entry.Substring(1) | ForEach-Object { $slotSources.Add($_) }
} else {
$slotSources.Add((& $expand $entry))
}
}
$sources[$slot] = @($slotSources | ForEach-Object { if ($_.EndsWith('.S')) { & $toElf $_ } else { $_ } })
}
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" }
}
$ninjaText = New-Object System.Text.StringBuilder
$escape = { param([string]$p) $p.Replace('$', '$$').Replace(':', '$:').Replace(' ', '$ ') }
@@ -432,37 +492,36 @@ function Invoke-QuestGameBuild {
[void]$ninjaText.AppendLine("rule cxx`n command = $(ConvertTo-QuotedArgument $ClangCxx) @`$flags -c `$in -o `$out`n description = Compiling `$in")
[void]$ninjaText.AppendLine("rule asm`n command = $(ConvertTo-QuotedArgument $ClangC) @`$flags -c `$in -o `$out`n description = Assembling `$in")
[void]$ninjaText.AppendLine("rule link`n command = $(ConvertTo-QuotedArgument $ClangCxx) @`$flags -o `$out @`$out.rsp`n rspfile = `$out.rsp`n rspfile_content = `$inputs`n description = Linking `$out")
$objectsOf = @{ runtime = @(); product = @(); translated = @() }
$objectsOf = @{}
$seen = @{}
foreach ($kind in 'runtime', 'runtimeAsm', 'product', 'translated') {
foreach ($source in $sources[$kind]) {
foreach ($slot in $sources.Keys) {
$objectsOf[$slot] = @()
foreach ($source in $sources[$slot]) {
$stem = [IO.Path]::GetFileNameWithoutExtension($source)
$object = "obj/$stem.o"
if ($seen.ContainsKey($object)) { throw "Two generated sources share the object name $object" }
$seen[$object] = $true
$rule = if ($kind -eq 'runtimeAsm') { 'asm' } else { 'cxx' }
$flags = switch ($kind) { 'runtimeAsm' { 'asm.rsp' } default { "$kind.rsp" } }
# Assembly is assembled; a mod shard compiles with the same flags as a translated one.
$assembly = $source.EndsWith('.S')
$rule = if ($assembly) { 'asm' } else { 'cxx' }
$flags = if ($assembly) { 'asm.rsp' } elseif ($slot -eq 'mod') { 'translated.rsp' } else { "$slot.rsp" }
[void]$ninjaText.AppendLine("build $(& $escape $object): $rule $(& $escape $source)`n flags = $(& $escape "$build/$flags")")
if ($kind -eq 'translated') { [void]$ninjaText.AppendLine(' pool = translated') }
$slot = if ($kind -eq 'runtimeAsm') { 'runtime' } else { $kind }
if ($slot -eq 'translated' -or $slot -eq 'mod') { [void]$ninjaText.AppendLine(' pool = translated') }
$objectsOf[$slot] += "$build/$object"
}
}
$linkInputs = New-Object System.Collections.Generic.List[string]
foreach ($linkInput in $recipe.link.inputs) {
switch ($linkInput) {
'{game:runtime}' { $objectsOf.runtime | ForEach-Object { $linkInputs.Add($_) } }
'{game:product}' { $objectsOf.product | ForEach-Object { $linkInputs.Add($_) } }
'{game:translated}' {
# Archive semantics, as libmkw_base_shared.a has in a CMake build.
$linkInputs.Add('-Wl,--start-lib')
$objectsOf.translated | ForEach-Object { $linkInputs.Add($_) }
$linkInputs.Add('-Wl,--end-lib')
}
default { $linkInputs.Add((& $expand $linkInput)) }
}
$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" }
# The base shards keep the archive semantics libmkw_base_shared.a has in a CMake build;
# every other slot is linked as plain objects, as the product's own CMake target does.
if ($slot -eq 'translated') { $linkInputs.Add('-Wl,--start-lib') }
$objectsOf[$slot] | ForEach-Object { $linkInputs.Add($_) }
if ($slot -eq 'translated') { $linkInputs.Add('-Wl,--end-lib') }
}
$allObjects = @($objectsOf.runtime + $objectsOf.product + $objectsOf.translated | ForEach-Object { & $escape $_.Substring($build.Length + 1) })
$allObjects = @($sources.Keys | ForEach-Object { $objectsOf[$_] } | ForEach-Object { & $escape $_.Substring($build.Length + 1) })
$rspInputs = ($linkInputs | ForEach-Object { ConvertTo-QuotedArgument $_ }) -join ' '
[void]$ninjaText.AppendLine("build libmain.so: link $($allObjects -join ' ')`n flags = $(& $escape "$build/link.rsp")`n inputs = $($rspInputs.Replace('$', '$$'))")
[IO.File]::WriteAllText("$build/build.ninja", $ninjaText.ToString(), (New-Object Text.UTF8Encoding $false))
+36 -23
View File
@@ -130,6 +130,8 @@ android {
// 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") {
@@ -138,10 +140,11 @@ android {
versionNameSuffix = "-retro-rewind"
buildConfigField("String", "MAIN_LIBRARY", "\"main_retro_rewind\"")
buildConfigField("String", "PROFILE", "\"retro_rewind\"")
// Not moved to the game kit yet: this flavour still bundles its translated library,
// so a Retro Rewind APK is for local use only.
buildConfigField("boolean", "EXTERNAL_GAME", "false")
externalNativeBuild { cmake { targets += "RetroRewind" } }
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" } }
}
}
@@ -170,8 +173,8 @@ android {
packaging {
jniLibs {
useLegacyPackaging = false
// Built only to produce the game kit; its objects travel as assets/game_kit.
excludes += "**/libmkw_quest_kit_probe.so"
// Built only to produce each flavour's game kit; their objects travel as assets/game_kit.
excludes += "**/libmkw_quest_kit_probe*.so"
}
}
lint {
@@ -201,6 +204,10 @@ 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
@@ -212,10 +219,11 @@ 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 == "libmkw_quest_kit_probe.so" && it.parentFile.name == "arm64-v8a" }
.filter { it.name == library && it.parentFile.name == "arm64-v8a" }
.maxByOrNull { it.lastModified() }
?: throw GradleException("No libmkw_quest_kit_probe.so; the native build did not produce the game kit probe")
?: throw GradleException("No $library; 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")
@@ -224,6 +232,7 @@ 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(),
)
}
}
@@ -263,32 +272,36 @@ abstract class PrepareQuestToolchain : DefaultTask() {
}
androidComponents {
onVariants(selector().withFlavor("profile" to "base")) { variant ->
val prepareToolchain = tasks.register<PrepareQuestToolchain>(
"prepare${variant.name.replaceFirstChar { it.uppercase() }}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/**") })
val host = if (System.getProperty("os.name").startsWith("Windows")) "windows-x86_64" else "linux-x86_64"
ndkLlvm.set(sdkComponents.ndkDirectory.map { it.dir("toolchains/llvm/prebuilt/$host") })
}
variant.sources.assets?.addGeneratedSourceDirectory(prepareToolchain, PrepareQuestToolchain::outputDir)
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() }
// AGP packages every library in the CMake output directory, so a game library left over
// from a build that still linked the game would otherwise ship in the APK.
// 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.
variant.packaging.jniLibs.excludes.add("**/libmain*.so")
val export = tasks.register<ExportQuestGameKit>("export${capitalized}QuestGameKit") {
dependsOn("merge${capitalized}NativeLibs")
appDir.set(layout.projectDirectory)
script.set(rootProject.layout.projectDirectory.file("Export-QuestGameKit.ps1"))
val host = if (System.getProperty("os.name").startsWith("Windows")) "windows-x86_64" else "linux-x86_64"
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)
}
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ add_subdirectory("${MKW_REPO_ROOT}/runtime" wiicompiled-runtime)
# App-owned JNI glue lives in each product library SDLActivity loads, and in the game kit
# probe (runtime/cmake/PublicProducts.cmake) whose objects the player's game is linked with.
foreach(product IN ITEMS WiiCompiled RetroRewind mkw_quest_kit_probe)
foreach(product IN ITEMS WiiCompiled RetroRewind mkw_quest_kit_probe mkw_quest_kit_probe_retro)
if(TARGET ${product})
target_sources(${product} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/quest_surface_jni.cpp")
target_include_directories(${product} PRIVATE "${MKW_REPO_ROOT}/aurora-main/include")
@@ -17,6 +17,8 @@ object GameStorage {
// Must match kApplicationDirectoryName in runtime/include/runtime_config.h.
const val APP_DIRECTORY = "WiiCompiledOpenXRVR"
const val DISC_DIRECTORY = "DATA"
/** The Retro Rewind pack the modded product reads (`[paths] retro_rewind_root`). */
const val MOD_DIRECTORY = "RetroRewind6"
enum class DiscStatus { Missing, Incomplete, Ready }
@@ -26,6 +28,12 @@ object GameStorage {
fun discDirectory(context: Context): File = File(gameRoot(context), DISC_DIRECTORY)
fun modDirectory(context: Context): File = File(gameRoot(context), MOD_DIRECTORY)
/** Whether this app needs the Retro Rewind pack and has it. Always true for the base game. */
fun modContentReady(context: Context): Boolean =
BuildConfig.PROFILE != "retro_rewind" || File(modDirectory(context), "Binaries/Code.pul").isFile
fun configFile(context: Context): File = File(gameRoot(context), "Config.toml")
fun logsDirectory(context: Context): File = File(gameRoot(context), "Logs")
@@ -60,22 +68,28 @@ object GameStorage {
importDirectory(context).mkdirs()
val config = configFile(context)
if (!config.isFile) {
val disc = discDirectory(context).absolutePath
config.writeText(
"""
# WiiCompiled Quest configuration. Edit with the launcher, the in-game panel or adb pull/push.
[paths]
dvd_root = "$disc"
[video]
widescreen = true
resolution_multiplier = 1.0
[vr]
enabled = true
render_scale = 1.0
""".trimIndent() + "\n",
// Written line by line: trimIndent runs after interpolation, so an interpolated line
// would take the indent off every other one.
val lines = mutableListOf(
"# WiiCompiled Quest configuration. Edit with the launcher, the in-game panel or adb pull/push.",
"[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}\""
}
lines += listOf(
"",
"[video]",
"widescreen = true",
"resolution_multiplier = 1.0",
"",
"[vr]",
"enabled = true",
"render_scale = 1.0",
)
config.writeText(lines.joinToString("\n", postfix = "\n"))
}
return gameRoot
}
@@ -9,10 +9,6 @@ import java.security.MessageDigest
*/
object BuildRecipe {
/** The translated source lists emit-build-shards writes into shards.cmake, in link order. */
val TRANSLATED_LISTS = listOf("MKW_BASE_COMMON_SHARDS", "MKW_BASE_PORTABLE_SENSITIVE_SHARDS")
const val REGISTRATION_LIST = "MKW_BASE_REGISTRATION_SOURCES"
/**
* One argument of a response file for clang or lld on Android, which read them with GNU
* quoting: a backslash escapes the next character.
@@ -53,6 +53,7 @@ object GameBuild {
}
private const val TAG = "WiiCompiledLauncher"
private const val KIT_SCHEMA = 2
private const val TOOLCHAIN_ASSETS = "quest_toolchain"
private const val KIT_ASSETS = "game_kit"
private const val MARKER = ".complete"
@@ -66,6 +67,9 @@ object GameBuild {
/** Null on success, otherwise the message to show. */
fun run(context: Context, 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."
}
val disc = GameStorage.discDirectory(context)
if (GameStorage.discStatus(context) != GameStorage.DiscStatus.Ready) {
return "The game files (DATA) are needed to build the game. Select your disc image first."
@@ -120,6 +124,9 @@ object GameBuild {
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."
}
report(40, Step.Prepare)
downloadNdk(JSONObject(context.assets.open("$TOOLCHAIN_ASSETS/${toolchainManifest.getString("ndk")}").reader().use { it.readText() }))
@@ -336,21 +343,42 @@ object GameBuild {
stamp.writeText(identity)
}
val blob = File(generated, "data_sections_init_blobs.S")
val elfBlob = File(work, "data_sections_init_blobs_android.S")
val blobText = blob.readText()
val elfText = BuildRecipe.elfBlobAssembly(blobText)
if (!elfBlob.isFile || elfBlob.readText() != elfText) elfBlob.writeText(elfText)
// Blob assembly generated on Windows is rewritten for ELF, as PublicProducts.cmake does.
fun elfAssembly(source: File): File {
val rewritten = File(work, source.nameWithoutExtension + "_android.S")
val text = BuildRecipe.elfBlobAssembly(source.readText())
if (!rewritten.isFile || rewritten.readText() != text) rewritten.writeText(text)
return rewritten
}
// Which sources fill each link slot is the recipe's to say, so this builds either flavour.
data class Job(val kind: String, val source: File, val slot: String)
val jobs = ArrayList<Job>()
jobs += Job("runtime", File(generated, "data_sections_init.cpp"), "runtime")
jobs += Job("runtime", File(generated, "guest_symbol_table.cpp"), "runtime")
jobs += Job("asm", elfBlob, "runtime")
BuildRecipe.sourceList(shards, BuildRecipe.REGISTRATION_LIST).forEach { jobs += Job("product", File(it), "product") }
val translated = BuildRecipe.TRANSLATED_LISTS.flatMap { BuildRecipe.sourceList(shards, it) }
if (translated.isEmpty()) throw IOException("The translation produced no game code.")
translated.forEach { jobs += Job("translated", File(it), "translated") }
val sources = recipe.getJSONObject("sources")
for (slot in sources.keys()) {
val entries = sources.getJSONArray(slot)
val slotSources = ArrayList<File>()
for (i in 0 until entries.length()) {
val entry = entries.getString(i)
when {
entry.startsWith("@") -> BuildRecipe.sourceList(shards, entry.substring(1)).forEach { slotSources += File(it) }
else -> slotSources += File(BuildRecipe.expand(entry, mapOf("workspace" to workspace.absolutePath)))
}
}
if (slotSources.isEmpty()) {
throw IOException("The translation has no $slot sources for a ${recipe.getString("product")} game.")
}
// Assembly is assembled; a mod shard compiles with the same flags as a translated one.
slotSources.forEach { source ->
val assembly = source.name.endsWith(".S")
val kind = when {
assembly -> "asm"
slot == "mod" -> "translated"
else -> slot
}
jobs += Job(kind, if (assembly) elfAssembly(source) else source, slot)
}
}
if (jobs.map { BuildRecipe.objectName(it.source.path) }.toSet().size != jobs.size) {
throw IOException("Two generated sources share an object name.")
}
@@ -361,7 +389,7 @@ object GameBuild {
"workspace" to workspace.absolutePath,
)
val compileFlags = recipe.getJSONObject("compile")
for (kind in listOf("runtime", "asm", "product", "translated")) {
for (kind in jobs.map { it.kind }.distinct()) {
val flags = compileFlags.getJSONArray(kind).let { array -> (0 until array.length()).map { BuildRecipe.expand(array.getString(it), values) } }
File(work, "$kind.rsp").writeText(BuildRecipe.responseFile(flags))
}
@@ -211,11 +211,12 @@ class LauncherActivity : Activity() {
mainAction = when {
running -> Action.Resume
discStatus != GameStorage.DiscStatus.Ready -> Action.SelectDisc
gameStatus != GameLibrary.Status.Ready -> Action.BuildGame
gameStatus != GameLibrary.Status.Ready -> if (BuildConfig.ON_DEVICE_BUILD) Action.BuildGame else Action.ImportGame
else -> Action.Play
}
secondaryAction = when {
settingUp || running -> null
mainAction == Action.ImportGame -> null
gameStatus != GameLibrary.Status.Ready || mainAction == Action.SelectDisc -> Action.ImportGame
else -> null
}
@@ -279,10 +280,14 @@ 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)
gameStatus == GameLibrary.Status.Stale -> showBanner(getString(R.string.home_game_stale), warning = true)
!GameStorage.modContentReady(this) ->
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.Missing && discStatus == GameStorage.DiscStatus.Missing ->
showBanner(getString(R.string.home_setup_intro), warning = false)
gameStatus == GameLibrary.Status.Missing -> showBanner(getString(R.string.home_game_missing), warning = false)
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)
discStatus == GameStorage.DiscStatus.Missing -> showBanner(getString(R.string.home_data_missing, disc), warning = false)
else -> showBanner(null)
}
@@ -322,7 +327,7 @@ class LauncherActivity : Activity() {
/** Builds the game on this headset from DATA, after saying what that takes. */
private fun buildGame() {
if (GameSetup.isRunning || GameStorage.discStatus(this) != GameStorage.DiscStatus.Ready) return
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
confirm(R.string.home_build_title, message, R.string.home_build) {
GameSetupService.startBuild(this)
@@ -282,11 +282,13 @@ class SettingsPage(
GameLibrary.Status.Missing -> activity.getString(R.string.about_game_missing)
}
info(R.string.about_game, gameText, stacked = true)
action(
R.string.home_build, R.string.about_build_helper, R.string.home_build,
enabled = !GameSetup.isRunning && GameStorage.discStatus(activity) == GameStorage.DiscStatus.Ready,
) {
buildGame()
if (BuildConfig.ON_DEVICE_BUILD) {
action(
R.string.home_build, R.string.about_build_helper, R.string.home_build,
enabled = !GameSetup.isRunning && GameStorage.discStatus(activity) == GameStorage.DiscStatus.Ready,
) {
buildGame()
}
}
action(R.string.home_import, R.string.about_import_helper, R.string.home_import, enabled = !GameSetup.isRunning) {
importGame()
@@ -24,6 +24,9 @@
<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_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_build">Build on this Quest</string>
<string name="home_build_preparing">Preparing…</string>
<string name="home_building">Building %1$d%%</string>
@@ -46,6 +49,7 @@
<string name="home_replace_game_title">Replace the game?</string>
<string name="home_replace_game_message">The game installed now stays until the new game file has been checked and completely imported.</string>
<string name="home_data_incomplete">DATA is there but incomplete or unreadable. Select your disc image to extract it again, or copy the whole extracted partition (sys/ and files/, not just files/) to:\n%1$s</string>
<string name="home_mod_missing">The Retro Rewind pack is missing, so the game cannot start. Copy the RetroRewind6 folder (the one with Binaries, Tracks and Assets in it) to:\n%1$s</string>
<string name="home_checking">Checking disc…</string>
<string name="home_extracting">Extracting %1$d%%</string>
<string name="home_finishing">Finishing…</string>
@@ -22,6 +22,17 @@ class BuildRecipeTest {
assertThrows(IllegalArgumentException::class.java) { BuildRecipe.expand("-I{workspace}", values) }
}
@Test
fun expandsAnyLinkSlotTheRecipeNames() {
// Retro Rewind adds a mod slot; a builder only has to expand what the recipe lists.
val arguments = BuildRecipe.linkArguments(
listOf("{game:product}", "{game:mod}", "--start-lib", "{game:translated}", "--end-lib"),
emptyMap(),
mapOf("product" to listOf("/o/reg.o"), "mod" to listOf("/o/m1.o", "/o/m2.o"), "translated" to listOf("/o/base.o")),
)
assertEquals(listOf("/o/reg.o", "/o/m1.o", "/o/m2.o", "--start-lib", "/o/base.o", "--end-lib"), arguments)
}
@Test
fun expandsTheLinkSlotsInPlace() {
val template = listOf("-o", "{output}", "{kit}/objects/001_a.o", "{game:runtime}", "--start-lib", "{game:translated}", "--end-lib", "-lm")
@@ -59,7 +70,10 @@ class BuildRecipeTest {
)
assertEquals(emptyList<String>(), BuildRecipe.sourceList(shards, "MKW_BASE_PORTABLE_SENSITIVE_SHARDS"))
assertEquals(emptyList<String>(), BuildRecipe.sourceList(shards, "MKW_RETRO_MOD_SHARDS"))
assertEquals(listOf("C:/w/generated/build_shards/base_registration/registration_00.cpp"), BuildRecipe.sourceList(shards, BuildRecipe.REGISTRATION_LIST))
assertEquals(
listOf("C:/w/generated/build_shards/base_registration/registration_00.cpp"),
BuildRecipe.sourceList(shards, "MKW_BASE_REGISTRATION_SOURCES"),
)
}
@Test
+33 -8
View File
@@ -234,11 +234,23 @@ 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 a base APK that contains any `libmain*.so` or lacks the kit, and the
base variant excludes `**/libmain*.so` from packaging, since AGP packages every library left in
the CMake output directory. The `func_8…` symbols the kit's runtime objects define are
`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
left in the CMake output directory. The `func_8…` symbols the kit's runtime objects define are
hand-written HLE overrides (`PPC_NATIVE_OVERRIDE_*` in `hle_stubs.h`), not translated code.
The Retro Rewind flavour has not moved to the kit yet and still bundles its library.
**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.
### Game packages (.wcgame) and Import from computer
@@ -365,7 +377,8 @@ Android facts this design rests on, all measured on a Quest 3:
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` pick the product target and library name.
flavours `base` and `retroRewind` each build their own game kit probe, and only `base` carries
the on-headset toolchain.
- `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`
@@ -384,7 +397,8 @@ 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 (needs translate-mod output)
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
adb push MarioKart.iso /sdcard/Download/ # then Select disc image in the launcher
```
@@ -446,6 +460,14 @@ What has been verified on the development machine (September 2026):
byte-identical to one the same toolchain built from a shell, and against the PC-built library it
has the same soname, `NEEDED` list, 785 undefined symbols and 29,995 translated functions
(61,974 defined against 61,975: the PC's older clang keeps one inline helper out of line).
- **Device, 2026-09-17: Retro Rewind from its own kit.** The Retro Rewind APK carries a kit and no
game (66 MB). Its game built from that kit on the PC in 2.9 minutes (166 generated sources:
72 base shards, 24 profile-sensitive, 48 mod shards, the mod's data patches, 17 registration
shards and the 3 disc-generated sources), linked to a 157.6 MB `libmain.so` with the same import
list as the base one, 67,872 defined symbols against the base library's 61,975, and 5,905 symbols
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`.
Bring-up fixes that only a device could reveal:
@@ -556,7 +578,10 @@ or `EndAccess` errors); a black mirror too points at Aurora itself.
- **Input.** D-pad (trick inputs) is not bound; remap in `Config.toml` or bind
the thumbstick directions in a follow-up. Haptics are wired but nothing calls
them yet.
- **Retro Rewind on device** needs the mod's translation and its extracted
content pushed next to `DATA`, exactly like the desktop product.
- **Retro Rewind on the headset** runs from a kit-built library (below), but its game must be built
on a PC and its pack copied next to `DATA` by hand. `adb push` cannot create directories inside
an app's external files directory (`secure_mkdirs failed`), so push the pack to `Download` and
copy it over on the device, then `chmod -R a+rX` it. The launcher does not fetch or update the
pack, and cannot build the mod on the headset.
- **Release signing and store packaging** are out of scope; `Build-Quest.ps1`
produces debug-signed APKs for sideloading.
+9 -1
View File
@@ -415,6 +415,14 @@ if(MKW_HAVE_RETRO_REWIND)
if(MKW_RETRO_BLOB_OBJECTS)
target_sources(RetroRewind PRIVATE ${MKW_RETRO_BLOB_OBJECTS})
endif()
if(MKW_PLATFORM_ANDROID)
# Retro Rewind's game kit, the same idea as the base probe above: RetroRewind without any
# translated code, so the APK carries neither the game nor the mod.
add_library(mkw_quest_kit_probe_retro SHARED "${MKW_RETRO_REWIND_PRODUCT_SOURCE}")
mkw_configure_product(mkw_quest_kit_probe_retro WITHOUT_GAME)
target_precompile_headers(mkw_quest_kit_probe_retro REUSE_FROM WiiCompiled)
target_link_options(mkw_quest_kit_probe_retro PRIVATE "-Wl,--unresolved-symbols=ignore-all")
endif()
add_custom_target(mkw_release DEPENDS WiiCompiled RetroRewind)
else()
add_custom_target(mkw_release DEPENDS WiiCompiled)
@@ -440,7 +448,7 @@ endif()
set(MKW_ALL_BUILD_TARGETS
mkw_runtime_common mkw_base_shared mkw_base_sensitive mkw_retro_sensitive
mkw_retro_rewind_functions WiiCompiled RetroRewind mkw_quest_kit_probe)
mkw_retro_rewind_functions WiiCompiled RetroRewind mkw_quest_kit_probe mkw_quest_kit_probe_retro)
foreach(target IN LISTS MKW_ALL_BUILD_TARGETS)
if(TARGET ${target} AND MKW_BASELINE_ARCH_FLAG)
target_compile_options(${target} PRIVATE ${MKW_BASELINE_ARCH_FLAG})