โ† vr 13 min read
Contents

For a few months, I tried everything I could think of to fix an ongoing issue with VRChat. I would randomly drop tracking for all devices. SteamVR logs showed it was about 3 seconds at a time. At first, I thought it was just a random bug introduced via a SteamVR update, but the more I dug into it, the more I realized it was related to Windows itself.

At first it was a few random events that didn't seem to be related:

  • Bluetooth would randomly drop out (dongle)
  • Trackers would fail to connect (dongle)
  • Headset would drop sync and grey screen for a few seconds (USB, Bigscreen Beyond 2E)
  • Mouse would become unresponsive for a few seconds (Bluetooth, and with its own dongle (M720 Triathlon))

The more I fiddled with things, the more it felt like I was going insane. I updated my BIOS (B550 Gaming Plus) to the latest, made sure my video drivers were up to date (grey screen), and made sure I had the latest Windows updates (yeah.....). Nothing worked. It came on all of a sudden, and I had a hunch a Windows update was causing it.

Fed up with the bugs, I swapped to another drive running CachyOS (Arch based) and started running VRChat. Once I ironed out a few bugs (using Monado and WayVR instead of SteamVR), it's been absolutely flawless. I get about 10% better frame rate and no more dropped tracking or signal.

I swapped back to Windows and again, same issue: dropped tracking, a flashing HUD (intermittent and seemingly random), and Index controllers that would randomly stop tracking.

Anyway, I have completely abandoned Windows, except for a couple of Adobe products that don't have a Linux counterpart (or working compatibility) yet. If you deal with random issues in games, I recommend trying this out for yourself, as Windows has completely gone to crap...

Current config for VR

Hardware: Bigscreen Beyond 2E, Valve Index controllers, 3x Vive Tracker 3.0, 2x Base Station 2.0, NVIDIA GPU, B550 Gaming Plus OS: CachyOS, KDE Plasma (Wayland)

The big one: SteamVR on Linux only runs the Beyond at 90 Hz / 3840x1920. To get the full 5088x2544 @ 75 Hz, I don't use SteamVR as the runtime. I use Monado + xrizer instead, and Monado borrows SteamVR's lighthouse driver for tracking.

Programs

What Package Why
Monado monado-git (AUR) OpenXR runtime that drives the headset. The stable monado package segfaulted in the lighthouse driver with SteamVR 2.17; -git works
xrizer xrizer + lib32-xrizer Translates OpenVR to OpenXR, so VRChat runs on Monado
SteamVR Steam Still installed, but not running. Monado uses its lighthouse driver
WayVR (formerly WlxOverlay-S) wayvr (AUR) Desktop/window overlay, replaces XSOverlay
VRCFaceTracking (Avalonia) vrcft-avalonia-bin (AUR) Face/eye tracking to VRChat over OSC
Baballonia upstream Linux release Eye tracking from the Beyond's eye camera, natively (still setting this up)
OSC DreamChatbox osc-dreamchatbox (AUR) VRChat chatbox
Bigscreen Beyond Utility Steam, under Proton Firmware/settings only. It can't see Linux SteamVR or do eye tracking under Proton. Don't let it flash eye-tracking firmware

Changes that mattered

1. NVIDIA "rainbow static" in one eye. This is a known NVIDIA DSC bug with the Beyond. It was fixed by a kernel with CachyOS's Beyond DSC patches (linux-cachyos 7.2.8+) plus NVIDIA 615.x.

2. Check supersampling if you're on SteamVR. Mine was at 184%, which caused about 1,300 missed frames a minute. 100% fixed it.

3. Monado settings, in ~/.config/systemd/user/monado.service.d/beyond.conf:

[Service]
Environment=STEAMVR_LH_ENABLE=true                    # use SteamVR's lighthouse driver
Environment=XRT_COMPOSITOR_DESIRED_MODE=0             # Beyond mode 0 = 5088x2544 @ 75 Hz
Environment=XRT_COMPOSITOR_FORCE_WAYLAND_DIRECT=true  # required on KDE Wayland
Environment=XRT_COMPOSITOR_COMPUTE=false              # NVIDIA stability (see 4)
Environment=XRT_COMPOSITOR_USE_PRESENT_WAIT=1
Environment=U_PACING_COMP_TIME_FRACTION_PERCENT=90
Environment=LH_DISCOVER_WAIT_MS=20000                 # give controllers/trackers 20 s to connect

Turn your controllers and trackers on before starting Monado. It can't add lighthouse devices after startup.

4. NVIDIA GPU lockup (Xid 51). monado-git's install script gives monado-service realtime priority (the cap_sys_nice capability). On my NVIDIA card, that hard-locked the GPU until a reboot. The fix is sudo setcap -r /usr/bin/monado-service. Every upgrade puts the capability back, so I added a pacman hook that removes it automatically. Also, always stop Monado with systemctl --user stop; never kill it.

