ref:4ee628073b4f2c8e7a41d151e1cd8a5a3342ecf6

feat(linux): hand QEMU DMABUF scanouts to GPU encoders without copying

Add a display_vram_t for capture = qemu, modeled on kmsgrab's: the listener advertises Listener.Unix.ScanoutDMABUF2 and handles ScanoutDMABUF, ScanoutDMABUF2 and UpdateDMABUF; frames carry duplicated descriptors in an egl::img_descriptor_t for the VAAPI, CUDA and Vulkan encoders, with the display rectangle as the encoder crop. The descriptor sequence changes only with a new buffer, so the encoder keeps its EGL image across damage updates. Disable shows black, the cursor goes to the existing GL cursor shader, and bottom-up buffers (y0_top false) set y_invert, which the GL conversion path now honors with a flipped blit. platf::display() picks the vram display when QEMU sends DMABUFs and the RAM display (with upload) for shared memory, re-creating the display if QEMU switches. Software encoders read linear RGB DMABUFs back through mmap. VAAPI and NVENC refuse to start when QEMU renders on a different GPU (rendernode= from the QEMU command line, found through the bus owner's pid). Tested with memfd buffers through the fake QEMU. The EGL import test and the E2E_GL=1 E2E variant skip without /dev/udmabuf, GBM and /dev/dri, so the zero-copy path is not verified end to end yet. Refs #3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: 4ee628073b4f2c8e7a41d151e1cd8a5a3342ecf6
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-12 22:51
Parents: 95b9419
20 files changed +2172 -132
Type
cmake/compile_definitions/linux.cmake +2 −0
@@ -327,6 +327,8 @@
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/frame_store.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/pixel_format.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/pixel_format.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/render_node.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/render_node.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/session.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/session.cpp")
endif()
docs/configuration.md +4 −1
@@ -2226,7 +2226,10 @@
<td>qemu</td>
<td>Stream a QEMU virtual machine started with `-display dbus`, with no software in the guest.
Select the bus with [qemu_dbus_address](#qemu_dbus_address) and the console with
[output_name](#output_name). This method is never selected automatically.
[output_name](#output_name) (a console id or label). This method is never selected automatically.
With `-display dbus,gl=on` QEMU sends GPU buffers (DMABUF) that VAAPI, NVENC and Vulkan encode
without a copy; QEMU must render on the same GPU, so match QEMU's `rendernode=` with
[adapter_name](#adapter_name).
@note{Applies to Linux only.}</td>
</tr>
<tr>
JOURNAL.md +54 −0
@@ -162,3 +162,57 @@
ids, so a label fell back to the first display. `qemu_display_names()` now lists the console
that `output_name` names by label under that label (all others by id).
### DMABUF zero-copy (REQ-CAP-003) — implemented, not verified end to end
This host can't run it: no `/dev/dri` (QEMU refuses `gl=on`), no `/dev/udmabuf`, no GBM. What
exists and what is tested here:
- **Session:** the listener advertises `org.qemu.Display1.Listener.Unix.ScanoutDMABUF2` and handles
`ScanoutDMABUF`, `ScanoutDMABUF2` (up to 4 planes, offsets, strides, x/y inside the backing
buffer, `y0_top`) and `UpdateDMABUF`. Descriptors come out of the `GUnixFDList` with
`g_unix_fd_list_get` (a dup) and are owned by `fd_t`. Inconsistent plane counts get a D-Bus
error. Tested with memfds through the fake QEMU.
- **Frame store:** keeps the current buffer; a new scanout replaces (closes) the previous
descriptors, damage only bumps the frame sequence. GPU displays take duplicated descriptors per
frame (`dmabuf_if_newer`); the buffer generation changes only on a new scanout.
- **`display_vram_t`** (modeled on kmsgrab's): fills `egl::img_descriptor_t` (`sd.fds` dups,
pitches, offsets, fourcc, modifier, backing size) and hands the display rectangle's x/y to
`va::make_avcodec_encode_device(..., true)` / `cuda::make_avcodec_gl_encode_device` /
`vk::make_avcodec_encode_device_vram` as the crop offset, like kmsgrab does. The descriptor
sequence only increments for a new buffer, so the encoder keeps its EGL image across damage and
drops the old one (`rgb = egl::rgb_t {}`) when it imports the next buffer; images in the pool
own their descriptor dups until reused, so the buffer outlives QEMU's close. Disable hands out
sequence-0 images (the encoders draw black) and forces a re-import afterwards. Cursor: the
image/position go into the descriptor for the existing GL cursor shader, as in kmsgrab. A moved
display rectangle returns `reinit` (the crop is fixed per encoder).
- **`y0_top = false`:** `img_descriptor_t::y_invert` already existed (the Vulkan encoder honors
it); the GL path (VAAPI, CUDA) ignored it. `sws_t::load_vram` now takes the copy path with a
`glBlitFramebuffer` flip when `y_invert` is set. QEMU's own consumer (`egl_fb_blit`) crops in
buffer memory coordinates and then flips, which is what both the GL path and the CPU readback
do. Only QEMU sets `y_invert` on the GL path, so other capture backends are unaffected.
- **Display selection:** `platf::display()` for VAAPI/CUDA/Vulkan registers the listener, waits for
the first scanout, and creates `display_vram_t` for DMABUF or `display_ram_t` (RAM → GPU upload)
for shared memory. If QEMU switches between them mid-stream, the display asks for `reinit`. A
software encoder with DMABUF scanouts reads linear single-plane RGB buffers back via `mmap` (with
`DMA_BUF_IOCTL_SYNC`), cropping and flipping; tiled or multi-plane buffers fail display creation
with a clear error. The readback is tested with memfds; the `DMA_BUF_IOCTL_SYNC` calls are no-ops
on memfds.
- **Same GPU:** QEMU doesn't report its render node over D-Bus. The QEMU pid comes from
`GetConnectionUnixProcessID` for `org.qemu`, the node from `rendernode=` on its command line,
else QEMU's default (first `renderD*` in `/dev/dri` directory order, as
`qemu_drm_rendernode_open` does). VAAPI fails with an error naming both nodes when it differs
from `adapter_name`/`resolve_render_device()` (compared by `st_rdev`); NVENC fails when QEMU's
node isn't bound to the `nvidia` driver. When QEMU runs in another pid namespace (no readable
cmdline) the default node is assumed, which may be wrong; Vulkan only logs the node.
- **Tests that skip here:** `QemuDmabufGpuTest.ImportsCropsAndFlipsBottomUpScanout` (udmabuf +
GBM + EGL import + `copy_flipped` + `glReadPixels`) and
`QemuCaptureTest.VaapiRequiresQemuToRenderOnTheEncoderGpu` (needs a render node). They have
never run; the first native Linux run may need fixes.
- **E2E:** `E2E_GL=1` (optionally `E2E_RENDERNODE`) runs QEMU with `virtio-vga-gl` and
`-display dbus,gl=on`; it exits 77 (skip) without `/dev/dri/renderD*`. Not run with a GPU.
- **Open questions for native Linux:** QEMU blocks guest GL (`graphic_hw_gl_block`) until
`UpdateDMABUF` is answered; we answer immediately, so the encoder may read a buffer the guest is
already drawing the next frame into (tearing). Answering after the encoder imported the frame
would need deferred replies across threads. The DMABUF p95 ≤ 10 ms target (REQ-NFR-001) is
unmeasured.
src/platform/linux/graphics.cpp +20 −3
@@ -118,6 +118,19 @@
gl::ctx.CopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, offset_x, offset_y, width, height);
}
void frame_buf_t::copy_flipped(int id, int texture, int offset_x, int offset_y, int width, int height) {
auto target = frame_buf_t::make(1);
target.bind(&texture, &texture + 1);
gl::ctx.BindFramebuffer(GL_READ_FRAMEBUFFER, (*this)[id]);
gl::ctx.ReadBuffer(GL_COLOR_ATTACHMENT0 + id);
gl::ctx.BindFramebuffer(GL_DRAW_FRAMEBUFFER, target[0]);
GLenum attachment = GL_COLOR_ATTACHMENT0;
gl::ctx.DrawBuffers(1, &attachment);
gl::ctx.BlitFramebuffer(offset_x, offset_y, offset_x + width, offset_y + height, 0, height, width, 0, GL_COLOR_BUFFER_BIT, GL_NEAREST);
gl::ctx.BindFramebuffer(GL_FRAMEBUFFER, 0);
}
std::string shader_t::err_str() {
int length;
ctx.GetShaderiv(handle(), GL_INFO_LOG_LENGTH, &length);
@@ -1270,14 +1283,18 @@
}
void sws_t::load_vram(img_descriptor_t &img, int offset_x, int offset_y, int texture, bool is_yuv444) {
// When only a sub-part of the image must be encoded...
const bool copy = offset_x || offset_y || img.sd.width != in_width || img.sd.height != in_height;
// When only a sub-part of the image must be encoded, or the image is stored bottom row first...
const bool copy = img.y_invert || offset_x || offset_y || img.sd.width != in_width || img.sd.height != in_height;
if (copy) {
auto framebuf = gl::frame_buf_t::make(1);
framebuf.bind(&texture, &texture + 1);
loaded_texture = tex[0];
if (img.y_invert) {
framebuf.copy(0, loaded_texture, offset_x, offset_y, in_width, in_height);
framebuf.copy_flipped(0, loaded_texture, offset_x, offset_y, in_width, in_height);
} else {
framebuf.copy(0, loaded_texture, offset_x, offset_y, in_width, in_height);
}
} else {
loaded_texture = texture;
}
src/platform/linux/graphics.h +12 −0
@@ -195,6 +195,18 @@
* @param height Frame or display height in pixels.
*/
void copy(int id, int texture, int offset_x, int offset_y, int width, int height);
/**
* @brief Copy a part of the framebuffer to a texture, flipping it vertically.
*
* @param id Framebuffer index to copy from.
* @param texture Destination texture receiving the copied pixels.
* @param offset_x Source X offset in pixels.
* @param offset_y Source Y offset in pixels.
* @param width Width of the copied region in pixels.
* @param height Height of the copied region in pixels.
*/
void copy_flipped(int id, int texture, int offset_x, int offset_y, int width, int height);
};
/**
src/platform/linux/qemu/capture.cpp +380 −94
@@ -1,17 +1,25 @@
/**
* @file src/platform/linux/qemu/capture.cpp
* @brief Definitions for the QEMU D-Bus display capture backend (`capture = qemu`).
* @details Like kmsgrab there are two display classes: `display_ram_t` for frames in system
* memory (shared memory scanouts, or DMABUFs read back for software encoding) and
* `display_vram_t`, which hands DMABUF scanouts to the VAAPI, CUDA and Vulkan encoders without
* copying them.
*/
// standard includes
#include <cstring>
#include <limits>
#include <mutex>
#include <optional>
#include <thread>
// local includes
#include "frame_store.h"
#include "render_node.h"
#include "session.h"
#include "src/config.h"
#include "src/logging.h"
#include "src/platform/common.h"
#include "src/platform/linux/graphics.h"
#include "src/platform/linux/misc.h"
#include "src/video.h"
@@ -22,5 +30,8 @@
#ifdef SUNSHINE_BUILD_CUDA
#include "src/platform/linux/cuda.h"
#endif
#ifdef SUNSHINE_BUILD_VULKAN
#include "src/platform/linux/vulkan_encode.h"
#endif
using namespace std::literals;
@@ -37,82 +48,138 @@
};
/**
* @brief A listener registered on one console, with the frame it reconstructs.
* @brief Display backend that streams a QEMU console from shared memory scanouts.
*/
class display_t: public platf::display_t {
public:
/**
* @brief Create an uninitialized display.
*
* @param mem_type Memory type the encoder expects.
*/
explicit display_t(platf::mem_type_e mem_type):
mem_type {mem_type} {
struct console_connection_t {
std::shared_ptr<session_t> session; ///< Session to the VM.
std::shared_ptr<frame_store_t> store; ///< Frame reconstructed from listener calls.
std::unique_ptr<listener_registration_t> registration; ///< Keeps the listener registered.
console_info_t console; ///< The selected console.
std::string vm_name; ///< VM name, for logs.
int width {0}; ///< Frame width at registration.
int height {0}; ///< Frame height at registration.
};
/**
* @brief Connect to QEMU, register a listener on the selected console, and wait for a frame.
*
* @param display_name Console id or label; empty selects the first graphical console.
* @param dmabuf_readback Whether DMABUF scanouts should be read back into system memory.
* @return The connection, or nothing on failure.
*/
std::optional<console_connection_t> connect_console(const std::string &display_name, bool dmabuf_readback) {
console_connection_t conn;
conn.session = shared_session(config::video.qemu_dbus_address);
if (!conn.session) {
return std::nullopt;
}
/**
* @brief Connect to QEMU, register a listener on the selected console, and wait for a frame.
*
* @param display_name Console id or label; empty selects the first graphical console.
auto vm = conn.session->vm();
auto console = find_console(vm, display_name);
if (!console) {
BOOST_LOG(warning) << "qemu: console ["sv << display_name << "] not found, using the first graphical console"sv;
console = find_console(vm, "");
}
if (!console) {
BOOST_LOG(error) << "qemu: VM ["sv << vm.name << "] has no graphical console"sv;
return std::nullopt;
}
conn.console = *console;
conn.vm_name = vm.name;
* @param config Stream configuration.
* @return 0 on success, -1 on failure.
*/
int init(const std::string &display_name, const ::video::config_t &config) {
delay = ::video::capture_frame_interval(config);
conn.store = std::make_shared<frame_store_t>(dmabuf_readback);
conn.registration = conn.session->register_listener(console->id, conn.store);
if (!conn.registration) {
session = shared_session(config::video.qemu_dbus_address);
if (!session) {
return std::nullopt;
}
return -1;
}
auto vm = session->vm();
// QEMU sends the current surface right after registration
if (conn.store->wait_for_frame(2s)) {
conn.width = conn.store->width();
conn.height = conn.store->height();
} else {
BOOST_LOG(warning) << "qemu: no scanout received yet; using the console size"sv;
conn.width = (int) console->width;
conn.height = (int) console->height;
}
if (conn.width <= 0 || conn.height <= 0) {
BOOST_LOG(error) << "qemu: console "sv << console->id << " has no size"sv;
return std::nullopt;
}
return conn;
}
auto console = find_console(vm, display_name);
if (!console) {
BOOST_LOG(warning) << "qemu: console ["sv << display_name << "] not found, using the first graphical console"sv;
console = find_console(vm, "");
}
if (!console) {
BOOST_LOG(error) << "qemu: VM ["sv << vm.name << "] has no graphical console"sv;
return -1;
}
/**
* @brief Check that QEMU renders DMABUF scanouts on the GPU the encoder uses.
*
* @param mem_type Memory type of the encoder.
* @param session Session to the VM, to find the QEMU process.
* @return False when the GPUs definitely differ.
store = std::make_shared<frame_store_t>();
registration = session->register_listener(console->id, store);
if (!registration) {
return -1;
}
*/
bool render_node_matches(platf::mem_type_e mem_type, const session_t &session) {
const auto qemu_node = qemu_render_node(session.qemu_pid());
if (!qemu_node) {
BOOST_LOG(warning) << "qemu: can't tell which render node QEMU uses; make sure it is the GPU Sunshine encodes on"sv;
return true;
}
switch (mem_type) {
case platf::mem_type_e::vaapi:
// QEMU sends the current surface right after registration
if (store->wait_for_frame(2s)) {
width = store->width();
height = store->height();
} else {
BOOST_LOG(warning) << "qemu: no scanout received yet; using the console size"sv;
width = (int) console->width;
height = (int) console->height;
{
const auto encoder_node = platf::resolve_render_device();
if (!same_device(*qemu_node, encoder_node)) {
BOOST_LOG(error) << "qemu: QEMU renders on "sv << *qemu_node << " but VAAPI encodes on "sv << encoder_node << "; set adapter_name = "sv << *qemu_node << " or start QEMU with rendernode="sv << encoder_node;
return false;
}
return true;
}
case platf::mem_type_e::cuda:
{
const auto driver = render_node_driver(*qemu_node);
if (!driver.empty() && driver != "nvidia") {
BOOST_LOG(error) << "qemu: QEMU renders on "sv << *qemu_node << " ("sv << driver << ") but NVENC needs an NVIDIA render node; start QEMU with rendernode= set to the NVIDIA GPU"sv;
return false;
}
return true;
}
default:
BOOST_LOG(info) << "qemu: QEMU renders on "sv << *qemu_node;
return true;
}
}
}
if (width <= 0 || height <= 0) {
BOOST_LOG(error) << "qemu: console "sv << console->id << " has no size"sv;
return -1;
}
/**
* @brief Display backend that streams one QEMU console; subclasses decide how frames are handed out.
*/
class display_base_t: public platf::display_t {
public:
/**
* @brief Take over a registered console connection.
*
* @param mem_type Memory type the encoder expects.
* @param conn Registered console.
* @param config Stream configuration.
*/
display_base_t(platf::mem_type_e mem_type, console_connection_t &&conn, const ::video::config_t &config):
mem_type {mem_type},
delay {::video::capture_frame_interval(config)},
conn {std::move(conn)} {
width = this->conn.width;
height = this->conn.height;
env_width = width;
env_height = height;
logical_width = width;
logical_height = height;
env_logical_width = width;
env_logical_height = height;
BOOST_LOG(info) << "qemu: streaming VM ["sv << vm.name << "] console "sv << console->id << " ["sv << console->label << "] at "sv << width << 'x' << height;
return 0;
}
/**
* @brief Push frames as soon as the guest damages the display, at most one per client frame interval.
* @details Waits on the frame store instead of a fixed tick, so damage is copied right after
* QEMU reports it. Without damage, the pipeline gets a heartbeat once per frame interval (so it
* can stop or reconfigure the capture) and the encoder's minimum frame rate repeats the last
* frame. The listener thread never waits for this loop.
* @details Waits on the frame store instead of a fixed tick, so damage is handed to the encoder
* right after QEMU reports it. Without damage, the pipeline gets a heartbeat once per frame
* interval (so it can stop or reconfigure the capture) and the encoder's minimum frame rate
* repeats the last frame. The listener thread never waits for this loop.
*
* @param push_captured_image_cb Callback receiving captured images and heartbeats.
* @param pull_free_image_cb Callback providing an image to fill.
@@ -125,7 +192,7 @@
while (true) {
auto now = std::chrono::steady_clock::now();
if (store->wait_for_change(copied_sequence, now + delay)) {
if (conn.store->wait_for_change(copied_sequence, now + delay)) {
// rate limit: never push more often than the client's frame interval
auto earliest = last_push + delay;
if (std::chrono::steady_clock::now() < earliest) {
@@ -134,7 +201,10 @@
}
std::shared_ptr<platf::img_t> img_out;
auto status = platf::capture_e::reinit;
auto status = snapshot(pull_free_image_cb, img_out, cursor && *cursor);
if (!gone_or_resized()) {
status = snapshot(pull_free_image_cb, img_out, cursor && *cursor);
}
switch (status) {
case platf::capture_e::reinit:
case platf::capture_e::error:
@@ -147,6 +217,10 @@
break;
case platf::capture_e::ok:
last_push = std::chrono::steady_clock::now();
if (img_out->frame_timestamp) {
damage_to_capture_logger.first_point(*img_out->frame_timestamp);
damage_to_capture_logger.second_point_now_and_log();
}
if (!push_captured_image_cb(std::move(img_out), true)) {
return platf::capture_e::ok;
}
@@ -158,19 +232,86 @@
}
}
protected:
/**
* @brief Copy the latest frame if the guest changed it.
* @brief Hand out the latest frame if the guest changed it.
*
* @param pull_free_image_cb Callback providing an image to fill.
* @param img_out Filled image on `ok`.
* @param draw_cursor Whether to draw the guest cursor.
* @return `ok` with a new frame, `timeout` when nothing changed, `reinit` when the scanout
* changed in a way that needs a new display, or `interrupted`.
*/
* @param draw_cursor Whether to blend the guest cursor into the image.
virtual platf::capture_e snapshot(const pull_free_image_cb_t &pull_free_image_cb, std::shared_ptr<platf::img_t> &img_out, bool draw_cursor) = 0;
/**
* @brief Report whether QEMU went away or the scanout size changed.
*
* @return True when the display has to be re-created.
* @return `ok` with a new frame, `timeout` when nothing changed, `reinit` when the size
* changed or QEMU went away, or `interrupted`.
*/
[[nodiscard]] bool gone_or_resized() const {
return !conn.session->alive() || !conn.store->connected() || conn.store->width() != width || conn.store->height() != height;
}
platf::mem_type_e mem_type; ///< Memory type the encoder expects.
std::chrono::nanoseconds delay; ///< Client frame interval.
console_connection_t conn; ///< Registered console.
std::uint64_t copied_sequence {0}; ///< Store sequence of the last frame handed out.
private:
logging::time_delta_periodic_logger damage_to_capture_logger {debug, "qemu: damage received to frame captured"}; ///< Latency from QEMU's call to the capture.
};
platf::capture_e snapshot(const pull_free_image_cb_t &pull_free_image_cb, std::shared_ptr<platf::img_t> &img_out, bool draw_cursor) {
if (!session->alive() || !store->connected() || store->width() != width || store->height() != height) {
/**
* @brief Display that copies frames into system memory, for software encoders or an upload to the GPU.
*/
class display_ram_t: public display_base_t {
public:
using display_base_t::display_base_t;
std::shared_ptr<platf::img_t> alloc_img() override {
auto img = std::make_shared<qemu::img_t>();
img->width = width;
img->height = height;
img->pixel_pitch = 4;
img->row_pitch = img->pixel_pitch * width;
img->data = new std::uint8_t[(std::size_t) height * img->row_pitch];
return img;
}
int dummy_img(platf::img_t *img) override {
if (!img) {
return -1;
}
std::memset(img->data, 0, (std::size_t) img->height * img->row_pitch);
return 0;
}
std::unique_ptr<platf::avcodec_encode_device_t> make_avcodec_encode_device(platf::pix_fmt_e pix_fmt) override {
#ifdef SUNSHINE_BUILD_VAAPI
if (mem_type == platf::mem_type_e::vaapi) {
return va::make_avcodec_encode_device(width, height, false);
}
#endif
#ifdef SUNSHINE_BUILD_CUDA
if (mem_type == platf::mem_type_e::cuda) {
return cuda::make_avcodec_encode_device(width, height, false);
}
#endif
#ifdef SUNSHINE_BUILD_VULKAN
if (mem_type == platf::mem_type_e::vulkan) {
return vk::make_avcodec_encode_device_ram(width, height);
}
#endif
return std::make_unique<platf::avcodec_encode_device_t>();
}
protected:
platf::capture_e snapshot(const pull_free_image_cb_t &pull_free_image_cb, std::shared_ptr<platf::img_t> &img_out, bool draw_cursor) override {
if (mem_type != platf::mem_type_e::system && conn.store->kind() == scanout_kind_e::dmabuf) {
BOOST_LOG(info) << "qemu: QEMU switched to DMABUF scanouts; re-creating the display for zero-copy encoding"sv;
return platf::capture_e::reinit;
}
if (store->sequence() == copied_sequence) {
if (conn.store->sequence() == copied_sequence) {
return platf::capture_e::timeout;
}
@@ -180,60 +321,162 @@
}
std::chrono::steady_clock::time_point timestamp;
switch (store->copy_if_newer(copied_sequence, width, height, img_out->data, timestamp, draw_cursor)) {
switch (conn.store->copy_if_newer(copied_sequence, width, height, img_out->data, timestamp, draw_cursor)) {
case frame_status_e::new_frame:
img_out->frame_timestamp = timestamp;
damage_to_capture_logger.first_point(timestamp);
damage_to_capture_logger.second_point_now_and_log();
return platf::capture_e::ok;
case frame_status_e::unchanged:
return platf::capture_e::timeout;
case frame_status_e::size_changed:
case frame_status_e::disconnected:
default:
return platf::capture_e::reinit;
}
}
};
/**
* @brief Display that hands DMABUF scanouts to a GPU encoder without copying them.
* @details Modeled on kmsgrab's `display_vram_t`: each frame carries duplicated descriptors in an
* `egl::img_descriptor_t`, which the encoder imports with `egl::import_source`. The descriptor
* sequence only changes when QEMU scans out a new buffer, so the encoder keeps its imported EGL
* image across damage updates and releases the previous one when the next buffer arrives.
*/
class display_vram_t: public display_base_t {
public:
/**
* @brief Take over a console that sends DMABUF scanouts.
*
* @param mem_type Memory type the encoder expects.
* @param conn Registered console.
* @param config Stream configuration.
* @param offset_x Left edge of the display rectangle in the buffer.
* @param offset_y Top edge of the display rectangle in the buffer.
*/
display_vram_t(platf::mem_type_e mem_type, console_connection_t &&conn, const ::video::config_t &config, int offset_x, int offset_y):
display_base_t(mem_type, std::move(conn), config),
offset_x {offset_x},
offset_y {offset_y} {
}
std::shared_ptr<platf::img_t> alloc_img() override {
auto img = std::make_shared<qemu::img_t>();
auto img = std::make_shared<egl::img_descriptor_t>();
img->width = width;
img->height = height;
img->serial = std::numeric_limits<decltype(img->serial)>::max();
img->data = nullptr;
img->pixel_pitch = 4;
img->row_pitch = img->pixel_pitch * width;
img->data = new std::uint8_t[(std::size_t) height * img->row_pitch];
img->sequence = 0;
std::fill_n(img->sd.fds, 4, -1);
return img;
}
int dummy_img(platf::img_t *img) override {
if (!img) {
return -1;
}
std::memset(img->data, 0, (std::size_t) img->height * img->row_pitch);
// images with sequence 0 are drawn as black by the encoder
return img ? 0 : -1;
return 0;
}
std::unique_ptr<platf::avcodec_encode_device_t> make_avcodec_encode_device(platf::pix_fmt_e pix_fmt) override {
#ifdef SUNSHINE_BUILD_VAAPI
if (mem_type == platf::mem_type_e::vaapi) {
return va::make_avcodec_encode_device(width, height, offset_x, offset_y, true);
}
#endif
#ifdef SUNSHINE_BUILD_VULKAN
if (mem_type == platf::mem_type_e::vulkan) {
return vk::make_avcodec_encode_device_vram(width, height, offset_x, offset_y);
return va::make_avcodec_encode_device(width, height, false);
}
#endif
#ifdef SUNSHINE_BUILD_CUDA
if (mem_type == platf::mem_type_e::cuda) {
return cuda::make_avcodec_encode_device(width, height, false);
return cuda::make_avcodec_gl_encode_device(width, height, offset_x, offset_y);
}
#endif
BOOST_LOG(error) << "qemu: unsupported pixel format for DMABUF capture: "sv << platf::from_pix_fmt(pix_fmt);
return nullptr;
}
protected:
platf::capture_e snapshot(const pull_free_image_cb_t &pull_free_image_cb, std::shared_ptr<platf::img_t> &img_out, bool draw_cursor) override {
if (conn.store->sequence() == copied_sequence) {
return platf::capture_e::timeout;
}
dmabuf_frame_t frame;
switch (conn.store->dmabuf_if_newer(copied_sequence, width, height, frame)) {
case frame_status_e::new_frame:
break;
case frame_status_e::unchanged:
return platf::capture_e::timeout;
default:
return platf::capture_e::reinit;
}
if (!frame.disabled && ((int) frame.buffer.x != offset_x || (int) frame.buffer.y != offset_y)) {
BOOST_LOG(info) << "qemu: DMABUF scanout moved to +"sv << frame.buffer.x << '+' << frame.buffer.y << "; re-creating the display"sv;
return platf::capture_e::reinit;
}
if (!pull_free_image_cb(img_out)) {
return platf::capture_e::interrupted;
}
auto img = (egl::img_descriptor_t *) img_out.get();
img->reset();
img->frame_timestamp = frame.timestamp;
if (frame.disabled) {
// sequence 0 makes the encoder draw black; import the buffer again once the display is back
img->sequence = 0;
img->width = width;
img->height = height;
img->data = nullptr;
reimport = true;
return platf::capture_e::ok;
}
if (frame.generation != imported_generation || reimport) {
imported_generation = frame.generation;
reimport = false;
sequence += 1;
}
img->sequence = sequence;
img->y_invert = !frame.buffer.y0_top;
auto &sd = img->sd;
sd.width = (int) frame.buffer.backing_width;
sd.height = (int) frame.buffer.backing_height;
sd.fourcc = frame.buffer.fourcc;
sd.modifier = frame.buffer.modifier;
for (std::uint32_t plane = 0; plane < 4; ++plane) {
const bool used = plane < frame.buffer.num_planes;
sd.fds[plane] = used ? frame.buffer.fds[plane].release() : -1;
sd.pitches[plane] = used ? frame.buffer.strides[plane] : 0;
sd.offsets[plane] = used ? frame.buffer.offsets[plane] : 0;
}
auto cursor = draw_cursor ? conn.store->cursor(img->serial) : cursor_state_t {};
if (cursor.drawable()) {
if (img->serial != cursor.serial) {
img->buffer = std::move(cursor.pixels);
img->serial = cursor.serial;
}
img->x = cursor.x - cursor.hot_x;
img->y = cursor.y - cursor.hot_y;
img->src_w = cursor.width;
img->src_h = cursor.height;
return std::make_unique<platf::avcodec_encode_device_t>();
img->width = cursor.width;
img->height = cursor.height;
img->pixel_pitch = 4;
img->row_pitch = img->pixel_pitch * img->width;
img->data = img->buffer.data();
} else {
img->data = nullptr;
}
return platf::capture_e::ok;
}
private:
platf::mem_type_e mem_type; ///< Memory type the encoder expects.
std::chrono::nanoseconds delay {}; ///< Capture interval for the client frame rate.
std::shared_ptr<session_t> session; ///< Session to the VM.
std::shared_ptr<frame_store_t> store; ///< Frame reconstructed from listener calls.
std::unique_ptr<listener_registration_t> registration; ///< Keeps the listener registered.
std::uint64_t copied_sequence {0}; ///< Store sequence of the last copied frame.
logging::time_delta_periodic_logger damage_to_capture_logger {debug, "qemu: damage received to frame captured"}; ///< Latency from QEMU's call to the capture copy.
int offset_x; ///< Left edge of the display rectangle in the buffer.
int offset_y; ///< Top edge of the display rectangle in the buffer.
std::uint64_t sequence {0}; ///< Descriptor sequence; incremented when the encoder must import a new buffer.
std::uint64_t imported_generation {0}; ///< Store generation of the buffer the encoder imported last.
bool reimport {false}; ///< Whether a black frame replaced the imported buffer.
};
} // namespace qemu
@@ -254,8 +497,10 @@
/**
* @brief List the graphical consoles of the VM.
* @details The console selected by label in `output_name` is listed under its label, so
* Sunshine's display selection finds it.
*
* @return Console ids, or an empty list when QEMU is unreachable.
* @return Console names, or an empty list when QEMU is unreachable.
*/
std::vector<std::string> qemu_display_names() {
auto session = qemu::shared_session(config::video.qemu_dbus_address);
@@ -271,6 +516,8 @@
/**
* @brief Create a QEMU display capture backend.
* @details GPU encoders get the zero-copy DMABUF display when QEMU sends DMABUF scanouts, and
* the system-memory display with an upload otherwise.
*
* @param hwdevice_type Memory type the encoder expects.
* @param display_name Console id or label.
@@ -278,15 +525,54 @@
* @return Display backend, or nullptr on failure.
*/
std::shared_ptr<display_t> qemu_display(mem_type_e hwdevice_type, const std::string &display_name, const video::config_t &config) {
if (hwdevice_type != mem_type_e::system && hwdevice_type != mem_type_e::vaapi && hwdevice_type != mem_type_e::cuda) {
if (hwdevice_type != mem_type_e::system && hwdevice_type != mem_type_e::vaapi && hwdevice_type != mem_type_e::cuda && hwdevice_type != mem_type_e::vulkan) {
BOOST_LOG(error) << "qemu: could not initialize display with the given hw device type"sv;
return nullptr;
}
#ifndef SUNSHINE_BUILD_CUDA
if (hwdevice_type == mem_type_e::cuda) {
BOOST_LOG(warning) << "qemu: attempting to use NVENC without CUDA support"sv;
return nullptr;
}
#endif
const bool gpu = hwdevice_type != mem_type_e::system;
auto display = std::make_shared<qemu::display_t>(hwdevice_type);
if (display->init(display_name, config)) {
auto conn = qemu::connect_console(display_name, !gpu);
if (!conn) {
return nullptr;
}
const auto console_id = conn->console.id;
const auto label = conn->console.label;
const auto vm_name = conn->vm_name;
std::shared_ptr<display_t> display;
if (conn->store->kind() == qemu::scanout_kind_e::dmabuf) {
if (!gpu && !conn->store->dmabuf_readable()) {
BOOST_LOG(error) << "qemu: QEMU sends DMABUF scanouts that can't be read from system memory; use a hardware encoder"sv;
return nullptr;
}
if (gpu) {
if (!qemu::render_node_matches(hwdevice_type, *conn->session)) {
return nullptr;
}
qemu::dmabuf_frame_t frame;
std::uint64_t probe_sequence = 0;
int offset_x = 0;
int offset_y = 0;
if (conn->store->dmabuf_if_newer(probe_sequence, conn->width, conn->height, frame) == qemu::frame_status_e::new_frame) {
offset_x = (int) frame.buffer.x;
offset_y = (int) frame.buffer.y;
}
display = std::make_shared<qemu::display_vram_t>(hwdevice_type, std::move(*conn), config, offset_x, offset_y);
}
} else if (gpu) {
BOOST_LOG(info) << "qemu: QEMU sends shared memory scanouts; uploading frames to the GPU"sv;
}
if (!display) {
display = std::make_shared<qemu::display_ram_t>(hwdevice_type, std::move(*conn), config);
}
BOOST_LOG(info) << "qemu: streaming VM ["sv << vm_name << "] console "sv << console_id << " ["sv << label << "] at "sv << display->width << 'x' << display->height;
return display;
}
} // namespace platf
src/platform/linux/qemu/frame_store.cpp +184 −5
@@ -7,11 +7,17 @@
// standard includes
#include <algorithm>
#include <array>
#include <cstring>
#include <string_view>
// platform includes
#include <fcntl.h>
#include <linux/dma-buf.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
// local includes
#include "src/logging.h"
@@ -97,9 +103,14 @@
}
}
frame_store_t::frame_store_t(bool dmabuf_readback):
readback {dmabuf_readback} {
}
frame_store_t::~frame_store_t() {
std::lock_guard lock {mutex};
unmap_locked();
release_dmabuf_locked();
}
void frame_store_t::scanout(std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) {
@@ -114,6 +125,9 @@
std::lock_guard lock {mutex};
unmap_locked();
release_dmabuf_locked();
scanout_kind = scanout_kind_e::memory;
is_disabled = false;
resize_locked((int) width, (int) height);
blit_locked(0, 0, (int) width, (int) height, data.data(), stride, *layout);
touch_locked();
@@ -130,6 +144,6 @@
}
std::lock_guard lock {mutex};
if (frame_width == 0 || map_addr) {
if (scanout_kind != scanout_kind_e::memory || map_addr) {
return;
}
@@ -174,6 +188,9 @@
std::lock_guard lock {mutex};
unmap_locked();
release_dmabuf_locked();
scanout_kind = scanout_kind_e::memory;
is_disabled = false;
map_fd = std::move(fd);
map_addr = (const std::uint8_t *) addr;
map_size = size;
@@ -205,8 +222,56 @@
touch_locked();
}
void frame_store_t::scanout_dmabuf(dmabuf_scanout_t buffer) {
const auto num_planes = buffer.num_planes;
if (num_planes < 1 || num_planes > buffer.fds.size() || buffer.fds[0].get() < 0 || buffer.width == 0 || buffer.height == 0 || (std::uint64_t) buffer.x + buffer.width > buffer.backing_width || (std::uint64_t) buffer.y + buffer.height > buffer.backing_height) {
BOOST_LOG(warning) << "qemu: ignoring DMABUF scanout "sv << buffer.width << 'x' << buffer.height << '+' << buffer.x << '+' << buffer.y << " of a "sv << buffer.backing_width << 'x' << buffer.backing_height << " buffer with "sv << num_planes << " plane(s)"sv;
return;
}
for (std::uint32_t plane = 1; plane < num_planes; ++plane) {
if (buffer.fds[plane].get() < 0) {
BOOST_LOG(warning) << "qemu: ignoring DMABUF scanout without a descriptor for plane "sv << plane;
return;
}
}
std::lock_guard lock {mutex};
unmap_locked();
release_dmabuf_locked();
dmabuf = std::move(buffer);
dmabuf_generation += 1;
scanout_kind = scanout_kind_e::dmabuf;
is_disabled = false;
resize_locked((int) dmabuf.width, (int) dmabuf.height);
if (readback) {
map_dmabuf_locked();
read_dmabuf_locked(0, 0, frame_width, frame_height);
}
touch_locked();
}
void frame_store_t::update_dmabuf(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) {
std::lock_guard lock {mutex};
if (scanout_kind != scanout_kind_e::dmabuf) {
return;
}
int x0 = std::max(x, 0);
int y0 = std::max(y, 0);
int x1 = std::min<std::int64_t>((std::int64_t) x + width, frame_width);
int y1 = std::min<std::int64_t>((std::int64_t) y + height, frame_height);
if (x0 >= x1 || y0 >= y1) {
return;
}
is_disabled = false;
read_dmabuf_locked(x0, y0, x1 - x0, y1 - y0);
touch_locked();
}
void frame_store_t::disable() {
std::lock_guard lock {mutex};
is_disabled = true;
std::ranges::fill(pixels, 0);
if (frame_width > 0) {
touch_locked();
@@ -294,8 +359,18 @@
bool frame_store_t::connected() const {
std::lock_guard lock {mutex};
return !is_disconnected;
}
scanout_kind_e frame_store_t::kind() const {
std::lock_guard lock {mutex};
return scanout_kind;
}
bool frame_store_t::dmabuf_readable() const {
std::lock_guard lock {mutex};
return dmabuf_addr != nullptr;
}
std::uint64_t frame_store_t::sequence() const {
std::lock_guard lock {mutex};
return change_sequence;
@@ -322,11 +397,115 @@
return frame_status_e::new_frame;
}
frame_status_e frame_store_t::dmabuf_if_newer(std::uint64_t &last_sequence, int width, int height, dmabuf_frame_t &frame) {
std::lock_guard lock {mutex};
if (is_disconnected) {
return frame_status_e::disconnected;
}
if (scanout_kind != scanout_kind_e::dmabuf || width != frame_width || height != frame_height) {
return frame_status_e::size_changed;
}
if (change_sequence == last_sequence) {
return frame_status_e::unchanged;
}
dmabuf_scanout_t copy;
for (std::uint32_t plane = 0; plane < dmabuf.num_planes; ++plane) {
copy.fds[plane] = fd_t {fcntl(dmabuf.fds[plane].get(), F_DUPFD_CLOEXEC, 3)};
if (copy.fds[plane].get() < 0) {
BOOST_LOG(error) << "qemu: couldn't duplicate DMABUF descriptor: "sv << std::strerror(errno);
return frame_status_e::unchanged;
}
}
copy.num_planes = dmabuf.num_planes;
copy.offsets = dmabuf.offsets;
copy.strides = dmabuf.strides;
copy.x = dmabuf.x;
copy.y = dmabuf.y;
copy.width = dmabuf.width;
copy.height = dmabuf.height;
copy.backing_width = dmabuf.backing_width;
copy.backing_height = dmabuf.backing_height;
copy.fourcc = dmabuf.fourcc;
copy.modifier = dmabuf.modifier;
copy.y0_top = dmabuf.y0_top;
frame.buffer = std::move(copy);
frame.generation = dmabuf_generation;
frame.disabled = is_disabled;
frame.timestamp = change_time;
last_sequence = change_sequence;
return frame_status_e::new_frame;
}
void frame_store_t::map_dmabuf_locked() {
const auto layout = pixman_from_drm_fourcc(dmabuf.fourcc).and_then(pixman_layout);
const bool linear = dmabuf.modifier == drm_fourcc::mod_linear || dmabuf.modifier == drm_fourcc::mod_invalid;
if (!layout || !linear || dmabuf.num_planes != 1) {
BOOST_LOG(warning) << "qemu: can't read DMABUF fourcc 0x"sv << std::hex << dmabuf.fourcc << " modifier 0x"sv << dmabuf.modifier << std::dec << " with "sv << dmabuf.num_planes << " plane(s) from system memory; use a hardware encoder"sv;
return;
}
const auto fd = dmabuf.fds[0].get();
const auto size = lseek(fd, 0, SEEK_END);
const std::uint64_t needed = (std::uint64_t) dmabuf.offsets[0] + (std::uint64_t) dmabuf.strides[0] * (dmabuf.backing_height - 1) + (std::uint64_t) dmabuf.backing_width * layout->bytes_per_pixel;
if (size <= 0 || (std::uint64_t) size < needed || dmabuf.strides[0] < dmabuf.backing_width * layout->bytes_per_pixel) {
BOOST_LOG(warning) << "qemu: DMABUF of "sv << size << " bytes doesn't cover a "sv << dmabuf.backing_width << 'x' << dmabuf.backing_height << " buffer with stride "sv << dmabuf.strides[0];
return;
}
auto addr = mmap(nullptr, (std::size_t) size, PROT_READ, MAP_SHARED, fd, 0);
if (addr == MAP_FAILED) {
BOOST_LOG(warning) << "qemu: couldn't map DMABUF for readback: "sv << std::strerror(errno) << "; use a hardware encoder"sv;
return;
}
dmabuf_addr = (const std::uint8_t *) addr;
dmabuf_size = (std::size_t) size;
dmabuf_layout = *layout;
}
void frame_store_t::read_dmabuf_locked(int x, int y, int w, int h) {
if (!dmabuf_addr) {
return;
}
const auto fd = dmabuf.fds[0].get();
dma_buf_sync sync {DMA_BUF_SYNC_START | DMA_BUF_SYNC_READ};
ioctl(fd, DMA_BUF_IOCTL_SYNC, &sync); // not a real DMABUF in tests; the mapping is still valid
const auto bpp = (std::size_t) dmabuf_layout.bytes_per_pixel;
const auto dst_stride = (std::size_t) frame_width * 4;
for (int row = y; row < y + h; ++row) {
// QEMU's scanout rectangle is in buffer memory order; flip bottom-up buffers for display
const std::size_t buffer_row = dmabuf.y + (dmabuf.y0_top ? row : frame_height - 1 - row);
const auto src = dmabuf_addr + dmabuf.offsets[0] + buffer_row * dmabuf.strides[0] + (dmabuf.x + (std::size_t) x) * bpp;
convert_row(dmabuf_layout, src, pixels.data() + (std::size_t) row * dst_stride + (std::size_t) x * 4, w);
}
sync.flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_READ;
ioctl(fd, DMA_BUF_IOCTL_SYNC, &sync);
}
void frame_store_t::release_dmabuf_locked() {
if (dmabuf_addr) {
munmap((void *) dmabuf_addr, dmabuf_size);
dmabuf_addr = nullptr;
dmabuf_size = 0;
}
dmabuf = dmabuf_scanout_t {};
}
void frame_store_t::resize_locked(int new_width, int new_height) {
int transport = 0;
if (scanout_kind == scanout_kind_e::dmabuf) {
transport = 2;
const bool mapped = map_addr != nullptr;
if (new_width != frame_width || new_height != frame_height || mapped != logged_mapped) {
BOOST_LOG(info) << "qemu: scanout "sv << new_width << 'x' << new_height << (mapped ? " via shared memory map"sv : " via D-Bus messages"sv);
logged_mapped = mapped;
} else if (map_addr) {
transport = 1;
}
if (new_width != frame_width || new_height != frame_height || transport != logged_transport) {
static constexpr std::array<std::string_view, 3> names {" via D-Bus messages"sv, " via shared memory map"sv, " via DMABUF"sv};
BOOST_LOG(info) << "qemu: scanout "sv << new_width << 'x' << new_height << names[transport];
logged_transport = transport;
}
frame_width = new_width;
frame_height = new_height;
src/platform/linux/qemu/frame_store.h +85 −3
@@ -29,6 +29,25 @@
};
/**
* @brief How QEMU currently delivers the frame.
*/
enum class scanout_kind_e {
none, ///< No scanout yet.
memory, ///< Pixels copied from D-Bus messages or a shared memory map.
dmabuf, ///< A DMABUF that stays on the GPU.
};
/**
* @brief A DMABUF frame handed to a GPU encoder.
*/
struct dmabuf_frame_t {
dmabuf_scanout_t buffer; ///< Duplicated descriptors and layout of the current buffer.
std::uint64_t generation {0}; ///< Changes whenever QEMU scans out a new buffer.
bool disabled {false}; ///< Whether the display is off; show black instead of the buffer.
std::chrono::steady_clock::time_point timestamp; ///< Receipt time of the call that produced this frame.
};
/**
* @brief Guest cursor defined by `CursorDefine` and placed by `MouseSet`.
*/
struct cursor_state_t {
@@ -66,11 +85,18 @@
* @brief Reconstructs the guest framebuffer from QEMU listener calls.
* @details Listener calls arrive on the session thread; the capture thread copies frames out. The
* stored frame is always 4 bytes per pixel in B, G, R, X order with a stride of `4 * width`,
* which is the layout Sunshine's system-memory images use. DMABUF scanouts are kept as
* which is the layout Sunshine's system-memory images use.
* descriptors for GPU encoders and, when `dmabuf_readback` is enabled, also read back into the
* frame for system-memory encoders.
*/
class frame_store_t: public display_listener_t {
public:
/**
* @brief Create an empty store.
*
* @param dmabuf_readback Whether to copy DMABUF scanouts into system memory.
*/
frame_store_t() = default;
explicit frame_store_t(bool dmabuf_readback = true);
~frame_store_t() override;
frame_store_t(const frame_store_t &) = delete;
@@ -80,6 +106,8 @@
void update(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) override;
void scanout_map(fd_t fd, std::uint32_t offset, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format) override;
void update_map(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) override;
void scanout_dmabuf(dmabuf_scanout_t buffer) override;
void update_dmabuf(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) override;
void disable() override;
void mouse_set(std::int32_t x, std::int32_t y, bool visible) override;
void cursor_define(std::int32_t width, std::int32_t height, std::int32_t hot_x, std::int32_t hot_y, std::span<const std::uint8_t> data) override;
@@ -126,6 +154,20 @@
[[nodiscard]] bool connected() const;
/**
* @brief How QEMU currently delivers the frame.
*
* @return Kind of the latest scanout.
*/
[[nodiscard]] scanout_kind_e kind() const;
/**
* @brief Report whether the current DMABUF scanout can be read back into system memory.
*
* @return True for single-plane linear packed RGB buffers that could be mapped.
*/
[[nodiscard]] bool dmabuf_readable() const;
/**
* @brief Sequence number of the latest change.
*
* @return Monotonic counter, 0 before the first scanout.
@@ -146,6 +188,17 @@
frame_status_e copy_if_newer(std::uint64_t &last_sequence, int width, int height, std::uint8_t *dst, std::chrono::steady_clock::time_point &timestamp, bool draw_cursor = false);
/**
* @brief Hand out the current DMABUF if the frame changed since the caller last took it.
*
* @param last_sequence Sequence of the caller's last frame; updated on `new_frame`.
* @param width Display width the caller expects.
* @param height Display height the caller expects.
* @param frame Receives duplicated descriptors that the caller owns, on `new_frame`.
* @return Poll result; `size_changed` also when the scanout is no longer a DMABUF.
*/
frame_status_e dmabuf_if_newer(std::uint64_t &last_sequence, int width, int height, dmabuf_frame_t &frame);
/**
* @brief Copy the cursor state.
*
* @param known_serial Serial of the image the caller already has; pixels are copied only when it differs.
@@ -176,6 +229,26 @@
void blit_locked(int x, int y, int w, int h, const std::uint8_t *src, std::uint32_t src_stride, const pixel_layout_t &layout);
/**
* @brief Map the current DMABUF for readback, if its layout allows it.
*/
void map_dmabuf_locked();
/**
* @brief Copy a rectangle of the mapped DMABUF into the frame.
*
* @param x Left edge in display coordinates.
* @param y Top edge in display coordinates.
* @param w Rectangle width.
* @param h Rectangle height.
*/
void read_dmabuf_locked(int x, int y, int w, int h);
/**
* @brief Release the current DMABUF and its mapping.
*/
void release_dmabuf_locked();
/**
* @brief Mark the frame as changed now.
*/
void touch_locked();
@@ -193,8 +266,10 @@
std::uint64_t change_sequence {0}; ///< Incremented on every change.
std::chrono::steady_clock::time_point change_time; ///< Receipt time of the latest change.
bool is_disconnected {false}; ///< Whether QEMU closed the listener connection.
bool logged_mapped {false}; ///< Whether the last logged scanout used the shared memory map.
int logged_transport {-1}; ///< Transport of the last logged scanout: messages, map or DMABUF.
cursor_state_t cursor_state; ///< Latest guest cursor.
scanout_kind_e scanout_kind {scanout_kind_e::none}; ///< How the latest scanout arrived.
bool is_disabled {false}; ///< Whether QEMU turned the display off since the last scanout.
fd_t map_fd; ///< Shared memory descriptor of the current map scanout.
const std::uint8_t *map_addr {nullptr}; ///< Mapping of `map_fd`.
@@ -202,5 +277,12 @@
std::uint32_t map_offset {0}; ///< Offset of the first pixel in the mapping.
std::uint32_t map_stride {0}; ///< Bytes per row in the mapping.
pixel_layout_t map_layout; ///< Pixel layout of the mapping.
bool readback; ///< Whether DMABUF scanouts are read back into `pixels`.
dmabuf_scanout_t dmabuf; ///< Current DMABUF scanout.
std::uint64_t dmabuf_generation {0}; ///< Incremented for every DMABUF scanout.
const std::uint8_t *dmabuf_addr {nullptr}; ///< Read-only mapping of the DMABUF's only plane, for readback.
std::size_t dmabuf_size {0}; ///< Length of the DMABUF mapping.
pixel_layout_t dmabuf_layout; ///< Pixel layout of the mapped DMABUF.
};
} // namespace qemu
src/platform/linux/qemu/render_node.cpp +110 −0
@@ -1,0 +1,110 @@
/**
* @file src/platform/linux/qemu/render_node.cpp
* @brief Definitions for matching the GPU QEMU renders on with the GPU Sunshine encodes on.
*/
// class header include
#include "render_node.h"
// standard includes
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string_view>
// platform includes
#include <dirent.h>
#include <sys/stat.h>
namespace qemu {
std::vector<std::string> read_cmdline(std::uint32_t pid) {
std::ifstream file {"/proc/" + std::to_string(pid) + "/cmdline", std::ios::binary};
std::string content {std::istreambuf_iterator<char> {file}, std::istreambuf_iterator<char> {}};
std::vector<std::string> args;
std::size_t start = 0;
while (start < content.size()) {
auto end = content.find('\0', start);
if (end == std::string::npos) {
end = content.size();
}
args.emplace_back(content.substr(start, end - start));
start = end + 1;
}
return args;
}
std::optional<std::string> render_node_from_cmdline(const std::vector<std::string> &args) {
constexpr std::string_view key = "rendernode=";
// later options override earlier ones, as in QEMU
std::optional<std::string> node;
for (const auto &arg : args) {
std::size_t start = 0;
while (start <= arg.size()) {
auto end = arg.find(',', start);
if (end == std::string::npos) {
end = arg.size();
}
std::string_view option {arg.data() + start, end - start};
if (option.starts_with(key) && option.size() > key.size()) {
node = std::string {option.substr(key.size())};
}
start = end + 1;
}
}
return node;
}
std::optional<std::string> default_render_node(const std::string &dri_dir) {
auto dir = opendir(dri_dir.c_str());
if (!dir) {
return std::nullopt;
}
std::optional<std::string> node;
while (auto entry = readdir(dir)) {
if (std::string_view {entry->d_name}.substr(0, 7) != "renderD") {
continue;
}
auto path = dri_dir + "/" + entry->d_name;
struct stat st {};
if (stat(path.c_str(), &st) == 0 && S_ISCHR(st.st_mode)) {
node = path;
break;
}
}
closedir(dir);
return node;
}
std::optional<std::string> qemu_render_node(std::optional<std::uint32_t> pid) {
if (pid) {
if (auto node = render_node_from_cmdline(read_cmdline(*pid))) {
return node;
}
}
return default_render_node();
}
bool same_device(const std::string &a, const std::string &b) {
struct stat sa {};
struct stat sb {};
if (stat(a.c_str(), &sa) != 0 || stat(b.c_str(), &sb) != 0) {
return false;
}
if (S_ISCHR(sa.st_mode) && S_ISCHR(sb.st_mode)) {
return sa.st_rdev == sb.st_rdev;
}
return sa.st_dev == sb.st_dev && sa.st_ino == sb.st_ino;
}
std::string render_node_driver(const std::string &node, const std::string &sysfs_root) {
std::error_code ec;
const auto name = std::filesystem::path {node}.filename();
const auto driver = std::filesystem::read_symlink(std::filesystem::path {sysfs_root} / "class/drm" / name / "device/driver", ec);
if (ec) {
return {};
}
return driver.filename().string();
}
} // namespace qemu
src/platform/linux/qemu/render_node.h +68 −0
@@ -1,0 +1,68 @@
/**
* @file src/platform/linux/qemu/render_node.h
* @brief Declarations for matching the GPU QEMU renders on with the GPU Sunshine encodes on.
* @details DMABUF scanouts are only zero-copy when QEMU (`-display dbus,gl=on,rendernode=...`) and
* the encoder use the same DRM device. QEMU doesn't report its render node over D-Bus, so it is
* read from the QEMU command line, falling back to the node QEMU picks by default.
*/
#pragma once
// standard includes
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace qemu {
/**
* @brief Read a process command line.
*
* @param pid Process id.
* @return Arguments, or an empty list when the process can't be inspected.
*/
std::vector<std::string> read_cmdline(std::uint32_t pid);
/**
* @brief Find the `rendernode=` option in a QEMU command line.
*
* @param args QEMU arguments, including the program name.
* @return Render node path, or nothing when QEMU picks its default node.
*/
std::optional<std::string> render_node_from_cmdline(const std::vector<std::string> &args);
/**
* @brief The render node QEMU opens when `rendernode=` isn't given.
* @details Mirrors `qemu_drm_rendernode_open()`: the first `renderD*` character device in
* directory order.
*
* @param dri_dir Directory to search, normally `/dev/dri`.
* @return Render node path, or nothing when there is none.
*/
std::optional<std::string> default_render_node(const std::string &dri_dir = "/dev/dri");
/**
* @brief The render node a QEMU process renders on.
*
* @param pid QEMU process id, if known.
* @return Render node path, or nothing when it can't be determined.
*/
std::optional<std::string> qemu_render_node(std::optional<std::uint32_t> pid);
/**
* @brief Report whether two device paths refer to the same device.
*
* @param a First path.
* @param b Second path.
* @return True when both exist and are the same character device.
*/
bool same_device(const std::string &a, const std::string &b);
/**
* @brief Kernel driver bound to a DRM node, for example "i915", "amdgpu" or "nvidia".
*
* @param node Node path such as `/dev/dri/renderD128`.
* @param sysfs_root Root of sysfs, overridable for tests.
* @return Driver name, or an empty string when unknown.
*/
std::string render_node_driver(const std::string &node, const std::string &sysfs_root = "/sys");
} // namespace qemu
src/platform/linux/qemu/session.cpp +113 −14
@@ -40,6 +40,7 @@
constexpr auto vm_path = "/org/qemu/Display1/VM"; ///< Object path of the VM interface.
constexpr auto listener_path = "/org/qemu/Display1/Listener"; ///< Object path QEMU calls on the listener connection.
constexpr auto unix_map_interface = "org.qemu.Display1.Listener.Unix.Map"; ///< Shared memory listener interface.
constexpr auto scanout_dmabuf2_interface = "org.qemu.Display1.Listener.Unix.ScanoutDMABUF2"; ///< Multi-plane DMABUF listener interface.
/**
* @brief Convert a NULL-terminated string vector to a vector of strings.
@@ -187,6 +188,23 @@
std::thread thread; ///< Thread running the loop.
};
/**
* @brief Reply to a scanout call whose descriptor couldn't be read.
*
* @param invocation Invocation to fail.
* @param err Error from GUnixFDList, may be null; cleared.
* @param method Method name for the log.
*/
void return_invalid_descriptor(GDBusMethodInvocation *invocation, GError *err, std::string_view method) {
BOOST_LOG(error) << "qemu: "sv << method << " without a valid descriptor"sv;
if (err) {
g_dbus_method_invocation_return_gerror(invocation, err);
g_clear_error(&err);
} else {
g_dbus_method_invocation_return_error_literal(invocation, G_DBUS_ERROR, G_DBUS_ERROR_INVALID_ARGS, "missing descriptor");
}
}
class session_impl_t;
/**
@@ -230,6 +248,7 @@
static gboolean on_cursor_define(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint width, gint height, gint hot_x, gint hot_y, GVariant *data, gpointer self);
static gboolean on_scanout_map(QemuDBusDisplay1ListenerUnixMap *object, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *handle, guint offset, guint width, guint height, guint stride, guint format, gpointer self);
static gboolean on_update_map(QemuDBusDisplay1ListenerUnixMap *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, gpointer self);
static gboolean on_scanout_dmabuf2(QemuDBusDisplay1ListenerUnixScanoutDMABUF2 *object, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *dmabuf, guint x, guint y, guint width, guint height, GVariant *offset, GVariant *stride, guint num_planes, guint fourcc, guint backing_width, guint backing_height, guint64 modifier, gboolean y0_top, gpointer self);
static void on_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *err, gpointer self);
std::shared_ptr<session_impl_t> session; ///< Keeps the loop thread alive while registered.
@@ -237,5 +256,6 @@
GDBusConnection *connection {nullptr}; ///< Peer-to-peer connection to QEMU.
QemuDBusDisplay1Listener *skeleton {nullptr}; ///< Exported Listener interface.
QemuDBusDisplay1ListenerUnixMap *map_skeleton {nullptr}; ///< Exported Listener.Unix.Map interface.
QemuDBusDisplay1ListenerUnixScanoutDMABUF2 *dmabuf2_skeleton {nullptr}; ///< Exported Listener.Unix.ScanoutDMABUF2 interface.
bool closed {false}; ///< Whether the peer closed the connection.
};
@@ -303,6 +323,27 @@
return is_alive;
}
std::optional<std::uint32_t> qemu_pid() const override {
std::optional<std::uint32_t> pid;
loop.invoke([&]() {
if (!connection) {
return;
}
GError *err = nullptr;
auto reply = g_dbus_connection_call_sync(connection, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetConnectionUnixProcessID", g_variant_new("(s)", bus_name), G_VARIANT_TYPE("(u)"), G_DBUS_CALL_FLAGS_NONE, timeout_ms, nullptr, &err);
if (!reply) {
BOOST_LOG(debug) << "qemu: couldn't get the QEMU process id: "sv << err->message;
g_clear_error(&err);
return;
}
guint32 value = 0;
g_variant_get(reply, "(u)", &value);
g_variant_unref(reply);
pid = value;
});
return pid;
}
std::unique_ptr<listener_registration_t> register_listener(std::uint32_t console_id, std::shared_ptr<display_listener_t> listener) override {
auto registration = std::make_unique<listener_impl_t>(shared_from_this(), std::move(listener));
bool ok = false;
@@ -529,7 +570,7 @@
g_dbus_connection_set_exit_on_close(connection, FALSE);
skeleton = qemu_dbus_display1_listener_skeleton_new();
const gchar *interfaces[] = {unix_map_interface, nullptr};
const gchar *interfaces[] = {unix_map_interface, scanout_dmabuf2_interface, nullptr};
qemu_dbus_display1_listener_set_interfaces(skeleton, interfaces);
g_signal_connect(skeleton, "handle-scanout", G_CALLBACK(&listener_impl_t::on_scanout), this);
g_signal_connect(skeleton, "handle-update", G_CALLBACK(&listener_impl_t::on_update), this);
@@ -543,7 +584,10 @@
g_signal_connect(map_skeleton, "handle-scanout-map", G_CALLBACK(&listener_impl_t::on_scanout_map), this);
g_signal_connect(map_skeleton, "handle-update-map", G_CALLBACK(&listener_impl_t::on_update_map), this);
if (!g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(skeleton), connection, listener_path, &err) || !g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(map_skeleton), connection, listener_path, &err)) {
dmabuf2_skeleton = qemu_dbus_display1_listener_unix_scanout_dmabuf2_skeleton_new();
g_signal_connect(dmabuf2_skeleton, "handle-scanout-dmabuf2", G_CALLBACK(&listener_impl_t::on_scanout_dmabuf2), this);
if (!g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(skeleton), connection, listener_path, &err) || !g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(map_skeleton), connection, listener_path, &err) || !g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(dmabuf2_skeleton), connection, listener_path, &err)) {
BOOST_LOG(error) << "qemu: couldn't export listener: "sv << err->message;
g_clear_error(&err);
stop();
@@ -566,6 +610,11 @@
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(map_skeleton));
g_clear_object(&map_skeleton);
}
if (dmabuf2_skeleton) {
g_signal_handlers_disconnect_by_data(dmabuf2_skeleton, this);
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(dmabuf2_skeleton));
g_clear_object(&dmabuf2_skeleton);
}
if (connection) {
g_signal_handlers_disconnect_by_data(connection, this);
if (!closed) {
@@ -592,16 +641,72 @@
}
gboolean listener_impl_t::on_scanout_dmabuf(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *dmabuf, guint width, guint height, guint stride, guint fourcc, guint64 modifier, gboolean y0_top, gpointer self) {
GError *err = nullptr;
int fd = fd_list ? g_unix_fd_list_get(fd_list, g_variant_get_handle(dmabuf), &err) : -1;
if (fd < 0) {
return_invalid_descriptor(invocation, err, "ScanoutDMABUF");
return TRUE;
}
dmabuf_scanout_t buffer;
buffer.fds[0] = fd_t {fd};
buffer.num_planes = 1;
buffer.strides[0] = stride;
// DMABUF scanouts need a GL display in QEMU and are handled by the vram capture path (REQ-CAP-003).
static std::once_flag logged;
std::call_once(logged, []() {
BOOST_LOG(warning) << "qemu: ignoring DMABUF scanout; start QEMU without gl=on for shared memory capture"sv;
});
buffer.width = width;
buffer.height = height;
buffer.backing_width = width;
buffer.backing_height = height;
buffer.fourcc = fourcc;
buffer.modifier = modifier;
buffer.y0_top = y0_top;
((listener_impl_t *) self)->listener->scanout_dmabuf(std::move(buffer));
qemu_dbus_display1_listener_complete_scanout_dmabuf(object, invocation, nullptr);
return TRUE;
}
gboolean listener_impl_t::on_scanout_dmabuf2(QemuDBusDisplay1ListenerUnixScanoutDMABUF2 *object, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *dmabuf, guint x, guint y, guint width, guint height, GVariant *offset, GVariant *stride, guint num_planes, guint fourcc, guint backing_width, guint backing_height, guint64 modifier, gboolean y0_top, gpointer self) {
gsize offset_count = 0;
gsize stride_count = 0;
auto offsets = (const guint32 *) g_variant_get_fixed_array(offset, &offset_count, sizeof(guint32));
auto strides = (const guint32 *) g_variant_get_fixed_array(stride, &stride_count, sizeof(guint32));
const gsize fd_count = g_variant_n_children(dmabuf);
if (num_planes < 1 || num_planes > 4 || fd_count < num_planes || offset_count < num_planes || stride_count < num_planes || !fd_list) {
BOOST_LOG(error) << "qemu: ScanoutDMABUF2 with "sv << num_planes << " planes, "sv << fd_count << " descriptors, "sv << offset_count << " offsets and "sv << stride_count << " strides"sv;
g_dbus_method_invocation_return_error_literal(invocation, G_DBUS_ERROR, G_DBUS_ERROR_INVALID_ARGS, "inconsistent planes");
return TRUE;
}
dmabuf_scanout_t buffer;
for (guint plane = 0; plane < num_planes; ++plane) {
GError *err = nullptr;
auto handle = g_variant_get_child_value(dmabuf, plane);
int fd = g_unix_fd_list_get(fd_list, g_variant_get_handle(handle), &err);
g_variant_unref(handle);
if (fd < 0) {
return_invalid_descriptor(invocation, err, "ScanoutDMABUF2");
return TRUE;
}
buffer.fds[plane] = fd_t {fd};
buffer.offsets[plane] = offsets[plane];
buffer.strides[plane] = strides[plane];
}
buffer.num_planes = num_planes;
buffer.x = x;
buffer.y = y;
buffer.width = width;
buffer.height = height;
buffer.backing_width = backing_width;
buffer.backing_height = backing_height;
buffer.fourcc = fourcc;
buffer.modifier = modifier;
buffer.y0_top = y0_top;
((listener_impl_t *) self)->listener->scanout_dmabuf(std::move(buffer));
qemu_dbus_display1_listener_unix_scanout_dmabuf2_complete_scanout_dmabuf2(object, invocation, nullptr);
return TRUE;
}
gboolean listener_impl_t::on_update_dmabuf(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, gpointer self) {
((listener_impl_t *) self)->listener->update_dmabuf(x, y, width, height);
qemu_dbus_display1_listener_complete_update_dmabuf(object, invocation);
return TRUE;
}
@@ -630,13 +735,7 @@
GError *err = nullptr;
int fd = fd_list ? g_unix_fd_list_get(fd_list, g_variant_get_handle(handle), &err) : -1;
if (fd < 0) {
return_invalid_descriptor(invocation, err, "ScanoutMap");
BOOST_LOG(error) << "qemu: ScanoutMap without a valid descriptor"sv;
if (err) {
g_dbus_method_invocation_return_gerror(invocation, err);
g_clear_error(&err);
} else {
g_dbus_method_invocation_return_error_literal(invocation, G_DBUS_ERROR, G_DBUS_ERROR_INVALID_ARGS, "missing descriptor");
}
return TRUE;
}
((listener_impl_t *) self)->listener->scanout_map(fd_t {fd}, offset, width, height, stride, format);
src/platform/linux/qemu/session.h +51 −0
@@ -8,6 +8,7 @@
#pragma once
// standard includes
#include <array>
#include <chrono>
#include <cstdint>
#include <memory>
@@ -78,6 +79,29 @@
};
/**
* @brief A DMABUF scanout from `ScanoutDMABUF` or `Listener.Unix.ScanoutDMABUF2`.
* @details Describes a (possibly multi-plane) GPU buffer of `backing_width` x `backing_height`
* pixels, of which the rectangle at (`x`, `y`) with size `width` x `height` is the display.
* Coordinates are in the buffer's memory order; when `y0_top` is false the buffer is stored
* bottom row first (OpenGL convention) and must be flipped vertically for display.
*/
struct dmabuf_scanout_t {
std::array<fd_t, 4> fds; ///< Descriptor of each plane; planes may share one buffer.
std::uint32_t num_planes {1}; ///< Number of planes in use, 1 to 4.
std::array<std::uint32_t, 4> offsets {}; ///< Offset of the first pixel of each plane, in bytes.
std::array<std::uint32_t, 4> strides {}; ///< Bytes per row of each plane.
std::uint32_t x {0}; ///< Left edge of the display rectangle in the buffer.
std::uint32_t y {0}; ///< Top edge of the display rectangle in the buffer.
std::uint32_t width {0}; ///< Display width in pixels.
std::uint32_t height {0}; ///< Display height in pixels.
std::uint32_t backing_width {0}; ///< Buffer width in pixels.
std::uint32_t backing_height {0}; ///< Buffer height in pixels.
std::uint32_t fourcc {0}; ///< DRM fourcc of the buffer.
std::uint64_t modifier {0}; ///< DRM format modifier of the buffer.
bool y0_top {true}; ///< Whether row 0 of the buffer is the top of the image.
};
/**
* @brief Snapshot of one QEMU console.
*/
struct console_info_t {
@@ -165,6 +189,26 @@
virtual void update_map(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) = 0;
/**
* @brief Handle a scanout backed by a DMABUF.
* @details The default implementation ignores the buffer, closing its descriptors.
*
* @param buffer Buffer description; descriptor ownership moves to the listener.
*/
virtual void scanout_dmabuf(dmabuf_scanout_t buffer) {
}
/**
* @brief Handle damage on the current DMABUF scanout.
*
* @param x Left edge of the damaged rectangle, relative to the display rectangle.
* @param y Top edge of the damaged rectangle, relative to the display rectangle.
* @param width Width of the damaged rectangle.
* @param height Height of the damaged rectangle.
*/
virtual void update_dmabuf(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) {
}
/**
* @brief Handle the display being turned off.
*/
virtual void disable() = 0;
@@ -237,6 +281,13 @@
* @return False once the bus connection closed or `org.qemu` lost its owner.
*/
[[nodiscard]] virtual bool alive() const = 0;
/**
* @brief Process id of the QEMU that owns `org.qemu`, as reported by the bus.
*
* @return Process id, or nothing when the bus doesn't know it.
*/
[[nodiscard]] virtual std::optional<std::uint32_t> qemu_pid() const = 0;
/**
* @brief Register a display listener on a console.
tests/e2e/qemu/e2e_stream.sh +15 −0
@@ -19,6 +19,9 @@
# 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_ENCODER Sunshine encoder: software, nvenc, vaapi, vulkan (default: software)
# E2E_GL set to 1 for QEMU's GL display (virtio-vga-gl, gl=on): DMABUF scanouts (REQ-CAP-003).
# Needs a DRM render node; exits 77 (skipped) without /dev/dri/renderD*.
# E2E_RENDERNODE render node for E2E_GL=1, passed to QEMU (rendernode=) and Sunshine (adapter_name)
# E2E_RESET_AFTER_FRAMES reboot the guest with QMP system_reset after this many decoded frames, then
# require E2E_FRAMES more frames and the pattern again (REQ-CAP-004)
# E2E_MAX_GAP_MS with E2E_RESET_AFTER_FRAMES, the longest allowed time without a decoded frame (default: 5000)
@@ -41,6 +44,15 @@
reset_after="${E2E_RESET_AFTER_FRAMES:-}"
max_gap_ms="${E2E_MAX_GAP_MS:-5000}"
if [[ "${E2E_GL:-0}" == 1 ]]; then
if ! compgen -G "/dev/dri/renderD*" > /dev/null; then
echo "e2e: SKIP: E2E_GL=1 needs a DRM render node (/dev/dri/renderD*) for QEMU's GL display" >&2
exit 77
fi
export VM_GL=1
export VM_RENDERNODE="${E2E_RENDERNODE:-}"
fi
for bin in "${sunshine_bin}" "${client_bin}"; do
if [[ ! -x "${bin}" ]]; then
echo "missing ${bin}; build it first" >&2
@@ -110,6 +122,9 @@
origin_web_ui_allowed = pc
notify_pre_releases = disabled
EOF
if [[ -n "${E2E_RENDERNODE:-}" ]]; then
echo "adapter_name = ${E2E_RENDERNODE}" >> "${work}/sunshine.conf"
fi
api_user="e2e"
api_pass="$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')"
tests/e2e/qemu/run_vm.sh +10 −2
@@ -8,6 +8,8 @@
# 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)
# VM_GL set to 1 for a GL display: virtio-vga-gl with -display dbus,gl=on (DMABUF scanouts)
# VM_RENDERNODE render node for VM_GL=1 (default: QEMU's choice, the first /dev/dri/renderD*)
#
# Writes into <work-dir>:
# bus.sock the private bus socket; bus address is unix:path=<work-dir>/bus.sock
@@ -37,6 +39,12 @@
mkdir -p "${work}"
bus_socket="${work}/bus.sock"
bus_address="unix:path=${bus_socket}"
display_device=virtio-vga
display_opts="dbus,addr=${bus_address}"
if [[ "${VM_GL:-0}" == 1 ]]; then
display_device=virtio-vga-gl
display_opts="dbus,gl=on,addr=${bus_address}${VM_RENDERNODE:+,rendernode=${VM_RENDERNODE}}"
fi
rm -f "${bus_socket}" "${work}/qmp.sock"
dbus-daemon --session --nofork --nopidfile --address="${bus_address}" > "${work}/dbus.log" 2>&1 &
@@ -56,8 +64,8 @@
-accel "${VM_ACCEL}" \
-m 128 \
-nodefaults \
-device virtio-vga \
-display dbus,addr="${bus_address}" \
-device "${display_device}" \
-display "${display_opts}" \
-audiodev dbus,id=snd0 \
-device intel-hda -device hda-output,audiodev=snd0 \
-device virtio-tablet-pci \
tests/unit/platform/linux/qemu/fake_qemu.h +98 −5
@@ -453,27 +453,113 @@
}
/**
* @brief Send a `ScanoutDMABUF` call, which the client does not support yet.
* @brief Send a `ScanoutDMABUF` call and wait for the reply.
*
* @param console_id Target console.
* @param fd Buffer descriptor; the caller keeps ownership.
* @param width Width in pixels.
* @param height Height in pixels.
* @param stride Bytes per row.
* @param fourcc DRM fourcc.
* @param modifier DRM modifier.
* @param y0_top Whether row 0 is the top of the image.
* @return True when the client acknowledged the call without an error.
*/
bool scanout_dmabuf(std::uint32_t console_id) {
bool scanout_dmabuf(std::uint32_t console_id, int fd, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t fourcc, std::uint64_t modifier, bool y0_top) {
auto proxy = listener_proxy(console_id);
if (!proxy) {
return false;
}
auto fd_list = g_unix_fd_list_new();
int fd = memfd_create("fake-dmabuf", MFD_CLOEXEC);
g_unix_fd_list_append(fd_list, fd, nullptr);
bool ok = qemu_dbus_display1_listener_call_scanout_dmabuf_sync(proxy, g_variant_new_handle(0), width, height, stride, fourcc, modifier, y0_top, G_DBUS_CALL_FLAGS_NONE, 5000, fd_list, nullptr, nullptr, nullptr);
g_object_unref(fd_list);
g_object_unref(proxy);
return ok;
}
/**
* @brief Plane layout for `ScanoutDMABUF2`.
*/
struct dmabuf2_t {
std::vector<int> fds; ///< One descriptor per plane; the caller keeps ownership.
std::vector<std::uint32_t> offsets; ///< Offset of each plane.
std::vector<std::uint32_t> strides; ///< Stride of each plane.
std::uint32_t num_planes {1}; ///< Planes QEMU reports.
std::uint32_t x {0}; ///< Display rectangle left edge.
std::uint32_t y {0}; ///< Display rectangle top edge.
std::uint32_t width {0}; ///< Display width.
std::uint32_t height {0}; ///< Display height.
std::uint32_t backing_width {0}; ///< Buffer width.
std::uint32_t backing_height {0}; ///< Buffer height.
std::uint32_t fourcc {0}; ///< DRM fourcc.
std::uint64_t modifier {0}; ///< DRM modifier.
bool y0_top {true}; ///< Whether row 0 is the top of the image.
};
/**
* @brief Send a `Listener.Unix.ScanoutDMABUF2` call and wait for the reply.
*
* @param console_id Target console.
* @param buffer Buffer layout.
* @return True when the client acknowledged the call without an error.
*/
bool scanout_dmabuf2(std::uint32_t console_id, const dmabuf2_t &buffer) {
QemuDBusDisplay1ListenerUnixScanoutDMABUF2 *proxy = nullptr;
{
std::lock_guard lock {mutex};
proxy = console_states[console_id].dmabuf2;
if (proxy) {
g_object_ref(proxy);
}
close(fd);
bool ok = qemu_dbus_display1_listener_call_scanout_dmabuf_sync(proxy, g_variant_new_handle(0), 16, 16, 64, 0x34325258, 0, TRUE, G_DBUS_CALL_FLAGS_NONE, 5000, fd_list, nullptr, nullptr, nullptr);
}
if (!proxy) {
return false;
}
auto fd_list = g_unix_fd_list_new();
GVariantBuilder handles;
g_variant_builder_init(&handles, G_VARIANT_TYPE("ah"));
for (auto fd : buffer.fds) {
g_variant_builder_add(&handles, "h", g_unix_fd_list_append(fd_list, fd, nullptr));
}
auto offsets = g_variant_new_fixed_array(G_VARIANT_TYPE_UINT32, buffer.offsets.data(), buffer.offsets.size(), sizeof(std::uint32_t));
auto strides = g_variant_new_fixed_array(G_VARIANT_TYPE_UINT32, buffer.strides.data(), buffer.strides.size(), sizeof(std::uint32_t));
bool ok = qemu_dbus_display1_listener_unix_scanout_dmabuf2_call_scanout_dmabuf2_sync(proxy, g_variant_builder_end(&handles), buffer.x, buffer.y, buffer.width, buffer.height, offsets, strides, buffer.num_planes, buffer.fourcc, buffer.backing_width, buffer.backing_height, buffer.modifier, buffer.y0_top, G_DBUS_CALL_FLAGS_NONE, 5000, fd_list, nullptr, nullptr, nullptr);
g_object_unref(fd_list);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send an `UpdateDMABUF` call and wait for the reply.
*
* @param console_id Target console.
* @param x Left edge.
* @param y Top edge.
* @param width Rectangle width.
* @param height Rectangle height.
* @return True when the client acknowledged the call.
*/
bool update_dmabuf(std::uint32_t console_id, std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) {
auto proxy = listener_proxy(console_id);
if (!proxy) {
return false;
}
bool ok = qemu_dbus_display1_listener_call_update_dmabuf_sync(proxy, x, y, width, height, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Get the listener process id the way QEMU's bus reports it.
*
* @return This process's id, since the fake runs in the test process.
*/
static std::uint32_t pid() {
return (std::uint32_t) getpid();
}
/**
* @brief Close the listener connection from the QEMU side, as when QEMU exits.
*
* @param console_id Target console.
@@ -508,6 +594,7 @@
GDBusConnection *peer {nullptr}; ///< Listener peer-to-peer connection.
QemuDBusDisplay1Listener *proxy {nullptr}; ///< Listener proxy.
QemuDBusDisplay1ListenerUnixMap *map {nullptr}; ///< Unix.Map proxy, when advertised.
QemuDBusDisplay1ListenerUnixScanoutDMABUF2 *dmabuf2 {nullptr}; ///< Unix.ScanoutDMABUF2 proxy, when advertised.
std::vector<std::string> listener_interfaces; ///< Interfaces the listener advertised.
int registrations {0}; ///< Number of successful registrations.
bool closed_by_peer {false}; ///< Whether the client closed the connection.
@@ -572,6 +659,7 @@
g_signal_handlers_disconnect_by_data(state.peer, &state);
g_clear_object(&state.proxy);
g_clear_object(&state.map);
g_clear_object(&state.dmabuf2);
g_dbus_connection_close_sync(state.peer, nullptr, nullptr);
g_clear_object(&state.peer);
}
@@ -634,6 +722,7 @@
std::vector<std::string> interfaces;
QemuDBusDisplay1ListenerUnixMap *map = nullptr;
QemuDBusDisplay1ListenerUnixScanoutDMABUF2 *dmabuf2 = nullptr;
if (auto names = qemu_dbus_display1_listener_get_interfaces(proxy)) {
for (auto name = names; *name; ++name) {
interfaces.emplace_back(*name);
@@ -641,6 +730,9 @@
if (g_strv_contains(names, "org.qemu.Display1.Listener.Unix.Map")) {
map = qemu_dbus_display1_listener_unix_map_proxy_new_sync(peer, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, nullptr, "/org/qemu/Display1/Listener", nullptr, nullptr);
}
if (g_strv_contains(names, "org.qemu.Display1.Listener.Unix.ScanoutDMABUF2")) {
dmabuf2 = qemu_dbus_display1_listener_unix_scanout_dmabuf2_proxy_new_sync(peer, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, nullptr, "/org/qemu/Display1/Listener", nullptr, nullptr);
}
}
g_signal_connect(peer, "closed", G_CALLBACK(&fake_qemu_t::on_peer_closed), state);
@@ -650,6 +742,7 @@
state->peer = peer;
state->proxy = proxy;
state->map = map;
state->dmabuf2 = dmabuf2;
state->listener_interfaces = std::move(interfaces);
state->registrations += 1;
state->closed_by_peer = false;
tests/unit/platform/linux/qemu/test_capture.cpp +321 −1
@@ -16,8 +16,15 @@
#include <thread>
#include <tuple>
// platform includes
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
// local includes
#include <src/config.h>
#include <src/platform/common.h>
#include <src/platform/linux/graphics.h>
#include <src/platform/linux/qemu/render_node.h>
#include <src/platform/linux/qemu/session.h>
#include <src/video.h>
@@ -589,7 +596,320 @@
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, RejectsUnsupportedMemoryType) {
EXPECT_EQ(platf::qemu_display(platf::mem_type_e::dxgi, "1", stream_config()), nullptr);
EXPECT_EQ(platf::qemu_display(platf::mem_type_e::unknown, "1", stream_config()), nullptr);
#ifndef SUNSHINE_BUILD_CUDA
EXPECT_EQ(platf::qemu_display(platf::mem_type_e::cuda, "1", stream_config()), nullptr);
#endif
}
namespace {
/**
* @brief A memfd standing in for a DMABUF, filled with one color, or an invalid buffer.
*/
struct memfd_buffer_t {
int fd {-1}; ///< Descriptor.
std::uint8_t *map {nullptr}; ///< Writable mapping.
std::size_t size {0}; ///< Mapping length.
/**
* @brief Create a buffer of solid BGRX pixels.
*
* @param width Buffer width.
* @param height Buffer height.
* @param b Blue.
* @param g Green.
* @param r Red.
*/
memfd_buffer_t(int width, int height, std::uint8_t b, std::uint8_t g, std::uint8_t r) {
auto pixels = solid(width, height, b, g, r);
size = pixels.size();
fd = memfd_create("capture-dmabuf", MFD_CLOEXEC);
if (fd < 0 || ftruncate(fd, (off_t) size) != 0) {
return;
}
map = (std::uint8_t *) mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
std::memcpy(map, pixels.data(), size);
}
~memfd_buffer_t() {
if (map && map != MAP_FAILED) {
munmap(map, size);
}
if (fd >= 0) {
close(fd);
}
}
memfd_buffer_t(const memfd_buffer_t &) = delete;
memfd_buffer_t &operator=(const memfd_buffer_t &) = delete;
/**
* @brief Inode of the buffer.
*
* @return Inode number.
*/
[[nodiscard]] ino_t inode() const {
struct stat st {};
fstat(fd, &st);
return st.st_ino;
}
};
} // namespace
// @tag requirements: [REQ-CAP-003]
TEST_F(QemuCaptureTest, ZeroCopyDisplayHandsDmabufDescriptorsToTheEncoder) {
// an 8x6 bottom-up buffer whose 4x3 display rectangle starts at (2, 1), sent as ScanoutDMABUF2
memfd_buffer_t first {8, 6, 0x10, 0x20, 0x30};
memfd_buffer_t second {8, 6, 0x40, 0x50, 0x60};
ASSERT_NE(first.map, nullptr);
ASSERT_NE(second.map, nullptr);
auto layout = [](int fd, std::uint32_t x) {
qemu_test::fake_qemu_t::dmabuf2_t buffer;
buffer.fds = {fd};
buffer.offsets = {0};
buffer.strides = {32};
buffer.x = x;
EXPECT_EQ(platf::qemu_display(platf::mem_type_e::vulkan, "1", stream_config()), nullptr);
buffer.y = 1;
buffer.width = 4;
buffer.height = 3;
buffer.backing_width = 8;
buffer.backing_height = 6;
buffer.fourcc = qemu::drm_fourcc::xrgb8888;
buffer.modifier = qemu::drm_fourcc::mod_linear;
buffer.y0_top = false;
return buffer;
};
std::thread sender {[&]() {
if (fake->wait_for_listener(1)) {
fake->scanout_dmabuf2(1, layout(first.fd, 2));
}
}};
// Vulkan has no render node check, so this doesn't depend on the host's GPUs
auto display = platf::qemu_display(platf::mem_type_e::vulkan, "1", stream_config());
sender.join();
ASSERT_NE(display, nullptr);
EXPECT_EQ(display->width, 4);
EXPECT_EQ(display->height, 3);
auto probe = display->alloc_img();
auto descriptor = std::dynamic_pointer_cast<egl::img_descriptor_t>(probe);
ASSERT_NE(descriptor, nullptr);
EXPECT_EQ(descriptor->sequence, 0);
EXPECT_EQ(display->dummy_img(probe.get()), 0);
EXPECT_EQ(display->dummy_img(nullptr), -1);
probe.reset();
descriptor.reset();
struct seen_t {
std::uint64_t sequence;
ino_t inode;
int sd_width;
int sd_height;
std::uint32_t pitch;
std::uint32_t fourcc;
bool y_invert;
bool has_cursor;
int cursor_x;
int cursor_y;
int cursor_w;
bool timestamp;
};
std::vector<seen_t> seen;
std::thread actor;
auto step = [&](std::function<void()> action) {
if (actor.joinable()) {
actor.join();
}
actor = std::thread {std::move(action)};
};
auto status = run_capture(
*display,
[&](std::shared_ptr<platf::img_t> &&img, bool frame_captured) {
if (!frame_captured) {
return true;
}
auto d = std::dynamic_pointer_cast<egl::img_descriptor_t>(img);
EXPECT_NE(d, nullptr);
if (!d) {
return false;
}
struct stat st {};
bool has_fd = d->sd.fds[0] >= 0 && fstat(d->sd.fds[0], &st) == 0;
EXPECT_EQ(d->sd.fds[1], -1);
seen.push_back({d->sequence, has_fd ? st.st_ino : 0, d->sd.width, d->sd.height, d->sd.pitches[0], d->sd.fourcc, d->y_invert, d->data != nullptr, d->x, d->y, d->src_w, d->frame_timestamp.has_value()});
switch (seen.size()) {
case 1:
step([&]() {
fake->update_dmabuf(1, 0, 0, 1, 1);
});
return true;
case 2:
step([&]() {
fake->scanout_dmabuf2(1, layout(second.fd, 2));
});
return true;
case 3:
step([&]() {
fake->cursor_define(1, 2, 2, 1, 0, std::vector<std::uint8_t>(16, 0xff));
fake->mouse_set(1, 3, 2, true);
});
return true;
case 4:
step([&]() {
fake->disable(1);
});
return true;
case 5:
step([&]() {
fake->update_dmabuf(1, 0, 0, 4, 3);
});
return true;
case 6:
step([&]() {
fake->scanout_dmabuf2(1, layout(second.fd, 0));
});
return true;
default:
return true;
}
},
true
);
if (actor.joinable()) {
actor.join();
}
EXPECT_EQ(status, platf::capture_e::reinit) << "moving the display rectangle changes the encoder crop";
ASSERT_GE(seen.size(), 6);
// first buffer: imported with sequence 1, flipped, cropped by the encoder
EXPECT_EQ(seen[0].sequence, 1);
EXPECT_EQ(seen[0].inode, first.inode());
EXPECT_EQ(seen[0].sd_width, 8);
EXPECT_EQ(seen[0].sd_height, 6);
EXPECT_EQ(seen[0].pitch, 32);
EXPECT_EQ(seen[0].fourcc, qemu::drm_fourcc::xrgb8888);
EXPECT_TRUE(seen[0].y_invert);
EXPECT_FALSE(seen[0].has_cursor);
EXPECT_TRUE(seen[0].timestamp);
// damage: same buffer, same sequence, so the encoder keeps its EGL image
EXPECT_EQ(seen[1].sequence, 1);
EXPECT_EQ(seen[1].inode, first.inode());
// new buffer: new sequence
EXPECT_EQ(seen[2].sequence, 2);
EXPECT_EQ(seen[2].inode, second.inode());
// cursor: drawn by the encoder's cursor shader at the pointer minus the hot spot
EXPECT_EQ(seen[3].sequence, 2);
EXPECT_TRUE(seen[3].has_cursor);
EXPECT_EQ(seen[3].cursor_x, 2);
EXPECT_EQ(seen[3].cursor_y, 2);
EXPECT_EQ(seen[3].cursor_w, 2);
// Disable: black frame (sequence 0, no buffer)
EXPECT_EQ(seen[4].sequence, 0);
EXPECT_EQ(seen[4].inode, 0);
// back on: the same buffer is imported again
EXPECT_EQ(seen[5].sequence, 3);
EXPECT_EQ(seen[5].inode, second.inode());
}
// @tag requirements: [REQ-CAP-003, REQ-CAP-004]
TEST_F(QemuCaptureTest, GpuEncoderUploadsSharedMemoryAndSwitchesToDmabuf) {
auto display = [&]() {
std::thread sender {[&]() {
if (fake->wait_for_listener(1)) {
fake->scanout(1, 4, 4, 16, qemu::pixman_format::x8r8g8b8, solid(4, 4, 1, 2, 3));
}
}};
auto d = platf::qemu_display(platf::mem_type_e::vaapi, "1", stream_config());
sender.join();
return d;
}();
ASSERT_NE(display, nullptr);
auto img = display->alloc_img();
ASSERT_NE(img, nullptr);
EXPECT_EQ(std::dynamic_pointer_cast<egl::img_descriptor_t>(img), nullptr) << "shared memory frames are uploaded from RAM";
EXPECT_NE(img->data, nullptr);
img.reset();
memfd_buffer_t buffer {4, 4, 9, 9, 9};
ASSERT_NE(buffer.map, nullptr);
std::thread switcher {[&]() {
std::this_thread::sleep_for(50ms);
fake->scanout_dmabuf(1, buffer.fd, 4, 4, 16, qemu::drm_fourcc::xrgb8888, qemu::drm_fourcc::mod_linear, true);
}};
auto status = run_capture(*display, [&](std::shared_ptr<platf::img_t> &&, bool) {
return true;
});
switcher.join();
EXPECT_EQ(status, platf::capture_e::reinit);
}
// @tag requirements: [REQ-CAP-003]
TEST_F(QemuCaptureTest, SoftwareEncoderReadsDmabufBackOrFails) {
memfd_buffer_t buffer {4, 2, 0x11, 0x22, 0x33};
ASSERT_NE(buffer.map, nullptr);
auto open = [&](std::uint64_t modifier) {
const int before = fake->registrations(1);
std::thread sender {[&]() {
if (qemu_test::wait_until([&]() {
return fake->registrations(1) > before;
})) {
fake->scanout_dmabuf(1, buffer.fd, 4, 2, 16, qemu::drm_fourcc::xrgb8888, modifier, true);
}
}};
auto d = platf::qemu_display(platf::mem_type_e::system, "1", stream_config());
sender.join();
return d;
};
auto display = open(qemu::drm_fourcc::mod_invalid);
ASSERT_NE(display, nullptr);
std::vector<std::uint8_t> frame;
auto status = run_capture(*display, [&](std::shared_ptr<platf::img_t> &&img, bool frame_captured) {
if (frame_captured) {
frame.assign(img->data, img->data + 4);
}
return !frame_captured;
});
EXPECT_EQ(status, platf::capture_e::ok);
EXPECT_EQ(frame, (std::vector<std::uint8_t> {0x11, 0x22, 0x33, 0xff}));
display.reset();
// a tiled GPU buffer can't be read from system memory
EXPECT_EQ(open(0x0100000000000001ULL), nullptr);
}
// @tag requirements: [REQ-CAP-003]
TEST_F(QemuCaptureTest, VaapiRequiresQemuToRenderOnTheEncoderGpu) {
auto node = qemu::default_render_node();
if (!node) {
GTEST_SKIP() << "no DRM render node on this host; the REQ-CAP-003 render node check can't be exercised";
}
auto saved_adapter = config::video.adapter_name;
memfd_buffer_t buffer {4, 4, 1, 1, 1};
auto open = [&]() {
const int before = fake->registrations(1);
std::thread sender {[&]() {
if (qemu_test::wait_until([&]() {
return fake->registrations(1) > before;
})) {
fake->scanout_dmabuf(1, buffer.fd, 4, 4, 16, qemu::drm_fourcc::xrgb8888, qemu::drm_fourcc::mod_linear, true);
}
}};
auto d = platf::qemu_display(platf::mem_type_e::vaapi, "1", stream_config());
sender.join();
return d;
};
config::video.adapter_name = "/dev/null";
EXPECT_EQ(open(), nullptr);
config::video.adapter_name = *node;
EXPECT_NE(open(), nullptr);
config::video.adapter_name = saved_adapter;
}
// @tag requirements: [REQ-CAP-001]
tests/unit/platform/linux/qemu/test_dmabuf_gpu.cpp +178 −0
@@ -1,0 +1,178 @@
/**
* @file tests/unit/platform/linux/qemu/test_dmabuf_gpu.cpp
* @brief Test importing QEMU-style DMABUF scanouts through EGL on a real GPU.
* @details Needs `/dev/udmabuf` and a DRM render node with GBM. Hosts without them (containers,
* WSL2) skip these tests, so REQ-CAP-003's EGL import is only verified on native Linux GPU hosts.
*/
#ifdef SUNSHINE_BUILD_QEMU
// test includes
#include "../../../../tests_common.h"
// standard includes
#include <array>
#include <vector>
// platform includes
#include <fcntl.h>
#include <linux/udmabuf.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <unistd.h>
// local includes
#include <src/platform/linux/graphics.h>
#include <src/platform/linux/qemu/pixel_format.h>
#include <src/platform/linux/qemu/render_node.h>
namespace {
/**
* @brief GPU resources for one test: render node, GBM device, EGL display and context.
*/
struct gpu_t {
file_t render_fd; ///< Render node descriptor.
gbm::gbm_t gbm; ///< GBM device.
egl::display_t display; ///< EGL display.
std::optional<egl::ctx_t> ctx; ///< Current EGL context.
};
/**
* @brief Set up EGL on the default render node.
*
* @param reason Why the GPU isn't usable, when it isn't.
* @return GPU resources, or nothing.
*/
std::optional<gpu_t> open_gpu(std::string &reason) {
auto node = qemu::default_render_node();
if (!node) {
reason = "no DRM render node";
return std::nullopt;
}
if (gbm::init()) {
reason = "libgbm is not available";
return std::nullopt;
}
if (!gladLoaderLoadEGL(nullptr)) {
reason = "libEGL is not available";
return std::nullopt;
}
gpu_t gpu;
gpu.render_fd.el = open(node->c_str(), O_RDWR | O_CLOEXEC);
if (gpu.render_fd.el < 0) {
reason = "can't open " + *node;
return std::nullopt;
}
gpu.gbm.reset(gbm::create_device(gpu.render_fd.el));
if (!gpu.gbm) {
reason = "can't create a GBM device on " + *node;
return std::nullopt;
}
gpu.display = egl::make_display(gpu.gbm.get());
if (!gpu.display) {
reason = "can't create an EGL display on " + *node;
return std::nullopt;
}
gpu.ctx = egl::make_ctx(gpu.display.get());
if (!gpu.ctx) {
reason = "can't create an EGL context on " + *node;
return std::nullopt;
}
return gpu;
}
/**
* @brief Create a udmabuf with position-encoded XRGB8888 pixels.
*
* @param width Buffer width.
* @param height Buffer height.
* @return DMABUF descriptor, or -1 when udmabuf isn't available.
*/
int make_udmabuf(int width, int height) {
int dev = open("/dev/udmabuf", O_RDWR | O_CLOEXEC);
if (dev < 0) {
return -1;
}
const auto page = (std::size_t) sysconf(_SC_PAGESIZE);
const auto size = (((std::size_t) width * height * 4) + page - 1) / page * page;
int memfd = memfd_create("qemu-gpu-test", MFD_ALLOW_SEALING | MFD_CLOEXEC);
int dmabuf = -1;
if (memfd >= 0 && ftruncate(memfd, (off_t) size) == 0 && fcntl(memfd, F_ADD_SEALS, F_SEAL_SHRINK) == 0) {
auto map = (std::uint8_t *) mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, memfd, 0);
if (map != MAP_FAILED) {
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
auto p = map + ((std::size_t) y * width + x) * 4;
p[0] = (std::uint8_t) (x * 40); // B
p[1] = (std::uint8_t) (y * 40); // G
p[2] = 0x80; // R
p[3] = 0xff;
}
}
munmap(map, size);
udmabuf_create create {};
create.memfd = (std::uint32_t) memfd;
create.flags = UDMABUF_FLAGS_CLOEXEC;
create.offset = 0;
create.size = size;
dmabuf = ioctl(dev, UDMABUF_CREATE, &create);
}
}
if (memfd >= 0) {
close(memfd);
}
close(dev);
return dmabuf;
}
} // namespace
// @tag requirements: [REQ-CAP-003]
TEST(QemuDmabufGpuTest, ImportsCropsAndFlipsBottomUpScanout) {
int dmabuf = make_udmabuf(4, 4);
if (dmabuf < 0) {
GTEST_SKIP() << "/dev/udmabuf is not available; REQ-CAP-003 EGL import is verified only on native Linux GPU hosts";
}
std::string reason;
auto gpu = open_gpu(reason);
if (!gpu) {
close(dmabuf);
GTEST_SKIP() << reason << "; REQ-CAP-003 EGL import is verified only on native Linux GPU hosts";
}
egl::surface_descriptor_t sd {};
sd.width = 4;
sd.height = 4;
sd.fds[0] = dmabuf;
sd.fds[1] = sd.fds[2] = sd.fds[3] = -1;
sd.fourcc = qemu::drm_fourcc::xrgb8888;
sd.modifier = qemu::drm_fourcc::mod_invalid;
sd.pitches[0] = 16;
sd.offsets[0] = 0;
auto rgb = egl::import_source(gpu->display.get(), sd);
close(dmabuf);
ASSERT_TRUE(rgb.has_value()) << "EGL couldn't import the DMABUF";
// the 2x2 display rectangle at (1, 1) of a bottom-up buffer, as sws_t::load_vram copies it
auto target = gl::tex_t::make(1);
gl::ctx.BindTexture(GL_TEXTURE_2D, target[0]);
gl::ctx.TexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 2, 2);
auto source = gl::frame_buf_t::make(1);
source.bind(&(*rgb)->tex[0], &(*rgb)->tex[0] + 1);
source.copy_flipped(0, target[0], 1, 1, 2, 2);
auto readback = gl::frame_buf_t::make(1);
readback.bind(&target[0], &target[0] + 1);
gl::ctx.BindFramebuffer(GL_READ_FRAMEBUFFER, readback[0]);
std::array<std::uint8_t, 2 * 2 * 4> pixels {};
gl::ctx.ReadPixels(0, 0, 2, 2, GL_BGRA, GL_UNSIGNED_BYTE, pixels.data());
gl::ctx.BindFramebuffer(GL_FRAMEBUFFER, 0);
// output row 0 is buffer row 2, output row 1 is buffer row 1; columns 1 and 2
auto pixel = [&](int x, int y) {
return std::array<std::uint8_t, 2> {pixels[(y * 2 + x) * 4], pixels[(y * 2 + x) * 4 + 1]};
};
EXPECT_EQ(pixel(0, 0), (std::array<std::uint8_t, 2> {40, 80}));
EXPECT_EQ(pixel(1, 0), (std::array<std::uint8_t, 2> {80, 80}));
EXPECT_EQ(pixel(0, 1), (std::array<std::uint8_t, 2> {40, 40}));
EXPECT_EQ(pixel(1, 1), (std::array<std::uint8_t, 2> {80, 40}));
}
#endif
tests/unit/platform/linux/qemu/test_frame_store.cpp +227 −0
@@ -12,6 +12,7 @@
// platform includes
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
// local includes
@@ -407,6 +408,232 @@
qemu::cursor_state_t hidden;
qemu::blend_cursor(hidden, frame.data(), 1, 1);
EXPECT_EQ(frame, std::vector<std::uint8_t>(4, 0x33));
}
namespace {
/**
* @brief A memfd standing in for a single-plane DMABUF, filled with position-encoded XRGB pixels.
*/
struct fake_dmabuf_t {
int fd {-1}; ///< Descriptor owned by the fixture.
std::uint8_t *map {nullptr}; ///< Writable mapping, to simulate the guest drawing.
std::size_t size {0}; ///< Mapping length.
/**
* @brief Create the buffer.
*
* @param width Buffer width.
* @param height Buffer height.
* @param stride Bytes per row.
* @param offset Offset of the first pixel.
*/
fake_dmabuf_t(int width, int height, int stride, std::uint32_t offset = 0) {
size = offset + (std::size_t) stride * height;
fd = memfd_create("frame-store-dmabuf", MFD_CLOEXEC);
if (fd < 0 || ftruncate(fd, (off_t) size) != 0) {
return;
}
map = (std::uint8_t *) mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
auto pixels = make_pixels(width, height, stride);
std::copy(pixels.begin(), pixels.end(), map + offset);
}
~fake_dmabuf_t() {
if (map && map != MAP_FAILED) {
munmap(map, size);
}
if (fd >= 0) {
close(fd);
}
}
fake_dmabuf_t(const fake_dmabuf_t &) = delete;
fake_dmabuf_t &operator=(const fake_dmabuf_t &) = delete;
/**
* @brief Describe the buffer the way the session delivers a DMABUF scanout.
*
* @param x Display rectangle left edge.
* @param y Display rectangle top edge.
* @param width Display width.
* @param height Display height.
* @param backing_width Buffer width.
* @param backing_height Buffer height.
* @param stride Bytes per row.
* @param y0_top Whether row 0 is the top.
* @return Scanout owning a duplicate of the descriptor.
*/
qemu::dmabuf_scanout_t scanout(std::uint32_t x, std::uint32_t y, std::uint32_t width, std::uint32_t height, std::uint32_t backing_width, std::uint32_t backing_height, std::uint32_t stride, bool y0_top) const {
qemu::dmabuf_scanout_t buffer;
buffer.fds[0] = qemu::fd_t {dup(fd)};
buffer.num_planes = 1;
buffer.strides[0] = stride;
buffer.x = x;
buffer.y = y;
buffer.width = width;
buffer.height = height;
buffer.backing_width = backing_width;
buffer.backing_height = backing_height;
buffer.fourcc = qemu::drm_fourcc::xrgb8888;
buffer.modifier = qemu::drm_fourcc::mod_linear;
buffer.y0_top = y0_top;
return buffer;
}
};
} // namespace
// @tag requirements: [REQ-CAP-003]
TEST(QemuFrameStoreTest, DmabufReadbackCropsFlipsAndFollowsDamage) {
fake_dmabuf_t dmabuf {4, 4, 16};
ASSERT_NE(dmabuf.map, nullptr);
qemu::frame_store_t store;
store.update_dmabuf(0, 0, 1, 1); // no DMABUF yet: ignored
EXPECT_EQ(store.sequence(), 0);
// the display is the 2x2 rectangle at (1, 1) of a bottom-up 4x4 buffer
store.scanout_dmabuf(dmabuf.scanout(1, 1, 2, 2, 4, 4, 16, false));
EXPECT_EQ(store.kind(), qemu::scanout_kind_e::dmabuf);
EXPECT_TRUE(store.dmabuf_readable());
EXPECT_EQ(store.width(), 2);
EXPECT_EQ(store.height(), 2);
std::vector<std::uint8_t> frame(2 * 2 * 4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
ASSERT_EQ(store.copy_if_newer(seq, 2, 2, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, 2, 0, 0), expected_bgr(1, 2));
EXPECT_EQ(bgr_at(frame, 2, 1, 0), expected_bgr(2, 2));
EXPECT_EQ(bgr_at(frame, 2, 0, 1), expected_bgr(1, 1));
EXPECT_EQ(bgr_at(frame, 2, 1, 1), expected_bgr(2, 1));
// the guest draws into display pixel (1, 0), which is buffer pixel (2, 2); QEMU reports the damage
dmabuf.map[16 * 2 + 4 * 2] = 0x42;
store.update_dmabuf(1, 0, 1, 1);
ASSERT_EQ(store.copy_if_newer(seq, 2, 2, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, 2, 1, 0)[0], 0x42);
EXPECT_EQ(bgr_at(frame, 2, 0, 1), expected_bgr(1, 1));
// damage outside the display is ignored
auto before = store.sequence();
store.update_dmabuf(5, 5, 1, 1);
EXPECT_EQ(store.sequence(), before);
// a top-down buffer is not flipped
store.scanout_dmabuf(dmabuf.scanout(1, 1, 2, 2, 4, 4, 16, true));
ASSERT_EQ(store.copy_if_newer(seq, 2, 2, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, 2, 0, 0), expected_bgr(1, 1));
EXPECT_EQ(bgr_at(frame, 2, 1, 1)[0], 0x42);
// memory scanouts replace the DMABUF; its damage is then ignored
store.scanout(2, 2, 8, qemu::pixman_format::x8r8g8b8, make_pixels(2, 2, 8));
EXPECT_EQ(store.kind(), qemu::scanout_kind_e::memory);
EXPECT_FALSE(store.dmabuf_readable());
before = store.sequence();
store.update_dmabuf(0, 0, 1, 1);
EXPECT_EQ(store.sequence(), before);
}
// @tag requirements: [REQ-CAP-003]
TEST(QemuFrameStoreTest, DmabufFramesDuplicateDescriptorsAndTrackBuffers) {
fake_dmabuf_t dmabuf {8, 6, 32};
ASSERT_NE(dmabuf.map, nullptr);
qemu::frame_store_t store {false};
qemu::dmabuf_frame_t frame;
std::uint64_t seq = 0;
EXPECT_EQ(store.dmabuf_if_newer(seq, 0, 0, frame), qemu::frame_status_e::size_changed) << "no DMABUF yet";
store.scanout_dmabuf(dmabuf.scanout(2, 1, 4, 3, 8, 6, 32, false));
EXPECT_FALSE(store.dmabuf_readable()) << "readback is off for GPU encoders";
ASSERT_EQ(store.dmabuf_if_newer(seq, 4, 3, frame), qemu::frame_status_e::new_frame);
struct stat original {};
struct stat copy {};
ASSERT_EQ(fstat(dmabuf.fd, &original), 0);
ASSERT_EQ(fstat(frame.buffer.fds[0].get(), &copy), 0);
EXPECT_EQ(copy.st_ino, original.st_ino);
EXPECT_NE(frame.buffer.fds[0].get(), dmabuf.fd);
EXPECT_EQ(frame.buffer.fds[1].get(), -1);
EXPECT_EQ(frame.buffer.x, 2);
EXPECT_EQ(frame.buffer.y, 1);
EXPECT_EQ(frame.buffer.backing_width, 8);
EXPECT_EQ(frame.buffer.strides[0], 32);
EXPECT_FALSE(frame.buffer.y0_top);
EXPECT_FALSE(frame.disabled);
const auto first_generation = frame.generation;
EXPECT_EQ(store.dmabuf_if_newer(seq, 4, 3, frame), qemu::frame_status_e::unchanged);
// damage keeps the buffer generation, a new scanout changes it
store.update_dmabuf(0, 0, 4, 3);
ASSERT_EQ(store.dmabuf_if_newer(seq, 4, 3, frame), qemu::frame_status_e::new_frame);
EXPECT_EQ(frame.generation, first_generation);
store.scanout_dmabuf(dmabuf.scanout(2, 1, 4, 3, 8, 6, 32, false));
ASSERT_EQ(store.dmabuf_if_newer(seq, 4, 3, frame), qemu::frame_status_e::new_frame);
EXPECT_GT(frame.generation, first_generation);
// Disable shows black until the guest draws again
store.disable();
ASSERT_EQ(store.dmabuf_if_newer(seq, 4, 3, frame), qemu::frame_status_e::new_frame);
EXPECT_TRUE(frame.disabled);
store.update_dmabuf(0, 0, 1, 1);
ASSERT_EQ(store.dmabuf_if_newer(seq, 4, 3, frame), qemu::frame_status_e::new_frame);
EXPECT_FALSE(frame.disabled);
// a size change or a switch to memory scanouts needs a new display
EXPECT_EQ(store.dmabuf_if_newer(seq, 8, 6, frame), qemu::frame_status_e::size_changed);
store.scanout(4, 3, 16, qemu::pixman_format::x8r8g8b8, make_pixels(4, 3, 16));
EXPECT_EQ(store.dmabuf_if_newer(seq, 4, 3, frame), qemu::frame_status_e::size_changed);
store.disconnected();
EXPECT_EQ(store.dmabuf_if_newer(seq, 4, 3, frame), qemu::frame_status_e::disconnected);
}
// @tag requirements: [REQ-CAP-003]
TEST(QemuFrameStoreTest, DmabufRejectsBadGeometryAndUnreadableLayouts) {
fake_dmabuf_t dmabuf {4, 4, 16};
ASSERT_NE(dmabuf.map, nullptr);
qemu::frame_store_t store;
// the display rectangle must lie inside the buffer, and a descriptor is required
store.scanout_dmabuf(dmabuf.scanout(2, 0, 4, 4, 4, 4, 16, true));
store.scanout_dmabuf(dmabuf.scanout(0, 0, 0, 4, 4, 4, 16, true));
auto no_fd = dmabuf.scanout(0, 0, 4, 4, 4, 4, 16, true);
no_fd.fds[0] = qemu::fd_t {};
store.scanout_dmabuf(std::move(no_fd));
auto missing_plane = dmabuf.scanout(0, 0, 4, 4, 4, 4, 16, true);
missing_plane.num_planes = 2;
store.scanout_dmabuf(std::move(missing_plane));
auto too_many_planes = dmabuf.scanout(0, 0, 4, 4, 4, 4, 16, true);
too_many_planes.num_planes = 5;
store.scanout_dmabuf(std::move(too_many_planes));
EXPECT_EQ(store.kind(), qemu::scanout_kind_e::none);
EXPECT_EQ(store.sequence(), 0);
// valid scanouts that can't be read from system memory still count as DMABUF frames
auto tiled = dmabuf.scanout(0, 0, 4, 4, 4, 4, 16, true);
tiled.modifier = 0x0100000000000001ULL;
store.scanout_dmabuf(std::move(tiled));
EXPECT_EQ(store.kind(), qemu::scanout_kind_e::dmabuf);
EXPECT_FALSE(store.dmabuf_readable());
auto nv12 = dmabuf.scanout(0, 0, 4, 4, 4, 4, 16, true);
nv12.fourcc = 0x3231564e;
store.scanout_dmabuf(std::move(nv12));
EXPECT_FALSE(store.dmabuf_readable());
auto wide_stride = dmabuf.scanout(0, 0, 4, 4, 4, 4, 64, true); // 64 * 3 + 16 bytes > 64
store.scanout_dmabuf(std::move(wide_stride));
EXPECT_FALSE(store.dmabuf_readable());
auto narrow_stride = dmabuf.scanout(0, 0, 4, 4, 4, 4, 8, true);
store.scanout_dmabuf(std::move(narrow_stride));
EXPECT_FALSE(store.dmabuf_readable());
std::vector<std::uint8_t> frame(4 * 4 * 4, 0x55);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
ASSERT_EQ(store.copy_if_newer(seq, 4, 4, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, 4, 0, 0), (std::array<std::uint8_t, 3> {0, 0, 0})) << "unreadable buffers show black";
}
// @tag requirements: [REQ-CAP-001]
tests/unit/platform/linux/qemu/test_render_node.cpp +103 −0
@@ -1,0 +1,103 @@
/**
* @file tests/unit/platform/linux/qemu/test_render_node.cpp
* @brief Test matching the render node QEMU uses with the encoder's.
*/
#ifdef SUNSHINE_BUILD_QEMU
// test includes
#include "../../../../tests_common.h"
// standard includes
#include <filesystem>
#include <fstream>
// platform includes
#include <unistd.h>
// local includes
#include <src/platform/linux/qemu/render_node.h>
namespace {
/**
* @brief A temporary directory removed at the end of the test.
*/
struct temp_dir_t {
std::filesystem::path path; ///< Directory path.
temp_dir_t() {
char name[] = "/tmp/sunshine-qemu-render-XXXXXX";
if (mkdtemp(name)) {
path = name;
}
}
~temp_dir_t() {
std::error_code ec;
std::filesystem::remove_all(path, ec);
}
temp_dir_t(const temp_dir_t &) = delete;
temp_dir_t &operator=(const temp_dir_t &) = delete;
};
} // namespace
// @tag requirements: [REQ-CAP-003]
TEST(QemuRenderNodeTest, FindsRendernodeOptionOnTheCommandLine) {
EXPECT_EQ(qemu::render_node_from_cmdline({"qemu-system-x86_64", "-display", "dbus,gl=on,rendernode=/dev/dri/renderD129,addr=unix:path=/run/bus"}), "/dev/dri/renderD129");
EXPECT_EQ(qemu::render_node_from_cmdline({"qemu", "-display", "egl-headless,rendernode=/dev/dri/renderD128"}), "/dev/dri/renderD128");
// the last occurrence wins, like QEMU's option parsing
EXPECT_EQ(qemu::render_node_from_cmdline({"qemu", "-display", "dbus,rendernode=/dev/dri/renderD128", "-display", "dbus,rendernode=/dev/dri/renderD130"}), "/dev/dri/renderD130");
EXPECT_FALSE(qemu::render_node_from_cmdline({"qemu", "-display", "dbus,gl=on"}).has_value());
EXPECT_FALSE(qemu::render_node_from_cmdline({"qemu", "-display", "dbus,rendernode="}).has_value());
EXPECT_FALSE(qemu::render_node_from_cmdline({}).has_value());
}
// @tag requirements: [REQ-CAP-003]
TEST(QemuRenderNodeTest, ReadsProcessCommandLine) {
auto args = qemu::read_cmdline((std::uint32_t) getpid());
ASSERT_FALSE(args.empty());
EXPECT_NE(args[0].find("test_sunshine"), std::string::npos);
EXPECT_TRUE(qemu::read_cmdline(0).empty());
// this process has no rendernode= option, so QEMU's default node is used
EXPECT_EQ(qemu::qemu_render_node((std::uint32_t) getpid()), qemu::default_render_node());
EXPECT_EQ(qemu::qemu_render_node(std::nullopt), qemu::default_render_node());
}
// @tag requirements: [REQ-CAP-003]
TEST(QemuRenderNodeTest, DefaultNodeIsTheFirstRenderCharacterDevice) {
temp_dir_t dri;
ASSERT_FALSE(dri.path.empty());
EXPECT_FALSE(qemu::default_render_node(dri.path.string()).has_value());
std::filesystem::create_symlink("/dev/null", dri.path / "card0");
EXPECT_FALSE(qemu::default_render_node(dri.path.string()).has_value()) << "primary nodes are skipped";
std::ofstream {dri.path / "renderD200"} << "not a device";
EXPECT_FALSE(qemu::default_render_node(dri.path.string()).has_value()) << "regular files are skipped";
std::filesystem::create_symlink("/dev/null", dri.path / "renderD128");
EXPECT_EQ(qemu::default_render_node(dri.path.string()), (dri.path / "renderD128").string());
EXPECT_FALSE(qemu::default_render_node("/nonexistent/dri").has_value());
}
// @tag requirements: [REQ-CAP-003]
TEST(QemuRenderNodeTest, ComparesDevicesAndReadsDrivers) {
temp_dir_t dir;
ASSERT_FALSE(dir.path.empty());
std::filesystem::create_symlink("/dev/null", dir.path / "renderD128");
EXPECT_TRUE(qemu::same_device("/dev/null", (dir.path / "renderD128").string()));
EXPECT_FALSE(qemu::same_device("/dev/null", "/dev/zero"));
EXPECT_FALSE(qemu::same_device("/dev/null", "/nonexistent/renderD128"));
std::ofstream {dir.path / "file"} << "x";
EXPECT_TRUE(qemu::same_device((dir.path / "file").string(), (dir.path / "file").string()));
EXPECT_FALSE(qemu::same_device((dir.path / "file").string(), "/dev/null"));
// sysfs: /sys/class/drm/renderD128/device/driver -> .../drivers/i915
auto device = dir.path / "sys/class/drm/renderD128/device";
std::filesystem::create_directories(device);
std::filesystem::create_directories(dir.path / "sys/bus/pci/drivers/i915");
std::filesystem::create_symlink(dir.path / "sys/bus/pci/drivers/i915", device / "driver");
EXPECT_EQ(qemu::render_node_driver("/dev/dri/renderD128", (dir.path / "sys").string()), "i915");
EXPECT_EQ(qemu::render_node_driver("/dev/dri/renderD129", (dir.path / "sys").string()), "");
}
#endif
tests/unit/platform/linux/qemu/test_session.cpp +137 −4
@@ -12,6 +12,7 @@
#include <cstring>
#include <fcntl.h>
#include <mutex>
#include <sys/stat.h>
// local includes
#include <src/platform/linux/qemu/session.h>
@@ -34,6 +35,8 @@
std::int32_t last_y {0};
std::uint32_t last_offset {0};
std::vector<std::uint8_t> mapped_bytes;
qemu::dmabuf_scanout_t last_dmabuf;
std::vector<ino_t> dmabuf_inodes;
std::atomic<int> disconnects {0};
void scanout(std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) override {
@@ -84,6 +87,27 @@
last_height = height;
}
void scanout_dmabuf(qemu::dmabuf_scanout_t buffer) override {
std::lock_guard lock {mutex};
calls.emplace_back("scanout_dmabuf");
// replace descriptors by the inode they refer to, so tests can compare them with the sender's
dmabuf_inodes.clear();
for (std::uint32_t plane = 0; plane < buffer.num_planes; ++plane) {
struct stat st {};
dmabuf_inodes.push_back(fstat(buffer.fds[plane].get(), &st) == 0 ? st.st_ino : 0);
}
last_dmabuf = std::move(buffer);
}
void update_dmabuf(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) override {
std::lock_guard lock {mutex};
calls.emplace_back("update_dmabuf");
last_x = x;
last_y = y;
last_width = width;
last_height = height;
}
void disable() override {
std::lock_guard lock {mutex};
calls.emplace_back("disable");
@@ -335,10 +359,66 @@
ASSERT_TRUE(fake->disable(1));
ASSERT_TRUE(fake->cursor(1));
EXPECT_EQ(listener->snapshot_calls(), (std::vector<std::string> {"disable", "mouse_set", "cursor_define"}));
}
/**
* @brief Inode of a descriptor, to recognize the same buffer after it crossed D-Bus.
*
* @param fd Descriptor.
* @return Inode number.
*/
static ino_t inode_of(int fd) {
struct stat st {};
fstat(fd, &st);
return st.st_ino;
}
// @tag requirements: [REQ-CAP-003]
TEST_F(QemuSessionTest, DeliversDmabufScanoutAndDamage) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
auto interfaces = fake->listener_interfaces(1);
EXPECT_NE(std::ranges::find(interfaces, "org.qemu.Display1.Listener.Unix.ScanoutDMABUF2"), interfaces.end());
int fd = memfd_create("session-dmabuf", MFD_CLOEXEC);
ASSERT_GE(fd, 0);
ASSERT_TRUE(fake->scanout_dmabuf(1, fd, 64, 32, 256, qemu::drm_fourcc::xrgb8888, 0x0100000000000001ULL, false));
ASSERT_TRUE(fake->update_dmabuf(1, 1, 2, 3, 4));
EXPECT_EQ(listener->snapshot_calls(), (std::vector<std::string> {"scanout_dmabuf", "update_dmabuf"}));
std::lock_guard lock {listener->mutex};
const auto &buffer = listener->last_dmabuf;
EXPECT_EQ(buffer.num_planes, 1);
ASSERT_EQ(listener->dmabuf_inodes.size(), 1);
EXPECT_EQ(listener->dmabuf_inodes[0], inode_of(fd));
EXPECT_NE(buffer.fds[0].get(), fd) << "the listener owns its own copy of the descriptor";
EXPECT_EQ(buffer.fds[1].get(), -1);
EXPECT_EQ(buffer.width, 64);
EXPECT_EQ(buffer.height, 32);
EXPECT_EQ(buffer.backing_width, 64);
EXPECT_EQ(buffer.backing_height, 32);
EXPECT_EQ(buffer.x, 0);
EXPECT_EQ(buffer.y, 0);
EXPECT_EQ(buffer.strides[0], 256);
EXPECT_EQ(buffer.offsets[0], 0);
EXPECT_EQ(buffer.fourcc, qemu::drm_fourcc::xrgb8888);
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, AcknowledgesUnsupportedDmabufScanout) {
EXPECT_EQ(buffer.modifier, 0x0100000000000001ULL);
EXPECT_FALSE(buffer.y0_top);
EXPECT_EQ(listener->last_x, 1);
EXPECT_EQ(listener->last_y, 2);
EXPECT_EQ(listener->last_width, 3);
EXPECT_EQ(listener->last_height, 4);
close(fd);
}
// @tag requirements: [REQ-CAP-003]
TEST_F(QemuSessionTest, DeliversMultiPlaneDmabuf2Scanout) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
@@ -347,8 +427,61 @@
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
int y_fd = memfd_create("session-dmabuf-y", MFD_CLOEXEC);
int uv_fd = memfd_create("session-dmabuf-uv", MFD_CLOEXEC);
ASSERT_GE(y_fd, 0);
ASSERT_GE(uv_fd, 0);
qemu_test::fake_qemu_t::dmabuf2_t buffer;
EXPECT_TRUE(fake->scanout_dmabuf(1));
EXPECT_TRUE(listener->snapshot_calls().empty());
buffer.fds = {y_fd, uv_fd};
buffer.offsets = {0, 16};
buffer.strides = {1920, 960};
buffer.num_planes = 2;
buffer.x = 10;
buffer.y = 20;
buffer.width = 1280;
buffer.height = 720;
buffer.backing_width = 1920;
buffer.backing_height = 1080;
buffer.fourcc = 0x3231564e; // NV12
buffer.modifier = qemu::drm_fourcc::mod_linear;
buffer.y0_top = true;
ASSERT_TRUE(fake->scanout_dmabuf2(1, buffer));
{
std::lock_guard lock {listener->mutex};
const auto &got = listener->last_dmabuf;
ASSERT_EQ(got.num_planes, 2);
EXPECT_EQ(listener->dmabuf_inodes, (std::vector<ino_t> {inode_of(y_fd), inode_of(uv_fd)}));
EXPECT_EQ(got.offsets[1], 16);
EXPECT_EQ(got.strides[0], 1920);
EXPECT_EQ(got.strides[1], 960);
EXPECT_EQ(got.x, 10);
EXPECT_EQ(got.y, 20);
EXPECT_EQ(got.width, 1280);
EXPECT_EQ(got.height, 720);
EXPECT_EQ(got.backing_width, 1920);
EXPECT_EQ(got.backing_height, 1080);
EXPECT_EQ(got.fourcc, 0x3231564e);
EXPECT_TRUE(got.y0_top);
}
// inconsistent plane counts are rejected with an error, and not delivered
buffer.num_planes = 3;
EXPECT_FALSE(fake->scanout_dmabuf2(1, buffer));
buffer.num_planes = 0;
EXPECT_FALSE(fake->scanout_dmabuf2(1, buffer));
EXPECT_EQ(listener->snapshot_calls(), (std::vector<std::string> {"scanout_dmabuf"}));
close(y_fd);
close(uv_fd);
}
// @tag requirements: [REQ-CAP-003]
TEST_F(QemuSessionTest, ReportsQemuProcessId) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
EXPECT_EQ(session->qemu_pid(), qemu_test::fake_qemu_t::pid());
}
// @tag requirements: [REQ-CAP-001]