Merge PR #2 from darthcircuit: Quest 1 support

Adds the modernQuest and quest1 headset flavours (Kryo CPU target, direct-VR
library entry on Quest 1), records the CPU target in the game kit and checks
it wherever a kit or game package is used, and gates the Quest 1 EFB-copy and
pipeline-scheduling workarounds on the monterey device.

Resolved docs/quest-port.md by keeping both sides: the foveated rendering
section and the Quest 1 renderer compatibility section, and both Build-Quest.ps1
command lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
iChris4andClaude Opus 5.5 committed 2026-09-26 20:31:49 +02:00
commit 83805a410e
21 files changed
+431 -55

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")) ||
+19 -6
View File
@@ -1,7 +1,7 @@
# Builds the standalone Meta Quest APK on a Windows host. # Builds the standalone Meta Quest APK on a Windows host.
# #
# powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 [-Generated <dir>] # powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 [-Generated <dir>]
# [-Configuration debug|release] [-Install] # [-Headset modern|quest1] [-Configuration debug|release] [-Install]
# #
# One app offers both games: the APK carries a kit for the base game, and for Retro Rewind too # One app offers both games: the APK carries a kit for the base game, and for Retro Rewind too
# when -Generated holds a translation that includes the mod. # when -Generated holds a translation that includes the mod.
@@ -25,6 +25,7 @@ param(
[string]$Generated = '', [string]$Generated = '',
[string]$Dependencies = '', [string]$Dependencies = '',
[string]$CMakeDir = '', [string]$CMakeDir = '',
[ValidateSet('modern', 'quest1')] [string]$Headset = 'modern',
[ValidateSet('debug', 'release')] [string]$Configuration = 'debug', [ValidateSet('debug', 'release')] [string]$Configuration = 'debug',
[switch]$Install, [switch]$Install,
[switch]$StockDawn [switch]$StockDawn
@@ -79,7 +80,9 @@ if (-not $Dependencies) {
} }
$variant = (Get-Culture).TextInfo.ToTitleCase($Configuration) $variant = (Get-Culture).TextInfo.ToTitleCase($Configuration)
$task = "app:assemble$variant" $flavour = if ($Headset -eq 'quest1') { 'Quest1' } else { 'ModernQuest' }
$expectedCpu = if ($Headset -eq 'quest1') { 'kryo' } else { 'cortex-a77' }
$task = "app:assemble$flavour$variant"
$gradleArgs = @( $gradleArgs = @(
'--project-dir', $root, '--project-dir', $root,
"-PmkwGeneratedDir=$Generated" "-PmkwGeneratedDir=$Generated"
@@ -95,7 +98,8 @@ Write-Host "gradlew $($gradleArgs -join ' ')"
& (Join-Path $root 'gradlew.bat') @gradleArgs & (Join-Path $root 'gradlew.bat') @gradleArgs
if ($LASTEXITCODE -ne 0) { throw "Gradle failed ($LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "Gradle failed ($LASTEXITCODE)" }
$apkDir = Join-Path $root "app\build\outputs\apk\$Configuration" $flavourDir = if ($Headset -eq 'quest1') { 'quest1' } else { 'modernQuest' }
$apkDir = Join-Path $root "app\build\outputs\apk\$flavourDir\$Configuration"
$apk = Get-ChildItem -Path $apkDir -Filter '*.apk' | Select-Object -First 1 $apk = Get-ChildItem -Path $apkDir -Filter '*.apk' | Select-Object -First 1
if (-not $apk) { throw "No APK under $apkDir" } if (-not $apk) { throw "No APK under $apkDir" }
Write-Host "APK: $($apk.FullName)" Write-Host "APK: $($apk.FullName)"
@@ -106,16 +110,25 @@ 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) {
$adb = Join-Path $sdkRoot 'platform-tools\adb.exe' $adb = Join-Path $sdkRoot 'platform-tools\adb.exe'
& $adb install -r $apk.FullName & $adb install -r $apk.FullName
if ($LASTEXITCODE -ne 0) { throw "adb install failed ($LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "adb install failed ($LASTEXITCODE)" }
Write-Host 'Installed. Build a game with Build on this Quest in the launcher, or on this PC with android/Build-QuestGame.ps1 -Install.' if ($Headset -eq 'quest1') {
Write-Host 'Installed. Use WiiCompiled Settings for setup, then launch WiiCompiled VR directly from the library.'
} else {
Write-Host 'Installed. Build a game with Build on this Quest in the launcher, or on this PC with android/Build-QuestGame.ps1 -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]+)\r?$').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') {
@@ -596,6 +623,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"
+59 -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,34 @@ android {
} }
} }
flavorDimensions += "headset"
productFlavors {
create("modernQuest") {
dimension = "headset"
manifestPlaceholders["mkwQuestSupportedDevices"] = "quest2|quest3|quest3s|questpro"
buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "false")
buildConfigField("String", "ANDROID_CPU", "\"cortex-a77\"")
externalNativeBuild {
cmake {
// Snapdragon XR2 and newer. Keep this as the default build target.
arguments += "-DMKW_ANDROID_CPU=cortex-a77"
}
}
}
create("quest1") {
dimension = "headset"
manifestPlaceholders["mkwQuestSupportedDevices"] = "quest|quest2"
buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "true")
buildConfigField("String", "ANDROID_CPU", "\"kryo\"")
externalNativeBuild {
cmake {
// Snapdragon 835. cortex-a77 binaries terminate with SIGILL on Quest 1.
arguments += "-DMKW_ANDROID_CPU=kryo"
}
}
}
}
externalNativeBuild { externalNativeBuild {
cmake { cmake {
// aurora-main needs CMake 3.25+, newer than the SDK's bundled 3.22.1; // aurora-main needs CMake 3.25+, newer than the SDK's bundled 3.22.1;
@@ -201,6 +230,9 @@ abstract class ExportQuestGameKit : DefaultTask() {
@get:Internal @get:Internal
abstract val llvmStrip: RegularFileProperty abstract val llvmStrip: RegularFileProperty
@get:Input
abstract val androidCpu: Property<String>
@get:Inject @get:Inject
abstract val execOperations: ExecOperations abstract val execOperations: ExecOperations
@@ -212,18 +244,37 @@ 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?)
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"))
}
.toList()
val expectedCpu = androidCpu.get()
val matching = candidates.filter {
it.cpu == expectedCpu && File(it.binaryDir, "build.ninja").isFile
}
val chosen = matching.maxByOrNull { it.probe.lastModified() }
?: throw GradleException(
"No CMake tree for Android CPU $expectedCpu. Found: " +
candidates.joinToString { "${it.binaryDir} (cpu=${it.cpu})" }
)
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,
) )
} }
} }
@@ -273,6 +324,8 @@ 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")
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 "")
}) })
+3 -11
View File
@@ -50,12 +50,13 @@
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.WiiCompiled.Quest"> android:theme="@style/Theme.WiiCompiled.Quest">
<meta-data android:name="com.oculus.supportedDevices" android:value="quest2|quest3|quest3s|questpro" /> <meta-data android:name="com.oculus.supportedDevices" android:value="${mkwQuestSupportedDevices}" />
<meta-data android:name="com.oculus.vr.focusaware" android:value="true" /> <meta-data android:name="com.oculus.vr.focusaware" android:value="true" />
<!-- Let the runtime move CPU budget to the GPU; Aurora's replay is GPU-bound. --> <!-- Let the runtime move CPU budget to the GPU; Aurora's replay is GPU-bound. -->
<meta-data android:name="com.oculus.trade_cpu_for_gpu_amount" android:value="-1" /> <meta-data android:name="com.oculus.trade_cpu_for_gpu_amount" android:value="-1" />
<!-- The app opens on this 2D panel: Play starts QuestActivity, Settings edits Config.toml. --> <!-- The setup panel: Play starts QuestActivity on modern Horizon OS; the
Quest 1 flavour exposes it as a separate Settings library entry. -->
<activity <activity
android:name=".launcher.LauncherActivity" android:name=".launcher.LauncherActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|smallestScreenSize|uiMode|density" android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|smallestScreenSize|uiMode|density"
@@ -68,10 +69,6 @@
android:defaultHeight="640dp" android:defaultHeight="640dp"
android:minWidth="760dp" android:minWidth="760dp"
android:minHeight="520dp" /> android:minHeight="520dp" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity> </activity>
<!-- Extracts the player's disc image and imports games built on a PC (launcher process). --> <!-- Extracts the player's disc image and imports games built on a PC (launcher process). -->
@@ -97,11 +94,6 @@
<property <property
android:name="android.window.PROPERTY_XR_ACTIVITY_START_MODE" android:name="android.window.PROPERTY_XR_ACTIVITY_START_MODE"
android:value="XR_ACTIVITY_START_MODE_FULL_SPACE_UNMANAGED" /> android:value="XR_ACTIVITY_START_MODE_FULL_SPACE_UNMANAGED" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="com.oculus.intent.category.VR" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity> </activity>
</application> </application>
@@ -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
} }
} }
@@ -140,6 +140,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)
@@ -646,6 +649,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)."
@@ -815,6 +815,10 @@ class LauncherActivity : Activity() {
} }
private fun play() { private fun play() {
if (BuildConfig.QUEST1_DIRECT_LAUNCH) {
Toast.makeText(this, R.string.home_quest1_launch_from_library, Toast.LENGTH_LONG).show()
return
}
if (launching) { if (launching) {
return return
} }
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">WiiCompiled VR</string> <string name="app_name">WiiCompiled VR</string>
<string name="quest1_settings_name">WiiCompiled Settings</string>
<string name="missing_game_data_title">Game data not found</string> <string name="missing_game_data_title">Game data not found</string>
<string name="missing_game_data_message">Copy the extracted DATA directory of your own PAL RMCP01 Mario Kart Wii disc to:\n\n%1$s\n\nFor example with adb: adb push DATA /sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA\n\nThen start the app again.</string> <string name="missing_game_data_message">Copy the extracted DATA directory of your own PAL RMCP01 Mario Kart Wii disc to:\n\n%1$s\n\nFor example with adb: adb push DATA /sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA\n\nThen start the app again.</string>
@@ -102,6 +103,7 @@
<string name="home_replace_title">Replace the game files?</string> <string name="home_replace_title">Replace the game files?</string>
<string name="home_replace_message">Your current DATA folder stays until the new disc image has been checked and completely extracted.</string> <string name="home_replace_message">Your current DATA folder stays until the new disc image has been checked and completely extracted.</string>
<string name="home_launch_failed">The game could not be started.</string> <string name="home_launch_failed">The game could not be started.</string>
<string name="home_quest1_launch_from_library">Quest 1 cannot switch this settings panel into VR. Close it, then launch WiiCompiled VR from your library.</string>
<string name="home_close_game_first">Close the game first.</string> <string name="home_close_game_first">Close the game first.</string>
<string name="home_reset">Reset installation</string> <string name="home_reset">Reset installation</string>
<string name="home_reset_title">Reset the installation?</string> <string name="home_reset_title">Reset the installation?</string>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Modern Horizon OS can move from this 2D setup panel into immersive VR. -->
<activity android:name=".launcher.LauncherActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".QuestActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="com.oculus.intent.category.VR" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Quest 1's final Horizon OS cannot promote a running 2D panel to an
immersive activity. Put the game itself in the library instead. -->
<activity android:name=".QuestActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="com.oculus.intent.category.VR" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter android:priority="1">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity-alias
android:name=".SettingsLauncher"
android:exported="true"
android:label="@string/quest1_settings_name"
android:targetActivity=".launcher.LauncherActivity">
<intent-filter android:priority="-1">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
</application>
</manifest>
+53 -5
View File
@@ -15,8 +15,25 @@
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring>
#if defined(__ANDROID__)
#include <sys/system_properties.h>
#endif
namespace { namespace {
#if defined(__ANDROID__)
bool quest1_direct_display_copy() noexcept {
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
constexpr bool quest1_direct_display_copy() noexcept { return false; }
#endif
struct CopyClearState { struct CopyClearState {
bool clearColor = false; bool clearColor = false;
bool clearAlpha = false; bool clearAlpha = false;
@@ -417,7 +434,15 @@ void GXCopyDisp(void* dest, GXBool clear) {
const auto logicalDstHeight = std::max<u32>( const auto logicalDstHeight = std::max<u32>(
g_gxState.dispCopyDstHeight != 0 ? g_gxState.dispCopyDstHeight : static_cast<u32>(g_gxState.dispCopySrc.height), g_gxState.dispCopyDstHeight != 0 ? g_gxState.dispCopyDstHeight : static_cast<u32>(g_gxState.dispCopySrc.height),
1); 1);
const auto [dstWidth, dstHeight] = scale_copy_dst(logicalDstWidth, logicalDstHeight); auto [dstWidth, dstHeight] = scale_copy_dst(logicalDstWidth, logicalDstHeight);
if (quest1_direct_display_copy()) {
// The Quest 1's Adreno 540 driver renders Aurora's filtered display
// copy as uniform black/white. Keeping the destination identical to the
// resolved EFB region selects WebGPU's plain CopyTextureToTexture path;
// the later presentation pass performs the final screen scaling.
dstWidth = static_cast<u32>(std::max(rect.width, 1));
dstHeight = static_cast<u32>(std::max(rect.height, 1));
}
if (!g_gxState.displayCopyTexture || g_gxState.displayCopyWidth != dstWidth || if (!g_gxState.displayCopyTexture || g_gxState.displayCopyWidth != dstWidth ||
g_gxState.displayCopyHeight != dstHeight) { g_gxState.displayCopyHeight != dstHeight) {
@@ -431,6 +456,15 @@ void GXCopyDisp(void* dest, GXBool clear) {
if (aurora::g_config.disableCopyFilter) { if (aurora::g_config.disableCopyFilter) {
copyFilter = {0, copyFilter[0] + copyFilter[1] + copyFilter[2], 0}; copyFilter = {0, copyFilter[0] + copyFilter[1] + copyFilter[2], 0};
} }
if (quest1_direct_display_copy()) {
copyFilter = {0, 64, 0};
static bool questDirectDisplayCopyLogged = false;
if (!questDirectDisplayCopyLogged) {
questDirectDisplayCopyLogged = true;
std::fprintf(stderr, "[gx] Quest 1 direct display copy: EFB rect %dx%d at %d,%d -> %ux%u\n",
rect.width, rect.height, rect.x, rect.y, dstWidth, dstHeight);
}
}
aurora::gfx::resolve_pass(g_gxState.displayCopyTexture, rect, clearState.clearColor, clearState.clearAlpha, aurora::gfx::resolve_pass(g_gxState.displayCopyTexture, rect, clearState.clearColor, clearState.clearAlpha,
clearState.clearDepth, clearState.clearColorValue, aurora::gx::clear_depth_value(), clearState.clearDepth, clearState.clearColorValue, aurora::gx::clear_depth_value(),
GX_TF_RGBA8, nullptr, false, &copyFilter, false, GX_TF_RGBA8, nullptr, false, &copyFilter, false,
@@ -450,11 +484,21 @@ void GXCopyTex(void* dest, GXBool clear) {
// Keep guest dimensions for cache identity while preserving scaled GPU detail. // Keep guest dimensions for cache identity while preserving scaled GPU detail.
const auto logicalDstWidth = std::max<u32>(g_gxState.texCopyDstWidth, 1); const auto logicalDstWidth = std::max<u32>(g_gxState.texCopyDstWidth, 1);
const auto logicalDstHeight = std::max<u32>(g_gxState.texCopyDstHeight, 1); const auto logicalDstHeight = std::max<u32>(g_gxState.texCopyDstHeight, 1);
const auto [scaledDstWidth, scaledDstHeight] = scale_copy_dst(logicalDstWidth, logicalDstHeight); auto [scaledDstWidth, scaledDstHeight] = scale_copy_dst(logicalDstWidth, logicalDstHeight);
const auto texCopyFmt = g_gxState.texCopyFmt; const auto texCopyFmt = g_gxState.texCopyFmt;
const bool sourceHasAlpha = aurora::gx::render_target_has_alpha(g_gxState.pixelFmt); const bool sourceHasAlpha = aurora::gx::render_target_has_alpha(g_gxState.pixelFmt);
const bool forceOpaqueAlpha = !sourceHasAlpha && !aurora::gx::is_depth_format(texCopyFmt); const bool forceOpaqueAlpha = !sourceHasAlpha && !aurora::gx::is_depth_format(texCopyFmt);
const auto resolveFmt = texCopyFmt; const bool quest1DirectRgb5a3 = quest1_direct_display_copy() && texCopyFmt == GX_TF_RGB5A3;
auto resolveFmt = texCopyFmt;
if (quest1DirectRgb5a3) {
// The host texture is RGBA8 regardless of the guest cache format. Match
// the EFB rectangle and advertise an RGBA8 resolve so this live menu copy
// becomes CopyTextureToTexture instead of invoking Adreno's broken
// RGB5A3 conversion/scaling shader.
scaledDstWidth = static_cast<u32>(std::max(rect.width, 1));
scaledDstHeight = static_cast<u32>(std::max(rect.height, 1));
resolveFmt = GX_TF_RGBA8;
}
const aurora::gx::GXState::CopyTextureKey key{ const aurora::gx::GXState::CopyTextureKey key{
.dest = dest, .dest = dest,
@@ -518,7 +562,10 @@ void GXCopyTex(void* dest, GXBool clear) {
if (aurora::gx::render_target_has_alpha(g_gxState.pixelFmt)) { if (aurora::gx::render_target_has_alpha(g_gxState.pixelFmt)) {
clearState.clearAlpha = clear && alphaUpdate; clearState.clearAlpha = clear && alphaUpdate;
} }
const auto copyFilter = combined_copy_filter_coefficients(g_gxState.copyFilterVFilter); auto copyFilter = combined_copy_filter_coefficients(g_gxState.copyFilterVFilter);
if (quest1DirectRgb5a3) {
copyFilter = {0, 64, 0};
}
// Skip only recurring color copies so one-shot copies are never lost. // Skip only recurring color copies so one-shot copies are never lost.
const bool producedConsecutively = handle.revision != 0 && currentFrame - handle.lastProducedFrame <= 1; const bool producedConsecutively = handle.revision != 0 && currentFrame - handle.lastProducedFrame <= 1;
const bool persistentCopy = !aurora::gx::is_depth_format(texCopyFmt) && !producedConsecutively; const bool persistentCopy = !aurora::gx::is_depth_format(texCopyFmt) && !producedConsecutively;
@@ -531,7 +578,8 @@ void GXCopyTex(void* dest, GXBool clear) {
} }
aurora::gfx::resolve_pass(handle.handle, rect, clearState.clearColor, clearState.clearAlpha, clearState.clearDepth, aurora::gfx::resolve_pass(handle.handle, rect, clearState.clearColor, clearState.clearAlpha, clearState.clearDepth,
clearState.clearColorValue, aurora::gx::clear_depth_value(), resolveFmt, clearState.clearColorValue, aurora::gx::clear_depth_value(), resolveFmt,
&sourceRect.sampleRect, g_gxState.texCopyHalfScale, &copyFilter, forceOpaqueAlpha, quest1DirectRgb5a3 ? nullptr : &sourceRect.sampleRect,
quest1DirectRgb5a3 ? false : g_gxState.texCopyHalfScale, &copyFilter, forceOpaqueAlpha,
sourceRect.sampleRect.w() / std::max<float>(g_gxState.texCopySrc.height, 1.0f), sourceRect.sampleRect.w() / std::max<float>(g_gxState.texCopySrc.height, 1.0f),
(g_gxState.copyClamp & GX_CLAMP_TOP) != 0, (g_gxState.copyClamp & GX_CLAMP_BOTTOM) != 0, (g_gxState.copyClamp & GX_CLAMP_TOP) != 0, (g_gxState.copyClamp & GX_CLAMP_BOTTOM) != 0,
persistentCopy); persistentCopy);
+46 -5
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>
@@ -23,6 +24,12 @@
#include <fmt/format.h> #include <fmt/format.h>
#include <tracy/Tracy.hpp> #include <tracy/Tracy.hpp>
#if defined(__ANDROID__)
#include <pthread.h>
#include <sys/resource.h>
#include <sys/system_properties.h>
#endif
namespace aurora::gfx { namespace aurora::gfx {
static Module Log("aurora::gfx::pipeline_cache"); static Module Log("aurora::gfx::pipeline_cache");
@@ -66,8 +73,26 @@ constexpr size_t MaxQueuedPipelineBuilds = 256;
// 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;
constexpr size_t MaxPipelineWorkers = 22; constexpr size_t MaxPipelineWorkers = 22;
// Cached clear and GX pipelines are prewarmed using the full worker pool. constexpr size_t Quest1MaxPipelineWorkers = 2;
constexpr size_t MaxBackgroundPipelineWorkers = MaxPipelineWorkers; static bool g_quest1PipelineScheduling = false;
static bool quest1_pipeline_scheduling() noexcept {
#if defined(__ANDROID__)
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
return false;
#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;
@@ -462,6 +487,12 @@ 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 recipe can sit dormant when background prewarm is disabled.
// Promoting it to first-use priority must wake a worker before
// bind_pipeline waits for completion, or both threads sleep forever.
if (g_pipelineFrameActive && deferGxPipeline) {
notifyWorker = true;
}
if (pending != nullptr && firstFrameUsed < pending->firstFrameUsed) { if (pending != nullptr && firstFrameUsed < pending->firstFrameUsed) {
pending->firstFrameUsed = firstFrameUsed; pending->firstFrameUsed = firstFrameUsed;
if (persist) { if (persist) {
@@ -1033,6 +1064,14 @@ static void pipeline_worker() {
#ifdef TRACY_ENABLE #ifdef TRACY_ENABLE
tracy::SetThreadName("Pipeline compilation thread"); tracy::SetThreadName("Pipeline compilation thread");
#endif #endif
#if defined(__ANDROID__)
pthread_setname_np(pthread_self(), "GXPipeline");
if (g_quest1PipelineScheduling) {
// setpriority(PRIO_PROCESS, 0, ...) targets the calling Linux thread.
// Quest 1 pipeline creation must not preempt gameplay or XR.
setpriority(PRIO_PROCESS, 0, 5);
}
#endif
while (true) { while (true) {
PendingPipeline pending; PendingPipeline pending;
@@ -1042,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) {
@@ -1091,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>
@@ -1318,6 +1358,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) {
@@ -1330,7 +1371,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();
+76 -1
View File
@@ -10,6 +10,12 @@
#include <absl/container/flat_hash_map.h> #include <absl/container/flat_hash_map.h>
#if defined(__ANDROID__)
#include <sys/system_properties.h>
#endif
#include <cstring>
#include "texture_convert.hpp" #include "texture_convert.hpp"
using namespace std::string_literals; using namespace std::string_literals;
@@ -195,6 +201,48 @@ static constexpr std::string_view FragPassthrough = R"(
} }
)"sv; )"sv;
// The Quest 1's Adreno compiler misrenders the general copy shader, whose
// sample helper contains texture-dimension math and a dynamic filter branch,
// even for passthrough RGBA copies. This equivalent shader deliberately keeps
// the same bind-group contract while omitting that unused filter machinery.
static constexpr std::string_view SimpleBlitShader = R"(
@group(0) @binding(0) var src_samp: sampler;
@group(0) @binding(1) var src: texture_2d<f32>;
struct UVTransform {
offset: vec2f,
scale: vec2f,
copy_filter: vec4f,
flags: vec4f,
};
@group(0) @binding(2) var<uniform> uv_xf: UVTransform;
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) uv: vec2f,
};
var<private> positions: array<vec2f, 3> = array(
vec2f(-1.0, 1.0), vec2f(-1.0, -3.0), vec2f(3.0, 1.0));
var<private> uvs: array<vec2f, 3> = array(
vec2f(0.0, 0.0), vec2f(0.0, 2.0), vec2f(2.0, 0.0));
@vertex fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOutput {
var out: VertexOutput;
out.pos = vec4f(positions[vi], 0.0, 1.0);
out.uv = uvs[vi] * uv_xf.scale + uv_xf.offset;
return out;
}
@fragment fn fs_main(in: VertexOutput) -> @location(0) vec4f {
let color = textureSample(src, src_samp, in.uv);
if (uv_xf.flags.x != 0.0) {
return vec4f(color.rgb, 1.0);
}
return color;
}
)"sv;
// GX_TF_I4: 4-bit intensity -> R8Unorm (quantized) // GX_TF_I4: 4-bit intensity -> R8Unorm (quantized)
static constexpr std::string_view FragI4 = R"( static constexpr std::string_view FragI4 = R"(
@fragment fn fs_main(in: VertexOutput) -> @location(0) vec4f { @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4f {
@@ -375,6 +423,19 @@ static wgpu::Sampler g_nearestSampler;
static wgpu::Sampler g_linearSampler; static wgpu::Sampler g_linearSampler;
static absl::flat_hash_map<GXTexFmt, wgpu::RenderPipeline> g_pipelines; static absl::flat_hash_map<GXTexFmt, wgpu::RenderPipeline> g_pipelines;
static wgpu::RenderPipeline g_blitPipeline; static wgpu::RenderPipeline g_blitPipeline;
static wgpu::RenderPipeline g_quest1BlitPipeline;
static bool quest1_simple_blit() noexcept {
#if defined(__ANDROID__)
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
return false;
#endif
}
static wgpu::RenderPipeline create_pipeline(const ConvPipeline& conv, const std::string_view shaderPreamble, static wgpu::RenderPipeline create_pipeline(const ConvPipeline& conv, const std::string_view shaderPreamble,
const wgpu::BindGroupLayout& bindGroupLayout) { const wgpu::BindGroupLayout& bindGroupLayout) {
@@ -491,6 +552,9 @@ void initialize() {
g_blitPipeline = create_pipeline( g_blitPipeline = create_pipeline(
{GX_TF_RGBA8, FragPassthrough, webgpu::g_graphicsConfig.surfaceConfiguration.format, "TexCopyConv Blit"}, {GX_TF_RGBA8, FragPassthrough, webgpu::g_graphicsConfig.surfaceConfiguration.format, "TexCopyConv Blit"},
ShaderPreamble, g_bindGroupLayout); ShaderPreamble, g_bindGroupLayout);
g_quest1BlitPipeline = create_pipeline(
{GX_TF_RGBA8, {}, webgpu::g_graphicsConfig.surfaceConfiguration.format, "Quest 1 Simple TexCopy Blit"},
SimpleBlitShader, g_bindGroupLayout);
for (const auto& conv : ConvPipelines) { for (const auto& conv : ConvPipelines) {
g_pipelines[conv.fmt] = create_pipeline(conv, ShaderPreamble, g_bindGroupLayout); g_pipelines[conv.fmt] = create_pipeline(conv, ShaderPreamble, g_bindGroupLayout);
if (conv.outputFormat != to_wgpu(conv.fmt)) { if (conv.outputFormat != to_wgpu(conv.fmt)) {
@@ -522,6 +586,7 @@ void initialize() {
void shutdown() { void shutdown() {
g_pipelines.clear(); g_pipelines.clear();
g_blitPipeline = {}; g_blitPipeline = {};
g_quest1BlitPipeline = {};
g_bindGroupLayout = {}; g_bindGroupLayout = {};
g_depthBindGroupLayout = {}; g_depthBindGroupLayout = {};
g_nearestSampler = {}; g_nearestSampler = {};
@@ -597,6 +662,14 @@ static void execute(const wgpu::CommandEncoder& cmd, const ConvRequest& req, con
} }
void run(const wgpu::CommandEncoder& cmd, const ConvRequest& req) { void run(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
if (quest1_simple_blit() && req.fmt == GX_TF_RGB5A3) {
// MKW's character/kart menu previews are live RGB5A3 EFB copies. The
// Adreno 540 miscompiles their quantizing conversion shader just like the
// filtered display-copy shader, leaving the preview panels black. Preserve
// the RGBA source directly; only the Wii-era RGB5A3 quantization is lost.
execute(cmd, req, g_quest1BlitPipeline);
return;
}
const auto it = g_pipelines.find(req.fmt); const auto it = g_pipelines.find(req.fmt);
if (it == g_pipelines.end()) { if (it == g_pipelines.end()) {
Log.fatal("No copy conversion pipeline for format {}", static_cast<int>(req.fmt)); Log.fatal("No copy conversion pipeline for format {}", static_cast<int>(req.fmt));
@@ -604,6 +677,8 @@ void run(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
execute(cmd, req, it->second); execute(cmd, req, it->second);
} }
void blit(const wgpu::CommandEncoder& cmd, const ConvRequest& req) { execute(cmd, req, g_blitPipeline); } void blit(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
execute(cmd, req, quest1_simple_blit() ? g_quest1BlitPipeline : g_blitPipeline);
}
} // namespace aurora::gfx::tex_copy_conv } // namespace aurora::gfx::tex_copy_conv
+46 -1
View File
@@ -182,6 +182,46 @@ density map does not reduce (the stores stay full size for non-subsampled images
stays off by default; it pays only when `render_scale` makes the eyes pixel-bound, as at 1.3 on stays off by default; it pays only when `render_scale` makes the eyes pixel-bound, as at 1.3 on
Luigi Circuit. Luigi Circuit.
### Quest 1 renderer compatibility
Quest 1 identifies itself as Android device `monterey` and uses an Adreno 540
driver that misrenders Aurora's general filtered EFB-copy shader. On that
device, `GXCopyDisp` keeps the destination equal to the resolved EFB region so
WebGPU can use `CopyTextureToTexture`; the later presentation pass still scales
the result. Live RGB5A3 menu copies use a minimal RGBA passthrough shader for
the same reason. Other Android devices retain the normal filtered and
format-converting paths.
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
`cortex-a77`, whose instructions can terminate the game with `SIGILL` on Quest
1. Each variant's exported game kit records this CPU target, and the APK build,
PC game build, on-headset build and package import all verify it. This prevents
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
panel into an immersive activity. Its APK therefore exposes two library
entries: **WiiCompiled VR** starts the game directly in VR, while
**WiiCompiled Settings** opens the setup/settings panel. After changing a
setting, close the panel and start WiiCompiled VR from the library. The Play
button in the settings panel explains this limitation instead of opening a
black panel. Modern Quest builds keep the normal single launcher, whose Play
button starts VR directly.
The workaround was isolated on a physical Quest 1: it changed the boot/menu
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
that device.
Pipeline compilation is also scheduled differently on Quest 1. Its Adreno
serializes much of `vkCreateGraphicsPipelines`, so a large equal-priority worker
pool starved the translated game and OpenXR pacing threads without shortening
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
workers are awakened when the game first requests it. Newer Android headsets
and desktop retain the normal worker pool and prewarm behavior.
### Controllers ### Controllers
Quest Touch controllers are not HID gamepads, so `openxr_input.cpp` syncs an Quest Touch controllers are not HID gamepads, so `openxr_input.cpp` syncs an
@@ -772,7 +812,10 @@ Android facts this design rests on, all measured on a Quest 3:
also learned `--target-os windows|macos|linux|android` for also learned `--target-os windows|macos|linux|android` for
`generate-data-init` and `translate-mod`, for pipelines that generate on `generate-data-init` and `translate-mod`, for pipelines that generate on
another host. another host.
- `android/`: the Gradle project, one app with no flavours. - `android/`: the Gradle project, with `modernQuest` (the default script
target) and `quest1` headset flavours. They share the application ID and
storage, but select the appropriate CPU baseline, supported-device manifest,
and launcher behavior.
`app/src/main/cpp/CMakeLists.txt` adds the repository's `runtime/` as a `app/src/main/cpp/CMakeLists.txt` adds the repository's `runtime/` as a
subdirectory with those Android choices and builds both game kit probes subdirectory with those Android choices and builds both game kit probes
(the Retro Rewind one only when the translation includes the mod), which (the Retro Rewind one only when the translation includes the mod), which
@@ -794,8 +837,10 @@ installer's `BuildWorkspace/generated`, produced by the normal Windows pipeline)
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # SDL3 3.4.4 AAR into android/app/libs powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # SDL3 3.4.4 AAR into android/app/libs
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, its game kit and toolchain, debug-signed (the first run also builds Dawn from source) powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, its game kit and toolchain, debug-signed (the first run also builds Dawn from source)
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 -Product retro_rewind -Mod <RetroRewind6> -Install # the mod and its pack (needs translate-mod output with --retro-wfc-payload) powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install # the mod and its pack (needs translate-mod output with --retro-wfc-payload)
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Headset quest1 -Install # game package from the Quest 1 kit
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
``` ```