5. VRChat launch options. Without these, VRChat can't reach Monado from inside Steam's container and starts in desktop mode:

PRESSURE_VESSEL_IMPORT_OPENXR_1_RUNTIMES=1 PRESSURE_VESSEL_FILESYSTEMS_RW=$XDG_RUNTIME_DIR/monado_comp_ipc %command%

6. Switching runtimes. Linux decides which VR runtime apps use from two files:

  • ~/.config/openvr/openvrpaths.vrpath: where OpenVR apps (VRChat, most SteamVR games) look for their runtime. Normally SteamVR; for Monado it points at xrizer.
  • ~/.config/openxr/1/active_runtime.json: which OpenXR runtime is active. WayVR and xrizer itself go through this.

I keep both runtimes installed and flip between them with two small scripts in ~/.local/bin, so I can always fall back to SteamVR. They need jq.

vr-monado, to switch to Monado + xrizer:

#!/usr/bin/env bash
# Switch to Monado + xrizer (full-res 75 Hz). Undo with: vr-steamvr
set -euo pipefail
steam_cfg=~/.local/share/Steam/config
openvr_paths=~/.config/openvr/openvrpaths.vrpath

# SteamVR must not be running: it holds the headset and rewrites its settings on exit.
pkill -x vrserver; pkill -x vrcompositor; pkill -x vrdashboard; sleep 3 || true

# Stop a controller button press from auto-launching SteamVR while Monado owns the devices.
jq '.power.autoLaunchSteamVROnButtonPress = false' "$steam_cfg/steamvr.vrsettings" > /tmp/vrs.tmp \
    && mv /tmp/vrs.tmp "$steam_cfg/steamvr.vrsettings"

# OpenVR apps (VRChat) -> xrizer
jq '.runtime = ["/opt/xrizer"]' "$openvr_paths" > /tmp/ovr.tmp && mv /tmp/ovr.tmp "$openvr_paths"

# OpenXR apps (WayVR, xrizer) -> Monado
ln -sf /usr/share/openxr/1/openxr_monado.json ~/.config/openxr/1/active_runtime.json

# WayVR's service reads its backend flag from this file
echo "WAYVR_BACKEND=--openxr" > ~/.config/wayvr/backend.env

systemctl --user start monado.socket
systemctl --user restart monado.service   # restart, not start: picks up config changes
systemctl --user restart wayvr.service

vr-steamvr, to switch back to SteamVR:

#!/usr/bin/env bash
# Switch back to SteamVR (undoes vr-monado).
set -euo pipefail
steam_cfg=~/.local/share/Steam/config
openvr_paths=~/.config/openvr/openvrpaths.vrpath
steamvr="$HOME/.local/share/Steam/steamapps/common/SteamVR"

systemctl --user stop monado.service monado.socket || true

# Put SteamVR's "launch on button press" back to its default
jq 'del(.power.autoLaunchSteamVROnButtonPress) | if .power == {} then del(.power) else . end' \
    "$steam_cfg/steamvr.vrsettings" > /tmp/vrs.tmp && mv /tmp/vrs.tmp "$steam_cfg/steamvr.vrsettings"

# OpenVR apps -> SteamVR
jq --arg p "$steamvr" '.runtime = [$p]' "$openvr_paths" > /tmp/ovr.tmp && mv /tmp/ovr.tmp "$openvr_paths"

# OpenXR apps -> SteamVR
ln -sf "$steamvr/steamxr_linux64.json" ~/.config/openxr/1/active_runtime.json

echo "WAYVR_BACKEND=--openvr" > ~/.config/wayvr/backend.env
systemctl --user restart wayvr.service

The WayVR service those scripts restart (~/.config/systemd/user/wayvr.service):

[Unit]
Description=WayVR overlay

[Service]
EnvironmentFile=%h/.config/wayvr/backend.env
ExecStart=/usr/bin/wayvr $WAYVR_BACKEND --wait
Restart=on-failure

Notes:

  • The paths are for an Arch/CachyOS install of the xrizer and monado-git packages, with Steam in its default location. Check yours: /opt/xrizer and /usr/share/openxr/1/openxr_monado.json may live somewhere else on other distros.
  • Back up openvrpaths.vrpath and steamvr.vrsettings before trying this, so you can copy them back if something goes wrong.
  • Run vr-monado with SteamVR closed, and turn your controllers and trackers on first (see 3).

7. WayVR tweaks.

Run it as a systemd user service (see the unit file in 6), not launched by SteamVR. WayVR's docs warn that SteamVR-launched instances misbehave, and the service lets the switch scripts flip it between --openxr (Monado) and --openvr (SteamVR).

