diff --git a/Justfile b/Justfile index 518d6b9f..1f7e76c6 100644 --- a/Justfile +++ b/Justfile @@ -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: diff --git a/hosts/idols-ai/VR.md b/hosts/idols-ai/VR.md new file mode 100644 index 00000000..c6ec6235 --- /dev/null +++ b/hosts/idols-ai/VR.md @@ -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//`. 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 `. 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 ` 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. diff --git a/hosts/idols-ai/ai/vr.nix b/hosts/idols-ai/ai/vr.nix new file mode 100644 index 00000000..f49db4f4 --- /dev/null +++ b/hosts/idols-ai/ai/vr.nix @@ -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"; + }; +} diff --git a/hosts/idols-ai/hardware-nvidia.nix b/hosts/idols-ai/hardware-nvidia.nix index 3f208bbb..ed5c8b21 100644 --- a/hosts/idols-ai/hardware-nvidia.nix +++ b/hosts/idols-ai/hardware-nvidia.nix @@ -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, diff --git a/hosts/idols-ai/preservation.nix b/hosts/idols-ai/preservation.nix index 192d0279..ff511043 100644 --- a/hosts/idols-ai/preservation.nix +++ b/hosts/idols-ai/preservation.nix @@ -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" diff --git a/outputs/x86_64-linux/tests/idols-ai-gpu/expected.nix b/outputs/x86_64-linux/tests/idols-ai-gpu/expected.nix index 73fcb8fd..a9fb9732 100644 --- a/outputs/x86_64-linux/tests/idols-ai-gpu/expected.nix +++ b/outputs/x86_64-linux/tests/idols-ai-gpu/expected.nix @@ -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; } diff --git a/outputs/x86_64-linux/tests/idols-ai-gpu/expr.nix b/outputs/x86_64-linux/tests/idols-ai-gpu/expr.nix index 45af6be8..1104ca06 100644 --- a/outputs/x86_64-linux/tests/idols-ai-gpu/expr.nix +++ b/outputs/x86_64-linux/tests/idols-ai-gpu/expr.nix @@ -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"; } diff --git a/scripts/wivrn-audio.nu b/scripts/wivrn-audio.nu new file mode 100644 index 00000000..d37c7440 --- /dev/null +++ b/scripts/wivrn-audio.nu @@ -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" } } + } +}