feat(linux): Phase 1 of the QEMU D-Bus backend: capture, audio, input, multi-VM deployment #2

open colechristensen cole.christensen@gmail.com wants to merge phase-1 into main

Phase 1 of #1: stream a QEMU VM to Moonlight through Sunshine’s QEMU D-Bus backend (capture = qemu) with video, audio, input and one Sunshine per VM. Builds on Phase 0 (#2). 28 commits, one per coherent piece (TDD, requirement-linked tests).

No issue is closed by this PR. Every issue has a Done-when item that can’t be checked on the WSL2 development host (no /dev/dri, no GUI Moonlight client, no libvirt). See “Open items” for exactly what is left.

Refs #3 Refs #4 Refs #5 Refs #6

Summary per issue

#3 Phase 1a: qemu capture source (REQ-CAP-002..006, REQ-NFR-001)

  • Pixel formats: a generic pixman decoder (pixel_format.{h,cpp}) covers every packed 16/24/32 bpp format QEMU emits, r5g6b5 included, with copy/swap fast paths.
  • Damage-driven capture: the capture loop waits on the frame store’s condition variable and pushes as soon as damage arrives, at most once per client frame interval. With no damage it returns once per interval so the pipeline can stop it. This takes shm + NVENC 1080p60 host latency from p95 ~20 ms to ~9 ms (table below).
  • Mode changes and reboot: Disable shows a black frame and keeps capturing; a size change returns reinit. The E2E test reboots the guest mid-stream with QMP system_reset. The boot-sector guest now shows text mode for 1 s first, so each reboot changes the display size twice every time.
  • Cursor: CursorDefine/MouseSet are blended into system-memory frames; on the GPU path the cursor goes to the existing GL cursor shader.
  • Console selection: output_name selects a console by id or label, also on multi-head guests.
  • DMABUF zero-copy (written, not verified end to end):
    • The listener handles ScanoutDMABUF, ScanoutDMABUF2 and UpdateDMABUF.
    • display_ram_t and display_vram_t split as in kmsgrab; display_vram_t fills egl::img_descriptor_t for VAAPI, CUDA and Vulkan.
    • y0_top=false is handled (a GL blit flip in graphics.cpp; a crop-then-flip in the Vulkan shader).
    • The render node QEMU uses is checked against adapter_name.
    • Software encoders read linear RGB DMABUFs back through mmap.

#4 Phase 1b: guest audio out (REQ-AUD-001)

  • With capture = qemu, platf::audio_control() returns a QEMU audio control that reports a fake “qemu” sink; src/audio.cpp is unchanged.
  • One AudioOutListener registration per VM session (QEMU accepts only one per bus client) feeds a mixer per capture:
    • any sample format to float, with volume and mute applied;
    • a polyphase windowed-sinc resampler to 48 kHz;
    • remix to stereo, 5.1 or 7.1, and mixing of several guest streams.
  • Buffering: 200 ms, dropping the oldest audio. Underruns give silence with continuous, otherwise a timeout. Fini and disconnect are handled, and guest volume is kept across streaming sessions.
  • New Linux test guest (tests/e2e/qemu/guest/linux): a pinned Alpine kernel plus a reproducible initramfs that plays a 1 kHz tone and logs every evdev event to serial.
  • run_vm.sh now passes audiodev=snd0 to -display dbus; without it QEMU has no Audio object.

#5 Phase 1c: keyboard, mouse, touch (REQ-INP-001..003)

  • platf::input() creates a qemu::input_t with capture = qemu. Each platform input function has a small #ifdef dispatch; gamepads stay on libvirtualhid, which with capture = qemu creates no idle host keyboard, mouse, touch or pen devices.
  • Keymap: Windows VK to Linux KEY_* (ported from libvirtualhid, MIT, plus media/IME keys), then to QEMU qnum (unmodified keymap-gen output from the keycodemapdb revision QEMU 11.1.1 pins, in third-party/qemu-keycodemapdb with provenance).
  • Mouse:
    • Absolute input is scaled to the console.
    • Relative input on an absolute guest moves a tracked pointer.
    • Absolute input on a relative guest becomes RelMotion from the last visible MouseSet position.
    • Scroll uses 120 units per wheel step and keeps the remainder.
  • Touch: slots are allocated below MaxSlots; pens drive the mouse.
  • All calls go through an ordered queue on a separate GLib thread, so Sunshine never blocks on QEMU. Everything the inputs hold is released when streaming stops.

