From 83fe04d160ab059126ef17eee0041e17a0462ace Mon Sep 17 00:00:00 2001 From: darthcircuit Date: Mon, 21 Sep 2026 21:34:01 -0600 Subject: [PATCH 1/4] Quest: port device compatibility and pipeline fixes --- aurora-main/lib/dolphin/gx/GXFrameBuffer.cpp | 58 +++++++++++++-- aurora-main/lib/gfx/pipeline_cache.cpp | 36 ++++++++- aurora-main/lib/gfx/tex_copy_conv.cpp | 77 +++++++++++++++++++- docs/quest-port.md | 23 ++++++ 4 files changed, 187 insertions(+), 7 deletions(-) diff --git a/aurora-main/lib/dolphin/gx/GXFrameBuffer.cpp b/aurora-main/lib/dolphin/gx/GXFrameBuffer.cpp index 3a6662e..e614633 100644 --- a/aurora-main/lib/dolphin/gx/GXFrameBuffer.cpp +++ b/aurora-main/lib/dolphin/gx/GXFrameBuffer.cpp @@ -15,8 +15,25 @@ #include #include #include +#include + +#if defined(__ANDROID__) +#include +#endif 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 { bool clearColor = false; bool clearAlpha = false; @@ -417,7 +434,15 @@ void GXCopyDisp(void* dest, GXBool clear) { const auto logicalDstHeight = std::max( g_gxState.dispCopyDstHeight != 0 ? g_gxState.dispCopyDstHeight : static_cast(g_gxState.dispCopySrc.height), 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(std::max(rect.width, 1)); + dstHeight = static_cast(std::max(rect.height, 1)); + } if (!g_gxState.displayCopyTexture || g_gxState.displayCopyWidth != dstWidth || g_gxState.displayCopyHeight != dstHeight) { @@ -431,6 +456,15 @@ void GXCopyDisp(void* dest, GXBool clear) { if (aurora::g_config.disableCopyFilter) { 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, clearState.clearDepth, clearState.clearColorValue, aurora::gx::clear_depth_value(), GX_TF_RGBA8, nullptr, false, ©Filter, false, @@ -450,11 +484,21 @@ void GXCopyTex(void* dest, GXBool clear) { // Keep guest dimensions for cache identity while preserving scaled GPU detail. const auto logicalDstWidth = std::max(g_gxState.texCopyDstWidth, 1); const auto logicalDstHeight = std::max(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 bool sourceHasAlpha = aurora::gx::render_target_has_alpha(g_gxState.pixelFmt); 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(std::max(rect.width, 1)); + scaledDstHeight = static_cast(std::max(rect.height, 1)); + resolveFmt = GX_TF_RGBA8; + } const aurora::gx::GXState::CopyTextureKey key{ .dest = dest, @@ -511,7 +555,10 @@ void GXCopyTex(void* dest, GXBool clear) { if (aurora::gx::render_target_has_alpha(g_gxState.pixelFmt)) { 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. const bool producedConsecutively = handle.revision != 0 && currentFrame - handle.lastProducedFrame <= 1; const bool persistentCopy = !aurora::gx::is_depth_format(texCopyFmt) && !producedConsecutively; @@ -524,7 +571,8 @@ void GXCopyTex(void* dest, GXBool clear) { } aurora::gfx::resolve_pass(handle.handle, rect, clearState.clearColor, clearState.clearAlpha, clearState.clearDepth, clearState.clearColorValue, aurora::gx::clear_depth_value(), resolveFmt, - &sourceRect.sampleRect, g_gxState.texCopyHalfScale, ©Filter, forceOpaqueAlpha, + quest1DirectRgb5a3 ? nullptr : &sourceRect.sampleRect, + quest1DirectRgb5a3 ? false : g_gxState.texCopyHalfScale, ©Filter, forceOpaqueAlpha, sourceRect.sampleRect.w() / std::max(g_gxState.texCopySrc.height, 1.0f), (g_gxState.copyClamp & GX_CLAMP_TOP) != 0, (g_gxState.copyClamp & GX_CLAMP_BOTTOM) != 0, persistentCopy); diff --git a/aurora-main/lib/gfx/pipeline_cache.cpp b/aurora-main/lib/gfx/pipeline_cache.cpp index 0038174..9b23d95 100644 --- a/aurora-main/lib/gfx/pipeline_cache.cpp +++ b/aurora-main/lib/gfx/pipeline_cache.cpp @@ -23,6 +23,11 @@ #include #include +#if defined(__ANDROID__) +#include +#include +#endif + namespace aurora::gfx { static Module Log("aurora::gfx::pipeline_cache"); @@ -65,9 +70,26 @@ constexpr size_t MaxQueuedPipelineBuilds = 256; // 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. 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; -// Cached clear and GX pipelines are prewarmed using the full worker pool. +#endif +// Cached clear and GX pipelines are normally prewarmed using the full worker +// pool. Mobile Adreno drivers serialize much of pipeline creation internally; +// prewarming hundreds of recipes there starves first-use pipelines for over a +// 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__) +constexpr size_t MaxBackgroundPipelineWorkers = 0; +#else constexpr size_t MaxBackgroundPipelineWorkers = MaxPipelineWorkers; +#endif // For synchronous pipeline fallback (OpenGL) #ifdef NDEBUG constexpr size_t BuildPipelinesPerFrame = 5; @@ -462,6 +484,12 @@ static PipelineRef find_pipeline_impl(ShaderType type, const PipelineConfig& con } } else if (g_pendingPipelines.contains(hash)) { auto* pending = touch_pending_pipeline(hash, g_pipelineFrameActive); + // A cached Android recipe can sit dormant because 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) { pending->firstFrameUsed = firstFrameUsed; if (persist) { @@ -1033,6 +1061,12 @@ static void pipeline_worker() { #ifdef TRACY_ENABLE tracy::SetThreadName("Pipeline compilation thread"); #endif +#if defined(__ANDROID__) + pthread_setname_np(pthread_self(), "GXPipeline"); + // setpriority(PRIO_PROCESS, 0, ...) targets the calling Linux thread. + // Pipeline creation may finish later, but must not preempt gameplay or XR. + setpriority(PRIO_PROCESS, 0, 5); +#endif while (true) { PendingPipeline pending; diff --git a/aurora-main/lib/gfx/tex_copy_conv.cpp b/aurora-main/lib/gfx/tex_copy_conv.cpp index 6b68864..c513027 100644 --- a/aurora-main/lib/gfx/tex_copy_conv.cpp +++ b/aurora-main/lib/gfx/tex_copy_conv.cpp @@ -9,6 +9,12 @@ #include +#if defined(__ANDROID__) +#include +#endif + +#include + #include "texture_convert.hpp" using namespace std::string_literals; @@ -194,6 +200,48 @@ static constexpr std::string_view FragPassthrough = R"( } )"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; + +struct UVTransform { + offset: vec2f, + scale: vec2f, + copy_filter: vec4f, + flags: vec4f, +}; +@group(0) @binding(2) var uv_xf: UVTransform; + +struct VertexOutput { + @builtin(position) pos: vec4f, + @location(0) uv: vec2f, +}; + +var positions: array = array( + vec2f(-1.0, 1.0), vec2f(-1.0, -3.0), vec2f(3.0, 1.0)); +var uvs: array = 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) static constexpr std::string_view FragI4 = R"( @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4f { @@ -374,6 +422,19 @@ static wgpu::Sampler g_nearestSampler; static wgpu::Sampler g_linearSampler; static absl::flat_hash_map g_pipelines; 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, const wgpu::BindGroupLayout& bindGroupLayout) { @@ -490,6 +551,9 @@ void initialize() { g_blitPipeline = create_pipeline( {GX_TF_RGBA8, FragPassthrough, webgpu::g_graphicsConfig.surfaceConfiguration.format, "TexCopyConv Blit"}, 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) { g_pipelines[conv.fmt] = create_pipeline(conv, ShaderPreamble, g_bindGroupLayout); if (conv.outputFormat != to_wgpu(conv.fmt)) { @@ -521,6 +585,7 @@ void initialize() { void shutdown() { g_pipelines.clear(); g_blitPipeline = {}; + g_quest1BlitPipeline = {}; g_bindGroupLayout = {}; g_depthBindGroupLayout = {}; g_nearestSampler = {}; @@ -595,6 +660,14 @@ static void execute(const wgpu::CommandEncoder& cmd, const ConvRequest& req, con } 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); if (it == g_pipelines.end()) { Log.fatal("No copy conversion pipeline for format {}", static_cast(req.fmt)); @@ -602,6 +675,8 @@ void run(const wgpu::CommandEncoder& cmd, const ConvRequest& req) { 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 diff --git a/docs/quest-port.md b/docs/quest-port.md index fbde6a3..fb65177 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -97,6 +97,29 @@ hit on Vulkan when a game's device was destroyed under the compositor. (there is no BGRA `AHardwareBuffer` format) and requests the two Dawn features the bridge needs. +### 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. + +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 Android. 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. Android 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. Desktop keeps the full +worker pool and prewarm behavior. + ### Controllers Quest Touch controllers are not HID gamepads, so `openxr_input.cpp` syncs an From 6e6406c20050bedcc622c160b25e3f8799fb4496 Mon Sep 17 00:00:00 2001 From: darthcircuit Date: Tue, 22 Sep 2026 11:07:39 -0600 Subject: [PATCH 2/4] Quest: make Quest 1 builds reproducible --- android/Build-Quest.ps1 | 15 +++++++--- android/app/build.gradle.kts | 26 +++++++++++++++++ android/app/src/main/AndroidManifest.xml | 14 ++------- .../quest/launcher/LauncherActivity.kt | 4 +++ android/app/src/main/res/values/strings.xml | 2 ++ .../app/src/modernQuest/AndroidManifest.xml | 20 +++++++++++++ android/app/src/quest1/AndroidManifest.xml | 29 +++++++++++++++++++ aurora-main/cmake/AuroraDawnProvider.cmake | 9 +++++- docs/quest-port.md | 22 +++++++++++++- 9 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 android/app/src/modernQuest/AndroidManifest.xml create mode 100644 android/app/src/quest1/AndroidManifest.xml diff --git a/android/Build-Quest.ps1 b/android/Build-Quest.ps1 index 279f618..772d1a6 100644 --- a/android/Build-Quest.ps1 +++ b/android/Build-Quest.ps1 @@ -1,7 +1,7 @@ # Builds the standalone Meta Quest APK on a Windows host. # # powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 [-Generated ] -# [-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 # when -Generated holds a translation that includes the mod. @@ -20,6 +20,7 @@ param( [string]$Generated = '', [string]$Dependencies = '', [string]$CMakeDir = '', + [ValidateSet('modern', 'quest1')] [string]$Headset = 'modern', [ValidateSet('debug', 'release')] [string]$Configuration = 'debug', [switch]$Install ) @@ -73,7 +74,8 @@ if (-not $Dependencies) { } $variant = (Get-Culture).TextInfo.ToTitleCase($Configuration) -$task = "app:assemble$variant" +$flavour = if ($Headset -eq 'quest1') { 'Quest1' } else { 'ModernQuest' } +$task = "app:assemble$flavour$variant" $gradleArgs = @( '--project-dir', $root, "-PmkwGeneratedDir=$Generated" @@ -84,7 +86,8 @@ Write-Host "gradlew $($gradleArgs -join ' ')" & (Join-Path $root 'gradlew.bat') @gradleArgs if ($LASTEXITCODE -ne 0) { throw "Gradle failed ($LASTEXITCODE)" } -$apkDir = Join-Path $root "app\build\outputs\apk\$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 if (-not $apk) { throw "No APK under $apkDir" } Write-Host "APK: $($apk.FullName)" @@ -106,5 +109,9 @@ if ($Install) { $adb = Join-Path $sdkRoot 'platform-tools\adb.exe' & $adb install -r $apk.FullName if ($LASTEXITCODE -ne 0) { throw "adb install failed ($LASTEXITCODE)" } - Write-Host 'Installed. Build 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.' + } } diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 84fedcc..d61670b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -139,6 +139,32 @@ android { } } + flavorDimensions += "headset" + productFlavors { + create("modernQuest") { + dimension = "headset" + manifestPlaceholders["mkwQuestSupportedDevices"] = "quest2|quest3|quest3s|questpro" + buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "false") + 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") + externalNativeBuild { + cmake { + // Snapdragon 835. cortex-a77 binaries terminate with SIGILL on Quest 1. + arguments += "-DMKW_ANDROID_CPU=kryo" + } + } + } + } + externalNativeBuild { cmake { // aurora-main needs CMake 3.25+, newer than the SDK's bundled 3.22.1; diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 11536b6..fcdebd2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -37,12 +37,13 @@ android:label="@string/app_name" android:theme="@style/Theme.WiiCompiled.Quest"> - + - + - - - - @@ -84,11 +81,6 @@ - - - - - diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt index b12b2a5..2d536c6 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/LauncherActivity.kt @@ -585,6 +585,10 @@ class LauncherActivity : Activity() { } 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) { return } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2337104..6bb1b61 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ WiiCompiled VR + WiiCompiled Settings Game data not found 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. @@ -77,6 +78,7 @@ Replace the game files? Your current DATA folder stays until the new disc image has been checked and completely extracted. The game could not be started. + Quest 1 cannot switch this settings panel into VR. Close it, then launch WiiCompiled VR from your library. Close the game first. Reset installation Reset the installation? diff --git a/android/app/src/modernQuest/AndroidManifest.xml b/android/app/src/modernQuest/AndroidManifest.xml new file mode 100644 index 0000000..3c40ec4 --- /dev/null +++ b/android/app/src/modernQuest/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/quest1/AndroidManifest.xml b/android/app/src/quest1/AndroidManifest.xml new file mode 100644 index 0000000..303d001 --- /dev/null +++ b/android/app/src/quest1/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/aurora-main/cmake/AuroraDawnProvider.cmake b/aurora-main/cmake/AuroraDawnProvider.cmake index 2cf1a36..90584d0 100644 --- a/aurora-main/cmake/AuroraDawnProvider.cmake +++ b/aurora-main/cmake/AuroraDawnProvider.cmake @@ -154,6 +154,13 @@ elseif (_aurora_dawn_provider STREQUAL "package") endif () set(AURORA_DAWN_PACKAGE_URL "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, # and a cached extraction is never re-verified. Pin the digest for the combinations we ship. @@ -181,7 +188,7 @@ elseif (_aurora_dawn_provider STREQUAL "package") "SHA256=f97701d26fd1f25bbcc260b4c31736ede134c730c12556029e2470fde967f424") elseif (_dawn_system STREQUAL "android" AND _dawn_arch STREQUAL "aarch64") set(AURORA_DAWN_PACKAGE_URL_HASH - "SHA256=0e63e8cbf53551f703f582d1306f4257c0380353f66b53369d96952ce6d9f934") + "SHA256=27d910dee1201fd1e5b6ac567f0ba2306ebf2135e9f40b6929976c365d38b09b") endif () endif () endif () diff --git a/docs/quest-port.md b/docs/quest-port.md index fb65177..5c3f107 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -107,6 +107,22 @@ 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. Android builds use a pinned Dawn package which does not enable +`VK_EXT_debug_utils`: the old Quest firmware advertises that extension but +rejects it when Dawn creates the Vulkan instance. + +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 @@ -459,7 +475,10 @@ Android facts this design rests on, all measured on a Quest 3: also learned `--target-os windows|macos|linux|android` for `generate-data-init` and `translate-mod`, for pipelines that generate on another host. -- `android/`: the Gradle project, 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 subdirectory with those Android choices and builds both game kit probes (the Retro Rewind one only when the translation includes the mod), which @@ -481,6 +500,7 @@ installer's `BuildWorkspace/generated`, produced by the normal Windows pipeline) ```powershell powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # SDL3 3.4.4 AAR into android/app/libs powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, its game kit and toolchain, debug-signed +powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -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 -Product retro_rewind -Mod -Install # the mod and its pack (needs translate-mod output) adb push MarioKart.iso /sdcard/Download/ # then Select disc image in the launcher From 02dd9bf8cc825e1fe9a2d924f51cbc6dc97ed13f Mon Sep 17 00:00:00 2001 From: darthcircuit Date: Wed, 23 Sep 2026 11:22:39 -0600 Subject: [PATCH 3/4] Quest: address flavor safety review --- .../QuestBuildService.cs | 11 +++- .../WiiCompiled.Setup.Windows/SelfTests.cs | 4 +- android/Build-Quest.ps1 | 10 +++- android/Build-QuestGame.ps1 | 14 +++-- android/Export-QuestGameKit.ps1 | 7 ++- android/QuestGameKit.psm1 | 30 +++++++++- android/README.md | 6 +- android/Run-Quest.ps1 | 11 +++- android/app/build.gradle.kts | 45 +++++++++++++-- .../java/org/wiicompiled/quest/GameLibrary.kt | 4 +- .../wiicompiled/quest/launcher/GameBuild.kt | 4 ++ .../quest/launcher/GamePackageImport.kt | 3 + aurora-main/cmake/AuroraDawnProvider.cmake | 9 +-- aurora-main/lib/gfx/pipeline_cache.cpp | 55 +++++++++++-------- docs/quest-port.md | 16 +++--- 15 files changed, 162 insertions(+), 67 deletions(-) diff --git a/Launcher/WiiCompiled.Setup.Windows/QuestBuildService.cs b/Launcher/WiiCompiled.Setup.Windows/QuestBuildService.cs index 5a07e40..93a774c 100644 --- a/Launcher/WiiCompiled.Setup.Windows/QuestBuildService.cs +++ b/Launcher/WiiCompiled.Setup.Windows/QuestBuildService.cs @@ -74,7 +74,7 @@ internal sealed class QuestBuildService var kitDirectory = Path.Combine(questRoot, "kit"); _reporter.Progress(InstallStages.QuestKit, "Reading the game kit from the Quest app...", 2); var kit = ExtractKit(apkPath, kitDirectory, cancellationToken); - _reporter.Diagnostic($"Quest game kit {kit.Fingerprint} [{string.Join(", ", kit.Products)}]"); + _reporter.Diagnostic($"Quest game kit {kit.Fingerprint} [{string.Join(", ", kit.Products)}, CPU {kit.AndroidCpu}]"); if (!kit.Products.Contains(product)) { throw new InvalidOperationException( @@ -185,7 +185,7 @@ internal sealed class QuestBuildService File.Exists(Path.Combine(directory, "sys", "main.dol")); /// What the extracted kit says it builds: its fingerprint and the products it carries. - internal sealed record Kit(string Fingerprint, IReadOnlyCollection Products); + internal sealed record Kit(string Fingerprint, string AndroidCpu, IReadOnlyCollection Products); /// Extracts assets/game_kit from the Quest app's APK and reads what it is for. 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."); } + 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) && value.ValueKind == JsonValueKind.Object ? value.EnumerateObject().Select(property => property.Name).ToArray() : throw new InvalidDataException("The Quest app's game kit lists no games it can build."); - return new Kit(fingerprint.GetString()!, products); + return new Kit(fingerprint.GetString()!, androidCpu.GetString()!, products); } private async Task EnsureToolchainAsync(string questRoot, CancellationToken cancellationToken) diff --git a/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs b/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs index ed43bc1..beed091 100644 --- a/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs +++ b/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs @@ -384,14 +384,14 @@ internal static class SelfTests using var writer = new StreamWriter(zip.CreateEntry(name).Open()); 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/runtime_resources/dsp_coef.bin", "not the kit"); Add("lib/arm64-v8a/libSDL3.so", "not the kit"); } var kit = Path.Combine(temp, "kit"); var extracted = QuestBuildService.ExtractKit(apk, kit, CancellationToken.None); - if (extracted.Fingerprint != "abc123" || !extracted.Products.Contains("base") || + if (extracted.Fingerprint != "abc123" || extracted.AndroidCpu != "kryo" || !extracted.Products.Contains("base") || !extracted.Products.Contains("retro_rewind")) throw new Exception("The kit fingerprint or its games were not read."); if (!File.Exists(Path.Combine(kit, "include", "memory.h")) || diff --git a/android/Build-Quest.ps1 b/android/Build-Quest.ps1 index 772d1a6..fd0e424 100644 --- a/android/Build-Quest.ps1 +++ b/android/Build-Quest.ps1 @@ -75,6 +75,7 @@ if (-not $Dependencies) { $variant = (Get-Culture).TextInfo.ToTitleCase($Configuration) $flavour = if ($Headset -eq 'quest1') { 'Quest1' } else { 'ModernQuest' } +$expectedCpu = if ($Headset -eq 'quest1') { 'kryo' } else { 'cortex-a77' } $task = "app:assemble$flavour$variant" $gradleArgs = @( '--project-dir', $root, @@ -98,11 +99,16 @@ Add-Type -AssemblyName System.IO.Compression.FileSystem $archive = [IO.Compression.ZipFile]::OpenRead($apk.FullName) try { $gameLibraries = @($archive.Entries | Where-Object { $_.FullName -match '^lib/[^/]+/libmain[^/]*\.so$' }) - $hasKit = @($archive.Entries | Where-Object { $_.FullName -eq 'assets/game_kit/kit.json' }).Count -gt 0 + $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 } finally { $archive.Dispose() } if ($gameLibraries.Count -gt 0) { throw "The APK contains a translated game library: $($gameLibraries.FullName -join ', ')" } -if (-not $hasKit) { throw 'The APK has no game kit (assets/game_kit/kit.json)' } +if (-not $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 ($Install) { diff --git a/android/Build-QuestGame.ps1 b/android/Build-QuestGame.ps1 index 679d9fb..3dbc754 100644 --- a/android/Build-QuestGame.ps1 +++ b/android/Build-QuestGame.ps1 @@ -6,6 +6,7 @@ # SDK's NDK and ninja: # # powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 [-Generated ] [-Kit ] +# [-Headset modern|quest1] [-Configuration debug|release] # [-Data ] [-Output ] [-Install] # # WiiCompiled Setup's --build-quest runs the copy staged in an installation's BuildWorkspace\android, @@ -32,6 +33,8 @@ param( [string]$Sysroot = '', [string]$Ninja = '', [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', [string]$Mod = '', [int]$TranslatedJobs = 0, @@ -44,12 +47,13 @@ $root = $PSScriptRoot $workspace = (Resolve-Path (Join-Path $root '..')).Path if (-not $Generated) { $Generated = Join-Path $workspace '.scratch\vr-build-workspace\BuildWorkspace\generated' } if (-not $Manifest) { $Manifest = Join-Path $workspace 'projects\mkwii\recomp.yml' } -if (-not $BuildDir) { $BuildDir = Join-Path $root "app\build\questGame\$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) { - # The kit the last APK build packaged, so the game matches the app it will be imported into. - $Kit = Get-ChildItem -Path (Join-Path $root 'app\build\generated') -Filter kit.json -Recurse -ErrorAction SilentlyContinue | - Where-Object { $_.Directory.Name -eq 'game_kit' } | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 | ForEach-Object { $_.DirectoryName } + # Select the requested variant explicitly. Modification time is unsafe now that flavours use + # different -mcpu targets and an up-to-date native probe is not necessarily the newest one. + $Kit = Join-Path $root "app\build\generated\assets\questGameKit\$variant\game_kit" } if (-not $Kit -or -not (Test-Path (Join-Path $Kit 'kit.json'))) { throw 'No game kit; run Build-Quest.ps1 first, or pass -Kit' diff --git a/android/Export-QuestGameKit.ps1 b/android/Export-QuestGameKit.ps1 index c0b3c0e..5304830 100644 --- a/android/Export-QuestGameKit.ps1 +++ b/android/Export-QuestGameKit.ps1 @@ -8,10 +8,11 @@ param( [Parameter(Mandatory)] [string]$CMakeBinaryDir, [Parameter(Mandatory)] [string]$OutputDir, - [Parameter(Mandatory)] [string]$LlvmStrip + [Parameter(Mandatory)] [string]$LlvmStrip, + [Parameter(Mandatory)] [string]$AndroidCpu ) $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'QuestGameKit.psm1') -Force $repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$kit = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip -Write-Host "Quest game kit $($kit.Fingerprint) [$($kit.Products -join ', ')] -> $OutputDir" +$kit = Export-QuestGameKit -CMakeBinaryDir $CMakeBinaryDir -OutputDir $OutputDir -RepoRoot $repo -LlvmStrip $LlvmStrip -AndroidCpu $AndroidCpu +Write-Host "Quest game kit $($kit.Fingerprint) [$($kit.Products -join ', '), CPU $AndroidCpu] -> $OutputDir" diff --git a/android/QuestGameKit.psm1 b/android/QuestGameKit.psm1 index ff422a9..5d23a66 100644 --- a/android/QuestGameKit.psm1 +++ b/android/QuestGameKit.psm1 @@ -268,10 +268,16 @@ function Export-QuestGameKit { [Parameter(Mandatory)] [string]$CMakeBinaryDir, [Parameter(Mandatory)] [string]$OutputDir, [Parameter(Mandatory)] [string]$RepoRoot, - [Parameter(Mandatory)] [string]$LlvmStrip + [Parameter(Mandatory)] [string]$LlvmStrip, + [Parameter(Mandatory)] [string]$AndroidCpu ) $ErrorActionPreference = 'Stop' $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') $ninja = [IO.File]::ReadAllText("$binary/build.ninja") $commands = Read-CompileCommands "$binary/compile_commands.json" @@ -405,6 +411,7 @@ function Export-QuestGameKit { $recipe = [ordered]@{ schema = $script:KitSchema + androidCpu = $AndroidCpu runtimeIncludeFingerprint = Get-RuntimeIncludeFingerprint (Join-Path $OutputDir 'include') products = $products } @@ -445,10 +452,16 @@ function Invoke-QuestGameBuild { $workspace = ConvertTo-ForwardPath (Split-Path -Parent $generated) $recipe = [IO.File]::ReadAllText("$kit/kit.json") | ConvertFrom-Json if ($recipe.schema -ne $script:KitSchema) { throw "Unsupported game kit schema $($recipe.schema)" } + if ([string]::IsNullOrWhiteSpace($recipe.androidCpu)) { throw 'The game kit does not name its Android CPU target' } if (-not $recipe.products.PSObject.Properties.Name.Contains($Product)) { throw "This Quest app's game kit cannot build $Product (it has: $($recipe.products.PSObject.Properties.Name -join ', '))" } $productRecipe = $recipe.products.$Product + 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 # kit's runtime headers and linked with the kit's runtime objects. $workspaceInclude = Join-Path $workspace 'runtime/include' @@ -460,6 +473,20 @@ function Invoke-QuestGameBuild { } New-Item -ItemType Directory -Force $BuildDir | Out-Null $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) } foreach ($kind in 'translated', 'product', 'runtime', 'asm') { @@ -583,6 +610,7 @@ function New-QuestGamePackage { dolSha256 = $DolSha256 relSha256 = $RelSha256 kitFingerprint = $recipe.fingerprint + androidCpu = $recipe.androidCpu library = $recipe.products.$Product.output librarySha256 = Get-Sha256Hex $Library includesData = [bool]$DataDir diff --git a/android/README.md b/android/README.md index d78a84a..8c3b3fc 100644 --- a/android/README.md +++ b/android/README.md @@ -1,7 +1,7 @@ # WiiCompiled VR for Meta Quest (Android) -Standalone Android/OpenXR build of the Mario Kart Wii recompilation for Quest 2, -Quest 3, Quest 3S and Quest Pro. The full design, build walkthrough and current +Standalone Android/OpenXR build of the Mario Kart Wii recompilation for Quest 1, +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 holds the Gradle project, its helper scripts, the game kit tooling (`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 -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 -Headset quest1 -Install # Quest 1 flavour ``` 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 -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 -Install ``` diff --git a/android/Run-Quest.ps1 b/android/Run-Quest.ps1 index 6cf351f..f2c3c40 100644 --- a/android/Run-Quest.ps1 +++ b/android/Run-Quest.ps1 @@ -3,7 +3,8 @@ # diagnostics for one session. # # powershell -ExecutionPolicy Bypass -File android/Run-Quest.ps1 [-Apk ] [-Data ] -# [-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/, # disc/ ...). It is pushed once to a staging folder, then moved into the app's @@ -24,6 +25,8 @@ param( [string]$Apk = '', [string]$Data = '', + [ValidateSet('modern', 'quest1')] [string]$Headset = 'modern', + [ValidateSet('debug', 'release')] [string]$Configuration = 'debug', [int]$Seconds = 60, [switch]$NoLaunch, [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 $Apk) { - $Apk = Get-ChildItem -Path (Join-Path $root 'app\build\outputs\apk') -Recurse -Filter '*.apk' | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 | ForEach-Object FullName + $flavour = if ($Headset -eq 'quest1') { 'quest1' } else { 'modernQuest' } + $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' } Write-Host "Installing $Apk" diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d61670b..51f7cb5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,4 +1,5 @@ import javax.inject.Inject +import org.gradle.api.provider.Property import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -145,6 +146,7 @@ android { 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. @@ -156,6 +158,7 @@ android { 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. @@ -221,6 +224,12 @@ abstract class ExportQuestGameKit : DefaultTask() { @get:Internal abstract val llvmStrip: RegularFileProperty + @get:Input + abstract val androidCpu: Property + + @get:Input + abstract val cmakeBuildType: Property + @get:Inject abstract val execOperations: ExecOperations @@ -232,18 +241,39 @@ abstract class ExportQuestGameKit : DefaultTask() { @TaskAction fun export() { 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" } - .maxByOrNull { it.lastModified() } - ?: throw GradleException("No libmkw_quest_kit_probe.so; the native build did not produce the game kit probe") - val configuration = probe.parentFile.parentFile.parentFile // / - val binaryDir = File(app, ".cxx/${configuration.parentFile.name}/${configuration.name}/arm64-v8a") - if (!File(binaryDir, "build.ninja").isFile) throw GradleException("No CMake tree at $binaryDir") + .map { probe -> + val configuration = probe.parentFile.parentFile.parentFile // / + val binaryDir = File(app, ".cxx/${configuration.parentFile.name}/${configuration.name}/arm64-v8a") + val cacheFile = File(binaryDir, "CMakeCache.txt") + 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") execOperations.exec { commandLine( "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.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") appDir.set(layout.projectDirectory) 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 { it.file("toolchains/llvm/prebuilt/$host/bin/llvm-strip" + if (host.startsWith("windows")) ".exe" else "") }) diff --git a/android/app/src/main/java/org/wiicompiled/quest/GameLibrary.kt b/android/app/src/main/java/org/wiicompiled/quest/GameLibrary.kt index 0aa9481..ba0c008 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/GameLibrary.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/GameLibrary.kt @@ -50,6 +50,8 @@ object GameLibrary { if (manifest == null || !library(context, profile).isFile) { 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 } } diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/GameBuild.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameBuild.kt index 0e1e2e2..cead748 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/GameBuild.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/GameBuild.kt @@ -133,6 +133,9 @@ object GameBuild { if (kitJson.optInt("schema") != KIT_SCHEMA) { 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) ?: return "This app's game kit cannot build ${profile.id}." report(40, Step.Prepare) @@ -570,6 +573,7 @@ object GameBuild { .put("dolSha256", BuildConfig.DISC_DOL_SHA256) .put("relSha256", BuildConfig.DISC_REL_SHA256) .put("kitFingerprint", kitFingerprint) + .put("androidCpu", BuildConfig.ANDROID_CPU) .put("library", GameLibrary.LIBRARY_NAME) .put("librarySha256", BuildRecipe.hex(digest.digest())) .put("includesData", false) diff --git a/android/app/src/main/java/org/wiicompiled/quest/launcher/GamePackageImport.kt b/android/app/src/main/java/org/wiicompiled/quest/launcher/GamePackageImport.kt index bf7c01b..eb7229c 100644 --- a/android/app/src/main/java/org/wiicompiled/quest/launcher/GamePackageImport.kt +++ b/android/app/src/main/java/org/wiicompiled/quest/launcher/GamePackageImport.kt @@ -157,6 +157,9 @@ object GamePackageImport { 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." } + 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") if (librarySha256 == null) { return "This game file has no game library (libmain.so)." diff --git a/aurora-main/cmake/AuroraDawnProvider.cmake b/aurora-main/cmake/AuroraDawnProvider.cmake index 90584d0..2cf1a36 100644 --- a/aurora-main/cmake/AuroraDawnProvider.cmake +++ b/aurora-main/cmake/AuroraDawnProvider.cmake @@ -154,13 +154,6 @@ elseif (_aurora_dawn_provider STREQUAL "package") endif () set(AURORA_DAWN_PACKAGE_URL "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, # 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") elseif (_dawn_system STREQUAL "android" AND _dawn_arch STREQUAL "aarch64") set(AURORA_DAWN_PACKAGE_URL_HASH - "SHA256=27d910dee1201fd1e5b6ac567f0ba2306ebf2135e9f40b6929976c365d38b09b") + "SHA256=0e63e8cbf53551f703f582d1306f4257c0380353f66b53369d96952ce6d9f934") endif () endif () endif () diff --git a/aurora-main/lib/gfx/pipeline_cache.cpp b/aurora-main/lib/gfx/pipeline_cache.cpp index 9b23d95..9adac95 100644 --- a/aurora-main/lib/gfx/pipeline_cache.cpp +++ b/aurora-main/lib/gfx/pipeline_cache.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +27,7 @@ #if defined(__ANDROID__) #include #include +#include #endif 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 // render and game threads, and cap large hosts to limit driver submissions and memory use. 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; -#endif -// Cached clear and GX pipelines are normally prewarmed using the full worker -// pool. Mobile Adreno drivers serialize much of pipeline creation internally; -// prewarming hundreds of recipes there starves first-use pipelines for over a -// minute. Keep cached recipes dormant on Android. A recipe is promoted to the -// priority queue as soon as the game actually requests it. +constexpr size_t Quest1MaxPipelineWorkers = 2; +static bool g_quest1PipelineScheduling = false; + +static bool quest1_pipeline_scheduling() noexcept { #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 -constexpr size_t MaxBackgroundPipelineWorkers = MaxPipelineWorkers; + 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) #ifdef NDEBUG 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)) { auto* pending = touch_pending_pipeline(hash, g_pipelineFrameActive); - // A cached Android recipe can sit dormant because background prewarm is - // disabled. Promoting it to first-use priority must wake a worker before + // 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; @@ -1063,9 +1066,11 @@ static void pipeline_worker() { #endif #if defined(__ANDROID__) pthread_setname_np(pthread_self(), "GXPipeline"); - // setpriority(PRIO_PROCESS, 0, ...) targets the calling Linux thread. - // Pipeline creation may finish later, but must not preempt gameplay or XR. - setpriority(PRIO_PROCESS, 0, 5); + 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) { @@ -1076,7 +1081,7 @@ static void pipeline_worker() { g_pipelineCv.wait(lock, [] { return !g_priorityPipelines.empty() || (!g_backgroundPipelines.empty() && - g_activeBackgroundPipelineWorkers < MaxBackgroundPipelineWorkers) || + g_activeBackgroundPipelineWorkers < max_background_pipeline_workers()) || g_pipelineThreadEnd; }); if (g_pipelineThreadEnd) { @@ -1125,7 +1130,8 @@ static size_t pipeline_worker_count() { } const size_t availableWorkers = 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 @@ -1286,6 +1292,7 @@ void initialize_pipeline_cache() { g_pipelineFrameActive = false; g_pipelineThreadEnd = false; g_activeBackgroundPipelineWorkers = 0; + g_quest1PipelineScheduling = quest1_pipeline_scheduling(); if (webgpu::g_backendType == wgpu::BackendType::OpenGL || webgpu::g_backendType == wgpu::BackendType::OpenGLES || webgpu::g_backendType == wgpu::BackendType::WebGPU) { @@ -1298,7 +1305,7 @@ void initialize_pipeline_cache() { g_pipelineThreads.emplace_back(pipeline_worker); } Log.info("Enabled {} priority pipeline compilation workers ({} background prewarm)", - workerCount, MaxBackgroundPipelineWorkers); + workerCount, max_background_pipeline_workers()); } load_pipeline_cache(); diff --git a/docs/quest-port.md b/docs/quest-port.md index 5c3f107..6a020f8 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -110,9 +110,10 @@ 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. Android builds use a pinned Dawn package which does not enable -`VK_EXT_debug_utils`: the old Quest firmware advertises that extension but -rejects it when Dawn creates the Vulkan instance. +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 @@ -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 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 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 -workers are awakened when the game first requests it. Desktop keeps the full -worker pool and prewarm behavior. +workers are awakened when the game first requests it. Newer Android headsets +and desktop retain the normal worker pool and prewarm behavior. ### 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 -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 -Headset quest1 -Install # game package from the Quest 1 kit powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod -Install # the mod and its pack (needs translate-mod output) adb push MarioKart.iso /sdcard/Download/ # then Select disc image in the launcher ``` From bca303caedc636103e2b1dc2db006fc833407465 Mon Sep 17 00:00:00 2001 From: darthcircuit Date: Wed, 23 Sep 2026 15:31:38 -0600 Subject: [PATCH 4/4] Quest: fix Windows game kit export --- android/QuestGameKit.psm1 | 2 +- android/app/build.gradle.kts | 16 +++++----------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/android/QuestGameKit.psm1 b/android/QuestGameKit.psm1 index b04b406..28cce90 100644 --- a/android/QuestGameKit.psm1 +++ b/android/QuestGameKit.psm1 @@ -274,7 +274,7 @@ function Export-QuestGameKit { $ErrorActionPreference = 'Stop' $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() + $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'" } diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index efc0314..49f9647 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -227,9 +227,6 @@ abstract class ExportQuestGameKit : DefaultTask() { @get:Input abstract val androidCpu: Property - @get:Input - abstract val cmakeBuildType: Property - @get:Inject abstract val execOperations: ExecOperations @@ -241,7 +238,7 @@ abstract class ExportQuestGameKit : DefaultTask() { @TaskAction fun export() { val app = appDir.get().asFile - data class Candidate(val probe: File, val binaryDir: File, val cpu: String?, val buildType: String?) + 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() @@ -253,19 +250,17 @@ abstract class ExportQuestGameKit : DefaultTask() { val binaryDir = File(app, ".cxx/${configuration.parentFile.name}/${configuration.name}/arm64-v8a") val cacheFile = File(binaryDir, "CMakeCache.txt") val cache = cacheFile.takeIf { it.isFile }?.readText().orEmpty() - Candidate(probe, binaryDir, cacheValue(cache, "MKW_ANDROID_CPU"), cacheValue(cache, "CMAKE_BUILD_TYPE")) + Candidate(probe, binaryDir, cacheValue(cache, "MKW_ANDROID_CPU")) } .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 + it.cpu == expectedCpu && 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})" } + "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") @@ -324,7 +319,6 @@ androidComponents { appDir.set(layout.projectDirectory) 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 { it.file("toolchains/llvm/prebuilt/$host/bin/llvm-strip" + if (host.startsWith("windows")) ".exe" else "")