docs/multiarch.md’s “no translation layer will be designed”. ADR-043’s other decisions stand for the phone build.
Related: ADR-001, ADR-012, ADR-013, ADR-033, ADR-037, ADR-050, ADR-052
Design and measurements: docs/vr/dynarmic-design.md. User page: docs/vr.md.
What changed
ADR-043 rejected the Quest build because running an arm64libroblox.so
looked like it needed a second, arm64 cordial-run under qemu-user, and that
container saw only llvmpipe. The VR work did not do that. The x86-64
cordial-run links the Quest build’s arm64 engine itself and runs its code
under dynarmic, in-process, and the engine’s Vulkan and OpenXR calls reach the
host’s own GPU driver and OpenXR runtime through generated thunks
(cordial-run --guest-arm64 --app-bridge). Measured on Monado’s simulated HMD
(design §9.5, §9.8, §9.9): 89.95 to 90.00 xrEndFrame/s at 90 Hz on the
signed-out landing panel in 3 of 3 runs, 143.93/s at 144 Hz; in game, 44 to 48
frames/s at 90 Hz after the first performance pass and 51.7 to 65.3 at 72 Hz in
the second. No frame rate has been measured on WiVRn. The design’s part B
(WiVRn, a Quest 3 displaying) has been used by hand but not measured: one
WiVRn run is recorded for a stop at 193 s (§9.6, since fixed), and a later
hands-on session is noted under “Not established” below.
Decision
-
VR is a launch mode of the built-in Android runtime, not a runtime in
ADR-052’s sense. ADR-052’s runtime is a separate program that advertises
a
runtime.jsonand is spoken to over a socket, and ADR-050 asked first what of Cordial’s Android layers it would reuse. The VR mode reuses all of them: the same binary, the bionic linker, the JNI layer, the framework answers, profiles, FastFlags, plugins and live settings. It differs by two arguments. Calling it a runtime would mean Cordial implementing the spec to talk to itself, and listing it beside third-party runtimes that ADR-052 deliberately does not list. ADR-052’s “a runtime declaresarch, and Cordial hides one that does not match the host” is not contradicted: the binary is x86-64 on an x86-64 host, and the guest ABI is internal to it, as an Android device’s ABI is internal to Android. - The translator stays inside ADR-001 on the design’s three conditions (§7): dispatch is keyed only on Cordial’s own stub addresses and SVC ids, never on engine addresses; no plugin sees translator state; and the engine’s code is never written. The engine’s unmodified instructions run, and behaviour changes only through what a real platform answers. A change that breaks one of the three reopens this record.
-
The user supplies the Quest build, and Cordial downloads none. Settings
→ VR imports an APK file or pulls it from a connected Quest with
adb(pm path com.roblox.client, thenadb pull), andcordial --import-quest-apk FILEdoes the same with no window. The archive must verify against a pinned certificate, as ADR-033 requires of every build. Measured: the Quest build 2.740.0.927 verifies to a different certificate from the phone build’s (6d13fc84…, self-signed, O=Roblox Corporation). It is pinned in a separatequest_certificateslist that only the import reads, so it widens nothing an update or a mirror download will accept. An arm64 phone build is refused by the absence oflibovrplatformloader.so. The headset route is a sequence of Settings subpages that each check before the next (adb, developer mode, the USB debugging prompt, the installed version, the copy), and is read-only on the headset:devices,pm list packages,dumpsys package,pm path,pull. Everypm pathentry is pulled, so a split install is filed too. -
The store is keyed by ABI and version. The phone build stays at
builds/<version>/, unchanged. The Quest build goes tobuilds/arm64-v8a/<version>/, with everylib/arm64-v8a/*.so(the translator links more than the engine), the APK,.version,.signerand.content-sha256.store::list_inskips a non-version directory, so the phone store’s listing, pruning and Version page never see it. The Quest store has its own lock and keeps three builds; there is no pin, because only the user adds to it. -
The OpenXR runtime is chosen per launch and never installed. Cordial
reads the system’s active runtime (
$XDG_CONFIG_HOME, then$XDG_CONFIG_DIRS, then/etc, the loader’s order), WiVRn’s Flatpak (flatpak info --show-location), SteamVR’s manifest, and manifests inshare/openxr/1/. A setting picks one, defaulting to the system’s, and the launcher passes it to that client alone inXR_RUNTIME_JSON. Cordial never writesactive_runtime.json. When WiVRn is the choice andwivrn-serveris not running, the launcher says so and names the command (--no-manage-active-runtime); it does not start it, because the shell starts no long-lived helpers and owning a server’s lifetime would be a design of its own. -
A profile serves both builds: one identity, two engine stores.
Why the engine storage is split. The builds are different engine versions (2.737 phone, 2.740 Quest) with different caches, and the Quest build writes VR state into
GlobalBasicSettings_13.xml. The same 74 keys compared between a Quest profile and Sober’s phone profile on one machine:VREnabled,HasEverUsedVR,FramerateCap,GraphicsOptimizationMode,ControlModeand the sensitivities differ. One file read by both would carry a VR frame-rate cap and control mode into the phone build. Why the assets are split: one directory re-extracted on every switch left each build’s extra files in the other’s tree (the Quest APK carriesshaders_vulkan_mobile_vr.pack) and would rewrite files under a running client of the other build. Why the sign-in is shared: the session is the account’s, both builds already resolve it from the profile path, and the requirement is that a user signs in once. The phone build keeps the top level, so an existing profile is untouched: nothing moves, and a profile never used for VR never gainsquest/. A VR run before this change wrote Quest storage todata/, so the client movesdata/andrun/intoquest/once, under the lock, only whenGlobalBasicSettings_13.xmlrecordsHasEverUsedVRtrue, and never whenquest/exists. INFERRED: that no phone build writesHasEverUsedVRtrue; Sober’s phone 2.737 profile reads false. -
VR lives in Settings → VR, not on the launcher. (Revised 2026-10-04;
the earlier text is kept below.) The launcher shows the Roblox button and
nothing else, because the maintainer wants it kept to that one control. The
page has a “Play in VR” row at the top whose button calls
win.launch-vr, the same launch the launcher button used to make, and which is greyed with the first missing prerequisite written beneath it: no Quest build imported, no usable OpenXR runtime, or WiVRn’s server not running. The page is absent on a host that is not x86-64, as before.--diagnosticsgains a VR line and--doctorVR checks, which are never worse thaninfounless a chosen runtime has gone. Superseded text: “Play in VR” sat under the Roblox button, which kept its place and look. It was absent on a host that is not x86-64, insensitive until all three prerequisites were met, and said the first missing one beneath it, with a link to Settings → VR. That put a second, large control on a window whose whole job is one button, which is the reason it moved. The cost is one more step to start a VR session; the readiness reasons are unchanged.
When Roblox stops accepting the build
Roblox refuses old clients after an update, so a Quest build goes stale with every Roblox update. Nothing tells Cordial in advance: theclient-version
endpoint answers only for the Windows and Mac players (AndroidApp gets 500,
cordial_update::version), and the release-notes number the updater reads
says a version was announced, not that the Quest build has it. Nothing tells
it at run time either, yet: the phone build reports app upgrade status 0/3 through a GameActivity hook (init_params.cpp) the Quest build never
calls, and no Quest run on this machine (594 logs) contains an upgrade signal.
A detector written now would be a guess, so there is none. What is built
instead: the headset step compares the headset’s versionName with the
stored build and says “update it on the headset first” when they match; the
crash page of a VR run says, conditionally, to update and copy again; and the
previous build stays in the store until a new one is filed. The first capture
of a refused Quest client is what a real detector needs.
Measured for this change, 2026-10-01
From the launcher’s own action (win.launch-vr, the same call the
button made) in a nested headless KWin, throwaway data root, Monado’s simulated
HMD: the Quest build imported from the CLI, XR_RUNTIME_JSON set from the
setting, FOCUSED, the landing panel, and a 60 s run ending in exit 0. Rate in
10 s windows: 71.5, 85.9, 90.0, 62.2, 68.5, 63.5 frames/s at 90 Hz, on a host
shared with other builds. A second client on the same profile, phone build,
was refused with exit 3 while the VR client held it. A phone launch on the
same profile afterwards used data/, run/ and cordial/assets, left
quest/ alone, and read the same <profile>/cookies path the VR run had.
One run ended in SIGSEGV 29 s into the session, in MangoHud’s Vulkan layer
inside Monado’s compositor (comp_target_swapchain_present → libMangoHud.so),
with MANGOHUD=1 in the environment; the same run with MANGOHUD=0 ran to
the end. Seen once.
Not established
- Sign-in carrying across the builds is INFERRED from both reading one path; no signed-in run of each build on one profile was made.
- Sharing
flags.jsonacross engine versions is INFERRED harmless: the engine ignores a flag it does not know. - No frame rate has been measured on WiVRn. A person ran the onboarding (Settings → VR → Get It from Your Quest) end to end with a real headset, and played a game made for VR on a Quest 3 over WiVRn, reporting it mostly above 60 frames/s with occasional slight stutters; that is an observation, with no frame log taken.
- The in-game Leave button and rejoining on WiVRn are still open
(
vr/play-button.md). Pressing Play in the headset is not: on a Quest 3 over WiVRn it joined in three sessions of three, eachGame.launchcarryingjoinAttemptOriginas a Play press does rather than a deep link’sreferralPage: "DeepLink"(play-button.md, “In the headset”). Haptics over WiVRn 26.9 buzz continuously because of a bug in WiVRn’s headset app, fixed upstream in WiVRn pull request #1131 and not yet released. - Pinning the Quest certificate is a trust decision for the maintainer; its provenance is the headset’s store install, recorded beside the digest.
Reopen when
A WiVRn run from the launcher measures a frame rate; Roblox ships a Quest build signed by another key; a phone build is seen writingHasEverUsedVR;
or a change to the translator crosses one of decision 2’s conditions.