Phase 0: QEMU D-Bus display capture backend and end-to-end harness #1

merged colechristensen cole.christensen@gmail.com wants to merge issue-2-phase0 into main

Closes #2

Phase 0 of #1: a Sunshine capture backend for QEMU’s D-Bus display, the end-to-end harness every later phase keeps green, and the first latency numbers.

What’s in it

Backend (src/platform/linux/qemu/, built when SUNSHINE_ENABLE_QEMU=ON)

  • session.{h,cpp}: qemu::session_t connects to the bus where QEMU owns org.qemu, reads the VM and console properties, and registers peer-to-peer listeners. QEMU is the authentication server on that socket. The listener advertises org.qemu.Display1.Listener.Unix.Map, so QEMU shares the framebuffer instead of copying it over D-Bus.
  • frame_store.{h,cpp}: rebuilds the guest framebuffer (BGRX) from Scanout/Update and ScanoutMap/UpdateMap, for the 32 bpp pixman formats.
  • capture.cpp: display_t for system-memory images, plus VAAPI/CUDA upload through the existing RAM encode path. Frames are pushed only on damage and stamped with the time QEMU’s call arrived. A resize or a VM that goes away returns reinit.
  • source::QEMU in misc.cpp. It’s used only when capture = qemu, never auto-detected (REQ-CMP-001).
  • New config key qemu_dbus_address, with docs, web UI and en locale. Empty means the session bus.
  • third-party/qemu-dbus-display/: QEMU 11.1.1 dbus-display1.xml, preprocessed for Linux, with provenance and license notes. Bindings are generated at configure time.

Tests

  • tests/unit/platform/linux/qemu/: 37 gtests against an in-process fake QEMU on a private dbus-daemon, covering the session, frame store, capture, and “never connects unless selected”. All pass across --gtest_repeat=5.
  • tests/e2e/: a boot-sector guest that draws a known pattern, run_vm.sh, e2e_stream.sh, and a headless Moonlight client (standalone CMake project: GameStream pairing/launch, moonlight-common-c, FFmpeg decode). See tests/e2e/README.md.

Deviations from the issue text (reasons in JOURNAL.md)

  • Console selection uses the existing output_name instead of a new qemu_console key. display_names() returns the graphical console ids, so output_name = 1 (or a label) works the same way it does for monitors. That’s one fewer option for upstream review.
  • Bindings are generated at configure time (execute_process, like GEN_WAYLAND), with the XML in CMAKE_CONFIGURE_DEPENDS. A build-time rule wouldn’t reach the test_sunshine target without extra custom targets.
  • REQ-NFR-001 is still draft. Its target is about the DMABUF + hardware encoder path. This host has an RTX 4090, but it runs WSL2, which has no DRM render node, so QEMU’s GL display (needed for DMABUF) won’t start. I recorded the measured shm numbers for NVENC and software encoding, plus proposed targets, in the requirement.
  • The E2E harness was written after the capture code, not strictly first. The red check below shows it fails without the feature.

Bugs found and fixed along the way

  • Deadlock: QEMU calls a new listener synchronously right after replying to RegisterListener. With one GLib thread, a second registration deadlocked both processes until the call timed out. Listener connections now dispatch on their own thread.
  • Hang: connection setup and authentication have no timeout of their own, so a qemu_dbus_address that accepts but never authenticates would hang Sunshine forever. A deadline now cancels them.
  • Missed disconnect: capture didn’t notice a dropped listener while the guest wasn’t drawing. It now returns reinit.

Verification (Ubuntu 24.04 on WSL2, gcc-14, KVM, RTX 4090)

Check Result
E2E, QEMU 11.1.1 (shared memory map) PASS: quadrants [253,0,0] [0,254,0] [0,0,254] [255,255,255]
E2E, QEMU 8.2.2 distro (D-Bus messages) PASS, same colors
E2E with encoder = nvenc (CUDA build, h264_nvenc), both QEMU versions PASS
E2E against Sunshine built without the backend (red check) FAIL as expected: launch returns 503
test_sunshine, SUNSHINE_ENABLE_QEMU=ON 592 passed, 14 skipped, 0 failed
test_sunshine, SUNSHINE_ENABLE_QEMU=OFF 555 passed, 14 skipped, 0 failed (same as clean upstream)
clang-format 23.1.1 (pip, as upstream lint) clean
doxygen 1.18.0 with upstream doxyconfig (FAIL_ON_WARNINGS) exit 0, no warnings
anvil requirement status passes. Linked: CAP-001 (23), CAP-002 (12), CMP-001 (2), E2E-001, NFR-001

Both test_sunshine runs exit 1 because of three PlatformTestSuite setup failures (Audio, MouseHID, Encoder). platf::init() finds no capture method under Xvfb on WSL2. The clean upstream tree fails the same way; see JOURNAL.md.

Latency (REQ-NFR-001): Release build, 1920x1080@60 stream, 640x400 guest framebuffer, about 330 samples each. Measured from receiving QEMU’s call to sending the encoded frame, as reported to Moonlight:

Transport Encoder p50 p95
Shared memory map (QEMU 11.1.1) NVENC 11.1 ms 20.4 ms
D-Bus messages (QEMU 8.2.2) NVENC 12.2 ms 19.5 ms
Shared memory map (QEMU 11.1.1) software x264 14.1 ms 21.4 ms
D-Bus messages (QEMU 8.2.2) software x264 14.1 ms 21.1 ms

Most of this comes from capture pacing, not encoding. The capture loop wakes on a fixed 16.7 ms tick, so new damage waits 0–16.7 ms (about 8 ms at p50, 16 ms at p95) before it’s copied. That plus ~3 ms of NVENC matches the numbers above. Waking the capture thread on damage should remove most of it; that’s suggested for #3.

Not done here

  • DMABUF, cursor, full mode-change and reboot handling (#3). DMABUF needs native Linux with a render node; it can’t run under WSL2. Audio (#4) and input (#5) still go through the host backends.
  • Damage-driven capture wakeup (the latency finding above).
  • CI on Anvil (#9). A local run needs QEMU with -display dbus, dbus-daemon, and gcc-14 on Ubuntu 24.04, because upstream’s kmsgrab.cpp needs std::ranges::to.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb

Created Sep 12, 2026 at 21:15 UTC | Merged Sep 12, 2026 at 21:47 UTC by colechristensen cole.christensen@gmail.com