diff --git a/docs/configuration.md b/docs/configuration.md
index d28f1df24e7..5cc26d51bd9 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -2426,6 +2426,10 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin
remoteidd |
Read the frames published by the OpenStreamMS RemoteIdd display driver of the current
session, without Desktop Duplication or WGC. Never chosen automatically.
+ If OpenStreamMS writes `osms-mosaic.txt` next to `sunshine.conf` (one
+ ` ` line per session, parts of a 2x2 grid such as `Left` or
+ `TopRight`, and optionally `canvas `), the stream becomes a mosaic: a canvas with every
+ listed session copied into its part, and mouse input mapped to this session's part.
@note{Applies to Windows only. Requires a hardware encoder and SDR.} |
diff --git a/src/platform/windows/display.h b/src/platform/windows/display.h
index be7bf283ae6..293b8dda372 100644
--- a/src/platform/windows/display.h
+++ b/src/platform/windows/display.h
@@ -4,6 +4,13 @@
*/
#pragma once
+// standard includes
+#include
+#include
+#include
+#include
+#include
+
// platform includes
#include
#include
@@ -863,6 +870,64 @@ namespace platf::dxgi {
capture_e release_snapshot() override;
};
+ /**
+ * @brief A session of a mosaic stream, as listed in osms-mosaic.txt.
+ */
+ struct mosaic_entry_t {
+ uint32_t session; ///< Terminal Services session id.
+ int cells; ///< Cells of the 2x2 grid it covers: 1 top-left, 2 top-right, 4 bottom-left, 8 bottom-right.
+ };
+
+ /**
+ * @brief Content of osms-mosaic.txt.
+ */
+ struct mosaic_layout_t {
+ int canvas_width = 0; ///< Canvas width from the "canvas W H" line; 0 = the stream size.
+ int canvas_height = 0; ///< Canvas height from the "canvas W H" line; 0 = the stream size.
+ std::vector entries; ///< Sessions in file order, without overlaps.
+ };
+
+ /**
+ * @brief Rectangle of a part of the mosaic canvas.
+ */
+ struct mosaic_rect_t {
+ int x; ///< Left edge.
+ int y; ///< Top edge.
+ int width; ///< Width.
+ int height; ///< Height.
+ };
+
+ /**
+ * @brief Grid cells covered by a part name (Full, Left, Right, Top, Bottom, TopLeft,
+ * TopRight, BottomLeft, BottomRight), the names OpenStreamMS uses for split seats.
+ *
+ * @param area Part name.
+ * @return Cell bits, or 0 for an unknown name.
+ */
+ int mosaic_area_cells(std::string_view area);
+
+ /**
+ * @brief Parse osms-mosaic.txt.
+ *
+ * Lines are "canvas " or " "; lines starting with #
+ * are comments. Lines that do not parse, and parts that overlap an earlier one, are skipped.
+ *
+ * @param text File content.
+ * @return Parsed layout.
+ */
+ mosaic_layout_t parse_mosaic_layout(const std::string &text);
+
+ /**
+ * @brief Rectangle covered by some grid cells on a canvas. With an odd size the left or
+ * top half gets the smaller side, like SeatMonitor.Region in OpenStreamMS.
+ *
+ * @param cells Grid cells.
+ * @param canvas_width Canvas width.
+ * @param canvas_height Canvas height.
+ * @return Rectangle of the part.
+ */
+ mosaic_rect_t mosaic_rect(int cells, int canvas_width, int canvas_height);
+
/**
* Display backend that reads the frames published by the OpenStreamMS RemoteIdd driver.
*
@@ -870,6 +935,11 @@ namespace platf::dxgi {
* mutex) and announces it through a named slot; this backend copies that texture into
* the capture image without going through Desktop Duplication or WGC. Only selected
* with capture = remoteidd.
+ *
+ * Mosaic: when OpenStreamMS writes osms-mosaic.txt next to sunshine.conf listing this
+ * session and at least one more, the stream becomes a canvas split like a 2x2 grid and
+ * every listed session is copied into its part. Mouse input is offset so that it lands
+ * in this session's part.
*/
class display_ridd_vram_t: public display_vram_t {
public:
@@ -901,7 +971,58 @@ namespace platf::dxgi {
*/
capture_e release_snapshot() override;
+ /**
+ * @brief Mosaic canvas, for tests and diagnostics.
+ *
+ * @return The canvas texture, or nullptr outside mosaic mode.
+ */
+ ID3D11Texture2D *canvas_texture() {
+ return canvas.get();
+ }
+
private:
- std::unique_ptr consumer; ///< Reader of the RemoteIdd frame slot.
+ /**
+ * @brief One session of a mosaic stream and its rectangle on the canvas.
+ */
+ struct mosaic_tile_t {
+ uint32_t session = 0; ///< Terminal Services session whose slot is read.
+ int x = 0; ///< Left edge on the canvas.
+ int y = 0; ///< Top edge on the canvas.
+ int width = 0; ///< Width on the canvas.
+ int height = 0; ///< Height on the canvas.
+ std::unique_ptr consumer; ///< Null until the session publishes frames.
+ uint64_t last_frame = 0; ///< Last FrameId copied onto the canvas.
+ uint32_t frame_width = 0; ///< Size of the last frame copied, to notice a shrink.
+ uint32_t frame_height = 0; ///< Size of the last frame copied, to notice a shrink.
+ std::chrono::steady_clock::time_point next_open; ///< When to retry opening the slot.
+ };
+
+ /**
+ * @brief Re-read osms-mosaic.txt (at most every 250 ms).
+ * @return True when it no longer matches the layout this display was built with.
+ */
+ bool layout_changed();
+ /**
+ * @brief Open the frame slot of a mosaic tile; on failure retry later.
+ * @param tile Tile to open.
+ * @return True when the slot is open.
+ */
+ bool open_tile(mosaic_tile_t &tile);
+ /**
+ * @brief Clear the canvas to black and copy every tile again on the next snapshot.
+ */
+ void clear_canvas();
+ /**
+ * @brief Snapshot in mosaic mode: copy the tiles that changed and then the canvas.
+ */
+ capture_e mosaic_snapshot(const pull_free_image_cb_t &pull_free_image_cb, std::shared_ptr &img_out, std::chrono::milliseconds timeout);
+
+ std::unique_ptr consumer; ///< Reader of the RemoteIdd frame slot (single-session mode).
+ std::vector tiles; ///< Sessions of the mosaic; empty in single-session mode.
+ texture2d_t canvas; ///< Mosaic canvas, the size of the stream.
+ render_target_t canvas_rt; ///< Render target view of the canvas, to clear it.
+ bool compose_now = false; ///< Compose without waiting for a new frame (after init or a clear).
+ std::string layout; ///< Content of osms-mosaic.txt when this display was built.
+ std::chrono::steady_clock::time_point next_layout_check; ///< Throttle for layout_changed().
};
} // namespace platf::dxgi
diff --git a/src/platform/windows/display_remoteidd.cpp b/src/platform/windows/display_remoteidd.cpp
index 94e6e2d098a..8607cfba619 100644
--- a/src/platform/windows/display_remoteidd.cpp
+++ b/src/platform/windows/display_remoteidd.cpp
@@ -5,14 +5,22 @@
* its image helpers.
*/
// standard includes
+#include
+#include
#include
+#include
+#include
#include
+#include
#include
+#include
+#include
// local includes
#include "display.h"
#include "misc.h"
#include "remoteidd/FrameConsumer.h"
+#include "src/config.h"
#include "src/logging.h"
namespace platf::dxgi {
@@ -53,8 +61,109 @@ namespace platf::dxgi {
ProcessIdToSessionId(GetCurrentProcessId(), &session);
return session;
}
+
+ /**
+ * @brief osms-mosaic.txt, written by OpenStreamMS next to this instance's sunshine.conf.
+ *
+ * See parse_mosaic_layout() for the format.
+ */
+ std::filesystem::path mosaic_path() {
+ return std::filesystem::path(config::sunshine.config_file).parent_path() / "osms-mosaic.txt";
+ }
+
+ /**
+ * @brief Whole content of osms-mosaic.txt, or empty when there is none.
+ */
+ std::string read_layout() {
+ std::ifstream file(mosaic_path(), std::ios::binary);
+ if (!file) {
+ return {};
+ }
+ std::ostringstream content;
+ content << file.rdbuf();
+ return content.str();
+ }
+
} // namespace
+ int mosaic_area_cells(std::string_view area) {
+ constexpr std::pair areas[] {
+ {"Full"sv, 0b1111},
+ {"Left"sv, 0b0101},
+ {"Right"sv, 0b1010},
+ {"Top"sv, 0b0011},
+ {"Bottom"sv, 0b1100},
+ {"TopLeft"sv, 0b0001},
+ {"TopRight"sv, 0b0010},
+ {"BottomLeft"sv, 0b0100},
+ {"BottomRight"sv, 0b1000},
+ };
+ for (const auto &[name, cells] : areas) {
+ if (name == area) {
+ return cells;
+ }
+ }
+ return 0;
+ }
+
+ mosaic_layout_t parse_mosaic_layout(const std::string &text) {
+ mosaic_layout_t layout;
+ int used = 0;
+ std::istringstream lines(text);
+ std::string line;
+ while (std::getline(lines, line)) {
+ if (!line.empty() && line.back() == '\r') {
+ line.pop_back();
+ }
+ if (line.empty() || line[0] == '#') {
+ continue;
+ }
+ std::istringstream fields(line);
+ std::string first;
+ std::string second;
+ if (!(fields >> first >> second)) {
+ continue;
+ }
+ if (first == "canvas") {
+ int w = 0;
+ int h = 0;
+ std::istringstream size(second);
+ if ((size >> w) && (fields >> h) && w > 0 && h > 0) {
+ layout.canvas_width = w;
+ layout.canvas_height = h;
+ }
+ continue;
+ }
+ uint32_t session = 0;
+ auto [end, ec] = std::from_chars(first.data(), first.data() + first.size(), session);
+ int cells = mosaic_area_cells(second);
+ if (ec != std::errc {} || end != first.data() + first.size() || !cells || (cells & used)) {
+ BOOST_LOG(warning) << "RemoteIdd mosaic: skipping line \""sv << line << '"';
+ continue;
+ }
+ used |= cells;
+ layout.entries.push_back({session, cells});
+ }
+ return layout;
+ }
+
+ mosaic_rect_t mosaic_rect(int cells, int canvas_width, int canvas_height) {
+ const bool left = cells & 0b0101;
+ const bool right = cells & 0b1010;
+ const bool top = cells & 0b0011;
+ const bool bottom = cells & 0b1100;
+ const int w1 = canvas_width / 2;
+ const int h1 = canvas_height / 2;
+ return {
+ left ? 0 : w1,
+ top ? 0 : h1,
+ left && right ? canvas_width : left ? w1 :
+ canvas_width - w1,
+ top && bottom ? canvas_height : top ? h1 :
+ canvas_height - h1,
+ };
+ }
+
// Out of line: FrameConsumer is incomplete in display.h.
display_ridd_vram_t::display_ridd_vram_t() = default;
display_ridd_vram_t::~display_ridd_vram_t() = default;
@@ -73,12 +182,119 @@ namespace platf::dxgi {
auto ns = ridd_namespace();
auto session = ridd_session();
- consumer = std::make_unique(Microsoft::WRL::ComPtr(device.get()), session, ns.c_str());
- if (!consumer->Open()) {
- BOOST_LOG(error) << "RemoteIdd: no frame slot for session "sv << session << "; is the driver running?"sv;
+
+ layout = read_layout();
+ next_layout_check = std::chrono::steady_clock::now() + 250ms;
+ auto parsed = parse_mosaic_layout(layout);
+ bool listed = std::ranges::any_of(parsed.entries, [&](const mosaic_entry_t &e) {
+ return e.session == session;
+ });
+
+ if (parsed.entries.size() < 2 || !listed) {
+ consumer = std::make_unique(Microsoft::WRL::ComPtr(device.get()), session, ns.c_str());
+ if (!consumer->Open()) {
+ BOOST_LOG(error) << "RemoteIdd: no frame slot for session "sv << session << "; is the driver running?"sv;
+ return -1;
+ }
+ BOOST_LOG(info) << "RemoteIdd: reading frames for session "sv << session;
+ return 0;
+ }
+
+ // Mosaic: OpenStreamMS sets the canvas size (the host session's own resolution) and
+ // reconnects each session to the size of its part; the encoder scales the canvas to
+ // the client like any display. Without a canvas line, the stream size is used.
+ const int canvas_w = parsed.canvas_width ? parsed.canvas_width : config.width;
+ const int canvas_h = parsed.canvas_height ? parsed.canvas_height : config.height;
+ if (canvas_w <= 0 || canvas_h <= 0) {
+ BOOST_LOG(error) << "RemoteIdd mosaic: invalid canvas size "sv << canvas_w << 'x' << canvas_h;
+ return -1;
+ }
+
+ for (const auto &e : parsed.entries) {
+ auto rect = mosaic_rect(e.cells, canvas_w, canvas_h);
+ mosaic_tile_t tile;
+ tile.session = e.session;
+ tile.x = rect.x;
+ tile.y = rect.y;
+ tile.width = rect.width;
+ tile.height = rect.height;
+
+ if (e.session == session) {
+ // Absolute mouse: the client points at the canvas, the desktop is this tile.
+ offset_x -= tile.x;
+ offset_y -= tile.y;
+ }
+ BOOST_LOG(info) << "RemoteIdd mosaic: session "sv << tile.session << " at "sv << tile.x << ',' << tile.y << ' ' << tile.width << 'x' << tile.height;
+ tiles.push_back(std::move(tile));
+ }
+
+ width = width_before_rotation = canvas_w;
+ height = height_before_rotation = canvas_h;
+
+ D3D11_TEXTURE2D_DESC desc {};
+ desc.Width = canvas_w;
+ desc.Height = canvas_h;
+ desc.MipLevels = 1;
+ desc.ArraySize = 1;
+ desc.Format = capture_format;
+ desc.SampleDesc.Count = 1;
+ desc.Usage = D3D11_USAGE_DEFAULT;
+ desc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE;
+ ID3D11Texture2D *raw_canvas = nullptr;
+ if (FAILED(device->CreateTexture2D(&desc, nullptr, &raw_canvas))) {
+ BOOST_LOG(error) << "RemoteIdd mosaic: failed to create the canvas"sv;
+ return -1;
+ }
+ canvas.reset(raw_canvas);
+ ID3D11RenderTargetView *raw_rt = nullptr;
+ if (FAILED(device->CreateRenderTargetView(canvas.get(), nullptr, &raw_rt))) {
+ BOOST_LOG(error) << "RemoteIdd mosaic: failed to create the canvas render target"sv;
return -1;
}
- BOOST_LOG(info) << "RemoteIdd: reading frames for session "sv << session;
+ canvas_rt.reset(raw_rt);
+ clear_canvas();
+
+ for (auto &tile : tiles) {
+ open_tile(tile);
+ }
+ BOOST_LOG(info) << "RemoteIdd mosaic: "sv << tiles.size() << " sessions on a "sv << canvas_w << 'x' << canvas_h << " canvas"sv;
return 0;
}
+
+ bool display_ridd_vram_t::layout_changed() {
+ auto now = std::chrono::steady_clock::now();
+ if (now < next_layout_check) {
+ return false;
+ }
+ next_layout_check = now + 250ms;
+ if (read_layout() == layout) {
+ return false;
+ }
+ BOOST_LOG(info) << "RemoteIdd: mosaic layout changed, reinitializing capture"sv;
+ return true;
+ }
+
+ bool display_ridd_vram_t::open_tile(mosaic_tile_t &tile) {
+ auto ns = ridd_namespace();
+ auto reader = std::make_unique(Microsoft::WRL::ComPtr(device.get()), tile.session, ns.c_str());
+ if (!reader->Open()) {
+ // The session may still be connecting: try again shortly.
+ tile.next_open = std::chrono::steady_clock::now() + 500ms;
+ return false;
+ }
+ tile.consumer = std::move(reader);
+ tile.last_frame = 0;
+ compose_now = true;
+ BOOST_LOG(info) << "RemoteIdd mosaic: reading frames for session "sv << tile.session;
+ return true;
+ }
+
+ void display_ridd_vram_t::clear_canvas() {
+ constexpr float black[4] {0.0f, 0.0f, 0.0f, 1.0f};
+ device_ctx->ClearRenderTargetView(canvas_rt.get(), black);
+ for (auto &tile : tiles) {
+ tile.last_frame = 0;
+ }
+ compose_now = true;
+ }
} // namespace platf::dxgi
diff --git a/src/platform/windows/display_vram.cpp b/src/platform/windows/display_vram.cpp
index 0597892faf0..2677dc7bf92 100644
--- a/src/platform/windows/display_vram.cpp
+++ b/src/platform/windows/display_vram.cpp
@@ -7,6 +7,8 @@
#include
#include
#include
+#include
+#include
// platform includes
#include
@@ -1919,6 +1921,15 @@ namespace platf::dxgi {
* Copy the most recent frame published by the RemoteIdd driver into a new snapshot texture.
*/
capture_e display_ridd_vram_t::snapshot(const pull_free_image_cb_t &pull_free_image_cb, std::shared_ptr &img_out, std::chrono::milliseconds timeout, bool /*cursor_visible*/) {
+ // OpenStreamMS added or removed a session of the mosaic: rebuild the display (and
+ // with it the canvas and the mouse mapping).
+ if (layout_changed()) {
+ return capture_e::reinit;
+ }
+ if (!tiles.empty()) {
+ return mosaic_snapshot(pull_free_image_cb, img_out, timeout);
+ }
+
switch (consumer->WaitFrame(static_cast(timeout.count()))) {
case osms::ridd::FrameConsumer::Wait::Frame:
break;
@@ -1978,6 +1989,125 @@ namespace platf::dxgi {
return capture_e::ok;
}
+ /**
+ * Mosaic: wait for a frame from any of the sessions, copy the tiles that changed onto
+ * the canvas and the canvas into a new snapshot texture. A session that does not change
+ * keeps its last frame on the canvas.
+ */
+ capture_e display_ridd_vram_t::mosaic_snapshot(const pull_free_image_cb_t &pull_free_image_cb, std::shared_ptr &img_out, std::chrono::milliseconds timeout) {
+ auto now = std::chrono::steady_clock::now();
+ std::vector events;
+ events.reserve(tiles.size());
+ for (auto &tile : tiles) {
+ if (!tile.consumer && now >= tile.next_open) {
+ open_tile(tile);
+ }
+ if (tile.consumer) {
+ events.push_back(tile.consumer->ReadyEvent());
+ }
+ }
+
+ if (!compose_now) {
+ if (events.empty()) {
+ // No session publishes yet: do not spin.
+ std::this_thread::sleep_for(std::min(timeout, 100ms));
+ return capture_e::timeout;
+ }
+ auto n = static_cast(events.size());
+ auto woke = WaitForMultipleObjects(n, events.data(), FALSE, static_cast(timeout.count()));
+ if (woke == WAIT_TIMEOUT) {
+ return capture_e::timeout;
+ }
+ if (woke >= WAIT_OBJECT_0 + n) {
+ return capture_e::reinit;
+ }
+ // Several sessions may have presented at once: consume their events too (all the
+ // tiles that changed are copied below), so one change is not encoded twice.
+ for (DWORD i = 0; i < n; ++i) {
+ if (i != woke - WAIT_OBJECT_0) {
+ WaitForSingleObject(events[i], 0);
+ }
+ }
+ }
+ compose_now = false;
+
+ bool updated = false;
+ uint64_t newest_qpc = 0;
+ for (auto &tile : tiles) {
+ if (!tile.consumer) {
+ continue;
+ }
+ auto id = tile.consumer->LatestFrameId();
+ if (!id || id == tile.last_frame) {
+ continue;
+ }
+
+ ID3D11Texture2D *raw = nullptr;
+ RemoteIddFrameInfo frame;
+ if (!tile.consumer->Acquire(4, &raw, &frame)) {
+ // Texture recreated or still held by the driver: the next frame will bring it.
+ continue;
+ }
+ texture2d_t src {raw};
+ auto release = util::fail_guard([&]() {
+ tile.consumer->Release();
+ });
+
+ if (static_cast(frame.DxgiFormat) != capture_format) {
+ continue;
+ }
+ if ((frame.Width < tile.frame_width || frame.Height < tile.frame_height) && (frame.Width < static_cast(tile.width) || frame.Height < static_cast(tile.height))) {
+ // The session shrank (it is being reconnected to its part): drop what is left of
+ // the bigger frame. Clearing resets every tile, so all are copied again.
+ tile.frame_width = frame.Width;
+ tile.frame_height = frame.Height;
+ clear_canvas();
+ compose_now = true;
+ return capture_e::timeout;
+ }
+ tile.frame_width = frame.Width;
+ tile.frame_height = frame.Height;
+
+ // Sessions are reconnected to the size of their part; while one is not (yet), it
+ // is cropped to its part rather than scaled.
+ D3D11_BOX box {0, 0, 0, std::min(frame.Width, tile.width), std::min(frame.Height, tile.height), 1};
+ device_ctx->CopySubresourceRegion(canvas.get(), 0, tile.x, tile.y, 0, src.get(), 0, &box);
+ tile.last_frame = frame.FrameId;
+ newest_qpc = std::max(newest_qpc, frame.QpcPresent);
+ updated = true;
+ }
+ if (!updated) {
+ return capture_e::timeout;
+ }
+
+ std::shared_ptr img;
+ if (!pull_free_image_cb(img)) {
+ return capture_e::interrupted;
+ }
+
+ auto d3d_img = std::static_pointer_cast(img);
+ d3d_img->blank = false;
+ if (complete_img(d3d_img.get(), false) != 0) {
+ return capture_e::capture_error;
+ }
+ {
+ texture_lock_helper lock_helper(d3d_img->capture_mutex.get());
+ if (!lock_helper.lock()) {
+ BOOST_LOG(error) << "Failed to lock capture texture";
+ return capture_e::capture_error;
+ }
+ device_ctx->CopyResource(d3d_img->capture_texture.get(), canvas.get());
+ }
+
+ auto frame_timestamp = std::chrono::steady_clock::now();
+ if (newest_qpc) {
+ frame_timestamp -= qpc_time_difference(qpc_counter(), static_cast(newest_qpc));
+ }
+ img_out = img;
+ img_out->frame_timestamp = frame_timestamp;
+ return capture_e::ok;
+ }
+
capture_e display_ridd_vram_t::release_snapshot() {
// The shared texture is released right after the copy in snapshot().
return capture_e::ok;
diff --git a/src/platform/windows/remoteidd/FrameConsumer.cpp b/src/platform/windows/remoteidd/FrameConsumer.cpp
index 142c6b75b5d..4f5a3abc636 100644
--- a/src/platform/windows/remoteidd/FrameConsumer.cpp
+++ b/src/platform/windows/remoteidd/FrameConsumer.cpp
@@ -70,6 +70,12 @@ FrameConsumer::Wait FrameConsumer::WaitFrame(DWORD timeoutMs)
}
}
+uint64_t FrameConsumer::LatestFrameId() const
+{
+ RemoteIddFrameInfo f;
+ return m_slot && RemoteIddSlotRead(m_slot, &f) ? f.FrameId : 0;
+}
+
bool FrameConsumer::OpenTexture(const RemoteIddFrameInfo& info)
{
if (m_texture && info.ProducerPid == m_producerPid && info.TextureGeneration == m_generation)
diff --git a/src/platform/windows/remoteidd/FrameConsumer.h b/src/platform/windows/remoteidd/FrameConsumer.h
index 3b868a3613c..b517a3d72c2 100644
--- a/src/platform/windows/remoteidd/FrameConsumer.h
+++ b/src/platform/windows/remoteidd/FrameConsumer.h
@@ -35,6 +35,12 @@ namespace osms::ridd
bool Open();
Wait WaitFrame(DWORD timeoutMs);
+ // Evento FrameReady (auto-reset) para esperar a varias sesiones a la vez con
+ // WaitForMultipleObjects (mosaico de Sunshine). nullptr hasta que Open() acierta.
+ HANDLE ReadyEvent() const { return m_evReady; }
+ // FrameId del descriptor más reciente sin tomar la textura; 0 si aún no hay frame.
+ uint64_t LatestFrameId() const;
+
// Lee el descriptor más reciente, (re)abre la textura si cambió y toma el keyed
// mutex. La textura sigue siendo del consumidor hasta Release().
bool Acquire(DWORD timeoutMs, ID3D11Texture2D** texture, RemoteIddFrameInfo* info);
diff --git a/tests/unit/platform/windows/test_remoteidd.cpp b/tests/unit/platform/windows/test_remoteidd.cpp
index 662048c2143..f5956e7b6b9 100644
--- a/tests/unit/platform/windows/test_remoteidd.cpp
+++ b/tests/unit/platform/windows/test_remoteidd.cpp
@@ -1,6 +1,6 @@
/**
* @file tests/unit/platform/windows/test_remoteidd.cpp
- * @brief Test the OpenStreamMS RemoteIdd frame hand-off and capture backend.
+ * @brief Test the OpenStreamMS RemoteIdd frame hand-off, capture backend and mosaic.
*/
// test includes
@@ -11,6 +11,9 @@
#include
#include
#include
+ #include
+ #include
+ #include
#include
#include
@@ -19,6 +22,7 @@
// local includes
#include "remoteidd/FramePublisher.h"
+
#include
#include
#include
@@ -320,10 +324,274 @@ TEST(RemoteIddCaptureTest, CapturesPublishedFramesAt240Hz) {
const double p50 = age_ms[age_ms.size() / 2];
const double p99 = age_ms[std::min(age_ms.size() - 1, age_ms.size() * 99 / 100)];
BOOST_LOG(info) << "RemoteIdd capture: " << captured << '/' << frames << " frames at " << width << 'x' << height
- << ", present->snapshot (CPU) p50 " << p50 << " ms, p99 " << p99 << " ms";
+ << ", present->snapshot (CPU) p50 " << p50 << " ms, p99 " << p99 << " ms";
EXPECT_EQ(reinit, 0);
EXPECT_GE(captured, frames * 95 / 100);
EXPECT_LE(p99, 4.0);
}
+
+/**
+ * @brief Part names map to the cells of the 2x2 grid; unknown names to none.
+ */
+TEST(RemoteIddMosaicLayoutTest, MapsPartNamesToCells) {
+ EXPECT_EQ(platf::dxgi::mosaic_area_cells("Full"), 0b1111);
+ EXPECT_EQ(platf::dxgi::mosaic_area_cells("Left"), 0b0101);
+ EXPECT_EQ(platf::dxgi::mosaic_area_cells("Right"), 0b1010);
+ EXPECT_EQ(platf::dxgi::mosaic_area_cells("Top"), 0b0011);
+ EXPECT_EQ(platf::dxgi::mosaic_area_cells("Bottom"), 0b1100);
+ EXPECT_EQ(platf::dxgi::mosaic_area_cells("TopLeft"), 0b0001);
+ EXPECT_EQ(platf::dxgi::mosaic_area_cells("TopRight"), 0b0010);
+ EXPECT_EQ(platf::dxgi::mosaic_area_cells("BottomLeft"), 0b0100);
+ EXPECT_EQ(platf::dxgi::mosaic_area_cells("BottomRight"), 0b1000);
+ EXPECT_EQ(platf::dxgi::mosaic_area_cells("left"), 0);
+ EXPECT_EQ(platf::dxgi::mosaic_area_cells(""), 0);
+}
+
+/**
+ * @brief The parser reads the canvas and the sessions and skips comments, bad lines and overlaps.
+ */
+TEST(RemoteIddMosaicLayoutTest, ParsesCanvasSessionsAndSkipsBadLines) {
+ auto layout = platf::dxgi::parse_mosaic_layout(
+ "# OpenStreamMS mosaic\r\n"
+ "canvas 1920 1080\r\n"
+ "7 TopLeft\r\n"
+ "\r\n"
+ "9 TopRight\n"
+ "11 Left\n" // overlaps 7
+ "x12 BottomLeft\n" // not a session id
+ "13 Middle\n" // unknown part
+ "14\n" // no part
+ "15 BottomRight\n"
+ );
+ EXPECT_EQ(layout.canvas_width, 1920);
+ EXPECT_EQ(layout.canvas_height, 1080);
+ ASSERT_EQ(layout.entries.size(), 3u);
+ EXPECT_EQ(layout.entries[0].session, 7u);
+ EXPECT_EQ(layout.entries[0].cells, 0b0001);
+ EXPECT_EQ(layout.entries[1].session, 9u);
+ EXPECT_EQ(layout.entries[1].cells, 0b0010);
+ EXPECT_EQ(layout.entries[2].session, 15u);
+ EXPECT_EQ(layout.entries[2].cells, 0b1000);
+}
+
+/**
+ * @brief Without a valid canvas line the canvas is left to the stream size.
+ */
+TEST(RemoteIddMosaicLayoutTest, IgnoresInvalidCanvas) {
+ EXPECT_EQ(platf::dxgi::parse_mosaic_layout("canvas 0 1080\n7 Left\n").canvas_width, 0);
+ EXPECT_EQ(platf::dxgi::parse_mosaic_layout("canvas 1920\n7 Left\n").canvas_width, 0);
+ EXPECT_EQ(platf::dxgi::parse_mosaic_layout("canvas a b\n7 Left\n").canvas_width, 0);
+ EXPECT_TRUE(platf::dxgi::parse_mosaic_layout("").entries.empty());
+}
+
+/**
+ * @brief Parts split the canvas like SeatMonitor.Region: the left/top half gets the smaller side.
+ */
+TEST(RemoteIddMosaicLayoutTest, SplitsCanvasIntoParts) {
+ auto check = [](int cells, int x, int y, int w, int h) {
+ auto r = platf::dxgi::mosaic_rect(cells, 1921, 1081);
+ EXPECT_EQ(r.x, x) << cells;
+ EXPECT_EQ(r.y, y) << cells;
+ EXPECT_EQ(r.width, w) << cells;
+ EXPECT_EQ(r.height, h) << cells;
+ };
+ check(0b1111, 0, 0, 1921, 1081);
+ check(0b0101, 0, 0, 960, 1081);
+ check(0b1010, 960, 0, 961, 1081);
+ check(0b0011, 0, 0, 1921, 540);
+ check(0b1100, 0, 540, 1921, 541);
+ check(0b0001, 0, 0, 960, 540);
+ check(0b0010, 960, 0, 961, 540);
+ check(0b0100, 0, 540, 960, 541);
+ check(0b1000, 960, 540, 961, 541);
+}
+
+namespace {
+ /**
+ * @brief Read the frame id painted at a point of a texture (see frame_source_t::paint).
+ */
+ UINT64 read_id_at(ID3D11Texture2D *tex, UINT x, UINT y) {
+ ComPtr dev;
+ ComPtr ctx;
+ tex->GetDevice(&dev);
+ dev->GetImmediateContext(&ctx);
+ return read_center_id(dev.Get(), ctx.Get(), tex, x * 2, y * 2);
+ }
+
+ /**
+ * @brief A temporary sunshine.conf location holding osms-mosaic.txt, restored on destruction.
+ */
+ struct mosaic_dir_t {
+ std::filesystem::path dir;
+ std::string restore = config::sunshine.config_file;
+
+ mosaic_dir_t() {
+ dir = std::filesystem::temp_directory_path() / ("sunshine-mosaic-test-" + std::to_string(GetCurrentProcessId()));
+ std::filesystem::create_directories(dir);
+ config::sunshine.config_file = (dir / "sunshine.conf").string();
+ }
+
+ ~mosaic_dir_t() {
+ config::sunshine.config_file = restore;
+ std::error_code ec;
+ std::filesystem::remove_all(dir, ec);
+ }
+
+ void write(const std::string &text) const {
+ std::ofstream(dir / "osms-mosaic.txt", std::ios::binary | std::ios::trunc) << text;
+ }
+ };
+} // namespace
+
+/**
+ * @brief In mosaic mode every session lands in its part of the canvas, a session that stops
+ * presenting keeps its last frame, and the mouse is offset to the own part.
+ */
+TEST(RemoteIddCaptureTest, ComposesSessionsIntoMosaic) {
+ ComPtr pdev;
+ ComPtr pctx;
+ if (!create_device(pdev, pctx)) {
+ GTEST_SKIP() << "No D3D11 hardware device";
+ }
+
+ const auto host = unique_session(4);
+ const auto guest = unique_session(5);
+ osms::ridd::FramePublisher host_pub(pdev, pctx, host, test_ns, nullptr);
+ osms::ridd::FramePublisher guest_pub(pdev, pctx, guest, test_ns, nullptr);
+ frame_source_t host_src;
+ frame_source_t guest_src;
+ ASSERT_TRUE(host_src.init(pdev.Get(), 64, 64));
+ ASSERT_TRUE(guest_src.init(pdev.Get(), 64, 64));
+ host_src.paint(pctx.Get(), 11);
+ guest_src.paint(pctx.Get(), 22);
+ ASSERT_EQ(host_pub.Publish(host_src.texture.Get(), 0), osms::ridd::FramePublisher::Result::Published);
+ ASSERT_EQ(guest_pub.Publish(guest_src.texture.Get(), 0), osms::ridd::FramePublisher::Result::Published);
+
+ mosaic_dir_t files;
+
+ SetEnvironmentVariableW(L"OSMS_RIDD_NS", test_ns);
+ SetEnvironmentVariableW(L"OSMS_RIDD_SESSION", std::to_wstring(host).c_str());
+ auto restore_capture = config::video.capture;
+ config::video.capture = "remoteidd";
+ auto cleanup = util::fail_guard([&]() {
+ config::video.capture = restore_capture;
+ SetEnvironmentVariableW(L"OSMS_RIDD_NS", nullptr);
+ SetEnvironmentVariableW(L"OSMS_RIDD_SESSION", nullptr);
+ });
+
+ video::config_t config {1920, 1080, 60, 0, 20000, 1, 1, 0, 1, 0, 0, 0};
+ auto plain = platf::display(platf::mem_type_e::dxgi, "", config);
+ if (!plain) {
+ GTEST_SKIP() << "No display output to initialize the capture backend";
+ }
+ const int plain_offset_x = plain->offset_x;
+ const int plain_offset_y = plain->offset_y;
+ plain.reset();
+
+ files.write("canvas 128 64\n" + std::to_string(host) + " Right\n" + std::to_string(guest) + " Left\n");
+ auto disp = std::dynamic_pointer_cast(platf::display(platf::mem_type_e::dxgi, "", config));
+ ASSERT_TRUE(disp);
+ ASSERT_NE(disp->canvas_texture(), nullptr);
+ EXPECT_EQ(disp->width_before_rotation, 128);
+ EXPECT_EQ(disp->height_before_rotation, 64);
+
+ std::vector> pool;
+ auto pull = [&](std::shared_ptr &out) {
+ pool.push_back(disp->alloc_img());
+ out = pool.back();
+ return true;
+ };
+ auto capture = [&]() {
+ for (int i = 0; i < 20; ++i) {
+ std::shared_ptr img;
+ auto status = disp->snapshot(pull, img, 100ms, false);
+ if (status == platf::capture_e::ok) {
+ disp->release_snapshot();
+ return true;
+ }
+ if (status != platf::capture_e::timeout) {
+ ADD_FAILURE() << "snapshot failed: " << static_cast(status);
+ return false;
+ }
+ }
+ return false;
+ };
+
+ // The first snapshot composes what is already published, without waiting for a present.
+ ASSERT_TRUE(capture());
+ EXPECT_EQ(read_id_at(disp->canvas_texture(), 32, 32), 22u); // guest, left
+ EXPECT_EQ(read_id_at(disp->canvas_texture(), 96, 32), 11u); // host, right
+
+ // Only the guest presents: the host keeps its last frame.
+ guest_src.paint(pctx.Get(), 33);
+ ASSERT_EQ(guest_pub.Publish(guest_src.texture.Get(), 0), osms::ridd::FramePublisher::Result::Published);
+ ASSERT_TRUE(capture());
+ EXPECT_EQ(read_id_at(disp->canvas_texture(), 32, 32), 33u);
+ EXPECT_EQ(read_id_at(disp->canvas_texture(), 96, 32), 11u);
+
+ // Absolute mouse: canvas x 64 is the host desktop's left edge.
+ EXPECT_EQ(disp->offset_x, plain_offset_x - 64);
+ EXPECT_EQ(disp->offset_y, plain_offset_y);
+}
+
+/**
+ * @brief Changing osms-mosaic.txt makes the backend ask for a reinit, in and out of mosaic mode.
+ */
+TEST(RemoteIddCaptureTest, ReinitializesWhenMosaicLayoutChanges) {
+ ComPtr pdev;
+ ComPtr pctx;
+ if (!create_device(pdev, pctx)) {
+ GTEST_SKIP() << "No D3D11 hardware device";
+ }
+
+ const auto host = unique_session(6);
+ osms::ridd::FramePublisher host_pub(pdev, pctx, host, test_ns, nullptr);
+ frame_source_t src;
+ ASSERT_TRUE(src.init(pdev.Get(), 64, 64));
+ ASSERT_EQ(host_pub.Publish(src.texture.Get(), 0), osms::ridd::FramePublisher::Result::Published);
+
+ mosaic_dir_t files; // no osms-mosaic.txt: single-session mode
+
+ SetEnvironmentVariableW(L"OSMS_RIDD_NS", test_ns);
+ SetEnvironmentVariableW(L"OSMS_RIDD_SESSION", std::to_wstring(host).c_str());
+ auto restore_capture = config::video.capture;
+ config::video.capture = "remoteidd";
+ auto cleanup = util::fail_guard([&]() {
+ config::video.capture = restore_capture;
+ SetEnvironmentVariableW(L"OSMS_RIDD_NS", nullptr);
+ SetEnvironmentVariableW(L"OSMS_RIDD_SESSION", nullptr);
+ });
+
+ video::config_t config {1920, 1080, 60, 0, 20000, 1, 1, 0, 1, 0, 0, 0};
+ auto disp = std::dynamic_pointer_cast(platf::display(platf::mem_type_e::dxgi, "", config));
+ if (!disp) {
+ GTEST_SKIP() << "No display output to initialize the capture backend";
+ }
+ EXPECT_EQ(disp->canvas_texture(), nullptr);
+
+ // A guest that does not publish yet still turns the stream into a mosaic.
+ files.write("canvas 128 64\n" + std::to_string(host) + " Left\n" + std::to_string(unique_session(7)) + " Right\n");
+ std::this_thread::sleep_for(300ms);
+ auto pull = [&](std::shared_ptr &out) {
+ out = disp->alloc_img();
+ return true;
+ };
+ std::shared_ptr img;
+ EXPECT_EQ(disp->snapshot(pull, img, 10ms, false), platf::capture_e::reinit);
+
+ auto mosaic = std::dynamic_pointer_cast(platf::display(platf::mem_type_e::dxgi, "", config));
+ ASSERT_TRUE(mosaic);
+ ASSERT_NE(mosaic->canvas_texture(), nullptr);
+
+ // The guest slot does not exist: the host alone is composed, and nothing fails.
+ std::shared_ptr first;
+ EXPECT_EQ(mosaic->snapshot(pull, first, 100ms, false), platf::capture_e::ok);
+
+ // Removing the file goes back to single-session mode.
+ std::filesystem::remove(files.dir / "osms-mosaic.txt");
+ std::this_thread::sleep_for(300ms);
+ std::shared_ptr again;
+ EXPECT_EQ(mosaic->snapshot(pull, again, 10ms, false), platf::capture_e::reinit);
+}
#endif