Gaming on Qubes 4.3 with AMD GPU passthrough while keeping keyboard/mouse in dom0

Gaming on Qubes 4.3 with AMD GPU passthrough while keeping keyboard/mouse in dom0

First of all, a thank you to the Qubes OS developers and community.

I’ve been using Qubes quite a lot, and one of the things I really like about it is that it lets me keep very different workloads isolated without giving up my normal desktop environment. Gaming obviously isn’t the main use case for Qubes, but I wanted to see how far I could take it without turning my gaming VM into a completely separate desktop with its own keyboard and mouse.

I eventually got a setup working that I’m very happy with:

  • Intel iGPU drives my normal Qubes/dom0 desktop and two monitors.

  • AMD RX 9060 XT is passed through to a StandaloneVM.

  • The game renders directly to a monitor connected to the RX 9060 XT.

  • Keyboard and mouse remain in dom0.

  • A gamepad is passed individually from sys-usb.

  • Normal Qubes windows from the gaming VM still work through the GUI agent.

  • Audio still goes through Qubes audio.

There were a few non-obvious problems along the way, so I’m documenting the complete setup here in case it helps someone else.

Setup: Qubes 4.3.1, dom0 Fedora 41 / XFCE, i5-14600K with the Intel iGPU driving two monitors for dom0, AMD RX 9060 XT (RDNA4) + its HDMI audio function attached to a StandaloneVM game running Fedora 43, its own kernel 7.1, qubes-gui-agent 4.3.19, Mesa 25.3.6 / RADV.

The game I used for testing was Assassin’s Creed Black Flag Resynced through Hydra Launcher, umu-run, UMU-Proton 10 and DXVK.

Why I stopped trying to play inside a normal Qubes window

Initially I wanted the game to behave like any other Qubes application: render on the passed-through AMD GPU but appear inside a normal Qubes window on the dom0 desktop.

Disabling software rendering does make this work partially:

qvm-service game software-rendering off

Inside the VM, glxinfo -B and vulkaninfo --summary correctly reported radeonsi / RADV, so the RX 9060 XT was really doing the rendering.

But I ran into two problems.

1. White window when moving the game between monitors

Dragging the GPU-rendered window to my second monitor made the entire window turn solid white. Dragging it back made it repaint normally.

Inside the VM I had:

~/.local/share/xorg/Xorg.0.log:
(EE) can't dump window without grant table allocation

journalctl -u qubes-gui-agent:
Failed to get window dump for window 0x1c00001

The virtual screen size and VideoRam were correct, and there was no grant-table exhaustion.

Looking through the Qubes GUI agent code, this appears related to the behavior introduced around commit 9a04fad, "Do not allocate grants for framebuffer by default".

When glamor initializes using the real passed-through GPU render node, the screen pixmap is apparently not grant-backed. The Qubes GUI agent then cannot export that window correctly to dom0, resulting in the white window.

I’m planning to report this separately, because it may still be useful to improve the seamless GPU-passthrough case.

2. Performance is inherently limited through the Qubes window

Even when the window displays correctly, this path is not ideal for high-FPS gaming.

The Qubes dummy X driver uses shared CPU memory for the window pixmaps, so frames rendered by the GPU have to be copied/read back and then transferred to dom0.

For normal accelerated applications this can still be useful, but for a game pushing many frames per second it adds exactly the kind of copy/readback path you normally want to avoid.

One additional thing worth checking:

qvm-prefs game memory

Once a PCI device is attached, normal memory ballooning is no longer available. My gaming VM was initially stuck at 4 GB and started swapping, which made the apparent GPU-performance problem even worse.

At that point I followed the general approach suggested by the Qubes Create a Gaming HVM guide: let the passed-through GPU drive a monitor directly.

Direct monitor output from the passed-through GPU

Physical setup

My left monitor now has two cables:

DisplayPort -> Intel iGPU -> dom0
HDMI        -> RX 9060 XT -> game qube

I simply switch the input using the monitor OSD when I want to play.

The nice part is that dom0’s monitor configuration never changes. From Qubes’ point of view, my normal desktop remains exactly as it was.

During setup I initially thought the AMD DRM stack was broken because:

cat /sys/class/drm/card1-HDMI-A-1/status

reported:

disconnected

and:

cat /sys/class/drm/card1-HDMI-A-1/edid | wc -c

returned 0.

It turned out to be much simpler: the HDMI connection/input was not properly detected. Once the cable was correctly seated and the monitor input selected, EDID appeared normally.

Running a second X server inside the gaming qube

I wanted to keep the Qubes GUI agent working.

That means I did not remove or replace the existing X server on :0.

Instead:

  • :0 remains the Qubes dummy display used by qubes-gui-agent.

  • :1 is a second X server using the physical AMD GPU and physical HDMI output.

This gives me both at the same time:

DISPLAY=:0 -> normal Qubes windows
DISPLAY=:1 -> physical gaming monitor

Install the AMD Xorg driver and a minimal window manager:

sudo dnf install xorg-x11-drv-amdgpu openbox

I chose Openbox because it starts without a first-run wizard. This matters because :1 initially has no keyboard attached to it.

My Xorg configuration for :1 uses:

Driver "amdgpu"

and the PCI BusID of the GPU.

One thing to watch out for: the PCI BusID seen inside the VM can change between boots, so my wrapper discovers it from lspci and generates the configuration at runtime.

I also use:

Option "AutoAddGPU" "off"

so this X server does not try to claim the Qubes dummy device.

