From 9aa554c1ed47611d48e5ab263b4d9cea36e4033c Mon Sep 17 00:00:00 2001 From: mlopezsegura <90263997+mlopezsegura@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:50:29 +0100 Subject: [PATCH 1/6] feat(windows): add RemoteIdd capture backend New capture method "remoteidd" for OpenStreamMS seats. Instead of Desktop Duplication or WGC, it reads the frames that the OpenStreamMS RemoteIdd display driver publishes for the current session: a shared texture (NT handle + keyed mutex) announced through a named slot with a seqlock. The texture is copied into the capture image and released right away, so the driver never waits for the encoder. - Only selected with capture = remoteidd; never part of auto-detection. - Requires a hardware encoder and SDR (IddCx 1.4 swapchains are 8-bit BGRA). - src/platform/windows/remoteidd/ is a copy of the contract and consumer from OpenStreamMS/Native/RemoteIdd; change it there and copy it back. - Tests: hand-off across devices, texture recreation on resize, publisher never blocking on a busy consumer, and the real backend capturing a 240 Hz producer (481/480 frames, no reinit). Co-Authored-By: Claude Opus 5.5 --- cmake/compile_definitions/windows.cmake | 4 + docs/configuration.md | 6 + src/platform/windows/display.h | 46 +++ src/platform/windows/display_base.cpp | 14 + src/platform/windows/display_remoteidd.cpp | 84 +++++ src/platform/windows/display_vram.cpp | 69 ++++ .../windows/remoteidd/FrameConsumer.cpp | 137 ++++++++ .../windows/remoteidd/FrameConsumer.h | 61 ++++ src/platform/windows/remoteidd/SharedFrame.h | 119 +++++++ .../assets/web/configs/tabs/Advanced.vue | 1 + .../windows/remoteidd/FramePublisher.cpp | 175 ++++++++++ .../windows/remoteidd/FramePublisher.h | 68 ++++ .../unit/platform/windows/test_remoteidd.cpp | 329 ++++++++++++++++++ 13 files changed, 1113 insertions(+) create mode 100644 src/platform/windows/display_remoteidd.cpp create mode 100644 src/platform/windows/remoteidd/FrameConsumer.cpp create mode 100644 src/platform/windows/remoteidd/FrameConsumer.h create mode 100644 src/platform/windows/remoteidd/SharedFrame.h create mode 100644 tests/unit/platform/windows/remoteidd/FramePublisher.cpp create mode 100644 tests/unit/platform/windows/remoteidd/FramePublisher.h create mode 100644 tests/unit/platform/windows/test_remoteidd.cpp 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 55920e78298..06f3218eda8 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/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..f920915256b 100644 --- a/src/platform/windows/display_base.cpp +++ b/src/platform/windows/display_base.cpp @@ -1027,6 +1027,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/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_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 From 95b52b67dbd5dd5be4f3bc4e4f8816f92e1e596b Mon Sep 17 00:00:00 2001 From: mlopezsegura <90263997+mlopezsegura@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:51:54 +0100 Subject: [PATCH 2/6] feat(windows): send frames as they arrive, capped at the client fps When the display refreshes much faster than the client asks for (an RDP seat composing at 250 Hz for a 60 fps stream), pacing by display ticks repeats or drops frames. Capture each frame when it arrives and cap the rate with frame_rate_limiter_t; hold back a frame that comes too early. Co-Authored-By: Claude Opus 5.5 --- src/platform/windows/display_base.cpp | 61 +++++++++- src/platform/windows/frame_rate_limiter.h | 80 +++++++++++++ .../windows/test_frame_rate_limiter.cpp | 110 ++++++++++++++++++ 3 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 src/platform/windows/frame_rate_limiter.h create mode 100644 tests/unit/platform/windows/test_frame_rate_limiter.cpp diff --git a/src/platform/windows/display_base.cpp b/src/platform/windows/display_base.cpp index f920915256b..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) { 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/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 From ddb8d0877156cb985e22585220f07ae318428b11 Mon Sep 17 00:00:00 2001 From: mlopezsegura <90263997+mlopezsegura@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:51:55 +0100 Subject: [PATCH 3/6] fix(windows): use Sunshine's own session for the user token when SYSTEM Running as SYSTEM inside an RDP seat (needed for the RemoteIdd backend), retrieve_users_token took the console session's user. Use the session the process runs in, and fall back to the console only from session 0. Co-Authored-By: Claude Opus 5.5 --- src/platform/windows/misc.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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."; From 33e71cdd9a8a69e5447606189e8f80c814df5091 Mon Sep 17 00:00:00 2001 From: mlopezsegura <90263997+mlopezsegura@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:29:37 +0100 Subject: [PATCH 4/6] feat(webrtc): tell Moonlight and WebRTC TV pairings apart in /api/pin Each pending pairing listed by GET /api/pin now carries a kind: "moonlight" for a Moonlight client and "webrtc" for a Moonlight WebRTC TV, so a frontend can say which device is waiting for a PIN. Co-Authored-By: Claude Opus 5.5 --- src/confighttp.cpp | 5 ++++- tests/unit/test_confighttp.cpp | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) 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/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) { From 8c4a849d577831cd035ecb42ac6c700f95372d5c Mon Sep 17 00:00:00 2001 From: mlopezsegura <90263997+mlopezsegura@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:08:17 +0100 Subject: [PATCH 5/6] feat(confighttp): report active streams in GET /api/streams Hosts that manage several Sunshine instances (OpenStreamMS) had no way to know whether anyone is streaming from an instance. GET /api/streams returns the Moonlight session count (rtsp_stream) and the Moonlight WebRTC TV session count (webrtc_stream), plus their total. Authenticated like the rest of the Web UI API. Tests cover the response and the authentication requirement. Co-Authored-By: Claude Opus 5.5 --- docs/api.md | 3 +++ src/confighttp.cpp | 36 ++++++++++++++++++++++++++++++++++ src/confighttp.h | 8 ++++++++ tests/unit/test_confighttp.cpp | 16 +++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/docs/api.md b/docs/api.md index 9ba23241589..7b870672f53 100644 --- a/docs/api.md +++ b/docs/api.md @@ -58,6 +58,9 @@ curl -u user:pass -H "X-CSRF-Token: your_token_here" \ ## POST /api/clients/unpair @copydoc confighttp::unpair() +## GET /api/streams +@copydoc confighttp::getActiveStreams() + ## POST /api/clients/unpair-all @copydoc confighttp::unpairAll() diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 0e22f58d59c..89966c351e1 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -1338,6 +1338,41 @@ namespace confighttp { send_response(response, output_tree); } + /** + * @brief Get how many clients are streaming right now. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * The response counts Moonlight sessions and Moonlight WebRTC TV sessions separately: + * @code{.json} + * { + * "moonlight": 1, + * "webrtc": 0, + * "total": 1, + * "status": true + * } + * @endcode + * + * @api_examples{/api/streams|:| GET|:| null} + */ + void getActiveStreams(const resp_https_t &response, const req_https_t &request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + const int moonlight = rtsp_stream::session_count(); + const int webrtc = webrtc_stream::session_count(); + + nlohmann::json output_tree; + output_tree["moonlight"] = moonlight; + output_tree["webrtc"] = webrtc; + output_tree["total"] = moonlight + webrtc; + output_tree["status"] = true; + send_response(response, output_tree); + } + /** * @brief Enable or disable a client. * @param response The HTTP response object. @@ -2525,6 +2560,7 @@ namespace confighttp { server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp; server.resource["^/api/apps/close$"]["POST"] = closeApp; server.resource["^/api/clients/list$"]["GET"] = getClients; + server.resource["^/api/streams$"]["GET"] = getActiveStreams; server.resource["^/api/clients/unpair$"]["POST"] = unpair; server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll; server.resource["^/api/clients/update$"]["POST"] = updateClient; diff --git a/src/confighttp.h b/src/confighttp.h index 1b8e9b2ff56..acd43d8f85b 100644 --- a/src/confighttp.h +++ b/src/confighttp.h @@ -182,6 +182,14 @@ namespace confighttp { */ void getPermissions(const resp_https_t &response, const req_https_t &request); + /** + * @brief Report how many clients are streaming right now. + * + * @param response HTTPS response receiving the stream counts JSON. + * @param request Authenticated HTTPS request. + */ + void getActiveStreams(const resp_https_t &response, const req_https_t &request); + /** * @brief Initiate a native permission request from the Web UI. * diff --git a/tests/unit/test_confighttp.cpp b/tests/unit/test_confighttp.cpp index 2f7319b6922..3cb9a915140 100644 --- a/tests/unit/test_confighttp.cpp +++ b/tests/unit/test_confighttp.cpp @@ -337,6 +337,7 @@ class ConfigHttpTest: public BaseTest { // NOSONAR(cpp:S3656): protected member server->resource["^/virtual-input-license-test$"]["GET"] = confighttp::getVirtualInputLicense; server->resource["^/virtual-input-license-test$"]["POST"] = confighttp::updateVirtualInputLicense; server->resource["^/permissions-test$"]["GET"] = confighttp::getPermissions; + server->resource["^/streams-test$"]["GET"] = confighttp::getActiveStreams; server->resource["^/permissions-test$"]["POST"] = confighttp::requestPermission; server->resource["^/pairing-test$"]["DELETE"] = confighttp::cancelPairing; server->resource["^/pairing-test$"]["GET"] = confighttp::getPendingPairings; @@ -512,6 +513,7 @@ INSTANTIATE_TEST_SUITE_P( endpoint_request_t {"CsrfToken", "GET", "/csrf-token-test", ""}, endpoint_request_t {"BrowseDirectory", "GET", "/browse-test", ""}, endpoint_request_t {"PairingList", "GET", "/pairing-test", ""}, + endpoint_request_t {"ActiveStreams", "GET", "/streams-test", ""}, endpoint_request_t {"PortalTokenReset", "POST", "/portal-token-reset-test", ""}, endpoint_request_t {"VirtualInputStatus", "GET", "/virtual-input-status-test", ""}, endpoint_request_t {"VirtualInputLicense", "GET", "/virtual-input-license-test", ""}, @@ -1759,6 +1761,20 @@ TEST_F(ConfigHttpTest, PermissionsEndpointReturnsRequiredAndOptionalStatuses) { EXPECT_FALSE(body.at("permissions").at(1).at("required").get()); } +TEST_F(ConfigHttpTest, ActiveStreamsEndpointReportsMoonlightAndWebRtcCounts) { + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Authorization", create_auth_header("testuser", "testpass")); + + const auto response = client->request("GET", "/streams-test", "", headers); + ASSERT_EQ(response->status_code, "200 OK"); + assert_security_headers(response); + const auto body = nlohmann::json::parse(response->content.string()); + EXPECT_TRUE(body.at("status").get()); + EXPECT_EQ(body.at("moonlight").get(), 0); + EXPECT_EQ(body.at("webrtc").get(), 0); + EXPECT_EQ(body.at("total").get(), 0); +} + TEST_F(ConfigHttpTest, PermissionsEndpointRejectsMalformedAndUnknownRequests) { SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json"); From 8bb3d49eef8c26dc16ad881b73fef2ef44985850 Mon Sep 17 00:00:00 2001 From: mlopezsegura <90263997+mlopezsegura@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:04:08 +0100 Subject: [PATCH 6/6] fix(windows): keep mouse and keyboard in Sunshine's own session With the Virtual HID Driver installed and licensed, libvirtualhid creates a driver-backed HID mouse and keyboard. Windows routes HID input to the console session, so a Sunshine running inside an RDP session moved the host's cursor instead of the session's (OpenStreamMS#39). When the process is not in the active console session, mouse and keyboard now go through SendInput, which injects into the caller's session. Gamepads, touch and pen keep using libvirtualhid. Co-Authored-By: Claude Opus 5.5 --- cmake/compile_definitions/windows.cmake | 2 + src/platform/virtualhid_input.cpp | 52 +++++ src/platform/windows/session_input.cpp | 249 ++++++++++++++++++++++++ src/platform/windows/session_input.h | 36 ++++ 4 files changed, 339 insertions(+) create mode 100644 src/platform/windows/session_input.cpp create mode 100644 src/platform/windows/session_input.h diff --git a/cmake/compile_definitions/windows.cmake b/cmake/compile_definitions/windows.cmake index 8e79406d829..996b3b24f88 100644 --- a/cmake/compile_definitions/windows.cmake +++ b/cmake/compile_definitions/windows.cmake @@ -67,6 +67,8 @@ set(PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/windows/misc.h" "${CMAKE_SOURCE_DIR}/src/platform/windows/misc.cpp" "${CMAKE_SOURCE_DIR}/src/platform/windows/input.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/session_input.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/session_input.cpp" "${CMAKE_SOURCE_DIR}/src/platform/windows/display.h" "${CMAKE_SOURCE_DIR}/src/platform/windows/display_base.cpp" "${CMAKE_SOURCE_DIR}/src/platform/windows/display_vram.cpp" diff --git a/src/platform/virtualhid_input.cpp b/src/platform/virtualhid_input.cpp index 7e255df5231..dc6e592b65c 100644 --- a/src/platform/virtualhid_input.cpp +++ b/src/platform/virtualhid_input.cpp @@ -23,6 +23,10 @@ #include "src/logging.h" #include "virtualhid_input.h" +#ifdef _WIN32 + #include "windows/session_input.h" +#endif + using namespace std::literals; namespace platf::virtualhid { @@ -1152,34 +1156,82 @@ namespace platf { #endif void move_mouse(input_t &input, int deltaX, int deltaY) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::move_mouse(deltaX, deltaY); + return; + } +#endif virtualhid::move_mouse(virtualhid::get_input_context(input), deltaX, deltaY); } void move_mouse(input_t &input, const touch_port_t &touch_port, int deltaX, int deltaY) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::move_mouse(deltaX, deltaY); + return; + } +#endif virtualhid::move_mouse(virtualhid::get_input_context(input), touch_port, deltaX, deltaY); } void abs_mouse(input_t &input, const touch_port_t &touch_port, float x, float y) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::abs_mouse(touch_port, x, y); + return; + } +#endif virtualhid::abs_mouse(virtualhid::get_input_context(input), touch_port, x, y); } void button_mouse(input_t &input, int button, bool release) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::button_mouse(button, release); + return; + } +#endif virtualhid::button_mouse(virtualhid::get_input_context(input), button, release); } void scroll(input_t &input, int high_res_distance) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::scroll(high_res_distance); + return; + } +#endif virtualhid::scroll(virtualhid::get_input_context(input), high_res_distance); } void hscroll(input_t &input, int high_res_distance) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::hscroll(high_res_distance); + return; + } +#endif virtualhid::hscroll(virtualhid::get_input_context(input), high_res_distance); } void keyboard_update(input_t &input, uint16_t modcode, bool release, uint8_t flags, bool extended) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::keyboard_update(modcode, release, flags, extended); + return; + } +#endif virtualhid::keyboard_update(virtualhid::get_input_context(input), modcode, release, flags, extended); } void unicode(input_t &input, const char *utf8, int size) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::unicode(utf8, size); + return; + } +#endif virtualhid::unicode(virtualhid::get_input_context(input), utf8, size); } diff --git a/src/platform/windows/session_input.cpp b/src/platform/windows/session_input.cpp new file mode 100644 index 00000000000..1ea2e432ca7 --- /dev/null +++ b/src/platform/windows/session_input.cpp @@ -0,0 +1,249 @@ +/** + * @file src/platform/windows/session_input.cpp + * @brief Session-local keyboard and mouse injection for non-console sessions. + */ +// standard includes +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#include + +// local includes +#include "misc.h" +#include "session_input.h" +#include "src/config.h" +#include "src/logging.h" +#include "third-party/libvirtualhid/src/platform/windows/keylayout.hpp" + +using namespace std::literals; + +namespace platf::session_input { + namespace { + /** + * @brief Input desktop last bound to this thread, used to retry SendInput after a desktop switch. + */ + thread_local HDESK last_input_desktop = nullptr; + + /** + * @brief Call SendInput() and retry once on the new input desktop (UAC, lock screen). + * + * @param inputs Events to inject. + */ + void send(std::span inputs) { + const auto count = static_cast(inputs.size()); + if (SendInput(count, inputs.data(), sizeof(INPUT)) == count) { + return; + } + + if (const auto desktop = syncThreadDesktop(); desktop != last_input_desktop) { + last_input_desktop = desktop; + if (SendInput(count, inputs.data(), sizeof(INPUT)) == count) { + return; + } + } + + BOOST_LOG(error) << "Couldn't send session-local input: "sv << GetLastError(); + } + + void send(INPUT input) { + std::array inputs {input}; + send(std::span {inputs}); + } + + INPUT mouse_input(DWORD flags, LONG dx = 0, LONG dy = 0, DWORD data = 0) { + INPUT input {}; + input.type = INPUT_MOUSE; + input.mi.dwFlags = flags; + input.mi.dx = dx; + input.mi.dy = dy; + input.mi.mouseData = data; + return input; + } + + /** + * @brief Scale a coordinate inside [0, dimension] to the 0..65535 range used by MOUSEEVENTF_ABSOLUTE. + */ + LONG scale_absolute_axis(float value, int dimension) { + if (dimension <= 1) { + return 0; + } + + const auto clamped = std::clamp(value, 0.0F, static_cast(dimension)); + return static_cast(std::lround(clamped * static_cast(std::numeric_limits::max()) / static_cast(dimension))); + } + + /** + * @brief Map a viewport-relative coordinate into the virtual desktop (same math as libvirtualhid's Win32 mouse). + */ + LONG scale_to_desktop(float value, int viewport_offset, int viewport_dimension, int desktop_offset, int desktop_dimension) { + if (viewport_dimension <= 0 || desktop_dimension <= 0) { + return 0; + } + + const auto clamped = std::clamp(value, 0.0F, static_cast(viewport_dimension)); + const auto span = static_cast(std::max(viewport_dimension - 1, 0)); + const auto target = static_cast(viewport_offset - desktop_offset) + clamped * span / static_cast(viewport_dimension); + return scale_absolute_axis(target, desktop_dimension); + } + + bool extended_key(std::uint16_t key_code) { + switch (key_code) { + case VK_LWIN: + case VK_RWIN: + case VK_RMENU: + case VK_RCONTROL: + case VK_INSERT: + case VK_DELETE: + case VK_HOME: + case VK_END: + case VK_PRIOR: + case VK_NEXT: + case VK_UP: + case VK_DOWN: + case VK_LEFT: + case VK_RIGHT: + case VK_DIVIDE: + case VK_APPS: + return true; + default: + return false; + } + } + } // namespace + + bool should_use() { + static const DWORD own_session = [] { + DWORD session = 0; + ProcessIdToSessionId(GetCurrentProcessId(), &session); + return session; + }(); + + // The console can move between sessions while streaming (e.g. someone logs + // in locally), so compare on every event. 0xFFFFFFFF means "no console + // session right now": SendInput is still the right choice then. + const bool bypass = WTSGetActiveConsoleSessionId() != own_session; + + static std::atomic logged {-1}; + if (const int state = bypass ? 1 : 0; logged.exchange(state) != state) { + if (bypass) { + BOOST_LOG(info) << "Sunshine runs outside the console session ("sv << own_session + << "): mouse and keyboard use SendInput instead of Virtual HID devices"sv; + } else { + BOOST_LOG(info) << "Sunshine runs in the console session: mouse and keyboard use libvirtualhid"sv; + } + } + return bypass; + } + + void move_mouse(int delta_x, int delta_y) { + send(mouse_input(MOUSEEVENTF_MOVE, delta_x, delta_y)); + } + + void abs_mouse(const touch_port_t &touch_port, float x, float y) { + // Same desktop/viewport split as virtualhid::pointer_viewports(): the + // desktop is the whole virtual screen, the viewport the streamed display. + const bool has_logical = touch_port.logical_width > 0 && touch_port.logical_height > 0; + const int viewport_x = has_logical ? touch_port.offset_x : touch_port.env_offset_x; + const int viewport_y = has_logical ? touch_port.offset_y : touch_port.env_offset_y; + const int viewport_w = has_logical ? touch_port.logical_width : touch_port.width; + const int viewport_h = has_logical ? touch_port.logical_height : touch_port.height; + + const auto dx = scale_to_desktop(x - static_cast(viewport_x), viewport_x, viewport_w, touch_port.env_offset_x, touch_port.width); + const auto dy = scale_to_desktop(y - static_cast(viewport_y), viewport_y, viewport_h, touch_port.env_offset_y, touch_port.height); + send(mouse_input(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK, dx, dy)); + } + + void button_mouse(int button, bool release) { + switch (button) { + case BUTTON_LEFT: + send(mouse_input(release ? MOUSEEVENTF_LEFTUP : MOUSEEVENTF_LEFTDOWN)); + return; + case BUTTON_MIDDLE: + send(mouse_input(release ? MOUSEEVENTF_MIDDLEUP : MOUSEEVENTF_MIDDLEDOWN)); + return; + case BUTTON_RIGHT: + send(mouse_input(release ? MOUSEEVENTF_RIGHTUP : MOUSEEVENTF_RIGHTDOWN)); + return; + case BUTTON_X1: + send(mouse_input(release ? MOUSEEVENTF_XUP : MOUSEEVENTF_XDOWN, 0, 0, XBUTTON1)); + return; + case BUTTON_X2: + send(mouse_input(release ? MOUSEEVENTF_XUP : MOUSEEVENTF_XDOWN, 0, 0, XBUTTON2)); + return; + default: + BOOST_LOG(warning) << "Unknown mouse button: "sv << button; + return; + } + } + + void scroll(int high_res_distance) { + send(mouse_input(MOUSEEVENTF_WHEEL, 0, 0, static_cast(high_res_distance))); + } + + void hscroll(int high_res_distance) { + send(mouse_input(MOUSEEVENTF_HWHEEL, 0, 0, static_cast(high_res_distance))); + } + + void keyboard_update(std::uint16_t modcode, bool release, std::uint8_t flags, bool extended) { + INPUT input {}; + input.type = INPUT_KEYBOARD; + auto &ki = input.ki; + ki.wVk = modcode; + + // Same rules as libvirtualhid's Win32 keyboard: normalized VK codes map + // through the US English layout; otherwise only map when asked to. + if (!(flags & SS_KBE_FLAG_NON_NORMALIZED)) { + ki.wScan = lvh::detail::windows_us_english_scan_code(modcode); + } else if (config::input.always_send_scancodes && modcode != VK_LWIN && modcode != VK_RWIN && modcode != VK_PAUSE) { + ki.wScan = static_cast(MapVirtualKeyW(modcode, MAPVK_VK_TO_VSC)); + } + + if (ki.wScan != 0) { + ki.wVk = 0; + ki.dwFlags |= KEYEVENTF_SCANCODE; + } + if (extended || extended_key(modcode)) { + ki.dwFlags |= KEYEVENTF_EXTENDEDKEY; + } + if (release) { + ki.dwFlags |= KEYEVENTF_KEYUP; + } + + send(input); + } + + void unicode(const char *utf8, int size) { + if (!utf8 || size <= 0) { + return; + } + + const auto chars = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8, size, nullptr, 0); + if (chars <= 0) { + return; + } + + std::vector wide(static_cast(chars)); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8, size, wide.data(), chars) <= 0) { + return; + } + + std::vector inputs; + inputs.reserve(wide.size() * 2); + for (const auto release : {false, true}) { + for (const auto ch : wide) { + INPUT input {}; + input.type = INPUT_KEYBOARD; + input.ki.wScan = ch; + input.ki.dwFlags = KEYEVENTF_UNICODE | (release ? KEYEVENTF_KEYUP : 0); + inputs.push_back(input); + } + } + send(std::span {inputs}); + } +} // namespace platf::session_input diff --git a/src/platform/windows/session_input.h b/src/platform/windows/session_input.h new file mode 100644 index 00000000000..c3444153fbb --- /dev/null +++ b/src/platform/windows/session_input.h @@ -0,0 +1,36 @@ +/** + * @file src/platform/windows/session_input.h + * @brief Session-local keyboard and mouse injection for non-console sessions. + * + * libvirtualhid prefers a driver-backed HID mouse and keyboard when the + * Virtual HID Driver is installed and licensed. Windows routes physical (HID) + * input to the session attached to the console, so when Sunshine runs inside + * an RDP session the host console receives the client's mouse and keyboard. + * SendInput() injects into the caller's own session, so these helpers bypass + * libvirtualhid for mouse and keyboard whenever Sunshine is not running in + * the active console session. + */ +#pragma once + +// standard includes +#include + +// local includes +#include "src/platform/common.h" + +namespace platf::session_input { + /** + * @brief Whether mouse and keyboard must bypass driver-backed HID devices. + * + * @return true when the current process is not in the active console session. + */ + bool should_use(); + + void move_mouse(int delta_x, int delta_y); + void abs_mouse(const touch_port_t &touch_port, float x, float y); + void button_mouse(int button, bool release); + void scroll(int high_res_distance); + void hscroll(int high_res_distance); + void keyboard_update(std::uint16_t modcode, bool release, std::uint8_t flags, bool extended); + void unicode(const char *utf8, int size); +} // namespace platf::session_input