Quest: address flavor safety review

This commit is contained in:
darthcircuit committed 2026-09-23 11:22:39 -06:00
1 parent 6e6406c200
commit 02dd9bf8cc
15 files changed
+162 -67

No files matched your search

@@ -74,7 +74,7 @@ internal sealed class QuestBuildService
var kitDirectory = Path.Combine(questRoot, "kit"); var kitDirectory = Path.Combine(questRoot, "kit");
_reporter.Progress(InstallStages.QuestKit, "Reading the game kit from the Quest app...", 2); _reporter.Progress(InstallStages.QuestKit, "Reading the game kit from the Quest app...", 2);
var kit = ExtractKit(apkPath, kitDirectory, cancellationToken); var kit = ExtractKit(apkPath, kitDirectory, cancellationToken);
_reporter.Diagnostic($"Quest game kit {kit.Fingerprint} [{string.Join(", ", kit.Products)}]"); _reporter.Diagnostic($"Quest game kit {kit.Fingerprint} [{string.Join(", ", kit.Products)}, CPU {kit.AndroidCpu}]");
if (!kit.Products.Contains(product)) if (!kit.Products.Contains(product))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -185,7 +185,7 @@ internal sealed class QuestBuildService
File.Exists(Path.Combine(directory, "sys", "main.dol")); File.Exists(Path.Combine(directory, "sys", "main.dol"));
/// <summary>What the extracted kit says it builds: its fingerprint and the products it carries.</summary> /// <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); internal sealed record Kit(string Fingerprint, string AndroidCpu, IReadOnlyCollection<string> Products);
/// <summary>Extracts <c>assets/game_kit</c> from the Quest app's APK and reads what it is for.</summary> /// <summary>Extracts <c>assets/game_kit</c> from the Quest app's APK and reads what it is for.</summary>
internal static Kit ExtractKit(string apkPath, string destination, CancellationToken cancellationToken) internal static Kit ExtractKit(string apkPath, string destination, CancellationToken cancellationToken)
@@ -219,11 +219,16 @@ internal sealed class QuestBuildService
{ {
throw new InvalidDataException("The Quest app's game kit has no fingerprint."); throw new InvalidDataException("The Quest app's game kit has no fingerprint.");
} }
if (!document.RootElement.TryGetProperty("androidCpu", out var androidCpu) ||
androidCpu.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(androidCpu.GetString()))
{
throw new InvalidDataException("The Quest app's game kit has no Android CPU target.");
}
var products = document.RootElement.TryGetProperty("products", out var value) && var products = document.RootElement.TryGetProperty("products", out var value) &&
value.ValueKind == JsonValueKind.Object value.ValueKind == JsonValueKind.Object
? value.EnumerateObject().Select(property => property.Name).ToArray() ? value.EnumerateObject().Select(property => property.Name).ToArray()
: throw new InvalidDataException("The Quest app's game kit lists no games it can build."); : throw new InvalidDataException("The Quest app's game kit lists no games it can build.");
return new Kit(fingerprint.GetString()!, products); return new Kit(fingerprint.GetString()!, androidCpu.GetString()!, products);
} }
private async Task<Toolchain> EnsureToolchainAsync(string questRoot, CancellationToken cancellationToken) private async Task<Toolchain> EnsureToolchainAsync(string questRoot, CancellationToken cancellationToken)
@@ -384,14 +384,14 @@ internal static class SelfTests
using var writer = new StreamWriter(zip.CreateEntry(name).Open()); using var writer = new StreamWriter(zip.CreateEntry(name).Open());
writer.Write(text); writer.Write(text);
} }
Add("assets/game_kit/kit.json", "{\"schema\":3,\"fingerprint\":\"abc123\",\"products\":{\"base\":{},\"retro_rewind\":{}}}"); Add("assets/game_kit/kit.json", "{\"schema\":3,\"fingerprint\":\"abc123\",\"androidCpu\":\"kryo\",\"products\":{\"base\":{},\"retro_rewind\":{}}}");
Add("assets/game_kit/include/memory.h", "#pragma once"); Add("assets/game_kit/include/memory.h", "#pragma once");
Add("assets/runtime_resources/dsp_coef.bin", "not the kit"); Add("assets/runtime_resources/dsp_coef.bin", "not the kit");
Add("lib/arm64-v8a/libSDL3.so", "not the kit"); Add("lib/arm64-v8a/libSDL3.so", "not the kit");
} }
var kit = Path.Combine(temp, "kit"); var kit = Path.Combine(temp, "kit");
var extracted = QuestBuildService.ExtractKit(apk, kit, CancellationToken.None); var extracted = QuestBuildService.ExtractKit(apk, kit, CancellationToken.None);
if (extracted.Fingerprint != "abc123" || !extracted.Products.Contains("base") || if (extracted.Fingerprint != "abc123" || extracted.AndroidCpu != "kryo" || !extracted.Products.Contains("base") ||
!extracted.Products.Contains("retro_rewind")) !extracted.Products.Contains("retro_rewind"))
throw new Exception("The kit fingerprint or its games were not read."); throw new Exception("The kit fingerprint or its games were not read.");
if (!File.Exists(Path.Combine(kit, "include", "memory.h")) || if (!File.Exists(Path.Combine(kit, "include", "memory.h")) ||
+8 -2
View File
@@ -75,6 +75,7 @@ if (-not $Dependencies) {
$variant = (Get-Culture).TextInfo.ToTitleCase($Configuration) $variant = (Get-Culture).TextInfo.ToTitleCase($Configuration)
$flavour = if ($Headset -eq 'quest1') { 'Quest1' } else { 'ModernQuest' } $flavour = if ($Headset -eq 'quest1') { 'Quest1' } else { 'ModernQuest' }
$expectedCpu = if ($Headset -eq 'quest1') { 'kryo' } else { 'cortex-a77' }
$task = "app:assemble$flavour$variant" $task = "app:assemble$flavour$variant"
$gradleArgs = @( $gradleArgs = @(
'--project-dir', $root, '--project-dir', $root,
@@ -98,11 +99,16 @@ Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [IO.Compression.ZipFile]::OpenRead($apk.FullName) $archive = [IO.Compression.ZipFile]::OpenRead($apk.FullName)
try { try {
$gameLibraries = @($archive.Entries | Where-Object { $_.FullName -match '^lib/[^/]+/libmain[^/]*\.so$' }) $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 $kitEntry = @($archive.Entries | Where-Object { $_.FullName -eq 'assets/game_kit/kit.json' }) | Select-Object -First 1
$kitCpu = if ($kitEntry) {
$reader = New-Object IO.StreamReader($kitEntry.Open())
try { ($reader.ReadToEnd() | ConvertFrom-Json).androidCpu } finally { $reader.Dispose() }
} else { '' }
$hasToolchain = @($archive.Entries | Where-Object { $_.FullName -eq 'assets/quest_toolchain/files.zip' }).Count -gt 0 $hasToolchain = @($archive.Entries | Where-Object { $_.FullName -eq 'assets/quest_toolchain/files.zip' }).Count -gt 0
} finally { $archive.Dispose() } } finally { $archive.Dispose() }
if ($gameLibraries.Count -gt 0) { throw "The APK contains a translated game library: $($gameLibraries.FullName -join ', ')" } if ($gameLibraries.Count -gt 0) { throw "The APK contains a translated game library: $($gameLibraries.FullName -join ', ')" }
if (-not $hasKit) { throw 'The APK has no game kit (assets/game_kit/kit.json)' } if (-not $kitEntry) { throw 'The APK has no game kit (assets/game_kit/kit.json)' }
if ($kitCpu -ne $expectedCpu) { throw "The $Headset APK contains a game kit for CPU '$kitCpu', expected '$expectedCpu'" }
if (-not $hasToolchain) { throw 'The APK has no build toolchain (assets/quest_toolchain/files.zip)' } if (-not $hasToolchain) { throw 'The APK has no build toolchain (assets/quest_toolchain/files.zip)' }
if ($Install) { if ($Install) {
+9 -5
View File
@@ -6,6 +6,7 @@
# SDK's NDK and ninja: # SDK's NDK and ninja:
# #
# powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 [-Generated <dir>] [-Kit <dir>] # powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 [-Generated <dir>] [-Kit <dir>]
# [-Headset modern|quest1] [-Configuration debug|release]
# [-Data <extracted disc dir>] [-Output <file.wcgame>] [-Install] # [-Data <extracted disc dir>] [-Output <file.wcgame>] [-Install]
# #
# WiiCompiled Setup's --build-quest runs the copy staged in an installation's BuildWorkspace\android, # WiiCompiled Setup's --build-quest runs the copy staged in an installation's BuildWorkspace\android,
@@ -32,6 +33,8 @@ param(
[string]$Sysroot = '', [string]$Sysroot = '',
[string]$Ninja = '', [string]$Ninja = '',
[string]$BuiltBy = 'android/Build-QuestGame.ps1', [string]$BuiltBy = 'android/Build-QuestGame.ps1',
[ValidateSet('modern', 'quest1')] [string]$Headset = 'modern',
[ValidateSet('debug', 'release')] [string]$Configuration = 'debug',
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base', [ValidateSet('base', 'retro_rewind')] [string]$Product = 'base',
[string]$Mod = '', [string]$Mod = '',
[int]$TranslatedJobs = 0, [int]$TranslatedJobs = 0,
@@ -44,12 +47,13 @@ $root = $PSScriptRoot
$workspace = (Resolve-Path (Join-Path $root '..')).Path $workspace = (Resolve-Path (Join-Path $root '..')).Path
if (-not $Generated) { $Generated = Join-Path $workspace '.scratch\vr-build-workspace\BuildWorkspace\generated' } if (-not $Generated) { $Generated = Join-Path $workspace '.scratch\vr-build-workspace\BuildWorkspace\generated' }
if (-not $Manifest) { $Manifest = Join-Path $workspace 'projects\mkwii\recomp.yml' } if (-not $Manifest) { $Manifest = Join-Path $workspace 'projects\mkwii\recomp.yml' }
if (-not $BuildDir) { $BuildDir = Join-Path $root "app\build\questGame\$Product" } $variant = if ($Headset -eq 'quest1') { 'quest1' } else { 'modernQuest' }
$variant += (Get-Culture).TextInfo.ToTitleCase($Configuration)
if (-not $BuildDir) { $BuildDir = Join-Path $root "app\build\questGame\$variant\$Product" }
if (-not $Kit) { if (-not $Kit) {
# The kit the last APK build packaged, so the game matches the app it will be imported into. # Select the requested variant explicitly. Modification time is unsafe now that flavours use
$Kit = Get-ChildItem -Path (Join-Path $root 'app\build\generated') -Filter kit.json -Recurse -ErrorAction SilentlyContinue | # different -mcpu targets and an up-to-date native probe is not necessarily the newest one.
Where-Object { $_.Directory.Name -eq 'game_kit' } | $Kit = Join-Path $root "app\build\generated\assets\questGameKit\$variant\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'))) { if (-not $Kit -or -not (Test-Path (Join-Path $Kit 'kit.json'))) {
throw 'No game kit; run Build-Quest.ps1 first, or pass -Kit' throw 'No game kit; run Build-Quest.ps1 first, or pass -Kit'
+4 -3
View File
@@ -8,10 +8,11 @@
param( param(
[Parameter(Mandatory)] [string]$CMakeBinaryDir, [Parameter(Mandatory)] [string]$CMakeBinaryDir,
[Parameter(Mandatory)] [string]$OutputDir, [Parameter(Mandatory)] [string]$OutputDir,
[Parameter(Mandatory)] [string]$LlvmStrip [Parameter(Mandatory)] [string]$LlvmStrip,
[Parameter(Mandatory)] [string]$AndroidCpu
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'QuestGameKit.psm1') -Force Import-Module (Join-Path $PSScriptRoot 'QuestGameKit.psm1') -Force
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$kit = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip $kit = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip -AndroidCpu $AndroidCpu
Write-Host "Quest game kit $($kit.Fingerprint) [$($kit.Products -join ', ')] -> $OutputDir" Write-Host "Quest game kit $($kit.Fingerprint) [$($kit.Products -join ', '), CPU $AndroidCpu] -> $OutputDir"
+29 -1
View File
@@ -268,10 +268,16 @@ function Export-QuestGameKit {
[Parameter(Mandatory)] [string]$CMakeBinaryDir, [Parameter(Mandatory)] [string]$CMakeBinaryDir,
[Parameter(Mandatory)] [string]$OutputDir, [Parameter(Mandatory)] [string]$OutputDir,
[Parameter(Mandatory)] [string]$RepoRoot, [Parameter(Mandatory)] [string]$RepoRoot,
[Parameter(Mandatory)] [string]$LlvmStrip [Parameter(Mandatory)] [string]$LlvmStrip,
[Parameter(Mandatory)] [string]$AndroidCpu
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$binary = ConvertTo-ForwardPath $CMakeBinaryDir $binary = ConvertTo-ForwardPath $CMakeBinaryDir
$cache = [IO.File]::ReadAllText("$binary/CMakeCache.txt")
$configuredCpu = [regex]::Match($cache, '(?m)^MKW_ANDROID_CPU(?::[^=\r\n]*)?=([^\r\n]+)$').Groups[1].Value.Trim()
if ($configuredCpu -ne $AndroidCpu) {
throw "CMake tree $binary targets Android CPU '$configuredCpu', expected '$AndroidCpu'"
}
$runtimeInclude = ConvertTo-ForwardPath (Join-Path $RepoRoot 'runtime/include') $runtimeInclude = ConvertTo-ForwardPath (Join-Path $RepoRoot 'runtime/include')
$ninja = [IO.File]::ReadAllText("$binary/build.ninja") $ninja = [IO.File]::ReadAllText("$binary/build.ninja")
$commands = Read-CompileCommands "$binary/compile_commands.json" $commands = Read-CompileCommands "$binary/compile_commands.json"
@@ -405,6 +411,7 @@ function Export-QuestGameKit {
$recipe = [ordered]@{ $recipe = [ordered]@{
schema = $script:KitSchema schema = $script:KitSchema
androidCpu = $AndroidCpu
runtimeIncludeFingerprint = Get-RuntimeIncludeFingerprint (Join-Path $OutputDir 'include') runtimeIncludeFingerprint = Get-RuntimeIncludeFingerprint (Join-Path $OutputDir 'include')
products = $products products = $products
} }
@@ -445,10 +452,16 @@ function Invoke-QuestGameBuild {
$workspace = ConvertTo-ForwardPath (Split-Path -Parent $generated) $workspace = ConvertTo-ForwardPath (Split-Path -Parent $generated)
$recipe = [IO.File]::ReadAllText("$kit/kit.json") | ConvertFrom-Json $recipe = [IO.File]::ReadAllText("$kit/kit.json") | ConvertFrom-Json
if ($recipe.schema -ne $script:KitSchema) { throw "Unsupported game kit schema $($recipe.schema)" } if ($recipe.schema -ne $script:KitSchema) { throw "Unsupported game kit schema $($recipe.schema)" }
if ([string]::IsNullOrWhiteSpace($recipe.androidCpu)) { throw 'The game kit does not name its Android CPU target' }
if (-not $recipe.products.PSObject.Properties.Name.Contains($Product)) { 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 ', '))" throw "This Quest app's game kit cannot build $Product (it has: $($recipe.products.PSObject.Properties.Name -join ', '))"
} }
$productRecipe = $recipe.products.$Product $productRecipe = $recipe.products.$Product
foreach ($kind in 'translated', 'product', 'runtime', 'asm') {
if (@($productRecipe.compile.$kind) -notcontains "-mcpu=$($recipe.androidCpu)") {
throw "The game kit says CPU '$($recipe.androidCpu)' but its $kind compile flags do not match"
}
}
# The translation must come from the same release as the kit: its code is compiled against the # The translation must come from the same release as the kit: its code is compiled against the
# kit's runtime headers and linked with the kit's runtime objects. # kit's runtime headers and linked with the kit's runtime objects.
$workspaceInclude = Join-Path $workspace 'runtime/include' $workspaceInclude = Join-Path $workspace 'runtime/include'
@@ -460,6 +473,20 @@ function Invoke-QuestGameBuild {
} }
New-Item -ItemType Directory -Force $BuildDir | Out-Null New-Item -ItemType Directory -Force $BuildDir | Out-Null
$build = ConvertTo-ForwardPath $BuildDir $build = ConvertTo-ForwardPath $BuildDir
# Ninja sees the response-file path in each command, not changes to that file's contents. A
# different kit can therefore otherwise reuse objects compiled with another flavour's -mcpu.
# Keep an explicit identity next to the build and discard every reusable native output when it
# changes. WiiCompiled Setup deliberately reuses one BuildDir across APK selections.
$identity = "$($recipe.fingerprint) $Product"
$identityFile = "$build/kit-identity.txt"
$previousIdentity = if (Test-Path $identityFile) { [IO.File]::ReadAllText($identityFile).Trim() } else { '' }
if ($previousIdentity -ne $identity) {
foreach ($stale in 'obj', 'libmain.so', '.ninja_deps', '.ninja_log') {
$path = "$build/$stale"
if (Test-Path $path) { Remove-Item -Recurse -Force $path }
}
}
[IO.File]::WriteAllText($identityFile, $identity, (New-Object Text.UTF8Encoding $false))
$expand = { param([string]$s) $s.Replace('{kit}', $kit).Replace('{sysroot}', (ConvertTo-ForwardPath $Sysroot)).Replace('{workspace}', $workspace) } $expand = { param([string]$s) $s.Replace('{kit}', $kit).Replace('{sysroot}', (ConvertTo-ForwardPath $Sysroot)).Replace('{workspace}', $workspace) }
foreach ($kind in 'translated', 'product', 'runtime', 'asm') { foreach ($kind in 'translated', 'product', 'runtime', 'asm') {
@@ -583,6 +610,7 @@ function New-QuestGamePackage {
dolSha256 = $DolSha256 dolSha256 = $DolSha256
relSha256 = $RelSha256 relSha256 = $RelSha256
kitFingerprint = $recipe.fingerprint kitFingerprint = $recipe.fingerprint
androidCpu = $recipe.androidCpu
library = $recipe.products.$Product.output library = $recipe.products.$Product.output
librarySha256 = Get-Sha256Hex $Library librarySha256 = Get-Sha256Hex $Library
includesData = [bool]$DataDir includesData = [bool]$DataDir
+4 -2
View File
@@ -1,7 +1,7 @@
# WiiCompiled VR for Meta Quest (Android) # WiiCompiled VR for Meta Quest (Android)
Standalone Android/OpenXR build of the Mario Kart Wii recompilation for Quest 2, Standalone Android/OpenXR build of the Mario Kart Wii recompilation for Quest 1,
Quest 3, Quest 3S and Quest Pro. The full design, build walkthrough and current Quest 2, Quest 3, Quest 3S and Quest Pro. The full design, build walkthrough and current
status live in [docs/quest-port.md](../docs/quest-port.md); this directory only status live in [docs/quest-port.md](../docs/quest-port.md); this directory only
holds the Gradle project, its helper scripts, the game kit tooling holds the Gradle project, its helper scripts, the game kit tooling
(`QuestGameKit.psm1`, `Build-QuestGame.ps1`), the on-headset build toolchain (`QuestGameKit.psm1`, `Build-QuestGame.ps1`), the on-headset build toolchain
@@ -10,6 +10,7 @@ holds the Gradle project, its helper scripts, the game kit tooling
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # stages the SDL3 3.4.4 AAR once powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # stages the SDL3 3.4.4 AAR once
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, debug-signed, installs over adb powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, debug-signed, installs over adb
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Headset quest1 -Install # Quest 1 flavour
``` ```
One app offers both games, with a toggle on Home: Mario Kart Wii and, when your One app offers both games, with a toggle on Home: Mario Kart Wii and, when your
@@ -23,6 +24,7 @@ half an hour) or on a PC and imported:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product base -Install powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product base -Install
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Headset quest1 -Product base -Install
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install
``` ```
+8 -3
View File
@@ -3,7 +3,8 @@
# diagnostics for one session. # diagnostics for one session.
# #
# powershell -ExecutionPolicy Bypass -File android/Run-Quest.ps1 [-Apk <path>] [-Data <extracted disc dir>] # powershell -ExecutionPolicy Bypass -File android/Run-Quest.ps1 [-Apk <path>] [-Data <extracted disc dir>]
# [-Seconds 60] [-NoLaunch] [-SkipInstall] # [-Headset modern|quest1] [-Configuration debug|release]
# [-Seconds 60] [-NoLaunch] [-SkipInstall]
# #
# -Data names the extracted disc partition (the directory holding sys/, files/, # -Data names the extracted disc partition (the directory holding sys/, files/,
# disc/ ...). It is pushed once to a staging folder, then moved into the app's # disc/ ...). It is pushed once to a staging folder, then moved into the app's
@@ -24,6 +25,8 @@
param( param(
[string]$Apk = '', [string]$Apk = '',
[string]$Data = '', [string]$Data = '',
[ValidateSet('modern', 'quest1')] [string]$Headset = 'modern',
[ValidateSet('debug', 'release')] [string]$Configuration = 'debug',
[int]$Seconds = 60, [int]$Seconds = 60,
[switch]$NoLaunch, [switch]$NoLaunch,
[switch]$SkipInstall [switch]$SkipInstall
@@ -49,8 +52,10 @@ if (-not $devices) { throw 'No device in "device" state; check the Quest is conn
if (-not $SkipInstall) { if (-not $SkipInstall) {
if (-not $Apk) { if (-not $Apk) {
$Apk = Get-ChildItem -Path (Join-Path $root 'app\build\outputs\apk') -Recurse -Filter '*.apk' | $flavour = if ($Headset -eq 'quest1') { 'quest1' } else { 'modernQuest' }
Sort-Object LastWriteTime -Descending | Select-Object -First 1 | ForEach-Object FullName $apkDir = Join-Path $root "app\build\outputs\apk\$flavour\$Configuration"
$Apk = Get-ChildItem -Path $apkDir -Filter '*.apk' -ErrorAction SilentlyContinue |
Select-Object -First 1 | ForEach-Object FullName
} }
if (-not $Apk -or -not (Test-Path $Apk)) { throw 'No APK found; run Build-Quest.ps1 first or pass -Apk' } if (-not $Apk -or -not (Test-Path $Apk)) { throw 'No APK found; run Build-Quest.ps1 first or pass -Apk' }
Write-Host "Installing $Apk" Write-Host "Installing $Apk"
+39 -6
View File
@@ -1,4 +1,5 @@
import javax.inject.Inject import javax.inject.Inject
import org.gradle.api.provider.Property
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
@@ -145,6 +146,7 @@ android {
dimension = "headset" dimension = "headset"
manifestPlaceholders["mkwQuestSupportedDevices"] = "quest2|quest3|quest3s|questpro" manifestPlaceholders["mkwQuestSupportedDevices"] = "quest2|quest3|quest3s|questpro"
buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "false") buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "false")
buildConfigField("String", "ANDROID_CPU", "\"cortex-a77\"")
externalNativeBuild { externalNativeBuild {
cmake { cmake {
// Snapdragon XR2 and newer. Keep this as the default build target. // Snapdragon XR2 and newer. Keep this as the default build target.
@@ -156,6 +158,7 @@ android {
dimension = "headset" dimension = "headset"
manifestPlaceholders["mkwQuestSupportedDevices"] = "quest|quest2" manifestPlaceholders["mkwQuestSupportedDevices"] = "quest|quest2"
buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "true") buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "true")
buildConfigField("String", "ANDROID_CPU", "\"kryo\"")
externalNativeBuild { externalNativeBuild {
cmake { cmake {
// Snapdragon 835. cortex-a77 binaries terminate with SIGILL on Quest 1. // Snapdragon 835. cortex-a77 binaries terminate with SIGILL on Quest 1.
@@ -221,6 +224,12 @@ abstract class ExportQuestGameKit : DefaultTask() {
@get:Internal @get:Internal
abstract val llvmStrip: RegularFileProperty abstract val llvmStrip: RegularFileProperty
@get:Input
abstract val androidCpu: Property<String>
@get:Input
abstract val cmakeBuildType: Property<String>
@get:Inject @get:Inject
abstract val execOperations: ExecOperations abstract val execOperations: ExecOperations
@@ -232,18 +241,39 @@ abstract class ExportQuestGameKit : DefaultTask() {
@TaskAction @TaskAction
fun export() { fun export() {
val app = appDir.get().asFile val app = appDir.get().asFile
val probe = File(app, "build/intermediates/cxx").walkTopDown() data class Candidate(val probe: File, val binaryDir: File, val cpu: String?, val buildType: String?)
fun cacheValue(cache: String, name: String): String? =
Regex("(?m)^${Regex.escape(name)}(?::[^=\\r\\n]*)?=([^\\r\\n]*)$")
.find(cache)?.groupValues?.get(1)?.trim()
val candidates = File(app, "build/intermediates/cxx").walkTopDown()
.filter { it.name == "libmkw_quest_kit_probe.so" && it.parentFile.name == "arm64-v8a" } .filter { it.name == "libmkw_quest_kit_probe.so" && it.parentFile.name == "arm64-v8a" }
.maxByOrNull { it.lastModified() } .map { 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 // <Variant>/<hash>
val configuration = probe.parentFile.parentFile.parentFile // <BuildType>/<hash> val binaryDir = File(app, ".cxx/${configuration.parentFile.name}/${configuration.name}/arm64-v8a")
val binaryDir = File(app, ".cxx/${configuration.parentFile.name}/${configuration.name}/arm64-v8a") val cacheFile = File(binaryDir, "CMakeCache.txt")
if (!File(binaryDir, "build.ninja").isFile) throw GradleException("No CMake tree at $binaryDir") val cache = cacheFile.takeIf { it.isFile }?.readText().orEmpty()
Candidate(probe, binaryDir, cacheValue(cache, "MKW_ANDROID_CPU"), cacheValue(cache, "CMAKE_BUILD_TYPE"))
}
.toList()
val expectedCpu = androidCpu.get()
val expectedBuildType = cmakeBuildType.get()
val matching = candidates.filter {
it.cpu == expectedCpu && it.buildType.equals(expectedBuildType, ignoreCase = true) &&
File(it.binaryDir, "build.ninja").isFile
}
val chosen = matching.maxByOrNull { it.probe.lastModified() }
?: throw GradleException(
"No $expectedBuildType CMake tree for Android CPU $expectedCpu. Found: " +
candidates.joinToString { "${it.binaryDir} (cpu=${it.cpu}, type=${it.buildType})" }
)
val binaryDir = chosen.binaryDir
val kitDir = File(outputDir.get().asFile, "game_kit") val kitDir = File(outputDir.get().asFile, "game_kit")
execOperations.exec { execOperations.exec {
commandLine( commandLine(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.get().asFile.path, "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.get().asFile.path,
"-CMakeBinaryDir", binaryDir.path, "-OutputDir", kitDir.path, "-LlvmStrip", llvmStrip.get().asFile.path, "-CMakeBinaryDir", binaryDir.path, "-OutputDir", kitDir.path, "-LlvmStrip", llvmStrip.get().asFile.path,
"-AndroidCpu", expectedCpu,
) )
} }
} }
@@ -293,6 +323,9 @@ androidComponents {
dependsOn("merge${capitalized}NativeLibs") dependsOn("merge${capitalized}NativeLibs")
appDir.set(layout.projectDirectory) appDir.set(layout.projectDirectory)
script.set(rootProject.layout.projectDirectory.file("Export-QuestGameKit.ps1")) script.set(rootProject.layout.projectDirectory.file("Export-QuestGameKit.ps1"))
androidCpu.set(if (variant.name.startsWith("quest1", ignoreCase = true)) "kryo" else "cortex-a77")
cmakeBuildType.set(variant.buildType)
outputDir.set(layout.buildDirectory.dir("generated/assets/questGameKit/${variant.name}"))
llvmStrip.set(sdkComponents.ndkDirectory.map { llvmStrip.set(sdkComponents.ndkDirectory.map {
it.file("toolchains/llvm/prebuilt/$host/bin/llvm-strip" + if (host.startsWith("windows")) ".exe" else "") it.file("toolchains/llvm/prebuilt/$host/bin/llvm-strip" + if (host.startsWith("windows")) ".exe" else "")
}) })
@@ -50,6 +50,8 @@ object GameLibrary {
if (manifest == null || !library(context, profile).isFile) { if (manifest == null || !library(context, profile).isFile) {
return Status.Missing return Status.Missing
} }
return if (manifest.optString("kitFingerprint") == kitFingerprint(context)) Status.Ready else Status.Stale return if (manifest.optString("kitFingerprint") == kitFingerprint(context) &&
manifest.optString("androidCpu") == BuildConfig.ANDROID_CPU
) Status.Ready else Status.Stale
} }
} }
@@ -133,6 +133,9 @@ object GameBuild {
if (kitJson.optInt("schema") != KIT_SCHEMA) { if (kitJson.optInt("schema") != KIT_SCHEMA) {
return "This app's game kit is version ${kitJson.optInt("schema")}, which this builder does not know." return "This app's game kit is version ${kitJson.optInt("schema")}, which this builder does not know."
} }
if (kitJson.optString("androidCpu") != BuildConfig.ANDROID_CPU) {
return "This app contains a game kit for ${kitJson.optString("androidCpu")}, but this build requires ${BuildConfig.ANDROID_CPU}."
}
val recipe = kitJson.getJSONObject("products").optJSONObject(profile.id) val recipe = kitJson.getJSONObject("products").optJSONObject(profile.id)
?: return "This app's game kit cannot build ${profile.id}." ?: return "This app's game kit cannot build ${profile.id}."
report(40, Step.Prepare) report(40, Step.Prepare)
@@ -570,6 +573,7 @@ object GameBuild {
.put("dolSha256", BuildConfig.DISC_DOL_SHA256) .put("dolSha256", BuildConfig.DISC_DOL_SHA256)
.put("relSha256", BuildConfig.DISC_REL_SHA256) .put("relSha256", BuildConfig.DISC_REL_SHA256)
.put("kitFingerprint", kitFingerprint) .put("kitFingerprint", kitFingerprint)
.put("androidCpu", BuildConfig.ANDROID_CPU)
.put("library", GameLibrary.LIBRARY_NAME) .put("library", GameLibrary.LIBRARY_NAME)
.put("librarySha256", BuildRecipe.hex(digest.digest())) .put("librarySha256", BuildRecipe.hex(digest.digest()))
.put("includesData", false) .put("includesData", false)
@@ -157,6 +157,9 @@ object GamePackageImport {
if (manifest.optString("kitFingerprint") != GameLibrary.kitFingerprint(context)) { if (manifest.optString("kitFingerprint") != GameLibrary.kitFingerprint(context)) {
return "This game file was built for a different version of this app. Build it again against the version installed on the headset." return "This game file was built for a different version of this app. Build it again against the version installed on the headset."
} }
if (manifest.optString("androidCpu") != BuildConfig.ANDROID_CPU) {
return "This game file targets ${manifest.optString("androidCpu")}, but this app requires ${BuildConfig.ANDROID_CPU}. Build it again for the correct headset."
}
val expected = manifest.optString("librarySha256") val expected = manifest.optString("librarySha256")
if (librarySha256 == null) { if (librarySha256 == null) {
return "This game file has no game library (libmain.so)." return "This game file has no game library (libmain.so)."
+1 -8
View File
@@ -154,13 +154,6 @@ elseif (_aurora_dawn_provider STREQUAL "package")
endif () endif ()
set(AURORA_DAWN_PACKAGE_URL set(AURORA_DAWN_PACKAGE_URL
"https://github.com/theofficialgman/dawn-build/releases/download/${AURORA_DAWN_VERSION}/dawn-${_dawn_system}-${_dawn_arch}.tar.gz") "https://github.com/theofficialgman/dawn-build/releases/download/${AURORA_DAWN_VERSION}/dawn-${_dawn_system}-${_dawn_arch}.tar.gz")
# Meta's Quest 1 firmware advertises VK_EXT_debug_utils but rejects it at
# vkCreateInstance. This pinned Android package disables that extension on
# Android; it is also the package validated on modern Quest headsets.
if (_dawn_system STREQUAL "android" AND _dawn_arch STREQUAL "aarch64")
set(AURORA_DAWN_PACKAGE_URL
"https://github.com/encounter/dawn-build/releases/download/${AURORA_DAWN_VERSION}/dawn-android-aarch64.tar.gz")
endif ()
# A release asset is mutable: the same tag has already served two different windows-amd64 archives, # A release asset is mutable: the same tag has already served two different windows-amd64 archives,
# and a cached extraction is never re-verified. Pin the digest for the combinations we ship. # and a cached extraction is never re-verified. Pin the digest for the combinations we ship.
@@ -188,7 +181,7 @@ elseif (_aurora_dawn_provider STREQUAL "package")
"SHA256=f97701d26fd1f25bbcc260b4c31736ede134c730c12556029e2470fde967f424") "SHA256=f97701d26fd1f25bbcc260b4c31736ede134c730c12556029e2470fde967f424")
elseif (_dawn_system STREQUAL "android" AND _dawn_arch STREQUAL "aarch64") elseif (_dawn_system STREQUAL "android" AND _dawn_arch STREQUAL "aarch64")
set(AURORA_DAWN_PACKAGE_URL_HASH set(AURORA_DAWN_PACKAGE_URL_HASH
"SHA256=27d910dee1201fd1e5b6ac567f0ba2306ebf2135e9f40b6929976c365d38b09b") "SHA256=0e63e8cbf53551f703f582d1306f4257c0380353f66b53369d96952ce6d9f934")
endif () endif ()
endif () endif ()
endif () endif ()
+31 -24
View File
@@ -10,6 +10,7 @@
#include <atomic> #include <atomic>
#include <chrono> #include <chrono>
#include <condition_variable> #include <condition_variable>
#include <cstring>
#include <deque> #include <deque>
#include <filesystem> #include <filesystem>
#include <limits> #include <limits>
@@ -26,6 +27,7 @@
#if defined(__ANDROID__) #if defined(__ANDROID__)
#include <pthread.h> #include <pthread.h>
#include <sys/resource.h> #include <sys/resource.h>
#include <sys/system_properties.h>
#endif #endif
namespace aurora::gfx { namespace aurora::gfx {
@@ -70,26 +72,27 @@ constexpr size_t MaxQueuedPipelineBuilds = 256;
// First-use compilation works best as a short parallel burst. Leave two logical processors for the // First-use compilation works best as a short parallel burst. Leave two logical processors for the
// render and game threads, and cap large hosts to limit driver submissions and memory use. // render and game threads, and cap large hosts to limit driver submissions and memory use.
constexpr size_t ReservedLogicalProcessors = 2; constexpr size_t ReservedLogicalProcessors = 2;
#if defined(__ANDROID__)
// Quest reports all eight Kryo cores, but the Adreno Vulkan driver serializes
// much of vkCreateGraphicsPipelines. Six equal-priority compiler threads crowd
// out the translated game and XR submission threads without materially
// shortening first-use compilation. Two background-priority workers keep
// pipeline discovery asynchronous while preserving frame cadence.
constexpr size_t MaxPipelineWorkers = 2;
#else
constexpr size_t MaxPipelineWorkers = 22; constexpr size_t MaxPipelineWorkers = 22;
#endif constexpr size_t Quest1MaxPipelineWorkers = 2;
// Cached clear and GX pipelines are normally prewarmed using the full worker static bool g_quest1PipelineScheduling = false;
// pool. Mobile Adreno drivers serialize much of pipeline creation internally;
// prewarming hundreds of recipes there starves first-use pipelines for over a static bool quest1_pipeline_scheduling() noexcept {
// minute. Keep cached recipes dormant on Android. A recipe is promoted to the
// priority queue as soon as the game actually requests it.
#if defined(__ANDROID__) #if defined(__ANDROID__)
constexpr size_t MaxBackgroundPipelineWorkers = 0; static const bool enabled = [] {
char device[PROP_VALUE_MAX]{};
return __system_property_get("ro.product.device", device) > 0 && std::strcmp(device, "monterey") == 0;
}();
return enabled;
#else #else
constexpr size_t MaxBackgroundPipelineWorkers = MaxPipelineWorkers; return false;
#endif #endif
}
static size_t max_background_pipeline_workers() noexcept {
// Quest 1's Adreno driver serializes pipeline creation. Leave cached recipes
// dormant until first use there; all newer headsets retain normal prewarm.
return g_quest1PipelineScheduling ? 0 : MaxPipelineWorkers;
}
// For synchronous pipeline fallback (OpenGL) // For synchronous pipeline fallback (OpenGL)
#ifdef NDEBUG #ifdef NDEBUG
constexpr size_t BuildPipelinesPerFrame = 5; constexpr size_t BuildPipelinesPerFrame = 5;
@@ -484,8 +487,8 @@ static PipelineRef find_pipeline_impl(ShaderType type, const PipelineConfig& con
} }
} else if (g_pendingPipelines.contains(hash)) { } else if (g_pendingPipelines.contains(hash)) {
auto* pending = touch_pending_pipeline(hash, g_pipelineFrameActive); auto* pending = touch_pending_pipeline(hash, g_pipelineFrameActive);
// A cached Android recipe can sit dormant because background prewarm is // A cached recipe can sit dormant when background prewarm is disabled.
// disabled. Promoting it to first-use priority must wake a worker before // Promoting it to first-use priority must wake a worker before
// bind_pipeline waits for completion, or both threads sleep forever. // bind_pipeline waits for completion, or both threads sleep forever.
if (g_pipelineFrameActive && deferGxPipeline) { if (g_pipelineFrameActive && deferGxPipeline) {
notifyWorker = true; notifyWorker = true;
@@ -1063,9 +1066,11 @@ static void pipeline_worker() {
#endif #endif
#if defined(__ANDROID__) #if defined(__ANDROID__)
pthread_setname_np(pthread_self(), "GXPipeline"); pthread_setname_np(pthread_self(), "GXPipeline");
// setpriority(PRIO_PROCESS, 0, ...) targets the calling Linux thread. if (g_quest1PipelineScheduling) {
// Pipeline creation may finish later, but must not preempt gameplay or XR. // setpriority(PRIO_PROCESS, 0, ...) targets the calling Linux thread.
setpriority(PRIO_PROCESS, 0, 5); // Quest 1 pipeline creation must not preempt gameplay or XR.
setpriority(PRIO_PROCESS, 0, 5);
}
#endif #endif
while (true) { while (true) {
@@ -1076,7 +1081,7 @@ static void pipeline_worker() {
g_pipelineCv.wait(lock, [] { g_pipelineCv.wait(lock, [] {
return !g_priorityPipelines.empty() || return !g_priorityPipelines.empty() ||
(!g_backgroundPipelines.empty() && (!g_backgroundPipelines.empty() &&
g_activeBackgroundPipelineWorkers < MaxBackgroundPipelineWorkers) || g_activeBackgroundPipelineWorkers < max_background_pipeline_workers()) ||
g_pipelineThreadEnd; g_pipelineThreadEnd;
}); });
if (g_pipelineThreadEnd) { if (g_pipelineThreadEnd) {
@@ -1125,7 +1130,8 @@ static size_t pipeline_worker_count() {
} }
const size_t availableWorkers = const size_t availableWorkers =
logicalProcessors > ReservedLogicalProcessors ? logicalProcessors - ReservedLogicalProcessors : 1; logicalProcessors > ReservedLogicalProcessors ? logicalProcessors - ReservedLogicalProcessors : 1;
return std::clamp(availableWorkers, size_t{1}, MaxPipelineWorkers); const size_t maximum = g_quest1PipelineScheduling ? Quest1MaxPipelineWorkers : MaxPipelineWorkers;
return std::clamp(availableWorkers, size_t{1}, maximum);
} }
template <typename PipelineConfig, typename CreateFn> template <typename PipelineConfig, typename CreateFn>
@@ -1286,6 +1292,7 @@ void initialize_pipeline_cache() {
g_pipelineFrameActive = false; g_pipelineFrameActive = false;
g_pipelineThreadEnd = false; g_pipelineThreadEnd = false;
g_activeBackgroundPipelineWorkers = 0; g_activeBackgroundPipelineWorkers = 0;
g_quest1PipelineScheduling = quest1_pipeline_scheduling();
if (webgpu::g_backendType == wgpu::BackendType::OpenGL || webgpu::g_backendType == wgpu::BackendType::OpenGLES || if (webgpu::g_backendType == wgpu::BackendType::OpenGL || webgpu::g_backendType == wgpu::BackendType::OpenGLES ||
webgpu::g_backendType == wgpu::BackendType::WebGPU) { webgpu::g_backendType == wgpu::BackendType::WebGPU) {
@@ -1298,7 +1305,7 @@ void initialize_pipeline_cache() {
g_pipelineThreads.emplace_back(pipeline_worker); g_pipelineThreads.emplace_back(pipeline_worker);
} }
Log.info("Enabled {} priority pipeline compilation workers ({} background prewarm)", Log.info("Enabled {} priority pipeline compilation workers ({} background prewarm)",
workerCount, MaxBackgroundPipelineWorkers); workerCount, max_background_pipeline_workers());
} }
load_pipeline_cache(); load_pipeline_cache();
+9 -7
View File
@@ -110,9 +110,10 @@ format-converting paths.
Quest 1 must be built with the `quest1` flavour (`Build-Quest.ps1 -Headset Quest 1 must be built with the `quest1` flavour (`Build-Quest.ps1 -Headset
quest1`). It targets the Snapdragon 835's Kryo CPU; the modern flavour targets quest1`). It targets the Snapdragon 835's Kryo CPU; the modern flavour targets
`cortex-a77`, whose instructions can terminate the game with `SIGILL` on Quest `cortex-a77`, whose instructions can terminate the game with `SIGILL` on Quest
1. Android builds use a pinned Dawn package which does not enable 1. Each variant's exported game kit records this CPU target, and the APK build,
`VK_EXT_debug_utils`: the old Quest firmware advertises that extension but PC game build, on-headset build and package import all verify it. This prevents
rejects it when Dawn creates the Vulkan instance. a kit left by another flavour from producing a library that can crash with
`SIGILL`.
The final Quest 1 firmware also cannot reliably promote this app's 2D setup The final Quest 1 firmware also cannot reliably promote this app's 2D setup
panel into an immersive activity. Its APK therefore exposes two library panel into an immersive activity. Its APK therefore exposes two library
@@ -128,13 +129,13 @@ output from flickering black or white frames to a complete menu with character
and vehicle previews. It does intentionally skip Wii-era RGB5A3 quantization on and vehicle previews. It does intentionally skip Wii-era RGB5A3 quantization on
that device. that device.
Pipeline compilation is also scheduled differently on Android. Adreno Pipeline compilation is also scheduled differently on Quest 1. Its Adreno
serializes much of `vkCreateGraphicsPipelines`, so a large equal-priority worker serializes much of `vkCreateGraphicsPipelines`, so a large equal-priority worker
pool starved the translated game and OpenXR pacing threads without shortening pool starved the translated game and OpenXR pacing threads without shortening
the compile materially. Android uses two nice-level 5 workers and does not the compile materially. Quest 1 uses two nice-level 5 workers and does not
prewarm cached recipes in the background; a cached recipe is promoted and its prewarm cached recipes in the background; a cached recipe is promoted and its
workers are awakened when the game first requests it. Desktop keeps the full workers are awakened when the game first requests it. Newer Android headsets
worker pool and prewarm behavior. and desktop retain the normal worker pool and prewarm behavior.
### Controllers ### Controllers
@@ -502,6 +503,7 @@ powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, its game kit and toolchain, debug-signed powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, its game kit and toolchain, debug-signed
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Headset quest1 -Install # Quest 1: Kryo CPU and direct-VR library entry powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Headset quest1 -Install # Quest 1: Kryo CPU and direct-VR library entry
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Install # your game, against that kit, into Import (or WheelWizard VR's Build for Quest) powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Install # your game, against that kit, into Import (or WheelWizard VR's Build for Quest)
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Headset quest1 -Install # game package from the Quest 1 kit
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install # the mod and its pack (needs translate-mod output) powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -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 adb push MarioKart.iso /sdcard/Download/ # then Select disc image in the launcher
``` ```