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/.github/copilot-instructions.md b/AGENTS.md similarity index 100% rename from .github/copilot-instructions.md rename to AGENTS.md 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/README.md b/README.md index b6d866b..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) @@ -229,14 +226,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/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 dcc5024..dc6cd88 100644 --- a/cmake/moonlight-dependencies.cmake +++ b/cmake/moonlight-dependencies.cmake @@ -1,5 +1,365 @@ 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() + +# 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 LISTS ARGN) + if(NOT EXISTS "${ffmpeg_support_file}") + message(FATAL_ERROR "Required FFmpeg support file not found: ${ffmpeg_support_file}") + endif() + endforeach() +endfunction() + +# 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 + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE ffmpeg_revision_result + ) + if(NOT ffmpeg_revision_result EQUAL 0) + 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) + 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(${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) + 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 ${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) + _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}") + + 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}") + _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() + + 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 +422,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 +432,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 +441,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/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() 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..6b50a8e --- /dev/null +++ b/scripts/ffmpeg-nxdk-cc.sh @@ -0,0 +1,87 @@ +#!/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" + +unset MSYS2_ARG_CONV_EXCL + +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 +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_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 + +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..9cc579f --- /dev/null +++ b/scripts/ffmpeg-nxdk-cxx.sh @@ -0,0 +1,91 @@ +#!/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" + +unset MSYS2_ARG_CONV_EXCL + +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 +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_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 + +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..4697569 --- /dev/null +++ b/src/_nxdk_compat/ffmpeg_compat.h @@ -0,0 +1,371 @@ +/** + * @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 + /** @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 + + /** @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..8187fcf --- /dev/null +++ b/src/_nxdk_compat/share.h @@ -0,0 +1,58 @@ +/** + * @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 + /** @brief Shared-open mode that denies no other access. */ + #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..f164044 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -28,6 +28,10 @@ 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, 60}; + 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. @@ -105,7 +109,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, 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 +119,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 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. + */ + 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 detected Xbox video mode. + * + * @param state Current client state containing detected stream-resolution modes. + */ + 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 = DEFAULT_STREAM_FRAMERATE; + 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 +526,42 @@ 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 detected Xbox video modes enabled by the console settings. The selected resolution is requested from the host the next time a stream starts.", + 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-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 End Stream Stats: ") + (state.settings.showPerformanceStats ? "On" : "Off"), + "Toggle the performance summary shown after streaming ends.", + true, + }, }; case app::SettingsCategory::input: return { @@ -1270,6 +1403,11 @@ namespace app { state.settings.logViewerPlacement = LogViewerPlacement::full; state.settings.loggingLevel = logging::LogLevel::none; state.settings.xemuConsoleLoggingLevel = logging::LogLevel::none; + state.settings.streamFramerate = DEFAULT_STREAM_FRAMERATE; + state.settings.streamBitrateKbps = DEFAULT_STREAM_BITRATE_KBPS; + state.settings.playAudioOnPc = false; + state.settings.showPerformanceStats = false; + state.settings.playAudioOnXbox = true; state.settings.dirty = false; state.settings.savedFilesDirty = true; return state; @@ -1834,6 +1972,54 @@ 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-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; + update->persistence.settingsChanged = true; + state.shell.statusMessage = std::string("End stream performance stats ") + (state.settings.showPerformanceStats ? "enabled" : "disabled"); + rebuild_menu(state, "toggle-show-performance-stats"); + return; + } if (detailUpdate.activatedItemId == "factory-reset") { open_confirmation( state, @@ -2082,8 +2268,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..323de80 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,14 @@ 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; ///< 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. + 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 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. @@ -361,6 +372,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..ffa244c 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.has_value()) { + 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.has_value()) { + 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,21 @@ 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("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; } @@ -308,6 +382,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 == "play_audio_on_xbox" || 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 +416,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 +483,16 @@ 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"]["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 21d8fe2..c322b6f 100644 --- a/src/app/settings_storage.h +++ b/src/app/settings_storage.h @@ -20,6 +20,13 @@ 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 = 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 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/logging/logger.cpp b/src/logging/logger.cpp index ffe3cb7..ab9bb47 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::scoped_lock lock(mutex_); minimumLevel_ = minimumLevel; } LogLevel Logger::minimum_level() const { + std::scoped_lock lock(mutex_); return minimumLevel_; } void Logger::set_file_sink(LogSink sink) { + std::scoped_lock lock(mutex_); fileSink_ = std::move(sink); } void Logger::set_file_minimum_level(LogLevel minimumLevel) { + std::scoped_lock lock(mutex_); fileMinimumLevel_ = minimumLevel; } LogLevel Logger::file_minimum_level() const { + std::scoped_lock lock(mutex_); return fileMinimumLevel_; } void Logger::set_startup_debug_enabled(bool enabled) { + std::scoped_lock lock(mutex_); startupDebugEnabled_ = enabled; } bool Logger::startup_debug_enabled() const { + std::scoped_lock lock(mutex_); return startupDebugEnabled_; } void Logger::set_debugger_console_minimum_level(LogLevel minimumLevel) { + std::scoped_lock lock(mutex_); debuggerConsoleMinimumLevel_ = minimumLevel; } LogLevel Logger::debugger_console_minimum_level() const { + std::scoped_lock lock(mutex_); return debuggerConsoleMinimumLevel_; } bool Logger::should_log(LogLevel level) const { + std::scoped_lock 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::scoped_lock lock(mutex_); + if (!should_log_unlocked(level)) { return false; } @@ -421,6 +436,7 @@ namespace logging { } void Logger::add_sink(LogSink sink, LogLevel minimumLevel) { + std::scoped_lock 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::scoped_lock 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/main.cpp b/src/main.cpp index 0a55582..deabc1b 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,54 @@ 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.playAudioOnXbox = settings.playAudioOnXbox; 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 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, 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; + } + + 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; + } + + 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; + } + void load_persisted_settings(app::ClientState &state) { const app::LoadAppSettingsResult loadResult = app::load_app_settings(); apply_persisted_settings(state, loadResult.settings); @@ -185,9 +231,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, encoderSettings); const VIDEO_MODE &bestVideoMode = videoModeSelection.bestVideoMode; debug_print_video_mode_selection(videoModeSelection); startup::log_memory_statistics(); @@ -200,7 +248,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..ac4a2b4 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,67 @@ 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; + 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)); + } + 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; + 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); + } + + 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 +2422,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 +2440,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 +2487,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 +2680,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 +2754,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..7d0548b 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -8,14 +8,49 @@ // 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 {{ + {640, 480}, + {720, 480}, + {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}; + } + + 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; + if (const int rightArea = right.width * right.height; 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) { @@ -58,6 +93,58 @@ 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; + } + + 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 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) { VideoModeSelection selection {}; diff --git a/src/startup/video_mode.h b/src/startup/video_mode.h index 0716f7b..f6911e6 100644 --- a/src/startup/video_mode.h +++ b/src/startup/video_mode.h @@ -41,6 +41,50 @@ namespace startup { */ VIDEO_MODE choose_best_video_mode(const std::vector &availableVideoModes); + /** + * @brief Return canonical Xbox video modes usable as stream-resolution choices. + * + * 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 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 Filter detected stream-resolution modes through display-aspect encoder settings. + * + * `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 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/ffmpeg_stream_backend.cpp b/src/streaming/ffmpeg_stream_backend.cpp new file mode 100644 index 0000000..e6087e7 --- /dev/null +++ b/src/streaming/ffmpeg_stream_backend.cpp @@ -0,0 +1,1233 @@ +/** + * @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" + +#ifdef NXDK + #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 { + + /** + * @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; + + /** + * @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. + * + * @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(MoonlightCallbackContext *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; + } + if (message.find("No accelerated colorspace conversion found") != std::string::npos) { + 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(ffmpeg_logging_once(), []() { + av_log_set_level(AV_LOG_ERROR); + 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, 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; + } + + active_video_backend() = backend; + return backend->setup_video_decoder(videoFormat, width, height, redrawRate, drFlags); + } + + void on_video_start() { + if (streaming::FfmpegStreamBackend *backend = active_video_backend(); backend != nullptr) { + backend->start_video_decoder(); + } + } + + void on_video_stop() { + if (streaming::FfmpegStreamBackend *backend = active_video_backend(); backend != nullptr) { + backend->stop_video_decoder(); + } + } + + void on_video_cleanup() { + if (streaming::FfmpegStreamBackend *backend = active_video_backend(); backend != nullptr) { + backend->cleanup_video_decoder(); + active_video_backend() = nullptr; + } + } + + 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; + } + + active_audio_backend() = backend; + return backend->initialize_audio_decoder(audioConfiguration, opusConfig, arFlags); + } + + void on_audio_start() { + if (streaming::FfmpegStreamBackend *backend = active_audio_backend(); backend != nullptr) { + backend->start_audio_playback(); + } + } + + void on_audio_stop() { + if (streaming::FfmpegStreamBackend *backend = active_audio_backend(); backend != nullptr) { + backend->stop_audio_playback(); + } + } + + void on_audio_cleanup() { + 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) { // 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); + } + } + +} // namespace + +namespace streaming { + + FfmpegStreamBackend::~FfmpegStreamBackend() { + shutdown(); + } + + void FfmpegStreamBackend::initialize_callbacks(DECODER_RENDERER_CALLBACKS *videoCallbacks, AUDIO_RENDERER_CALLBACKS *audioCallbacks) const { + 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 = nullptr; + videoCallbacks->capabilities = CAPABILITY_PULL_RENDERER; + } + + 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 = 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(); + } + + bool FfmpegStreamBackend::has_decoded_video() const { + return video_.hasFrame.load(); + } + + /** + * @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; + } + + 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 { + 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; + } + + bool FfmpegStreamBackend::render_latest_video_frame(SDL_Renderer *renderer, int screenWidth, int screenHeight, bool allowDirectFramebuffer) { + if (renderer == nullptr || !video_.hasFrame.load()) { + return false; + } + + bool textureNeedsUpload = false; + 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; + textureNeedsUpload = true; + } + } + + if (video_.renderFrame.width <= 0 || video_.renderFrame.height <= 0) { + 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); + video_.texture = nullptr; + } + + 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 = video_.renderFrame.width; + video_.textureHeight = video_.renderFrame.height; + 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) { + 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, int drFlags) { + (void) width; + (void) height; + (void) redrawRate; + (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; + 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; + 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; + } + + return 0; + } + + 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; + } + 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; + } + if (video_.presentScaleContext != nullptr) { + sws_freeContext(video_.presentScaleContext); + video_.presentScaleContext = nullptr; + } + + video_.convertedBuffer.clear(); + video_.packetBuffer.clear(); + video_.renderFrame = LatestVideoFrame {}; + video_.decodeFrame = LatestVideoFrame {}; + { + std::scoped_lock lock(video_.frameMutex); + video_.latestFrame = LatestVideoFrame {}; + video_.latestFrameVersion = 0; + } + 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); + video_.decodedFrameCount.store(0); + video_.droppedDecodeUnitCount.store(0); + video_.lastDecodeQueueUs.store(0); + video_.lastReceiveAgeUs.store(0); + video_.lastDecodedFrameUs.store(0); + video_.lastDecodeFrameNumber.store(0); + } + + 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; + } + + int FfmpegStreamBackend::run_video_decode_thread() { + if (SDL_SetThreadPriority(SDL_THREAD_PRIORITY_HIGH) != 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; + } + + int decodeStatus = DR_OK; + if (!video_.decoderStopRequested.load() && decodeUnit != nullptr) { + decodeStatus = prepare_video_decode_unit(&frameHandle, &decodeUnit); + if (decodeStatus == DR_OK) { + 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; + } + + 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; + + 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) + + " | newest_frame=" + std::to_string(newestDecodeUnit != nullptr ? newestDecodeUnit->frameNumber : -1) + + (newestDecodeUnit != nullptr && newestDecodeUnit->frameType != FRAME_TYPE_IDR ? " | waiting_for_idr" : "") + ); + } + + return droppedFrames; + } + + 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; + } + + 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::scoped_lock lock(video_.frameMutex); + std::swap(video_.latestFrame, video_.decodeFrame); + ++video_.latestFrameVersion; + video_.publishedFrameVersion.store(video_.latestFrameVersion); + } + + video_.lastDecodedFrameUs.store(PltGetMicroseconds()); + video_.hasFrame.store(true); + video_.decodedFrameCount.fetch_add(1); + 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_letterboxed_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::array sourceData { + video_.renderFrame.yPlane.data(), + video_.renderFrame.uPlane.data(), + video_.renderFrame.vPlane.data(), + nullptr, + }; + const std::array sourceLinesize { + video_.renderFrame.yPitch, + video_.renderFrame.uPitch, + video_.renderFrame.vPitch, + 0, + }; + std::array destinationData { + framebuffer + (static_cast(destination.y) * static_cast(framebufferPitch)) + (static_cast(destination.x) * static_cast(bytesPerPixel)), + nullptr, + nullptr, + nullptr, + }; + const std::array destinationLinesize { + framebufferPitch, + 0, + 0, + 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; + } + + 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); + 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; + } + + const 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; + 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; + } + + 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; + } + + 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; + } + + 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; + 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( + "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) + ); + } + + int sendResult = avcodec_send_packet(video_.codecContext, video_.packet); + if (sendResult == AVERROR(EAGAIN)) { + 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; + } + 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; + } + + 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::initialize_audio_decoder(int audioConfiguration, const OPUS_MULTISTREAM_CONFIGURATION *opusConfig, int arFlags) { + (void) arFlags; + + 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; + } + + 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 && 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; + } + + 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; + } + + 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_.outputBuffer.clear(); + 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; + if (const bool needsReconfigure = audio_.resampleContext == nullptr || audio_.resampleInputSampleRate != inputSampleRate || audio_.resampleInputSampleFormat != inputSampleFormat || audio_.resampleInputChannelCount != inputChannelCount; !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(const char *sampleData, int sampleLength) { + if (!audioPlaybackEnabled_.load()) { + return; + } + + if (audio_.codecContext == nullptr || audio_.packet == nullptr || audio_.decodedFrame == nullptr || audio_.deviceId == 0) { + return; + } + + if (sampleData == nullptr || sampleLength <= 0) { + return; + } + + 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; + } + + 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; + } + + audio_.outputBuffer.resize(static_cast(outputBufferSize)); + std::array outputData {audio_.outputBuffer.data()}; + const int convertedSamples = swr_convert( + audio_.resampleContext, + outputData.data(), + 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)); + 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) { + 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..07a8fe9 --- /dev/null +++ b/src/streaming/ffmpeg_stream_backend.h @@ -0,0 +1,321 @@ +/** + * @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 Opaque SDL thread context payload. + */ + using SdlThreadContext = void; + + /** + * @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) const; + + /** + * @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. + */ + 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. + * @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 allowDirectFramebuffer = true); + + /** + * @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 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 Return how long it has been since FFmpeg published a decoded video frame. + * + * @param nowMicroseconds Current platform monotonic time. + * @return Milliseconds since the last decoded frame, or zero before a frame is published. + */ + std::uint64_t milliseconds_since_last_decoded_video_frame(std::uint64_t nowMicroseconds) 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 drFlags Moonlight decoder flags. + * @return Zero on success. + */ + int setup_video_decoder(int videoFormat, int width, int height, int redrawRate, 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 Initialize the FFmpeg Opus decoder and SDL playback device. + * + * @param audioConfiguration Negotiated Moonlight audio configuration. + * @param opusConfig Negotiated Opus multistream parameters. + * @param arFlags Moonlight audio renderer flags. + * @return Zero on success. + */ + int initialize_audio_decoder(int audioConfiguration, const OPUS_MULTISTREAM_CONFIGURATION *opusConfig, 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(const 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(SdlThreadContext *context); + + /** + * @brief Pull Moonlight decode units and feed them into FFmpeg. + * + * @return Zero when the worker exits normally. + */ + 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 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. + * + * @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(const 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. + */ + 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 { // NOSONAR(cpp:S1820) Owned video pipeline state intentionally groups FFmpeg, SDL, queue, and metric lifetimes. + AVCodecContext *codecContext = nullptr; + SwsContext *scaleContext = nullptr; + SwsContext *presentScaleContext = nullptr; + AVFrame *decodedFrame = nullptr; + 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; + 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; + std::atomic submittedDecodeUnitCount = 0; + std::atomic decodedFrameCount = 0; + std::atomic droppedDecodeUnitCount = 0; + std::atomic lastDecodeQueueUs = 0; + std::atomic lastReceiveAgeUs = 0; + std::atomic lastDecodedFrameUs = 0; + std::atomic lastDecodeFrameNumber = 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 {}; + std::vector outputBuffer; + int resampleInputSampleRate = 0; + int resampleInputSampleFormat = -1; + int resampleInputChannelCount = 0; + std::atomic deviceStarted = false; + std::atomic queuedAudioBytes = 0; + }; + + VideoState video_ {}; + AudioState audio_ {}; + std::atomic audioPlaybackEnabled_ = false; + }; + +} // namespace streaming diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp new file mode 100644 index 0000000..11fce20 --- /dev/null +++ b/src/streaming/session.cpp @@ -0,0 +1,1643 @@ +/** + * @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 + +/** + * @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 { + + 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 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; + constexpr int DEFAULT_STREAM_BITRATE_KBPS = 1000; + constexpr int MIN_STREAM_FPS = 15; + 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; + 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 {}; + mutable std::mutex controllerMutex; + 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 StreamInputThreadState { + StreamUiResources *resources = nullptr; + StreamConnectionState *connectionState = nullptr; + std::atomic stopRequested = false; + }; + + 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 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; + int bitrateKbps = DEFAULT_STREAM_BITRATE_KBPS; + int packetSize = DEFAULT_PACKET_SIZE; + int streamingRemotely = STREAM_CFG_AUTO; + }; + + void assign_status_message(std::string *statusMessage, std::string_view message) { + if (statusMessage != nullptr) { + statusMessage->assign(message.data(), message.size()); + } + } + + 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; + }; + + 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) { + if (streamVideoMode.width > 0 && streamVideoMode.height > 0) { + return streamVideoMode; + } + + return fallbackVideoMode; + } + + 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. + * + * @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"; + } + } + + /** + * @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. + * + * @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 auto textCharacter = static_cast(text[index]); + const auto prefixCharacter = static_cast(prefix[index]); + if (std::tolower(textCharacter) != std::tolower(prefixCharacter)) { + return false; + } + } + + 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::array 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", + }; + + 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) { + 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. + * + * @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; + 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"; + } + + 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); // NOSONAR(cpp:S5281) format is supplied by moonlight-common-c. + 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); // 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)); + } + + /** + * @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::scoped_lock 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::scoped_lock 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::scoped_lock 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(); + { + std::scoped_lock lock(resources->controllerMutex); + 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 std::string_view 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]; // 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, videoMode.width) : 640; + } + + int select_stream_height(const VIDEO_MODE &videoMode) { + return videoMode.height > 0 ? std::max(240, videoMode.height) : 480; + } + + int select_client_refresh_rate_x100(const VIDEO_MODE &videoMode) { + return videoMode.refresh > 0 ? 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 (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 (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 (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 (StreamConnectionState *connectionState = active_connection_state(); connectionState != nullptr) { + connectionState->connectionStarted.store(true); + } + logging::info("stream", "Streaming transport started"); + } + + void on_connection_terminated(int errorCode) { + 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 (StreamConnectionState *connectionState = active_connection_state(); connectionState != nullptr) { + const bool poorConnection = connectionStatus != CONN_STATUS_OKAY; + if (connectionState->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, ...) { // NOSONAR(cpp:S923) moonlight-common-c log callback requires printf-style ellipsis. + 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() || is_high_volume_connection_log(message)) { + return; + } + + append_connection_protocol_message(active_connection_state(), message); + logging::log(connection_log_level(message), "moonlight", message); + } + + int run_stream_start_thread(void *context) { + auto *startContext = static_cast(context); + if (startContext == nullptr || startContext->connectionState == nullptr) { + return -1; + } + + 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, 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(), layout.color, static_cast(layout.maxWidth)); + if (surface == nullptr) { + if (layout.drawnHeight != nullptr) { + *layout.drawnHeight = 0; + } + return false; + } + + SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, surface); + if (texture == nullptr) { + SDL_FreeSurface(surface); + if (layout.drawnHeight != nullptr) { + *layout.drawnHeight = 0; + } + return false; + } + + 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 (layout.drawnHeight != nullptr) { + *layout.drawnHeight = surfaceHeight; + } + 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()) + ")"; + } + 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 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; + } + } + + 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) { + if (resources == nullptr) { + continue; + } + + if (event.type == SDL_CONTROLLERDEVICEADDED) { + const SDL_ControllerDeviceEvent controllerEvent = copy_controller_device_event(event); + open_controller_if_needed(resources, controllerEvent.which); + } else if (event.type == SDL_CONTROLLERDEVICEREMOVED) { + const SDL_ControllerDeviceEvent controllerEvent = copy_controller_device_event(event); + close_controller_if_removed(resources, controllerEvent.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; + if (const bool startPressed = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_START) != 0; !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 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; + if (const bool startPressed = (snapshot.buttonFlags & PLAY_FLAG) != 0; !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::scoped_lock 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; + } + + if (!*arrivalSent) { + LiSendControllerArrivalEvent(0, PRESENT_GAMEPAD_MASK, LI_CTYPE_XBOX, CONTROLLER_BUTTON_CAPABILITIES, CONTROLLER_CAPABILITIES); + *arrivalSent = true; + } + + if (controller_snapshots_match(snapshot, *lastSnapshot)) { + return; + } + + LiSendControllerEvent(snapshot.buttonFlags, snapshot.leftTrigger, snapshot.rightTrigger, snapshot.leftStickX, snapshot.leftStickY, snapshot.rightStickX, snapshot.rightStickY); + *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, + context.streamConfiguration.height, + context.streamConfiguration.fps, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + connectionState.poorConnection.load(), + }; + + uint32_t estimatedRtt = 0; + if (uint32_t estimatedRttVariance = 0; 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; + } + + void request_idle_video_refresh_if_needed(const streaming::FfmpegStreamBackend *mediaBackend, Uint32 *lastRequestTicks) { + if (mediaBackend == nullptr || lastRequestTicks == nullptr || !mediaBackend->has_decoded_video()) { + return; + } + + 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; + } + + 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, + const StreamStartContext &context, + const StreamConnectionState &connectionState, + streaming::FfmpegStreamBackend *mediaBackend, + 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(); +#ifdef NXDK + if (hasDecodedVideo && mediaBackend->render_latest_video_frame(resources->renderer, screenWidth, screenHeight, true)) { + return true; + } +#endif + 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, false); + if (renderedVideo) { + SDL_RenderPresent(resources->renderer); + return true; + } + + 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}); + 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.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."); + } + + 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; + } + + 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, &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 = !hasDecodedVideo || loopContext.resources.mediaBackend.has_unrendered_video_frame(); shouldRenderFrame) { + render_stream_frame( + loopContext.host, + loopContext.app, + loopContext.startContext, + loopContext.connectionState, + &loopContext.resources.mediaBackend, + &loopContext.resources + ); + } + request_idle_video_refresh_if_needed(&loopContext.resources.mediaBackend, lastIdleVideoIdrRequestTick); + SDL_Delay(hasDecodedVideo ? 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()) + ")"; + 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 + ) { + 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, activeStreamVideoMode, &resources, &initializationError)) { + assign_status_message(statusMessage, initializationError); + logging::error("stream", initializationError); + return false; + } + ScopedThreadPriority streamThreadPriority(SDL_THREAD_PRIORITY_HIGH); + + 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); + assign_status_message(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 {}; + 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(activeStreamVideoMode); + launchConfiguration.remoteInputAesKeyHex = hex_encode(remoteInputKey.data(), remoteInputKey.size()); + launchConfiguration.remoteInputAesIvHex = hex_encode(remoteInputIv.data(), remoteInputIv.size()); + + if (std::string launchError; !network::launch_or_resume_stream(hostAddress, httpPort, clientIdentity, launchConfiguration, &launchResult, &launchError)) { + close_stream_ui_resources(&resources); + assign_status_message(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, activeStreamVideoMode, 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) + ); + 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}) + + " | 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; + + bool rtspFallbackAttempted = false; + if (const StreamStartAttemptContext startAttempt {host, app, startContext, connectionState, resources, statusMessage}; !run_stream_start_with_rtsp_fallback(startAttempt, &rtspFallbackAttempted)) { + 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"; + } + active_connection_state() = nullptr; + close_stream_ui_resources(&resources); + assign_status_message(statusMessage, failureMessage); + logging::warn("stream", failureMessage); + return false; + } + + 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; + } + +} // namespace streaming diff --git a/src/streaming/session.h b/src/streaming/session.h new file mode 100644 index 0000000..a653ca7 --- /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 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. + * @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/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/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 1115a43..c427101 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,13 @@ 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, + state.settings.playAudioOnXbox, }; } @@ -4325,6 +4333,8 @@ namespace { (void) threadResult; } + void finalize_shell_tasks(ShellRuntimeState *runtime); + /** * @brief Open the first detected SDL game controller for shell navigation. * @@ -4482,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; } @@ -4531,9 +4540,78 @@ 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 {}; + if (std::string identityError; !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. * + * @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 +4619,8 @@ namespace { * @param runtime Runtime state that owns background tasks and redraw flags. */ void process_shell_command( - const VIDEO_MODE &videoMode, + SDL_Window *window, + const VIDEO_MODE *videoMode, app::ClientState &state, input::UiCommand command, ShellResources *resources, @@ -4570,11 +4649,16 @@ namespace { persist_settings_if_needed(state, update); persist_hosts_if_needed(state, update); + 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, 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) { @@ -4585,19 +4669,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, const 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); @@ -4615,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, 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; } @@ -4633,9 +4717,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()); @@ -4671,8 +4755,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 +4766,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..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) { @@ -978,8 +979,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 +998,112 @@ 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, DisplaySettingsCanCycleXboxVideoModeStreamChoices) { + app::ClientState state = app::create_initial_state(); + state.settings.availableVideoModes = { + 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; + + 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, 720); + EXPECT_EQ(state.settings.preferredVideoMode.height, 480); + 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); + 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); @@ -1037,7 +1143,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..07d1e09 100644 --- a/tests/unit/app/settings_storage_test.cpp +++ b/tests/unit/app/settings_storage_test.cpp @@ -45,6 +45,13 @@ namespace { logging::LogLevel::debug, logging::LogLevel::warning, app::LogViewerPlacement::left, + VIDEO_MODE {1280, 720, 32, 60}, + true, + 24, + 2500, + true, + true, + true, }; const app::SaveAppSettingsResult saveResult = app::save_app_settings(savedSettings, settingsPath); @@ -56,9 +63,48 @@ 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_TRUE(loadResult.settings.playAudioOnXbox); EXPECT_FALSE(loadResult.cleanupRequired); } + TEST_F(SettingsStorageTest, SavesAndLoadsXboxStreamResolutionModes) { + const app::AppSettings savedSettings { + logging::LogLevel::none, + logging::LogLevel::none, + app::LogViewerPlacement::full, + VIDEO_MODE {640, 480, 32, 60}, + true, + 15, + 500, + false, + 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, 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); + EXPECT_EQ(loadResult.settings.streamBitrateKbps, 500); + EXPECT_FALSE(loadResult.settings.playAudioOnXbox); + } + TEST_F(SettingsStorageTest, MissingFilesReturnDefaultsWithoutWarnings) { const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); @@ -68,6 +114,12 @@ 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, 30); + EXPECT_EQ(loadResult.settings.streamBitrateKbps, 1000); + EXPECT_FALSE(loadResult.settings.playAudioOnPc); + EXPECT_FALSE(loadResult.settings.showPerformanceStats); + EXPECT_TRUE(loadResult.settings.playAudioOnXbox); } TEST_F(SettingsStorageTest, InvalidValuesFallBackToDefaultsWithWarnings) { @@ -79,16 +131,25 @@ 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" + "play_audio_on_xbox = \"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, 30); + EXPECT_FALSE(loadResult.settings.playAudioOnPc); + EXPECT_TRUE(loadResult.settings.playAudioOnXbox); } TEST_F(SettingsStorageTest, LegacyLoggingKeyLoadsAndRequestsCleanup) { @@ -135,7 +196,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 +211,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 +224,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/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()); diff --git a/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index e56f946..d23ad90 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" @@ -135,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); @@ -256,8 +275,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 +493,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 +542,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 +584,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 +906,198 @@ 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_unauthenticated_unique_id_request(request, identity, "/serverinfo"); + }, + 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_authenticated_unique_id_request(request, identity, "/serverinfo"); + }, + 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_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"); + 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_authenticated_unique_id_request(request, identity, "/launch"); + EXPECT_EQ(request.address, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + }, + 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..70a02f1 100644 --- a/tests/unit/startup/video_mode_test.cpp +++ b/tests/unit/startup/video_mode_test.cpp @@ -60,4 +60,106 @@ namespace { EXPECT_EQ(bestVideoMode.refresh, 60); } + TEST(VideoModeTest, ExposesOnlyXboxVideoModeStreamChoicesAsFallbacks) { + const std::vector presets = startup::stream_resolution_presets(32, 60); + + 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, 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, 640); + EXPECT_EQ(defaultPreset.height, 480); + EXPECT_EQ(defaultPreset.bpp, 32); + EXPECT_EQ(defaultPreset.refresh, 60); + } + + TEST(VideoModeTest, ChoosesSdXboxModeFor60HzOutputModes) { + const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({640, 480, 32, 60}); + + EXPECT_EQ(defaultPreset.width, 640); + EXPECT_EQ(defaultPreset.height, 480); + EXPECT_EQ(defaultPreset.bpp, 32); + EXPECT_EQ(defaultPreset.refresh, 60); + } + + TEST(VideoModeTest, ChoosesSdXboxModeFor50HzOutputModes) { + const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({640, 480, 32, 50}); + + 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 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 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