Post

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

  1. Patch the timeout. Cloned medusalix/xone, changed XONE_MT_USB_TIMEOUT from 1000ms to 5000ms in transport/mt76.c, and built the module against the running kernel’s kernel-devel package.
  2. Install it somewhere that survives an ostree image. Bazzite is immutable at /usr/usr/lib/modules isn’t writable, so the built .ko files live in /etc/xone-patched/ instead, which is part of the writable, persistent side of the image.
  3. Hit an SELinux wall with zero audit trail. insmod on the patched modules failed with a plain “Permission denied,” even as root, and ausearch -m avc turned up nothing. The cause: files placed in /etc default to the etc_t SELinux 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 semanage call makes the label survive a filesystem relabel; chcon alone wouldn’t have.

  4. Point module lookups at the patched build, via /etc/modprobe.d/xone-patched.conf install directives for xone_dongle, xone_gip, xone_gip_gamepad, and related module names.
  5. Beat the boot-time race. The kernel’s automatic module autoload — triggered the instant it sees the USB device — fires before modprobe.d overrides reliably take effect. A plain reboot still loaded the stock (unpatched) driver. The fix is a small systemd unit that runs after local-fs.target: explicitly modprobes the patched modules, then runs udevadm trigger --subsystem-match=usb --action=add to 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.
This post is licensed under CC BY 4.0 by the author.