ref:main

EPIC Epic: Stream QEMU VMs to Moonlight via a Sunshine QEMU D-Bus backend #1

open Opened by cole.christensen@gmail.com

Epic: Stream QEMU VMs to Moonlight via a Sunshine QEMU D-Bus backend

Goal

Any QEMU VM started with -display dbus -audiodev dbus can be paired and streamed from an unmodified Moonlight client, with no agent in the guest. It works from firmware through OS boot, on any guest OS. Hardware-encoded where possible, and zero-copy when QEMU hands us DMABUFs.

Non-goals (for now)

  • macOS or Windows hosts. Linux/KVM hosts only (see #10, deferred).
  • HDR (virtio-gpu can’t produce HDR scanouts today).
  • Clipboard, USB redirection, file transfer.
  • Any change to the Moonlight client.

START HERE (for the implementing agent)

  1. Host: a Linux x86_64 or aarch64 machine. KVM (/dev/kvm) is strongly preferred. A VAAPI (Intel/AMD) or NVENC GPU is needed for the DMABUF/hwenc requirements; everything else runs with software encoding.
  2. Clone: anvil repo clone fangorn/sunshine-qemu && cd sunshine-qemu && git submodule update --init --recursive
  3. Read upstream’s AGENTS.md first. These rules are enforced upstream and we must follow them so the work can be sent upstream (#8):
    • gtest; the test binary is tests/test_sunshine inside the build dir.
    • Build dirs are prefixed cmake-build-.
    • Everything must have doxygen docs or the build fails. Use /** @brief ... */ blocks and ///< for inline comments.
    • Follow .clang-format.
    • Add tests for all new or changed code; target 100% coverage of changed code.
    • Never create issues or PRs in the LizardByte GitHub org. All our issues and PRs go on Anvil fangorn/sunshine-qemu.
  4. Workflow (fangorn rules): TDD (red → green → refactor). One branch and one Anvil PR per issue, with Closes #N. main is protected. Before calling anything done, run anvil requirement status --repo fangorn/sunshine-qemu.
  5. Requirement traceability: annotate every gtest with a comment directly above it: // @tag requirements: [REQ-CAP-002]. Then link it: anvil requirement link REQ-CAP-002 --test "QemuDbusCapture.ShmScanoutDeliversFrame" --test-file tests/unit/platform/linux/qemu/test_capture.cpp --test-line 42 --repo fangorn/sunshine-qemu
  6. Order: #2#3 → (#4, #5 in parallel) → #9#6#7#8. #10 and #11 are deferred.
  7. Keep JOURNAL.md notes at stopping points on your branch (not upstreamable; keep it out of the upstream PR in #8).

Architecture

QEMU (-display dbus[,addr=...] -audiodev dbus,id=snd0)
│ D-Bus: /org/qemu/Display1/VM, /Console_N, /Audio
│ per-listener p2p socketpair (QEMU is the auth *server* on it)
Sunshine process (one per VM)
src/platform/linux/qemu/ <- NEW
session.{h,cpp} QemuSession: one GDBus connection, console discovery,
GMainContext thread, reconnect/VM-gone handling
generated/ gdbus-codegen output from vendored XML
capture.cpp display_t impl: Listener object (Scanout/Update,
Unix.Map, ScanoutDMABUF, ScanoutDMABUF2, cursor)
audio.cpp audio_control_t / mic_t from AudioOutListener
input.cpp Moonlight input -> Keyboard/Mouse/MultiTouch
keymap.{h,cpp} Windows VK -> Linux KEY_* -> QEMU qnum
src/platform/linux/misc.cpp: source::QEMU in source_e, verify_qemu(),
display()/display_names() dispatch
src/platform/virtualhid_input.cpp + src/audio.cpp: route to qemu impls
when capture == "qemu"
existing encode pipeline (FFmpeg: VAAPI / CUDA-NVENC / Vulkan / software)
Moonlight client

Design decisions (already made; don’t revisit without cause)

  • Out-of-process Sunshine module, not code inside QEMU. QEMU is GPL-2.0-only and Sunshine is GPL-3.0, and QEMU upstream moved remote-display servers out of process by design.
  • C++ and GDBus (GLib/gio), bindings generated with gdbus-codegen, the same way QEMU does it. We don’t use the Rust qemu-display crate.
  • Vendor the preprocessed XML. QEMU’s ui/dbus-display1.xml has <?if $(env.HOST_OS) ...?> processing instructions that gdbus-codegen can’t parse. QEMU runs scripts/xml-preprocess.py with HOST_OS=linux first. Run the same step once and commit the output to third-party/qemu-dbus-display/dbus-display1.xml, recording the QEMU commit and license in a README. Regenerate the C bindings at build time with the same flags QEMU uses: --interface-prefix org.qemu. --c-namespace QemuDBus --glib-min-required 2.64.
  • Connection modes: support bus mode first. QEMU owns org.qemu on the session bus or on addr=; tests use a private dbus-daemon. Add p2p mode (-display dbus,p2p=yes plus QMP add_client protocol=@dbus-display) in #6 if libvirt needs it.
  • Capability negotiation is client-advertised. QEMU only uses Listener.Unix.Map and Listener.Unix.ScanoutDMABUF2 if our /org/qemu/Display1/Listener object lists them in its Interfaces property (see QEMU ui/dbus-listener.c, dbus_display_listener_implements). Forgetting this silently falls back to copying full pixel data over the socket.
  • Build flag: SUNSHINE_ENABLE_QEMU in cmake/prep/options.cmake, alongside SUNSHINE_ENABLE_KWIN/PORTAL. Default ON on Linux when gio ≥ 2.64 is found. When capture isn’t qemu, Sunshine must behave exactly as before (REQ-CMP-001).
  • One Sunshine process per VM. Multi-VM is handled by deployment (#6), not by making Sunshine multi-host.

Verified facts (checked against source on 2026-09-12)

  • Sunshine main at upstream dd7a1f7:
    • platf::display_t is in src/platform/common.h:680; capture() at :714; make_avcodec_encode_device() at :737.
    • mic_t (:803) and audio_control_t (:819) are host audio capture. Sunshine has no client→host microphone path (REQ-AUD-002 is deprecated for that reason).
    • Linux source selection is source_e plus config::video.capture ("kms"|"wlr"|"x11"|"portal"|"kwin"|"nvfbc") in src/platform/linux/misc.cpp:1109, 1200-1380.
    • Input on Linux and macOS goes through libvirtualhid via free functions in src/platform/virtualhid_input.cpp:1026-1100; platf::input() is called from src/input.cpp:2277.
    • Audio controller is created by platf::audio_control(), called from src/audio.cpp:320.
    • Keyboard modcodes from Moonlight are Windows virtual-key codes.
    • Config keys: capture, port (base port; others offset via net::map_port), sunshine_name, file_state, credentials_file, pkey, cert, file_apps, min_log_level.
    • Headless pairing: POST /api/pin {"pairing_id","pin","name"} on the config web server.
  • QEMU 11.1.x ui/dbus-display1.xml:
    • VM (Name, UUID, ConsoleIDs); Console (RegisterListener(h), SetUIInfo, Width/Height/Label).
    • Keyboard (Press/Release with a QEMU qnum keycode, i.e. xtkbd with the high bit re-encoded; QEMU has a Linux→qcode table in ui/input-keymap.c).
    • Mouse (Press/Release with buttons Left=0 Middle=1 Right=2 WheelUp=3 WheelDown=4 Side=5 Extra=6; SetAbsPosition only when IsAbsolute; RelMotion only when not).
    • MultiTouch.SendEvent(kind, slot, x, y).
    • Listener (Scanout/Update with pixman format and data; ScanoutDMABUF/UpdateDMABUF; Disable; MouseSet; CursorDefine as ARGB32); Listener.Unix.Map (ScanoutMap(fd, offset, w, h, stride, fmt)/UpdateMap); Listener.Unix.ScanoutDMABUF2 (multi-plane).
    • Audio.RegisterOutListener(h); AudioOutListener (Init with PCM format, Write(id, ay), SetEnabled, SetVolume, Fini); NSamples defaults to 480, i.e. 10 ms at 48 kHz.
    • No gamepad interface.
  • No existing Sunshine-on-QEMU-D-Bus project was found in a web search on 2026-09-12.

Children

  • #2 Phase 0: dev environment, E2E harness, QemuSession core, capture spike
  • #3 Phase 1a: capture source (shm + DMABUF zero-copy)
  • #4 Phase 1b: guest audio out
  • #5 Phase 1c: keyboard, mouse, touch input
  • #6 Phase 1d: multi-VM deployment (ports, mDNS, systemd, libvirt)
  • #7 Phase 2: gamepads (Linux guests)
  • #8 Phase 3: upstream to LizardByte, QEMU, libvirt
  • #9 CI: Linux build + headless QEMU integration job on Anvil
  • #10 Deferred: macOS host support
  • #11 Deferred: emulated USB gamepad device in QEMU for Windows guests

Requirements

REQ-E2E-001, REQ-CAP-001…006, REQ-AUD-001, REQ-INP-001…004, REQ-DEP-001/002, REQ-CMP-001, REQ-NFR-001 (draft). REQ-AUD-002 is deprecated.