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
xrizerandmonado-gitpackages, with Steam in its default location. Check yours:/opt/xrizerand/usr/share/openxr/1/openxr_monado.jsonmay live somewhere else on other distros. - Back up
openvrpaths.vrpathandsteamvr.vrsettingsbefore trying this, so you can copy them back if something goes wrong. - Run
vr-monadowith 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
0asleep,2standby,9booting,11on
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:
- Refuses to start if
monado-servicehas the realtime capability again (see 4), and prints thesetcap -rcommand to fix it. - Tells you to turn on the controllers and trackers, and wakes the base stations in the background (
basestations on). - Saves your current power profile and switches to
performance(powerprofilesctl). - Runs
vr-monado, then watches Monado's log (journalctl --user -u monado.service) and counts theFound lighthouse HMD / controller / trackerlines. 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. - Switches the mic and audio to the headset (see 9).
- 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:
- Closes VRChat (TERM, then KILL after 15 s), then the chatbox, Baballonia and VRCFaceTracking.
- Stops WayVR before Monado, so WayVR gets to save its overlay layout, then stops Monado (
systemctl --user stop monado.service monado.socket). - Kills leftover Proton processes from VRChat's prefix (
pkill -f compatdata/438100) and stops the noise filter. - Switches the mic and audio back to the desktop devices, sleeps the base stations (
basestations off), and restores the saved power profile. - 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-gitupdate 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.