From fd8912a363b3c048f3a87f85982d8a2b95a2cc01 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:24:55 -0400 Subject: [PATCH 01/19] feat: streaming --- .gitmodules | 11 + README.md | 4 +- cmake/moonlight-dependencies.cmake | 259 ++++ cmake/sources.cmake | 2 + cmake/xbox-build.cmake | 3 + scripts/ffmpeg-nxdk-cc.sh | 73 ++ scripts/ffmpeg-nxdk-cxx.sh | 77 ++ src/_nxdk_compat/ffmpeg_compat.h | 363 ++++++ src/_nxdk_compat/share.h | 57 + src/app/client_state.cpp | 176 ++- src/app/client_state.h | 11 + src/app/settings_storage.cpp | 108 +- src/app/settings_storage.h | 6 + src/main.cpp | 48 +- src/network/host_pairing.cpp | 205 ++- src/network/host_pairing.h | 54 + src/startup/video_mode.cpp | 49 + src/startup/video_mode.h | 24 + src/streaming/ffmpeg_stream_backend.cpp | 869 +++++++++++++ src/streaming/ffmpeg_stream_backend.h | 220 ++++ src/streaming/session.cpp | 1141 +++++++++++++++++ src/streaming/session.h | 48 + src/ui/shell_screen.cpp | 113 +- tests/unit/app/client_state_test.cpp | 44 +- tests/unit/app/settings_storage_test.cpp | 73 +- tests/unit/network/host_pairing_test.cpp | 244 +++- tests/unit/startup/video_mode_test.cpp | 45 + .../unit/ui/host_probe_result_queue_test.cpp | 14 +- third-party/ffmpeg | 1 + third-party/nxdk | 2 +- 30 files changed, 4310 insertions(+), 34 deletions(-) create mode 100644 scripts/ffmpeg-nxdk-cc.sh create mode 100644 scripts/ffmpeg-nxdk-cxx.sh create mode 100644 src/_nxdk_compat/ffmpeg_compat.h create mode 100644 src/_nxdk_compat/share.h create mode 100644 src/streaming/ffmpeg_stream_backend.cpp create mode 100644 src/streaming/ffmpeg_stream_backend.h create mode 100644 src/streaming/session.cpp create mode 100644 src/streaming/session.h create mode 160000 third-party/ffmpeg diff --git a/.gitmodules b/.gitmodules index c06338e..fede70b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,22 +2,33 @@ path = third-party/doxyconfig url = https://github.com/LizardByte/doxyconfig.git branch = master + shallow = true +[submodule "third-party/ffmpeg"] + path = third-party/ffmpeg + url = https://github.com/FFmpeg/FFmpeg.git + branch = release/8.1 + shallow = true [submodule "third-party/googletest"] path = third-party/googletest url = https://github.com/google/googletest.git + shallow = true [submodule "third-party/moonlight-common-c"] path = third-party/moonlight-common-c url = https://github.com/ReenigneArcher/moonlight-common-c.git branch = nxdk-compat + shallow = true [submodule "third-party/nxdk"] path = third-party/nxdk url = https://github.com/ReenigneArcher/nxdk.git branch = moonlight + shallow = true [submodule "third-party/openssl"] path = third-party/openssl url = https://github.com/openssl/openssl.git branch = OpenSSL_1_1_1-stable + shallow = true [submodule "third-party/tomlplusplus"] path = third-party/tomlplusplus url = https://github.com/marzer/tomlplusplus.git branch = master + shallow = true diff --git a/README.md b/README.md index b6d866b..1625944 100644 --- a/README.md +++ b/README.md @@ -229,14 +229,14 @@ scripts\setup-xemu.cmd --skip-support-files - [x] App Details - [ ] Pause/Hotkey overlay - Streaming - - [ ] Video - https://www.xbmc4xbox.org.uk/wiki/XBMC_Features_and_Supported_Formats#Xbox_supported_video_formats_and_resolutions + - [x] Video - https://www.xbmc4xbox.org.uk/wiki/XBMC_Features_and_Supported_Formats#Xbox_supported_video_formats_and_resolutions - [ ] Audio - [ ] Mono - [ ] Stereo - [ ] 5.1 Surround - [ ] 7.1 Surround - Input - - [ ] Gamepad Input + - [x] Gamepad Input - [ ] Keyboard Input - [ ] Mouse Input - [ ] Mouse Emulation via Gamepad diff --git a/cmake/moonlight-dependencies.cmake b/cmake/moonlight-dependencies.cmake index dcc5024..4cb811d 100644 --- a/cmake/moonlight-dependencies.cmake +++ b/cmake/moonlight-dependencies.cmake @@ -1,5 +1,260 @@ include_guard(GLOBAL) +# Normalize FFmpeg configure probes that incorrectly succeed against the nxdk toolchain. +function(_moonlight_patch_ffmpeg_config_header ffmpeg_config_header) + if(NOT EXISTS "${ffmpeg_config_header}") + return() + endif() + + file(READ "${ffmpeg_config_header}" ffmpeg_config_text) + + foreach(ffmpeg_disabled_probe + IN ITEMS + HAVE_ALIGNED_MALLOC + HAVE_EXP2 + HAVE_EXP2F + HAVE_LLRINT + HAVE_LLRINTF + HAVE_LOG2 + HAVE_LOG2F + HAVE_LRINT + HAVE_LRINTF + HAVE_MEMALIGN + HAVE_MMAP + HAVE_POSIX_MEMALIGN + HAVE_RINT + HAVE_RINTF + HAVE_SCHED_GETAFFINITY + HAVE_STRERROR_R + HAVE_SYSCTL) + string(REGEX REPLACE + "#define ${ffmpeg_disabled_probe} [0-9]+" + "#define ${ffmpeg_disabled_probe} 0" + ffmpeg_config_text + "${ffmpeg_config_text}") + endforeach() + + file(WRITE "${ffmpeg_config_header}" "${ffmpeg_config_text}") +endfunction() + +# Prepare the static FFmpeg libraries used by the Xbox streaming runtime. +function(moonlight_prepare_xbox_ffmpeg nxdk_dir) + set(ffmpeg_source_dir "${CMAKE_SOURCE_DIR}/third-party/ffmpeg") + set(ffmpeg_cc_wrapper "${CMAKE_SOURCE_DIR}/scripts/ffmpeg-nxdk-cc.sh") + set(ffmpeg_cxx_wrapper "${CMAKE_SOURCE_DIR}/scripts/ffmpeg-nxdk-cxx.sh") + set(ffmpeg_compat_header "${CMAKE_SOURCE_DIR}/src/_nxdk_compat/ffmpeg_compat.h") + if(NOT EXISTS "${ffmpeg_source_dir}/configure") + message(FATAL_ERROR + "FFmpeg source directory not found: ${ffmpeg_source_dir}\n" + "Run: git submodule update --init --recursive") + endif() + + foreach(ffmpeg_support_file + IN ITEMS + "${ffmpeg_cc_wrapper}" + "${ffmpeg_cxx_wrapper}" + "${ffmpeg_compat_header}") + if(NOT EXISTS "${ffmpeg_support_file}") + message(FATAL_ERROR "Required FFmpeg support file not found: ${ffmpeg_support_file}") + endif() + endforeach() + + set(ffmpeg_state_dir "${CMAKE_BINARY_DIR}/third-party/ffmpeg") + set(ffmpeg_build_dir "${ffmpeg_state_dir}/build") + set(ffmpeg_install_dir "${ffmpeg_state_dir}/install") + set(signature_file "${ffmpeg_state_dir}/build.signature") + + execute_process( + COMMAND git -C "${ffmpeg_source_dir}" rev-parse HEAD + OUTPUT_VARIABLE ffmpeg_revision + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE ffmpeg_revision_result + ) + if(NOT ffmpeg_revision_result EQUAL 0) + set(ffmpeg_revision unknown) + endif() + + file(SHA256 "${ffmpeg_cc_wrapper}" ffmpeg_cc_wrapper_hash) + file(SHA256 "${ffmpeg_cxx_wrapper}" ffmpeg_cxx_wrapper_hash) + file(SHA256 "${ffmpeg_compat_header}" ffmpeg_compat_header_hash) + file(SHA256 "${CMAKE_SOURCE_DIR}/cmake/moonlight-dependencies.cmake" ffmpeg_dependency_manifest_hash) + + set(signature_inputs + "FFMPEG_REVISION=${ffmpeg_revision}" + "NXDK_DIR=${nxdk_dir}" + "FFMPEG_PROFILE=h264-opus-xbox" + "FFMPEG_TARGET_OS=none" + "FFMPEG_ARCH=x86" + "FFMPEG_CC_WRAPPER_SHA256=${ffmpeg_cc_wrapper_hash}" + "FFMPEG_CXX_WRAPPER_SHA256=${ffmpeg_cxx_wrapper_hash}" + "FFMPEG_COMPAT_HEADER_SHA256=${ffmpeg_compat_header_hash}" + "FFMPEG_DEPENDENCY_MANIFEST_SHA256=${ffmpeg_dependency_manifest_hash}") + list(JOIN signature_inputs "\n" signature_text) + string(SHA256 signature "${signature_text}") + + set(required_outputs + "${ffmpeg_install_dir}/include/libavcodec/avcodec.h" + "${ffmpeg_install_dir}/lib/libavcodec.a" + "${ffmpeg_install_dir}/lib/libavutil.a" + "${ffmpeg_install_dir}/lib/libswscale.a" + "${ffmpeg_install_dir}/lib/libswresample.a") + + file(MAKE_DIRECTORY "${ffmpeg_state_dir}") + + set(need_rebuild FALSE) + if(NOT EXISTS "${signature_file}") + set(need_rebuild TRUE) + else() + file(READ "${signature_file}" saved_signature) + string(STRIP "${saved_signature}" saved_signature) + if(NOT saved_signature STREQUAL signature) + set(need_rebuild TRUE) + endif() + endif() + + _moonlight_has_missing_output(ffmpeg_missing_output ${required_outputs}) + if(ffmpeg_missing_output) + set(need_rebuild TRUE) + endif() + + if(need_rebuild) + message(STATUS "Preparing FFmpeg for Xbox at ${ffmpeg_build_dir}") + file(REMOVE_RECURSE "${ffmpeg_build_dir}" "${ffmpeg_install_dir}") + file(MAKE_DIRECTORY "${ffmpeg_build_dir}") + + if(CMAKE_HOST_WIN32) + _moonlight_to_msys_path(ffmpeg_source_shell_path "${ffmpeg_source_dir}") + _moonlight_to_msys_path(ffmpeg_install_shell_path "${ffmpeg_install_dir}") + _moonlight_to_msys_path(ffmpeg_build_shell_path "${ffmpeg_build_dir}") + _moonlight_to_msys_path(nxdk_shell_path "${nxdk_dir}") + _moonlight_to_msys_path(ffmpeg_cc_shell_path "${ffmpeg_cc_wrapper}") + _moonlight_to_msys_path(ffmpeg_cxx_shell_path "${ffmpeg_cxx_wrapper}") + else() + set(ffmpeg_source_shell_path "${ffmpeg_source_dir}") + set(ffmpeg_install_shell_path "${ffmpeg_install_dir}") + set(ffmpeg_cc_shell_path nxdk-cc) + set(ffmpeg_cxx_shell_path nxdk-cxx) + endif() + + set(ffmpeg_configure_args + sh + "${ffmpeg_source_shell_path}/configure" + "--prefix=${ffmpeg_install_shell_path}" + --enable-cross-compile + --arch=x86 + --cpu=i686 + --target-os=none + "--cc=${ffmpeg_cc_shell_path}" + "--cxx=${ffmpeg_cxx_shell_path}" + --ar=llvm-ar + --ranlib=llvm-ranlib + --nm=llvm-nm + --enable-static + --disable-shared + --disable-autodetect + --disable-asm + --disable-inline-asm + --disable-x86asm + --disable-debug + --disable-doc + --disable-programs + --disable-network + --disable-everything + --disable-avdevice + --disable-avfilter + --disable-avformat + --disable-iconv + --disable-zlib + --disable-bzlib + --disable-lzma + --disable-sdl2 + --disable-symver + --disable-runtime-cpudetect + --disable-pthreads + --disable-w32threads + --disable-os2threads + --disable-hwaccels + --enable-avcodec + --enable-avutil + --enable-swscale + --enable-swresample + --enable-parser=h264 + --enable-decoder=h264 + --enable-decoder=opus) + + if(CMAKE_HOST_WIN32) + set(msys2_shell "C:/msys64/msys2_shell.cmd") + if(NOT EXISTS "${msys2_shell}") + message(FATAL_ERROR "MSYS2 shell not found at ${msys2_shell}") + endif() + _moonlight_join_shell_command(ffmpeg_configure_command ${ffmpeg_configure_args}) + _moonlight_join_shell_command(ffmpeg_build_command make -j4 install) + _moonlight_shell_quote(quoted_nxdk_shell_path "${nxdk_shell_path}") + _moonlight_shell_quote(quoted_ffmpeg_build_shell_path "${ffmpeg_build_shell_path}") + + string(CONCAT ffmpeg_configure_script + "unset MAKEFLAGS MFLAGS GNUMAKEFLAGS MAKELEVEL; " + "export NXDK_DIR=${quoted_nxdk_shell_path}; " + "export PATH=\"$NXDK_DIR/bin:$PATH\"; " + "cd ${quoted_ffmpeg_build_shell_path}; " + "exec ${ffmpeg_configure_command}") + execute_process( + COMMAND "${msys2_shell}" -defterm -here -no-start -mingw64 -c "${ffmpeg_configure_script}" + RESULT_VARIABLE ffmpeg_configure_result + ) + if(NOT ffmpeg_configure_result EQUAL 0) + message(FATAL_ERROR "FFmpeg configure failed with exit code ${ffmpeg_configure_result}") + endif() + + set(ffmpeg_config_header "${ffmpeg_build_dir}/config.h") + _moonlight_patch_ffmpeg_config_header("${ffmpeg_config_header}") + + string(CONCAT ffmpeg_build_script + "unset MAKEFLAGS MFLAGS GNUMAKEFLAGS MAKELEVEL; " + "export NXDK_DIR=${quoted_nxdk_shell_path}; " + "export PATH=\"$NXDK_DIR/bin:$PATH\"; " + "cd ${quoted_ffmpeg_build_shell_path}; " + "exec ${ffmpeg_build_command}") + execute_process( + COMMAND "${msys2_shell}" -defterm -here -no-start -mingw64 -c "${ffmpeg_build_script}" + RESULT_VARIABLE ffmpeg_build_result + ) + if(NOT ffmpeg_build_result EQUAL 0) + message(FATAL_ERROR "FFmpeg build failed with exit code ${ffmpeg_build_result}") + endif() + else() + moonlight_run_nxdk_command( + "FFmpeg configure" + "${nxdk_dir}" + "${ffmpeg_build_dir}" + ${ffmpeg_configure_args} + ) + set(ffmpeg_config_header "${ffmpeg_build_dir}/config.h") + _moonlight_patch_ffmpeg_config_header("${ffmpeg_config_header}") + moonlight_run_nxdk_command( + "FFmpeg build" + "${nxdk_dir}" + "${ffmpeg_build_dir}" + make + -j4 + install + ) + endif() + + file(WRITE "${signature_file}" "${signature}\n") + else() + message(STATUS "Using existing FFmpeg Xbox outputs from ${ffmpeg_install_dir}") + endif() + + set(MOONLIGHT_FFMPEG_INCLUDE_DIR "${ffmpeg_install_dir}/include" PARENT_SCOPE) + set(MOONLIGHT_FFMPEG_LIBRARIES + "${ffmpeg_install_dir}/lib/libavcodec.a" + "${ffmpeg_install_dir}/lib/libswscale.a" + "${ffmpeg_install_dir}/lib/libswresample.a" + "${ffmpeg_install_dir}/lib/libavutil.a" + PARENT_SCOPE) +endfunction() + # Prepare dependencies that are common to multiple Moonlight components macro(MOONLIGHT_PREPARE_COMMON_DEPENDENCIES) include(GetOpenSSL REQUIRED) @@ -62,6 +317,7 @@ macro(MOONLIGHT_PREPARE_COMMON_DEPENDENCIES) ) if(TARGET enet) + target_compile_definitions(enet PRIVATE NXDK) target_link_libraries(enet PUBLIC NXDK::NXDK NXDK::Net) target_include_directories(enet PRIVATE "${MOONLIGHT_NXDK_NET_INCLUDE_DIR}" @@ -71,6 +327,7 @@ macro(MOONLIGHT_PREPARE_COMMON_DEPENDENCIES) endif() if(TARGET moonlight-common-c) + target_compile_definitions(moonlight-common-c PRIVATE NXDK) target_include_directories(moonlight-common-c PRIVATE "${MOONLIGHT_NXDK_NET_INCLUDE_DIR}" "${MOONLIGHT_NXDK_LIBC_EXTENSIONS_DIR}" @@ -79,5 +336,7 @@ macro(MOONLIGHT_PREPARE_COMMON_DEPENDENCIES) -Wno-unused-function -Wno-error=unused-function) endif() + + moonlight_prepare_xbox_ffmpeg("${NXDK_DIR}") endif() endmacro() diff --git a/cmake/sources.cmake b/cmake/sources.cmake index b6427ed..b1a6e69 100644 --- a/cmake/sources.cmake +++ b/cmake/sources.cmake @@ -16,7 +16,9 @@ list(REMOVE_ITEM MOONLIGHT_SOURCES set(MOONLIGHT_TEST_EXCLUDED_SOURCES "${MOONLIGHT_SOURCE_ROOT}/src/main.cpp" "${MOONLIGHT_SOURCE_ROOT}/src/splash/splash_screen.cpp" + "${MOONLIGHT_SOURCE_ROOT}/src/streaming/ffmpeg_stream_backend.cpp" "${MOONLIGHT_SOURCE_ROOT}/src/startup/memory_stats.cpp" + "${MOONLIGHT_SOURCE_ROOT}/src/streaming/session.cpp" "${MOONLIGHT_SOURCE_ROOT}/src/ui/shell_screen.cpp" ) diff --git a/cmake/xbox-build.cmake b/cmake/xbox-build.cmake index 3b100ca..2fbbea0 100644 --- a/cmake/xbox-build.cmake +++ b/cmake/xbox-build.cmake @@ -62,6 +62,7 @@ target_sources(${CMAKE_PROJECT_NAME} target_include_directories(${CMAKE_PROJECT_NAME} SYSTEM PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" + "${MOONLIGHT_FFMPEG_INCLUDE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/third-party/tomlplusplus/include" "${MOONLIGHT_NXDK_NET_INCLUDE_DIR}" "${MOONLIGHT_NXDK_LIBC_EXTENSIONS_DIR}" @@ -69,6 +70,8 @@ target_include_directories(${CMAKE_PROJECT_NAME} ) target_link_libraries(${CMAKE_PROJECT_NAME} PUBLIC + moonlight-common-c + ${MOONLIGHT_FFMPEG_LIBRARIES} NXDK::NXDK NXDK::NXDK_CXX NXDK::Net diff --git a/scripts/ffmpeg-nxdk-cc.sh b/scripts/ffmpeg-nxdk-cc.sh new file mode 100644 index 0000000..ad35586 --- /dev/null +++ b/scripts/ffmpeg-nxdk-cc.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env sh + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ffmpeg_compat_header="${script_dir}/../src/_nxdk_compat/ffmpeg_compat.h" +ffmpeg_compat_include_dir="${script_dir}/../src/_nxdk_compat" + +compile_only=0 +output_path= +previous_arg= +for arg in "$@"; do + case "$arg" in + -c|-E) + compile_only=1 + ;; + esac + + if [ "$previous_arg" = "-o" ]; then + output_path="$arg" + previous_arg= + continue + fi + + previous_arg="$arg" +done + +if [ "$compile_only" -eq 1 ]; then + exec clang \ + -target i386-pc-win32 \ + -march=pentium3 \ + -ffreestanding \ + -nostdlib \ + -fno-builtin \ + -include "${ffmpeg_compat_header}" \ + -I"${ffmpeg_compat_include_dir}" \ + -I"${NXDK_DIR}/lib" \ + -I"${NXDK_DIR}/lib/xboxrt/libc_extensions" \ + -isystem "${NXDK_DIR}/lib/pdclib/include" \ + -I"${NXDK_DIR}/lib/pdclib/platform/xbox/include" \ + -I"${NXDK_DIR}/lib/winapi" \ + -I"${NXDK_DIR}/lib/xboxrt/vcruntime" \ + -Wno-builtin-macro-redefined \ + -DNXDK \ + -D__STDC__=1 \ + -U__STDC_NO_THREADS__ \ + "$@" +fi + +if [ -n "$output_path" ]; then + : > "$output_path" + exit 0 +fi + +exec clang \ + -target i386-pc-win32 \ + -march=pentium3 \ + -ffreestanding \ + -nostdlib \ + -fno-builtin \ + -include "${ffmpeg_compat_header}" \ + -I"${ffmpeg_compat_include_dir}" \ + -I"${NXDK_DIR}/lib" \ + -I"${NXDK_DIR}/lib/xboxrt/libc_extensions" \ + -isystem "${NXDK_DIR}/lib/pdclib/include" \ + -I"${NXDK_DIR}/lib/pdclib/platform/xbox/include" \ + -I"${NXDK_DIR}/lib/winapi" \ + -I"${NXDK_DIR}/lib/xboxrt/vcruntime" \ + -Wno-builtin-macro-redefined \ + -DNXDK \ + -D__STDC__=1 \ + -U__STDC_NO_THREADS__ \ + "$@" diff --git a/scripts/ffmpeg-nxdk-cxx.sh b/scripts/ffmpeg-nxdk-cxx.sh new file mode 100644 index 0000000..b62ba21 --- /dev/null +++ b/scripts/ffmpeg-nxdk-cxx.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env sh + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ffmpeg_compat_header="${script_dir}/../src/_nxdk_compat/ffmpeg_compat.h" +ffmpeg_compat_include_dir="${script_dir}/../src/_nxdk_compat" + +compile_only=0 +output_path= +previous_arg= +for arg in "$@"; do + case "$arg" in + -c|-E) + compile_only=1 + ;; + esac + + if [ "$previous_arg" = "-o" ]; then + output_path="$arg" + previous_arg= + continue + fi + + previous_arg="$arg" +done + +if [ "$compile_only" -eq 1 ]; then + exec clang \ + -target i386-pc-win32 \ + -march=pentium3 \ + -ffreestanding \ + -nostdlib \ + -fno-builtin \ + -include "${ffmpeg_compat_header}" \ + -I"${ffmpeg_compat_include_dir}" \ + -I"${NXDK_DIR}/lib/libcxx/include" \ + -I"${NXDK_DIR}/lib" \ + -I"${NXDK_DIR}/lib/xboxrt/libc_extensions" \ + -isystem "${NXDK_DIR}/lib/pdclib/include" \ + -I"${NXDK_DIR}/lib/pdclib/platform/xbox/include" \ + -I"${NXDK_DIR}/lib/winapi" \ + -I"${NXDK_DIR}/lib/xboxrt/vcruntime" \ + -Wno-builtin-macro-redefined \ + -DNXDK \ + -D__STDC__=1 \ + -U__STDC_NO_THREADS__ \ + -fno-exceptions \ + "$@" +fi + +if [ -n "$output_path" ]; then + : > "$output_path" + exit 0 +fi + +exec clang \ + -target i386-pc-win32 \ + -march=pentium3 \ + -ffreestanding \ + -nostdlib \ + -fno-builtin \ + -include "${ffmpeg_compat_header}" \ + -I"${ffmpeg_compat_include_dir}" \ + -I"${NXDK_DIR}/lib/libcxx/include" \ + -I"${NXDK_DIR}/lib" \ + -I"${NXDK_DIR}/lib/xboxrt/libc_extensions" \ + -isystem "${NXDK_DIR}/lib/pdclib/include" \ + -I"${NXDK_DIR}/lib/pdclib/platform/xbox/include" \ + -I"${NXDK_DIR}/lib/winapi" \ + -I"${NXDK_DIR}/lib/xboxrt/vcruntime" \ + -Wno-builtin-macro-redefined \ + -DNXDK \ + -D__STDC__=1 \ + -U__STDC_NO_THREADS__ \ + -fno-exceptions \ + "$@" diff --git a/src/_nxdk_compat/ffmpeg_compat.h b/src/_nxdk_compat/ffmpeg_compat.h new file mode 100644 index 0000000..580ec24 --- /dev/null +++ b/src/_nxdk_compat/ffmpeg_compat.h @@ -0,0 +1,363 @@ +/** + * @file src/_nxdk_compat/ffmpeg_compat.h + * @brief Provides nxdk compatibility shims for the FFmpeg Xbox build. + */ + +#pragma once + +#ifdef NXDK + + #include + #include + #include + #include + #include + #include + #include + #include + + #ifdef __cplusplus +extern "C" { + #endif + + #ifndef ENOSYS + #define ENOSYS 38 + #endif + + #ifndef O_BINARY + #define O_BINARY 0 + #endif + + #ifndef F_SETFD + #define F_SETFD 2 + #endif + + #ifndef FD_CLOEXEC + #define FD_CLOEXEC 1 + #endif + + #ifndef CP_ACP + #define CP_ACP 0U + #endif + + #ifndef CP_UTF8 + #define CP_UTF8 65001U + #endif + + #ifndef MB_ERR_INVALID_CHARS + #define MB_ERR_INVALID_CHARS 0x00000008UL + #endif + + #ifndef WC_ERR_INVALID_CHARS + #define WC_ERR_INVALID_CHARS 0x00000080UL + #endif + + /** @brief Redirect access to the nxdk FFmpeg compatibility shim. */ + #define access moonlight_nxdk_ffmpeg_access + /** @brief Redirect close to the nxdk FFmpeg compatibility shim. */ + #define close moonlight_nxdk_ffmpeg_close + /** @brief Redirect fcntl to the nxdk FFmpeg compatibility shim. */ + #define fcntl moonlight_nxdk_ffmpeg_fcntl + /** @brief Redirect fdopen to the nxdk FFmpeg compatibility shim. */ + #define fdopen moonlight_nxdk_ffmpeg_fdopen + /** @brief Redirect isatty to the nxdk FFmpeg compatibility shim. */ + #define isatty moonlight_nxdk_ffmpeg_isatty + /** @brief Redirect GetFullPathNameW to the nxdk FFmpeg compatibility shim. */ + #define GetFullPathNameW moonlight_nxdk_ffmpeg_GetFullPathNameW + /** @brief Redirect mkstemp to the nxdk FFmpeg compatibility shim. */ + #define mkstemp moonlight_nxdk_ffmpeg_mkstemp + /** @brief Redirect MultiByteToWideChar to the nxdk FFmpeg compatibility shim. */ + #define MultiByteToWideChar moonlight_nxdk_ffmpeg_MultiByteToWideChar + /** @brief Redirect open to the nxdk FFmpeg compatibility shim. */ + #define open moonlight_nxdk_ffmpeg_open + /** @brief Redirect strerror_r to the nxdk FFmpeg compatibility shim. */ + #define strerror_r moonlight_nxdk_ffmpeg_strerror_r + /** @brief Redirect tempnam to the nxdk FFmpeg compatibility shim. */ + #define tempnam moonlight_nxdk_ffmpeg_tempnam + /** @brief Redirect usleep to the nxdk FFmpeg compatibility shim. */ + #define usleep moonlight_nxdk_ffmpeg_usleep + /** @brief Redirect WideCharToMultiByte to the nxdk FFmpeg compatibility shim. */ + #define WideCharToMultiByte moonlight_nxdk_ffmpeg_WideCharToMultiByte + + /** + * @brief Report unsupported path access checks during FFmpeg builds. + * + * @param path Requested file system path. + * @param mode Requested access mode. + * @return Always -1 with errno set to ENOSYS. + */ + static inline int moonlight_nxdk_ffmpeg_access(const char *path, int mode) { + (void) path; + (void) mode; + errno = ENOSYS; + return -1; + } + + /** + * @brief Treat descriptor close requests as successful during FFmpeg builds. + * + * @param fd Descriptor to close. + * @return Always 0. + */ + static inline int moonlight_nxdk_ffmpeg_close(int fd) { + (void) fd; + return 0; + } + + /** + * @brief Report unsupported fcntl requests during FFmpeg builds. + * + * @param fd Descriptor to operate on. + * @param cmd fcntl command. + * @param ... Ignored command arguments. + * @return Always -1 with errno set to ENOSYS. + */ + static inline int moonlight_nxdk_ffmpeg_fcntl(int fd, int cmd, ...) { + (void) fd; + (void) cmd; + errno = ENOSYS; + return -1; + } + + /** + * @brief Report unsupported descriptor-backed stdio requests during FFmpeg builds. + * + * @param fd Descriptor to convert. + * @param mode Requested fopen mode string. + * @return Always NULL with errno set to ENOSYS. + */ + static inline FILE *moonlight_nxdk_ffmpeg_fdopen(int fd, const char *mode) { + (void) fd; + (void) mode; + errno = ENOSYS; + return NULL; + } + + /** + * @brief Report that FFmpeg output is not connected to a terminal. + * + * @param fd Descriptor to inspect. + * @return Always 0. + */ + static inline int moonlight_nxdk_ffmpeg_isatty(int fd) { + (void) fd; + return 0; + } + + /** + * @brief Provide a minimal high-resolution timer fallback for FFmpeg. + * + * @return A monotonic placeholder timestamp value. + */ + static inline int64_t gethrtime(void) { + return 0; + } + + /** + * @brief Provide a minimal GetFullPathNameW fallback for FFmpeg path normalization. + * + * @param path Source wide-character path. + * @param buffer_size Destination buffer capacity in wide characters. + * @param buffer Destination buffer. + * @param file_part Optional pointer to the filename portion. + * @return Required or written character count, including the terminating null. + */ + static inline unsigned long moonlight_nxdk_ffmpeg_GetFullPathNameW(const wchar_t *path, unsigned long buffer_size, wchar_t *buffer, wchar_t **file_part) { + size_t length; + wchar_t *last_separator; + + if (path == NULL) { + return 0; + } + + length = wcslen(path); + last_separator = NULL; + for (size_t index = 0; index < length; ++index) { + if (path[index] == L'\\' || path[index] == L'/') { + last_separator = (wchar_t *) &path[index + 1U]; + } + } + + if (file_part != NULL) { + *file_part = last_separator; + } + + if (buffer == NULL || buffer_size == 0U) { + return (unsigned long) (length + 1U); + } + + if (length + 1U > buffer_size) { + if (buffer_size > 0U) { + wcsncpy(buffer, path, buffer_size - 1U); + buffer[buffer_size - 1U] = L'\0'; + } + return (unsigned long) (length + 1U); + } + + wcscpy(buffer, path); + return (unsigned long) length; + } + + /** + * @brief Report unsupported temporary file creation during FFmpeg builds. + * + * @param pattern Writable mkstemp pattern. + * @return Always -1 with errno set to ENOSYS. + */ + static inline int moonlight_nxdk_ffmpeg_mkstemp(char *pattern) { + (void) pattern; + errno = ENOSYS; + return -1; + } + + /** + * @brief Provide a minimal UTF-8 to wchar_t conversion fallback. + * + * @param code_page Requested Windows code page. + * @param flags Requested conversion flags. + * @param source Source multibyte string. + * @param source_length Length of @p source or -1 for null-terminated input. + * @param destination Destination wide-character buffer. + * @param destination_length Capacity of @p destination in wide characters. + * @return Required or written character count, including the terminating null. + */ + static inline int moonlight_nxdk_ffmpeg_MultiByteToWideChar(unsigned int code_page, unsigned long flags, const char *source, int source_length, wchar_t *destination, int destination_length) { + size_t length; + + (void) code_page; + (void) flags; + + if (source == NULL) { + return 0; + } + + length = source_length >= 0 ? (size_t) source_length : strlen(source) + 1U; + if (destination == NULL || destination_length <= 0) { + return (int) length; + } + + if ((size_t) destination_length < length) { + return 0; + } + + for (size_t index = 0; index < length; ++index) { + destination[index] = (unsigned char) source[index]; + } + + return (int) length; + } + + /** + * @brief Report unsupported file opening during FFmpeg builds. + * + * @param path Requested file system path. + * @param flags Requested open flags. + * @param ... Ignored mode argument. + * @return Always -1 with errno set to ENOSYS. + */ + static inline int moonlight_nxdk_ffmpeg_open(const char *path, int flags, ...) { + (void) path; + (void) flags; + errno = ENOSYS; + return -1; + } + + /** + * @brief Provide a simple strerror_r fallback backed by strerror. + * + * @param errnum Error number to describe. + * @param buffer Destination character buffer. + * @param buffer_size Size of @p buffer in bytes. + * @return Zero when the buffer is usable, otherwise -1. + */ + static inline int moonlight_nxdk_ffmpeg_strerror_r(int errnum, char *buffer, size_t buffer_size) { + const char *message; + + if (buffer == NULL || buffer_size == 0) { + errno = EINVAL; + return -1; + } + + message = strerror(errnum); + if (message == NULL) { + message = "Unknown error"; + } + + strncpy(buffer, message, buffer_size - 1U); + buffer[buffer_size - 1U] = '\0'; + return 0; + } + + /** + * @brief Report unsupported tempnam requests during FFmpeg builds. + * + * @param dir Ignored preferred directory. + * @param prefix Ignored preferred file prefix. + * @return Always NULL with errno set to ENOSYS. + */ + static inline char *moonlight_nxdk_ffmpeg_tempnam(const char *dir, const char *prefix) { + (void) dir; + (void) prefix; + errno = ENOSYS; + return NULL; + } + + /** + * @brief Provide a no-op microsecond sleep fallback for FFmpeg. + * + * @param usec Requested sleep duration in microseconds. + * @return Always 0. + */ + static inline int moonlight_nxdk_ffmpeg_usleep(unsigned int usec) { + (void) usec; + return 0; + } + + /** + * @brief Provide a minimal wchar_t to multibyte conversion fallback. + * + * @param code_page Requested Windows code page. + * @param flags Requested conversion flags. + * @param source Source wide-character string. + * @param source_length Length of @p source or -1 for null-terminated input. + * @param destination Destination multibyte buffer. + * @param destination_length Capacity of @p destination in bytes. + * @param default_char Ignored Windows default character pointer. + * @param used_default_char Ignored Windows default-character output flag. + * @return Required or written character count, including the terminating null. + */ + static inline int moonlight_nxdk_ffmpeg_WideCharToMultiByte(unsigned int code_page, unsigned long flags, const wchar_t *source, int source_length, char *destination, int destination_length, const char *default_char, int *used_default_char) { + size_t length; + + (void) code_page; + (void) flags; + (void) default_char; + if (used_default_char != NULL) { + *used_default_char = 0; + } + + if (source == NULL) { + return 0; + } + + length = source_length >= 0 ? (size_t) source_length : wcslen(source) + 1U; + if (destination == NULL || destination_length <= 0) { + return (int) length; + } + + if ((size_t) destination_length < length) { + return 0; + } + + for (size_t index = 0; index < length; ++index) { + destination[index] = (char) source[index]; + } + + return (int) length; + } + + #ifdef __cplusplus +} + #endif + +#endif diff --git a/src/_nxdk_compat/share.h b/src/_nxdk_compat/share.h new file mode 100644 index 0000000..371da8d --- /dev/null +++ b/src/_nxdk_compat/share.h @@ -0,0 +1,57 @@ +/** + * @file src/_nxdk_compat/share.h + * @brief Provides the small share.h surface needed by the FFmpeg Xbox build. + */ + +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef SH_DENYNO + #define SH_DENYNO 0 +#endif + + /** + * @brief Stub the wide-character shared open helper used by FFmpeg's Win32 path. + * + * @param path Requested file system path. + * @param oflag Requested open flags. + * @param shflag Requested sharing mode. + * @param pmode Requested permission mode. + * @return Always -1 with errno set to ENOSYS. + */ + static inline int _wsopen(const wchar_t *path, int oflag, int shflag, int pmode) { + (void) path; + (void) oflag; + (void) shflag; + (void) pmode; + errno = ENOSYS; + return -1; + } + + /** + * @brief Stub the narrow shared open helper used by FFmpeg's Win32 path. + * + * @param path Requested file system path. + * @param oflag Requested open flags. + * @param shflag Requested sharing mode. + * @param pmode Requested permission mode. + * @return Always -1 with errno set to ENOSYS. + */ + static inline int _sopen(const char *path, int oflag, int shflag, int pmode) { + (void) path; + (void) oflag; + (void) shflag; + (void) pmode; + errno = ENOSYS; + return -1; + } + +#ifdef __cplusplus +} +#endif diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index 1d5c3ec..71f792a 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -28,6 +28,8 @@ namespace { constexpr const char *SETTINGS_CATEGORY_PREFIX = "settings-category:"; constexpr std::array ADD_HOST_ADDRESS_KEYPAD_CHARACTERS {'1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0'}; constexpr std::array ADD_HOST_PORT_KEYPAD_CHARACTERS {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}; + constexpr std::array STREAM_FRAMERATE_OPTIONS {15, 20, 24, 25, 30}; + constexpr std::array STREAM_BITRATE_OPTIONS {1000, 1500, 2000, 2500, 3000, 4000, 5000}; /** * @brief Describes the keypad characters available for the active add-host field. @@ -105,7 +107,7 @@ namespace { case app::SettingsCategory::logging: return "Control the runtime log file, the in-app log viewer, and xemu debugger output verbosity."; case app::SettingsCategory::display: - return "Display options will live here when video and layout tuning settings are added."; + return "Tune streaming video resolution, frame rate, bitrate, host audio playback, and the in-stream diagnostics overlay."; case app::SettingsCategory::input: return "Input options will live here when controller and navigation customization is added."; case app::SettingsCategory::reset: @@ -115,6 +117,100 @@ namespace { return "Control the runtime log file, the in-app log viewer, and xemu debugger output verbosity."; } + /** + * @brief Return whether two stream-resolution entries target the same size. + * + * @param left First stream-resolution entry to compare. + * @param right Second stream-resolution entry to compare. + * @return True when both entries describe the same width and height. + */ + bool stream_resolutions_match(const VIDEO_MODE &left, const VIDEO_MODE &right) { + return left.width == right.width && left.height == right.height; + } + + /** + * @brief Format one stream resolution for display in the settings menu. + * + * @param videoMode Resolution to stringify. + * @return Human-readable stream-resolution label. + */ + std::string describe_stream_resolution(const VIDEO_MODE &videoMode) { + if (videoMode.width <= 0 || videoMode.height <= 0) { + return "Unavailable"; + } + + return std::to_string(videoMode.width) + "x" + std::to_string(videoMode.height); + } + + /** + * @brief Return the selected stream-resolution index inside the preset list. + * + * @param state Current client state containing the preferred mode. + * @return Zero-based index of the preferred mode, or zero when no exact match exists. + */ + std::size_t selected_stream_video_mode_index(const app::ClientState &state) { + if (!state.settings.preferredVideoModeSet || state.settings.availableVideoModes.empty()) { + return 0U; + } + + for (std::size_t index = 0; index < state.settings.availableVideoModes.size(); ++index) { + if (stream_resolutions_match(state.settings.availableVideoModes[index], state.settings.preferredVideoMode)) { + return index; + } + } + + return 0U; + } + + /** + * @brief Advance the preferred stream resolution to the next preset. + * + * @param state Current client state containing the configured stream-resolution presets. + */ + void cycle_stream_video_mode(app::ClientState &state) { + if (state.settings.availableVideoModes.empty()) { + state.settings.preferredVideoMode = {}; + state.settings.preferredVideoModeSet = false; + return; + } + + const std::size_t nextIndex = (selected_stream_video_mode_index(state) + 1U) % state.settings.availableVideoModes.size(); + state.settings.preferredVideoMode = state.settings.availableVideoModes[nextIndex]; + state.settings.preferredVideoModeSet = true; + } + + /** + * @brief Advance the preferred stream frame rate to the next supported option. + * + * @param state Current client state containing the preferred frame rate. + */ + void cycle_stream_framerate(app::ClientState &state) { + const auto current = std::find(STREAM_FRAMERATE_OPTIONS.begin(), STREAM_FRAMERATE_OPTIONS.end(), state.settings.streamFramerate); + if (current == STREAM_FRAMERATE_OPTIONS.end()) { + state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS.front(); + return; + } + + const std::size_t nextIndex = (static_cast(std::distance(STREAM_FRAMERATE_OPTIONS.begin(), current)) + 1U) % STREAM_FRAMERATE_OPTIONS.size(); + state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS[nextIndex]; + } + + /** + * @brief Advance the preferred stream bitrate to the next supported option. + * + * @param state Current client state containing the preferred bitrate. + */ + void cycle_stream_bitrate(app::ClientState &state) { + const auto current = std::find(STREAM_BITRATE_OPTIONS.begin(), STREAM_BITRATE_OPTIONS.end(), state.settings.streamBitrateKbps); + if (current == STREAM_BITRATE_OPTIONS.end()) { + state.settings.streamBitrateKbps = STREAM_BITRATE_OPTIONS.front(); + return; + } + + const std::size_t nextIndex = (static_cast(std::distance(STREAM_BITRATE_OPTIONS.begin(), current)) + 1U) % STREAM_BITRATE_OPTIONS.size(); + state.settings.streamBitrateKbps = STREAM_BITRATE_OPTIONS[nextIndex]; + } + std::string pairing_reset_endpoint_key(std::string_view address, uint16_t port) { return app::normalize_ipv4_address(address) + ":" + std::to_string(app::effective_host_port(port)); } @@ -428,7 +524,36 @@ namespace { }; case app::SettingsCategory::display: return { - {"display-placeholder", "Display settings are not implemented yet", "Display-specific options are planned, but there are no adjustable display settings in this build yet.", true}, + { + "cycle-stream-video-mode", + std::string("Stream Resolution: ") + describe_stream_resolution(state.settings.preferredVideoMode), + "Cycle through fixed stream-resolution presets. The selected resolution is requested from the host the next time a stream starts and does not change the Xbox output mode.", + true, + }, + { + "cycle-stream-framerate", + std::string("Stream Frame Rate: ") + std::to_string(state.settings.streamFramerate) + " FPS", + "Cycle through the preferred stream frame rate. Lower frame rates can reduce video packet pressure on slower or lossy networks.", + true, + }, + { + "cycle-stream-bitrate", + std::string("Stream Bitrate: ") + std::to_string(state.settings.streamBitrateKbps) + " kbps", + "Cycle through the preferred video bitrate. Lower bitrates reduce bandwidth use and can help when running Sunshine and xemu on the same NATed host.", + true, + }, + { + "toggle-play-audio-on-pc", + std::string("Play Audio on PC: ") + (state.settings.playAudioOnPc ? "On" : "Off"), + "Toggle whether the host PC should continue local audio playback while also streaming audio to this Xbox client.", + true, + }, + { + "toggle-show-performance-stats", + std::string("Show Performance Stats: ") + (state.settings.showPerformanceStats ? "On" : "Off"), + "Toggle the in-stream performance overlay that shows decoded frames, queued audio, and transport telemetry over the video output.", + true, + }, }; case app::SettingsCategory::input: return { @@ -1270,6 +1395,10 @@ namespace app { state.settings.logViewerPlacement = LogViewerPlacement::full; state.settings.loggingLevel = logging::LogLevel::none; state.settings.xemuConsoleLoggingLevel = logging::LogLevel::none; + state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS[1]; + state.settings.streamBitrateKbps = STREAM_BITRATE_OPTIONS[1]; + state.settings.playAudioOnPc = false; + state.settings.showPerformanceStats = false; state.settings.dirty = false; state.settings.savedFilesDirty = true; return state; @@ -1834,6 +1963,46 @@ namespace app { rebuild_menu(state, "cycle-xemu-console-log-level"); return; } + if (detailUpdate.activatedItemId == "cycle-stream-video-mode") { + cycle_stream_video_mode(state); + state.settings.dirty = true; + update->persistence.settingsChanged = true; + state.shell.statusMessage = state.settings.preferredVideoModeSet ? std::string("Stream resolution set to ") + describe_stream_resolution(state.settings.preferredVideoMode) : "No stream resolutions are currently available"; + rebuild_menu(state, "cycle-stream-video-mode"); + return; + } + if (detailUpdate.activatedItemId == "cycle-stream-framerate") { + cycle_stream_framerate(state); + state.settings.dirty = true; + update->persistence.settingsChanged = true; + state.shell.statusMessage = std::string("Stream frame rate set to ") + std::to_string(state.settings.streamFramerate) + " FPS"; + rebuild_menu(state, "cycle-stream-framerate"); + return; + } + if (detailUpdate.activatedItemId == "cycle-stream-bitrate") { + cycle_stream_bitrate(state); + state.settings.dirty = true; + update->persistence.settingsChanged = true; + state.shell.statusMessage = std::string("Stream bitrate set to ") + std::to_string(state.settings.streamBitrateKbps) + " kbps"; + rebuild_menu(state, "cycle-stream-bitrate"); + return; + } + if (detailUpdate.activatedItemId == "toggle-play-audio-on-pc") { + state.settings.playAudioOnPc = !state.settings.playAudioOnPc; + state.settings.dirty = true; + update->persistence.settingsChanged = true; + state.shell.statusMessage = std::string("Play audio on PC ") + (state.settings.playAudioOnPc ? "enabled" : "disabled"); + rebuild_menu(state, "toggle-play-audio-on-pc"); + return; + } + if (detailUpdate.activatedItemId == "toggle-show-performance-stats") { + state.settings.showPerformanceStats = !state.settings.showPerformanceStats; + state.settings.dirty = true; + update->persistence.settingsChanged = true; + state.shell.statusMessage = std::string("Performance stats overlay ") + (state.settings.showPerformanceStats ? "enabled" : "disabled"); + rebuild_menu(state, "toggle-show-performance-stats"); + return; + } if (detailUpdate.activatedItemId == "factory-reset") { open_confirmation( state, @@ -2082,8 +2251,9 @@ namespace app { case input::UiCommand::activate: case input::UiCommand::confirm: if (const HostAppRecord *appRecord = selected_app(state); appRecord != nullptr) { - state.shell.statusMessage = "Launching " + appRecord->name + " is not implemented yet"; + state.shell.statusMessage = "Starting stream for " + appRecord->name + "..."; update->navigation.activatedItemId = "launch-app"; + update->requests.streamLaunchRequested = true; } return true; case input::UiCommand::back: diff --git a/src/app/client_state.h b/src/app/client_state.h index d33c340..9c54469 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -9,6 +9,9 @@ #include #include +// nxdk includes +#include + // standard includes #include "src/app/host_records.h" #include "src/app/pairing_flow.h" @@ -313,6 +316,13 @@ namespace app { LogViewerPlacement logViewerPlacement = LogViewerPlacement::full; ///< Log viewer pane placement relative to the shell. logging::LogLevel loggingLevel = logging::LogLevel::none; ///< Minimum runtime log level written to the persisted log file. logging::LogLevel xemuConsoleLoggingLevel = logging::LogLevel::none; ///< Minimum runtime log level mirrored through DbgPrint() to xemu's serial console. + std::vector availableVideoModes; ///< Fixed stream-resolution presets exposed by the settings UI. + VIDEO_MODE preferredVideoMode {}; ///< Preferred stream resolution requested from the host. + bool preferredVideoModeSet = false; ///< True when preferredVideoMode contains a user-selected or default mode. + int streamFramerate = 20; ///< Preferred stream frame rate in frames per second. + int streamBitrateKbps = 1500; ///< Preferred stream bitrate in kilobits per second. + bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. + bool showPerformanceStats = false; ///< True when the streaming overlay should remain visible over decoded video. bool dirty = false; ///< True when persisted TOML-backed settings changed and should be saved. std::vector savedFiles; ///< Saved-file catalog shown on the reset settings page. bool savedFilesDirty = true; ///< True when the saved-file catalog should be refreshed. @@ -361,6 +371,7 @@ namespace app { std::string pairingPin; ///< Generated client PIN that should be shown to the user. bool appsBrowseRequested = false; ///< True when app browsing for the selected host should begin. bool appsBrowseShowHidden = false; ///< Hidden-app visibility requested for the app browse action. + bool streamLaunchRequested = false; ///< True when the selected host app should start or resume streaming. bool logViewRequested = false; ///< True when the log viewer should be refreshed from disk. }; diff --git a/src/app/settings_storage.cpp b/src/app/settings_storage.cpp index eef1e44..a0bfd95 100644 --- a/src/app/settings_storage.cpp +++ b/src/app/settings_storage.cpp @@ -263,6 +263,66 @@ namespace { append_invalid_value_warning(warnings, filePath, "ui.log_viewer_placement", ""); } + /** + * @brief Load one integer settings value when present. + * + * @param settingNode TOML node to parse. + * @param filePath Settings file path used in warnings. + * @param keyPath Fully qualified settings key used in warnings. + * @param value Receives the parsed integer on success. + * @param warnings Warning collection updated for invalid values. + */ + void load_integer_setting( + toml::node_view settingNode, + const std::string &filePath, + std::string_view keyPath, + int *value, + std::vector *warnings + ) { + if (!settingNode) { + return; + } + + if (const auto parsedValue = settingNode.value(); parsedValue) { + if (value != nullptr) { + *value = static_cast(*parsedValue); + } + return; + } + + append_invalid_value_warning(warnings, filePath, keyPath, ""); + } + + /** + * @brief Load one boolean settings value when present. + * + * @param settingNode TOML node to parse. + * @param filePath Settings file path used in warnings. + * @param keyPath Fully qualified settings key used in warnings. + * @param value Receives the parsed boolean on success. + * @param warnings Warning collection updated for invalid values. + */ + void load_boolean_setting( + toml::node_view settingNode, + const std::string &filePath, + std::string_view keyPath, + bool *value, + std::vector *warnings + ) { + if (!settingNode) { + return; + } + + if (const auto parsedValue = settingNode.value(); parsedValue) { + if (value != nullptr) { + *value = *parsedValue; + } + return; + } + + append_invalid_value_warning(warnings, filePath, keyPath, ""); + } + std::string format_settings_toml(const app::AppSettings &settings) { std::string content; content += "# Moonlight Xbox OG user settings\n"; @@ -275,7 +335,19 @@ namespace { content += std::string("xemu_console_minimum_level = \"") + logging_level_text(settings.xemuConsoleLoggingLevel) + "\"\n\n"; content += "[ui]\n"; content += "# Preferred placement for the in-app log viewer.\n"; - content += std::string("log_viewer_placement = \"") + log_viewer_placement_text(settings.logViewerPlacement) + "\"\n"; + content += std::string("log_viewer_placement = \"") + log_viewer_placement_text(settings.logViewerPlacement) + "\"\n\n"; + content += "[streaming]\n"; + content += "# Preferred stream resolution requested from the host.\n"; + content += std::string("video_width = ") + std::to_string(settings.preferredVideoMode.width) + "\n"; + content += std::string("video_height = ") + std::to_string(settings.preferredVideoMode.height) + "\n"; + content += std::string("video_bpp = ") + std::to_string(settings.preferredVideoMode.bpp) + "\n"; + content += std::string("video_refresh = ") + std::to_string(settings.preferredVideoMode.refresh) + "\n"; + content += std::string("video_mode_selected = ") + (settings.preferredVideoModeSet ? "true" : "false") + "\n"; + content += "# Preferred streaming parameters.\n"; + content += std::string("fps = ") + std::to_string(settings.streamFramerate) + "\n"; + content += std::string("bitrate_kbps = ") + std::to_string(settings.streamBitrateKbps) + "\n"; + content += std::string("play_audio_on_pc = ") + (settings.playAudioOnPc ? "true" : "false") + "\n"; + content += std::string("show_performance_stats = ") + (settings.showPerformanceStats ? "true" : "false") + "\n"; return content; } @@ -308,6 +380,25 @@ namespace { } } + /** + * @brief Mark unknown streaming keys for cleanup on the next settings save. + * + * @param streamingTable Parsed streaming settings table. + * @param filePath Settings file path used in warnings. + * @param result Load result updated with cleanup warnings. + */ + void inspect_streaming_keys(const toml::table &streamingTable, const std::string &filePath, app::LoadAppSettingsResult *result) { + for (const auto &[rawKey, node] : streamingTable) { + const std::string key(rawKey.str()); + if (key == "video_width" || key == "video_height" || key == "video_bpp" || key == "video_refresh" || key == "video_mode_selected" || key == "fps" || key == "bitrate_kbps" || key == "play_audio_on_pc" || key == "show_performance_stats") { + continue; + } + + (void) node; + mark_cleanup_required(result, filePath, std::string("streaming.") + key, "obsolete"); + } + } + void inspect_top_level_keys(const toml::table &settingsTable, const std::string &filePath, app::LoadAppSettingsResult *result) { for (const auto &[rawKey, node] : settingsTable) { const std::string key(rawKey.str()); @@ -323,6 +414,12 @@ namespace { } continue; } + if (key == "streaming") { + if (const auto *streamingTable = node.as_table(); streamingTable != nullptr) { + inspect_streaming_keys(*streamingTable, filePath, result); + } + continue; + } if (key == "debug") { mark_cleanup_required(result, filePath, "debug", "obsolete"); continue; @@ -384,6 +481,15 @@ namespace app { &result.warnings ); load_log_viewer_placement_setting(settingsTable["ui"]["log_viewer_placement"], filePath, &result.settings.logViewerPlacement, &result.warnings); + load_integer_setting(settingsTable["streaming"]["video_width"], filePath, "streaming.video_width", &result.settings.preferredVideoMode.width, &result.warnings); + load_integer_setting(settingsTable["streaming"]["video_height"], filePath, "streaming.video_height", &result.settings.preferredVideoMode.height, &result.warnings); + load_integer_setting(settingsTable["streaming"]["video_bpp"], filePath, "streaming.video_bpp", &result.settings.preferredVideoMode.bpp, &result.warnings); + load_integer_setting(settingsTable["streaming"]["video_refresh"], filePath, "streaming.video_refresh", &result.settings.preferredVideoMode.refresh, &result.warnings); + load_boolean_setting(settingsTable["streaming"]["video_mode_selected"], filePath, "streaming.video_mode_selected", &result.settings.preferredVideoModeSet, &result.warnings); + load_integer_setting(settingsTable["streaming"]["fps"], filePath, "streaming.fps", &result.settings.streamFramerate, &result.warnings); + load_integer_setting(settingsTable["streaming"]["bitrate_kbps"], filePath, "streaming.bitrate_kbps", &result.settings.streamBitrateKbps, &result.warnings); + load_boolean_setting(settingsTable["streaming"]["play_audio_on_pc"], filePath, "streaming.play_audio_on_pc", &result.settings.playAudioOnPc, &result.warnings); + load_boolean_setting(settingsTable["streaming"]["show_performance_stats"], filePath, "streaming.show_performance_stats", &result.settings.showPerformanceStats, &result.warnings); return result; } diff --git a/src/app/settings_storage.h b/src/app/settings_storage.h index 21d8fe2..53e89d0 100644 --- a/src/app/settings_storage.h +++ b/src/app/settings_storage.h @@ -20,6 +20,12 @@ namespace app { logging::LogLevel loggingLevel = logging::LogLevel::none; ///< Minimum runtime log level written to the log file. logging::LogLevel xemuConsoleLoggingLevel = logging::LogLevel::none; ///< Minimum runtime log level written through DbgPrint() for xemu's serial console. app::LogViewerPlacement logViewerPlacement = app::LogViewerPlacement::full; ///< Preferred placement for the in-app log viewer. + VIDEO_MODE preferredVideoMode {}; ///< Preferred stream resolution requested from the host. + bool preferredVideoModeSet = false; ///< True when preferredVideoMode contains a saved user preference. + int streamFramerate = 20; ///< Preferred stream frame rate in frames per second. + int streamBitrateKbps = 1500; ///< Preferred stream bitrate in kilobits per second. + bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. + bool showPerformanceStats = false; ///< True when the streaming overlay should remain visible over decoded video. }; /** diff --git a/src/main.cpp b/src/main.cpp index 0a55582..5b8b9e6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,6 +8,7 @@ #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names // standard includes +#include #include #include #include @@ -39,9 +40,53 @@ namespace { state.settings.loggingLevel = settings.loggingLevel; state.settings.xemuConsoleLoggingLevel = settings.xemuConsoleLoggingLevel; state.settings.logViewerPlacement = settings.logViewerPlacement; + state.settings.preferredVideoMode = settings.preferredVideoMode; + state.settings.preferredVideoModeSet = settings.preferredVideoModeSet; + state.settings.streamFramerate = settings.streamFramerate; + state.settings.streamBitrateKbps = settings.streamBitrateKbps; + state.settings.playAudioOnPc = settings.playAudioOnPc; + state.settings.showPerformanceStats = settings.showPerformanceStats; state.settings.dirty = false; } + /** + * @brief Return whether two stream-resolution entries target the same width and height. + * + * @param left First stream-resolution entry to compare. + * @param right Second stream-resolution entry to compare. + * @return True when both entries describe the same stream resolution. + */ + bool stream_resolutions_match(const VIDEO_MODE &left, const VIDEO_MODE &right) { + return left.width == right.width && left.height == right.height; + } + + /** + * @brief Finalize the preferred stream resolution after startup detection. + * + * @param state Mutable client state receiving the fixed stream-resolution presets. + * @param selection Detected Xbox output modes and preferred startup mode. + */ + void initialize_stream_video_mode_settings(app::ClientState &state, const startup::VideoModeSelection &selection) { + state.settings.availableVideoModes = startup::stream_resolution_presets( + selection.bestVideoMode.bpp > 0 ? selection.bestVideoMode.bpp : 32, + selection.bestVideoMode.refresh > 0 ? selection.bestVideoMode.refresh : 60 + ); + const auto preferredMode = std::find_if( + state.settings.availableVideoModes.begin(), + state.settings.availableVideoModes.end(), + [&state](const VIDEO_MODE &candidate) { + return stream_resolutions_match(candidate, state.settings.preferredVideoMode); + } + ); + if (state.settings.preferredVideoModeSet && preferredMode != state.settings.availableVideoModes.end()) { + state.settings.preferredVideoMode = *preferredMode; + return; + } + + state.settings.preferredVideoMode = startup::choose_default_stream_video_mode(selection.bestVideoMode); + state.settings.preferredVideoModeSet = state.settings.preferredVideoMode.width > 0 && state.settings.preferredVideoMode.height > 0; + } + void load_persisted_settings(app::ClientState &state) { const app::LoadAppSettingsResult loadResult = app::load_app_settings(); apply_persisted_settings(state, loadResult.settings); @@ -188,6 +233,7 @@ int main() { debug_print_encoder_settings(XVideoGetEncoderSettings()); const startup::VideoModeSelection videoModeSelection = startup::select_best_video_mode(); + initialize_stream_video_mode_settings(clientState, videoModeSelection); const VIDEO_MODE &bestVideoMode = videoModeSelection.bestVideoMode; debug_print_video_mode_selection(videoModeSelection); startup::log_memory_statistics(); @@ -200,7 +246,7 @@ int main() { debug_print_startup_checkpoint(setVideoModeResult ? "Returned from XVideoSetMode successfully" : "XVideoSetMode returned failure"); debug_print_startup_checkpoint("About to call SDL_Init"); - if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) { + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMECONTROLLER) != 0) { return report_startup_failure("sdl", std::string("SDL_Init failed: ") + SDL_GetError()); } debug_print_startup_checkpoint("SDL_Init succeeded"); diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index 7e06a2d..029484c 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -35,6 +35,9 @@ #include "src/network/runtime_network.h" #include "src/platform/error_utils.h" +// third-party includes +#include "third-party/moonlight-common-c/src/Limelight.h" + // platform includes #ifdef NXDK #include @@ -115,9 +118,11 @@ namespace { constexpr int SOCKET_TIMEOUT_MILLISECONDS = 5000; constexpr uint16_t DEFAULT_SERVERINFO_HTTP_PORT = 47989; constexpr uint16_t FALLBACK_SERVERINFO_HTTP_PORT = 47984; + constexpr uint16_t DEFAULT_SERVERINFO_HTTPS_PORT = 47990; constexpr std::string_view DEFAULT_SERVERINFO_UNIQUE_ID = "0123456789ABCDEF"; constexpr std::string_view DEFAULT_SERVERINFO_UUID = "11111111-2222-3333-4444-555555555555"; constexpr std::string_view UNPAIRED_CLIENT_ERROR_MESSAGE = "The host reports that this client is no longer paired. Pair the host again."; + constexpr int DEFAULT_SERVER_CODEC_MODE_SUPPORT = SCM_H264; network::testing::HostPairingHttpTestHandler &host_pairing_http_test_handler() { static network::testing::HostPairingHttpTestHandler handler; ///< Optional scripted transport used by host-native unit tests. @@ -432,6 +437,70 @@ namespace { }; } + int surround_audio_info_from_audio_configuration(int audioConfiguration) { + const int channelCount = (audioConfiguration >> 8) & 0xFF; + const int channelMask = (audioConfiguration >> 16) & 0xFFFF; + return (channelMask << 16) | channelCount; + } + + bool is_hexadecimal_text(std::string_view text) { + if (text.empty()) { + return false; + } + + for (char character : text) { + unsigned char ignored = 0; + if (!hex_value(character, &ignored)) { + return false; + } + } + return true; + } + + const char *launch_url_query_parameters() { + return "&corever=1"; + } + + std::string build_stream_resume_path( + std::string_view uniqueId, + const network::StreamLaunchConfiguration &configuration + ) { + const std::string queryPrefix = "/resume?uniqueid=" + std::string(uniqueId); + std::string path = queryPrefix + + "&rikey=" + configuration.remoteInputAesKeyHex + + "&rikeyid=" + configuration.remoteInputAesIvHex + + "&localAudioPlayMode=" + std::to_string(configuration.playAudioOnPc ? 1 : 0) + + "&surroundAudioInfo=" + std::to_string(surround_audio_info_from_audio_configuration(configuration.audioConfiguration)); + if (configuration.clientRefreshRateX100 > 0) { + path += "&clientRefreshRateX100=" + std::to_string(configuration.clientRefreshRateX100); + } + path += launch_url_query_parameters(); + return path; + } + + std::string build_stream_launch_path( + std::string_view uniqueId, + const network::StreamLaunchConfiguration &configuration + ) { + std::string path = "/launch?uniqueid=" + std::string(uniqueId) + + "&appid=" + std::to_string(configuration.appId) + + "&mode=" + std::to_string(configuration.width) + "x" + std::to_string(configuration.height) + "x" + std::to_string(configuration.fps) + + "&additionalStates=1" + + "&sops=1" + + "&rikey=" + configuration.remoteInputAesKeyHex + + "&rikeyid=" + configuration.remoteInputAesIvHex + + "&localAudioPlayMode=" + std::to_string(configuration.playAudioOnPc ? 1 : 0) + + "&surroundAudioInfo=" + std::to_string(surround_audio_info_from_audio_configuration(configuration.audioConfiguration)) + + "&remoteControllersBitmap=1" + + "&gcmap=0" + + "&hdrMode=0"; + if (configuration.clientRefreshRateX100 > 0) { + path += "&clientRefreshRateX100=" + std::to_string(configuration.clientRefreshRateX100); + } + path += launch_url_query_parameters(); + return path; + } + std::string_view trim_ascii_whitespace(std::string_view text) { while (!text.empty() && (text.front() == ' ' || text.front() == '\t' || text.front() == '\r' || text.front() == '\n')) { text.remove_prefix(1); @@ -1291,6 +1360,69 @@ namespace { return true; } + bool parse_stream_launch_response( + const HttpResponse &response, + const network::HostPairingServerInfo &serverInfo, + bool resumedSession, + network::StreamLaunchResult *result, + std::string *errorMessage + ) { + if (response.statusCode == 401 || response.statusCode == 403) { + return append_error(errorMessage, std::string(UNPAIRED_CLIENT_ERROR_MESSAGE)); + } + if (response.statusCode != 200) { + return append_error(errorMessage, "The host returned HTTP " + std::to_string(response.statusCode) + " while starting the stream session"); + } + + uint32_t rootStatusCode = 200; + std::string rootStatusMessage; + if (extract_root_status(response.body, &rootStatusCode, &rootStatusMessage) && rootStatusCode != 200U) { + if (network::error_indicates_unpaired_client(rootStatusMessage)) { + return append_error(errorMessage, std::string(UNPAIRED_CLIENT_ERROR_MESSAGE)); + } + return append_error(errorMessage, rootStatusMessage.empty() ? "The host rejected the stream launch request" : rootStatusMessage); + } + + std::string appVersion = serverInfo.appVersion; + std::string gfeVersion = serverInfo.gfeVersion; + std::string rtspSessionUrl; + std::string serverCodecModeSupportText; + extract_xml_tag_value(response.body, "appversion", &appVersion); + extract_xml_tag_value(response.body, "GfeVersion", &gfeVersion) || extract_xml_tag_value(response.body, "gfeversion", &gfeVersion); + extract_xml_tag_value(response.body, "sessionUrl0", &rtspSessionUrl); + extract_xml_tag_value(response.body, "ServerCodecModeSupport", &serverCodecModeSupportText); + + int serverCodecModeSupport = serverInfo.serverCodecModeSupport == 0 ? DEFAULT_SERVER_CODEC_MODE_SUPPORT : serverInfo.serverCodecModeSupport; + uint32_t parsedServerCodecModeSupport = 0; + if (!serverCodecModeSupportText.empty() && try_parse_uint32(trim_ascii_whitespace(serverCodecModeSupportText), &parsedServerCodecModeSupport)) { + serverCodecModeSupport = parsedServerCodecModeSupport == 0U ? DEFAULT_SERVER_CODEC_MODE_SUPPORT : static_cast(parsedServerCodecModeSupport); + } + + if (appVersion.empty()) { + return append_error(errorMessage, "The host launch response did not include a usable appversion"); + } + + if (result != nullptr) { + result->resumedSession = resumedSession; + result->serverInfo = serverInfo; + result->rtspSessionUrl = std::move(rtspSessionUrl); + result->appVersion = std::move(appVersion); + result->gfeVersion = std::move(gfeVersion); + result->serverCodecModeSupport = serverCodecModeSupport; + } + return true; + } + + int parse_server_major_version(std::string_view appVersion) { + const std::size_t separatorIndex = appVersion.find('.'); + const std::string_view majorVersionText = separatorIndex == std::string_view::npos ? appVersion : appVersion.substr(0, separatorIndex); + uint32_t majorVersion = 0; + if (!try_parse_uint32(majorVersionText, &majorVersion)) { + return 0; + } + return static_cast(majorVersion); + } + bool prepare_pairing_socket_address(const std::string &address, uint16_t port, sockaddr_in *socketAddress, std::string *errorMessage) { if (socketAddress == nullptr) { return append_error(errorMessage, "Internal pairing error while preparing the host connection"); @@ -2292,8 +2424,10 @@ namespace network { bool parse_server_info_response(std::string_view xml, uint16_t fallbackHttpPort, HostPairingServerInfo *serverInfo, std::string *errorMessage) { std::string appVersion; + std::string gfeVersion; std::string httpPortText; std::string httpsPortText; + std::string serverCodecModeSupportText; std::string pairStatus; if (!extract_xml_tag_value(xml, "appversion", &appVersion) || !extract_xml_tag_value(xml, "PairStatus", &pairStatus)) { return append_error(errorMessage, "The host serverinfo response was missing required pairing fields"); @@ -2308,6 +2442,20 @@ namespace network { if (extract_xml_tag_value(xml, "HttpsPort", &httpsPortText)) { try_parse_port(httpsPortText, &httpsPort); } + if (httpsPort == 0) { + httpsPort = DEFAULT_SERVERINFO_HTTPS_PORT; + } + + extract_xml_tag_value(xml, "GfeVersion", &gfeVersion) || extract_xml_tag_value(xml, "gfeversion", &gfeVersion); + extract_xml_tag_value(xml, "ServerCodecModeSupport", &serverCodecModeSupportText); + + uint32_t serverCodecModeSupport = DEFAULT_SERVER_CODEC_MODE_SUPPORT; + if (!serverCodecModeSupportText.empty()) { + try_parse_uint32(trim_ascii_whitespace(serverCodecModeSupportText), &serverCodecModeSupport); + if (serverCodecModeSupport == 0U) { + serverCodecModeSupport = DEFAULT_SERVER_CODEC_MODE_SUPPORT; + } + } std::string hostName; extract_xml_tag_value(xml, "hostname", &hostName) || extract_xml_tag_value(xml, "HostName", &hostName); @@ -2341,10 +2489,13 @@ namespace network { } if (serverInfo != nullptr) { - serverInfo->serverMajorVersion = std::atoi(appVersion.c_str()); + serverInfo->serverMajorVersion = parse_server_major_version(appVersion); + serverInfo->appVersion = appVersion; + serverInfo->gfeVersion = gfeVersion; serverInfo->httpPort = httpPort == 0 ? fallbackHttpPort : httpPort; serverInfo->httpsPort = httpsPort == 0 ? fallbackHttpPort : httpsPort; serverInfo->paired = pairStatus == "1"; + serverInfo->serverCodecModeSupport = static_cast(serverCodecModeSupport); serverInfo->hostName = std::move(hostName); serverInfo->uuid = std::move(uuid); serverInfo->activeAddress = std::move(activeAddress); @@ -2531,6 +2682,53 @@ namespace network { return true; } + bool launch_or_resume_stream( + const std::string &address, + uint16_t preferredHttpPort, + const PairingIdentity &clientIdentity, + const StreamLaunchConfiguration &configuration, + StreamLaunchResult *result, + std::string *errorMessage + ) { + if (address.empty()) { + return append_error(errorMessage, "The stream launch request requires a valid host address"); + } + if (!is_valid_pairing_identity(clientIdentity)) { + return append_error(errorMessage, std::string(UNPAIRED_CLIENT_ERROR_MESSAGE)); + } + if (configuration.appId <= 0) { + return append_error(errorMessage, "The stream launch request requires a valid host app ID"); + } + if (configuration.width <= 0 || configuration.height <= 0 || configuration.fps <= 0) { + return append_error(errorMessage, "The stream launch request requires a valid mode and frame rate"); + } + if (configuration.remoteInputAesKeyHex.size() != 32U || configuration.remoteInputAesIvHex.size() != 32U || !is_hexadecimal_text(configuration.remoteInputAesKeyHex) || !is_hexadecimal_text(configuration.remoteInputAesIvHex)) { + return append_error(errorMessage, "The stream launch request requires 16-byte hex-encoded remote-input keys"); + } + + HostPairingServerInfo serverInfo {}; + if (!query_server_info(address, preferredHttpPort, &clientIdentity, &serverInfo, errorMessage)) { + return false; + } + if (!serverInfo.paired) { + return append_error(errorMessage, std::string(UNPAIRED_CLIENT_ERROR_MESSAGE)); + } + if (serverInfo.httpsPort == 0U) { + return append_error(errorMessage, "The host did not report an HTTPS port for authenticated streaming"); + } + + const bool resumeExistingSession = serverInfo.runningGameId != 0U && serverInfo.runningGameId == static_cast(configuration.appId); + const std::string pathAndQuery = resumeExistingSession ? build_stream_resume_path(resolve_client_unique_id(&clientIdentity), configuration) : build_stream_launch_path(resolve_client_unique_id(&clientIdentity), configuration); + const std::string requestAddress = resolve_reachable_address(address, serverInfo); + serverInfo.activeAddress = requestAddress; + HttpResponse response {}; + if (!http_get(requestAddress, serverInfo.httpsPort, pathAndQuery, true, &clientIdentity, {}, &response, errorMessage)) { + return false; + } + + return parse_stream_launch_response(response, serverInfo, resumeExistingSession, result, errorMessage); + } + uint64_t hash_app_list_entries(const std::vector &apps) { uint64_t hash = 1469598103934665603ULL; for (const HostAppEntry &entry : apps) { @@ -2558,7 +2756,10 @@ namespace network { for (const std::string &path : build_app_asset_paths(resolve_client_unique_id(clientIdentity), appId)) { HttpResponse response {}; if (std::string attemptError; !http_get(address, httpsPort, path, true, clientIdentity, {}, &response, &attemptError)) { - attemptFailures.push_back(path + ": " + attemptError); + std::string failure = path; + failure += ": "; + failure += attemptError; + attemptFailures.push_back(std::move(failure)); continue; } diff --git a/src/network/host_pairing.h b/src/network/host_pairing.h index 6f167d6..2bb6013 100644 --- a/src/network/host_pairing.h +++ b/src/network/host_pairing.h @@ -28,11 +28,14 @@ namespace network { */ struct HostPairingServerInfo { int serverMajorVersion = 0; ///< Major version reported by the host server. + std::string appVersion; ///< Full appversion string reported by the host server. + std::string gfeVersion; ///< Full GFE or Sunshine version string reported by the host server. uint16_t httpPort = 0; ///< HTTP port reported or inferred for plaintext requests. uint16_t httpsPort = 0; ///< HTTPS port reported for encrypted requests and assets. bool paired = false; ///< True when the host reports that this client is paired. bool pairingStatusCurrentClientKnown = false; ///< True when the host explicitly reported pairing status for this client. bool pairingStatusCurrentClient = false; ///< Pairing status reported for this specific client identity. + int serverCodecModeSupport = 0; ///< Codec capability mask reported by the host. std::string hostName; ///< User-facing host name reported by the server. std::string uuid; ///< Stable host UUID. std::string activeAddress; ///< Best currently reachable address chosen for live requests. @@ -73,6 +76,33 @@ namespace network { std::string message; ///< User-visible success or failure detail. }; + /** + * @brief Parameters required to start or resume a streaming session. + */ + struct StreamLaunchConfiguration { + int appId = 0; ///< Host application identifier that should be launched or resumed. + int width = 640; ///< Requested stream width in pixels. + int height = 480; ///< Requested stream height in pixels. + int fps = 30; ///< Requested stream frame rate. + int audioConfiguration = 0; ///< Moonlight audio configuration bitfield. + bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. + int clientRefreshRateX100 = 0; ///< Optional client refresh rate multiplied by 100. + std::string remoteInputAesKeyHex; ///< Hex-encoded 16-byte remote-input AES key. + std::string remoteInputAesIvHex; ///< Hex-encoded 16-byte remote-input AES IV. + }; + + /** + * @brief Parsed result of a successful `/launch` or `/resume` request. + */ + struct StreamLaunchResult { + bool resumedSession = false; ///< True when an existing host session was resumed instead of launching a new app. + HostPairingServerInfo serverInfo; ///< Latest host server-info metadata used for the launch decision. + std::string rtspSessionUrl; ///< Optional RTSP session URL returned by the host. + std::string appVersion; ///< Full appversion string to pass to moonlight-common-c. + std::string gfeVersion; ///< Full GFE or Sunshine version string reported during launch. + int serverCodecModeSupport = 0; ///< Codec capability mask to pass to moonlight-common-c. + }; + /** * @brief Return whether a pairing identity contains the required PEM materials. * @@ -202,6 +232,30 @@ namespace network { std::string *errorMessage = nullptr ); + /** + * @brief Launch or resume one host application and return stream session details. + * + * The helper first refreshes `/serverinfo` using the supplied paired client + * identity, then resumes the running session when the requested app is + * already active or launches a new session otherwise. + * + * @param address Host address to query. + * @param preferredHttpPort Preferred HTTP port override. + * @param clientIdentity Paired client identity used for authenticated launch requests. + * @param configuration Stream launch parameters including app ID and remote-input keys. + * @param result Output populated with launch metadata required by moonlight-common-c. + * @param errorMessage Optional output for request or parse failures. + * @return true when the host accepted the launch or resume request. + */ + bool launch_or_resume_stream( + const std::string &address, + uint16_t preferredHttpPort, + const PairingIdentity &clientIdentity, + const StreamLaunchConfiguration &configuration, + StreamLaunchResult *result, + std::string *errorMessage = nullptr + ); + /** * @brief Pair the client with a host using the provided request parameters. * diff --git a/src/startup/video_mode.cpp b/src/startup/video_mode.cpp index a5c664b..5697cf3 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -8,14 +8,38 @@ // local includes #include "src/logging/logger.h" +// standard includes +#include + namespace startup { namespace { + struct StreamResolutionPreset { + int width; + int height; + }; + + constexpr std::array STREAM_RESOLUTION_PRESETS {{ + {352, 240}, + {352, 288}, + {480, 480}, + {480, 576}, + {720, 480}, + {720, 576}, + {960, 540}, + {1280, 720}, + {1920, 1080}, + }}; + bool is_1080i_mode(const VIDEO_MODE &videoMode) { return videoMode.width >= 1920 && videoMode.height >= 1080; } + VIDEO_MODE make_stream_video_mode(const StreamResolutionPreset &preset, int bpp, int refresh) { + return {preset.width, preset.height, bpp, refresh}; + } + } // namespace bool is_preferred_video_mode(const VIDEO_MODE &candidateVideoMode, const VIDEO_MODE ¤tBestVideoMode) { @@ -58,6 +82,31 @@ namespace startup { return bestVideoMode; } + std::vector stream_resolution_presets(int bpp, int refresh) { + std::vector presets; + presets.reserve(STREAM_RESOLUTION_PRESETS.size()); + for (const StreamResolutionPreset &preset : STREAM_RESOLUTION_PRESETS) { + presets.push_back(make_stream_video_mode(preset, bpp, refresh)); + } + return presets; + } + + VIDEO_MODE choose_default_stream_video_mode(const VIDEO_MODE &outputVideoMode) { + const int bpp = outputVideoMode.bpp > 0 ? outputVideoMode.bpp : 32; + const int refresh = outputVideoMode.refresh > 0 ? outputVideoMode.refresh : 60; + + if (outputVideoMode.width >= 1920 && outputVideoMode.height >= 1080) { + return make_stream_video_mode({1920, 1080}, bpp, refresh); + } + if (outputVideoMode.width >= 1280 && outputVideoMode.height >= 720) { + return make_stream_video_mode({1280, 720}, bpp, refresh); + } + if (refresh <= 50 || outputVideoMode.height >= 576) { + return make_stream_video_mode({720, 576}, bpp, refresh); + } + return make_stream_video_mode({720, 480}, bpp, refresh); + } + VideoModeSelection select_best_video_mode(int bpp, int refresh) { VideoModeSelection selection {}; diff --git a/src/startup/video_mode.h b/src/startup/video_mode.h index 0716f7b..f15f2fd 100644 --- a/src/startup/video_mode.h +++ b/src/startup/video_mode.h @@ -41,6 +41,30 @@ namespace startup { */ VIDEO_MODE choose_best_video_mode(const std::vector &availableVideoModes); + /** + * @brief Return the fixed stream-resolution presets exposed in the settings UI. + * + * These presets are independent from the Xbox output modes returned by + * `XVideoListModes()`. They define only the host stream resolution that + * Moonlight requests when starting a session. + * + * @param bpp Bits-per-pixel metadata to attach to each preset. + * @param refresh Refresh-rate metadata to attach to each preset. + * @return Ordered list of stream-resolution presets. + */ + std::vector stream_resolution_presets(int bpp = 32, int refresh = 60); + + /** + * @brief Choose the default stream-resolution preset for the current output mode. + * + * The shell output mode still comes from Xbox video-mode detection, but stream + * quality is controlled separately through the settings presets. + * + * @param outputVideoMode Active Xbox output mode selected at startup. + * @return Default stream-resolution preset for new or missing settings. + */ + VIDEO_MODE choose_default_stream_video_mode(const VIDEO_MODE &outputVideoMode); + /** * @brief Detect and choose the best available video mode. * diff --git a/src/streaming/ffmpeg_stream_backend.cpp b/src/streaming/ffmpeg_stream_backend.cpp new file mode 100644 index 0000000..3938ae5 --- /dev/null +++ b/src/streaming/ffmpeg_stream_backend.cpp @@ -0,0 +1,869 @@ +/** + * @file src/streaming/ffmpeg_stream_backend.cpp + * @brief Implements the FFmpeg-backed streaming decode backend for Xbox sessions. + */ +#include "src/streaming/ffmpeg_stream_backend.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include + +// lib includes +extern "C" { +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +} + +// local includes +#include "src/logging/logger.h" + +namespace { + + constexpr Uint32 STREAM_OVERLAY_AUDIO_QUEUE_LIMIT_MS = 250U; + constexpr std::uint64_t STREAM_VIDEO_SUBMISSION_LOG_INTERVAL = 120; + + streaming::FfmpegStreamBackend *g_active_video_backend = nullptr; + streaming::FfmpegStreamBackend *g_active_audio_backend = nullptr; + std::once_flag g_ffmpeg_logging_once; + + /** + * @brief Convert an FFmpeg error code into readable text. + * + * @param errorCode Negative FFmpeg return code. + * @return User-readable FFmpeg error text. + */ + std::string describe_ffmpeg_error(int errorCode) { + std::array buffer {}; + av_strerror(errorCode, buffer.data(), buffer.size()); + return std::string(buffer.data()); + } + + /** + * @brief Remove trailing CR and LF characters from an FFmpeg log line. + * + * @param message Candidate log line. + * @return Trimmed log line. + */ + std::string trim_ffmpeg_log_line(std::string message) { + while (!message.empty() && (message.back() == '\n' || message.back() == '\r')) { + message.pop_back(); + } + return message; + } + + /** + * @brief Map one FFmpeg log level to the project logger severity. + * + * @param ffmpegLevel FFmpeg log level constant. + * @return Corresponding project log level. + */ + logging::LogLevel map_ffmpeg_log_level(int ffmpegLevel) { + if (ffmpegLevel <= AV_LOG_ERROR) { + return logging::LogLevel::error; + } + if (ffmpegLevel <= AV_LOG_WARNING) { + return logging::LogLevel::warning; + } + if (ffmpegLevel <= AV_LOG_INFO) { + return logging::LogLevel::info; + } + if (ffmpegLevel <= AV_LOG_VERBOSE) { + return logging::LogLevel::debug; + } + return logging::LogLevel::trace; + } + + /** + * @brief Forward FFmpeg's internal logs into the project logger. + * + * @param avClassContext FFmpeg component instance emitting the line. + * @param level FFmpeg log level. + * @param format `printf`-style format string. + * @param arguments Variadic arguments matching `format`. + */ + void ffmpeg_log_callback(void *avClassContext, int level, const char *format, va_list arguments) { + std::array buffer {}; + int printPrefix = 1; + va_list argumentsCopy; + va_copy(argumentsCopy, arguments); + const int formattedLength = av_log_format_line2(avClassContext, level, format, argumentsCopy, buffer.data(), static_cast(buffer.size()), &printPrefix); + va_end(argumentsCopy); + if (formattedLength < 0) { + return; + } + + std::string message; + if (static_cast(formattedLength) < buffer.size()) { + message.assign(buffer.data(), static_cast(formattedLength)); + } else { + message.assign(buffer.data()); + } + message = trim_ffmpeg_log_line(std::move(message)); + if (message.empty()) { + return; + } + + logging::log(map_ffmpeg_log_level(level), "ffmpeg", std::move(message)); + } + + /** + * @brief Install the shared FFmpeg log callback once for the process. + */ + void ensure_ffmpeg_logging_installed() { + std::call_once(g_ffmpeg_logging_once, []() { + av_log_set_level(AV_LOG_VERBOSE); + av_log_set_callback(ffmpeg_log_callback); + }); + } + + /** + * @brief Return the number of bytes generated per second for the SDL audio format. + * + * @param audioSpec SDL audio format in use for playback. + * @return Number of bytes generated per second. + */ + Uint32 audio_bytes_per_second(const SDL_AudioSpec &audioSpec) { + return static_cast(audioSpec.freq) * static_cast(audioSpec.channels) * (SDL_AUDIO_BITSIZE(audioSpec.format) / 8U); + } + + /** + * @brief Compute a centered destination rectangle that preserves aspect ratio. + * + * @param screenWidth Renderer output width. + * @param screenHeight Renderer output height. + * @param frameWidth Decoded video width. + * @param frameHeight Decoded video height. + * @return Letterboxed destination rectangle. + */ + SDL_Rect build_letterboxed_destination(int screenWidth, int screenHeight, int frameWidth, int frameHeight) { + if (screenWidth <= 0 || screenHeight <= 0 || frameWidth <= 0 || frameHeight <= 0) { + return SDL_Rect {0, 0, 0, 0}; + } + + const double screenAspect = static_cast(screenWidth) / static_cast(screenHeight); + const double frameAspect = static_cast(frameWidth) / static_cast(frameHeight); + + int destinationWidth = screenWidth; + int destinationHeight = screenHeight; + if (frameAspect > screenAspect) { + destinationHeight = std::max(1, static_cast(static_cast(screenWidth) / frameAspect)); + } else { + destinationWidth = std::max(1, static_cast(static_cast(screenHeight) * frameAspect)); + } + + return SDL_Rect { + (screenWidth - destinationWidth) / 2, + (screenHeight - destinationHeight) / 2, + destinationWidth, + destinationHeight, + }; + } + + /** + * @brief Create FFmpeg Opus extradata for the stereo Moonlight stream. + * + * @param channelCount Negotiated channel count. + * @param sampleRate Negotiated sample rate. + * @param codecContext Audio codec context that will own the extradata. + * @return True when the extradata was allocated successfully. + */ + bool configure_opus_extradata(int channelCount, int sampleRate, AVCodecContext *codecContext) { + if (codecContext == nullptr || channelCount <= 0 || channelCount > 2) { + return false; + } + + std::array opusHead { + static_cast('O'), + static_cast('p'), + static_cast('u'), + static_cast('s'), + static_cast('H'), + static_cast('e'), + static_cast('a'), + static_cast('d'), + 1U, + static_cast(channelCount), + 0U, + 0U, + static_cast(sampleRate & 0xFF), + static_cast((sampleRate >> 8) & 0xFF), + static_cast((sampleRate >> 16) & 0xFF), + static_cast((sampleRate >> 24) & 0xFF), + 0U, + 0U, + 0U, + }; + + codecContext->extradata = static_cast(av_mallocz(opusHead.size() + AV_INPUT_BUFFER_PADDING_SIZE)); + if (codecContext->extradata == nullptr) { + return false; + } + + codecContext->extradata_size = static_cast(opusHead.size()); + std::memcpy(codecContext->extradata, opusHead.data(), opusHead.size()); + return true; + } + + int on_video_setup(int videoFormat, int width, int height, int redrawRate, void *context, int drFlags) { + auto *backend = static_cast(context); + if (backend == nullptr) { + return -1; + } + + g_active_video_backend = backend; + return backend->setup_video_decoder(videoFormat, width, height, redrawRate, context, drFlags); + } + + void on_video_start() { + if (g_active_video_backend != nullptr) { + g_active_video_backend->start_video_decoder(); + } + } + + void on_video_stop() { + if (g_active_video_backend != nullptr) { + g_active_video_backend->stop_video_decoder(); + } + } + + void on_video_cleanup() { + if (g_active_video_backend != nullptr) { + g_active_video_backend->cleanup_video_decoder(); + g_active_video_backend = nullptr; + } + } + + int on_video_submit_decode_unit(PDECODE_UNIT decodeUnit) { + if (g_active_video_backend == nullptr) { + return DR_NEED_IDR; + } + + return g_active_video_backend->submit_video_decode_unit(decodeUnit); + } + + int on_audio_init(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, void *context, int arFlags) { + auto *backend = static_cast(context); + if (backend == nullptr) { + return -1; + } + + g_active_audio_backend = backend; + return backend->initialize_audio_decoder(audioConfiguration, opusConfig, context, arFlags); + } + + void on_audio_start() { + if (g_active_audio_backend != nullptr) { + g_active_audio_backend->start_audio_playback(); + } + } + + void on_audio_stop() { + if (g_active_audio_backend != nullptr) { + g_active_audio_backend->stop_audio_playback(); + } + } + + void on_audio_cleanup() { + if (g_active_audio_backend != nullptr) { + g_active_audio_backend->cleanup_audio_decoder(); + g_active_audio_backend = nullptr; + } + } + + void on_audio_decode_and_play_sample(char *sampleData, int sampleLength) { + if (g_active_audio_backend != nullptr) { + g_active_audio_backend->decode_and_play_audio_sample(sampleData, sampleLength); + } + } + +} // namespace + +namespace streaming { + + FfmpegStreamBackend::~FfmpegStreamBackend() { + shutdown(); + } + + void FfmpegStreamBackend::initialize_callbacks(DECODER_RENDERER_CALLBACKS *videoCallbacks, AUDIO_RENDERER_CALLBACKS *audioCallbacks) { + ensure_ffmpeg_logging_installed(); + + if (videoCallbacks != nullptr) { + LiInitializeVideoCallbacks(videoCallbacks); + videoCallbacks->setup = on_video_setup; + videoCallbacks->start = on_video_start; + videoCallbacks->stop = on_video_stop; + videoCallbacks->cleanup = on_video_cleanup; + videoCallbacks->submitDecodeUnit = on_video_submit_decode_unit; + videoCallbacks->capabilities = 0; + } + + if (audioCallbacks != nullptr) { + LiInitializeAudioCallbacks(audioCallbacks); + audioCallbacks->init = on_audio_init; + audioCallbacks->start = on_audio_start; + audioCallbacks->stop = on_audio_stop; + audioCallbacks->cleanup = on_audio_cleanup; + audioCallbacks->decodeAndPlaySample = on_audio_decode_and_play_sample; + audioCallbacks->capabilities = 0; + } + } + + void FfmpegStreamBackend::shutdown() { + cleanup_audio_decoder(); + cleanup_video_decoder(); + } + + bool FfmpegStreamBackend::has_decoded_video() const { + return video_.hasFrame.load(); + } + + std::string FfmpegStreamBackend::build_overlay_status_line() const { + std::string audioState = audio_.deviceId != 0 ? std::to_string(SDL_GetQueuedAudioSize(audio_.deviceId)) + " queued audio bytes" : "audio idle"; + return std::string("Video units: ") + std::to_string(video_.submittedDecodeUnitCount.load()) + " submitted / " + std::to_string(video_.decodedFrameCount.load()) + " decoded | " + audioState; + } + + bool FfmpegStreamBackend::render_latest_video_frame(SDL_Renderer *renderer, int screenWidth, int screenHeight) { + if (renderer == nullptr || !video_.hasFrame.load()) { + return false; + } + + LatestVideoFrame frameSnapshot {}; + { + std::lock_guard lock(video_.frameMutex); + if (video_.latestFrame.width <= 0 || video_.latestFrame.height <= 0) { + return false; + } + frameSnapshot = video_.latestFrame; + } + + if (video_.texture == nullptr || video_.textureWidth != frameSnapshot.width || video_.textureHeight != frameSnapshot.height) { + if (video_.texture != nullptr) { + SDL_DestroyTexture(video_.texture); + video_.texture = nullptr; + } + + video_.texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_IYUV, SDL_TEXTUREACCESS_STREAMING, frameSnapshot.width, frameSnapshot.height); + if (video_.texture == nullptr) { + logging::error("stream", std::string("SDL_CreateTexture failed for video presentation: ") + SDL_GetError()); + return false; + } + + video_.textureWidth = frameSnapshot.width; + video_.textureHeight = frameSnapshot.height; + } + + if (SDL_UpdateYUVTexture( + video_.texture, + nullptr, + reinterpret_cast(frameSnapshot.yPlane.data()), + frameSnapshot.yPitch, + reinterpret_cast(frameSnapshot.uPlane.data()), + frameSnapshot.uPitch, + reinterpret_cast(frameSnapshot.vPlane.data()), + frameSnapshot.vPitch + ) != 0) { + logging::error("stream", std::string("SDL_UpdateYUVTexture failed during video presentation: ") + SDL_GetError()); + return false; + } + + const SDL_Rect destination = build_letterboxed_destination(screenWidth, screenHeight, frameSnapshot.width, frameSnapshot.height); + return SDL_RenderCopy(renderer, video_.texture, nullptr, &destination) == 0; + } + + int FfmpegStreamBackend::setup_video_decoder(int videoFormat, int width, int height, int redrawRate, void *context, int drFlags) { + (void) width; + (void) height; + (void) redrawRate; + (void) context; + (void) drFlags; + + cleanup_video_decoder(); + + if ((videoFormat & VIDEO_FORMAT_MASK_H264) == 0) { + logging::error("stream", "The FFmpeg backend currently supports only H.264 video streams"); + return -1; + } + + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_H264); + if (codec == nullptr) { + logging::error("stream", "FFmpeg did not provide an H.264 decoder"); + return -1; + } + + video_.codecContext = avcodec_alloc_context3(codec); + video_.decodedFrame = av_frame_alloc(); + video_.convertedFrame = av_frame_alloc(); + video_.packet = av_packet_alloc(); + if (video_.codecContext == nullptr || video_.decodedFrame == nullptr || video_.convertedFrame == nullptr || video_.packet == nullptr) { + logging::error("stream", "Failed to allocate FFmpeg video decoder resources"); + cleanup_video_decoder(); + return -1; + } + + video_.codecContext->thread_count = 1; + video_.codecContext->thread_type = 0; + const int openResult = avcodec_open2(video_.codecContext, codec, nullptr); + if (openResult < 0) { + logging::error("stream", std::string("avcodec_open2 failed for H.264: ") + describe_ffmpeg_error(openResult)); + cleanup_video_decoder(); + return openResult; + } + + return 0; + } + + void FfmpegStreamBackend::start_video_decoder() { + } + + void FfmpegStreamBackend::stop_video_decoder() { + } + + void FfmpegStreamBackend::cleanup_video_decoder() { + if (video_.texture != nullptr) { + SDL_DestroyTexture(video_.texture); + video_.texture = nullptr; + } + video_.textureWidth = 0; + video_.textureHeight = 0; + + if (video_.packet != nullptr) { + av_packet_free(&video_.packet); + } + if (video_.decodedFrame != nullptr) { + av_frame_free(&video_.decodedFrame); + } + if (video_.convertedFrame != nullptr) { + av_frame_free(&video_.convertedFrame); + } + if (video_.codecContext != nullptr) { + avcodec_free_context(&video_.codecContext); + } + if (video_.scaleContext != nullptr) { + sws_freeContext(video_.scaleContext); + video_.scaleContext = nullptr; + } + + video_.convertedBuffer.clear(); + { + std::lock_guard lock(video_.frameMutex); + video_.latestFrame = LatestVideoFrame {}; + } + video_.hasFrame.store(false); + video_.submittedDecodeUnitCount.store(0); + video_.decodedFrameCount.store(0); + } + + int FfmpegStreamBackend::submit_video_decode_unit(PDECODE_UNIT decodeUnit) { + if (decodeUnit == nullptr || video_.codecContext == nullptr || video_.packet == nullptr || video_.decodedFrame == nullptr || video_.convertedFrame == nullptr) { + return DR_NEED_IDR; + } + + const int packetResult = av_new_packet(video_.packet, decodeUnit->fullLength); + if (packetResult < 0) { + logging::error("stream", std::string("av_new_packet failed for video decode: ") + describe_ffmpeg_error(packetResult)); + return DR_NEED_IDR; + } + + int offset = 0; + for (PLENTRY buffer = decodeUnit->bufferList; buffer != nullptr; buffer = buffer->next) { + if (buffer->length <= 0) { + continue; + } + + std::memcpy(video_.packet->data + offset, buffer->data, static_cast(buffer->length)); + offset += buffer->length; + } + video_.packet->size = offset; + + const std::uint64_t submittedDecodeUnitCount = video_.submittedDecodeUnitCount.fetch_add(1) + 1; + if (submittedDecodeUnitCount == 1 || (submittedDecodeUnitCount % STREAM_VIDEO_SUBMISSION_LOG_INTERVAL) == 0) { + const std::uint64_t receiveWindowUs = decodeUnit->enqueueTimeUs >= decodeUnit->receiveTimeUs ? decodeUnit->enqueueTimeUs - decodeUnit->receiveTimeUs : 0; + logging::debug( + "stream", + std::string("Submitted video decode unit ") + std::to_string(submittedDecodeUnitCount) + " frame=" + std::to_string(decodeUnit->frameNumber) + " bytes=" + std::to_string(decodeUnit->fullLength) + " queue_us=" + std::to_string(receiveWindowUs) + ); + } + + const auto receive_available_frames = [&]() -> int { + while (true) { + const int receiveResult = avcodec_receive_frame(video_.codecContext, video_.decodedFrame); + if (receiveResult == AVERROR(EAGAIN) || receiveResult == AVERROR_EOF) { + return receiveResult; + } + if (receiveResult < 0) { + logging::warn("stream", std::string("avcodec_receive_frame failed for H.264: ") + describe_ffmpeg_error(receiveResult)); + return receiveResult; + } + + AVFrame *frameToPresent = video_.decodedFrame; + if (video_.decodedFrame->format != AV_PIX_FMT_YUV420P) { + video_.scaleContext = sws_getCachedContext( + video_.scaleContext, + video_.decodedFrame->width, + video_.decodedFrame->height, + static_cast(video_.decodedFrame->format), + video_.decodedFrame->width, + video_.decodedFrame->height, + AV_PIX_FMT_YUV420P, + SWS_FAST_BILINEAR, + nullptr, + nullptr, + nullptr + ); + if (video_.scaleContext == nullptr) { + logging::warn("stream", "sws_getCachedContext failed for video conversion"); + av_frame_unref(video_.decodedFrame); + return AVERROR(EINVAL); + } + + const int bufferSize = av_image_get_buffer_size(AV_PIX_FMT_YUV420P, video_.decodedFrame->width, video_.decodedFrame->height, 1); + if (bufferSize <= 0) { + logging::warn("stream", "av_image_get_buffer_size failed for converted video frame"); + av_frame_unref(video_.decodedFrame); + return AVERROR(EINVAL); + } + + video_.convertedBuffer.resize(static_cast(bufferSize)); + av_frame_unref(video_.convertedFrame); + video_.convertedFrame->format = AV_PIX_FMT_YUV420P; + video_.convertedFrame->width = video_.decodedFrame->width; + video_.convertedFrame->height = video_.decodedFrame->height; + const int fillResult = av_image_fill_arrays( + video_.convertedFrame->data, + video_.convertedFrame->linesize, + video_.convertedBuffer.data(), + AV_PIX_FMT_YUV420P, + video_.decodedFrame->width, + video_.decodedFrame->height, + 1 + ); + if (fillResult < 0) { + logging::warn("stream", std::string("av_image_fill_arrays failed for converted frame: ") + describe_ffmpeg_error(fillResult)); + av_frame_unref(video_.decodedFrame); + return fillResult; + } + + sws_scale( + video_.scaleContext, + video_.decodedFrame->data, + video_.decodedFrame->linesize, + 0, + video_.decodedFrame->height, + video_.convertedFrame->data, + video_.convertedFrame->linesize + ); + frameToPresent = video_.convertedFrame; + } + + LatestVideoFrame nextFrame {}; + nextFrame.width = frameToPresent->width; + nextFrame.height = frameToPresent->height; + nextFrame.yPitch = frameToPresent->linesize[0]; + nextFrame.uPitch = frameToPresent->linesize[1]; + nextFrame.vPitch = frameToPresent->linesize[2]; + nextFrame.yPlane.resize(static_cast(frameToPresent->linesize[0] * frameToPresent->height)); + nextFrame.uPlane.resize(static_cast(frameToPresent->linesize[1] * ((frameToPresent->height + 1) / 2))); + nextFrame.vPlane.resize(static_cast(frameToPresent->linesize[2] * ((frameToPresent->height + 1) / 2))); + std::memcpy(nextFrame.yPlane.data(), frameToPresent->data[0], nextFrame.yPlane.size()); + std::memcpy(nextFrame.uPlane.data(), frameToPresent->data[1], nextFrame.uPlane.size()); + std::memcpy(nextFrame.vPlane.data(), frameToPresent->data[2], nextFrame.vPlane.size()); + + { + std::lock_guard lock(video_.frameMutex); + video_.latestFrame = std::move(nextFrame); + } + + video_.hasFrame.store(true); + video_.decodedFrameCount.fetch_add(1); + av_frame_unref(video_.decodedFrame); + } + }; + + int sendResult = avcodec_send_packet(video_.codecContext, video_.packet); + if (sendResult == AVERROR(EAGAIN)) { + const int drainResult = receive_available_frames(); + if (drainResult < 0 && drainResult != AVERROR(EAGAIN) && drainResult != AVERROR_EOF) { + av_packet_unref(video_.packet); + return DR_NEED_IDR; + } + sendResult = avcodec_send_packet(video_.codecContext, video_.packet); + } + av_packet_unref(video_.packet); + if (sendResult < 0) { + logging::warn("stream", std::string("avcodec_send_packet failed for H.264: ") + describe_ffmpeg_error(sendResult)); + return DR_NEED_IDR; + } + + const int receiveResult = receive_available_frames(); + if (receiveResult < 0 && receiveResult != AVERROR(EAGAIN) && receiveResult != AVERROR_EOF) { + return DR_NEED_IDR; + } + + return DR_OK; + } + + int FfmpegStreamBackend::initialize_audio_decoder(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, void *context, int arFlags) { + (void) context; + (void) arFlags; + + cleanup_audio_decoder(); + + if (opusConfig == nullptr) { + logging::error("stream", "Moonlight did not provide an Opus configuration for audio startup"); + return -1; + } + + const int channelCount = CHANNEL_COUNT_FROM_AUDIO_CONFIGURATION(audioConfiguration); + if (channelCount <= 0 || channelCount > 2) { + logging::error("stream", "The FFmpeg backend currently supports mono or stereo audio only"); + return -1; + } + + SDL_AudioSpec desiredSpec {}; + desiredSpec.freq = opusConfig->sampleRate > 0 ? opusConfig->sampleRate : 48000; + desiredSpec.format = AUDIO_S16SYS; + desiredSpec.channels = static_cast(channelCount); + desiredSpec.samples = 1024; + desiredSpec.callback = nullptr; + + if ((SDL_WasInit(SDL_INIT_AUDIO) & SDL_INIT_AUDIO) == 0) { + if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) { + logging::error("stream", std::string("SDL_InitSubSystem(SDL_INIT_AUDIO) failed for streaming playback: ") + SDL_GetError()); + cleanup_audio_decoder(); + return -1; + } + } + + audio_.deviceId = SDL_OpenAudioDevice(nullptr, 0, &desiredSpec, &audio_.obtainedSpec, 0); + if (audio_.deviceId == 0) { + logging::error("stream", std::string("SDL_OpenAudioDevice failed for streaming playback: ") + SDL_GetError()); + cleanup_audio_decoder(); + return -1; + } + + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_OPUS); + if (codec == nullptr) { + logging::error("stream", "FFmpeg did not provide an Opus decoder"); + cleanup_audio_decoder(); + return -1; + } + + audio_.codecContext = avcodec_alloc_context3(codec); + audio_.decodedFrame = av_frame_alloc(); + audio_.packet = av_packet_alloc(); + if (audio_.codecContext == nullptr || audio_.decodedFrame == nullptr || audio_.packet == nullptr) { + logging::error("stream", "Failed to allocate FFmpeg audio decoder resources"); + cleanup_audio_decoder(); + return -1; + } + + audio_.codecContext->sample_rate = desiredSpec.freq; + av_channel_layout_default(&audio_.codecContext->ch_layout, channelCount); + if (!configure_opus_extradata(channelCount, desiredSpec.freq, audio_.codecContext)) { + logging::error("stream", "Failed to allocate Opus decoder extradata for FFmpeg"); + cleanup_audio_decoder(); + return -1; + } + + const int openResult = avcodec_open2(audio_.codecContext, codec, nullptr); + if (openResult < 0) { + logging::error("stream", std::string("avcodec_open2 failed for Opus: ") + describe_ffmpeg_error(openResult)); + cleanup_audio_decoder(); + return openResult; + } + + return 0; + } + + void FfmpegStreamBackend::start_audio_playback() { + if (audio_.deviceId != 0) { + SDL_ClearQueuedAudio(audio_.deviceId); + SDL_PauseAudioDevice(audio_.deviceId, 0); + audio_.deviceStarted.store(true); + } + } + + void FfmpegStreamBackend::stop_audio_playback() { + if (audio_.deviceId != 0) { + SDL_PauseAudioDevice(audio_.deviceId, 1); + audio_.deviceStarted.store(false); + } + } + + void FfmpegStreamBackend::cleanup_audio_decoder() { + stop_audio_playback(); + + if (audio_.deviceId != 0) { + SDL_ClearQueuedAudio(audio_.deviceId); + SDL_CloseAudioDevice(audio_.deviceId); + audio_.deviceId = 0; + } + + if (audio_.packet != nullptr) { + av_packet_free(&audio_.packet); + } + if (audio_.decodedFrame != nullptr) { + av_frame_free(&audio_.decodedFrame); + } + if (audio_.codecContext != nullptr) { + avcodec_free_context(&audio_.codecContext); + } + if (audio_.resampleContext != nullptr) { + swr_free(&audio_.resampleContext); + } + + audio_.obtainedSpec = SDL_AudioSpec {}; + audio_.resampleInputSampleRate = 0; + audio_.resampleInputSampleFormat = -1; + audio_.resampleInputChannelCount = 0; + audio_.queuedAudioBytes.store(0); + } + + bool FfmpegStreamBackend::ensure_audio_resampler() { + if (audio_.decodedFrame == nullptr) { + return false; + } + + const int inputSampleRate = audio_.decodedFrame->sample_rate; + const int inputSampleFormat = audio_.decodedFrame->format; + const int inputChannelCount = audio_.decodedFrame->ch_layout.nb_channels; + const bool needsReconfigure = audio_.resampleContext == nullptr || audio_.resampleInputSampleRate != inputSampleRate || audio_.resampleInputSampleFormat != inputSampleFormat || audio_.resampleInputChannelCount != inputChannelCount; + if (!needsReconfigure) { + return true; + } + + if (audio_.resampleContext != nullptr) { + swr_free(&audio_.resampleContext); + } + + AVChannelLayout outputLayout {}; + av_channel_layout_default(&outputLayout, audio_.obtainedSpec.channels); + const int resampleConfigResult = swr_alloc_set_opts2( + &audio_.resampleContext, + &outputLayout, + AV_SAMPLE_FMT_S16, + audio_.obtainedSpec.freq, + &audio_.decodedFrame->ch_layout, + static_cast(audio_.decodedFrame->format), + audio_.decodedFrame->sample_rate, + 0, + nullptr + ); + av_channel_layout_uninit(&outputLayout); + if (resampleConfigResult < 0) { + logging::warn("stream", std::string("swr_alloc_set_opts2 failed for Opus: ") + describe_ffmpeg_error(resampleConfigResult)); + return false; + } + if (audio_.resampleContext == nullptr || swr_init(audio_.resampleContext) < 0) { + logging::warn("stream", "swr_init failed for the streaming audio resampler"); + return false; + } + + audio_.resampleInputSampleRate = inputSampleRate; + audio_.resampleInputSampleFormat = inputSampleFormat; + audio_.resampleInputChannelCount = inputChannelCount; + return true; + } + + void FfmpegStreamBackend::decode_and_play_audio_sample(char *sampleData, int sampleLength) { + if (audio_.codecContext == nullptr || audio_.packet == nullptr || audio_.decodedFrame == nullptr || audio_.deviceId == 0) { + return; + } + + if (sampleData == nullptr || sampleLength <= 0) { + return; + } + + const int packetResult = av_new_packet(audio_.packet, sampleLength); + if (packetResult < 0) { + logging::warn("stream", std::string("av_new_packet failed for audio decode: ") + describe_ffmpeg_error(packetResult)); + return; + } + + std::memcpy(audio_.packet->data, sampleData, static_cast(sampleLength)); + audio_.packet->size = sampleLength; + + const int sendResult = avcodec_send_packet(audio_.codecContext, audio_.packet); + av_packet_unref(audio_.packet); + if (sendResult < 0 && sendResult != AVERROR(EAGAIN)) { + logging::warn("stream", std::string("avcodec_send_packet failed for Opus: ") + describe_ffmpeg_error(sendResult)); + return; + } + + while (true) { + const int receiveResult = avcodec_receive_frame(audio_.codecContext, audio_.decodedFrame); + if (receiveResult == AVERROR(EAGAIN) || receiveResult == AVERROR_EOF) { + break; + } + if (receiveResult < 0) { + logging::warn("stream", std::string("avcodec_receive_frame failed for Opus: ") + describe_ffmpeg_error(receiveResult)); + return; + } + + if (!ensure_audio_resampler()) { + av_frame_unref(audio_.decodedFrame); + return; + } + + const int outputSamples = av_rescale_rnd( + swr_get_delay(audio_.resampleContext, audio_.decodedFrame->sample_rate) + audio_.decodedFrame->nb_samples, + audio_.obtainedSpec.freq, + audio_.decodedFrame->sample_rate, + AV_ROUND_UP + ); + const int outputBufferSize = av_samples_get_buffer_size( + nullptr, + audio_.obtainedSpec.channels, + outputSamples, + AV_SAMPLE_FMT_S16, + 1 + ); + if (outputBufferSize <= 0) { + logging::warn("stream", "av_samples_get_buffer_size failed for decoded audio output"); + av_frame_unref(audio_.decodedFrame); + return; + } + + std::vector outputBuffer(static_cast(outputBufferSize)); + std::uint8_t *outputData[] = {outputBuffer.data()}; + const int convertedSamples = swr_convert( + audio_.resampleContext, + outputData, + outputSamples, + const_cast(audio_.decodedFrame->extended_data), + audio_.decodedFrame->nb_samples + ); + if (convertedSamples < 0) { + logging::warn("stream", std::string("swr_convert failed for Opus: ") + describe_ffmpeg_error(convertedSamples)); + av_frame_unref(audio_.decodedFrame); + return; + } + + const int convertedBytes = convertedSamples * audio_.obtainedSpec.channels * static_cast(sizeof(std::int16_t)); + const Uint32 maxQueuedBytes = (audio_bytes_per_second(audio_.obtainedSpec) * STREAM_OVERLAY_AUDIO_QUEUE_LIMIT_MS) / 1000U; + if (SDL_GetQueuedAudioSize(audio_.deviceId) > maxQueuedBytes) { + SDL_ClearQueuedAudio(audio_.deviceId); + } + if (convertedBytes > 0 && SDL_QueueAudio(audio_.deviceId, outputBuffer.data(), static_cast(convertedBytes)) == 0) { + audio_.queuedAudioBytes.fetch_add(static_cast(convertedBytes)); + } + + av_frame_unref(audio_.decodedFrame); + } + } + +} // namespace streaming diff --git a/src/streaming/ffmpeg_stream_backend.h b/src/streaming/ffmpeg_stream_backend.h new file mode 100644 index 0000000..a683e1a --- /dev/null +++ b/src/streaming/ffmpeg_stream_backend.h @@ -0,0 +1,220 @@ +/** + * @file src/streaming/ffmpeg_stream_backend.h + * @brief Declares the FFmpeg-backed streaming decode backend for Xbox sessions. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include + +// lib includes +#include + +// local includes +#include "third-party/moonlight-common-c/src/Limelight.h" + +struct AVCodecContext; +struct AVFrame; +struct AVPacket; +struct SwrContext; +struct SwsContext; + +namespace streaming { + + /** + * @brief Owns the FFmpeg decode and SDL presentation state for one stream. + * + * The backend exposes Moonlight-compatible callback tables for video and audio, + * decodes H.264 video and Opus stereo audio with FFmpeg, presents the most + * recent decoded frame through SDL, and queues decoded PCM samples to SDL audio. + */ + class FfmpegStreamBackend { + public: + /** + * @brief Construct an empty backend. + */ + FfmpegStreamBackend() = default; + + /** + * @brief Destroy the backend and release all resources. + */ + ~FfmpegStreamBackend(); + + FfmpegStreamBackend(const FfmpegStreamBackend &) = delete; + FfmpegStreamBackend &operator=(const FfmpegStreamBackend &) = delete; + + /** + * @brief Populate Moonlight callback tables for this backend. + * + * @param videoCallbacks Output video callback table. + * @param audioCallbacks Output audio callback table. + */ + void initialize_callbacks(DECODER_RENDERER_CALLBACKS *videoCallbacks, AUDIO_RENDERER_CALLBACKS *audioCallbacks); + + /** + * @brief Release all FFmpeg, SDL, and cached frame resources. + */ + void shutdown(); + + /** + * @brief Render the latest decoded video frame to the supplied renderer. + * + * @param renderer SDL renderer used by the stream session. + * @param screenWidth Current renderer output width. + * @param screenHeight Current renderer output height. + * @return True when a decoded frame was available and rendered. + */ + bool render_latest_video_frame(SDL_Renderer *renderer, int screenWidth, int screenHeight); + + /** + * @brief Report whether at least one decoded video frame is available. + * + * @return True when a decoded frame is ready for presentation. + */ + bool has_decoded_video() const; + + /** + * @brief Build a short user-visible media status line. + * + * @return Summary of decoded video and queued audio state. + */ + std::string build_overlay_status_line() const; + + /** + * @brief Initialize the FFmpeg H.264 decoder for Moonlight video callbacks. + * + * @param videoFormat Negotiated Moonlight video format. + * @param width Negotiated stream width. + * @param height Negotiated stream height. + * @param redrawRate Negotiated redraw rate. + * @param context Moonlight renderer context. + * @param drFlags Moonlight decoder flags. + * @return Zero on success. + */ + int setup_video_decoder(int videoFormat, int width, int height, int redrawRate, void *context, int drFlags); + + /** + * @brief Start the video decode path. + */ + void start_video_decoder(); + + /** + * @brief Stop the video decode path. + */ + void stop_video_decoder(); + + /** + * @brief Clean up all FFmpeg video decode resources. + */ + void cleanup_video_decoder(); + + /** + * @brief Submit one Moonlight decode unit to FFmpeg. + * + * @param decodeUnit Moonlight Annex B frame payload. + * @return Moonlight decoder status code. + */ + int submit_video_decode_unit(PDECODE_UNIT decodeUnit); + + /** + * @brief Initialize the FFmpeg Opus decoder and SDL playback device. + * + * @param audioConfiguration Negotiated Moonlight audio configuration. + * @param opusConfig Negotiated Opus multistream parameters. + * @param context Moonlight audio context. + * @param arFlags Moonlight audio renderer flags. + * @return Zero on success. + */ + int initialize_audio_decoder(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, void *context, int arFlags); + + /** + * @brief Start SDL audio playback. + */ + void start_audio_playback(); + + /** + * @brief Stop SDL audio playback. + */ + void stop_audio_playback(); + + /** + * @brief Clean up all FFmpeg audio decode resources. + */ + void cleanup_audio_decoder(); + + /** + * @brief Ensure the audio resampler matches the current decoded frame. + * + * @return True when the resampler is ready for audio conversion. + */ + bool ensure_audio_resampler(); + + /** + * @brief Decode and queue one Moonlight Opus audio payload. + * + * @param sampleData Encoded Opus payload. + * @param sampleLength Encoded payload size in bytes. + */ + void decode_and_play_audio_sample(char *sampleData, int sampleLength); + + private: + /** + * @brief Hold the latest IYUV video frame ready for SDL upload. + */ + struct LatestVideoFrame { + int width = 0; + int height = 0; + int yPitch = 0; + int uPitch = 0; + int vPitch = 0; + std::vector yPlane; + std::vector uPlane; + std::vector vPlane; + }; + + /** + * @brief Hold FFmpeg and SDL state used by the video path. + */ + struct VideoState { + AVCodecContext *codecContext = nullptr; + SwsContext *scaleContext = nullptr; + AVFrame *decodedFrame = nullptr; + AVFrame *convertedFrame = nullptr; + AVPacket *packet = nullptr; + SDL_Texture *texture = nullptr; + int textureWidth = 0; + int textureHeight = 0; + std::vector convertedBuffer; + mutable std::mutex frameMutex; + LatestVideoFrame latestFrame; + std::atomic hasFrame = false; + std::atomic submittedDecodeUnitCount = 0; + std::atomic decodedFrameCount = 0; + }; + + /** + * @brief Hold FFmpeg and SDL state used by the audio path. + */ + struct AudioState { + AVCodecContext *codecContext = nullptr; + SwrContext *resampleContext = nullptr; + AVFrame *decodedFrame = nullptr; + AVPacket *packet = nullptr; + SDL_AudioDeviceID deviceId = 0; + SDL_AudioSpec obtainedSpec {}; + int resampleInputSampleRate = 0; + int resampleInputSampleFormat = -1; + int resampleInputChannelCount = 0; + std::atomic deviceStarted = false; + std::atomic queuedAudioBytes = 0; + }; + + VideoState video_ {}; + AudioState audio_ {}; + }; + +} // namespace streaming diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp new file mode 100644 index 0000000..9abdc9c --- /dev/null +++ b/src/streaming/session.cpp @@ -0,0 +1,1141 @@ +/** + * @file src/streaming/session.cpp + * @brief Implements the Xbox streaming session runtime. + */ +#include "src/streaming/session.h" + +#include "src/logging/logger.h" +#include "src/os.h" +#include "src/startup/memory_stats.h" +#include "src/streaming/ffmpeg_stream_backend.h" +#include "src/streaming/stats_overlay.h" +#include "third-party/moonlight-common-c/src/Limelight.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // NOSONAR(cpp:S3806) nxdk requires lowercase header names + +#ifdef NXDK +/** + * @brief Fill an integer with cryptographically strong random bytes on nxdk. + * + * @param randomValue Output integer populated with secure random bits. + * @return Zero on success, or a non-zero platform error code on failure. + */ +extern "C" int rand_s(unsigned int *randomValue); +#endif + +namespace { + + constexpr Uint8 BACKGROUND_RED = 0x08; + constexpr Uint8 BACKGROUND_GREEN = 0x0A; + constexpr Uint8 BACKGROUND_BLUE = 0x10; + constexpr Uint8 TEXT_RED = 0xF2; + constexpr Uint8 TEXT_GREEN = 0xF5; + constexpr Uint8 TEXT_BLUE = 0xF8; + constexpr Uint8 ACCENT_RED = 0x00; + constexpr Uint8 ACCENT_GREEN = 0xF3; + constexpr Uint8 ACCENT_BLUE = 0xD4; + constexpr Uint32 STREAM_EXIT_COMBO_HOLD_MILLISECONDS = 900U; + constexpr Uint32 STREAM_FRAME_DELAY_MILLISECONDS = 16U; + constexpr int DEFAULT_STREAM_FPS = 20; + constexpr int DEFAULT_STREAM_BITRATE_KBPS = 1500; + constexpr int MIN_STREAM_FPS = 15; + constexpr int MAX_STREAM_FPS = 30; + constexpr int MIN_STREAM_BITRATE_KBPS = 250; + constexpr int MAX_STREAM_BITRATE_KBPS = 50000; + constexpr int DEFAULT_PACKET_SIZE = 1024; + constexpr std::size_t MAX_CONNECTION_PROTOCOL_MESSAGES = 24U; + constexpr uint16_t PRESENT_GAMEPAD_MASK = 0x0001; + constexpr uint32_t CONTROLLER_BUTTON_CAPABILITIES = + static_cast(A_FLAG | B_FLAG | X_FLAG | Y_FLAG | UP_FLAG | DOWN_FLAG | LEFT_FLAG | RIGHT_FLAG | LB_FLAG | RB_FLAG | PLAY_FLAG | BACK_FLAG | LS_CLK_FLAG | RS_CLK_FLAG | SPECIAL_FLAG); + constexpr uint16_t CONTROLLER_CAPABILITIES = LI_CCAP_ANALOG_TRIGGERS | LI_CCAP_RUMBLE; + + struct StreamUiResources { + SDL_Renderer *renderer = nullptr; + TTF_Font *titleFont = nullptr; + TTF_Font *bodyFont = nullptr; + SDL_GameController *controller = nullptr; + streaming::FfmpegStreamBackend mediaBackend {}; + bool ttfInitialized = false; + }; + + struct ControllerSnapshot { + int buttonFlags = 0; + unsigned char leftTrigger = 0; + unsigned char rightTrigger = 0; + short leftStickX = 0; + short leftStickY = 0; + short rightStickX = 0; + short rightStickY = 0; + }; + + struct StreamConnectionState { + std::atomic currentStage = STAGE_NONE; + std::atomic failedStage = STAGE_NONE; + std::atomic failedCode = 0; + std::atomic terminationError = 0; + std::atomic startResult = -1; + std::atomic startCompleted = false; + std::atomic connectionStarted = false; + std::atomic connectionTerminated = false; + std::atomic poorConnection = false; + std::atomic stopRequested = false; + mutable std::mutex protocolLogMutex; + std::deque recentProtocolMessages; + }; + + struct StreamStartContext { + StreamConnectionState *connectionState = nullptr; + STREAM_CONFIGURATION streamConfiguration {}; + SERVER_INFORMATION serverInformation {}; + CONNECTION_LISTENER_CALLBACKS connectionCallbacks {}; + DECODER_RENDERER_CALLBACKS videoCallbacks {}; + AUDIO_RENDERER_CALLBACKS audioCallbacks {}; + streaming::FfmpegStreamBackend *mediaBackend = nullptr; + std::string address; + std::string reportedAppVersion; + std::string appVersion; + std::string gfeVersion; + std::string rtspSessionUrl; + }; + + struct ResolvedStreamParameters { + VIDEO_MODE videoMode {}; + int fps = DEFAULT_STREAM_FPS; + int bitrateKbps = DEFAULT_STREAM_BITRATE_KBPS; + int packetSize = DEFAULT_PACKET_SIZE; + int streamingRemotely = STREAM_CFG_AUTO; + }; + + /** + * @brief Describe the active Moonlight network profile for logging. + * + * @param streamingRemotely Moonlight stream locality mode. + * @return Human-readable profile label. + */ + const char *describe_streaming_remotely_mode(int streamingRemotely) { + switch (streamingRemotely) { + case STREAM_CFG_LOCAL: + return "local"; + case STREAM_CFG_REMOTE: + return "remote"; + default: + return "auto"; + } + } + + StreamConnectionState *g_active_connection_state = nullptr; + + /** + * @brief Remove trailing CR and LF characters from a log line. + * + * @param message Candidate log line. + * @return Trimmed log line. + */ + std::string trim_trailing_line_breaks(std::string message) { + while (!message.empty() && (message.back() == '\n' || message.back() == '\r')) { + message.pop_back(); + } + return message; + } + + /** + * @brief Return a printable fallback for optional log fields. + * + * @param value Candidate string value. + * @return The original value, or `` when empty. + */ + std::string printable_log_value(std::string_view value) { + return value.empty() ? std::string("") : std::string(value); + } + + /** + * @brief Return whether a string begins with the requested ASCII prefix. + * + * @param text Candidate text. + * @param prefix Expected prefix. + * @return True when the prefix matches case-insensitively. + */ + bool starts_with_ascii_case_insensitive(std::string_view text, std::string_view prefix) { + if (text.size() < prefix.size()) { + return false; + } + + for (std::size_t index = 0; index < prefix.size(); ++index) { + const unsigned char textCharacter = static_cast(text[index]); + const unsigned char prefixCharacter = static_cast(prefix[index]); + if (std::tolower(textCharacter) != std::tolower(prefixCharacter)) { + return false; + } + } + + return true; + } + + /** + * @brief Return whether the host metadata indicates Sunshine. + * + * @param gfeVersion Reported GFE or Sunshine version string. + * @return True when the host looks like Sunshine. + */ + bool is_sunshine_host_version(std::string_view gfeVersion) { + return starts_with_ascii_case_insensitive(gfeVersion, "Sunshine"); + } + + /** + * @brief Normalize the host appversion string for Sunshine protocol selection. + * + * moonlight-common-c uses the appversion quad to pick RTSP behavior and marks + * Sunshine hosts by making the fourth component negative. Sunshine also tracks + * the newer Gen 7 TCP RTSP flow, so older emulated appversion values are + * normalized to the 7.1.431 generation before the connection starts. + * + * @param appVersion Reported appversion from the host launch response. + * @param gfeVersion Reported GFE or Sunshine version string. + * @return Appversion string to pass into moonlight-common-c. + */ + std::string normalize_streaming_app_version(std::string_view appVersion, std::string_view gfeVersion) { + if (!is_sunshine_host_version(gfeVersion)) { + return std::string(appVersion); + } + + int major = 7; + int minor = 1; + int patch = 431; + int build = 0; + const int parsedFields = std::sscanf(std::string(appVersion).c_str(), "%d.%d.%d.%d", &major, &minor, &patch, &build); + if (parsedFields < 3) { + return "7.1.431.-1"; + } + + if (major < 7 || (major == 7 && minor < 1) || (major == 7 && minor == 1 && patch < 431)) { + major = 7; + minor = 1; + patch = 431; + } + + return std::to_string(major) + "." + std::to_string(minor) + "." + std::to_string(patch) + ".-1"; + } + + /** + * @brief Format one `moonlight-common-c` log callback line. + * + * @param format `printf`-style format string supplied by the library. + * @param arguments Variadic argument list matching `format`. + * @return Formatted log line. + */ + std::string format_connection_log_message(const char *format, va_list arguments) { + if (format == nullptr) { + return {}; + } + + std::array stackBuffer {}; + va_list argumentsCopy; + va_copy(argumentsCopy, arguments); + const int requiredLength = std::vsnprintf(stackBuffer.data(), stackBuffer.size(), format, argumentsCopy); + va_end(argumentsCopy); + if (requiredLength < 0) { + return {}; + } + if (static_cast(requiredLength) < stackBuffer.size()) { + return trim_trailing_line_breaks(std::string(stackBuffer.data(), static_cast(requiredLength))); + } + + std::string dynamicBuffer(static_cast(requiredLength) + 1U, '\0'); + va_copy(argumentsCopy, arguments); + std::vsnprintf(dynamicBuffer.data(), dynamicBuffer.size(), format, argumentsCopy); + va_end(argumentsCopy); + dynamicBuffer.resize(static_cast(requiredLength)); + return trim_trailing_line_breaks(std::move(dynamicBuffer)); + } + + /** + * @brief Append one connection-protocol log line to the retained rolling buffer. + * + * @param connectionState Connection state owning the retained protocol log buffer. + * @param message Log line to retain. + */ + void append_connection_protocol_message(StreamConnectionState *connectionState, std::string message) { + if (connectionState == nullptr) { + return; + } + + message = trim_trailing_line_breaks(std::move(message)); + if (message.empty()) { + return; + } + + std::lock_guard lock(connectionState->protocolLogMutex); + if (connectionState->recentProtocolMessages.size() >= MAX_CONNECTION_PROTOCOL_MESSAGES) { + connectionState->recentProtocolMessages.pop_front(); + } + connectionState->recentProtocolMessages.push_back(std::move(message)); + } + + /** + * @brief Return the most recent retained connection-protocol line. + * + * @param connectionState Connection state holding retained protocol logs. + * @return Latest protocol message, or an empty string when none was recorded. + */ + std::string latest_connection_protocol_message(const StreamConnectionState &connectionState) { + std::lock_guard lock(connectionState.protocolLogMutex); + return connectionState.recentProtocolMessages.empty() ? std::string {} : connectionState.recentProtocolMessages.back(); + } + + /** + * @brief Reset the mutable connection state before retrying stream startup. + * + * @param connectionState State object to reset. + */ + void reset_connection_state(StreamConnectionState *connectionState) { + if (connectionState == nullptr) { + return; + } + + connectionState->currentStage.store(STAGE_NONE); + connectionState->failedStage.store(STAGE_NONE); + connectionState->failedCode.store(0); + connectionState->terminationError.store(0); + connectionState->startResult.store(-1); + connectionState->startCompleted.store(false); + connectionState->connectionStarted.store(false); + connectionState->connectionTerminated.store(false); + connectionState->poorConnection.store(false); + connectionState->stopRequested.store(false); + std::lock_guard lock(connectionState->protocolLogMutex); + connectionState->recentProtocolMessages.clear(); + } + + std::string build_font_path() { + return std::string(DATA_PATH) + "assets" + PATH_SEP + "fonts" + PATH_SEP + "vegur-regular.ttf"; + } + + void close_controller(SDL_GameController *controller) { + if (controller != nullptr) { + SDL_GameControllerClose(controller); + } + } + + void close_stream_ui_resources(StreamUiResources *resources) { + if (resources == nullptr) { + return; + } + + resources->mediaBackend.shutdown(); + close_controller(resources->controller); + resources->controller = nullptr; + if (resources->bodyFont != nullptr) { + TTF_CloseFont(resources->bodyFont); + resources->bodyFont = nullptr; + } + if (resources->titleFont != nullptr) { + TTF_CloseFont(resources->titleFont); + resources->titleFont = nullptr; + } + if (resources->renderer != nullptr) { + SDL_DestroyRenderer(resources->renderer); + resources->renderer = nullptr; + } + if (resources->ttfInitialized) { + TTF_Quit(); + resources->ttfInitialized = false; + } + } + + SDL_GameController *open_primary_controller() { + for (int joystickIndex = 0; joystickIndex < SDL_NumJoysticks(); ++joystickIndex) { + if (!SDL_IsGameController(joystickIndex)) { + continue; + } + + SDL_GameController *controller = SDL_GameControllerOpen(joystickIndex); + if (controller != nullptr) { + return controller; + } + } + + return nullptr; + } + + bool initialize_stream_ui_resources(SDL_Window *window, const VIDEO_MODE &videoMode, StreamUiResources *resources, std::string *errorMessage) { + if (window == nullptr || resources == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = "Streaming requires a valid SDL window"; + } + return false; + } + + if (TTF_Init() != 0) { + if (errorMessage != nullptr) { + *errorMessage = std::string("TTF_Init failed for the streaming session: ") + TTF_GetError(); + } + return false; + } + resources->ttfInitialized = true; + + resources->renderer = SDL_CreateRenderer(window, -1, 0); + if (resources->renderer == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = std::string("SDL_CreateRenderer failed for the streaming session: ") + SDL_GetError(); + } + close_stream_ui_resources(resources); + return false; + } + + const std::string fontPath = build_font_path(); + resources->titleFont = TTF_OpenFont(fontPath.c_str(), std::max(22, videoMode.height / 18)); + resources->bodyFont = TTF_OpenFont(fontPath.c_str(), std::max(16, videoMode.height / 28)); + if (resources->titleFont == nullptr || resources->bodyFont == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = std::string("Failed to load the streaming session font from ") + fontPath + ": " + TTF_GetError(); + } + close_stream_ui_resources(resources); + return false; + } + + resources->controller = open_primary_controller(); + return true; + } + + bool fill_random_bytes(unsigned char *buffer, std::size_t size, std::string *errorMessage) { +#ifdef NXDK + if (buffer == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = "Streaming could not allocate the random-input key buffer"; + } + return false; + } + + std::size_t offset = 0; + while (offset < size) { + unsigned int randomValue = 0; + if (::rand_s(&randomValue) != 0) { + if (errorMessage != nullptr) { + *errorMessage = "Failed to generate secure random bytes for the streaming input keys"; + } + return false; + } + + const std::size_t chunkSize = std::min(sizeof(randomValue), size - offset); + std::memcpy(buffer + offset, &randomValue, chunkSize); + offset += chunkSize; + } + return true; +#else + (void) buffer; + (void) size; + if (errorMessage != nullptr) { + *errorMessage = "The streaming runtime is only supported on the Xbox build"; + } + return false; +#endif + } + + std::string hex_encode(const unsigned char *data, std::size_t size) { + static constexpr char HEX_DIGITS[] = "0123456789abcdef"; + + std::string output; + output.resize(size * 2U); + for (std::size_t index = 0; index < size; ++index) { + output[index * 2U] = HEX_DIGITS[(data[index] >> 4U) & 0x0F]; + output[(index * 2U) + 1U] = HEX_DIGITS[data[index] & 0x0F]; + } + return output; + } + + int select_stream_width(const VIDEO_MODE &videoMode) { + return videoMode.width > 0 ? std::max(320, static_cast(videoMode.width)) : 640; + } + + int select_stream_height(const VIDEO_MODE &videoMode) { + return videoMode.height > 0 ? std::max(240, static_cast(videoMode.height)) : 480; + } + + int select_client_refresh_rate_x100(const VIDEO_MODE &videoMode) { + return videoMode.refresh > 0 ? static_cast(videoMode.refresh) * 100 : 6000; + } + + /** + * @brief Resolve the effective stream resolution to request for the current stream. + * + * @param fallbackVideoMode Active shell output mode. + * @param settings Current shell settings. + * @return Preferred stream resolution, or fallbackVideoMode when no override exists. + */ + VIDEO_MODE select_effective_stream_video_mode(const VIDEO_MODE &fallbackVideoMode, const app::SettingsState &settings) { + return settings.preferredVideoModeSet ? settings.preferredVideoMode : fallbackVideoMode; + } + + /** + * @brief Clamp the preferred frame rate into the supported stream range. + * + * @param settings Current shell settings. + * @return Effective frame rate for the next stream. + */ + int select_stream_fps(const app::SettingsState &settings) { + return std::clamp(settings.streamFramerate, MIN_STREAM_FPS, MAX_STREAM_FPS); + } + + /** + * @brief Clamp the preferred bitrate into a reasonable stream range. + * + * @param settings Current shell settings. + * @return Effective bitrate for the next stream in kilobits per second. + */ + int select_stream_bitrate_kbps(const app::SettingsState &settings) { + return std::clamp(settings.streamBitrateKbps, MIN_STREAM_BITRATE_KBPS, MAX_STREAM_BITRATE_KBPS); + } + + /** + * @brief Resolve the effective stream parameters for the next session. + * + * @param fallbackVideoMode Active shell output mode. + * @param settings Current shell settings. + * @return Stream parameters requested through the settings UI. + */ + ResolvedStreamParameters resolve_stream_parameters(const VIDEO_MODE &fallbackVideoMode, const app::SettingsState &settings) { + ResolvedStreamParameters resolved {}; + resolved.videoMode = select_effective_stream_video_mode(fallbackVideoMode, settings); + resolved.fps = select_stream_fps(settings); + resolved.bitrateKbps = select_stream_bitrate_kbps(settings); + + return resolved; + } + + void configure_stream_start_context( + const network::StreamLaunchResult &launchResult, + const std::array &remoteInputKey, + const std::array &remoteInputIv, + const VIDEO_MODE &outputVideoMode, + const ResolvedStreamParameters &streamParameters, + StreamStartContext *context + ) { + if (context == nullptr) { + return; + } + + context->address = launchResult.serverInfo.activeAddress; + if (context->address.empty()) { + context->address = launchResult.serverInfo.localAddress; + } + if (context->address.empty()) { + context->address = launchResult.serverInfo.remoteAddress; + } + context->reportedAppVersion = launchResult.appVersion; + context->appVersion = normalize_streaming_app_version(launchResult.appVersion, launchResult.gfeVersion); + context->gfeVersion = launchResult.gfeVersion; + context->rtspSessionUrl = launchResult.rtspSessionUrl; + + LiInitializeServerInformation(&context->serverInformation); + context->serverInformation.address = context->address.c_str(); + context->serverInformation.serverInfoAppVersion = context->appVersion.c_str(); + context->serverInformation.serverInfoGfeVersion = context->gfeVersion.empty() ? nullptr : context->gfeVersion.c_str(); + context->serverInformation.rtspSessionUrl = context->rtspSessionUrl.empty() ? nullptr : context->rtspSessionUrl.c_str(); + context->serverInformation.serverCodecModeSupport = launchResult.serverCodecModeSupport; + + LiInitializeStreamConfiguration(&context->streamConfiguration); + context->streamConfiguration.width = select_stream_width(streamParameters.videoMode); + context->streamConfiguration.height = select_stream_height(streamParameters.videoMode); + context->streamConfiguration.fps = streamParameters.fps; + context->streamConfiguration.bitrate = streamParameters.bitrateKbps; + context->streamConfiguration.packetSize = streamParameters.packetSize; + context->streamConfiguration.streamingRemotely = streamParameters.streamingRemotely; + context->streamConfiguration.audioConfiguration = AUDIO_CONFIGURATION_STEREO; + context->streamConfiguration.supportedVideoFormats = VIDEO_FORMAT_H264; + context->streamConfiguration.clientRefreshRateX100 = select_client_refresh_rate_x100(outputVideoMode); + context->streamConfiguration.colorSpace = COLORSPACE_REC_601; + context->streamConfiguration.colorRange = COLOR_RANGE_LIMITED; + context->streamConfiguration.encryptionFlags = ENCFLG_NONE; + std::memcpy(context->streamConfiguration.remoteInputAesKey, remoteInputKey.data(), remoteInputKey.size()); + std::memcpy(context->streamConfiguration.remoteInputAesIv, remoteInputIv.data(), remoteInputIv.size()); + + LiInitializeConnectionCallbacks(&context->connectionCallbacks); + } + + void on_stage_starting(int stage) { + if (g_active_connection_state != nullptr) { + g_active_connection_state->currentStage.store(stage); + } + logging::debug("stream", std::string("Starting connection stage: ") + LiGetStageName(stage)); + } + + void on_stage_complete(int stage) { + if (g_active_connection_state != nullptr) { + g_active_connection_state->currentStage.store(stage); + } + logging::debug("stream", std::string("Completed connection stage: ") + LiGetStageName(stage)); + } + + void on_stage_failed(int stage, int errorCode) { + if (g_active_connection_state != nullptr) { + g_active_connection_state->failedStage.store(stage); + g_active_connection_state->failedCode.store(errorCode); + } + logging::warn("stream", std::string("Connection stage failed: ") + LiGetStageName(stage) + " (error " + std::to_string(errorCode) + ")"); + } + + void on_connection_started() { + if (g_active_connection_state != nullptr) { + g_active_connection_state->connectionStarted.store(true); + } + logging::info("stream", "Streaming transport started"); + } + + void on_connection_terminated(int errorCode) { + if (g_active_connection_state != nullptr) { + g_active_connection_state->terminationError.store(errorCode); + g_active_connection_state->connectionTerminated.store(true); + } + logging::warn("stream", std::string("Streaming transport terminated with error ") + std::to_string(errorCode)); + } + + void on_connection_status_update(int connectionStatus) { + if (g_active_connection_state != nullptr) { + const bool poorConnection = connectionStatus != CONN_STATUS_OKAY; + if (g_active_connection_state->poorConnection.exchange(poorConnection) != poorConnection) { + logging::warn("stream", poorConnection ? "Streaming transport reported poor network conditions" : "Streaming transport recovered to okay network conditions"); + } + } + } + + void on_log_message(const char *format, ...) { + va_list arguments; + va_start(arguments, format); + const std::string message = format_connection_log_message(format, arguments); + va_end(arguments); + if (message.empty()) { + return; + } + + append_connection_protocol_message(g_active_connection_state, message); + logging::debug("moonlight", message); + } + + int run_stream_start_thread(void *context) { + auto *startContext = static_cast(context); + if (startContext == nullptr || startContext->connectionState == nullptr) { + return -1; + } + + g_active_connection_state = startContext->connectionState; + startContext->connectionState->startResult.store( + LiStartConnection( + &startContext->serverInformation, + &startContext->streamConfiguration, + &startContext->connectionCallbacks, + &startContext->videoCallbacks, + &startContext->audioCallbacks, + startContext->mediaBackend, + 0, + startContext->mediaBackend, + 0 + ) + ); + startContext->connectionState->startCompleted.store(true); + return 0; + } + + bool render_text_line(SDL_Renderer *renderer, TTF_Font *font, const std::string &text, SDL_Color color, int x, int y, int maxWidth, int *drawnHeight = nullptr) { + if (renderer == nullptr || font == nullptr || maxWidth <= 0) { + if (drawnHeight != nullptr) { + *drawnHeight = 0; + } + return false; + } + + SDL_Surface *surface = TTF_RenderUTF8_Blended_Wrapped(font, text.c_str(), color, static_cast(maxWidth)); + if (surface == nullptr) { + if (drawnHeight != nullptr) { + *drawnHeight = 0; + } + return false; + } + + SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, surface); + if (texture == nullptr) { + SDL_FreeSurface(surface); + if (drawnHeight != nullptr) { + *drawnHeight = 0; + } + return false; + } + + const SDL_Rect destination {x, y, surface->w, surface->h}; + const int surfaceHeight = surface->h; + SDL_FreeSurface(surface); + const bool rendered = SDL_RenderCopy(renderer, texture, nullptr, &destination) == 0; + SDL_DestroyTexture(texture); + if (drawnHeight != nullptr) { + *drawnHeight = surfaceHeight; + } + return rendered; + } + + std::string build_stage_status_line(const StreamConnectionState &connectionState) { + const int failedStage = connectionState.failedStage.load(); + if (failedStage != STAGE_NONE) { + return std::string("Connection failed during ") + LiGetStageName(failedStage) + " (error " + std::to_string(connectionState.failedCode.load()) + ")"; + } + if (connectionState.connectionTerminated.load()) { + const int terminationError = connectionState.terminationError.load(); + return terminationError == ML_ERROR_GRACEFUL_TERMINATION ? "The host ended the stream gracefully." : "The host ended the stream (error " + std::to_string(terminationError) + ")"; + } + if (connectionState.connectionStarted.load()) { + return "Streaming transport is active. Hold Back + Start to stop."; + } + return std::string("Connecting: ") + LiGetStageName(connectionState.currentStage.load()); + } + + void pump_stream_events(StreamUiResources *resources) { + SDL_Event event {}; + while (SDL_PollEvent(&event) != 0) { + if (event.type == SDL_CONTROLLERDEVICEADDED && resources != nullptr && resources->controller == nullptr) { + resources->controller = SDL_GameControllerOpen(event.cdevice.which); + } + } + } + + unsigned char convert_trigger_axis(Sint16 value) { + const int normalized = std::clamp(static_cast(value), 0, 32767); + return static_cast((normalized * 255) / 32767); + } + + ControllerSnapshot read_controller_snapshot(SDL_GameController *controller) { + ControllerSnapshot snapshot {}; + if (controller == nullptr) { + return snapshot; + } + + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_A) != 0) { + snapshot.buttonFlags |= A_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_B) != 0) { + snapshot.buttonFlags |= B_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_X) != 0) { + snapshot.buttonFlags |= X_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_Y) != 0) { + snapshot.buttonFlags |= Y_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_UP) != 0) { + snapshot.buttonFlags |= UP_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_DOWN) != 0) { + snapshot.buttonFlags |= DOWN_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_LEFT) != 0) { + snapshot.buttonFlags |= LEFT_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_RIGHT) != 0) { + snapshot.buttonFlags |= RIGHT_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_LEFTSHOULDER) != 0) { + snapshot.buttonFlags |= LB_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) != 0) { + snapshot.buttonFlags |= RB_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_START) != 0) { + snapshot.buttonFlags |= PLAY_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_BACK) != 0) { + snapshot.buttonFlags |= BACK_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_LEFTSTICK) != 0) { + snapshot.buttonFlags |= LS_CLK_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_RIGHTSTICK) != 0) { + snapshot.buttonFlags |= RS_CLK_FLAG; + } + + snapshot.leftTrigger = convert_trigger_axis(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERLEFT)); + snapshot.rightTrigger = convert_trigger_axis(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERRIGHT)); + snapshot.leftStickX = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTX); + snapshot.leftStickY = static_cast(-SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTY)); + snapshot.rightStickX = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_RIGHTX); + snapshot.rightStickY = static_cast(-SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_RIGHTY)); + return snapshot; + } + + bool controller_snapshots_match(const ControllerSnapshot &left, const ControllerSnapshot &right) { + return left.buttonFlags == right.buttonFlags && left.leftTrigger == right.leftTrigger && left.rightTrigger == right.rightTrigger && left.leftStickX == right.leftStickX && left.leftStickY == right.leftStickY && + left.rightStickX == right.rightStickX && left.rightStickY == right.rightStickY; + } + + void update_stream_exit_combo(SDL_GameController *controller, Uint32 *comboActivatedTick, StreamConnectionState *connectionState) { + if (comboActivatedTick == nullptr || connectionState == nullptr || controller == nullptr) { + return; + } + + const bool backPressed = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_BACK) != 0; + const bool startPressed = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_START) != 0; + if (!backPressed || !startPressed) { + *comboActivatedTick = 0U; + return; + } + + const Uint32 now = SDL_GetTicks(); + if (*comboActivatedTick == 0U) { + *comboActivatedTick = now; + return; + } + if (now - *comboActivatedTick >= STREAM_EXIT_COMBO_HOLD_MILLISECONDS) { + connectionState->stopRequested.store(true); + } + } + + void send_controller_state_if_needed(SDL_GameController *controller, bool *arrivalSent, ControllerSnapshot *lastSnapshot) { + if (controller == nullptr || arrivalSent == nullptr || lastSnapshot == nullptr) { + return; + } + + if (!*arrivalSent) { + LiSendControllerArrivalEvent(0, PRESENT_GAMEPAD_MASK, LI_CTYPE_XBOX, CONTROLLER_BUTTON_CAPABILITIES, CONTROLLER_CAPABILITIES); + *arrivalSent = true; + } + + const ControllerSnapshot snapshot = read_controller_snapshot(controller); + if (controller_snapshots_match(snapshot, *lastSnapshot)) { + return; + } + + LiSendControllerEvent(snapshot.buttonFlags, snapshot.leftTrigger, snapshot.rightTrigger, snapshot.leftStickX, snapshot.leftStickY, snapshot.rightStickX, snapshot.rightStickY); + *lastSnapshot = snapshot; + } + + streaming::StreamStatisticsSnapshot sample_stream_statistics(const StreamStartContext &context, const StreamConnectionState &connectionState) { + streaming::StreamStatisticsSnapshot snapshot { + context.streamConfiguration.width, + context.streamConfiguration.height, + context.streamConfiguration.fps, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + connectionState.poorConnection.load(), + }; + + uint32_t estimatedRtt = 0; + uint32_t estimatedRttVariance = 0; + if (LiGetEstimatedRttInfo(&estimatedRtt, &estimatedRttVariance)) { + (void) estimatedRttVariance; + snapshot.roundTripTimeMs = static_cast(estimatedRtt); + } + + snapshot.videoQueueDepth = LiGetPendingVideoFrames(); + snapshot.audioQueueDurationMs = LiGetPendingAudioDuration(); + if (const RTP_VIDEO_STATS *videoStats = LiGetRTPVideoStats(); videoStats != nullptr) { + snapshot.videoPacketsReceived = static_cast(videoStats->packetCountVideo); + snapshot.videoPacketsRecovered = static_cast(videoStats->packetCountFecRecovered); + snapshot.videoPacketsLost = static_cast(videoStats->packetCountFecFailed); + } + return snapshot; + } + + bool render_stream_frame( + const app::HostRecord &host, + const app::HostAppRecord &app, + const StreamStartContext &context, + const StreamConnectionState &connectionState, + streaming::FfmpegStreamBackend *mediaBackend, + bool showPerformanceStats, + StreamUiResources *resources + ) { + if (resources == nullptr || resources->renderer == nullptr || resources->titleFont == nullptr || resources->bodyFont == nullptr) { + return false; + } + + int screenWidth = 0; + int screenHeight = 0; + SDL_GetRendererOutputSize(resources->renderer, &screenWidth, &screenHeight); + + const bool hasDecodedVideo = mediaBackend != nullptr && mediaBackend->has_decoded_video(); + if (hasDecodedVideo) { + SDL_SetRenderDrawColor(resources->renderer, 0x00, 0x00, 0x00, 0xFF); + } else { + SDL_SetRenderDrawColor(resources->renderer, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xFF); + } + SDL_RenderClear(resources->renderer); + + const bool renderedVideo = hasDecodedVideo && mediaBackend->render_latest_video_frame(resources->renderer, screenWidth, screenHeight); + if (renderedVideo && showPerformanceStats) { + SDL_SetRenderDrawBlendMode(resources->renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(resources->renderer, 0x00, 0x00, 0x00, 0x90); + const SDL_Rect overlayBackground {18, 18, std::max(1, screenWidth - 36), std::max(1, std::min(screenHeight - 36, 220))}; + SDL_RenderFillRect(resources->renderer, &overlayBackground); + } + + if (renderedVideo && !showPerformanceStats) { + SDL_RenderPresent(resources->renderer); + return true; + } + + int cursorY = 28; + int titleHeight = 0; + render_text_line(resources->renderer, resources->titleFont, renderedVideo ? "Moonlight Streaming" : "Moonlight Streaming", {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, 28, cursorY, std::max(1, screenWidth - 56), &titleHeight); + cursorY += titleHeight + 8; + + std::vector lines = { + std::string("Host: ") + host.displayName + " (" + context.address + ")", + std::string("App: ") + app.name + (context.rtspSessionUrl.empty() ? std::string {} : std::string(" | Session URL received")), + build_stage_status_line(connectionState), + mediaBackend != nullptr ? mediaBackend->build_overlay_status_line() : std::string("FFmpeg decode backend unavailable"), + "Hold Back + Start for about one second to stop streaming.", + }; + if (!renderedVideo) { + lines.insert(lines.begin() + 2, std::string("Mode: ") + std::to_string(context.streamConfiguration.width) + "x" + std::to_string(context.streamConfiguration.height) + " @ " + std::to_string(context.streamConfiguration.fps) + " FPS | H.264 | Stereo"); + lines.insert(lines.begin() + 4, std::string("Launch mode: ") + (context.serverInformation.rtspSessionUrl != nullptr ? "Session URL supplied by host" : "Default RTSP discovery")); + lines.insert(lines.begin() + 5, "Waiting for the first decoded video frame and audio output."); + } + + if (showPerformanceStats) { + for (const std::string &line : streaming::build_stats_overlay_lines(sample_stream_statistics(context, connectionState))) { + lines.push_back(line); + } + } + + for (const std::string &line : lines) { + int drawnHeight = 0; + render_text_line(resources->renderer, resources->bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, 28, cursorY, std::max(1, screenWidth - 56), &drawnHeight); + cursorY += drawnHeight + 6; + } + + SDL_RenderPresent(resources->renderer); + return true; + } + + std::string describe_start_failure(const StreamConnectionState &connectionState) { + if (const int failedStage = connectionState.failedStage.load(); failedStage != STAGE_NONE) { + std::string message = std::string("Failed to start streaming during ") + LiGetStageName(failedStage) + " (error " + std::to_string(connectionState.failedCode.load()) + ")"; + if (const std::string protocolMessage = latest_connection_protocol_message(connectionState); !protocolMessage.empty()) { + message += ": "; + message += protocolMessage; + } + return message; + } + if (const int startResult = connectionState.startResult.load(); startResult != 0) { + std::string message = std::string("Failed to start streaming transport (library error ") + std::to_string(startResult) + ")"; + if (const std::string protocolMessage = latest_connection_protocol_message(connectionState); !protocolMessage.empty()) { + message += ": "; + message += protocolMessage; + } + return message; + } + return "Failed to start streaming transport"; + } + + std::string describe_session_end(const StreamConnectionState &connectionState, std::string_view appName) { + if (connectionState.stopRequested.load()) { + return "Stopped streaming " + std::string(appName); + } + if (const int terminationError = connectionState.terminationError.load(); terminationError == ML_ERROR_GRACEFUL_TERMINATION) { + return std::string("The host closed ") + std::string(appName) + " cleanly"; + } + if (connectionState.connectionTerminated.load()) { + return std::string("The stream ended unexpectedly for ") + std::string(appName) + " (error " + std::to_string(connectionState.terminationError.load()) + ")"; + } + return std::string("Streaming session ended for ") + std::string(appName); + } + +} // namespace + +namespace streaming { + + bool run_stream_session( + SDL_Window *window, + const VIDEO_MODE &videoMode, + const app::SettingsState &settings, + const app::HostRecord &host, + const app::HostAppRecord &app, + const network::PairingIdentity &clientIdentity, + std::string *statusMessage + ) { + StreamUiResources resources {}; + if (std::string initializationError; !initialize_stream_ui_resources(window, videoMode, &resources, &initializationError)) { + if (statusMessage != nullptr) { + *statusMessage = initializationError; + } + logging::error("stream", initializationError); + return false; + } + + startup::log_memory_statistics(); + + std::array remoteInputKey {}; + std::array remoteInputIv {}; + if (std::string randomError; !fill_random_bytes(remoteInputKey.data(), remoteInputKey.size(), &randomError) || !fill_random_bytes(remoteInputIv.data(), remoteInputIv.size(), &randomError)) { + close_stream_ui_resources(&resources); + if (statusMessage != nullptr) { + *statusMessage = randomError; + } + logging::error("stream", randomError); + return false; + } + + const std::string hostAddress = host.activeAddress.empty() ? host.address : host.activeAddress; + const uint16_t httpPort = host.resolvedHttpPort == 0U ? app::effective_host_port(host.port) : host.resolvedHttpPort; + + network::StreamLaunchResult launchResult {}; + network::StreamLaunchConfiguration launchConfiguration {}; + const ResolvedStreamParameters streamParameters = resolve_stream_parameters(videoMode, settings); + launchConfiguration.appId = app.id; + launchConfiguration.width = select_stream_width(streamParameters.videoMode); + launchConfiguration.height = select_stream_height(streamParameters.videoMode); + launchConfiguration.fps = streamParameters.fps; + launchConfiguration.audioConfiguration = AUDIO_CONFIGURATION_STEREO; + launchConfiguration.playAudioOnPc = settings.playAudioOnPc; + launchConfiguration.clientRefreshRateX100 = select_client_refresh_rate_x100(videoMode); + launchConfiguration.remoteInputAesKeyHex = hex_encode(remoteInputKey.data(), remoteInputKey.size()); + launchConfiguration.remoteInputAesIvHex = hex_encode(remoteInputIv.data(), remoteInputIv.size()); + + std::string launchError; + if (!network::launch_or_resume_stream(hostAddress, httpPort, clientIdentity, launchConfiguration, &launchResult, &launchError)) { + close_stream_ui_resources(&resources); + if (statusMessage != nullptr) { + *statusMessage = launchError; + } + logging::error("stream", launchError); + return false; + } + + StreamConnectionState connectionState {}; + StreamStartContext startContext {}; + startContext.connectionState = &connectionState; + startContext.mediaBackend = &resources.mediaBackend; + configure_stream_start_context(launchResult, remoteInputKey, remoteInputIv, videoMode, streamParameters, &startContext); + if (startContext.appVersion != startContext.reportedAppVersion) { + logging::info( + "stream", + std::string("Normalized host appversion from ") + startContext.reportedAppVersion + " to " + startContext.appVersion + " for " + (startContext.gfeVersion.empty() ? std::string("the current host") : startContext.gfeVersion) + ); + } + logging::info( + "stream", + std::string("Starting stream setup for ") + app.name + " on " + host.displayName + " at " + startContext.address + " | appversion " + startContext.appVersion + (startContext.rtspSessionUrl.empty() ? std::string(" | default RTSP discovery") : std::string(" | host session URL supplied")) + ); + logging::info( + "stream", + std::string("Requested stream configuration | resolution=") + std::to_string(startContext.streamConfiguration.width) + "x" + std::to_string(startContext.streamConfiguration.height) + " | fps=" + std::to_string(startContext.streamConfiguration.fps) + " | bitrate=" + std::to_string(startContext.streamConfiguration.bitrate) + " kbps | packetSize=" + std::to_string(startContext.streamConfiguration.packetSize) + " | networkProfile=" + describe_streaming_remotely_mode(startContext.streamConfiguration.streamingRemotely) + " | clientRefreshX100=" + std::to_string(startContext.streamConfiguration.clientRefreshRateX100) + ); + logging::debug( + "stream", + std::string("Stream connection metadata | active=") + printable_log_value(startContext.serverInformation.address == nullptr ? std::string_view {} : std::string_view {startContext.serverInformation.address}) + + " | local=" + printable_log_value(launchResult.serverInfo.localAddress) + + " | remote=" + printable_log_value(launchResult.serverInfo.remoteAddress) + + " | gfeVersion=" + printable_log_value(startContext.gfeVersion) + + " | sessionUrl=" + printable_log_value(startContext.rtspSessionUrl) + ); + resources.mediaBackend.initialize_callbacks(&startContext.videoCallbacks, &startContext.audioCallbacks); + startContext.connectionCallbacks.stageStarting = on_stage_starting; + startContext.connectionCallbacks.stageComplete = on_stage_complete; + startContext.connectionCallbacks.stageFailed = on_stage_failed; + startContext.connectionCallbacks.connectionStarted = on_connection_started; + startContext.connectionCallbacks.connectionTerminated = on_connection_terminated; + startContext.connectionCallbacks.connectionStatusUpdate = on_connection_status_update; + startContext.connectionCallbacks.logMessage = on_log_message; + + const auto run_start_attempt = [&]() -> bool { + SDL_Thread *startThread = SDL_CreateThread(run_stream_start_thread, "start-stream", &startContext); + if (startThread == nullptr) { + const std::string createThreadError = std::string("Failed to start the streaming transport thread: ") + SDL_GetError(); + close_stream_ui_resources(&resources); + if (statusMessage != nullptr) { + *statusMessage = createThreadError; + } + logging::error("stream", createThreadError); + return false; + } + + Uint32 exitComboActivatedTick = 0U; + while (!connectionState.startCompleted.load() && !connectionState.stopRequested.load()) { + pump_stream_events(&resources); + update_stream_exit_combo(resources.controller, &exitComboActivatedTick, &connectionState); + render_stream_frame(host, app, startContext, connectionState, &resources.mediaBackend, true, &resources); + if (connectionState.stopRequested.load()) { + LiInterruptConnection(); + } + SDL_Delay(STREAM_FRAME_DELAY_MILLISECONDS); + } + + int threadResult = 0; + SDL_WaitThread(startThread, &threadResult); + (void) threadResult; + return true; + }; + + if (!run_start_attempt()) { + return false; + } + + bool rtspFallbackAttempted = false; + if (!connectionState.stopRequested.load() && !connectionState.connectionStarted.load() && connectionState.failedStage.load() == STAGE_RTSP_HANDSHAKE && !startContext.rtspSessionUrl.empty()) { + rtspFallbackAttempted = true; + logging::warn("stream", "RTSP handshake failed with the host-supplied session URL; retrying with default RTSP discovery"); + resources.mediaBackend.shutdown(); + startContext.rtspSessionUrl.clear(); + startContext.serverInformation.rtspSessionUrl = nullptr; + reset_connection_state(&connectionState); + if (!run_start_attempt()) { + return false; + } + } + + if (connectionState.startResult.load() != 0 || !connectionState.connectionStarted.load()) { + std::string failureMessage = connectionState.stopRequested.load() ? std::string("Cancelled the stream start for ") + app.name : describe_start_failure(connectionState); + if (rtspFallbackAttempted && !connectionState.stopRequested.load()) { + failureMessage += " after retrying default RTSP discovery"; + } + g_active_connection_state = nullptr; + close_stream_ui_resources(&resources); + if (statusMessage != nullptr) { + *statusMessage = failureMessage; + } + logging::warn("stream", failureMessage); + return false; + } + + bool controllerArrivalSent = false; + ControllerSnapshot lastControllerSnapshot {}; + Uint32 exitComboActivatedTick = 0U; + logging::info("stream", std::string(launchResult.resumedSession ? "Resumed stream for " : "Launched stream for ") + app.name + " on " + host.displayName); + while (!connectionState.connectionTerminated.load() && !connectionState.stopRequested.load()) { + pump_stream_events(&resources); + update_stream_exit_combo(resources.controller, &exitComboActivatedTick, &connectionState); + send_controller_state_if_needed(resources.controller, &controllerArrivalSent, &lastControllerSnapshot); + render_stream_frame(host, app, startContext, connectionState, &resources.mediaBackend, settings.showPerformanceStats, &resources); + SDL_Delay(STREAM_FRAME_DELAY_MILLISECONDS); + } + + LiStopConnection(); + g_active_connection_state = nullptr; + + const std::string finalMessage = describe_session_end(connectionState, app.name); + logging::info("stream", finalMessage); + startup::log_memory_statistics(); + close_stream_ui_resources(&resources); + if (statusMessage != nullptr) { + *statusMessage = finalMessage; + } + return true; + } + +} // namespace streaming diff --git a/src/streaming/session.h b/src/streaming/session.h new file mode 100644 index 0000000..16de502 --- /dev/null +++ b/src/streaming/session.h @@ -0,0 +1,48 @@ +/** + * @file src/streaming/session.h + * @brief Declares the Xbox streaming session runtime. + */ +#pragma once + +// standard includes +#include + +// local includes +#include "src/app/client_state.h" +#include "src/app/host_records.h" +#include "src/network/host_pairing.h" +#include "src/startup/video_mode.h" + +struct SDL_Window; + +namespace streaming { + + /** + * @brief Run one Xbox streaming session for the selected host app. + * + * The session launches or resumes the selected app on the host, starts the + * Moonlight transport runtime, decodes H.264 video and Opus audio with + * FFmpeg, forwards controller input, renders the latest decoded frame with a + * lightweight overlay, and returns once the user stops streaming or the host + * terminates the session. + * + * @param window Shared SDL window reused from the shell. + * @param videoMode Active Xbox video mode. + * @param settings Active shell settings that control stream resolution, bitrate, frame rate, host audio playback, and the optional stats overlay. + * @param host Selected paired host. + * @param app Selected host app. + * @param clientIdentity Paired client identity used for authenticated launch requests. + * @param statusMessage Output message describing the final session result. + * @return True when the stream session started successfully. + */ + bool run_stream_session( + SDL_Window *window, + const VIDEO_MODE &videoMode, + const app::SettingsState &settings, + const app::HostRecord &host, + const app::HostAppRecord &app, + const network::PairingIdentity &clientIdentity, + std::string *statusMessage + ); + +} // namespace streaming diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 1115a43..9678a4e 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -43,6 +43,7 @@ #include "src/startup/cover_art_cache.h" #include "src/startup/host_storage.h" #include "src/startup/saved_files.h" +#include "src/streaming/session.h" #include "src/ui/host_probe_result_queue.h" #include "src/ui/shell_view.h" @@ -2160,6 +2161,12 @@ namespace { state.settings.loggingLevel, state.settings.xemuConsoleLoggingLevel, state.settings.logViewerPlacement, + state.settings.preferredVideoMode, + state.settings.preferredVideoModeSet, + state.settings.streamFramerate, + state.settings.streamBitrateKbps, + state.settings.playAudioOnPc, + state.settings.showPerformanceStats, }; } @@ -2178,6 +2185,44 @@ namespace { logging::error("settings", saveResult.errorMessage); } + /** + * @brief Return whether two Xbox video modes describe the same output mode. + * + * @param left First video mode to compare. + * @param right Second video mode to compare. + * @return True when the modes match exactly. + */ + bool video_modes_match(const VIDEO_MODE &left, const VIDEO_MODE &right) { + return left.width == right.width && left.height == right.height && left.bpp == right.bpp && left.refresh == right.refresh; + } + + /** + * @brief Switch the shared SDL window to the requested Xbox video mode. + * + * @param window Shared SDL window reused for the shell and streaming session. + * @param videoMode Requested Xbox output mode. + * @param errorMessage Receives a user-visible error when the mode change fails. + * @return True when the requested mode is now active. + */ + bool apply_shell_video_mode(SDL_Window *window, const VIDEO_MODE &videoMode, std::string *errorMessage) { + if (videoMode.width <= 0 || videoMode.height <= 0) { + return true; + } + + if (!XVideoSetMode(videoMode.width, videoMode.height, videoMode.bpp, videoMode.refresh)) { + return platform::append_error( + errorMessage, + "Failed to switch Xbox video mode to " + std::to_string(videoMode.width) + "x" + std::to_string(videoMode.height) + " @ " + std::to_string(videoMode.refresh) + " Hz" + ); + } + + if (window != nullptr) { + SDL_SetWindowSize(window, videoMode.width, videoMode.height); + } + + return true; + } + bool update_host_metadata_from_server_info(app::HostRecord *host, const std::string &address, const network::HostPairingServerInfo &serverInfo) { if (host == nullptr) { return false; @@ -4325,6 +4370,10 @@ namespace { (void) threadResult; } + struct ShellRuntimeState; + + void finalize_shell_tasks(ShellRuntimeState *runtime); + /** * @brief Open the first detected SDL game controller for shell navigation. * @@ -4534,6 +4583,7 @@ namespace { /** * @brief Apply a single translated UI command inside the shell loop. * + * @param window Shared SDL window reused when entering a streaming session. * @param videoMode Active output mode used by the shell renderer. * @param state Client state to mutate. * @param command UI command to process. @@ -4541,7 +4591,8 @@ namespace { * @param runtime Runtime state that owns background tasks and redraw flags. */ void process_shell_command( - const VIDEO_MODE &videoMode, + SDL_Window *window, + VIDEO_MODE *videoMode, app::ClientState &state, input::UiCommand command, ShellResources *resources, @@ -4570,11 +4621,52 @@ namespace { persist_settings_if_needed(state, update); persist_hosts_if_needed(state, update); + if (update.requests.streamLaunchRequested) { + const app::HostRecord *streamHost = app::apps_host(state); + const app::HostAppRecord *streamApp = app::selected_app(state); + if (streamHost == nullptr || streamApp == nullptr) { + state.shell.statusMessage = "Select a valid app before starting a stream."; + logging::warn("stream", state.shell.statusMessage); + } else { + network::PairingIdentity clientIdentity {}; + std::string identityError; + if (!load_saved_pairing_identity_for_streaming(&clientIdentity, &identityError)) { + state.shell.statusMessage = identityError; + logging::warn("stream", identityError); + } else if (window == nullptr) { + state.shell.statusMessage = "Streaming requires a valid SDL window."; + logging::error("stream", state.shell.statusMessage); + } else { + finalize_shell_tasks(runtime); + close_shell_resources(resources); + + std::string sessionMessage; + const VIDEO_MODE activeVideoMode = videoMode != nullptr ? *videoMode : VIDEO_MODE {}; + const bool streamStarted = streaming::run_stream_session(window, activeVideoMode, state.settings, *streamHost, *streamApp, clientIdentity, &sessionMessage); + state.shell.statusMessage = sessionMessage; + + if (ShellInitializationFailure initializationFailure {}; !initialize_shell_resources(window, activeVideoMode, resources, &initializationFailure)) { + report_shell_failure(initializationFailure.category, initializationFailure.message); + runtime->running = false; + state.shell.shouldExit = true; + return; + } + + initialize_shell_runtime(state, runtime); + if (streamStarted && state.hosts.activeLoaded) { + state.hosts.active.appListState = app::HostAppListState::loading; + state.hosts.active.appListStatusMessage = "Refreshing host apps after the stream session..."; + state.hosts.active.lastAppListRefreshTick = 0U; + } + } + } + } + if (previousScreen != state.shell.activeScreen) { release_page_resources_for_screen(previousScreen, state.shell.activeScreen, &resources->coverArtTextureCache, &resources->keypadModalLayoutCache); ensure_hosts_loaded_for_active_screen(state); } - if ((previousScreen != state.shell.activeScreen || update.navigation.screenChanged) && !draw_current_shell_frame(videoMode, state, resources, runtime)) { + if ((previousScreen != state.shell.activeScreen || update.navigation.screenChanged) && !draw_current_shell_frame(videoMode != nullptr ? *videoMode : VIDEO_MODE {}, state, resources, runtime)) { return; } if (state.shell.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible) { @@ -4585,19 +4677,20 @@ namespace { /** * @brief Process one shell frame, including input, background tasks, and redraws. * + * @param window Shared SDL window reused for shell and streaming rendering. * @param videoMode Active output mode used by the shell renderer. * @param state Client state to update. * @param resources Shell resources used by the frame. * @param runtime Runtime state that carries input and task progress. * @return True when the shell should continue processing future frames. */ - bool run_shell_frame(const VIDEO_MODE &videoMode, app::ClientState &state, ShellResources *resources, ShellRuntimeState *runtime) { - if (resources == nullptr || runtime == nullptr) { + bool run_shell_frame(SDL_Window *window, VIDEO_MODE *videoMode, app::ClientState &state, ShellResources *resources, ShellRuntimeState *runtime) { + if (window == nullptr || resources == nullptr || runtime == nullptr) { return false; } - const auto processCommand = [&state, &videoMode, resources, runtime](input::UiCommand command) { - process_shell_command(videoMode, state, command, resources, runtime); + const auto processCommand = [window, &state, videoMode, resources, runtime](input::UiCommand command) { + process_shell_command(window, videoMode, state, command, resources, runtime); }; ensure_hosts_loaded_for_active_screen(state); @@ -4616,7 +4709,7 @@ namespace { start_shell_background_tasks_if_needed(state, runtime, SDL_GetTicks()); if ((state.shell.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible || runtime->keypadRedrawRequested) && - !draw_current_shell_frame(videoMode, state, resources, runtime)) { + !draw_current_shell_frame(videoMode != nullptr ? *videoMode : VIDEO_MODE {}, state, resources, runtime)) { return false; } @@ -4671,8 +4764,10 @@ namespace ui { return report_shell_failure("sdl", "Shell requires a valid SDL window"); } + VIDEO_MODE activeVideoMode = videoMode; + ShellResources resources {}; - if (ShellInitializationFailure initializationFailure {}; !initialize_shell_resources(window, videoMode, &resources, &initializationFailure)) { + if (ShellInitializationFailure initializationFailure {}; !initialize_shell_resources(window, activeVideoMode, &resources, &initializationFailure)) { return report_shell_failure(initializationFailure.category, initializationFailure.message); } @@ -4680,7 +4775,7 @@ namespace ui { initialize_shell_runtime(state, &runtime); while (runtime.running && !state.shell.shouldExit) { - if (!run_shell_frame(videoMode, state, &resources, &runtime)) { + if (!run_shell_frame(window, &activeVideoMode, state, &resources, &runtime)) { break; } } diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index afe3c56..a34d8ed 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -978,8 +978,14 @@ namespace { EXPECT_TRUE(state.hosts.active.apps.front().favorite); } - TEST(ClientStateTest, SettingsPlaceholderActivationAndBackNavigationUpdateFocusAndStatus) { + TEST(ClientStateTest, DisplaySettingsCanBeActivatedAndBackNavigationReturnsFocusToCategories) { app::ClientState state = app::create_initial_state(); + state.settings.availableVideoModes = { + VIDEO_MODE {640, 480, 32, 60}, + VIDEO_MODE {1280, 720, 32, 60}, + }; + state.settings.preferredVideoMode = state.settings.availableVideoModes.front(); + state.settings.preferredVideoModeSet = true; app::handle_command(state, input::UiCommand::move_left); app::handle_command(state, input::UiCommand::move_left); app::handle_command(state, input::UiCommand::activate); @@ -991,13 +997,44 @@ namespace { EXPECT_EQ(state.settings.focusArea, app::SettingsFocusArea::options); update = app::handle_command(state, input::UiCommand::activate); - EXPECT_EQ(state.shell.statusMessage, "display-placeholder is not implemented yet"); + EXPECT_EQ(update.navigation.activatedItemId, "cycle-stream-video-mode"); + EXPECT_TRUE(update.persistence.settingsChanged); + EXPECT_EQ(state.settings.preferredVideoMode.width, 1280); + EXPECT_EQ(state.settings.preferredVideoMode.height, 720); update = app::handle_command(state, input::UiCommand::back); EXPECT_EQ(state.settings.focusArea, app::SettingsFocusArea::categories); EXPECT_FALSE(update.navigation.screenChanged); } + TEST(ClientStateTest, DisplaySettingsCanCycleLowStreamResolutionPresets) { + app::ClientState state = app::create_initial_state(); + state.settings.availableVideoModes = { + VIDEO_MODE {352, 240, 32, 60}, + VIDEO_MODE {352, 288, 32, 60}, + VIDEO_MODE {480, 480, 32, 60}, + }; + state.settings.preferredVideoMode = state.settings.availableVideoModes.front(); + state.settings.preferredVideoModeSet = true; + + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.settings.selectedCategory, app::SettingsCategory::display); + ASSERT_EQ(state.settings.focusArea, app::SettingsFocusArea::options); + + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(update.navigation.activatedItemId, "cycle-stream-video-mode"); + EXPECT_TRUE(update.persistence.settingsChanged); + EXPECT_EQ(state.settings.preferredVideoMode.width, 352); + EXPECT_EQ(state.settings.preferredVideoMode.height, 288); + EXPECT_EQ(state.shell.statusMessage, "Stream resolution set to 352x288"); + } + TEST(ClientStateTest, ConfirmationModalCanBeCancelledWithoutRequestingPersistenceChanges) { app::ClientState state = app::create_initial_state(); app::handle_command(state, input::UiCommand::move_left); @@ -1037,7 +1074,8 @@ namespace { app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); EXPECT_EQ(update.navigation.activatedItemId, "launch-app"); - EXPECT_EQ(state.shell.statusMessage, "Launching Steam is not implemented yet"); + EXPECT_TRUE(update.requests.streamLaunchRequested); + EXPECT_EQ(state.shell.statusMessage, "Starting stream for Steam..."); update = app::handle_command(state, input::UiCommand::delete_character); EXPECT_TRUE(state.shell.statusMessage.empty()); diff --git a/tests/unit/app/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp index ca87480..b4294bd 100644 --- a/tests/unit/app/settings_storage_test.cpp +++ b/tests/unit/app/settings_storage_test.cpp @@ -45,6 +45,12 @@ namespace { logging::LogLevel::debug, logging::LogLevel::warning, app::LogViewerPlacement::left, + VIDEO_MODE {1280, 720, 32, 60}, + true, + 24, + 2500, + true, + true, }; const app::SaveAppSettingsResult saveResult = app::save_app_settings(savedSettings, settingsPath); @@ -56,9 +62,45 @@ namespace { EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::debug); EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::warning); EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::left); + EXPECT_TRUE(loadResult.settings.preferredVideoModeSet); + EXPECT_EQ(loadResult.settings.preferredVideoMode.width, 1280); + EXPECT_EQ(loadResult.settings.preferredVideoMode.height, 720); + EXPECT_EQ(loadResult.settings.preferredVideoMode.refresh, 60); + EXPECT_EQ(loadResult.settings.streamFramerate, 24); + EXPECT_EQ(loadResult.settings.streamBitrateKbps, 2500); + EXPECT_TRUE(loadResult.settings.playAudioOnPc); + EXPECT_TRUE(loadResult.settings.showPerformanceStats); EXPECT_FALSE(loadResult.cleanupRequired); } + TEST_F(SettingsStorageTest, SavesAndLoadsLowStreamResolutionPresets) { + const app::AppSettings savedSettings { + logging::LogLevel::none, + logging::LogLevel::none, + app::LogViewerPlacement::full, + VIDEO_MODE {352, 240, 32, 60}, + true, + 15, + 500, + false, + false, + }; + + const app::SaveAppSettingsResult saveResult = app::save_app_settings(savedSettings, settingsPath); + ASSERT_TRUE(saveResult.success) << saveResult.errorMessage; + + const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); + EXPECT_TRUE(loadResult.fileFound); + EXPECT_TRUE(loadResult.warnings.empty()); + EXPECT_TRUE(loadResult.settings.preferredVideoModeSet); + EXPECT_EQ(loadResult.settings.preferredVideoMode.width, 352); + EXPECT_EQ(loadResult.settings.preferredVideoMode.height, 240); + EXPECT_EQ(loadResult.settings.preferredVideoMode.bpp, 32); + EXPECT_EQ(loadResult.settings.preferredVideoMode.refresh, 60); + EXPECT_EQ(loadResult.settings.streamFramerate, 15); + EXPECT_EQ(loadResult.settings.streamBitrateKbps, 500); + } + TEST_F(SettingsStorageTest, MissingFilesReturnDefaultsWithoutWarnings) { const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); @@ -68,6 +110,11 @@ namespace { EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::full); + EXPECT_FALSE(loadResult.settings.preferredVideoModeSet); + EXPECT_EQ(loadResult.settings.streamFramerate, 20); + EXPECT_EQ(loadResult.settings.streamBitrateKbps, 1500); + EXPECT_FALSE(loadResult.settings.playAudioOnPc); + EXPECT_FALSE(loadResult.settings.showPerformanceStats); } TEST_F(SettingsStorageTest, InvalidValuesFallBackToDefaultsWithWarnings) { @@ -79,16 +126,23 @@ namespace { "[debug]\n" "startup_console_enabled = \"sometimes\"\n\n" "[ui]\n" - "log_viewer_placement = \"top\"\n" + "log_viewer_placement = \"top\"\n\n" + "[streaming]\n" + "video_width = \"wide\"\n" + "fps = \"fast\"\n" + "play_audio_on_pc = \"sometimes\"\n" ); const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); EXPECT_TRUE(loadResult.fileFound); - EXPECT_GE(loadResult.warnings.size(), 3U); + EXPECT_GE(loadResult.warnings.size(), 6U); EXPECT_TRUE(loadResult.cleanupRequired); EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::full); + EXPECT_FALSE(loadResult.settings.preferredVideoModeSet); + EXPECT_EQ(loadResult.settings.streamFramerate, 20); + EXPECT_FALSE(loadResult.settings.playAudioOnPc); } TEST_F(SettingsStorageTest, LegacyLoggingKeyLoadsAndRequestsCleanup) { @@ -135,7 +189,10 @@ namespace { "file_minimum_level = \"info\"\n" "obsolete_key = true\n\n" "[ui]\n" - "log_viewer_placement = \"left\"\n" + "log_viewer_placement = \"left\"\n\n" + "[streaming]\n" + "fps = 30\n" + "obsolete_key = true\n" "theme = \"green\"\n\n" "[debug]\n" "startup_console_enabled = true\n\n" @@ -147,9 +204,10 @@ namespace { EXPECT_TRUE(loadResult.fileFound); EXPECT_TRUE(loadResult.cleanupRequired); - EXPECT_GE(loadResult.warnings.size(), 4U); + EXPECT_GE(loadResult.warnings.size(), 5U); EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::info); EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::left); + EXPECT_EQ(loadResult.settings.streamFramerate, 30); } TEST_F(SettingsStorageTest, ReportsParseAndTypeErrorsAsWarnings) { @@ -159,15 +217,18 @@ namespace { "file_minimum_level = 7\n" "xemu_console_minimum_level = false\n\n" "[ui]\n" - "log_viewer_placement = 42\n" + "log_viewer_placement = 42\n\n" + "[streaming]\n" + "show_performance_stats = \"sometimes\"\n" ); app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); EXPECT_TRUE(loadResult.fileFound); - EXPECT_GE(loadResult.warnings.size(), 3U); + EXPECT_GE(loadResult.warnings.size(), 4U); EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::full); + EXPECT_FALSE(loadResult.settings.showPerformanceStats); write_text_file(settingsPath, "[logging\nfile_minimum_level = \"info\"\n"); loadResult = app::load_app_settings(settingsPath); diff --git a/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index e56f946..fdc0eca 100644 --- a/tests/unit/network/host_pairing_test.cpp +++ b/tests/unit/network/host_pairing_test.cpp @@ -22,6 +22,9 @@ #include #include +// third-party includes +#include "third-party/moonlight-common-c/src/Limelight.h" + // test includes #include "tests/support/network_test_constants.h" @@ -256,8 +259,17 @@ namespace { return bytes; } - std::string make_server_info_xml(bool paired, uint16_t httpPort, uint16_t httpsPort, std::string_view hostName = "Scripted Host", std::string_view uuid = "scripted-host") { - return "" + std::string(hostName) + "7.1.0.0" + std::string(uuid) + "" + + std::string make_server_info_xml( + bool paired, + uint16_t httpPort, + uint16_t httpsPort, + std::string_view hostName = "Scripted Host", + std::string_view uuid = "scripted-host", + int serverCodecModeSupport = SCM_H264, + std::string_view gfeVersion = "99.0.0" + ) { + return "" + std::string(hostName) + "7.1.0.0" + std::string(gfeVersion) + "" + + std::to_string(serverCodecModeSupport) + "" + std::string(uuid) + "" + std::string(test_support::kTestIpv4Addresses[test_support::kIpServerLocal]) + "" + std::string(test_support::kTestIpv4Addresses[test_support::kIpServerExternal]) + "" + std::to_string(httpPort) + "" + std::to_string(httpsPort) + "" + (paired ? "1" : "0") + ""; } @@ -465,9 +477,12 @@ namespace { ASSERT_TRUE(network::parse_server_info_response(xml, test_support::kTestPorts[test_support::kPortPairing], &serverInfo, &errorMessage)) << errorMessage; EXPECT_EQ(serverInfo.serverMajorVersion, 7); + EXPECT_EQ(serverInfo.appVersion, "7.1.431.0"); + EXPECT_TRUE(serverInfo.gfeVersion.empty()); EXPECT_EQ(serverInfo.httpPort, test_support::kTestPorts[test_support::kPortResolvedHttp]); EXPECT_EQ(serverInfo.httpsPort, test_support::kTestPorts[test_support::kPortResolvedHttps]); EXPECT_TRUE(serverInfo.paired); + EXPECT_EQ(serverInfo.serverCodecModeSupport, SCM_H264); EXPECT_EQ(serverInfo.hostName, "Sunshine-PC"); EXPECT_EQ(serverInfo.uuid, "host-uuid-123"); EXPECT_EQ(serverInfo.activeAddress, test_support::kTestIpv4Addresses[test_support::kIpServerLocal]); @@ -511,9 +526,11 @@ namespace { ASSERT_TRUE(network::parse_server_info_response(xml, test_support::kTestPorts[test_support::kPortPairing], &serverInfo, &errorMessage)) << errorMessage; EXPECT_EQ(serverInfo.serverMajorVersion, 8); + EXPECT_EQ(serverInfo.appVersion, "8.2.0.0"); EXPECT_EQ(serverInfo.httpPort, test_support::kTestPorts[test_support::kPortPairing]); - EXPECT_EQ(serverInfo.httpsPort, test_support::kTestPorts[test_support::kPortPairing]); + EXPECT_EQ(serverInfo.httpsPort, 47990U); EXPECT_FALSE(serverInfo.paired); + EXPECT_EQ(serverInfo.serverCodecModeSupport, SCM_H264); EXPECT_EQ(serverInfo.hostName, "Bedroom PC"); EXPECT_EQ(serverInfo.uuid, "host-uuid-456"); EXPECT_EQ(serverInfo.activeAddress, test_support::kTestIpv4Addresses[test_support::kIpLocalFallback]); @@ -551,6 +568,25 @@ namespace { ); } + TEST(HostPairingTest, ParsesServerCodecModeSupportAndGfeVersionWhenReported) { + const std::string xml = + "" + "Codec Host" + "7.2.0.0" + "Sunshine-2026.4.19" + "257" + "47990" + "1" + ""; + + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + ASSERT_TRUE(network::parse_server_info_response(xml, test_support::kTestPorts[test_support::kPortPairing], &serverInfo, &errorMessage)) << errorMessage; + EXPECT_EQ(serverInfo.gfeVersion, "Sunshine-2026.4.19"); + EXPECT_EQ(serverInfo.serverCodecModeSupport, 257); + } + TEST(HostPairingTest, FallsBackToReportedAddressWhenRequestedAddressIsMissing) { network::HostPairingServerInfo serverInfo {}; serverInfo.activeAddress = test_support::kTestIpv4Addresses[test_support::kIpExternalFallback]; @@ -854,6 +890,208 @@ namespace { EXPECT_NE(errorMessage.find("serverinfo unavailable"), std::string::npos); } + TEST(HostPairingTest, LaunchesANewStreamSessionWithAuthenticatedHttpsRequest) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + ScriptedHostPairingHttpHandler handler({ + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_FALSE(request.useTls); + EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + identity.uniqueId), std::string::npos); + }, + true, + 200, + make_server_info_xml(true, 47989U, 47990U, "Launch Host", "launch-host", SCM_H264 | SCM_HEVC, "Sunshine-2026.4.19"), + }, + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_TRUE(request.useTls); + ASSERT_NE(request.tlsClientIdentity, nullptr); + EXPECT_EQ(request.tlsClientIdentity->uniqueId, identity.uniqueId); + EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + identity.uniqueId), std::string::npos); + }, + true, + 200, + make_server_info_xml(true, 47989U, 47990U, "Launch Host", "launch-host", SCM_H264 | SCM_HEVC, "Sunshine-2026.4.19"), + }, + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_TRUE(request.useTls); + ASSERT_NE(request.tlsClientIdentity, nullptr); + EXPECT_EQ(request.tlsClientIdentity->uniqueId, identity.uniqueId); + EXPECT_NE(request.pathAndQuery.find("/launch?uniqueid=" + identity.uniqueId), std::string::npos); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "appid"), "101"); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "mode"), "640x480x30"); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "localAudioPlayMode"), "1"); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "surroundAudioInfo"), std::to_string(SURROUNDAUDIOINFO_FROM_AUDIO_CONFIGURATION(AUDIO_CONFIGURATION_STEREO))); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "rikey"), std::string(32U, '1')); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "rikeyid"), std::string(32U, '2')); + }, + true, + 200, + "rtsp://10.0.0.2:480107.1.431.0Sunshine-2026.4.19257", + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + network::StreamLaunchResult result {}; + std::string errorMessage; + + ASSERT_TRUE( + network::launch_or_resume_stream( + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + test_support::kTestPorts[test_support::kPortPairing], + identity, + { + 101, + 640, + 480, + 30, + AUDIO_CONFIGURATION_STEREO, + true, + 6000, + std::string(32U, '1'), + std::string(32U, '2'), + }, + &result, + &errorMessage + ) + ) << errorMessage; + EXPECT_TRUE(handler.all_consumed()); + EXPECT_FALSE(result.resumedSession); + EXPECT_EQ(result.rtspSessionUrl, "rtsp://10.0.0.2:48010"); + EXPECT_EQ(result.appVersion, "7.1.431.0"); + EXPECT_EQ(result.gfeVersion, "Sunshine-2026.4.19"); + EXPECT_EQ(result.serverCodecModeSupport, 257); + } + + TEST(HostPairingTest, LaunchPreservesTheReachableRequestAddressForStreaming) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + ScriptedHostPairingHttpHandler handler({ + { + {}, + true, + 200, + std::string("Loopback Host7.1.431.0Sunshine-2026.4.19257127.0.0.1203.0.113.25479901"), + }, + { + {}, + true, + 200, + std::string("Loopback Host7.1.431.0Sunshine-2026.4.19257127.0.0.1203.0.113.25479901"), + }, + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_TRUE(request.useTls); + ASSERT_NE(request.tlsClientIdentity, nullptr); + EXPECT_EQ(request.tlsClientIdentity->uniqueId, identity.uniqueId); + EXPECT_EQ(request.address, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_NE(request.pathAndQuery.find("/launch?uniqueid=" + identity.uniqueId), std::string::npos); + }, + true, + 200, + "rtsp://127.0.0.1:480107.1.431.0Sunshine-2026.4.19257", + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + network::StreamLaunchResult result {}; + std::string errorMessage; + + ASSERT_TRUE( + network::launch_or_resume_stream( + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + test_support::kTestPorts[test_support::kPortPairing], + identity, + { + 101, + 640, + 480, + 30, + AUDIO_CONFIGURATION_STEREO, + false, + 6000, + std::string(32U, '1'), + std::string(32U, '2'), + }, + &result, + &errorMessage + ) + ) << errorMessage; + + EXPECT_TRUE(handler.all_consumed()); + EXPECT_EQ(result.serverInfo.localAddress, "127.0.0.1"); + EXPECT_EQ(result.serverInfo.activeAddress, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_EQ(result.rtspSessionUrl, "rtsp://127.0.0.1:48010"); + } + + TEST(HostPairingTest, ResumesTheRunningSessionWhenTheRequestedAppIsAlreadyActive) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + const std::string serverInfoXml = + "Resume Host7.1.0.0110147989479901"; + + ScriptedHostPairingHttpHandler handler({ + { + {}, + true, + 200, + serverInfoXml, + }, + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_TRUE(request.useTls); + EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + identity.uniqueId), std::string::npos); + }, + true, + 200, + serverInfoXml, + }, + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_TRUE(request.useTls); + EXPECT_NE(request.pathAndQuery.find("/resume?uniqueid=" + identity.uniqueId), std::string::npos); + EXPECT_TRUE(extract_query_parameter(request.pathAndQuery, "appid").empty()); + EXPECT_TRUE(extract_query_parameter(request.pathAndQuery, "mode").empty()); + }, + true, + 200, + "rtsp://10.0.0.2:480107.1.0.0", + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + network::StreamLaunchResult result {}; + std::string errorMessage; + + ASSERT_TRUE( + network::launch_or_resume_stream( + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + test_support::kTestPorts[test_support::kPortPairing], + identity, + { + 101, + 640, + 480, + 30, + AUDIO_CONFIGURATION_STEREO, + false, + 6000, + std::string(32U, 'a'), + std::string(32U, 'b'), + }, + &result, + &errorMessage + ) + ) << errorMessage; + EXPECT_TRUE(result.resumedSession); + EXPECT_TRUE(handler.all_consumed()); + } + TEST(HostPairingTest, QueryAppListMapsUnauthorizedHttpResponsesToTheUnpairedMessage) { ScriptedHostPairingHttpHandler handler({ { diff --git a/tests/unit/startup/video_mode_test.cpp b/tests/unit/startup/video_mode_test.cpp index a198968..552bd95 100644 --- a/tests/unit/startup/video_mode_test.cpp +++ b/tests/unit/startup/video_mode_test.cpp @@ -60,4 +60,49 @@ namespace { EXPECT_EQ(bestVideoMode.refresh, 60); } + TEST(VideoModeTest, ExposesFixedStreamResolutionPresetsIncludingLowModes) { + const std::vector presets = startup::stream_resolution_presets(32, 60); + + ASSERT_EQ(presets.size(), 9U); + EXPECT_EQ(presets.front().width, 352); + EXPECT_EQ(presets.front().height, 240); + EXPECT_EQ(presets[1].width, 352); + EXPECT_EQ(presets[1].height, 288); + EXPECT_EQ(presets[4].width, 720); + EXPECT_EQ(presets[4].height, 480); + EXPECT_EQ(presets[7].width, 1280); + EXPECT_EQ(presets[7].height, 720); + EXPECT_EQ(presets.back().width, 1920); + EXPECT_EQ(presets.back().height, 1080); + EXPECT_EQ(presets.back().bpp, 32); + EXPECT_EQ(presets.back().refresh, 60); + } + + TEST(VideoModeTest, Chooses720pAsTheDefaultHdStreamPreset) { + const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({1280, 720, 32, 60}); + + EXPECT_EQ(defaultPreset.width, 1280); + EXPECT_EQ(defaultPreset.height, 720); + EXPECT_EQ(defaultPreset.bpp, 32); + EXPECT_EQ(defaultPreset.refresh, 60); + } + + TEST(VideoModeTest, ChoosesNtscSdPresetFor60HzOutputModes) { + const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({640, 480, 32, 60}); + + EXPECT_EQ(defaultPreset.width, 720); + EXPECT_EQ(defaultPreset.height, 480); + EXPECT_EQ(defaultPreset.bpp, 32); + EXPECT_EQ(defaultPreset.refresh, 60); + } + + TEST(VideoModeTest, ChoosesPalSdPresetFor50HzOutputModes) { + const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({640, 480, 32, 50}); + + EXPECT_EQ(defaultPreset.width, 720); + EXPECT_EQ(defaultPreset.height, 576); + EXPECT_EQ(defaultPreset.bpp, 32); + EXPECT_EQ(defaultPreset.refresh, 50); + } + } // namespace diff --git a/tests/unit/ui/host_probe_result_queue_test.cpp b/tests/unit/ui/host_probe_result_queue_test.cpp index 022c9fb..22b401a 100644 --- a/tests/unit/ui/host_probe_result_queue_test.cpp +++ b/tests/unit/ui/host_probe_result_queue_test.cpp @@ -18,6 +18,14 @@ namespace { + network::HostPairingServerInfo make_probe_server_info(std::string_view hostName) { + network::HostPairingServerInfo serverInfo {}; + serverInfo.httpPort = test_support::kTestPorts[test_support::kPortResolvedHttp]; + serverInfo.httpsPort = test_support::kTestPorts[test_support::kPortResolvedHttps]; + serverInfo.hostName = hostName; + return serverInfo; + } + TEST(HostProbeResultQueueTest, DrainsPublishedResultsBeforeTheRoundCompletes) { ui::HostProbeResultQueue queue {}; ui::begin_host_probe_result_round(&queue, 3U); @@ -26,7 +34,7 @@ namespace { test_support::kTestIpv4Addresses[test_support::kIpHostGridA], test_support::kTestPorts[test_support::kPortDefaultHost], true, - {0, test_support::kTestPorts[test_support::kPortResolvedHttp], test_support::kTestPorts[test_support::kPortResolvedHttps], false, false, false, "Host A"}, + make_probe_server_info("Host A"), }); std::vector drainedResults = ui::drain_host_probe_results(&queue); @@ -45,7 +53,7 @@ namespace { test_support::kTestIpv4Addresses[test_support::kIpHostGridC], test_support::kTestPorts[test_support::kPortDefaultHost], true, - {0, test_support::kTestPorts[test_support::kPortResolvedHttp], test_support::kTestPorts[test_support::kPortResolvedHttps], false, false, false, "Host C"}, + make_probe_server_info("Host C"), }); drainedResults = ui::drain_host_probe_results(&queue); @@ -64,7 +72,7 @@ namespace { test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], true, - {0, test_support::kTestPorts[test_support::kPortResolvedHttp], test_support::kTestPorts[test_support::kPortResolvedHttps], false, false, false, "Office PC"}, + make_probe_server_info("Office PC"), }); const std::vector drainedResults = ui::drain_host_probe_results(&queue); diff --git a/third-party/ffmpeg b/third-party/ffmpeg new file mode 160000 index 0000000..9047fa1 --- /dev/null +++ b/third-party/ffmpeg @@ -0,0 +1 @@ +Subproject commit 9047fa1b084f76b1b4d065af2d743df1b40dfb56 diff --git a/third-party/nxdk b/third-party/nxdk index e7cc20b..9d174a4 160000 --- a/third-party/nxdk +++ b/third-party/nxdk @@ -1 +1 @@ -Subproject commit e7cc20be2e9f6f87fda06655e752ef62afa92313 +Subproject commit 9d174a4df442c7527d2e361aefe59ac7894ebb42 From 898aa8cfebfeb2b0472ae94786653dce7c32b4b8 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 11:30:47 -0400 Subject: [PATCH 02/19] Video decoding worker thread; input polling thread Move video decode work off the render loop by adding a prioritized SDL worker thread for video decoding and refactor decode logic. Introduce run_video_decode_thread, decode_video_decode_unit, receive/publish helpers, packet buffering and frame-versioning to avoid unnecessary copies and only upload textures when a new frame or size change occurs. Expose submit_video_decode_unit as a thin wrapper and mark pull-renderer capability; add proper start/stop/join handling for the decoder thread. Add controllerMutex and a separate input polling thread to safely read/send controller snapshots and detect stream-exit combo (with a fallback poll when the thread can't start). Misc: SDL texture/format handling, resource cleanup updates, and various safety/validation checks and logging. --- src/streaming/ffmpeg_stream_backend.cpp | 352 +++++++++++++++--------- src/streaming/ffmpeg_stream_backend.h | 46 ++++ src/streaming/session.cpp | 138 +++++++++- 3 files changed, 398 insertions(+), 138 deletions(-) diff --git a/src/streaming/ffmpeg_stream_backend.cpp b/src/streaming/ffmpeg_stream_backend.cpp index 3938ae5..582902c 100644 --- a/src/streaming/ffmpeg_stream_backend.cpp +++ b/src/streaming/ffmpeg_stream_backend.cpp @@ -307,8 +307,8 @@ namespace streaming { videoCallbacks->start = on_video_start; videoCallbacks->stop = on_video_stop; videoCallbacks->cleanup = on_video_cleanup; - videoCallbacks->submitDecodeUnit = on_video_submit_decode_unit; - videoCallbacks->capabilities = 0; + videoCallbacks->submitDecodeUnit = nullptr; + videoCallbacks->capabilities = CAPABILITY_PULL_RENDERER; } if (audioCallbacks != nullptr) { @@ -341,46 +341,55 @@ namespace streaming { return false; } - LatestVideoFrame frameSnapshot {}; - { + bool textureNeedsUpload = false; + const std::uint64_t publishedFrameVersion = video_.publishedFrameVersion.load(); + if (publishedFrameVersion != video_.renderedFrameVersion) { std::lock_guard lock(video_.frameMutex); - if (video_.latestFrame.width <= 0 || video_.latestFrame.height <= 0) { - return false; + if (video_.latestFrameVersion != video_.renderedFrameVersion && video_.latestFrame.width > 0 && video_.latestFrame.height > 0) { + std::swap(video_.renderFrame, video_.latestFrame); + video_.renderedFrameVersion = video_.latestFrameVersion; + textureNeedsUpload = true; } - frameSnapshot = video_.latestFrame; } - if (video_.texture == nullptr || video_.textureWidth != frameSnapshot.width || video_.textureHeight != frameSnapshot.height) { + if (video_.renderFrame.width <= 0 || video_.renderFrame.height <= 0) { + return false; + } + + if (video_.texture == nullptr || video_.textureWidth != video_.renderFrame.width || video_.textureHeight != video_.renderFrame.height) { if (video_.texture != nullptr) { SDL_DestroyTexture(video_.texture); video_.texture = nullptr; } - video_.texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_IYUV, SDL_TEXTUREACCESS_STREAMING, frameSnapshot.width, frameSnapshot.height); + video_.texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_IYUV, SDL_TEXTUREACCESS_STREAMING, video_.renderFrame.width, video_.renderFrame.height); if (video_.texture == nullptr) { logging::error("stream", std::string("SDL_CreateTexture failed for video presentation: ") + SDL_GetError()); return false; } - video_.textureWidth = frameSnapshot.width; - video_.textureHeight = frameSnapshot.height; + video_.textureWidth = video_.renderFrame.width; + video_.textureHeight = video_.renderFrame.height; + textureNeedsUpload = true; } - if (SDL_UpdateYUVTexture( - video_.texture, - nullptr, - reinterpret_cast(frameSnapshot.yPlane.data()), - frameSnapshot.yPitch, - reinterpret_cast(frameSnapshot.uPlane.data()), - frameSnapshot.uPitch, - reinterpret_cast(frameSnapshot.vPlane.data()), - frameSnapshot.vPitch - ) != 0) { - logging::error("stream", std::string("SDL_UpdateYUVTexture failed during video presentation: ") + SDL_GetError()); - return false; + if (textureNeedsUpload) { + if (SDL_UpdateYUVTexture( + video_.texture, + nullptr, + reinterpret_cast(video_.renderFrame.yPlane.data()), + video_.renderFrame.yPitch, + reinterpret_cast(video_.renderFrame.uPlane.data()), + video_.renderFrame.uPitch, + reinterpret_cast(video_.renderFrame.vPlane.data()), + video_.renderFrame.vPitch + ) != 0) { + logging::error("stream", std::string("SDL_UpdateYUVTexture failed during video presentation: ") + SDL_GetError()); + return false; + } } - const SDL_Rect destination = build_letterboxed_destination(screenWidth, screenHeight, frameSnapshot.width, frameSnapshot.height); + const SDL_Rect destination = build_letterboxed_destination(screenWidth, screenHeight, video_.renderFrame.width, video_.renderFrame.height); return SDL_RenderCopy(renderer, video_.texture, nullptr, &destination) == 0; } @@ -427,12 +436,35 @@ namespace streaming { } void FfmpegStreamBackend::start_video_decoder() { + if (video_.decoderThread != nullptr) { + return; + } + + video_.decoderStopRequested.store(false); + video_.decoderThread = SDL_CreateThread(run_video_decode_thread_trampoline, "stream-video", this); + if (video_.decoderThread == nullptr) { + logging::error("stream", std::string("Failed to start the prioritized video decoder thread: ") + SDL_GetError()); + video_.decoderStopRequested.store(true); + } } void FfmpegStreamBackend::stop_video_decoder() { + if (video_.decoderThread == nullptr) { + return; + } + + video_.decoderStopRequested.store(true); + LiWakeWaitForVideoFrame(); + + int threadResult = 0; + SDL_WaitThread(video_.decoderThread, &threadResult); + (void) threadResult; + video_.decoderThread = nullptr; } void FfmpegStreamBackend::cleanup_video_decoder() { + stop_video_decoder(); + if (video_.texture != nullptr) { SDL_DestroyTexture(video_.texture); video_.texture = nullptr; @@ -458,35 +490,194 @@ namespace streaming { } video_.convertedBuffer.clear(); + video_.packetBuffer.clear(); + video_.renderFrame = LatestVideoFrame {}; + video_.decodeFrame = LatestVideoFrame {}; { std::lock_guard lock(video_.frameMutex); video_.latestFrame = LatestVideoFrame {}; + video_.latestFrameVersion = 0; } + video_.renderedFrameVersion = 0; + video_.publishedFrameVersion.store(0); + video_.decoderStopRequested.store(false); video_.hasFrame.store(false); video_.submittedDecodeUnitCount.store(0); video_.decodedFrameCount.store(0); } - int FfmpegStreamBackend::submit_video_decode_unit(PDECODE_UNIT decodeUnit) { + int FfmpegStreamBackend::run_video_decode_thread_trampoline(void *context) { + auto *backend = static_cast(context); + return backend != nullptr ? backend->run_video_decode_thread() : -1; + } + + int FfmpegStreamBackend::run_video_decode_thread() { + if (SDL_SetThreadPriority(SDL_THREAD_PRIORITY_TIME_CRITICAL) != 0) { + logging::debug("stream", std::string("SDL_SetThreadPriority failed for video decoder: ") + SDL_GetError()); + } + + while (!video_.decoderStopRequested.load()) { + VIDEO_FRAME_HANDLE frameHandle = nullptr; + PDECODE_UNIT decodeUnit = nullptr; + if (!LiWaitForNextVideoFrame(&frameHandle, &decodeUnit)) { + break; + } + + const int decodeStatus = video_.decoderStopRequested.load() || decodeUnit == nullptr ? DR_OK : decode_video_decode_unit(decodeUnit); + LiCompleteVideoFrame(frameHandle, decodeStatus); + } + + return 0; + } + + bool FfmpegStreamBackend::publish_video_frame(AVFrame *frameToPresent) { + if (frameToPresent == nullptr || frameToPresent->width <= 0 || frameToPresent->height <= 0 || frameToPresent->linesize[0] <= 0 || frameToPresent->linesize[1] <= 0 || frameToPresent->linesize[2] <= 0 || + frameToPresent->data[0] == nullptr || frameToPresent->data[1] == nullptr || frameToPresent->data[2] == nullptr) { + return false; + } + + LatestVideoFrame &nextFrame = video_.decodeFrame; + nextFrame.width = frameToPresent->width; + nextFrame.height = frameToPresent->height; + nextFrame.yPitch = frameToPresent->linesize[0]; + nextFrame.uPitch = frameToPresent->linesize[1]; + nextFrame.vPitch = frameToPresent->linesize[2]; + + const int chromaHeight = (frameToPresent->height + 1) / 2; + const std::size_t yPlaneSize = static_cast(frameToPresent->linesize[0]) * static_cast(frameToPresent->height); + const std::size_t uPlaneSize = static_cast(frameToPresent->linesize[1]) * static_cast(chromaHeight); + const std::size_t vPlaneSize = static_cast(frameToPresent->linesize[2]) * static_cast(chromaHeight); + + nextFrame.yPlane.resize(yPlaneSize); + nextFrame.uPlane.resize(uPlaneSize); + nextFrame.vPlane.resize(vPlaneSize); + std::memcpy(nextFrame.yPlane.data(), frameToPresent->data[0], nextFrame.yPlane.size()); + std::memcpy(nextFrame.uPlane.data(), frameToPresent->data[1], nextFrame.uPlane.size()); + std::memcpy(nextFrame.vPlane.data(), frameToPresent->data[2], nextFrame.vPlane.size()); + + { + std::lock_guard lock(video_.frameMutex); + std::swap(video_.latestFrame, video_.decodeFrame); + ++video_.latestFrameVersion; + video_.publishedFrameVersion.store(video_.latestFrameVersion); + } + + video_.hasFrame.store(true); + video_.decodedFrameCount.fetch_add(1); + return true; + } + + int FfmpegStreamBackend::receive_available_video_frames() { + while (true) { + const int receiveResult = avcodec_receive_frame(video_.codecContext, video_.decodedFrame); + if (receiveResult == AVERROR(EAGAIN) || receiveResult == AVERROR_EOF) { + return receiveResult; + } + if (receiveResult < 0) { + logging::warn("stream", std::string("avcodec_receive_frame failed for H.264: ") + describe_ffmpeg_error(receiveResult)); + return receiveResult; + } + + AVFrame *frameToPresent = video_.decodedFrame; + if (video_.decodedFrame->format != AV_PIX_FMT_YUV420P) { + video_.scaleContext = sws_getCachedContext( + video_.scaleContext, + video_.decodedFrame->width, + video_.decodedFrame->height, + static_cast(video_.decodedFrame->format), + video_.decodedFrame->width, + video_.decodedFrame->height, + AV_PIX_FMT_YUV420P, + SWS_FAST_BILINEAR, + nullptr, + nullptr, + nullptr + ); + if (video_.scaleContext == nullptr) { + logging::warn("stream", "sws_getCachedContext failed for video conversion"); + av_frame_unref(video_.decodedFrame); + return AVERROR(EINVAL); + } + + const int bufferSize = av_image_get_buffer_size(AV_PIX_FMT_YUV420P, video_.decodedFrame->width, video_.decodedFrame->height, 1); + if (bufferSize <= 0) { + logging::warn("stream", "av_image_get_buffer_size failed for converted video frame"); + av_frame_unref(video_.decodedFrame); + return AVERROR(EINVAL); + } + + video_.convertedBuffer.resize(static_cast(bufferSize)); + av_frame_unref(video_.convertedFrame); + video_.convertedFrame->format = AV_PIX_FMT_YUV420P; + video_.convertedFrame->width = video_.decodedFrame->width; + video_.convertedFrame->height = video_.decodedFrame->height; + const int fillResult = av_image_fill_arrays( + video_.convertedFrame->data, + video_.convertedFrame->linesize, + video_.convertedBuffer.data(), + AV_PIX_FMT_YUV420P, + video_.decodedFrame->width, + video_.decodedFrame->height, + 1 + ); + if (fillResult < 0) { + logging::warn("stream", std::string("av_image_fill_arrays failed for converted frame: ") + describe_ffmpeg_error(fillResult)); + av_frame_unref(video_.decodedFrame); + return fillResult; + } + + sws_scale( + video_.scaleContext, + video_.decodedFrame->data, + video_.decodedFrame->linesize, + 0, + video_.decodedFrame->height, + video_.convertedFrame->data, + video_.convertedFrame->linesize + ); + frameToPresent = video_.convertedFrame; + } + + if (!publish_video_frame(frameToPresent)) { + av_frame_unref(video_.decodedFrame); + return AVERROR(EINVAL); + } + + av_frame_unref(video_.decodedFrame); + } + } + + int FfmpegStreamBackend::decode_video_decode_unit(PDECODE_UNIT decodeUnit) { if (decodeUnit == nullptr || video_.codecContext == nullptr || video_.packet == nullptr || video_.decodedFrame == nullptr || video_.convertedFrame == nullptr) { return DR_NEED_IDR; } - const int packetResult = av_new_packet(video_.packet, decodeUnit->fullLength); - if (packetResult < 0) { - logging::error("stream", std::string("av_new_packet failed for video decode: ") + describe_ffmpeg_error(packetResult)); + if (decodeUnit->fullLength <= 0) { return DR_NEED_IDR; } + video_.packetBuffer.resize(static_cast(decodeUnit->fullLength) + AV_INPUT_BUFFER_PADDING_SIZE); int offset = 0; for (PLENTRY buffer = decodeUnit->bufferList; buffer != nullptr; buffer = buffer->next) { if (buffer->length <= 0) { continue; } - std::memcpy(video_.packet->data + offset, buffer->data, static_cast(buffer->length)); + if (offset + buffer->length > decodeUnit->fullLength) { + logging::warn("stream", "Moonlight supplied a video decode unit larger than its declared length"); + return DR_NEED_IDR; + } + + std::memcpy(video_.packetBuffer.data() + offset, buffer->data, static_cast(buffer->length)); offset += buffer->length; } + if (offset <= 0) { + return DR_NEED_IDR; + } + std::memset(video_.packetBuffer.data() + offset, 0, AV_INPUT_BUFFER_PADDING_SIZE); + + av_packet_unref(video_.packet); + video_.packet->data = video_.packetBuffer.data(); video_.packet->size = offset; const std::uint64_t submittedDecodeUnitCount = video_.submittedDecodeUnitCount.fetch_add(1) + 1; @@ -498,104 +689,9 @@ namespace streaming { ); } - const auto receive_available_frames = [&]() -> int { - while (true) { - const int receiveResult = avcodec_receive_frame(video_.codecContext, video_.decodedFrame); - if (receiveResult == AVERROR(EAGAIN) || receiveResult == AVERROR_EOF) { - return receiveResult; - } - if (receiveResult < 0) { - logging::warn("stream", std::string("avcodec_receive_frame failed for H.264: ") + describe_ffmpeg_error(receiveResult)); - return receiveResult; - } - - AVFrame *frameToPresent = video_.decodedFrame; - if (video_.decodedFrame->format != AV_PIX_FMT_YUV420P) { - video_.scaleContext = sws_getCachedContext( - video_.scaleContext, - video_.decodedFrame->width, - video_.decodedFrame->height, - static_cast(video_.decodedFrame->format), - video_.decodedFrame->width, - video_.decodedFrame->height, - AV_PIX_FMT_YUV420P, - SWS_FAST_BILINEAR, - nullptr, - nullptr, - nullptr - ); - if (video_.scaleContext == nullptr) { - logging::warn("stream", "sws_getCachedContext failed for video conversion"); - av_frame_unref(video_.decodedFrame); - return AVERROR(EINVAL); - } - - const int bufferSize = av_image_get_buffer_size(AV_PIX_FMT_YUV420P, video_.decodedFrame->width, video_.decodedFrame->height, 1); - if (bufferSize <= 0) { - logging::warn("stream", "av_image_get_buffer_size failed for converted video frame"); - av_frame_unref(video_.decodedFrame); - return AVERROR(EINVAL); - } - - video_.convertedBuffer.resize(static_cast(bufferSize)); - av_frame_unref(video_.convertedFrame); - video_.convertedFrame->format = AV_PIX_FMT_YUV420P; - video_.convertedFrame->width = video_.decodedFrame->width; - video_.convertedFrame->height = video_.decodedFrame->height; - const int fillResult = av_image_fill_arrays( - video_.convertedFrame->data, - video_.convertedFrame->linesize, - video_.convertedBuffer.data(), - AV_PIX_FMT_YUV420P, - video_.decodedFrame->width, - video_.decodedFrame->height, - 1 - ); - if (fillResult < 0) { - logging::warn("stream", std::string("av_image_fill_arrays failed for converted frame: ") + describe_ffmpeg_error(fillResult)); - av_frame_unref(video_.decodedFrame); - return fillResult; - } - - sws_scale( - video_.scaleContext, - video_.decodedFrame->data, - video_.decodedFrame->linesize, - 0, - video_.decodedFrame->height, - video_.convertedFrame->data, - video_.convertedFrame->linesize - ); - frameToPresent = video_.convertedFrame; - } - - LatestVideoFrame nextFrame {}; - nextFrame.width = frameToPresent->width; - nextFrame.height = frameToPresent->height; - nextFrame.yPitch = frameToPresent->linesize[0]; - nextFrame.uPitch = frameToPresent->linesize[1]; - nextFrame.vPitch = frameToPresent->linesize[2]; - nextFrame.yPlane.resize(static_cast(frameToPresent->linesize[0] * frameToPresent->height)); - nextFrame.uPlane.resize(static_cast(frameToPresent->linesize[1] * ((frameToPresent->height + 1) / 2))); - nextFrame.vPlane.resize(static_cast(frameToPresent->linesize[2] * ((frameToPresent->height + 1) / 2))); - std::memcpy(nextFrame.yPlane.data(), frameToPresent->data[0], nextFrame.yPlane.size()); - std::memcpy(nextFrame.uPlane.data(), frameToPresent->data[1], nextFrame.uPlane.size()); - std::memcpy(nextFrame.vPlane.data(), frameToPresent->data[2], nextFrame.vPlane.size()); - - { - std::lock_guard lock(video_.frameMutex); - video_.latestFrame = std::move(nextFrame); - } - - video_.hasFrame.store(true); - video_.decodedFrameCount.fetch_add(1); - av_frame_unref(video_.decodedFrame); - } - }; - int sendResult = avcodec_send_packet(video_.codecContext, video_.packet); if (sendResult == AVERROR(EAGAIN)) { - const int drainResult = receive_available_frames(); + const int drainResult = receive_available_video_frames(); if (drainResult < 0 && drainResult != AVERROR(EAGAIN) && drainResult != AVERROR_EOF) { av_packet_unref(video_.packet); return DR_NEED_IDR; @@ -608,7 +704,7 @@ namespace streaming { return DR_NEED_IDR; } - const int receiveResult = receive_available_frames(); + const int receiveResult = receive_available_video_frames(); if (receiveResult < 0 && receiveResult != AVERROR(EAGAIN) && receiveResult != AVERROR_EOF) { return DR_NEED_IDR; } @@ -616,6 +712,10 @@ namespace streaming { return DR_OK; } + int FfmpegStreamBackend::submit_video_decode_unit(PDECODE_UNIT decodeUnit) { + return decode_video_decode_unit(decodeUnit); + } + int FfmpegStreamBackend::initialize_audio_decoder(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, void *context, int arFlags) { (void) context; (void) arFlags; diff --git a/src/streaming/ffmpeg_stream_backend.h b/src/streaming/ffmpeg_stream_backend.h index a683e1a..b7f1a1a 100644 --- a/src/streaming/ffmpeg_stream_backend.h +++ b/src/streaming/ffmpeg_stream_backend.h @@ -162,6 +162,44 @@ namespace streaming { void decode_and_play_audio_sample(char *sampleData, int sampleLength); private: + /** + * @brief Entry point used by the SDL video decode worker thread. + * + * @param context Backend instance. + * @return Zero when the worker exits normally. + */ + static int run_video_decode_thread_trampoline(void *context); + + /** + * @brief Pull Moonlight decode units and feed them into FFmpeg. + * + * @return Zero when the worker exits normally. + */ + int run_video_decode_thread(); + + /** + * @brief Decode one Moonlight video decode unit on the video worker thread. + * + * @param decodeUnit Moonlight Annex B frame payload. + * @return Moonlight decoder status code. + */ + int decode_video_decode_unit(PDECODE_UNIT decodeUnit); + + /** + * @brief Drain decoded frames currently available from FFmpeg. + * + * @return FFmpeg receive result. + */ + int receive_available_video_frames(); + + /** + * @brief Publish one decoded or converted frame for SDL presentation. + * + * @param frameToPresent FFmpeg frame in IYUV-compatible layout. + * @return True when the frame was copied into the presentation buffer. + */ + bool publish_video_frame(AVFrame *frameToPresent); + /** * @brief Hold the latest IYUV video frame ready for SDL upload. */ @@ -186,12 +224,20 @@ namespace streaming { AVFrame *convertedFrame = nullptr; AVPacket *packet = nullptr; SDL_Texture *texture = nullptr; + SDL_Thread *decoderThread = nullptr; int textureWidth = 0; int textureHeight = 0; + std::uint64_t latestFrameVersion = 0; + std::uint64_t renderedFrameVersion = 0; std::vector convertedBuffer; + std::vector packetBuffer; mutable std::mutex frameMutex; LatestVideoFrame latestFrame; + LatestVideoFrame decodeFrame; + LatestVideoFrame renderFrame; + std::atomic decoderStopRequested = false; std::atomic hasFrame = false; + std::atomic publishedFrameVersion = 0; std::atomic submittedDecodeUnitCount = 0; std::atomic decodedFrameCount = 0; }; diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index 9abdc9c..98e9cb9 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -50,6 +50,7 @@ namespace { constexpr Uint8 ACCENT_BLUE = 0xD4; constexpr Uint32 STREAM_EXIT_COMBO_HOLD_MILLISECONDS = 900U; constexpr Uint32 STREAM_FRAME_DELAY_MILLISECONDS = 16U; + constexpr Uint32 STREAM_INPUT_POLL_MILLISECONDS = 4U; constexpr int DEFAULT_STREAM_FPS = 20; constexpr int DEFAULT_STREAM_BITRATE_KBPS = 1500; constexpr int MIN_STREAM_FPS = 15; @@ -69,6 +70,7 @@ namespace { TTF_Font *bodyFont = nullptr; SDL_GameController *controller = nullptr; streaming::FfmpegStreamBackend mediaBackend {}; + mutable std::mutex controllerMutex; bool ttfInitialized = false; }; @@ -97,6 +99,12 @@ namespace { std::deque recentProtocolMessages; }; + struct StreamInputThreadState { + StreamUiResources *resources = nullptr; + StreamConnectionState *connectionState = nullptr; + std::atomic stopRequested = false; + }; + struct StreamStartContext { StreamConnectionState *connectionState = nullptr; STREAM_CONFIGURATION streamConfiguration {}; @@ -336,8 +344,11 @@ namespace { } resources->mediaBackend.shutdown(); - close_controller(resources->controller); - resources->controller = nullptr; + { + std::lock_guard lock(resources->controllerMutex); + close_controller(resources->controller); + resources->controller = nullptr; + } if (resources->bodyFont != nullptr) { TTF_CloseFont(resources->bodyFont); resources->bodyFont = nullptr; @@ -703,8 +714,24 @@ namespace { void pump_stream_events(StreamUiResources *resources) { SDL_Event event {}; while (SDL_PollEvent(&event) != 0) { - if (event.type == SDL_CONTROLLERDEVICEADDED && resources != nullptr && resources->controller == nullptr) { - resources->controller = SDL_GameControllerOpen(event.cdevice.which); + if (resources == nullptr) { + continue; + } + + if (event.type == SDL_CONTROLLERDEVICEADDED) { + std::lock_guard lock(resources->controllerMutex); + if (resources->controller == nullptr) { + resources->controller = SDL_GameControllerOpen(event.cdevice.which); + } + } else if (event.type == SDL_CONTROLLERDEVICEREMOVED) { + std::lock_guard lock(resources->controllerMutex); + if (resources->controller != nullptr) { + SDL_Joystick *joystick = SDL_GameControllerGetJoystick(resources->controller); + if (joystick != nullptr && SDL_JoystickInstanceID(joystick) == event.cdevice.which) { + close_controller(resources->controller); + resources->controller = nullptr; + } + } } } } @@ -799,8 +826,52 @@ namespace { } } - void send_controller_state_if_needed(SDL_GameController *controller, bool *arrivalSent, ControllerSnapshot *lastSnapshot) { - if (controller == nullptr || arrivalSent == nullptr || lastSnapshot == nullptr) { + void update_stream_exit_combo_from_snapshot(const ControllerSnapshot &snapshot, bool controllerPresent, Uint32 *comboActivatedTick, StreamConnectionState *connectionState) { + if (comboActivatedTick == nullptr || connectionState == nullptr || !controllerPresent) { + if (comboActivatedTick != nullptr) { + *comboActivatedTick = 0U; + } + return; + } + + const bool backPressed = (snapshot.buttonFlags & BACK_FLAG) != 0; + const bool startPressed = (snapshot.buttonFlags & PLAY_FLAG) != 0; + if (!backPressed || !startPressed) { + *comboActivatedTick = 0U; + return; + } + + const Uint32 now = SDL_GetTicks(); + if (*comboActivatedTick == 0U) { + *comboActivatedTick = now; + return; + } + if (now - *comboActivatedTick >= STREAM_EXIT_COMBO_HOLD_MILLISECONDS) { + connectionState->stopRequested.store(true); + } + } + + ControllerSnapshot read_controller_snapshot(StreamUiResources *resources, bool *controllerPresent) { + if (controllerPresent != nullptr) { + *controllerPresent = false; + } + if (resources == nullptr) { + return {}; + } + + std::lock_guard lock(resources->controllerMutex); + if (resources->controller == nullptr) { + return {}; + } + + if (controllerPresent != nullptr) { + *controllerPresent = true; + } + return read_controller_snapshot(resources->controller); + } + + void send_controller_snapshot_if_needed(const ControllerSnapshot &snapshot, bool controllerPresent, bool *arrivalSent, ControllerSnapshot *lastSnapshot) { + if (!controllerPresent || arrivalSent == nullptr || lastSnapshot == nullptr) { return; } @@ -809,7 +880,6 @@ namespace { *arrivalSent = true; } - const ControllerSnapshot snapshot = read_controller_snapshot(controller); if (controller_snapshots_match(snapshot, *lastSnapshot)) { return; } @@ -818,6 +888,31 @@ namespace { *lastSnapshot = snapshot; } + int run_stream_input_thread(void *context) { + auto *inputThreadState = static_cast(context); + if (inputThreadState == nullptr || inputThreadState->resources == nullptr || inputThreadState->connectionState == nullptr) { + return -1; + } + + if (SDL_SetThreadPriority(SDL_THREAD_PRIORITY_HIGH) != 0) { + logging::debug("stream", std::string("SDL_SetThreadPriority failed for stream input: ") + SDL_GetError()); + } + + bool controllerArrivalSent = false; + ControllerSnapshot lastControllerSnapshot {}; + Uint32 exitComboActivatedTick = 0U; + + while (!inputThreadState->stopRequested.load() && !inputThreadState->connectionState->connectionTerminated.load() && !inputThreadState->connectionState->stopRequested.load()) { + bool controllerPresent = false; + const ControllerSnapshot snapshot = read_controller_snapshot(inputThreadState->resources, &controllerPresent); + update_stream_exit_combo_from_snapshot(snapshot, controllerPresent, &exitComboActivatedTick, inputThreadState->connectionState); + send_controller_snapshot_if_needed(snapshot, controllerPresent, &controllerArrivalSent, &lastControllerSnapshot); + SDL_Delay(STREAM_INPUT_POLL_MILLISECONDS); + } + + return 0; + } + streaming::StreamStatisticsSnapshot sample_stream_statistics(const StreamStartContext &context, const StreamConnectionState &connectionState) { streaming::StreamStatisticsSnapshot snapshot { context.streamConfiguration.width, @@ -1113,18 +1208,37 @@ namespace streaming { return false; } - bool controllerArrivalSent = false; - ControllerSnapshot lastControllerSnapshot {}; - Uint32 exitComboActivatedTick = 0U; + StreamInputThreadState inputThreadState {}; + inputThreadState.resources = &resources; + inputThreadState.connectionState = &connectionState; + SDL_Thread *inputThread = SDL_CreateThread(run_stream_input_thread, "stream-input", &inputThreadState); + if (inputThread == nullptr) { + logging::warn("stream", std::string("Failed to start the stream input thread; polling controller from the render loop: ") + SDL_GetError()); + } + + bool fallbackControllerArrivalSent = false; + ControllerSnapshot fallbackLastControllerSnapshot {}; + Uint32 fallbackExitComboActivatedTick = 0U; logging::info("stream", std::string(launchResult.resumedSession ? "Resumed stream for " : "Launched stream for ") + app.name + " on " + host.displayName); while (!connectionState.connectionTerminated.load() && !connectionState.stopRequested.load()) { pump_stream_events(&resources); - update_stream_exit_combo(resources.controller, &exitComboActivatedTick, &connectionState); - send_controller_state_if_needed(resources.controller, &controllerArrivalSent, &lastControllerSnapshot); + if (inputThread == nullptr) { + bool controllerPresent = false; + const ControllerSnapshot snapshot = read_controller_snapshot(&resources, &controllerPresent); + update_stream_exit_combo_from_snapshot(snapshot, controllerPresent, &fallbackExitComboActivatedTick, &connectionState); + send_controller_snapshot_if_needed(snapshot, controllerPresent, &fallbackControllerArrivalSent, &fallbackLastControllerSnapshot); + } render_stream_frame(host, app, startContext, connectionState, &resources.mediaBackend, settings.showPerformanceStats, &resources); SDL_Delay(STREAM_FRAME_DELAY_MILLISECONDS); } + inputThreadState.stopRequested.store(true); + if (inputThread != nullptr) { + int inputThreadResult = 0; + SDL_WaitThread(inputThread, &inputThreadResult); + (void) inputThreadResult; + } + LiStopConnection(); g_active_connection_state = nullptr; From 52bc92e24c19ca05d7b36e3d116b7abf1a67bca3 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 11:31:03 -0400 Subject: [PATCH 03/19] Rename .github/copilot-instructions.md to AGENTS.md Move/rename the existing .github/copilot-instructions.md file to AGENTS.md. File contents are unchanged (100% similarity); this is purely a rename to relocate or rebrand the instructions file. --- .github/copilot-instructions.md => AGENTS.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/copilot-instructions.md => AGENTS.md (100%) diff --git a/.github/copilot-instructions.md b/AGENTS.md similarity index 100% rename from .github/copilot-instructions.md rename to AGENTS.md From 4fc78a062af091fa76340ed4db6cf83854b04547 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 12:23:55 -0400 Subject: [PATCH 04/19] Adjust stream defaults and add video drop handling Update streaming defaults and improve video decode handling for lower-latency playback. Key changes: - Change default stream settings: framerate moved from 20 -> 30 and bitrate from 1500kbps -> 1000kbps (client_state, settings_storage, session constants and tests updated). - Reduce exposed stream resolution presets and favor low-latency small presets (startup/video_mode.cpp) and simplify default selection to pick low-latency NTSC/PAL presets. Unit tests updated accordingly. - Add decode-queue dropping support: new drop_queued_video_decode_units, has_unrendered_video_frame, droppedDecodeUnitCount counter, and logging for dropped frames (ffmpeg_stream_backend.{cpp,h}). The video decode loop now drops queued decode units before decoding and requests IDR when needed. - Improve FFmpeg codec behavior by setting low-delay / output-corrupt flags, fast/show-all flags, and stricter error recognition; zero dropped count on reset and include dropped counts in overlay status. - Tweak render loop timing: faster present polling when decoded video exists and performance overlay is disabled; adjust default packet size to 1392. Files touched include src/app/*, src/startup/video_mode.cpp, src/streaming/ffmpeg_stream_backend.* and src/streaming/session.cpp, plus tests reflecting the new defaults and presets. --- src/app/client_state.cpp | 6 +-- src/app/client_state.h | 4 +- src/app/settings_storage.h | 4 +- src/startup/video_mode.cpp | 16 +----- src/streaming/ffmpeg_stream_backend.cpp | 67 +++++++++++++++++++++++- src/streaming/ffmpeg_stream_backend.h | 17 ++++++ src/streaming/session.cpp | 15 ++++-- tests/unit/app/settings_storage_test.cpp | 6 +-- tests/unit/startup/video_mode_test.cpp | 28 +++++----- 9 files changed, 117 insertions(+), 46 deletions(-) diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index 71f792a..24fb623 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -187,7 +187,7 @@ namespace { void cycle_stream_framerate(app::ClientState &state) { const auto current = std::find(STREAM_FRAMERATE_OPTIONS.begin(), STREAM_FRAMERATE_OPTIONS.end(), state.settings.streamFramerate); if (current == STREAM_FRAMERATE_OPTIONS.end()) { - state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS.front(); + state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS.back(); return; } @@ -1395,8 +1395,8 @@ namespace app { state.settings.logViewerPlacement = LogViewerPlacement::full; state.settings.loggingLevel = logging::LogLevel::none; state.settings.xemuConsoleLoggingLevel = logging::LogLevel::none; - state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS[1]; - state.settings.streamBitrateKbps = STREAM_BITRATE_OPTIONS[1]; + state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS.back(); + state.settings.streamBitrateKbps = STREAM_BITRATE_OPTIONS.front(); state.settings.playAudioOnPc = false; state.settings.showPerformanceStats = false; state.settings.dirty = false; diff --git a/src/app/client_state.h b/src/app/client_state.h index 9c54469..2b25203 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -319,8 +319,8 @@ namespace app { std::vector availableVideoModes; ///< Fixed stream-resolution presets exposed by the settings UI. VIDEO_MODE preferredVideoMode {}; ///< Preferred stream resolution requested from the host. bool preferredVideoModeSet = false; ///< True when preferredVideoMode contains a user-selected or default mode. - int streamFramerate = 20; ///< Preferred stream frame rate in frames per second. - int streamBitrateKbps = 1500; ///< Preferred stream bitrate in kilobits per second. + int streamFramerate = 30; ///< Preferred stream frame rate in frames per second. + int streamBitrateKbps = 1000; ///< Preferred stream bitrate in kilobits per second. bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. bool showPerformanceStats = false; ///< True when the streaming overlay should remain visible over decoded video. bool dirty = false; ///< True when persisted TOML-backed settings changed and should be saved. diff --git a/src/app/settings_storage.h b/src/app/settings_storage.h index 53e89d0..2dca14d 100644 --- a/src/app/settings_storage.h +++ b/src/app/settings_storage.h @@ -22,8 +22,8 @@ namespace app { app::LogViewerPlacement logViewerPlacement = app::LogViewerPlacement::full; ///< Preferred placement for the in-app log viewer. VIDEO_MODE preferredVideoMode {}; ///< Preferred stream resolution requested from the host. bool preferredVideoModeSet = false; ///< True when preferredVideoMode contains a saved user preference. - int streamFramerate = 20; ///< Preferred stream frame rate in frames per second. - int streamBitrateKbps = 1500; ///< Preferred stream bitrate in kilobits per second. + int streamFramerate = 30; ///< Preferred stream frame rate in frames per second. + int streamBitrateKbps = 1000; ///< Preferred stream bitrate in kilobits per second. bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. bool showPerformanceStats = false; ///< True when the streaming overlay should remain visible over decoded video. }; diff --git a/src/startup/video_mode.cpp b/src/startup/video_mode.cpp index 5697cf3..bdcd36d 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -20,16 +20,13 @@ namespace startup { int height; }; - constexpr std::array STREAM_RESOLUTION_PRESETS {{ + constexpr std::array STREAM_RESOLUTION_PRESETS {{ {352, 240}, {352, 288}, {480, 480}, {480, 576}, {720, 480}, {720, 576}, - {960, 540}, - {1280, 720}, - {1920, 1080}, }}; bool is_1080i_mode(const VIDEO_MODE &videoMode) { @@ -95,16 +92,7 @@ namespace startup { const int bpp = outputVideoMode.bpp > 0 ? outputVideoMode.bpp : 32; const int refresh = outputVideoMode.refresh > 0 ? outputVideoMode.refresh : 60; - if (outputVideoMode.width >= 1920 && outputVideoMode.height >= 1080) { - return make_stream_video_mode({1920, 1080}, bpp, refresh); - } - if (outputVideoMode.width >= 1280 && outputVideoMode.height >= 720) { - return make_stream_video_mode({1280, 720}, bpp, refresh); - } - if (refresh <= 50 || outputVideoMode.height >= 576) { - return make_stream_video_mode({720, 576}, bpp, refresh); - } - return make_stream_video_mode({720, 480}, bpp, refresh); + return refresh <= 50 ? make_stream_video_mode({352, 288}, bpp, refresh) : make_stream_video_mode({352, 240}, bpp, refresh); } VideoModeSelection select_best_video_mode(int bpp, int refresh) { diff --git a/src/streaming/ffmpeg_stream_backend.cpp b/src/streaming/ffmpeg_stream_backend.cpp index 582902c..46a4e34 100644 --- a/src/streaming/ffmpeg_stream_backend.cpp +++ b/src/streaming/ffmpeg_stream_backend.cpp @@ -35,6 +35,8 @@ namespace { constexpr Uint32 STREAM_OVERLAY_AUDIO_QUEUE_LIMIT_MS = 250U; constexpr std::uint64_t STREAM_VIDEO_SUBMISSION_LOG_INTERVAL = 120; + constexpr int STREAM_VIDEO_DROPPED_FRAME_STATUS = -2; + constexpr std::uint64_t STREAM_VIDEO_DROP_LOG_INTERVAL = 30; streaming::FfmpegStreamBackend *g_active_video_backend = nullptr; streaming::FfmpegStreamBackend *g_active_audio_backend = nullptr; @@ -331,9 +333,14 @@ namespace streaming { return video_.hasFrame.load(); } + bool FfmpegStreamBackend::has_unrendered_video_frame() const { + return video_.publishedFrameVersion.load() != video_.renderedFrameVersion; + } + std::string FfmpegStreamBackend::build_overlay_status_line() const { std::string audioState = audio_.deviceId != 0 ? std::to_string(SDL_GetQueuedAudioSize(audio_.deviceId)) + " queued audio bytes" : "audio idle"; - return std::string("Video units: ") + std::to_string(video_.submittedDecodeUnitCount.load()) + " submitted / " + std::to_string(video_.decodedFrameCount.load()) + " decoded | " + audioState; + return std::string("Video units: ") + std::to_string(video_.submittedDecodeUnitCount.load()) + " submitted / " + std::to_string(video_.decodedFrameCount.load()) + " decoded / " + + std::to_string(video_.droppedDecodeUnitCount.load()) + " dropped | " + audioState; } bool FfmpegStreamBackend::render_latest_video_frame(SDL_Renderer *renderer, int screenWidth, int screenHeight) { @@ -425,6 +432,9 @@ namespace streaming { video_.codecContext->thread_count = 1; video_.codecContext->thread_type = 0; + video_.codecContext->flags |= AV_CODEC_FLAG_LOW_DELAY | AV_CODEC_FLAG_OUTPUT_CORRUPT; + video_.codecContext->flags2 |= AV_CODEC_FLAG2_FAST | AV_CODEC_FLAG2_SHOW_ALL; + video_.codecContext->err_recognition = AV_EF_EXPLODE; const int openResult = avcodec_open2(video_.codecContext, codec, nullptr); if (openResult < 0) { logging::error("stream", std::string("avcodec_open2 failed for H.264: ") + describe_ffmpeg_error(openResult)); @@ -504,6 +514,7 @@ namespace streaming { video_.hasFrame.store(false); video_.submittedDecodeUnitCount.store(0); video_.decodedFrameCount.store(0); + video_.droppedDecodeUnitCount.store(0); } int FfmpegStreamBackend::run_video_decode_thread_trampoline(void *context) { @@ -523,13 +534,65 @@ namespace streaming { break; } - const int decodeStatus = video_.decoderStopRequested.load() || decodeUnit == nullptr ? DR_OK : decode_video_decode_unit(decodeUnit); + int decodeStatus = DR_OK; + if (!video_.decoderStopRequested.load() && decodeUnit != nullptr) { + const int droppedFrames = drop_queued_video_decode_units(&frameHandle, &decodeUnit); + if (droppedFrames > 0 && decodeUnit != nullptr && decodeUnit->frameType == FRAME_TYPE_IDR && video_.codecContext != nullptr) { + avcodec_flush_buffers(video_.codecContext); + } + decodeStatus = decode_video_decode_unit(decodeUnit); + } LiCompleteVideoFrame(frameHandle, decodeStatus); } return 0; } + int FfmpegStreamBackend::drop_queued_video_decode_units(VIDEO_FRAME_HANDLE *frameHandle, PDECODE_UNIT *decodeUnit) { + if (frameHandle == nullptr || decodeUnit == nullptr || *frameHandle == nullptr || *decodeUnit == nullptr) { + return 0; + } + + int droppedFrames = 0; + VIDEO_FRAME_HANDLE newestHandle = *frameHandle; + PDECODE_UNIT newestDecodeUnit = *decodeUnit; + + while (true) { + VIDEO_FRAME_HANDLE nextHandle = nullptr; + PDECODE_UNIT nextDecodeUnit = nullptr; + if (!LiPollNextVideoFrame(&nextHandle, &nextDecodeUnit)) { + break; + } + + LiCompleteVideoFrame(newestHandle, STREAM_VIDEO_DROPPED_FRAME_STATUS); + newestHandle = nextHandle; + newestDecodeUnit = nextDecodeUnit; + ++droppedFrames; + } + + if (droppedFrames == 0) { + return 0; + } + + *frameHandle = newestHandle; + *decodeUnit = newestDecodeUnit; + + const std::uint64_t droppedDecodeUnitCount = video_.droppedDecodeUnitCount.fetch_add(static_cast(droppedFrames)) + static_cast(droppedFrames); + if (newestDecodeUnit != nullptr && newestDecodeUnit->frameType != FRAME_TYPE_IDR) { + LiRequestIdrFrame(); + } + + if (droppedDecodeUnitCount <= static_cast(droppedFrames) || (droppedDecodeUnitCount % STREAM_VIDEO_DROP_LOG_INTERVAL) < static_cast(droppedFrames)) { + logging::warn( + "stream", + std::string("Dropped queued video decode units before decode | dropped_total=") + std::to_string(droppedDecodeUnitCount) + " | dropped_now=" + std::to_string(droppedFrames) + + " | newest_frame=" + std::to_string(newestDecodeUnit != nullptr ? newestDecodeUnit->frameNumber : -1) + ); + } + + return droppedFrames; + } + bool FfmpegStreamBackend::publish_video_frame(AVFrame *frameToPresent) { if (frameToPresent == nullptr || frameToPresent->width <= 0 || frameToPresent->height <= 0 || frameToPresent->linesize[0] <= 0 || frameToPresent->linesize[1] <= 0 || frameToPresent->linesize[2] <= 0 || frameToPresent->data[0] == nullptr || frameToPresent->data[1] == nullptr || frameToPresent->data[2] == nullptr) { diff --git a/src/streaming/ffmpeg_stream_backend.h b/src/streaming/ffmpeg_stream_backend.h index b7f1a1a..db11e89 100644 --- a/src/streaming/ffmpeg_stream_backend.h +++ b/src/streaming/ffmpeg_stream_backend.h @@ -77,6 +77,13 @@ namespace streaming { */ bool has_decoded_video() const; + /** + * @brief Report whether a newly decoded video frame has not been presented yet. + * + * @return True when rendering would present a newer decoded frame. + */ + bool has_unrendered_video_frame() const; + /** * @brief Build a short user-visible media status line. * @@ -177,6 +184,15 @@ namespace streaming { */ int run_video_decode_thread(); + /** + * @brief Drop queued decode units so the decoder works on the newest frame. + * + * @param frameHandle Current frame handle, replaced with the newest queued handle. + * @param decodeUnit Current decode unit, replaced with the newest queued unit. + * @return Number of decode units discarded. + */ + int drop_queued_video_decode_units(VIDEO_FRAME_HANDLE *frameHandle, PDECODE_UNIT *decodeUnit); + /** * @brief Decode one Moonlight video decode unit on the video worker thread. * @@ -240,6 +256,7 @@ namespace streaming { std::atomic publishedFrameVersion = 0; std::atomic submittedDecodeUnitCount = 0; std::atomic decodedFrameCount = 0; + std::atomic droppedDecodeUnitCount = 0; }; /** diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index 98e9cb9..0e5c364 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -50,14 +50,15 @@ namespace { constexpr Uint8 ACCENT_BLUE = 0xD4; constexpr Uint32 STREAM_EXIT_COMBO_HOLD_MILLISECONDS = 900U; constexpr Uint32 STREAM_FRAME_DELAY_MILLISECONDS = 16U; + constexpr Uint32 STREAM_PRESENT_POLL_MILLISECONDS = 2U; constexpr Uint32 STREAM_INPUT_POLL_MILLISECONDS = 4U; - constexpr int DEFAULT_STREAM_FPS = 20; - constexpr int DEFAULT_STREAM_BITRATE_KBPS = 1500; + constexpr int DEFAULT_STREAM_FPS = 30; + constexpr int DEFAULT_STREAM_BITRATE_KBPS = 1000; constexpr int MIN_STREAM_FPS = 15; constexpr int MAX_STREAM_FPS = 30; constexpr int MIN_STREAM_BITRATE_KBPS = 250; constexpr int MAX_STREAM_BITRATE_KBPS = 50000; - constexpr int DEFAULT_PACKET_SIZE = 1024; + constexpr int DEFAULT_PACKET_SIZE = 1392; constexpr std::size_t MAX_CONNECTION_PROTOCOL_MESSAGES = 24U; constexpr uint16_t PRESENT_GAMEPAD_MASK = 0x0001; constexpr uint32_t CONTROLLER_BUTTON_CAPABILITIES = @@ -1228,8 +1229,12 @@ namespace streaming { update_stream_exit_combo_from_snapshot(snapshot, controllerPresent, &fallbackExitComboActivatedTick, &connectionState); send_controller_snapshot_if_needed(snapshot, controllerPresent, &fallbackControllerArrivalSent, &fallbackLastControllerSnapshot); } - render_stream_frame(host, app, startContext, connectionState, &resources.mediaBackend, settings.showPerformanceStats, &resources); - SDL_Delay(STREAM_FRAME_DELAY_MILLISECONDS); + const bool hasDecodedVideo = resources.mediaBackend.has_decoded_video(); + const bool shouldRenderFrame = settings.showPerformanceStats || !hasDecodedVideo || resources.mediaBackend.has_unrendered_video_frame(); + if (shouldRenderFrame) { + render_stream_frame(host, app, startContext, connectionState, &resources.mediaBackend, settings.showPerformanceStats, &resources); + } + SDL_Delay(hasDecodedVideo && !settings.showPerformanceStats ? STREAM_PRESENT_POLL_MILLISECONDS : STREAM_FRAME_DELAY_MILLISECONDS); } inputThreadState.stopRequested.store(true); diff --git a/tests/unit/app/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp index b4294bd..e1df220 100644 --- a/tests/unit/app/settings_storage_test.cpp +++ b/tests/unit/app/settings_storage_test.cpp @@ -111,8 +111,8 @@ namespace { EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::full); EXPECT_FALSE(loadResult.settings.preferredVideoModeSet); - EXPECT_EQ(loadResult.settings.streamFramerate, 20); - EXPECT_EQ(loadResult.settings.streamBitrateKbps, 1500); + EXPECT_EQ(loadResult.settings.streamFramerate, 30); + EXPECT_EQ(loadResult.settings.streamBitrateKbps, 1000); EXPECT_FALSE(loadResult.settings.playAudioOnPc); EXPECT_FALSE(loadResult.settings.showPerformanceStats); } @@ -141,7 +141,7 @@ namespace { EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::full); EXPECT_FALSE(loadResult.settings.preferredVideoModeSet); - EXPECT_EQ(loadResult.settings.streamFramerate, 20); + EXPECT_EQ(loadResult.settings.streamFramerate, 30); EXPECT_FALSE(loadResult.settings.playAudioOnPc); } diff --git a/tests/unit/startup/video_mode_test.cpp b/tests/unit/startup/video_mode_test.cpp index 552bd95..03049f6 100644 --- a/tests/unit/startup/video_mode_test.cpp +++ b/tests/unit/startup/video_mode_test.cpp @@ -60,47 +60,45 @@ namespace { EXPECT_EQ(bestVideoMode.refresh, 60); } - TEST(VideoModeTest, ExposesFixedStreamResolutionPresetsIncludingLowModes) { + TEST(VideoModeTest, ExposesFixedStreamResolutionPresetsWithinSoftwareDecodeRange) { const std::vector presets = startup::stream_resolution_presets(32, 60); - ASSERT_EQ(presets.size(), 9U); + ASSERT_EQ(presets.size(), 6U); EXPECT_EQ(presets.front().width, 352); EXPECT_EQ(presets.front().height, 240); EXPECT_EQ(presets[1].width, 352); EXPECT_EQ(presets[1].height, 288); EXPECT_EQ(presets[4].width, 720); EXPECT_EQ(presets[4].height, 480); - EXPECT_EQ(presets[7].width, 1280); - EXPECT_EQ(presets[7].height, 720); - EXPECT_EQ(presets.back().width, 1920); - EXPECT_EQ(presets.back().height, 1080); + EXPECT_EQ(presets.back().width, 720); + EXPECT_EQ(presets.back().height, 576); EXPECT_EQ(presets.back().bpp, 32); EXPECT_EQ(presets.back().refresh, 60); } - TEST(VideoModeTest, Chooses720pAsTheDefaultHdStreamPreset) { + TEST(VideoModeTest, ChoosesLowLatencyNtscPresetForHdStreamDefaults) { const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({1280, 720, 32, 60}); - EXPECT_EQ(defaultPreset.width, 1280); - EXPECT_EQ(defaultPreset.height, 720); + EXPECT_EQ(defaultPreset.width, 352); + EXPECT_EQ(defaultPreset.height, 240); EXPECT_EQ(defaultPreset.bpp, 32); EXPECT_EQ(defaultPreset.refresh, 60); } - TEST(VideoModeTest, ChoosesNtscSdPresetFor60HzOutputModes) { + TEST(VideoModeTest, ChoosesLowLatencyNtscPresetFor60HzOutputModes) { const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({640, 480, 32, 60}); - EXPECT_EQ(defaultPreset.width, 720); - EXPECT_EQ(defaultPreset.height, 480); + EXPECT_EQ(defaultPreset.width, 352); + EXPECT_EQ(defaultPreset.height, 240); EXPECT_EQ(defaultPreset.bpp, 32); EXPECT_EQ(defaultPreset.refresh, 60); } - TEST(VideoModeTest, ChoosesPalSdPresetFor50HzOutputModes) { + TEST(VideoModeTest, ChoosesLowLatencyPalPresetFor50HzOutputModes) { const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({640, 480, 32, 50}); - EXPECT_EQ(defaultPreset.width, 720); - EXPECT_EQ(defaultPreset.height, 576); + EXPECT_EQ(defaultPreset.width, 352); + EXPECT_EQ(defaultPreset.height, 288); EXPECT_EQ(defaultPreset.bpp, 32); EXPECT_EQ(defaultPreset.refresh, 50); } From f3758d24eda180b75a2ddc9d471fb62f5dc3b71f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 12:51:28 -0400 Subject: [PATCH 05/19] Add thread-safety to Logger and NXDK framebuffer Introduce a mutex to logging::Logger and guard getters, setters and log paths (added should_log_unlocked) to make logging safe for concurrent use; add a unit test to verify bounded retained buffer under concurrent logging. Add NXDK-specific direct framebuffer presentation: new helpers (build_unscaled_destination, rectangles_match, xbox framebuffer format/bytes helpers), a render_latest_video_frame_to_framebuffer path, presentScaleContext and bookkeeping fields in VideoState, and attempt direct presentation when allowed. Also filter/normalize noisy connection logs in session (high-volume message suppression and connection_log_level) and update render_latest_video_frame API to accept an allowDirectFramebuffer flag with a default. Misc: free presentScaleContext on reset and minor includes/headers updates. --- src/logging/logger.cpp | 19 ++- src/logging/logger.h | 4 + src/streaming/ffmpeg_stream_backend.cpp | 176 +++++++++++++++++++++++- src/streaming/ffmpeg_stream_backend.h | 15 +- src/streaming/session.cpp | 81 ++++++++++- tests/unit/logging/logger_test.cpp | 28 ++++ 6 files changed, 317 insertions(+), 6 deletions(-) diff --git a/src/logging/logger.cpp b/src/logging/logger.cpp index ffe3cb7..b60e797 100644 --- a/src/logging/logger.cpp +++ b/src/logging/logger.cpp @@ -304,42 +304,56 @@ namespace logging { } void Logger::set_minimum_level(LogLevel minimumLevel) { + std::lock_guard lock(mutex_); minimumLevel_ = minimumLevel; } LogLevel Logger::minimum_level() const { + std::lock_guard lock(mutex_); return minimumLevel_; } void Logger::set_file_sink(LogSink sink) { + std::lock_guard lock(mutex_); fileSink_ = std::move(sink); } void Logger::set_file_minimum_level(LogLevel minimumLevel) { + std::lock_guard lock(mutex_); fileMinimumLevel_ = minimumLevel; } LogLevel Logger::file_minimum_level() const { + std::lock_guard lock(mutex_); return fileMinimumLevel_; } void Logger::set_startup_debug_enabled(bool enabled) { + std::lock_guard lock(mutex_); startupDebugEnabled_ = enabled; } bool Logger::startup_debug_enabled() const { + std::lock_guard lock(mutex_); return startupDebugEnabled_; } void Logger::set_debugger_console_minimum_level(LogLevel minimumLevel) { + std::lock_guard lock(mutex_); debuggerConsoleMinimumLevel_ = minimumLevel; } LogLevel Logger::debugger_console_minimum_level() const { + std::lock_guard lock(mutex_); return debuggerConsoleMinimumLevel_; } bool Logger::should_log(LogLevel level) const { + std::lock_guard lock(mutex_); + return should_log_unlocked(level); + } + + bool Logger::should_log_unlocked(LogLevel level) const { if (is_enabled(level, minimumLevel_)) { return true; } @@ -359,7 +373,8 @@ namespace logging { } bool Logger::log(LogLevel level, std::string category, std::string message, LogSourceLocation location) { - if (!should_log(level)) { + std::lock_guard lock(mutex_); + if (!should_log_unlocked(level)) { return false; } @@ -421,6 +436,7 @@ namespace logging { } void Logger::add_sink(LogSink sink, LogLevel minimumLevel) { + std::lock_guard lock(mutex_); if (sink) { sinks_.push_back({minimumLevel, std::move(sink)}); } @@ -431,6 +447,7 @@ namespace logging { } std::vector Logger::snapshot(LogLevel minimumLevel) const { + std::lock_guard lock(mutex_); std::vector filteredEntries; for (const LogEntry &entry : entries_) { diff --git a/src/logging/logger.h b/src/logging/logger.h index 6243d08..0061017 100644 --- a/src/logging/logger.h +++ b/src/logging/logger.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -476,7 +477,10 @@ namespace logging { LogSink sink; ///< Callback invoked for matching entries. }; + bool should_log_unlocked(LogLevel level) const; + std::size_t capacity_; + mutable std::mutex mutex_; LogLevel minimumLevel_ = LogLevel::none; bool startupDebugEnabled_ = true; LogSink fileSink_; diff --git a/src/streaming/ffmpeg_stream_backend.cpp b/src/streaming/ffmpeg_stream_backend.cpp index 46a4e34..b41b588 100644 --- a/src/streaming/ffmpeg_stream_backend.cpp +++ b/src/streaming/ffmpeg_stream_backend.cpp @@ -31,6 +31,10 @@ extern "C" { // local includes #include "src/logging/logger.h" +#ifdef NXDK + #include +#endif + namespace { constexpr Uint32 STREAM_OVERLAY_AUDIO_QUEUE_LIMIT_MS = 250U; @@ -333,6 +337,80 @@ namespace streaming { return video_.hasFrame.load(); } + /** + * @brief Compute a destination rectangle that never enlarges the decoded frame. + * + * @param screenWidth Framebuffer output width. + * @param screenHeight Framebuffer output height. + * @param frameWidth Decoded video width. + * @param frameHeight Decoded video height. + * @return Centered destination rectangle, scaled down only when required. + */ + SDL_Rect build_unscaled_destination(int screenWidth, int screenHeight, int frameWidth, int frameHeight) { + if (screenWidth <= 0 || screenHeight <= 0 || frameWidth <= 0 || frameHeight <= 0) { + return SDL_Rect {0, 0, 0, 0}; + } + if (frameWidth <= screenWidth && frameHeight <= screenHeight) { + return SDL_Rect { + (screenWidth - frameWidth) / 2, + (screenHeight - frameHeight) / 2, + frameWidth, + frameHeight, + }; + } + return build_letterboxed_destination(screenWidth, screenHeight, frameWidth, frameHeight); + } + + /** + * @brief Return whether two SDL rectangles describe the same area. + * + * @param left First rectangle. + * @param right Second rectangle. + * @return True when every rectangle field matches. + */ + bool rectangles_match(const SDL_Rect &left, const SDL_Rect &right) { + return left.x == right.x && left.y == right.y && left.w == right.w && left.h == right.h; + } + +#ifdef NXDK + /** + * @brief Convert the active Xbox framebuffer bpp to FFmpeg's packed RGB format. + * + * @param bitsPerPixel Active framebuffer bits per pixel. + * @return Matching FFmpeg pixel format, or none when unsupported. + */ + AVPixelFormat xbox_framebuffer_pixel_format(int bitsPerPixel) { + switch (bitsPerPixel) { + case 15: + return AV_PIX_FMT_RGB555LE; + case 16: + return AV_PIX_FMT_RGB565LE; + case 32: + return AV_PIX_FMT_BGR0; + default: + return AV_PIX_FMT_NONE; + } + } + + /** + * @brief Return bytes per pixel for one Xbox framebuffer mode. + * + * @param bitsPerPixel Active framebuffer bits per pixel. + * @return Bytes per pixel, or zero when unsupported. + */ + int xbox_framebuffer_bytes_per_pixel(int bitsPerPixel) { + switch (bitsPerPixel) { + case 15: + case 16: + return 2; + case 32: + return 4; + default: + return 0; + } + } +#endif + bool FfmpegStreamBackend::has_unrendered_video_frame() const { return video_.publishedFrameVersion.load() != video_.renderedFrameVersion; } @@ -343,7 +421,7 @@ namespace streaming { std::to_string(video_.droppedDecodeUnitCount.load()) + " dropped | " + audioState; } - bool FfmpegStreamBackend::render_latest_video_frame(SDL_Renderer *renderer, int screenWidth, int screenHeight) { + bool FfmpegStreamBackend::render_latest_video_frame(SDL_Renderer *renderer, int screenWidth, int screenHeight, bool allowDirectFramebuffer) { if (renderer == nullptr || !video_.hasFrame.load()) { return false; } @@ -363,6 +441,14 @@ namespace streaming { return false; } +#ifdef NXDK + if (allowDirectFramebuffer && render_latest_video_frame_to_framebuffer(screenWidth, screenHeight)) { + return true; + } +#else + (void) allowDirectFramebuffer; +#endif + if (video_.texture == nullptr || video_.textureWidth != video_.renderFrame.width || video_.textureHeight != video_.renderFrame.height) { if (video_.texture != nullptr) { SDL_DestroyTexture(video_.texture); @@ -498,6 +584,10 @@ namespace streaming { sws_freeContext(video_.scaleContext); video_.scaleContext = nullptr; } + if (video_.presentScaleContext != nullptr) { + sws_freeContext(video_.presentScaleContext); + video_.presentScaleContext = nullptr; + } video_.convertedBuffer.clear(); video_.packetBuffer.clear(); @@ -510,6 +600,8 @@ namespace streaming { } video_.renderedFrameVersion = 0; video_.publishedFrameVersion.store(0); + video_.directFramebufferDestination = SDL_Rect {0, 0, 0, 0}; + video_.directFramebufferCleared = false; video_.decoderStopRequested.store(false); video_.hasFrame.store(false); video_.submittedDecodeUnitCount.store(0); @@ -630,6 +722,88 @@ namespace streaming { return true; } + bool FfmpegStreamBackend::render_latest_video_frame_to_framebuffer(int screenWidth, int screenHeight) { +#ifdef NXDK + const VIDEO_MODE videoMode = XVideoGetMode(); + std::uint8_t *framebuffer = XVideoGetFB(); + const int bytesPerPixel = xbox_framebuffer_bytes_per_pixel(videoMode.bpp); + const AVPixelFormat destinationFormat = xbox_framebuffer_pixel_format(videoMode.bpp); + if (framebuffer == nullptr || videoMode.width <= 0 || videoMode.height <= 0 || bytesPerPixel <= 0 || destinationFormat == AV_PIX_FMT_NONE) { + return false; + } + + const int framebufferWidth = screenWidth > 0 ? std::min(screenWidth, videoMode.width) : videoMode.width; + const int framebufferHeight = screenHeight > 0 ? std::min(screenHeight, videoMode.height) : videoMode.height; + const SDL_Rect destination = build_unscaled_destination(framebufferWidth, framebufferHeight, video_.renderFrame.width, video_.renderFrame.height); + if (destination.w <= 0 || destination.h <= 0) { + return false; + } + + const int framebufferPitch = videoMode.width * bytesPerPixel; + if (!video_.directFramebufferCleared || !rectangles_match(video_.directFramebufferDestination, destination)) { + std::memset(framebuffer, 0, static_cast(framebufferPitch) * static_cast(videoMode.height)); + video_.directFramebufferDestination = destination; + video_.directFramebufferCleared = true; + } + + video_.presentScaleContext = sws_getCachedContext( + video_.presentScaleContext, + video_.renderFrame.width, + video_.renderFrame.height, + AV_PIX_FMT_YUV420P, + destination.w, + destination.h, + destinationFormat, + SWS_FAST_BILINEAR, + nullptr, + nullptr, + nullptr + ); + if (video_.presentScaleContext == nullptr) { + logging::warn("stream", "sws_getCachedContext failed for direct framebuffer presentation"); + return false; + } + + const std::uint8_t *sourceData[] = { + video_.renderFrame.yPlane.data(), + video_.renderFrame.uPlane.data(), + video_.renderFrame.vPlane.data(), + nullptr, + }; + const int sourceLinesize[] = { + video_.renderFrame.yPitch, + video_.renderFrame.uPitch, + video_.renderFrame.vPitch, + 0, + }; + std::uint8_t *destinationData[] = { + framebuffer + (static_cast(destination.y) * static_cast(framebufferPitch)) + (static_cast(destination.x) * static_cast(bytesPerPixel)), + nullptr, + nullptr, + nullptr, + }; + const int destinationLinesize[] = { + framebufferPitch, + 0, + 0, + 0, + }; + + const int scaledRows = sws_scale(video_.presentScaleContext, sourceData, sourceLinesize, 0, video_.renderFrame.height, destinationData, destinationLinesize); + if (scaledRows <= 0) { + logging::warn("stream", "sws_scale failed for direct framebuffer presentation"); + return false; + } + + XVideoFlushFB(); + return true; +#else + (void) screenWidth; + (void) screenHeight; + return false; +#endif + } + int FfmpegStreamBackend::receive_available_video_frames() { while (true) { const int receiveResult = avcodec_receive_frame(video_.codecContext, video_.decodedFrame); diff --git a/src/streaming/ffmpeg_stream_backend.h b/src/streaming/ffmpeg_stream_backend.h index db11e89..960d086 100644 --- a/src/streaming/ffmpeg_stream_backend.h +++ b/src/streaming/ffmpeg_stream_backend.h @@ -66,9 +66,10 @@ namespace streaming { * @param renderer SDL renderer used by the stream session. * @param screenWidth Current renderer output width. * @param screenHeight Current renderer output height. + * @param allowDirectFramebuffer True when platform-specific direct presentation may bypass SDL's renderer. * @return True when a decoded frame was available and rendered. */ - bool render_latest_video_frame(SDL_Renderer *renderer, int screenWidth, int screenHeight); + bool render_latest_video_frame(SDL_Renderer *renderer, int screenWidth, int screenHeight, bool allowDirectFramebuffer = true); /** * @brief Report whether at least one decoded video frame is available. @@ -216,6 +217,15 @@ namespace streaming { */ bool publish_video_frame(AVFrame *frameToPresent); + /** + * @brief Present the latest decoded frame through a platform-specific direct framebuffer path. + * + * @param screenWidth Current framebuffer width. + * @param screenHeight Current framebuffer height. + * @return True when the frame was presented without SDL renderer involvement. + */ + bool render_latest_video_frame_to_framebuffer(int screenWidth, int screenHeight); + /** * @brief Hold the latest IYUV video frame ready for SDL upload. */ @@ -236,6 +246,7 @@ namespace streaming { struct VideoState { AVCodecContext *codecContext = nullptr; SwsContext *scaleContext = nullptr; + SwsContext *presentScaleContext = nullptr; AVFrame *decodedFrame = nullptr; AVFrame *convertedFrame = nullptr; AVPacket *packet = nullptr; @@ -248,9 +259,11 @@ namespace streaming { std::vector convertedBuffer; std::vector packetBuffer; mutable std::mutex frameMutex; + SDL_Rect directFramebufferDestination {0, 0, 0, 0}; LatestVideoFrame latestFrame; LatestVideoFrame decodeFrame; LatestVideoFrame renderFrame; + bool directFramebufferCleared = false; std::atomic decoderStopRequested = false; std::atomic hasFrame = false; std::atomic publishedFrameVersion = 0; diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index 0e5c364..7a48249 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -194,6 +194,72 @@ namespace { return true; } + bool contains_ascii_case_insensitive(std::string_view text, std::string_view needle) { + if (needle.empty()) { + return true; + } + if (text.size() < needle.size()) { + return false; + } + + for (std::size_t offset = 0; offset <= text.size() - needle.size(); ++offset) { + if (starts_with_ascii_case_insensitive(text.substr(offset), needle)) { + return true; + } + } + + return false; + } + + bool is_high_volume_connection_log(std::string_view text) { + static constexpr std::string_view HIGH_VOLUME_PREFIXES[] { + "Audio packet queue overflow", + "Control message took over 10 ms", + "Depacketizer detected corrupt frame", + "Failed to decrypt audio packet", + "Failed to decrypt video packet", + "IDR frame request sent", + "Input queue reached maximum size limit", + "Invalidate reference frame request sent", + "Invalid last payload length", + "Leaving speculative RFI mode", + "Network dropped ", + "Next post-invalidation frame is:", + "Received OOS audio data", + "Recovered ", + "Requesting IDR frame on behalf of DR", + "Sending RFI request for unrecoverable frame", + "Sending speculative RFI request", + "Unable to recover audio data", + "Unrecoverable frame ", + "Video decode unit queue overflow", + "Waiting for IDR frame", + "Waiting for RFI frame", + }; + + for (const std::string_view prefix : HIGH_VOLUME_PREFIXES) { + if (starts_with_ascii_case_insensitive(text, prefix)) { + return true; + } + } + + return false; + } + + logging::LogLevel connection_log_level(std::string_view message) { + if ( + starts_with_ascii_case_insensitive(message, "WARNING:") || + starts_with_ascii_case_insensitive(message, "Failed") || + starts_with_ascii_case_insensitive(message, "Invalid") || + starts_with_ascii_case_insensitive(message, "No ") || + contains_ascii_case_insensitive(message, " failed") + ) { + return logging::LogLevel::warning; + } + + return logging::LogLevel::debug; + } + /** * @brief Return whether the host metadata indicates Sunshine. * @@ -625,16 +691,20 @@ namespace { } void on_log_message(const char *format, ...) { + if (format == nullptr || is_high_volume_connection_log(format)) { + return; + } + va_list arguments; va_start(arguments, format); const std::string message = format_connection_log_message(format, arguments); va_end(arguments); - if (message.empty()) { + if (message.empty() || is_high_volume_connection_log(message)) { return; } append_connection_protocol_message(g_active_connection_state, message); - logging::debug("moonlight", message); + logging::log(connection_log_level(message), "moonlight", message); } int run_stream_start_thread(void *context) { @@ -965,6 +1035,11 @@ namespace { SDL_GetRendererOutputSize(resources->renderer, &screenWidth, &screenHeight); const bool hasDecodedVideo = mediaBackend != nullptr && mediaBackend->has_decoded_video(); +#ifdef NXDK + if (hasDecodedVideo && !showPerformanceStats && mediaBackend->render_latest_video_frame(resources->renderer, screenWidth, screenHeight, true)) { + return true; + } +#endif if (hasDecodedVideo) { SDL_SetRenderDrawColor(resources->renderer, 0x00, 0x00, 0x00, 0xFF); } else { @@ -972,7 +1047,7 @@ namespace { } SDL_RenderClear(resources->renderer); - const bool renderedVideo = hasDecodedVideo && mediaBackend->render_latest_video_frame(resources->renderer, screenWidth, screenHeight); + const bool renderedVideo = hasDecodedVideo && mediaBackend->render_latest_video_frame(resources->renderer, screenWidth, screenHeight, false); if (renderedVideo && showPerformanceStats) { SDL_SetRenderDrawBlendMode(resources->renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(resources->renderer, 0x00, 0x00, 0x00, 0x90); diff --git a/tests/unit/logging/logger_test.cpp b/tests/unit/logging/logger_test.cpp index 44d5c8d..38e03d5 100644 --- a/tests/unit/logging/logger_test.cpp +++ b/tests/unit/logging/logger_test.cpp @@ -7,6 +7,7 @@ // standard includes #include +#include #include // lib includes @@ -235,6 +236,33 @@ namespace { EXPECT_EQ(logger.entries().front().message, "second"); } + TEST(LoggerTest, ConcurrentLoggingKeepsTheRetainedBufferBounded) { + logging::Logger logger(32, []() { + return logging::LogTimestamp {2026, 4, 5, 13, 7, 9, 42}; + }); + logger.set_minimum_level(logging::LogLevel::info); + logger.set_startup_debug_enabled(false); + + std::vector threads; + for (int threadIndex = 0; threadIndex < 4; ++threadIndex) { + threads.emplace_back([&logger, threadIndex]() { + for (int entryIndex = 0; entryIndex < 128; ++entryIndex) { + logger.info("stream", "thread-" + std::to_string(threadIndex) + "-" + std::to_string(entryIndex)); + } + }); + } + + for (std::thread &thread : threads) { + thread.join(); + } + + const std::vector retainedEntries = logger.snapshot(logging::LogLevel::info); + ASSERT_EQ(retainedEntries.size(), 32U); + for (std::size_t index = 1; index < retainedEntries.size(); ++index) { + EXPECT_LT(retainedEntries[index - 1].sequence, retainedEntries[index].sequence); + } + } + TEST(LoggerTest, StartupConsoleHelpersPreserveLabelsAndCanBeToggled) { logging::set_startup_console_enabled(true); EXPECT_TRUE(logging::startup_console_enabled()); From be3e60fd57d2b0bf2ee2e0115da6f942c38a3c5a Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 15:10:01 -0400 Subject: [PATCH 06/19] Add Xbox audio toggle and streaming refinements Add a new playAudioOnXbox setting with UI/menu toggle and persistence (default: false). Wire the new setting through client state, settings storage, main startup, and unit tests. Enhance FFmpeg/streaming behavior to support disabling Xbox-side audio decode/playback: add FfmpegStreamBackend::set_audio_playback_enabled, short-circuit Opus decode when disabled, expose capability flags, and avoid unnecessary audio work. Improve audio buffering by reusing an output buffer vector and tracking queued bytes. Make several decoder and runtime improvements to reduce latency and robustness: lower FFmpeg log level and ignore a noisy message, add codec skip flags, yield briefly in the decoder loop, track per-frame decode timing/queue/age and expose it in the overlay, request IDR frames after idle video periods, adjust thread priorities (scoped RAII helper), and change default packet size. Also: expand stream bitrate options and introduce DEFAULT_STREAM_BITRATE_KBPS constant; minor UI text tweak and updated unit tests to account for the new setting. --- src/app/client_state.cpp | 22 +++++++-- src/app/client_state.h | 1 + src/app/settings_storage.cpp | 4 +- src/app/settings_storage.h | 1 + src/main.cpp | 1 + src/streaming/ffmpeg_stream_backend.cpp | 63 +++++++++++++++++++++--- src/streaming/ffmpeg_stream_backend.h | 21 ++++++++ src/streaming/session.cpp | 50 ++++++++++++++++++- src/ui/shell_screen.cpp | 1 + tests/unit/app/settings_storage_test.cpp | 5 ++ 10 files changed, 156 insertions(+), 13 deletions(-) diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index 24fb623..76a8193 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -28,8 +28,9 @@ namespace { constexpr const char *SETTINGS_CATEGORY_PREFIX = "settings-category:"; constexpr std::array ADD_HOST_ADDRESS_KEYPAD_CHARACTERS {'1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0'}; constexpr std::array ADD_HOST_PORT_KEYPAD_CHARACTERS {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}; + constexpr int DEFAULT_STREAM_BITRATE_KBPS = 1000; constexpr std::array STREAM_FRAMERATE_OPTIONS {15, 20, 24, 25, 30}; - constexpr std::array STREAM_BITRATE_OPTIONS {1000, 1500, 2000, 2500, 3000, 4000, 5000}; + constexpr std::array STREAM_BITRATE_OPTIONS {500, 750, 1000, 1500, 2000, 2500, 3000, 4000, 5000}; /** * @brief Describes the keypad characters available for the active add-host field. @@ -107,7 +108,7 @@ namespace { case app::SettingsCategory::logging: return "Control the runtime log file, the in-app log viewer, and xemu debugger output verbosity."; case app::SettingsCategory::display: - return "Tune streaming video resolution, frame rate, bitrate, host audio playback, and the in-stream diagnostics overlay."; + return "Tune streaming video resolution, frame rate, bitrate, audio playback, and the in-stream diagnostics overlay."; case app::SettingsCategory::input: return "Input options will live here when controller and navigation customization is added."; case app::SettingsCategory::reset: @@ -548,6 +549,12 @@ namespace { "Toggle whether the host PC should continue local audio playback while also streaming audio to this Xbox client.", true, }, + { + "toggle-play-audio-on-xbox", + std::string("Play Audio on Xbox: ") + (state.settings.playAudioOnXbox ? "On" : "Off"), + "Toggle local audio playback on the Xbox. Disable this to skip Opus decode work when video latency matters more than sound.", + true, + }, { "toggle-show-performance-stats", std::string("Show Performance Stats: ") + (state.settings.showPerformanceStats ? "On" : "Off"), @@ -1396,9 +1403,10 @@ namespace app { state.settings.loggingLevel = logging::LogLevel::none; state.settings.xemuConsoleLoggingLevel = logging::LogLevel::none; state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS.back(); - state.settings.streamBitrateKbps = STREAM_BITRATE_OPTIONS.front(); + state.settings.streamBitrateKbps = DEFAULT_STREAM_BITRATE_KBPS; state.settings.playAudioOnPc = false; state.settings.showPerformanceStats = false; + state.settings.playAudioOnXbox = false; state.settings.dirty = false; state.settings.savedFilesDirty = true; return state; @@ -1995,6 +2003,14 @@ namespace app { rebuild_menu(state, "toggle-play-audio-on-pc"); return; } + if (detailUpdate.activatedItemId == "toggle-play-audio-on-xbox") { + state.settings.playAudioOnXbox = !state.settings.playAudioOnXbox; + state.settings.dirty = true; + update->persistence.settingsChanged = true; + state.shell.statusMessage = std::string("Play audio on Xbox ") + (state.settings.playAudioOnXbox ? "enabled" : "disabled"); + rebuild_menu(state, "toggle-play-audio-on-xbox"); + return; + } if (detailUpdate.activatedItemId == "toggle-show-performance-stats") { state.settings.showPerformanceStats = !state.settings.showPerformanceStats; state.settings.dirty = true; diff --git a/src/app/client_state.h b/src/app/client_state.h index 2b25203..d8bfbb8 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -323,6 +323,7 @@ namespace app { int streamBitrateKbps = 1000; ///< Preferred stream bitrate in kilobits per second. bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. bool showPerformanceStats = false; ///< True when the streaming overlay should remain visible over decoded video. + bool playAudioOnXbox = false; ///< True when the Xbox should decode and play streamed audio locally. bool dirty = false; ///< True when persisted TOML-backed settings changed and should be saved. std::vector savedFiles; ///< Saved-file catalog shown on the reset settings page. bool savedFilesDirty = true; ///< True when the saved-file catalog should be refreshed. diff --git a/src/app/settings_storage.cpp b/src/app/settings_storage.cpp index a0bfd95..4a99805 100644 --- a/src/app/settings_storage.cpp +++ b/src/app/settings_storage.cpp @@ -347,6 +347,7 @@ namespace { content += std::string("fps = ") + std::to_string(settings.streamFramerate) + "\n"; content += std::string("bitrate_kbps = ") + std::to_string(settings.streamBitrateKbps) + "\n"; content += std::string("play_audio_on_pc = ") + (settings.playAudioOnPc ? "true" : "false") + "\n"; + content += std::string("play_audio_on_xbox = ") + (settings.playAudioOnXbox ? "true" : "false") + "\n"; content += std::string("show_performance_stats = ") + (settings.showPerformanceStats ? "true" : "false") + "\n"; return content; } @@ -390,7 +391,7 @@ namespace { void inspect_streaming_keys(const toml::table &streamingTable, const std::string &filePath, app::LoadAppSettingsResult *result) { for (const auto &[rawKey, node] : streamingTable) { const std::string key(rawKey.str()); - if (key == "video_width" || key == "video_height" || key == "video_bpp" || key == "video_refresh" || key == "video_mode_selected" || key == "fps" || key == "bitrate_kbps" || key == "play_audio_on_pc" || key == "show_performance_stats") { + if (key == "video_width" || key == "video_height" || key == "video_bpp" || key == "video_refresh" || key == "video_mode_selected" || key == "fps" || key == "bitrate_kbps" || key == "play_audio_on_pc" || key == "play_audio_on_xbox" || key == "show_performance_stats") { continue; } @@ -489,6 +490,7 @@ namespace app { load_integer_setting(settingsTable["streaming"]["fps"], filePath, "streaming.fps", &result.settings.streamFramerate, &result.warnings); load_integer_setting(settingsTable["streaming"]["bitrate_kbps"], filePath, "streaming.bitrate_kbps", &result.settings.streamBitrateKbps, &result.warnings); load_boolean_setting(settingsTable["streaming"]["play_audio_on_pc"], filePath, "streaming.play_audio_on_pc", &result.settings.playAudioOnPc, &result.warnings); + load_boolean_setting(settingsTable["streaming"]["play_audio_on_xbox"], filePath, "streaming.play_audio_on_xbox", &result.settings.playAudioOnXbox, &result.warnings); load_boolean_setting(settingsTable["streaming"]["show_performance_stats"], filePath, "streaming.show_performance_stats", &result.settings.showPerformanceStats, &result.warnings); return result; diff --git a/src/app/settings_storage.h b/src/app/settings_storage.h index 2dca14d..a0c0b95 100644 --- a/src/app/settings_storage.h +++ b/src/app/settings_storage.h @@ -26,6 +26,7 @@ namespace app { int streamBitrateKbps = 1000; ///< Preferred stream bitrate in kilobits per second. bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. bool showPerformanceStats = false; ///< True when the streaming overlay should remain visible over decoded video. + bool playAudioOnXbox = false; ///< True when the Xbox should decode and play streamed audio locally. }; /** diff --git a/src/main.cpp b/src/main.cpp index 5b8b9e6..f724a0b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -46,6 +46,7 @@ namespace { state.settings.streamBitrateKbps = settings.streamBitrateKbps; state.settings.playAudioOnPc = settings.playAudioOnPc; state.settings.showPerformanceStats = settings.showPerformanceStats; + state.settings.playAudioOnXbox = settings.playAudioOnXbox; state.settings.dirty = false; } diff --git a/src/streaming/ffmpeg_stream_backend.cpp b/src/streaming/ffmpeg_stream_backend.cpp index b41b588..884194c 100644 --- a/src/streaming/ffmpeg_stream_backend.cpp +++ b/src/streaming/ffmpeg_stream_backend.cpp @@ -35,12 +35,15 @@ extern "C" { #include #endif +extern "C" std::uint64_t PltGetMicroseconds(void); + namespace { constexpr Uint32 STREAM_OVERLAY_AUDIO_QUEUE_LIMIT_MS = 250U; constexpr std::uint64_t STREAM_VIDEO_SUBMISSION_LOG_INTERVAL = 120; constexpr int STREAM_VIDEO_DROPPED_FRAME_STATUS = -2; constexpr std::uint64_t STREAM_VIDEO_DROP_LOG_INTERVAL = 30; + constexpr Uint32 STREAM_VIDEO_DECODE_YIELD_MILLISECONDS = 1U; streaming::FfmpegStreamBackend *g_active_video_backend = nullptr; streaming::FfmpegStreamBackend *g_active_audio_backend = nullptr; @@ -122,6 +125,9 @@ namespace { if (message.empty()) { return; } + if (message.find("No accelerated colorspace conversion found") != std::string::npos) { + return; + } logging::log(map_ffmpeg_log_level(level), "ffmpeg", std::move(message)); } @@ -131,7 +137,7 @@ namespace { */ void ensure_ffmpeg_logging_installed() { std::call_once(g_ffmpeg_logging_once, []() { - av_log_set_level(AV_LOG_VERBOSE); + av_log_set_level(AV_LOG_ERROR); av_log_set_callback(ffmpeg_log_callback); }); } @@ -324,10 +330,17 @@ namespace streaming { audioCallbacks->stop = on_audio_stop; audioCallbacks->cleanup = on_audio_cleanup; audioCallbacks->decodeAndPlaySample = on_audio_decode_and_play_sample; - audioCallbacks->capabilities = 0; + audioCallbacks->capabilities = CAPABILITY_SLOW_OPUS_DECODER; + if (!audioPlaybackEnabled_.load()) { + audioCallbacks->capabilities |= CAPABILITY_DIRECT_SUBMIT; + } } } + void FfmpegStreamBackend::set_audio_playback_enabled(bool enabled) { + audioPlaybackEnabled_.store(enabled); + } + void FfmpegStreamBackend::shutdown() { cleanup_audio_decoder(); cleanup_video_decoder(); @@ -415,10 +428,16 @@ namespace streaming { return video_.publishedFrameVersion.load() != video_.renderedFrameVersion; } + Uint32 FfmpegStreamBackend::milliseconds_since_last_decoded_video_frame(Uint32 nowTicks) const { + const Uint32 lastDecodedFrameTicks = video_.lastDecodedFrameTicks.load(); + return lastDecodedFrameTicks == 0U ? 0U : nowTicks - lastDecodedFrameTicks; + } + std::string FfmpegStreamBackend::build_overlay_status_line() const { - std::string audioState = audio_.deviceId != 0 ? std::to_string(SDL_GetQueuedAudioSize(audio_.deviceId)) + " queued audio bytes" : "audio idle"; + std::string audioState = !audioPlaybackEnabled_.load() ? "audio decode disabled" : (audio_.deviceId != 0 ? std::to_string(SDL_GetQueuedAudioSize(audio_.deviceId)) + " queued audio bytes" : "audio idle"); return std::string("Video units: ") + std::to_string(video_.submittedDecodeUnitCount.load()) + " submitted / " + std::to_string(video_.decodedFrameCount.load()) + " decoded / " + - std::to_string(video_.droppedDecodeUnitCount.load()) + " dropped | " + audioState; + std::to_string(video_.droppedDecodeUnitCount.load()) + " dropped | frame " + std::to_string(video_.lastDecodeFrameNumber.load()) + " queue " + std::to_string(video_.lastDecodeQueueUs.load() / 1000U) + + " ms / age " + std::to_string(video_.lastReceiveAgeUs.load() / 1000U) + " ms | " + audioState; } bool FfmpegStreamBackend::render_latest_video_frame(SDL_Renderer *renderer, int screenWidth, int screenHeight, bool allowDirectFramebuffer) { @@ -521,6 +540,8 @@ namespace streaming { video_.codecContext->flags |= AV_CODEC_FLAG_LOW_DELAY | AV_CODEC_FLAG_OUTPUT_CORRUPT; video_.codecContext->flags2 |= AV_CODEC_FLAG2_FAST | AV_CODEC_FLAG2_SHOW_ALL; video_.codecContext->err_recognition = AV_EF_EXPLODE; + video_.codecContext->skip_loop_filter = AVDISCARD_ALL; + video_.codecContext->skip_idct = AVDISCARD_NONREF; const int openResult = avcodec_open2(video_.codecContext, codec, nullptr); if (openResult < 0) { logging::error("stream", std::string("avcodec_open2 failed for H.264: ") + describe_ffmpeg_error(openResult)); @@ -607,6 +628,10 @@ namespace streaming { video_.submittedDecodeUnitCount.store(0); video_.decodedFrameCount.store(0); video_.droppedDecodeUnitCount.store(0); + video_.lastDecodeQueueUs.store(0); + video_.lastReceiveAgeUs.store(0); + video_.lastDecodedFrameTicks.store(0); + video_.lastDecodeFrameNumber.store(0); } int FfmpegStreamBackend::run_video_decode_thread_trampoline(void *context) { @@ -615,7 +640,7 @@ namespace streaming { } int FfmpegStreamBackend::run_video_decode_thread() { - if (SDL_SetThreadPriority(SDL_THREAD_PRIORITY_TIME_CRITICAL) != 0) { + if (SDL_SetThreadPriority(SDL_THREAD_PRIORITY_HIGH) != 0) { logging::debug("stream", std::string("SDL_SetThreadPriority failed for video decoder: ") + SDL_GetError()); } @@ -635,6 +660,9 @@ namespace streaming { decodeStatus = decode_video_decode_unit(decodeUnit); } LiCompleteVideoFrame(frameHandle, decodeStatus); + if (!video_.decoderStopRequested.load() && decodeStatus == DR_OK) { + SDL_Delay(STREAM_VIDEO_DECODE_YIELD_MILLISECONDS); + } } return 0; @@ -717,6 +745,7 @@ namespace streaming { video_.publishedFrameVersion.store(video_.latestFrameVersion); } + video_.lastDecodedFrameTicks.store(SDL_GetTicks()); video_.hasFrame.store(true); video_.decodedFrameCount.fetch_add(1); return true; @@ -918,6 +947,14 @@ namespace streaming { video_.packet->size = offset; const std::uint64_t submittedDecodeUnitCount = video_.submittedDecodeUnitCount.fetch_add(1) + 1; + const std::uint64_t decodeStartUs = PltGetMicroseconds(); + video_.lastDecodeFrameNumber.store(decodeUnit->frameNumber); + if (decodeUnit->enqueueTimeUs > 0 && decodeStartUs >= decodeUnit->enqueueTimeUs) { + video_.lastDecodeQueueUs.store(decodeStartUs - decodeUnit->enqueueTimeUs); + } + if (decodeUnit->receiveTimeUs > 0 && decodeStartUs >= decodeUnit->receiveTimeUs) { + video_.lastReceiveAgeUs.store(decodeStartUs - decodeUnit->receiveTimeUs); + } if (submittedDecodeUnitCount == 1 || (submittedDecodeUnitCount % STREAM_VIDEO_SUBMISSION_LOG_INTERVAL) == 0) { const std::uint64_t receiveWindowUs = decodeUnit->enqueueTimeUs >= decodeUnit->receiveTimeUs ? decodeUnit->enqueueTimeUs - decodeUnit->receiveTimeUs : 0; logging::debug( @@ -959,6 +996,11 @@ namespace streaming { cleanup_audio_decoder(); + if (!audioPlaybackEnabled_.load()) { + logging::info("stream", "Xbox audio playback is disabled; dropping streamed audio before Opus decode"); + return 0; + } + if (opusConfig == nullptr) { logging::error("stream", "Moonlight did not provide an Opus configuration for audio startup"); return -1; @@ -1064,6 +1106,7 @@ namespace streaming { } audio_.obtainedSpec = SDL_AudioSpec {}; + audio_.outputBuffer.clear(); audio_.resampleInputSampleRate = 0; audio_.resampleInputSampleFormat = -1; audio_.resampleInputChannelCount = 0; @@ -1117,6 +1160,10 @@ namespace streaming { } void FfmpegStreamBackend::decode_and_play_audio_sample(char *sampleData, int sampleLength) { + if (!audioPlaybackEnabled_.load()) { + return; + } + if (audio_.codecContext == nullptr || audio_.packet == nullptr || audio_.decodedFrame == nullptr || audio_.deviceId == 0) { return; } @@ -1175,8 +1222,8 @@ namespace streaming { return; } - std::vector outputBuffer(static_cast(outputBufferSize)); - std::uint8_t *outputData[] = {outputBuffer.data()}; + audio_.outputBuffer.resize(static_cast(outputBufferSize)); + std::uint8_t *outputData[] = {audio_.outputBuffer.data()}; const int convertedSamples = swr_convert( audio_.resampleContext, outputData, @@ -1195,7 +1242,7 @@ namespace streaming { if (SDL_GetQueuedAudioSize(audio_.deviceId) > maxQueuedBytes) { SDL_ClearQueuedAudio(audio_.deviceId); } - if (convertedBytes > 0 && SDL_QueueAudio(audio_.deviceId, outputBuffer.data(), static_cast(convertedBytes)) == 0) { + if (convertedBytes > 0 && SDL_QueueAudio(audio_.deviceId, audio_.outputBuffer.data(), static_cast(convertedBytes)) == 0) { audio_.queuedAudioBytes.fetch_add(static_cast(convertedBytes)); } diff --git a/src/streaming/ffmpeg_stream_backend.h b/src/streaming/ffmpeg_stream_backend.h index 960d086..000ef39 100644 --- a/src/streaming/ffmpeg_stream_backend.h +++ b/src/streaming/ffmpeg_stream_backend.h @@ -55,6 +55,13 @@ namespace streaming { */ void initialize_callbacks(DECODER_RENDERER_CALLBACKS *videoCallbacks, AUDIO_RENDERER_CALLBACKS *audioCallbacks); + /** + * @brief Configure whether streamed audio should be decoded and played locally. + * + * @param enabled True to decode and play Opus audio on the Xbox. + */ + void set_audio_playback_enabled(bool enabled); + /** * @brief Release all FFmpeg, SDL, and cached frame resources. */ @@ -85,6 +92,14 @@ namespace streaming { */ bool has_unrendered_video_frame() const; + /** + * @brief Return how long it has been since FFmpeg published a decoded video frame. + * + * @param nowTicks Current SDL tick value. + * @return Milliseconds since the last decoded frame, or zero before a frame is published. + */ + Uint32 milliseconds_since_last_decoded_video_frame(Uint32 nowTicks) const; + /** * @brief Build a short user-visible media status line. * @@ -270,6 +285,10 @@ namespace streaming { std::atomic submittedDecodeUnitCount = 0; std::atomic decodedFrameCount = 0; std::atomic droppedDecodeUnitCount = 0; + std::atomic lastDecodeQueueUs = 0; + std::atomic lastReceiveAgeUs = 0; + std::atomic lastDecodedFrameTicks = 0; + std::atomic lastDecodeFrameNumber = 0; }; /** @@ -282,6 +301,7 @@ namespace streaming { AVPacket *packet = nullptr; SDL_AudioDeviceID deviceId = 0; SDL_AudioSpec obtainedSpec {}; + std::vector outputBuffer; int resampleInputSampleRate = 0; int resampleInputSampleFormat = -1; int resampleInputChannelCount = 0; @@ -291,6 +311,7 @@ namespace streaming { VideoState video_ {}; AudioState audio_ {}; + std::atomic audioPlaybackEnabled_ = false; }; } // namespace streaming diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index 7a48249..59bc4ba 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -52,13 +52,15 @@ namespace { constexpr Uint32 STREAM_FRAME_DELAY_MILLISECONDS = 16U; constexpr Uint32 STREAM_PRESENT_POLL_MILLISECONDS = 2U; constexpr Uint32 STREAM_INPUT_POLL_MILLISECONDS = 4U; + constexpr Uint32 STREAM_VIDEO_IDLE_IDR_MILLISECONDS = 1500U; + constexpr Uint32 STREAM_VIDEO_IDLE_IDR_COOLDOWN_MILLISECONDS = 2000U; constexpr int DEFAULT_STREAM_FPS = 30; constexpr int DEFAULT_STREAM_BITRATE_KBPS = 1000; constexpr int MIN_STREAM_FPS = 15; constexpr int MAX_STREAM_FPS = 30; constexpr int MIN_STREAM_BITRATE_KBPS = 250; constexpr int MAX_STREAM_BITRATE_KBPS = 50000; - constexpr int DEFAULT_PACKET_SIZE = 1392; + constexpr int DEFAULT_PACKET_SIZE = 1024; constexpr std::size_t MAX_CONNECTION_PROTOCOL_MESSAGES = 24U; constexpr uint16_t PRESENT_GAMEPAD_MASK = 0x0001; constexpr uint32_t CONTROLLER_BUTTON_CAPABILITIES = @@ -129,6 +131,28 @@ namespace { int streamingRemotely = STREAM_CFG_AUTO; }; + class ScopedThreadPriority { + public: + explicit ScopedThreadPriority(SDL_ThreadPriority priority) { + active_ = SDL_SetThreadPriority(priority) == 0; + if (!active_) { + logging::debug("stream", std::string("SDL_SetThreadPriority failed for stream render thread: ") + SDL_GetError()); + } + } + + ~ScopedThreadPriority() { + if (active_) { + SDL_SetThreadPriority(SDL_THREAD_PRIORITY_NORMAL); + } + } + + ScopedThreadPriority(const ScopedThreadPriority &) = delete; + ScopedThreadPriority &operator=(const ScopedThreadPriority &) = delete; + + private: + bool active_ = false; + }; + /** * @brief Describe the active Moonlight network profile for logging. * @@ -1017,6 +1041,25 @@ namespace { return snapshot; } + void request_idle_video_refresh_if_needed(streaming::FfmpegStreamBackend *mediaBackend, Uint32 *lastRequestTicks) { + if (mediaBackend == nullptr || lastRequestTicks == nullptr || !mediaBackend->has_decoded_video()) { + return; + } + + const Uint32 nowTicks = SDL_GetTicks(); + const Uint32 idleMilliseconds = mediaBackend->milliseconds_since_last_decoded_video_frame(nowTicks); + if (idleMilliseconds < STREAM_VIDEO_IDLE_IDR_MILLISECONDS) { + return; + } + if (*lastRequestTicks != 0U && nowTicks - *lastRequestTicks < STREAM_VIDEO_IDLE_IDR_COOLDOWN_MILLISECONDS) { + return; + } + + LiRequestIdrFrame(); + *lastRequestTicks = nowTicks; + logging::debug("stream", std::string("Requested IDR frame after ") + std::to_string(idleMilliseconds) + " ms without decoded video"); + } + bool render_stream_frame( const app::HostRecord &host, const app::HostAppRecord &app, @@ -1148,6 +1191,7 @@ namespace streaming { logging::error("stream", initializationError); return false; } + ScopedThreadPriority streamThreadPriority(SDL_THREAD_PRIORITY_HIGH); startup::log_memory_statistics(); @@ -1207,6 +1251,8 @@ namespace streaming { "stream", std::string("Requested stream configuration | resolution=") + std::to_string(startContext.streamConfiguration.width) + "x" + std::to_string(startContext.streamConfiguration.height) + " | fps=" + std::to_string(startContext.streamConfiguration.fps) + " | bitrate=" + std::to_string(startContext.streamConfiguration.bitrate) + " kbps | packetSize=" + std::to_string(startContext.streamConfiguration.packetSize) + " | networkProfile=" + describe_streaming_remotely_mode(startContext.streamConfiguration.streamingRemotely) + " | clientRefreshX100=" + std::to_string(startContext.streamConfiguration.clientRefreshRateX100) ); + resources.mediaBackend.set_audio_playback_enabled(settings.playAudioOnXbox); + logging::info("stream", std::string("Xbox audio playback ") + (settings.playAudioOnXbox ? "enabled" : "disabled for lower decode latency")); logging::debug( "stream", std::string("Stream connection metadata | active=") + printable_log_value(startContext.serverInformation.address == nullptr ? std::string_view {} : std::string_view {startContext.serverInformation.address}) + @@ -1295,6 +1341,7 @@ namespace streaming { bool fallbackControllerArrivalSent = false; ControllerSnapshot fallbackLastControllerSnapshot {}; Uint32 fallbackExitComboActivatedTick = 0U; + Uint32 lastIdleVideoIdrRequestTick = 0U; logging::info("stream", std::string(launchResult.resumedSession ? "Resumed stream for " : "Launched stream for ") + app.name + " on " + host.displayName); while (!connectionState.connectionTerminated.load() && !connectionState.stopRequested.load()) { pump_stream_events(&resources); @@ -1309,6 +1356,7 @@ namespace streaming { if (shouldRenderFrame) { render_stream_frame(host, app, startContext, connectionState, &resources.mediaBackend, settings.showPerformanceStats, &resources); } + request_idle_video_refresh_if_needed(&resources.mediaBackend, &lastIdleVideoIdrRequestTick); SDL_Delay(hasDecodedVideo && !settings.showPerformanceStats ? STREAM_PRESENT_POLL_MILLISECONDS : STREAM_FRAME_DELAY_MILLISECONDS); } diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 9678a4e..a952b10 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -2167,6 +2167,7 @@ namespace { state.settings.streamBitrateKbps, state.settings.playAudioOnPc, state.settings.showPerformanceStats, + state.settings.playAudioOnXbox, }; } diff --git a/tests/unit/app/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp index e1df220..b8efd4f 100644 --- a/tests/unit/app/settings_storage_test.cpp +++ b/tests/unit/app/settings_storage_test.cpp @@ -51,6 +51,7 @@ namespace { 2500, true, true, + true, }; const app::SaveAppSettingsResult saveResult = app::save_app_settings(savedSettings, settingsPath); @@ -70,6 +71,7 @@ namespace { EXPECT_EQ(loadResult.settings.streamBitrateKbps, 2500); EXPECT_TRUE(loadResult.settings.playAudioOnPc); EXPECT_TRUE(loadResult.settings.showPerformanceStats); + EXPECT_TRUE(loadResult.settings.playAudioOnXbox); EXPECT_FALSE(loadResult.cleanupRequired); } @@ -115,6 +117,7 @@ namespace { EXPECT_EQ(loadResult.settings.streamBitrateKbps, 1000); EXPECT_FALSE(loadResult.settings.playAudioOnPc); EXPECT_FALSE(loadResult.settings.showPerformanceStats); + EXPECT_FALSE(loadResult.settings.playAudioOnXbox); } TEST_F(SettingsStorageTest, InvalidValuesFallBackToDefaultsWithWarnings) { @@ -131,6 +134,7 @@ namespace { "video_width = \"wide\"\n" "fps = \"fast\"\n" "play_audio_on_pc = \"sometimes\"\n" + "play_audio_on_xbox = \"sometimes\"\n" ); const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); @@ -143,6 +147,7 @@ namespace { EXPECT_FALSE(loadResult.settings.preferredVideoModeSet); EXPECT_EQ(loadResult.settings.streamFramerate, 30); EXPECT_FALSE(loadResult.settings.playAudioOnPc); + EXPECT_FALSE(loadResult.settings.playAudioOnXbox); } TEST_F(SettingsStorageTest, LegacyLoggingKeyLoadsAndRequestsCleanup) { From cdab225153f6867fcb52a692aa8e8ca741c8b953 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 15:31:28 -0400 Subject: [PATCH 07/19] Use microsecond timestamps and improve video handling Switch video timestamping from SDL ticks to platform microseconds (PltGetMicroseconds): rename lastDecodedFrameTicks -> lastDecodedFrameUs, change milliseconds_since_last_decoded_video_frame to accept microseconds, and persist microsecond timestamps. Tighten decode logic: avoid decoding non-IDR frames after dropped frames, mark need for IDR, and append "waiting_for_idr" to drop logs. Remove unused build_unscaled_destination and use build_letterboxed_destination for rendering. Add presentation helpers: select_stream_presentation_video_mode and ScopedStreamVideoMode (NXDK-only) to optionally switch output mode for stream presentation and use the active mode when initializing UI resources and configuring stream launch parameters. Minor reorder of idle/IDR checks to use the new time API. --- src/streaming/ffmpeg_stream_backend.cpp | 58 +++++---------- src/streaming/ffmpeg_stream_backend.h | 6 +- src/streaming/session.cpp | 97 +++++++++++++++++++++++-- 3 files changed, 114 insertions(+), 47 deletions(-) diff --git a/src/streaming/ffmpeg_stream_backend.cpp b/src/streaming/ffmpeg_stream_backend.cpp index 884194c..8ed731e 100644 --- a/src/streaming/ffmpeg_stream_backend.cpp +++ b/src/streaming/ffmpeg_stream_backend.cpp @@ -350,30 +350,6 @@ namespace streaming { return video_.hasFrame.load(); } - /** - * @brief Compute a destination rectangle that never enlarges the decoded frame. - * - * @param screenWidth Framebuffer output width. - * @param screenHeight Framebuffer output height. - * @param frameWidth Decoded video width. - * @param frameHeight Decoded video height. - * @return Centered destination rectangle, scaled down only when required. - */ - SDL_Rect build_unscaled_destination(int screenWidth, int screenHeight, int frameWidth, int frameHeight) { - if (screenWidth <= 0 || screenHeight <= 0 || frameWidth <= 0 || frameHeight <= 0) { - return SDL_Rect {0, 0, 0, 0}; - } - if (frameWidth <= screenWidth && frameHeight <= screenHeight) { - return SDL_Rect { - (screenWidth - frameWidth) / 2, - (screenHeight - frameHeight) / 2, - frameWidth, - frameHeight, - }; - } - return build_letterboxed_destination(screenWidth, screenHeight, frameWidth, frameHeight); - } - /** * @brief Return whether two SDL rectangles describe the same area. * @@ -428,9 +404,12 @@ namespace streaming { return video_.publishedFrameVersion.load() != video_.renderedFrameVersion; } - Uint32 FfmpegStreamBackend::milliseconds_since_last_decoded_video_frame(Uint32 nowTicks) const { - const Uint32 lastDecodedFrameTicks = video_.lastDecodedFrameTicks.load(); - return lastDecodedFrameTicks == 0U ? 0U : nowTicks - lastDecodedFrameTicks; + std::uint64_t FfmpegStreamBackend::milliseconds_since_last_decoded_video_frame(std::uint64_t nowMicroseconds) const { + const std::uint64_t lastDecodedFrameUs = video_.lastDecodedFrameUs.load(); + if (lastDecodedFrameUs == 0U || nowMicroseconds < lastDecodedFrameUs) { + return 0U; + } + return (nowMicroseconds - lastDecodedFrameUs) / 1000U; } std::string FfmpegStreamBackend::build_overlay_status_line() const { @@ -630,7 +609,7 @@ namespace streaming { video_.droppedDecodeUnitCount.store(0); video_.lastDecodeQueueUs.store(0); video_.lastReceiveAgeUs.store(0); - video_.lastDecodedFrameTicks.store(0); + video_.lastDecodedFrameUs.store(0); video_.lastDecodeFrameNumber.store(0); } @@ -654,10 +633,16 @@ namespace streaming { int decodeStatus = DR_OK; if (!video_.decoderStopRequested.load() && decodeUnit != nullptr) { const int droppedFrames = drop_queued_video_decode_units(&frameHandle, &decodeUnit); - if (droppedFrames > 0 && decodeUnit != nullptr && decodeUnit->frameType == FRAME_TYPE_IDR && video_.codecContext != nullptr) { - avcodec_flush_buffers(video_.codecContext); + if (droppedFrames > 0 && decodeUnit != nullptr) { + if (decodeUnit->frameType == FRAME_TYPE_IDR && video_.codecContext != nullptr) { + avcodec_flush_buffers(video_.codecContext); + } else { + decodeStatus = DR_NEED_IDR; + } + } + if (decodeStatus == DR_OK) { + decodeStatus = decode_video_decode_unit(decodeUnit); } - decodeStatus = decode_video_decode_unit(decodeUnit); } LiCompleteVideoFrame(frameHandle, decodeStatus); if (!video_.decoderStopRequested.load() && decodeStatus == DR_OK) { @@ -698,15 +683,12 @@ namespace streaming { *decodeUnit = newestDecodeUnit; const std::uint64_t droppedDecodeUnitCount = video_.droppedDecodeUnitCount.fetch_add(static_cast(droppedFrames)) + static_cast(droppedFrames); - if (newestDecodeUnit != nullptr && newestDecodeUnit->frameType != FRAME_TYPE_IDR) { - LiRequestIdrFrame(); - } - if (droppedDecodeUnitCount <= static_cast(droppedFrames) || (droppedDecodeUnitCount % STREAM_VIDEO_DROP_LOG_INTERVAL) < static_cast(droppedFrames)) { logging::warn( "stream", std::string("Dropped queued video decode units before decode | dropped_total=") + std::to_string(droppedDecodeUnitCount) + " | dropped_now=" + std::to_string(droppedFrames) + - " | newest_frame=" + std::to_string(newestDecodeUnit != nullptr ? newestDecodeUnit->frameNumber : -1) + " | newest_frame=" + std::to_string(newestDecodeUnit != nullptr ? newestDecodeUnit->frameNumber : -1) + + (newestDecodeUnit != nullptr && newestDecodeUnit->frameType != FRAME_TYPE_IDR ? " | waiting_for_idr" : "") ); } @@ -745,7 +727,7 @@ namespace streaming { video_.publishedFrameVersion.store(video_.latestFrameVersion); } - video_.lastDecodedFrameTicks.store(SDL_GetTicks()); + video_.lastDecodedFrameUs.store(PltGetMicroseconds()); video_.hasFrame.store(true); video_.decodedFrameCount.fetch_add(1); return true; @@ -763,7 +745,7 @@ namespace streaming { const int framebufferWidth = screenWidth > 0 ? std::min(screenWidth, videoMode.width) : videoMode.width; const int framebufferHeight = screenHeight > 0 ? std::min(screenHeight, videoMode.height) : videoMode.height; - const SDL_Rect destination = build_unscaled_destination(framebufferWidth, framebufferHeight, video_.renderFrame.width, video_.renderFrame.height); + const SDL_Rect destination = build_letterboxed_destination(framebufferWidth, framebufferHeight, video_.renderFrame.width, video_.renderFrame.height); if (destination.w <= 0 || destination.h <= 0) { return false; } diff --git a/src/streaming/ffmpeg_stream_backend.h b/src/streaming/ffmpeg_stream_backend.h index 000ef39..4dc8932 100644 --- a/src/streaming/ffmpeg_stream_backend.h +++ b/src/streaming/ffmpeg_stream_backend.h @@ -95,10 +95,10 @@ namespace streaming { /** * @brief Return how long it has been since FFmpeg published a decoded video frame. * - * @param nowTicks Current SDL tick value. + * @param nowMicroseconds Current platform monotonic time. * @return Milliseconds since the last decoded frame, or zero before a frame is published. */ - Uint32 milliseconds_since_last_decoded_video_frame(Uint32 nowTicks) const; + std::uint64_t milliseconds_since_last_decoded_video_frame(std::uint64_t nowMicroseconds) const; /** * @brief Build a short user-visible media status line. @@ -287,7 +287,7 @@ namespace streaming { std::atomic droppedDecodeUnitCount = 0; std::atomic lastDecodeQueueUs = 0; std::atomic lastReceiveAgeUs = 0; - std::atomic lastDecodedFrameTicks = 0; + std::atomic lastDecodedFrameUs = 0; std::atomic lastDecodeFrameNumber = 0; }; diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index 59bc4ba..1a636fa 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -37,6 +37,8 @@ extern "C" int rand_s(unsigned int *randomValue); #endif +extern "C" std::uint64_t PltGetMicroseconds(void); + namespace { constexpr Uint8 BACKGROUND_RED = 0x08; @@ -153,6 +155,85 @@ namespace { bool active_ = false; }; + bool video_modes_match(const VIDEO_MODE &left, const VIDEO_MODE &right) { + return left.width == right.width && left.height == right.height && left.bpp == right.bpp && left.refresh == right.refresh; + } + + VIDEO_MODE select_stream_presentation_video_mode(const VIDEO_MODE &fallbackVideoMode, const VIDEO_MODE &streamVideoMode) { + VIDEO_MODE presentationMode = fallbackVideoMode; + presentationMode.bpp = fallbackVideoMode.bpp > 0 ? fallbackVideoMode.bpp : 32; + presentationMode.refresh = fallbackVideoMode.refresh > 0 ? fallbackVideoMode.refresh : 60; + + if (streamVideoMode.height <= 288) { + presentationMode.width = 640; + presentationMode.height = 480; + } else if (streamVideoMode.height <= 480) { + presentationMode.width = streamVideoMode.width >= 700 ? 720 : 640; + presentationMode.height = 480; + } else if (streamVideoMode.height <= 576) { + presentationMode.width = 720; + presentationMode.height = presentationMode.refresh <= 50 ? 576 : 480; + } + + return presentationMode; + } + + class ScopedStreamVideoMode { + public: + ScopedStreamVideoMode(SDL_Window *window, const VIDEO_MODE &originalVideoMode, const VIDEO_MODE &requestedVideoMode): + window_(window), + originalVideoMode_(originalVideoMode), + activeVideoMode_(originalVideoMode) { + if (requestedVideoMode.width <= 0 || requestedVideoMode.height <= 0 || video_modes_match(requestedVideoMode, originalVideoMode)) { + return; + } + +#ifdef NXDK + if (!XVideoSetMode(requestedVideoMode.width, requestedVideoMode.height, requestedVideoMode.bpp, requestedVideoMode.refresh)) { + logging::warn( + "stream", + "Failed to switch Xbox video mode for stream presentation to " + std::to_string(requestedVideoMode.width) + "x" + std::to_string(requestedVideoMode.height) + " @ " + + std::to_string(requestedVideoMode.refresh) + " Hz; using current output mode" + ); + return; + } + + activeVideoMode_ = requestedVideoMode; + changed_ = true; + if (window_ != nullptr) { + SDL_SetWindowSize(window_, activeVideoMode_.width, activeVideoMode_.height); + } + logging::info("stream", "Switched Xbox video mode for stream presentation to " + std::to_string(activeVideoMode_.width) + "x" + std::to_string(activeVideoMode_.height)); +#else + (void) requestedVideoMode; +#endif + } + + ~ScopedStreamVideoMode() { +#ifdef NXDK + if (changed_) { + XVideoSetMode(originalVideoMode_.width, originalVideoMode_.height, originalVideoMode_.bpp, originalVideoMode_.refresh); + if (window_ != nullptr) { + SDL_SetWindowSize(window_, originalVideoMode_.width, originalVideoMode_.height); + } + } +#endif + } + + ScopedStreamVideoMode(const ScopedStreamVideoMode &) = delete; + ScopedStreamVideoMode &operator=(const ScopedStreamVideoMode &) = delete; + + const VIDEO_MODE &active_video_mode() const { + return activeVideoMode_; + } + + private: + SDL_Window *window_ = nullptr; + VIDEO_MODE originalVideoMode_ {}; + VIDEO_MODE activeVideoMode_ {}; + bool changed_ = false; + }; + /** * @brief Describe the active Moonlight network profile for logging. * @@ -1046,11 +1127,11 @@ namespace { return; } - const Uint32 nowTicks = SDL_GetTicks(); - const Uint32 idleMilliseconds = mediaBackend->milliseconds_since_last_decoded_video_frame(nowTicks); + const std::uint64_t idleMilliseconds = mediaBackend->milliseconds_since_last_decoded_video_frame(PltGetMicroseconds()); if (idleMilliseconds < STREAM_VIDEO_IDLE_IDR_MILLISECONDS) { return; } + const Uint32 nowTicks = SDL_GetTicks(); if (*lastRequestTicks != 0U && nowTicks - *lastRequestTicks < STREAM_VIDEO_IDLE_IDR_COOLDOWN_MILLISECONDS) { return; } @@ -1183,8 +1264,13 @@ namespace streaming { const network::PairingIdentity &clientIdentity, std::string *statusMessage ) { + const ResolvedStreamParameters streamParameters = resolve_stream_parameters(videoMode, settings); + const VIDEO_MODE streamPresentationVideoMode = select_stream_presentation_video_mode(videoMode, streamParameters.videoMode); + ScopedStreamVideoMode scopedStreamVideoMode(window, videoMode, streamPresentationVideoMode); + const VIDEO_MODE &activeStreamVideoMode = scopedStreamVideoMode.active_video_mode(); + StreamUiResources resources {}; - if (std::string initializationError; !initialize_stream_ui_resources(window, videoMode, &resources, &initializationError)) { + if (std::string initializationError; !initialize_stream_ui_resources(window, activeStreamVideoMode, &resources, &initializationError)) { if (statusMessage != nullptr) { *statusMessage = initializationError; } @@ -1211,14 +1297,13 @@ namespace streaming { network::StreamLaunchResult launchResult {}; network::StreamLaunchConfiguration launchConfiguration {}; - const ResolvedStreamParameters streamParameters = resolve_stream_parameters(videoMode, settings); launchConfiguration.appId = app.id; launchConfiguration.width = select_stream_width(streamParameters.videoMode); launchConfiguration.height = select_stream_height(streamParameters.videoMode); launchConfiguration.fps = streamParameters.fps; launchConfiguration.audioConfiguration = AUDIO_CONFIGURATION_STEREO; launchConfiguration.playAudioOnPc = settings.playAudioOnPc; - launchConfiguration.clientRefreshRateX100 = select_client_refresh_rate_x100(videoMode); + launchConfiguration.clientRefreshRateX100 = select_client_refresh_rate_x100(activeStreamVideoMode); launchConfiguration.remoteInputAesKeyHex = hex_encode(remoteInputKey.data(), remoteInputKey.size()); launchConfiguration.remoteInputAesIvHex = hex_encode(remoteInputIv.data(), remoteInputIv.size()); @@ -1236,7 +1321,7 @@ namespace streaming { StreamStartContext startContext {}; startContext.connectionState = &connectionState; startContext.mediaBackend = &resources.mediaBackend; - configure_stream_start_context(launchResult, remoteInputKey, remoteInputIv, videoMode, streamParameters, &startContext); + configure_stream_start_context(launchResult, remoteInputKey, remoteInputIv, activeStreamVideoMode, streamParameters, &startContext); if (startContext.appVersion != startContext.reportedAppVersion) { logging::info( "stream", From 3ef7d5e2cb1129dd7c92443d204fc6f36548731d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 15:44:59 -0400 Subject: [PATCH 08/19] Use detected Xbox video modes for stream choices Switch stream-resolution settings from fixed presets to detected Xbox video modes and add encoder-aware filtering. Introduces filter_stream_video_modes_for_encoder_settings() to hide SD wide-width modes unless VIDEO_WIDESCREEN is set, and overloads choose_default_stream_video_mode() to prefer a detected mode (or smallest available) with a canonical fallback. initialize_stream_video_mode_settings() now accepts encoder settings and handles empty mode lists, and select_stream_presentation_video_mode() is simplified to use the chosen stream mode when available. Updated comments/labels and adjusted unit tests to match new mode list, defaults, and behavior. --- src/app/client_state.cpp | 8 +- src/app/client_state.h | 2 +- src/main.cpp | 23 +++--- src/startup/video_mode.cpp | 65 ++++++++++++++-- src/startup/video_mode.h | 42 +++++++--- src/streaming/session.cpp | 21 ++--- tests/unit/app/client_state_test.cpp | 14 ++-- tests/unit/app/settings_storage_test.cpp | 8 +- tests/unit/startup/video_mode_test.cpp | 97 +++++++++++++++++++----- 9 files changed, 202 insertions(+), 78 deletions(-) diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index 76a8193..25bb63b 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -144,7 +144,7 @@ namespace { } /** - * @brief Return the selected stream-resolution index inside the preset list. + * @brief Return the selected stream-resolution index inside the detected mode list. * * @param state Current client state containing the preferred mode. * @return Zero-based index of the preferred mode, or zero when no exact match exists. @@ -164,9 +164,9 @@ namespace { } /** - * @brief Advance the preferred stream resolution to the next preset. + * @brief Advance the preferred stream resolution to the next detected Xbox video mode. * - * @param state Current client state containing the configured stream-resolution presets. + * @param state Current client state containing detected stream-resolution modes. */ void cycle_stream_video_mode(app::ClientState &state) { if (state.settings.availableVideoModes.empty()) { @@ -528,7 +528,7 @@ namespace { { "cycle-stream-video-mode", std::string("Stream Resolution: ") + describe_stream_resolution(state.settings.preferredVideoMode), - "Cycle through fixed stream-resolution presets. The selected resolution is requested from the host the next time a stream starts and does not change the Xbox output mode.", + "Cycle through detected Xbox video modes enabled by the console settings. The selected resolution is requested from the host the next time a stream starts.", true, }, { diff --git a/src/app/client_state.h b/src/app/client_state.h index d8bfbb8..8586e3c 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -316,7 +316,7 @@ namespace app { LogViewerPlacement logViewerPlacement = LogViewerPlacement::full; ///< Log viewer pane placement relative to the shell. logging::LogLevel loggingLevel = logging::LogLevel::none; ///< Minimum runtime log level written to the persisted log file. logging::LogLevel xemuConsoleLoggingLevel = logging::LogLevel::none; ///< Minimum runtime log level mirrored through DbgPrint() to xemu's serial console. - std::vector availableVideoModes; ///< Fixed stream-resolution presets exposed by the settings UI. + std::vector availableVideoModes; ///< Detected Xbox video modes exposed as stream-resolution choices. VIDEO_MODE preferredVideoMode {}; ///< Preferred stream resolution requested from the host. bool preferredVideoModeSet = false; ///< True when preferredVideoMode contains a user-selected or default mode. int streamFramerate = 30; ///< Preferred stream frame rate in frames per second. diff --git a/src/main.cpp b/src/main.cpp index f724a0b..8927e34 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -64,14 +64,18 @@ namespace { /** * @brief Finalize the preferred stream resolution after startup detection. * - * @param state Mutable client state receiving the fixed stream-resolution presets. + * @param state Mutable client state receiving detected Xbox video modes. * @param selection Detected Xbox output modes and preferred startup mode. + * @param encoderSettings Active Xbox video encoder settings. */ - void initialize_stream_video_mode_settings(app::ClientState &state, const startup::VideoModeSelection &selection) { - state.settings.availableVideoModes = startup::stream_resolution_presets( - selection.bestVideoMode.bpp > 0 ? selection.bestVideoMode.bpp : 32, - selection.bestVideoMode.refresh > 0 ? selection.bestVideoMode.refresh : 60 - ); + void initialize_stream_video_mode_settings(app::ClientState &state, const startup::VideoModeSelection &selection, DWORD encoderSettings) { + state.settings.availableVideoModes = startup::filter_stream_video_modes_for_encoder_settings(selection.availableVideoModes, encoderSettings); + if (state.settings.availableVideoModes.empty()) { + state.settings.preferredVideoMode = {}; + state.settings.preferredVideoModeSet = false; + return; + } + const auto preferredMode = std::find_if( state.settings.availableVideoModes.begin(), state.settings.availableVideoModes.end(), @@ -84,7 +88,7 @@ namespace { return; } - state.settings.preferredVideoMode = startup::choose_default_stream_video_mode(selection.bestVideoMode); + state.settings.preferredVideoMode = startup::choose_default_stream_video_mode(state.settings.availableVideoModes, selection.bestVideoMode); state.settings.preferredVideoModeSet = state.settings.preferredVideoMode.width > 0 && state.settings.preferredVideoMode.height > 0; } @@ -231,10 +235,11 @@ int main() { logging::info("app", std::string("Initial screen: ") + app::to_string(clientState.shell.activeScreen)); debug_print_startup_checkpoint("Runtime logging initialized"); - debug_print_encoder_settings(XVideoGetEncoderSettings()); + const DWORD encoderSettings = XVideoGetEncoderSettings(); + debug_print_encoder_settings(encoderSettings); const startup::VideoModeSelection videoModeSelection = startup::select_best_video_mode(); - initialize_stream_video_mode_settings(clientState, videoModeSelection); + initialize_stream_video_mode_settings(clientState, videoModeSelection, encoderSettings); const VIDEO_MODE &bestVideoMode = videoModeSelection.bestVideoMode; debug_print_video_mode_selection(videoModeSelection); startup::log_memory_statistics(); diff --git a/src/startup/video_mode.cpp b/src/startup/video_mode.cpp index bdcd36d..489e30f 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -20,13 +20,11 @@ namespace startup { int height; }; - constexpr std::array STREAM_RESOLUTION_PRESETS {{ - {352, 240}, - {352, 288}, - {480, 480}, - {480, 576}, + constexpr std::array STREAM_RESOLUTION_PRESETS {{ + {640, 480}, {720, 480}, - {720, 576}, + {1280, 720}, + {1920, 1080}, }}; bool is_1080i_mode(const VIDEO_MODE &videoMode) { @@ -37,6 +35,23 @@ namespace startup { return {preset.width, preset.height, bpp, refresh}; } + bool stream_resolutions_match(const VIDEO_MODE &left, const VIDEO_MODE &right) { + return left.width == right.width && left.height == right.height; + } + + bool is_smaller_video_mode(const VIDEO_MODE &left, const VIDEO_MODE &right) { + const int leftArea = left.width * left.height; + const int rightArea = right.width * right.height; + if (leftArea != rightArea) { + return leftArea < rightArea; + } + return left.width < right.width; + } + + bool is_sd_wide_width_mode(const VIDEO_MODE &videoMode) { + return videoMode.height <= 576 && videoMode.width > 640; + } + } // namespace bool is_preferred_video_mode(const VIDEO_MODE &candidateVideoMode, const VIDEO_MODE ¤tBestVideoMode) { @@ -88,11 +103,47 @@ namespace startup { return presets; } + std::vector filter_stream_video_modes_for_encoder_settings(const std::vector &availableVideoModes, unsigned long encoderSettings) { + if ((encoderSettings & VIDEO_WIDESCREEN) != 0UL) { + return availableVideoModes; + } + + std::vector filteredVideoModes; + filteredVideoModes.reserve(availableVideoModes.size()); + for (const VIDEO_MODE &availableVideoMode : availableVideoModes) { + if (!is_sd_wide_width_mode(availableVideoMode)) { + filteredVideoModes.push_back(availableVideoMode); + } + } + return filteredVideoModes; + } + VIDEO_MODE choose_default_stream_video_mode(const VIDEO_MODE &outputVideoMode) { const int bpp = outputVideoMode.bpp > 0 ? outputVideoMode.bpp : 32; const int refresh = outputVideoMode.refresh > 0 ? outputVideoMode.refresh : 60; - return refresh <= 50 ? make_stream_video_mode({352, 288}, bpp, refresh) : make_stream_video_mode({352, 240}, bpp, refresh); + return make_stream_video_mode({640, 480}, bpp, refresh); + } + + VIDEO_MODE choose_default_stream_video_mode(const std::vector &availableVideoModes, const VIDEO_MODE &outputVideoMode) { + const VIDEO_MODE fallbackDefault = choose_default_stream_video_mode(outputVideoMode); + for (const VIDEO_MODE &availableVideoMode : availableVideoModes) { + if (stream_resolutions_match(availableVideoMode, fallbackDefault)) { + return availableVideoMode; + } + } + + if (availableVideoModes.empty()) { + return fallbackDefault; + } + + VIDEO_MODE smallestVideoMode = availableVideoModes.front(); + for (const VIDEO_MODE &availableVideoMode : availableVideoModes) { + if (is_smaller_video_mode(availableVideoMode, smallestVideoMode)) { + smallestVideoMode = availableVideoMode; + } + } + return smallestVideoMode; } VideoModeSelection select_best_video_mode(int bpp, int refresh) { diff --git a/src/startup/video_mode.h b/src/startup/video_mode.h index f15f2fd..f6911e6 100644 --- a/src/startup/video_mode.h +++ b/src/startup/video_mode.h @@ -42,29 +42,49 @@ namespace startup { VIDEO_MODE choose_best_video_mode(const std::vector &availableVideoModes); /** - * @brief Return the fixed stream-resolution presets exposed in the settings UI. + * @brief Return canonical Xbox video modes usable as stream-resolution choices. * - * These presets are independent from the Xbox output modes returned by - * `XVideoListModes()`. They define only the host stream resolution that - * Moonlight requests when starting a session. + * Runtime code should prefer modes detected by `XVideoListModes()`. These + * values are a fallback for code paths without live video-mode detection. * - * @param bpp Bits-per-pixel metadata to attach to each preset. - * @param refresh Refresh-rate metadata to attach to each preset. - * @return Ordered list of stream-resolution presets. + * @param bpp Bits-per-pixel metadata to attach to each mode. + * @param refresh Refresh-rate metadata to attach to each mode. + * @return Ordered list of canonical stream-resolution modes. */ std::vector stream_resolution_presets(int bpp = 32, int refresh = 60); /** - * @brief Choose the default stream-resolution preset for the current output mode. + * @brief Filter detected stream-resolution modes through display-aspect encoder settings. * - * The shell output mode still comes from Xbox video-mode detection, but stream - * quality is controlled separately through the settings presets. + * `XVideoListModes()` already filters progressive and HD modes through the + * encoder settings. This helper applies the widescreen flag so SD wide-width + * modes are only exposed when the console is configured for widescreen. + * + * @param availableVideoModes Detected Xbox video modes from `XVideoListModes()`. + * @param encoderSettings The value returned by `XVideoGetEncoderSettings()`. + * @return Modes suitable for the stream-resolution settings list. + */ + std::vector filter_stream_video_modes_for_encoder_settings(const std::vector &availableVideoModes, unsigned long encoderSettings); + + /** + * @brief Choose the default stream-resolution mode for the current output mode. + * + * The default favors an SD Xbox mode to keep software decoding practical. * * @param outputVideoMode Active Xbox output mode selected at startup. - * @return Default stream-resolution preset for new or missing settings. + * @return Default stream-resolution mode for new or missing settings. */ VIDEO_MODE choose_default_stream_video_mode(const VIDEO_MODE &outputVideoMode); + /** + * @brief Choose the default stream-resolution mode from detected Xbox modes. + * + * @param availableVideoModes Detected Xbox video modes exposed in settings. + * @param outputVideoMode Active Xbox output mode selected at startup. + * @return A detected default mode when possible, otherwise a canonical fallback. + */ + VIDEO_MODE choose_default_stream_video_mode(const std::vector &availableVideoModes, const VIDEO_MODE &outputVideoMode); + /** * @brief Detect and choose the best available video mode. * diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index 1a636fa..b47f871 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -160,22 +160,11 @@ namespace { } VIDEO_MODE select_stream_presentation_video_mode(const VIDEO_MODE &fallbackVideoMode, const VIDEO_MODE &streamVideoMode) { - VIDEO_MODE presentationMode = fallbackVideoMode; - presentationMode.bpp = fallbackVideoMode.bpp > 0 ? fallbackVideoMode.bpp : 32; - presentationMode.refresh = fallbackVideoMode.refresh > 0 ? fallbackVideoMode.refresh : 60; - - if (streamVideoMode.height <= 288) { - presentationMode.width = 640; - presentationMode.height = 480; - } else if (streamVideoMode.height <= 480) { - presentationMode.width = streamVideoMode.width >= 700 ? 720 : 640; - presentationMode.height = 480; - } else if (streamVideoMode.height <= 576) { - presentationMode.width = 720; - presentationMode.height = presentationMode.refresh <= 50 ? 576 : 480; - } - - return presentationMode; + if (streamVideoMode.width > 0 && streamVideoMode.height > 0) { + return streamVideoMode; + } + + return fallbackVideoMode; } class ScopedStreamVideoMode { diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index a34d8ed..962fe1a 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -1007,12 +1007,12 @@ namespace { EXPECT_FALSE(update.navigation.screenChanged); } - TEST(ClientStateTest, DisplaySettingsCanCycleLowStreamResolutionPresets) { + TEST(ClientStateTest, DisplaySettingsCanCycleXboxVideoModeStreamChoices) { app::ClientState state = app::create_initial_state(); state.settings.availableVideoModes = { - VIDEO_MODE {352, 240, 32, 60}, - VIDEO_MODE {352, 288, 32, 60}, - VIDEO_MODE {480, 480, 32, 60}, + VIDEO_MODE {640, 480, 32, 60}, + VIDEO_MODE {720, 480, 32, 60}, + VIDEO_MODE {1280, 720, 32, 60}, }; state.settings.preferredVideoMode = state.settings.availableVideoModes.front(); state.settings.preferredVideoModeSet = true; @@ -1030,9 +1030,9 @@ namespace { const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); EXPECT_EQ(update.navigation.activatedItemId, "cycle-stream-video-mode"); EXPECT_TRUE(update.persistence.settingsChanged); - EXPECT_EQ(state.settings.preferredVideoMode.width, 352); - EXPECT_EQ(state.settings.preferredVideoMode.height, 288); - EXPECT_EQ(state.shell.statusMessage, "Stream resolution set to 352x288"); + EXPECT_EQ(state.settings.preferredVideoMode.width, 720); + EXPECT_EQ(state.settings.preferredVideoMode.height, 480); + EXPECT_EQ(state.shell.statusMessage, "Stream resolution set to 720x480"); } TEST(ClientStateTest, ConfirmationModalCanBeCancelledWithoutRequestingPersistenceChanges) { diff --git a/tests/unit/app/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp index b8efd4f..fc27ca3 100644 --- a/tests/unit/app/settings_storage_test.cpp +++ b/tests/unit/app/settings_storage_test.cpp @@ -75,12 +75,12 @@ namespace { EXPECT_FALSE(loadResult.cleanupRequired); } - TEST_F(SettingsStorageTest, SavesAndLoadsLowStreamResolutionPresets) { + TEST_F(SettingsStorageTest, SavesAndLoadsXboxStreamResolutionModes) { const app::AppSettings savedSettings { logging::LogLevel::none, logging::LogLevel::none, app::LogViewerPlacement::full, - VIDEO_MODE {352, 240, 32, 60}, + VIDEO_MODE {640, 480, 32, 60}, true, 15, 500, @@ -95,8 +95,8 @@ namespace { EXPECT_TRUE(loadResult.fileFound); EXPECT_TRUE(loadResult.warnings.empty()); EXPECT_TRUE(loadResult.settings.preferredVideoModeSet); - EXPECT_EQ(loadResult.settings.preferredVideoMode.width, 352); - EXPECT_EQ(loadResult.settings.preferredVideoMode.height, 240); + EXPECT_EQ(loadResult.settings.preferredVideoMode.width, 640); + EXPECT_EQ(loadResult.settings.preferredVideoMode.height, 480); EXPECT_EQ(loadResult.settings.preferredVideoMode.bpp, 32); EXPECT_EQ(loadResult.settings.preferredVideoMode.refresh, 60); EXPECT_EQ(loadResult.settings.streamFramerate, 15); diff --git a/tests/unit/startup/video_mode_test.cpp b/tests/unit/startup/video_mode_test.cpp index 03049f6..70a02f1 100644 --- a/tests/unit/startup/video_mode_test.cpp +++ b/tests/unit/startup/video_mode_test.cpp @@ -60,47 +60,106 @@ namespace { EXPECT_EQ(bestVideoMode.refresh, 60); } - TEST(VideoModeTest, ExposesFixedStreamResolutionPresetsWithinSoftwareDecodeRange) { + TEST(VideoModeTest, ExposesOnlyXboxVideoModeStreamChoicesAsFallbacks) { const std::vector presets = startup::stream_resolution_presets(32, 60); - ASSERT_EQ(presets.size(), 6U); - EXPECT_EQ(presets.front().width, 352); - EXPECT_EQ(presets.front().height, 240); - EXPECT_EQ(presets[1].width, 352); - EXPECT_EQ(presets[1].height, 288); - EXPECT_EQ(presets[4].width, 720); - EXPECT_EQ(presets[4].height, 480); - EXPECT_EQ(presets.back().width, 720); - EXPECT_EQ(presets.back().height, 576); + ASSERT_EQ(presets.size(), 4U); + EXPECT_EQ(presets.front().width, 640); + EXPECT_EQ(presets.front().height, 480); + EXPECT_EQ(presets[1].width, 720); + EXPECT_EQ(presets[1].height, 480); + EXPECT_EQ(presets[2].width, 1280); + EXPECT_EQ(presets[2].height, 720); + EXPECT_EQ(presets.back().width, 1920); + EXPECT_EQ(presets.back().height, 1080); EXPECT_EQ(presets.back().bpp, 32); EXPECT_EQ(presets.back().refresh, 60); } - TEST(VideoModeTest, ChoosesLowLatencyNtscPresetForHdStreamDefaults) { + TEST(VideoModeTest, FiltersSdWideWidthModesWhenWidescreenIsDisabled) { + const std::vector availableVideoModes = { + {640, 480, 32, 60}, + {720, 480, 32, 60}, + {1280, 720, 32, 60}, + }; + + const std::vector filteredVideoModes = startup::filter_stream_video_modes_for_encoder_settings(availableVideoModes, 0UL); + + ASSERT_EQ(filteredVideoModes.size(), 2U); + EXPECT_EQ(filteredVideoModes[0].width, 640); + EXPECT_EQ(filteredVideoModes[0].height, 480); + EXPECT_EQ(filteredVideoModes[1].width, 1280); + EXPECT_EQ(filteredVideoModes[1].height, 720); + } + + TEST(VideoModeTest, KeepsSdWideWidthModesWhenWidescreenIsEnabled) { + const std::vector availableVideoModes = { + {640, 480, 32, 60}, + {720, 480, 32, 60}, + {1280, 720, 32, 60}, + }; + + const std::vector filteredVideoModes = startup::filter_stream_video_modes_for_encoder_settings(availableVideoModes, VIDEO_WIDESCREEN); + + ASSERT_EQ(filteredVideoModes.size(), 3U); + EXPECT_EQ(filteredVideoModes[1].width, 720); + EXPECT_EQ(filteredVideoModes[1].height, 480); + } + + TEST(VideoModeTest, ChoosesSdXboxModeForHdStreamDefaults) { const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({1280, 720, 32, 60}); - EXPECT_EQ(defaultPreset.width, 352); - EXPECT_EQ(defaultPreset.height, 240); + EXPECT_EQ(defaultPreset.width, 640); + EXPECT_EQ(defaultPreset.height, 480); EXPECT_EQ(defaultPreset.bpp, 32); EXPECT_EQ(defaultPreset.refresh, 60); } - TEST(VideoModeTest, ChoosesLowLatencyNtscPresetFor60HzOutputModes) { + TEST(VideoModeTest, ChoosesSdXboxModeFor60HzOutputModes) { const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({640, 480, 32, 60}); - EXPECT_EQ(defaultPreset.width, 352); - EXPECT_EQ(defaultPreset.height, 240); + EXPECT_EQ(defaultPreset.width, 640); + EXPECT_EQ(defaultPreset.height, 480); EXPECT_EQ(defaultPreset.bpp, 32); EXPECT_EQ(defaultPreset.refresh, 60); } - TEST(VideoModeTest, ChoosesLowLatencyPalPresetFor50HzOutputModes) { + TEST(VideoModeTest, ChoosesSdXboxModeFor50HzOutputModes) { const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({640, 480, 32, 50}); - EXPECT_EQ(defaultPreset.width, 352); - EXPECT_EQ(defaultPreset.height, 288); + EXPECT_EQ(defaultPreset.width, 640); + EXPECT_EQ(defaultPreset.height, 480); EXPECT_EQ(defaultPreset.bpp, 32); EXPECT_EQ(defaultPreset.refresh, 50); } + TEST(VideoModeTest, ChoosesDetectedSdModeWhenAvailable) { + const std::vector availableVideoModes = { + {720, 480, 32, 60}, + {640, 480, 16, 60}, + {1280, 720, 32, 60}, + }; + + const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode(availableVideoModes, {1280, 720, 32, 60}); + + EXPECT_EQ(defaultPreset.width, 640); + EXPECT_EQ(defaultPreset.height, 480); + EXPECT_EQ(defaultPreset.bpp, 16); + EXPECT_EQ(defaultPreset.refresh, 60); + } + + TEST(VideoModeTest, ChoosesSmallestDetectedModeWhenSdModeIsUnavailable) { + const std::vector availableVideoModes = { + {1280, 720, 32, 60}, + {720, 480, 32, 60}, + }; + + const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode(availableVideoModes, {1280, 720, 32, 60}); + + EXPECT_EQ(defaultPreset.width, 720); + EXPECT_EQ(defaultPreset.height, 480); + EXPECT_EQ(defaultPreset.bpp, 32); + EXPECT_EQ(defaultPreset.refresh, 60); + } + } // namespace From 5d8fa9071d9b3d156f57e46af389303a5d73f708 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 15:57:59 -0400 Subject: [PATCH 09/19] Document compat defines and add PltGetMicroseconds Add explanatory comments for several compatibility macros in ffmpeg_compat.h and share.h (ENOSYS, O_BINARY, F_SETFD, FD_CLOEXEC, CP_ACP, CP_UTF8, MB_ERR_INVALID_CHARS, WC_ERR_INVALID_CHARS, SH_DENYNO) to clarify their purpose for FFmpeg/nxdk builds. Also add an extern "C" declaration (with doc comment) for PltGetMicroseconds in ffmpeg_stream_backend.cpp and session.cpp so the platform monotonic clock is visible to moonlight-common-c. These are documentation and forward-declaration changes to improve maintainability and cross-module compatibility; no behavioral changes intended. --- README.md | 5 +---- src/_nxdk_compat/ffmpeg_compat.h | 8 ++++++++ src/_nxdk_compat/share.h | 1 + src/streaming/ffmpeg_stream_backend.cpp | 5 +++++ src/streaming/session.cpp | 5 +++++ 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1625944..6ba521f 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,7 @@ [![GitHub Workflow Status (CI)](https://img.shields.io/github/actions/workflow/status/lizardbyte/moonlight-xboxog/ci.yml.svg?branch=master&label=CI%20build&logo=github&style=for-the-badge)](https://github.com/LizardByte/Moonlight-XboxOG/actions/workflows/CI.yml?query=branch%3Amaster) [![Codecov](https://img.shields.io/codecov/c/gh/LizardByte/Moonlight-XboxOG?token=DoIh5pkEzA&style=for-the-badge&logo=codecov&label=codecov)](https://codecov.io/gh/LizardByte/Moonlight-XboxOG) -Port of Moonlight for the Original Xbox. Unlikely to ever actually work. Do NOT use! - -> [!WARNING] -> Streaming does not work yet. +Port of Moonlight for the Original Xbox. ![Splash Screen](./docs/images/screenshots/01-splash.png) ![Hosts Screen](./docs/images/screenshots/02-hosts.png) diff --git a/src/_nxdk_compat/ffmpeg_compat.h b/src/_nxdk_compat/ffmpeg_compat.h index 580ec24..4697569 100644 --- a/src/_nxdk_compat/ffmpeg_compat.h +++ b/src/_nxdk_compat/ffmpeg_compat.h @@ -21,34 +21,42 @@ extern "C" { #endif #ifndef ENOSYS + /** @brief Error code used when an FFmpeg compatibility operation is unsupported. */ #define ENOSYS 38 #endif #ifndef O_BINARY + /** @brief Binary-open flag placeholder for nxdk file APIs. */ #define O_BINARY 0 #endif #ifndef F_SETFD + /** @brief fcntl command placeholder used by FFmpeg close-on-exec setup. */ #define F_SETFD 2 #endif #ifndef FD_CLOEXEC + /** @brief close-on-exec descriptor flag placeholder for FFmpeg builds. */ #define FD_CLOEXEC 1 #endif #ifndef CP_ACP + /** @brief Windows ANSI code-page identifier used by FFmpeg path conversion code. */ #define CP_ACP 0U #endif #ifndef CP_UTF8 + /** @brief Windows UTF-8 code-page identifier used by FFmpeg path conversion code. */ #define CP_UTF8 65001U #endif #ifndef MB_ERR_INVALID_CHARS + /** @brief Windows multibyte conversion validation flag used by FFmpeg. */ #define MB_ERR_INVALID_CHARS 0x00000008UL #endif #ifndef WC_ERR_INVALID_CHARS + /** @brief Windows wide-character conversion validation flag used by FFmpeg. */ #define WC_ERR_INVALID_CHARS 0x00000080UL #endif diff --git a/src/_nxdk_compat/share.h b/src/_nxdk_compat/share.h index 371da8d..8187fcf 100644 --- a/src/_nxdk_compat/share.h +++ b/src/_nxdk_compat/share.h @@ -13,6 +13,7 @@ extern "C" { #endif #ifndef SH_DENYNO + /** @brief Shared-open mode that denies no other access. */ #define SH_DENYNO 0 #endif diff --git a/src/streaming/ffmpeg_stream_backend.cpp b/src/streaming/ffmpeg_stream_backend.cpp index 8ed731e..32bc378 100644 --- a/src/streaming/ffmpeg_stream_backend.cpp +++ b/src/streaming/ffmpeg_stream_backend.cpp @@ -35,6 +35,11 @@ extern "C" { #include #endif +/** + * @brief Return the platform monotonic clock value in microseconds. + * + * @return Microseconds from the platform timer used by moonlight-common-c. + */ extern "C" std::uint64_t PltGetMicroseconds(void); namespace { diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index b47f871..f0c2a90 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -37,6 +37,11 @@ extern "C" int rand_s(unsigned int *randomValue); #endif +/** + * @brief Return the platform monotonic clock value in microseconds. + * + * @return Microseconds from the platform timer used by moonlight-common-c. + */ extern "C" std::uint64_t PltGetMicroseconds(void); namespace { From 11d93ba987cb4dd90bcc14735841f70ad5d0c0cc Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 16:49:24 -0400 Subject: [PATCH 10/19] style: sonar fixes --- scripts/ffmpeg-nxdk-cc.sh | 2 + scripts/ffmpeg-nxdk-cxx.sh | 2 + src/app/settings_storage.cpp | 4 +- src/logging/logger.cpp | 26 +-- src/startup/video_mode.cpp | 3 +- src/streaming/ffmpeg_stream_backend.cpp | 246 +++++++++++++----------- src/streaming/ffmpeg_stream_backend.h | 36 ++-- src/streaming/session.cpp | 88 +++++---- src/ui/shell_screen.cpp | 161 ++++++++-------- 9 files changed, 296 insertions(+), 272 deletions(-) diff --git a/scripts/ffmpeg-nxdk-cc.sh b/scripts/ffmpeg-nxdk-cc.sh index ad35586..722ebb6 100644 --- a/scripts/ffmpeg-nxdk-cc.sh +++ b/scripts/ffmpeg-nxdk-cc.sh @@ -14,6 +14,8 @@ for arg in "$@"; do -c|-E) compile_only=1 ;; + *) + ;; esac if [ "$previous_arg" = "-o" ]; then diff --git a/scripts/ffmpeg-nxdk-cxx.sh b/scripts/ffmpeg-nxdk-cxx.sh index b62ba21..f5d5444 100644 --- a/scripts/ffmpeg-nxdk-cxx.sh +++ b/scripts/ffmpeg-nxdk-cxx.sh @@ -14,6 +14,8 @@ for arg in "$@"; do -c|-E) compile_only=1 ;; + *) + ;; esac if [ "$previous_arg" = "-o" ]; then diff --git a/src/app/settings_storage.cpp b/src/app/settings_storage.cpp index 4a99805..915f504 100644 --- a/src/app/settings_storage.cpp +++ b/src/app/settings_storage.cpp @@ -283,7 +283,7 @@ namespace { return; } - if (const auto parsedValue = settingNode.value(); parsedValue) { + if (const auto parsedValue = settingNode.value(); parsedValue.has_value()) { if (value != nullptr) { *value = static_cast(*parsedValue); } @@ -313,7 +313,7 @@ namespace { return; } - if (const auto parsedValue = settingNode.value(); parsedValue) { + if (const auto parsedValue = settingNode.value(); parsedValue.has_value()) { if (value != nullptr) { *value = *parsedValue; } diff --git a/src/logging/logger.cpp b/src/logging/logger.cpp index b60e797..ab9bb47 100644 --- a/src/logging/logger.cpp +++ b/src/logging/logger.cpp @@ -304,52 +304,52 @@ namespace logging { } void Logger::set_minimum_level(LogLevel minimumLevel) { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); minimumLevel_ = minimumLevel; } LogLevel Logger::minimum_level() const { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); return minimumLevel_; } void Logger::set_file_sink(LogSink sink) { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); fileSink_ = std::move(sink); } void Logger::set_file_minimum_level(LogLevel minimumLevel) { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); fileMinimumLevel_ = minimumLevel; } LogLevel Logger::file_minimum_level() const { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); return fileMinimumLevel_; } void Logger::set_startup_debug_enabled(bool enabled) { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); startupDebugEnabled_ = enabled; } bool Logger::startup_debug_enabled() const { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); return startupDebugEnabled_; } void Logger::set_debugger_console_minimum_level(LogLevel minimumLevel) { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); debuggerConsoleMinimumLevel_ = minimumLevel; } LogLevel Logger::debugger_console_minimum_level() const { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); return debuggerConsoleMinimumLevel_; } bool Logger::should_log(LogLevel level) const { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); return should_log_unlocked(level); } @@ -373,7 +373,7 @@ namespace logging { } bool Logger::log(LogLevel level, std::string category, std::string message, LogSourceLocation location) { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); if (!should_log_unlocked(level)) { return false; } @@ -436,7 +436,7 @@ namespace logging { } void Logger::add_sink(LogSink sink, LogLevel minimumLevel) { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); if (sink) { sinks_.push_back({minimumLevel, std::move(sink)}); } @@ -447,7 +447,7 @@ namespace logging { } std::vector Logger::snapshot(LogLevel minimumLevel) const { - std::lock_guard lock(mutex_); + std::scoped_lock lock(mutex_); std::vector filteredEntries; for (const LogEntry &entry : entries_) { diff --git a/src/startup/video_mode.cpp b/src/startup/video_mode.cpp index 489e30f..7d0548b 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -41,8 +41,7 @@ namespace startup { bool is_smaller_video_mode(const VIDEO_MODE &left, const VIDEO_MODE &right) { const int leftArea = left.width * left.height; - const int rightArea = right.width * right.height; - if (leftArea != rightArea) { + if (const int rightArea = right.width * right.height; leftArea != rightArea) { return leftArea < rightArea; } return left.width < right.width; diff --git a/src/streaming/ffmpeg_stream_backend.cpp b/src/streaming/ffmpeg_stream_backend.cpp index 32bc378..c3749db 100644 --- a/src/streaming/ffmpeg_stream_backend.cpp +++ b/src/streaming/ffmpeg_stream_backend.cpp @@ -44,15 +44,46 @@ extern "C" std::uint64_t PltGetMicroseconds(void); namespace { + /** + * @brief Opaque callback context passed by moonlight-common-c and FFmpeg. + */ + using MoonlightCallbackContext = void; + constexpr Uint32 STREAM_OVERLAY_AUDIO_QUEUE_LIMIT_MS = 250U; constexpr std::uint64_t STREAM_VIDEO_SUBMISSION_LOG_INTERVAL = 120; constexpr int STREAM_VIDEO_DROPPED_FRAME_STATUS = -2; constexpr std::uint64_t STREAM_VIDEO_DROP_LOG_INTERVAL = 30; constexpr Uint32 STREAM_VIDEO_DECODE_YIELD_MILLISECONDS = 1U; - streaming::FfmpegStreamBackend *g_active_video_backend = nullptr; - streaming::FfmpegStreamBackend *g_active_audio_backend = nullptr; - std::once_flag g_ffmpeg_logging_once; + /** + * @brief Return the backend currently receiving video callbacks. + * + * @return Mutable callback backend slot. + */ + streaming::FfmpegStreamBackend *&active_video_backend() { + static streaming::FfmpegStreamBackend *backend = nullptr; + return backend; + } + + /** + * @brief Return the backend currently receiving audio callbacks. + * + * @return Mutable callback backend slot. + */ + streaming::FfmpegStreamBackend *&active_audio_backend() { + static streaming::FfmpegStreamBackend *backend = nullptr; + return backend; + } + + /** + * @brief Return the one-time FFmpeg logging setup guard. + * + * @return Shared initialization flag. + */ + std::once_flag &ffmpeg_logging_once() { + static std::once_flag once; + return once; + } /** * @brief Convert an FFmpeg error code into readable text. @@ -109,7 +140,7 @@ namespace { * @param format `printf`-style format string. * @param arguments Variadic arguments matching `format`. */ - void ffmpeg_log_callback(void *avClassContext, int level, const char *format, va_list arguments) { + void ffmpeg_log_callback(MoonlightCallbackContext *avClassContext, int level, const char *format, va_list arguments) { std::array buffer {}; int printPrefix = 1; va_list argumentsCopy; @@ -141,7 +172,7 @@ namespace { * @brief Install the shared FFmpeg log callback once for the process. */ void ensure_ffmpeg_logging_installed() { - std::call_once(g_ffmpeg_logging_once, []() { + std::call_once(ffmpeg_logging_once(), []() { av_log_set_level(AV_LOG_ERROR); av_log_set_callback(ffmpeg_log_callback); }); @@ -235,75 +266,67 @@ namespace { return true; } - int on_video_setup(int videoFormat, int width, int height, int redrawRate, void *context, int drFlags) { + int on_video_setup(int videoFormat, int width, int height, int redrawRate, MoonlightCallbackContext *context, int drFlags) { auto *backend = static_cast(context); if (backend == nullptr) { return -1; } - g_active_video_backend = backend; - return backend->setup_video_decoder(videoFormat, width, height, redrawRate, context, drFlags); + active_video_backend() = backend; + return backend->setup_video_decoder(videoFormat, width, height, redrawRate, drFlags); } void on_video_start() { - if (g_active_video_backend != nullptr) { - g_active_video_backend->start_video_decoder(); + if (streaming::FfmpegStreamBackend *backend = active_video_backend(); backend != nullptr) { + backend->start_video_decoder(); } } void on_video_stop() { - if (g_active_video_backend != nullptr) { - g_active_video_backend->stop_video_decoder(); + if (streaming::FfmpegStreamBackend *backend = active_video_backend(); backend != nullptr) { + backend->stop_video_decoder(); } } void on_video_cleanup() { - if (g_active_video_backend != nullptr) { - g_active_video_backend->cleanup_video_decoder(); - g_active_video_backend = nullptr; + if (streaming::FfmpegStreamBackend *backend = active_video_backend(); backend != nullptr) { + backend->cleanup_video_decoder(); + active_video_backend() = nullptr; } } - int on_video_submit_decode_unit(PDECODE_UNIT decodeUnit) { - if (g_active_video_backend == nullptr) { - return DR_NEED_IDR; - } - - return g_active_video_backend->submit_video_decode_unit(decodeUnit); - } - - int on_audio_init(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, void *context, int arFlags) { + int on_audio_init(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, MoonlightCallbackContext *context, int arFlags) { auto *backend = static_cast(context); if (backend == nullptr) { return -1; } - g_active_audio_backend = backend; - return backend->initialize_audio_decoder(audioConfiguration, opusConfig, context, arFlags); + active_audio_backend() = backend; + return backend->initialize_audio_decoder(audioConfiguration, opusConfig, arFlags); } void on_audio_start() { - if (g_active_audio_backend != nullptr) { - g_active_audio_backend->start_audio_playback(); + if (streaming::FfmpegStreamBackend *backend = active_audio_backend(); backend != nullptr) { + backend->start_audio_playback(); } } void on_audio_stop() { - if (g_active_audio_backend != nullptr) { - g_active_audio_backend->stop_audio_playback(); + if (streaming::FfmpegStreamBackend *backend = active_audio_backend(); backend != nullptr) { + backend->stop_audio_playback(); } } void on_audio_cleanup() { - if (g_active_audio_backend != nullptr) { - g_active_audio_backend->cleanup_audio_decoder(); - g_active_audio_backend = nullptr; + if (streaming::FfmpegStreamBackend *backend = active_audio_backend(); backend != nullptr) { + backend->cleanup_audio_decoder(); + active_audio_backend() = nullptr; } } void on_audio_decode_and_play_sample(char *sampleData, int sampleLength) { - if (g_active_audio_backend != nullptr) { - g_active_audio_backend->decode_and_play_audio_sample(sampleData, sampleLength); + if (streaming::FfmpegStreamBackend *backend = active_audio_backend(); backend != nullptr) { + backend->decode_and_play_audio_sample(sampleData, sampleLength); } } @@ -315,7 +338,7 @@ namespace streaming { shutdown(); } - void FfmpegStreamBackend::initialize_callbacks(DECODER_RENDERER_CALLBACKS *videoCallbacks, AUDIO_RENDERER_CALLBACKS *audioCallbacks) { + void FfmpegStreamBackend::initialize_callbacks(DECODER_RENDERER_CALLBACKS *videoCallbacks, AUDIO_RENDERER_CALLBACKS *audioCallbacks) const { ensure_ffmpeg_logging_installed(); if (videoCallbacks != nullptr) { @@ -418,7 +441,10 @@ namespace streaming { } std::string FfmpegStreamBackend::build_overlay_status_line() const { - std::string audioState = !audioPlaybackEnabled_.load() ? "audio decode disabled" : (audio_.deviceId != 0 ? std::to_string(SDL_GetQueuedAudioSize(audio_.deviceId)) + " queued audio bytes" : "audio idle"); + std::string audioState = "audio decode disabled"; + if (audioPlaybackEnabled_.load()) { + audioState = audio_.deviceId != 0 ? std::to_string(SDL_GetQueuedAudioSize(audio_.deviceId)) + " queued audio bytes" : "audio idle"; + } return std::string("Video units: ") + std::to_string(video_.submittedDecodeUnitCount.load()) + " submitted / " + std::to_string(video_.decodedFrameCount.load()) + " decoded / " + std::to_string(video_.droppedDecodeUnitCount.load()) + " dropped | frame " + std::to_string(video_.lastDecodeFrameNumber.load()) + " queue " + std::to_string(video_.lastDecodeQueueUs.load() / 1000U) + " ms / age " + std::to_string(video_.lastReceiveAgeUs.load() / 1000U) + " ms | " + audioState; @@ -430,9 +456,8 @@ namespace streaming { } bool textureNeedsUpload = false; - const std::uint64_t publishedFrameVersion = video_.publishedFrameVersion.load(); - if (publishedFrameVersion != video_.renderedFrameVersion) { - std::lock_guard lock(video_.frameMutex); + if (const std::uint64_t publishedFrameVersion = video_.publishedFrameVersion.load(); publishedFrameVersion != video_.renderedFrameVersion) { + std::scoped_lock lock(video_.frameMutex); if (video_.latestFrameVersion != video_.renderedFrameVersion && video_.latestFrame.width > 0 && video_.latestFrame.height > 0) { std::swap(video_.renderFrame, video_.latestFrame); video_.renderedFrameVersion = video_.latestFrameVersion; @@ -469,31 +494,29 @@ namespace streaming { textureNeedsUpload = true; } - if (textureNeedsUpload) { - if (SDL_UpdateYUVTexture( - video_.texture, - nullptr, - reinterpret_cast(video_.renderFrame.yPlane.data()), - video_.renderFrame.yPitch, - reinterpret_cast(video_.renderFrame.uPlane.data()), - video_.renderFrame.uPitch, - reinterpret_cast(video_.renderFrame.vPlane.data()), - video_.renderFrame.vPitch - ) != 0) { - logging::error("stream", std::string("SDL_UpdateYUVTexture failed during video presentation: ") + SDL_GetError()); - return false; - } + if (textureNeedsUpload && + SDL_UpdateYUVTexture( + video_.texture, + nullptr, + reinterpret_cast(video_.renderFrame.yPlane.data()), + video_.renderFrame.yPitch, + reinterpret_cast(video_.renderFrame.uPlane.data()), + video_.renderFrame.uPitch, + reinterpret_cast(video_.renderFrame.vPlane.data()), + video_.renderFrame.vPitch + ) != 0) { + logging::error("stream", std::string("SDL_UpdateYUVTexture failed during video presentation: ") + SDL_GetError()); + return false; } const SDL_Rect destination = build_letterboxed_destination(screenWidth, screenHeight, video_.renderFrame.width, video_.renderFrame.height); return SDL_RenderCopy(renderer, video_.texture, nullptr, &destination) == 0; } - int FfmpegStreamBackend::setup_video_decoder(int videoFormat, int width, int height, int redrawRate, void *context, int drFlags) { + int FfmpegStreamBackend::setup_video_decoder(int videoFormat, int width, int height, int redrawRate, int drFlags) { (void) width; (void) height; (void) redrawRate; - (void) context; (void) drFlags; cleanup_video_decoder(); @@ -526,8 +549,7 @@ namespace streaming { video_.codecContext->err_recognition = AV_EF_EXPLODE; video_.codecContext->skip_loop_filter = AVDISCARD_ALL; video_.codecContext->skip_idct = AVDISCARD_NONREF; - const int openResult = avcodec_open2(video_.codecContext, codec, nullptr); - if (openResult < 0) { + if (const int openResult = avcodec_open2(video_.codecContext, codec, nullptr); openResult < 0) { logging::error("stream", std::string("avcodec_open2 failed for H.264: ") + describe_ffmpeg_error(openResult)); cleanup_video_decoder(); return openResult; @@ -599,7 +621,7 @@ namespace streaming { video_.renderFrame = LatestVideoFrame {}; video_.decodeFrame = LatestVideoFrame {}; { - std::lock_guard lock(video_.frameMutex); + std::scoped_lock lock(video_.frameMutex); video_.latestFrame = LatestVideoFrame {}; video_.latestFrameVersion = 0; } @@ -618,7 +640,7 @@ namespace streaming { video_.lastDecodeFrameNumber.store(0); } - int FfmpegStreamBackend::run_video_decode_thread_trampoline(void *context) { + int FfmpegStreamBackend::run_video_decode_thread_trampoline(SdlThreadContext *context) { auto *backend = static_cast(context); return backend != nullptr ? backend->run_video_decode_thread() : -1; } @@ -637,14 +659,7 @@ namespace streaming { int decodeStatus = DR_OK; if (!video_.decoderStopRequested.load() && decodeUnit != nullptr) { - const int droppedFrames = drop_queued_video_decode_units(&frameHandle, &decodeUnit); - if (droppedFrames > 0 && decodeUnit != nullptr) { - if (decodeUnit->frameType == FRAME_TYPE_IDR && video_.codecContext != nullptr) { - avcodec_flush_buffers(video_.codecContext); - } else { - decodeStatus = DR_NEED_IDR; - } - } + decodeStatus = prepare_video_decode_unit(&frameHandle, &decodeUnit); if (decodeStatus == DR_OK) { decodeStatus = decode_video_decode_unit(decodeUnit); } @@ -687,8 +702,8 @@ namespace streaming { *frameHandle = newestHandle; *decodeUnit = newestDecodeUnit; - const std::uint64_t droppedDecodeUnitCount = video_.droppedDecodeUnitCount.fetch_add(static_cast(droppedFrames)) + static_cast(droppedFrames); - if (droppedDecodeUnitCount <= static_cast(droppedFrames) || (droppedDecodeUnitCount % STREAM_VIDEO_DROP_LOG_INTERVAL) < static_cast(droppedFrames)) { + if (const std::uint64_t droppedDecodeUnitCount = video_.droppedDecodeUnitCount.fetch_add(static_cast(droppedFrames)) + static_cast(droppedFrames); + droppedDecodeUnitCount <= static_cast(droppedFrames) || (droppedDecodeUnitCount % STREAM_VIDEO_DROP_LOG_INTERVAL) < static_cast(droppedFrames)) { logging::warn( "stream", std::string("Dropped queued video decode units before decode | dropped_total=") + std::to_string(droppedDecodeUnitCount) + " | dropped_now=" + std::to_string(droppedFrames) + @@ -700,7 +715,20 @@ namespace streaming { return droppedFrames; } - bool FfmpegStreamBackend::publish_video_frame(AVFrame *frameToPresent) { + int FfmpegStreamBackend::prepare_video_decode_unit(VIDEO_FRAME_HANDLE *frameHandle, PDECODE_UNIT *decodeUnit) { + if (const int droppedFrames = drop_queued_video_decode_units(frameHandle, decodeUnit); droppedFrames <= 0 || *decodeUnit == nullptr) { + return DR_OK; + } + + if ((*decodeUnit)->frameType == FRAME_TYPE_IDR && video_.codecContext != nullptr) { + avcodec_flush_buffers(video_.codecContext); + return DR_OK; + } + + return DR_NEED_IDR; + } + + bool FfmpegStreamBackend::publish_video_frame(const AVFrame *frameToPresent) { if (frameToPresent == nullptr || frameToPresent->width <= 0 || frameToPresent->height <= 0 || frameToPresent->linesize[0] <= 0 || frameToPresent->linesize[1] <= 0 || frameToPresent->linesize[2] <= 0 || frameToPresent->data[0] == nullptr || frameToPresent->data[1] == nullptr || frameToPresent->data[2] == nullptr) { return false; @@ -726,7 +754,7 @@ namespace streaming { std::memcpy(nextFrame.vPlane.data(), frameToPresent->data[2], nextFrame.vPlane.size()); { - std::lock_guard lock(video_.frameMutex); + std::scoped_lock lock(video_.frameMutex); std::swap(video_.latestFrame, video_.decodeFrame); ++video_.latestFrameVersion; video_.publishedFrameVersion.store(video_.latestFrameVersion); @@ -780,33 +808,32 @@ namespace streaming { return false; } - const std::uint8_t *sourceData[] = { + const std::array sourceData { video_.renderFrame.yPlane.data(), video_.renderFrame.uPlane.data(), video_.renderFrame.vPlane.data(), nullptr, }; - const int sourceLinesize[] = { + const std::array sourceLinesize { video_.renderFrame.yPitch, video_.renderFrame.uPitch, video_.renderFrame.vPitch, 0, }; - std::uint8_t *destinationData[] = { + std::array destinationData { framebuffer + (static_cast(destination.y) * static_cast(framebufferPitch)) + (static_cast(destination.x) * static_cast(bytesPerPixel)), nullptr, nullptr, nullptr, }; - const int destinationLinesize[] = { + const std::array destinationLinesize { framebufferPitch, 0, 0, 0, }; - const int scaledRows = sws_scale(video_.presentScaleContext, sourceData, sourceLinesize, 0, video_.renderFrame.height, destinationData, destinationLinesize); - if (scaledRows <= 0) { + if (const int scaledRows = sws_scale(video_.presentScaleContext, sourceData.data(), sourceLinesize.data(), 0, video_.renderFrame.height, destinationData.data(), destinationLinesize.data()); scaledRows <= 0) { logging::warn("stream", "sws_scale failed for direct framebuffer presentation"); return false; } @@ -864,16 +891,16 @@ namespace streaming { video_.convertedFrame->format = AV_PIX_FMT_YUV420P; video_.convertedFrame->width = video_.decodedFrame->width; video_.convertedFrame->height = video_.decodedFrame->height; - const int fillResult = av_image_fill_arrays( - video_.convertedFrame->data, - video_.convertedFrame->linesize, - video_.convertedBuffer.data(), - AV_PIX_FMT_YUV420P, - video_.decodedFrame->width, - video_.decodedFrame->height, - 1 - ); - if (fillResult < 0) { + if (const int fillResult = av_image_fill_arrays( + video_.convertedFrame->data, + video_.convertedFrame->linesize, + video_.convertedBuffer.data(), + AV_PIX_FMT_YUV420P, + video_.decodedFrame->width, + video_.decodedFrame->height, + 1 + ); + fillResult < 0) { logging::warn("stream", std::string("av_image_fill_arrays failed for converted frame: ") + describe_ffmpeg_error(fillResult)); av_frame_unref(video_.decodedFrame); return fillResult; @@ -952,8 +979,7 @@ namespace streaming { int sendResult = avcodec_send_packet(video_.codecContext, video_.packet); if (sendResult == AVERROR(EAGAIN)) { - const int drainResult = receive_available_video_frames(); - if (drainResult < 0 && drainResult != AVERROR(EAGAIN) && drainResult != AVERROR_EOF) { + if (const int drainResult = receive_available_video_frames(); drainResult < 0 && drainResult != AVERROR(EAGAIN) && drainResult != AVERROR_EOF) { av_packet_unref(video_.packet); return DR_NEED_IDR; } @@ -965,20 +991,14 @@ namespace streaming { return DR_NEED_IDR; } - const int receiveResult = receive_available_video_frames(); - if (receiveResult < 0 && receiveResult != AVERROR(EAGAIN) && receiveResult != AVERROR_EOF) { + if (const int receiveResult = receive_available_video_frames(); receiveResult < 0 && receiveResult != AVERROR(EAGAIN) && receiveResult != AVERROR_EOF) { return DR_NEED_IDR; } return DR_OK; } - int FfmpegStreamBackend::submit_video_decode_unit(PDECODE_UNIT decodeUnit) { - return decode_video_decode_unit(decodeUnit); - } - - int FfmpegStreamBackend::initialize_audio_decoder(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, void *context, int arFlags) { - (void) context; + int FfmpegStreamBackend::initialize_audio_decoder(int audioConfiguration, const OPUS_MULTISTREAM_CONFIGURATION *opusConfig, int arFlags) { (void) arFlags; cleanup_audio_decoder(); @@ -1006,12 +1026,10 @@ namespace streaming { desiredSpec.samples = 1024; desiredSpec.callback = nullptr; - if ((SDL_WasInit(SDL_INIT_AUDIO) & SDL_INIT_AUDIO) == 0) { - if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) { - logging::error("stream", std::string("SDL_InitSubSystem(SDL_INIT_AUDIO) failed for streaming playback: ") + SDL_GetError()); - cleanup_audio_decoder(); - return -1; - } + if ((SDL_WasInit(SDL_INIT_AUDIO) & SDL_INIT_AUDIO) == 0 && SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) { + logging::error("stream", std::string("SDL_InitSubSystem(SDL_INIT_AUDIO) failed for streaming playback: ") + SDL_GetError()); + cleanup_audio_decoder(); + return -1; } audio_.deviceId = SDL_OpenAudioDevice(nullptr, 0, &desiredSpec, &audio_.obtainedSpec, 0); @@ -1045,8 +1063,7 @@ namespace streaming { return -1; } - const int openResult = avcodec_open2(audio_.codecContext, codec, nullptr); - if (openResult < 0) { + if (const int openResult = avcodec_open2(audio_.codecContext, codec, nullptr); openResult < 0) { logging::error("stream", std::string("avcodec_open2 failed for Opus: ") + describe_ffmpeg_error(openResult)); cleanup_audio_decoder(); return openResult; @@ -1108,8 +1125,9 @@ namespace streaming { const int inputSampleRate = audio_.decodedFrame->sample_rate; const int inputSampleFormat = audio_.decodedFrame->format; const int inputChannelCount = audio_.decodedFrame->ch_layout.nb_channels; - const bool needsReconfigure = audio_.resampleContext == nullptr || audio_.resampleInputSampleRate != inputSampleRate || audio_.resampleInputSampleFormat != inputSampleFormat || audio_.resampleInputChannelCount != inputChannelCount; - if (!needsReconfigure) { + if (const bool needsReconfigure = + audio_.resampleContext == nullptr || audio_.resampleInputSampleRate != inputSampleRate || audio_.resampleInputSampleFormat != inputSampleFormat || audio_.resampleInputChannelCount != inputChannelCount; + !needsReconfigure) { return true; } @@ -1146,7 +1164,7 @@ namespace streaming { return true; } - void FfmpegStreamBackend::decode_and_play_audio_sample(char *sampleData, int sampleLength) { + void FfmpegStreamBackend::decode_and_play_audio_sample(const char *sampleData, int sampleLength) { if (!audioPlaybackEnabled_.load()) { return; } @@ -1159,8 +1177,7 @@ namespace streaming { return; } - const int packetResult = av_new_packet(audio_.packet, sampleLength); - if (packetResult < 0) { + if (const int packetResult = av_new_packet(audio_.packet, sampleLength); packetResult < 0) { logging::warn("stream", std::string("av_new_packet failed for audio decode: ") + describe_ffmpeg_error(packetResult)); return; } @@ -1210,10 +1227,10 @@ namespace streaming { } audio_.outputBuffer.resize(static_cast(outputBufferSize)); - std::uint8_t *outputData[] = {audio_.outputBuffer.data()}; + std::array outputData {audio_.outputBuffer.data()}; const int convertedSamples = swr_convert( audio_.resampleContext, - outputData, + outputData.data(), outputSamples, const_cast(audio_.decodedFrame->extended_data), audio_.decodedFrame->nb_samples @@ -1225,8 +1242,7 @@ namespace streaming { } const int convertedBytes = convertedSamples * audio_.obtainedSpec.channels * static_cast(sizeof(std::int16_t)); - const Uint32 maxQueuedBytes = (audio_bytes_per_second(audio_.obtainedSpec) * STREAM_OVERLAY_AUDIO_QUEUE_LIMIT_MS) / 1000U; - if (SDL_GetQueuedAudioSize(audio_.deviceId) > maxQueuedBytes) { + if (const Uint32 maxQueuedBytes = (audio_bytes_per_second(audio_.obtainedSpec) * STREAM_OVERLAY_AUDIO_QUEUE_LIMIT_MS) / 1000U; SDL_GetQueuedAudioSize(audio_.deviceId) > maxQueuedBytes) { SDL_ClearQueuedAudio(audio_.deviceId); } if (convertedBytes > 0 && SDL_QueueAudio(audio_.deviceId, audio_.outputBuffer.data(), static_cast(convertedBytes)) == 0) { diff --git a/src/streaming/ffmpeg_stream_backend.h b/src/streaming/ffmpeg_stream_backend.h index 4dc8932..b210aaf 100644 --- a/src/streaming/ffmpeg_stream_backend.h +++ b/src/streaming/ffmpeg_stream_backend.h @@ -25,6 +25,11 @@ struct SwsContext; namespace streaming { + /** + * @brief Opaque SDL thread context payload. + */ + using SdlThreadContext = void; + /** * @brief Owns the FFmpeg decode and SDL presentation state for one stream. * @@ -53,7 +58,7 @@ namespace streaming { * @param videoCallbacks Output video callback table. * @param audioCallbacks Output audio callback table. */ - void initialize_callbacks(DECODER_RENDERER_CALLBACKS *videoCallbacks, AUDIO_RENDERER_CALLBACKS *audioCallbacks); + void initialize_callbacks(DECODER_RENDERER_CALLBACKS *videoCallbacks, AUDIO_RENDERER_CALLBACKS *audioCallbacks) const; /** * @brief Configure whether streamed audio should be decoded and played locally. @@ -114,11 +119,10 @@ namespace streaming { * @param width Negotiated stream width. * @param height Negotiated stream height. * @param redrawRate Negotiated redraw rate. - * @param context Moonlight renderer context. * @param drFlags Moonlight decoder flags. * @return Zero on success. */ - int setup_video_decoder(int videoFormat, int width, int height, int redrawRate, void *context, int drFlags); + int setup_video_decoder(int videoFormat, int width, int height, int redrawRate, int drFlags); /** * @brief Start the video decode path. @@ -135,24 +139,15 @@ namespace streaming { */ void cleanup_video_decoder(); - /** - * @brief Submit one Moonlight decode unit to FFmpeg. - * - * @param decodeUnit Moonlight Annex B frame payload. - * @return Moonlight decoder status code. - */ - int submit_video_decode_unit(PDECODE_UNIT decodeUnit); - /** * @brief Initialize the FFmpeg Opus decoder and SDL playback device. * * @param audioConfiguration Negotiated Moonlight audio configuration. * @param opusConfig Negotiated Opus multistream parameters. - * @param context Moonlight audio context. * @param arFlags Moonlight audio renderer flags. * @return Zero on success. */ - int initialize_audio_decoder(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, void *context, int arFlags); + int initialize_audio_decoder(int audioConfiguration, const OPUS_MULTISTREAM_CONFIGURATION *opusConfig, int arFlags); /** * @brief Start SDL audio playback. @@ -182,7 +177,7 @@ namespace streaming { * @param sampleData Encoded Opus payload. * @param sampleLength Encoded payload size in bytes. */ - void decode_and_play_audio_sample(char *sampleData, int sampleLength); + void decode_and_play_audio_sample(const char *sampleData, int sampleLength); private: /** @@ -191,7 +186,7 @@ namespace streaming { * @param context Backend instance. * @return Zero when the worker exits normally. */ - static int run_video_decode_thread_trampoline(void *context); + static int run_video_decode_thread_trampoline(SdlThreadContext *context); /** * @brief Pull Moonlight decode units and feed them into FFmpeg. @@ -209,6 +204,15 @@ namespace streaming { */ int drop_queued_video_decode_units(VIDEO_FRAME_HANDLE *frameHandle, PDECODE_UNIT *decodeUnit); + /** + * @brief Drop stale queued video decode units and prepare the newest unit. + * + * @param frameHandle Current frame handle, replaced with the newest queued handle. + * @param decodeUnit Current decode unit, replaced with the newest queued unit. + * @return Moonlight decoder status after stale-frame handling. + */ + int prepare_video_decode_unit(VIDEO_FRAME_HANDLE *frameHandle, PDECODE_UNIT *decodeUnit); + /** * @brief Decode one Moonlight video decode unit on the video worker thread. * @@ -230,7 +234,7 @@ namespace streaming { * @param frameToPresent FFmpeg frame in IYUV-compatible layout. * @return True when the frame was copied into the presentation buffer. */ - bool publish_video_frame(AVFrame *frameToPresent); + bool publish_video_frame(const AVFrame *frameToPresent); /** * @brief Present the latest decoded frame through a platform-specific direct framebuffer path. diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index f0c2a90..6a88d98 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -245,7 +245,15 @@ namespace { } } - StreamConnectionState *g_active_connection_state = nullptr; + /** + * @brief Return the connection state currently receiving moonlight-common-c callbacks. + * + * @return Mutable callback connection-state slot. + */ + StreamConnectionState *&active_connection_state() { + static StreamConnectionState *connectionState = nullptr; + return connectionState; + } /** * @brief Remove trailing CR and LF characters from a log line. @@ -311,7 +319,7 @@ namespace { } bool is_high_volume_connection_log(std::string_view text) { - static constexpr std::string_view HIGH_VOLUME_PREFIXES[] { + static constexpr std::array HIGH_VOLUME_PREFIXES { "Audio packet queue overflow", "Control message took over 10 ms", "Depacketizer detected corrupt frame", @@ -336,13 +344,9 @@ namespace { "Waiting for RFI frame", }; - for (const std::string_view prefix : HIGH_VOLUME_PREFIXES) { - if (starts_with_ascii_case_insensitive(text, prefix)) { - return true; - } - } - - return false; + return std::any_of(HIGH_VOLUME_PREFIXES.begin(), HIGH_VOLUME_PREFIXES.end(), [text](std::string_view prefix) { + return starts_with_ascii_case_insensitive(text, prefix); + }); } logging::LogLevel connection_log_level(std::string_view message) { @@ -419,7 +423,7 @@ namespace { std::array stackBuffer {}; va_list argumentsCopy; va_copy(argumentsCopy, arguments); - const int requiredLength = std::vsnprintf(stackBuffer.data(), stackBuffer.size(), format, argumentsCopy); + const int requiredLength = std::vsnprintf(stackBuffer.data(), stackBuffer.size(), format, argumentsCopy); // NOSONAR(cpp:S5281) format is supplied by moonlight-common-c. va_end(argumentsCopy); if (requiredLength < 0) { return {}; @@ -430,7 +434,7 @@ namespace { std::string dynamicBuffer(static_cast(requiredLength) + 1U, '\0'); va_copy(argumentsCopy, arguments); - std::vsnprintf(dynamicBuffer.data(), dynamicBuffer.size(), format, argumentsCopy); + std::vsnprintf(dynamicBuffer.data(), dynamicBuffer.size(), format, argumentsCopy); // NOSONAR(cpp:S5281) format is supplied by moonlight-common-c. va_end(argumentsCopy); dynamicBuffer.resize(static_cast(requiredLength)); return trim_trailing_line_breaks(std::move(dynamicBuffer)); @@ -452,7 +456,7 @@ namespace { return; } - std::lock_guard lock(connectionState->protocolLogMutex); + std::scoped_lock lock(connectionState->protocolLogMutex); if (connectionState->recentProtocolMessages.size() >= MAX_CONNECTION_PROTOCOL_MESSAGES) { connectionState->recentProtocolMessages.pop_front(); } @@ -466,7 +470,7 @@ namespace { * @return Latest protocol message, or an empty string when none was recorded. */ std::string latest_connection_protocol_message(const StreamConnectionState &connectionState) { - std::lock_guard lock(connectionState.protocolLogMutex); + std::scoped_lock lock(connectionState.protocolLogMutex); return connectionState.recentProtocolMessages.empty() ? std::string {} : connectionState.recentProtocolMessages.back(); } @@ -490,7 +494,7 @@ namespace { connectionState->connectionTerminated.store(false); connectionState->poorConnection.store(false); connectionState->stopRequested.store(false); - std::lock_guard lock(connectionState->protocolLogMutex); + std::scoped_lock lock(connectionState->protocolLogMutex); connectionState->recentProtocolMessages.clear(); } @@ -511,7 +515,7 @@ namespace { resources->mediaBackend.shutdown(); { - std::lock_guard lock(resources->controllerMutex); + std::scoped_lock lock(resources->controllerMutex); close_controller(resources->controller); resources->controller = nullptr; } @@ -623,7 +627,7 @@ namespace { } std::string hex_encode(const unsigned char *data, std::size_t size) { - static constexpr char HEX_DIGITS[] = "0123456789abcdef"; + static constexpr std::string_view HEX_DIGITS = "0123456789abcdef"; std::string output; output.resize(size * 2U); @@ -744,46 +748,46 @@ namespace { } void on_stage_starting(int stage) { - if (g_active_connection_state != nullptr) { - g_active_connection_state->currentStage.store(stage); + if (StreamConnectionState *connectionState = active_connection_state(); connectionState != nullptr) { + connectionState->currentStage.store(stage); } logging::debug("stream", std::string("Starting connection stage: ") + LiGetStageName(stage)); } void on_stage_complete(int stage) { - if (g_active_connection_state != nullptr) { - g_active_connection_state->currentStage.store(stage); + if (StreamConnectionState *connectionState = active_connection_state(); connectionState != nullptr) { + connectionState->currentStage.store(stage); } logging::debug("stream", std::string("Completed connection stage: ") + LiGetStageName(stage)); } void on_stage_failed(int stage, int errorCode) { - if (g_active_connection_state != nullptr) { - g_active_connection_state->failedStage.store(stage); - g_active_connection_state->failedCode.store(errorCode); + if (StreamConnectionState *connectionState = active_connection_state(); connectionState != nullptr) { + connectionState->failedStage.store(stage); + connectionState->failedCode.store(errorCode); } logging::warn("stream", std::string("Connection stage failed: ") + LiGetStageName(stage) + " (error " + std::to_string(errorCode) + ")"); } void on_connection_started() { - if (g_active_connection_state != nullptr) { - g_active_connection_state->connectionStarted.store(true); + if (StreamConnectionState *connectionState = active_connection_state(); connectionState != nullptr) { + connectionState->connectionStarted.store(true); } logging::info("stream", "Streaming transport started"); } void on_connection_terminated(int errorCode) { - if (g_active_connection_state != nullptr) { - g_active_connection_state->terminationError.store(errorCode); - g_active_connection_state->connectionTerminated.store(true); + if (StreamConnectionState *connectionState = active_connection_state(); connectionState != nullptr) { + connectionState->terminationError.store(errorCode); + connectionState->connectionTerminated.store(true); } logging::warn("stream", std::string("Streaming transport terminated with error ") + std::to_string(errorCode)); } void on_connection_status_update(int connectionStatus) { - if (g_active_connection_state != nullptr) { + if (StreamConnectionState *connectionState = active_connection_state(); connectionState != nullptr) { const bool poorConnection = connectionStatus != CONN_STATUS_OKAY; - if (g_active_connection_state->poorConnection.exchange(poorConnection) != poorConnection) { + if (connectionState->poorConnection.exchange(poorConnection) != poorConnection) { logging::warn("stream", poorConnection ? "Streaming transport reported poor network conditions" : "Streaming transport recovered to okay network conditions"); } } @@ -802,7 +806,7 @@ namespace { return; } - append_connection_protocol_message(g_active_connection_state, message); + append_connection_protocol_message(active_connection_state(), message); logging::log(connection_log_level(message), "moonlight", message); } @@ -812,7 +816,7 @@ namespace { return -1; } - g_active_connection_state = startContext->connectionState; + active_connection_state() = startContext->connectionState; startContext->connectionState->startResult.store( LiStartConnection( &startContext->serverInformation, @@ -889,15 +893,19 @@ namespace { } if (event.type == SDL_CONTROLLERDEVICEADDED) { - std::lock_guard lock(resources->controllerMutex); + SDL_ControllerDeviceEvent controllerEvent {}; + std::memcpy(&controllerEvent, &event, sizeof(controllerEvent)); + std::scoped_lock lock(resources->controllerMutex); if (resources->controller == nullptr) { - resources->controller = SDL_GameControllerOpen(event.cdevice.which); + resources->controller = SDL_GameControllerOpen(controllerEvent.which); } } else if (event.type == SDL_CONTROLLERDEVICEREMOVED) { - std::lock_guard lock(resources->controllerMutex); + SDL_ControllerDeviceEvent controllerEvent {}; + std::memcpy(&controllerEvent, &event, sizeof(controllerEvent)); + std::scoped_lock lock(resources->controllerMutex); if (resources->controller != nullptr) { SDL_Joystick *joystick = SDL_GameControllerGetJoystick(resources->controller); - if (joystick != nullptr && SDL_JoystickInstanceID(joystick) == event.cdevice.which) { + if (joystick != nullptr && SDL_JoystickInstanceID(joystick) == controllerEvent.which) { close_controller(resources->controller); resources->controller = nullptr; } @@ -1029,7 +1037,7 @@ namespace { return {}; } - std::lock_guard lock(resources->controllerMutex); + std::scoped_lock lock(resources->controllerMutex); if (resources->controller == nullptr) { return {}; } @@ -1180,7 +1188,7 @@ namespace { int cursorY = 28; int titleHeight = 0; - render_text_line(resources->renderer, resources->titleFont, renderedVideo ? "Moonlight Streaming" : "Moonlight Streaming", {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, 28, cursorY, std::max(1, screenWidth - 56), &titleHeight); + render_text_line(resources->renderer, resources->titleFont, "Moonlight Streaming", {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, 28, cursorY, std::max(1, screenWidth - 56), &titleHeight); cursorY += titleHeight + 8; std::vector lines = { @@ -1400,7 +1408,7 @@ namespace streaming { if (rtspFallbackAttempted && !connectionState.stopRequested.load()) { failureMessage += " after retrying default RTSP discovery"; } - g_active_connection_state = nullptr; + active_connection_state() = nullptr; close_stream_ui_resources(&resources); if (statusMessage != nullptr) { *statusMessage = failureMessage; @@ -1447,7 +1455,7 @@ namespace streaming { } LiStopConnection(); - g_active_connection_state = nullptr; + active_connection_state() = nullptr; const std::string finalMessage = describe_session_end(connectionState, app.name); logging::info("stream", finalMessage); diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index a952b10..b9ef369 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -2186,44 +2186,6 @@ namespace { logging::error("settings", saveResult.errorMessage); } - /** - * @brief Return whether two Xbox video modes describe the same output mode. - * - * @param left First video mode to compare. - * @param right Second video mode to compare. - * @return True when the modes match exactly. - */ - bool video_modes_match(const VIDEO_MODE &left, const VIDEO_MODE &right) { - return left.width == right.width && left.height == right.height && left.bpp == right.bpp && left.refresh == right.refresh; - } - - /** - * @brief Switch the shared SDL window to the requested Xbox video mode. - * - * @param window Shared SDL window reused for the shell and streaming session. - * @param videoMode Requested Xbox output mode. - * @param errorMessage Receives a user-visible error when the mode change fails. - * @return True when the requested mode is now active. - */ - bool apply_shell_video_mode(SDL_Window *window, const VIDEO_MODE &videoMode, std::string *errorMessage) { - if (videoMode.width <= 0 || videoMode.height <= 0) { - return true; - } - - if (!XVideoSetMode(videoMode.width, videoMode.height, videoMode.bpp, videoMode.refresh)) { - return platform::append_error( - errorMessage, - "Failed to switch Xbox video mode to " + std::to_string(videoMode.width) + "x" + std::to_string(videoMode.height) + " @ " + std::to_string(videoMode.refresh) + " Hz" - ); - } - - if (window != nullptr) { - SDL_SetWindowSize(window, videoMode.width, videoMode.height); - } - - return true; - } - bool update_host_metadata_from_server_info(app::HostRecord *host, const std::string &address, const network::HostPairingServerInfo &serverInfo) { if (host == nullptr) { return false; @@ -4371,8 +4333,6 @@ namespace { (void) threadResult; } - struct ShellRuntimeState; - void finalize_shell_tasks(ShellRuntimeState *runtime); /** @@ -4581,6 +4541,75 @@ namespace { start_app_art_task_if_needed(state, &runtime->appArtTask); } + /** + * @brief Launch the selected stream when requested by the app state update. + * + * @param window Shared SDL window reused when entering a streaming session. + * @param activeVideoMode Active output mode used by the shell renderer. + * @param state Client state to mutate. + * @param update App command result containing requested side effects. + * @param resources Shell resources supplying render caches. + * @param runtime Runtime state that owns background tasks and redraw flags. + * @return True when command processing can continue. + */ + bool launch_stream_if_requested( + SDL_Window *window, + const VIDEO_MODE &activeVideoMode, + app::ClientState &state, + const app::AppUpdate &update, + ShellResources *resources, + ShellRuntimeState *runtime + ) { + if (!update.requests.streamLaunchRequested) { + return true; + } + + const app::HostRecord *streamHost = app::apps_host(state); + const app::HostAppRecord *streamApp = app::selected_app(state); + if (streamHost == nullptr || streamApp == nullptr) { + state.shell.statusMessage = "Select a valid app before starting a stream."; + logging::warn("stream", state.shell.statusMessage); + return true; + } + + network::PairingIdentity clientIdentity {}; + std::string identityError; + if (!load_saved_pairing_identity_for_streaming(&clientIdentity, &identityError)) { + state.shell.statusMessage = identityError; + logging::warn("stream", identityError); + return true; + } + + if (window == nullptr) { + state.shell.statusMessage = "Streaming requires a valid SDL window."; + logging::error("stream", state.shell.statusMessage); + return true; + } + + finalize_shell_tasks(runtime); + close_shell_resources(resources); + + std::string sessionMessage; + const bool streamStarted = streaming::run_stream_session(window, activeVideoMode, state.settings, *streamHost, *streamApp, clientIdentity, &sessionMessage); + state.shell.statusMessage = sessionMessage; + + if (ShellInitializationFailure initializationFailure {}; !initialize_shell_resources(window, activeVideoMode, resources, &initializationFailure)) { + report_shell_failure(initializationFailure.category, initializationFailure.message); + runtime->running = false; + state.shell.shouldExit = true; + return false; + } + + initialize_shell_runtime(state, runtime); + if (streamStarted && state.hosts.activeLoaded) { + state.hosts.active.appListState = app::HostAppListState::loading; + state.hosts.active.appListStatusMessage = "Refreshing host apps after the stream session..."; + state.hosts.active.lastAppListRefreshTick = 0U; + } + + return true; + } + /** * @brief Apply a single translated UI command inside the shell loop. * @@ -4593,7 +4622,7 @@ namespace { */ void process_shell_command( SDL_Window *window, - VIDEO_MODE *videoMode, + const VIDEO_MODE *videoMode, app::ClientState &state, input::UiCommand command, ShellResources *resources, @@ -4622,52 +4651,16 @@ namespace { persist_settings_if_needed(state, update); persist_hosts_if_needed(state, update); - if (update.requests.streamLaunchRequested) { - const app::HostRecord *streamHost = app::apps_host(state); - const app::HostAppRecord *streamApp = app::selected_app(state); - if (streamHost == nullptr || streamApp == nullptr) { - state.shell.statusMessage = "Select a valid app before starting a stream."; - logging::warn("stream", state.shell.statusMessage); - } else { - network::PairingIdentity clientIdentity {}; - std::string identityError; - if (!load_saved_pairing_identity_for_streaming(&clientIdentity, &identityError)) { - state.shell.statusMessage = identityError; - logging::warn("stream", identityError); - } else if (window == nullptr) { - state.shell.statusMessage = "Streaming requires a valid SDL window."; - logging::error("stream", state.shell.statusMessage); - } else { - finalize_shell_tasks(runtime); - close_shell_resources(resources); - - std::string sessionMessage; - const VIDEO_MODE activeVideoMode = videoMode != nullptr ? *videoMode : VIDEO_MODE {}; - const bool streamStarted = streaming::run_stream_session(window, activeVideoMode, state.settings, *streamHost, *streamApp, clientIdentity, &sessionMessage); - state.shell.statusMessage = sessionMessage; - - if (ShellInitializationFailure initializationFailure {}; !initialize_shell_resources(window, activeVideoMode, resources, &initializationFailure)) { - report_shell_failure(initializationFailure.category, initializationFailure.message); - runtime->running = false; - state.shell.shouldExit = true; - return; - } - - initialize_shell_runtime(state, runtime); - if (streamStarted && state.hosts.activeLoaded) { - state.hosts.active.appListState = app::HostAppListState::loading; - state.hosts.active.appListStatusMessage = "Refreshing host apps after the stream session..."; - state.hosts.active.lastAppListRefreshTick = 0U; - } - } - } + const VIDEO_MODE activeVideoMode = videoMode != nullptr ? *videoMode : VIDEO_MODE {}; + if (!launch_stream_if_requested(window, activeVideoMode, state, update, resources, runtime)) { + return; } if (previousScreen != state.shell.activeScreen) { release_page_resources_for_screen(previousScreen, state.shell.activeScreen, &resources->coverArtTextureCache, &resources->keypadModalLayoutCache); ensure_hosts_loaded_for_active_screen(state); } - if ((previousScreen != state.shell.activeScreen || update.navigation.screenChanged) && !draw_current_shell_frame(videoMode != nullptr ? *videoMode : VIDEO_MODE {}, state, resources, runtime)) { + if ((previousScreen != state.shell.activeScreen || update.navigation.screenChanged) && !draw_current_shell_frame(activeVideoMode, state, resources, runtime)) { return; } if (state.shell.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible) { @@ -4727,9 +4720,9 @@ namespace { return; } - if (runtime->pairingTask.activeAttempt != nullptr) { - runtime->pairingTask.activeAttempt->discardResult.store(true); - finalize_pairing_attempt(nullptr, std::move(runtime->pairingTask.activeAttempt)); + if (std::unique_ptr activeAttempt = std::move(runtime->pairingTask.activeAttempt); activeAttempt != nullptr) { + activeAttempt->discardResult.store(true); + finalize_pairing_attempt(nullptr, std::move(activeAttempt)); } while (!runtime->pairingTask.retiredAttempts.empty()) { std::unique_ptr attempt = std::move(runtime->pairingTask.retiredAttempts.back()); From 3f933bb4ed2c77f6623271e95b3947b9b7482ab1 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 18:21:26 -0400 Subject: [PATCH 11/19] Handle -o output and set wrappers for nxdk Rename cmake variables to explicit *wrapper* names and set ffmpeg_cc_shell_path / ffmpeg_cxx_shell_path to invoke the wrapper via `sh`. Update ffmpeg-nxdk-cc.sh and ffmpeg-nxdk-cxx.sh to correctly handle `-o` and `-o` forms, create output directories when needed, touch the output file, and try to mark it executable. These changes ensure the nxdk ffmpeg wrappers are invoked reliably and produce expected output files during build. --- cmake/moonlight-dependencies.cmake | 10 ++++++---- scripts/ffmpeg-nxdk-cc.sh | 26 ++++++++++++++++++-------- scripts/ffmpeg-nxdk-cxx.sh | 26 ++++++++++++++++++-------- 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/cmake/moonlight-dependencies.cmake b/cmake/moonlight-dependencies.cmake index 4cb811d..b6b513f 100644 --- a/cmake/moonlight-dependencies.cmake +++ b/cmake/moonlight-dependencies.cmake @@ -127,14 +127,16 @@ function(moonlight_prepare_xbox_ffmpeg nxdk_dir) _moonlight_to_msys_path(ffmpeg_install_shell_path "${ffmpeg_install_dir}") _moonlight_to_msys_path(ffmpeg_build_shell_path "${ffmpeg_build_dir}") _moonlight_to_msys_path(nxdk_shell_path "${nxdk_dir}") - _moonlight_to_msys_path(ffmpeg_cc_shell_path "${ffmpeg_cc_wrapper}") - _moonlight_to_msys_path(ffmpeg_cxx_shell_path "${ffmpeg_cxx_wrapper}") + _moonlight_to_msys_path(ffmpeg_cc_wrapper_shell_path "${ffmpeg_cc_wrapper}") + _moonlight_to_msys_path(ffmpeg_cxx_wrapper_shell_path "${ffmpeg_cxx_wrapper}") else() set(ffmpeg_source_shell_path "${ffmpeg_source_dir}") set(ffmpeg_install_shell_path "${ffmpeg_install_dir}") - set(ffmpeg_cc_shell_path nxdk-cc) - set(ffmpeg_cxx_shell_path nxdk-cxx) + set(ffmpeg_cc_wrapper_shell_path "${ffmpeg_cc_wrapper}") + set(ffmpeg_cxx_wrapper_shell_path "${ffmpeg_cxx_wrapper}") endif() + set(ffmpeg_cc_shell_path "sh ${ffmpeg_cc_wrapper_shell_path}") + set(ffmpeg_cxx_shell_path "sh ${ffmpeg_cxx_wrapper_shell_path}") set(ffmpeg_configure_args sh diff --git a/scripts/ffmpeg-nxdk-cc.sh b/scripts/ffmpeg-nxdk-cc.sh index 722ebb6..a54f077 100644 --- a/scripts/ffmpeg-nxdk-cc.sh +++ b/scripts/ffmpeg-nxdk-cc.sh @@ -10,21 +10,26 @@ compile_only=0 output_path= previous_arg= for arg in "$@"; do + if [ "$previous_arg" = "-o" ]; then + output_path="$arg" + previous_arg= + continue + fi + case "$arg" in -c|-E) compile_only=1 ;; + -o) + previous_arg="-o" + ;; + -o?*) + output_path=${arg#-o} + ;; *) + previous_arg= ;; esac - - if [ "$previous_arg" = "-o" ]; then - output_path="$arg" - previous_arg= - continue - fi - - previous_arg="$arg" done if [ "$compile_only" -eq 1 ]; then @@ -50,7 +55,12 @@ if [ "$compile_only" -eq 1 ]; then fi if [ -n "$output_path" ]; then + output_dir=$(dirname -- "$output_path") + if [ "$output_dir" != "." ]; then + mkdir -p "$output_dir" + fi : > "$output_path" + chmod +x "$output_path" 2>/dev/null || true exit 0 fi diff --git a/scripts/ffmpeg-nxdk-cxx.sh b/scripts/ffmpeg-nxdk-cxx.sh index f5d5444..c77994c 100644 --- a/scripts/ffmpeg-nxdk-cxx.sh +++ b/scripts/ffmpeg-nxdk-cxx.sh @@ -10,21 +10,26 @@ compile_only=0 output_path= previous_arg= for arg in "$@"; do + if [ "$previous_arg" = "-o" ]; then + output_path="$arg" + previous_arg= + continue + fi + case "$arg" in -c|-E) compile_only=1 ;; + -o) + previous_arg="-o" + ;; + -o?*) + output_path=${arg#-o} + ;; *) + previous_arg= ;; esac - - if [ "$previous_arg" = "-o" ]; then - output_path="$arg" - previous_arg= - continue - fi - - previous_arg="$arg" done if [ "$compile_only" -eq 1 ]; then @@ -52,7 +57,12 @@ if [ "$compile_only" -eq 1 ]; then fi if [ -n "$output_path" ]; then + output_dir=$(dirname -- "$output_path") + if [ "$output_dir" != "." ]; then + mkdir -p "$output_dir" + fi : > "$output_path" + chmod +x "$output_path" 2>/dev/null || true exit 0 fi From 22279cfc9e751b0ad21e0a9ba1eaa181ba8fc4a4 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 18:53:50 -0400 Subject: [PATCH 12/19] Refactor stream/session flow and code cleanup Modularize and clean up streaming and session code: extract helper structs and functions in session.cpp (StreamStartAttemptContext, ActiveStreamLoopContext, TextLineLayout), add assign_status_message, and split stream startup and active-loop logic into run_stream_start_attempt, run_stream_start_with_rtsp_fallback, run_active_stream_loop and related helpers. Improve controller handling (open/close helpers, fallback polling), implement RTSP session URL fallback retry, and centralize input-thread lifecycle and rendering/polling behavior. Also applied numerous style and safety improvements across the codebase: use if-with-init and compact conditionals, add NOSONAR annotations to silence specific static analysis warnings in ffmpeg_stream_backend, make several const-correctness and type simplifications (remove unnecessary casts), refactor render_text_line to accept a layout struct, and minor cleanups in main.cpp, host_pairing.cpp, ffmpeg_stream_backend.{cpp,h}, and ui/shell_screen.cpp. These changes are intended to improve readability, maintainability, and static-analysis cleanliness without changing external behavior. --- src/main.cpp | 12 +- src/network/host_pairing.cpp | 6 +- src/streaming/ffmpeg_stream_backend.cpp | 43 +-- src/streaming/ffmpeg_stream_backend.h | 2 +- src/streaming/session.cpp | 372 ++++++++++++++---------- src/ui/shell_screen.cpp | 11 +- 6 files changed, 246 insertions(+), 200 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 8927e34..deabc1b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -76,14 +76,10 @@ namespace { return; } - const auto preferredMode = std::find_if( - state.settings.availableVideoModes.begin(), - state.settings.availableVideoModes.end(), - [&state](const VIDEO_MODE &candidate) { - return stream_resolutions_match(candidate, state.settings.preferredVideoMode); - } - ); - if (state.settings.preferredVideoModeSet && preferredMode != state.settings.availableVideoModes.end()) { + if (const auto preferredMode = std::find_if(state.settings.availableVideoModes.begin(), state.settings.availableVideoModes.end(), [&state](const VIDEO_MODE &candidate) { + return stream_resolutions_match(candidate, state.settings.preferredVideoMode); + }); + state.settings.preferredVideoModeSet && preferredMode != state.settings.availableVideoModes.end()) { state.settings.preferredVideoMode = *preferredMode; return; } diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index 029484c..ac4a2b4 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -1375,8 +1375,7 @@ namespace { } uint32_t rootStatusCode = 200; - std::string rootStatusMessage; - if (extract_root_status(response.body, &rootStatusCode, &rootStatusMessage) && rootStatusCode != 200U) { + if (std::string rootStatusMessage; extract_root_status(response.body, &rootStatusCode, &rootStatusMessage) && rootStatusCode != 200U) { if (network::error_indicates_unpaired_client(rootStatusMessage)) { return append_error(errorMessage, std::string(UNPAIRED_CLIENT_ERROR_MESSAGE)); } @@ -1393,8 +1392,7 @@ namespace { extract_xml_tag_value(response.body, "ServerCodecModeSupport", &serverCodecModeSupportText); int serverCodecModeSupport = serverInfo.serverCodecModeSupport == 0 ? DEFAULT_SERVER_CODEC_MODE_SUPPORT : serverInfo.serverCodecModeSupport; - uint32_t parsedServerCodecModeSupport = 0; - if (!serverCodecModeSupportText.empty() && try_parse_uint32(trim_ascii_whitespace(serverCodecModeSupportText), &parsedServerCodecModeSupport)) { + if (uint32_t parsedServerCodecModeSupport = 0; !serverCodecModeSupportText.empty() && try_parse_uint32(trim_ascii_whitespace(serverCodecModeSupportText), &parsedServerCodecModeSupport)) { serverCodecModeSupport = parsedServerCodecModeSupport == 0U ? DEFAULT_SERVER_CODEC_MODE_SUPPORT : static_cast(parsedServerCodecModeSupport); } diff --git a/src/streaming/ffmpeg_stream_backend.cpp b/src/streaming/ffmpeg_stream_backend.cpp index c3749db..e6087e7 100644 --- a/src/streaming/ffmpeg_stream_backend.cpp +++ b/src/streaming/ffmpeg_stream_backend.cpp @@ -266,7 +266,7 @@ namespace { return true; } - int on_video_setup(int videoFormat, int width, int height, int redrawRate, MoonlightCallbackContext *context, int drFlags) { + int on_video_setup(int videoFormat, int width, int height, int redrawRate, MoonlightCallbackContext *context, int drFlags) { // NOSONAR(cpp:S5008) moonlight-common-c requires a void* render context callback. auto *backend = static_cast(context); if (backend == nullptr) { return -1; @@ -295,7 +295,7 @@ namespace { } } - int on_audio_init(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, MoonlightCallbackContext *context, int arFlags) { + int on_audio_init(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, MoonlightCallbackContext *context, int arFlags) { // NOSONAR(cpp:S5008,cpp:S995) moonlight-common-c requires this callback signature. auto *backend = static_cast(context); if (backend == nullptr) { return -1; @@ -324,7 +324,7 @@ namespace { } } - void on_audio_decode_and_play_sample(char *sampleData, int sampleLength) { + void on_audio_decode_and_play_sample(char *sampleData, int sampleLength) { // NOSONAR(cpp:S995) moonlight-common-c supplies mutable sample data by callback signature. if (streaming::FfmpegStreamBackend *backend = active_audio_backend(); backend != nullptr) { backend->decode_and_play_audio_sample(sampleData, sampleLength); } @@ -494,17 +494,7 @@ namespace streaming { textureNeedsUpload = true; } - if (textureNeedsUpload && - SDL_UpdateYUVTexture( - video_.texture, - nullptr, - reinterpret_cast(video_.renderFrame.yPlane.data()), - video_.renderFrame.yPitch, - reinterpret_cast(video_.renderFrame.uPlane.data()), - video_.renderFrame.uPitch, - reinterpret_cast(video_.renderFrame.vPlane.data()), - video_.renderFrame.vPitch - ) != 0) { + if (textureNeedsUpload && SDL_UpdateYUVTexture(video_.texture, nullptr, reinterpret_cast(video_.renderFrame.yPlane.data()), video_.renderFrame.yPitch, reinterpret_cast(video_.renderFrame.uPlane.data()), video_.renderFrame.uPitch, reinterpret_cast(video_.renderFrame.vPlane.data()), video_.renderFrame.vPitch) != 0) { logging::error("stream", std::string("SDL_UpdateYUVTexture failed during video presentation: ") + SDL_GetError()); return false; } @@ -640,7 +630,7 @@ namespace streaming { video_.lastDecodeFrameNumber.store(0); } - int FfmpegStreamBackend::run_video_decode_thread_trampoline(SdlThreadContext *context) { + int FfmpegStreamBackend::run_video_decode_thread_trampoline(SdlThreadContext *context) { // NOSONAR(cpp:S5008) SDL_CreateThread requires a void* trampoline context. auto *backend = static_cast(context); return backend != nullptr ? backend->run_video_decode_thread() : -1; } @@ -702,8 +692,7 @@ namespace streaming { *frameHandle = newestHandle; *decodeUnit = newestDecodeUnit; - if (const std::uint64_t droppedDecodeUnitCount = video_.droppedDecodeUnitCount.fetch_add(static_cast(droppedFrames)) + static_cast(droppedFrames); - droppedDecodeUnitCount <= static_cast(droppedFrames) || (droppedDecodeUnitCount % STREAM_VIDEO_DROP_LOG_INTERVAL) < static_cast(droppedFrames)) { + if (const std::uint64_t droppedDecodeUnitCount = video_.droppedDecodeUnitCount.fetch_add(static_cast(droppedFrames)) + static_cast(droppedFrames); droppedDecodeUnitCount <= static_cast(droppedFrames) || (droppedDecodeUnitCount % STREAM_VIDEO_DROP_LOG_INTERVAL) < static_cast(droppedFrames)) { logging::warn( "stream", std::string("Dropped queued video decode units before decode | dropped_total=") + std::to_string(droppedDecodeUnitCount) + " | dropped_now=" + std::to_string(droppedFrames) + @@ -729,8 +718,7 @@ namespace streaming { } bool FfmpegStreamBackend::publish_video_frame(const AVFrame *frameToPresent) { - if (frameToPresent == nullptr || frameToPresent->width <= 0 || frameToPresent->height <= 0 || frameToPresent->linesize[0] <= 0 || frameToPresent->linesize[1] <= 0 || frameToPresent->linesize[2] <= 0 || - frameToPresent->data[0] == nullptr || frameToPresent->data[1] == nullptr || frameToPresent->data[2] == nullptr) { + if (frameToPresent == nullptr || frameToPresent->width <= 0 || frameToPresent->height <= 0 || frameToPresent->linesize[0] <= 0 || frameToPresent->linesize[1] <= 0 || frameToPresent->linesize[2] <= 0 || frameToPresent->data[0] == nullptr || frameToPresent->data[1] == nullptr || frameToPresent->data[2] == nullptr) { return false; } @@ -858,7 +846,7 @@ namespace streaming { return receiveResult; } - AVFrame *frameToPresent = video_.decodedFrame; + const AVFrame *frameToPresent = video_.decodedFrame; if (video_.decodedFrame->format != AV_PIX_FMT_YUV420P) { video_.scaleContext = sws_getCachedContext( video_.scaleContext, @@ -891,16 +879,7 @@ namespace streaming { video_.convertedFrame->format = AV_PIX_FMT_YUV420P; video_.convertedFrame->width = video_.decodedFrame->width; video_.convertedFrame->height = video_.decodedFrame->height; - if (const int fillResult = av_image_fill_arrays( - video_.convertedFrame->data, - video_.convertedFrame->linesize, - video_.convertedBuffer.data(), - AV_PIX_FMT_YUV420P, - video_.decodedFrame->width, - video_.decodedFrame->height, - 1 - ); - fillResult < 0) { + if (const int fillResult = av_image_fill_arrays(video_.convertedFrame->data, video_.convertedFrame->linesize, video_.convertedBuffer.data(), AV_PIX_FMT_YUV420P, video_.decodedFrame->width, video_.decodedFrame->height, 1); fillResult < 0) { logging::warn("stream", std::string("av_image_fill_arrays failed for converted frame: ") + describe_ffmpeg_error(fillResult)); av_frame_unref(video_.decodedFrame); return fillResult; @@ -1125,9 +1104,7 @@ namespace streaming { const int inputSampleRate = audio_.decodedFrame->sample_rate; const int inputSampleFormat = audio_.decodedFrame->format; const int inputChannelCount = audio_.decodedFrame->ch_layout.nb_channels; - if (const bool needsReconfigure = - audio_.resampleContext == nullptr || audio_.resampleInputSampleRate != inputSampleRate || audio_.resampleInputSampleFormat != inputSampleFormat || audio_.resampleInputChannelCount != inputChannelCount; - !needsReconfigure) { + if (const bool needsReconfigure = audio_.resampleContext == nullptr || audio_.resampleInputSampleRate != inputSampleRate || audio_.resampleInputSampleFormat != inputSampleFormat || audio_.resampleInputChannelCount != inputChannelCount; !needsReconfigure) { return true; } diff --git a/src/streaming/ffmpeg_stream_backend.h b/src/streaming/ffmpeg_stream_backend.h index b210aaf..07a8fe9 100644 --- a/src/streaming/ffmpeg_stream_backend.h +++ b/src/streaming/ffmpeg_stream_backend.h @@ -262,7 +262,7 @@ namespace streaming { /** * @brief Hold FFmpeg and SDL state used by the video path. */ - struct VideoState { + struct VideoState { // NOSONAR(cpp:S1820) Owned video pipeline state intentionally groups FFmpeg, SDL, queue, and metric lifetimes. AVCodecContext *codecContext = nullptr; SwsContext *scaleContext = nullptr; SwsContext *presentScaleContext = nullptr; diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index 6a88d98..d8369d1 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -130,6 +130,32 @@ namespace { std::string rtspSessionUrl; }; + struct StreamStartAttemptContext { + const app::HostRecord &host; + const app::HostAppRecord &app; + StreamStartContext &startContext; + StreamConnectionState &connectionState; + StreamUiResources &resources; + std::string *statusMessage = nullptr; + }; + + struct ActiveStreamLoopContext { + const app::SettingsState &settings; + const app::HostRecord &host; + const app::HostAppRecord &app; + StreamStartContext &startContext; + StreamConnectionState &connectionState; + StreamUiResources &resources; + }; + + struct TextLineLayout { + SDL_Color color {}; + int x = 0; + int y = 0; + int maxWidth = 0; + int *drawnHeight = nullptr; + }; + struct ResolvedStreamParameters { VIDEO_MODE videoMode {}; int fps = DEFAULT_STREAM_FPS; @@ -138,6 +164,12 @@ namespace { int streamingRemotely = STREAM_CFG_AUTO; }; + void assign_status_message(std::string *statusMessage, const std::string &message) { + if (statusMessage != nullptr) { + *statusMessage = message; + } + } + class ScopedThreadPriority { public: explicit ScopedThreadPriority(SDL_ThreadPriority priority) { @@ -291,8 +323,8 @@ namespace { } for (std::size_t index = 0; index < prefix.size(); ++index) { - const unsigned char textCharacter = static_cast(text[index]); - const unsigned char prefixCharacter = static_cast(prefix[index]); + const auto textCharacter = static_cast(text[index]); + const auto prefixCharacter = static_cast(prefix[index]); if (std::tolower(textCharacter) != std::tolower(prefixCharacter)) { return false; } @@ -394,8 +426,7 @@ namespace { int minor = 1; int patch = 431; int build = 0; - const int parsedFields = std::sscanf(std::string(appVersion).c_str(), "%d.%d.%d.%d", &major, &minor, &patch, &build); - if (parsedFields < 3) { + if (const int parsedFields = std::sscanf(std::string(appVersion).c_str(), "%d.%d.%d.%d", &major, &minor, &patch, &build); parsedFields < 3) { return "7.1.431.-1"; } @@ -632,22 +663,22 @@ namespace { std::string output; output.resize(size * 2U); for (std::size_t index = 0; index < size; ++index) { - output[index * 2U] = HEX_DIGITS[(data[index] >> 4U) & 0x0F]; - output[(index * 2U) + 1U] = HEX_DIGITS[data[index] & 0x0F]; + output[index * 2U] = HEX_DIGITS[(data[index] >> 4U) & 0x0F]; // NOSONAR(cpp:S6022) hex encoding is byte-oriented by design. + output[(index * 2U) + 1U] = HEX_DIGITS[data[index] & 0x0F]; // NOSONAR(cpp:S6022) hex encoding is byte-oriented by design. } return output; } int select_stream_width(const VIDEO_MODE &videoMode) { - return videoMode.width > 0 ? std::max(320, static_cast(videoMode.width)) : 640; + return videoMode.width > 0 ? std::max(320, videoMode.width) : 640; } int select_stream_height(const VIDEO_MODE &videoMode) { - return videoMode.height > 0 ? std::max(240, static_cast(videoMode.height)) : 480; + return videoMode.height > 0 ? std::max(240, videoMode.height) : 480; } int select_client_refresh_rate_x100(const VIDEO_MODE &videoMode) { - return videoMode.refresh > 0 ? static_cast(videoMode.refresh) * 100 : 6000; + return videoMode.refresh > 0 ? videoMode.refresh * 100 : 6000; } /** @@ -793,7 +824,7 @@ namespace { } } - void on_log_message(const char *format, ...) { + void on_log_message(const char *format, ...) { // NOSONAR(cpp:S923) moonlight-common-c log callback requires printf-style ellipsis. if (format == nullptr || is_high_volume_connection_log(format)) { return; } @@ -834,18 +865,18 @@ namespace { return 0; } - bool render_text_line(SDL_Renderer *renderer, TTF_Font *font, const std::string &text, SDL_Color color, int x, int y, int maxWidth, int *drawnHeight = nullptr) { - if (renderer == nullptr || font == nullptr || maxWidth <= 0) { - if (drawnHeight != nullptr) { - *drawnHeight = 0; + bool render_text_line(SDL_Renderer *renderer, TTF_Font *font, const std::string &text, const TextLineLayout &layout) { + if (renderer == nullptr || font == nullptr || layout.maxWidth <= 0) { + if (layout.drawnHeight != nullptr) { + *layout.drawnHeight = 0; } return false; } - SDL_Surface *surface = TTF_RenderUTF8_Blended_Wrapped(font, text.c_str(), color, static_cast(maxWidth)); + SDL_Surface *surface = TTF_RenderUTF8_Blended_Wrapped(font, text.c_str(), layout.color, static_cast(layout.maxWidth)); if (surface == nullptr) { - if (drawnHeight != nullptr) { - *drawnHeight = 0; + if (layout.drawnHeight != nullptr) { + *layout.drawnHeight = 0; } return false; } @@ -853,26 +884,25 @@ namespace { SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, surface); if (texture == nullptr) { SDL_FreeSurface(surface); - if (drawnHeight != nullptr) { - *drawnHeight = 0; + if (layout.drawnHeight != nullptr) { + *layout.drawnHeight = 0; } return false; } - const SDL_Rect destination {x, y, surface->w, surface->h}; + const SDL_Rect destination {layout.x, layout.y, surface->w, surface->h}; const int surfaceHeight = surface->h; SDL_FreeSurface(surface); const bool rendered = SDL_RenderCopy(renderer, texture, nullptr, &destination) == 0; SDL_DestroyTexture(texture); - if (drawnHeight != nullptr) { - *drawnHeight = surfaceHeight; + if (layout.drawnHeight != nullptr) { + *layout.drawnHeight = surfaceHeight; } return rendered; } std::string build_stage_status_line(const StreamConnectionState &connectionState) { - const int failedStage = connectionState.failedStage.load(); - if (failedStage != STAGE_NONE) { + if (const int failedStage = connectionState.failedStage.load(); failedStage != STAGE_NONE) { return std::string("Connection failed during ") + LiGetStageName(failedStage) + " (error " + std::to_string(connectionState.failedCode.load()) + ")"; } if (connectionState.connectionTerminated.load()) { @@ -885,6 +915,34 @@ namespace { return std::string("Connecting: ") + LiGetStageName(connectionState.currentStage.load()); } + void open_controller_if_needed(StreamUiResources *resources, int deviceIndex) { + if (resources == nullptr) { + return; + } + + std::scoped_lock lock(resources->controllerMutex); + if (resources->controller == nullptr) { + resources->controller = SDL_GameControllerOpen(deviceIndex); + } + } + + void close_controller_if_removed(StreamUiResources *resources, int joystickInstanceId) { + if (resources == nullptr) { + return; + } + + std::scoped_lock lock(resources->controllerMutex); + if (resources->controller == nullptr) { + return; + } + + SDL_Joystick *joystick = SDL_GameControllerGetJoystick(resources->controller); + if (joystick != nullptr && SDL_JoystickInstanceID(joystick) == joystickInstanceId) { + close_controller(resources->controller); + resources->controller = nullptr; + } + } + void pump_stream_events(StreamUiResources *resources) { SDL_Event event {}; while (SDL_PollEvent(&event) != 0) { @@ -893,23 +951,9 @@ namespace { } if (event.type == SDL_CONTROLLERDEVICEADDED) { - SDL_ControllerDeviceEvent controllerEvent {}; - std::memcpy(&controllerEvent, &event, sizeof(controllerEvent)); - std::scoped_lock lock(resources->controllerMutex); - if (resources->controller == nullptr) { - resources->controller = SDL_GameControllerOpen(controllerEvent.which); - } + open_controller_if_needed(resources, event.cdevice.which); } else if (event.type == SDL_CONTROLLERDEVICEREMOVED) { - SDL_ControllerDeviceEvent controllerEvent {}; - std::memcpy(&controllerEvent, &event, sizeof(controllerEvent)); - std::scoped_lock lock(resources->controllerMutex); - if (resources->controller != nullptr) { - SDL_Joystick *joystick = SDL_GameControllerGetJoystick(resources->controller); - if (joystick != nullptr && SDL_JoystickInstanceID(joystick) == controllerEvent.which) { - close_controller(resources->controller); - resources->controller = nullptr; - } - } + close_controller_if_removed(resources, event.cdevice.which); } } } @@ -988,8 +1032,7 @@ namespace { } const bool backPressed = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_BACK) != 0; - const bool startPressed = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_START) != 0; - if (!backPressed || !startPressed) { + if (const bool startPressed = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_START) != 0; !backPressed || !startPressed) { *comboActivatedTick = 0U; return; } @@ -1013,8 +1056,7 @@ namespace { } const bool backPressed = (snapshot.buttonFlags & BACK_FLAG) != 0; - const bool startPressed = (snapshot.buttonFlags & PLAY_FLAG) != 0; - if (!backPressed || !startPressed) { + if (const bool startPressed = (snapshot.buttonFlags & PLAY_FLAG) != 0; !backPressed || !startPressed) { *comboActivatedTick = 0U; return; } @@ -1108,8 +1150,7 @@ namespace { }; uint32_t estimatedRtt = 0; - uint32_t estimatedRttVariance = 0; - if (LiGetEstimatedRttInfo(&estimatedRtt, &estimatedRttVariance)) { + if (uint32_t estimatedRttVariance = 0; LiGetEstimatedRttInfo(&estimatedRtt, &estimatedRttVariance)) { (void) estimatedRttVariance; snapshot.roundTripTimeMs = static_cast(estimatedRtt); } @@ -1124,7 +1165,7 @@ namespace { return snapshot; } - void request_idle_video_refresh_if_needed(streaming::FfmpegStreamBackend *mediaBackend, Uint32 *lastRequestTicks) { + void request_idle_video_refresh_if_needed(const streaming::FfmpegStreamBackend *mediaBackend, Uint32 *lastRequestTicks) { if (mediaBackend == nullptr || lastRequestTicks == nullptr || !mediaBackend->has_decoded_video()) { return; } @@ -1188,7 +1229,7 @@ namespace { int cursorY = 28; int titleHeight = 0; - render_text_line(resources->renderer, resources->titleFont, "Moonlight Streaming", {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, 28, cursorY, std::max(1, screenWidth - 56), &titleHeight); + render_text_line(resources->renderer, resources->titleFont, "Moonlight Streaming", {{ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, 28, cursorY, std::max(1, screenWidth - 56), &titleHeight}); cursorY += titleHeight + 8; std::vector lines = { @@ -1199,9 +1240,9 @@ namespace { "Hold Back + Start for about one second to stop streaming.", }; if (!renderedVideo) { - lines.insert(lines.begin() + 2, std::string("Mode: ") + std::to_string(context.streamConfiguration.width) + "x" + std::to_string(context.streamConfiguration.height) + " @ " + std::to_string(context.streamConfiguration.fps) + " FPS | H.264 | Stereo"); - lines.insert(lines.begin() + 4, std::string("Launch mode: ") + (context.serverInformation.rtspSessionUrl != nullptr ? "Session URL supplied by host" : "Default RTSP discovery")); - lines.insert(lines.begin() + 5, "Waiting for the first decoded video frame and audio output."); + lines.emplace(lines.begin() + 2, std::string("Mode: ") + std::to_string(context.streamConfiguration.width) + "x" + std::to_string(context.streamConfiguration.height) + " @ " + std::to_string(context.streamConfiguration.fps) + " FPS | H.264 | Stereo"); + lines.emplace(lines.begin() + 4, std::string("Launch mode: ") + (context.serverInformation.rtspSessionUrl != nullptr ? "Session URL supplied by host" : "Default RTSP discovery")); + lines.emplace(lines.begin() + 5, "Waiting for the first decoded video frame and audio output."); } if (showPerformanceStats) { @@ -1212,7 +1253,7 @@ namespace { for (const std::string &line : lines) { int drawnHeight = 0; - render_text_line(resources->renderer, resources->bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, 28, cursorY, std::max(1, screenWidth - 56), &drawnHeight); + render_text_line(resources->renderer, resources->bodyFont, line, {{TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, 28, cursorY, std::max(1, screenWidth - 56), &drawnHeight}); cursorY += drawnHeight + 6; } @@ -1220,6 +1261,128 @@ namespace { return true; } + bool run_stream_start_attempt(const StreamStartAttemptContext &attempt) { + if (SDL_Thread *startThread = SDL_CreateThread(run_stream_start_thread, "start-stream", &attempt.startContext); startThread != nullptr) { + Uint32 exitComboActivatedTick = 0U; + while (!attempt.connectionState.startCompleted.load() && !attempt.connectionState.stopRequested.load()) { + pump_stream_events(&attempt.resources); + update_stream_exit_combo(attempt.resources.controller, &exitComboActivatedTick, &attempt.connectionState); + render_stream_frame(attempt.host, attempt.app, attempt.startContext, attempt.connectionState, &attempt.resources.mediaBackend, true, &attempt.resources); + if (attempt.connectionState.stopRequested.load()) { + LiInterruptConnection(); + } + SDL_Delay(STREAM_FRAME_DELAY_MILLISECONDS); + } + + int threadResult = 0; + SDL_WaitThread(startThread, &threadResult); + (void) threadResult; + return true; + } + + const std::string createThreadError = std::string("Failed to start the streaming transport thread: ") + SDL_GetError(); + close_stream_ui_resources(&attempt.resources); + assign_status_message(attempt.statusMessage, createThreadError); + logging::error("stream", createThreadError); + return false; + } + + bool should_retry_stream_without_rtsp_session_url(const StreamStartContext &startContext, const StreamConnectionState &connectionState) { + return !connectionState.stopRequested.load() && !connectionState.connectionStarted.load() && connectionState.failedStage.load() == STAGE_RTSP_HANDSHAKE && !startContext.rtspSessionUrl.empty(); + } + + bool run_stream_start_with_rtsp_fallback(const StreamStartAttemptContext &attempt, bool *rtspFallbackAttempted) { + if (rtspFallbackAttempted != nullptr) { + *rtspFallbackAttempted = false; + } + if (!run_stream_start_attempt(attempt)) { + return false; + } + if (!should_retry_stream_without_rtsp_session_url(attempt.startContext, attempt.connectionState)) { + return true; + } + + if (rtspFallbackAttempted != nullptr) { + *rtspFallbackAttempted = true; + } + logging::warn("stream", "RTSP handshake failed with the host-supplied session URL; retrying with default RTSP discovery"); + attempt.resources.mediaBackend.shutdown(); + attempt.startContext.rtspSessionUrl.clear(); + attempt.startContext.serverInformation.rtspSessionUrl = nullptr; + reset_connection_state(&attempt.connectionState); + return run_stream_start_attempt(attempt); + } + + void poll_fallback_controller_if_needed( + const SDL_Thread *inputThread, + StreamUiResources *resources, + StreamConnectionState *connectionState, + Uint32 *exitComboActivatedTick, + bool *controllerArrivalSent, + ControllerSnapshot *lastControllerSnapshot + ) { + if (inputThread != nullptr) { + return; + } + + bool controllerPresent = false; + const ControllerSnapshot snapshot = read_controller_snapshot(resources, &controllerPresent); + update_stream_exit_combo_from_snapshot(snapshot, controllerPresent, exitComboActivatedTick, connectionState); + send_controller_snapshot_if_needed(snapshot, controllerPresent, controllerArrivalSent, lastControllerSnapshot); + } + + void render_active_stream_frame_if_needed(const ActiveStreamLoopContext &loopContext, Uint32 *lastIdleVideoIdrRequestTick) { + const bool hasDecodedVideo = loopContext.resources.mediaBackend.has_decoded_video(); + if (const bool shouldRenderFrame = loopContext.settings.showPerformanceStats || !hasDecodedVideo || loopContext.resources.mediaBackend.has_unrendered_video_frame(); shouldRenderFrame) { + render_stream_frame( + loopContext.host, + loopContext.app, + loopContext.startContext, + loopContext.connectionState, + &loopContext.resources.mediaBackend, + loopContext.settings.showPerformanceStats, + &loopContext.resources + ); + } + request_idle_video_refresh_if_needed(&loopContext.resources.mediaBackend, lastIdleVideoIdrRequestTick); + SDL_Delay(hasDecodedVideo && !loopContext.settings.showPerformanceStats ? STREAM_PRESENT_POLL_MILLISECONDS : STREAM_FRAME_DELAY_MILLISECONDS); + } + + void wait_for_stream_input_thread(SDL_Thread *inputThread, StreamInputThreadState *inputThreadState) { + if (inputThreadState != nullptr) { + inputThreadState->stopRequested.store(true); + } + if (inputThread == nullptr) { + return; + } + + int inputThreadResult = 0; + SDL_WaitThread(inputThread, &inputThreadResult); + (void) inputThreadResult; + } + + void run_active_stream_loop(const ActiveStreamLoopContext &loopContext) { + StreamInputThreadState inputThreadState {}; + inputThreadState.resources = &loopContext.resources; + inputThreadState.connectionState = &loopContext.connectionState; + SDL_Thread *inputThread = SDL_CreateThread(run_stream_input_thread, "stream-input", &inputThreadState); + if (inputThread == nullptr) { + logging::warn("stream", std::string("Failed to start the stream input thread; polling controller from the render loop: ") + SDL_GetError()); + } + + bool fallbackControllerArrivalSent = false; + ControllerSnapshot fallbackLastControllerSnapshot {}; + Uint32 fallbackExitComboActivatedTick = 0U; + Uint32 lastIdleVideoIdrRequestTick = 0U; + while (!loopContext.connectionState.connectionTerminated.load() && !loopContext.connectionState.stopRequested.load()) { + pump_stream_events(&loopContext.resources); + poll_fallback_controller_if_needed(inputThread, &loopContext.resources, &loopContext.connectionState, &fallbackExitComboActivatedTick, &fallbackControllerArrivalSent, &fallbackLastControllerSnapshot); + render_active_stream_frame_if_needed(loopContext, &lastIdleVideoIdrRequestTick); + } + + wait_for_stream_input_thread(inputThread, &inputThreadState); + } + std::string describe_start_failure(const StreamConnectionState &connectionState) { if (const int failedStage = connectionState.failedStage.load(); failedStage != STAGE_NONE) { std::string message = std::string("Failed to start streaming during ") + LiGetStageName(failedStage) + " (error " + std::to_string(connectionState.failedCode.load()) + ")"; @@ -1273,9 +1436,7 @@ namespace streaming { StreamUiResources resources {}; if (std::string initializationError; !initialize_stream_ui_resources(window, activeStreamVideoMode, &resources, &initializationError)) { - if (statusMessage != nullptr) { - *statusMessage = initializationError; - } + assign_status_message(statusMessage, initializationError); logging::error("stream", initializationError); return false; } @@ -1287,9 +1448,7 @@ namespace streaming { std::array remoteInputIv {}; if (std::string randomError; !fill_random_bytes(remoteInputKey.data(), remoteInputKey.size(), &randomError) || !fill_random_bytes(remoteInputIv.data(), remoteInputIv.size(), &randomError)) { close_stream_ui_resources(&resources); - if (statusMessage != nullptr) { - *statusMessage = randomError; - } + assign_status_message(statusMessage, randomError); logging::error("stream", randomError); return false; } @@ -1309,12 +1468,9 @@ namespace streaming { launchConfiguration.remoteInputAesKeyHex = hex_encode(remoteInputKey.data(), remoteInputKey.size()); launchConfiguration.remoteInputAesIvHex = hex_encode(remoteInputIv.data(), remoteInputIv.size()); - std::string launchError; - if (!network::launch_or_resume_stream(hostAddress, httpPort, clientIdentity, launchConfiguration, &launchResult, &launchError)) { + if (std::string launchError; !network::launch_or_resume_stream(hostAddress, httpPort, clientIdentity, launchConfiguration, &launchResult, &launchError)) { close_stream_ui_resources(&resources); - if (statusMessage != nullptr) { - *statusMessage = launchError; - } + assign_status_message(statusMessage, launchError); logging::error("stream", launchError); return false; } @@ -1357,50 +1513,10 @@ namespace streaming { startContext.connectionCallbacks.connectionStatusUpdate = on_connection_status_update; startContext.connectionCallbacks.logMessage = on_log_message; - const auto run_start_attempt = [&]() -> bool { - SDL_Thread *startThread = SDL_CreateThread(run_stream_start_thread, "start-stream", &startContext); - if (startThread == nullptr) { - const std::string createThreadError = std::string("Failed to start the streaming transport thread: ") + SDL_GetError(); - close_stream_ui_resources(&resources); - if (statusMessage != nullptr) { - *statusMessage = createThreadError; - } - logging::error("stream", createThreadError); - return false; - } - - Uint32 exitComboActivatedTick = 0U; - while (!connectionState.startCompleted.load() && !connectionState.stopRequested.load()) { - pump_stream_events(&resources); - update_stream_exit_combo(resources.controller, &exitComboActivatedTick, &connectionState); - render_stream_frame(host, app, startContext, connectionState, &resources.mediaBackend, true, &resources); - if (connectionState.stopRequested.load()) { - LiInterruptConnection(); - } - SDL_Delay(STREAM_FRAME_DELAY_MILLISECONDS); - } - - int threadResult = 0; - SDL_WaitThread(startThread, &threadResult); - (void) threadResult; - return true; - }; - - if (!run_start_attempt()) { - return false; - } - bool rtspFallbackAttempted = false; - if (!connectionState.stopRequested.load() && !connectionState.connectionStarted.load() && connectionState.failedStage.load() == STAGE_RTSP_HANDSHAKE && !startContext.rtspSessionUrl.empty()) { - rtspFallbackAttempted = true; - logging::warn("stream", "RTSP handshake failed with the host-supplied session URL; retrying with default RTSP discovery"); - resources.mediaBackend.shutdown(); - startContext.rtspSessionUrl.clear(); - startContext.serverInformation.rtspSessionUrl = nullptr; - reset_connection_state(&connectionState); - if (!run_start_attempt()) { - return false; - } + const StreamStartAttemptContext startAttempt {host, app, startContext, connectionState, resources, statusMessage}; + if (!run_stream_start_with_rtsp_fallback(startAttempt, &rtspFallbackAttempted)) { + return false; } if (connectionState.startResult.load() != 0 || !connectionState.connectionStarted.load()) { @@ -1410,49 +1526,13 @@ namespace streaming { } active_connection_state() = nullptr; close_stream_ui_resources(&resources); - if (statusMessage != nullptr) { - *statusMessage = failureMessage; - } + assign_status_message(statusMessage, failureMessage); logging::warn("stream", failureMessage); return false; } - StreamInputThreadState inputThreadState {}; - inputThreadState.resources = &resources; - inputThreadState.connectionState = &connectionState; - SDL_Thread *inputThread = SDL_CreateThread(run_stream_input_thread, "stream-input", &inputThreadState); - if (inputThread == nullptr) { - logging::warn("stream", std::string("Failed to start the stream input thread; polling controller from the render loop: ") + SDL_GetError()); - } - - bool fallbackControllerArrivalSent = false; - ControllerSnapshot fallbackLastControllerSnapshot {}; - Uint32 fallbackExitComboActivatedTick = 0U; - Uint32 lastIdleVideoIdrRequestTick = 0U; logging::info("stream", std::string(launchResult.resumedSession ? "Resumed stream for " : "Launched stream for ") + app.name + " on " + host.displayName); - while (!connectionState.connectionTerminated.load() && !connectionState.stopRequested.load()) { - pump_stream_events(&resources); - if (inputThread == nullptr) { - bool controllerPresent = false; - const ControllerSnapshot snapshot = read_controller_snapshot(&resources, &controllerPresent); - update_stream_exit_combo_from_snapshot(snapshot, controllerPresent, &fallbackExitComboActivatedTick, &connectionState); - send_controller_snapshot_if_needed(snapshot, controllerPresent, &fallbackControllerArrivalSent, &fallbackLastControllerSnapshot); - } - const bool hasDecodedVideo = resources.mediaBackend.has_decoded_video(); - const bool shouldRenderFrame = settings.showPerformanceStats || !hasDecodedVideo || resources.mediaBackend.has_unrendered_video_frame(); - if (shouldRenderFrame) { - render_stream_frame(host, app, startContext, connectionState, &resources.mediaBackend, settings.showPerformanceStats, &resources); - } - request_idle_video_refresh_if_needed(&resources.mediaBackend, &lastIdleVideoIdrRequestTick); - SDL_Delay(hasDecodedVideo && !settings.showPerformanceStats ? STREAM_PRESENT_POLL_MILLISECONDS : STREAM_FRAME_DELAY_MILLISECONDS); - } - - inputThreadState.stopRequested.store(true); - if (inputThread != nullptr) { - int inputThreadResult = 0; - SDL_WaitThread(inputThread, &inputThreadResult); - (void) inputThreadResult; - } + run_active_stream_loop({settings, host, app, startContext, connectionState, resources}); LiStopConnection(); active_connection_state() = nullptr; @@ -1461,9 +1541,7 @@ namespace streaming { logging::info("stream", finalMessage); startup::log_memory_statistics(); close_stream_ui_resources(&resources); - if (statusMessage != nullptr) { - *statusMessage = finalMessage; - } + assign_status_message(statusMessage, finalMessage); return true; } diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index b9ef369..c427101 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -4492,8 +4492,7 @@ namespace { } const std::vector retainedEntries = logging::snapshot(logging::LogLevel::info); - if (const auto viewModel = ui::build_shell_view_model(state, retainedEntries); - draw_shell(resources->renderer, videoMode, resources->encoderSettings, resources->titleFont, resources->bodyFont, resources->smallFont, viewModel, &resources->coverArtTextureCache, &resources->assetTextureCache, &resources->keypadModalLayoutCache)) { + if (const auto viewModel = ui::build_shell_view_model(state, retainedEntries); draw_shell(resources->renderer, videoMode, resources->encoderSettings, resources->titleFont, resources->bodyFont, resources->smallFont, viewModel, &resources->coverArtTextureCache, &resources->assetTextureCache, &resources->keypadModalLayoutCache)) { runtime->keypadRedrawRequested = false; return true; } @@ -4573,8 +4572,7 @@ namespace { } network::PairingIdentity clientIdentity {}; - std::string identityError; - if (!load_saved_pairing_identity_for_streaming(&clientIdentity, &identityError)) { + if (std::string identityError; !load_saved_pairing_identity_for_streaming(&clientIdentity, &identityError)) { state.shell.statusMessage = identityError; logging::warn("stream", identityError); return true; @@ -4678,7 +4676,7 @@ namespace { * @param runtime Runtime state that carries input and task progress. * @return True when the shell should continue processing future frames. */ - bool run_shell_frame(SDL_Window *window, VIDEO_MODE *videoMode, app::ClientState &state, ShellResources *resources, ShellRuntimeState *runtime) { + bool run_shell_frame(SDL_Window *window, const VIDEO_MODE *videoMode, app::ClientState &state, ShellResources *resources, ShellRuntimeState *runtime) { if (window == nullptr || resources == nullptr || runtime == nullptr) { return false; } @@ -4702,8 +4700,7 @@ namespace { finish_shell_background_tasks(state, resources, runtime); start_shell_background_tasks_if_needed(state, runtime, SDL_GetTicks()); - if ((state.shell.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible || runtime->keypadRedrawRequested) && - !draw_current_shell_frame(videoMode != nullptr ? *videoMode : VIDEO_MODE {}, state, resources, runtime)) { + if ((state.shell.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible || runtime->keypadRedrawRequested) && !draw_current_shell_frame(videoMode != nullptr ? *videoMode : VIDEO_MODE {}, state, resources, runtime)) { return false; } From 3ce691f7c620da0aae0242ce55a8551c07378070 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 19:07:38 -0400 Subject: [PATCH 13/19] Unset MSYS2_ARG_CONV_EXCL for nxdk ffmpeg builds Unset the MSYS2_ARG_CONV_EXCL environment variable in the cmake nxdk ffmpeg preparation and in both ffmpeg-nxdk-cc.sh and ffmpeg-nxdk-cxx.sh scripts. This prevents MSYS2 argument/path conversion from interfering with nxdk/FFmpeg build steps and helps ensure consistent behavior in the build environment. --- cmake/moonlight-dependencies.cmake | 2 ++ scripts/ffmpeg-nxdk-cc.sh | 2 ++ scripts/ffmpeg-nxdk-cxx.sh | 2 ++ 3 files changed, 6 insertions(+) diff --git a/cmake/moonlight-dependencies.cmake b/cmake/moonlight-dependencies.cmake index b6b513f..f67308b 100644 --- a/cmake/moonlight-dependencies.cmake +++ b/cmake/moonlight-dependencies.cmake @@ -196,6 +196,7 @@ function(moonlight_prepare_xbox_ffmpeg nxdk_dir) string(CONCAT ffmpeg_configure_script "unset MAKEFLAGS MFLAGS GNUMAKEFLAGS MAKELEVEL; " + "unset MSYS2_ARG_CONV_EXCL; " "export NXDK_DIR=${quoted_nxdk_shell_path}; " "export PATH=\"$NXDK_DIR/bin:$PATH\"; " "cd ${quoted_ffmpeg_build_shell_path}; " @@ -213,6 +214,7 @@ function(moonlight_prepare_xbox_ffmpeg nxdk_dir) string(CONCAT ffmpeg_build_script "unset MAKEFLAGS MFLAGS GNUMAKEFLAGS MAKELEVEL; " + "unset MSYS2_ARG_CONV_EXCL; " "export NXDK_DIR=${quoted_nxdk_shell_path}; " "export PATH=\"$NXDK_DIR/bin:$PATH\"; " "cd ${quoted_ffmpeg_build_shell_path}; " diff --git a/scripts/ffmpeg-nxdk-cc.sh b/scripts/ffmpeg-nxdk-cc.sh index a54f077..3b6611f 100644 --- a/scripts/ffmpeg-nxdk-cc.sh +++ b/scripts/ffmpeg-nxdk-cc.sh @@ -6,6 +6,8 @@ script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) ffmpeg_compat_header="${script_dir}/../src/_nxdk_compat/ffmpeg_compat.h" ffmpeg_compat_include_dir="${script_dir}/../src/_nxdk_compat" +unset MSYS2_ARG_CONV_EXCL + compile_only=0 output_path= previous_arg= diff --git a/scripts/ffmpeg-nxdk-cxx.sh b/scripts/ffmpeg-nxdk-cxx.sh index c77994c..a66925b 100644 --- a/scripts/ffmpeg-nxdk-cxx.sh +++ b/scripts/ffmpeg-nxdk-cxx.sh @@ -6,6 +6,8 @@ script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) ffmpeg_compat_header="${script_dir}/../src/_nxdk_compat/ffmpeg_compat.h" ffmpeg_compat_include_dir="${script_dir}/../src/_nxdk_compat" +unset MSYS2_ARG_CONV_EXCL + compile_only=0 output_path= previous_arg= From bbc39301094934b17cd9c54fb9639a5e39a71b83 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 19:17:22 -0400 Subject: [PATCH 14/19] Use string_view and add controller/event helpers Update session.cpp to accept std::string_view in assign_status_message and use string::assign to copy the view, improving clarity and avoiding implicit conversions. Add copy_controller_device_event to safely copy SDL controller device events and use it in pump_stream_events when opening/closing controllers. Simplify StreamStartAttemptContext usage by moving its initialization into an if-with-initializer. In tests, add expect_unique_id_request, expect_unauthenticated_unique_id_request, and expect_authenticated_unique_id_request helpers and refactor repetitive EXPECT_* checks to use these helpers for clearer, less duplicated assertions. --- src/streaming/session.cpp | 19 ++++++++----- tests/unit/network/host_pairing_test.cpp | 34 ++++++++++++++---------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index d8369d1..f5834d3 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -164,9 +164,9 @@ namespace { int streamingRemotely = STREAM_CFG_AUTO; }; - void assign_status_message(std::string *statusMessage, const std::string &message) { + void assign_status_message(std::string *statusMessage, std::string_view message) { if (statusMessage != nullptr) { - *statusMessage = message; + statusMessage->assign(message.data(), message.size()); } } @@ -943,6 +943,12 @@ namespace { } } + SDL_ControllerDeviceEvent copy_controller_device_event(const SDL_Event &event) { + SDL_ControllerDeviceEvent controllerEvent {}; + std::memcpy(&controllerEvent, &event, sizeof(controllerEvent)); + return controllerEvent; + } + void pump_stream_events(StreamUiResources *resources) { SDL_Event event {}; while (SDL_PollEvent(&event) != 0) { @@ -951,9 +957,11 @@ namespace { } if (event.type == SDL_CONTROLLERDEVICEADDED) { - open_controller_if_needed(resources, event.cdevice.which); + const SDL_ControllerDeviceEvent controllerEvent = copy_controller_device_event(event); + open_controller_if_needed(resources, controllerEvent.which); } else if (event.type == SDL_CONTROLLERDEVICEREMOVED) { - close_controller_if_removed(resources, event.cdevice.which); + const SDL_ControllerDeviceEvent controllerEvent = copy_controller_device_event(event); + close_controller_if_removed(resources, controllerEvent.which); } } } @@ -1514,8 +1522,7 @@ namespace streaming { startContext.connectionCallbacks.logMessage = on_log_message; bool rtspFallbackAttempted = false; - const StreamStartAttemptContext startAttempt {host, app, startContext, connectionState, resources, statusMessage}; - if (!run_stream_start_with_rtsp_fallback(startAttempt, &rtspFallbackAttempted)) { + if (const StreamStartAttemptContext startAttempt {host, app, startContext, connectionState, resources, statusMessage}; !run_stream_start_with_rtsp_fallback(startAttempt, &rtspFallbackAttempted)) { return false; } diff --git a/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index fdc0eca..d23ad90 100644 --- a/tests/unit/network/host_pairing_test.cpp +++ b/tests/unit/network/host_pairing_test.cpp @@ -138,6 +138,22 @@ namespace { return std::string(pathAndQuery.substr(valueStart, valueEnd == std::string_view::npos ? std::string_view::npos : valueEnd - valueStart)); } + void expect_unique_id_request(const HostPairingHttpTestRequest &request, const network::PairingIdentity &identity, std::string_view path) { + EXPECT_NE(request.pathAndQuery.find(std::string(path) + "?uniqueid=" + identity.uniqueId), std::string::npos); + } + + void expect_unauthenticated_unique_id_request(const HostPairingHttpTestRequest &request, const network::PairingIdentity &identity, std::string_view path) { + EXPECT_FALSE(request.useTls); + expect_unique_id_request(request, identity, path); + } + + void expect_authenticated_unique_id_request(const HostPairingHttpTestRequest &request, const network::PairingIdentity &identity, std::string_view path) { + EXPECT_TRUE(request.useTls); + ASSERT_NE(request.tlsClientIdentity, nullptr); + EXPECT_EQ(request.tlsClientIdentity->uniqueId, identity.uniqueId); + expect_unique_id_request(request, identity, path); + } + std::vector to_unsigned_bytes(const std::byte *data, std::size_t size) { std::vector bytes; bytes.reserve(size); @@ -897,8 +913,7 @@ namespace { ScriptedHostPairingHttpHandler handler({ { [&identity](const HostPairingHttpTestRequest &request) { - EXPECT_FALSE(request.useTls); - EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + identity.uniqueId), std::string::npos); + expect_unauthenticated_unique_id_request(request, identity, "/serverinfo"); }, true, 200, @@ -906,10 +921,7 @@ namespace { }, { [&identity](const HostPairingHttpTestRequest &request) { - EXPECT_TRUE(request.useTls); - ASSERT_NE(request.tlsClientIdentity, nullptr); - EXPECT_EQ(request.tlsClientIdentity->uniqueId, identity.uniqueId); - EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + identity.uniqueId), std::string::npos); + expect_authenticated_unique_id_request(request, identity, "/serverinfo"); }, true, 200, @@ -917,10 +929,7 @@ namespace { }, { [&identity](const HostPairingHttpTestRequest &request) { - EXPECT_TRUE(request.useTls); - ASSERT_NE(request.tlsClientIdentity, nullptr); - EXPECT_EQ(request.tlsClientIdentity->uniqueId, identity.uniqueId); - EXPECT_NE(request.pathAndQuery.find("/launch?uniqueid=" + identity.uniqueId), std::string::npos); + expect_authenticated_unique_id_request(request, identity, "/launch"); EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "appid"), "101"); EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "mode"), "640x480x30"); EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "localAudioPlayMode"), "1"); @@ -985,11 +994,8 @@ namespace { }, { [&identity](const HostPairingHttpTestRequest &request) { - EXPECT_TRUE(request.useTls); - ASSERT_NE(request.tlsClientIdentity, nullptr); - EXPECT_EQ(request.tlsClientIdentity->uniqueId, identity.uniqueId); + expect_authenticated_unique_id_request(request, identity, "/launch"); EXPECT_EQ(request.address, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); - EXPECT_NE(request.pathAndQuery.find("/launch?uniqueid=" + identity.uniqueId), std::string::npos); }, true, 200, From fbad3866e9ab3bf5adbfc3b57e065f00ab68f7c2 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 19:37:21 -0400 Subject: [PATCH 15/19] Refactor FFmpeg nxdk build and MSYS handling Modularize and harden the FFmpeg-for-Xbox build logic: extract validation, revision/signature computation, shell path conversion, configure-args composition, Windows MSYS2 command runner, and the rebuild routine into helper functions. The main moonlight_prepare_xbox_ffmpeg now validates inputs, computes a rebuild signature from sources and wrapper files, and only rebuilds when needed. Also make MSYS2 configure invocation more robust in GetOpenSSL by concatenating the configure script, and tighten CMake formatting. Update ffmpeg wrapper scripts to set CDPATH='' when resolving script_dir to avoid unexpected path resolution behavior on some shells. --- CMakeLists.txt | 6 +- cmake/modules/GetOpenSSL.cmake | 5 +- cmake/moonlight-dependencies.cmake | 409 ++++++++++++++++++----------- scripts/ffmpeg-nxdk-cc.sh | 2 +- scripts/ffmpeg-nxdk-cxx.sh | 2 +- 5 files changed, 268 insertions(+), 156 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b76265b..582659d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,7 +40,11 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(CMAKE_HOST_WIN32 AND CMAKE_GENERATOR MATCHES "Makefiles") - set(CMAKE_DEPENDS_USE_COMPILER FALSE CACHE BOOL "Use CMake depfile scanning with Windows makefile generators" FORCE) + set(CMAKE_DEPENDS_USE_COMPILER + FALSE + CACHE BOOL + "Use CMake depfile scanning with Windows makefile generators" + FORCE) endif() # diff --git a/cmake/modules/GetOpenSSL.cmake b/cmake/modules/GetOpenSSL.cmake index 8074599..b822d80 100644 --- a/cmake/modules/GetOpenSSL.cmake +++ b/cmake/modules/GetOpenSSL.cmake @@ -194,10 +194,13 @@ else() "--prefix=${_openssl_install_dir_msys}" "--openssldir=${_openssl_install_dir_msys}/ssl") _moonlight_shell_quote(_openssl_build_dir_msys_quoted "${_openssl_build_dir_msys}") + string(CONCAT _openssl_configure_script + "cd ${_openssl_build_dir_msys_quoted} && " + "${_openssl_tool_prefix} exec ${_openssl_configure_command}") set(OPENSSL_CONFIGURE_COMMAND "${OPENSSL_MSYS2_SHELL}" -defterm -here -no-start -mingw64 - -c "cd ${_openssl_build_dir_msys_quoted} && ${_openssl_tool_prefix} exec ${_openssl_configure_command}") + -c "${_openssl_configure_script}") set(OPENSSL_BUILD_COMMAND "${OPENSSL_MSYS2_SHELL}" -defterm -here -no-start -mingw64 diff --git a/cmake/moonlight-dependencies.cmake b/cmake/moonlight-dependencies.cmake index f67308b..bb546c4 100644 --- a/cmake/moonlight-dependencies.cmake +++ b/cmake/moonlight-dependencies.cmake @@ -37,33 +37,23 @@ function(_moonlight_patch_ffmpeg_config_header ffmpeg_config_header) file(WRITE "${ffmpeg_config_header}" "${ffmpeg_config_text}") endfunction() -# Prepare the static FFmpeg libraries used by the Xbox streaming runtime. -function(moonlight_prepare_xbox_ffmpeg nxdk_dir) - set(ffmpeg_source_dir "${CMAKE_SOURCE_DIR}/third-party/ffmpeg") - set(ffmpeg_cc_wrapper "${CMAKE_SOURCE_DIR}/scripts/ffmpeg-nxdk-cc.sh") - set(ffmpeg_cxx_wrapper "${CMAKE_SOURCE_DIR}/scripts/ffmpeg-nxdk-cxx.sh") - set(ffmpeg_compat_header "${CMAKE_SOURCE_DIR}/src/_nxdk_compat/ffmpeg_compat.h") +# Validate FFmpeg source and support files before attempting a configure. +function(_moonlight_validate_xbox_ffmpeg_inputs ffmpeg_source_dir) if(NOT EXISTS "${ffmpeg_source_dir}/configure") message(FATAL_ERROR "FFmpeg source directory not found: ${ffmpeg_source_dir}\n" "Run: git submodule update --init --recursive") endif() - foreach(ffmpeg_support_file - IN ITEMS - "${ffmpeg_cc_wrapper}" - "${ffmpeg_cxx_wrapper}" - "${ffmpeg_compat_header}") + foreach(ffmpeg_support_file IN LISTS ARGN) if(NOT EXISTS "${ffmpeg_support_file}") message(FATAL_ERROR "Required FFmpeg support file not found: ${ffmpeg_support_file}") endif() endforeach() +endfunction() - set(ffmpeg_state_dir "${CMAKE_BINARY_DIR}/third-party/ffmpeg") - set(ffmpeg_build_dir "${ffmpeg_state_dir}/build") - set(ffmpeg_install_dir "${ffmpeg_state_dir}/install") - set(signature_file "${ffmpeg_state_dir}/build.signature") - +# Read the FFmpeg source revision used in the rebuild signature. +function(_moonlight_get_ffmpeg_revision out_var ffmpeg_source_dir) execute_process( COMMAND git -C "${ffmpeg_source_dir}" rev-parse HEAD OUTPUT_VARIABLE ffmpeg_revision @@ -74,6 +64,16 @@ function(moonlight_prepare_xbox_ffmpeg nxdk_dir) set(ffmpeg_revision unknown) endif() + set(${out_var} "${ffmpeg_revision}" PARENT_SCOPE) +endfunction() + +# Compute the FFmpeg rebuild signature from source and support-file inputs. +function(_moonlight_compute_ffmpeg_signature out_var nxdk_dir ffmpeg_source_dir) + set(ffmpeg_cc_wrapper "${ARGV3}") + set(ffmpeg_cxx_wrapper "${ARGV4}") + set(ffmpeg_compat_header "${ARGV5}") + + _moonlight_get_ffmpeg_revision(ffmpeg_revision "${ffmpeg_source_dir}") file(SHA256 "${ffmpeg_cc_wrapper}" ffmpeg_cc_wrapper_hash) file(SHA256 "${ffmpeg_cxx_wrapper}" ffmpeg_cxx_wrapper_hash) file(SHA256 "${ffmpeg_compat_header}" ffmpeg_compat_header_hash) @@ -92,15 +92,11 @@ function(moonlight_prepare_xbox_ffmpeg nxdk_dir) list(JOIN signature_inputs "\n" signature_text) string(SHA256 signature "${signature_text}") - set(required_outputs - "${ffmpeg_install_dir}/include/libavcodec/avcodec.h" - "${ffmpeg_install_dir}/lib/libavcodec.a" - "${ffmpeg_install_dir}/lib/libavutil.a" - "${ffmpeg_install_dir}/lib/libswscale.a" - "${ffmpeg_install_dir}/lib/libswresample.a") - - file(MAKE_DIRECTORY "${ffmpeg_state_dir}") + set(${out_var} "${signature}" PARENT_SCOPE) +endfunction() +# Determine whether FFmpeg must be rebuilt from the saved signature and expected outputs. +function(_moonlight_should_rebuild_ffmpeg out_var signature_file signature) set(need_rebuild FALSE) if(NOT EXISTS "${signature_file}") set(need_rebuild TRUE) @@ -112,140 +108,249 @@ function(moonlight_prepare_xbox_ffmpeg nxdk_dir) endif() endif() - _moonlight_has_missing_output(ffmpeg_missing_output ${required_outputs}) + _moonlight_has_missing_output(ffmpeg_missing_output ${ARGN}) if(ffmpeg_missing_output) set(need_rebuild TRUE) endif() + set(${out_var} "${need_rebuild}" PARENT_SCOPE) +endfunction() + +# Convert FFmpeg build paths to shell paths for the active host platform. +function(_moonlight_get_ffmpeg_shell_paths out_var) + set(ffmpeg_source_dir "${ARGV1}") + set(ffmpeg_install_dir "${ARGV2}") + set(ffmpeg_build_dir "${ARGV3}") + set(nxdk_dir "${ARGV4}") + set(ffmpeg_cc_wrapper "${ARGV5}") + set(ffmpeg_cxx_wrapper "${ARGV6}") + + if(CMAKE_HOST_WIN32) + _moonlight_to_msys_path(ffmpeg_source_shell_path "${ffmpeg_source_dir}") + _moonlight_to_msys_path(ffmpeg_install_shell_path "${ffmpeg_install_dir}") + _moonlight_to_msys_path(ffmpeg_build_shell_path "${ffmpeg_build_dir}") + _moonlight_to_msys_path(nxdk_shell_path "${nxdk_dir}") + _moonlight_to_msys_path(ffmpeg_cc_wrapper_shell_path "${ffmpeg_cc_wrapper}") + _moonlight_to_msys_path(ffmpeg_cxx_wrapper_shell_path "${ffmpeg_cxx_wrapper}") + else() + set(ffmpeg_source_shell_path "${ffmpeg_source_dir}") + set(ffmpeg_install_shell_path "${ffmpeg_install_dir}") + set(ffmpeg_build_shell_path "${ffmpeg_build_dir}") + set(nxdk_shell_path "${nxdk_dir}") + set(ffmpeg_cc_wrapper_shell_path "${ffmpeg_cc_wrapper}") + set(ffmpeg_cxx_wrapper_shell_path "${ffmpeg_cxx_wrapper}") + endif() + + set(ffmpeg_shell_paths + "${ffmpeg_source_shell_path}" + "${ffmpeg_install_shell_path}" + "${ffmpeg_build_shell_path}" + "${nxdk_shell_path}" + "${ffmpeg_cc_wrapper_shell_path}" + "${ffmpeg_cxx_wrapper_shell_path}") + set(${out_var} "${ffmpeg_shell_paths}" PARENT_SCOPE) +endfunction() + +# Compose FFmpeg configure arguments for the nxdk target. +function(_moonlight_get_ffmpeg_configure_args out_var) + set(ffmpeg_source_shell_path "${ARGV1}") + set(ffmpeg_install_shell_path "${ARGV2}") + set(ffmpeg_cc_shell_path "sh ${ARGV3}") + set(ffmpeg_cxx_shell_path "sh ${ARGV4}") + + set(ffmpeg_configure_args + sh + "${ffmpeg_source_shell_path}/configure" + "--prefix=${ffmpeg_install_shell_path}" + --enable-cross-compile + --arch=x86 + --cpu=i686 + --target-os=none + "--cc=${ffmpeg_cc_shell_path}" + "--cxx=${ffmpeg_cxx_shell_path}" + --ar=llvm-ar + --ranlib=llvm-ranlib + --nm=llvm-nm + --enable-static + --disable-shared + --disable-autodetect + --disable-asm + --disable-inline-asm + --disable-x86asm + --disable-debug + --disable-doc + --disable-programs + --disable-network + --disable-everything + --disable-avdevice + --disable-avfilter + --disable-avformat + --disable-iconv + --disable-zlib + --disable-bzlib + --disable-lzma + --disable-sdl2 + --disable-symver + --disable-runtime-cpudetect + --disable-pthreads + --disable-w32threads + --disable-os2threads + --disable-hwaccels + --enable-avcodec + --enable-avutil + --enable-swscale + --enable-swresample + --enable-parser=h264 + --enable-decoder=h264 + --enable-decoder=opus) + + set(${out_var} "${ffmpeg_configure_args}" PARENT_SCOPE) +endfunction() + +# Execute an FFmpeg command in MSYS2 with nxdk paths ahead of the host PATH. +function(_moonlight_run_windows_ffmpeg_command description nxdk_shell_path ffmpeg_build_shell_path) + set(msys2_shell "C:/msys64/msys2_shell.cmd") + if(NOT EXISTS "${msys2_shell}") + message(FATAL_ERROR "MSYS2 shell not found at ${msys2_shell}") + endif() + + _moonlight_join_shell_command(ffmpeg_command ${ARGN}) + _moonlight_shell_quote(quoted_nxdk_shell_path "${nxdk_shell_path}") + _moonlight_shell_quote(quoted_ffmpeg_build_shell_path "${ffmpeg_build_shell_path}") + + string(CONCAT ffmpeg_script + "unset MAKEFLAGS MFLAGS GNUMAKEFLAGS MAKELEVEL; " + "unset MSYS2_ARG_CONV_EXCL; " + "export NXDK_DIR=${quoted_nxdk_shell_path}; " + "export PATH=\"$NXDK_DIR/bin:$PATH\"; " + "cd ${quoted_ffmpeg_build_shell_path}; " + "exec ${ffmpeg_command}") + execute_process( + COMMAND "${msys2_shell}" -defterm -here -no-start -mingw64 -c "${ffmpeg_script}" + RESULT_VARIABLE ffmpeg_command_result + ) + if(NOT ffmpeg_command_result EQUAL 0) + message(FATAL_ERROR "${description} failed with exit code ${ffmpeg_command_result}") + endif() +endfunction() + +# Rebuild and install FFmpeg for the Xbox target. +function(_moonlight_rebuild_xbox_ffmpeg nxdk_dir ffmpeg_source_dir ffmpeg_build_dir ffmpeg_install_dir) + set(ffmpeg_cc_wrapper "${ARGV4}") + set(ffmpeg_cxx_wrapper "${ARGV5}") + set(signature_file "${ARGV6}") + set(signature "${ARGV7}") + + file(REMOVE_RECURSE "${ffmpeg_build_dir}" "${ffmpeg_install_dir}") + file(MAKE_DIRECTORY "${ffmpeg_build_dir}") + + _moonlight_get_ffmpeg_shell_paths( + ffmpeg_shell_paths + "${ffmpeg_source_dir}" + "${ffmpeg_install_dir}" + "${ffmpeg_build_dir}" + "${nxdk_dir}" + "${ffmpeg_cc_wrapper}" + "${ffmpeg_cxx_wrapper}") + list(GET ffmpeg_shell_paths 0 ffmpeg_source_shell_path) + list(GET ffmpeg_shell_paths 1 ffmpeg_install_shell_path) + list(GET ffmpeg_shell_paths 2 ffmpeg_build_shell_path) + list(GET ffmpeg_shell_paths 3 nxdk_shell_path) + list(GET ffmpeg_shell_paths 4 ffmpeg_cc_wrapper_shell_path) + list(GET ffmpeg_shell_paths 5 ffmpeg_cxx_wrapper_shell_path) + _moonlight_get_ffmpeg_configure_args( + ffmpeg_configure_args + "${ffmpeg_source_shell_path}" + "${ffmpeg_install_shell_path}" + "${ffmpeg_cc_wrapper_shell_path}" + "${ffmpeg_cxx_wrapper_shell_path}") + + if(CMAKE_HOST_WIN32) + _moonlight_run_windows_ffmpeg_command( + "FFmpeg configure" + "${nxdk_shell_path}" + "${ffmpeg_build_shell_path}" + ${ffmpeg_configure_args}) + else() + moonlight_run_nxdk_command( + "FFmpeg configure" + "${nxdk_dir}" + "${ffmpeg_build_dir}" + ${ffmpeg_configure_args}) + endif() + + set(ffmpeg_config_header "${ffmpeg_build_dir}/config.h") + _moonlight_patch_ffmpeg_config_header("${ffmpeg_config_header}") + + if(CMAKE_HOST_WIN32) + _moonlight_run_windows_ffmpeg_command( + "FFmpeg build" + "${nxdk_shell_path}" + "${ffmpeg_build_shell_path}" + make + -j4 + install) + else() + moonlight_run_nxdk_command( + "FFmpeg build" + "${nxdk_dir}" + "${ffmpeg_build_dir}" + make + -j4 + install) + endif() + + file(WRITE "${signature_file}" "${signature}\n") +endfunction() + +# Prepare the static FFmpeg libraries used by the Xbox streaming runtime. +function(moonlight_prepare_xbox_ffmpeg nxdk_dir) + set(ffmpeg_source_dir "${CMAKE_SOURCE_DIR}/third-party/ffmpeg") + set(ffmpeg_cc_wrapper "${CMAKE_SOURCE_DIR}/scripts/ffmpeg-nxdk-cc.sh") + set(ffmpeg_cxx_wrapper "${CMAKE_SOURCE_DIR}/scripts/ffmpeg-nxdk-cxx.sh") + set(ffmpeg_compat_header "${CMAKE_SOURCE_DIR}/src/_nxdk_compat/ffmpeg_compat.h") + _moonlight_validate_xbox_ffmpeg_inputs( + "${ffmpeg_source_dir}" + "${ffmpeg_cc_wrapper}" + "${ffmpeg_cxx_wrapper}" + "${ffmpeg_compat_header}") + + set(ffmpeg_state_dir "${CMAKE_BINARY_DIR}/third-party/ffmpeg") + set(ffmpeg_build_dir "${ffmpeg_state_dir}/build") + set(ffmpeg_install_dir "${ffmpeg_state_dir}/install") + set(signature_file "${ffmpeg_state_dir}/build.signature") + set(required_outputs + "${ffmpeg_install_dir}/include/libavcodec/avcodec.h" + "${ffmpeg_install_dir}/lib/libavcodec.a" + "${ffmpeg_install_dir}/lib/libavutil.a" + "${ffmpeg_install_dir}/lib/libswscale.a" + "${ffmpeg_install_dir}/lib/libswresample.a") + + file(MAKE_DIRECTORY "${ffmpeg_state_dir}") + _moonlight_compute_ffmpeg_signature( + signature + "${nxdk_dir}" + "${ffmpeg_source_dir}" + "${ffmpeg_cc_wrapper}" + "${ffmpeg_cxx_wrapper}" + "${ffmpeg_compat_header}") + _moonlight_should_rebuild_ffmpeg( + need_rebuild + "${signature_file}" + "${signature}" + ${required_outputs}) + if(need_rebuild) message(STATUS "Preparing FFmpeg for Xbox at ${ffmpeg_build_dir}") - file(REMOVE_RECURSE "${ffmpeg_build_dir}" "${ffmpeg_install_dir}") - file(MAKE_DIRECTORY "${ffmpeg_build_dir}") - - if(CMAKE_HOST_WIN32) - _moonlight_to_msys_path(ffmpeg_source_shell_path "${ffmpeg_source_dir}") - _moonlight_to_msys_path(ffmpeg_install_shell_path "${ffmpeg_install_dir}") - _moonlight_to_msys_path(ffmpeg_build_shell_path "${ffmpeg_build_dir}") - _moonlight_to_msys_path(nxdk_shell_path "${nxdk_dir}") - _moonlight_to_msys_path(ffmpeg_cc_wrapper_shell_path "${ffmpeg_cc_wrapper}") - _moonlight_to_msys_path(ffmpeg_cxx_wrapper_shell_path "${ffmpeg_cxx_wrapper}") - else() - set(ffmpeg_source_shell_path "${ffmpeg_source_dir}") - set(ffmpeg_install_shell_path "${ffmpeg_install_dir}") - set(ffmpeg_cc_wrapper_shell_path "${ffmpeg_cc_wrapper}") - set(ffmpeg_cxx_wrapper_shell_path "${ffmpeg_cxx_wrapper}") - endif() - set(ffmpeg_cc_shell_path "sh ${ffmpeg_cc_wrapper_shell_path}") - set(ffmpeg_cxx_shell_path "sh ${ffmpeg_cxx_wrapper_shell_path}") - - set(ffmpeg_configure_args - sh - "${ffmpeg_source_shell_path}/configure" - "--prefix=${ffmpeg_install_shell_path}" - --enable-cross-compile - --arch=x86 - --cpu=i686 - --target-os=none - "--cc=${ffmpeg_cc_shell_path}" - "--cxx=${ffmpeg_cxx_shell_path}" - --ar=llvm-ar - --ranlib=llvm-ranlib - --nm=llvm-nm - --enable-static - --disable-shared - --disable-autodetect - --disable-asm - --disable-inline-asm - --disable-x86asm - --disable-debug - --disable-doc - --disable-programs - --disable-network - --disable-everything - --disable-avdevice - --disable-avfilter - --disable-avformat - --disable-iconv - --disable-zlib - --disable-bzlib - --disable-lzma - --disable-sdl2 - --disable-symver - --disable-runtime-cpudetect - --disable-pthreads - --disable-w32threads - --disable-os2threads - --disable-hwaccels - --enable-avcodec - --enable-avutil - --enable-swscale - --enable-swresample - --enable-parser=h264 - --enable-decoder=h264 - --enable-decoder=opus) - - if(CMAKE_HOST_WIN32) - set(msys2_shell "C:/msys64/msys2_shell.cmd") - if(NOT EXISTS "${msys2_shell}") - message(FATAL_ERROR "MSYS2 shell not found at ${msys2_shell}") - endif() - _moonlight_join_shell_command(ffmpeg_configure_command ${ffmpeg_configure_args}) - _moonlight_join_shell_command(ffmpeg_build_command make -j4 install) - _moonlight_shell_quote(quoted_nxdk_shell_path "${nxdk_shell_path}") - _moonlight_shell_quote(quoted_ffmpeg_build_shell_path "${ffmpeg_build_shell_path}") - - string(CONCAT ffmpeg_configure_script - "unset MAKEFLAGS MFLAGS GNUMAKEFLAGS MAKELEVEL; " - "unset MSYS2_ARG_CONV_EXCL; " - "export NXDK_DIR=${quoted_nxdk_shell_path}; " - "export PATH=\"$NXDK_DIR/bin:$PATH\"; " - "cd ${quoted_ffmpeg_build_shell_path}; " - "exec ${ffmpeg_configure_command}") - execute_process( - COMMAND "${msys2_shell}" -defterm -here -no-start -mingw64 -c "${ffmpeg_configure_script}" - RESULT_VARIABLE ffmpeg_configure_result - ) - if(NOT ffmpeg_configure_result EQUAL 0) - message(FATAL_ERROR "FFmpeg configure failed with exit code ${ffmpeg_configure_result}") - endif() - - set(ffmpeg_config_header "${ffmpeg_build_dir}/config.h") - _moonlight_patch_ffmpeg_config_header("${ffmpeg_config_header}") - - string(CONCAT ffmpeg_build_script - "unset MAKEFLAGS MFLAGS GNUMAKEFLAGS MAKELEVEL; " - "unset MSYS2_ARG_CONV_EXCL; " - "export NXDK_DIR=${quoted_nxdk_shell_path}; " - "export PATH=\"$NXDK_DIR/bin:$PATH\"; " - "cd ${quoted_ffmpeg_build_shell_path}; " - "exec ${ffmpeg_build_command}") - execute_process( - COMMAND "${msys2_shell}" -defterm -here -no-start -mingw64 -c "${ffmpeg_build_script}" - RESULT_VARIABLE ffmpeg_build_result - ) - if(NOT ffmpeg_build_result EQUAL 0) - message(FATAL_ERROR "FFmpeg build failed with exit code ${ffmpeg_build_result}") - endif() - else() - moonlight_run_nxdk_command( - "FFmpeg configure" - "${nxdk_dir}" - "${ffmpeg_build_dir}" - ${ffmpeg_configure_args} - ) - set(ffmpeg_config_header "${ffmpeg_build_dir}/config.h") - _moonlight_patch_ffmpeg_config_header("${ffmpeg_config_header}") - moonlight_run_nxdk_command( - "FFmpeg build" - "${nxdk_dir}" - "${ffmpeg_build_dir}" - make - -j4 - install - ) - endif() - - file(WRITE "${signature_file}" "${signature}\n") + _moonlight_rebuild_xbox_ffmpeg( + "${nxdk_dir}" + "${ffmpeg_source_dir}" + "${ffmpeg_build_dir}" + "${ffmpeg_install_dir}" + "${ffmpeg_cc_wrapper}" + "${ffmpeg_cxx_wrapper}" + "${signature_file}" + "${signature}") else() message(STATUS "Using existing FFmpeg Xbox outputs from ${ffmpeg_install_dir}") endif() diff --git a/scripts/ffmpeg-nxdk-cc.sh b/scripts/ffmpeg-nxdk-cc.sh index 3b6611f..6b50a8e 100644 --- a/scripts/ffmpeg-nxdk-cc.sh +++ b/scripts/ffmpeg-nxdk-cc.sh @@ -2,7 +2,7 @@ set -eu -script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) ffmpeg_compat_header="${script_dir}/../src/_nxdk_compat/ffmpeg_compat.h" ffmpeg_compat_include_dir="${script_dir}/../src/_nxdk_compat" diff --git a/scripts/ffmpeg-nxdk-cxx.sh b/scripts/ffmpeg-nxdk-cxx.sh index a66925b..9cc579f 100644 --- a/scripts/ffmpeg-nxdk-cxx.sh +++ b/scripts/ffmpeg-nxdk-cxx.sh @@ -2,7 +2,7 @@ set -eu -script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) ffmpeg_compat_header="${script_dir}/../src/_nxdk_compat/ffmpeg_compat.h" ffmpeg_compat_include_dir="${script_dir}/../src/_nxdk_compat" From b63cde41a4a7feace421fb8a69b92299cf2efeec Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 20:04:17 -0400 Subject: [PATCH 16/19] Use CMAKE_HOST_WIN32 and MSYS2 helper Replace direct WIN32 checks with CMAKE_HOST_WIN32 across CMake scripts and centralize MSYS2 shell lookup. Calls to the hardcoded C:/msys64/msys2_shell.cmd were removed in favor of _moonlight_get_windows_msys2_shell, and several nxdk-related functions now use CMAKE_HOST_WIN32 to detect a Windows host. Modified files: cmake/moonlight-dependencies.cmake, cmake/msys2.cmake, cmake/nxdk.cmake. This makes host detection and MSYS2 resolution more robust (e.g. when cross-compiling) and avoids embedding a fixed MSYS2 path. --- cmake/moonlight-dependencies.cmake | 6 +----- cmake/msys2.cmake | 2 +- cmake/nxdk.cmake | 10 +++++----- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/cmake/moonlight-dependencies.cmake b/cmake/moonlight-dependencies.cmake index bb546c4..dc6cd88 100644 --- a/cmake/moonlight-dependencies.cmake +++ b/cmake/moonlight-dependencies.cmake @@ -209,11 +209,7 @@ endfunction() # Execute an FFmpeg command in MSYS2 with nxdk paths ahead of the host PATH. function(_moonlight_run_windows_ffmpeg_command description nxdk_shell_path ffmpeg_build_shell_path) - set(msys2_shell "C:/msys64/msys2_shell.cmd") - if(NOT EXISTS "${msys2_shell}") - message(FATAL_ERROR "MSYS2 shell not found at ${msys2_shell}") - endif() - + _moonlight_get_windows_msys2_shell(msys2_shell) _moonlight_join_shell_command(ffmpeg_command ${ARGN}) _moonlight_shell_quote(quoted_nxdk_shell_path "${nxdk_shell_path}") _moonlight_shell_quote(quoted_ffmpeg_build_shell_path "${ffmpeg_build_shell_path}") diff --git a/cmake/msys2.cmake b/cmake/msys2.cmake index 78a2ae4..979576c 100644 --- a/cmake/msys2.cmake +++ b/cmake/msys2.cmake @@ -174,7 +174,7 @@ endfunction() # Detect the MSYS2 installation root on Windows and cache the resolved path. function(moonlight_detect_windows_msys2_root out_var) - if(NOT WIN32) + if(NOT CMAKE_HOST_WIN32) message(FATAL_ERROR "moonlight_detect_windows_msys2_root is only available on Windows hosts") endif() diff --git a/cmake/nxdk.cmake b/cmake/nxdk.cmake index 7fbf11d..b3a0368 100644 --- a/cmake/nxdk.cmake +++ b/cmake/nxdk.cmake @@ -30,7 +30,7 @@ endfunction() # Locate the make program appropriate for driving the Xbox nxdk build. function(_moonlight_get_xbox_make_program out_var) - if(WIN32) + if(CMAKE_HOST_WIN32) moonlight_get_windows_msys2_usr_bin(_msys2_usr_bin) set(make_program "${_msys2_usr_bin}/make.exe") if(NOT EXISTS "${make_program}") @@ -86,7 +86,7 @@ function(_moonlight_get_nxdk_path out_var nxdk_dir) endforeach() endif() - if(WIN32) + if(CMAKE_HOST_WIN32) moonlight_get_windows_msys2_msystem_bin(_msys2_mingw_bin mingw64) moonlight_get_windows_msys2_usr_bin(_msys2_usr_bin) list(APPEND path_entries "${_msys2_mingw_bin}" "${_msys2_usr_bin}") @@ -109,7 +109,7 @@ endfunction() # Execute a command inside the nxdk environment, sourcing the NXDK activation on all platforms. function(moonlight_run_nxdk_command description nxdk_dir working_directory) - if(WIN32) + if(CMAKE_HOST_WIN32) _moonlight_get_windows_msys2_shell(_msys2_shell) _moonlight_to_msys_path(_msys_nxdk_dir "${nxdk_dir}") _moonlight_to_msys_path(_msys_working_directory "${working_directory}") @@ -149,7 +149,7 @@ endfunction() # Run make inside the nxdk environment with the platform-appropriate make tool. function(_moonlight_run_nxdk_make nxdk_dir description) - if(WIN32) + if(CMAKE_HOST_WIN32) set(make_program make) else() _moonlight_get_xbox_make_program(make_program) @@ -179,7 +179,7 @@ function(_moonlight_prepare_cxbe nxdk_dir cxbe_path) endif() message(STATUS "Preparing cxbe at ${nxdk_dir}") - if(WIN32) + if(CMAKE_HOST_WIN32) moonlight_run_nxdk_command("cxbe build" "${nxdk_dir}" "${nxdk_dir}/tools/cxbe" make) return() endif() From 84a8954ac56646250cf2a84dd577c2f02e432bec Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 21:02:55 -0400 Subject: [PATCH 17/19] Show end-of-stream summary; Xbox audio default Add an end-of-stream performance summary display and update related settings/labels. This change introduces rendering helpers (render_text_lines, render_stream_end_statistics_frame, show_stream_end_statistics) plus a polling loop to wait for user input before leaving the summary screen. The in-stream overlay handling was simplified (removed per-frame overlay blend) and stream-frame rendering calls were updated to the new signature that uses StreamUiResources. A new poll interval constant (STREAM_END_STATS_POLL_MILLISECONDS) was added. Settings text and persistence comment for show_performance_stats were updated to reflect the new end-of-stream semantics, and playAudioOnXbox default was changed to true; unit tests adjusted accordingly. Misc docs/comments were tweaked to match the new behavior. --- src/app/client_state.cpp | 8 +- src/app/client_state.h | 4 +- src/app/settings_storage.cpp | 1 + src/app/settings_storage.h | 4 +- src/streaming/session.cpp | 132 +++++++++++++++++++---- src/streaming/session.h | 2 +- src/streaming/stats_overlay.cpp | 2 +- src/streaming/stats_overlay.h | 4 +- tests/unit/app/settings_storage_test.cpp | 4 +- 9 files changed, 125 insertions(+), 36 deletions(-) diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index 25bb63b..2eaed05 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -557,8 +557,8 @@ namespace { }, { "toggle-show-performance-stats", - std::string("Show Performance Stats: ") + (state.settings.showPerformanceStats ? "On" : "Off"), - "Toggle the in-stream performance overlay that shows decoded frames, queued audio, and transport telemetry over the video output.", + std::string("Show End Stream Stats: ") + (state.settings.showPerformanceStats ? "On" : "Off"), + "Toggle the performance summary shown after streaming ends.", true, }, }; @@ -1406,7 +1406,7 @@ namespace app { state.settings.streamBitrateKbps = DEFAULT_STREAM_BITRATE_KBPS; state.settings.playAudioOnPc = false; state.settings.showPerformanceStats = false; - state.settings.playAudioOnXbox = false; + state.settings.playAudioOnXbox = true; state.settings.dirty = false; state.settings.savedFilesDirty = true; return state; @@ -2015,7 +2015,7 @@ namespace app { state.settings.showPerformanceStats = !state.settings.showPerformanceStats; state.settings.dirty = true; update->persistence.settingsChanged = true; - state.shell.statusMessage = std::string("Performance stats overlay ") + (state.settings.showPerformanceStats ? "enabled" : "disabled"); + state.shell.statusMessage = std::string("End stream performance stats ") + (state.settings.showPerformanceStats ? "enabled" : "disabled"); rebuild_menu(state, "toggle-show-performance-stats"); return; } diff --git a/src/app/client_state.h b/src/app/client_state.h index 8586e3c..323de80 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -322,8 +322,8 @@ namespace app { int streamFramerate = 30; ///< Preferred stream frame rate in frames per second. int streamBitrateKbps = 1000; ///< Preferred stream bitrate in kilobits per second. bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. - bool showPerformanceStats = false; ///< True when the streaming overlay should remain visible over decoded video. - bool playAudioOnXbox = false; ///< True when the Xbox should decode and play streamed audio locally. + bool showPerformanceStats = false; ///< True when stream telemetry should be shown after streaming ends. + bool playAudioOnXbox = true; ///< True when the Xbox should decode and play streamed audio locally. bool dirty = false; ///< True when persisted TOML-backed settings changed and should be saved. std::vector savedFiles; ///< Saved-file catalog shown on the reset settings page. bool savedFilesDirty = true; ///< True when the saved-file catalog should be refreshed. diff --git a/src/app/settings_storage.cpp b/src/app/settings_storage.cpp index 915f504..ffa244c 100644 --- a/src/app/settings_storage.cpp +++ b/src/app/settings_storage.cpp @@ -348,6 +348,7 @@ namespace { content += std::string("bitrate_kbps = ") + std::to_string(settings.streamBitrateKbps) + "\n"; content += std::string("play_audio_on_pc = ") + (settings.playAudioOnPc ? "true" : "false") + "\n"; content += std::string("play_audio_on_xbox = ") + (settings.playAudioOnXbox ? "true" : "false") + "\n"; + content += "# Show stream telemetry after streaming ends.\n"; content += std::string("show_performance_stats = ") + (settings.showPerformanceStats ? "true" : "false") + "\n"; return content; } diff --git a/src/app/settings_storage.h b/src/app/settings_storage.h index a0c0b95..c322b6f 100644 --- a/src/app/settings_storage.h +++ b/src/app/settings_storage.h @@ -25,8 +25,8 @@ namespace app { int streamFramerate = 30; ///< Preferred stream frame rate in frames per second. int streamBitrateKbps = 1000; ///< Preferred stream bitrate in kilobits per second. bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. - bool showPerformanceStats = false; ///< True when the streaming overlay should remain visible over decoded video. - bool playAudioOnXbox = false; ///< True when the Xbox should decode and play streamed audio locally. + bool showPerformanceStats = false; ///< True when stream telemetry should be shown after streaming ends. + bool playAudioOnXbox = true; ///< True when the Xbox should decode and play streamed audio locally. }; /** diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index f5834d3..89ea0cb 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -59,6 +59,7 @@ namespace { constexpr Uint32 STREAM_FRAME_DELAY_MILLISECONDS = 16U; constexpr Uint32 STREAM_PRESENT_POLL_MILLISECONDS = 2U; constexpr Uint32 STREAM_INPUT_POLL_MILLISECONDS = 4U; + constexpr Uint32 STREAM_END_STATS_POLL_MILLISECONDS = 50U; constexpr Uint32 STREAM_VIDEO_IDLE_IDR_MILLISECONDS = 1500U; constexpr Uint32 STREAM_VIDEO_IDLE_IDR_COOLDOWN_MILLISECONDS = 2000U; constexpr int DEFAULT_STREAM_FPS = 30; @@ -901,6 +902,18 @@ namespace { return rendered; } + void render_text_lines(SDL_Renderer *renderer, TTF_Font *font, const std::vector &lines, int screenWidth, int *cursorY) { + if (cursorY == nullptr) { + return; + } + + for (const std::string &line : lines) { + int drawnHeight = 0; + render_text_line(renderer, font, line, {{TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, 28, *cursorY, std::max(1, screenWidth - 56), &drawnHeight}); + *cursorY += drawnHeight + 6; + } + } + std::string build_stage_status_line(const StreamConnectionState &connectionState) { if (const int failedStage = connectionState.failedStage.load(); failedStage != STAGE_NONE) { return std::string("Connection failed during ") + LiGetStageName(failedStage) + " (error " + std::to_string(connectionState.failedCode.load()) + ")"; @@ -1198,7 +1211,6 @@ namespace { const StreamStartContext &context, const StreamConnectionState &connectionState, streaming::FfmpegStreamBackend *mediaBackend, - bool showPerformanceStats, StreamUiResources *resources ) { if (resources == nullptr || resources->renderer == nullptr || resources->titleFont == nullptr || resources->bodyFont == nullptr) { @@ -1211,7 +1223,7 @@ namespace { const bool hasDecodedVideo = mediaBackend != nullptr && mediaBackend->has_decoded_video(); #ifdef NXDK - if (hasDecodedVideo && !showPerformanceStats && mediaBackend->render_latest_video_frame(resources->renderer, screenWidth, screenHeight, true)) { + if (hasDecodedVideo && mediaBackend->render_latest_video_frame(resources->renderer, screenWidth, screenHeight, true)) { return true; } #endif @@ -1223,14 +1235,7 @@ namespace { SDL_RenderClear(resources->renderer); const bool renderedVideo = hasDecodedVideo && mediaBackend->render_latest_video_frame(resources->renderer, screenWidth, screenHeight, false); - if (renderedVideo && showPerformanceStats) { - SDL_SetRenderDrawBlendMode(resources->renderer, SDL_BLENDMODE_BLEND); - SDL_SetRenderDrawColor(resources->renderer, 0x00, 0x00, 0x00, 0x90); - const SDL_Rect overlayBackground {18, 18, std::max(1, screenWidth - 36), std::max(1, std::min(screenHeight - 36, 220))}; - SDL_RenderFillRect(resources->renderer, &overlayBackground); - } - - if (renderedVideo && !showPerformanceStats) { + if (renderedVideo) { SDL_RenderPresent(resources->renderer); return true; } @@ -1253,29 +1258,109 @@ namespace { lines.emplace(lines.begin() + 5, "Waiting for the first decoded video frame and audio output."); } - if (showPerformanceStats) { - for (const std::string &line : streaming::build_stats_overlay_lines(sample_stream_statistics(context, connectionState))) { - lines.push_back(line); - } + render_text_lines(resources->renderer, resources->bodyFont, lines, screenWidth, &cursorY); + + SDL_RenderPresent(resources->renderer); + return true; + } + + bool render_stream_end_statistics_frame( + const app::HostRecord &host, + const app::HostAppRecord &app, + const StreamStartContext &context, + const streaming::StreamStatisticsSnapshot &statisticsSnapshot, + const std::string &finalMessage, + StreamUiResources *resources + ) { + if (resources == nullptr || resources->renderer == nullptr || resources->titleFont == nullptr || resources->bodyFont == nullptr) { + return false; } - for (const std::string &line : lines) { - int drawnHeight = 0; - render_text_line(resources->renderer, resources->bodyFont, line, {{TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, 28, cursorY, std::max(1, screenWidth - 56), &drawnHeight}); - cursorY += drawnHeight + 6; + int screenWidth = 0; + SDL_GetRendererOutputSize(resources->renderer, &screenWidth, nullptr); + + SDL_SetRenderDrawColor(resources->renderer, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xFF); + SDL_RenderClear(resources->renderer); + + int cursorY = 28; + int titleHeight = 0; + render_text_line(resources->renderer, resources->titleFont, "Stream Summary", {{ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, 28, cursorY, std::max(1, screenWidth - 56), &titleHeight}); + cursorY += titleHeight + 8; + + std::vector lines = { + finalMessage, + std::string("Host: ") + host.displayName + " (" + context.address + ")", + std::string("App: ") + app.name, + }; + for (const std::string &line : streaming::build_stats_overlay_lines(statisticsSnapshot)) { + lines.push_back(line); } + lines.emplace_back("Press any button to return."); + + render_text_lines(resources->renderer, resources->bodyFont, lines, screenWidth, &cursorY); SDL_RenderPresent(resources->renderer); return true; } + bool should_close_stream_end_statistics(const SDL_Event &event, StreamUiResources *resources) { + if (event.type == SDL_CONTROLLERDEVICEADDED) { + const SDL_ControllerDeviceEvent controllerEvent = copy_controller_device_event(event); + open_controller_if_needed(resources, controllerEvent.which); + return false; + } + if (event.type == SDL_CONTROLLERDEVICEREMOVED) { + const SDL_ControllerDeviceEvent controllerEvent = copy_controller_device_event(event); + close_controller_if_removed(resources, controllerEvent.which); + return false; + } + return event.type == SDL_QUIT || event.type == SDL_KEYDOWN || event.type == SDL_MOUSEBUTTONDOWN; + } + + bool controller_snapshot_has_button_press(const ControllerSnapshot &snapshot) { + return snapshot.buttonFlags != 0 || snapshot.leftTrigger > 0 || snapshot.rightTrigger > 0; + } + + void show_stream_end_statistics( + const app::HostRecord &host, + const app::HostAppRecord &app, + const StreamStartContext &context, + const streaming::StreamStatisticsSnapshot &statisticsSnapshot, + const std::string &finalMessage, + StreamUiResources *resources + ) { + if (!render_stream_end_statistics_frame(host, app, context, statisticsSnapshot, finalMessage, resources)) { + return; + } + + bool initialControllerPresent = false; + const ControllerSnapshot initialSnapshot = read_controller_snapshot(resources, &initialControllerPresent); + bool controllerReadyForPress = !initialControllerPresent || !controller_snapshot_has_button_press(initialSnapshot); + + for (;;) { + SDL_Event event {}; + while (SDL_PollEvent(&event) != 0) { + if (should_close_stream_end_statistics(event, resources)) { + return; + } + } + bool controllerPresent = false; + if (const ControllerSnapshot snapshot = read_controller_snapshot(resources, &controllerPresent); !controllerPresent || !controller_snapshot_has_button_press(snapshot)) { + controllerReadyForPress = true; + } else if (controllerReadyForPress) { + return; + } + SDL_Delay(STREAM_END_STATS_POLL_MILLISECONDS); + } + } + bool run_stream_start_attempt(const StreamStartAttemptContext &attempt) { if (SDL_Thread *startThread = SDL_CreateThread(run_stream_start_thread, "start-stream", &attempt.startContext); startThread != nullptr) { Uint32 exitComboActivatedTick = 0U; while (!attempt.connectionState.startCompleted.load() && !attempt.connectionState.stopRequested.load()) { pump_stream_events(&attempt.resources); update_stream_exit_combo(attempt.resources.controller, &exitComboActivatedTick, &attempt.connectionState); - render_stream_frame(attempt.host, attempt.app, attempt.startContext, attempt.connectionState, &attempt.resources.mediaBackend, true, &attempt.resources); + render_stream_frame(attempt.host, attempt.app, attempt.startContext, attempt.connectionState, &attempt.resources.mediaBackend, &attempt.resources); if (attempt.connectionState.stopRequested.load()) { LiInterruptConnection(); } @@ -1341,19 +1426,18 @@ namespace { void render_active_stream_frame_if_needed(const ActiveStreamLoopContext &loopContext, Uint32 *lastIdleVideoIdrRequestTick) { const bool hasDecodedVideo = loopContext.resources.mediaBackend.has_decoded_video(); - if (const bool shouldRenderFrame = loopContext.settings.showPerformanceStats || !hasDecodedVideo || loopContext.resources.mediaBackend.has_unrendered_video_frame(); shouldRenderFrame) { + if (const bool shouldRenderFrame = !hasDecodedVideo || loopContext.resources.mediaBackend.has_unrendered_video_frame(); shouldRenderFrame) { render_stream_frame( loopContext.host, loopContext.app, loopContext.startContext, loopContext.connectionState, &loopContext.resources.mediaBackend, - loopContext.settings.showPerformanceStats, &loopContext.resources ); } request_idle_video_refresh_if_needed(&loopContext.resources.mediaBackend, lastIdleVideoIdrRequestTick); - SDL_Delay(hasDecodedVideo && !loopContext.settings.showPerformanceStats ? STREAM_PRESENT_POLL_MILLISECONDS : STREAM_FRAME_DELAY_MILLISECONDS); + SDL_Delay(hasDecodedVideo ? STREAM_PRESENT_POLL_MILLISECONDS : STREAM_FRAME_DELAY_MILLISECONDS); } void wait_for_stream_input_thread(SDL_Thread *inputThread, StreamInputThreadState *inputThreadState) { @@ -1541,12 +1625,16 @@ namespace streaming { logging::info("stream", std::string(launchResult.resumedSession ? "Resumed stream for " : "Launched stream for ") + app.name + " on " + host.displayName); run_active_stream_loop({settings, host, app, startContext, connectionState, resources}); + const streaming::StreamStatisticsSnapshot finalStatistics = sample_stream_statistics(startContext, connectionState); LiStopConnection(); active_connection_state() = nullptr; const std::string finalMessage = describe_session_end(connectionState, app.name); logging::info("stream", finalMessage); startup::log_memory_statistics(); + if (settings.showPerformanceStats) { + show_stream_end_statistics(host, app, startContext, finalStatistics, finalMessage, &resources); + } close_stream_ui_resources(&resources); assign_status_message(statusMessage, finalMessage); return true; diff --git a/src/streaming/session.h b/src/streaming/session.h index 16de502..a653ca7 100644 --- a/src/streaming/session.h +++ b/src/streaming/session.h @@ -28,7 +28,7 @@ namespace streaming { * * @param window Shared SDL window reused from the shell. * @param videoMode Active Xbox video mode. - * @param settings Active shell settings that control stream resolution, bitrate, frame rate, host audio playback, and the optional stats overlay. + * @param settings Active shell settings that control stream resolution, bitrate, frame rate, host audio playback, and the optional end-of-stream stats summary. * @param host Selected paired host. * @param app Selected host app. * @param clientIdentity Paired client identity used for authenticated launch requests. diff --git a/src/streaming/stats_overlay.cpp b/src/streaming/stats_overlay.cpp index c016bfa..c3979ae 100644 --- a/src/streaming/stats_overlay.cpp +++ b/src/streaming/stats_overlay.cpp @@ -1,6 +1,6 @@ /** * @file src/streaming/stats_overlay.cpp - * @brief Implements the streaming statistics overlay. + * @brief Implements streaming statistics text formatting. */ // class header include #include "src/streaming/stats_overlay.h" diff --git a/src/streaming/stats_overlay.h b/src/streaming/stats_overlay.h index cc2dcd8..2ece177 100644 --- a/src/streaming/stats_overlay.h +++ b/src/streaming/stats_overlay.h @@ -11,7 +11,7 @@ namespace streaming { /** - * @brief Snapshot of stream telemetry shown in the on-screen stats overlay. + * @brief Snapshot of stream telemetry shown in diagnostics or stream summaries. */ struct StreamStatisticsSnapshot { int width; ///< Stream width in pixels. @@ -29,7 +29,7 @@ namespace streaming { }; /** - * @brief Build text rows for the streaming statistics overlay. + * @brief Build text rows for streaming statistics displays. * * Negative metric values are treated as unavailable and omitted from the * corresponding row. diff --git a/tests/unit/app/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp index fc27ca3..e499310 100644 --- a/tests/unit/app/settings_storage_test.cpp +++ b/tests/unit/app/settings_storage_test.cpp @@ -117,7 +117,7 @@ namespace { EXPECT_EQ(loadResult.settings.streamBitrateKbps, 1000); EXPECT_FALSE(loadResult.settings.playAudioOnPc); EXPECT_FALSE(loadResult.settings.showPerformanceStats); - EXPECT_FALSE(loadResult.settings.playAudioOnXbox); + EXPECT_TRUE(loadResult.settings.playAudioOnXbox); } TEST_F(SettingsStorageTest, InvalidValuesFallBackToDefaultsWithWarnings) { @@ -147,7 +147,7 @@ namespace { EXPECT_FALSE(loadResult.settings.preferredVideoModeSet); EXPECT_EQ(loadResult.settings.streamFramerate, 30); EXPECT_FALSE(loadResult.settings.playAudioOnPc); - EXPECT_FALSE(loadResult.settings.playAudioOnXbox); + EXPECT_TRUE(loadResult.settings.playAudioOnXbox); } TEST_F(SettingsStorageTest, LegacyLoggingKeyLoadsAndRequestsCleanup) { From 8206c6a09d366b5c7a95f9842c9ed095e49da664 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 21:29:30 -0400 Subject: [PATCH 18/19] Add unit tests for settings and stats overlay Add tests to increase coverage for settings UI, storage defaults, and stats formatting: - tests/unit/app/client_state_test.cpp: add DisplaySettingsCanToggleXboxAudioAndEndStreamStats to verify toggling "Play Audio on Xbox" and "Show End Stream Stats" updates state, status messages, and persistence flags. - tests/unit/app/settings_storage_test.cpp: include playAudioOnXbox in the saved defaults and assert loaded value is false. - tests/unit/streaming/stats_overlay_test.cpp: add FormatsPartiallyAvailableMetricGroups to validate overlay line formatting when some metrics are missing. These tests ensure toggles, default persistence, and stats overlay formatting behave correctly with partial data. --- tests/unit/app/client_state_test.cpp | 34 +++++++++++++++++++++ tests/unit/app/settings_storage_test.cpp | 2 ++ tests/unit/streaming/stats_overlay_test.cpp | 26 ++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index 962fe1a..a8388c4 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -1035,6 +1035,40 @@ namespace { EXPECT_EQ(state.shell.statusMessage, "Stream resolution set to 720x480"); } + TEST(ClientStateTest, DisplaySettingsCanToggleXboxAudioAndEndStreamStats) { + app::ClientState state = app::create_initial_state(); + ASSERT_TRUE(state.settings.playAudioOnXbox); + ASSERT_FALSE(state.settings.showPerformanceStats); + + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.settings.selectedCategory, app::SettingsCategory::display); + ASSERT_EQ(state.settings.focusArea, app::SettingsFocusArea::options); + + ASSERT_TRUE(state.detailMenu.select_item_by_id("toggle-play-audio-on-xbox")); + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(update.navigation.activatedItemId, "toggle-play-audio-on-xbox"); + EXPECT_TRUE(update.persistence.settingsChanged); + EXPECT_FALSE(state.settings.playAudioOnXbox); + EXPECT_EQ(state.shell.statusMessage, "Play audio on Xbox disabled"); + ASSERT_NE(state.detailMenu.selected_item(), nullptr); + EXPECT_EQ(state.detailMenu.selected_item()->label, "Play Audio on Xbox: Off"); + + ASSERT_TRUE(state.detailMenu.select_item_by_id("toggle-show-performance-stats")); + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(update.navigation.activatedItemId, "toggle-show-performance-stats"); + EXPECT_TRUE(update.persistence.settingsChanged); + EXPECT_TRUE(state.settings.showPerformanceStats); + EXPECT_EQ(state.shell.statusMessage, "End stream performance stats enabled"); + ASSERT_NE(state.detailMenu.selected_item(), nullptr); + EXPECT_EQ(state.detailMenu.selected_item()->label, "Show End Stream Stats: On"); + } + TEST(ClientStateTest, ConfirmationModalCanBeCancelledWithoutRequestingPersistenceChanges) { app::ClientState state = app::create_initial_state(); app::handle_command(state, input::UiCommand::move_left); diff --git a/tests/unit/app/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp index e499310..07d1e09 100644 --- a/tests/unit/app/settings_storage_test.cpp +++ b/tests/unit/app/settings_storage_test.cpp @@ -86,6 +86,7 @@ namespace { 500, false, false, + false, }; const app::SaveAppSettingsResult saveResult = app::save_app_settings(savedSettings, settingsPath); @@ -101,6 +102,7 @@ namespace { EXPECT_EQ(loadResult.settings.preferredVideoMode.refresh, 60); EXPECT_EQ(loadResult.settings.streamFramerate, 15); EXPECT_EQ(loadResult.settings.streamBitrateKbps, 500); + EXPECT_FALSE(loadResult.settings.playAudioOnXbox); } TEST_F(SettingsStorageTest, MissingFilesReturnDefaultsWithoutWarnings) { diff --git a/tests/unit/streaming/stats_overlay_test.cpp b/tests/unit/streaming/stats_overlay_test.cpp index fa82b56..95fec91 100644 --- a/tests/unit/streaming/stats_overlay_test.cpp +++ b/tests/unit/streaming/stats_overlay_test.cpp @@ -58,4 +58,30 @@ namespace { EXPECT_EQ(lines[1], "Connection: Poor"); } + TEST(StreamStatsOverlayTest, FormatsPartiallyAvailableMetricGroups) { + const streaming::StreamStatisticsSnapshot snapshot { + 720, + 480, + 30, + -1, + 7, + -1, + -1, + 42, + -1, + 2, + -1, + false, + }; + + const std::vector lines = streaming::build_stats_overlay_lines(snapshot); + + ASSERT_EQ(lines.size(), 5U); + EXPECT_EQ(lines[0], "Stream: 720x480 @ 30 FPS"); + EXPECT_EQ(lines[1], "Latency: Host 7 ms"); + EXPECT_EQ(lines[2], "Queues: Audio 42 ms"); + EXPECT_EQ(lines[3], "Video packets: 2 recovered"); + EXPECT_EQ(lines[4], "Connection: Okay"); + } + } // namespace From f215558cd8e54d177d989f515ce83ba7e6d15760 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 23 May 2026 21:41:10 -0400 Subject: [PATCH 19/19] Add 60 FPS stream option and tests Introduce 60 FPS streaming support and related behavior changes. Added DEFAULT_STREAM_FRAMERATE (30) and extended STREAM_FRAMERATE_OPTIONS to include 60; initialize settings.streamFramerate from the default constant and make cycle_stream_framerate fall back to the default when the current value is invalid. Bumped MAX_STREAM_FPS to 60 in the streaming session constants. Updated unit tests to expect the default 30 FPS and added a test that verifies cycling includes 60 FPS and that invalid values reset to the default. --- src/app/client_state.cpp | 7 +++--- src/streaming/session.cpp | 2 +- tests/unit/app/client_state_test.cpp | 35 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index 2eaed05..f164044 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -28,8 +28,9 @@ namespace { constexpr const char *SETTINGS_CATEGORY_PREFIX = "settings-category:"; constexpr std::array ADD_HOST_ADDRESS_KEYPAD_CHARACTERS {'1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0'}; constexpr std::array ADD_HOST_PORT_KEYPAD_CHARACTERS {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}; + constexpr int DEFAULT_STREAM_FRAMERATE = 30; constexpr int DEFAULT_STREAM_BITRATE_KBPS = 1000; - constexpr std::array STREAM_FRAMERATE_OPTIONS {15, 20, 24, 25, 30}; + constexpr std::array STREAM_FRAMERATE_OPTIONS {15, 20, 24, 25, 30, 60}; constexpr std::array STREAM_BITRATE_OPTIONS {500, 750, 1000, 1500, 2000, 2500, 3000, 4000, 5000}; /** @@ -188,7 +189,7 @@ namespace { void cycle_stream_framerate(app::ClientState &state) { const auto current = std::find(STREAM_FRAMERATE_OPTIONS.begin(), STREAM_FRAMERATE_OPTIONS.end(), state.settings.streamFramerate); if (current == STREAM_FRAMERATE_OPTIONS.end()) { - state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS.back(); + state.settings.streamFramerate = DEFAULT_STREAM_FRAMERATE; return; } @@ -1402,7 +1403,7 @@ namespace app { state.settings.logViewerPlacement = LogViewerPlacement::full; state.settings.loggingLevel = logging::LogLevel::none; state.settings.xemuConsoleLoggingLevel = logging::LogLevel::none; - state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS.back(); + state.settings.streamFramerate = DEFAULT_STREAM_FRAMERATE; state.settings.streamBitrateKbps = DEFAULT_STREAM_BITRATE_KBPS; state.settings.playAudioOnPc = false; state.settings.showPerformanceStats = false; diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index 89ea0cb..11fce20 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -65,7 +65,7 @@ namespace { constexpr int DEFAULT_STREAM_FPS = 30; constexpr int DEFAULT_STREAM_BITRATE_KBPS = 1000; constexpr int MIN_STREAM_FPS = 15; - constexpr int MAX_STREAM_FPS = 30; + constexpr int MAX_STREAM_FPS = 60; constexpr int MIN_STREAM_BITRATE_KBPS = 250; constexpr int MAX_STREAM_BITRATE_KBPS = 50000; constexpr int DEFAULT_PACKET_SIZE = 1024; diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index a8388c4..d53ceb3 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -24,6 +24,7 @@ namespace { EXPECT_FALSE(state.shell.shouldExit); EXPECT_FALSE(state.hosts.dirty); EXPECT_EQ(state.settings.loggingLevel, logging::LogLevel::none); + EXPECT_EQ(state.settings.streamFramerate, 30); } TEST(ClientStateTest, ReplacesHostsFromPersistenceWithoutMarkingThemDirty) { @@ -1035,6 +1036,40 @@ namespace { EXPECT_EQ(state.shell.statusMessage, "Stream resolution set to 720x480"); } + TEST(ClientStateTest, DisplaySettingsCanCycleStreamFramerateThroughSixtyFps) { + app::ClientState state = app::create_initial_state(); + + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.settings.selectedCategory, app::SettingsCategory::display); + ASSERT_EQ(state.settings.focusArea, app::SettingsFocusArea::options); + + ASSERT_TRUE(state.detailMenu.select_item_by_id("cycle-stream-framerate")); + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(update.navigation.activatedItemId, "cycle-stream-framerate"); + EXPECT_TRUE(update.persistence.settingsChanged); + EXPECT_EQ(state.settings.streamFramerate, 60); + EXPECT_EQ(state.shell.statusMessage, "Stream frame rate set to 60 FPS"); + ASSERT_NE(state.detailMenu.selected_item(), nullptr); + EXPECT_EQ(state.detailMenu.selected_item()->label, "Stream Frame Rate: 60 FPS"); + + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(update.navigation.activatedItemId, "cycle-stream-framerate"); + EXPECT_EQ(state.settings.streamFramerate, 15); + EXPECT_EQ(state.shell.statusMessage, "Stream frame rate set to 15 FPS"); + + state.settings.streamFramerate = 999; + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(update.navigation.activatedItemId, "cycle-stream-framerate"); + EXPECT_EQ(state.settings.streamFramerate, 30); + EXPECT_EQ(state.shell.statusMessage, "Stream frame rate set to 30 FPS"); + } + TEST(ClientStateTest, DisplaySettingsCanToggleXboxAudioAndEndStreamStats) { app::ClientState state = app::create_initial_state(); ASSERT_TRUE(state.settings.playAudioOnXbox);