Playspace drag on the B button, like OVR Advanced Settings. OVRAS only works on SteamVR. WayVR has its own playspace drag, but with Monado it's bound through WayVR's OpenXR bindings file, not the actions_binding_*.json files (those are for OpenVR only). Copy WayVR's default openxr_actions.json5 into ~/.config/wayvr/ and edit the Index controller block (profile: "/interaction_profiles/valve/index_controller"):

grab: {
  // light squeeze grabs (the default needed a hard squeeze)
  left: "/user/hand/left/input/squeeze/force",
  right: "/user/hand/right/input/squeeze/force",
  threshold_left: [0.05, 0.15],    // [release, activate]
  threshold_right: [0.05, 0.15]
},
scroll: {
  // scroll is a 2D action in WayVR 26.8: binding thumbstick/y gave no input on Monado
  left: "/user/hand/left/input/thumbstick",
  right: "/user/hand/right/input/thumbstick"
},
show_hide: {
  // moved off left B, since B is now playspace drag
  double_click: true,
  left: "/user/hand/left/input/system/click",
},
space_drag: {
  // hold B on either hand to drag the playspace
  left: "/user/hand/left/input/b/click",
  right: "/user/hand/right/input/b/click",
},
space_reset: {
  // double-press the left trackpad to reset the drag
  left: "/user/hand/left/input/trackpad/force",
  double_click: true,
},

Then in WayVR's settings (saved to ~/.config/wayvr/conf.d/zz-saved-config.json5), set "space_gravity_enabled": false, or the playspace "falls" back after you let go. space_drag_unlocked and space_rotate_unlocked should be true.

Custom skybox (optional). WayVR only loads .dds skyboxes. Convert a 2:1 360ยฐ panorama with ImageMagick:

magick panorama.png -filter Lanczos -resize 4096x2048! -define dds:compression=dxt1 ~/.config/wayvr/my-skybox.dds

Then set "skybox_texture": "my-skybox.dds" (relative to ~/.config/wayvr).

8. Base stations without SteamVR. SteamVR wakes and sleeps Lighthouse 2.0 base stations for you; Monado doesn't. They're plain Bluetooth LE devices with a power characteristic:

  • UUID 00001525-1212-efde-1523-785feabcd124
  • Write 1 = on, 0 = sleep
  • Reading it gives 0 asleep, 2 standby, 9 booting, 11 on

The AUR lighthouse tool could see mine but never connected, so I wrote basestations on|off|status using plain BlueZ (busctl). Find your stations' MAC addresses with bluetoothctl scan le; they show up as LHB-XXXXXXXX. Here's a trimmed version:

#!/usr/bin/env bash
# basestations on|off|status - Lighthouse 2.0 power over BlueZ
MACS=(AA:BB:CC:DD:EE:01 AA:BB:CC:DD:EE:02)        # yours, from: bluetoothctl scan le
POWER_UUID=00001525-1212-efde-1523-785feabcd124
case "$1" in on) value=1 ;; off) value=0 ;; status) value= ;; *) echo "usage: $0 on|off|status"; exit 2 ;; esac

dev_path() { echo "/org/bluez/hci0/dev_${1//:/_}"; }
prop() { busctl get-property org.bluez "$1" org.bluez.Device1 "$2" 2>/dev/null | awk '{print $2}'; }

connect() {   # BlueZ forgets unpaired LE devices after they disconnect, so rescan if Connect fails
    local dev; dev=$(dev_path "$1")
    for _ in 1 2 3; do
        busctl call org.bluez "$dev" org.bluez.Device1 Connect >/dev/null 2>&1 \
            || { bluetoothctl --timeout 6 scan le >/dev/null 2>&1; continue; }
        for _ in $(seq 20); do [ "$(prop "$dev" ServicesResolved)" = true ] && return 0; sleep 0.5; done
    done
    return 1
}

power_char() {   # find the GATT characteristic with the power UUID
    for c in $(busctl tree org.bluez --list | grep "^$(dev_path "$1")/service[0-9a-f]*/char[0-9a-f]*$"); do
        busctl get-property org.bluez "$c" org.bluez.GattCharacteristic1 UUID | grep -q "$POWER_UUID" && { echo "$c"; return; }
    done
    return 1
}

for mac in "${MACS[@]}"; do
    connect "$mac" && char=$(power_char "$mac") || { echo "$mac: unreachable"; continue; }
    state=$(busctl call org.bluez "$char" org.bluez.GattCharacteristic1 ReadValue 'a{sv}' 0 | awk '{print $3}')
    if [ "$value" = 1 ] && [ "$state" != 0 ] && [ "$state" != 2 ]; then
        echo "$mac: already on"            # writing 1 to a running station confuses its state
    elif [ -n "$value" ]; then
        busctl call org.bluez "$char" org.bluez.GattCharacteristic1 WriteValue 'aya{sv}' 1 "$value" 0 && echo "$mac: $1"
    else
        echo "$mac: state $state"
    fi
    busctl call org.bluez "$(dev_path "$mac")" org.bluez.Device1 Disconnect >/dev/null 2>&1
