diff --git a/cmake/compile_definitions/windows.cmake b/cmake/compile_definitions/windows.cmake
index a7c18bde3d0..8e79406d829 100644
--- a/cmake/compile_definitions/windows.cmake
+++ b/cmake/compile_definitions/windows.cmake
@@ -72,6 +72,10 @@ set(PLATFORM_TARGET_FILES
"${CMAKE_SOURCE_DIR}/src/platform/windows/display_vram.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/windows/display_ram.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/windows/display_wgc.cpp"
+ "${CMAKE_SOURCE_DIR}/src/platform/windows/display_remoteidd.cpp"
+ "${CMAKE_SOURCE_DIR}/src/platform/windows/remoteidd/SharedFrame.h"
+ "${CMAKE_SOURCE_DIR}/src/platform/windows/remoteidd/FrameConsumer.h"
+ "${CMAKE_SOURCE_DIR}/src/platform/windows/remoteidd/FrameConsumer.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/windows/audio.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/windows/utf_utils.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/windows/utf_utils.h"
diff --git a/docs/configuration.md b/docs/configuration.md
index 9f654f663a7..d28f1df24e7 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -2422,6 +2422,12 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin
@note{Applies to Windows only.}
@attention{This capture method is not compatible with the Sunshine service.}
+
+
remoteidd
+
Read the frames published by the OpenStreamMS RemoteIdd display driver of the current
+ session, without Desktop Duplication or WGC. Never chosen automatically.
+ @note{Applies to Windows only. Requires a hardware encoder and SDR.}
+
### fp16_sdr_gamma_encoded
diff --git a/src/confighttp.cpp b/src/confighttp.cpp
index ab2b3c98965..0e22f58d59c 100644
--- a/src/confighttp.cpp
+++ b/src/confighttp.cpp
@@ -1822,7 +1822,8 @@ namespace confighttp {
* @brief List client pairing requests that are waiting for PIN approval.
*
* Moonlight WebRTC TVs waiting to pair are listed beside Moonlight clients and are approved
- * or cancelled through the same endpoints.
+ * or cancelled through the same endpoints. Each request's `kind` tells them apart:
+ * `moonlight` for a Moonlight client, `webrtc` for a Moonlight WebRTC TV.
*
* @api_examples{/api/pin|:| GET|:| null}
*/
@@ -1840,6 +1841,7 @@ namespace confighttp {
{"id", pairing.id},
{"name", pairing.name},
{"address", pairing.address},
+ {"kind", "moonlight"},
});
}
// Moonlight WebRTC TVs show their PIN like Moonlight does and are approved in the same form.
@@ -1848,6 +1850,7 @@ namespace confighttp {
{"id", pairing.id},
{"name", pairing.name},
{"address", pairing.address},
+ {"kind", "webrtc"},
});
}
send_response(response, output_tree);
diff --git a/src/platform/windows/display.h b/src/platform/windows/display.h
index 802042ea86c..be7bf283ae6 100644
--- a/src/platform/windows/display.h
+++ b/src/platform/windows/display.h
@@ -19,6 +19,10 @@
#include "src/utility.h"
#include "src/video.h"
+namespace osms::ridd {
+ class FrameConsumer;
+}
+
namespace platf::dxgi {
/**
* @brief Diagnostic names indexed by DXGI_FORMAT.
@@ -858,4 +862,46 @@ namespace platf::dxgi {
*/
capture_e release_snapshot() override;
};
+
+ /**
+ * Display backend that reads the frames published by the OpenStreamMS RemoteIdd driver.
+ *
+ * The driver copies every swapchain buffer into a shared texture (NT handle + keyed
+ * 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.
+ */
+ class display_ridd_vram_t: public display_vram_t {
+ public:
+ display_ridd_vram_t();
+ ~display_ridd_vram_t() override;
+
+ /**
+ * @brief Initialize the display and open the RemoteIdd frame slot.
+ *
+ * @param config Configuration values to apply.
+ * @param display_name Display name.
+ * @return 0 on success; nonzero when the driver is not publishing frames.
+ */
+ int init(const ::video::config_t &config, const std::string &display_name);
+ /**
+ * @brief Copy the most recent RemoteIdd frame into the provided image object.
+ *
+ * @param pull_free_image_cb Callback that provides an available image buffer.
+ * @param img_out Captured image buffer returned to the streaming pipeline.
+ * @param timeout Maximum time to wait for the operation.
+ * @param cursor_visible Unused: the cursor is composed into the IddCx swapchain.
+ * @return Capture status reported to the streaming pipeline.
+ */
+ capture_e snapshot(const pull_free_image_cb_t &pull_free_image_cb, std::shared_ptr &img_out, std::chrono::milliseconds timeout, bool cursor_visible) override;
+ /**
+ * @brief Release resources associated with the last captured snapshot.
+ *
+ * @return Capture status after releasing the current snapshot.
+ */
+ capture_e release_snapshot() override;
+
+ private:
+ std::unique_ptr consumer; ///< Reader of the RemoteIdd frame slot.
+ };
} // namespace platf::dxgi
diff --git a/src/platform/windows/display_base.cpp b/src/platform/windows/display_base.cpp
index 2a013367107..60aebfe1468 100644
--- a/src/platform/windows/display_base.cpp
+++ b/src/platform/windows/display_base.cpp
@@ -3,7 +3,9 @@
* @brief Definitions for the Windows display base code.
*/
// standard includes
+#include
#include
+#include
#include
#include
#include
@@ -20,6 +22,7 @@
#include
// local includes
+#include "frame_rate_limiter.h"
#include "utf_utils.h"
// Including Boost.Process before Windows.h prevents the definition of NTSTATUS, so we must define it ourselves.
@@ -251,6 +254,23 @@ namespace platf::dxgi {
std::optional frame_pacing_group_start;
uint32_t frame_pacing_group_frames = 0;
+ // When the display composes much faster than the client rate (e.g. an RDP session whose
+ // composition interval is set well above the client rate), a fixed client-rate grid loses
+ // frames: updates land on display ticks and a slot can come back empty. Send frames as they
+ // arrive instead, capped at the client rate.
+ std::optional arrival_limiter;
+ std::shared_ptr held_img;
+ if (display_refresh_rate.Numerator > 0 && display_refresh_rate.Denominator > 0) {
+ const double display_rate = (double) display_refresh_rate.Numerator / display_refresh_rate.Denominator;
+ const double client_rate = (double) client_frame_rate_adjusted.Numerator / client_frame_rate_adjusted.Denominator;
+ if (display_rate >= client_rate * 1.5) {
+ const std::chrono::nanoseconds period = std::chrono::nanoseconds(1s) * client_frame_rate_adjusted.Denominator / client_frame_rate_adjusted.Numerator;
+ const std::chrono::nanoseconds tick = std::chrono::nanoseconds(1s) * display_refresh_rate.Denominator / display_refresh_rate.Numerator;
+ arrival_limiter.emplace(period, std::min(tick, period / 2));
+ BOOST_LOG(info) << "Capturing frames as they arrive, capped at " << client_rate << "fps (display " << display_rate << "Hz)";
+ }
+ }
+
// Keep the display awake during capture. If the display goes to sleep during
// capture, best case is that capture stops until it powers back on. However,
// worst case it will trigger us to reinit DD, waking the display back up in
@@ -273,8 +293,45 @@ namespace platf::dxgi {
platf::capture_e status = capture_e::ok;
std::shared_ptr img_out;
+ if (arrival_limiter) {
+ // Sleep until the earliest time the next frame can be accepted instead of spinning
+ if (const auto next_time = arrival_limiter->next_accept_time()) {
+ const auto now = std::chrono::steady_clock::now();
+ if (*next_time > now) {
+ timer->sleep_for(*next_time - now);
+ }
+ }
+
+ // If an early frame is already held, probe with 0ms to see if a newer one arrived;
+ // otherwise wait up to 200ms for the next frame.
+ const auto wait = held_img ? 0ms : 200ms;
+
+ status = snapshot(pull_free_image_cb, img_out, wait, *cursor);
+
+ if (status == capture_e::ok && img_out) {
+ const auto timestamp = img_out->frame_timestamp.value_or(std::chrono::steady_clock::now());
+ if (!arrival_limiter->accept(timestamp)) {
+ // Too early for the client rate: keep it, it is sent at its slot unless a newer frame replaces it.
+ held_img = std::move(img_out);
+ status = release_snapshot();
+ if (status != platf::capture_e::ok) {
+ return status;
+ }
+ continue;
+ }
+ held_img.reset();
+ } else if (status == capture_e::timeout && held_img) {
+ arrival_limiter->accept(std::chrono::steady_clock::now());
+ img_out = std::move(held_img);
+ status = capture_e::ok;
+ } else if (status == capture_e::timeout) {
+ // Nothing is updating the display: let the encoding thread take the device lock (see below).
+ std::this_thread::sleep_for(10ms);
+ }
+ }
+
// Try to continue frame pacing group, snapshot() is called with zero timeout after waiting for client frame interval
- if (frame_pacing_group_start) {
+ else if (frame_pacing_group_start) {
const uint32_t seconds = (uint64_t) frame_pacing_group_frames * client_frame_rate_adjusted.Denominator / client_frame_rate_adjusted.Numerator;
const uint32_t remainder = (uint64_t) frame_pacing_group_frames * client_frame_rate_adjusted.Denominator % client_frame_rate_adjusted.Numerator;
const auto sleep_target = *frame_pacing_group_start +
@@ -304,7 +361,7 @@ namespace platf::dxgi {
}
// Start new frame pacing group if necessary, snapshot() is called with non-zero timeout
- if (status == capture_e::timeout || (status == capture_e::ok && !frame_pacing_group_start)) {
+ if (!arrival_limiter && (status == capture_e::timeout || (status == capture_e::ok && !frame_pacing_group_start))) {
status = snapshot(pull_free_image_cb, img_out, 200ms, *cursor);
if (status == capture_e::ok && img_out) {
@@ -1027,6 +1084,20 @@ namespace platf {
* @param hwdevice_type enables possible use of hardware encoder
*/
std::shared_ptr display(mem_type_e hwdevice_type, const std::string &display_name, const video::config_t &config) {
+ // OpenStreamMS RemoteIdd: only on explicit request, never part of auto-detection.
+ if (config::video.capture == "remoteidd") {
+ if (hwdevice_type == mem_type_e::dxgi) {
+ auto disp = std::make_shared();
+
+ if (!disp->init(config, display_name)) {
+ return disp;
+ }
+ } else {
+ BOOST_LOG(error) << "RemoteIdd capture requires a hardware encoder"sv;
+ }
+ return nullptr;
+ }
+
if (config::video.capture == "ddx" || config::video.capture.empty()) {
if (hwdevice_type == mem_type_e::dxgi) {
auto disp = std::make_shared();
diff --git a/src/platform/windows/display_remoteidd.cpp b/src/platform/windows/display_remoteidd.cpp
new file mode 100644
index 00000000000..94e6e2d098a
--- /dev/null
+++ b/src/platform/windows/display_remoteidd.cpp
@@ -0,0 +1,84 @@
+/**
+ * @file src/platform/windows/display_remoteidd.cpp
+ * @brief Definitions for the OpenStreamMS RemoteIdd capture backend.
+ * @note snapshot() lives in display_vram.cpp, next to the other VRAM backends that share
+ * its image helpers.
+ */
+// standard includes
+#include
+#include
+#include
+
+// local includes
+#include "display.h"
+#include "misc.h"
+#include "remoteidd/FrameConsumer.h"
+#include "src/logging.h"
+
+namespace platf::dxgi {
+ using namespace std::literals;
+
+ namespace {
+ /**
+ * @brief Read a process environment variable (Win32 view, not the CRT copy).
+ */
+ std::wstring env(const wchar_t *name) {
+ wchar_t buf[64];
+ DWORD n = GetEnvironmentVariableW(name, buf, static_cast(std::size(buf)));
+ return (n > 0 && n < std::size(buf)) ? std::wstring(buf, n) : std::wstring();
+ }
+
+ /**
+ * @brief Namespace prefix of the RemoteIdd named objects.
+ *
+ * The driver publishes in Global\. OSMS_RIDD_NS lets tests use Local\ without privileges.
+ */
+ std::wstring ridd_namespace() {
+ if (auto ns = env(L"OSMS_RIDD_NS"); !ns.empty()) {
+ return ns;
+ }
+ return REMOTEIDD_NS;
+ }
+
+ /**
+ * @brief Terminal Services session whose slot this process reads.
+ *
+ * Sunshine runs inside the seat session; OSMS_RIDD_SESSION overrides it for tests.
+ */
+ uint32_t ridd_session() {
+ if (auto s = env(L"OSMS_RIDD_SESSION"); !s.empty()) {
+ return static_cast(std::wcstoul(s.c_str(), nullptr, 10));
+ }
+ DWORD session = 0;
+ ProcessIdToSessionId(GetCurrentProcessId(), &session);
+ return session;
+ }
+ } // namespace
+
+ // 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;
+
+ int display_ridd_vram_t::init(const ::video::config_t &config, const std::string &display_name) {
+ if (config.dynamicRange) {
+ BOOST_LOG(error) << "RemoteIdd capture does not support HDR yet"sv;
+ return -1;
+ }
+ if (display_base_t::init(config, display_name)) {
+ return -1;
+ }
+
+ // IddCx 1.4 swapchains are always 8-bit BGRA.
+ capture_format = DXGI_FORMAT_B8G8R8A8_UNORM;
+
+ 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;
+ return -1;
+ }
+ BOOST_LOG(info) << "RemoteIdd: reading frames for session "sv << session;
+ return 0;
+ }
+} // namespace platf::dxgi
diff --git a/src/platform/windows/display_vram.cpp b/src/platform/windows/display_vram.cpp
index 917c5c390ad..0597892faf0 100644
--- a/src/platform/windows/display_vram.cpp
+++ b/src/platform/windows/display_vram.cpp
@@ -24,6 +24,7 @@ extern "C" {
// local includes
#include "display.h"
#include "misc.h"
+#include "remoteidd/FrameConsumer.h"
#include "src/config.h"
#include "src/logging.h"
#include "src/nvenc/nvenc_config.h"
@@ -1914,6 +1915,74 @@ namespace platf::dxgi {
return dup.release_frame();
}
+ /**
+ * 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*/) {
+ switch (consumer->WaitFrame(static_cast(timeout.count()))) {
+ case osms::ridd::FrameConsumer::Wait::Frame:
+ break;
+ case osms::ridd::FrameConsumer::Wait::Timeout:
+ return capture_e::timeout;
+ default:
+ return capture_e::reinit;
+ }
+
+ ID3D11Texture2D *raw = nullptr;
+ RemoteIddFrameInfo frame;
+ if (!consumer->Acquire(static_cast(timeout.count()), &raw, &frame)) {
+ // Texture recreated or still held by the driver: the next frame will bring it.
+ return capture_e::timeout;
+ }
+ texture2d_t src {raw};
+
+ // From here on the driver cannot write the texture: copy it and let go quickly.
+ auto release = util::fail_guard([&]() {
+ consumer->Release();
+ });
+
+ if (frame.Width != width_before_rotation || frame.Height != height_before_rotation) {
+ BOOST_LOG(info) << "Capture size changed ["sv << width << 'x' << height << " -> "sv << frame.Width << 'x' << frame.Height << ']';
+ return capture_e::reinit;
+ }
+ if (static_cast(frame.DxgiFormat) != capture_format) {
+ BOOST_LOG(info) << "Capture format changed ["sv << dxgi_format_to_string(capture_format) << " -> "sv << dxgi_format_to_string(static_cast(frame.DxgiFormat)) << ']';
+ return capture_e::reinit;
+ }
+
+ 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(), src.get());
+ }
+
+ auto frame_timestamp = std::chrono::steady_clock::now();
+ if (frame.QpcPresent) {
+ frame_timestamp -= qpc_time_difference(qpc_counter(), static_cast(frame.QpcPresent));
+ }
+ 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;
+ }
+
int display_wgc_vram_t::init(const ::video::config_t &config, const std::string &display_name) {
if (display_base_t::init(config, display_name) || dup.init(this, config)) {
return -1;
diff --git a/src/platform/windows/frame_rate_limiter.h b/src/platform/windows/frame_rate_limiter.h
new file mode 100644
index 00000000000..3f224df5ee2
--- /dev/null
+++ b/src/platform/windows/frame_rate_limiter.h
@@ -0,0 +1,80 @@
+/**
+ * @file src/platform/windows/frame_rate_limiter.h
+ * @brief Declarations for the arrival-driven capture rate limiter.
+ */
+#pragma once
+
+// standard includes
+#include
+#include
+
+namespace platf::dxgi {
+ /**
+ * @brief Passes captured frames as they arrive while capping their average rate.
+ *
+ * @details When the display composes much faster than the client frame rate, sampling it on a
+ * fixed client-rate grid loses frames: content updates land on display ticks, so a grid slot can
+ * fall just before the next update and come back empty. This limiter instead accepts each new
+ * frame when it arrives, as long as it is not earlier than its slot minus a tolerance of one
+ * display tick. Slots advance by one client frame period per accepted frame, so the long-run
+ * rate never exceeds the client frame rate. After a stall longer than one period the schedule is
+ * re-anchored on the next frame.
+ */
+ class frame_rate_limiter_t {
+ public:
+ using clock = std::chrono::steady_clock; ///< Clock used for frame timestamps.
+
+ /**
+ * @brief Create a limiter.
+ *
+ * @param period Client frame period: the minimum average spacing between accepted frames.
+ * @param tolerance How much earlier than its slot a frame may still be accepted.
+ */
+ frame_rate_limiter_t(std::chrono::nanoseconds period, std::chrono::nanoseconds tolerance):
+ period_ {period},
+ tolerance_ {tolerance} {
+ }
+
+ /**
+ * @brief Decide whether a frame that became available at @p timestamp should be sent.
+ *
+ * @param timestamp Time the frame was composed.
+ * @return True when the frame should be sent; the next slot is then advanced.
+ */
+ bool accept(clock::time_point timestamp) {
+ if (!next_slot_ || timestamp > *next_slot_ + period_) {
+ next_slot_ = timestamp + period_;
+ return true;
+ }
+ if (timestamp + tolerance_ >= *next_slot_) {
+ *next_slot_ += period_;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * @brief Time from which the next frame will be accepted.
+ *
+ * @return The earliest accepted timestamp, or no value before the first frame.
+ */
+ [[nodiscard]] std::optional next_accept_time() const {
+ if (!next_slot_) {
+ return std::nullopt;
+ }
+ return *next_slot_ - tolerance_;
+ }
+
+ /**
+ * @brief Forget the schedule; the next frame is accepted and re-anchors it.
+ */
+ void reset() {
+ next_slot_.reset();
+ }
+
+ private:
+ std::chrono::nanoseconds period_; ///< Client frame period.
+ std::chrono::nanoseconds tolerance_; ///< Early-arrival tolerance.
+ std::optional next_slot_; ///< Slot of the next frame to send.
+ };
+} // namespace platf::dxgi
diff --git a/src/platform/windows/misc.cpp b/src/platform/windows/misc.cpp
index 9556275828d..e4de7edfff9 100644
--- a/src/platform/windows/misc.cpp
+++ b/src/platform/windows/misc.cpp
@@ -321,8 +321,12 @@ namespace platf {
TOKEN_ELEVATION_TYPE elevationType;
DWORD dwSize;
- // Get the session ID of the active console session
- consoleSessionId = WTSGetActiveConsoleSessionId();
+ // Session whose user runs the commands. As a service (session 0) that is the active console
+ // session; a SYSTEM Sunshine launched inside a user session (OpenStreamMS seats with
+ // capture = remoteidd) must use its own session, or apps would open on the console.
+ if (!ProcessIdToSessionId(GetCurrentProcessId(), &consoleSessionId) || consoleSessionId == 0) {
+ consoleSessionId = WTSGetActiveConsoleSessionId();
+ }
if (0xFFFFFFFF == consoleSessionId) {
// If there is no active console session, log a warning and return null
BOOST_LOG(warning) << "There isn't an active user session, therefore it is not possible to execute commands under the users profile.";
diff --git a/src/platform/windows/remoteidd/FrameConsumer.cpp b/src/platform/windows/remoteidd/FrameConsumer.cpp
new file mode 100644
index 00000000000..142c6b75b5d
--- /dev/null
+++ b/src/platform/windows/remoteidd/FrameConsumer.cpp
@@ -0,0 +1,137 @@
+// Copia de OpenStreamMS/Native/RemoteIdd (contrato con el driver RemoteIdd). No editar aquí:
+// cambiar en OpenStreamMS y volver a copiar, para que productor y consumidor no diverjan.
+// FrameConsumer.cpp — ver FrameConsumer.h.
+
+#include "FrameConsumer.h"
+#include
+#include
+
+using namespace Microsoft::WRL;
+namespace osms::ridd {
+
+FrameConsumer::FrameConsumer(ComPtr device, UINT32 sessionId, const wchar_t* ns)
+ : m_sessionId(sessionId)
+{
+ device.As(&m_device);
+ wcsncpy_s(m_ns, ns, _TRUNCATE);
+}
+
+FrameConsumer::~FrameConsumer()
+{
+ if (m_held) Release();
+ m_mutex.Reset();
+ m_texture.Reset();
+ if (m_slot) UnmapViewOfFile(m_slot);
+ if (m_slotMap) CloseHandle(m_slotMap);
+ if (m_evReady) CloseHandle(m_evReady);
+ if (m_evDone) CloseHandle(m_evDone);
+}
+
+bool FrameConsumer::Open()
+{
+ if (m_slot) return true;
+ if (!m_device) return false;
+
+ // Solo se abren objetos existentes: el consumidor nunca crea nada con nombre.
+ wchar_t name[128];
+ swprintf_s(name, REMOTEIDD_FMT_SLOT, m_ns, m_sessionId);
+ HANDLE map = OpenFileMappingW(FILE_MAP_READ, FALSE, name);
+ if (!map) return false;
+ auto* slot = reinterpret_cast(
+ MapViewOfFile(map, FILE_MAP_READ, 0, 0, sizeof(RemoteIddSharedSlot)));
+ swprintf_s(name, REMOTEIDD_FMT_READY, m_ns, m_sessionId);
+ HANDLE ready = OpenEventW(SYNCHRONIZE, FALSE, name);
+ swprintf_s(name, REMOTEIDD_FMT_DONE, m_ns, m_sessionId);
+ HANDLE done = OpenEventW(EVENT_MODIFY_STATE, FALSE, name);
+
+ if (!slot || !ready || !done || slot->Version != REMOTEIDD_SHARED_VERSION)
+ {
+ if (slot) UnmapViewOfFile(slot);
+ CloseHandle(map);
+ if (ready) CloseHandle(ready);
+ if (done) CloseHandle(done);
+ return false;
+ }
+ m_slotMap = map;
+ m_slot = slot;
+ m_evReady = ready;
+ m_evDone = done;
+ return true;
+}
+
+FrameConsumer::Wait FrameConsumer::WaitFrame(DWORD timeoutMs)
+{
+ if (!m_evReady) return Wait::Error;
+ switch (WaitForSingleObject(m_evReady, timeoutMs))
+ {
+ case WAIT_OBJECT_0: return Wait::Frame;
+ case WAIT_TIMEOUT: return Wait::Timeout;
+ default: return Wait::Error;
+ }
+}
+
+bool FrameConsumer::OpenTexture(const RemoteIddFrameInfo& info)
+{
+ if (m_texture && info.ProducerPid == m_producerPid && info.TextureGeneration == m_generation)
+ return true;
+
+ m_mutex.Reset();
+ m_texture.Reset();
+
+ // El handle es del proceso del productor: se duplica a este proceso, se abre la
+ // textura y se cierra la copia (la textura abierta mantiene su propia referencia).
+ HANDLE producer = OpenProcess(PROCESS_DUP_HANDLE, FALSE, info.ProducerPid);
+ if (!producer) return false;
+ HANDLE h = nullptr;
+ BOOL dup = DuplicateHandle(producer, reinterpret_cast(info.SharedTextureHandle),
+ GetCurrentProcess(), &h, 0, FALSE, DUPLICATE_SAME_ACCESS);
+ CloseHandle(producer);
+ if (!dup) return false;
+
+ ComPtr tex;
+ HRESULT hr = m_device->OpenSharedResource1(h, IID_PPV_ARGS(&tex));
+ CloseHandle(h);
+ if (FAILED(hr) || FAILED(tex.As(&m_mutex))) return false;
+
+ m_texture = tex;
+ m_producerPid = info.ProducerPid;
+ m_generation = info.TextureGeneration;
+ return true;
+}
+
+bool FrameConsumer::Acquire(DWORD timeoutMs, ID3D11Texture2D** texture, RemoteIddFrameInfo* info)
+{
+ if (!m_slot || m_held) return false;
+
+ RemoteIddFrameInfo f;
+ if (!RemoteIddSlotRead(m_slot, &f) || !f.FrameId) return false;
+ if (!OpenTexture(f)) return false;
+
+ HRESULT hr = m_mutex->AcquireSync(0, timeoutMs);
+ if (hr != S_OK && hr != static_cast(WAIT_ABANDONED)) return false;
+ m_held = true;
+
+ // Si la textura se recreó mientras esperábamos, el contenido no es el de `f`; se
+ // suelta y el siguiente WaitFrame traerá la generación nueva.
+ RemoteIddFrameInfo now;
+ if (RemoteIddSlotRead(m_slot, &now) && now.TextureGeneration != f.TextureGeneration)
+ {
+ Release();
+ return false;
+ }
+
+ *texture = m_texture.Get();
+ (*texture)->AddRef();
+ *info = f;
+ return true;
+}
+
+void FrameConsumer::Release()
+{
+ if (!m_held) return;
+ m_mutex->ReleaseSync(0);
+ m_held = false;
+ SetEvent(m_evDone);
+}
+
+} // namespace osms::ridd
diff --git a/src/platform/windows/remoteidd/FrameConsumer.h b/src/platform/windows/remoteidd/FrameConsumer.h
new file mode 100644
index 00000000000..3b868a3613c
--- /dev/null
+++ b/src/platform/windows/remoteidd/FrameConsumer.h
@@ -0,0 +1,61 @@
+// Copia de OpenStreamMS/Native/RemoteIdd (contrato con el driver RemoteIdd). No editar aquí:
+// cambiar en OpenStreamMS y volver a copiar, para que productor y consumidor no diverjan.
+// FrameConsumer.h — lado consumidor del contrato SharedFrame.h (lo usa Sunshine).
+//
+// Uso por frame:
+// if (c.WaitFrame(ms) == Wait::Frame && c.Acquire(ms, &tex, &info)) {
+// ... copiar/codificar tex en el dispositivo propio ...
+// c.Release();
+// }
+// Entre Acquire y Release el productor no puede escribir la textura: conviene copiarla
+// a una textura propia y soltarla enseguida, o el driver perderá frames.
+#pragma once
+
+#include
+#include
+#include
+
+#include "SharedFrame.h"
+
+namespace osms::ridd
+{
+ class FrameConsumer
+ {
+ public:
+ enum class Wait { Frame, Timeout, Error };
+
+ FrameConsumer(Microsoft::WRL::ComPtr device, UINT32 sessionId,
+ const wchar_t* ns = REMOTEIDD_NS);
+ ~FrameConsumer();
+
+ FrameConsumer(const FrameConsumer&) = delete;
+ FrameConsumer& operator=(const FrameConsumer&) = delete;
+
+ // Abre slot y eventos. false si el productor aún no los ha creado (reintentar).
+ bool Open();
+ Wait WaitFrame(DWORD timeoutMs);
+
+ // 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);
+ void Release();
+
+ private:
+ bool OpenTexture(const RemoteIddFrameInfo& info);
+
+ Microsoft::WRL::ComPtr m_device;
+ UINT32 m_sessionId;
+ wchar_t m_ns[16] = {};
+
+ HANDLE m_slotMap = nullptr;
+ const RemoteIddSharedSlot* m_slot = nullptr;
+ HANDLE m_evReady = nullptr;
+ HANDLE m_evDone = nullptr;
+
+ Microsoft::WRL::ComPtr m_texture;
+ Microsoft::WRL::ComPtr m_mutex;
+ UINT32 m_producerPid = 0;
+ UINT32 m_generation = 0;
+ bool m_held = false;
+ };
+}
diff --git a/src/platform/windows/remoteidd/SharedFrame.h b/src/platform/windows/remoteidd/SharedFrame.h
new file mode 100644
index 00000000000..03a9a4d78b6
--- /dev/null
+++ b/src/platform/windows/remoteidd/SharedFrame.h
@@ -0,0 +1,119 @@
+// Copia de OpenStreamMS/Native/RemoteIdd (contrato con el driver RemoteIdd). No editar aquí:
+// cambiar en OpenStreamMS y volver a copiar, para que productor y consumidor no diverjan.
+// SharedFrame.h — contrato de entrega de frames RemoteIdd -> Sunshine.
+//
+// Independiente del protocolo remoto: define cómo el driver publica cada frame del
+// swapchain y cómo Sunshine lo consume. Modelado sobre lo observado en DuoIdd
+// (objetos con nombre "Global\\DuoIdd%uFrameReady" / "...FrameEncoded"), pero con un
+// contrato explícito y versionado.
+//
+// Un frame NO se copia por este header: se comparte la textura por handle NT y aquí
+// viaja solo el descriptor + sincronización. Evita una copia en CPU, igual que hace el
+// presentador entre sesiones de OpenStreamMS.
+//
+// Invasividad: solo objetos con nombre que viven mientras viva el driver (sin registro,
+// sin ficheros, sin servicios). Su DACL se limita a SYSTEM y LocalService.
+#pragma once
+#include
+#include
+
+#define REMOTEIDD_SHARED_VERSION 2u // v2: seqlock (Seq) + RemoteIddFrameInfo
+
+// Prefijo del espacio de nombres. "Global\\" porque productor (WUDFHost, sesión 0) y
+// consumidor (Sunshine, sesión del puesto) no comparten sesión. Crear objetos en
+// Global\ exige SeCreateGlobalPrivilege (lo tienen los servicios, no un usuario normal);
+// el test lo redefine a "Local\\" para correr sin privilegios.
+#ifndef REMOTEIDD_NS
+#define REMOTEIDD_NS L"Global\\"
+#endif
+
+// Nombres de los objetos, por sesión de Terminal Services:
+// OsmsRiddSlot -> file mapping con RemoteIddSharedSlot
+// OsmsRiddFrameReady -> auto-reset event, lo pone el driver
+// OsmsRiddFrameDone -> auto-reset event, lo pone Sunshine
+#define REMOTEIDD_NAME_SLOT REMOTEIDD_NS L"OsmsRidd%uSlot"
+#define REMOTEIDD_NAME_READY REMOTEIDD_NS L"OsmsRidd%uFrameReady"
+#define REMOTEIDD_NAME_DONE REMOTEIDD_NS L"OsmsRidd%uFrameDone"
+
+// Las mismas, con el prefijo en tiempo de ejecución (FramePublisher / FrameConsumer):
+// swprintf_s(buf, REMOTEIDD_FMT_SLOT, ns, session).
+#define REMOTEIDD_FMT_SLOT L"%lsOsmsRidd%uSlot"
+#define REMOTEIDD_FMT_READY L"%lsOsmsRidd%uFrameReady"
+#define REMOTEIDD_FMT_DONE L"%lsOsmsRidd%uFrameDone"
+
+// DACL de los objetos con nombre: SYSTEM (Sunshine se lanza como SYSTEM en la sesión del
+// puesto), LocalService (cuenta del WUDFHost) y administradores (diagnóstico con
+// RemoteIddPerf watch). Protegida: no hereda nada más; un usuario normal no entra.
+#define REMOTEIDD_SDDL L"D:P(A;;GA;;;SY)(A;;GA;;;LS)(A;;GA;;;BA)"
+
+// Descriptor del frame más reciente. Se escribe y se lee SIEMPRE con
+// RemoteIddSlotPublish / RemoteIddSlotRead, nunca campo a campo.
+typedef struct _RemoteIddFrameInfo
+{
+ uint32_t Width; // ancho actual en píxeles
+ uint32_t Height; // alto actual en píxeles
+ uint32_t RefreshNumer; // Hz = Numer/Denom (p. ej. 60000/1000)
+ uint32_t RefreshDenom;
+ uint32_t DxgiFormat; // DXGI_FORMAT del buffer (p. ej. 87 = B8G8R8A8_UNORM)
+ uint32_t ProducerPid; // pid del WUDFHost del driver, para DuplicateHandle
+
+ // Handle NT compartido de la textura (con keyed mutex, clave 0), VÁLIDO EN EL PROCESO
+ // DEL PRODUCTOR. El consumidor lo duplica con DuplicateHandle desde ProducerPid y lo
+ // abre con ID3D11Device1::OpenSharedResource1. Así el driver no necesita conocer ni
+ // abrir el proceso de Sunshine. Cambia solo al cambiar de tamaño/formato (bit0 Flags).
+ uint64_t SharedTextureHandle;
+
+ uint64_t FrameId; // monotónico; el consumidor detecta saltos/pérdidas
+ uint64_t QpcPresent; // QueryPerformanceCounter del momento de present
+ uint32_t Flags; // bit0 = textura/modo cambió desde el frame anterior
+ // Sube cada vez que se recrea la textura. El consumidor reabre por generación y no
+ // por valor de handle: un handle nuevo puede reutilizar el valor del anterior.
+ uint32_t TextureGeneration;
+} RemoteIddFrameInfo;
+
+#define REMOTEIDD_FLAG_TEXTURE_CHANGED 0x1u
+
+#pragma pack(push, 8)
+typedef struct _RemoteIddSharedSlot
+{
+ uint32_t Version; // REMOTEIDD_SHARED_VERSION
+ uint32_t SessionId; // sesión de TS a la que pertenece este slot
+ volatile LONG64 Seq; // seqlock: impar = escritura en curso
+ RemoteIddFrameInfo Frame;
+} RemoteIddSharedSlot;
+#pragma pack(pop)
+
+// Productor (un único escritor). Las dos InterlockedIncrement64 son barreras completas.
+inline void RemoteIddSlotPublish(RemoteIddSharedSlot* slot, const RemoteIddFrameInfo& f)
+{
+ InterlockedIncrement64(&slot->Seq); // -> impar
+ slot->Frame = f;
+ InterlockedIncrement64(&slot->Seq); // -> par
+}
+
+// Consumidor. Copia un descriptor coherente; false si tras `maxTries` el productor
+// seguía escribiendo (prácticamente imposible: una escritura son ~60 bytes).
+inline bool RemoteIddSlotRead(const RemoteIddSharedSlot* slot, RemoteIddFrameInfo* out,
+ int maxTries = 1000)
+{
+ for (int i = 0; i < maxTries; ++i)
+ {
+ // Lecturas volatile + barreras completas: válido en MSVC y MinGW (Sunshine), que no
+ // tiene ReadAcquire64. El coste (~decenas de ns por frame) es despreciable.
+ LONG64 before = slot->Seq;
+ MemoryBarrier();
+ if (before & 1) { YieldProcessor(); continue; }
+ *out = slot->Frame;
+ MemoryBarrier();
+ if (slot->Seq == before) return true;
+ }
+ return false;
+}
+
+// Protocolo de sincronización (sin bloqueo del lado productor):
+// Driver: copia el frame a la textura compartida (si el keyed mutex está libre),
+// RemoteIddSlotPublish(...), SetEvent(Ready).
+// Sunshine: WaitForSingleObject(Ready) -> RemoteIddSlotRead -> AcquireSync(0) ->
+// codifica -> ReleaseSync(0) -> SetEvent(Done).
+// El driver NO espera a Done ni al keyed mutex (AcquireSync con timeout 0): si
+// Sunshine va lento, se pierde el frame intermedio, nunca se bloquea la sesión.
diff --git a/src_assets/common/assets/web/configs/tabs/Advanced.vue b/src_assets/common/assets/web/configs/tabs/Advanced.vue
index 17a2b8ead24..ba65e5e109f 100644
--- a/src_assets/common/assets/web/configs/tabs/Advanced.vue
+++ b/src_assets/common/assets/web/configs/tabs/Advanced.vue
@@ -81,6 +81,7 @@ const config = ref(props.config)
+
diff --git a/tests/unit/platform/windows/remoteidd/FramePublisher.cpp b/tests/unit/platform/windows/remoteidd/FramePublisher.cpp
new file mode 100644
index 00000000000..280244922fb
--- /dev/null
+++ b/tests/unit/platform/windows/remoteidd/FramePublisher.cpp
@@ -0,0 +1,175 @@
+// Copia de OpenStreamMS/Native/RemoteIdd (contrato con el driver RemoteIdd). No editar aquí:
+// cambiar en OpenStreamMS y volver a copiar, para que productor y consumidor no diverjan.
+// FramePublisher.cpp — ver FramePublisher.h.
+
+#include "FramePublisher.h"
+#include
+#include
+#include
+#include
+
+using namespace Microsoft::WRL;
+namespace osms::ridd {
+
+static void Log(const wchar_t* fmt, ...)
+{
+ wchar_t buf[256];
+ va_list args;
+ va_start(args, fmt);
+ _vsnwprintf_s(buf, _TRUNCATE, fmt, args);
+ va_end(args);
+ OutputDebugStringW(buf);
+}
+
+FramePublisher::FramePublisher(ComPtr device, ComPtr context,
+ UINT32 sessionId, const wchar_t* ns, const wchar_t* sddl)
+ : m_device(std::move(device)), m_context(std::move(context)),
+ m_sessionId(sessionId), m_sddl(sddl)
+{
+ wcsncpy_s(m_ns, ns, _TRUNCATE);
+}
+
+FramePublisher::~FramePublisher()
+{
+ m_sharedMutex.Reset();
+ m_shared.Reset();
+ if (m_sharedHandle) CloseHandle(m_sharedHandle);
+ CloseSharedObjects();
+}
+
+void FramePublisher::CloseSharedObjects()
+{
+ if (m_slot) { UnmapViewOfFile(m_slot); m_slot = nullptr; }
+ if (m_slotMap) { CloseHandle(m_slotMap); m_slotMap = nullptr; }
+ if (m_evReady) { CloseHandle(m_evReady); m_evReady = nullptr; }
+ if (m_evDone) { CloseHandle(m_evDone); m_evDone = nullptr; }
+}
+
+void FramePublisher::SetSession(UINT32 sessionId)
+{
+ if (sessionId == m_sessionId) return;
+ CloseSharedObjects();
+ m_sessionId = sessionId;
+}
+
+bool FramePublisher::EnsureSharedObjects()
+{
+ if (m_slot && m_evReady && m_evDone) return true;
+
+ PSECURITY_DESCRIPTOR sd = nullptr;
+ if (m_sddl && !ConvertStringSecurityDescriptorToSecurityDescriptorW(m_sddl, SDDL_REVISION_1, &sd, nullptr))
+ return false;
+ SECURITY_ATTRIBUTES sa = { sizeof(sa), sd, FALSE };
+ SECURITY_ATTRIBUTES* psa = sd ? &sa : nullptr;
+
+ wchar_t name[128];
+ if (!m_slotMap)
+ {
+ swprintf_s(name, REMOTEIDD_FMT_SLOT, m_ns, m_sessionId);
+ m_slotMap = CreateFileMappingW(INVALID_HANDLE_VALUE, psa, PAGE_READWRITE, 0,
+ sizeof(RemoteIddSharedSlot), name);
+ }
+ if (m_slotMap && !m_slot)
+ m_slot = reinterpret_cast(
+ MapViewOfFile(m_slotMap, FILE_MAP_WRITE, 0, 0, sizeof(RemoteIddSharedSlot)));
+ if (!m_evReady)
+ {
+ swprintf_s(name, REMOTEIDD_FMT_READY, m_ns, m_sessionId);
+ m_evReady = CreateEventW(psa, FALSE, FALSE, name);
+ }
+ if (!m_evDone)
+ {
+ swprintf_s(name, REMOTEIDD_FMT_DONE, m_ns, m_sessionId);
+ m_evDone = CreateEventW(psa, FALSE, FALSE, name);
+ }
+ if (sd) LocalFree(sd);
+
+ if (!m_slot || !m_evReady || !m_evDone)
+ {
+ Log(L"RemoteIdd: no se pudieron crear los objetos de SharedFrame (err %lu)\n", GetLastError());
+ return false; // se reintenta en el siguiente frame; lo ya creado se reutiliza
+ }
+ m_slot->Version = REMOTEIDD_SHARED_VERSION;
+ m_slot->SessionId = m_sessionId;
+ m_info.ProducerPid = GetCurrentProcessId();
+ return true;
+}
+
+bool FramePublisher::EnsureSharedTexture(const D3D11_TEXTURE2D_DESC& src)
+{
+ if (m_shared)
+ {
+ D3D11_TEXTURE2D_DESC cur;
+ m_shared->GetDesc(&cur);
+ if (cur.Width == src.Width && cur.Height == src.Height && cur.Format == src.Format)
+ return true;
+ }
+
+ // Cambio de tamaño/formato (o primer frame): se recrea y sube TextureGeneration.
+ m_sharedMutex.Reset();
+ m_shared.Reset();
+ if (m_sharedHandle) { CloseHandle(m_sharedHandle); m_sharedHandle = nullptr; }
+
+ D3D11_TEXTURE2D_DESC desc = {};
+ desc.Width = src.Width;
+ desc.Height = src.Height;
+ desc.MipLevels = 1;
+ desc.ArraySize = 1;
+ desc.Format = src.Format;
+ desc.SampleDesc.Count = 1;
+ desc.Usage = D3D11_USAGE_DEFAULT;
+ desc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET;
+ desc.MiscFlags = D3D11_RESOURCE_MISC_SHARED_NTHANDLE | D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX;
+
+ ComPtr tex;
+ ComPtr mutex;
+ ComPtr res;
+ HANDLE h = nullptr;
+ if (FAILED(m_device->CreateTexture2D(&desc, nullptr, &tex)) ||
+ FAILED(tex.As(&mutex)) || FAILED(tex.As(&res)) ||
+ FAILED(res->CreateSharedHandle(nullptr, DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE,
+ nullptr, &h)))
+ {
+ Log(L"RemoteIdd: no se pudo crear la textura compartida %ux%u fmt %u\n",
+ src.Width, src.Height, static_cast(src.Format));
+ return false;
+ }
+
+ m_shared = tex;
+ m_sharedMutex = mutex;
+ m_sharedHandle = h;
+ m_textureChanged = true;
+ m_info.TextureGeneration++;
+ return true;
+}
+
+FramePublisher::Result FramePublisher::Publish(ID3D11Texture2D* src, UINT64 qpcPresent)
+{
+ if (!src || !EnsureSharedObjects()) return Result::Failed;
+
+ D3D11_TEXTURE2D_DESC desc;
+ src->GetDesc(&desc);
+ if (!EnsureSharedTexture(desc)) return Result::Failed;
+
+ // Limitador por llegada también en la GPU: si el consumidor aún tiene la textura
+ // (keyed mutex ocupado), este frame se pierde. AcquireSync con timeout 0 nunca bloquea.
+ HRESULT hr = m_sharedMutex->AcquireSync(0, 0);
+ if (hr != S_OK && hr != static_cast(WAIT_ABANDONED)) return Result::Dropped;
+ m_context->CopyResource(m_shared.Get(), src);
+ m_sharedMutex->ReleaseSync(0);
+
+ m_info.Width = desc.Width;
+ m_info.Height = desc.Height;
+ m_info.DxgiFormat = static_cast(desc.Format);
+ m_info.SharedTextureHandle = reinterpret_cast(m_sharedHandle);
+ m_info.FrameId++;
+ m_info.QpcPresent = qpcPresent;
+ m_info.Flags = m_textureChanged ? REMOTEIDD_FLAG_TEXTURE_CHANGED : 0;
+ m_textureChanged = false;
+
+ RemoteIddSlotPublish(m_slot, m_info);
+ SetEvent(m_evReady);
+ return Result::Published;
+}
+
+} // namespace osms::ridd
diff --git a/tests/unit/platform/windows/remoteidd/FramePublisher.h b/tests/unit/platform/windows/remoteidd/FramePublisher.h
new file mode 100644
index 00000000000..40ef1e68095
--- /dev/null
+++ b/tests/unit/platform/windows/remoteidd/FramePublisher.h
@@ -0,0 +1,68 @@
+// Copia de OpenStreamMS/Native/RemoteIdd (contrato con el driver RemoteIdd). No editar aquí:
+// cambiar en OpenStreamMS y volver a copiar, para que productor y consumidor no diverjan.
+// FramePublisher.h — lado productor del contrato SharedFrame.h.
+//
+// Independiente de IddCx: el driver le pasa cada buffer del swapchain y el test de
+// rendimiento le pasa texturas propias, así se mide exactamente el mismo código.
+// Copia el frame a una textura compartida (handle NT + keyed mutex) y lo publica en el
+// slot con seqlock. Nunca bloquea: si el consumidor tiene la textura, el frame se pierde.
+#pragma once
+
+#include
+#include
+#include
+
+#include
+
+namespace osms::ridd
+{
+ class FramePublisher
+ {
+ public:
+ enum class Result { Published, Dropped, Failed };
+
+ // ns: REMOTEIDD_NS en el driver; L"Local\\" en pruebas sin privilegios.
+ // sddl: DACL de los objetos con nombre; nullptr = seguridad por defecto.
+ FramePublisher(Microsoft::WRL::ComPtr device,
+ Microsoft::WRL::ComPtr context,
+ UINT32 sessionId,
+ const wchar_t* ns = REMOTEIDD_NS,
+ const wchar_t* sddl = REMOTEIDD_SDDL);
+ ~FramePublisher();
+
+ FramePublisher(const FramePublisher&) = delete;
+ FramePublisher& operator=(const FramePublisher&) = delete;
+
+ Result Publish(ID3D11Texture2D* src, UINT64 qpcPresent);
+
+ // Cambia la sesión de los objetos con nombre (la da el proveedor de protocolo
+ // después de cargar el driver). Se recrean en el siguiente Publish.
+ void SetSession(UINT32 sessionId);
+
+ void SetRefresh(UINT32 numer, UINT32 denom) { m_info.RefreshNumer = numer; m_info.RefreshDenom = denom; }
+ UINT64 LastFrameId() const { return m_info.FrameId; }
+
+ private:
+ bool EnsureSharedObjects();
+ void CloseSharedObjects();
+ bool EnsureSharedTexture(const D3D11_TEXTURE2D_DESC& src);
+
+ Microsoft::WRL::ComPtr m_device;
+ Microsoft::WRL::ComPtr m_context;
+ UINT32 m_sessionId;
+ wchar_t m_ns[16] = {};
+ const wchar_t* m_sddl;
+
+ HANDLE m_slotMap = nullptr;
+ RemoteIddSharedSlot* m_slot = nullptr;
+ HANDLE m_evReady = nullptr;
+ HANDLE m_evDone = nullptr;
+
+ Microsoft::WRL::ComPtr m_shared;
+ Microsoft::WRL::ComPtr m_sharedMutex;
+ HANDLE m_sharedHandle = nullptr;
+ bool m_textureChanged = false;
+
+ RemoteIddFrameInfo m_info = {};
+ };
+}
diff --git a/tests/unit/platform/windows/test_frame_rate_limiter.cpp b/tests/unit/platform/windows/test_frame_rate_limiter.cpp
new file mode 100644
index 00000000000..eb5d520d8ad
--- /dev/null
+++ b/tests/unit/platform/windows/test_frame_rate_limiter.cpp
@@ -0,0 +1,110 @@
+/**
+ * @file tests/unit/platform/windows/test_frame_rate_limiter.cpp
+ * @brief Test the arrival-driven capture rate limiter.
+ */
+
+// test includes
+#include "../../../tests_common.h"
+
+#ifdef _WIN32
+ // standard includes
+ #include
+ #include
+
+ // local includes
+ #include
+
+using namespace std::chrono_literals;
+using limiter_t = platf::dxgi::frame_rate_limiter_t;
+
+namespace {
+ constexpr std::chrono::nanoseconds period_60 {16'666'667};
+ constexpr std::chrono::nanoseconds tick_125 {8'000'000};
+
+ /**
+ * @brief Count the frames a limiter accepts when content is presented at @p content_period and
+ * composed on a @p tick grid, over @p duration.
+ */
+ int accepted_frames(std::chrono::nanoseconds content_period, std::chrono::nanoseconds tick, std::chrono::nanoseconds duration) {
+ limiter_t limiter {period_60, tick};
+ const auto origin = limiter_t::clock::time_point {} + 1h;
+ int accepted = 0;
+ for (std::chrono::nanoseconds present {0}; present < duration; present += content_period) {
+ const auto ticks = (present.count() + tick.count() - 1) / tick.count();
+ if (limiter.accept(origin + tick * ticks)) {
+ ++accepted;
+ }
+ }
+ return accepted;
+ }
+} // namespace
+
+/**
+ * @brief The first frame is always accepted.
+ */
+TEST(FrameRateLimiterTests, AcceptsFirstFrame) {
+ limiter_t limiter {period_60, tick_125};
+ EXPECT_FALSE(limiter.next_accept_time());
+ EXPECT_TRUE(limiter.accept(limiter_t::clock::now()));
+ EXPECT_TRUE(limiter.next_accept_time());
+}
+
+/**
+ * @brief A frame earlier than its slot minus the tolerance is rejected, and one inside it is not.
+ */
+TEST(FrameRateLimiterTests, RejectsFramesBeforeTheirSlot) {
+ limiter_t limiter {period_60, tick_125};
+ const auto start = limiter_t::clock::now();
+ ASSERT_TRUE(limiter.accept(start));
+ EXPECT_EQ(*limiter.next_accept_time(), start + period_60 - tick_125);
+ EXPECT_FALSE(limiter.accept(start + 8ms));
+ EXPECT_TRUE(limiter.accept(start + 9ms));
+}
+
+/**
+ * @brief After a stall longer than one period the schedule restarts at the next frame.
+ */
+TEST(FrameRateLimiterTests, ReanchorsAfterStall) {
+ limiter_t limiter {period_60, tick_125};
+ const auto start = limiter_t::clock::now();
+ ASSERT_TRUE(limiter.accept(start));
+ const auto late = start + 100ms;
+ EXPECT_TRUE(limiter.accept(late));
+ EXPECT_EQ(*limiter.next_accept_time(), late + period_60 - tick_125);
+}
+
+/**
+ * @brief Reset forgets the schedule.
+ */
+TEST(FrameRateLimiterTests, ResetForgetsSchedule) {
+ limiter_t limiter {period_60, tick_125};
+ const auto start = limiter_t::clock::now();
+ ASSERT_TRUE(limiter.accept(start));
+ limiter.reset();
+ EXPECT_FALSE(limiter.next_accept_time());
+ EXPECT_TRUE(limiter.accept(start + 1ms));
+}
+
+/**
+ * @brief Content capped at 60 fps and composed at 125 Hz passes every frame.
+ */
+TEST(FrameRateLimiterTests, PassesCappedContentOn125HzComposition) {
+ EXPECT_EQ(accepted_frames(period_60, tick_125, 10s), 600);
+}
+
+/**
+ * @brief Content at 62.5 fps composed at 125 Hz is held to about 60 frames per second.
+ */
+TEST(FrameRateLimiterTests, CapsFasterContentToClientRate) {
+ const int accepted = accepted_frames(16ms, tick_125, 10s);
+ EXPECT_LE(accepted, 601);
+ EXPECT_GE(accepted, 595);
+}
+
+/**
+ * @brief Content slower than the client rate passes unchanged.
+ */
+TEST(FrameRateLimiterTests, PassesSlowerContent) {
+ EXPECT_EQ(accepted_frames(20ms, tick_125, 10s), 500);
+}
+#endif
diff --git a/tests/unit/platform/windows/test_remoteidd.cpp b/tests/unit/platform/windows/test_remoteidd.cpp
new file mode 100644
index 00000000000..662048c2143
--- /dev/null
+++ b/tests/unit/platform/windows/test_remoteidd.cpp
@@ -0,0 +1,329 @@
+/**
+ * @file tests/unit/platform/windows/test_remoteidd.cpp
+ * @brief Test the OpenStreamMS RemoteIdd frame hand-off and capture backend.
+ */
+
+// test includes
+#include "../../../tests_common.h"
+
+#ifdef _WIN32
+ // standard includes
+ #include
+ #include
+ #include
+ #include
+ #include
+
+ // platform includes
+ #include
+
+ // local includes
+ #include "remoteidd/FramePublisher.h"
+ #include
+ #include
+ #include
+ #include
+
+using Microsoft::WRL::ComPtr;
+using namespace std::chrono_literals;
+
+namespace {
+ constexpr const wchar_t *test_ns = L"Local\\";
+
+ bool create_device(ComPtr &dev, ComPtr &ctx) {
+ ComPtr factory;
+ ComPtr adapter;
+ if (FAILED(CreateDXGIFactory1(IID_PPV_ARGS(&factory))) || FAILED(factory->EnumAdapters1(0, &adapter))) {
+ return false;
+ }
+ return SUCCEEDED(D3D11CreateDevice(adapter.Get(), D3D_DRIVER_TYPE_UNKNOWN, nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, nullptr, 0, D3D11_SDK_VERSION, &dev, nullptr, &ctx));
+ }
+
+ /**
+ * @brief Source frames as an IddCx swapchain would hand them to the driver.
+ */
+ struct frame_source_t {
+ ComPtr texture;
+ ComPtr rtv;
+
+ bool init(ID3D11Device *dev, UINT width, UINT height) {
+ D3D11_TEXTURE2D_DESC d {};
+ d.Width = width;
+ d.Height = height;
+ d.MipLevels = d.ArraySize = 1;
+ d.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
+ d.SampleDesc.Count = 1;
+ d.Usage = D3D11_USAGE_DEFAULT;
+ d.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE;
+ return SUCCEEDED(dev->CreateTexture2D(&d, nullptr, &texture)) && SUCCEEDED(dev->CreateRenderTargetView(texture.Get(), nullptr, &rtv));
+ }
+
+ // The color encodes the frame id: B8G8R8A8_UNORM stores n/255 back as the byte n.
+ void paint(ID3D11DeviceContext *ctx, UINT64 id) const {
+ const float rgba[4] = {static_cast(id & 0xFF) / 255.0f, static_cast((id >> 8) & 0xFF) / 255.0f, static_cast((id >> 16) & 0xFF) / 255.0f, 1.0f};
+ ctx->ClearRenderTargetView(rtv.Get(), rgba);
+ }
+ };
+
+ UINT64 read_center_id(ID3D11Device *dev, ID3D11DeviceContext *ctx, ID3D11Texture2D *tex, UINT width, UINT height) {
+ D3D11_TEXTURE2D_DESC sd {};
+ sd.Width = sd.Height = 1;
+ sd.MipLevels = sd.ArraySize = 1;
+ sd.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
+ sd.SampleDesc.Count = 1;
+ sd.Usage = D3D11_USAGE_STAGING;
+ sd.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
+ ComPtr staging;
+ if (FAILED(dev->CreateTexture2D(&sd, nullptr, &staging))) {
+ return 0;
+ }
+ D3D11_BOX box {width / 2, height / 2, 0, width / 2 + 1, height / 2 + 1, 1};
+ ctx->CopySubresourceRegion(staging.Get(), 0, 0, 0, 0, tex, 0, &box);
+ D3D11_MAPPED_SUBRESOURCE m;
+ if (FAILED(ctx->Map(staging.Get(), 0, D3D11_MAP_READ, 0, &m))) {
+ return 0;
+ }
+ auto px = static_cast(m.pData);
+ UINT64 id = px[2] | (px[1] << 8) | (px[0] << 16);
+ ctx->Unmap(staging.Get(), 0);
+ return id;
+ }
+
+ /**
+ * @brief Sleep until @p deadline with a high-resolution waitable timer.
+ *
+ * std::this_thread::sleep_until rounds to the ~15.6 ms system tick and would turn a
+ * 240 Hz producer into bursts; this does not change the global timer resolution.
+ */
+ void sleep_until_precise(std::chrono::steady_clock::time_point deadline) {
+ static thread_local HANDLE timer = CreateWaitableTimerExW(nullptr, nullptr, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS);
+ for (;;) {
+ auto left = deadline - std::chrono::steady_clock::now();
+ if (left <= 0ns) {
+ return;
+ }
+ if (timer && left > 500us) {
+ LARGE_INTEGER due;
+ due.QuadPart = -std::chrono::duration_cast(left - 300us).count() / 100;
+ SetWaitableTimer(timer, &due, 0, nullptr, nullptr, FALSE);
+ WaitForSingleObject(timer, INFINITE);
+ } else {
+ YieldProcessor();
+ }
+ }
+ }
+
+ UINT32 unique_session(UINT32 offset) {
+ return 90000 + (GetCurrentProcessId() % 1000) * 10 + offset;
+ }
+} // namespace
+
+/**
+ * @brief Every published frame reaches the consumer with the right pixels, across devices.
+ */
+TEST(RemoteIddHandOffTest, DeliversFrameContentAcrossDevices) {
+ ComPtr pdev, cdev;
+ ComPtr pctx, cctx;
+ if (!create_device(pdev, pctx) || !create_device(cdev, cctx)) {
+ GTEST_SKIP() << "No D3D11 hardware device";
+ }
+
+ const auto session = unique_session(0);
+ osms::ridd::FramePublisher publisher(pdev, pctx, session, test_ns, nullptr);
+ osms::ridd::FrameConsumer consumer(cdev, session, test_ns);
+ frame_source_t src;
+ ASSERT_TRUE(src.init(pdev.Get(), 1280, 720));
+
+ for (UINT64 i = 1; i <= 30; ++i) {
+ src.paint(pctx.Get(), publisher.LastFrameId() + 1);
+ ASSERT_EQ(publisher.Publish(src.texture.Get(), 0), osms::ridd::FramePublisher::Result::Published);
+ if (i == 1) {
+ ASSERT_TRUE(consumer.Open());
+ }
+ ASSERT_EQ(consumer.WaitFrame(1000), osms::ridd::FrameConsumer::Wait::Frame);
+
+ ComPtr tex;
+ RemoteIddFrameInfo frame;
+ ASSERT_TRUE(consumer.Acquire(1000, &tex, &frame));
+ EXPECT_EQ(frame.FrameId, i);
+ EXPECT_EQ(frame.Width, 1280u);
+ EXPECT_EQ(read_center_id(cdev.Get(), cctx.Get(), tex.Get(), frame.Width, frame.Height), i);
+ consumer.Release();
+ }
+}
+
+/**
+ * @brief A resize recreates the shared texture and the consumer follows the new generation.
+ */
+TEST(RemoteIddHandOffTest, FollowsTextureRecreationOnResize) {
+ ComPtr pdev, cdev;
+ ComPtr pctx, cctx;
+ if (!create_device(pdev, pctx) || !create_device(cdev, cctx)) {
+ GTEST_SKIP() << "No D3D11 hardware device";
+ }
+
+ const auto session = unique_session(1);
+ osms::ridd::FramePublisher publisher(pdev, pctx, session, test_ns, nullptr);
+ osms::ridd::FrameConsumer consumer(cdev, session, test_ns);
+
+ const UINT sizes[][2] = {{1920, 1080}, {1280, 720}, {3840, 2160}};
+ UINT32 last_generation = 0;
+ for (const auto &size : sizes) {
+ frame_source_t src;
+ ASSERT_TRUE(src.init(pdev.Get(), size[0], size[1]));
+ src.paint(pctx.Get(), publisher.LastFrameId() + 1);
+ ASSERT_EQ(publisher.Publish(src.texture.Get(), 0), osms::ridd::FramePublisher::Result::Published);
+ ASSERT_TRUE(consumer.Open());
+ ASSERT_EQ(consumer.WaitFrame(1000), osms::ridd::FrameConsumer::Wait::Frame);
+
+ ComPtr tex;
+ RemoteIddFrameInfo frame;
+ ASSERT_TRUE(consumer.Acquire(1000, &tex, &frame));
+ D3D11_TEXTURE2D_DESC desc;
+ tex->GetDesc(&desc);
+ EXPECT_EQ(desc.Width, size[0]);
+ EXPECT_EQ(desc.Height, size[1]);
+ EXPECT_GT(frame.TextureGeneration, last_generation);
+ EXPECT_TRUE(frame.Flags & REMOTEIDD_FLAG_TEXTURE_CHANGED);
+ EXPECT_EQ(read_center_id(cdev.Get(), cctx.Get(), tex.Get(), desc.Width, desc.Height), frame.FrameId);
+ last_generation = frame.TextureGeneration;
+ consumer.Release();
+ }
+}
+
+/**
+ * @brief While the consumer holds the texture, publishing drops the frame instead of blocking.
+ */
+TEST(RemoteIddHandOffTest, PublishNeverBlocksOnBusyConsumer) {
+ ComPtr pdev, cdev;
+ ComPtr pctx, cctx;
+ if (!create_device(pdev, pctx) || !create_device(cdev, cctx)) {
+ GTEST_SKIP() << "No D3D11 hardware device";
+ }
+
+ const auto session = unique_session(2);
+ osms::ridd::FramePublisher publisher(pdev, pctx, session, test_ns, nullptr);
+ osms::ridd::FrameConsumer consumer(cdev, session, test_ns);
+ frame_source_t src;
+ ASSERT_TRUE(src.init(pdev.Get(), 1920, 1080));
+
+ ASSERT_EQ(publisher.Publish(src.texture.Get(), 0), osms::ridd::FramePublisher::Result::Published);
+ ASSERT_TRUE(consumer.Open());
+ ASSERT_EQ(consumer.WaitFrame(1000), osms::ridd::FrameConsumer::Wait::Frame);
+ ComPtr tex;
+ RemoteIddFrameInfo frame;
+ ASSERT_TRUE(consumer.Acquire(1000, &tex, &frame));
+
+ const auto start = std::chrono::steady_clock::now();
+ for (int i = 0; i < 20; ++i) {
+ EXPECT_EQ(publisher.Publish(src.texture.Get(), 0), osms::ridd::FramePublisher::Result::Dropped);
+ }
+ const auto elapsed = std::chrono::steady_clock::now() - start;
+ EXPECT_LT(elapsed, 20ms) << "20 publishes against a held texture must not wait";
+ consumer.Release();
+
+ EXPECT_EQ(publisher.Publish(src.texture.Get(), 0), osms::ridd::FramePublisher::Result::Published);
+}
+
+/**
+ * @brief The capture backend delivers a producer's frames at 240 Hz with low added latency.
+ */
+TEST(RemoteIddCaptureTest, CapturesPublishedFramesAt240Hz) {
+ ComPtr pdev;
+ ComPtr pctx;
+ if (!create_device(pdev, pctx)) {
+ GTEST_SKIP() << "No D3D11 hardware device";
+ }
+
+ const auto session = unique_session(3);
+ osms::ridd::FramePublisher publisher(pdev, pctx, session, test_ns, nullptr);
+
+ // The backend opens the slot during init, so the producer has to exist first.
+ frame_source_t boot;
+ ASSERT_TRUE(boot.init(pdev.Get(), 64, 64));
+ ASSERT_EQ(publisher.Publish(boot.texture.Get(), 0), osms::ridd::FramePublisher::Result::Published);
+
+ SetEnvironmentVariableW(L"OSMS_RIDD_NS", test_ns);
+ SetEnvironmentVariableW(L"OSMS_RIDD_SESSION", std::to_wstring(session).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, 240, 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";
+ }
+
+ const UINT width = disp->width_before_rotation;
+ const UINT height = disp->height_before_rotation;
+ frame_source_t src;
+ ASSERT_TRUE(src.init(pdev.Get(), width, height));
+ // Leave a frame of the display size in the slot so the first snapshot does not see the boot one.
+ src.paint(pctx.Get(), publisher.LastFrameId() + 1);
+ ASSERT_EQ(publisher.Publish(src.texture.Get(), 0), osms::ridd::FramePublisher::Result::Published);
+
+ constexpr int frames = 480; // 2 s at 240 Hz
+ std::atomic producing {true};
+ std::thread producer([&]() {
+ auto next = std::chrono::steady_clock::now();
+ for (int i = 0; i < frames && producing; ++i) {
+ sleep_until_precise(next);
+ next += std::chrono::nanoseconds(1'000'000'000 / 240);
+ src.paint(pctx.Get(), publisher.LastFrameId() + 1);
+ LARGE_INTEGER qpc;
+ QueryPerformanceCounter(&qpc);
+ publisher.Publish(src.texture.Get(), static_cast(qpc.QuadPart));
+ }
+ producing = false;
+ });
+
+ std::vector> pool;
+ auto pull = [&](std::shared_ptr &out) {
+ auto it = std::find_if(pool.begin(), pool.end(), [](auto &img) {
+ return img.use_count() == 1;
+ });
+ if (it == pool.end()) {
+ pool.push_back(disp->alloc_img());
+ it = pool.end() - 1;
+ }
+ out = *it;
+ return true;
+ };
+
+ int captured = 0, reinit = 0;
+ std::vector age_ms;
+ while (producing || captured == 0) {
+ std::shared_ptr img;
+ auto status = disp->snapshot(pull, img, 100ms, false);
+ if (status == platf::capture_e::ok) {
+ ++captured;
+ ASSERT_TRUE(img->frame_timestamp.has_value());
+ age_ms.push_back(std::chrono::duration(std::chrono::steady_clock::now() - *img->frame_timestamp).count());
+ disp->release_snapshot();
+ } else if (status == platf::capture_e::reinit) {
+ ++reinit;
+ } else if (status != platf::capture_e::timeout) {
+ ADD_FAILURE() << "snapshot failed: " << static_cast(status);
+ break;
+ }
+ }
+ producing = false;
+ producer.join();
+
+ ASSERT_FALSE(age_ms.empty());
+ std::sort(age_ms.begin(), age_ms.end());
+ 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";
+
+ EXPECT_EQ(reinit, 0);
+ EXPECT_GE(captured, frames * 95 / 100);
+ EXPECT_LE(p99, 4.0);
+}
+#endif
diff --git a/tests/unit/test_confighttp.cpp b/tests/unit/test_confighttp.cpp
index 6aaf9f11c1a..2f7319b6922 100644
--- a/tests/unit/test_confighttp.cpp
+++ b/tests/unit/test_confighttp.cpp
@@ -629,6 +629,7 @@ TEST_F(ConfigHttpTest, PairingRestApiListsAuthenticatedPendingRequests) {
EXPECT_EQ(body.at("pairings").front().at("id"), pairing_id);
EXPECT_EQ(body.at("pairings").front().at("name"), "REST client");
EXPECT_EQ(body.at("pairings").front().at("address"), "192.0.2.30");
+ EXPECT_EQ(body.at("pairings").front().at("kind"), "moonlight");
}
TEST_F(ConfigHttpTest, PairingRestApiRejectsMalformedFieldsWithoutConsumingRequest) {