mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 08:00:25 +02:00
489 lines
31 KiB
Markdown
489 lines
31 KiB
Markdown
# 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/`, the `kartpad-android` runtime branch of the
|
|
WiiCompiled fork) proved that the translated game runs on Android arm64 with
|
|
Aurora on Dawn/Vulkan under SDL3's `SDLActivity`. 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 real `std::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 leaves `IDLE`; `XrInstanceCreateInfoAndroidKHR`
|
|
must be chained on instance creation; the manifest needs the Khronos runtime
|
|
broker queries, the `OPENXR_SYSTEM` permission, the `com.oculus.intent.category.VR`
|
|
intent category and the `XR_ACTIVITY_START_MODE_FULL_SPACE_UNMANAGED`
|
|
property; `XR_KHR_android_thread_settings` may 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 `AHardwareBuffer`s. |
|
|
|
|
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:
|
|
|
|
1. `xrCreateVulkanInstanceKHR` / `xrCreateVulkanDeviceKHR` (`XR_KHR_vulkan_enable2`,
|
|
with an `XR_KHR_vulkan_enable` fallback that queries the extension lists)
|
|
create a small Vulkan device the runtime is happy with.
|
|
2. Per eye, two `AHardwareBuffer`s (R8G8B8A8_UNORM, or RGBA16F when Aurora
|
|
renders float) are allocated and imported on that device
|
|
(`VK_ANDROID_external_memory_android_hardware_buffer`).
|
|
3. 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 of
|
|
`d3d12_interop.cpp` and registered through the same stereo sink).
|
|
4. Ordering across the two devices uses Android sync file descriptors:
|
|
Dawn's `EndAccess` exports a `SharedFenceSyncFD` the OpenXR device waits on
|
|
before its `vkCmdCopyImage` into 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 in `GENERAL` with a
|
|
transition-free release so Dawn's acquire can mirror it.
|
|
5. The copy runs on the queue bound to the session before
|
|
`xrReleaseSwapchainImage`, so the compositor sees ordinary same-queue work.
|
|
|
|
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`: `xrInitializeLoaderKHR` with the
|
|
JavaVM and activity SDL already holds, the `XrInstanceCreateInfoAndroidKHR`
|
|
chain (`OpenXRConfig::instance_create_next`), and the optional thread hint.
|
|
- `runtime/src/platform/host_platform.cpp` / `runtime_config.h`: the activity
|
|
exports `MKW_ANDROID_DATA_DIR` (external files dir, user reachable) and
|
|
`MKW_ANDROID_RESOURCES_DIR` (unpacked `wii_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.cpp` includes `SDL_main.h` on Android so `SDLActivity` finds
|
|
`SDL_main` in `libmain.so`, and passes the resources path to Aurora.
|
|
- Fibers use the vendored libco AArch64 backend (Bionic is Linux), guest memory
|
|
uses the Linux `mmap` path, the MPRIS media monitor is compiled out.
|
|
- **Surface readiness.** Aurora presents only while `g_surfaceReady` is set, and
|
|
on Android that flag starts false. Stock SDL3 exports neither its activity
|
|
mutex (`Android_LockActivityMutex`) nor a readiness hook, so the app's
|
|
`QuestSurface` subclass brackets SDL's `surfaceChanged`/`surfaceDestroyed`
|
|
with `aurora_android_begin/end_surface_mutation` (`aurora/android.h`), and
|
|
Aurora's `SurfaceLock` owns 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. `QuestSurface` pins 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_display` in `lib/aurora.cpp`). The game's
|
|
own render size is unaffected: at `resolution_multiplier = 1` it is 640x528.
|
|
- **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_events` is a no-op on Android and `UpdateAuroraAndProcessEvents`
|
|
defers 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 behind `steady_clock` on 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 after
|
|
`SDL_main` has 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:
|
|
|
|
1. The file name must be a format nod reads: ISO, GCM, GCZ, CISO, WBFS, WIA or RVZ.
|
|
2. The header must be the pinned game ID, and `main.dol` and `StaticR.rel`,
|
|
read straight from the image, must match the pinned SHA-256s. Gradle
|
|
reads all three pins from `projects/mkwii/recomp.yml` into `BuildConfig`, so
|
|
a wrong disc fails in seconds, before anything is written.
|
|
3. The data partition is extracted into `DATA.extracting` next to `DATA`, after a
|
|
free-space check.
|
|
4. The extracted files are checked against the same pins, as
|
|
`ValidateExtractedGame` does. Only then is an existing `DATA` renamed away,
|
|
the new one moved in, and the old one deleted. A failed, cancelled or killed
|
|
run never costs a working `DATA`.
|
|
|
|
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.cmake` defines `mkw_quest_kit_probe` on Android: WiiCompiled
|
|
`WITHOUT_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 base flavour builds this probe instead of the product.
|
|
- `android/QuestGameKit.psm1` (`Export-QuestGameKit`, run by the `exportBase*QuestGameKit`
|
|
Gradle tasks) turns the probe's ninja link edge into `kit.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-lib` with archive semantics as
|
|
`libmkw_base_shared.a` did. It takes the compile flags CMake recorded for one source of each
|
|
generated kind. The fingerprint hashes the recipe and every file.
|
|
- `Invoke-QuestGameBuild` replays 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, `NEEDED` list and soname as the CMake-built one, in 2.4 minutes.
|
|
|
|
`Build-Quest.ps1` refuses a base APK that contains any `libmain*.so` or lacks the kit, and the
|
|
base variant excludes `**/libmain*.so` from packaging, since AGP packages every library left in
|
|
the CMake output directory. The `func_8…` symbols the kit's runtime objects define are
|
|
hand-written HLE overrides (`PPC_NATIVE_OVERRIDE_*` in `hle_stubs.h`), not translated code.
|
|
The Retro Rewind flavour has not moved to the kit yet and still bundles its library.
|
|
|
|
### Game packages (.wcgame) and Import from computer
|
|
|
|
A `.wcgame` is a zip holding `game.json`, `libmain.so` and optionally `DATA/…` (the extracted
|
|
disc, written without compression). `game.json` records the profile, game ID, `main.dol` and
|
|
`StaticR.rel` pins, the kit fingerprint, the library's SHA-256 and who built it.
|
|
`android/Build-QuestGame.ps1` builds one on a PC from the translator's output and the kit the
|
|
last APK build exported, and `-Data` includes the game files. `-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.
|
|
|
|
Players get the same build from WheelWizard VR: Settings → WiiCompiled → Meta Quest → **Build**.
|
|
WheelWizard asks for the Quest app's APK, whether to include the game files 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> [--include-game-files] --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: **Import from computer** until a game is installed
|
|
(with **Select disc image** beside it while there are no game files), **Select disc image** when
|
|
only the game files are missing, and **Play** once both are present. Building the game on the
|
|
headset itself is the next step of this work. The translator (a self-contained
|
|
`linux-bionic-arm64` .NET build, which needs heap pointer tagging disabled) and Termux's clang
|
|
21.1.8 both ran on a Quest 3. A base translation took 6.7 minutes with a 3.1 GB memory peak, and
|
|
the heaviest shard compiled in 65 seconds with a 480 MB peak.
|
|
|
|
### Build system
|
|
|
|
- `runtime/CMakeLists.txt` recognises `CMAKE_SYSTEM_NAME=Android` on arm64 as
|
|
`MKW_PLATFORM_ANDROID`: OpenXR on by default, Dawn from the pinned
|
|
android-aarch64 package (digest pinned in `AuroraDawnProvider.cmake`, which
|
|
also rewrites the package's absolute `liblog.so` path and looks the package
|
|
up with `NO_CMAKE_FIND_ROOT_PATH` so the NDK sysroot rule does not hide it),
|
|
SDL3 built shared (or `-DAURORA_SDL3_PROVIDER=system` for the AAR prefab),
|
|
tests off, products built as `libmain.so` / `libmain_retro_rewind.so`,
|
|
`-mcpu=cortex-a77` (Quest 2's XR2 Gen 1; Quest 3/Pro are supersets).
|
|
- `runtime/cmake/PublicProducts.cmake` gains `MKW_GENERATED_DIR` so 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 `.S` into ELF `.rodata` plus a GNU-stack note. The translator itself
|
|
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. `app/src/main/cpp/CMakeLists.txt` adds the
|
|
repository's `runtime/` as a subdirectory with those Android choices;
|
|
flavours `base` and `retroRewind` pick the product target and library name.
|
|
- `android/nod-jni`: Gradle's `buildNodJni` task runs `cargo build --release
|
|
--locked --target aarch64-linux-android` with the NDK's clang as linker and C
|
|
compiler. `stageNodJni` puts `libnod_jni.so` into the APK's `arm64-v8a`
|
|
libraries. `-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`;
|
|
a translated graph for your own disc (the 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 and its game kit, debug-signed
|
|
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Install # your game, against that kit, into Import (or WheelWizard VR's Build for Quest)
|
|
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Flavor retroRewind # Retro Rewind (needs translate-mod output)
|
|
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:
|
|
|
|
```powershell
|
|
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 test` passes with the new `--target-os` tests (17/17 in
|
|
the touched suites).
|
|
- Windows: `mkw_openxr_replay_tests` and `mkw_vr_policy_tests` pass; the
|
|
`mkw_runtime_common` and `aurora_core` targets compile with the refactored
|
|
integration; the workspace product rebuild links `WiiCompiled.exe`.
|
|
- Android: the cross-compile audit passes. `mkw_android_native_compile`,
|
|
`aurora_core` and `aurora_gx` all build for `aarch64-none-linux-android29`
|
|
with NDK 29.0.14206865, which covers the whole native runtime including
|
|
`openxr_vulkan.cpp`, `openxr_android.cpp`, `openxr_input.cpp` and the Aurora
|
|
AHardwareBuffer bridge. Three Bionic portability fixes came out of it: the
|
|
`std::min` call in `hle/audio/audio.cpp` needed an explicit type (`int64_t`
|
|
is `long` on LP64 Android while the clock rep is `long long`),
|
|
`guest_flat_memory.cpp` needs a `__NR_memfd_create` shim below API 30, and
|
|
Crypto++'s `cpu.cpp` needs the NDK's `cpu-features` source 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
|
|
1680x1760 `R8G8B8A8_SRGB` (VkFormat 43) swapchains, attaches the controller
|
|
actions, and the session reaches `FOCUSED`. The game boots through the title
|
|
movies into the attract race, the policy switches to `immersive-race` with
|
|
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.
|
|
|
|
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 |
|
|
|
|
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 * stride` term. 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` clicks both thumbsticks, opening or closing the settings panel (see `OPENXR.md`) |
|
|
| `debug.wiicompiled.fpslog 1` | Logs the game's rendered frame rate every 5 s. The compositor's `VrApi` log line gives headset FPS, `GPU%`, `CPU%` and app GPU time (`App=`) |
|
|
|
|
The injector makes headset tests possible with nobody wearing the headset.
|
|
Keep the display awake, drive the menus, then take a compositor screenshot:
|
|
|
|
```powershell
|
|
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`.
|
|
|
|
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.
|
|
|
|
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_enable2` device
|
|
extension negotiation, Dawn's begin/end layout reporting for AHardwareBuffer
|
|
imports, and swapchain format choice (`R8G8B8A8_SRGB` is expected).
|
|
- **Performance.** The desktop product targets x86-64-v3; nothing has been
|
|
profiled on the XR2. Expect shader compilation stalls on first run (Aurora's
|
|
pipeline cache is bundled) and start with `render_scale` below 1.0 if the
|
|
compositor reports missed frames. `XR_FB_foveation` is not used yet.
|
|
- **Lifecycle.** Backgrounding (the Quest menu, guardian) pauses the session
|
|
through the ordinary `STOPPING`/`READY` events; 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.toml` or bind
|
|
the thumbstick directions in a follow-up. Haptics are wired but nothing calls
|
|
them yet.
|
|
- **Retro Rewind on device** needs the mod's translation and its extracted
|
|
content pushed next to `DATA`, exactly like the desktop product.
|
|
- **Release signing and store packaging** are out of scope; `Build-Quest.ps1`
|
|
produces debug-signed APKs for sideloading.
|