#6 Phase 1d: multi-VM deployment (REQ-DEP-001, REQ-DEP-002)

  • libvirt investigation: answered on #6 before coding. System libvirt’s per-domain bus only admits the domain’s own user (and root from 11.2), so peer-to-peer is required.
  • mDNS: the service name now follows sunshine_name (UTF-8, up to 63 bytes), so two instances don’t collide. The default (host name) is unchanged.
  • qemu_dbus_address also accepts:
    • qmp:<socket>: QMP getfd + add_client protocol=@dbus-display;
    • libvirt:<domain>[?uri=]: libvirt.so.0 loaded at run time, virDomainOpenGraphicsFD for p2p='yes', otherwise the bus address from the live XML; the default URI is qemu:///system.
  • packaging/linux/qemu:
    • sunshine-qemu@.service (system and user variants), sysusers and example.conf;
    • sunshine-qemu-wait-vm (ExecStartPre, because Sunshine checks the display only once at startup);
    • sunshine-qemu-port (base ports 100 apart, --check/--write);
    • libvirt-hook-qemu (opt-in via /etc/sunshine-qemu/<domain>.conf, systemctl --no-block).
  • Docs: docs/qemu.md covers raw QEMU, libvirt XML, virt-manager, systemd and troubleshooting; configuration.md is updated.

Review

A review pass on the whole branch found 20 issues. All were checked against the code (and QEMU 11.1.1’s source), and all were fixed test-first where reproducible (commits a1becb05..2398a0ac, details in JOURNAL.md).

Deviations from the issue text

  • #3
    • Pacing follows each push instead of a fixed grid. With no damage the loop returns “nothing captured” once per interval, and the encoder’s minimum FPS repeats static frames.
    • The render node is read from rendernode= on QEMU’s /proc/<pid>/cmdline, because QEMU doesn’t report it over D-Bus. Through libvirt, or when the pid is unknown, QEMU’s default node is assumed.
    • sws_t::load_vram (shared graphics.cpp) now honors y_invert; only the qemu backend sets it on the GL path.
    • Software encoders read DMABUFs back via mmap, which the issue didn’t ask for.
    • QEMU 8.2 never sends the VGA text-mode scanout, so its reboot E2E uses E2E_RESET_MIN_DISPLAYS=1.
    • REQ-NFR-001 stays draft because the DMABUF latency can’t be measured here.
  • #4
    • Not FFmpeg swresample: the build-deps FFmpeg has CONFIG_SWRESAMPLE 0, and the system libswresample clashes with the static libavutil. A tested polyphase resampler is used instead.
    • One listener registration per VM session instead of one per mic_t.
    • Underruns follow the Windows behavior (silence with continuous, otherwise a timeout).
    • Guest channel order is assumed to be WAVE/ALSA.
  • #5
    • The VK table is ported, not linked (it is private to libvirtualhid’s uhid backend).
    • Release-all is tied to platf::streaming_will_stop() plus Sunshine’s existing per-client input::reset().
    • QEMU exports MultiTouch on every console, so a missing touch device can’t be detected. example.conf sets native_pen_touch = disabled and the docs explain it, but a plain capture = qemu config still advertises pen/touch, and touches are lost on VMs without virtio-multitouch-pci, unlike #5’s mouse fallback.
    • hscroll and unicode are unsupported and log once.
  • #6
    • No new config key for p2p; qemu_dbus_address gains the qmp: and libvirt: schemes.
    • Hook opt-in is the presence of a config file, not <metadata>.
    • Restart=on-failure alone doesn’t cover a VM that isn’t up yet, hence sunshine-qemu-wait-vm.
    • The mDNS change is in the Linux Avahi publisher only.
    • The system unit keeps HOME=<state dir> (driver caches under ProtectSystem=strict) and uses CONFIGURATION_DIRECTORY for appdata.
    • The helper is packaging/linux/qemu/sunshine-qemu-port, not scripts/sunshine-qemu-port.sh.

