15 KiB
Cardboard VR and SteamVR on Turnip: what we learned
Written 2026-10-13, after the first stretch of Cardboard mode work on the AYN Thor (Snapdragon 8 Gen 2,
Adreno 740, Turnip on KGSL). docs/cardboard-vr.md describes how the feature works. This page records
what it took to get there: the traps, why each fix looks the way it does, how to debug a live session,
and what is still open. Read it before the next round of VR work.
Where it stands
| What | Result |
|---|---|
| Cardboard mode (lens pass, 3DoF head tracking, recentre) | Works on any picture; picture follows SteamVR on and off |
SteamVR 2.17.10 ARM64 (the Steam Frame's deckard-steamvr) |
Starts from the drawer; driver-direct at 60 Hz, dashboard and Steam library in VR |
| Thumper (356400, D3D11, VR launch option) | Menus and levels at 60 Hz, 7 ms CPU / 13 ms GPU, 23 of 6485 frames dropped |
| The Thrill of the Fight (494150, Unity) | Gym loads, 60 Hz, 6.5 ms CPU / 8.5 ms GPU |
| The Lab (450390) | Loads, then Android's low-memory killer takes the whole app |
| SUPERHOT VR (617830) | Reaches its EULA in the dashboard (not accepted in testing) |
| SteamVR Home | Blocked: Proton's vrclient_x64 lacks IVROverlayView_002 |
helloxr -g Vulkan2 (OpenXR) |
Session focused and submitting, picture shows as torn tiles |
Screenshots from the Thor: Thumper gameplay, Thumper title, The Thrill of the Fight, the SteamVR dashboard with a game's EULA.
Games run as x86-64 Windows builds through Proton ARM64 (FEX, DXVK). Proton's vrclient_x64 forwards
OpenVR to SteamVR's ARM64 vrclient.so, so no x86 SteamVR is involved.
Turnip on KGSL: the four things SteamVR trips over
-
VK_KHR_displaymakes the GPU disappear. Turnip on KGSL enumerates no physical device when an instance enables it ("I can't KHR_display").vrcompositoralways asks for it. The layer takes the display extensions off the instance and fakes one display, mode and plane backed by a headless surface. Headless surfaces report aUINT32_MAXextent, so the swapchain is sized from the mode. -
No exportable semaphores. SteamVR shares a timeline semaphore with every shared image as an opaque fd, and Turnip/KGSL cannot export one. The layer creates the semaphore unshared and hands out a memfd (
droiddeck-shared-sem) holding a 64-bit counter. A poller thread in each process carries values both ways every 300 µs. Dropping the semaphores outright, the first attempt, gave torn dashboard tiles. -
Wait-before-signal blocks the submitting thread. Turnip does not defer a submit that waits on a timeline value nobody has signalled yet;
vkQueueSubmitsleeps until it is. SteamVR's client waits on a compositor value that the compositor signals only after the client has submitted and said so, so every client deadlocked before its first frame:helloxrblocked in a wait-idle invrclient.so;- DXVK's
dxvk-queuethread blocked in Proton games.
The layer now gives a submit's wait on an imported shared value 3 ms, then drops it, at once when the shared counter has not moved for 100 ms. It is kept when the value is already reached, when this process has queued the signal itself, or while a Quest session's frame socket exists (checked at most once a second). Three details mattered:
- the waits are judged before the submit's own signals count, because an acquire waits on one value and signals the next on the same semaphore;
- SteamVR's client interop device is Vulkan 1.0, so only the
KHRentry points resolve there; - a Proton game waits on two values a frame that are never signalled (5,500 dropped, none reached within 200 ms afterwards). With the grace on each, Angry Birds VR's submit thread lost 6 ms a frame and the game ran at 60 fps; without it, 120.
-
UBWC layouts must agree. An imported image has to be created with the same
VkImageFormatListCreateInfo(sRGB + UNORM) as its creator, or Turnip lays out the compression differently and the reader sees garbage. The presenter's eye-texture import does this.
Other traps, in the order they cost time
- A still headset goes to standby. A phone on a table looks unworn, SteamVR enters standby after about 15 s, and the compositor stops submitting. The driver's proximity sensor reads "worn" while Cardboard is on.
- Emulated SysV semaphores never set
sem_otime. SteamVR's web helper waits for it to become non-zero before using a semaphore set, so the dashboard never appeared.sysv.cnow stampssem_otimeandsem_ctime. - Steam's web helper needs the layer too. It shares Steam's in-VR overlays with SteamVR and crashed
without the layer; after a few crashes Steam quits. Its GPU process outlives SteamVR runs, so
steamwebhelpergets the semaphore bridge whenever SteamVR is installed, not only while SteamVR'sactivemarker exists. It never gets the display stand-in. - Keep the layer out of ordinary sessions. As an implicit layer it loads everywhere. Display
extensions are only taken off while SteamVR runs, a new session clears a leftover
activemarker, and without SteamVR installed every process gets the driver's functions unchanged. Review feedback on the first version pointed out that gamescope asking for a display extension would have been rewritten in an ordinary session. - The
activemarker race. Clearing a stale marker whenevervrserveris not running also cleared it during startup, beforevrserverappeared, because the app polls status every 3 s. Steam quit as a result. Only a marker older than 2 minutes is cleared now. - Signal handlers in Proton games. The layer's crash tracer replaced the
SIGSEGVhandler that FEX and Wine rely on, and The Lab crashed in FEX's JIT. It now installs only where nothing handles the signal. - mangoapp owns gamescope's single external overlay slot, so an X11 presenter has to use
STEAM_OVERLAY. The default presenter is now a Wayland window on DroidDeck's own compositor, kept on top by app id. - Memory. Steam, SteamVR and one VR game fit in the Thor's 12 GB. A large game such as The Lab, or
two games at once, push the device into critical pressure, and the low-memory killer takes
DroidDeck itself (
ended-without-teardown.txtin the session logs,lowmemorykillerin logcat). - Adreno 840 forced sysmem. On the Fold with VITURE Luma Ultra, launching SteamVR from the library
inherited
TU_DEBUG=sysmemand producedvkerror=-4, triggering recovery. Addingnoubwcstill failed; replacing the flags withnoubwcor an empty value worked. The shared launch helper removes onlysysmemonAdreno840*, leaving other flags, the parent Steam session and other GPUs unchanged. The user confirmed improved landing-area brightness and colors with this configuration and asked to preserve them. - Adreno 840 with DD-Turnip v0.2.0. SteamVR reproduced
vkerror=-4withsysmemremoved, including on the previous app build and with the presenter disabled. After a phone reboot, the unchangedfcf8c588baseline rendered for 109 seconds without observed device loss.TU_DEBUG=flushallinitially appeared promising: a global run rendered for over a minute, and a SteamVR-only run rendered for about 32 seconds before background suspension. The packaged SteamVR-only change then reproduced device loss withflushallconfirmed invrcompositor, so the automatic flag was removed. These bounded runs do not establish the underlying cause or a reliable workaround. - Live files are not cache. Android's
installdevictedcache/shm/u10528-*while SteamVR was running, followed by Chromium shared-memory failures and lost IPC. Shared memory, emulated device files, sandbox trees and PRoot temporary files now useno_backup/guest-runtime, retaining their existing cleanup lifetimes. Session state must survive cache eviction. - One host signal owner. The submit-wait path must not mirror a shared timeline value itself: the poller can signal it between the submit thread's counter read and signal, producing an invalid duplicate signal. The poller now owns mirroring; a forced-interleaving test covers this race.
- Lost hand tracking is not a disconnect. Publishing a disconnected pose and then a connected, untracked pose on every optical update floods SteamVR with device-change notifications. With uncalibrated hands this grew its web buffer to about 34 MB and triggered the Web thread watchdog. Release inputs and publish only the connected, untracked pose; reserve disconnection for an actual controller removal. The controller regression exercises repeated loss and reacquisition.
- Failed realtime scheduling can still install a CPU limit. On the Fold, SteamVR's SDL RTKit
fallback set
RLIMIT_RTTIMEto 100/200 ms despite realtime priority being refused. The render thread later received kernelSIGXCPUduringIOCTL_KGSL_GPU_COMMAND, with policySCHED_OTHERand the soft limit advanced to 1100 ms, confirming the realtime watchdog fired. The VR scheduling preload rejects lowering this limit only invrcompositorwhenRLIMIT_RTPRIOis zero andCAP_SYS_NICEis absent. It preserves inherited limits, other processes and other resources; it does not intercept signals. SDL's scheduling hint cannot cover SteamVR's directSDL_LinuxSetThreadPriorityAndPolicycall. A native preload regression covers rejection and forwarding. The exact Android kernel boost/accounting path was not established. - Frames shown out of order. Video from the glasses jumped back a few frames in bursts while the
head turned. Two causes, each found by stamping a frame counter into the presenter's output:
- the presenter copied SteamVR's eye texture a fixed 4 ms after
Present, with no fence. Under GPU loadvrcompositorhad not drawn it yet, so the copy got that texture's previous frame. The layer now fencesvrcompositor's submits and the presenter waits for the frame's count; - DroidDeck's compositor copies a Wayland client's dma-buf without waiting for its implicit fence, and KGSL does not order the copy after the client's GPU work. The presenter's blit sat behind the game's frame, so the compositor showed the swapchain image's older picture. The presenter now waits for its blit before it presents.
- the presenter copied SteamVR's eye texture a fixed 4 ms after
- Flat games under SteamVR. With SteamVR running, Steam lists a flat game after its own window in
gamescope's base layer, so Big Picture keeps the focus and the game never shows, theatre setting or
not. SteamVR is therefore started only by Play SteamVR, never with the glasses alone. A flat game
launched while SteamVR runs now opens as a SteamVR panel instead (
droiddeck-steamvr-flat). Games launched from the SteamVR dashboard do not pass through aSteamClient.Apps.RunGamehook installed later, so the launcher asks Steam at run time rather than marking the click. - A new projection needs an event.
SetDisplayProjectionRawchanged whatvrserverreports, butvrcompositorkept the frustum it started with, so the glasses' field of view did nothing until SteamVR restarted. The driver now follows it withVREvent_LensDistortionChanged. - Head-tracking axes. With the display at
ROTATION_90, screen x is minus device y and screen y is device x. Getting this wrong made a turn at the hips tilt the view. Recentring now keeps the world vertical and resets the heading only.
Library launch validation (2026-10-15)
Two hands-off launches through the Steam library's Launch button completed 308 seconds and 188 seconds, with an intentional stop between runs. Each retained its compositor process and session, sustained 60 fps, showed dashboard/world transitions, and kept USB control and valid 6DoF head tracking. Neither run reported device loss, a watchdog abort, recovery, or a session restart. Hand controllers remained enabled but uncalibrated. Brightness stayed at 8; no shader color changes or temporary graphics overrides were used.
These timed runs used APK SHA-256
4ce7a623ceb6b1a100c0ed1184530ece2ea8e13a1cb9ee6c0ab24666a8249004. A newer calibration/recenter
APK was subsequently installed and verified; see luma-spatial-evidence.md. The timed results
do not establish sustained gameplay or calibrated-hand acceptance for that newer build.
Debugging a live session
-
Logs:
~/.local/share/Steam/logs/{vrserver,vrcompositor,xrclient_*}.txtand/run/droiddeck/droiddeck-steamvr/vrstartup.log. Lines from the driver start withdroiddeck:, lines from the layer with[droiddeck-vr-layer]. A game's layer messages land in the session log. -
Markers beside
activein/run/droiddeck/droiddeck-steamvr/:crashtraceprints backtraces on fatal signals;semtracelogs each bridged semaphore's local and shared value every 2 s, named by the memfd inode so the same semaphore can be matched across processes, plus the first waits, signals and shared image create infos.
Both are read when a process starts.
-
Frame stats: in
/opt/steamvr/bin/linuxarm64,LD_LIBRARY_PATH=. ./vrcmd --statsreports submits, drops and app CPU/GPU times../vrcmd --hidedashboardcloses the menu SteamVR opens over a new game. -
Where a process is stuck: a stack scan of
/proc/<tid>/memfrom the saved stack pointer (a small Python helper) gives module+offset frames. That is how the wait-idle invrclient.soand the blockeddxvk-queuethread were found. -
VR launch options:
SteamClient.Apps.GetLaunchOptionsForApp(id)lists them, andSteamClient.Apps.RunGame("<id>", "", <index>, 100)starts one, throughdroiddeckctl cdp eval. -
Quitting:
droiddeckctl quit <appid>. Without the id the game keeps running. -
Pitfalls when testing:
- never
pkill -forpgrep -fa pattern inside a guest command, because it matches the command's own shell; usepgrep -x; - replace a loaded
.soby copying to a temporary name andmv, nevercpover it; - a session start restages the driver and layer from the APK assets, so a hand-copied build is lost on the next start.
- never
Open items
helloxrpicture. Both sides create the swapchain images with identical parameters, so this is not the UBWC import issue. Reading an image mid-render (the dropped wait) or binning tiles is the next suspicion.- SteamVR Home needs
IVROverlayView_002in Proton'svrclient, or an ARM64 build of Home. - The Lab and other large games need memory headroom. Candidates: stop Steam's own web helpers while a VR game runs, or lower SteamVR's footprint.
- Wait drops are a heuristic. A 3 ms grace keeps the common case in sync, and an idle counter skips it. A submit thread in the layer, deferring submits until their waits are reachable, would be exact.
- Layers: only the first layer of each frame is presented. No reprojection.
- Controllers are a gaze pointer driven by the pad. Pad presses did not close the SteamVR menu in testing. Untested: whether that came from gamescope focus being on Steam, or from the system button mapping.