fangorn/sunshine-qemu
public
ref:main
/**
* @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;
}