feat(linux): Phase 1 of the QEMU D-Bus backend: capture, audio, input, multi-VM deployment #2
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,r5g6b5included, 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:
Disableshows a black frame and keeps capturing; a size change returnsreinit. The E2E test reboots the guest mid-stream with QMPsystem_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/MouseSetare blended into system-memory frames; on the GPU path the cursor goes to the existing GL cursor shader. - Console selection:
output_nameselects a console by id or label, also on multi-head guests. - DMABUF zero-copy (written, not verified end to end):
- The listener handles
ScanoutDMABUF,ScanoutDMABUF2andUpdateDMABUF. display_ram_tanddisplay_vram_tsplit as in kmsgrab;display_vram_tfillsegl::img_descriptor_tfor VAAPI, CUDA and Vulkan.y0_top=falseis handled (a GL blit flip ingraphics.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.
- The listener handles
#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.cppis unchanged. - One
AudioOutListenerregistration 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.Finiand 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.shnow passesaudiodev=snd0to-display dbus; without it QEMU has no Audio object.
#5 Phase 1c: keyboard, mouse, touch (REQ-INP-001..003)
platf::input()creates aqemu::input_twithcapture = qemu. Each platform input function has a small#ifdefdispatch; gamepads stay on libvirtualhid, which withcapture = qemucreates 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, inthird-party/qemu-keycodemapdbwith 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
RelMotionfrom the last visibleMouseSetposition. - 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_addressalso accepts:qmp:<socket>: QMPgetfd+add_client protocol=@dbus-display;libvirt:<domain>[?uri=]:libvirt.so.0loaded at run time,virDomainOpenGraphicsFDforp2p='yes', otherwise the bus address from the live XML; the default URI isqemu:///system.
packaging/linux/qemu:sunshine-qemu@.service(system and user variants), sysusers andexample.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.mdcovers raw QEMU, libvirt XML, virt-manager, systemd and troubleshooting;configuration.mdis 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(sharedgraphics.cpp) now honorsy_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
draftbecause 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.
- Not FFmpeg swresample: the build-deps FFmpeg has
- #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-clientinput::reset(). - QEMU exports
MultiTouchon every console, so a missing touch device can’t be detected.example.confsetsnative_pen_touch = disabledand the docs explain it, but a plaincapture = qemuconfig still advertises pen/touch, and touches are lost on VMs withoutvirtio-multitouch-pci, unlike #5’s mouse fallback. hscrollandunicodeare unsupported and log once.
- #6
- No new config key for p2p;
qemu_dbus_addressgains theqmp:andlibvirt:schemes. - Hook opt-in is the presence of a config file, not
<metadata>. Restart=on-failurealone doesn’t cover a VM that isn’t up yet, hencesunshine-qemu-wait-vm.- The mDNS change is in the Linux Avahi publisher only.
- The system unit keeps
HOME=<state dir>(driver caches underProtectSystem=strict) and usesCONFIGURATION_DIRECTORYfor appdata. - The helper is
packaging/linux/qemu/sunshine-qemu-port, notscripts/sunshine-qemu-port.sh.
- No new config key for p2p;
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=1with 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_invertblit, 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:
UpdateDMABUFis 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.
- The DMABUF E2E (
- #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_touchdefault 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).
virDomainOpenGraphicsFDwas only tested against a fakelibvirt.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