I enabled TearFree and VariableRefresh as well.

The second server is then started as root:

Xorg :1 -config /opt/xorg-x1.conf -configdir /dev/null -noreset -ac &

and the display mode is configured with:

DISPLAY=:1 xrandr --output HDMI-A-0 --mode 1920x1080 --rate 144

At this point the monitor attached to the RX 9060 XT is a completely normal AMD-driven X display.

Launching games on :1

One trap here was Hydra Launcher itself.

Running:

DISPLAY=:1 hydralauncher

did not move the launcher to the other display.

Electron applications are often single-instance, so this just wakes the already-running Hydra instance on :0.

Instead, I launch the actual game directly with umu-run.

To determine the exact command Hydra uses, I first start the game normally and inspect the running process:

/proc/<pid>/cmdline
/proc/<pid>/environ

Then I close that copy and reproduce the same environment with DISPLAY=:1.

Conceptually it looks like this:

export DISPLAY=:1
export PULSE_SERVER=unix:/run/user/1000/pulse/native

export WINEPREFIX=~/.config/hydralauncher/wine-prefixes/<id>
export GAMEID=umu-<id>
export PROTONPATH=~/.local/share/Steam/compatibilitytools.d/UMU-Proton-10.0-4
export MANGOHUD=1

python3 /opt/Hydra/resources/umu-run "/path/to/Game.exe"

This launches Proton/DXVK directly on the physical AMD X server.

Audio still goes through the normal Qubes audio path because of:

PULSE_SERVER=unix:/run/user/1000/pulse/native

MANGOHUD=1 also works. Pressure Vessel imported the host implicit Vulkan layer correctly in my setup.

One important detail: don’t run the same Wine prefix simultaneously on :0 and :1. Close the original copy first.

Debugging a black screen without moving keyboard/mouse to the VM

One thing I found surprisingly useful was taking screenshots of :1 from my normal Qubes terminal.

For example:

DISPLAY=:1 ffmpeg \
    -f x11grab \
    -video_size 1920x1080 \
    -i :1 \
    -frames:v 1 \
    /tmp/x1.png

This let me discover that one apparent “black screen” was not actually black at all.

The game had started correctly and was sitting behind two dialogs:

  • Ubisoft’s unsupported-video-driver warning, harmless with RADV in this case.

  • An online-service error.

Since I was deliberately keeping keyboard/mouse in dom0, I dismissed the dialog remotely:

DISPLAY=:1 xdotool key --window <id> Return

There was also one genuinely black-monitor incident after switching inputs/modes. That one was simply the monitor itself getting confused; power-cycling the monitor fixed it.

Passing only the gamepad through sys-usb

I wanted keyboard and mouse to remain under dom0 control, so instead of passing an entire USB controller I attached only the gamepad from sys-usb.

Initially:

qvm-device usb attach game sys-usb:2-5.4

failed with the rather mysterious:

Device attach failed:

with nothing after the colon.

The reason was that my StandaloneVM uses Fedora’s own kernel, and the required USB/IP pieces were not installed.

Inside the gaming VM:

sudo dnf install usbip kernel-modules-extra

After that, USB attachment worked normally.

To make the assignment persistent:

qvm-device usb assign game sys-usb:<port>

On Qubes 4.3, assign already causes the device to attach when the qube starts; there is no --auto-attach option.

Proton could see the controller but could not open it

The next problem was permissions.

The device appeared as something like:

/dev/input/eventN
root:input 0660

A session launched through qrexec does not have a normal local logind seat, so the usual uaccess ACL was not being granted.

I added the normal user to the input group:

sudo usermod -aG input user

and added a udev rule for my Xbox controller:

SUBSYSTEM=="input", ATTRS{idVendor}=="045e", MODE="0660", GROUP="input"

There is one important trap here.

After usermod, opening a new shell with:

su - user

worked, but applications launched through qvm-run still had the old group list.

The existing qrexec user session had been created before the group change.

The fix was simply:

reboot the qube.

After rebooting, Proton could access the gamepad normally.

Final result

The setup I ended up with is:

Intel iGPU
  -> dom0
  -> normal Qubes desktop
  -> keyboard + mouse

RX 9060 XT
  -> PCI passthrough
  -> game StandaloneVM
  -> Xorg :1
  -> physical HDMI monitor

Qubes dummy Xorg :0
  -> qubes-gui-agent
  -> normal game-VM windows still appear in dom0

sys-usb
  -> individual gamepad
  -> game VM

Qubes audio
  -> game audio remains available through PulseAudio

For me this is a nice compromise.

I still get the isolation and desktop workflow that made me choose Qubes in the first place, while the game itself gets a direct display path from the passed-through GPU instead of sending every frame through the Qubes GUI protocol.

Everything required for the setup survives reboot:

  • packages

  • udev rule

  • user group

  • USB assignment

  • configuration and wrapper scripts under /opt

I eventually made a few small Bash wrappers that:

  • detect the AMD PCI BusID

  • generate the :1 Xorg configuration

  • start Xorg/Openbox

  • select a game from a small config

  • launch it through umu-run

  • stop :1 when the game exits

  • add a new Hydra game by capturing the command/environment of a currently running instance

They’re only around 80 lines total. If anyone is interested, I’m happy to clean them up and post them here as well.

And again, thanks to the Qubes developers and everyone writing the existing GPU/USB guides and forum posts. A lot of this setup came from combining pieces scattered across those discussions.

Sources / threads that helped

3 Likes

Thanks for the comprehensive guide!

1 Like