GPU Passthrough to a Bazzite VM: A Steam Box I Didn't Have to Wait For
Why Build This Instead of Waiting for a Steam Machine
Valve’s Steam Machine is real this time, but it isn’t shipping yet, and preordering one means waiting for hardware I’d have to find a place for. I already own a machine that can do the same job today: a ZimaCube Pro running Proxmox, with an NVIDIA RTX 2000 Ada sitting inside it that was, until this week, spending its life doing double duty as a Kubernetes GPU node for LLM inference and emulator workloads.
A dedicated GPU, a hypervisor that already supports PCI passthrough, and a controller are the entire hardware requirement for a living-room-capable Steam box. There was no reason to wait for Valve to ship a box when Proxmox could carve one out of hardware already racked and running. The plan: free the GPU from its Kubernetes duty, hand it to a dedicated virtual machine running Bazzite (an immutable, SteamOS-like Fedora Atomic image built for exactly this use case), and get a controller working over USB passthrough so it plays like a console, not a remote desktop session.
The GPU move was the easy part. Getting a wireless Xbox controller to actually work through PCI passthrough is where most of the real engineering happened.
Architecture: Two Passthrough Devices, One VM
The gaming VM (bazzite-gaming, 6 vCPU, 16GB RAM, q35 machine type, OVMF/UEFI) gets two PCI devices handed to it directly, and nothing else about its I/O touches the host:
| Device | PCI passthrough target | Why whole-device instead of partial |
|---|---|---|
| GPU | NVIDIA RTX 2000 Ada (GPU + audio functions) | Standard vfio-pci GPU passthrough; vga: none on the VM since the passed-through card is the only display output now — Proxmox’s own noVNC console is blank by design, not broken. |
| USB controller | The host’s entire Alder Lake PCH USB 3.2 xHCI controller (cleanly isolated in its own IOMMU group) | Passing through the whole controller, rather than individual USB devices one at a time, means anything plugged into the box’s regular USB-A ports natively belongs to the VM — no per-device USB passthrough juggling every time a new peripheral shows up. |
The GPU used to belong to a Kubernetes worker node. Freeing it meant migrating that node’s GPU workloads (an LLM proxy, a local LLM deployment, and a few emulator apps) to a different, already-24x7 node first, then draining and permanently retiring the old node. That migration is its own story — PV binding gotchas, KEDA autoscaler surprises — and out of scope here, but it’s the reason the GPU was available to redeploy in the first place.
flowchart TD
subgraph Host["Proxmox Host (ZimaCube Pro)"]
GPU["NVIDIA RTX 2000 Ada<br/>(0000:07:00, GPU + audio)"]
USBC["Alder Lake PCH USB xHCI controller<br/>(0000:00:14.0, IOMMU group 9)"]
OtherUSB["Thunderbolt controller<br/>(separate IOMMU group, untouched)"]
end
subgraph VM["VM 136 — bazzite-gaming<br/>(q35, OVMF, vga: none)"]
Bazzite["Bazzite<br/>(Fedora Atomic / SteamOS-like)"]
Steam["Steam + games<br/>(dedicated 500GB library disk)"]
Nvidia["NVIDIA driver<br/>(verified via nvidia-smi)"]
XoneDriver["xone driver<br/>(Xbox Wireless Adapter support)"]
end
subgraph Peripherals["Physical peripherals — must be on the passed-through ports"]
Xbox["Xbox Wireless Adapter<br/>dongle (045e:02fe)"]
KBM["Keyboard / mouse<br/>(USB receiver)"]
end
GPU -->|vfio-pci passthrough| Nvidia
Nvidia --> Steam
USBC -->|"vfio-pci passthrough,<br/>whole controller"| Bazzite
Xbox -. plugged into passed-through port .-> USBC
KBM -. plugged into passed-through port .-> USBC
XoneDriver --> Xbox
OtherUSB -. "not passed through,<br/>peripherals here won't work" .-> Host
style GPU fill:#4c8bf5,color:#fff
style USBC fill:#e0a638,color:#fff
style OtherUSB fill:#888,color:#fff
One consequence of whole-controller passthrough that only showed up later: the host’s other USB ports — the ones wired to a separate Thunderbolt controller — are simply not available to the VM at all. Any peripheral has to physically go into the ports tied to the passed-through xHCI controller, or it doesn’t exist as far as Bazzite is concerned.
The other consequence is a real operational risk, not just an inconvenience: this specific USB controller has no working VFIO reset mechanism (Cannot reset device, no available reset mechanism on every VM start/stop). It hasn’t caused an outage on its own, but a later, unrelated reconfiguration of the VM triggered a genuine kernel-level hang on the host itself — a stuck cleanup process trying to reset this same controller, wedged in an unkillable state that even a graceful reboot couldn’t clear. Recovering it took a physical power cycle. That’s the tradeoff of whole-controller passthrough: it’s far more convenient day-to-day than per-device passthrough, but a reset-incapable controller turns “stop this VM” into an occasional live wire. Worth knowing before choosing the same approach.
The Controller Problem: An Xbox Dongle That Wouldn’t Sync
With the USB controller passed through, the Xbox Wireless Adapter enumerated on the bus — lsusb saw it — but the controller itself never paired. The dongle’s own driver (xone, the community driver for Xbox Wireless hardware on Linux) was failing during radio initialization every time:
1
xone-dongle 9-3.4:1.0: xone_dongle_fw_load: init radio failed: -110
-110 is ETIMEDOUT. Not a missing-firmware problem (the bundled firmware loaded fine) and not CPU scheduling jitter (reproduced identically at 2 and 4 vCPUs) — something about running behind PCI passthrough was making one specific step in the radio bring-up sequence too slow to finish inside its allotted window.
sequenceDiagram
participant Dongle as Xbox Wireless Adapter
participant Driver as xone_dongle driver
participant Radio as mt76 radio-init routine
Note over Dongle,Radio: Stock driver — fails under passthrough
Dongle->>Driver: USB enumerate (045e:02fe)
Driver->>Radio: begin radio init sequence
loop register read/write, crystal cal,<br/>radio cal, channel init
Radio->>Dongle: USB control/bulk transfer
Note right of Dongle: hardcoded 1000ms timeout<br/>per transfer (XONE_MT_USB_TIMEOUT)
end
Dongle--xRadio: transfer exceeds 1000ms under VFIO passthrough
Radio--xDriver: ETIMEDOUT (-110)
Driver--xDongle: init radio failed — controller never pairs
Note over Dongle,Radio: Patched driver — succeeds
Dongle->>Driver: USB enumerate (045e:02fe)
Driver->>Radio: begin radio init sequence (timeout raised to 5000ms)
loop same sequence, more headroom per transfer
Radio->>Dongle: USB control/bulk transfer
end
Dongle->>Radio: all transfers complete within window
Radio->>Driver: radio init succeeded
Driver->>Dongle: dongle ready — sync button now pairs controller
The Fix
- Patch the timeout. Cloned
medusalix/xone, changedXONE_MT_USB_TIMEOUTfrom 1000ms to 5000ms intransport/mt76.c, and built the module against the running kernel’skernel-develpackage. - Install it somewhere that survives an ostree image. Bazzite is immutable at
/usr—/usr/lib/modulesisn’t writable, so the built.kofiles live in/etc/xone-patched/instead, which is part of the writable, persistent side of the image. - Hit an SELinux wall with zero audit trail.
insmodon the patched modules failed with a plain “Permission denied,” even as root, andausearch -m avcturned up nothing. The cause: files placed in/etcdefault to theetc_tSELinux context, which blocks module loading — silently, with no AVC record to point at it. Fixed with:1 2
chcon -t modules_object_t /etc/xone-patched/*.ko semanage fcontext -a -t modules_object_t '/etc/xone-patched(/.*)?\.ko'The
semanagecall makes the label survive a filesystem relabel;chconalone wouldn’t have. - Point module lookups at the patched build, via
/etc/modprobe.d/xone-patched.confinstalldirectives forxone_dongle,xone_gip,xone_gip_gamepad, and related module names. - Beat the boot-time race. The kernel’s automatic module autoload — triggered the instant it sees the USB device — fires before
modprobe.doverrides reliably take effect. A plain reboot still loaded the stock (unpatched) driver. The fix is a small systemd unit that runs afterlocal-fs.target: explicitlymodprobes the patched modules, then runsudevadm trigger --subsystem-match=usb --action=addto force the already-connected dongle to re-probe against the driver that’s now actually loaded.
Verified clean across multiple full reboots: modules load automatically, radio init succeeds with no errors, and the controller pairs via its normal sync button — confirmed with live analog stick and button input via evtest, not just “no error in the log.”
If this VM’s disk is ever rebuilt from a fresh Bazzite image, none of this survives — the whole fix lives in /etc, which doesn’t carry over. Worth upstreaming the timeout change to medusalix/xone at some point so a wider timeout just ships by default.
GPU Passthrough, Verified
hostpci1 on the VM points at the RTX 2000 Ada; vga is set to none so the passed-through card owns the only display output. Confirmed immediately after boot:
1
2
3
$ nvidia-smi
NVIDIA RTX 2000 Ada Generation
Driver Version: 610.43.03
Plasma’s login greeter was already rendering through the passed-through card by the time that check ran, and the host side confirmed a clean, single-owner vfio-pci binding on the GPU with no conflicting drivers attached.
A second, 500GB disk was hot-attached for the games library — unlike the PCI passthrough devices, SCSI disks support hot-plug cleanly, no VM restart required. Steam needs to be told about the new mount point manually (Settings → Storage → Add Drive) — mounting the disk at the OS level doesn’t make Steam aware of it as an install target on its own.
Results
- A fully working Steam box, running today, on hardware already sitting in a rack — no Valve preorder, no waiting on a ship date.
- A repurposed GPU that was previously locked into always-on Kubernetes duty now does double duty on its own terms: gaming when it’s wanted, still available for the LLM/emulator workloads that moved to another node.
- A wireless Xbox controller that pairs over its normal sync button on every boot, with the actual root cause (a too-tight USB transfer timeout under PCI passthrough) fixed at the driver level rather than worked around.
- One real, known operational risk accepted deliberately: whole-controller USB passthrough is far more convenient than per-device passthrough, but this controller’s missing VFIO reset path means a future reconfiguration of the VM could wedge the host again. That’s a tradeoff worth making for a couch gaming box, not one I’d take lightly on anything load-bearing.