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) {