The native-registration scan only knew PPC_NATIVE_OVERRIDE, so every GX_DEFERRED_OVERRIDE_VOID was missing from the index; the deferred form registers `symbol` and posts the hand-written `symbol_gx`. A Retro Rewind kit built from a translation without the Retro-WFC payload now refuses to package instead of crashing on entering WFC, and on Android ImGui keeps off SDL's cursor, which ART aborts on from a guest fiber's stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
55 KiB
WiiCompiled VR on Meta Quest (standalone Android)
This document is the design and build reference for the native Quest build. It
complements OPENXR.md, which remains the specification for the presentation
policy, the virtual screen, the first-person camera and frame interpolation:
all of that is shared, unchanged, between the Windows D3D12 product and the
Quest Vulkan product. What differs is everything below the stereo replay: the
graphics binding, the app shell and the platform glue.
Sources of the design
- KartPad (
kartpad-main/, thekartpad-androidruntime branch of the WiiCompiled fork) proved that the translated game runs on Android arm64 with Aurora on Dawn/Vulkan under SDL3'sSDLActivity. Its lessons carried over: the products are shared libraries SDL loads, the activity exports its directories through the environment before native code runs, the Windows-generated blob assembly needs its section syntax rewritten for ELF, and Dawn's android-aarch64 prebuilt package needs one path rewritten. KartPad's Android fiber/JNI split (never calling Java-backed SDL APIs from a guest fiber stack) is respected here by keeping every OpenXR call on the dedicated pacing thread, which is a realstd::thread. - DolphinXR (
Dolphin-OpenXR-2/, quest flavour) supplied the Quest-side specifics: the loader must be initialised with the activity as its context or the session never leavesIDLE;XrInstanceCreateInfoAndroidKHRmust be chained on instance creation; the manifest needs the Khronos runtime broker queries, theOPENXR_SYSTEMpermission, thecom.oculus.intent.category.VRintent category and theXR_ACTIVITY_START_MODE_FULL_SPACE_UNMANAGEDproperty;XR_KHR_android_thread_settingsmay reject the renderer-worker type on some runtime builds.
Architecture
One runtime, two graphics bindings
runtime/src/vr/openxr_integration.cpp owns the pacing thread, policy
evaluation, the retained-layer protocol and the head-pose maths. It is written
against the backend-neutral vocabulary in runtime/include/vr/openxr_backend.h
(OpenXRPresentation, OpenXRBackendFrame, OpenXRBeginStatus,
OpenXRSubmissionStatus) and selects one backend class at compile time:
| Platform | Backend | Binding |
|---|---|---|
| Windows | OpenXRD3D12Backend (openxr_d3d12.cpp) |
Dawn's own D3D12 device and queue are bound to the session; eyes are copied on that queue. |
| Android | OpenXRVulkanBackend (openxr_vulkan.cpp) |
The backend creates its own VkInstance/VkDevice through the OpenXR runtime; Dawn and that device meet on AHardwareBuffers. |
The D3D12 types keep their old names through aliases, so openxr_d3d12_replay_tests
and the desktop code did not change.
Why a second Vulkan device
The pinned Dawn package (v20260603.191052) exposes only VkInstance from its
Vulkan backend, no VkDevice, VkQueue or queue family, and it will not enable
the device extensions the OpenXR runtime demands. Binding Dawn's device to the
session is therefore impossible without a patched Dawn. Instead:
xrCreateVulkanInstanceKHR/xrCreateVulkanDeviceKHR(XR_KHR_vulkan_enable2, with anXR_KHR_vulkan_enablefallback that queries the extension lists) create a small Vulkan device the runtime is happy with.- Per eye, two
AHardwareBuffers (R8G8B8A8_UNORM, or RGBA16F when Aurora renders float) are allocated and imported on that device (VK_ANDROID_external_memory_android_hardware_buffer). - Aurora imports the same buffers as Dawn shared texture memory
(
SharedTextureMemoryAHardwareBuffer) and, inside the frame worker's command buffer, copies each replayed eye into the buffer (aurora-main/lib/webgpu/vulkan_interop.cpp, the twin ofd3d12_interop.cppand registered through the same stereo sink). - Ordering across the two devices uses Android sync file descriptors:
Dawn's
EndAccessexports aSharedFenceSyncFDthe OpenXR device waits on before itsvkCmdCopyImageinto the acquired swapchain image, and the copy signals an exportable semaphore whose sync fd Dawn waits on before it writes that buffer again. Image layouts follow Vulkan's rule that a queue family ownership release and acquire must repeat the same old/new layout pair: Dawn reports its release layout, the OpenXR side acquires with it, transitions for the copy, and hands the buffer back inGENERALwith a transition-free release so Dawn's acquire can mirror it. - The copy runs on the queue bound to the session before
xrReleaseSwapchainImage, so the compositor sees ordinary same-queue work.
Dawn's release fences are imported into semaphores owned by the copy's
submission slot, not by the shared buffer: importing into a semaphore whose
previous wait is still pending is invalid, and only the slot's fence proves
that wait completed before the semaphore is reused. Each frame hands Dawn a
duplicate of the buffer's copy-out fence, so a frame cancelled before encoding
keeps the ordering against the last real reader. A copy that never reached the
queue (a submit refused for memory, Aurora failing before it recorded anything)
ends its frame on the retained layer with the VkResult in the log, and the
next frame is tried; only work that may have been queued with no completion
marker, a lost device above all, ends the session. Three hundred skipped copies
in a row end it too.
The cost is one extra GPU copy per eye per frame, a few hundred microseconds at Quest eye resolutions; the benefit is that stock Dawn is used unchanged and the OpenXR device outlives Aurora's, which is exactly the failure DolphinXR hit on Vulkan when a game's device was destroyed under the compositor.
gpu.cpp steers Aurora to an RGBA8 surface format under xrInterop on Android
(there is no BGRA AHardwareBuffer format) and requests the two Dawn features
the bridge needs.
Controllers
Quest Touch controllers are not HID gamepads, so openxr_input.cpp syncs an
OpenXR action set on the pacing thread and feeds a virtual SDL joystick
(SDL_AttachVirtualJoystick, type gamepad) that Aurora assigns to player 1.
By default ([vr] controller_mode = "wii_remote") that port is then served
through KPAD as a Wii Remote with a Nunchuk, with motion and an IR pointer
aimed at the virtual screen; see "Controllers" in OPENXR.md for the mapping
and the geometry. With controller_mode = "gamepad" it stays an ordinary pad:
A/B → South/East, X/Y → West/North, index triggers → trigger axes, grips →
shoulders, thumbsticks → sticks (clicks → stick buttons), left menu → Start,
and every existing binding, dead zone and overlay setting applies. Bindings are
suggested for oculus/touch_controller and khr/simple_controller.
Android platform glue
runtime/src/vr/openxr_android.cpp:xrInitializeLoaderKHRwith the JavaVM and activity SDL already holds, theXrInstanceCreateInfoAndroidKHRchain (OpenXRConfig::instance_create_next), and theXR_KHR_android_thread_settingshints: the game thread (SDL's main thread, which carries the guest fibers) as application main, Aurora's frame worker as renderer main and the pacing thread as renderer worker, so the runtime keeps the two busy threads on the fast cores. The log says which hints the runtime accepted.runtime/src/platform/host_platform.cpp/runtime_config.h: the activity exportsMKW_ANDROID_DATA_DIR(external files dir, user reachable) andMKW_ANDROID_RESOURCES_DIR(unpackedwii_bootstrap/,dsp_coef.bin,initial_pipeline_cache.db); the latter stands in for the executable directory so the existing adjacent-file lookups work unchanged.main.cppincludesSDL_main.hon Android soSDLActivityfindsSDL_maininlibmain.so, and passes the resources path to Aurora.- Fibers use the vendored libco AArch64 backend (Bionic is Linux), guest memory
uses the Linux
mmappath, the MPRIS media monitor is compiled out. - Surface readiness. Aurora presents only while
g_surfaceReadyis set, and on Android that flag starts false. Stock SDL3 exports neither its activity mutex (Android_LockActivityMutex) nor a readiness hook, so the app'sQuestSurfacesubclass brackets SDL'ssurfaceChanged/surfaceDestroyedwithaurora_android_begin/end_surface_mutation(aurora/android.h), and Aurora'sSurfaceLockowns its own recursive mutex. This is KartPad's design. - No full-display mirror. SDL sizes the app's Android surface to the whole
display (4128x2208 on a Quest 3), which nobody sees while OpenXR drives the
headset.
QuestSurfacepins the surface buffer to 1280x720. That size also sets Aurora's presentation snapshot, which is the image the menu virtual screen shows in each eye, where it spans about 900 pixels. While a stereo provider is registered on Android, Aurora skips the surface present and the desktop mirror copy (headset_owns_displayinlib/aurora.cpp). The game's own render size is unaffected: atresolution_multiplier = 1it is 640x528. Since 2026-09-19 an immersive race also stops that native render after the last pass whose EFB copy the eye replays sample (last_pass_feeding_replay): the main scene and display copy of a 1280x720 image nobody sees were 4 to 6 ms of a 12 ms GPU frame on a Quest 3. A pending CPU readback of an EFB copy or a frame capture still renders the whole image, and menus (the virtual screen) keep it because their eyes are built from that snapshot. - JNI only on the real thread stack. Guest threads run on libco stacks
inside the SDL thread, and SDL's Android event pump can reach Java (joystick
polling, HIDAPI). ART binds JNI transitions to the thread's real stack, so
Aurora's
pump_eventsis a no-op on Android andUpdateAuroraAndProcessEventsdefers a poll made from a guest fiber until control is back on the scheduler context (GuestFiberManager::IsOnSchedulerFiber). The default guest thread shares the scheduler context, so most polls run immediately. Also KartPad's finding, from device crashes. - Time conversion for frame interpolation uses
XR_KHR_convert_timespec_time(CLOCK_MONOTONIC, the clock behindsteady_clockon Bionic).
Launcher and game process
The app opens on LauncherActivity (android/app/src/main/java/org/wiicompiled/quest/launcher/),
a 2D Horizon OS panel modelled on the PC launcher, WheelWizard VR, and using its
palette. Home has the Play button and reports a missing or incomplete DATA
(the check is the runtime's own IsDvdDataRoot: files/ and sys/fst.bin).
Settings edits Config.toml in tabs: VR (camera, rotation, driver hiding,
lean back, render scale, VR interpolation, virtual screen size and distance),
Graphics (resolution, widescreen, bloom, shader stutter), Controls (controller
mode, vibration, the Wii Remote mapping), Audio, and About (paths, OpenXR
logging). The launch-time geometry (render_scale, hud_distance_meters,
hud_width_meters) is only reachable here, not from the in-headset panel.
The launcher follows the runtime's rules exactly. TomlConfig edits one line
the way RuntimeConfigFile::WriteSetting does, and every edit re-reads the file,
so values the in-headset panel wrote are kept. Each row reads its key with the
runtime's default and accepted range. TomlConfigTest (gradlew :app:testBaseDebugUnitTest) covers the editor.
QuestActivity, the immersive game, is started from Play, like DolphinXR's
EmulationActivity: com.oculus.intent.category.VR without LAUNCHER. It runs
in its own :game process, and its onDestroy ends that process. This is
required, not tidiness:
- SDLActivity calls
System.exit(0)when an activity is created again afterSDL_mainhas returned. In one shared process, the second Play would kill the launcher along with the game. - Guest memory, fibers, static configuration and the OpenXR Vulkan device all belong to the process. DolphinXR crashed when it opened a second Vulkan VR session in the same process.
Every session therefore starts in a fresh process, and the launcher loads no
game code. While the :game process is alive, Home shows Resume and Settings
warns that changes wait for a restart. adb shell am start -n org.wiicompiled.quest/.QuestActivity still starts the game directly, which is
what Run-Quest.ps1 does.
Extracting the player's disc image
Without DATA, Home's main button is Select disc image (About has the same
action for replacing DATA). The player picks their own image with Android's
document picker, and the launcher extracts it the way the PC installer does
(Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs). Both use nod
v2.0.0-alpha.10, so both write the same layout:
- The file name must be a format nod reads: ISO, GCM, GCZ, CISO, WBFS, WIA or RVZ.
- The header must be the pinned game ID, and
main.dolandStaticR.rel, read straight from the image, must match the pinned SHA-256s. Gradle reads all three pins fromprojects/mkwii/recomp.ymlintoBuildConfig, so a wrong disc fails in seconds, before anything is written. - The data partition is extracted into
DATA.extractingnext toDATA, after a free-space check. - The extracted files are checked against the same pins, as
ValidateExtractedGamedoes. Only then is an existingDATArenamed away, the new one moved in, and the old one deleted. A failed, cancelled or killed run never costs a workingDATA.
nod is the one piece of native code the launcher loads: android/nod-jni is
a Rust cdylib with three JNI calls (header, read one file, extract). The
extraction is nodtool's extract command, plus progress reporting and
cancellation. It reads the picker's file descriptor with pread, so nod's
preloader threads each hold their own clone. Wii partition decryption needs
the common key, and that key lives in the nod crate fetched at build time, not
in this repository. The PC installer is the same way: it downloads nodtool.
GameSetupService runs the job as a dataSync foreground service with a
partial wake lock. A multi-minute extraction then survives the panel being
closed and the headset being taken off. DiscChecksTest covers the acceptance
rules and their messages.
No game code in the APK: the game kit
Like the PC installer, which compiles the translated game on the player's machine, the base
APK contains no translated Mario Kart code. It carries a game kit (assets/game_kit, about
105 MB before compression). The kit is everything libmain.so links except the game: the
runtime, aurora, Dawn and the other dependencies, prebuilt and stripped, plus kit.json, the
recipe that compiles and links a player's own translation against them. The player's
libmain.so is built from their disc and loaded from internal private storage (GameLibrary),
the only place Android lets an app load native code it did not install.
The kit is exported from CMake's own build graph, so its flags cannot drift from a normal build:
runtime/cmake/PublicProducts.cmakedefinesmkw_quest_kit_probeon Android: WiiCompiledWITHOUT_GAME(no base shards, and the runtime objects$<FILTER>ed of the two disc-generated sources, which skip the unity build on Android for that reason), linked with the game's symbols unresolved. The app builds this probe instead of the product.android/QuestGameKit.psm1(Export-QuestGameKit, run by theexport*QuestGameKitGradle tasks) turns the probe's ninja link edge intokit.json's ordered link inputs. It adds{game:runtime},{game:product}and{game:translated}markers where WiiCompiled had those objects, and translated shards link inside--start-lib/--end-libwith archive semantics aslibmkw_base_shared.adid. It takes the compile flags CMake recorded for one source of each generated kind. The fingerprint hashes the recipe and every file.Invoke-QuestGameBuildreplays the recipe with ninja. On the development PC, a library built this way had the same 61,975 defined and 785 undefined dynamic symbols, 29,995 translated functions,NEEDEDlist and soname as the CMake-built one, in 2.4 minutes.
Build-Quest.ps1 refuses an APK that contains any libmain*.so or lacks the kit, and packaging
excludes **/libmain*.so and **/libmkw_quest_kit_probe*.so, since AGP packages every library
left in the CMake output directory. The func_8… symbols the kit's runtime objects define are
hand-written HLE overrides (PPC_NATIVE_OVERRIDE_* in hle_stubs.h), not translated code.
Retro Rewind rides in the same app, on its own kit (mkw_quest_kit_probe_retro, RetroRewind
without any translated code), so the APK ships neither game. The mod needs one more link slot than
the base game, because the modded product links more kinds of translated code: {game:runtime}
(the disc-generated sources), {game:product} (the mod's registration and dispatch shards),
{game:mod} (the mod's own shards, the profile-sensitive base shards it replaces, and the mod's
data patches) and {game:translated} (the shared base shards, with the archive semantics
libmkw_base_shared.a has). Rather than teach each builder which game it is building, kit.json
carries a sources map naming the shards.cmake list behind each slot, and the builders just
follow it.
One APK, one kit: kit.json (schema 3) holds a products map with a base entry and, when the
translation includes the mod, a retro_rewind entry, each with its own compile flags, link line,
slots and fingerprint; runtimeIncludeFingerprint and the kit's own fingerprint stay at the
top level. Everything that builds a game names the product it wants: Build-QuestGame.ps1 -Product base|retro_rewind, Setup --quest-product, and on the headset GameProfile. Each game
lives in its own directory under files/game/<profile>, so both can be installed at once and the
launcher's toggle switches between them; the selected one is remembered in filesDir/selected-game
(a plain file, because the launcher and the :game process do not share preferences).
Retro Rewind also needs its 2 GB pack on the headset. The app writes [paths] retro_rewind_root
into Config.toml, and the pack arrives either way a PC player gets it:
- Download Retro Rewind (
RetroRewindPack, aGameSetuptask) fetches it from Retro Rewind's own distribution server, exactly as WheelWizard does on a PC.RetroRewindInstall.txtnames the full install zip,RetroRewindVersion.txtlists<version> <url> <path> <description>per published update andRetroRewindDelete.txtlists<version> <path>deletions; an installation is the base zip plus every update newer than theversion.txtit holds. Only entries underRetroRewind6/are kept (the Riivolution XML beside them belongs to a Wii setup), a base install is staged and swapped like every other task, and the version is written after the last update, so an interrupted update simply runs again. Nothing of the pack ships in the APK. - Or a
.wcgamecarries it (below), for a headset with no Wi‑Fi to spare.
Home's main button becomes Download Retro Rewind whenever that game is selected and its pack is
missing, and Settings → About shows the installed version with an Update button. Building the mod on
the headset needs the mod's Code.pul, which is part of the pack, so the same rule covers it.
Online play (Retro Rewind WFC) needs the Retro-WFC payload translated into the mod, as on a PC:
translate-mod --retro-wfc-payload, with the payload Setup downloads and verifies from
https://rwfc.net/api/wfc/payload?g=RMCPD00. Without it the mod downloads WWFC/Payload while
connecting and jumps into code that was never translated, and the game stops with a missing
translated function (seen: 0x81895BF4, called from rr_kamek_* on the NHTTPi_CommThreadProc
thread, with r3 pointing at "WWFC/Payload"). So the headset
build downloads the payload before translating and checks it with validate-retro-wfc-payload, and
Invoke-QuestGameBuild refuses a Retro Rewind translation whose mod_data_patches.cpp has no
kRetroWfcInitializerAddress. The payload is fixed at build time: when rwfc.net publishes a new
one, rebuild the game.
Game packages (.wcgame) and Import from computer
A .wcgame is a zip holding game.json, libmain.so and optionally DATA/… (the extracted
disc) and MOD/… (the RetroRewind6 pack), both written without compression. game.json records
the profile, game ID, main.dol and StaticR.rel pins, the kit fingerprint, the library's
SHA-256, whether the package carries game files and mod content, and who built it.
android/Build-QuestGame.ps1 -Product base|retro_rewind builds one on a PC from the translator's
output and the kit the last APK build exported; -Data includes the game files and -Mod <RetroRewind6> the pack. -Install pushes it into the app's Import folder. The launcher
creates that folder itself so it owns it, and imports the newest package the next time it opens,
once per package. An import selects the game it just installed.
Players get the same build from WheelWizard VR: Settings → WiiCompiled → Meta Quest → Build. WheelWizard asks which game, whether to include the game files, for Retro Rewind whether to include its pack, for the Quest app's APK and where to save the package, then runs the installed setup:
WiiCompiled-Setup.exe --build-quest --install-dir <install> --quest-apk <app.apk> --output <file.wcgame>
--quest-product base|retro_rewind [--include-game-files]
[--retro-dir <RetroRewind6> --include-mod-content] --progress-json
Setup extracts assets/game_kit from the APK into <install>\QuestBuild\kit, so the game always
matches the app it goes to. It then runs the installation's staged copy of Build-QuestGame.ps1
over its own BuildWorkspace\generated, recomp.yml, the toolkit's ninja and, with
--include-game-files, GameAssets\DATA. The Android compiler is not part of the toolkit: the
first build downloads Google's android-ndk-r29-windows.zip (834 MB, SHA-1 pinned in
QuestBuildService.Ndk, under the Android SDK License, so it is never redistributed). It keeps
only the ~230 MB that a build needs (clang, lld, clang's headers, the aarch64 runtime libraries and
sysroot) in <install>\QuestBuild\android-ndk-29.0.14206865, and deletes the archive.
WIICOMPILED_QUEST_NDK_TOOLCHAIN points it at an existing NDK LLVM directory instead. Besides
progress and the terminal result, the stream carries one
{"type":"quest-package","path","kitFingerprint","includesGameFiles","sizeBytes"} line. A setup
that supports all this says "questBuild": true in --info-json, and WheelWizard asks older ones
to update. The package is written as <file>.partial and renamed at the end; a failed or
cancelled build removes it. The kit's runtimeIncludeFingerprint must match the installation's
runtime/include, so an installation only builds for the Quest app from the same release.
GamePackageImport (Home's Import from computer, or the Import folder) stages the library
and any DATA next to their destinations. It accepts them only if game.json names this
profile, the pinned disc, this APK's kit fingerprint, and a library hash matching the bytes, and
if the game files pass the same checks as an extraction. A package built for another app version
is refused, and an installed game whose kit fingerprint no longer matches the APK shows as stale
on Home.
Home's main button is always the next step: Select disc image while there are no game files,
Build on this Quest once they are there and no game is installed (or the installed one is
stale), and Play once both are present. Import from computer sits beside the first two.
Reset installation leads instead when the game files are there but unusable, or when an
attempt to set them up failed and left none that work; a failed attempt over working files offers
it as the second button, and Settings → Other always has it. It removes the game files with any
unfinished extraction or import, and on request the built games with the on-device build
workspace and the Retro Rewind pack; Config.toml, the saves and the logs stay (InstallReset, a
GameSetup task like the others, with progress and cancel). Nothing that replaces files the game
reads, a reset included, starts while the game process is alive.
Building the game on the headset
Build on this Quest (GameBuild, a GameSetup task in GameSetupService) does on the
headset what Build-QuestGame.ps1 does on a PC, from DATA, in about 28 minutes on a Quest 3:
- It unpacks
assets/quest_toolchainandassets/game_kitintofiles/build/. - It downloads the NDK files a build needs (below) into
files/build/ndk. - It translates the disc:
translate-recursive,generate-data-init --target-os androidandemit-build-shards, in a workspace made of the kit'stranslation/copy ofrecomp.yml,MAP.txtandruntime/src, plusmain.dolandStaticR.relfromDATA. A translation of the same kit, toolchain and disc is reused. - It compiles the generated sources with the kit's flags, up to four at a time (fewer when the
available memory allows less than 700 MB each). The blob assembly is compiled as plain
-x assembler: preprocessing a.Smakes clang start itself, which the linker trick below cannot do. Finished objects survive a cancelled or failed run. - It links with
kit.json'slink.lldand installslibmain.soandgame.jsonthrough the same staging swap as an import.builtBysays the headset built it. A successful build deletesfiles/build.
The log goes to Logs/build_<stamp>.log next to DATA.
The toolchain (android/Prepare-QuestToolchain.ps1, run by the prepareBase*QuestToolchain
Gradle tasks) is 249 MB unpacked. It travels as one deflated 80 MB files.zip, because asset
packaging never compresses .so files, next to toolchain.json, which lists every file. It holds:
- the translator published for
linux-bionic-arm64from a copy of the sources retargeted to net10.0, which is the first .NET with those runtime packs. That runtime is Mono. translator_host(android/toolchain/translator_host.c).- Termux's clang/lld 21.1.8 and the ten shared libraries they and the translator load, pinned by
package SHA-256 and stored under the names their users load. OpenSSL is stored as
libssl.so, the name .NET's shim opens on Android; Android's own BoringSSL lacks symbols it needs. ndk.json: the pin of the NDK files.
Android facts this design rests on, all measured on a Quest 3:
- An app cannot
execfiles in its private storage, but/system/bin/linker64 <absolute path>runs them (PrivateCodeExecutionTest). Every tool starts that way (ToolProcess), withLD_LIBRARY_PATHat the toolchain'sllvm/lib. - Under the linker, the .NET apphost reads
/proc/self/exe, gets the linker, and cannot find the app.translator_hosthands hostfxr the app directory instead. It also turns off bionic's heap pointer tagging, which crashes the runtime at startup. - Termux's clang driver compiles with the same
cc1arguments as the NDK's. Its link line is patched for Termux (-rpath,-L/system/lib64), and clang could not start lld anyway. So the kit export expands the link with the NDK's own driver (clang++ -###) intolink.lld, a raw lld command with{kit},{ndk},{output}and{game:*}placeholders, and the headset runsld.llddirectly. The compile uses Termux's clang resource headers, which match that compiler. - The NDK files come from Google's
android-ndk-r29-linux.zip, not from the APK.RemoteZipreads the zip's central directory and then only the 3,499 wanted entries, with HTTP range requests: the aarch64 sysroot,libc++_shared.so, the API 29 stubs and CRT objects, compiler-rt builtins,libunwind.aandlibatomic.a. That is about 8 MB compressed of 784 MB. Each file must match the SHA-256 inndk.json, and the list must match the digest pinned in the script. The requests sayAccept-Encoding: identity: Android's HTTP stack asks for gzip by default, and Google's server then serves a gzip-encoded zip whose byte ranges are not the file's (HTTP 416). The Linux zip is used because its sysroot holds headers whose names differ only in case (xt_TCPMSS.h,xt_tcpmss.h), which a Windows copy of the NDK loses. - Translation peaks at 3.1 GB with Mono's default heap and 2.1 GB with
MONO_GC_PARAMS=soft-heap-limit=1200mand four threads, for byte-identical output. The builder uses the latter.
BuildRecipeTest and RemoteZipTest cover the command lines and the zip reader.
Build system
runtime/CMakeLists.txtrecognisesCMAKE_SYSTEM_NAME=Androidon arm64 asMKW_PLATFORM_ANDROID: OpenXR on by default, Dawn from the pinned android-aarch64 package (digest pinned inAuroraDawnProvider.cmake, which also rewrites the package's absoluteliblog.sopath and looks the package up withNO_CMAKE_FIND_ROOT_PATHso the NDK sysroot rule does not hide it), SDL3 built shared (or-DAURORA_SDL3_PROVIDER=systemfor the AAR prefab), tests off, products built aslibmain.so/libmain_retro_rewind.so,-mcpu=cortex-a77(Quest 2's XR2 Gen 1; Quest 3/Pro are supersets). The products link with-Wl,-Bsymbolicand the translated shards compile with-fno-stack-protector: without them every one of the 29,000 translated functions called its neighbours and the runtime through a PLT stub (26,973 of them, 702 after), which was 4% of the game thread on a Quest 3, and the NDK's default canaries cost cycles in code whose state lives in guest memory. With those and a larger indirect-dispatch memo (kIndirectDispatchCacheEntries), a twelve-kart race start went from a 30 fps retrace lock to a steady 60. The initial-exec TLS model is not an option: Bionic refuses it in a library loaded withdlopen, which is how SDL loads the game (dlopen failed: TLS symbol ... using IE access model).runtime/cmake/PublicProducts.cmakegainsMKW_GENERATED_DIRso a build configured from a checkout can name the translator output tree, and on Android rewrites the PE/COFF.section .rdata,"dr"of a Windows-generated blob.Sinto ELF.rodataplus a GNU-stack note. The translator itself also learned--target-os windows|macos|linux|androidforgenerate-data-initandtranslate-mod, for pipelines that generate on another host.android/: the Gradle project, one app with no flavours.app/src/main/cpp/CMakeLists.txtadds the repository'sruntime/as a subdirectory with those Android choices and builds both game kit probes (the Retro Rewind one only when the translation includes the mod), whichexportDebugQuestGameKitturns into the single kit the app carries.android/nod-jni: Gradle'sbuildNodJnitask runscargo build --release --locked --target aarch64-linux-androidwith the NDK's clang as linker and C compiler.stageNodJniputslibnod_jni.sointo the APK'sarm64-v8alibraries.-Pcargo=<path>overrides the cargo binary.
Building
Prerequisites on the Windows host (all already present on the machine this
was developed on): JDK 17, Android SDK with platform 34+, NDK 29.0.14206865,
SDK CMake 3.22.1, adb, Rust 1.85+ with rustup target add aarch64-linux-android,
the .NET 10 SDK (for the headset's translator; the APK build downloads ~70 MB of Termux
packages into android/.dependencies the first time); a translated graph for your own disc (the
installer's BuildWorkspace/generated, produced by the normal Windows pipeline).
powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # SDL3 3.4.4 AAR into android/app/libs
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, its game kit and toolchain, debug-signed
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Install # your game, against that kit, into Import (or WheelWizard VR's Build for Quest)
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install # the mod and its pack (needs translate-mod output with --retro-wfc-payload)
adb push MarioKart.iso /sdcard/Download/ # then Select disc image in the launcher
Instead of the disc image, an already extracted partition can be pushed to
/sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA, or included in the
game package with Build-QuestGame.ps1 -Data <dir>. A game package only fits the APK whose kit
it was built against. After a native or runtime change, run both scripts again; after a
Kotlin-only change the kit fingerprint stays the same and the installed game keeps working.
Config.toml, saves and per-run logs live next to DATA under
WiiCompiledOpenXRVR; the launcher (or the game activity, when started
directly) writes a first Config.toml with [vr] enabled = true and
paths.dvd_root set. Logs: adb logcat -s SDL WiiCompiledQuest WiiCompiledLauncher
plus the Logs/<product>_<stamp>_pid<pid>/console.log folder the runtime writes.
A CMake-only cross-compile of the native runtime (no game) is the quick compile check and needs no Gradle:
cmake -S runtime -B .scratch/android-audit-build -G Ninja `
-DCMAKE_TOOLCHAIN_FILE=$env:LOCALAPPDATA/Android/Sdk/ndk/29.0.14206865/build/cmake/android.toolchain.cmake `
-DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-29 -DANDROID_STL=c++_shared `
-DCMAKE_BUILD_TYPE=Release -DMKW_BUILD_PRODUCTS=OFF
cmake --build .scratch/android-audit-build --target mkw_android_native_compile aurora_core aurora_gx
Validation status
What has been verified on the development machine (September 2026):
- Translator:
dotnet testpasses with the new--target-ostests (17/17 in the touched suites). - Windows:
mkw_openxr_replay_testsandmkw_vr_policy_testspass; themkw_runtime_commonandaurora_coretargets compile with the refactored integration; the workspace product rebuild linksWiiCompiled.exe. - Android: the cross-compile audit passes.
mkw_android_native_compile,aurora_coreandaurora_gxall build foraarch64-none-linux-android29with NDK 29.0.14206865, which covers the whole native runtime includingopenxr_vulkan.cpp,openxr_android.cpp,openxr_input.cppand the Aurora AHardwareBuffer bridge. Three Bionic portability fixes came out of it: thestd::mincall inhle/audio/audio.cppneeded an explicit type (int64_tislongon LP64 Android while the clock rep islong long),guest_flat_memory.cppneeds a__NR_memfd_createshim below API 30, and Crypto++'scpu.cppneeds the NDK'scpu-featuressource compiled in. - Device, 2026-09-16: running on a Quest 3 (HorizonOS 14, API 34). The
runtime negotiates
XR_KHR_vulkan_enable2(Vulkan 1.0 to 1.2), creates 1680x1760R8G8B8A8_SRGB(VkFormat 43) swapchains, attaches the controller actions, and the session reachesFOCUSED. The game boots through the title movies into the attract race, the policy switches toimmersive-racewith all 189 perspective draws replayed per eye, the first projection layer is submitted, and the menus return to the virtual screen. No WebGPU or OpenXR errors over a 90 second session. - Device, 2026-09-17: the headset built its own game. Build on this Quest ran inside the app
in 27.8 minutes on a Quest 3: unpacking the toolchain, 43 MB of NDK files downloaded from Google
and checked in about 6 seconds, translation in 646 s (2.1 GB peak), 92 sources compiled four at a
time in 16 minutes (about 250 MB each, 3 GB still available),
ld.lldin under a second, then the install and the cleanup of everything it unpacked. The game ran from the result: sessionFOCUSED, past 1,000 frames, the intro movie on the virtual screen. Thatlibmain.sois byte-identical to one the same toolchain built from a shell, and against the PC-built library it has the same soname,NEEDEDlist, 785 undefined symbols and 29,995 translated functions (61,974 defined against 61,975: the PC's older clang keeps one inline helper out of line). - Device, 2026-09-17: Retro Rewind from its own kit. The Retro Rewind APK carries a kit and no
game (66 MB). Its game built from that kit on the PC in 2.9 minutes (166 generated sources:
72 base shards, 24 profile-sensitive, 48 mod shards, the mod's data patches, 17 registration
shards and the 3 disc-generated sources), linked to a 157.6 MB
libmain.sowith the same import list as the base one, 67,872 defined symbols against the base library's 61,975, and 5,905 symbols the base library does not have. Packaged with the disc files (2.55 GB), imported on the Quest 3 in under two minutes, and the mod runs: its own title screen, "Press the A Button", and the licence menu, with the pack read fromretro_rewind_root. - Device, 2026-09-17: one app, both games. The merged APK (121 MB) carries one kit with a
baseand aretro_rewindrecipe, nolibmain*.soand no probe. Both games were built on the PC from that one kit (base in 0.1 min from cached objects, Retro Rewind in 3.3 min) and imported on a Quest 3: the base package in 3 s, the Retro Rewind one — 2.0 GB, carrying the pack — in 93 s, which installedRetroRewind6and selected the game it had just installed. Both live side by side infiles/game/<profile>. Retro Rewind ran first (its title screen), then Home's toggle switched to Mario Kart Wii, which ran from the same app. Two bugs this found: the on-device builder read a per-productfingerprintthat schema 3 keeps at the kit's top level, and aConfig.tomlwritten before this app offered Retro Rewind named no pack, so the mod would have found none —GameStorage.preparenow adds that one line to an existing config.
Bring-up fixes that only a device could reveal:
| Symptom | Cause | Fix |
|---|---|---|
Activity crashed with EACCES on Config.toml |
adb shell mkdir had created the app's data directory, so the shell user owned it |
Let the app create its own directory; Run-Quest.ps1 launches once before placing DATA |
| DATA unreadable by the game | adb-placed files stay owned by the shell user and directories are 2770 |
chmod -R a+rX DATA as the owning shell user; run-as cannot reach shared storage (SELinux) |
| "Cannot persist NAND setting.txt" | FUSE storage has no hard links; link() fails with EACCES, not EPERM |
Android falls back to exists-check plus rename in nand_settings.h |
SharedFence ... signaled value (0) was not 1 |
A sync fd is binary; Dawn expects value 1 | vulkan_interop.cpp passes 1 |
Link error on Android_LockActivityMutex |
SDL's activity mutex is not exported | Aurora-owned mutex plus the QuestSurface bracket (above) |
Crypto++ cpu-features.h not found |
The NDK ships cpu-features as source | Compiled into mkw_cryptopp on Android |
| Exploded racers and menu characters; smeared movie panels in the menus; then, once those were fixed, damaged eyes and slightly misplaced detail on characters | The Adreno 740 driver reads the wrong bytes when the shader multiplies an index by a stride that is not a multiple of 4. That covers the vertex fetch (ubuf.vtx_start + vidx * stride + offset) and indexed array reads (array_start + index * stride, e.g. 6-byte S16 normals). GX packs both byte-tight, so skinned models (a 1-byte PNMTXIDX first, stride 7) broke everywhere |
Android pads every uploaded vertex and every indexed-array element to a 4-byte stride (padded_upload_stride in lib/gx/gx.cpp). Offsets inside a vertex or element are unchanged, and desktop is unchanged. Fixed, headset-verified 2026-09-16 at character select and a Grand Prix start |
| Every launch recompiled every shader: a 14 to 34 s prewarm, and the Dawn blob cache reporting exactly one miss and no stores | Dawn's monolithic Vulkan pipeline cache is only written by PerformIdleTasks, which gpu.cpp resolved through the Windows DLL alone, and the quit path ends the process without aurora_shutdown, so nothing compiled after prewarm was kept either |
Static Dawn calls it directly; aurora_store_pipeline_caches runs at a race exit, when the session loses focus and on the quit path, and the compiler stores idle bursts itself while the headset shows the virtual screen. The unpacked initial_pipeline_cache.db is also refreshed per APK install now |
How the explosion was isolated, so the next Adreno rendering bug starts further ahead:
- The CPU side was identical to Windows: a per-draw audit of palette indices and matrices matched byte for byte. Menus reach the headset as the mono desktop image, so stereo replay was not involved either.
- Shader-side rewrites did not help and were removed: constant-index palette
matrix selection, shift-free sign extension, replacing
extractBits, and byte helpers rewritten with constant shifts or integer division. The last two made menus worse, which is what pointed away from any one helper. - Moving every attribute to a 4-byte boundary on the CPU fixed the explosion.
Padding only the stride, with offsets still packed, fixed it just as well,
which narrows the fault to the
vidx * strideterm. Mario's eyes stayed wrong under both, until indexed arrays got the same element padding. That combined padding is the shipped fix. It costs one copy per vertex and per array element. Peak uploads at a 12-racer race start were about 570 KB of the 3 MB vertex buffer and 710 KB of the 8 MB storage buffer. - KartPad's Android reports of corrupted drivers on Adreno 750 match this symptom. That is plausible but not tested.
Diagnostics that stay in the build, all read once at launch from system
properties. Set them with adb shell setprop <name> <value> before starting
the app:
| Property | Effect |
|---|---|
debug.wiicompiled.vtxpad 0 |
Turns the stride padding off, to re-check a driver update |
debug.wiicompiled.validation 1 |
Keeps WebGPU validation and robustness on in release builds |
debug.wiicompiled.inject <n>:<button> |
Presses a, b, x, y, start, up, down, left or right for 12 XR frames each time <n> changes. As a Wii Remote, x/y/start are 1/2/+, the directions push the Nunchuk stick, and home, c and z also exist. panel presses the settings panel's button (left Y, or both thumbsticks as a gamepad), opening or closing it (see OPENXR.md) |
debug.wiicompiled.fpslog 1 |
Logs the game's rendered frame rate every 5 s, with per-frame averages of the producer's waits for the frame worker's DONE and SEALED phases and of the worker's seal, permit wait, prepare and encode stretches. A third line reports the GX thread's command ring (records, waits, busy share). A second line gives the GPU time per frame from timestamp queries on every pass (mono native render, eyeL/eyeR replays, screen, panel, efbcopy, palette, peek, plus passes-span from the first pass begin to the last pass end and between-passes for copies and idle gaps). The compositor's VrApi log line gives headset FPS, GPU%, CPU%, clock levels and app GPU time (App=) |
A Config.toml written with adb push (or sed -i in adb shell) belongs
to the shell user afterwards, and the app then fails every save with EACCES
(the launcher logs GameStorage.prepare ... open failed). chmod 664 on the
pushed file gives the app's group write access back; a file the app created
itself never has the problem.
The injector makes headset tests possible with nobody wearing the headset. Keep the display awake, drive the menus, then take a compositor screenshot:
adb shell am broadcast -a com.oculus.vrpowermanager.prox_close
adb shell setprop debug.wiicompiled.inject 1:a # title -> licence; bump the number per press
adb shell am startservice -n com.oculus.metacam/.capture.CaptureService -a TAKE_SCREENSHOT
adb pull /sdcard/Oculus/Screenshots/<newest>.jpg
From a cold start, five a presses about 5 s apart, starting once the title
screen is up, reach Grand Prix character select. A value left over from an
earlier run is ignored on the first read. Presses only land while the XR
session is FOCUSED.
A debug APK also builds the game on the headset without a press, once DATA is there; the
build log is the newest Logs/build_*.log:
adb shell am start -n org.wiicompiled.quest/.launcher.LauncherActivity --ez org.wiicompiled.quest.debug.BUILD_GAME true
Performance, measured 2026-09-16 on a 50cc Luigi Circuit start with the player
idle, over 40 s, with an optimized build (-O3, translated code -O2,
-mcpu=cortex-a77):
| Build | Game FPS | Headset FPS | App GPU time | GPU% |
|---|---|---|---|---|
| Full-display mirror (4128x2208) | about 48.5 | 48.7 | 15.6 ms | 83 |
| 1280x720 surface, no present | about 48.6 | 49.3 | 14.9 ms | 81 |
Removing the mirror saved about 0.7 ms of GPU time per frame but did not raise the game rate. Two leads remain. The game runs below 60 FPS with the GPU at about 80%, and CPU and GPU clock levels sit at 4/3. The headset FPS also follows the game rate instead of holding 72 Hz, so the pacing thread is not repeating the last layer as it does on desktop. Both need profiling on the XR2 Gen 2.
Profiled 2026-09-19 on a twelve-kart 50cc Grand Prix start (Luigi Circuit,
render_scale 0.8, intro skipped, driven unattended by the button injector:
ten a presses 5 s apart from the title screen reach the race, one more skips
the course intro). The game thread is the limit, not the GPU: it is one
libco-hosted thread (about 60% translated game code, 20% GX HLE, 12% Aurora's
FIFO decode), and Aurora's frame worker, which encodes and submits the Dawn
work, runs at about half a core with most of its own time inside the Adreno
driver's ioctls. Trimming the GX HLE (a 4 KiB write-tracking granule, one
pointer probe for the texture-object shadow, inline padded vertex copies, a
throttled clock poll) changed nothing measurable against the same automated
start, and asking for XR_EXT_performance_settings BOOST is accepted but the
runtime keeps its own dynamic clocks (CPU level 4 at 1.9 to 2.2 GHz, GPU level
3 at 490 to 640 MHz). What did matter was a scheduler trace of the game
thread: it slept 3 to 4.5 ms of every frame, in 1 ms slices, on Aurora's
SEALED phase. Without interpolation the worker published SEALED only after the
whole encode and submit, so the producer's first GX drain of each frame waited
for the previous frame's encode (5 ms in menus, 9 to 11 ms in a race). The
worker now always releases the producer right after sealing; the same start
went from 47 to 53 fps to 55 to 58 fps, the game thread from 82% to 98% busy,
and menus lost the same 4.5 ms of idle wait per frame. With fpslog on, the
Game frame rate line now carries that breakdown (producer waits for DONE and
SEALED; the worker's seal, permit wait, prepare and encode) so the next
regression of this kind shows up in the session log. The remaining gap to 60
at the start is about 1 ms of game-thread CPU per frame, with the GPU at 85 to
89%, so the next steps are on both sides: the guest-code share (translator
output quality) and the eye replay's GPU cost.
The GPU side, measured the same day with per-pass timestamp queries (the second
fpslog line): on SNES Ghost Valley 2 at render_scale 0.5 (840x880 eyes) a
stereo frame cost 13.2 ms, of which the native render was 5.7 ms, the eyes 3.5
and 3.8, copies and gaps 0.4. That native render is a 1280x720 image nobody
sees during an immersive race, so it now stops after the last pass whose EFB
copy the eyes sample: mono fell to 0.15 ms and a Luigi Circuit start at 0.5
renders in 5.5 to 10 ms of GPU per frame. The last limiter was the headset
pacing: with the display at 72 or 90 Hz, each headset frame stayed open for
the next 60 Hz game frame plus the whole encode (open 16 ms in the pacing
summary), so cycles spanned one to two display slots and the headset got 40 to
60 frames per second while the game rendered 60. The Vulkan backend now paces
render-first (PreparePacket, BeginFrameForPacket, CopyRenderedEyes in
openxr_vulkan.cpp; see OPENXR.md): the packet is located and handed to
Aurora with no compositor frame open, and the frame is begun only once the
eyes exist, for the copy alone. On the same automated start at 0.75 the
summary reads cycles=60 skipped-slots=12 late=0 layers new=60 repeat=0 open=5.5 end-gap=16.7, the compositor shows 60 to 61 of 72 with the
inherent 12 stale slots, app-to-compositor latency fell from 51 to 9 to 13 ms,
and the frame worker's encode fell from 8 to 2.7 ms because the eye copy and
its fence wait moved off the worker onto the pacing thread.
Retro Rewind tracks then showed a game-thread limit of their own: on Athens
Dash (a Mario Kart Tour port) the display-list index scan
(WalkDisplayList<DlIndexScanVisitor>) was 11.5% of the thread while the base
game's tracks spend 0.3% there. The scan cache in gx_dl.cpp refused lists
above 64 KiB, so that track's large shape lists were scanned again on every
call; the cap is now 4 MiB. With it the scan is 0.2%, the game rate on Athens
Dash went from 47 to 51 fps to 50 to 58, and the thread splits into 62% game
plus mod code, 9% GX HLE, 6% FIFO decode, 4% memory copies, 3.5% dispatch and
the rest. What remains on such tracks is the game's own code plus the mod's,
which no host change shrinks; a GX thread could move about 20% of it.
That GX thread exists now (runtime/include/gx_thread.h, [video] gx_thread,
on by default on Android and opt-in elsewhere). Every GX HLE override is split
into a game-thread front, which keeps the guest-visible side effects (GXData
shadow registers, the getters, display-list recording, the texture meta table),
and a _gx back holding the aurora work and the parser state, posted through
one ordered 16 MiB command ring; immediate-mode gather-pipe bytes travel as
8 KiB chunks in call order. The hazard rule follows the hardware: whatever the
SDK copied into the FIFO at call time (matrices, projection, colours, light
objects, copy filters, layout quads, texture object registers) is snapshotted
when posted, and whatever the GP read from memory when it reached the command
(display lists, vertex arrays, indexed matrices, texture data) is read when the
GX thread executes it, so GXDrawDone drains the ring and the frame's
schedule, first-person anchor and policy tag are latched into the present
record on the game thread. The desktop overlay became a game-thread-owned
ImGui frame whose draw data Aurora copies per sealed frame, which also removed
the frame-worker join GXCopyDisp used to make. With fpslog on, a third
line reports the ring: records and bytes per frame, the game thread's waits
for ring space and in drains, the GX thread's busy share and any exceptions
it caught. A texture or matrix that is wrong only with the thread on is a
hazard-rule violation (a front reading guest memory the game rewrites before
the GX thread runs, or a back writing guest memory). Measured on the same
automated Grand Prix start at render_scale 0.75, same build, switched by the
config key: with the thread off the crowded first half minute ran at 52 to
56 fps before settling at 60; with it on the same stretch ran at 56.5 in the
window that includes the countdown and 60.0 in every window after, while the
ring carried 4.5k to 6.2k records (250 to 380 KiB) per frame, the game thread
waited under 0.1 ms per frame in its two GXDrawDone drains and never for
ring space, and the GX thread was 25 to 40% busy. Retro Rewind's menus were
unaffected (prewarm 5.2 s, 60 fps).
Two things the first day on it taught. The Retro Rewind menu with the blurred
background fell to 14 to 18 fps, with the GX thread on or off, and the
per-record profile that the fpslog line now carries (costliest:) put it
all in the FIFO records: the game re-initialises its capture texture objects
every frame, and the split had kept one aurora object per guest object alive
across those re-initialisations, so GXInitTexObjData kept incrementing
texDataVersion, which is part of aurora's static upload key, and every
frame converted every such texture again (convert_texture 18% of the
thread). A guest GXInitTexObj now rebuilds the aurora object, as it always
had, so the version restarts and the upload cache hits. Second, that menu
calls GXDrawDone 22 to 24 times per frame (the base main menu 9 times),
and each drain cost about 1.5 ms while the game thread slept on a condition
variable: both the drain and the idle consumer now spin for a few hundred
microseconds before blocking, with a sequentially consistent sleep handshake,
and the 22 drains cost 2.6 ms per frame in total; that screen runs at 60 with
the thread on.
Verified on device since: the menus on the virtual screen, controller input
(the user has driven races), and an immersive Grand Prix start with all 12
racers rendering correctly. Not yet verified: stereo comfort and scale,
lifecycle (Quest menu, guardian, sleep), and a full race to the finish.
When diagnosing a new device, the session log should show, in order: the loader log line
(OpenXR Android loader initialized), the requirements line (which binding
extension was negotiated), OpenXR Vulkan swapchains ready, the session
state reaching FOCUSED, presentation=virtual-screen for the menus, and
first immersive packet consumed on race entry. A black headset with a working
Android mirror points at the AHardwareBuffer copy (check for vkImportSemaphoreFdKHR
or EndAccess errors); a black mirror too points at Aurora itself.
Known gaps and next steps
- Device bring-up. Run on a Quest 3, capture logcat, fix what the runtime
rejects. Likely first candidates: the exact
XR_KHR_vulkan_enable2device extension negotiation, Dawn's begin/end layout reporting for AHardwareBuffer imports, and swapchain format choice (R8G8B8A8_SRGBis expected). - Performance. The desktop product targets x86-64-v3; nothing has been
profiled on the XR2. The first run compiles every bundled pipeline recipe
(about half a minute); later runs load Dawn's pipeline cache from
Cache/next toDATA.render_scaledefaults to 0.8 here (1.0 on PC); lower it further if the compositor reports missed frames.XR_FB_foveationis not used yet. - Lifecycle. Backgrounding (the Quest menu, guardian) pauses the session
through the ordinary
STOPPING/READYevents; SDL's Android surface loss is handled by Aurora's existing Android paths. Neither has been exercised. - Input. D-pad (trick inputs) is not bound; remap in
Config.tomlor bind the thumbstick directions in a follow-up. Haptics are wired but nothing calls them yet. - Retro Rewind on the headset runs from a kit-built library (below), but its game must be built
on a PC and its pack copied next to
DATAby hand.adb pushcannot create directories inside an app's external files directory (secure_mkdirs failed), so push the pack toDownloadand copy it over on the device, thenchmod -R a+rXit. The launcher does not fetch or update the pack, and cannot build the mod on the headset. - Release signing and store packaging are out of scope;
Build-Quest.ps1produces debug-signed APKs for sideloading.