Verification (final run on 2398a0ac, 2026-09-12)

Host: Ubuntu 24.04 on WSL2, 20 cores, KVM, RTX 4090 via /dev/dxg (no /dev/dri), gcc-14, CUDA 13.2. QEMU 11.1.1 (shared memory map) and distro QEMU 8.2.2 (D-Bus message path). All VMs ran under KVM.

Check Result
Clean build cmake-build-debug (SUNSHINE_ENABLE_QEMU=ON, tests) OK, 0 compiler warnings
Clean build cmake-build-noqemu (SUNSHINE_ENABLE_QEMU=OFF) OK
cmake-build-release-cuda (Release, NVENC) and the E2E client OK
Full test_sunshine, ON 715 tests, 698 passed, 17 skipped, 0 failed tests; exit 1 only from the known AudioTest/MouseHIDTest/EncoderTest suite setup (same on clean upstream on this host)
Full test_sunshine, OFF (REQ-CMP-001) 570 tests, 556 passed, 14 skipped, 0 failed tests; same three suite setups; baseline plus the new mDNS test
Qemu*:Mdns*, --gtest_repeat=3 150 passed, 3 skipped, in every run. The skips need /dev/dri or /dev/udmabuf: QemuCaptureTest.VaapiRequiresQemuToRenderOnTheEncoderGpu, QemuDmabufGpuTest.ImportsCropsAndFlipsBottomUpScanout, QemuDmabufGpuTest.VulkanCropsAndFlipsBottomUpScanoutLikeQemu
clang-format (upstream lint version) on 45 changed C/C++ files clean
Doxygen 1.18.0, upstream doxyconfig, FAIL_ON_WARNINGS exit 0, 0 warnings
tests/packaging/linux/qemu/test_sunshine_qemu.sh with SUNSHINE_QEMU_PKG_REQUIRE_TOOLS=1 16/16 ok: hook with a fake systemctl, wait-vm, port helper, systemd-analyze verify (systemd 255, system and --user, with stub binaries), shellcheck. CTest sunshine_qemu_packaging passes
shellcheck on tests/e2e/qemu/*.sh only the SC2329 info note that is already on main
E2E shm pattern, software, 1280x800@30 PASS on QEMU 11.1.1 and 8.2.2
E2E shm pattern over QMP p2p (E2E_TRANSPORT=qmp) PASS on QEMU 11.1.1 and 8.2.2
E2E NVENC 1920x1080@60, 600 frames, Release, QEMU 11.1.1 PASS 3/3 (latency below)
E2E audio (E2E_AUDIO=1) PASS on QEMU 11.1.1 (999.997 Hz on both channels, RMS 0.353, 618 packets, 0 lost, 0 silent blocks) and on 8.2.2 (999.997 Hz, RMS 0.351, 0 silent); E2E_SESSIONS=3 on 11.1.1: 3/3 streams passed
E2E input absolute (virtio-tablet/keyboard) PASS on 11.1.1 and 8.2.2: keys 30/42 in order, ABS at the center then center + (100, 50), BTN_LEFT, REL_WHEEL
E2E input relative (PS/2, vmport=off) PASS on 11.1.1 and 8.2.2: REL_X sum 120, REL_Y sum -75, no ABS, click, wheel
E2E input touch (virtio-multitouch) PASS on 11.1.1
E2E reboot during stream (E2E_RESET_AFTER_FRAMES=60) PASS on 11.1.1 (4 displays, 38 picture changes after the reset, max gap 111 ms) and on 8.2.2 with E2E_RESET_MIN_DISPLAYS=1 (34 changes, max gap 61 ms). Release + NVENC 1080p60 on 11.1.1: PASS (3 displays, 94 changes, max gap 151 ms)
multi_instance.sh: two VMs, two Sunshine instances, two clients at once PASS: both VMs on buses (11.1.1); VM 2 over QMP (11.1.1); E2E_STATE_LAYOUT=unit (11.1.1); both on buses (8.2.2). Checks mDNS names in a sandboxed Avahi, port ranges, per-instance pairing, no PulseAudio sinks, no writes to the shared HOME
E2E GL/DMABUF (E2E_GL=1) SKIP (exit 77): no DRM render node on WSL2
anvil requirement status passed (17 requirements; only REQ-AUD-002 (deprecated) and REQ-INP-004 (Phase 2 gamepad, #7) have no tests). Every @tag requirements in the QEMU and mDNS gtests matches a link (163 pairs, none missing either way)

Latency (REQ-NFR-001, host processing time from the QEMU display call to the encoded frame sent)

Build / path p50 p95
Phase 0, fixed capture tick, shm + NVENC 1080p60 (from #2) 11.1 ms 20.4 ms
Before damage-driven capture, re-measured in Phase 1a (5 runs) 10.9–13.7 ms 17.8–21.8 ms
This PR, shm + NVENC 1080p60, QEMU 11.1.1, 600 frames (3 runs, final verification) 4.2 ms 9.1 / 9.3 / 9.1 ms
This PR, reboot run, NVENC 1080p60 3.7 ms 9.5 ms
Phase 1a on a quiet host (earlier runs, same path) 3.8–4.0 ms 4.4–5.4 ms
Phase 1d, two instances streaming 1080p60 NVENC at once 3.7 ms 10.8–10.9 ms

The #3 target (shm + hardware encoder p95 ≤ 12 ms at 1080p60) is met. The DMABUF latency target (p95 ≤ 10 ms) is unmeasured.

Open items (why no Closes)

  • #3
    • The DMABUF E2E (E2E_GL=1 with VAAPI and NVENC) has not run. It needs a native Linux host with /dev/dri; record GPU, driver and distro there.
    • The EGL import, the y_invert blit, the GL cursor path, the Vulkan crop/flip shader and the VAAPI/CUDA/Vulkan vram encode devices are compiled and descriptor-tested only. The 3 GPU tests above have never run.
    • DMABUF latency is unmeasured, so REQ-NFR-001 stays draft.
    • Possible tearing: UpdateDMABUF is acknowledged right away rather than after the encoder has read the buffer.
    • The cursor is only unit-tested; the test guests have no hardware cursor.
  • #4
    • The manual check with a real Moonlight client has not been done (no GUI client on this host).
    • Multichannel guest streams, rates other than 44.1 kHz and non-s16 formats are unit-tested only.
  • #5
    • The manual typing/mouse check from a real Moonlight client, in PS/2 and virtio-tablet guests, has not been done.
    • Absolute input on a relative guest is unit-tested only (needs a guest with a hardware cursor).
    • The native_pen_touch default deviation described above.
    • QEMU limitation: with virtio-multitouch-pci, mouse buttons go to the touch device and the wheel is lost.
    • Keyboard LED state isn’t synchronized.
  • #6
    • The manual virt-manager check (a domain streams with no manual Sunshine launch) has not been done (no libvirt here).
    • virDomainOpenGraphicsFD was only tested against a fake libvirt.so.0; it needs real libvirt ≥ 8.4 under AppArmor (Ubuntu) and SELinux (Fedora).
    • The units haven’t run under a real systemd (only systemd-analyze verify), and the polkit rule in the docs is untested.
    • No real Moonlight has discovered two instances over real mDNS.
    • A second p2p client (for example virt-viewer) takes the display away from Sunshine; this is documented, not handled.
  • General
    • No CI config runs any of this yet (#9).
    • Coverage wasn’t measured with gcov.
    • shellcheck reports two warnings in the guest’s busybox init (SC2010 and SC2046, where word splitting is intended); it isn’t Sunshine code.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb

Created Sep 13, 2026 at 02:38 UTC