Wired Quest 2 VR on Linux
Wired VR on Linux with ALVR and a Meta Quest 2
Everyone building Linux VR tooling assumes you're streaming over WiFi. The defaults are tuned for it, the documentation covers it, and the communities talk about it. Wired barely gets a mention. This is the guide that should exist.
Hardware: CachyOS Hyprland NVIDIA RTX 3060 Ti (hybrid PRIME with Intel iGPU) Meta Quest 2.
Correction, 2026-07-27. The original version of this post blamed the latency sawtooth on a SteamVR frame-timing regression, and recommended four
session.jsonchanges to fight the queuing latency I thought was causing it. The root cause turned out to be different, and two of those recommendations were wrong. I have rewritten the sawtooth section and cut the bad config advice. If you copied the oldconnectionblock, dropmax_queued_server_video_framesandminimum_idr_interval_msback to their defaults. Details in both sections below.
Why wired
Wireless VR needs a dedicated WiFi 6E router, line-of-sight placement, nothing else on the band. People spend $200-400 on a router to make wireless VR tolerable. A USB-C cable costs $10 and gives you zero packet loss, zero jitter, and lower latency than any wireless setup can achieve. The only reason everyone does wireless first is that Meta markets the Quest as a standalone headset you walk around with, so the whole ecosystem is built around that assumption.
Why not WiVRn
WiVRn is excellent and I'm still running it for native OpenXR apps. It streams through Monado, bypasses SteamVR entirely, and the latency with a wired ADB tunnel is genuinely good. Install it, set tcp-only: true in the config (hyphen, not underscore, underscore is silently ignored and your video goes over WiFi instead of the cable), and it just works.
But VRChat is hardcoded to SteamVR. It detects OpenVR at startup and refuses to use OpenXR. OpenComposite can translate calls between the two, but the frame timing translation is broken enough that VRChat runs at 120-140ms motion-to-photon with OpenComposite, which is completely unplayable. WiVRn can't fix this because it's a SteamVR architecture problem.
For VRChat specifically, you need a SteamVR driver. That's ALVR.
Building ALVR from source
ALVR's releases don't work on NVIDIA driver 580+ because they ship against nv-codec-headers 12.1.14.0 and the newer drivers need 13.0.19.0. You have to build from source.
git clone https://github.com/alvr-org/ALVR
cd ALVR
git submodule update --init --recursive
Edit alvr/xtask/src/dependencies.rs line 268, change the codec header version:
let codec_header_version = "13.0.19.0"; // was "12.1.14.0"
Build:
cargo xtask prepare-deps --platform linux --no-nvidia
cargo xtask build-server-lib
cargo xtask build-alxr-client --no-nvidia
cargo xtask package-server --no-self-build
The output ends up in build/alvr_streamer_linux/.
Setting up the ADB tunnel
The Quest connects to ALVR over TCP ports 9943 (control/handshake) and 9944 (stream). To route these over the USB cable instead of WiFi, you use ADB reverse tunnels. This requires developer mode on your Quest, which you enable through Meta's mobile app under your account settings.
adb reverse tcp:9943 tcp:9943
adb reverse tcp:9944 tcp:9944
ALVR runs adb reverse automatically when it detects a connected Quest, but only for 9944. Manually running both before launching saves confusion.
Put this in a launch script so the tunnel is always set up correctly:
#!/usr/bin/env bash
pkill -f vrmonitor 2>/dev/null
pkill -f steamvr 2>/dev/null
pkill -f alvr_dashboard 2>/dev/null
sleep 1
adb kill-server
adb start-server
adb reverse tcp:9943 tcp:9943
adb reverse tcp:9944 tcp:9944
exec env \
__GLX_VENDOR_LIBRARY_NAME=nvidia \
__NV_PRIME_RENDER_OFFLOAD=1 \
VK_DRIVER_FILES=/usr/share/vulkan/icd.d/nvidia_icd.json \
/path/to/ALVR/build/alvr_streamer_linux/bin/alvr_dashboard
The PRIME environment variables are required if you have a hybrid Intel/NVIDIA laptop or desktop. Without them, ALVR renders on the iGPU and NVENC doesn't see the frames.
Registering ALVR as the OpenVR driver
~/.config/openvr/openvrpaths.vrpath tells SteamVR which external drivers to load. Add ALVR's driver path:
{
"config": ["/home/user/.local/share/Steam/config"],
"external_drivers": ["/path/to/ALVR/build/alvr_streamer_linux/lib64/alvr"],
"jsonid": "vrpathreg",
"log": ["/home/user/.local/share/Steam/logs"],
"runtime": ["/home/user/.local/share/Steam/steamapps/common/SteamVR"],
"version": 1
}
If you've ever run OpenComposite or WiVRn with openvr-compat-path set, this file may have been modified. Check that the runtime field points to SteamVR and not OpenComposite.
SteamVR launch options (in Steam, right-click SteamVR, Properties, Launch Options):
__GLX_VENDOR_LIBRARY_NAME=nvidia __NV_PRIME_RENDER_OFFLOAD=1 VK_DRIVER_FILES=/usr/share/vulkan/icd.d/nvidia_icd.json /home/pax/.local/share/Steam/steamapps/common/SteamVR/bin/vrmonitor.sh %command%
The latency sawtooth (June 2026)
If you get a latency sawtooth in ALVR's stats, latency climbing linearly from 0 to ~1000ms over 5-7 seconds then resetting and repeating, with WARNING Desync detected. Attempting recovery. in the log and a huge number under "Game Render" while encode, network and decode are each under 6ms, this is the bug.
I originally wrote this section blaming a SteamVR frame-timing regression. That was wrong. The real cause is an ALVR bug that a SteamVR update exposed, diagnosed by @besauce in ALVR PR #3342.
What actually happens. ALVR's vulkan layer recovers the per-frame HMD pose by walking vrcompositor's call stack looking for the symbol _ZN13CRenderThread11UpdateAsyncEv (alvr/vulkan_layer/util/pose.cpp). Valve stopped exporting that symbol. The stack walk now fails on every single present, and an all-zero pose matrix gets shipped to the server.
Server side, GetBestPoseMatch() degenerates on that input. Every valid rotation matrix scores an identical distance against a zero matrix, so the winner is decided by floating point noise. One arbitrary history entry wins and keeps winning, frames get tagged with its timestamp, and its age grows at one second per second until it expires from the history buffer and another arbitrary entry takes over at a random younger age. That is your sawtooth.
The number shows up under "Game Render" because that figure is not a rendering measurement. game_time_latency in server_core/src/statistics.rs is frame_present - tracking_received for the frame matched by target_timestamp, so a stale timestamp reads as game time.
Why it looks like a Linux-only SteamVR problem. ALVR has a desync recovery loop in alvr/server_openvr/src/lib.rs that is explicitly gated to Linux:
if cfg!(target_os = "linux") && game_latency.as_secs_f32() > 0.25 {
// rate limited to once per 100ms
warn!("Desync detected. Attempting recovery.");
unsafe { RequestDriverResync(); }
}
That loop is real and my original description of it was accurate. What I got wrong was the input. It is not firing because SteamVR's frame delivery went unstable, it is firing because it is being fed a fabricated latency number. The loop is what converts a bad pose match into a visible sawtooth, and it is why this presents as a Linux-only bug.
Why the previous branch fixes it. Because the older vrcompositor still exports the symbol. Checked on my own install:
$ readelf -sW ~/.local/share/Steam/steamapps/common/SteamVR/bin/linux64/vrcompositor.real \
| grep -c _ZN13CRenderThread11UpdateAsyncEv
1
One hit on the previous branch build. The PR reports zero hits on both the current default and beta branch builds. So the workaround is not fixing SteamVR's timing, it is rolling back to a binary that still has the symbol ALVR is looking for. It will break again the next time that build moves.
The workaround, still the practical answer until #3342 merges:
Steam → Library → SteamVR → right-click → Properties → Betas → previous
The actual fix is #3342, which backs off the doomed stack walk after 300 consecutive misses and falls back to the newest known pose when an all-zero matrix comes in. If you build ALVR from source anyway, which you do if you are on NVIDIA 580+, you can apply it now.
session.json tuning for wired
Some of ALVR's defaults are worth changing for wired. Fewer than I originally claimed. The config file lives at ~/.config/alvr/session.json.
Kill ALVR before editing. It writes session.json on exit and will overwrite your changes if you edit while it's running.
{
"session_settings": {
"video": {
"bitrate": {
"mode": {
"ConstantMbps": 100,
"variant": "ConstantMbps"
}
},
"foveated_encoding": {
"enabled": true,
"content": {
"center_size_x": 0.65,
"center_size_y": 0.6,
"center_shift_x": 0.4,
"center_shift_y": 0.1,
"edge_ratio_x": 2.0,
"edge_ratio_y": 2.5
}
},
"encoder_config": {
"nvenc": {
"quality_preset": { "variant": "P1" },
"tuning_preset": { "variant": "LowLatency" },
"multi_pass": { "variant": "Disabled" }
}
},
"max_buffering_frames": 1.0
},
"headset": {
"controllers": {
"content": {
"steamvr_pipeline_frames": 1.5
}
},
"performance_level": {
"cpu": { "enabled": true, "content": { "variant": "HighPerformance" } },
"gpu": { "enabled": true, "content": { "variant": "HighPerformance" } }
}
},
"connection": {
"stream_protocol": { "variant": "Tcp" }
},
"extra": {
"steamvr_launcher": {
"open_close_steamvr_with_dashboard": true
}
}
}
}
What each change does:
stream_protocol: Tcp — required for the ADB tunnel. ADB only forwards TCP. UDP frames bypass the tunnel and go over WiFi, adding 50-100ms latency. This is the one setting that is genuinely mandatory for wired.
bitrate ConstantMbps: 100 — the Quest 2's hardware decoder is the ceiling, not the cable. Adaptive bitrate is solving a problem you do not have on a lossless link.
max_buffering_frames: 1.0 and steamvr_pipeline_frames: 1.5 (default 2.1) — less buffering and less prediction slack. These are real latency levers and they are safe to tighten on wired, where you are not absorbing wireless jitter.
encoder_config nvenc P1 / LowLatency / multi-pass disabled — fastest NVENC preset. Quality per bit is worse, but at 100 Mbps on a cable you have bits to spare and the encode time matters more.
foveated_encoding — keeps peripheral quality reasonable without taxing the decoder. Edge ratios in the 2.0-2.5 range are a sane starting point.
performance_level HighPerformance — stops the headset downclocking mid-session.
Three settings I previously recommended and now do not
max_queued_server_video_frames. I said set it to 4, down from the 1024 default, on the theory that 1024 frames at 90fps is 11 seconds of latency waiting to accumulate. That reading is wrong. It is not a buffer depth, it is a drop threshold. ALVR sizes a bounded channel with it (server_core/src/connection.rs) and pushes with try_send; when the channel is full it drops the packet, flags the stream corrupted and requests an IDR, logging Dropping video packet. Reason: Can't push to network. On a link that keeps up, the channel sits near empty and the value never comes into play. At 4 it is 44ms of headroom at 90fps, so a brief stall becomes dropped frames instead of a shorter queue. Leave it alone.
minimum_idr_interval_ms. I said raise it to 2000 from the 100 default, describing the default as 10 keyframes per second. It is not a keyframe generator. IDRs are emitted when something requests one, plus one at stream start. Worse, the setting appears to do nothing at all: IDRScheduler.cpp assigns m_minIDRFrameInterval from it and then never reads it, using a hardcoded 100ms static instead. Changing it has no effect.
enforce_server_frame_pacing: false. I said this was a Windows-only no-op and harmless to disable. Both halves are wrong. It lives in the shared Rust driver path with no platform gate, and it controls whether the vsync wait does a real thread::sleep to the next vsync or a bare thread::yield_now(). Setting it false replaces a proper sleep with a busy spin. The default is true. Leave it there.
I also had max_prediction_ms: 200, up from the 100 default. That was masking the sawtooth: the fabricated motion-to-photon figure was permanently blowing past the limit and spamming Latency is too high. Clamping prediction. With the pose bug fixed or the previous branch pinned, the default is fine.
Socket buffers, an honest maybe
The default server_buffer_config and client_buffer_config use variant: "Maximum", which asks the kernel for the largest socket buffer it will give you. On my box that logged as a 3.9MB send buffer. On TCP, a send buffer that large can hold a meaningful amount of video in the kernel before flow control bites, which is a real bufferbloat mechanism.
I originally presented this as a measured cause of ~320ms of latency. It was not measured. I never showed the buffer was actually filling, and the latency figure I was reasoning from turned out to be the fabricated one. Switching both sides to variant: "Custom" with 100000 bytes is still a reasonable thing to try on wired, but treat it as an experiment rather than a fix.
One thing here is worth knowing regardless: Custom: 100000 sits in session.json right next to variant: "Maximum" and is silently ignored. Reading your own config, it looks like the 100000 is in effect. It is not, unless the variant says so.
steamvr.vrsettings
Also add to ~/.local/share/Steam/config/steamvr.vrsettings:
"steamvr": {
"compositorIdleTime": 0
}
This prevents vrcompositor from entering a reduced-rate idle mode mid-session.
What you get
With these settings on a Quest 2 connected via USB-C: encoder latency under 2ms, transport latency 0ms, decoder latency under 4ms. Total motion-to-photon under 30ms. VRChat is playable, once the pose bug is dealt with by either the previous branch or #3342.
The Quest 2's hardware decoder (Snapdragon XR2) caps out around 100-150 Mbps H264 before the decode queue overflows and you start dropping frames. 100 Mbps constant bitrate is the practical ceiling. At that bitrate, foveated encoding with moderate settings (edge ratio 2.0-2.5) keeps the peripheral quality reasonable without taxing the decoder.
The wired setup is strictly better than wireless for fixed-location play. No router, no interference, no 5GHz band management. The tooling just needs someone to care about the defaults, and I should have been more careful about which defaults were actually the problem.