done

9. Audio. The Beyond's mic and the Beyond Audio Strap only appear once the headset is powered, so play-vr waits for them, then makes them the defaults. stop-vr switches back to my desktop mic and headphones. Find your device names with pactl list short sources (mics) and pactl list short sinks (outputs):

BEYOND_MIC=alsa_input.usb-Bigscreen_Beyond_<serial>-00.analog-stereo
BEYOND_STRAP=alsa_output.usb-Bigscreen_Beyond_Audio_Strap-00.analog-stereo
for _ in $(seq 10); do pactl list short sources | grep -q "$BEYOND_MIC" && break; sleep 1; done
pactl set-default-source "$BEYOND_MIC"
pactl set-default-sink "$BEYOND_STRAP" || echo "No audio strap, keeping current output"

Optional noise suppression. play-vr --noise puts the Beyond mic through RNNoise (the noise-suppression-for-voice package). It's a standalone PipeWire filter, ~/.config/pipewire/beyond-rnnoise.conf, run on demand by a user service (ExecStart=/usr/bin/pipewire -c beyond-rnnoise.conf). The important part:

{ name = libpipewire-module-filter-chain
  args = {
      node.description = "Beyond Mic (noise suppressed)"
      filter.graph = {
          nodes = [ { type = ladspa  name = rnnoise
                      plugin = /usr/lib/ladspa/librnnoise_ladspa.so
                      label  = noise_suppressor_mono
                      control = { "VAD Threshold (%)" = 60.0
                                  "VAD Grace Period (ms)" = 250
                                  "Retroactive VAD Grace (ms)" = 30 } } ]
      }
      capture.props  = { node.name = mic_clean_vr.capture  node.passive = true
                         audio.position = [ MONO ]   # downmixes the stereo Beyond mic
                         target.object = "<your Beyond mic name>"  node.dont-fallback = true }
      playback.props = { node.name = mic_clean_vr  media.class = Audio/Source  audio.position = [ MONO ] }
  }
}

Then pactl set-default-source mic_clean_vr. Raise the VAD threshold if fan noise leaks between words; lower it if words get clipped.

10. One command to start, one to stop. Both live in ~/.local/bin.

play-vr, in order:

  1. Refuses to start if monado-service has the realtime capability again (see 4), and prints the setcap -r command to fix it.
  2. Tells you to turn on the controllers and trackers, and wakes the base stations in the background (basestations on).
  3. Saves your current power profile and switches to performance (powerprofilesctl).
  4. Runs vr-monado, then watches Monado's log (journalctl --user -u monado.service) and counts the Found lighthouse HMD / controller / tracker lines. Once it sees the HMD, 2 controllers and 3 trackers, it moves on without waiting out the full discovery window. If something's missing, it asks: R to restart Monado (after you turn the missing device on), or Enter to continue anyway.
  5. Switches the mic and audio to the headset (see 9).
  6. Launches VRChat (steam steam://rungameid/438100), then the companions: OSC DreamChatbox, Baballonia and VRCFaceTracking. Each only starts if it isn't already running.

The core of the device wait:

start=$(date +%s)
vr-monado >/dev/null
count() { journalctl --user -u monado.service --since "@$start" -o cat | grep -c "Found lighthouse $1"; }
for _ in $(seq 60); do
    [ "$(count HMD)" -ge 1 ] && [ "$(count controller)" -ge 2 ] && [ "$(count tracker)" -ge 3 ] && break
    sleep 1
done

stop-vr, in order:

  1. Closes VRChat (TERM, then KILL after 15 s), then the chatbox, Baballonia and VRCFaceTracking.
  2. Stops WayVR before Monado, so WayVR gets to save its overlay layout, then stops Monado (systemctl --user stop monado.service monado.socket).
  3. Kills leftover Proton processes from VRChat's prefix (pkill -f compatdata/438100) and stops the noise filter.
  4. Switches the mic and audio back to the desktop devices, sleeps the base stations (basestations off), and restores the saved power profile.
  5. Runs backup-vr, which tars up every VR config (the scripts, the Monado/WayVR units and overrides, the runtime selection, WayVR's config, SteamVR's settings files and the pacman hook) into ~/vr-backups/ and keeps the newest 20. When a -git update breaks something, you can diff against the last working session.

Limitations

  • No dynamic resolution on Monado. Resolution is fixed at startup, and OVR Dynamic Resolution needs SteamVR.
  • OVR Advanced Settings is SteamVR-only. WayVR covers playspace drag.
  • The Beyond Utility's eye tracking doesn't work under Proton. The eye camera works fine natively, so I'm using Baballonia.