diff --git a/.gitmodules b/.gitmodules index c06338e..fede70b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,22 +2,33 @@ path = third-party/doxyconfig url = https://github.com/LizardByte/doxyconfig.git branch = master + shallow = true +[submodule "third-party/ffmpeg"] + path = third-party/ffmpeg + url = https://github.com/FFmpeg/FFmpeg.git + branch = release/8.1 + shallow = true [submodule "third-party/googletest"] path = third-party/googletest url = https://github.com/google/googletest.git + shallow = true [submodule "third-party/moonlight-common-c"] path = third-party/moonlight-common-c url = https://github.com/ReenigneArcher/moonlight-common-c.git branch = nxdk-compat + shallow = true [submodule "third-party/nxdk"] path = third-party/nxdk url = https://github.com/ReenigneArcher/nxdk.git branch = moonlight + shallow = true [submodule "third-party/openssl"] path = third-party/openssl url = https://github.com/openssl/openssl.git branch = OpenSSL_1_1_1-stable + shallow = true [submodule "third-party/tomlplusplus"] path = third-party/tomlplusplus url = https://github.com/marzer/tomlplusplus.git branch = master + shallow = true diff --git a/README.md b/README.md index b6d866b..1625944 100644 --- a/README.md +++ b/README.md @@ -229,14 +229,14 @@ scripts\setup-xemu.cmd --skip-support-files - [x] App Details - [ ] Pause/Hotkey overlay - Streaming - - [ ] Video - https://www.xbmc4xbox.org.uk/wiki/XBMC_Features_and_Supported_Formats#Xbox_supported_video_formats_and_resolutions + - [x] Video - https://www.xbmc4xbox.org.uk/wiki/XBMC_Features_and_Supported_Formats#Xbox_supported_video_formats_and_resolutions - [ ] Audio - [ ] Mono - [ ] Stereo - [ ] 5.1 Surround - [ ] 7.1 Surround - Input - - [ ] Gamepad Input + - [x] Gamepad Input - [ ] Keyboard Input - [ ] Mouse Input - [ ] Mouse Emulation via Gamepad diff --git a/cmake/moonlight-dependencies.cmake b/cmake/moonlight-dependencies.cmake index dcc5024..4cb811d 100644 --- a/cmake/moonlight-dependencies.cmake +++ b/cmake/moonlight-dependencies.cmake @@ -1,5 +1,260 @@ include_guard(GLOBAL) +# Normalize FFmpeg configure probes that incorrectly succeed against the nxdk toolchain. +function(_moonlight_patch_ffmpeg_config_header ffmpeg_config_header) + if(NOT EXISTS "${ffmpeg_config_header}") + return() + endif() + + file(READ "${ffmpeg_config_header}" ffmpeg_config_text) + + foreach(ffmpeg_disabled_probe + IN ITEMS + HAVE_ALIGNED_MALLOC + HAVE_EXP2 + HAVE_EXP2F + HAVE_LLRINT + HAVE_LLRINTF + HAVE_LOG2 + HAVE_LOG2F + HAVE_LRINT + HAVE_LRINTF + HAVE_MEMALIGN + HAVE_MMAP + HAVE_POSIX_MEMALIGN + HAVE_RINT + HAVE_RINTF + HAVE_SCHED_GETAFFINITY + HAVE_STRERROR_R + HAVE_SYSCTL) + string(REGEX REPLACE + "#define ${ffmpeg_disabled_probe} [0-9]+" + "#define ${ffmpeg_disabled_probe} 0" + ffmpeg_config_text + "${ffmpeg_config_text}") + endforeach() + + file(WRITE "${ffmpeg_config_header}" "${ffmpeg_config_text}") +endfunction() + +# Prepare the static FFmpeg libraries used by the Xbox streaming runtime. +function(moonlight_prepare_xbox_ffmpeg nxdk_dir) + set(ffmpeg_source_dir "${CMAKE_SOURCE_DIR}/third-party/ffmpeg") + set(ffmpeg_cc_wrapper "${CMAKE_SOURCE_DIR}/scripts/ffmpeg-nxdk-cc.sh") + set(ffmpeg_cxx_wrapper "${CMAKE_SOURCE_DIR}/scripts/ffmpeg-nxdk-cxx.sh") + set(ffmpeg_compat_header "${CMAKE_SOURCE_DIR}/src/_nxdk_compat/ffmpeg_compat.h") + if(NOT EXISTS "${ffmpeg_source_dir}/configure") + message(FATAL_ERROR + "FFmpeg source directory not found: ${ffmpeg_source_dir}\n" + "Run: git submodule update --init --recursive") + endif() + + foreach(ffmpeg_support_file + IN ITEMS + "${ffmpeg_cc_wrapper}" + "${ffmpeg_cxx_wrapper}" + "${ffmpeg_compat_header}") + if(NOT EXISTS "${ffmpeg_support_file}") + message(FATAL_ERROR "Required FFmpeg support file not found: ${ffmpeg_support_file}") + endif() + endforeach() + + set(ffmpeg_state_dir "${CMAKE_BINARY_DIR}/third-party/ffmpeg") + set(ffmpeg_build_dir "${ffmpeg_state_dir}/build") + set(ffmpeg_install_dir "${ffmpeg_state_dir}/install") + set(signature_file "${ffmpeg_state_dir}/build.signature") + + execute_process( + COMMAND git -C "${ffmpeg_source_dir}" rev-parse HEAD + OUTPUT_VARIABLE ffmpeg_revision + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE ffmpeg_revision_result + ) + if(NOT ffmpeg_revision_result EQUAL 0) + set(ffmpeg_revision unknown) + endif() + + file(SHA256 "${ffmpeg_cc_wrapper}" ffmpeg_cc_wrapper_hash) + file(SHA256 "${ffmpeg_cxx_wrapper}" ffmpeg_cxx_wrapper_hash) + file(SHA256 "${ffmpeg_compat_header}" ffmpeg_compat_header_hash) + file(SHA256 "${CMAKE_SOURCE_DIR}/cmake/moonlight-dependencies.cmake" ffmpeg_dependency_manifest_hash) + + set(signature_inputs + "FFMPEG_REVISION=${ffmpeg_revision}" + "NXDK_DIR=${nxdk_dir}" + "FFMPEG_PROFILE=h264-opus-xbox" + "FFMPEG_TARGET_OS=none" + "FFMPEG_ARCH=x86" + "FFMPEG_CC_WRAPPER_SHA256=${ffmpeg_cc_wrapper_hash}" + "FFMPEG_CXX_WRAPPER_SHA256=${ffmpeg_cxx_wrapper_hash}" + "FFMPEG_COMPAT_HEADER_SHA256=${ffmpeg_compat_header_hash}" + "FFMPEG_DEPENDENCY_MANIFEST_SHA256=${ffmpeg_dependency_manifest_hash}") + list(JOIN signature_inputs "\n" signature_text) + string(SHA256 signature "${signature_text}") + + set(required_outputs + "${ffmpeg_install_dir}/include/libavcodec/avcodec.h" + "${ffmpeg_install_dir}/lib/libavcodec.a" + "${ffmpeg_install_dir}/lib/libavutil.a" + "${ffmpeg_install_dir}/lib/libswscale.a" + "${ffmpeg_install_dir}/lib/libswresample.a") + + file(MAKE_DIRECTORY "${ffmpeg_state_dir}") + + set(need_rebuild FALSE) + if(NOT EXISTS "${signature_file}") + set(need_rebuild TRUE) + else() + file(READ "${signature_file}" saved_signature) + string(STRIP "${saved_signature}" saved_signature) + if(NOT saved_signature STREQUAL signature) + set(need_rebuild TRUE) + endif() + endif() + + _moonlight_has_missing_output(ffmpeg_missing_output ${required_outputs}) + if(ffmpeg_missing_output) + set(need_rebuild TRUE) + endif() + + if(need_rebuild) + message(STATUS "Preparing FFmpeg for Xbox at ${ffmpeg_build_dir}") + file(REMOVE_RECURSE "${ffmpeg_build_dir}" "${ffmpeg_install_dir}") + file(MAKE_DIRECTORY "${ffmpeg_build_dir}") + + if(CMAKE_HOST_WIN32) + _moonlight_to_msys_path(ffmpeg_source_shell_path "${ffmpeg_source_dir}") + _moonlight_to_msys_path(ffmpeg_install_shell_path "${ffmpeg_install_dir}") + _moonlight_to_msys_path(ffmpeg_build_shell_path "${ffmpeg_build_dir}") + _moonlight_to_msys_path(nxdk_shell_path "${nxdk_dir}") + _moonlight_to_msys_path(ffmpeg_cc_shell_path "${ffmpeg_cc_wrapper}") + _moonlight_to_msys_path(ffmpeg_cxx_shell_path "${ffmpeg_cxx_wrapper}") + else() + set(ffmpeg_source_shell_path "${ffmpeg_source_dir}") + set(ffmpeg_install_shell_path "${ffmpeg_install_dir}") + set(ffmpeg_cc_shell_path nxdk-cc) + set(ffmpeg_cxx_shell_path nxdk-cxx) + endif() + + set(ffmpeg_configure_args + sh + "${ffmpeg_source_shell_path}/configure" + "--prefix=${ffmpeg_install_shell_path}" + --enable-cross-compile + --arch=x86 + --cpu=i686 + --target-os=none + "--cc=${ffmpeg_cc_shell_path}" + "--cxx=${ffmpeg_cxx_shell_path}" + --ar=llvm-ar + --ranlib=llvm-ranlib + --nm=llvm-nm + --enable-static + --disable-shared + --disable-autodetect + --disable-asm + --disable-inline-asm + --disable-x86asm + --disable-debug + --disable-doc + --disable-programs + --disable-network + --disable-everything + --disable-avdevice + --disable-avfilter + --disable-avformat + --disable-iconv + --disable-zlib + --disable-bzlib + --disable-lzma + --disable-sdl2 + --disable-symver + --disable-runtime-cpudetect + --disable-pthreads + --disable-w32threads + --disable-os2threads + --disable-hwaccels + --enable-avcodec + --enable-avutil + --enable-swscale + --enable-swresample + --enable-parser=h264 + --enable-decoder=h264 + --enable-decoder=opus) + + if(CMAKE_HOST_WIN32) + set(msys2_shell "C:/msys64/msys2_shell.cmd") + if(NOT EXISTS "${msys2_shell}") + message(FATAL_ERROR "MSYS2 shell not found at ${msys2_shell}") + endif() + _moonlight_join_shell_command(ffmpeg_configure_command ${ffmpeg_configure_args}) + _moonlight_join_shell_command(ffmpeg_build_command make -j4 install) + _moonlight_shell_quote(quoted_nxdk_shell_path "${nxdk_shell_path}") + _moonlight_shell_quote(quoted_ffmpeg_build_shell_path "${ffmpeg_build_shell_path}") + + string(CONCAT ffmpeg_configure_script + "unset MAKEFLAGS MFLAGS GNUMAKEFLAGS MAKELEVEL; " + "export NXDK_DIR=${quoted_nxdk_shell_path}; " + "export PATH=\"$NXDK_DIR/bin:$PATH\"; " + "cd ${quoted_ffmpeg_build_shell_path}; " + "exec ${ffmpeg_configure_command}") + execute_process( + COMMAND "${msys2_shell}" -defterm -here -no-start -mingw64 -c "${ffmpeg_configure_script}" + RESULT_VARIABLE ffmpeg_configure_result + ) + if(NOT ffmpeg_configure_result EQUAL 0) + message(FATAL_ERROR "FFmpeg configure failed with exit code ${ffmpeg_configure_result}") + endif() + + set(ffmpeg_config_header "${ffmpeg_build_dir}/config.h") + _moonlight_patch_ffmpeg_config_header("${ffmpeg_config_header}") + + string(CONCAT ffmpeg_build_script + "unset MAKEFLAGS MFLAGS GNUMAKEFLAGS MAKELEVEL; " + "export NXDK_DIR=${quoted_nxdk_shell_path}; " + "export PATH=\"$NXDK_DIR/bin:$PATH\"; " + "cd ${quoted_ffmpeg_build_shell_path}; " + "exec ${ffmpeg_build_command}") + execute_process( + COMMAND "${msys2_shell}" -defterm -here -no-start -mingw64 -c "${ffmpeg_build_script}" + RESULT_VARIABLE ffmpeg_build_result + ) + if(NOT ffmpeg_build_result EQUAL 0) + message(FATAL_ERROR "FFmpeg build failed with exit code ${ffmpeg_build_result}") + endif() + else() + moonlight_run_nxdk_command( + "FFmpeg configure" + "${nxdk_dir}" + "${ffmpeg_build_dir}" + ${ffmpeg_configure_args} + ) + set(ffmpeg_config_header "${ffmpeg_build_dir}/config.h") + _moonlight_patch_ffmpeg_config_header("${ffmpeg_config_header}") + moonlight_run_nxdk_command( + "FFmpeg build" + "${nxdk_dir}" + "${ffmpeg_build_dir}" + make + -j4 + install + ) + endif() + + file(WRITE "${signature_file}" "${signature}\n") + else() + message(STATUS "Using existing FFmpeg Xbox outputs from ${ffmpeg_install_dir}") + endif() + + set(MOONLIGHT_FFMPEG_INCLUDE_DIR "${ffmpeg_install_dir}/include" PARENT_SCOPE) + set(MOONLIGHT_FFMPEG_LIBRARIES + "${ffmpeg_install_dir}/lib/libavcodec.a" + "${ffmpeg_install_dir}/lib/libswscale.a" + "${ffmpeg_install_dir}/lib/libswresample.a" + "${ffmpeg_install_dir}/lib/libavutil.a" + PARENT_SCOPE) +endfunction() + # Prepare dependencies that are common to multiple Moonlight components macro(MOONLIGHT_PREPARE_COMMON_DEPENDENCIES) include(GetOpenSSL REQUIRED) @@ -62,6 +317,7 @@ macro(MOONLIGHT_PREPARE_COMMON_DEPENDENCIES) ) if(TARGET enet) + target_compile_definitions(enet PRIVATE NXDK) target_link_libraries(enet PUBLIC NXDK::NXDK NXDK::Net) target_include_directories(enet PRIVATE "${MOONLIGHT_NXDK_NET_INCLUDE_DIR}" @@ -71,6 +327,7 @@ macro(MOONLIGHT_PREPARE_COMMON_DEPENDENCIES) endif() if(TARGET moonlight-common-c) + target_compile_definitions(moonlight-common-c PRIVATE NXDK) target_include_directories(moonlight-common-c PRIVATE "${MOONLIGHT_NXDK_NET_INCLUDE_DIR}" "${MOONLIGHT_NXDK_LIBC_EXTENSIONS_DIR}" @@ -79,5 +336,7 @@ macro(MOONLIGHT_PREPARE_COMMON_DEPENDENCIES) -Wno-unused-function -Wno-error=unused-function) endif() + + moonlight_prepare_xbox_ffmpeg("${NXDK_DIR}") endif() endmacro() diff --git a/cmake/sources.cmake b/cmake/sources.cmake index b6427ed..b1a6e69 100644 --- a/cmake/sources.cmake +++ b/cmake/sources.cmake @@ -16,7 +16,9 @@ list(REMOVE_ITEM MOONLIGHT_SOURCES set(MOONLIGHT_TEST_EXCLUDED_SOURCES "${MOONLIGHT_SOURCE_ROOT}/src/main.cpp" "${MOONLIGHT_SOURCE_ROOT}/src/splash/splash_screen.cpp" + "${MOONLIGHT_SOURCE_ROOT}/src/streaming/ffmpeg_stream_backend.cpp" "${MOONLIGHT_SOURCE_ROOT}/src/startup/memory_stats.cpp" + "${MOONLIGHT_SOURCE_ROOT}/src/streaming/session.cpp" "${MOONLIGHT_SOURCE_ROOT}/src/ui/shell_screen.cpp" ) diff --git a/cmake/xbox-build.cmake b/cmake/xbox-build.cmake index 3b100ca..2fbbea0 100644 --- a/cmake/xbox-build.cmake +++ b/cmake/xbox-build.cmake @@ -62,6 +62,7 @@ target_sources(${CMAKE_PROJECT_NAME} target_include_directories(${CMAKE_PROJECT_NAME} SYSTEM PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" + "${MOONLIGHT_FFMPEG_INCLUDE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/third-party/tomlplusplus/include" "${MOONLIGHT_NXDK_NET_INCLUDE_DIR}" "${MOONLIGHT_NXDK_LIBC_EXTENSIONS_DIR}" @@ -69,6 +70,8 @@ target_include_directories(${CMAKE_PROJECT_NAME} ) target_link_libraries(${CMAKE_PROJECT_NAME} PUBLIC + moonlight-common-c + ${MOONLIGHT_FFMPEG_LIBRARIES} NXDK::NXDK NXDK::NXDK_CXX NXDK::Net diff --git a/scripts/ffmpeg-nxdk-cc.sh b/scripts/ffmpeg-nxdk-cc.sh new file mode 100644 index 0000000..ad35586 --- /dev/null +++ b/scripts/ffmpeg-nxdk-cc.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env sh + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ffmpeg_compat_header="${script_dir}/../src/_nxdk_compat/ffmpeg_compat.h" +ffmpeg_compat_include_dir="${script_dir}/../src/_nxdk_compat" + +compile_only=0 +output_path= +previous_arg= +for arg in "$@"; do + case "$arg" in + -c|-E) + compile_only=1 + ;; + esac + + if [ "$previous_arg" = "-o" ]; then + output_path="$arg" + previous_arg= + continue + fi + + previous_arg="$arg" +done + +if [ "$compile_only" -eq 1 ]; then + exec clang \ + -target i386-pc-win32 \ + -march=pentium3 \ + -ffreestanding \ + -nostdlib \ + -fno-builtin \ + -include "${ffmpeg_compat_header}" \ + -I"${ffmpeg_compat_include_dir}" \ + -I"${NXDK_DIR}/lib" \ + -I"${NXDK_DIR}/lib/xboxrt/libc_extensions" \ + -isystem "${NXDK_DIR}/lib/pdclib/include" \ + -I"${NXDK_DIR}/lib/pdclib/platform/xbox/include" \ + -I"${NXDK_DIR}/lib/winapi" \ + -I"${NXDK_DIR}/lib/xboxrt/vcruntime" \ + -Wno-builtin-macro-redefined \ + -DNXDK \ + -D__STDC__=1 \ + -U__STDC_NO_THREADS__ \ + "$@" +fi + +if [ -n "$output_path" ]; then + : > "$output_path" + exit 0 +fi + +exec clang \ + -target i386-pc-win32 \ + -march=pentium3 \ + -ffreestanding \ + -nostdlib \ + -fno-builtin \ + -include "${ffmpeg_compat_header}" \ + -I"${ffmpeg_compat_include_dir}" \ + -I"${NXDK_DIR}/lib" \ + -I"${NXDK_DIR}/lib/xboxrt/libc_extensions" \ + -isystem "${NXDK_DIR}/lib/pdclib/include" \ + -I"${NXDK_DIR}/lib/pdclib/platform/xbox/include" \ + -I"${NXDK_DIR}/lib/winapi" \ + -I"${NXDK_DIR}/lib/xboxrt/vcruntime" \ + -Wno-builtin-macro-redefined \ + -DNXDK \ + -D__STDC__=1 \ + -U__STDC_NO_THREADS__ \ + "$@" diff --git a/scripts/ffmpeg-nxdk-cxx.sh b/scripts/ffmpeg-nxdk-cxx.sh new file mode 100644 index 0000000..b62ba21 --- /dev/null +++ b/scripts/ffmpeg-nxdk-cxx.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env sh + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ffmpeg_compat_header="${script_dir}/../src/_nxdk_compat/ffmpeg_compat.h" +ffmpeg_compat_include_dir="${script_dir}/../src/_nxdk_compat" + +compile_only=0 +output_path= +previous_arg= +for arg in "$@"; do + case "$arg" in + -c|-E) + compile_only=1 + ;; + esac + + if [ "$previous_arg" = "-o" ]; then + output_path="$arg" + previous_arg= + continue + fi + + previous_arg="$arg" +done + +if [ "$compile_only" -eq 1 ]; then + exec clang \ + -target i386-pc-win32 \ + -march=pentium3 \ + -ffreestanding \ + -nostdlib \ + -fno-builtin \ + -include "${ffmpeg_compat_header}" \ + -I"${ffmpeg_compat_include_dir}" \ + -I"${NXDK_DIR}/lib/libcxx/include" \ + -I"${NXDK_DIR}/lib" \ + -I"${NXDK_DIR}/lib/xboxrt/libc_extensions" \ + -isystem "${NXDK_DIR}/lib/pdclib/include" \ + -I"${NXDK_DIR}/lib/pdclib/platform/xbox/include" \ + -I"${NXDK_DIR}/lib/winapi" \ + -I"${NXDK_DIR}/lib/xboxrt/vcruntime" \ + -Wno-builtin-macro-redefined \ + -DNXDK \ + -D__STDC__=1 \ + -U__STDC_NO_THREADS__ \ + -fno-exceptions \ + "$@" +fi + +if [ -n "$output_path" ]; then + : > "$output_path" + exit 0 +fi + +exec clang \ + -target i386-pc-win32 \ + -march=pentium3 \ + -ffreestanding \ + -nostdlib \ + -fno-builtin \ + -include "${ffmpeg_compat_header}" \ + -I"${ffmpeg_compat_include_dir}" \ + -I"${NXDK_DIR}/lib/libcxx/include" \ + -I"${NXDK_DIR}/lib" \ + -I"${NXDK_DIR}/lib/xboxrt/libc_extensions" \ + -isystem "${NXDK_DIR}/lib/pdclib/include" \ + -I"${NXDK_DIR}/lib/pdclib/platform/xbox/include" \ + -I"${NXDK_DIR}/lib/winapi" \ + -I"${NXDK_DIR}/lib/xboxrt/vcruntime" \ + -Wno-builtin-macro-redefined \ + -DNXDK \ + -D__STDC__=1 \ + -U__STDC_NO_THREADS__ \ + -fno-exceptions \ + "$@" diff --git a/src/_nxdk_compat/ffmpeg_compat.h b/src/_nxdk_compat/ffmpeg_compat.h new file mode 100644 index 0000000..580ec24 --- /dev/null +++ b/src/_nxdk_compat/ffmpeg_compat.h @@ -0,0 +1,363 @@ +/** + * @file src/_nxdk_compat/ffmpeg_compat.h + * @brief Provides nxdk compatibility shims for the FFmpeg Xbox build. + */ + +#pragma once + +#ifdef NXDK + + #include + #include + #include + #include + #include + #include + #include + #include + + #ifdef __cplusplus +extern "C" { + #endif + + #ifndef ENOSYS + #define ENOSYS 38 + #endif + + #ifndef O_BINARY + #define O_BINARY 0 + #endif + + #ifndef F_SETFD + #define F_SETFD 2 + #endif + + #ifndef FD_CLOEXEC + #define FD_CLOEXEC 1 + #endif + + #ifndef CP_ACP + #define CP_ACP 0U + #endif + + #ifndef CP_UTF8 + #define CP_UTF8 65001U + #endif + + #ifndef MB_ERR_INVALID_CHARS + #define MB_ERR_INVALID_CHARS 0x00000008UL + #endif + + #ifndef WC_ERR_INVALID_CHARS + #define WC_ERR_INVALID_CHARS 0x00000080UL + #endif + + /** @brief Redirect access to the nxdk FFmpeg compatibility shim. */ + #define access moonlight_nxdk_ffmpeg_access + /** @brief Redirect close to the nxdk FFmpeg compatibility shim. */ + #define close moonlight_nxdk_ffmpeg_close + /** @brief Redirect fcntl to the nxdk FFmpeg compatibility shim. */ + #define fcntl moonlight_nxdk_ffmpeg_fcntl + /** @brief Redirect fdopen to the nxdk FFmpeg compatibility shim. */ + #define fdopen moonlight_nxdk_ffmpeg_fdopen + /** @brief Redirect isatty to the nxdk FFmpeg compatibility shim. */ + #define isatty moonlight_nxdk_ffmpeg_isatty + /** @brief Redirect GetFullPathNameW to the nxdk FFmpeg compatibility shim. */ + #define GetFullPathNameW moonlight_nxdk_ffmpeg_GetFullPathNameW + /** @brief Redirect mkstemp to the nxdk FFmpeg compatibility shim. */ + #define mkstemp moonlight_nxdk_ffmpeg_mkstemp + /** @brief Redirect MultiByteToWideChar to the nxdk FFmpeg compatibility shim. */ + #define MultiByteToWideChar moonlight_nxdk_ffmpeg_MultiByteToWideChar + /** @brief Redirect open to the nxdk FFmpeg compatibility shim. */ + #define open moonlight_nxdk_ffmpeg_open + /** @brief Redirect strerror_r to the nxdk FFmpeg compatibility shim. */ + #define strerror_r moonlight_nxdk_ffmpeg_strerror_r + /** @brief Redirect tempnam to the nxdk FFmpeg compatibility shim. */ + #define tempnam moonlight_nxdk_ffmpeg_tempnam + /** @brief Redirect usleep to the nxdk FFmpeg compatibility shim. */ + #define usleep moonlight_nxdk_ffmpeg_usleep + /** @brief Redirect WideCharToMultiByte to the nxdk FFmpeg compatibility shim. */ + #define WideCharToMultiByte moonlight_nxdk_ffmpeg_WideCharToMultiByte + + /** + * @brief Report unsupported path access checks during FFmpeg builds. + * + * @param path Requested file system path. + * @param mode Requested access mode. + * @return Always -1 with errno set to ENOSYS. + */ + static inline int moonlight_nxdk_ffmpeg_access(const char *path, int mode) { + (void) path; + (void) mode; + errno = ENOSYS; + return -1; + } + + /** + * @brief Treat descriptor close requests as successful during FFmpeg builds. + * + * @param fd Descriptor to close. + * @return Always 0. + */ + static inline int moonlight_nxdk_ffmpeg_close(int fd) { + (void) fd; + return 0; + } + + /** + * @brief Report unsupported fcntl requests during FFmpeg builds. + * + * @param fd Descriptor to operate on. + * @param cmd fcntl command. + * @param ... Ignored command arguments. + * @return Always -1 with errno set to ENOSYS. + */ + static inline int moonlight_nxdk_ffmpeg_fcntl(int fd, int cmd, ...) { + (void) fd; + (void) cmd; + errno = ENOSYS; + return -1; + } + + /** + * @brief Report unsupported descriptor-backed stdio requests during FFmpeg builds. + * + * @param fd Descriptor to convert. + * @param mode Requested fopen mode string. + * @return Always NULL with errno set to ENOSYS. + */ + static inline FILE *moonlight_nxdk_ffmpeg_fdopen(int fd, const char *mode) { + (void) fd; + (void) mode; + errno = ENOSYS; + return NULL; + } + + /** + * @brief Report that FFmpeg output is not connected to a terminal. + * + * @param fd Descriptor to inspect. + * @return Always 0. + */ + static inline int moonlight_nxdk_ffmpeg_isatty(int fd) { + (void) fd; + return 0; + } + + /** + * @brief Provide a minimal high-resolution timer fallback for FFmpeg. + * + * @return A monotonic placeholder timestamp value. + */ + static inline int64_t gethrtime(void) { + return 0; + } + + /** + * @brief Provide a minimal GetFullPathNameW fallback for FFmpeg path normalization. + * + * @param path Source wide-character path. + * @param buffer_size Destination buffer capacity in wide characters. + * @param buffer Destination buffer. + * @param file_part Optional pointer to the filename portion. + * @return Required or written character count, including the terminating null. + */ + static inline unsigned long moonlight_nxdk_ffmpeg_GetFullPathNameW(const wchar_t *path, unsigned long buffer_size, wchar_t *buffer, wchar_t **file_part) { + size_t length; + wchar_t *last_separator; + + if (path == NULL) { + return 0; + } + + length = wcslen(path); + last_separator = NULL; + for (size_t index = 0; index < length; ++index) { + if (path[index] == L'\\' || path[index] == L'/') { + last_separator = (wchar_t *) &path[index + 1U]; + } + } + + if (file_part != NULL) { + *file_part = last_separator; + } + + if (buffer == NULL || buffer_size == 0U) { + return (unsigned long) (length + 1U); + } + + if (length + 1U > buffer_size) { + if (buffer_size > 0U) { + wcsncpy(buffer, path, buffer_size - 1U); + buffer[buffer_size - 1U] = L'\0'; + } + return (unsigned long) (length + 1U); + } + + wcscpy(buffer, path); + return (unsigned long) length; + } + + /** + * @brief Report unsupported temporary file creation during FFmpeg builds. + * + * @param pattern Writable mkstemp pattern. + * @return Always -1 with errno set to ENOSYS. + */ + static inline int moonlight_nxdk_ffmpeg_mkstemp(char *pattern) { + (void) pattern; + errno = ENOSYS; + return -1; + } + + /** + * @brief Provide a minimal UTF-8 to wchar_t conversion fallback. + * + * @param code_page Requested Windows code page. + * @param flags Requested conversion flags. + * @param source Source multibyte string. + * @param source_length Length of @p source or -1 for null-terminated input. + * @param destination Destination wide-character buffer. + * @param destination_length Capacity of @p destination in wide characters. + * @return Required or written character count, including the terminating null. + */ + static inline int moonlight_nxdk_ffmpeg_MultiByteToWideChar(unsigned int code_page, unsigned long flags, const char *source, int source_length, wchar_t *destination, int destination_length) { + size_t length; + + (void) code_page; + (void) flags; + + if (source == NULL) { + return 0; + } + + length = source_length >= 0 ? (size_t) source_length : strlen(source) + 1U; + if (destination == NULL || destination_length <= 0) { + return (int) length; + } + + if ((size_t) destination_length < length) { + return 0; + } + + for (size_t index = 0; index < length; ++index) { + destination[index] = (unsigned char) source[index]; + } + + return (int) length; + } + + /** + * @brief Report unsupported file opening during FFmpeg builds. + * + * @param path Requested file system path. + * @param flags Requested open flags. + * @param ... Ignored mode argument. + * @return Always -1 with errno set to ENOSYS. + */ + static inline int moonlight_nxdk_ffmpeg_open(const char *path, int flags, ...) { + (void) path; + (void) flags; + errno = ENOSYS; + return -1; + } + + /** + * @brief Provide a simple strerror_r fallback backed by strerror. + * + * @param errnum Error number to describe. + * @param buffer Destination character buffer. + * @param buffer_size Size of @p buffer in bytes. + * @return Zero when the buffer is usable, otherwise -1. + */ + static inline int moonlight_nxdk_ffmpeg_strerror_r(int errnum, char *buffer, size_t buffer_size) { + const char *message; + + if (buffer == NULL || buffer_size == 0) { + errno = EINVAL; + return -1; + } + + message = strerror(errnum); + if (message == NULL) { + message = "Unknown error"; + } + + strncpy(buffer, message, buffer_size - 1U); + buffer[buffer_size - 1U] = '\0'; + return 0; + } + + /** + * @brief Report unsupported tempnam requests during FFmpeg builds. + * + * @param dir Ignored preferred directory. + * @param prefix Ignored preferred file prefix. + * @return Always NULL with errno set to ENOSYS. + */ + static inline char *moonlight_nxdk_ffmpeg_tempnam(const char *dir, const char *prefix) { + (void) dir; + (void) prefix; + errno = ENOSYS; + return NULL; + } + + /** + * @brief Provide a no-op microsecond sleep fallback for FFmpeg. + * + * @param usec Requested sleep duration in microseconds. + * @return Always 0. + */ + static inline int moonlight_nxdk_ffmpeg_usleep(unsigned int usec) { + (void) usec; + return 0; + } + + /** + * @brief Provide a minimal wchar_t to multibyte conversion fallback. + * + * @param code_page Requested Windows code page. + * @param flags Requested conversion flags. + * @param source Source wide-character string. + * @param source_length Length of @p source or -1 for null-terminated input. + * @param destination Destination multibyte buffer. + * @param destination_length Capacity of @p destination in bytes. + * @param default_char Ignored Windows default character pointer. + * @param used_default_char Ignored Windows default-character output flag. + * @return Required or written character count, including the terminating null. + */ + static inline int moonlight_nxdk_ffmpeg_WideCharToMultiByte(unsigned int code_page, unsigned long flags, const wchar_t *source, int source_length, char *destination, int destination_length, const char *default_char, int *used_default_char) { + size_t length; + + (void) code_page; + (void) flags; + (void) default_char; + if (used_default_char != NULL) { + *used_default_char = 0; + } + + if (source == NULL) { + return 0; + } + + length = source_length >= 0 ? (size_t) source_length : wcslen(source) + 1U; + if (destination == NULL || destination_length <= 0) { + return (int) length; + } + + if ((size_t) destination_length < length) { + return 0; + } + + for (size_t index = 0; index < length; ++index) { + destination[index] = (char) source[index]; + } + + return (int) length; + } + + #ifdef __cplusplus +} + #endif + +#endif diff --git a/src/_nxdk_compat/share.h b/src/_nxdk_compat/share.h new file mode 100644 index 0000000..371da8d --- /dev/null +++ b/src/_nxdk_compat/share.h @@ -0,0 +1,57 @@ +/** + * @file src/_nxdk_compat/share.h + * @brief Provides the small share.h surface needed by the FFmpeg Xbox build. + */ + +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef SH_DENYNO + #define SH_DENYNO 0 +#endif + + /** + * @brief Stub the wide-character shared open helper used by FFmpeg's Win32 path. + * + * @param path Requested file system path. + * @param oflag Requested open flags. + * @param shflag Requested sharing mode. + * @param pmode Requested permission mode. + * @return Always -1 with errno set to ENOSYS. + */ + static inline int _wsopen(const wchar_t *path, int oflag, int shflag, int pmode) { + (void) path; + (void) oflag; + (void) shflag; + (void) pmode; + errno = ENOSYS; + return -1; + } + + /** + * @brief Stub the narrow shared open helper used by FFmpeg's Win32 path. + * + * @param path Requested file system path. + * @param oflag Requested open flags. + * @param shflag Requested sharing mode. + * @param pmode Requested permission mode. + * @return Always -1 with errno set to ENOSYS. + */ + static inline int _sopen(const char *path, int oflag, int shflag, int pmode) { + (void) path; + (void) oflag; + (void) shflag; + (void) pmode; + errno = ENOSYS; + return -1; + } + +#ifdef __cplusplus +} +#endif diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index 1d5c3ec..71f792a 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -28,6 +28,8 @@ namespace { constexpr const char *SETTINGS_CATEGORY_PREFIX = "settings-category:"; constexpr std::array ADD_HOST_ADDRESS_KEYPAD_CHARACTERS {'1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0'}; constexpr std::array ADD_HOST_PORT_KEYPAD_CHARACTERS {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}; + constexpr std::array STREAM_FRAMERATE_OPTIONS {15, 20, 24, 25, 30}; + constexpr std::array STREAM_BITRATE_OPTIONS {1000, 1500, 2000, 2500, 3000, 4000, 5000}; /** * @brief Describes the keypad characters available for the active add-host field. @@ -105,7 +107,7 @@ namespace { case app::SettingsCategory::logging: return "Control the runtime log file, the in-app log viewer, and xemu debugger output verbosity."; case app::SettingsCategory::display: - return "Display options will live here when video and layout tuning settings are added."; + return "Tune streaming video resolution, frame rate, bitrate, host audio playback, and the in-stream diagnostics overlay."; case app::SettingsCategory::input: return "Input options will live here when controller and navigation customization is added."; case app::SettingsCategory::reset: @@ -115,6 +117,100 @@ namespace { return "Control the runtime log file, the in-app log viewer, and xemu debugger output verbosity."; } + /** + * @brief Return whether two stream-resolution entries target the same size. + * + * @param left First stream-resolution entry to compare. + * @param right Second stream-resolution entry to compare. + * @return True when both entries describe the same width and height. + */ + bool stream_resolutions_match(const VIDEO_MODE &left, const VIDEO_MODE &right) { + return left.width == right.width && left.height == right.height; + } + + /** + * @brief Format one stream resolution for display in the settings menu. + * + * @param videoMode Resolution to stringify. + * @return Human-readable stream-resolution label. + */ + std::string describe_stream_resolution(const VIDEO_MODE &videoMode) { + if (videoMode.width <= 0 || videoMode.height <= 0) { + return "Unavailable"; + } + + return std::to_string(videoMode.width) + "x" + std::to_string(videoMode.height); + } + + /** + * @brief Return the selected stream-resolution index inside the preset list. + * + * @param state Current client state containing the preferred mode. + * @return Zero-based index of the preferred mode, or zero when no exact match exists. + */ + std::size_t selected_stream_video_mode_index(const app::ClientState &state) { + if (!state.settings.preferredVideoModeSet || state.settings.availableVideoModes.empty()) { + return 0U; + } + + for (std::size_t index = 0; index < state.settings.availableVideoModes.size(); ++index) { + if (stream_resolutions_match(state.settings.availableVideoModes[index], state.settings.preferredVideoMode)) { + return index; + } + } + + return 0U; + } + + /** + * @brief Advance the preferred stream resolution to the next preset. + * + * @param state Current client state containing the configured stream-resolution presets. + */ + void cycle_stream_video_mode(app::ClientState &state) { + if (state.settings.availableVideoModes.empty()) { + state.settings.preferredVideoMode = {}; + state.settings.preferredVideoModeSet = false; + return; + } + + const std::size_t nextIndex = (selected_stream_video_mode_index(state) + 1U) % state.settings.availableVideoModes.size(); + state.settings.preferredVideoMode = state.settings.availableVideoModes[nextIndex]; + state.settings.preferredVideoModeSet = true; + } + + /** + * @brief Advance the preferred stream frame rate to the next supported option. + * + * @param state Current client state containing the preferred frame rate. + */ + void cycle_stream_framerate(app::ClientState &state) { + const auto current = std::find(STREAM_FRAMERATE_OPTIONS.begin(), STREAM_FRAMERATE_OPTIONS.end(), state.settings.streamFramerate); + if (current == STREAM_FRAMERATE_OPTIONS.end()) { + state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS.front(); + return; + } + + const std::size_t nextIndex = (static_cast(std::distance(STREAM_FRAMERATE_OPTIONS.begin(), current)) + 1U) % STREAM_FRAMERATE_OPTIONS.size(); + state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS[nextIndex]; + } + + /** + * @brief Advance the preferred stream bitrate to the next supported option. + * + * @param state Current client state containing the preferred bitrate. + */ + void cycle_stream_bitrate(app::ClientState &state) { + const auto current = std::find(STREAM_BITRATE_OPTIONS.begin(), STREAM_BITRATE_OPTIONS.end(), state.settings.streamBitrateKbps); + if (current == STREAM_BITRATE_OPTIONS.end()) { + state.settings.streamBitrateKbps = STREAM_BITRATE_OPTIONS.front(); + return; + } + + const std::size_t nextIndex = (static_cast(std::distance(STREAM_BITRATE_OPTIONS.begin(), current)) + 1U) % STREAM_BITRATE_OPTIONS.size(); + state.settings.streamBitrateKbps = STREAM_BITRATE_OPTIONS[nextIndex]; + } + std::string pairing_reset_endpoint_key(std::string_view address, uint16_t port) { return app::normalize_ipv4_address(address) + ":" + std::to_string(app::effective_host_port(port)); } @@ -428,7 +524,36 @@ namespace { }; case app::SettingsCategory::display: return { - {"display-placeholder", "Display settings are not implemented yet", "Display-specific options are planned, but there are no adjustable display settings in this build yet.", true}, + { + "cycle-stream-video-mode", + std::string("Stream Resolution: ") + describe_stream_resolution(state.settings.preferredVideoMode), + "Cycle through fixed stream-resolution presets. The selected resolution is requested from the host the next time a stream starts and does not change the Xbox output mode.", + true, + }, + { + "cycle-stream-framerate", + std::string("Stream Frame Rate: ") + std::to_string(state.settings.streamFramerate) + " FPS", + "Cycle through the preferred stream frame rate. Lower frame rates can reduce video packet pressure on slower or lossy networks.", + true, + }, + { + "cycle-stream-bitrate", + std::string("Stream Bitrate: ") + std::to_string(state.settings.streamBitrateKbps) + " kbps", + "Cycle through the preferred video bitrate. Lower bitrates reduce bandwidth use and can help when running Sunshine and xemu on the same NATed host.", + true, + }, + { + "toggle-play-audio-on-pc", + std::string("Play Audio on PC: ") + (state.settings.playAudioOnPc ? "On" : "Off"), + "Toggle whether the host PC should continue local audio playback while also streaming audio to this Xbox client.", + true, + }, + { + "toggle-show-performance-stats", + std::string("Show Performance Stats: ") + (state.settings.showPerformanceStats ? "On" : "Off"), + "Toggle the in-stream performance overlay that shows decoded frames, queued audio, and transport telemetry over the video output.", + true, + }, }; case app::SettingsCategory::input: return { @@ -1270,6 +1395,10 @@ namespace app { state.settings.logViewerPlacement = LogViewerPlacement::full; state.settings.loggingLevel = logging::LogLevel::none; state.settings.xemuConsoleLoggingLevel = logging::LogLevel::none; + state.settings.streamFramerate = STREAM_FRAMERATE_OPTIONS[1]; + state.settings.streamBitrateKbps = STREAM_BITRATE_OPTIONS[1]; + state.settings.playAudioOnPc = false; + state.settings.showPerformanceStats = false; state.settings.dirty = false; state.settings.savedFilesDirty = true; return state; @@ -1834,6 +1963,46 @@ namespace app { rebuild_menu(state, "cycle-xemu-console-log-level"); return; } + if (detailUpdate.activatedItemId == "cycle-stream-video-mode") { + cycle_stream_video_mode(state); + state.settings.dirty = true; + update->persistence.settingsChanged = true; + state.shell.statusMessage = state.settings.preferredVideoModeSet ? std::string("Stream resolution set to ") + describe_stream_resolution(state.settings.preferredVideoMode) : "No stream resolutions are currently available"; + rebuild_menu(state, "cycle-stream-video-mode"); + return; + } + if (detailUpdate.activatedItemId == "cycle-stream-framerate") { + cycle_stream_framerate(state); + state.settings.dirty = true; + update->persistence.settingsChanged = true; + state.shell.statusMessage = std::string("Stream frame rate set to ") + std::to_string(state.settings.streamFramerate) + " FPS"; + rebuild_menu(state, "cycle-stream-framerate"); + return; + } + if (detailUpdate.activatedItemId == "cycle-stream-bitrate") { + cycle_stream_bitrate(state); + state.settings.dirty = true; + update->persistence.settingsChanged = true; + state.shell.statusMessage = std::string("Stream bitrate set to ") + std::to_string(state.settings.streamBitrateKbps) + " kbps"; + rebuild_menu(state, "cycle-stream-bitrate"); + return; + } + if (detailUpdate.activatedItemId == "toggle-play-audio-on-pc") { + state.settings.playAudioOnPc = !state.settings.playAudioOnPc; + state.settings.dirty = true; + update->persistence.settingsChanged = true; + state.shell.statusMessage = std::string("Play audio on PC ") + (state.settings.playAudioOnPc ? "enabled" : "disabled"); + rebuild_menu(state, "toggle-play-audio-on-pc"); + return; + } + if (detailUpdate.activatedItemId == "toggle-show-performance-stats") { + state.settings.showPerformanceStats = !state.settings.showPerformanceStats; + state.settings.dirty = true; + update->persistence.settingsChanged = true; + state.shell.statusMessage = std::string("Performance stats overlay ") + (state.settings.showPerformanceStats ? "enabled" : "disabled"); + rebuild_menu(state, "toggle-show-performance-stats"); + return; + } if (detailUpdate.activatedItemId == "factory-reset") { open_confirmation( state, @@ -2082,8 +2251,9 @@ namespace app { case input::UiCommand::activate: case input::UiCommand::confirm: if (const HostAppRecord *appRecord = selected_app(state); appRecord != nullptr) { - state.shell.statusMessage = "Launching " + appRecord->name + " is not implemented yet"; + state.shell.statusMessage = "Starting stream for " + appRecord->name + "..."; update->navigation.activatedItemId = "launch-app"; + update->requests.streamLaunchRequested = true; } return true; case input::UiCommand::back: diff --git a/src/app/client_state.h b/src/app/client_state.h index d33c340..9c54469 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -9,6 +9,9 @@ #include #include +// nxdk includes +#include + // standard includes #include "src/app/host_records.h" #include "src/app/pairing_flow.h" @@ -313,6 +316,13 @@ namespace app { LogViewerPlacement logViewerPlacement = LogViewerPlacement::full; ///< Log viewer pane placement relative to the shell. logging::LogLevel loggingLevel = logging::LogLevel::none; ///< Minimum runtime log level written to the persisted log file. logging::LogLevel xemuConsoleLoggingLevel = logging::LogLevel::none; ///< Minimum runtime log level mirrored through DbgPrint() to xemu's serial console. + std::vector availableVideoModes; ///< Fixed stream-resolution presets exposed by the settings UI. + VIDEO_MODE preferredVideoMode {}; ///< Preferred stream resolution requested from the host. + bool preferredVideoModeSet = false; ///< True when preferredVideoMode contains a user-selected or default mode. + int streamFramerate = 20; ///< Preferred stream frame rate in frames per second. + int streamBitrateKbps = 1500; ///< Preferred stream bitrate in kilobits per second. + bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. + bool showPerformanceStats = false; ///< True when the streaming overlay should remain visible over decoded video. bool dirty = false; ///< True when persisted TOML-backed settings changed and should be saved. std::vector savedFiles; ///< Saved-file catalog shown on the reset settings page. bool savedFilesDirty = true; ///< True when the saved-file catalog should be refreshed. @@ -361,6 +371,7 @@ namespace app { std::string pairingPin; ///< Generated client PIN that should be shown to the user. bool appsBrowseRequested = false; ///< True when app browsing for the selected host should begin. bool appsBrowseShowHidden = false; ///< Hidden-app visibility requested for the app browse action. + bool streamLaunchRequested = false; ///< True when the selected host app should start or resume streaming. bool logViewRequested = false; ///< True when the log viewer should be refreshed from disk. }; diff --git a/src/app/settings_storage.cpp b/src/app/settings_storage.cpp index eef1e44..a0bfd95 100644 --- a/src/app/settings_storage.cpp +++ b/src/app/settings_storage.cpp @@ -263,6 +263,66 @@ namespace { append_invalid_value_warning(warnings, filePath, "ui.log_viewer_placement", ""); } + /** + * @brief Load one integer settings value when present. + * + * @param settingNode TOML node to parse. + * @param filePath Settings file path used in warnings. + * @param keyPath Fully qualified settings key used in warnings. + * @param value Receives the parsed integer on success. + * @param warnings Warning collection updated for invalid values. + */ + void load_integer_setting( + toml::node_view settingNode, + const std::string &filePath, + std::string_view keyPath, + int *value, + std::vector *warnings + ) { + if (!settingNode) { + return; + } + + if (const auto parsedValue = settingNode.value(); parsedValue) { + if (value != nullptr) { + *value = static_cast(*parsedValue); + } + return; + } + + append_invalid_value_warning(warnings, filePath, keyPath, ""); + } + + /** + * @brief Load one boolean settings value when present. + * + * @param settingNode TOML node to parse. + * @param filePath Settings file path used in warnings. + * @param keyPath Fully qualified settings key used in warnings. + * @param value Receives the parsed boolean on success. + * @param warnings Warning collection updated for invalid values. + */ + void load_boolean_setting( + toml::node_view settingNode, + const std::string &filePath, + std::string_view keyPath, + bool *value, + std::vector *warnings + ) { + if (!settingNode) { + return; + } + + if (const auto parsedValue = settingNode.value(); parsedValue) { + if (value != nullptr) { + *value = *parsedValue; + } + return; + } + + append_invalid_value_warning(warnings, filePath, keyPath, ""); + } + std::string format_settings_toml(const app::AppSettings &settings) { std::string content; content += "# Moonlight Xbox OG user settings\n"; @@ -275,7 +335,19 @@ namespace { content += std::string("xemu_console_minimum_level = \"") + logging_level_text(settings.xemuConsoleLoggingLevel) + "\"\n\n"; content += "[ui]\n"; content += "# Preferred placement for the in-app log viewer.\n"; - content += std::string("log_viewer_placement = \"") + log_viewer_placement_text(settings.logViewerPlacement) + "\"\n"; + content += std::string("log_viewer_placement = \"") + log_viewer_placement_text(settings.logViewerPlacement) + "\"\n\n"; + content += "[streaming]\n"; + content += "# Preferred stream resolution requested from the host.\n"; + content += std::string("video_width = ") + std::to_string(settings.preferredVideoMode.width) + "\n"; + content += std::string("video_height = ") + std::to_string(settings.preferredVideoMode.height) + "\n"; + content += std::string("video_bpp = ") + std::to_string(settings.preferredVideoMode.bpp) + "\n"; + content += std::string("video_refresh = ") + std::to_string(settings.preferredVideoMode.refresh) + "\n"; + content += std::string("video_mode_selected = ") + (settings.preferredVideoModeSet ? "true" : "false") + "\n"; + content += "# Preferred streaming parameters.\n"; + content += std::string("fps = ") + std::to_string(settings.streamFramerate) + "\n"; + content += std::string("bitrate_kbps = ") + std::to_string(settings.streamBitrateKbps) + "\n"; + content += std::string("play_audio_on_pc = ") + (settings.playAudioOnPc ? "true" : "false") + "\n"; + content += std::string("show_performance_stats = ") + (settings.showPerformanceStats ? "true" : "false") + "\n"; return content; } @@ -308,6 +380,25 @@ namespace { } } + /** + * @brief Mark unknown streaming keys for cleanup on the next settings save. + * + * @param streamingTable Parsed streaming settings table. + * @param filePath Settings file path used in warnings. + * @param result Load result updated with cleanup warnings. + */ + void inspect_streaming_keys(const toml::table &streamingTable, const std::string &filePath, app::LoadAppSettingsResult *result) { + for (const auto &[rawKey, node] : streamingTable) { + const std::string key(rawKey.str()); + if (key == "video_width" || key == "video_height" || key == "video_bpp" || key == "video_refresh" || key == "video_mode_selected" || key == "fps" || key == "bitrate_kbps" || key == "play_audio_on_pc" || key == "show_performance_stats") { + continue; + } + + (void) node; + mark_cleanup_required(result, filePath, std::string("streaming.") + key, "obsolete"); + } + } + void inspect_top_level_keys(const toml::table &settingsTable, const std::string &filePath, app::LoadAppSettingsResult *result) { for (const auto &[rawKey, node] : settingsTable) { const std::string key(rawKey.str()); @@ -323,6 +414,12 @@ namespace { } continue; } + if (key == "streaming") { + if (const auto *streamingTable = node.as_table(); streamingTable != nullptr) { + inspect_streaming_keys(*streamingTable, filePath, result); + } + continue; + } if (key == "debug") { mark_cleanup_required(result, filePath, "debug", "obsolete"); continue; @@ -384,6 +481,15 @@ namespace app { &result.warnings ); load_log_viewer_placement_setting(settingsTable["ui"]["log_viewer_placement"], filePath, &result.settings.logViewerPlacement, &result.warnings); + load_integer_setting(settingsTable["streaming"]["video_width"], filePath, "streaming.video_width", &result.settings.preferredVideoMode.width, &result.warnings); + load_integer_setting(settingsTable["streaming"]["video_height"], filePath, "streaming.video_height", &result.settings.preferredVideoMode.height, &result.warnings); + load_integer_setting(settingsTable["streaming"]["video_bpp"], filePath, "streaming.video_bpp", &result.settings.preferredVideoMode.bpp, &result.warnings); + load_integer_setting(settingsTable["streaming"]["video_refresh"], filePath, "streaming.video_refresh", &result.settings.preferredVideoMode.refresh, &result.warnings); + load_boolean_setting(settingsTable["streaming"]["video_mode_selected"], filePath, "streaming.video_mode_selected", &result.settings.preferredVideoModeSet, &result.warnings); + load_integer_setting(settingsTable["streaming"]["fps"], filePath, "streaming.fps", &result.settings.streamFramerate, &result.warnings); + load_integer_setting(settingsTable["streaming"]["bitrate_kbps"], filePath, "streaming.bitrate_kbps", &result.settings.streamBitrateKbps, &result.warnings); + load_boolean_setting(settingsTable["streaming"]["play_audio_on_pc"], filePath, "streaming.play_audio_on_pc", &result.settings.playAudioOnPc, &result.warnings); + load_boolean_setting(settingsTable["streaming"]["show_performance_stats"], filePath, "streaming.show_performance_stats", &result.settings.showPerformanceStats, &result.warnings); return result; } diff --git a/src/app/settings_storage.h b/src/app/settings_storage.h index 21d8fe2..53e89d0 100644 --- a/src/app/settings_storage.h +++ b/src/app/settings_storage.h @@ -20,6 +20,12 @@ namespace app { logging::LogLevel loggingLevel = logging::LogLevel::none; ///< Minimum runtime log level written to the log file. logging::LogLevel xemuConsoleLoggingLevel = logging::LogLevel::none; ///< Minimum runtime log level written through DbgPrint() for xemu's serial console. app::LogViewerPlacement logViewerPlacement = app::LogViewerPlacement::full; ///< Preferred placement for the in-app log viewer. + VIDEO_MODE preferredVideoMode {}; ///< Preferred stream resolution requested from the host. + bool preferredVideoModeSet = false; ///< True when preferredVideoMode contains a saved user preference. + int streamFramerate = 20; ///< Preferred stream frame rate in frames per second. + int streamBitrateKbps = 1500; ///< Preferred stream bitrate in kilobits per second. + bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. + bool showPerformanceStats = false; ///< True when the streaming overlay should remain visible over decoded video. }; /** diff --git a/src/main.cpp b/src/main.cpp index 0a55582..5b8b9e6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,6 +8,7 @@ #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names // standard includes +#include #include #include #include @@ -39,9 +40,53 @@ namespace { state.settings.loggingLevel = settings.loggingLevel; state.settings.xemuConsoleLoggingLevel = settings.xemuConsoleLoggingLevel; state.settings.logViewerPlacement = settings.logViewerPlacement; + state.settings.preferredVideoMode = settings.preferredVideoMode; + state.settings.preferredVideoModeSet = settings.preferredVideoModeSet; + state.settings.streamFramerate = settings.streamFramerate; + state.settings.streamBitrateKbps = settings.streamBitrateKbps; + state.settings.playAudioOnPc = settings.playAudioOnPc; + state.settings.showPerformanceStats = settings.showPerformanceStats; state.settings.dirty = false; } + /** + * @brief Return whether two stream-resolution entries target the same width and height. + * + * @param left First stream-resolution entry to compare. + * @param right Second stream-resolution entry to compare. + * @return True when both entries describe the same stream resolution. + */ + bool stream_resolutions_match(const VIDEO_MODE &left, const VIDEO_MODE &right) { + return left.width == right.width && left.height == right.height; + } + + /** + * @brief Finalize the preferred stream resolution after startup detection. + * + * @param state Mutable client state receiving the fixed stream-resolution presets. + * @param selection Detected Xbox output modes and preferred startup mode. + */ + void initialize_stream_video_mode_settings(app::ClientState &state, const startup::VideoModeSelection &selection) { + state.settings.availableVideoModes = startup::stream_resolution_presets( + selection.bestVideoMode.bpp > 0 ? selection.bestVideoMode.bpp : 32, + selection.bestVideoMode.refresh > 0 ? selection.bestVideoMode.refresh : 60 + ); + const auto preferredMode = std::find_if( + state.settings.availableVideoModes.begin(), + state.settings.availableVideoModes.end(), + [&state](const VIDEO_MODE &candidate) { + return stream_resolutions_match(candidate, state.settings.preferredVideoMode); + } + ); + if (state.settings.preferredVideoModeSet && preferredMode != state.settings.availableVideoModes.end()) { + state.settings.preferredVideoMode = *preferredMode; + return; + } + + state.settings.preferredVideoMode = startup::choose_default_stream_video_mode(selection.bestVideoMode); + state.settings.preferredVideoModeSet = state.settings.preferredVideoMode.width > 0 && state.settings.preferredVideoMode.height > 0; + } + void load_persisted_settings(app::ClientState &state) { const app::LoadAppSettingsResult loadResult = app::load_app_settings(); apply_persisted_settings(state, loadResult.settings); @@ -188,6 +233,7 @@ int main() { debug_print_encoder_settings(XVideoGetEncoderSettings()); const startup::VideoModeSelection videoModeSelection = startup::select_best_video_mode(); + initialize_stream_video_mode_settings(clientState, videoModeSelection); const VIDEO_MODE &bestVideoMode = videoModeSelection.bestVideoMode; debug_print_video_mode_selection(videoModeSelection); startup::log_memory_statistics(); @@ -200,7 +246,7 @@ int main() { debug_print_startup_checkpoint(setVideoModeResult ? "Returned from XVideoSetMode successfully" : "XVideoSetMode returned failure"); debug_print_startup_checkpoint("About to call SDL_Init"); - if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) { + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMECONTROLLER) != 0) { return report_startup_failure("sdl", std::string("SDL_Init failed: ") + SDL_GetError()); } debug_print_startup_checkpoint("SDL_Init succeeded"); diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index 7e06a2d..029484c 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -35,6 +35,9 @@ #include "src/network/runtime_network.h" #include "src/platform/error_utils.h" +// third-party includes +#include "third-party/moonlight-common-c/src/Limelight.h" + // platform includes #ifdef NXDK #include @@ -115,9 +118,11 @@ namespace { constexpr int SOCKET_TIMEOUT_MILLISECONDS = 5000; constexpr uint16_t DEFAULT_SERVERINFO_HTTP_PORT = 47989; constexpr uint16_t FALLBACK_SERVERINFO_HTTP_PORT = 47984; + constexpr uint16_t DEFAULT_SERVERINFO_HTTPS_PORT = 47990; constexpr std::string_view DEFAULT_SERVERINFO_UNIQUE_ID = "0123456789ABCDEF"; constexpr std::string_view DEFAULT_SERVERINFO_UUID = "11111111-2222-3333-4444-555555555555"; constexpr std::string_view UNPAIRED_CLIENT_ERROR_MESSAGE = "The host reports that this client is no longer paired. Pair the host again."; + constexpr int DEFAULT_SERVER_CODEC_MODE_SUPPORT = SCM_H264; network::testing::HostPairingHttpTestHandler &host_pairing_http_test_handler() { static network::testing::HostPairingHttpTestHandler handler; ///< Optional scripted transport used by host-native unit tests. @@ -432,6 +437,70 @@ namespace { }; } + int surround_audio_info_from_audio_configuration(int audioConfiguration) { + const int channelCount = (audioConfiguration >> 8) & 0xFF; + const int channelMask = (audioConfiguration >> 16) & 0xFFFF; + return (channelMask << 16) | channelCount; + } + + bool is_hexadecimal_text(std::string_view text) { + if (text.empty()) { + return false; + } + + for (char character : text) { + unsigned char ignored = 0; + if (!hex_value(character, &ignored)) { + return false; + } + } + return true; + } + + const char *launch_url_query_parameters() { + return "&corever=1"; + } + + std::string build_stream_resume_path( + std::string_view uniqueId, + const network::StreamLaunchConfiguration &configuration + ) { + const std::string queryPrefix = "/resume?uniqueid=" + std::string(uniqueId); + std::string path = queryPrefix + + "&rikey=" + configuration.remoteInputAesKeyHex + + "&rikeyid=" + configuration.remoteInputAesIvHex + + "&localAudioPlayMode=" + std::to_string(configuration.playAudioOnPc ? 1 : 0) + + "&surroundAudioInfo=" + std::to_string(surround_audio_info_from_audio_configuration(configuration.audioConfiguration)); + if (configuration.clientRefreshRateX100 > 0) { + path += "&clientRefreshRateX100=" + std::to_string(configuration.clientRefreshRateX100); + } + path += launch_url_query_parameters(); + return path; + } + + std::string build_stream_launch_path( + std::string_view uniqueId, + const network::StreamLaunchConfiguration &configuration + ) { + std::string path = "/launch?uniqueid=" + std::string(uniqueId) + + "&appid=" + std::to_string(configuration.appId) + + "&mode=" + std::to_string(configuration.width) + "x" + std::to_string(configuration.height) + "x" + std::to_string(configuration.fps) + + "&additionalStates=1" + + "&sops=1" + + "&rikey=" + configuration.remoteInputAesKeyHex + + "&rikeyid=" + configuration.remoteInputAesIvHex + + "&localAudioPlayMode=" + std::to_string(configuration.playAudioOnPc ? 1 : 0) + + "&surroundAudioInfo=" + std::to_string(surround_audio_info_from_audio_configuration(configuration.audioConfiguration)) + + "&remoteControllersBitmap=1" + + "&gcmap=0" + + "&hdrMode=0"; + if (configuration.clientRefreshRateX100 > 0) { + path += "&clientRefreshRateX100=" + std::to_string(configuration.clientRefreshRateX100); + } + path += launch_url_query_parameters(); + return path; + } + std::string_view trim_ascii_whitespace(std::string_view text) { while (!text.empty() && (text.front() == ' ' || text.front() == '\t' || text.front() == '\r' || text.front() == '\n')) { text.remove_prefix(1); @@ -1291,6 +1360,69 @@ namespace { return true; } + bool parse_stream_launch_response( + const HttpResponse &response, + const network::HostPairingServerInfo &serverInfo, + bool resumedSession, + network::StreamLaunchResult *result, + std::string *errorMessage + ) { + if (response.statusCode == 401 || response.statusCode == 403) { + return append_error(errorMessage, std::string(UNPAIRED_CLIENT_ERROR_MESSAGE)); + } + if (response.statusCode != 200) { + return append_error(errorMessage, "The host returned HTTP " + std::to_string(response.statusCode) + " while starting the stream session"); + } + + uint32_t rootStatusCode = 200; + std::string rootStatusMessage; + if (extract_root_status(response.body, &rootStatusCode, &rootStatusMessage) && rootStatusCode != 200U) { + if (network::error_indicates_unpaired_client(rootStatusMessage)) { + return append_error(errorMessage, std::string(UNPAIRED_CLIENT_ERROR_MESSAGE)); + } + return append_error(errorMessage, rootStatusMessage.empty() ? "The host rejected the stream launch request" : rootStatusMessage); + } + + std::string appVersion = serverInfo.appVersion; + std::string gfeVersion = serverInfo.gfeVersion; + std::string rtspSessionUrl; + std::string serverCodecModeSupportText; + extract_xml_tag_value(response.body, "appversion", &appVersion); + extract_xml_tag_value(response.body, "GfeVersion", &gfeVersion) || extract_xml_tag_value(response.body, "gfeversion", &gfeVersion); + extract_xml_tag_value(response.body, "sessionUrl0", &rtspSessionUrl); + extract_xml_tag_value(response.body, "ServerCodecModeSupport", &serverCodecModeSupportText); + + int serverCodecModeSupport = serverInfo.serverCodecModeSupport == 0 ? DEFAULT_SERVER_CODEC_MODE_SUPPORT : serverInfo.serverCodecModeSupport; + uint32_t parsedServerCodecModeSupport = 0; + if (!serverCodecModeSupportText.empty() && try_parse_uint32(trim_ascii_whitespace(serverCodecModeSupportText), &parsedServerCodecModeSupport)) { + serverCodecModeSupport = parsedServerCodecModeSupport == 0U ? DEFAULT_SERVER_CODEC_MODE_SUPPORT : static_cast(parsedServerCodecModeSupport); + } + + if (appVersion.empty()) { + return append_error(errorMessage, "The host launch response did not include a usable appversion"); + } + + if (result != nullptr) { + result->resumedSession = resumedSession; + result->serverInfo = serverInfo; + result->rtspSessionUrl = std::move(rtspSessionUrl); + result->appVersion = std::move(appVersion); + result->gfeVersion = std::move(gfeVersion); + result->serverCodecModeSupport = serverCodecModeSupport; + } + return true; + } + + int parse_server_major_version(std::string_view appVersion) { + const std::size_t separatorIndex = appVersion.find('.'); + const std::string_view majorVersionText = separatorIndex == std::string_view::npos ? appVersion : appVersion.substr(0, separatorIndex); + uint32_t majorVersion = 0; + if (!try_parse_uint32(majorVersionText, &majorVersion)) { + return 0; + } + return static_cast(majorVersion); + } + bool prepare_pairing_socket_address(const std::string &address, uint16_t port, sockaddr_in *socketAddress, std::string *errorMessage) { if (socketAddress == nullptr) { return append_error(errorMessage, "Internal pairing error while preparing the host connection"); @@ -2292,8 +2424,10 @@ namespace network { bool parse_server_info_response(std::string_view xml, uint16_t fallbackHttpPort, HostPairingServerInfo *serverInfo, std::string *errorMessage) { std::string appVersion; + std::string gfeVersion; std::string httpPortText; std::string httpsPortText; + std::string serverCodecModeSupportText; std::string pairStatus; if (!extract_xml_tag_value(xml, "appversion", &appVersion) || !extract_xml_tag_value(xml, "PairStatus", &pairStatus)) { return append_error(errorMessage, "The host serverinfo response was missing required pairing fields"); @@ -2308,6 +2442,20 @@ namespace network { if (extract_xml_tag_value(xml, "HttpsPort", &httpsPortText)) { try_parse_port(httpsPortText, &httpsPort); } + if (httpsPort == 0) { + httpsPort = DEFAULT_SERVERINFO_HTTPS_PORT; + } + + extract_xml_tag_value(xml, "GfeVersion", &gfeVersion) || extract_xml_tag_value(xml, "gfeversion", &gfeVersion); + extract_xml_tag_value(xml, "ServerCodecModeSupport", &serverCodecModeSupportText); + + uint32_t serverCodecModeSupport = DEFAULT_SERVER_CODEC_MODE_SUPPORT; + if (!serverCodecModeSupportText.empty()) { + try_parse_uint32(trim_ascii_whitespace(serverCodecModeSupportText), &serverCodecModeSupport); + if (serverCodecModeSupport == 0U) { + serverCodecModeSupport = DEFAULT_SERVER_CODEC_MODE_SUPPORT; + } + } std::string hostName; extract_xml_tag_value(xml, "hostname", &hostName) || extract_xml_tag_value(xml, "HostName", &hostName); @@ -2341,10 +2489,13 @@ namespace network { } if (serverInfo != nullptr) { - serverInfo->serverMajorVersion = std::atoi(appVersion.c_str()); + serverInfo->serverMajorVersion = parse_server_major_version(appVersion); + serverInfo->appVersion = appVersion; + serverInfo->gfeVersion = gfeVersion; serverInfo->httpPort = httpPort == 0 ? fallbackHttpPort : httpPort; serverInfo->httpsPort = httpsPort == 0 ? fallbackHttpPort : httpsPort; serverInfo->paired = pairStatus == "1"; + serverInfo->serverCodecModeSupport = static_cast(serverCodecModeSupport); serverInfo->hostName = std::move(hostName); serverInfo->uuid = std::move(uuid); serverInfo->activeAddress = std::move(activeAddress); @@ -2531,6 +2682,53 @@ namespace network { return true; } + bool launch_or_resume_stream( + const std::string &address, + uint16_t preferredHttpPort, + const PairingIdentity &clientIdentity, + const StreamLaunchConfiguration &configuration, + StreamLaunchResult *result, + std::string *errorMessage + ) { + if (address.empty()) { + return append_error(errorMessage, "The stream launch request requires a valid host address"); + } + if (!is_valid_pairing_identity(clientIdentity)) { + return append_error(errorMessage, std::string(UNPAIRED_CLIENT_ERROR_MESSAGE)); + } + if (configuration.appId <= 0) { + return append_error(errorMessage, "The stream launch request requires a valid host app ID"); + } + if (configuration.width <= 0 || configuration.height <= 0 || configuration.fps <= 0) { + return append_error(errorMessage, "The stream launch request requires a valid mode and frame rate"); + } + if (configuration.remoteInputAesKeyHex.size() != 32U || configuration.remoteInputAesIvHex.size() != 32U || !is_hexadecimal_text(configuration.remoteInputAesKeyHex) || !is_hexadecimal_text(configuration.remoteInputAesIvHex)) { + return append_error(errorMessage, "The stream launch request requires 16-byte hex-encoded remote-input keys"); + } + + HostPairingServerInfo serverInfo {}; + if (!query_server_info(address, preferredHttpPort, &clientIdentity, &serverInfo, errorMessage)) { + return false; + } + if (!serverInfo.paired) { + return append_error(errorMessage, std::string(UNPAIRED_CLIENT_ERROR_MESSAGE)); + } + if (serverInfo.httpsPort == 0U) { + return append_error(errorMessage, "The host did not report an HTTPS port for authenticated streaming"); + } + + const bool resumeExistingSession = serverInfo.runningGameId != 0U && serverInfo.runningGameId == static_cast(configuration.appId); + const std::string pathAndQuery = resumeExistingSession ? build_stream_resume_path(resolve_client_unique_id(&clientIdentity), configuration) : build_stream_launch_path(resolve_client_unique_id(&clientIdentity), configuration); + const std::string requestAddress = resolve_reachable_address(address, serverInfo); + serverInfo.activeAddress = requestAddress; + HttpResponse response {}; + if (!http_get(requestAddress, serverInfo.httpsPort, pathAndQuery, true, &clientIdentity, {}, &response, errorMessage)) { + return false; + } + + return parse_stream_launch_response(response, serverInfo, resumeExistingSession, result, errorMessage); + } + uint64_t hash_app_list_entries(const std::vector &apps) { uint64_t hash = 1469598103934665603ULL; for (const HostAppEntry &entry : apps) { @@ -2558,7 +2756,10 @@ namespace network { for (const std::string &path : build_app_asset_paths(resolve_client_unique_id(clientIdentity), appId)) { HttpResponse response {}; if (std::string attemptError; !http_get(address, httpsPort, path, true, clientIdentity, {}, &response, &attemptError)) { - attemptFailures.push_back(path + ": " + attemptError); + std::string failure = path; + failure += ": "; + failure += attemptError; + attemptFailures.push_back(std::move(failure)); continue; } diff --git a/src/network/host_pairing.h b/src/network/host_pairing.h index 6f167d6..2bb6013 100644 --- a/src/network/host_pairing.h +++ b/src/network/host_pairing.h @@ -28,11 +28,14 @@ namespace network { */ struct HostPairingServerInfo { int serverMajorVersion = 0; ///< Major version reported by the host server. + std::string appVersion; ///< Full appversion string reported by the host server. + std::string gfeVersion; ///< Full GFE or Sunshine version string reported by the host server. uint16_t httpPort = 0; ///< HTTP port reported or inferred for plaintext requests. uint16_t httpsPort = 0; ///< HTTPS port reported for encrypted requests and assets. bool paired = false; ///< True when the host reports that this client is paired. bool pairingStatusCurrentClientKnown = false; ///< True when the host explicitly reported pairing status for this client. bool pairingStatusCurrentClient = false; ///< Pairing status reported for this specific client identity. + int serverCodecModeSupport = 0; ///< Codec capability mask reported by the host. std::string hostName; ///< User-facing host name reported by the server. std::string uuid; ///< Stable host UUID. std::string activeAddress; ///< Best currently reachable address chosen for live requests. @@ -73,6 +76,33 @@ namespace network { std::string message; ///< User-visible success or failure detail. }; + /** + * @brief Parameters required to start or resume a streaming session. + */ + struct StreamLaunchConfiguration { + int appId = 0; ///< Host application identifier that should be launched or resumed. + int width = 640; ///< Requested stream width in pixels. + int height = 480; ///< Requested stream height in pixels. + int fps = 30; ///< Requested stream frame rate. + int audioConfiguration = 0; ///< Moonlight audio configuration bitfield. + bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. + int clientRefreshRateX100 = 0; ///< Optional client refresh rate multiplied by 100. + std::string remoteInputAesKeyHex; ///< Hex-encoded 16-byte remote-input AES key. + std::string remoteInputAesIvHex; ///< Hex-encoded 16-byte remote-input AES IV. + }; + + /** + * @brief Parsed result of a successful `/launch` or `/resume` request. + */ + struct StreamLaunchResult { + bool resumedSession = false; ///< True when an existing host session was resumed instead of launching a new app. + HostPairingServerInfo serverInfo; ///< Latest host server-info metadata used for the launch decision. + std::string rtspSessionUrl; ///< Optional RTSP session URL returned by the host. + std::string appVersion; ///< Full appversion string to pass to moonlight-common-c. + std::string gfeVersion; ///< Full GFE or Sunshine version string reported during launch. + int serverCodecModeSupport = 0; ///< Codec capability mask to pass to moonlight-common-c. + }; + /** * @brief Return whether a pairing identity contains the required PEM materials. * @@ -202,6 +232,30 @@ namespace network { std::string *errorMessage = nullptr ); + /** + * @brief Launch or resume one host application and return stream session details. + * + * The helper first refreshes `/serverinfo` using the supplied paired client + * identity, then resumes the running session when the requested app is + * already active or launches a new session otherwise. + * + * @param address Host address to query. + * @param preferredHttpPort Preferred HTTP port override. + * @param clientIdentity Paired client identity used for authenticated launch requests. + * @param configuration Stream launch parameters including app ID and remote-input keys. + * @param result Output populated with launch metadata required by moonlight-common-c. + * @param errorMessage Optional output for request or parse failures. + * @return true when the host accepted the launch or resume request. + */ + bool launch_or_resume_stream( + const std::string &address, + uint16_t preferredHttpPort, + const PairingIdentity &clientIdentity, + const StreamLaunchConfiguration &configuration, + StreamLaunchResult *result, + std::string *errorMessage = nullptr + ); + /** * @brief Pair the client with a host using the provided request parameters. * diff --git a/src/startup/video_mode.cpp b/src/startup/video_mode.cpp index a5c664b..5697cf3 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -8,14 +8,38 @@ // local includes #include "src/logging/logger.h" +// standard includes +#include + namespace startup { namespace { + struct StreamResolutionPreset { + int width; + int height; + }; + + constexpr std::array STREAM_RESOLUTION_PRESETS {{ + {352, 240}, + {352, 288}, + {480, 480}, + {480, 576}, + {720, 480}, + {720, 576}, + {960, 540}, + {1280, 720}, + {1920, 1080}, + }}; + bool is_1080i_mode(const VIDEO_MODE &videoMode) { return videoMode.width >= 1920 && videoMode.height >= 1080; } + VIDEO_MODE make_stream_video_mode(const StreamResolutionPreset &preset, int bpp, int refresh) { + return {preset.width, preset.height, bpp, refresh}; + } + } // namespace bool is_preferred_video_mode(const VIDEO_MODE &candidateVideoMode, const VIDEO_MODE ¤tBestVideoMode) { @@ -58,6 +82,31 @@ namespace startup { return bestVideoMode; } + std::vector stream_resolution_presets(int bpp, int refresh) { + std::vector presets; + presets.reserve(STREAM_RESOLUTION_PRESETS.size()); + for (const StreamResolutionPreset &preset : STREAM_RESOLUTION_PRESETS) { + presets.push_back(make_stream_video_mode(preset, bpp, refresh)); + } + return presets; + } + + VIDEO_MODE choose_default_stream_video_mode(const VIDEO_MODE &outputVideoMode) { + const int bpp = outputVideoMode.bpp > 0 ? outputVideoMode.bpp : 32; + const int refresh = outputVideoMode.refresh > 0 ? outputVideoMode.refresh : 60; + + if (outputVideoMode.width >= 1920 && outputVideoMode.height >= 1080) { + return make_stream_video_mode({1920, 1080}, bpp, refresh); + } + if (outputVideoMode.width >= 1280 && outputVideoMode.height >= 720) { + return make_stream_video_mode({1280, 720}, bpp, refresh); + } + if (refresh <= 50 || outputVideoMode.height >= 576) { + return make_stream_video_mode({720, 576}, bpp, refresh); + } + return make_stream_video_mode({720, 480}, bpp, refresh); + } + VideoModeSelection select_best_video_mode(int bpp, int refresh) { VideoModeSelection selection {}; diff --git a/src/startup/video_mode.h b/src/startup/video_mode.h index 0716f7b..f15f2fd 100644 --- a/src/startup/video_mode.h +++ b/src/startup/video_mode.h @@ -41,6 +41,30 @@ namespace startup { */ VIDEO_MODE choose_best_video_mode(const std::vector &availableVideoModes); + /** + * @brief Return the fixed stream-resolution presets exposed in the settings UI. + * + * These presets are independent from the Xbox output modes returned by + * `XVideoListModes()`. They define only the host stream resolution that + * Moonlight requests when starting a session. + * + * @param bpp Bits-per-pixel metadata to attach to each preset. + * @param refresh Refresh-rate metadata to attach to each preset. + * @return Ordered list of stream-resolution presets. + */ + std::vector stream_resolution_presets(int bpp = 32, int refresh = 60); + + /** + * @brief Choose the default stream-resolution preset for the current output mode. + * + * The shell output mode still comes from Xbox video-mode detection, but stream + * quality is controlled separately through the settings presets. + * + * @param outputVideoMode Active Xbox output mode selected at startup. + * @return Default stream-resolution preset for new or missing settings. + */ + VIDEO_MODE choose_default_stream_video_mode(const VIDEO_MODE &outputVideoMode); + /** * @brief Detect and choose the best available video mode. * diff --git a/src/streaming/ffmpeg_stream_backend.cpp b/src/streaming/ffmpeg_stream_backend.cpp new file mode 100644 index 0000000..3938ae5 --- /dev/null +++ b/src/streaming/ffmpeg_stream_backend.cpp @@ -0,0 +1,869 @@ +/** + * @file src/streaming/ffmpeg_stream_backend.cpp + * @brief Implements the FFmpeg-backed streaming decode backend for Xbox sessions. + */ +#include "src/streaming/ffmpeg_stream_backend.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include + +// lib includes +extern "C" { +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +} + +// local includes +#include "src/logging/logger.h" + +namespace { + + constexpr Uint32 STREAM_OVERLAY_AUDIO_QUEUE_LIMIT_MS = 250U; + constexpr std::uint64_t STREAM_VIDEO_SUBMISSION_LOG_INTERVAL = 120; + + streaming::FfmpegStreamBackend *g_active_video_backend = nullptr; + streaming::FfmpegStreamBackend *g_active_audio_backend = nullptr; + std::once_flag g_ffmpeg_logging_once; + + /** + * @brief Convert an FFmpeg error code into readable text. + * + * @param errorCode Negative FFmpeg return code. + * @return User-readable FFmpeg error text. + */ + std::string describe_ffmpeg_error(int errorCode) { + std::array buffer {}; + av_strerror(errorCode, buffer.data(), buffer.size()); + return std::string(buffer.data()); + } + + /** + * @brief Remove trailing CR and LF characters from an FFmpeg log line. + * + * @param message Candidate log line. + * @return Trimmed log line. + */ + std::string trim_ffmpeg_log_line(std::string message) { + while (!message.empty() && (message.back() == '\n' || message.back() == '\r')) { + message.pop_back(); + } + return message; + } + + /** + * @brief Map one FFmpeg log level to the project logger severity. + * + * @param ffmpegLevel FFmpeg log level constant. + * @return Corresponding project log level. + */ + logging::LogLevel map_ffmpeg_log_level(int ffmpegLevel) { + if (ffmpegLevel <= AV_LOG_ERROR) { + return logging::LogLevel::error; + } + if (ffmpegLevel <= AV_LOG_WARNING) { + return logging::LogLevel::warning; + } + if (ffmpegLevel <= AV_LOG_INFO) { + return logging::LogLevel::info; + } + if (ffmpegLevel <= AV_LOG_VERBOSE) { + return logging::LogLevel::debug; + } + return logging::LogLevel::trace; + } + + /** + * @brief Forward FFmpeg's internal logs into the project logger. + * + * @param avClassContext FFmpeg component instance emitting the line. + * @param level FFmpeg log level. + * @param format `printf`-style format string. + * @param arguments Variadic arguments matching `format`. + */ + void ffmpeg_log_callback(void *avClassContext, int level, const char *format, va_list arguments) { + std::array buffer {}; + int printPrefix = 1; + va_list argumentsCopy; + va_copy(argumentsCopy, arguments); + const int formattedLength = av_log_format_line2(avClassContext, level, format, argumentsCopy, buffer.data(), static_cast(buffer.size()), &printPrefix); + va_end(argumentsCopy); + if (formattedLength < 0) { + return; + } + + std::string message; + if (static_cast(formattedLength) < buffer.size()) { + message.assign(buffer.data(), static_cast(formattedLength)); + } else { + message.assign(buffer.data()); + } + message = trim_ffmpeg_log_line(std::move(message)); + if (message.empty()) { + return; + } + + logging::log(map_ffmpeg_log_level(level), "ffmpeg", std::move(message)); + } + + /** + * @brief Install the shared FFmpeg log callback once for the process. + */ + void ensure_ffmpeg_logging_installed() { + std::call_once(g_ffmpeg_logging_once, []() { + av_log_set_level(AV_LOG_VERBOSE); + av_log_set_callback(ffmpeg_log_callback); + }); + } + + /** + * @brief Return the number of bytes generated per second for the SDL audio format. + * + * @param audioSpec SDL audio format in use for playback. + * @return Number of bytes generated per second. + */ + Uint32 audio_bytes_per_second(const SDL_AudioSpec &audioSpec) { + return static_cast(audioSpec.freq) * static_cast(audioSpec.channels) * (SDL_AUDIO_BITSIZE(audioSpec.format) / 8U); + } + + /** + * @brief Compute a centered destination rectangle that preserves aspect ratio. + * + * @param screenWidth Renderer output width. + * @param screenHeight Renderer output height. + * @param frameWidth Decoded video width. + * @param frameHeight Decoded video height. + * @return Letterboxed destination rectangle. + */ + SDL_Rect build_letterboxed_destination(int screenWidth, int screenHeight, int frameWidth, int frameHeight) { + if (screenWidth <= 0 || screenHeight <= 0 || frameWidth <= 0 || frameHeight <= 0) { + return SDL_Rect {0, 0, 0, 0}; + } + + const double screenAspect = static_cast(screenWidth) / static_cast(screenHeight); + const double frameAspect = static_cast(frameWidth) / static_cast(frameHeight); + + int destinationWidth = screenWidth; + int destinationHeight = screenHeight; + if (frameAspect > screenAspect) { + destinationHeight = std::max(1, static_cast(static_cast(screenWidth) / frameAspect)); + } else { + destinationWidth = std::max(1, static_cast(static_cast(screenHeight) * frameAspect)); + } + + return SDL_Rect { + (screenWidth - destinationWidth) / 2, + (screenHeight - destinationHeight) / 2, + destinationWidth, + destinationHeight, + }; + } + + /** + * @brief Create FFmpeg Opus extradata for the stereo Moonlight stream. + * + * @param channelCount Negotiated channel count. + * @param sampleRate Negotiated sample rate. + * @param codecContext Audio codec context that will own the extradata. + * @return True when the extradata was allocated successfully. + */ + bool configure_opus_extradata(int channelCount, int sampleRate, AVCodecContext *codecContext) { + if (codecContext == nullptr || channelCount <= 0 || channelCount > 2) { + return false; + } + + std::array opusHead { + static_cast('O'), + static_cast('p'), + static_cast('u'), + static_cast('s'), + static_cast('H'), + static_cast('e'), + static_cast('a'), + static_cast('d'), + 1U, + static_cast(channelCount), + 0U, + 0U, + static_cast(sampleRate & 0xFF), + static_cast((sampleRate >> 8) & 0xFF), + static_cast((sampleRate >> 16) & 0xFF), + static_cast((sampleRate >> 24) & 0xFF), + 0U, + 0U, + 0U, + }; + + codecContext->extradata = static_cast(av_mallocz(opusHead.size() + AV_INPUT_BUFFER_PADDING_SIZE)); + if (codecContext->extradata == nullptr) { + return false; + } + + codecContext->extradata_size = static_cast(opusHead.size()); + std::memcpy(codecContext->extradata, opusHead.data(), opusHead.size()); + return true; + } + + int on_video_setup(int videoFormat, int width, int height, int redrawRate, void *context, int drFlags) { + auto *backend = static_cast(context); + if (backend == nullptr) { + return -1; + } + + g_active_video_backend = backend; + return backend->setup_video_decoder(videoFormat, width, height, redrawRate, context, drFlags); + } + + void on_video_start() { + if (g_active_video_backend != nullptr) { + g_active_video_backend->start_video_decoder(); + } + } + + void on_video_stop() { + if (g_active_video_backend != nullptr) { + g_active_video_backend->stop_video_decoder(); + } + } + + void on_video_cleanup() { + if (g_active_video_backend != nullptr) { + g_active_video_backend->cleanup_video_decoder(); + g_active_video_backend = nullptr; + } + } + + int on_video_submit_decode_unit(PDECODE_UNIT decodeUnit) { + if (g_active_video_backend == nullptr) { + return DR_NEED_IDR; + } + + return g_active_video_backend->submit_video_decode_unit(decodeUnit); + } + + int on_audio_init(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, void *context, int arFlags) { + auto *backend = static_cast(context); + if (backend == nullptr) { + return -1; + } + + g_active_audio_backend = backend; + return backend->initialize_audio_decoder(audioConfiguration, opusConfig, context, arFlags); + } + + void on_audio_start() { + if (g_active_audio_backend != nullptr) { + g_active_audio_backend->start_audio_playback(); + } + } + + void on_audio_stop() { + if (g_active_audio_backend != nullptr) { + g_active_audio_backend->stop_audio_playback(); + } + } + + void on_audio_cleanup() { + if (g_active_audio_backend != nullptr) { + g_active_audio_backend->cleanup_audio_decoder(); + g_active_audio_backend = nullptr; + } + } + + void on_audio_decode_and_play_sample(char *sampleData, int sampleLength) { + if (g_active_audio_backend != nullptr) { + g_active_audio_backend->decode_and_play_audio_sample(sampleData, sampleLength); + } + } + +} // namespace + +namespace streaming { + + FfmpegStreamBackend::~FfmpegStreamBackend() { + shutdown(); + } + + void FfmpegStreamBackend::initialize_callbacks(DECODER_RENDERER_CALLBACKS *videoCallbacks, AUDIO_RENDERER_CALLBACKS *audioCallbacks) { + ensure_ffmpeg_logging_installed(); + + if (videoCallbacks != nullptr) { + LiInitializeVideoCallbacks(videoCallbacks); + videoCallbacks->setup = on_video_setup; + videoCallbacks->start = on_video_start; + videoCallbacks->stop = on_video_stop; + videoCallbacks->cleanup = on_video_cleanup; + videoCallbacks->submitDecodeUnit = on_video_submit_decode_unit; + videoCallbacks->capabilities = 0; + } + + if (audioCallbacks != nullptr) { + LiInitializeAudioCallbacks(audioCallbacks); + audioCallbacks->init = on_audio_init; + audioCallbacks->start = on_audio_start; + audioCallbacks->stop = on_audio_stop; + audioCallbacks->cleanup = on_audio_cleanup; + audioCallbacks->decodeAndPlaySample = on_audio_decode_and_play_sample; + audioCallbacks->capabilities = 0; + } + } + + void FfmpegStreamBackend::shutdown() { + cleanup_audio_decoder(); + cleanup_video_decoder(); + } + + bool FfmpegStreamBackend::has_decoded_video() const { + return video_.hasFrame.load(); + } + + std::string FfmpegStreamBackend::build_overlay_status_line() const { + std::string audioState = audio_.deviceId != 0 ? std::to_string(SDL_GetQueuedAudioSize(audio_.deviceId)) + " queued audio bytes" : "audio idle"; + return std::string("Video units: ") + std::to_string(video_.submittedDecodeUnitCount.load()) + " submitted / " + std::to_string(video_.decodedFrameCount.load()) + " decoded | " + audioState; + } + + bool FfmpegStreamBackend::render_latest_video_frame(SDL_Renderer *renderer, int screenWidth, int screenHeight) { + if (renderer == nullptr || !video_.hasFrame.load()) { + return false; + } + + LatestVideoFrame frameSnapshot {}; + { + std::lock_guard lock(video_.frameMutex); + if (video_.latestFrame.width <= 0 || video_.latestFrame.height <= 0) { + return false; + } + frameSnapshot = video_.latestFrame; + } + + if (video_.texture == nullptr || video_.textureWidth != frameSnapshot.width || video_.textureHeight != frameSnapshot.height) { + if (video_.texture != nullptr) { + SDL_DestroyTexture(video_.texture); + video_.texture = nullptr; + } + + video_.texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_IYUV, SDL_TEXTUREACCESS_STREAMING, frameSnapshot.width, frameSnapshot.height); + if (video_.texture == nullptr) { + logging::error("stream", std::string("SDL_CreateTexture failed for video presentation: ") + SDL_GetError()); + return false; + } + + video_.textureWidth = frameSnapshot.width; + video_.textureHeight = frameSnapshot.height; + } + + if (SDL_UpdateYUVTexture( + video_.texture, + nullptr, + reinterpret_cast(frameSnapshot.yPlane.data()), + frameSnapshot.yPitch, + reinterpret_cast(frameSnapshot.uPlane.data()), + frameSnapshot.uPitch, + reinterpret_cast(frameSnapshot.vPlane.data()), + frameSnapshot.vPitch + ) != 0) { + logging::error("stream", std::string("SDL_UpdateYUVTexture failed during video presentation: ") + SDL_GetError()); + return false; + } + + const SDL_Rect destination = build_letterboxed_destination(screenWidth, screenHeight, frameSnapshot.width, frameSnapshot.height); + return SDL_RenderCopy(renderer, video_.texture, nullptr, &destination) == 0; + } + + int FfmpegStreamBackend::setup_video_decoder(int videoFormat, int width, int height, int redrawRate, void *context, int drFlags) { + (void) width; + (void) height; + (void) redrawRate; + (void) context; + (void) drFlags; + + cleanup_video_decoder(); + + if ((videoFormat & VIDEO_FORMAT_MASK_H264) == 0) { + logging::error("stream", "The FFmpeg backend currently supports only H.264 video streams"); + return -1; + } + + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_H264); + if (codec == nullptr) { + logging::error("stream", "FFmpeg did not provide an H.264 decoder"); + return -1; + } + + video_.codecContext = avcodec_alloc_context3(codec); + video_.decodedFrame = av_frame_alloc(); + video_.convertedFrame = av_frame_alloc(); + video_.packet = av_packet_alloc(); + if (video_.codecContext == nullptr || video_.decodedFrame == nullptr || video_.convertedFrame == nullptr || video_.packet == nullptr) { + logging::error("stream", "Failed to allocate FFmpeg video decoder resources"); + cleanup_video_decoder(); + return -1; + } + + video_.codecContext->thread_count = 1; + video_.codecContext->thread_type = 0; + const int openResult = avcodec_open2(video_.codecContext, codec, nullptr); + if (openResult < 0) { + logging::error("stream", std::string("avcodec_open2 failed for H.264: ") + describe_ffmpeg_error(openResult)); + cleanup_video_decoder(); + return openResult; + } + + return 0; + } + + void FfmpegStreamBackend::start_video_decoder() { + } + + void FfmpegStreamBackend::stop_video_decoder() { + } + + void FfmpegStreamBackend::cleanup_video_decoder() { + if (video_.texture != nullptr) { + SDL_DestroyTexture(video_.texture); + video_.texture = nullptr; + } + video_.textureWidth = 0; + video_.textureHeight = 0; + + if (video_.packet != nullptr) { + av_packet_free(&video_.packet); + } + if (video_.decodedFrame != nullptr) { + av_frame_free(&video_.decodedFrame); + } + if (video_.convertedFrame != nullptr) { + av_frame_free(&video_.convertedFrame); + } + if (video_.codecContext != nullptr) { + avcodec_free_context(&video_.codecContext); + } + if (video_.scaleContext != nullptr) { + sws_freeContext(video_.scaleContext); + video_.scaleContext = nullptr; + } + + video_.convertedBuffer.clear(); + { + std::lock_guard lock(video_.frameMutex); + video_.latestFrame = LatestVideoFrame {}; + } + video_.hasFrame.store(false); + video_.submittedDecodeUnitCount.store(0); + video_.decodedFrameCount.store(0); + } + + int FfmpegStreamBackend::submit_video_decode_unit(PDECODE_UNIT decodeUnit) { + if (decodeUnit == nullptr || video_.codecContext == nullptr || video_.packet == nullptr || video_.decodedFrame == nullptr || video_.convertedFrame == nullptr) { + return DR_NEED_IDR; + } + + const int packetResult = av_new_packet(video_.packet, decodeUnit->fullLength); + if (packetResult < 0) { + logging::error("stream", std::string("av_new_packet failed for video decode: ") + describe_ffmpeg_error(packetResult)); + return DR_NEED_IDR; + } + + int offset = 0; + for (PLENTRY buffer = decodeUnit->bufferList; buffer != nullptr; buffer = buffer->next) { + if (buffer->length <= 0) { + continue; + } + + std::memcpy(video_.packet->data + offset, buffer->data, static_cast(buffer->length)); + offset += buffer->length; + } + video_.packet->size = offset; + + const std::uint64_t submittedDecodeUnitCount = video_.submittedDecodeUnitCount.fetch_add(1) + 1; + if (submittedDecodeUnitCount == 1 || (submittedDecodeUnitCount % STREAM_VIDEO_SUBMISSION_LOG_INTERVAL) == 0) { + const std::uint64_t receiveWindowUs = decodeUnit->enqueueTimeUs >= decodeUnit->receiveTimeUs ? decodeUnit->enqueueTimeUs - decodeUnit->receiveTimeUs : 0; + logging::debug( + "stream", + std::string("Submitted video decode unit ") + std::to_string(submittedDecodeUnitCount) + " frame=" + std::to_string(decodeUnit->frameNumber) + " bytes=" + std::to_string(decodeUnit->fullLength) + " queue_us=" + std::to_string(receiveWindowUs) + ); + } + + const auto receive_available_frames = [&]() -> int { + while (true) { + const int receiveResult = avcodec_receive_frame(video_.codecContext, video_.decodedFrame); + if (receiveResult == AVERROR(EAGAIN) || receiveResult == AVERROR_EOF) { + return receiveResult; + } + if (receiveResult < 0) { + logging::warn("stream", std::string("avcodec_receive_frame failed for H.264: ") + describe_ffmpeg_error(receiveResult)); + return receiveResult; + } + + AVFrame *frameToPresent = video_.decodedFrame; + if (video_.decodedFrame->format != AV_PIX_FMT_YUV420P) { + video_.scaleContext = sws_getCachedContext( + video_.scaleContext, + video_.decodedFrame->width, + video_.decodedFrame->height, + static_cast(video_.decodedFrame->format), + video_.decodedFrame->width, + video_.decodedFrame->height, + AV_PIX_FMT_YUV420P, + SWS_FAST_BILINEAR, + nullptr, + nullptr, + nullptr + ); + if (video_.scaleContext == nullptr) { + logging::warn("stream", "sws_getCachedContext failed for video conversion"); + av_frame_unref(video_.decodedFrame); + return AVERROR(EINVAL); + } + + const int bufferSize = av_image_get_buffer_size(AV_PIX_FMT_YUV420P, video_.decodedFrame->width, video_.decodedFrame->height, 1); + if (bufferSize <= 0) { + logging::warn("stream", "av_image_get_buffer_size failed for converted video frame"); + av_frame_unref(video_.decodedFrame); + return AVERROR(EINVAL); + } + + video_.convertedBuffer.resize(static_cast(bufferSize)); + av_frame_unref(video_.convertedFrame); + video_.convertedFrame->format = AV_PIX_FMT_YUV420P; + video_.convertedFrame->width = video_.decodedFrame->width; + video_.convertedFrame->height = video_.decodedFrame->height; + const int fillResult = av_image_fill_arrays( + video_.convertedFrame->data, + video_.convertedFrame->linesize, + video_.convertedBuffer.data(), + AV_PIX_FMT_YUV420P, + video_.decodedFrame->width, + video_.decodedFrame->height, + 1 + ); + if (fillResult < 0) { + logging::warn("stream", std::string("av_image_fill_arrays failed for converted frame: ") + describe_ffmpeg_error(fillResult)); + av_frame_unref(video_.decodedFrame); + return fillResult; + } + + sws_scale( + video_.scaleContext, + video_.decodedFrame->data, + video_.decodedFrame->linesize, + 0, + video_.decodedFrame->height, + video_.convertedFrame->data, + video_.convertedFrame->linesize + ); + frameToPresent = video_.convertedFrame; + } + + LatestVideoFrame nextFrame {}; + nextFrame.width = frameToPresent->width; + nextFrame.height = frameToPresent->height; + nextFrame.yPitch = frameToPresent->linesize[0]; + nextFrame.uPitch = frameToPresent->linesize[1]; + nextFrame.vPitch = frameToPresent->linesize[2]; + nextFrame.yPlane.resize(static_cast(frameToPresent->linesize[0] * frameToPresent->height)); + nextFrame.uPlane.resize(static_cast(frameToPresent->linesize[1] * ((frameToPresent->height + 1) / 2))); + nextFrame.vPlane.resize(static_cast(frameToPresent->linesize[2] * ((frameToPresent->height + 1) / 2))); + std::memcpy(nextFrame.yPlane.data(), frameToPresent->data[0], nextFrame.yPlane.size()); + std::memcpy(nextFrame.uPlane.data(), frameToPresent->data[1], nextFrame.uPlane.size()); + std::memcpy(nextFrame.vPlane.data(), frameToPresent->data[2], nextFrame.vPlane.size()); + + { + std::lock_guard lock(video_.frameMutex); + video_.latestFrame = std::move(nextFrame); + } + + video_.hasFrame.store(true); + video_.decodedFrameCount.fetch_add(1); + av_frame_unref(video_.decodedFrame); + } + }; + + int sendResult = avcodec_send_packet(video_.codecContext, video_.packet); + if (sendResult == AVERROR(EAGAIN)) { + const int drainResult = receive_available_frames(); + if (drainResult < 0 && drainResult != AVERROR(EAGAIN) && drainResult != AVERROR_EOF) { + av_packet_unref(video_.packet); + return DR_NEED_IDR; + } + sendResult = avcodec_send_packet(video_.codecContext, video_.packet); + } + av_packet_unref(video_.packet); + if (sendResult < 0) { + logging::warn("stream", std::string("avcodec_send_packet failed for H.264: ") + describe_ffmpeg_error(sendResult)); + return DR_NEED_IDR; + } + + const int receiveResult = receive_available_frames(); + if (receiveResult < 0 && receiveResult != AVERROR(EAGAIN) && receiveResult != AVERROR_EOF) { + return DR_NEED_IDR; + } + + return DR_OK; + } + + int FfmpegStreamBackend::initialize_audio_decoder(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, void *context, int arFlags) { + (void) context; + (void) arFlags; + + cleanup_audio_decoder(); + + if (opusConfig == nullptr) { + logging::error("stream", "Moonlight did not provide an Opus configuration for audio startup"); + return -1; + } + + const int channelCount = CHANNEL_COUNT_FROM_AUDIO_CONFIGURATION(audioConfiguration); + if (channelCount <= 0 || channelCount > 2) { + logging::error("stream", "The FFmpeg backend currently supports mono or stereo audio only"); + return -1; + } + + SDL_AudioSpec desiredSpec {}; + desiredSpec.freq = opusConfig->sampleRate > 0 ? opusConfig->sampleRate : 48000; + desiredSpec.format = AUDIO_S16SYS; + desiredSpec.channels = static_cast(channelCount); + desiredSpec.samples = 1024; + desiredSpec.callback = nullptr; + + if ((SDL_WasInit(SDL_INIT_AUDIO) & SDL_INIT_AUDIO) == 0) { + if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) { + logging::error("stream", std::string("SDL_InitSubSystem(SDL_INIT_AUDIO) failed for streaming playback: ") + SDL_GetError()); + cleanup_audio_decoder(); + return -1; + } + } + + audio_.deviceId = SDL_OpenAudioDevice(nullptr, 0, &desiredSpec, &audio_.obtainedSpec, 0); + if (audio_.deviceId == 0) { + logging::error("stream", std::string("SDL_OpenAudioDevice failed for streaming playback: ") + SDL_GetError()); + cleanup_audio_decoder(); + return -1; + } + + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_OPUS); + if (codec == nullptr) { + logging::error("stream", "FFmpeg did not provide an Opus decoder"); + cleanup_audio_decoder(); + return -1; + } + + audio_.codecContext = avcodec_alloc_context3(codec); + audio_.decodedFrame = av_frame_alloc(); + audio_.packet = av_packet_alloc(); + if (audio_.codecContext == nullptr || audio_.decodedFrame == nullptr || audio_.packet == nullptr) { + logging::error("stream", "Failed to allocate FFmpeg audio decoder resources"); + cleanup_audio_decoder(); + return -1; + } + + audio_.codecContext->sample_rate = desiredSpec.freq; + av_channel_layout_default(&audio_.codecContext->ch_layout, channelCount); + if (!configure_opus_extradata(channelCount, desiredSpec.freq, audio_.codecContext)) { + logging::error("stream", "Failed to allocate Opus decoder extradata for FFmpeg"); + cleanup_audio_decoder(); + return -1; + } + + const int openResult = avcodec_open2(audio_.codecContext, codec, nullptr); + if (openResult < 0) { + logging::error("stream", std::string("avcodec_open2 failed for Opus: ") + describe_ffmpeg_error(openResult)); + cleanup_audio_decoder(); + return openResult; + } + + return 0; + } + + void FfmpegStreamBackend::start_audio_playback() { + if (audio_.deviceId != 0) { + SDL_ClearQueuedAudio(audio_.deviceId); + SDL_PauseAudioDevice(audio_.deviceId, 0); + audio_.deviceStarted.store(true); + } + } + + void FfmpegStreamBackend::stop_audio_playback() { + if (audio_.deviceId != 0) { + SDL_PauseAudioDevice(audio_.deviceId, 1); + audio_.deviceStarted.store(false); + } + } + + void FfmpegStreamBackend::cleanup_audio_decoder() { + stop_audio_playback(); + + if (audio_.deviceId != 0) { + SDL_ClearQueuedAudio(audio_.deviceId); + SDL_CloseAudioDevice(audio_.deviceId); + audio_.deviceId = 0; + } + + if (audio_.packet != nullptr) { + av_packet_free(&audio_.packet); + } + if (audio_.decodedFrame != nullptr) { + av_frame_free(&audio_.decodedFrame); + } + if (audio_.codecContext != nullptr) { + avcodec_free_context(&audio_.codecContext); + } + if (audio_.resampleContext != nullptr) { + swr_free(&audio_.resampleContext); + } + + audio_.obtainedSpec = SDL_AudioSpec {}; + audio_.resampleInputSampleRate = 0; + audio_.resampleInputSampleFormat = -1; + audio_.resampleInputChannelCount = 0; + audio_.queuedAudioBytes.store(0); + } + + bool FfmpegStreamBackend::ensure_audio_resampler() { + if (audio_.decodedFrame == nullptr) { + return false; + } + + const int inputSampleRate = audio_.decodedFrame->sample_rate; + const int inputSampleFormat = audio_.decodedFrame->format; + const int inputChannelCount = audio_.decodedFrame->ch_layout.nb_channels; + const bool needsReconfigure = audio_.resampleContext == nullptr || audio_.resampleInputSampleRate != inputSampleRate || audio_.resampleInputSampleFormat != inputSampleFormat || audio_.resampleInputChannelCount != inputChannelCount; + if (!needsReconfigure) { + return true; + } + + if (audio_.resampleContext != nullptr) { + swr_free(&audio_.resampleContext); + } + + AVChannelLayout outputLayout {}; + av_channel_layout_default(&outputLayout, audio_.obtainedSpec.channels); + const int resampleConfigResult = swr_alloc_set_opts2( + &audio_.resampleContext, + &outputLayout, + AV_SAMPLE_FMT_S16, + audio_.obtainedSpec.freq, + &audio_.decodedFrame->ch_layout, + static_cast(audio_.decodedFrame->format), + audio_.decodedFrame->sample_rate, + 0, + nullptr + ); + av_channel_layout_uninit(&outputLayout); + if (resampleConfigResult < 0) { + logging::warn("stream", std::string("swr_alloc_set_opts2 failed for Opus: ") + describe_ffmpeg_error(resampleConfigResult)); + return false; + } + if (audio_.resampleContext == nullptr || swr_init(audio_.resampleContext) < 0) { + logging::warn("stream", "swr_init failed for the streaming audio resampler"); + return false; + } + + audio_.resampleInputSampleRate = inputSampleRate; + audio_.resampleInputSampleFormat = inputSampleFormat; + audio_.resampleInputChannelCount = inputChannelCount; + return true; + } + + void FfmpegStreamBackend::decode_and_play_audio_sample(char *sampleData, int sampleLength) { + if (audio_.codecContext == nullptr || audio_.packet == nullptr || audio_.decodedFrame == nullptr || audio_.deviceId == 0) { + return; + } + + if (sampleData == nullptr || sampleLength <= 0) { + return; + } + + const int packetResult = av_new_packet(audio_.packet, sampleLength); + if (packetResult < 0) { + logging::warn("stream", std::string("av_new_packet failed for audio decode: ") + describe_ffmpeg_error(packetResult)); + return; + } + + std::memcpy(audio_.packet->data, sampleData, static_cast(sampleLength)); + audio_.packet->size = sampleLength; + + const int sendResult = avcodec_send_packet(audio_.codecContext, audio_.packet); + av_packet_unref(audio_.packet); + if (sendResult < 0 && sendResult != AVERROR(EAGAIN)) { + logging::warn("stream", std::string("avcodec_send_packet failed for Opus: ") + describe_ffmpeg_error(sendResult)); + return; + } + + while (true) { + const int receiveResult = avcodec_receive_frame(audio_.codecContext, audio_.decodedFrame); + if (receiveResult == AVERROR(EAGAIN) || receiveResult == AVERROR_EOF) { + break; + } + if (receiveResult < 0) { + logging::warn("stream", std::string("avcodec_receive_frame failed for Opus: ") + describe_ffmpeg_error(receiveResult)); + return; + } + + if (!ensure_audio_resampler()) { + av_frame_unref(audio_.decodedFrame); + return; + } + + const int outputSamples = av_rescale_rnd( + swr_get_delay(audio_.resampleContext, audio_.decodedFrame->sample_rate) + audio_.decodedFrame->nb_samples, + audio_.obtainedSpec.freq, + audio_.decodedFrame->sample_rate, + AV_ROUND_UP + ); + const int outputBufferSize = av_samples_get_buffer_size( + nullptr, + audio_.obtainedSpec.channels, + outputSamples, + AV_SAMPLE_FMT_S16, + 1 + ); + if (outputBufferSize <= 0) { + logging::warn("stream", "av_samples_get_buffer_size failed for decoded audio output"); + av_frame_unref(audio_.decodedFrame); + return; + } + + std::vector outputBuffer(static_cast(outputBufferSize)); + std::uint8_t *outputData[] = {outputBuffer.data()}; + const int convertedSamples = swr_convert( + audio_.resampleContext, + outputData, + outputSamples, + const_cast(audio_.decodedFrame->extended_data), + audio_.decodedFrame->nb_samples + ); + if (convertedSamples < 0) { + logging::warn("stream", std::string("swr_convert failed for Opus: ") + describe_ffmpeg_error(convertedSamples)); + av_frame_unref(audio_.decodedFrame); + return; + } + + const int convertedBytes = convertedSamples * audio_.obtainedSpec.channels * static_cast(sizeof(std::int16_t)); + const Uint32 maxQueuedBytes = (audio_bytes_per_second(audio_.obtainedSpec) * STREAM_OVERLAY_AUDIO_QUEUE_LIMIT_MS) / 1000U; + if (SDL_GetQueuedAudioSize(audio_.deviceId) > maxQueuedBytes) { + SDL_ClearQueuedAudio(audio_.deviceId); + } + if (convertedBytes > 0 && SDL_QueueAudio(audio_.deviceId, outputBuffer.data(), static_cast(convertedBytes)) == 0) { + audio_.queuedAudioBytes.fetch_add(static_cast(convertedBytes)); + } + + av_frame_unref(audio_.decodedFrame); + } + } + +} // namespace streaming diff --git a/src/streaming/ffmpeg_stream_backend.h b/src/streaming/ffmpeg_stream_backend.h new file mode 100644 index 0000000..a683e1a --- /dev/null +++ b/src/streaming/ffmpeg_stream_backend.h @@ -0,0 +1,220 @@ +/** + * @file src/streaming/ffmpeg_stream_backend.h + * @brief Declares the FFmpeg-backed streaming decode backend for Xbox sessions. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include + +// lib includes +#include + +// local includes +#include "third-party/moonlight-common-c/src/Limelight.h" + +struct AVCodecContext; +struct AVFrame; +struct AVPacket; +struct SwrContext; +struct SwsContext; + +namespace streaming { + + /** + * @brief Owns the FFmpeg decode and SDL presentation state for one stream. + * + * The backend exposes Moonlight-compatible callback tables for video and audio, + * decodes H.264 video and Opus stereo audio with FFmpeg, presents the most + * recent decoded frame through SDL, and queues decoded PCM samples to SDL audio. + */ + class FfmpegStreamBackend { + public: + /** + * @brief Construct an empty backend. + */ + FfmpegStreamBackend() = default; + + /** + * @brief Destroy the backend and release all resources. + */ + ~FfmpegStreamBackend(); + + FfmpegStreamBackend(const FfmpegStreamBackend &) = delete; + FfmpegStreamBackend &operator=(const FfmpegStreamBackend &) = delete; + + /** + * @brief Populate Moonlight callback tables for this backend. + * + * @param videoCallbacks Output video callback table. + * @param audioCallbacks Output audio callback table. + */ + void initialize_callbacks(DECODER_RENDERER_CALLBACKS *videoCallbacks, AUDIO_RENDERER_CALLBACKS *audioCallbacks); + + /** + * @brief Release all FFmpeg, SDL, and cached frame resources. + */ + void shutdown(); + + /** + * @brief Render the latest decoded video frame to the supplied renderer. + * + * @param renderer SDL renderer used by the stream session. + * @param screenWidth Current renderer output width. + * @param screenHeight Current renderer output height. + * @return True when a decoded frame was available and rendered. + */ + bool render_latest_video_frame(SDL_Renderer *renderer, int screenWidth, int screenHeight); + + /** + * @brief Report whether at least one decoded video frame is available. + * + * @return True when a decoded frame is ready for presentation. + */ + bool has_decoded_video() const; + + /** + * @brief Build a short user-visible media status line. + * + * @return Summary of decoded video and queued audio state. + */ + std::string build_overlay_status_line() const; + + /** + * @brief Initialize the FFmpeg H.264 decoder for Moonlight video callbacks. + * + * @param videoFormat Negotiated Moonlight video format. + * @param width Negotiated stream width. + * @param height Negotiated stream height. + * @param redrawRate Negotiated redraw rate. + * @param context Moonlight renderer context. + * @param drFlags Moonlight decoder flags. + * @return Zero on success. + */ + int setup_video_decoder(int videoFormat, int width, int height, int redrawRate, void *context, int drFlags); + + /** + * @brief Start the video decode path. + */ + void start_video_decoder(); + + /** + * @brief Stop the video decode path. + */ + void stop_video_decoder(); + + /** + * @brief Clean up all FFmpeg video decode resources. + */ + void cleanup_video_decoder(); + + /** + * @brief Submit one Moonlight decode unit to FFmpeg. + * + * @param decodeUnit Moonlight Annex B frame payload. + * @return Moonlight decoder status code. + */ + int submit_video_decode_unit(PDECODE_UNIT decodeUnit); + + /** + * @brief Initialize the FFmpeg Opus decoder and SDL playback device. + * + * @param audioConfiguration Negotiated Moonlight audio configuration. + * @param opusConfig Negotiated Opus multistream parameters. + * @param context Moonlight audio context. + * @param arFlags Moonlight audio renderer flags. + * @return Zero on success. + */ + int initialize_audio_decoder(int audioConfiguration, const POPUS_MULTISTREAM_CONFIGURATION opusConfig, void *context, int arFlags); + + /** + * @brief Start SDL audio playback. + */ + void start_audio_playback(); + + /** + * @brief Stop SDL audio playback. + */ + void stop_audio_playback(); + + /** + * @brief Clean up all FFmpeg audio decode resources. + */ + void cleanup_audio_decoder(); + + /** + * @brief Ensure the audio resampler matches the current decoded frame. + * + * @return True when the resampler is ready for audio conversion. + */ + bool ensure_audio_resampler(); + + /** + * @brief Decode and queue one Moonlight Opus audio payload. + * + * @param sampleData Encoded Opus payload. + * @param sampleLength Encoded payload size in bytes. + */ + void decode_and_play_audio_sample(char *sampleData, int sampleLength); + + private: + /** + * @brief Hold the latest IYUV video frame ready for SDL upload. + */ + struct LatestVideoFrame { + int width = 0; + int height = 0; + int yPitch = 0; + int uPitch = 0; + int vPitch = 0; + std::vector yPlane; + std::vector uPlane; + std::vector vPlane; + }; + + /** + * @brief Hold FFmpeg and SDL state used by the video path. + */ + struct VideoState { + AVCodecContext *codecContext = nullptr; + SwsContext *scaleContext = nullptr; + AVFrame *decodedFrame = nullptr; + AVFrame *convertedFrame = nullptr; + AVPacket *packet = nullptr; + SDL_Texture *texture = nullptr; + int textureWidth = 0; + int textureHeight = 0; + std::vector convertedBuffer; + mutable std::mutex frameMutex; + LatestVideoFrame latestFrame; + std::atomic hasFrame = false; + std::atomic submittedDecodeUnitCount = 0; + std::atomic decodedFrameCount = 0; + }; + + /** + * @brief Hold FFmpeg and SDL state used by the audio path. + */ + struct AudioState { + AVCodecContext *codecContext = nullptr; + SwrContext *resampleContext = nullptr; + AVFrame *decodedFrame = nullptr; + AVPacket *packet = nullptr; + SDL_AudioDeviceID deviceId = 0; + SDL_AudioSpec obtainedSpec {}; + int resampleInputSampleRate = 0; + int resampleInputSampleFormat = -1; + int resampleInputChannelCount = 0; + std::atomic deviceStarted = false; + std::atomic queuedAudioBytes = 0; + }; + + VideoState video_ {}; + AudioState audio_ {}; + }; + +} // namespace streaming diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp new file mode 100644 index 0000000..9abdc9c --- /dev/null +++ b/src/streaming/session.cpp @@ -0,0 +1,1141 @@ +/** + * @file src/streaming/session.cpp + * @brief Implements the Xbox streaming session runtime. + */ +#include "src/streaming/session.h" + +#include "src/logging/logger.h" +#include "src/os.h" +#include "src/startup/memory_stats.h" +#include "src/streaming/ffmpeg_stream_backend.h" +#include "src/streaming/stats_overlay.h" +#include "third-party/moonlight-common-c/src/Limelight.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // NOSONAR(cpp:S3806) nxdk requires lowercase header names + +#ifdef NXDK +/** + * @brief Fill an integer with cryptographically strong random bytes on nxdk. + * + * @param randomValue Output integer populated with secure random bits. + * @return Zero on success, or a non-zero platform error code on failure. + */ +extern "C" int rand_s(unsigned int *randomValue); +#endif + +namespace { + + constexpr Uint8 BACKGROUND_RED = 0x08; + constexpr Uint8 BACKGROUND_GREEN = 0x0A; + constexpr Uint8 BACKGROUND_BLUE = 0x10; + constexpr Uint8 TEXT_RED = 0xF2; + constexpr Uint8 TEXT_GREEN = 0xF5; + constexpr Uint8 TEXT_BLUE = 0xF8; + constexpr Uint8 ACCENT_RED = 0x00; + constexpr Uint8 ACCENT_GREEN = 0xF3; + constexpr Uint8 ACCENT_BLUE = 0xD4; + constexpr Uint32 STREAM_EXIT_COMBO_HOLD_MILLISECONDS = 900U; + constexpr Uint32 STREAM_FRAME_DELAY_MILLISECONDS = 16U; + constexpr int DEFAULT_STREAM_FPS = 20; + constexpr int DEFAULT_STREAM_BITRATE_KBPS = 1500; + constexpr int MIN_STREAM_FPS = 15; + constexpr int MAX_STREAM_FPS = 30; + constexpr int MIN_STREAM_BITRATE_KBPS = 250; + constexpr int MAX_STREAM_BITRATE_KBPS = 50000; + constexpr int DEFAULT_PACKET_SIZE = 1024; + constexpr std::size_t MAX_CONNECTION_PROTOCOL_MESSAGES = 24U; + constexpr uint16_t PRESENT_GAMEPAD_MASK = 0x0001; + constexpr uint32_t CONTROLLER_BUTTON_CAPABILITIES = + static_cast(A_FLAG | B_FLAG | X_FLAG | Y_FLAG | UP_FLAG | DOWN_FLAG | LEFT_FLAG | RIGHT_FLAG | LB_FLAG | RB_FLAG | PLAY_FLAG | BACK_FLAG | LS_CLK_FLAG | RS_CLK_FLAG | SPECIAL_FLAG); + constexpr uint16_t CONTROLLER_CAPABILITIES = LI_CCAP_ANALOG_TRIGGERS | LI_CCAP_RUMBLE; + + struct StreamUiResources { + SDL_Renderer *renderer = nullptr; + TTF_Font *titleFont = nullptr; + TTF_Font *bodyFont = nullptr; + SDL_GameController *controller = nullptr; + streaming::FfmpegStreamBackend mediaBackend {}; + bool ttfInitialized = false; + }; + + struct ControllerSnapshot { + int buttonFlags = 0; + unsigned char leftTrigger = 0; + unsigned char rightTrigger = 0; + short leftStickX = 0; + short leftStickY = 0; + short rightStickX = 0; + short rightStickY = 0; + }; + + struct StreamConnectionState { + std::atomic currentStage = STAGE_NONE; + std::atomic failedStage = STAGE_NONE; + std::atomic failedCode = 0; + std::atomic terminationError = 0; + std::atomic startResult = -1; + std::atomic startCompleted = false; + std::atomic connectionStarted = false; + std::atomic connectionTerminated = false; + std::atomic poorConnection = false; + std::atomic stopRequested = false; + mutable std::mutex protocolLogMutex; + std::deque recentProtocolMessages; + }; + + struct StreamStartContext { + StreamConnectionState *connectionState = nullptr; + STREAM_CONFIGURATION streamConfiguration {}; + SERVER_INFORMATION serverInformation {}; + CONNECTION_LISTENER_CALLBACKS connectionCallbacks {}; + DECODER_RENDERER_CALLBACKS videoCallbacks {}; + AUDIO_RENDERER_CALLBACKS audioCallbacks {}; + streaming::FfmpegStreamBackend *mediaBackend = nullptr; + std::string address; + std::string reportedAppVersion; + std::string appVersion; + std::string gfeVersion; + std::string rtspSessionUrl; + }; + + struct ResolvedStreamParameters { + VIDEO_MODE videoMode {}; + int fps = DEFAULT_STREAM_FPS; + int bitrateKbps = DEFAULT_STREAM_BITRATE_KBPS; + int packetSize = DEFAULT_PACKET_SIZE; + int streamingRemotely = STREAM_CFG_AUTO; + }; + + /** + * @brief Describe the active Moonlight network profile for logging. + * + * @param streamingRemotely Moonlight stream locality mode. + * @return Human-readable profile label. + */ + const char *describe_streaming_remotely_mode(int streamingRemotely) { + switch (streamingRemotely) { + case STREAM_CFG_LOCAL: + return "local"; + case STREAM_CFG_REMOTE: + return "remote"; + default: + return "auto"; + } + } + + StreamConnectionState *g_active_connection_state = nullptr; + + /** + * @brief Remove trailing CR and LF characters from a log line. + * + * @param message Candidate log line. + * @return Trimmed log line. + */ + std::string trim_trailing_line_breaks(std::string message) { + while (!message.empty() && (message.back() == '\n' || message.back() == '\r')) { + message.pop_back(); + } + return message; + } + + /** + * @brief Return a printable fallback for optional log fields. + * + * @param value Candidate string value. + * @return The original value, or `` when empty. + */ + std::string printable_log_value(std::string_view value) { + return value.empty() ? std::string("") : std::string(value); + } + + /** + * @brief Return whether a string begins with the requested ASCII prefix. + * + * @param text Candidate text. + * @param prefix Expected prefix. + * @return True when the prefix matches case-insensitively. + */ + bool starts_with_ascii_case_insensitive(std::string_view text, std::string_view prefix) { + if (text.size() < prefix.size()) { + return false; + } + + for (std::size_t index = 0; index < prefix.size(); ++index) { + const unsigned char textCharacter = static_cast(text[index]); + const unsigned char prefixCharacter = static_cast(prefix[index]); + if (std::tolower(textCharacter) != std::tolower(prefixCharacter)) { + return false; + } + } + + return true; + } + + /** + * @brief Return whether the host metadata indicates Sunshine. + * + * @param gfeVersion Reported GFE or Sunshine version string. + * @return True when the host looks like Sunshine. + */ + bool is_sunshine_host_version(std::string_view gfeVersion) { + return starts_with_ascii_case_insensitive(gfeVersion, "Sunshine"); + } + + /** + * @brief Normalize the host appversion string for Sunshine protocol selection. + * + * moonlight-common-c uses the appversion quad to pick RTSP behavior and marks + * Sunshine hosts by making the fourth component negative. Sunshine also tracks + * the newer Gen 7 TCP RTSP flow, so older emulated appversion values are + * normalized to the 7.1.431 generation before the connection starts. + * + * @param appVersion Reported appversion from the host launch response. + * @param gfeVersion Reported GFE or Sunshine version string. + * @return Appversion string to pass into moonlight-common-c. + */ + std::string normalize_streaming_app_version(std::string_view appVersion, std::string_view gfeVersion) { + if (!is_sunshine_host_version(gfeVersion)) { + return std::string(appVersion); + } + + int major = 7; + int minor = 1; + int patch = 431; + int build = 0; + const int parsedFields = std::sscanf(std::string(appVersion).c_str(), "%d.%d.%d.%d", &major, &minor, &patch, &build); + if (parsedFields < 3) { + return "7.1.431.-1"; + } + + if (major < 7 || (major == 7 && minor < 1) || (major == 7 && minor == 1 && patch < 431)) { + major = 7; + minor = 1; + patch = 431; + } + + return std::to_string(major) + "." + std::to_string(minor) + "." + std::to_string(patch) + ".-1"; + } + + /** + * @brief Format one `moonlight-common-c` log callback line. + * + * @param format `printf`-style format string supplied by the library. + * @param arguments Variadic argument list matching `format`. + * @return Formatted log line. + */ + std::string format_connection_log_message(const char *format, va_list arguments) { + if (format == nullptr) { + return {}; + } + + std::array stackBuffer {}; + va_list argumentsCopy; + va_copy(argumentsCopy, arguments); + const int requiredLength = std::vsnprintf(stackBuffer.data(), stackBuffer.size(), format, argumentsCopy); + va_end(argumentsCopy); + if (requiredLength < 0) { + return {}; + } + if (static_cast(requiredLength) < stackBuffer.size()) { + return trim_trailing_line_breaks(std::string(stackBuffer.data(), static_cast(requiredLength))); + } + + std::string dynamicBuffer(static_cast(requiredLength) + 1U, '\0'); + va_copy(argumentsCopy, arguments); + std::vsnprintf(dynamicBuffer.data(), dynamicBuffer.size(), format, argumentsCopy); + va_end(argumentsCopy); + dynamicBuffer.resize(static_cast(requiredLength)); + return trim_trailing_line_breaks(std::move(dynamicBuffer)); + } + + /** + * @brief Append one connection-protocol log line to the retained rolling buffer. + * + * @param connectionState Connection state owning the retained protocol log buffer. + * @param message Log line to retain. + */ + void append_connection_protocol_message(StreamConnectionState *connectionState, std::string message) { + if (connectionState == nullptr) { + return; + } + + message = trim_trailing_line_breaks(std::move(message)); + if (message.empty()) { + return; + } + + std::lock_guard lock(connectionState->protocolLogMutex); + if (connectionState->recentProtocolMessages.size() >= MAX_CONNECTION_PROTOCOL_MESSAGES) { + connectionState->recentProtocolMessages.pop_front(); + } + connectionState->recentProtocolMessages.push_back(std::move(message)); + } + + /** + * @brief Return the most recent retained connection-protocol line. + * + * @param connectionState Connection state holding retained protocol logs. + * @return Latest protocol message, or an empty string when none was recorded. + */ + std::string latest_connection_protocol_message(const StreamConnectionState &connectionState) { + std::lock_guard lock(connectionState.protocolLogMutex); + return connectionState.recentProtocolMessages.empty() ? std::string {} : connectionState.recentProtocolMessages.back(); + } + + /** + * @brief Reset the mutable connection state before retrying stream startup. + * + * @param connectionState State object to reset. + */ + void reset_connection_state(StreamConnectionState *connectionState) { + if (connectionState == nullptr) { + return; + } + + connectionState->currentStage.store(STAGE_NONE); + connectionState->failedStage.store(STAGE_NONE); + connectionState->failedCode.store(0); + connectionState->terminationError.store(0); + connectionState->startResult.store(-1); + connectionState->startCompleted.store(false); + connectionState->connectionStarted.store(false); + connectionState->connectionTerminated.store(false); + connectionState->poorConnection.store(false); + connectionState->stopRequested.store(false); + std::lock_guard lock(connectionState->protocolLogMutex); + connectionState->recentProtocolMessages.clear(); + } + + std::string build_font_path() { + return std::string(DATA_PATH) + "assets" + PATH_SEP + "fonts" + PATH_SEP + "vegur-regular.ttf"; + } + + void close_controller(SDL_GameController *controller) { + if (controller != nullptr) { + SDL_GameControllerClose(controller); + } + } + + void close_stream_ui_resources(StreamUiResources *resources) { + if (resources == nullptr) { + return; + } + + resources->mediaBackend.shutdown(); + close_controller(resources->controller); + resources->controller = nullptr; + if (resources->bodyFont != nullptr) { + TTF_CloseFont(resources->bodyFont); + resources->bodyFont = nullptr; + } + if (resources->titleFont != nullptr) { + TTF_CloseFont(resources->titleFont); + resources->titleFont = nullptr; + } + if (resources->renderer != nullptr) { + SDL_DestroyRenderer(resources->renderer); + resources->renderer = nullptr; + } + if (resources->ttfInitialized) { + TTF_Quit(); + resources->ttfInitialized = false; + } + } + + SDL_GameController *open_primary_controller() { + for (int joystickIndex = 0; joystickIndex < SDL_NumJoysticks(); ++joystickIndex) { + if (!SDL_IsGameController(joystickIndex)) { + continue; + } + + SDL_GameController *controller = SDL_GameControllerOpen(joystickIndex); + if (controller != nullptr) { + return controller; + } + } + + return nullptr; + } + + bool initialize_stream_ui_resources(SDL_Window *window, const VIDEO_MODE &videoMode, StreamUiResources *resources, std::string *errorMessage) { + if (window == nullptr || resources == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = "Streaming requires a valid SDL window"; + } + return false; + } + + if (TTF_Init() != 0) { + if (errorMessage != nullptr) { + *errorMessage = std::string("TTF_Init failed for the streaming session: ") + TTF_GetError(); + } + return false; + } + resources->ttfInitialized = true; + + resources->renderer = SDL_CreateRenderer(window, -1, 0); + if (resources->renderer == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = std::string("SDL_CreateRenderer failed for the streaming session: ") + SDL_GetError(); + } + close_stream_ui_resources(resources); + return false; + } + + const std::string fontPath = build_font_path(); + resources->titleFont = TTF_OpenFont(fontPath.c_str(), std::max(22, videoMode.height / 18)); + resources->bodyFont = TTF_OpenFont(fontPath.c_str(), std::max(16, videoMode.height / 28)); + if (resources->titleFont == nullptr || resources->bodyFont == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = std::string("Failed to load the streaming session font from ") + fontPath + ": " + TTF_GetError(); + } + close_stream_ui_resources(resources); + return false; + } + + resources->controller = open_primary_controller(); + return true; + } + + bool fill_random_bytes(unsigned char *buffer, std::size_t size, std::string *errorMessage) { +#ifdef NXDK + if (buffer == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = "Streaming could not allocate the random-input key buffer"; + } + return false; + } + + std::size_t offset = 0; + while (offset < size) { + unsigned int randomValue = 0; + if (::rand_s(&randomValue) != 0) { + if (errorMessage != nullptr) { + *errorMessage = "Failed to generate secure random bytes for the streaming input keys"; + } + return false; + } + + const std::size_t chunkSize = std::min(sizeof(randomValue), size - offset); + std::memcpy(buffer + offset, &randomValue, chunkSize); + offset += chunkSize; + } + return true; +#else + (void) buffer; + (void) size; + if (errorMessage != nullptr) { + *errorMessage = "The streaming runtime is only supported on the Xbox build"; + } + return false; +#endif + } + + std::string hex_encode(const unsigned char *data, std::size_t size) { + static constexpr char HEX_DIGITS[] = "0123456789abcdef"; + + std::string output; + output.resize(size * 2U); + for (std::size_t index = 0; index < size; ++index) { + output[index * 2U] = HEX_DIGITS[(data[index] >> 4U) & 0x0F]; + output[(index * 2U) + 1U] = HEX_DIGITS[data[index] & 0x0F]; + } + return output; + } + + int select_stream_width(const VIDEO_MODE &videoMode) { + return videoMode.width > 0 ? std::max(320, static_cast(videoMode.width)) : 640; + } + + int select_stream_height(const VIDEO_MODE &videoMode) { + return videoMode.height > 0 ? std::max(240, static_cast(videoMode.height)) : 480; + } + + int select_client_refresh_rate_x100(const VIDEO_MODE &videoMode) { + return videoMode.refresh > 0 ? static_cast(videoMode.refresh) * 100 : 6000; + } + + /** + * @brief Resolve the effective stream resolution to request for the current stream. + * + * @param fallbackVideoMode Active shell output mode. + * @param settings Current shell settings. + * @return Preferred stream resolution, or fallbackVideoMode when no override exists. + */ + VIDEO_MODE select_effective_stream_video_mode(const VIDEO_MODE &fallbackVideoMode, const app::SettingsState &settings) { + return settings.preferredVideoModeSet ? settings.preferredVideoMode : fallbackVideoMode; + } + + /** + * @brief Clamp the preferred frame rate into the supported stream range. + * + * @param settings Current shell settings. + * @return Effective frame rate for the next stream. + */ + int select_stream_fps(const app::SettingsState &settings) { + return std::clamp(settings.streamFramerate, MIN_STREAM_FPS, MAX_STREAM_FPS); + } + + /** + * @brief Clamp the preferred bitrate into a reasonable stream range. + * + * @param settings Current shell settings. + * @return Effective bitrate for the next stream in kilobits per second. + */ + int select_stream_bitrate_kbps(const app::SettingsState &settings) { + return std::clamp(settings.streamBitrateKbps, MIN_STREAM_BITRATE_KBPS, MAX_STREAM_BITRATE_KBPS); + } + + /** + * @brief Resolve the effective stream parameters for the next session. + * + * @param fallbackVideoMode Active shell output mode. + * @param settings Current shell settings. + * @return Stream parameters requested through the settings UI. + */ + ResolvedStreamParameters resolve_stream_parameters(const VIDEO_MODE &fallbackVideoMode, const app::SettingsState &settings) { + ResolvedStreamParameters resolved {}; + resolved.videoMode = select_effective_stream_video_mode(fallbackVideoMode, settings); + resolved.fps = select_stream_fps(settings); + resolved.bitrateKbps = select_stream_bitrate_kbps(settings); + + return resolved; + } + + void configure_stream_start_context( + const network::StreamLaunchResult &launchResult, + const std::array &remoteInputKey, + const std::array &remoteInputIv, + const VIDEO_MODE &outputVideoMode, + const ResolvedStreamParameters &streamParameters, + StreamStartContext *context + ) { + if (context == nullptr) { + return; + } + + context->address = launchResult.serverInfo.activeAddress; + if (context->address.empty()) { + context->address = launchResult.serverInfo.localAddress; + } + if (context->address.empty()) { + context->address = launchResult.serverInfo.remoteAddress; + } + context->reportedAppVersion = launchResult.appVersion; + context->appVersion = normalize_streaming_app_version(launchResult.appVersion, launchResult.gfeVersion); + context->gfeVersion = launchResult.gfeVersion; + context->rtspSessionUrl = launchResult.rtspSessionUrl; + + LiInitializeServerInformation(&context->serverInformation); + context->serverInformation.address = context->address.c_str(); + context->serverInformation.serverInfoAppVersion = context->appVersion.c_str(); + context->serverInformation.serverInfoGfeVersion = context->gfeVersion.empty() ? nullptr : context->gfeVersion.c_str(); + context->serverInformation.rtspSessionUrl = context->rtspSessionUrl.empty() ? nullptr : context->rtspSessionUrl.c_str(); + context->serverInformation.serverCodecModeSupport = launchResult.serverCodecModeSupport; + + LiInitializeStreamConfiguration(&context->streamConfiguration); + context->streamConfiguration.width = select_stream_width(streamParameters.videoMode); + context->streamConfiguration.height = select_stream_height(streamParameters.videoMode); + context->streamConfiguration.fps = streamParameters.fps; + context->streamConfiguration.bitrate = streamParameters.bitrateKbps; + context->streamConfiguration.packetSize = streamParameters.packetSize; + context->streamConfiguration.streamingRemotely = streamParameters.streamingRemotely; + context->streamConfiguration.audioConfiguration = AUDIO_CONFIGURATION_STEREO; + context->streamConfiguration.supportedVideoFormats = VIDEO_FORMAT_H264; + context->streamConfiguration.clientRefreshRateX100 = select_client_refresh_rate_x100(outputVideoMode); + context->streamConfiguration.colorSpace = COLORSPACE_REC_601; + context->streamConfiguration.colorRange = COLOR_RANGE_LIMITED; + context->streamConfiguration.encryptionFlags = ENCFLG_NONE; + std::memcpy(context->streamConfiguration.remoteInputAesKey, remoteInputKey.data(), remoteInputKey.size()); + std::memcpy(context->streamConfiguration.remoteInputAesIv, remoteInputIv.data(), remoteInputIv.size()); + + LiInitializeConnectionCallbacks(&context->connectionCallbacks); + } + + void on_stage_starting(int stage) { + if (g_active_connection_state != nullptr) { + g_active_connection_state->currentStage.store(stage); + } + logging::debug("stream", std::string("Starting connection stage: ") + LiGetStageName(stage)); + } + + void on_stage_complete(int stage) { + if (g_active_connection_state != nullptr) { + g_active_connection_state->currentStage.store(stage); + } + logging::debug("stream", std::string("Completed connection stage: ") + LiGetStageName(stage)); + } + + void on_stage_failed(int stage, int errorCode) { + if (g_active_connection_state != nullptr) { + g_active_connection_state->failedStage.store(stage); + g_active_connection_state->failedCode.store(errorCode); + } + logging::warn("stream", std::string("Connection stage failed: ") + LiGetStageName(stage) + " (error " + std::to_string(errorCode) + ")"); + } + + void on_connection_started() { + if (g_active_connection_state != nullptr) { + g_active_connection_state->connectionStarted.store(true); + } + logging::info("stream", "Streaming transport started"); + } + + void on_connection_terminated(int errorCode) { + if (g_active_connection_state != nullptr) { + g_active_connection_state->terminationError.store(errorCode); + g_active_connection_state->connectionTerminated.store(true); + } + logging::warn("stream", std::string("Streaming transport terminated with error ") + std::to_string(errorCode)); + } + + void on_connection_status_update(int connectionStatus) { + if (g_active_connection_state != nullptr) { + const bool poorConnection = connectionStatus != CONN_STATUS_OKAY; + if (g_active_connection_state->poorConnection.exchange(poorConnection) != poorConnection) { + logging::warn("stream", poorConnection ? "Streaming transport reported poor network conditions" : "Streaming transport recovered to okay network conditions"); + } + } + } + + void on_log_message(const char *format, ...) { + va_list arguments; + va_start(arguments, format); + const std::string message = format_connection_log_message(format, arguments); + va_end(arguments); + if (message.empty()) { + return; + } + + append_connection_protocol_message(g_active_connection_state, message); + logging::debug("moonlight", message); + } + + int run_stream_start_thread(void *context) { + auto *startContext = static_cast(context); + if (startContext == nullptr || startContext->connectionState == nullptr) { + return -1; + } + + g_active_connection_state = startContext->connectionState; + startContext->connectionState->startResult.store( + LiStartConnection( + &startContext->serverInformation, + &startContext->streamConfiguration, + &startContext->connectionCallbacks, + &startContext->videoCallbacks, + &startContext->audioCallbacks, + startContext->mediaBackend, + 0, + startContext->mediaBackend, + 0 + ) + ); + startContext->connectionState->startCompleted.store(true); + return 0; + } + + bool render_text_line(SDL_Renderer *renderer, TTF_Font *font, const std::string &text, SDL_Color color, int x, int y, int maxWidth, int *drawnHeight = nullptr) { + if (renderer == nullptr || font == nullptr || maxWidth <= 0) { + if (drawnHeight != nullptr) { + *drawnHeight = 0; + } + return false; + } + + SDL_Surface *surface = TTF_RenderUTF8_Blended_Wrapped(font, text.c_str(), color, static_cast(maxWidth)); + if (surface == nullptr) { + if (drawnHeight != nullptr) { + *drawnHeight = 0; + } + return false; + } + + SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, surface); + if (texture == nullptr) { + SDL_FreeSurface(surface); + if (drawnHeight != nullptr) { + *drawnHeight = 0; + } + return false; + } + + const SDL_Rect destination {x, y, surface->w, surface->h}; + const int surfaceHeight = surface->h; + SDL_FreeSurface(surface); + const bool rendered = SDL_RenderCopy(renderer, texture, nullptr, &destination) == 0; + SDL_DestroyTexture(texture); + if (drawnHeight != nullptr) { + *drawnHeight = surfaceHeight; + } + return rendered; + } + + std::string build_stage_status_line(const StreamConnectionState &connectionState) { + const int failedStage = connectionState.failedStage.load(); + if (failedStage != STAGE_NONE) { + return std::string("Connection failed during ") + LiGetStageName(failedStage) + " (error " + std::to_string(connectionState.failedCode.load()) + ")"; + } + if (connectionState.connectionTerminated.load()) { + const int terminationError = connectionState.terminationError.load(); + return terminationError == ML_ERROR_GRACEFUL_TERMINATION ? "The host ended the stream gracefully." : "The host ended the stream (error " + std::to_string(terminationError) + ")"; + } + if (connectionState.connectionStarted.load()) { + return "Streaming transport is active. Hold Back + Start to stop."; + } + return std::string("Connecting: ") + LiGetStageName(connectionState.currentStage.load()); + } + + void pump_stream_events(StreamUiResources *resources) { + SDL_Event event {}; + while (SDL_PollEvent(&event) != 0) { + if (event.type == SDL_CONTROLLERDEVICEADDED && resources != nullptr && resources->controller == nullptr) { + resources->controller = SDL_GameControllerOpen(event.cdevice.which); + } + } + } + + unsigned char convert_trigger_axis(Sint16 value) { + const int normalized = std::clamp(static_cast(value), 0, 32767); + return static_cast((normalized * 255) / 32767); + } + + ControllerSnapshot read_controller_snapshot(SDL_GameController *controller) { + ControllerSnapshot snapshot {}; + if (controller == nullptr) { + return snapshot; + } + + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_A) != 0) { + snapshot.buttonFlags |= A_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_B) != 0) { + snapshot.buttonFlags |= B_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_X) != 0) { + snapshot.buttonFlags |= X_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_Y) != 0) { + snapshot.buttonFlags |= Y_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_UP) != 0) { + snapshot.buttonFlags |= UP_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_DOWN) != 0) { + snapshot.buttonFlags |= DOWN_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_LEFT) != 0) { + snapshot.buttonFlags |= LEFT_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_RIGHT) != 0) { + snapshot.buttonFlags |= RIGHT_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_LEFTSHOULDER) != 0) { + snapshot.buttonFlags |= LB_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) != 0) { + snapshot.buttonFlags |= RB_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_START) != 0) { + snapshot.buttonFlags |= PLAY_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_BACK) != 0) { + snapshot.buttonFlags |= BACK_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_LEFTSTICK) != 0) { + snapshot.buttonFlags |= LS_CLK_FLAG; + } + if (SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_RIGHTSTICK) != 0) { + snapshot.buttonFlags |= RS_CLK_FLAG; + } + + snapshot.leftTrigger = convert_trigger_axis(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERLEFT)); + snapshot.rightTrigger = convert_trigger_axis(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERRIGHT)); + snapshot.leftStickX = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTX); + snapshot.leftStickY = static_cast(-SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTY)); + snapshot.rightStickX = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_RIGHTX); + snapshot.rightStickY = static_cast(-SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_RIGHTY)); + return snapshot; + } + + bool controller_snapshots_match(const ControllerSnapshot &left, const ControllerSnapshot &right) { + return left.buttonFlags == right.buttonFlags && left.leftTrigger == right.leftTrigger && left.rightTrigger == right.rightTrigger && left.leftStickX == right.leftStickX && left.leftStickY == right.leftStickY && + left.rightStickX == right.rightStickX && left.rightStickY == right.rightStickY; + } + + void update_stream_exit_combo(SDL_GameController *controller, Uint32 *comboActivatedTick, StreamConnectionState *connectionState) { + if (comboActivatedTick == nullptr || connectionState == nullptr || controller == nullptr) { + return; + } + + const bool backPressed = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_BACK) != 0; + const bool startPressed = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_START) != 0; + if (!backPressed || !startPressed) { + *comboActivatedTick = 0U; + return; + } + + const Uint32 now = SDL_GetTicks(); + if (*comboActivatedTick == 0U) { + *comboActivatedTick = now; + return; + } + if (now - *comboActivatedTick >= STREAM_EXIT_COMBO_HOLD_MILLISECONDS) { + connectionState->stopRequested.store(true); + } + } + + void send_controller_state_if_needed(SDL_GameController *controller, bool *arrivalSent, ControllerSnapshot *lastSnapshot) { + if (controller == nullptr || arrivalSent == nullptr || lastSnapshot == nullptr) { + return; + } + + if (!*arrivalSent) { + LiSendControllerArrivalEvent(0, PRESENT_GAMEPAD_MASK, LI_CTYPE_XBOX, CONTROLLER_BUTTON_CAPABILITIES, CONTROLLER_CAPABILITIES); + *arrivalSent = true; + } + + const ControllerSnapshot snapshot = read_controller_snapshot(controller); + if (controller_snapshots_match(snapshot, *lastSnapshot)) { + return; + } + + LiSendControllerEvent(snapshot.buttonFlags, snapshot.leftTrigger, snapshot.rightTrigger, snapshot.leftStickX, snapshot.leftStickY, snapshot.rightStickX, snapshot.rightStickY); + *lastSnapshot = snapshot; + } + + streaming::StreamStatisticsSnapshot sample_stream_statistics(const StreamStartContext &context, const StreamConnectionState &connectionState) { + streaming::StreamStatisticsSnapshot snapshot { + context.streamConfiguration.width, + context.streamConfiguration.height, + context.streamConfiguration.fps, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + connectionState.poorConnection.load(), + }; + + uint32_t estimatedRtt = 0; + uint32_t estimatedRttVariance = 0; + if (LiGetEstimatedRttInfo(&estimatedRtt, &estimatedRttVariance)) { + (void) estimatedRttVariance; + snapshot.roundTripTimeMs = static_cast(estimatedRtt); + } + + snapshot.videoQueueDepth = LiGetPendingVideoFrames(); + snapshot.audioQueueDurationMs = LiGetPendingAudioDuration(); + if (const RTP_VIDEO_STATS *videoStats = LiGetRTPVideoStats(); videoStats != nullptr) { + snapshot.videoPacketsReceived = static_cast(videoStats->packetCountVideo); + snapshot.videoPacketsRecovered = static_cast(videoStats->packetCountFecRecovered); + snapshot.videoPacketsLost = static_cast(videoStats->packetCountFecFailed); + } + return snapshot; + } + + bool render_stream_frame( + const app::HostRecord &host, + const app::HostAppRecord &app, + const StreamStartContext &context, + const StreamConnectionState &connectionState, + streaming::FfmpegStreamBackend *mediaBackend, + bool showPerformanceStats, + StreamUiResources *resources + ) { + if (resources == nullptr || resources->renderer == nullptr || resources->titleFont == nullptr || resources->bodyFont == nullptr) { + return false; + } + + int screenWidth = 0; + int screenHeight = 0; + SDL_GetRendererOutputSize(resources->renderer, &screenWidth, &screenHeight); + + const bool hasDecodedVideo = mediaBackend != nullptr && mediaBackend->has_decoded_video(); + if (hasDecodedVideo) { + SDL_SetRenderDrawColor(resources->renderer, 0x00, 0x00, 0x00, 0xFF); + } else { + SDL_SetRenderDrawColor(resources->renderer, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xFF); + } + SDL_RenderClear(resources->renderer); + + const bool renderedVideo = hasDecodedVideo && mediaBackend->render_latest_video_frame(resources->renderer, screenWidth, screenHeight); + if (renderedVideo && showPerformanceStats) { + SDL_SetRenderDrawBlendMode(resources->renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(resources->renderer, 0x00, 0x00, 0x00, 0x90); + const SDL_Rect overlayBackground {18, 18, std::max(1, screenWidth - 36), std::max(1, std::min(screenHeight - 36, 220))}; + SDL_RenderFillRect(resources->renderer, &overlayBackground); + } + + if (renderedVideo && !showPerformanceStats) { + SDL_RenderPresent(resources->renderer); + return true; + } + + int cursorY = 28; + int titleHeight = 0; + render_text_line(resources->renderer, resources->titleFont, renderedVideo ? "Moonlight Streaming" : "Moonlight Streaming", {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, 28, cursorY, std::max(1, screenWidth - 56), &titleHeight); + cursorY += titleHeight + 8; + + std::vector lines = { + std::string("Host: ") + host.displayName + " (" + context.address + ")", + std::string("App: ") + app.name + (context.rtspSessionUrl.empty() ? std::string {} : std::string(" | Session URL received")), + build_stage_status_line(connectionState), + mediaBackend != nullptr ? mediaBackend->build_overlay_status_line() : std::string("FFmpeg decode backend unavailable"), + "Hold Back + Start for about one second to stop streaming.", + }; + if (!renderedVideo) { + lines.insert(lines.begin() + 2, std::string("Mode: ") + std::to_string(context.streamConfiguration.width) + "x" + std::to_string(context.streamConfiguration.height) + " @ " + std::to_string(context.streamConfiguration.fps) + " FPS | H.264 | Stereo"); + lines.insert(lines.begin() + 4, std::string("Launch mode: ") + (context.serverInformation.rtspSessionUrl != nullptr ? "Session URL supplied by host" : "Default RTSP discovery")); + lines.insert(lines.begin() + 5, "Waiting for the first decoded video frame and audio output."); + } + + if (showPerformanceStats) { + for (const std::string &line : streaming::build_stats_overlay_lines(sample_stream_statistics(context, connectionState))) { + lines.push_back(line); + } + } + + for (const std::string &line : lines) { + int drawnHeight = 0; + render_text_line(resources->renderer, resources->bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, 28, cursorY, std::max(1, screenWidth - 56), &drawnHeight); + cursorY += drawnHeight + 6; + } + + SDL_RenderPresent(resources->renderer); + return true; + } + + std::string describe_start_failure(const StreamConnectionState &connectionState) { + if (const int failedStage = connectionState.failedStage.load(); failedStage != STAGE_NONE) { + std::string message = std::string("Failed to start streaming during ") + LiGetStageName(failedStage) + " (error " + std::to_string(connectionState.failedCode.load()) + ")"; + if (const std::string protocolMessage = latest_connection_protocol_message(connectionState); !protocolMessage.empty()) { + message += ": "; + message += protocolMessage; + } + return message; + } + if (const int startResult = connectionState.startResult.load(); startResult != 0) { + std::string message = std::string("Failed to start streaming transport (library error ") + std::to_string(startResult) + ")"; + if (const std::string protocolMessage = latest_connection_protocol_message(connectionState); !protocolMessage.empty()) { + message += ": "; + message += protocolMessage; + } + return message; + } + return "Failed to start streaming transport"; + } + + std::string describe_session_end(const StreamConnectionState &connectionState, std::string_view appName) { + if (connectionState.stopRequested.load()) { + return "Stopped streaming " + std::string(appName); + } + if (const int terminationError = connectionState.terminationError.load(); terminationError == ML_ERROR_GRACEFUL_TERMINATION) { + return std::string("The host closed ") + std::string(appName) + " cleanly"; + } + if (connectionState.connectionTerminated.load()) { + return std::string("The stream ended unexpectedly for ") + std::string(appName) + " (error " + std::to_string(connectionState.terminationError.load()) + ")"; + } + return std::string("Streaming session ended for ") + std::string(appName); + } + +} // namespace + +namespace streaming { + + bool run_stream_session( + SDL_Window *window, + const VIDEO_MODE &videoMode, + const app::SettingsState &settings, + const app::HostRecord &host, + const app::HostAppRecord &app, + const network::PairingIdentity &clientIdentity, + std::string *statusMessage + ) { + StreamUiResources resources {}; + if (std::string initializationError; !initialize_stream_ui_resources(window, videoMode, &resources, &initializationError)) { + if (statusMessage != nullptr) { + *statusMessage = initializationError; + } + logging::error("stream", initializationError); + return false; + } + + startup::log_memory_statistics(); + + std::array remoteInputKey {}; + std::array remoteInputIv {}; + if (std::string randomError; !fill_random_bytes(remoteInputKey.data(), remoteInputKey.size(), &randomError) || !fill_random_bytes(remoteInputIv.data(), remoteInputIv.size(), &randomError)) { + close_stream_ui_resources(&resources); + if (statusMessage != nullptr) { + *statusMessage = randomError; + } + logging::error("stream", randomError); + return false; + } + + const std::string hostAddress = host.activeAddress.empty() ? host.address : host.activeAddress; + const uint16_t httpPort = host.resolvedHttpPort == 0U ? app::effective_host_port(host.port) : host.resolvedHttpPort; + + network::StreamLaunchResult launchResult {}; + network::StreamLaunchConfiguration launchConfiguration {}; + const ResolvedStreamParameters streamParameters = resolve_stream_parameters(videoMode, settings); + launchConfiguration.appId = app.id; + launchConfiguration.width = select_stream_width(streamParameters.videoMode); + launchConfiguration.height = select_stream_height(streamParameters.videoMode); + launchConfiguration.fps = streamParameters.fps; + launchConfiguration.audioConfiguration = AUDIO_CONFIGURATION_STEREO; + launchConfiguration.playAudioOnPc = settings.playAudioOnPc; + launchConfiguration.clientRefreshRateX100 = select_client_refresh_rate_x100(videoMode); + launchConfiguration.remoteInputAesKeyHex = hex_encode(remoteInputKey.data(), remoteInputKey.size()); + launchConfiguration.remoteInputAesIvHex = hex_encode(remoteInputIv.data(), remoteInputIv.size()); + + std::string launchError; + if (!network::launch_or_resume_stream(hostAddress, httpPort, clientIdentity, launchConfiguration, &launchResult, &launchError)) { + close_stream_ui_resources(&resources); + if (statusMessage != nullptr) { + *statusMessage = launchError; + } + logging::error("stream", launchError); + return false; + } + + StreamConnectionState connectionState {}; + StreamStartContext startContext {}; + startContext.connectionState = &connectionState; + startContext.mediaBackend = &resources.mediaBackend; + configure_stream_start_context(launchResult, remoteInputKey, remoteInputIv, videoMode, streamParameters, &startContext); + if (startContext.appVersion != startContext.reportedAppVersion) { + logging::info( + "stream", + std::string("Normalized host appversion from ") + startContext.reportedAppVersion + " to " + startContext.appVersion + " for " + (startContext.gfeVersion.empty() ? std::string("the current host") : startContext.gfeVersion) + ); + } + logging::info( + "stream", + std::string("Starting stream setup for ") + app.name + " on " + host.displayName + " at " + startContext.address + " | appversion " + startContext.appVersion + (startContext.rtspSessionUrl.empty() ? std::string(" | default RTSP discovery") : std::string(" | host session URL supplied")) + ); + logging::info( + "stream", + std::string("Requested stream configuration | resolution=") + std::to_string(startContext.streamConfiguration.width) + "x" + std::to_string(startContext.streamConfiguration.height) + " | fps=" + std::to_string(startContext.streamConfiguration.fps) + " | bitrate=" + std::to_string(startContext.streamConfiguration.bitrate) + " kbps | packetSize=" + std::to_string(startContext.streamConfiguration.packetSize) + " | networkProfile=" + describe_streaming_remotely_mode(startContext.streamConfiguration.streamingRemotely) + " | clientRefreshX100=" + std::to_string(startContext.streamConfiguration.clientRefreshRateX100) + ); + logging::debug( + "stream", + std::string("Stream connection metadata | active=") + printable_log_value(startContext.serverInformation.address == nullptr ? std::string_view {} : std::string_view {startContext.serverInformation.address}) + + " | local=" + printable_log_value(launchResult.serverInfo.localAddress) + + " | remote=" + printable_log_value(launchResult.serverInfo.remoteAddress) + + " | gfeVersion=" + printable_log_value(startContext.gfeVersion) + + " | sessionUrl=" + printable_log_value(startContext.rtspSessionUrl) + ); + resources.mediaBackend.initialize_callbacks(&startContext.videoCallbacks, &startContext.audioCallbacks); + startContext.connectionCallbacks.stageStarting = on_stage_starting; + startContext.connectionCallbacks.stageComplete = on_stage_complete; + startContext.connectionCallbacks.stageFailed = on_stage_failed; + startContext.connectionCallbacks.connectionStarted = on_connection_started; + startContext.connectionCallbacks.connectionTerminated = on_connection_terminated; + startContext.connectionCallbacks.connectionStatusUpdate = on_connection_status_update; + startContext.connectionCallbacks.logMessage = on_log_message; + + const auto run_start_attempt = [&]() -> bool { + SDL_Thread *startThread = SDL_CreateThread(run_stream_start_thread, "start-stream", &startContext); + if (startThread == nullptr) { + const std::string createThreadError = std::string("Failed to start the streaming transport thread: ") + SDL_GetError(); + close_stream_ui_resources(&resources); + if (statusMessage != nullptr) { + *statusMessage = createThreadError; + } + logging::error("stream", createThreadError); + return false; + } + + Uint32 exitComboActivatedTick = 0U; + while (!connectionState.startCompleted.load() && !connectionState.stopRequested.load()) { + pump_stream_events(&resources); + update_stream_exit_combo(resources.controller, &exitComboActivatedTick, &connectionState); + render_stream_frame(host, app, startContext, connectionState, &resources.mediaBackend, true, &resources); + if (connectionState.stopRequested.load()) { + LiInterruptConnection(); + } + SDL_Delay(STREAM_FRAME_DELAY_MILLISECONDS); + } + + int threadResult = 0; + SDL_WaitThread(startThread, &threadResult); + (void) threadResult; + return true; + }; + + if (!run_start_attempt()) { + return false; + } + + bool rtspFallbackAttempted = false; + if (!connectionState.stopRequested.load() && !connectionState.connectionStarted.load() && connectionState.failedStage.load() == STAGE_RTSP_HANDSHAKE && !startContext.rtspSessionUrl.empty()) { + rtspFallbackAttempted = true; + logging::warn("stream", "RTSP handshake failed with the host-supplied session URL; retrying with default RTSP discovery"); + resources.mediaBackend.shutdown(); + startContext.rtspSessionUrl.clear(); + startContext.serverInformation.rtspSessionUrl = nullptr; + reset_connection_state(&connectionState); + if (!run_start_attempt()) { + return false; + } + } + + if (connectionState.startResult.load() != 0 || !connectionState.connectionStarted.load()) { + std::string failureMessage = connectionState.stopRequested.load() ? std::string("Cancelled the stream start for ") + app.name : describe_start_failure(connectionState); + if (rtspFallbackAttempted && !connectionState.stopRequested.load()) { + failureMessage += " after retrying default RTSP discovery"; + } + g_active_connection_state = nullptr; + close_stream_ui_resources(&resources); + if (statusMessage != nullptr) { + *statusMessage = failureMessage; + } + logging::warn("stream", failureMessage); + return false; + } + + bool controllerArrivalSent = false; + ControllerSnapshot lastControllerSnapshot {}; + Uint32 exitComboActivatedTick = 0U; + logging::info("stream", std::string(launchResult.resumedSession ? "Resumed stream for " : "Launched stream for ") + app.name + " on " + host.displayName); + while (!connectionState.connectionTerminated.load() && !connectionState.stopRequested.load()) { + pump_stream_events(&resources); + update_stream_exit_combo(resources.controller, &exitComboActivatedTick, &connectionState); + send_controller_state_if_needed(resources.controller, &controllerArrivalSent, &lastControllerSnapshot); + render_stream_frame(host, app, startContext, connectionState, &resources.mediaBackend, settings.showPerformanceStats, &resources); + SDL_Delay(STREAM_FRAME_DELAY_MILLISECONDS); + } + + LiStopConnection(); + g_active_connection_state = nullptr; + + const std::string finalMessage = describe_session_end(connectionState, app.name); + logging::info("stream", finalMessage); + startup::log_memory_statistics(); + close_stream_ui_resources(&resources); + if (statusMessage != nullptr) { + *statusMessage = finalMessage; + } + return true; + } + +} // namespace streaming diff --git a/src/streaming/session.h b/src/streaming/session.h new file mode 100644 index 0000000..16de502 --- /dev/null +++ b/src/streaming/session.h @@ -0,0 +1,48 @@ +/** + * @file src/streaming/session.h + * @brief Declares the Xbox streaming session runtime. + */ +#pragma once + +// standard includes +#include + +// local includes +#include "src/app/client_state.h" +#include "src/app/host_records.h" +#include "src/network/host_pairing.h" +#include "src/startup/video_mode.h" + +struct SDL_Window; + +namespace streaming { + + /** + * @brief Run one Xbox streaming session for the selected host app. + * + * The session launches or resumes the selected app on the host, starts the + * Moonlight transport runtime, decodes H.264 video and Opus audio with + * FFmpeg, forwards controller input, renders the latest decoded frame with a + * lightweight overlay, and returns once the user stops streaming or the host + * terminates the session. + * + * @param window Shared SDL window reused from the shell. + * @param videoMode Active Xbox video mode. + * @param settings Active shell settings that control stream resolution, bitrate, frame rate, host audio playback, and the optional stats overlay. + * @param host Selected paired host. + * @param app Selected host app. + * @param clientIdentity Paired client identity used for authenticated launch requests. + * @param statusMessage Output message describing the final session result. + * @return True when the stream session started successfully. + */ + bool run_stream_session( + SDL_Window *window, + const VIDEO_MODE &videoMode, + const app::SettingsState &settings, + const app::HostRecord &host, + const app::HostAppRecord &app, + const network::PairingIdentity &clientIdentity, + std::string *statusMessage + ); + +} // namespace streaming diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 1115a43..9678a4e 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -43,6 +43,7 @@ #include "src/startup/cover_art_cache.h" #include "src/startup/host_storage.h" #include "src/startup/saved_files.h" +#include "src/streaming/session.h" #include "src/ui/host_probe_result_queue.h" #include "src/ui/shell_view.h" @@ -2160,6 +2161,12 @@ namespace { state.settings.loggingLevel, state.settings.xemuConsoleLoggingLevel, state.settings.logViewerPlacement, + state.settings.preferredVideoMode, + state.settings.preferredVideoModeSet, + state.settings.streamFramerate, + state.settings.streamBitrateKbps, + state.settings.playAudioOnPc, + state.settings.showPerformanceStats, }; } @@ -2178,6 +2185,44 @@ namespace { logging::error("settings", saveResult.errorMessage); } + /** + * @brief Return whether two Xbox video modes describe the same output mode. + * + * @param left First video mode to compare. + * @param right Second video mode to compare. + * @return True when the modes match exactly. + */ + bool video_modes_match(const VIDEO_MODE &left, const VIDEO_MODE &right) { + return left.width == right.width && left.height == right.height && left.bpp == right.bpp && left.refresh == right.refresh; + } + + /** + * @brief Switch the shared SDL window to the requested Xbox video mode. + * + * @param window Shared SDL window reused for the shell and streaming session. + * @param videoMode Requested Xbox output mode. + * @param errorMessage Receives a user-visible error when the mode change fails. + * @return True when the requested mode is now active. + */ + bool apply_shell_video_mode(SDL_Window *window, const VIDEO_MODE &videoMode, std::string *errorMessage) { + if (videoMode.width <= 0 || videoMode.height <= 0) { + return true; + } + + if (!XVideoSetMode(videoMode.width, videoMode.height, videoMode.bpp, videoMode.refresh)) { + return platform::append_error( + errorMessage, + "Failed to switch Xbox video mode to " + std::to_string(videoMode.width) + "x" + std::to_string(videoMode.height) + " @ " + std::to_string(videoMode.refresh) + " Hz" + ); + } + + if (window != nullptr) { + SDL_SetWindowSize(window, videoMode.width, videoMode.height); + } + + return true; + } + bool update_host_metadata_from_server_info(app::HostRecord *host, const std::string &address, const network::HostPairingServerInfo &serverInfo) { if (host == nullptr) { return false; @@ -4325,6 +4370,10 @@ namespace { (void) threadResult; } + struct ShellRuntimeState; + + void finalize_shell_tasks(ShellRuntimeState *runtime); + /** * @brief Open the first detected SDL game controller for shell navigation. * @@ -4534,6 +4583,7 @@ namespace { /** * @brief Apply a single translated UI command inside the shell loop. * + * @param window Shared SDL window reused when entering a streaming session. * @param videoMode Active output mode used by the shell renderer. * @param state Client state to mutate. * @param command UI command to process. @@ -4541,7 +4591,8 @@ namespace { * @param runtime Runtime state that owns background tasks and redraw flags. */ void process_shell_command( - const VIDEO_MODE &videoMode, + SDL_Window *window, + VIDEO_MODE *videoMode, app::ClientState &state, input::UiCommand command, ShellResources *resources, @@ -4570,11 +4621,52 @@ namespace { persist_settings_if_needed(state, update); persist_hosts_if_needed(state, update); + if (update.requests.streamLaunchRequested) { + const app::HostRecord *streamHost = app::apps_host(state); + const app::HostAppRecord *streamApp = app::selected_app(state); + if (streamHost == nullptr || streamApp == nullptr) { + state.shell.statusMessage = "Select a valid app before starting a stream."; + logging::warn("stream", state.shell.statusMessage); + } else { + network::PairingIdentity clientIdentity {}; + std::string identityError; + if (!load_saved_pairing_identity_for_streaming(&clientIdentity, &identityError)) { + state.shell.statusMessage = identityError; + logging::warn("stream", identityError); + } else if (window == nullptr) { + state.shell.statusMessage = "Streaming requires a valid SDL window."; + logging::error("stream", state.shell.statusMessage); + } else { + finalize_shell_tasks(runtime); + close_shell_resources(resources); + + std::string sessionMessage; + const VIDEO_MODE activeVideoMode = videoMode != nullptr ? *videoMode : VIDEO_MODE {}; + const bool streamStarted = streaming::run_stream_session(window, activeVideoMode, state.settings, *streamHost, *streamApp, clientIdentity, &sessionMessage); + state.shell.statusMessage = sessionMessage; + + if (ShellInitializationFailure initializationFailure {}; !initialize_shell_resources(window, activeVideoMode, resources, &initializationFailure)) { + report_shell_failure(initializationFailure.category, initializationFailure.message); + runtime->running = false; + state.shell.shouldExit = true; + return; + } + + initialize_shell_runtime(state, runtime); + if (streamStarted && state.hosts.activeLoaded) { + state.hosts.active.appListState = app::HostAppListState::loading; + state.hosts.active.appListStatusMessage = "Refreshing host apps after the stream session..."; + state.hosts.active.lastAppListRefreshTick = 0U; + } + } + } + } + if (previousScreen != state.shell.activeScreen) { release_page_resources_for_screen(previousScreen, state.shell.activeScreen, &resources->coverArtTextureCache, &resources->keypadModalLayoutCache); ensure_hosts_loaded_for_active_screen(state); } - if ((previousScreen != state.shell.activeScreen || update.navigation.screenChanged) && !draw_current_shell_frame(videoMode, state, resources, runtime)) { + if ((previousScreen != state.shell.activeScreen || update.navigation.screenChanged) && !draw_current_shell_frame(videoMode != nullptr ? *videoMode : VIDEO_MODE {}, state, resources, runtime)) { return; } if (state.shell.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible) { @@ -4585,19 +4677,20 @@ namespace { /** * @brief Process one shell frame, including input, background tasks, and redraws. * + * @param window Shared SDL window reused for shell and streaming rendering. * @param videoMode Active output mode used by the shell renderer. * @param state Client state to update. * @param resources Shell resources used by the frame. * @param runtime Runtime state that carries input and task progress. * @return True when the shell should continue processing future frames. */ - bool run_shell_frame(const VIDEO_MODE &videoMode, app::ClientState &state, ShellResources *resources, ShellRuntimeState *runtime) { - if (resources == nullptr || runtime == nullptr) { + bool run_shell_frame(SDL_Window *window, VIDEO_MODE *videoMode, app::ClientState &state, ShellResources *resources, ShellRuntimeState *runtime) { + if (window == nullptr || resources == nullptr || runtime == nullptr) { return false; } - const auto processCommand = [&state, &videoMode, resources, runtime](input::UiCommand command) { - process_shell_command(videoMode, state, command, resources, runtime); + const auto processCommand = [window, &state, videoMode, resources, runtime](input::UiCommand command) { + process_shell_command(window, videoMode, state, command, resources, runtime); }; ensure_hosts_loaded_for_active_screen(state); @@ -4616,7 +4709,7 @@ namespace { start_shell_background_tasks_if_needed(state, runtime, SDL_GetTicks()); if ((state.shell.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible || runtime->keypadRedrawRequested) && - !draw_current_shell_frame(videoMode, state, resources, runtime)) { + !draw_current_shell_frame(videoMode != nullptr ? *videoMode : VIDEO_MODE {}, state, resources, runtime)) { return false; } @@ -4671,8 +4764,10 @@ namespace ui { return report_shell_failure("sdl", "Shell requires a valid SDL window"); } + VIDEO_MODE activeVideoMode = videoMode; + ShellResources resources {}; - if (ShellInitializationFailure initializationFailure {}; !initialize_shell_resources(window, videoMode, &resources, &initializationFailure)) { + if (ShellInitializationFailure initializationFailure {}; !initialize_shell_resources(window, activeVideoMode, &resources, &initializationFailure)) { return report_shell_failure(initializationFailure.category, initializationFailure.message); } @@ -4680,7 +4775,7 @@ namespace ui { initialize_shell_runtime(state, &runtime); while (runtime.running && !state.shell.shouldExit) { - if (!run_shell_frame(videoMode, state, &resources, &runtime)) { + if (!run_shell_frame(window, &activeVideoMode, state, &resources, &runtime)) { break; } } diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index afe3c56..a34d8ed 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -978,8 +978,14 @@ namespace { EXPECT_TRUE(state.hosts.active.apps.front().favorite); } - TEST(ClientStateTest, SettingsPlaceholderActivationAndBackNavigationUpdateFocusAndStatus) { + TEST(ClientStateTest, DisplaySettingsCanBeActivatedAndBackNavigationReturnsFocusToCategories) { app::ClientState state = app::create_initial_state(); + state.settings.availableVideoModes = { + VIDEO_MODE {640, 480, 32, 60}, + VIDEO_MODE {1280, 720, 32, 60}, + }; + state.settings.preferredVideoMode = state.settings.availableVideoModes.front(); + state.settings.preferredVideoModeSet = true; app::handle_command(state, input::UiCommand::move_left); app::handle_command(state, input::UiCommand::move_left); app::handle_command(state, input::UiCommand::activate); @@ -991,13 +997,44 @@ namespace { EXPECT_EQ(state.settings.focusArea, app::SettingsFocusArea::options); update = app::handle_command(state, input::UiCommand::activate); - EXPECT_EQ(state.shell.statusMessage, "display-placeholder is not implemented yet"); + EXPECT_EQ(update.navigation.activatedItemId, "cycle-stream-video-mode"); + EXPECT_TRUE(update.persistence.settingsChanged); + EXPECT_EQ(state.settings.preferredVideoMode.width, 1280); + EXPECT_EQ(state.settings.preferredVideoMode.height, 720); update = app::handle_command(state, input::UiCommand::back); EXPECT_EQ(state.settings.focusArea, app::SettingsFocusArea::categories); EXPECT_FALSE(update.navigation.screenChanged); } + TEST(ClientStateTest, DisplaySettingsCanCycleLowStreamResolutionPresets) { + app::ClientState state = app::create_initial_state(); + state.settings.availableVideoModes = { + VIDEO_MODE {352, 240, 32, 60}, + VIDEO_MODE {352, 288, 32, 60}, + VIDEO_MODE {480, 480, 32, 60}, + }; + state.settings.preferredVideoMode = state.settings.availableVideoModes.front(); + state.settings.preferredVideoModeSet = true; + + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.settings.selectedCategory, app::SettingsCategory::display); + ASSERT_EQ(state.settings.focusArea, app::SettingsFocusArea::options); + + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(update.navigation.activatedItemId, "cycle-stream-video-mode"); + EXPECT_TRUE(update.persistence.settingsChanged); + EXPECT_EQ(state.settings.preferredVideoMode.width, 352); + EXPECT_EQ(state.settings.preferredVideoMode.height, 288); + EXPECT_EQ(state.shell.statusMessage, "Stream resolution set to 352x288"); + } + TEST(ClientStateTest, ConfirmationModalCanBeCancelledWithoutRequestingPersistenceChanges) { app::ClientState state = app::create_initial_state(); app::handle_command(state, input::UiCommand::move_left); @@ -1037,7 +1074,8 @@ namespace { app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); EXPECT_EQ(update.navigation.activatedItemId, "launch-app"); - EXPECT_EQ(state.shell.statusMessage, "Launching Steam is not implemented yet"); + EXPECT_TRUE(update.requests.streamLaunchRequested); + EXPECT_EQ(state.shell.statusMessage, "Starting stream for Steam..."); update = app::handle_command(state, input::UiCommand::delete_character); EXPECT_TRUE(state.shell.statusMessage.empty()); diff --git a/tests/unit/app/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp index ca87480..b4294bd 100644 --- a/tests/unit/app/settings_storage_test.cpp +++ b/tests/unit/app/settings_storage_test.cpp @@ -45,6 +45,12 @@ namespace { logging::LogLevel::debug, logging::LogLevel::warning, app::LogViewerPlacement::left, + VIDEO_MODE {1280, 720, 32, 60}, + true, + 24, + 2500, + true, + true, }; const app::SaveAppSettingsResult saveResult = app::save_app_settings(savedSettings, settingsPath); @@ -56,9 +62,45 @@ namespace { EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::debug); EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::warning); EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::left); + EXPECT_TRUE(loadResult.settings.preferredVideoModeSet); + EXPECT_EQ(loadResult.settings.preferredVideoMode.width, 1280); + EXPECT_EQ(loadResult.settings.preferredVideoMode.height, 720); + EXPECT_EQ(loadResult.settings.preferredVideoMode.refresh, 60); + EXPECT_EQ(loadResult.settings.streamFramerate, 24); + EXPECT_EQ(loadResult.settings.streamBitrateKbps, 2500); + EXPECT_TRUE(loadResult.settings.playAudioOnPc); + EXPECT_TRUE(loadResult.settings.showPerformanceStats); EXPECT_FALSE(loadResult.cleanupRequired); } + TEST_F(SettingsStorageTest, SavesAndLoadsLowStreamResolutionPresets) { + const app::AppSettings savedSettings { + logging::LogLevel::none, + logging::LogLevel::none, + app::LogViewerPlacement::full, + VIDEO_MODE {352, 240, 32, 60}, + true, + 15, + 500, + false, + false, + }; + + const app::SaveAppSettingsResult saveResult = app::save_app_settings(savedSettings, settingsPath); + ASSERT_TRUE(saveResult.success) << saveResult.errorMessage; + + const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); + EXPECT_TRUE(loadResult.fileFound); + EXPECT_TRUE(loadResult.warnings.empty()); + EXPECT_TRUE(loadResult.settings.preferredVideoModeSet); + EXPECT_EQ(loadResult.settings.preferredVideoMode.width, 352); + EXPECT_EQ(loadResult.settings.preferredVideoMode.height, 240); + EXPECT_EQ(loadResult.settings.preferredVideoMode.bpp, 32); + EXPECT_EQ(loadResult.settings.preferredVideoMode.refresh, 60); + EXPECT_EQ(loadResult.settings.streamFramerate, 15); + EXPECT_EQ(loadResult.settings.streamBitrateKbps, 500); + } + TEST_F(SettingsStorageTest, MissingFilesReturnDefaultsWithoutWarnings) { const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); @@ -68,6 +110,11 @@ namespace { EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::full); + EXPECT_FALSE(loadResult.settings.preferredVideoModeSet); + EXPECT_EQ(loadResult.settings.streamFramerate, 20); + EXPECT_EQ(loadResult.settings.streamBitrateKbps, 1500); + EXPECT_FALSE(loadResult.settings.playAudioOnPc); + EXPECT_FALSE(loadResult.settings.showPerformanceStats); } TEST_F(SettingsStorageTest, InvalidValuesFallBackToDefaultsWithWarnings) { @@ -79,16 +126,23 @@ namespace { "[debug]\n" "startup_console_enabled = \"sometimes\"\n\n" "[ui]\n" - "log_viewer_placement = \"top\"\n" + "log_viewer_placement = \"top\"\n\n" + "[streaming]\n" + "video_width = \"wide\"\n" + "fps = \"fast\"\n" + "play_audio_on_pc = \"sometimes\"\n" ); const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); EXPECT_TRUE(loadResult.fileFound); - EXPECT_GE(loadResult.warnings.size(), 3U); + EXPECT_GE(loadResult.warnings.size(), 6U); EXPECT_TRUE(loadResult.cleanupRequired); EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::full); + EXPECT_FALSE(loadResult.settings.preferredVideoModeSet); + EXPECT_EQ(loadResult.settings.streamFramerate, 20); + EXPECT_FALSE(loadResult.settings.playAudioOnPc); } TEST_F(SettingsStorageTest, LegacyLoggingKeyLoadsAndRequestsCleanup) { @@ -135,7 +189,10 @@ namespace { "file_minimum_level = \"info\"\n" "obsolete_key = true\n\n" "[ui]\n" - "log_viewer_placement = \"left\"\n" + "log_viewer_placement = \"left\"\n\n" + "[streaming]\n" + "fps = 30\n" + "obsolete_key = true\n" "theme = \"green\"\n\n" "[debug]\n" "startup_console_enabled = true\n\n" @@ -147,9 +204,10 @@ namespace { EXPECT_TRUE(loadResult.fileFound); EXPECT_TRUE(loadResult.cleanupRequired); - EXPECT_GE(loadResult.warnings.size(), 4U); + EXPECT_GE(loadResult.warnings.size(), 5U); EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::info); EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::left); + EXPECT_EQ(loadResult.settings.streamFramerate, 30); } TEST_F(SettingsStorageTest, ReportsParseAndTypeErrorsAsWarnings) { @@ -159,15 +217,18 @@ namespace { "file_minimum_level = 7\n" "xemu_console_minimum_level = false\n\n" "[ui]\n" - "log_viewer_placement = 42\n" + "log_viewer_placement = 42\n\n" + "[streaming]\n" + "show_performance_stats = \"sometimes\"\n" ); app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); EXPECT_TRUE(loadResult.fileFound); - EXPECT_GE(loadResult.warnings.size(), 3U); + EXPECT_GE(loadResult.warnings.size(), 4U); EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::none); EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::full); + EXPECT_FALSE(loadResult.settings.showPerformanceStats); write_text_file(settingsPath, "[logging\nfile_minimum_level = \"info\"\n"); loadResult = app::load_app_settings(settingsPath); diff --git a/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index e56f946..fdc0eca 100644 --- a/tests/unit/network/host_pairing_test.cpp +++ b/tests/unit/network/host_pairing_test.cpp @@ -22,6 +22,9 @@ #include #include +// third-party includes +#include "third-party/moonlight-common-c/src/Limelight.h" + // test includes #include "tests/support/network_test_constants.h" @@ -256,8 +259,17 @@ namespace { return bytes; } - std::string make_server_info_xml(bool paired, uint16_t httpPort, uint16_t httpsPort, std::string_view hostName = "Scripted Host", std::string_view uuid = "scripted-host") { - return "" + std::string(hostName) + "7.1.0.0" + std::string(uuid) + "" + + std::string make_server_info_xml( + bool paired, + uint16_t httpPort, + uint16_t httpsPort, + std::string_view hostName = "Scripted Host", + std::string_view uuid = "scripted-host", + int serverCodecModeSupport = SCM_H264, + std::string_view gfeVersion = "99.0.0" + ) { + return "" + std::string(hostName) + "7.1.0.0" + std::string(gfeVersion) + "" + + std::to_string(serverCodecModeSupport) + "" + std::string(uuid) + "" + std::string(test_support::kTestIpv4Addresses[test_support::kIpServerLocal]) + "" + std::string(test_support::kTestIpv4Addresses[test_support::kIpServerExternal]) + "" + std::to_string(httpPort) + "" + std::to_string(httpsPort) + "" + (paired ? "1" : "0") + ""; } @@ -465,9 +477,12 @@ namespace { ASSERT_TRUE(network::parse_server_info_response(xml, test_support::kTestPorts[test_support::kPortPairing], &serverInfo, &errorMessage)) << errorMessage; EXPECT_EQ(serverInfo.serverMajorVersion, 7); + EXPECT_EQ(serverInfo.appVersion, "7.1.431.0"); + EXPECT_TRUE(serverInfo.gfeVersion.empty()); EXPECT_EQ(serverInfo.httpPort, test_support::kTestPorts[test_support::kPortResolvedHttp]); EXPECT_EQ(serverInfo.httpsPort, test_support::kTestPorts[test_support::kPortResolvedHttps]); EXPECT_TRUE(serverInfo.paired); + EXPECT_EQ(serverInfo.serverCodecModeSupport, SCM_H264); EXPECT_EQ(serverInfo.hostName, "Sunshine-PC"); EXPECT_EQ(serverInfo.uuid, "host-uuid-123"); EXPECT_EQ(serverInfo.activeAddress, test_support::kTestIpv4Addresses[test_support::kIpServerLocal]); @@ -511,9 +526,11 @@ namespace { ASSERT_TRUE(network::parse_server_info_response(xml, test_support::kTestPorts[test_support::kPortPairing], &serverInfo, &errorMessage)) << errorMessage; EXPECT_EQ(serverInfo.serverMajorVersion, 8); + EXPECT_EQ(serverInfo.appVersion, "8.2.0.0"); EXPECT_EQ(serverInfo.httpPort, test_support::kTestPorts[test_support::kPortPairing]); - EXPECT_EQ(serverInfo.httpsPort, test_support::kTestPorts[test_support::kPortPairing]); + EXPECT_EQ(serverInfo.httpsPort, 47990U); EXPECT_FALSE(serverInfo.paired); + EXPECT_EQ(serverInfo.serverCodecModeSupport, SCM_H264); EXPECT_EQ(serverInfo.hostName, "Bedroom PC"); EXPECT_EQ(serverInfo.uuid, "host-uuid-456"); EXPECT_EQ(serverInfo.activeAddress, test_support::kTestIpv4Addresses[test_support::kIpLocalFallback]); @@ -551,6 +568,25 @@ namespace { ); } + TEST(HostPairingTest, ParsesServerCodecModeSupportAndGfeVersionWhenReported) { + const std::string xml = + "" + "Codec Host" + "7.2.0.0" + "Sunshine-2026.4.19" + "257" + "47990" + "1" + ""; + + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + ASSERT_TRUE(network::parse_server_info_response(xml, test_support::kTestPorts[test_support::kPortPairing], &serverInfo, &errorMessage)) << errorMessage; + EXPECT_EQ(serverInfo.gfeVersion, "Sunshine-2026.4.19"); + EXPECT_EQ(serverInfo.serverCodecModeSupport, 257); + } + TEST(HostPairingTest, FallsBackToReportedAddressWhenRequestedAddressIsMissing) { network::HostPairingServerInfo serverInfo {}; serverInfo.activeAddress = test_support::kTestIpv4Addresses[test_support::kIpExternalFallback]; @@ -854,6 +890,208 @@ namespace { EXPECT_NE(errorMessage.find("serverinfo unavailable"), std::string::npos); } + TEST(HostPairingTest, LaunchesANewStreamSessionWithAuthenticatedHttpsRequest) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + ScriptedHostPairingHttpHandler handler({ + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_FALSE(request.useTls); + EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + identity.uniqueId), std::string::npos); + }, + true, + 200, + make_server_info_xml(true, 47989U, 47990U, "Launch Host", "launch-host", SCM_H264 | SCM_HEVC, "Sunshine-2026.4.19"), + }, + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_TRUE(request.useTls); + ASSERT_NE(request.tlsClientIdentity, nullptr); + EXPECT_EQ(request.tlsClientIdentity->uniqueId, identity.uniqueId); + EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + identity.uniqueId), std::string::npos); + }, + true, + 200, + make_server_info_xml(true, 47989U, 47990U, "Launch Host", "launch-host", SCM_H264 | SCM_HEVC, "Sunshine-2026.4.19"), + }, + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_TRUE(request.useTls); + ASSERT_NE(request.tlsClientIdentity, nullptr); + EXPECT_EQ(request.tlsClientIdentity->uniqueId, identity.uniqueId); + EXPECT_NE(request.pathAndQuery.find("/launch?uniqueid=" + identity.uniqueId), std::string::npos); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "appid"), "101"); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "mode"), "640x480x30"); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "localAudioPlayMode"), "1"); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "surroundAudioInfo"), std::to_string(SURROUNDAUDIOINFO_FROM_AUDIO_CONFIGURATION(AUDIO_CONFIGURATION_STEREO))); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "rikey"), std::string(32U, '1')); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "rikeyid"), std::string(32U, '2')); + }, + true, + 200, + "rtsp://10.0.0.2:480107.1.431.0Sunshine-2026.4.19257", + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + network::StreamLaunchResult result {}; + std::string errorMessage; + + ASSERT_TRUE( + network::launch_or_resume_stream( + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + test_support::kTestPorts[test_support::kPortPairing], + identity, + { + 101, + 640, + 480, + 30, + AUDIO_CONFIGURATION_STEREO, + true, + 6000, + std::string(32U, '1'), + std::string(32U, '2'), + }, + &result, + &errorMessage + ) + ) << errorMessage; + EXPECT_TRUE(handler.all_consumed()); + EXPECT_FALSE(result.resumedSession); + EXPECT_EQ(result.rtspSessionUrl, "rtsp://10.0.0.2:48010"); + EXPECT_EQ(result.appVersion, "7.1.431.0"); + EXPECT_EQ(result.gfeVersion, "Sunshine-2026.4.19"); + EXPECT_EQ(result.serverCodecModeSupport, 257); + } + + TEST(HostPairingTest, LaunchPreservesTheReachableRequestAddressForStreaming) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + ScriptedHostPairingHttpHandler handler({ + { + {}, + true, + 200, + std::string("Loopback Host7.1.431.0Sunshine-2026.4.19257127.0.0.1203.0.113.25479901"), + }, + { + {}, + true, + 200, + std::string("Loopback Host7.1.431.0Sunshine-2026.4.19257127.0.0.1203.0.113.25479901"), + }, + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_TRUE(request.useTls); + ASSERT_NE(request.tlsClientIdentity, nullptr); + EXPECT_EQ(request.tlsClientIdentity->uniqueId, identity.uniqueId); + EXPECT_EQ(request.address, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_NE(request.pathAndQuery.find("/launch?uniqueid=" + identity.uniqueId), std::string::npos); + }, + true, + 200, + "rtsp://127.0.0.1:480107.1.431.0Sunshine-2026.4.19257", + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + network::StreamLaunchResult result {}; + std::string errorMessage; + + ASSERT_TRUE( + network::launch_or_resume_stream( + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + test_support::kTestPorts[test_support::kPortPairing], + identity, + { + 101, + 640, + 480, + 30, + AUDIO_CONFIGURATION_STEREO, + false, + 6000, + std::string(32U, '1'), + std::string(32U, '2'), + }, + &result, + &errorMessage + ) + ) << errorMessage; + + EXPECT_TRUE(handler.all_consumed()); + EXPECT_EQ(result.serverInfo.localAddress, "127.0.0.1"); + EXPECT_EQ(result.serverInfo.activeAddress, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_EQ(result.rtspSessionUrl, "rtsp://127.0.0.1:48010"); + } + + TEST(HostPairingTest, ResumesTheRunningSessionWhenTheRequestedAppIsAlreadyActive) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + const std::string serverInfoXml = + "Resume Host7.1.0.0110147989479901"; + + ScriptedHostPairingHttpHandler handler({ + { + {}, + true, + 200, + serverInfoXml, + }, + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_TRUE(request.useTls); + EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + identity.uniqueId), std::string::npos); + }, + true, + 200, + serverInfoXml, + }, + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_TRUE(request.useTls); + EXPECT_NE(request.pathAndQuery.find("/resume?uniqueid=" + identity.uniqueId), std::string::npos); + EXPECT_TRUE(extract_query_parameter(request.pathAndQuery, "appid").empty()); + EXPECT_TRUE(extract_query_parameter(request.pathAndQuery, "mode").empty()); + }, + true, + 200, + "rtsp://10.0.0.2:480107.1.0.0", + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + network::StreamLaunchResult result {}; + std::string errorMessage; + + ASSERT_TRUE( + network::launch_or_resume_stream( + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + test_support::kTestPorts[test_support::kPortPairing], + identity, + { + 101, + 640, + 480, + 30, + AUDIO_CONFIGURATION_STEREO, + false, + 6000, + std::string(32U, 'a'), + std::string(32U, 'b'), + }, + &result, + &errorMessage + ) + ) << errorMessage; + EXPECT_TRUE(result.resumedSession); + EXPECT_TRUE(handler.all_consumed()); + } + TEST(HostPairingTest, QueryAppListMapsUnauthorizedHttpResponsesToTheUnpairedMessage) { ScriptedHostPairingHttpHandler handler({ { diff --git a/tests/unit/startup/video_mode_test.cpp b/tests/unit/startup/video_mode_test.cpp index a198968..552bd95 100644 --- a/tests/unit/startup/video_mode_test.cpp +++ b/tests/unit/startup/video_mode_test.cpp @@ -60,4 +60,49 @@ namespace { EXPECT_EQ(bestVideoMode.refresh, 60); } + TEST(VideoModeTest, ExposesFixedStreamResolutionPresetsIncludingLowModes) { + const std::vector presets = startup::stream_resolution_presets(32, 60); + + ASSERT_EQ(presets.size(), 9U); + EXPECT_EQ(presets.front().width, 352); + EXPECT_EQ(presets.front().height, 240); + EXPECT_EQ(presets[1].width, 352); + EXPECT_EQ(presets[1].height, 288); + EXPECT_EQ(presets[4].width, 720); + EXPECT_EQ(presets[4].height, 480); + EXPECT_EQ(presets[7].width, 1280); + EXPECT_EQ(presets[7].height, 720); + EXPECT_EQ(presets.back().width, 1920); + EXPECT_EQ(presets.back().height, 1080); + EXPECT_EQ(presets.back().bpp, 32); + EXPECT_EQ(presets.back().refresh, 60); + } + + TEST(VideoModeTest, Chooses720pAsTheDefaultHdStreamPreset) { + const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({1280, 720, 32, 60}); + + EXPECT_EQ(defaultPreset.width, 1280); + EXPECT_EQ(defaultPreset.height, 720); + EXPECT_EQ(defaultPreset.bpp, 32); + EXPECT_EQ(defaultPreset.refresh, 60); + } + + TEST(VideoModeTest, ChoosesNtscSdPresetFor60HzOutputModes) { + const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({640, 480, 32, 60}); + + EXPECT_EQ(defaultPreset.width, 720); + EXPECT_EQ(defaultPreset.height, 480); + EXPECT_EQ(defaultPreset.bpp, 32); + EXPECT_EQ(defaultPreset.refresh, 60); + } + + TEST(VideoModeTest, ChoosesPalSdPresetFor50HzOutputModes) { + const VIDEO_MODE defaultPreset = startup::choose_default_stream_video_mode({640, 480, 32, 50}); + + EXPECT_EQ(defaultPreset.width, 720); + EXPECT_EQ(defaultPreset.height, 576); + EXPECT_EQ(defaultPreset.bpp, 32); + EXPECT_EQ(defaultPreset.refresh, 50); + } + } // namespace diff --git a/tests/unit/ui/host_probe_result_queue_test.cpp b/tests/unit/ui/host_probe_result_queue_test.cpp index 022c9fb..22b401a 100644 --- a/tests/unit/ui/host_probe_result_queue_test.cpp +++ b/tests/unit/ui/host_probe_result_queue_test.cpp @@ -18,6 +18,14 @@ namespace { + network::HostPairingServerInfo make_probe_server_info(std::string_view hostName) { + network::HostPairingServerInfo serverInfo {}; + serverInfo.httpPort = test_support::kTestPorts[test_support::kPortResolvedHttp]; + serverInfo.httpsPort = test_support::kTestPorts[test_support::kPortResolvedHttps]; + serverInfo.hostName = hostName; + return serverInfo; + } + TEST(HostProbeResultQueueTest, DrainsPublishedResultsBeforeTheRoundCompletes) { ui::HostProbeResultQueue queue {}; ui::begin_host_probe_result_round(&queue, 3U); @@ -26,7 +34,7 @@ namespace { test_support::kTestIpv4Addresses[test_support::kIpHostGridA], test_support::kTestPorts[test_support::kPortDefaultHost], true, - {0, test_support::kTestPorts[test_support::kPortResolvedHttp], test_support::kTestPorts[test_support::kPortResolvedHttps], false, false, false, "Host A"}, + make_probe_server_info("Host A"), }); std::vector drainedResults = ui::drain_host_probe_results(&queue); @@ -45,7 +53,7 @@ namespace { test_support::kTestIpv4Addresses[test_support::kIpHostGridC], test_support::kTestPorts[test_support::kPortDefaultHost], true, - {0, test_support::kTestPorts[test_support::kPortResolvedHttp], test_support::kTestPorts[test_support::kPortResolvedHttps], false, false, false, "Host C"}, + make_probe_server_info("Host C"), }); drainedResults = ui::drain_host_probe_results(&queue); @@ -64,7 +72,7 @@ namespace { test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], true, - {0, test_support::kTestPorts[test_support::kPortResolvedHttp], test_support::kTestPorts[test_support::kPortResolvedHttps], false, false, false, "Office PC"}, + make_probe_server_info("Office PC"), }); const std::vector drainedResults = ui::drain_host_probe_results(&queue); diff --git a/third-party/ffmpeg b/third-party/ffmpeg new file mode 160000 index 0000000..9047fa1 --- /dev/null +++ b/third-party/ffmpeg @@ -0,0 +1 @@ +Subproject commit 9047fa1b084f76b1b4d065af2d743df1b40dfb56 diff --git a/third-party/nxdk b/third-party/nxdk index e7cc20b..9d174a4 160000 --- a/third-party/nxdk +++ b/third-party/nxdk @@ -1 +1 @@ -Subproject commit e7cc20be2e9f6f87fda06655e752ef62afa92313 +Subproject commit 9d174a4df442c7527d2e361aefe59ac7894ebb42