ref:c209feed52ee0b32c3a16a5dc1dcc991e0ade278

test(e2e): stream a QEMU guest to a headless Moonlight client

tests/e2e/qemu/e2e_stream.sh boots a boot-sector guest that draws a known pattern, starts Sunshine with capture = qemu and software encoding, pairs a headless client (PIN approved through the web API), streams through moonlight-common-c, decodes with FFmpeg and checks the quadrant colors. It also reports Sunshine's frame processing latency. JOURNAL.md holds fork-only Phase 0 notes and measurements. Closes #2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: c209feed52ee0b32c3a16a5dc1dcc991e0ade278
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-12 21:14
Parents: c293626
11 files changed +1714 -0
Type
JOURNAL.md +89 −0
@@ -1,0 +1,89 @@
# Journal (fork-only, not upstreamable)
Working notes for the QEMU D-Bus backend. Keep out of the upstream series (#8).
## 2026-09-12 — Phase 0 (#2)
### Environment
- Host: Ubuntu 24.04.4 on WSL2 (kernel 6.18), 20 cores, `/dev/kvm` present, no `/dev/dri`
(so no VAAPI/DMABUF here; the shm path and software encoding only).
- Toolchain: upstream needs **gcc-14** on Ubuntu 24.04 (`scripts/linux_build.sh` picks it;
`src/platform/linux/kmsgrab.cpp` uses `std::ranges::to`). The default gcc 13 fails to build a
clean upstream tree. Configure with `CC=gcc-14 CXX=g++-14`.
- CUDA isn't installed: configure with `-DCUDA_FAIL_ON_MISSING=OFF`.
- Distro QEMU is 8.2.2. Its D-Bus display has no `Listener.Unix.Map` and no
`Listener.Unix.ScanoutDMABUF2` (checked the strings in `ui-dbus.so`), so it only sends
`Scanout`/`Update` pixel copies. Built QEMU 11.1.1 from the release tarball into
`~/.cache/sunshine-qemu/qemu-install` with
`--target-list=x86_64-softmmu --enable-dbus-display --enable-kvm --enable-slirp --enable-opengl --enable-modules`.
### Baseline (clean upstream `dd7a1f7`, `SUNSHINE_ENABLE_QEMU=OFF`)
`xvfb-run -a cmake-build-noqemu/tests/test_sunshine`: 569 tests, 555 passed, 14 skipped,
**exit code 1**. The failure is `PlatformTestSuite::SetUpTestSuite` (`tests/tests_common.h:162`):
`platf::init()` logs "Unable to initialize capture method" on WSL2 (no KMS, portal interfaces
missing, X11 capture unavailable under Xvfb here). That makes the `MouseHIDTest`, audio and video
platform suites skip and the binary exit 1. Not caused by this work.
### Decisions and deviations from the issue text
- **Console selection uses Sunshine's existing `output_name`**, not a new `qemu_console` key.
`platf::display_names()` returns the graphical console ids, so `output_name = 1` (or a label)
selects a console exactly like it selects a monitor for other backends. One fewer config key for
upstream review; an unknown name falls back to the first graphical console with a warning.
- **Bindings are generated at configure time** (`execute_process`, like `GEN_WAYLAND`), with the XML
in `CMAKE_CONFIGURE_DEPENDS`. A build-time `add_custom_command` would not propagate to the
`test_sunshine` target in `tests/` without extra custom targets.
- VM gone / listener closed / scanout size change all return `capture_e::reinit`. `video.cpp` then
re-creates the display in a retry loop, so a VM restart doesn't end the stream.
- Frames are pushed only when QEMU reports damage; the encoder's minimum-FPS repeat covers static
screens. `img_t::frame_timestamp` is the receipt time of the QEMU call, so Sunshine's existing
`frame_processing_latency` (logged at debug and sent to Moonlight in each frame header) measures
"listener callback receipt → packet out" for REQ-NFR-001.
- Connection setup and the listener handshake have a deadline (a watchdog cancels the
`GCancellable`). Without it, a `qemu_dbus_address` that accepts but never authenticates hangs
Sunshine forever.
- **Two GLib threads per session.** After replying to `RegisterListener`, QEMU makes *synchronous*
calls on the new listener (property `GetAll`, `ScanoutMap`). With one thread, a second
`RegisterListener` issued while QEMU waited on the first listener deadlocked both processes until
the call timeout (found by `QemuSessionTest.SupportsMultipleListenersAndReregistration`). The bus
thread makes blocking calls; listener connections are dispatched on a thread that never blocks on
QEMU.
### Results
- Unit tests: 37 QEMU tests (session against a fake QEMU on a private dbus-daemon, frame store,
capture, REQ-CMP-001 "never connects unless selected"), stable across `--gtest_repeat=5`.
- E2E (`tests/e2e/qemu/e2e_stream.sh`), KVM, software encoding:
- QEMU 11.1.1, shared memory map path: PASS, quadrants `[253,0,0] [0,254,0] [0,0,254] [255,255,255]`.
- QEMU 8.2.2 (distro), D-Bus message path: PASS, same colors.
- Red check: the same harness against a Sunshine built without the backend fails at `launch`
with HTTP 503 "Failed to initialize video capture/encoding".
- The harness was written after the capture spike rather than strictly first; the red check above
is the evidence that it fails without the feature.
### REQ-NFR-001 measurements (host processing latency: QEMU call receipt → encoded frame sent)
Release build, gcc-14, 20-core host under WSL2, KVM, `encoder = software` (libx264), guest
framebuffer 640x400 scaled by Sunshine, ~330 samples per run over 18 s:
| Transport (QEMU) | Stream | p50 | p95 |
|---------------------------------|-------------|---------|---------|
| Shared memory map (11.1.1) | 1920x1080@60 | 14.1 ms | 21.4 ms |
| D-Bus messages (8.2.2) | 1920x1080@60 | 14.1 ms | 21.1 ms |
| Shared memory map, Debug build | 1280x800@30 | 17.2 ms | 32.9 ms |
| D-Bus messages, Debug build | 1280x800@30 | 20.2 ms | 34.6 ms |
The transports tie because the guest framebuffer is small; software scaling and x264 dominate.
No VAAPI/NVENC or DMABUF on this host, so the draft "p95 ≤ 10 ms on the DMABUF path" can't be
confirmed here; it stays for #3 on a GPU host.
### Observations for later phases
- Encoder probing creates many short-lived displays; `qemu::shared_session` only caches a
`weak_ptr`, so each probe reconnects (~2-5 ms each). Cheap, but a short keep-alive would avoid it.
- `platf::init()` runs `verify_qemu()` at Sunshine startup, so QEMU must already own `org.qemu`
when Sunshine starts. Relevant for the systemd/libvirt ordering in #6.
- In the E2E runs, PulseAudio isn't running ("Failed to create client: Daemon not running") and
libvirtualhid gamepads are unavailable; video streaming is unaffected. #4 and #5 replace both.
tests/CMakeLists.txt +2 −0
@@ -108,6 +108,8 @@
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS
${CMAKE_SOURCE_DIR}/tests/*.h
${CMAKE_SOURCE_DIR}/tests/*.cpp)
# end-to-end test tools are standalone programs with their own build
list(FILTER TEST_SOURCES EXCLUDE REGEX "^${CMAKE_SOURCE_DIR}/tests/e2e/")
if(SUNSHINE_ENABLE_TRAY AND (WIN32 OR APPLE OR CMAKE_SYSTEM_NAME STREQUAL "Linux"))
set(SUNSHINE_TRAY_TEST_ICON_SOURCE_DIR
"${CMAKE_SOURCE_DIR}/src_assets/common/assets/web/public/images")
tests/e2e/moonlight_client/CMakeLists.txt +38 −0
@@ -1,0 +1,38 @@
# Headless Moonlight client for Sunshine end-to-end tests.
#
# Standalone project, not part of the Sunshine build: it needs the full moonlight-common-c library
# (Sunshine only compiles a few of its sources) and a decoder-enabled FFmpeg.
#
# cmake -S tests/e2e/moonlight_client -B cmake-build-e2e-client -G Ninja
# ninja -C cmake-build-e2e-client
cmake_minimum_required(VERSION 3.20)
project(sunshine_e2e_moonlight_client LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(SUNSHINE_SOURCE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../..")
set(BUILD_SHARED_LIBS OFF)
add_subdirectory("${SUNSHINE_SOURCE_ROOT}/third-party/moonlight-common-c"
"${CMAKE_CURRENT_BINARY_DIR}/moonlight-common-c")
find_package(Threads REQUIRED)
find_package(CURL REQUIRED)
find_package(OpenSSL REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavcodec libavutil libswscale)
add_executable(moonlight_e2e_client
main.cpp
gamestream.h
gamestream.cpp)
target_include_directories(moonlight_e2e_client PRIVATE
"${SUNSHINE_SOURCE_ROOT}/third-party/moonlight-common-c/src")
target_link_libraries(moonlight_e2e_client PRIVATE
moonlight-common-c
CURL::libcurl
OpenSSL::SSL
OpenSSL::Crypto
PkgConfig::FFMPEG
Threads::Threads)
tests/e2e/moonlight_client/gamestream.cpp +458 −0
@@ -1,0 +1,458 @@
/**
* @file tests/e2e/moonlight_client/gamestream.cpp
* @brief Minimal GameStream HTTP client (serverinfo, pairing, applist, launch) for end-to-end tests.
* @details Implements the client side of the pairing handshake that Sunshine's `src/nvhttp.cpp`
* serves: AES-128-ECB with a key derived from SHA-256(salt || PIN), SHA-256 challenge hashes and
* RSA-SHA256 signatures.
*/
// class header include
#include "gamestream.h"
// standard includes
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <future>
#include <memory>
#include <regex>
#include <sstream>
#include <stdexcept>
// lib includes
#include <curl/curl.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/rand.h>
#include <openssl/x509.h>
namespace e2e {
namespace {
/**
* @brief Read a whole file.
*
* @param path File path.
* @return File contents.
*/
std::string read_file(const std::filesystem::path &path) {
std::ifstream in {path, std::ios::binary};
std::stringstream ss;
ss << in.rdbuf();
return ss.str();
}
/**
* @brief Write a whole file.
*
* @param path File path.
* @param data Contents.
*/
void write_file(const std::filesystem::path &path, const std::string &data) {
std::ofstream out {path, std::ios::binary | std::ios::trunc};
out << data;
}
/**
* @brief Decode hex text.
*
* @param hex Hex string.
* @return Bytes.
*/
std::vector<std::uint8_t> from_hex(const std::string &hex) {
std::vector<std::uint8_t> out;
for (std::size_t i = 0; i + 1 < hex.size(); i += 2) {
out.push_back((std::uint8_t) std::stoi(hex.substr(i, 2), nullptr, 16));
}
return out;
}
/**
* @brief SHA-256 of a buffer.
*
* @param data Input bytes.
* @return 32-byte digest.
*/
std::vector<std::uint8_t> sha256(const std::vector<std::uint8_t> &data) {
std::vector<std::uint8_t> out(32);
unsigned int len = 0;
EVP_Digest(data.data(), data.size(), out.data(), &len, EVP_sha256(), nullptr);
out.resize(len);
return out;
}
/**
* @brief AES-128-ECB without padding.
*
* @param key 16-byte key.
* @param data Input, a multiple of 16 bytes.
* @param encrypt Whether to encrypt or decrypt.
* @return Output bytes.
*/
std::vector<std::uint8_t> aes_ecb(const std::vector<std::uint8_t> &key, const std::vector<std::uint8_t> &data, bool encrypt) {
std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> ctx {EVP_CIPHER_CTX_new(), &EVP_CIPHER_CTX_free};
EVP_CipherInit_ex(ctx.get(), EVP_aes_128_ecb(), nullptr, key.data(), nullptr, encrypt ? 1 : 0);
EVP_CIPHER_CTX_set_padding(ctx.get(), 0);
std::vector<std::uint8_t> out(data.size() + 16);
int len = 0;
int total = 0;
EVP_CipherUpdate(ctx.get(), out.data(), &len, data.data(), (int) data.size());
total = len;
EVP_CipherFinal_ex(ctx.get(), out.data() + total, &len);
total += len;
out.resize(total);
return out;
}
/**
* @brief Sign with RSA-SHA256.
*
* @param key_pem Private key.
* @param data Data to sign.
* @return Signature.
*/
std::vector<std::uint8_t> sign_sha256(const std::string &key_pem, const std::vector<std::uint8_t> &data) {
std::unique_ptr<BIO, decltype(&BIO_free)> bio {BIO_new_mem_buf(key_pem.data(), (int) key_pem.size()), &BIO_free};
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> pkey {PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr), &EVP_PKEY_free};
std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)> ctx {EVP_MD_CTX_new(), &EVP_MD_CTX_free};
EVP_DigestSignInit(ctx.get(), nullptr, EVP_sha256(), nullptr, pkey.get());
std::size_t len = 0;
EVP_DigestSign(ctx.get(), nullptr, &len, data.data(), data.size());
std::vector<std::uint8_t> sig(len);
EVP_DigestSign(ctx.get(), sig.data(), &len, data.data(), data.size());
sig.resize(len);
return sig;
}
/**
* @brief Get the signature bytes embedded in a PEM certificate.
*
* @param cert_pem Certificate.
* @return Signature bytes.
*/
std::vector<std::uint8_t> cert_signature(const std::string &cert_pem) {
std::unique_ptr<BIO, decltype(&BIO_free)> bio {BIO_new_mem_buf(cert_pem.data(), (int) cert_pem.size()), &BIO_free};
std::unique_ptr<X509, decltype(&X509_free)> x509 {PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr), &X509_free};
if (!x509) {
return {};
}
const ASN1_BIT_STRING *sig = nullptr;
const X509_ALGOR *alg = nullptr;
X509_get0_signature(&sig, &alg, x509.get());
return {sig->data, sig->data + sig->length};
}
/**
* @brief Create a self-signed RSA-2048 certificate.
*
* @return Certificate and key PEM.
*/
std::pair<std::string, std::string> make_certificate() {
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> pkey {EVP_RSA_gen(2048), &EVP_PKEY_free};
std::unique_ptr<X509, decltype(&X509_free)> x509 {X509_new(), &X509_free};
X509_set_version(x509.get(), 2);
ASN1_INTEGER_set(X509_get_serialNumber(x509.get()), 1);
X509_gmtime_adj(X509_getm_notBefore(x509.get()), 0);
X509_gmtime_adj(X509_getm_notAfter(x509.get()), 60L * 60 * 24 * 365 * 20);
X509_set_pubkey(x509.get(), pkey.get());
auto name = X509_get_subject_name(x509.get());
X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (const unsigned char *) "Sunshine E2E Client", -1, -1, 0);
X509_set_issuer_name(x509.get(), name);
X509_sign(x509.get(), pkey.get(), EVP_sha256());
std::unique_ptr<BIO, decltype(&BIO_free)> cert_bio {BIO_new(BIO_s_mem()), &BIO_free};
PEM_write_bio_X509(cert_bio.get(), x509.get());
std::unique_ptr<BIO, decltype(&BIO_free)> key_bio {BIO_new(BIO_s_mem()), &BIO_free};
PEM_write_bio_PrivateKey(key_bio.get(), pkey.get(), nullptr, nullptr, 0, nullptr, nullptr);
char *data = nullptr;
auto cert_len = BIO_get_mem_data(cert_bio.get(), &data);
std::string cert {data, (std::size_t) cert_len};
auto key_len = BIO_get_mem_data(key_bio.get(), &data);
std::string key {data, (std::size_t) key_len};
return {cert, key};
}
/**
* @brief libcurl write callback appending to a string.
*
* @param ptr Data.
* @param size Element size.
* @param nmemb Element count.
* @param userdata Target string.
* @return Bytes consumed.
*/
std::size_t append_body(char *ptr, std::size_t size, std::size_t nmemb, void *userdata) {
((std::string *) userdata)->append(ptr, size * nmemb);
return size * nmemb;
}
/**
* @brief Load a PEM string into a curl blob option.
*
* @param curl Handle.
* @param option Blob option.
* @param pem PEM text.
*/
void set_blob(CURL *curl, CURLoption option, const std::string &pem) {
curl_blob blob {};
blob.data = (void *) pem.data();
blob.len = pem.size();
blob.flags = CURL_BLOB_COPY;
curl_easy_setopt(curl, option, &blob);
}
} // namespace
identity_t load_or_create_identity(const std::string &dir) {
std::filesystem::create_directories(dir);
const auto cert_path = std::filesystem::path {dir} / "client.pem";
const auto key_path = std::filesystem::path {dir} / "client.key";
const auto id_path = std::filesystem::path {dir} / "uniqueid";
identity_t identity;
if (std::filesystem::exists(cert_path) && std::filesystem::exists(key_path) && std::filesystem::exists(id_path)) {
identity.cert_pem = read_file(cert_path);
identity.key_pem = read_file(key_path);
identity.unique_id = read_file(id_path);
return identity;
}
std::tie(identity.cert_pem, identity.key_pem) = make_certificate();
identity.unique_id = to_hex(random_bytes(8));
write_file(cert_path, identity.cert_pem);
write_file(key_path, identity.key_pem);
write_file(id_path, identity.unique_id);
return identity;
}
std::string to_hex(const std::vector<std::uint8_t> &data) {
static constexpr char digits[] = "0123456789ABCDEF";
std::string out;
out.reserve(data.size() * 2);
for (auto b : data) {
out.push_back(digits[b >> 4]);
out.push_back(digits[b & 0xf]);
}
return out;
}
std::vector<std::uint8_t> random_bytes(std::size_t n) {
std::vector<std::uint8_t> out(n);
RAND_bytes(out.data(), (int) n);
return out;
}
std::optional<std::string> xml_tag(const std::string &xml, const std::string &tag) {
std::smatch match;
if (std::regex_search(xml, match, std::regex {"<" + tag + ">([\\s\\S]*?)</" + tag + ">"})) {
return match[1].str();
}
return std::nullopt;
}
int xml_status(const std::string &xml) {
std::smatch match;
if (std::regex_search(xml, match, std::regex {"status_code=\"(-?\\d+)\""})) {
return std::stoi(match[1].str());
}
return -1;
}
client_t::client_t(std::string host, int http_port, identity_t identity):
host {std::move(host)},
http {http_port},
id {std::move(identity)} {
}
std::optional<std::string> client_t::get(bool use_https, const std::string &path, const std::string &query, long timeout_s) {
std::unique_ptr<CURL, decltype(&curl_easy_cleanup)> curl {curl_easy_init(), &curl_easy_cleanup};
auto url = std::string {use_https ? "https://" : "http://"} + host + ":" + std::to_string(use_https ? https : http) + path + "?uniqueid=" + id.unique_id + "&uuid=" + to_hex(random_bytes(16)) + (query.empty() ? "" : "&" + query);
std::string body;
curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str());
curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, &append_body);
curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT, timeout_s);
curl_easy_setopt(curl.get(), CURLOPT_NOSIGNAL, 1L);
if (use_https) {
// Sunshine's certificate is self-signed; pairing pins it, so peer verification is skipped here.
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYHOST, 0L);
set_blob(curl.get(), CURLOPT_SSLCERT_BLOB, id.cert_pem);
curl_easy_setopt(curl.get(), CURLOPT_SSLCERTTYPE, "PEM");
set_blob(curl.get(), CURLOPT_SSLKEY_BLOB, id.key_pem);
curl_easy_setopt(curl.get(), CURLOPT_SSLKEYTYPE, "PEM");
}
auto rc = curl_easy_perform(curl.get());
if (rc != CURLE_OK) {
std::fprintf(stderr, "e2e: GET %s failed: %s\n", path.c_str(), curl_easy_strerror(rc));
return std::nullopt;
}
return body;
}
std::optional<server_info_t> client_t::server_info(bool use_https) {
auto body = get(use_https, "/serverinfo", "");
if (!body || xml_status(*body) != 200) {
return std::nullopt;
}
server_info_t info;
info.app_version = xml_tag(*body, "appversion").value_or("");
info.gfe_version = xml_tag(*body, "GfeVersion").value_or("");
info.https_port = std::stoi(xml_tag(*body, "HttpsPort").value_or("0"));
info.codec_mode_support = std::stoi(xml_tag(*body, "ServerCodecModeSupport").value_or("0"));
info.paired = xml_tag(*body, "PairStatus").value_or("0") == "1";
info.state = xml_tag(*body, "state").value_or("");
https = info.https_port;
return info;
}
bool client_t::pair(const std::string &pin, const std::string &device_name, const std::function<bool()> &approve) {
auto salt = random_bytes(16);
std::vector<std::uint8_t> salted {salt};
salted.insert(salted.end(), pin.begin(), pin.end());
auto key = sha256(salted);
key.resize(16);
// Phase 1: send our certificate; the request stays pending until the PIN is approved.
auto approval = std::async(std::launch::async, approve);
auto phase1 = get(false, "/pair", "devicename=" + device_name + "&updateState=1&phrase=getservercert&salt=" + to_hex(salt) + "&clientcert=" + to_hex({id.cert_pem.begin(), id.cert_pem.end()}), 120);
if (!phase1 || xml_tag(*phase1, "paired").value_or("0") != "1") {
std::fprintf(stderr, "e2e: getservercert failed: %s\n", phase1 ? phase1->c_str() : "no response");
approval.wait();
return false;
}
auto server_cert_hex = xml_tag(*phase1, "plaincert").value_or("");
auto server_cert_bytes = from_hex(server_cert_hex);
std::string server_cert {server_cert_bytes.begin(), server_cert_bytes.end()};
// Phase 2: client challenge
auto client_challenge = random_bytes(16);
auto phase2 = get(false, "/pair", "devicename=" + device_name + "&updateState=1&clientchallenge=" + to_hex(aes_ecb(key, client_challenge, true)));
if (!phase2 || xml_tag(*phase2, "paired").value_or("0") != "1") {
std::fprintf(stderr, "e2e: clientchallenge failed\n");
approval.wait();
return false;
}
auto challenge_response = aes_ecb(key, from_hex(xml_tag(*phase2, "challengeresponse").value_or("")), false);
if (challenge_response.size() < 48) {
std::fprintf(stderr, "e2e: short challenge response\n");
approval.wait();
return false;
}
std::vector<std::uint8_t> server_response_hash {challenge_response.begin(), challenge_response.begin() + 32};
std::vector<std::uint8_t> server_challenge {challenge_response.begin() + 32, challenge_response.begin() + 48};
// Phase 3: answer the server challenge
auto client_secret = random_bytes(16);
std::vector<std::uint8_t> to_hash {server_challenge};
auto client_signature = cert_signature(id.cert_pem);
to_hash.insert(to_hash.end(), client_signature.begin(), client_signature.end());
to_hash.insert(to_hash.end(), client_secret.begin(), client_secret.end());
auto phase3 = get(false, "/pair", "devicename=" + device_name + "&updateState=1&serverchallengeresp=" + to_hex(aes_ecb(key, sha256(to_hash), true)));
if (!phase3 || xml_tag(*phase3, "paired").value_or("0") != "1") {
std::fprintf(stderr, "e2e: serverchallengeresp failed\n");
approval.wait();
return false;
}
auto pairing_secret = from_hex(xml_tag(*phase3, "pairingsecret").value_or(""));
if (pairing_secret.size() < 16) {
std::fprintf(stderr, "e2e: short pairing secret\n");
approval.wait();
return false;
}
// Detect a man in the middle: the server proves it knew the PIN and owns its certificate.
std::vector<std::uint8_t> server_secret {pairing_secret.begin(), pairing_secret.begin() + 16};
std::vector<std::uint8_t> expected {client_challenge};
auto server_signature = cert_signature(server_cert);
expected.insert(expected.end(), server_signature.begin(), server_signature.end());
expected.insert(expected.end(), server_secret.begin(), server_secret.end());
if (sha256(expected) != server_response_hash) {
std::fprintf(stderr, "e2e: server response hash mismatch (wrong PIN?)\n");
approval.wait();
return false;
}
// Phase 4: send our signed secret
std::vector<std::uint8_t> client_pairing_secret {client_secret};
auto secret_signature = sign_sha256(id.key_pem, client_secret);
client_pairing_secret.insert(client_pairing_secret.end(), secret_signature.begin(), secret_signature.end());
auto phase4 = get(false, "/pair", "devicename=" + device_name + "&updateState=1&clientpairingsecret=" + to_hex(client_pairing_secret));
bool approved = approval.get();
if (!phase4 || xml_tag(*phase4, "paired").value_or("0") != "1") {
std::fprintf(stderr, "e2e: clientpairingsecret failed\n");
return false;
}
// Phase 5: confirm over HTTPS with the now-trusted client certificate
if (https == 0) {
server_info(false);
}
auto phase5 = get(true, "/pair", "devicename=" + device_name + "&updateState=1&phrase=pairchallenge");
return approved && phase5 && xml_tag(*phase5, "paired").value_or("0") == "1";
}
std::vector<app_t> client_t::app_list() {
std::vector<app_t> apps;
auto body = get(true, "/applist", "");
if (!body) {
return apps;
}
std::regex app_re {"<App>([\\s\\S]*?)</App>"};
for (std::sregex_iterator it {body->begin(), body->end(), app_re}, end; it != end; ++it) {
auto app_xml = (*it)[1].str();
app_t app;
app.title = xml_tag(app_xml, "AppTitle").value_or("");
app.id = std::stoi(xml_tag(app_xml, "ID").value_or("0"));
apps.push_back(app);
}
return apps;
}
std::optional<std::string> client_t::launch(int app_id, int width, int height, int fps, const std::vector<std::uint8_t> &ri_key, int ri_key_id, const std::string &extra_query) {
auto query = "appid=" + std::to_string(app_id) + "&mode=" + std::to_string(width) + "x" + std::to_string(height) + "x" + std::to_string(fps) + "&additionalStates=1&sops=0&rikey=" + to_hex(ri_key) + "&rikeyid=" + std::to_string(ri_key_id) + "&localAudioPlayMode=0&surroundAudioInfo=196610&remoteControllersBitmap=0&gcmap=0" + extra_query;
auto body = get(true, "/launch", query, 60);
if (!body || xml_status(*body) != 200) {
std::fprintf(stderr, "e2e: launch failed: %s\n", body ? body->c_str() : "no response");
return std::nullopt;
}
return xml_tag(*body, "sessionUrl0");
}
bool client_t::cancel() {
auto body = get(true, "/cancel", "");
return body && xml_status(*body) == 200;
}
std::optional<std::string> api_request(const std::string &method, const std::string &url, const std::string &user, const std::string &password, const std::string &body) {
std::unique_ptr<CURL, decltype(&curl_easy_cleanup)> curl {curl_easy_init(), &curl_easy_cleanup};
std::string response;
auto userpwd = user + ":" + password;
curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str());
curl_easy_setopt(curl.get(), CURLOPT_CUSTOMREQUEST, method.c_str());
curl_easy_setopt(curl.get(), CURLOPT_USERPWD, userpwd.c_str());
curl_easy_setopt(curl.get(), CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYHOST, 0L);
curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, &append_body);
curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &response);
curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT, 60L);
curl_easy_setopt(curl.get(), CURLOPT_NOSIGNAL, 1L);
std::unique_ptr<curl_slist, decltype(&curl_slist_free_all)> headers {nullptr, &curl_slist_free_all};
if (!body.empty()) {
headers.reset(curl_slist_append(nullptr, "Content-Type: application/json"));
curl_easy_setopt(curl.get(), CURLOPT_HTTPHEADER, headers.get());
curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, body.c_str());
}
auto rc = curl_easy_perform(curl.get());
if (rc != CURLE_OK) {
std::fprintf(stderr, "e2e: %s %s failed: %s\n", method.c_str(), url.c_str(), curl_easy_strerror(rc));
return std::nullopt;
}
return response;
}
} // namespace e2e
tests/e2e/moonlight_client/gamestream.h +184 −0
@@ -1,0 +1,184 @@
/**
* @file tests/e2e/moonlight_client/gamestream.h
* @brief Minimal GameStream HTTP client (serverinfo, pairing, applist, launch) for end-to-end tests.
*/
#pragma once
// standard includes
#include <cstdint>
#include <functional>
#include <map>
#include <optional>
#include <string>
#include <vector>
namespace e2e {
/**
* @brief Client identity used for pairing and HTTPS requests.
*/
struct identity_t {
std::string cert_pem; ///< Self-signed client certificate.
std::string key_pem; ///< Private key of the certificate.
std::string unique_id; ///< Client unique id sent with every request.
};
/**
* @brief Load an identity from a directory, creating it on first use.
*
* @param dir Directory holding `client.pem`, `client.key` and `uniqueid`.
* @return Identity.
*/
identity_t load_or_create_identity(const std::string &dir);
/**
* @brief Parsed `/serverinfo` fields needed to start a stream.
*/
struct server_info_t {
std::string app_version; ///< `appversion`.
std::string gfe_version; ///< `GfeVersion`.
int https_port {0}; ///< `HttpsPort`.
int codec_mode_support {0}; ///< `ServerCodecModeSupport`.
bool paired {false}; ///< `PairStatus`.
std::string state; ///< `state`.
};
/**
* @brief One application from `/applist`.
*/
struct app_t {
std::string title; ///< App title.
int id {0}; ///< App id.
};
/**
* @brief GameStream client bound to one host.
*/
class client_t {
public:
/**
* @brief Create a client.
*
* @param host Host name or address.
* @param http_port Plain HTTP port (Sunshine `port`).
* @param identity Client identity.
*/
client_t(std::string host, int http_port, identity_t identity);
/**
* @brief Fetch `/serverinfo` over HTTP, or HTTPS when paired.
*
* @param https Whether to use the HTTPS port with the client certificate.
* @return Server information, or nothing on failure.
*/
std::optional<server_info_t> server_info(bool https);
/**
* @brief Run the four-phase pairing handshake.
*
* @param pin Four-digit PIN.
* @param device_name Name shown in Sunshine's pending pairing list.
* @param approve Called on another thread once `getservercert` is pending; must submit the PIN.
* @return True when the host reports the client as paired.
*/
bool pair(const std::string &pin, const std::string &device_name, const std::function<bool()> &approve);
/**
* @brief List applications over HTTPS.
*
* @return Applications, empty on failure.
*/
std::vector<app_t> app_list();
/**
* @brief Launch an application over HTTPS.
*
* @param app_id App id.
* @param width Stream width.
* @param height Stream height.
* @param fps Stream frame rate.
* @param ri_key Remote input AES key (16 bytes).
* @param ri_key_id Remote input key id.
* @param extra_query Extra query parameters, starting with `&`.
* @return RTSP session URL, or nothing on failure.
*/
std::optional<std::string> launch(int app_id, int width, int height, int fps, const std::vector<std::uint8_t> &ri_key, int ri_key_id, const std::string &extra_query);
/**
* @brief Quit the running application over HTTPS.
*
* @return True on success.
*/
bool cancel();
/**
* @brief HTTPS port learned from serverinfo.
*
* @return Port number.
*/
[[nodiscard]] int https_port() const {
return https;
}
private:
/**
* @brief Perform a GET request.
*
* @param https Whether to use HTTPS with the client certificate.
* @param path Path starting with `/`.
* @param query Query string without the leading `?`; uniqueid and uuid are appended.
* @param timeout_s Request timeout in seconds.
* @return Response body, or nothing on transport failure.
*/
std::optional<std::string> get(bool https, const std::string &path, const std::string &query, long timeout_s = 10);
std::string host; ///< Host name or address.
int http; ///< HTTP port.
int https {0}; ///< HTTPS port.
identity_t id; ///< Client identity.
};
/**
* @brief Extract the text of the first `<tag>` element.
*
* @param xml XML document.
* @param tag Element name.
* @return Element text, or nothing.
*/
std::optional<std::string> xml_tag(const std::string &xml, const std::string &tag);
/**
* @brief Extract the `status_code` attribute of the root element.
*
* @param xml XML document.
* @return Status code, or -1.
*/
int xml_status(const std::string &xml);
/**
* @brief Encode bytes as uppercase hex.
*
* @param data Bytes.
* @return Hex string.
*/
std::string to_hex(const std::vector<std::uint8_t> &data);
/**
* @brief Generate random bytes.
*
* @param n Number of bytes.
* @return Random bytes.
*/
std::vector<std::uint8_t> random_bytes(std::size_t n);
/**
* @brief Perform an authenticated JSON request against Sunshine's web API.
*
* @param method HTTP method.
* @param url Full URL.
* @param user Web UI user name.
* @param password Web UI password.
* @param body JSON body, empty for none.
* @return Response body, or nothing on transport failure.
*/
std::optional<std::string> api_request(const std::string &method, const std::string &url, const std::string &user, const std::string &password, const std::string &body);
} // namespace e2e
tests/e2e/moonlight_client/main.cpp +523 −0
@@ -1,0 +1,523 @@
/**
* @file tests/e2e/moonlight_client/main.cpp
* @brief Headless Moonlight client for Sunshine end-to-end tests.
* @details Pairs with a Sunshine host (approving the PIN through Sunshine's web API), launches an
* app, streams through moonlight-common-c, decodes H.264 with FFmpeg and checks the decoded picture
* against expected quadrant colors. Exit code 0 means the stream matched.
*
* Usage:
* @code{.sh}
* moonlight_e2e_client --port 47989 --api-user u --api-pass p --state-dir DIR \
* --expect "255,0,0;0,255,0;0,0,255;255,255,255" --out last.ppm --summary summary.json
* @endcode
*/
// standard includes
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <map>
#include <mutex>
#include <optional>
#include <regex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
// lib includes
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/imgutils.h>
#include <libswscale/swscale.h>
#include <Limelight.h>
}
// local includes
#include "gamestream.h"
using namespace std::literals;
namespace {
/**
* @brief Command line options.
*/
struct options_t {
std::string host {"127.0.0.1"}; ///< Sunshine host.
int port {47989}; ///< Sunshine base port (HTTP).
std::string api_user; ///< Web UI user.
std::string api_pass; ///< Web UI password.
std::string state_dir {"e2e-client"}; ///< Directory for the client identity.
int width {1280}; ///< Stream width.
int height {800}; ///< Stream height.
int fps {30}; ///< Stream frame rate.
int bitrate {8000}; ///< Stream bitrate in kbps.
int min_frames {30}; ///< Decoded frames required before checking.
int timeout_s {90}; ///< Overall stream timeout.
std::string app {"Desktop"}; ///< App title to launch.
std::string expect; ///< Expected quadrant colors "r,g,b;r,g,b;r,g,b;r,g,b".
int tolerance {48}; ///< Allowed per-channel difference.
std::string out; ///< PPM path for the last decoded frame.
std::string summary; ///< JSON path for the run summary.
};
/**
* @brief State shared with moonlight-common-c's context-free callbacks.
*/
struct stream_state_t {
std::mutex mutex; ///< Guards the fields below.
AVCodecContext *codec {nullptr}; ///< H.264 decoder.
AVFrame *frame {nullptr}; ///< Decoded frame.
AVPacket *packet {nullptr}; ///< Packet reused for decode units.
SwsContext *sws {nullptr}; ///< Converter to RGB24.
std::vector<std::uint8_t> rgb; ///< Last decoded picture, RGB24.
int rgb_width {0}; ///< Width of `rgb`.
int rgb_height {0}; ///< Height of `rgb`.
int decoded {0}; ///< Decoded frame count.
std::vector<double> host_latency_ms; ///< Host processing latency per frame.
std::atomic<bool> terminated {false}; ///< Whether the connection ended.
int termination_error {0}; ///< Error from connectionTerminated.
std::atomic<bool> started {false}; ///< Whether the connection started.
};
stream_state_t state;
int dr_setup(int video_format, int width, int height, int redraw_rate, void *context, int dr_flags) {
auto codec = avcodec_find_decoder(AV_CODEC_ID_H264);
if (!codec) {
std::fprintf(stderr, "e2e: FFmpeg has no H.264 decoder\n");
return -1;
}
state.codec = avcodec_alloc_context3(codec);
state.codec->thread_count = 1;
if (avcodec_open2(state.codec, codec, nullptr) < 0) {
std::fprintf(stderr, "e2e: couldn't open the H.264 decoder\n");
return -1;
}
state.frame = av_frame_alloc();
state.packet = av_packet_alloc();
std::fprintf(stderr, "e2e: decoder set up for %dx%d@%d\n", width, height, redraw_rate);
return 0;
}
void dr_cleanup() {
std::lock_guard lock {state.mutex};
sws_freeContext(state.sws);
state.sws = nullptr;
av_packet_free(&state.packet);
av_frame_free(&state.frame);
avcodec_free_context(&state.codec);
}
int dr_submit(PDECODE_UNIT unit) {
std::vector<std::uint8_t> data;
data.reserve(unit->fullLength + AV_INPUT_BUFFER_PADDING_SIZE);
for (auto entry = unit->bufferList; entry; entry = entry->next) {
data.insert(data.end(), entry->data, entry->data + entry->length);
}
data.resize(data.size() + AV_INPUT_BUFFER_PADDING_SIZE, 0);
std::lock_guard lock {state.mutex};
if (unit->frameHostProcessingLatency) {
state.host_latency_ms.push_back(unit->frameHostProcessingLatency / 10.0);
}
state.packet->data = data.data();
state.packet->size = unit->fullLength;
if (avcodec_send_packet(state.codec, state.packet) < 0) {
return DR_NEED_IDR;
}
while (avcodec_receive_frame(state.codec, state.frame) == 0) {
auto w = state.frame->width;
auto h = state.frame->height;
state.sws = sws_getCachedContext(state.sws, w, h, (AVPixelFormat) state.frame->format, w, h, AV_PIX_FMT_RGB24, SWS_POINT, nullptr, nullptr, nullptr);
state.rgb.resize((std::size_t) w * h * 3);
std::uint8_t *dst[1] = {state.rgb.data()};
int dst_stride[1] = {w * 3};
sws_scale(state.sws, (const std::uint8_t *const *) state.frame->data, state.frame->linesize, 0, h, dst, dst_stride);
state.rgb_width = w;
state.rgb_height = h;
state.decoded += 1;
}
return DR_OK;
}
void cl_stage_failed(int stage, int error_code) {
std::fprintf(stderr, "e2e: stage %s failed: %d\n", LiGetStageName(stage), error_code);
}
void cl_connection_started() {
std::fprintf(stderr, "e2e: connection started\n");
state.started = true;
}
void cl_connection_terminated(int error_code) {
std::fprintf(stderr, "e2e: connection terminated: %d\n", error_code);
state.termination_error = error_code;
state.terminated = true;
}
void cl_log(const char *format, ...) {
va_list args;
va_start(args, format);
std::fprintf(stderr, "moonlight: ");
std::vfprintf(stderr, format, args);
va_end(args);
}
/**
* @brief Parse "r,g,b;r,g,b;r,g,b;r,g,b".
*
* @param text Expectation text.
* @return Four RGB triples, or nothing on a syntax error.
*/
std::optional<std::vector<std::array<int, 3>>> parse_expect(const std::string &text) {
std::vector<std::array<int, 3>> colors;
std::stringstream ss {text};
std::string item;
while (std::getline(ss, item, ';')) {
std::array<int, 3> rgb {};
if (std::sscanf(item.c_str(), "%d,%d,%d", &rgb[0], &rgb[1], &rgb[2]) != 3) {
return std::nullopt;
}
colors.push_back(rgb);
}
if (colors.size() != 4) {
return std::nullopt;
}
return colors;
}
/**
* @brief Mean color of a square around a point.
*
* @param rgb Picture.
* @param w Width.
* @param h Height.
* @param cx Center X.
* @param cy Center Y.
* @return Mean RGB.
*/
std::array<int, 3> mean_color(const std::vector<std::uint8_t> &rgb, int w, int h, int cx, int cy) {
const int r = std::max(2, std::min(w, h) / 40);
long sum[3] = {0, 0, 0};
long n = 0;
for (int y = std::max(0, cy - r); y < std::min(h, cy + r); ++y) {
for (int x = std::max(0, cx - r); x < std::min(w, cx + r); ++x) {
auto p = &rgb[((std::size_t) y * w + x) * 3];
sum[0] += p[0];
sum[1] += p[1];
sum[2] += p[2];
n += 1;
}
}
if (n == 0) {
return {0, 0, 0};
}
return {(int) (sum[0] / n), (int) (sum[1] / n), (int) (sum[2] / n)};
}
/**
* @brief Sample the quadrant centers of a picture.
*
* @param rgb Picture.
* @param w Width.
* @param h Height.
* @return Top-left, top-right, bottom-left, bottom-right mean colors.
*/
std::vector<std::array<int, 3>> quadrant_colors(const std::vector<std::uint8_t> &rgb, int w, int h) {
return {
mean_color(rgb, w, h, w / 4, h / 4),
mean_color(rgb, w, h, 3 * w / 4, h / 4),
mean_color(rgb, w, h, w / 4, 3 * h / 4),
mean_color(rgb, w, h, 3 * w / 4, 3 * h / 4),
};
}
/**
* @brief Compare sampled colors to the expectation.
*
* @param got Sampled colors.
* @param want Expected colors.
* @param tolerance Allowed per-channel difference.
* @return True when every channel is within tolerance.
*/
bool colors_match(const std::vector<std::array<int, 3>> &got, const std::vector<std::array<int, 3>> &want, int tolerance) {
for (std::size_t q = 0; q < want.size(); ++q) {
for (int c = 0; c < 3; ++c) {
if (std::abs(got[q][c] - want[q][c]) > tolerance) {
return false;
}
}
}
return true;
}
/**
* @brief Percentile of a sample set.
*
* @param values Samples.
* @param p Percentile in [0, 100].
* @return Percentile value, or 0 for no samples.
*/
double percentile(std::vector<double> values, double p) {
if (values.empty()) {
return 0;
}
std::ranges::sort(values);
auto index = (std::size_t) std::lround((p / 100.0) * (values.size() - 1));
return values[index];
}
/**
* @brief Approve the pending pairing through Sunshine's web API.
*
* @param opts Options.
* @param device_name Name the client paired with.
* @param pin PIN to submit.
* @return True when Sunshine accepted the PIN.
*/
bool approve_pin(const options_t &opts, const std::string &device_name, const std::string &pin) {
const auto api = "https://" + opts.host + ":" + std::to_string(opts.port + 1) + "/api/pin";
const auto deadline = std::chrono::steady_clock::now() + 60s;
while (std::chrono::steady_clock::now() < deadline) {
auto list = e2e::api_request("GET", api, opts.api_user, opts.api_pass, "");
std::smatch match;
std::regex entry {"\\{[^}]*\"id\"\\s*:\\s*\"([0-9a-f]{32})\"[^}]*\"name\"\\s*:\\s*\"" + device_name + "\"[^}]*\\}"};
std::regex entry_name_first {"\\{[^}]*\"name\"\\s*:\\s*\"" + device_name + "\"[^}]*\"id\"\\s*:\\s*\"([0-9a-f]{32})\"[^}]*\\}"};
if (list && (std::regex_search(*list, match, entry) || std::regex_search(*list, match, entry_name_first))) {
const auto body = "{\"pairing_id\":\"" + match[1].str() + "\",\"pin\":\"" + pin + "\",\"name\":\"" + device_name + "\"}";
auto result = e2e::api_request("POST", api, opts.api_user, opts.api_pass, body);
std::fprintf(stderr, "e2e: PIN approval response: %s\n", result ? result->c_str() : "none");
return result && result->find("\"status\":true") != std::string::npos;
}
std::this_thread::sleep_for(200ms);
}
std::fprintf(stderr, "e2e: pairing request never appeared in /api/pin\n");
return false;
}
/**
* @brief Write the last frame as a binary PPM.
*
* @param path Output path.
*/
void write_ppm(const std::string &path) {
std::lock_guard lock {state.mutex};
if (path.empty() || state.rgb.empty()) {
return;
}
std::ofstream out {path, std::ios::binary};
out << "P6\n"
<< state.rgb_width << ' ' << state.rgb_height << "\n255\n";
out.write((const char *) state.rgb.data(), (std::streamsize) state.rgb.size());
}
/**
* @brief Parse the command line.
*
* @param argc Argument count.
* @param argv Arguments.
* @return Options, or nothing on a usage error.
*/
std::optional<options_t> parse_args(int argc, char **argv) {
options_t opts;
std::map<std::string, std::string *> strings {
{"--host", &opts.host},
{"--api-user", &opts.api_user},
{"--api-pass", &opts.api_pass},
{"--state-dir", &opts.state_dir},
{"--app", &opts.app},
{"--expect", &opts.expect},
{"--out", &opts.out},
{"--summary", &opts.summary},
};
std::map<std::string, int *> ints {
{"--port", &opts.port},
{"--width", &opts.width},
{"--height", &opts.height},
{"--fps", &opts.fps},
{"--bitrate", &opts.bitrate},
{"--frames", &opts.min_frames},
{"--timeout", &opts.timeout_s},
{"--tolerance", &opts.tolerance},
};
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if (i + 1 >= argc) {
std::fprintf(stderr, "missing value for %s\n", arg.c_str());
return std::nullopt;
}
if (auto s = strings.find(arg); s != strings.end()) {
*s->second = argv[++i];
} else if (auto n = ints.find(arg); n != ints.end()) {
*n->second = std::stoi(argv[++i]);
} else {
std::fprintf(stderr, "unknown option %s\n", arg.c_str());
return std::nullopt;
}
}
return opts;
}
} // namespace
int main(int argc, char **argv) {
auto parsed = parse_args(argc, argv);
if (!parsed) {
return 2;
}
const auto opts = *parsed;
std::optional<std::vector<std::array<int, 3>>> expected;
if (!opts.expect.empty()) {
expected = parse_expect(opts.expect);
if (!expected) {
std::fprintf(stderr, "e2e: bad --expect value\n");
return 2;
}
}
auto identity = e2e::load_or_create_identity(opts.state_dir);
e2e::client_t client {opts.host, opts.port, identity};
auto info = client.server_info(false);
if (!info) {
std::fprintf(stderr, "e2e: serverinfo failed\n");
return 1;
}
std::fprintf(stderr, "e2e: host appversion=%s state=%s paired=%d\n", info->app_version.c_str(), info->state.c_str(), info->paired);
if (!client.server_info(true) || !client.server_info(true)->paired) {
const auto device_name = "e2e-" + identity.unique_id.substr(0, 8);
const auto pin_bytes = e2e::random_bytes(2);
char pin[5];
std::snprintf(pin, sizeof(pin), "%04d", ((pin_bytes[0] << 8) | pin_bytes[1]) % 10000);
std::fprintf(stderr, "e2e: pairing as %s\n", device_name.c_str());
if (!client.pair(pin, device_name, [&]() {
return approve_pin(opts, device_name, pin);
})) {
std::fprintf(stderr, "e2e: pairing failed\n");
return 1;
}
std::fprintf(stderr, "e2e: paired\n");
info = client.server_info(true);
if (!info || !info->paired) {
std::fprintf(stderr, "e2e: host doesn't report the client as paired\n");
return 1;
}
}
auto apps = client.app_list();
auto app = std::ranges::find_if(apps, [&](const e2e::app_t &a) {
return a.title == opts.app;
});
if (app == apps.end()) {
std::fprintf(stderr, "e2e: app [%s] not found (%zu apps)\n", opts.app.c_str(), apps.size());
return 1;
}
auto ri_key = e2e::random_bytes(16);
const int ri_key_id = 0x1234;
auto session_url = client.launch(app->id, opts.width, opts.height, opts.fps, ri_key, ri_key_id, LiGetLaunchUrlQueryParameters());
if (!session_url) {
return 1;
}
std::fprintf(stderr, "e2e: launched app %d, session %s\n", app->id, session_url->c_str());
SERVER_INFORMATION server;
LiInitializeServerInformation(&server);
server.address = opts.host.c_str();
server.serverInfoAppVersion = info->app_version.c_str();
server.serverInfoGfeVersion = info->gfe_version.c_str();
server.rtspSessionUrl = session_url->c_str();
server.serverCodecModeSupport = info->codec_mode_support;
STREAM_CONFIGURATION config;
LiInitializeStreamConfiguration(&config);
config.width = opts.width;
config.height = opts.height;
config.fps = opts.fps;
config.bitrate = opts.bitrate;
config.packetSize = 1392;
config.streamingRemotely = STREAM_CFG_LOCAL;
config.audioConfiguration = AUDIO_CONFIGURATION_STEREO;
config.supportedVideoFormats = VIDEO_FORMAT_H264;
config.encryptionFlags = ENCFLG_AUDIO;
std::memcpy(config.remoteInputAesKey, ri_key.data(), 16);
std::memset(config.remoteInputAesIv, 0, 16);
config.remoteInputAesIv[0] = (char) ((ri_key_id >> 24) & 0xff);
config.remoteInputAesIv[1] = (char) ((ri_key_id >> 16) & 0xff);
config.remoteInputAesIv[2] = (char) ((ri_key_id >> 8) & 0xff);
config.remoteInputAesIv[3] = (char) (ri_key_id & 0xff);
CONNECTION_LISTENER_CALLBACKS listener;
LiInitializeConnectionCallbacks(&listener);
listener.stageFailed = cl_stage_failed;
listener.connectionStarted = cl_connection_started;
listener.connectionTerminated = cl_connection_terminated;
listener.logMessage = cl_log;
DECODER_RENDERER_CALLBACKS decoder;
LiInitializeVideoCallbacks(&decoder);
decoder.setup = dr_setup;
decoder.cleanup = dr_cleanup;
decoder.submitDecodeUnit = dr_submit;
const auto stream_start = std::chrono::steady_clock::now();
if (LiStartConnection(&server, &config, &listener, &decoder, nullptr, nullptr, 0, nullptr, 0) != 0) {
std::fprintf(stderr, "e2e: LiStartConnection failed\n");
client.cancel();
return 1;
}
bool matched = false;
std::vector<std::array<int, 3>> last_colors;
const auto deadline = stream_start + std::chrono::seconds(opts.timeout_s);
while (std::chrono::steady_clock::now() < deadline && !state.terminated) {
std::this_thread::sleep_for(100ms);
std::lock_guard lock {state.mutex};
if (state.decoded < opts.min_frames || state.rgb.empty()) {
continue;
}
if (!expected) {
matched = true;
break;
}
last_colors = quadrant_colors(state.rgb, state.rgb_width, state.rgb_height);
if (colors_match(last_colors, *expected, opts.tolerance)) {
matched = true;
break;
}
}
const auto elapsed = std::chrono::duration<double>(std::chrono::steady_clock::now() - stream_start).count();
write_ppm(opts.out);
LiStopConnection();
client.cancel();
std::vector<double> latencies;
int decoded = 0;
{
std::lock_guard lock {state.mutex};
latencies = state.host_latency_ms;
decoded = state.decoded;
}
std::ostringstream summary;
summary << "{\"matched\":" << (matched ? "true" : "false") << ",\"decoded_frames\":" << decoded << ",\"seconds\":" << elapsed << ",\"host_latency_ms\":{\"samples\":" << latencies.size() << ",\"p50\":" << percentile(latencies, 50) << ",\"p95\":" << percentile(latencies, 95) << "},\"quadrants\":[";
for (std::size_t i = 0; i < last_colors.size(); ++i) {
summary << (i ? "," : "") << "[" << last_colors[i][0] << "," << last_colors[i][1] << "," << last_colors[i][2] << "]";
}
summary << "],\"terminated\":" << (state.terminated ? "true" : "false") << "}";
std::printf("%s\n", summary.str().c_str());
if (!opts.summary.empty()) {
std::ofstream {opts.summary} << summary.str() << "\n";
}
return matched ? 0 : 1;
}
tests/e2e/qemu/e2e_stream.sh +151 −0
@@ -1,0 +1,151 @@
#!/usr/bin/env bash
# @tag requirements: [REQ-E2E-001, REQ-NFR-001]
# End-to-end test (REQ-E2E-001): an unmodified Moonlight client streams a QEMU VM that has no guest agent.
#
# 1. start a private dbus-daemon and a QEMU guest that draws a known pattern (run_vm.sh)
# 2. start Sunshine with capture = qemu and software encoding on a unique port
# 3. pair a headless Moonlight client (PIN approved through Sunshine's web API), launch Desktop
# 4. decode the stream and check the quadrant colors of the guest pattern
# 5. tear everything down, also on failure
#
# Environment:
# SUNSHINE_BIN Sunshine binary (default: <repo>/cmake-build-debug/sunshine)
# CLIENT_BIN E2E client (default: <repo>/cmake-build-e2e-client/moonlight_e2e_client)
# QEMU QEMU binary (default: qemu-system-x86_64)
# E2E_CACHE cache for the guest image (default: ~/.cache/sunshine-qemu/e2e)
# E2E_ARTIFACTS where logs and the last frame go (default: <work-dir>/artifacts, kept on failure)
# E2E_PORT Sunshine base port (default: random in 21000-39900, step 100)
# E2E_TIMEOUT stream timeout in seconds (default: 90; raise it for TCG)
# E2E_WIDTH/E2E_HEIGHT/E2E_FPS stream mode (default: 1280x800 at 30 fps)
# E2E_FRAMES decoded frames to receive before checking (default: 30; raise for latency runs)
# E2E_KEEP set to 1 to keep the work directory
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "${script_dir}/../../.." && pwd)"
sunshine_bin="${SUNSHINE_BIN:-${repo}/cmake-build-debug/sunshine}"
client_bin="${CLIENT_BIN:-${repo}/cmake-build-e2e-client/moonlight_e2e_client}"
cache="${E2E_CACHE:-${HOME}/.cache/sunshine-qemu/e2e}"
port="${E2E_PORT:-$((21000 + (RANDOM % 190) * 100))}"
timeout_s="${E2E_TIMEOUT:-90}"
width="${E2E_WIDTH:-1280}"
height="${E2E_HEIGHT:-800}"
fps="${E2E_FPS:-30}"
frames="${E2E_FRAMES:-30}"
for bin in "${sunshine_bin}" "${client_bin}"; do
if [[ ! -x "${bin}" ]]; then
echo "missing ${bin}; build it first" >&2
exit 2
fi
done
work="$(mktemp -d "${TMPDIR:-/tmp}/sunshine-e2e.XXXXXX")"
artifacts="${E2E_ARTIFACTS:-${work}/artifacts}"
mkdir -p "${artifacts}"
result=1
cleanup() {
set +e
for pidfile in "${work}/sunshine.pid" "${work}/vm/qemu.pid" "${work}/vm/dbus.pid"; do
if [[ -f "${pidfile}" ]]; then
pid="$(cat "${pidfile}")"
kill "${pid}" 2>/dev/null
for _ in $(seq 1 50); do
kill -0 "${pid}" 2>/dev/null || break
sleep 0.1
done
kill -9 "${pid}" 2>/dev/null
fi
done
cp -f "${work}/sunshine.log" "${work}/sunshine.stdout" "${work}/client.log" "${work}/vm/qemu.log" "${work}/vm/dbus.log" "${artifacts}/" 2>/dev/null
if [[ ${result} == 0 && "${E2E_KEEP:-0}" != 1 && -z "${E2E_ARTIFACTS:-}" ]]; then
rm -rf "${work}"
else
echo "e2e: work directory kept at ${work} (artifacts: ${artifacts})" >&2
fi
}
trap cleanup EXIT
# 1. guest
image="${cache}/pattern-$(sha256sum "${script_dir}/guest/pattern.S" | cut -c1-16).img"
if [[ ! -f "${image}" ]]; then
"${script_dir}/guest/build_guest.sh" "${image}" > /dev/null
fi
bus_address="$("${script_dir}/run_vm.sh" "${work}/vm" "${image}")"
echo "e2e: VM up on ${bus_address}" >&2
# 2. Sunshine
cat > "${work}/apps.json" <<'EOF'
{
"env": {},
"apps": [
{"name": "Desktop", "image-path": "desktop.png"}
]
}
EOF
cat > "${work}/sunshine.conf" <<EOF
capture = qemu
qemu_dbus_address = ${bus_address}
encoder = software
port = ${port}
sunshine_name = sunshine-e2e-${port}
file_state = ${work}/sunshine_state.json
credentials_file = ${work}/sunshine_credentials.json
pkey = ${work}/sunshine.key
cert = ${work}/sunshine.crt
file_apps = ${work}/apps.json
log_path = ${work}/sunshine.log
min_log_level = debug
upnp = disabled
system_tray = disabled
origin_web_ui_allowed = pc
notify_pre_releases = disabled
EOF
api_user="e2e"
api_pass="$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')"
"${sunshine_bin}" "${work}/sunshine.conf" --creds "${api_user}" "${api_pass}" > "${work}/creds.log" 2>&1
"${sunshine_bin}" "${work}/sunshine.conf" > "${work}/sunshine.stdout" 2>&1 &
echo $! > "${work}/sunshine.pid"
for _ in $(seq 1 300); do
if ! kill -0 "$(cat "${work}/sunshine.pid")" 2>/dev/null; then
echo "e2e: Sunshine exited early" >&2
tail -50 "${work}/sunshine.stdout" >&2
exit 1
fi
if curl -s --max-time 1 "http://127.0.0.1:${port}/serverinfo" | grep -q "status_code=\"200\""; then
break
fi
sleep 0.1
done
echo "e2e: Sunshine up on port ${port}" >&2
# 3-4. pair, stream, check the pattern (red, green, blue, white quadrants)
set +e
"${client_bin}" \
--port "${port}" \
--api-user "${api_user}" \
--api-pass "${api_pass}" \
--state-dir "${work}/client" \
--width "${width}" --height "${height}" --fps "${fps}" \
--frames "${frames}" \
--timeout "${timeout_s}" \
--expect "255,0,0;0,255,0;0,0,255;255,255,255" \
--tolerance 48 \
--out "${artifacts}/last_frame.ppm" \
--summary "${artifacts}/summary.json" \
2> "${work}/client.log"
result=$?
set -e
if [[ ${result} == 0 ]]; then
echo "e2e: PASS $(cat "${artifacts}/summary.json")"
else
echo "e2e: FAIL (client exit ${result})" >&2
tail -40 "${work}/client.log" >&2
fi
exit "${result}"
tests/e2e/qemu/guest/build_guest.sh +28 −0
@@ -1,0 +1,28 @@
#!/usr/bin/env bash
# Build the test-pattern guest disk image.
#
# Usage: build_guest.sh <output-image>
#
# Produces a 1 MiB raw disk whose first sector is the boot sector from pattern.S.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
output="${1:?usage: build_guest.sh <output-image>}"
work="$(mktemp -d)"
trap 'rm -rf "${work}"' EXIT
as --32 -o "${work}/pattern.o" "${script_dir}/pattern.S"
ld -m elf_i386 -Ttext 0x7c00 -e _start --oformat binary -o "${work}/pattern.bin" "${work}/pattern.o"
size="$(stat -c %s "${work}/pattern.bin")"
if [[ "${size}" != 512 ]]; then
echo "boot sector is ${size} bytes, expected 512" >&2
exit 1
fi
mkdir -p "$(dirname "${output}")"
truncate -s 1M "${work}/disk.img"
dd if="${work}/pattern.bin" of="${work}/disk.img" conv=notrunc status=none
mv "${work}/disk.img" "${output}"
echo "${output}"
tests/e2e/qemu/guest/pattern.S +98 −0
@@ -1,0 +1,98 @@
/*
* Boot sector that draws a known test pattern, with no guest agent or OS.
*
* VGA mode 13h (320x200, 256 colors) with an explicit palette:
* top-left red, top-right green, bottom-left blue, bottom-right white.
* A 16x16 square in the center alternates between black and yellow on every BIOS timer tick
* (about 18 times a second), so QEMU keeps sending damage for it.
*
* Build: see build_guest.sh (GNU as + ld, no other tools).
*/
.code16
.globl _start
_start:
cli
xor %ax, %ax
mov %ax, %ds
mov %ax, %ss
mov $0x7c00, %sp
cld
sti
/* VGA mode 13h */
mov $0x0013, %ax
int $0x10
/* palette entries 1..6, 6-bit DAC values */
mov $0x3c8, %dx
mov $1, %al
outb %al, %dx
inc %dx
mov $palette, %si
mov $18, %cx
set_palette:
lodsb
outb %al, %dx
loop set_palette
/* quadrants: color = 1 + (x >= 160) + 2 * (y >= 100) */
mov $0xa000, %ax
mov %ax, %es
xor %di, %di
xor %bx, %bx
fill_row:
xor %cx, %cx
fill_col:
mov $1, %al
cmp $160, %cx
jb 1f
inc %al
1:
cmp $100, %bx
jb 2f
add $2, %al
2:
stosb
inc %cx
cmp $320, %cx
jb fill_col
inc %bx
cmp $200, %bx
jb fill_row
animate:
/* BIOS tick count in CX:DX; odd ticks draw yellow (6), even ticks black (5) */
xor %ah, %ah
int $0x1a
mov %dl, %al
and $1, %al
add $5, %al
mov $92, %bx
square_row:
/* di = y * 320 + 152 */
mov %bx, %di
shl $6, %di
mov %bx, %dx
shl $8, %dx
add %dx, %di
add $152, %di
mov $16, %cx
rep stosb
inc %bx
cmp $108, %bx
jb square_row
hlt
jmp animate
palette:
.byte 63, 0, 0 /* 1: red */
.byte 0, 63, 0 /* 2: green */
.byte 0, 0, 63 /* 3: blue */
.byte 63, 63, 63 /* 4: white */
.byte 0, 0, 0 /* 5: black */
.byte 63, 63, 0 /* 6: yellow */
.org 510
.word 0xaa55
tests/e2e/qemu/run_vm.sh +85 −0
@@ -1,0 +1,85 @@
#!/usr/bin/env bash
# Start a private dbus-daemon and a QEMU guest that exports its display and audio on it.
#
# Usage: run_vm.sh <work-dir> <disk-image> [extra qemu args...]
#
# Environment:
# QEMU QEMU binary (default: qemu-system-x86_64 from PATH)
# VM_NAME VM name (default: e2e-guest)
# VM_ACCEL "kvm" or "tcg" (default: kvm when /dev/kvm is usable, else tcg)
# VM_START_WAIT seconds to wait for org.qemu on the bus (default: 30)
#
# Writes into <work-dir>:
# bus.sock the private bus socket; bus address is unix:path=<work-dir>/bus.sock
# dbus.pid dbus-daemon pid
# qemu.pid QEMU pid
# qmp.sock QMP socket
# qemu.log QEMU stdout/stderr
# dbus.log dbus-daemon stdout/stderr
set -euo pipefail
work="${1:?usage: run_vm.sh <work-dir> <disk-image> [qemu args...]}"
image="${2:?usage: run_vm.sh <work-dir> <disk-image> [qemu args...]}"
shift 2
qemu="${QEMU:-qemu-system-x86_64}"
name="${VM_NAME:-e2e-guest}"
wait_s="${VM_START_WAIT:-30}"
if [[ -z "${VM_ACCEL:-}" ]]; then
if [[ -r /dev/kvm && -w /dev/kvm ]]; then
VM_ACCEL=kvm
else
VM_ACCEL=tcg
fi
fi
mkdir -p "${work}"
bus_socket="${work}/bus.sock"
bus_address="unix:path=${bus_socket}"
rm -f "${bus_socket}" "${work}/qmp.sock"
dbus-daemon --session --nofork --nopidfile --address="${bus_address}" > "${work}/dbus.log" 2>&1 &
echo $! > "${work}/dbus.pid"
for _ in $(seq 1 100); do
[[ -S "${bus_socket}" ]] && break
sleep 0.05
done
if [[ ! -S "${bus_socket}" ]]; then
echo "dbus-daemon didn't create ${bus_socket}" >&2
exit 1
fi
"${qemu}" \
-name "${name}" \
-accel "${VM_ACCEL}" \
-m 128 \
-nodefaults \
-device virtio-vga \
-display dbus,addr="${bus_address}" \
-audiodev dbus,id=snd0 \
-device intel-hda -device hda-output,audiodev=snd0 \
-device virtio-tablet-pci \
-device virtio-keyboard-pci \
-drive file="${image}",format=raw,if=ide,snapshot=on \
-qmp unix:"${work}/qmp.sock",server=on,wait=off \
"$@" > "${work}/qemu.log" 2>&1 &
echo $! > "${work}/qemu.pid"
for _ in $(seq 1 $((wait_s * 10))); do
if ! kill -0 "$(cat "${work}/qemu.pid")" 2>/dev/null; then
echo "QEMU exited early; see ${work}/qemu.log" >&2
cat "${work}/qemu.log" >&2
exit 1
fi
if gdbus call --address "${bus_address}" --dest org.freedesktop.DBus --object-path /org/freedesktop/DBus \
--method org.freedesktop.DBus.NameHasOwner org.qemu 2>/dev/null | grep -q true; then
echo "${bus_address}"
exit 0
fi
sleep 0.1
done
echo "QEMU didn't own org.qemu within ${wait_s}s" >&2
exit 1
tests/e2e/README.md +58 −0
@@ -1,0 +1,58 @@
# End-to-end tests
These tests run the real pipeline: a QEMU VM with no guest agent, Sunshine with `capture = qemu`,
and a headless Moonlight client that pairs, streams, decodes, and checks the picture. They are not
part of `test_sunshine`; `tests/CMakeLists.txt` excludes this directory.
## Layout
| Path | What it is |
|------------------------------|-------------------------------------------------------------------------------------------|
| `qemu/guest/pattern.S` | 512-byte boot sector: VGA mode 13h with red, green, blue, white quadrants and a blinking square |
| `qemu/guest/build_guest.sh` | Builds the guest disk image with GNU `as`/`ld` (no binaries are committed) |
| `qemu/run_vm.sh` | Starts a private `dbus-daemon` and QEMU with `-display dbus,addr=...` and `-audiodev dbus` |
| `qemu/e2e_stream.sh` | The end-to-end test (REQ-E2E-001), also used for latency numbers (REQ-NFR-001) |
| `moonlight_client/` | Headless client: GameStream pairing/launch plus moonlight-common-c and FFmpeg decoding |
## Requirements
- A Sunshine build with `SUNSHINE_ENABLE_QEMU=ON`.
- QEMU with D-Bus display (`qemu-system-x86_64 -display help | grep dbus`). Both paths are tested:
QEMU 8.2 (Ubuntu 24.04) sends pixel copies over D-Bus; QEMU 11.1 uses the shared memory map
(`org.qemu.Display1.Listener.Unix.Map`).
- `dbus-daemon`, `gdbus`, `curl`, GNU binutils, and for the client: libcurl, OpenSSL, FFmpeg
(`libavcodec`, `libswscale`) development packages.
- `/dev/kvm` is optional; without it the VM runs under TCG (set a longer `E2E_TIMEOUT`).
## Running
```bash
cmake -B cmake-build-debug -G Ninja -DSUNSHINE_ENABLE_QEMU=ON -DBUILD_TESTS=ON
ninja -C cmake-build-debug sunshine
cmake -S tests/e2e/moonlight_client -B cmake-build-e2e-client -G Ninja
ninja -C cmake-build-e2e-client
tests/e2e/qemu/e2e_stream.sh
```
On success it prints `e2e: PASS` and a JSON summary:
```json
{"matched":true,"decoded_frames":31,"seconds":1.82,"host_latency_ms":{"samples":31,"p50":17.2,"p95":32.9},
"quadrants":[[253,0,0],[0,254,0],[0,0,254],[255,255,255]],"terminated":false}
```
`host_latency_ms` is the frame processing latency Sunshine reports in each video frame header: the
time from receiving the QEMU display call to sending the encoded frame.
On failure, or when `E2E_ARTIFACTS` is set, the Sunshine, QEMU, dbus-daemon and client logs, the
last decoded frame (`last_frame.ppm`) and `summary.json` are kept. See the header of
`qemu/e2e_stream.sh` for all environment variables (binaries, port, stream mode, frame count).
## Latency runs
```bash
SUNSHINE_BIN=cmake-build-release/sunshine E2E_WIDTH=1920 E2E_HEIGHT=1080 E2E_FPS=60 E2E_FRAMES=600 \
E2E_ARTIFACTS=/tmp/latency tests/e2e/qemu/e2e_stream.sh
```