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