feat(hosts): add WiVRn CLI streaming for Quest 3 #392

This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-10-10 22:49:48 +08:00
1 parent 7c09a1d082
commit 3e66bbf24f
8 files changed
+426 -10

No files matched your search

+34
View File
@@ -382,6 +382,40 @@ game:
del-failed:
kubectl delete pod --all-namespaces --field-selector="status.phase==Failed"
# Start PC VR on idols-ai and route desktop audio to the headset.
[linux]
[group('vr')]
vr:
systemctl --user start wivrn.service
systemctl --user stop wivrn-audio.service | complete | ignore
systemd-run --user --collect --unit=wivrn-audio nu {{absolute_path("scripts/wivrn-audio.nu")}} switch --timeout 600
# Route audio to a headset that is connected right now.
[linux]
[group('vr')]
vr-audio:
nu {{absolute_path("scripts/wivrn-audio.nu")}} switch --timeout 60
# Stop PC VR when finished and restore the previous audio devices.
[linux]
[group('vr')]
vr-stop:
systemctl --user stop wivrn-audio.service | complete | ignore
nu {{absolute_path("scripts/wivrn-audio.nu")}} restore
systemctl --user stop wivrn.service
# Show PC VR service status.
[linux]
[group('vr')]
vr-status:
systemctl --user status wivrn.service --no-pager
# Follow PC VR service logs.
[linux]
[group('vr')]
vr-logs:
journalctl --user -u wivrn.service -f
[linux]
[group('services')]
list-inactive:
+149
View File
@@ -0,0 +1,149 @@
# Quest 3 PC VR on idols-ai
WiVRn streams PC VR games to Quest 3. It supplies the OpenXR runtime; xrizer translates OpenVR
games. This host uses the server and command-line tools without the Dashboard. SteamVR does not need
to run, but compatibility still depends on the game and translation layer.
## First activation and persistence
Run `just niri` on idols-ai from this branch's worktree, or after merging it. Log out and back in so
Steam sees `PRESSURE_VESSEL_IMPORT_OPENXR_1_RUNTIMES=1`. That variable is what makes pressure-vessel
mount WiVRn's `$XDG_RUNTIME_DIR/wivrn/comp_ipc` socket into a game's container; without it the
game's OpenXR client fails with `ERROR_RUNTIME_UNAVAILABLE` and the headset stays black.
The root filesystem is a 4 GiB tmpfs. These directories are preserved with mode 0700:
- `~/.config/wivrn/`: server settings, paired headset public keys and discovery cookie.
- `~/.android/`: ADB host keys, so USB authorization survives a reboot.
Before the first activation, migrate any existing directories to their matching locations under
`/persistent/home/<user>/`. Stop WiVRn first. If both locations contain data, reconcile them before
activating; do not overwrite existing pairing state or ADB keys. Do not display or upload ADB
private keys, or copy them into this repository.
OpenXR/OpenVR runtime pointers and sockets remain temporary. WiVRn switches runtime pointers while a
headset is connected and restores previous configuration when it disconnects. Do not manage these
pointers as read-only Home Manager files, persist IPC sockets, or delete existing runtime
configurations blindly.
## Wireless connection and pairing
1. Connect the PC by Ethernet and Quest 3 to the trusted home LAN (`192.168.5.0/24`). Prefer 5 GHz
or 6 GHz Wi-Fi near the access point. Guest Wi-Fi or client isolation can block discovery and
streaming.
2. Install the WiVRn 26.9 client on the headset: the Meta Store build is packaged as
`org.meumeu.wivrn`, the GitHub release APK as `org.meumeu.wivrn.github`. Client and server
versions must match; check the installed server with `wivrn-server --version` after updates.
3. The server does not start automatically at login or boot. From this repository on idols-ai, start
it when you want to use VR:
```console
just vr
just vr-status
```
`just vr` returns immediately and leaves `wivrn-audio.service` waiting up to ten minutes for the
headset. When it connects, that unit makes WiVRn's speaker and microphone the defaults and moves
any stream that is already playing. `just vr-audio` does the same switch in the foreground when
the headset is already connected, and `just vr-stop` cancels the wait and restores the saved
devices. Starting the service does not enable it for future logins.
4. Allow a new headset to pair for two minutes:
```console
wivrnctl pair --duration 2
```
This prints a PIN. Open WiVRn on the headset, connect to `ai`, and enter the PIN. If discovery
fails, enter `192.168.5.100` manually. Reconnection after pairing does not require running this
command again.
5. Audio follows the headset: WiVRn's `wivrn.sink` and `wivrn.source` exist only while a headset is
connected, so the switch happens then, not at `just vr` time. Check the current defaults with
`pactl get-default-sink` and `pactl get-default-source`.
Manage paired headsets with `wivrnctl list-paired`, `wivrnctl rename`, or `wivrnctl unpair`. Consult
`wivrnctl --help` and the subcommand's `--help` for arguments.
## Service, settings and logs
Manage the server through systemd so starts retain NVIDIA offload and the CAP_SYS_NICE wrapper:
```console
systemctl --user restart wivrn
just vr-stop
just vr-status
journalctl --user -u wivrn -b -n 100 --no-pager
just vr-logs
```
Service logs use the host's existing persistent journal. There is no separate Dashboard log folder
or log exporter. For server settings, edit `~/.config/wivrn/config.json` following the WiVRn 26.9
configuration schema, then restart the service. The NixOS module also supports declarative server
configuration; an enabled declarative config takes precedence over the user file. Headset-side
settings remain available in the headset application.
## Launch games on the RTX 4090
Set this in each VR game's Steam Properties -> Launch Options:
```text
__NV_PRIME_RENDER_OFFLOAD=1 __NV_PRIME_RENDER_OFFLOAD_PROVIDER=NVIDIA-G0 __GLX_VENDOR_LIBRARY_NAME=nvidia __VK_LAYER_NV_optimus=NVIDIA_only %command%
```
These are the variables the system's `nvidia-offload` wrapper sets, written out because Steam runs
inside a bubblewrap sandbox that does not mount `/run`, so it cannot see
`/run/current-system/sw/bin/nvidia-offload`.
The game has to land on the same GPU as the server. A game left on the Intel iGPU makes the server's
swapchain allocation fail, which shows up as `vkAllocateMemory: VK_ERROR_OUT_OF_DEVICE_MEMORY` and
`xrt_comp_create_swapchain failed` in `journalctl --user -u wivrn`, followed by an xrizer panic that
kills the game. This applies when launching from the headset too: WiVRn starts applications in
separate systemd units, and an already running Steam client keeps its own environment, so neither
path inherits the server's GPU environment. For non-Steam applications, start them under
`nvidia-offload` from a normal shell. Select a Proton version according to the game's compatibility
reports; GE-Proton or DW-Proton alone does not guarantee VR compatibility.
## USB connection
Enable Quest developer mode, connect a data-capable USB cable, and accept the headset's USB
debugging authorization prompt. ADB is already installed in the desktop profile. The current systemd
handles USB access for the active local session. Developer mode is needed for USB or sideloading,
not for the Meta Store wireless workflow.
For sideloading, download the matching official APK and use `adb install -r <apk-path>`. The GitHub
release APK is packaged as `org.meumeu.wivrn.github`, the Meta Store build as `org.meumeu.wivrn`;
pass the package you actually installed.
Pair over Wi-Fi first. For an already paired headset with the GitHub APK installed:
```console
adb devices
adb reverse tcp:9757 tcp:9757
adb shell am start -a android.intent.action.VIEW -d "wivrn+tcp://127.0.0.1:9757" org.meumeu.wivrn.github
```
If multiple devices are connected, add `-s <serial>` to each ADB command. Disable headset Wi-Fi
while validating USB so the test cannot silently use wireless streaming. Recreate the reverse
mapping after disconnecting USB.
## Encoding and hardware validation
The package enables Vulkan Video encoding but does not enable NVENC. WiVRn 26.9 probes Vulkan
encoding before falling back to VAAPI or x264 when NVENC is absent. Check the journal's encoder
configuration after connecting: offload variables alone do not prove hardware encoding. Vulkan
encoding in this release supports H.264/H.265, not AV1. NVENC/AV1 requires a CUDA-enabled WiVRn
build.
- Pair, connect, launch a game, and check both compositor and game GPU usage:
`nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv` has to list both
`wivrn-server` and the game.
- Confirm the journal has no `vkAllocateMemory` or `xrt_comp_create_swapchain` failures, and that
`~/.local/state/xrizer/xrizer.txt` has no `ERROR_RUNTIME_UNAVAILABLE`.
- Restart with systemd and verify the server remains owned by `wivrn.service`.
- Reboot the PC and reconnect without pairing again; verify saved settings and USB authorization
remain intact.
- Test audio routing (`just vr` and `just vr-stop`), microphone, controllers, and a supported
OpenXR/OpenVR game.
Configuration and package checks do not replace these headset tests.
+39
View File
@@ -0,0 +1,39 @@
{ pkgs, ... }:
{
# WiVRn supplies the OpenXR runtime; xrizer translates OpenVR games.
# The server uses the host GPU environment from hardware-nvidia.nix.
services.wivrn = {
enable = true;
autoStart = false; # Start only when requested with just vr.
# Use the unmodified nixpkgs package without installing its standalone GUI.
# This reuses the upstream build; it does not trim Qt from its dependency closure.
package = pkgs.symlinkJoin {
name = "wivrn-cli-${pkgs.wivrn.version}";
inherit (pkgs.wivrn) version meta;
paths = [ pkgs.wivrn ];
postBuild = ''
rm "$out/bin/wivrn-dashboard" "$out/bin/.wivrn-dashboard-wrapped"
rm "$out/share/applications/"*.desktop
test -x "$out/bin/wivrn-server"
test -x "$out/bin/wivrnctl"
test ! -e "$out/bin/wivrn-dashboard"
for entry in "$out/share/applications/"*.desktop; do
test ! -e "$entry"
done
'';
};
# CAP_SYS_NICE for the server. Games need their own PRIME offload: WiVRn
# starts them in separate units, which do not inherit this environment.
highPriority = true;
steam.importOXRRuntimes = true;
};
# Wait for the control interface used by wivrnctl before reporting success.
systemd.user.services.wivrn.serviceConfig = {
Type = "dbus";
BusName = "io.github.wivrn.Server";
TimeoutStartSec = "30s";
};
}
+21 -10
View File
@@ -3,6 +3,22 @@
pkgs,
...
}:
let
# prime.offload keeps the Intel iGPU as the default renderer so the dGPU stays
# free for LLM workloads, so each consumer that should use the RTX 4090 opts
# into PRIME render offload explicitly. Games need these variables in their
# Steam launch options: Steam's bubblewrap sandbox does not expose /run, so
# the nvidia-offload wrapper on PATH is unusable there. WiVRn's server is a
# headless Vulkan app and needs the same environment; headset-launched games
# start in separate units or an already running Steam client, so they need
# the same per-game launch option (see hosts/idols-ai/VR.md).
dgpuEnv = {
__NV_PRIME_RENDER_OFFLOAD = "1";
__NV_PRIME_RENDER_OFFLOAD_PROVIDER = "NVIDIA-G0";
__GLX_VENDOR_LIBRARY_NAME = "nvidia";
__VK_LAYER_NV_optimus = "NVIDIA_only";
};
in
{
# ===============================================================================================
# for Nvidia GPU
@@ -22,16 +38,11 @@
nvidiaBusId = "PCI:2@0:0:0";
};
# Games launched through GameScope should use the dGPU. prime.offload keeps the
# Intel iGPU as the default renderer so the dGPU stays free for LLM workloads,
# so opt GameScope games into PRIME render offload here. Games launched outside
# GameScope need `nvidia-offload %command%` in their Steam launch options.
programs.gamescope.env = {
__NV_PRIME_RENDER_OFFLOAD = "1";
__NV_PRIME_RENDER_OFFLOAD_PROVIDER = "NVIDIA-G0";
__GLX_VENDOR_LIBRARY_NAME = "nvidia";
__VK_LAYER_NV_optimus = "NVIDIA_only";
};
programs.gamescope.env = dgpuEnv;
# Apply offload to the WiVRn server (see ai/vr.nix). Games need their own
# offload configuration; this service environment does not propagate to them.
services.wivrn.monadoEnvironment = dgpuEnv;
boot.kernelParams = [
# Since NVIDIA does not load kernel mode setting by default,
+10
View File
@@ -304,6 +304,16 @@ in
".local/share/umu"
".local/share/Steam"
# WiVRn atomically replaces its settings and pairing files, so preserve
# the directory rather than individual files.
{
directory = ".config/wivrn";
mode = "0700";
}
{
directory = ".android"; # ADB host keys for Quest USB authorization
mode = "0700";
}
".local/state/Heroic"
".config/heroic"
@@ -7,4 +7,14 @@
sunshineUsesWlrCapture = "wlr";
sunshineStartsAfterNiri = true;
sunshineWaitsForNiriOutput = true;
wivrnEnabled = true;
wivrnAutoStarts = false;
wivrnHighPriority = true;
wivrnWaitsForBus = true;
wivrnImportsOpenXRToSteam = true;
wivrnUsesNvidiaVulkan = true;
wivrnSharesDgpuEnvWithGamescope = true;
wivrnStatePreserved = true;
adbStatePreserved = true;
}
@@ -6,6 +6,12 @@
let
cfg = outputs.nixosConfigurations.ai-niri.config;
niriHardware = builtins.readFile ../../../../hosts/idols-ai/niri-hardware.kdl;
preserved = cfg.preservation.preserveAt."/persistent".users.ryan.directories;
privatelyPreserved =
path:
lib.any (
d: d.directory == cfg.users.users.ryan.home + "/" + path && d.how == "bindmount" && d.mode == "0700"
) preserved;
in
{
loadsVirtualDisplay = builtins.elem "vkms" cfg.boot.kernelModules;
@@ -16,4 +22,19 @@ in
sunshineUsesWlrCapture = cfg.services.sunshine.settings.capture;
sunshineStartsAfterNiri = builtins.elem "niri.service" cfg.systemd.user.services.sunshine.after;
sunshineWaitsForNiriOutput = lib.hasInfix "niri msg outputs" cfg.systemd.user.services.sunshine.preStart;
wivrnEnabled = cfg.services.wivrn.enable;
wivrnAutoStarts = builtins.elem "default.target" cfg.systemd.user.services.wivrn.wantedBy;
wivrnHighPriority = cfg.services.wivrn.highPriority;
wivrnWaitsForBus =
cfg.systemd.user.services.wivrn.serviceConfig.Type == "dbus"
&& cfg.systemd.user.services.wivrn.serviceConfig.BusName == "io.github.wivrn.Server"
&& cfg.systemd.user.services.wivrn.serviceConfig.TimeoutStartSec == "30s";
wivrnImportsOpenXRToSteam =
cfg.environment.sessionVariables.PRESSURE_VESSEL_IMPORT_OPENXR_1_RUNTIMES == "1";
wivrnUsesNvidiaVulkan = cfg.services.wivrn.monadoEnvironment.__VK_LAYER_NV_optimus == "NVIDIA_only";
wivrnSharesDgpuEnvWithGamescope =
cfg.services.wivrn.monadoEnvironment == cfg.programs.gamescope.env;
wivrnStatePreserved = privatelyPreserved ".config/wivrn";
adbStatePreserved = privatelyPreserved ".android";
}
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env nu
#
# Route desktop audio to the WiVRn headset, and back again.
#
# WiVRn creates its speaker (wivrn.sink) and microphone (wivrn.source) only
# while a headset is connected, so this script waits (bounded) for those nodes
# before it switches the default devices and moves the streams that are already
# playing. The previous defaults are saved under $XDG_RUNTIME_DIR, so they last
# for the login session only; WirePlumber's persisted defaults stay untouched.
#
# just vr start the service, then route audio to the headset
# just vr-audio route audio after a headset connected later
# just vr-stop restore the previous devices, then stop the service
#
# See hosts/idols-ai/VR.md.
# WiVRn's PipeWire node names (server/driver/wivrn_session.cpp).
const SINK = "wivrn.sink"
const SOURCE = "wivrn.source"
# 'pactl list' output for a target ("sinks", "sources", "sink-inputs", ...).
def list-devices [target: string] {
(^pactl --format=json list $target | complete | get stdout | from json)
}
# The device called $name, or null.
def find-device [target: string, name: string] {
let matches = (list-devices $target | where name == $name)
if ($matches | is-empty) { null } else { $matches | get 0 }
}
# Wait for $name to appear, up to $seconds; returns null on timeout.
def wait-for-device [target: string, name: string, seconds: int] {
let deadline = (date now) + ($seconds * 1sec)
mut waited = 0
loop {
let device = (find-device $target $name)
if $device != null { return $device }
if (date now) > $deadline { return null }
sleep 2sec
$waited += 2
if ($waited mod 30) == 0 { print $"audio: still waiting for the headset (($waited)s)" }
}
}
# Where the previous defaults are remembered (per login session).
def state-path [] {
let runtime = ($env.XDG_RUNTIME_DIR? | default $"/run/user/(^id -u | str trim)")
$runtime | path join "wivrn-audio.json"
}
# The current default sink or source name.
def default-device [kind: string] {
(^pactl $"get-default-($kind)" | complete | get stdout | str trim)
}
# Point the default sink or source at $name.
def set-default-device [kind: string, name: string] {
^pactl $"set-default-($kind)" $name | complete | ignore
}
# Move every playback stream to $sink.
def move-sink-inputs [sink: string] {
for input in (list-devices "sink-inputs") {
^pactl move-sink-input ($input.index | into string) $sink | complete | ignore
}
}
# Move every recording stream to $source.
def move-source-outputs [source: string] {
for output in (list-devices "source-outputs") {
^pactl move-source-output ($output.index | into string) $source | complete | ignore
}
}
# Point the default devices at the headset and follow with current streams.
def switch [seconds: int] {
print $"audio: waiting up to ($seconds)s for the headset to connect"
let sink = (wait-for-device "sinks" $SINK $seconds)
if $sink == null {
print $"audio: the headset did not connect within ($seconds)s; run 'just vr-audio' once it does"
return
}
let state = (state-path)
if (default-device "sink") != $SINK {
{ sink: (default-device "sink"), source: (default-device "source") } | to json | save -f $state
}
set-default-device "sink" $SINK
move-sink-inputs $SINK
print $"audio: playback -> ($SINK), previous default saved in ($state)"
let source = (find-device "sources" $SOURCE)
if $source != null {
set-default-device "source" $SOURCE
move-source-outputs $SOURCE
print $"audio: microphone -> ($SOURCE)"
} else {
print $"audio: no ($SOURCE) yet, microphone left alone"
}
}
# Put the remembered default devices back.
def restore [] {
let state = (state-path)
if not ($state | path exists) {
print "audio: no saved defaults, nothing to restore"
return
}
let previous = (open --raw $state | from json)
rm $state
if (find-device "sinks" $previous.sink) == null {
print $"audio: ($previous.sink) is gone, leaving the default to WirePlumber"
} else {
set-default-device "sink" $previous.sink
move-sink-inputs $previous.sink
print $"audio: playback -> ($previous.sink)"
}
if (find-device "sources" $previous.source) == null {
print $"audio: ($previous.source) is gone, leaving the default to WirePlumber"
} else {
set-default-device "source" $previous.source
move-source-outputs $previous.source
print $"audio: microphone -> ($previous.source)"
}
}
# Entry point: 'nu wivrn-audio.nu switch' / '... restore'.
def main [
action: string = "switch" # switch to the headset, or restore the previous devices
--timeout: int = 120 # seconds to wait for the headset audio devices
] {
match $action {
"switch" => { switch $timeout }
"restore" => { restore }
_ => { error make { msg: $"unknown action '($action)'; expected switch or restore" } }
}
}