{ den, ... }: { den.aspects.winvm.nixos = { config, pkgs, lib, ... }: let cfg = config.local.winvm; hookScript = pkgs.writeShellScript "qemu-hook" '' #!/usr/bin/env bash # Libvirt QEMU hook for single-GPU passthrough. # Libvirt calls this script as: qemu # On "prepare begin": unbind GPU from NVIDIA and hand it to the VM. # On "release end": return GPU to the host and restart the display session. VM_NAME="$1" OPERATION="$2" GPU_ADDR="${cfg.gpuPcieAddress}" GPU_AUDIO_ADDR="${cfg.gpuAudioPcieAddress}" # Exit early for any VM that is not the configured passthrough target if [[ "$VM_NAME" != "${cfg.vmName}" ]]; then exit 0 fi bind_vfio() { # Stop greetd before terminating the session so it cannot restart tuigreet systemctl stop greetd.service # Terminate the Niri/Wayland session and all user services (including Pipewire) loginctl terminate-user zoty 2>/dev/null || true sleep 3 # Detach virtual console framebuffer bindings held by the NVIDIA driver echo 0 > /sys/class/vtconsole/vtcon0/bind 2>/dev/null || true echo 0 > /sys/class/vtconsole/vtcon1/bind 2>/dev/null || true # Detach the EFI framebuffer platform driver if it is present echo efi-framebuffer.0 \ > /sys/bus/platform/drivers/efi-framebuffer/unbind 2>/dev/null || true sleep 1 # Unload NVIDIA kernel modules in reverse dependency order modprobe -r nvidia_drm nvidia_modeset nvidia_uvm nvidia # Bind GPU and its HDMI audio function to the vfio-pci driver echo "$GPU_ADDR" > /sys/bus/pci/drivers/vfio-pci/bind echo "$GPU_AUDIO_ADDR" > /sys/bus/pci/drivers/vfio-pci/bind } unbind_vfio() { # Release both GPU PCI functions from vfio-pci echo "$GPU_ADDR" > /sys/bus/pci/drivers/vfio-pci/unbind echo "$GPU_AUDIO_ADDR" > /sys/bus/pci/drivers/vfio-pci/unbind sleep 1 # Reload NVIDIA modules in dependency order modprobe nvidia modprobe nvidia_modeset modprobe nvidia_uvm modprobe nvidia_drm # Restore the EFI framebuffer driver echo efi-framebuffer.0 \ > /sys/bus/platform/drivers/efi-framebuffer/bind 2>/dev/null || true # Restore virtual console framebuffer bindings echo 1 > /sys/class/vtconsole/vtcon0/bind 2>/dev/null || true echo 1 > /sys/class/vtconsole/vtcon1/bind 2>/dev/null || true # Restart greetd so tuigreet is available for login systemctl start greetd.service } case "$OPERATION" in prepare) bind_vfio ;; release) unbind_vfio ;; esac ''; screamService = { Unit = { Description = "Scream audio receiver for Windows VM"; # Start after Pipewire is ready so the sink exists when scream connects After = [ "pipewire-pulse.service" ]; Requires = [ "pipewire-pulse.service" ]; }; Service = { # virbr0 is the default libvirt NAT bridge; scream listens for UDP audio # streams from Windows and forwards them to the PulseAudio/Pipewire sink ExecStart = "${pkgs.scream}/bin/scream -i virbr0 -o pulse"; Restart = "on-failure"; }; Install.WantedBy = [ "default.target" ]; }; in { options.local.winvm = { gpuPcieAddress = lib.mkOption { type = lib.types.str; description = "PCIe address of the NVIDIA GPU (e.g. 0000:01:00.0)."; }; gpuAudioPcieAddress = lib.mkOption { type = lib.types.str; description = "PCIe address of the NVIDIA GPU HDMI audio device (e.g. 0000:01:00.1)."; }; vmName = lib.mkOption { type = lib.types.str; default = "windows"; description = "Libvirt VM name that triggers the GPU handoff hook."; }; }; config = { boot.kernelParams = [ "intel_iommu=on" "iommu=pt" ]; boot.kernelModules = [ "vfio" "vfio_pci" "vfio_iommu_type1" ]; virtualisation.libvirtd = { enable = true; qemu = { package = pkgs.qemu_kvm; runAsRoot = true; swtpm.enable = true; }; }; programs.virt-manager.enable = true; environment.systemPackages = [ pkgs.virtio-win ]; users.users.zoty.extraGroups = [ "libvirtd" "kvm" ]; environment.etc."libvirt/hooks/qemu" = { source = hookScript; mode = "0755"; }; # Scream receiver runs as a user service so it has access to the # Pipewire session. It starts automatically after login and waits # for audio packets from the Windows VM on the libvirt bridge. home-manager.users.zoty.systemd.user.services.scream = screamService; }; }; }