diff --git a/.clang-format b/.clang-format index 1428d3c71e..8ffd4ebe96 100644 --- a/.clang-format +++ b/.clang-format @@ -2,7 +2,7 @@ Language: Cpp Standard: C++03 AccessModifierOffset: -4 -AlignAfterOpenBracket: Align +AlignAfterOpenBracket: DontAlign AlignConsecutiveAssignments: false AlignConsecutiveDeclarations: false AlignOperands: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1412aec639..139b0654d3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,6 +10,7 @@ on: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} jobs: build-linux: @@ -24,10 +25,10 @@ jobs: runner: ubuntu-latest preset: gcc artifact_arch: x86_64 - - name: GCC aarch64 - runner: ubuntu-24.04-arm - preset: gcc - artifact_arch: aarch64 + # - name: GCC aarch64 + # runner: ubuntu-24.04-arm + # preset: gcc + # artifact_arch: aarch64 # - name: Clang x86_64 # runner: ubuntu-latest # preset: clang @@ -46,10 +47,13 @@ jobs: zlib1g-dev libglu1-mesa-dev libdbus-1-dev libvulkan-dev libxi-dev libxrandr-dev libasound2-dev \ libpulse-dev libudev-dev libpng-dev libncurses5-dev libx11-xcb-dev libfreetype-dev \ libxinerama-dev libxcursor-dev python3-markupsafe libgtk-3-dev libssl-dev \ - libxss-dev libfuse2 + libxss-dev libfuse2 libusb-1.0-0-dev libdecor-0-dev libpipewire-0.3-dev libunwind-dev - name: Setup sccache - uses: mozilla-actions/sccache-action@v0.0.9 + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Print sccache stats + run: sccache --show-stats - name: Configure CMake run: cmake --preset x-linux-ci-${{matrix.preset}} @@ -63,12 +67,11 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: dusk-${{env.DUSK_VERSION}}-linux-${{matrix.preset}}-${{matrix.artifact_arch}} + name: dusklight-${{env.DUSK_VERSION}}-linux-${{matrix.preset}}-${{matrix.artifact_arch}} path: | - build/install/Dusk-*.AppImage + build/install/Dusklight-*.AppImage build/install/debug.tar.* - build-apple: name: Build Apple (${{matrix.name}}) runs-on: macos-latest @@ -76,15 +79,19 @@ jobs: fail-fast: false matrix: include: - - name: AppleClang macOS universal + - name: AppleClang macOS arm64 platform: macos - preset: x-macos-ci - artifact_name: macos-appleclang-universal - # - name: AppleClang iOS arm64 # TODO enable when CI is free - # platform: ios - # preset: x-ios-ci - # artifact_name: ios-appleclang-arm64 - # - name: AppleClang tvOS arm64 # TODO enable when CI is free + preset: x-macos-ci-arm64 + artifact_name: macos-appleclang-arm64 + - name: AppleClang macOS x86_64 + platform: macos + preset: x-macos-ci-x86_64 + artifact_name: macos-appleclang-x86_64 + - name: AppleClang iOS arm64 + platform: ios + preset: x-ios-ci + artifact_name: ios-appleclang-arm64 + # - name: AppleClang tvOS arm64 # platform: tvos # preset: x-tvos-ci # artifact_name: tvos-appleclang-arm64 @@ -95,22 +102,14 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Update Homebrew - if: matrix.platform == 'tvos' - run: | - brew update - brew upgrade --formula - - name: Install dependencies run: brew install cmake ninja - - name: Install markupsafe - if: matrix.platform == 'tvos' - run: pip3 install --break-system-packages markupsafe - - name: Install Rust iOS target if: matrix.platform == 'ios' - run: rustup target add aarch64-apple-ios + run: | + rustup toolchain install stable + rustup target add aarch64-apple-ios - name: Install Rust tvOS target if: matrix.platform == 'tvos' @@ -118,8 +117,14 @@ jobs: rustup toolchain install nightly rustup target add --toolchain nightly aarch64-apple-tvos + - name: Install Rust x86_64 macOS target + if: endsWith(matrix.preset, 'x86_64') + run: | + rustup toolchain install stable + rustup target add x86_64-apple-darwin + - name: Setup sccache - uses: mozilla-actions/sccache-action@v0.0.9 + uses: mozilla-actions/sccache-action@v0.0.10 - name: Configure CMake run: cmake --preset ${{matrix.preset}} @@ -130,19 +135,82 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: dusk-${{env.DUSK_VERSION}}-${{matrix.artifact_name}} + name: dusklight-${{env.DUSK_VERSION}}-${{matrix.artifact_name}} path: | - build/install/Dusk.app + build/install/Dusklight.app build/install/debug.tar.* + build-android: + name: Build Android (${{matrix.name}}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - name: Clang arm64-v8a + preset: x-android-ci-arm64 + abi: arm64-v8a + artifact_arch: arm64 + rust_target: aarch64-linux-android + + env: + ANDROID_NDK_VERSION: "29.0.14206865" + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get -y install ninja-build + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup Android SDK + uses: android-actions/setup-android@v4 + + - name: Install Android SDK packages + run: sdkmanager "platforms;android-36" "build-tools;36.1.0" "ndk;${ANDROID_NDK_VERSION}" + + - name: Install Rust Android target + run: | + rustup toolchain install stable + rustup target add ${{matrix.rust_target}} + + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Configure CMake + run: cmake --preset ${{matrix.preset}} + + - name: Build native library + run: cmake --build --preset ${{matrix.preset}} --target dusklight + + - name: Stage stripped JNI library + run: ANDROID_STAGE_ABIS="${{matrix.abi}}" platforms/android/scripts/stage-jni-libs.sh + + - name: Build APK + working-directory: platforms/android + run: ./gradlew :app:assembleRelease --rerun-tasks + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: dusklight-${{env.DUSK_VERSION}}-android-${{matrix.artifact_arch}} + path: platforms/android/app/build/outputs/apk/release/app-${{matrix.abi}}-release-unsigned.apk + build-windows: name: Build Windows (${{matrix.name}}) runs-on: ${{matrix.runner}} - env: - BUILD_DIR: C:\build - SCCACHE_DIR: C:\sccache - strategy: fail-fast: false matrix: @@ -153,12 +221,12 @@ jobs: msvc_arch: amd64 vcpkg_arch: x64 artifact_arch: x86_64 - - name: MSVC arm64 - runner: windows-11-arm - preset: arm64-msvc - msvc_arch: arm64 - vcpkg_arch: arm64 - artifact_arch: arm64 + # - name: MSVC arm64 + # runner: windows-11-arm + # preset: arm64-msvc + # msvc_arch: arm64 + # vcpkg_arch: arm64 + # artifact_arch: arm64 # - name: Clang x86_64 # runner: windows-latest # preset: clang @@ -187,9 +255,7 @@ jobs: - name: Install dependencies run: | choco install ninja - vcpkg install zlib:${{matrix.vcpkg_arch}}-windows-static bzip2:${{matrix.vcpkg_arch}}-windows-static ` - zstd:${{matrix.vcpkg_arch}}-windows-static liblzma:${{matrix.vcpkg_arch}}-windows-static ` - freetype:${{matrix.vcpkg_arch}}-windows-static + vcpkg install freetype:${{matrix.vcpkg_arch}}-windows-static zstd:${{matrix.vcpkg_arch}}-windows-static - name: Configure CMake run: cmake --preset x-windows-ci-${{matrix.preset}} @@ -200,7 +266,9 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: dusk-${{env.DUSK_VERSION}}-win32-msvc-${{matrix.artifact_arch}} + name: dusklight-${{env.DUSK_VERSION}}-win32-msvc-${{matrix.artifact_arch}} path: | - ${{env.BUILD_DIR}}/install/*.exe - ${{env.BUILD_DIR}}/install/debug.7z + build/install/*.exe + build/install/*.dll + build/install/res/ + build/install/debug.7z diff --git a/.gitignore b/.gitignore index a1839ff34e..43b2e4a1c3 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,10 @@ compile_commands.json # MacOS .DS_Store +# direnv / nix +.direnv/ +.envrc + # ISOs *.iso diff --git a/.vscode/launch.json b/.vscode/launch.json index 4cc435870a..515473f4d0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,11 +2,11 @@ "version": "0.2.0", "configurations": [ { - "name": "(gdb) Launch Dusk MSVC", + "name": "(gdb) Launch Dusklight MSVC", "type": "cppvsdbg", "request": "launch", "program": "${command:cmake.launchTargetPath}", - "args": ["-l", "1", "--dvd", "${workspaceRoot}/orig/GZ2E01/GZ2E01.iso"], + "args": ["-l", "1", "--dvd", "${workspaceRoot}/orig/GZ2E01/GZ2E01.iso", "--console"], "MIMode": "gdb", "miDebuggerPath": "gdb", "symbolSearchPath": "${command:cmake.launchTargetPath}", diff --git a/.vscode/settings.json b/.vscode/settings.json index bacff1eb9b..a5beeac1b9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "cmake.buildDirectory": "${workspaceFolder}/build/dusk/${buildType}/${variant:tp_version}", + "cmake.buildDirectory": "${workspaceFolder}/build/dusklight/${buildType}/${variant:tp_version}", "cmake.generator": "Ninja", "cmake.configureSettings": { "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" diff --git a/CMakeLists.txt b/CMakeLists.txt index 775606ca8e..cf69a8e2ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,8 +5,19 @@ if (NOT CMAKE_BUILD_TYPE) "Build type options: Debug Release RelWithDebInfo MinSizeRel" FORCE) endif () -# obtain revision info from git -find_package(Git) +set(DUSK_VERSION_OVERRIDE "" CACHE STRING "Override version string (skips git detection and format validation)") + +if (DUSK_VERSION_OVERRIDE) + set(DUSK_WC_DESCRIBE "${DUSK_VERSION_OVERRIDE}") + set(DUSK_VERSION_STRING "0.0.0.0") + set(DUSK_SHORT_VERSION_STRING "0.0.0") + set(DUSK_WC_REVISION "") + set(DUSK_WC_BRANCH "") + set(DUSK_WC_DATE "") + message(STATUS "Dusklight version overridden to ${DUSK_WC_DESCRIBE}") +else () + # obtain revision info from git + find_package(Git) if (GIT_FOUND) # make sure version information gets re-run when the current Git HEAD changes execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --git-path HEAD @@ -48,21 +59,33 @@ else () message(STATUS "Unable to find git, commit information will not be available") endif () -if (DUSK_WC_DESCRIBE) - string(REGEX REPLACE "v([0-9]+)\.([0-9]+)\.([0-9]+)\-([0-9]+).*" "\\1.\\2.\\3.\\4" DUSK_VERSION_STRING "${DUSK_WC_DESCRIBE}") - string(REGEX REPLACE "v([0-9]+)\.([0-9]+)\.([0-9]+).*" "\\1.\\2.\\3" DUSK_SHORT_VERSION_STRING "${DUSK_WC_DESCRIBE}") +if (DUSK_WC_DESCRIBE MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)([-+].*)?$") + set(DUSK_SHORT_VERSION_STRING "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}.${CMAKE_MATCH_3}") + set(DUSK_VERSION_TWEAK "0") + if (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-([0-9]+)(-dirty)?$") + set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") + elseif (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-[0-9A-Za-z.-]+-([0-9]+)(-dirty)?$") + set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") + endif () + set(DUSK_VERSION_STRING "${DUSK_SHORT_VERSION_STRING}.${DUSK_VERSION_TWEAK}") else () set(DUSK_WC_DESCRIBE "UNKNOWN-VERSION") - set(DUSK_VERSION_STRING "0.0.0") + set(DUSK_VERSION_STRING "0.0.0.0") + set(DUSK_SHORT_VERSION_STRING "0.0.0") +endif () + endif () # Add version information to CI environment variables if(DEFINED ENV{GITHUB_ENV}) file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION=${DUSK_WC_DESCRIBE}\n") endif() -message(STATUS "Dusk version set to ${DUSK_WC_DESCRIBE}") +message(STATUS "Dusklight version set to ${DUSK_WC_DESCRIBE}") message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") -project(dusk LANGUAGES C CXX VERSION ${DUSK_VERSION_STRING}) +project(dusklight LANGUAGES C CXX VERSION ${DUSK_VERSION_STRING}) +if (APPLE) + enable_language(OBJC OBJCXX) +endif () if (APPLE AND NOT TVOS AND CMAKE_SYSTEM_NAME STREQUAL tvOS) # ios.toolchain.cmake hack for SDL set(TVOS ON) @@ -82,25 +105,46 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_POSITION_INDEPENDENT_CODE ON) set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) +# Folder-based instead of target-based organization +# in Visual Studio and Xcode generators +set_property(GLOBAL PROPERTY USE_FOLDERS ON) +set_property(GLOBAL PROPERTY PREDEFINED_TARGETS_FOLDER "_cmake") + if (CMAKE_SYSTEM_NAME STREQUAL Linux) set(DAWN_USE_WAYLAND ON CACHE BOOL "Enable support for Wayland surface" FORCE) endif () set(AURORA_ENABLE_DVD ON CACHE BOOL "Enable DVD API support" FORCE) set(AURORA_ENABLE_CARD ON CACHE BOOL "Enable CARD API support" FORCE) +set(AURORA_ENABLE_RMLUI ON CACHE BOOL "Enable RmlUi UI support" FORCE) add_subdirectory(extern/aurora EXCLUDE_FROM_ALL) +target_compile_definitions(aurora_mtx PRIVATE MTX_USE_PS=1) add_subdirectory(libs/freeverb) option(DUSK_BUILD_WARNINGS "Enable compiler warnings (off by default)") option(DUSK_SELECTED_OPT "If on, selected parts of the project will be compiled with optimizations on Debug, intending to make the game run at 30 FPS. Note for MSVC: you will need to remove '/RTC1' from your debug flags in CMake.") option(DUSK_MOVIE_SUPPORT "If on, compile against libjpeg-turbo to enable THP file decoding" ON) +option(DUSK_ENABLE_UPDATE_CHECKER "Enable update checking support" ON) +option(DUSK_ENABLE_SENTRY_NATIVE "Enable sentry-native crash reporting support" OFF) +set(DUSK_SENTRY_DSN "" CACHE STRING "Sentry DSN") +set(DUSK_SENTRY_ENVIRONMENT "development" CACHE STRING "Sentry environment") + +# Edit & Continue +if (MSVC) + if ("${CMAKE_MSVC_DEBUG_INFORMATION_FORMAT}" STREQUAL "" AND CMAKE_BUILD_TYPE STREQUAL "Debug") + set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "EditAndContinue") + endif () + if (CMAKE_MSVC_DEBUG_INFORMATION_FORMAT STREQUAL "EditAndContinue") + add_link_options("/INCREMENTAL") + endif () +endif () if (DUSK_MOVIE_SUPPORT) - find_package(libjpeg-turbo QUIET) + find_package(libjpeg-turbo 3.0 CONFIG QUIET) if (libjpeg-turbo_FOUND) - message(STATUS "dusk: Using system libjpeg-turbo") + message(STATUS "dusklight: Using system libjpeg-turbo") else () - message(STATUS "dusk: Fetching libjpeg-turbo") + message(STATUS "dusklight: Fetching libjpeg-turbo") include(ExternalProject) set(_jpeg_install_dir ${CMAKE_BINARY_DIR}/libjpeg-turbo-install) if (WIN32) @@ -108,19 +152,40 @@ if (DUSK_MOVIE_SUPPORT) else () set(_jpeg_lib ${_jpeg_install_dir}/lib/libturbojpeg.a) endif () + set(_jpeg_cmake_args + -DCMAKE_INSTALL_PREFIX=${_jpeg_install_dir} + -DENABLE_SHARED=OFF + -DWITH_TURBOJPEG=ON + -DWITH_JAVA=OFF + ) + if (CMAKE_TOOLCHAIN_FILE) + get_filename_component(_jpeg_toolchain_file "${CMAKE_TOOLCHAIN_FILE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}") + list(APPEND _jpeg_cmake_args -DCMAKE_TOOLCHAIN_FILE=${_jpeg_toolchain_file}) + endif () + set(_jpeg_passthrough_vars + ANDROID_ABI + ANDROID_PLATFORM + CMAKE_BUILD_TYPE + CMAKE_C_COMPILER + CMAKE_C_COMPILER_LAUNCHER + CMAKE_MAKE_PROGRAM + CMAKE_MSVC_RUNTIME_LIBRARY + CMAKE_MSVC_DEBUG_INFORMATION_FORMAT + CMAKE_OSX_ARCHITECTURES + DEPLOYMENT_TARGET + ENABLE_ARC + ENABLE_BITCODE + PLATFORM + ) + foreach(_var IN LISTS _jpeg_passthrough_vars) + if (DEFINED ${_var}) + list(APPEND _jpeg_cmake_args -D${_var}=${${_var}}) + endif () + endforeach () ExternalProject_Add(libjpeg-turbo-ext URL https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/3.1.0.tar.gz URL_HASH SHA256=35fec2e1ddfb05ecf6d93e50bc57c1e54bc81c16d611ddf6eff73fff266d8285 - CMAKE_ARGS - -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} - -DCMAKE_INSTALL_PREFIX=${_jpeg_install_dir} - -DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE} - -DCMAKE_C_COMPILER=${CMAKE_C_COMPILER} - -DCMAKE_C_COMPILER_LAUNCHER=${CMAKE_C_COMPILER_LAUNCHER} - -DCMAKE_MSVC_RUNTIME_LIBRARY=${CMAKE_MSVC_RUNTIME_LIBRARY} - -DENABLE_SHARED=OFF - -DWITH_TURBOJPEG=ON - -DWITH_JAVA=OFF + CMAKE_ARGS ${_jpeg_cmake_args} BUILD_BYPRODUCTS ${_jpeg_lib} ) file(MAKE_DIRECTORY ${_jpeg_install_dir}/include) @@ -148,34 +213,36 @@ elseif (APPLE) set(CMAKE_INSTALL_RPATH "$ORIGIN") set(CMAKE_BUILD_RPATH "$ORIGIN") elseif (MSVC) - add_compile_options(/bigobj) - add_compile_options(/Zc:strictStrings-) - add_compile_options(/MP) - add_compile_options(/FS) + add_compile_options( + $<$:/bigobj> + $<$:/Zc:strictStrings-> + $<$:/MP> + $<$:/FS> + ) if (NOT DUSK_BUILD_WARNINGS) - add_compile_options(/W0) + add_compile_options($<$:/W0>) else () # Disable warnings - add_compile_options(/wd4068) # unknown pragma - add_compile_options(/wd4291) # no matching delete operator, leaks if exception thrown + add_compile_options($<$:/wd4068>) # unknown pragma + add_compile_options($<$:/wd4291>) # no matching delete operator, leaks if exception thrown # Only show warnings once - add_compile_options(/wo4244) # narrowing conversion, possible data loss + add_compile_options($<$:/wo4244>) # narrowing conversion, possible data loss endif () - add_compile_options(/utf-8) + add_compile_options($<$:/utf-8>) endif () include(FetchContent) # Declare all dependencies first so CMake can download them in parallel -message(STATUS "dusk: Fetching cxxopts") +message(STATUS "dusklight: Fetching cxxopts") FetchContent_Declare(cxxopts URL https://github.com/jarro2783/cxxopts/archive/refs/tags/v3.3.1.tar.gz URL_HASH SHA256=3bfc70542c521d4b55a46429d808178916a579b28d048bd8c727ee76c39e2072 DOWNLOAD_EXTRACT_TIMESTAMP TRUE ) -message(STATUS "dusk: Fetching nlohmann/json") +message(STATUS "dusklight: Fetching nlohmann/json") FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa @@ -183,38 +250,130 @@ FetchContent_Declare(json ) FetchContent_MakeAvailable(cxxopts json) +if (DUSK_ENABLE_SENTRY_NATIVE) + message(STATUS "dusklight: Fetching sentry-native") + set(SENTRY_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + set(SENTRY_BACKEND crashpad CACHE STRING "" FORCE) + if (WIN32) + set(SENTRY_TRANSPORT winhttp CACHE STRING "" FORCE) + endif () + set(SENTRY_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(SENTRY_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + set(SENTRY_BUILD_BENCHMARKS OFF CACHE BOOL "" FORCE) + FetchContent_Declare(sentry_native + GIT_REPOSITORY https://github.com/getsentry/sentry-native.git + GIT_TAG 0.13.6 + GIT_SHALLOW TRUE + GIT_PROGRESS TRUE + GIT_SUBMODULES_RECURSE TRUE + ) + if (NOT sentry_native_POPULATED) + FetchContent_Populate(sentry_native) + set(_dusk_skip_install_rules ${CMAKE_SKIP_INSTALL_RULES}) + set(CMAKE_SKIP_INSTALL_RULES ON) + add_subdirectory(${sentry_native_SOURCE_DIR} ${sentry_native_BINARY_DIR} EXCLUDE_FROM_ALL) + set(CMAKE_SKIP_INSTALL_RULES ${_dusk_skip_install_rules}) + endif () +endif () + +# Use signed char on ARM to match the original game (and x86) +string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _arch) +if(_arch MATCHES "^(arm|aarch64)" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "GNU") + add_compile_options(-fsigned-char) +endif() + +if (CMAKE_SYSTEM_NAME STREQUAL Windows) + set(PLATFORM_NAME win32) +elseif (CMAKE_SYSTEM_NAME STREQUAL Darwin) + if (IOS) + set(PLATFORM_NAME ios) + elseif (TVOS) + set(PLATFORM_NAME tvos) + else () + set(PLATFORM_NAME macos) + endif () +else () + string(TOLOWER CMAKE_SYSTEM_NAME PLATFORM_NAME) +endif () + configure_file(${CMAKE_SOURCE_DIR}/version.h.in ${CMAKE_BINARY_DIR}/version.h) include(files.cmake) # TODO: version handling for res includes -set(DUSK_BUNDLE_NAME Dusk) -set(DUSK_BUNDLE_IDENTIFIER dev.decomp.dusk) -set(DUSK_GAME_NAME "GZ2E") -set(DUSK_GAME_VERSION "01") -set(DUSK_TP_VERSION ${DUSK_GAME_NAME}${DUSK_GAME_VERSION}) +set(DUSK_BUNDLE_NAME Dusklight) +set(DUSK_BUNDLE_IDENTIFIER dev.twilitrealm.dusk) +set(DUSK_COMPANY_NAME "Twilit Realm") +set(DUSK_FILE_DESCRIPTION "Dusklight") +set(DUSK_PRODUCT_NAME "Dusklight") +set(DUSK_COPYRIGHT "Copyright (C) Twilit Realm contributors") -message(STATUS "dusk: Game Version: ${DUSK_TP_VERSION}") +source_group("dolzel" FILES ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${REL_FILES}) +source_group("dusklight" FILES ${DUSK_FILES} ${DUSK_HTTP_BACKEND_FILES}) -source_group("dolzel" FILES ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${JSYSTEM_FILES} ${JSYSTEM_DEBUG_FILES} ${REL_FILES}) -source_group("dusk" FILES ${DUSK_FILES}) - -set(GAME_COMPILE_DEFS TARGET_PC WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 - DUSK_TP_VERSION="${DUSK_TP_VERSION}" DUSK_GAME_NAME="${DUSK_GAME_NAME}" DUSK_GAME_VERSION="${DUSK_GAME_VERSION}") +set(GAME_COMPILE_DEFS TARGET_PC WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 MTX_USE_PS=1) set(GAME_INCLUDE_DIRS include src - assets/${DUSK_TP_VERSION} + assets/GZ2E01 # TODO: make this dynamic if needed? libs/JSystem/include libs extern/aurora/include/dolphin extern ${CMAKE_BINARY_DIR}) +find_package(Threads REQUIRED) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd - aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient) + aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt + Threads::Threads) + +list(APPEND GAME_LIBS libzstd_static) + +if (DUSK_ENABLE_SENTRY_NATIVE) + list(APPEND GAME_LIBS sentry) + list(APPEND GAME_COMPILE_DEFS DUSK_ENABLE_SENTRY_NATIVE=1 SENTRY_BUILD_STATIC=1) +endif () + +if (WIN32) + list(APPEND GAME_LIBS Ws2_32) +endif () + +set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/no_backend.cpp) +if (DUSK_ENABLE_UPDATE_CHECKER) + list(APPEND GAME_COMPILE_DEFS DUSK_ENABLE_UPDATE_CHECKER=1) + if (WIN32) + set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/winhttp.cpp) + list(APPEND GAME_LIBS winhttp) + list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_WINHTTP=1) + message(STATUS "dusklight: Enabled update checker (WinHTTP)") + elseif (ANDROID) + set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/android.cpp) + list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_ANDROID=1) + message(STATUS "dusklight: Enabled update checker (Android)") + elseif (APPLE) + find_library(FOUNDATION_FRAMEWORK Foundation REQUIRED) + set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/url_session.mm) + set_source_files_properties(src/dusk/http/url_session.mm PROPERTIES COMPILE_FLAGS -fobjc-arc) + list(APPEND GAME_LIBS ${FOUNDATION_FRAMEWORK}) + list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_URLSESSION=1) + message(STATUS "dusklight: Enabled update checker (NSURLSession)") + elseif (CMAKE_SYSTEM_NAME STREQUAL Linux) + find_package(CURL QUIET OPTIONAL_COMPONENTS HTTPS SSL) + if (CURL_FOUND AND CURL_HTTPS_FOUND AND CURL_SSL_FOUND) + set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/curl.cpp) + list(APPEND GAME_LIBS CURL::libcurl) + list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_LIBCURL=1) + message(STATUS "dusklight: Enabled update checker (libcurl)") + else () + message(STATUS "dusklight: Disabled update checker (libcurl + HTTPS/SSL not found)") + endif () + else () + message(STATUS "dusklight: Disabled update checker (unsupported platform)") + endif () +endif () +list(APPEND DUSK_FILES ${DUSK_HTTP_BACKEND_SOURCE}) if (DUSK_MOVIE_SUPPORT) if (TARGET libjpeg-turbo::turbojpeg-static) @@ -225,102 +384,191 @@ if (DUSK_MOVIE_SUPPORT) list(APPEND GAME_COMPILE_DEFS MOVIE_SUPPORT=1) endif () -# Edit & Continue -if (MSVC) - add_compile_options("/ZI") - add_link_options("/INCREMENTAL") +set(DUSK_ENABLE_DISCORD_DEFAULT ON) +if (DEFINED DUSK_ENABLE_DISCORD_RPC AND NOT DEFINED DUSK_ENABLE_DISCORD) + set(DUSK_ENABLE_DISCORD_DEFAULT ${DUSK_ENABLE_DISCORD_RPC}) +endif () +option(DUSK_ENABLE_DISCORD "Enable Discord Rich Presence support" ${DUSK_ENABLE_DISCORD_DEFAULT}) +if (DUSK_ENABLE_DISCORD AND NOT ANDROID AND NOT IOS AND NOT TVOS) + list(APPEND GAME_COMPILE_DEFS DUSK_DISCORD=1) +endif () + +if(ANDROID) + list(APPEND GAME_COMPILE_DEFS TARGET_ANDROID=1) endif () # game_debug is for game code files that we know work when compiled with DEBUG=1 # Of course, if building a release build, this distinction is irrelevant -add_library(game_debug OBJECT ${JSYSTEM_DEBUG_FILES} ${SSYSTEM_FILES} +set(GAME_DEBUG_FILES + ${SSYSTEM_FILES} src/dusk/audio/DuskAudioSystem.cpp src/dusk/audio/JASCriticalSection.cpp src/dusk/audio/DuskDsp.cpp src/dusk/audio/Adpcm.cpp src/dusk/audio/DspStub.cpp - src/dusk/imgui/ImGuiAudio.cpp) + src/dusk/imgui/ImGuiAudio.cpp +) +set_source_files_properties( + ${GAME_DEBUG_FILES} + PROPERTIES + COMPILE_DEFINITIONS "$<$:DEBUG=1>;$<$:PARTIAL_DEBUG=1>" +) # game_base is for all other game code files -add_library(game_base OBJECT ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${JSYSTEM_FILES} ${REL_FILES} ${DUSK_FILES} ${DOLPHIN_FILES}) - -target_compile_definitions(game_debug PRIVATE ${GAME_COMPILE_DEFS} $<$:DEBUG=1> $<$:PARTIAL_DEBUG=1>) -target_compile_definitions(game_base PRIVATE ${GAME_COMPILE_DEFS} NDEBUG=1 NDEBUG_DEFINED=1 DEBUG_DEFINED=0 $<$:PARTIAL_DEBUG=1>) - -# only apply PCH to game_base since not all headers are necessarily validated with DEBUG=1 -target_precompile_headers(game_base PRIVATE "$<$:${CMAKE_SOURCE_DIR}/include/dusk_pch.hpp>") - -target_include_directories(game_debug PRIVATE ${GAME_INCLUDE_DIRS}) -target_include_directories(game_base PRIVATE ${GAME_INCLUDE_DIRS}) - -# This implicitly pulls in the library include directories even though no -# linking actually takes place for object libraries -target_link_libraries(game_debug PRIVATE ${GAME_LIBS}) -target_link_libraries(game_base PRIVATE ${GAME_LIBS}) - -# Combined game library -add_library(game STATIC - $ - $) -target_link_libraries(game PUBLIC ${GAME_LIBS}) - -add_executable(dusk src/dusk/main.cpp) -target_compile_definitions(dusk PRIVATE TARGET_PC AVOID_UB=1 VERSION=0) -target_include_directories(dusk PRIVATE include) -target_link_libraries(dusk PRIVATE game aurora::main) - -add_custom_command(TARGET dusk POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CMAKE_SOURCE_DIR}/res" - "$/res" - COMMENT "Copying resources" +set(GAME_BASE_FILES + ${DOLZEL_FILES} + ${Z2AUDIOLIB_FILES} + ${REL_FILES} + ${DUSK_FILES} + ${DOLPHIN_FILES} ) +set_source_files_properties( + ${GAME_BASE_FILES} + PROPERTIES + COMPILE_DEFINITIONS "NDEBUG=1;NDEBUG_DEFINED=1;DEBUG_DEFINED=0;$<$:PARTIAL_DEBUG=1>" +) + +foreach(jsystem_lib IN LISTS JSYSTEM_LIBRARIES) + target_compile_definitions(${jsystem_lib} PRIVATE + ${GAME_COMPILE_DEFS} + $<$:DEBUG=1> + $<$:PARTIAL_DEBUG=1> + ) + target_include_directories(${jsystem_lib} PRIVATE ${GAME_INCLUDE_DIRS}) + target_link_libraries(${jsystem_lib} PRIVATE ${GAME_LIBS}) + set_target_properties(${jsystem_lib} PROPERTIES FOLDER "JSystem") +endforeach() + +set(JSYSTEM_LINK_LIBRARIES ${JSYSTEM_LIBRARIES}) +if (CMAKE_CXX_LINK_GROUP_USING_RESCAN_SUPPORTED OR CMAKE_LINK_GROUP_USING_RESCAN_SUPPORTED) + # GNU ld resolves static archives in a single left-to-right pass. The split + # JSystem libraries reference each other, so they need a RESCAN group there. + set(JSYSTEM_LINK_LIBRARIES "$") +endif () + +set(DUSK_FILES src/dusk/main.cpp ${GAME_BASE_FILES} ${GAME_DEBUG_FILES}) +if(ANDROID) + add_library(dusklight SHARED ${DUSK_FILES}) + set_target_properties(dusklight PROPERTIES OUTPUT_NAME main) +else () + add_executable(dusklight ${DUSK_FILES}) +endif () + +target_compile_definitions(dusklight PRIVATE ${GAME_COMPILE_DEFS}) +target_include_directories(dusklight PRIVATE ${GAME_INCLUDE_DIRS}) +target_link_libraries(dusklight PRIVATE aurora::main ${GAME_LIBS} ${JSYSTEM_LINK_LIBRARIES}) +target_precompile_headers(dusklight PRIVATE "$<$:${CMAKE_SOURCE_DIR}/include/dusk_pch.hpp>") +if (TARGET crashpad_handler) + add_dependencies(dusklight crashpad_handler) + add_custom_command(TARGET dusklight POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "$" + COMMENT "Copying crashpad handler" + ) +endif () + +if (ANDROID) + # SDLActivity loads SDL_main via dlsym on Android. Since aurora::main is a static + # archive, force an undefined reference so the linker keeps the SDL_main object. + target_link_options(dusklight PRIVATE "-Wl,-u,SDL_main") +endif () + +if (NOT APPLE) + add_custom_command(TARGET dusklight POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/res" + "$/res" + COMMENT "Copying resources" + ) +endif () + +if (WIN32) + set(DUSK_WINDOWS_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/windows) + set(DUSK_WINDOWS_ICON_PNG ${CMAKE_CURRENT_SOURCE_DIR}/res/icon.png) + set(DUSK_WINDOWS_ICON_ICO ${CMAKE_CURRENT_BINARY_DIR}/dusklight.ico) + set(DUSK_WINDOWS_RC ${CMAKE_CURRENT_BINARY_DIR}/dusklight.rc) + set(DUSK_WINDOWS_MANIFEST ${CMAKE_CURRENT_BINARY_DIR}/dusklight.manifest) + + add_custom_command( + OUTPUT ${DUSK_WINDOWS_ICON_ICO} + COMMAND powershell -ExecutionPolicy Bypass -File + ${DUSK_WINDOWS_RESOURCE_DIR}/Create-IcoFromPng.ps1 + -InputPng ${DUSK_WINDOWS_ICON_PNG} + -OutputIco ${DUSK_WINDOWS_ICON_ICO} + DEPENDS ${DUSK_WINDOWS_ICON_PNG} ${DUSK_WINDOWS_RESOURCE_DIR}/Create-IcoFromPng.ps1 + VERBATIM + COMMENT "Generating Windows icon" + ) + + configure_file(${DUSK_WINDOWS_RESOURCE_DIR}/dusklight.manifest.in ${DUSK_WINDOWS_MANIFEST} @ONLY) + configure_file(${DUSK_WINDOWS_RESOURCE_DIR}/dusklight.rc.in ${DUSK_WINDOWS_RC} @ONLY) + + target_sources(dusklight PRIVATE ${DUSK_WINDOWS_ICON_ICO} ${DUSK_WINDOWS_RC}) + set_target_properties(dusklight PROPERTIES WIN32_EXECUTABLE TRUE) + + if (MSVC) + target_link_options(dusklight PRIVATE /MANIFEST:NO) + endif () +endif () if (APPLE) if (IOS) set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios) - set(DUSK_INFO_PLIST ${DUSK_RESOURCE_DIR}/Info.plist.in) - file(GLOB_RECURSE DUSK_RESOURCE_FILES "${DUSK_RESOURCE_DIR}/Base.lproj/*") - endif () - if (IOS OR TVOS) - target_sources(dusk PRIVATE ${DUSK_RESOURCE_FILES}) - foreach (FILE ${DUSK_RESOURCE_FILES}) - file(RELATIVE_PATH NEW_FILE "${DUSK_RESOURCE_DIR}" ${FILE}) - get_filename_component(NEW_FILE_PATH ${NEW_FILE} DIRECTORY) - set_property(SOURCE ${FILE} PROPERTY MACOSX_PACKAGE_LOCATION "Resources/${NEW_FILE_PATH}") - endforeach () - set_target_properties( - dusk PROPERTIES - MACOSX_BUNDLE TRUE - MACOSX_BUNDLE_BUNDLE_NAME ${DUSK_BUNDLE_NAME} - MACOSX_BUNDLE_GUI_IDENTIFIER ${DUSK_BUNDLE_IDENTIFIER} - MACOSX_BUNDLE_BUNDLE_VERSION ${DUSK_VERSION_STRING} - MACOSX_BUNDLE_SHORT_VERSION_STRING ${DUSK_SHORT_VERSION_STRING} - OUTPUT_NAME dusk - XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES" - XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "YES" - ) - if (CMAKE_GENERATOR STREQUAL "Xcode") - set_target_properties(dusk PROPERTIES MACOSX_BUNDLE_INFO_PLIST ${DUSK_INFO_PLIST}) - elseif (DEFINED DUSK_INFO_PLIST) - set(MACOSX_BUNDLE_EXECUTABLE_NAME dusk) - set(MACOSX_BUNDLE_GUI_IDENTIFIER ${DUSK_BUNDLE_IDENTIFIER}) - set(MACOSX_BUNDLE_BUNDLE_NAME ${DUSK_BUNDLE_NAME}) - set(MACOSX_BUNDLE_BUNDLE_VERSION ${DUSK_VERSION_STRING}) - set(MACOSX_BUNDLE_SHORT_VERSION_STRING ${DUSK_SHORT_VERSION_STRING}) - set(DUSK_GENERATED_INFO_PLIST ${CMAKE_CURRENT_BINARY_DIR}/dusk.Info.plist) - configure_file(${DUSK_INFO_PLIST} ${DUSK_GENERATED_INFO_PLIST}) - add_custom_command( - TARGET dusk POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different ${DUSK_GENERATED_INFO_PLIST} $/Info.plist - VERBATIM - ) - endif () + elseif (TVOS) + set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/tvos) + else () + set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/macos) endif () + set(DUSK_INFO_PLIST ${DUSK_RESOURCE_DIR}/Info.plist.in) + file(GLOB_RECURSE DUSK_RESOURCE_FILES + "${DUSK_RESOURCE_DIR}/Assets.car" + "${DUSK_RESOURCE_DIR}/Base.lproj/*" + "${DUSK_RESOURCE_DIR}/Dusklight.icns") + file(GLOB_RECURSE DUSK_APP_RESOURCE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/res/*") + target_sources(dusklight PRIVATE ${DUSK_RESOURCE_FILES}) + target_sources(dusklight PRIVATE ${DUSK_APP_RESOURCE_FILES}) + foreach (FILE ${DUSK_RESOURCE_FILES}) + file(RELATIVE_PATH NEW_FILE "${DUSK_RESOURCE_DIR}" ${FILE}) + get_filename_component(NEW_FILE_PATH ${NEW_FILE} DIRECTORY) + set_property(SOURCE ${FILE} PROPERTY MACOSX_PACKAGE_LOCATION "Resources/${NEW_FILE_PATH}") + endforeach () + foreach (FILE ${DUSK_APP_RESOURCE_FILES}) + file(RELATIVE_PATH NEW_FILE "${CMAKE_CURRENT_SOURCE_DIR}" ${FILE}) + get_filename_component(NEW_FILE_PATH ${NEW_FILE} DIRECTORY) + set_property(SOURCE ${FILE} PROPERTY MACOSX_PACKAGE_LOCATION "Resources/${NEW_FILE_PATH}") + endforeach () + set_target_properties( + dusklight PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME ${DUSK_BUNDLE_NAME} + MACOSX_BUNDLE_GUI_IDENTIFIER ${DUSK_BUNDLE_IDENTIFIER} + MACOSX_BUNDLE_BUNDLE_VERSION ${DUSK_VERSION_STRING} + MACOSX_BUNDLE_SHORT_VERSION_STRING ${DUSK_SHORT_VERSION_STRING} + MACOSX_BUNDLE_INFO_PLIST ${DUSK_INFO_PLIST} + OUTPUT_NAME Dusklight + XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES" + XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "YES" + ) +endif () + +if (APPLE AND NOT IOS AND NOT TVOS) + find_library(APPKIT_FRAMEWORK AppKit REQUIRED) + target_sources(dusklight PRIVATE src/dusk/file_select_macos.mm) + set_source_files_properties(src/dusk/file_select_macos.mm PROPERTIES COMPILE_FLAGS -fobjc-arc) + target_link_libraries(dusklight PRIVATE ${APPKIT_FRAMEWORK}) +endif () + +if (IOS) + find_library(UIKIT_FRAMEWORK UIKit REQUIRED) + find_library(UNIFORM_TYPE_IDENTIFIERS_FRAMEWORK UniformTypeIdentifiers REQUIRED) + target_sources(dusklight PRIVATE src/dusk/ios/FileSelectDialog.m) + set_source_files_properties(src/dusk/ios/FileSelectDialog.m PROPERTIES COMPILE_FLAGS -fobjc-arc) + target_link_libraries(dusklight PRIVATE ${UIKIT_FRAMEWORK} ${UNIFORM_TYPE_IDENTIFIERS_FRAMEWORK}) endif () include(extern/aurora/cmake/AuroraCopyRuntimeDLLs.cmake) -aurora_copy_runtime_dlls(dusk) +aurora_copy_runtime_dlls(dusklight) if (DUSK_SELECTED_OPT) if (CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") @@ -358,12 +606,16 @@ function(get_target_prefix target result_var) endif () endif () endfunction() -list(APPEND BINARY_TARGETS dusk) +list(APPEND BINARY_TARGETS dusklight) set(EXTRA_TARGETS "") if (TARGET crashpad_handler) list(APPEND EXTRA_TARGETS crashpad_handler) endif () install(TARGETS ${BINARY_TARGETS} ${EXTRA_TARGETS} DESTINATION ${CMAKE_INSTALL_PREFIX}) +aurora_install_runtime_dlls(dusklight ${CMAKE_INSTALL_PREFIX}) +if (NOT APPLE) + install(DIRECTORY ${CMAKE_SOURCE_DIR}/res DESTINATION ${CMAKE_INSTALL_PREFIX}) +endif () if (CMAKE_BUILD_TYPE STREQUAL Debug OR CMAKE_BUILD_TYPE STREQUAL RelWithDebInfo) set(DEBUG_FILES_LIST "") foreach (target IN LISTS BINARY_TARGETS EXTRA_TARGETS) @@ -385,18 +637,22 @@ if (CMAKE_BUILD_TYPE STREQUAL Debug OR CMAKE_BUILD_TYPE STREQUAL RelWithDebInfo) endif () list(APPEND DEBUG_FILES_LIST "${output_name}") endforeach () - if (WIN32) - list(TRANSFORM DEBUG_FILES_LIST APPEND ".pdb") - list(JOIN DEBUG_FILES_LIST " " DEBUG_FILES) - install(CODE "execute_process(WORKING_DIRECTORY \"${CMAKE_INSTALL_PREFIX}\" COMMAND 7z a -t7z \"${CMAKE_INSTALL_PREFIX}/debug.7z\" ${DEBUG_FILES})") - elseif (APPLE) - list(TRANSFORM DEBUG_FILES_LIST APPEND ".dSYM") - list(JOIN DEBUG_FILES_LIST " " DEBUG_FILES) - install(CODE "execute_process(WORKING_DIRECTORY \"${CMAKE_INSTALL_PREFIX}\" COMMAND tar acfv \"${CMAKE_INSTALL_PREFIX}/debug.tar.xz\" ${DEBUG_FILES})") - elseif (UNIX) - list(TRANSFORM DEBUG_FILES_LIST APPEND ".dbg") - list(JOIN DEBUG_FILES_LIST " " DEBUG_FILES) - install(CODE "execute_process(WORKING_DIRECTORY \"${CMAKE_INSTALL_PREFIX}\" COMMAND tar -I \"xz -9 -T0\" -cvf \"${CMAKE_INSTALL_PREFIX}/debug.tar.xz\" ${DEBUG_FILES})") + # This is a terrible hack to only run this on CI + # until I turn this into a script or something + if(DEFINED ENV{GITHUB_ENV}) + if (WIN32) + list(TRANSFORM DEBUG_FILES_LIST APPEND ".pdb") + list(JOIN DEBUG_FILES_LIST " " DEBUG_FILES) + install(CODE "execute_process(WORKING_DIRECTORY \"${CMAKE_INSTALL_PREFIX}\" COMMAND 7z a -t7z \"${CMAKE_INSTALL_PREFIX}/debug.7z\" ${DEBUG_FILES})") + elseif (APPLE) + list(TRANSFORM DEBUG_FILES_LIST APPEND ".dSYM") + list(JOIN DEBUG_FILES_LIST " " DEBUG_FILES) + install(CODE "execute_process(WORKING_DIRECTORY \"${CMAKE_INSTALL_PREFIX}\" COMMAND tar acfv \"${CMAKE_INSTALL_PREFIX}/debug.tar.xz\" ${DEBUG_FILES})") + elseif (UNIX) + list(TRANSFORM DEBUG_FILES_LIST APPEND ".dbg") + list(JOIN DEBUG_FILES_LIST " " DEBUG_FILES) + install(CODE "execute_process(WORKING_DIRECTORY \"${CMAKE_INSTALL_PREFIX}\" COMMAND tar -I \"xz -9 -T0\" -cvf \"${CMAKE_INSTALL_PREFIX}/debug.tar.xz\" ${DEBUG_FILES})") + endif () endif () endif () foreach (target IN LISTS BINARY_TARGETS) diff --git a/CMakePresets.json b/CMakePresets.json index 73c9915aba..0201c61ed6 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -22,6 +22,20 @@ "CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreaded" } }, + { + "name": "ci", + "hidden": true, + "cacheVariables": { + "CMAKE_C_COMPILER_LAUNCHER": "sccache", + "CMAKE_CXX_COMPILER_LAUNCHER": "sccache", + "DUSK_ENABLE_SENTRY_NATIVE": { + "type": "BOOL", + "value": true + }, + "DUSK_SENTRY_DSN": "$env{SENTRY_DSN}", + "DUSK_SENTRY_ENVIRONMENT": "production" + } + }, { "name": "linux-default", "displayName": "Linux (default)", @@ -88,7 +102,7 @@ "name": "windows-msvc", "displayName": "Windows (MSVC)", "generator": "Ninja", - "binaryDir": "${sourceDir}/out/build/${presetName}", + "binaryDir": "${sourceDir}/build/${presetName}", "architecture": { "value": "x64", "strategy": "external" @@ -96,7 +110,7 @@ "cacheVariables": { "CMAKE_C_COMPILER": "cl", "CMAKE_CXX_COMPILER": "cl", - "CMAKE_INSTALL_PREFIX": "${sourceDir}/out/install" + "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install" }, "vendor": { "microsoft.com/VisualStudioSettings/CMake/1.0": { @@ -126,7 +140,7 @@ "name": "windows-arm64-msvc", "displayName": "Windows ARM64 (MSVC)", "generator": "Ninja", - "binaryDir": "${sourceDir}/out/build/${presetName}", + "binaryDir": "${sourceDir}/build/${presetName}", "architecture": { "value": "arm64", "strategy": "external" @@ -134,8 +148,7 @@ "cacheVariables": { "CMAKE_C_COMPILER": "cl", "CMAKE_CXX_COMPILER": "cl", - "CMAKE_INSTALL_PREFIX": "${sourceDir}/out/install", - "AURORA_DAWN_PROVIDER": "vendor" + "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install" }, "vendor": { "microsoft.com/VisualStudioSettings/CMake/1.0": { @@ -236,22 +249,11 @@ "type": "BOOL", "value": false }, - "CMAKE_DISABLE_FIND_PACKAGE_BZip2": { + "CMAKE_DISABLE_FIND_PACKAGE_PkgConfig": { "type": "BOOL", "value": true }, - "CMAKE_DISABLE_FIND_PACKAGE_LibLZMA": { - "type": "BOOL", - "value": true - }, - "CMAKE_DISABLE_FIND_PACKAGE_zstd": { - "type": "BOOL", - "value": true - }, - "CMAKE_DISABLE_FIND_PACKAGE_Freetype": { - "type": "BOOL", - "value": true - } + "CMAKE_IGNORE_PREFIX_PATH": "/opt/homebrew" }, "vendor": { "microsoft.com/VisualStudioSettings/CMake/1.0": { @@ -316,7 +318,11 @@ "cacheVariables": { "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install", "CMAKE_TOOLCHAIN_FILE": "$env{ANDROID_HOME}/ndk/$env{ANDROID_NDK_VERSION}/build/cmake/android.toolchain.cmake", - "ANDROID_PLATFORM": "android-24" + "ANDROID_PLATFORM": "android-28", + "BUILD_SHARED_LIBS": { + "type": "BOOL", + "value": false + } } }, { @@ -339,15 +345,40 @@ "ANDROID_ABI": "x86_64" } }, + { + "name": "x-android-ci", + "hidden": true, + "inherits": [ + "android-base", + "ci" + ], + "cacheVariables": { + "DUSK_ENABLE_SENTRY_NATIVE": { + "type": "BOOL", + "value": false + } + } + }, + { + "name": "x-android-ci-arm64", + "binaryDir": "${sourceDir}/build/android-arm64", + "inherits": [ + "x-android-ci" + ], + "cacheVariables": { + "ANDROID_ABI": "arm64-v8a", + "Rust_CARGO_TARGET": "aarch64-linux-android" + } + }, { "name": "x-linux-ci", "hidden": true, "inherits": [ - "relwithdebinfo" + "relwithdebinfo", + "ci" ], "cacheVariables": { - "CMAKE_C_COMPILER_LAUNCHER": "sccache", - "CMAKE_CXX_COMPILER_LAUNCHER": "sccache" + "AURORA_SDL3_PROVIDER": "vendor" } }, { @@ -367,11 +398,41 @@ { "name": "x-macos-ci", "inherits": [ - "macos-default-relwithdebinfo" + "macos-default-relwithdebinfo", + "ci" ], "cacheVariables": { - "CMAKE_C_COMPILER_LAUNCHER": "sccache", - "CMAKE_CXX_COMPILER_LAUNCHER": "sccache" + "AURORA_NOD_PROVIDER": "vendor", + "CMAKE_DISABLE_FIND_PACKAGE_PkgConfig": { + "type": "BOOL", + "value": true + }, + "CMAKE_OSX_DEPLOYMENT_TARGET": "11.0", + "CMAKE_IGNORE_PREFIX_PATH": "/opt/homebrew", + "BUILD_SHARED_LIBS": { + "type": "BOOL", + "value": false + } + } + }, + { + "name": "x-macos-ci-arm64", + "inherits": [ + "x-macos-ci" + ], + "cacheVariables": { + "CMAKE_OSX_ARCHITECTURES": "arm64" + } + }, + { + "name": "x-macos-ci-x86_64", + "inherits": [ + "x-macos-ci" + ], + "cacheVariables": { + "AURORA_DAWN_PROVIDER": "vendor", + "CMAKE_OSX_ARCHITECTURES": "x86_64", + "Rust_CARGO_TARGET": "x86_64-apple-darwin" } }, { @@ -398,14 +459,16 @@ "name": "x-windows-ci", "hidden": true, "inherits": [ - "relwithdebinfo" + "relwithdebinfo", + "ci" ], - "binaryDir": "$env{BUILD_DIR}", "cacheVariables": { - "CMAKE_INSTALL_PREFIX": "$env{BUILD_DIR}/install", - "CMAKE_C_COMPILER_LAUNCHER": "sccache", - "CMAKE_CXX_COMPILER_LAUNCHER": "sccache", - "CMAKE_MSVC_DEBUG_INFORMATION_FORMAT": "Embedded" + "CMAKE_MSVC_DEBUG_INFORMATION_FORMAT": "Embedded", + "CMAKE_TOOLCHAIN_FILE": { + "type": "FILEPATH", + "value": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" + }, + "VCPKG_TARGET_TRIPLET": "x64-windows" } }, { @@ -473,7 +536,7 @@ "description": "iOS release build with debug info", "displayName": "iOS RelWithDebInfo", "targets": [ - "dusk" + "dusklight" ] }, { @@ -482,7 +545,7 @@ "description": "tvOS release build with debug info", "displayName": "tvOS RelWithDebInfo", "targets": [ - "dusk" + "dusklight" ] }, { @@ -491,7 +554,7 @@ "description": "Android arm64-v8a release build with debug info", "displayName": "Android arm64-v8a RelWithDebInfo", "targets": [ - "dusk" + "dusklight" ] }, { @@ -500,7 +563,16 @@ "description": "Android x86_64 release build with debug info", "displayName": "Android x86_64 RelWithDebInfo", "targets": [ - "dusk" + "dusklight" + ] + }, + { + "name": "x-android-ci-arm64", + "configurePreset": "x-android-ci-arm64", + "description": "(Internal) Android CI arm64-v8a", + "displayName": "(Internal) Android CI arm64-v8a", + "targets": [ + "dusklight" ] }, { @@ -546,10 +618,19 @@ ] }, { - "name": "x-macos-ci", - "configurePreset": "x-macos-ci", - "description": "(Internal) macOS CI", - "displayName": "(Internal) macOS CI", + "name": "x-macos-ci-arm64", + "configurePreset": "x-macos-ci-arm64", + "description": "(Internal) macOS CI arm64", + "displayName": "(Internal) macOS CI arm64", + "targets": [ + "install" + ] + }, + { + "name": "x-macos-ci-x86_64", + "configurePreset": "x-macos-ci-x86_64", + "description": "(Internal) macOS CI x86_64", + "displayName": "(Internal) macOS CI x86_64", "targets": [ "install" ] @@ -557,8 +638,8 @@ { "name": "x-ios-ci", "configurePreset": "x-ios-ci", - "description": "(Internal) iOS CI", - "displayName": "(Internal) iOS CI", + "description": "(Internal) iOS CI arm64", + "displayName": "(Internal) iOS CI arm64", "targets": [ "install" ] @@ -566,8 +647,8 @@ { "name": "x-tvos-ci", "configurePreset": "x-tvos-ci", - "description": "(Internal) tvOS CI", - "displayName": "(Internal) tvOS CI", + "description": "(Internal) tvOS CI arm64", + "displayName": "(Internal) tvOS CI arm64", "targets": [ "install" ] diff --git a/README.md b/README.md index c41985d394..106d2c0a0e 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,69 @@ -## Dusk +
+ Logo -### Building -#### Prerequisites -* [CMake 3.25+](https://cmake.org) - * Windows: Install `CMake Tools` in Visual Studio - * macOS: `brew install cmake` -* [Python 3+](https://python.org) - * Windows: [Microsoft Store](https://go.microsoft.com/fwlink?linkID=2082640) - * Verify it's added to `%PATH%` by typing `python` in `cmd`. - * macOS: `brew install python@3` -* **[Windows]** [Visual Studio 2026 Community](https://www.visualstudio.com/en-us/products/visual-studio-community-vs.aspx) - * Select `C++ Development` and verify the following packages are included: - * `Windows 11 SDK` - * `CMake Tools` - * `C++ Clang Compiler` - * `C++ Clang-cl` -* **[macOS]** [Xcode 16.4+](https://developer.apple.com/xcode/download/) -* **[Linux]** Actively tested on Ubuntu 24.04, Arch Linux & derivatives. - * Ubuntu 24.04+ packages - ``` - build-essential curl git ninja-build clang lld zlib1g-dev libcurl4-openssl-dev \ - libglu1-mesa-dev libdbus-1-dev libvulkan-dev libxi-dev libxrandr-dev libasound2-dev libpulse-dev \ - libudev-dev libpng-dev libncurses5-dev cmake libx11-xcb-dev python3 python-is-python3 \ - libclang-dev libfreetype-dev libxinerama-dev libxcursor-dev python3-markupsafe libgtk-3-dev \ - libxss-dev libxtst-dev - ``` - * Arch Linux packages - ``` - base-devel cmake ninja llvm vulkan-headers python python-markupsafe clang lld alsa-lib libpulse libxrandr freetype2 - ``` - * Fedora packages - ``` - cmake vulkan-headers ninja-build clang-devel llvm-devel libpng-devel - ``` - * It's also important that you install the developer tools and libraries - ``` - sudo dnf groupinstall "Development Tools" "Development Libraries" - ``` -#### Setup -Clone and initialize the Dusk repository -```sh -git clone --recursive https://github.com/TwilitRealm/dusk.git -cd dusk -git pull -git submodule update --init --recursive -``` +

+ Official Website + • + Discord +

+
-#### Building +# Overview -**CLion (Windows / macOS / Linux)** +Dusklight is a reverse-engineered reimplementation of Twilight Princess. -Open the project directory in CLion. Enable the appropriate presets for your platform: +It aims to be as accurate as possible to the original while also providing new options, enhancements, and tools to customize your experience. -![CLion](assets/clion.png) +# Setup -**Visual Studio (Windows)** +> [!IMPORTANT] +> Dusklight does *not* provide any copyrighted assets. You must provide your own copy of the original game. -Open the project directory in Visual Studio. The CMake configuration will be loaded automatically. +> [!IMPORTANT] +> At a minimum, Dusklight requires a GPU with support for either D3D12, Vulkan, or Metal. Your experience with specific hardware, operating systems, and drivers may vary. In particular, older Intel iGPUs have a high likelihood of incompatibility. We are also aware of a number of issues on devices with Adreno GPUs and are working to resolve them. -**ninja (macOS)** +### 1. Verify your dump -```sh -cmake --preset macos-default-relwithdebinfo -cmake --build --preset macos-default-relwithdebinfo -``` +First, make sure your dump of the game is clean and supported by Dusklight. You can do this by checking the SHA-1 hash of your dump against this list of supported versions: -Alternate presets available: -- `macos-default-debug`: Clang, Debug +| Version | SHA-1 hash | +|--------------| ------------------------------------------ | +| GameCube USA | `75edd3ddff41f125d1b4ce1a40378f1b565519e7` | +| GameCube EUR | `2601822a488eeb86fb89db16ca8f29c2c953e1ca` | -**ninja (Linux)** +*Support for other versions of the game is planned in the future. -```sh -cmake --preset linux-default-relwithdebinfo -cmake --build --preset linux-default-relwithdebinfo -``` +### 2. Download [Dusklight](https://github.com/TwilitRealm/dusklight/releases) -Alternate presets available: -- `linux-default-debug`: GCC, Debug -- `linux-clang-relwithdebinfo`: Clang, RelWithDebInfo -- `linux-clang-debug`: Clang, Debug +### 3. Setup the game +**Windows / macOS / Linux** +- Extract the .zip file +- Launch Dusklight +- Press **Select Disc Image** and provide the path to your supported game dump +- Press **Play**! -**ninja (Windows)** +**iOS** +- Follow the [iOS setup guide](docs/ios-install-altstore.md) -```sh -cmake --preset windows-msvc-relwithdebinfo -cmake --build --preset windows-msvc-relwithdebinfo -``` +**Android** +- Install the Dusklight APK +- Launch Dusklight +- Press **Select Disc Image** and provide the path to your supported game dump +- Press **Play**! -Alternate presets available: -- `windows-msvc-debug`: MSVC, Debug -- `windows-clang-relwithdebinfo`: Clang-cl, RelWithDebInfo -- `windows-clang-debug`: Clang-cl, Debug +# Building -#### Running -Pass the disc image as a positional argument. Supported formats: ISO (GCM), RVZ, WIA, WBFS, CISO, GCZ -```sh -build/{preset}/dusk /path/to/game.rvz -``` -If no path is specified, Dusk defaults to `game.iso` in the current working directory. +If you'd like to build Dusklight from source, please read the [build instructions](docs/building.md). -#### 30 FPS on Debug -When compiled fully in a Debug the game runs too slowly to hit playable 30 FPS. To avoid this, you can set a CMake cache variable to optimize specific critical files without hampering debuggability in the rest of the program: `-DDUSK_SELECTED_OPT=ON`. When building for MSVC (Windows) you must also modify `CMAKE_CXX_FLAGS_DEBUG` and `CMAKE_C_FLAGS_DEBUG` to remove `/RTC1` from the flags, like so: `-DCMAKE_CXX_FLAGS_DEBUG="/MDd /Zi /Ob0 /Od" -DCMAKE_C_FLAGS_DEBUG="/MDd /Zi /Ob0 /Od"` +Pull requests are welcomed! Note that we do not accept contributions that are primarily AI-generated and will close your PR if we suspect as much. Please also see the [code conventions](docs/code-conventions.md). + +# Credits + +Special thanks to the [TP decompilation](https://github.com/zeldaret/tp) team, the GC/Wii decompilation community, the [Aurora](https://github.com/encounter/aurora) developers, the [TP speedrunning community](https://zsrtp.link), and all [contributors](https://github.com/TwilitRealm/dusklight/graphs/contributors). + +
+ diff --git a/assets/aurora-powered.png b/assets/aurora-powered.png new file mode 100644 index 0000000000..35ff017776 Binary files /dev/null and b/assets/aurora-powered.png differ diff --git a/assets/objdiff.png b/assets/objdiff.png deleted file mode 100644 index 94835fa1b3..0000000000 Binary files a/assets/objdiff.png and /dev/null differ diff --git a/assets/org-icon.svg b/assets/org-icon.svg new file mode 100644 index 0000000000..b1e82ead45 --- /dev/null +++ b/assets/org-icon.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ci/build-appimage.sh b/ci/build-appimage.sh index 82c4cffaeb..6653876f71 100755 --- a/ci/build-appimage.sh +++ b/ci/build-appimage.sh @@ -1,18 +1,26 @@ #!/bin/bash -ex -shopt -s extglob + +if [[ -n "${GITHUB_WORKSPACE:-}" ]]; then + cd "$GITHUB_WORKSPACE" +fi + +build_dir="$PWD/build" +linuxdeploy="$build_dir/linuxdeploy-$(uname -m).AppImage" # Get linuxdeploy -cd "$RUNNER_WORKSPACE" -curl -fOL https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-$(uname -m).AppImage -chmod +x linuxdeploy-$(uname -m).AppImage +mkdir -p "$build_dir" +curl -fL "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-$(uname -m).AppImage" -o "$linuxdeploy" +chmod +x "$linuxdeploy" # Build AppImage -cd "$GITHUB_WORKSPACE" mkdir -p build/appdir/usr/{bin,share/{applications,icons/hicolor}} -cp build/install/!(*.*) build/appdir/usr/bin +for install_path in build/install/*; do + [[ "$(basename "$install_path")" == *.* ]] && continue + cp -r "$install_path" build/appdir/usr/bin +done cp -r platforms/freedesktop/{16x16,32x32,48x48,64x64,128x128,256x256,512x512,1024x1024} build/appdir/usr/share/icons/hicolor -cp platforms/freedesktop/dusk.desktop build/appdir/usr/share/applications +cp platforms/freedesktop/dusklight.desktop build/appdir/usr/share/applications cd build/install -VERSION="$DUSK_VERSION" NO_STRIP=1 "$RUNNER_WORKSPACE"/linuxdeploy-$(uname -m).AppImage \ - --appdir "$GITHUB_WORKSPACE"/build/appdir --output appimage +VERSION="$DUSK_VERSION" NO_STRIP=1 "$linuxdeploy" \ + -l /usr/lib/x86_64-linux-gnu/libusb-1.0.so --appdir "$build_dir/appdir" --output appimage diff --git a/cmake-variants.yaml b/cmake-variants.yaml index 62d2642311..37edcede95 100644 --- a/cmake-variants.yaml +++ b/cmake-variants.yaml @@ -13,13 +13,3 @@ buildType: short: RelWithDebInfo long: Optimized, with debug symbols buildType: RelWithDebInfo - -tp_version: - default: GZ2E01 - description: TP Version - choices: - GZ2E01: - short: GZ2E01 - long: GZ2E01 - settings: - DUSK_TP_VERSION: GZ2E01 diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 0000000000..9f7879ab48 --- /dev/null +++ b/docs/building.md @@ -0,0 +1,228 @@ +# Building Dusklight + +## Dependencies + +The following dependencies are required: + +* [CMake 3.25+](https://cmake.org) +* [Python 3+](https://python.org) + +### Windows + +* Install [CMake 3.25+](https://cmake.org) by searching `CMake Tools` in Visual Studio +* Install Python 3 from the [Microsoft Store](https://go.microsoft.com/fwlink?linkID=2082640) and verify it's added to `%PATH%` by typing `python` in `cmd`. + +Recommended IDEs: + +* [Visual Studio 2026 Community](https://www.visualstudio.com/en-us/products/visual-studio-community-vs.aspx). During installation: + * Select `C++ Development` and verify the following packages are included: + * `Windows 11 SDK` + * `CMake Tools` + * `C++ Clang Compiler` + * `C++ Clang-cl` + +### macOS + +* Make sure [Homebrew](https://brew.sh) is installed +* Install [CMake 3.25+](https://cmake.org) + +```sh +brew install cmake +``` + +* Install Python 3 + +```sh +brew install python@3 +``` + +Recommended IDEs: + +* [Xcode 16.4 or later](https://developer.apple.com/xcode/) +* [Visual Studio Code](https://code.visualstudio.com/download/) +* [CLion](https://www.jetbrains.com/clion/) + +### Linux + +Actively tested on Ubuntu 24.04, Arch Linux & derivatives. + +**Ubuntu 24.04+ packages** + +
+Click to expand + +* Run the following command to install the required dependencies: + +```sh +sudo apt update && sudo apt install -y \ + build-essential \ + clang \ + cmake \ + curl \ + git \ + libasound2-dev \ + libclang-dev \ + libcurl4-openssl-dev \ + libdbus-1-dev \ + libfreetype-dev \ + libglu1-mesa-dev \ + libgtk-3-dev \ + libncurses5-dev \ + libpng-dev \ + libpulse-dev \ + libudev-dev \ + libvulkan-dev \ + libx11-xcb-dev \ + libxcursor-dev \ + libxi-dev \ + libxinerama-dev \ + libxrandr-dev \ + libxss-dev \ + libxtst-dev \ + lld \ + ninja-build \ + python-is-python3 \ + python3 \ + python3-markupsafe \ + zlib1g-dev +``` + +
+
+ +**Arch Linux packages** + +
+Click to expand + +* Run the following command to install the required dependencies: + +```sh +sudo pacman -S --needed \ + alsa-lib \ + base-devel \ + clang \ + cmake \ + freetype2 \ + libpulse \ + libxrandr \ + lld \ + llvm \ + ninja \ + python \ + python-markupsafe \ + vulkan-headers +``` + +
+
+ +**Fedora packages** + +
+Click to expand + +* Run the following command to install the required dependencies: + +```sh +sudo dnf install -y \ + clang-devel \ + cmake \ + libpng-devel \ + llvm-devel \ + ninja-build \ + vulkan-headers +``` + +* It's also important that you install the developer tools and libraries + +```sh +sudo dnf groupinstall \ + "Development Libraries" "Development Tools" +``` + +
+
+ +Recommended IDEs: + +* [CLion](https://www.jetbrains.com/clion/) +* [Visual Studio Code](https://code.visualstudio.com/download/) + +## Building + +* Clone and initialize the Dusklight repository: + +```sh +git clone --recursive https://github.com/TwilitRealm/dusklight.git +git pull +cd dusklight +git submodule update --init --recursive +``` + +**CLion (Windows / macOS / Linux)** + +Open the project directory in CLion. Enable the appropriate presets for your platform: + +![CLion](../assets/clion.png) + +**Visual Studio (Windows)** + +Open the project directory in Visual Studio. The CMake configuration will be loaded automatically. + +**ninja (macOS)** + +```sh +cmake --preset macos-default-relwithdebinfo +cmake --build --preset macos-default-relwithdebinfo +``` + +Alternate presets available: + +* `macos-default-debug`: Clang, Debug + +**ninja (Linux)** + +```sh +cmake --preset linux-default-relwithdebinfo +cmake --build --preset linux-default-relwithdebinfo +``` + +Alternate presets available: + +* `linux-default-debug`: GCC, Debug +* `linux-clang-relwithdebinfo`: Clang, RelWithDebInfo +* `linux-clang-debug`: Clang, Debug + +**ninja (Windows)** + +```sh +cmake --preset windows-msvc-relwithdebinfo +cmake --build --preset windows-msvc-relwithdebinfo +``` + +Alternate presets available: + +* `windows-msvc-debug`: MSVC, Debug +* `windows-clang-relwithdebinfo`: Clang-cl, RelWithDebInfo +* `windows-clang-debug`: Clang-cl, Debug + +## Running + +**Windows / Linux** + +* Pass the disc image as a positional argument using the `--dvd` flag. Supported formats are: ISO (GCM), RVZ, WIA, WBFS, CISO, GCZ + +```sh +build/{preset}/dusklight --dvd /path/to/game.iso +``` + +**macOS** + +macOS builds an `.app` bundle which contains the executable and all necessary resources. + +* Pass the disc image as a positional argument using the `--dvd` flag. Supported formats are: ISO (GCM), RVZ, WIA, WBFS, CISO, GCZ + +```sh +build/{preset}/Dusklight.app/Contents/MacOS/Dusklight --dvd /path/to/game.iso +``` diff --git a/docs/code-conventions.md b/docs/code-conventions.md new file mode 100644 index 0000000000..9c2659ac02 --- /dev/null +++ b/docs/code-conventions.md @@ -0,0 +1,13 @@ +# Code conventions for Dusk + +## Upstream when appropriate + +Bug fixes, documentation improvements, code cleanup, etc that also apply to the [original decompilation project](https://github.com/zeldaret/tp) should preferably be PR'd there. + +## Properly indicate Dusk-modified code + +When modifying original game code (i.e. in decomp) for Dusk's purposes, please clearly delineate such code as being Dusk-specific. Generally, this can be done by using `#if TARGET_PC` and keeping the original code in place. Use `#if AVOID_UB` for Undefined Behavior fixes to the original codebase. + +## Miscellaneous things + +* The original codebase makes heavy use of global `operator new` and similar overloads to allocate into a strict tree of heaps. This would cause many linkage headaches for us, so effectively all uses of `new` or `delete` in the original game code have been replaced with `JKR_NEW`, `JKR_DELETE`, or similar macros. See `JKRHeap.h` for the full list. diff --git a/docs/ios-install-altstore.md b/docs/ios-install-altstore.md new file mode 100644 index 0000000000..5c0f071878 --- /dev/null +++ b/docs/ios-install-altstore.md @@ -0,0 +1,48 @@ +# Installing Dusklight on iOS via iloader + +## Prerequisites + +- A Windows, Linux, or macOS device +- iOS device connected to computer via USB +- Dusklight IPA file (download the latest `Dusklight-vX.X.X-ios-arm64.ipa` from the [releases page](https://github.com/TwilitRealm/dusklight/releases)) +- Legally acquired game disc - `GZ2E01` (Gamecube USA) or `GZ2PE01` (Gamecube PAL) + +## 1. Install iloader + +- Executable bundles can be installed from [iloader's main page](https://iloader.app/) or [their GitHub](https://github.com/nab138/iloader) for Windows, Linux, and macOS. +- Windows WILL require iTunes to be installed +- Linux WILL require usbmuxd to be installed, this is installed by default in most distros though + +## 2. Enable Developer Mode (iOS 16+) + +- On your iPhone, go to **Settings > Privacy & Security > Developer Mode** +- Toggle it on, put in your device passcode, and restart when prompted + +## 3. Install Dusklight on Your iPhone + +1. Sign into your Apple ID (this is required for registering app IDs, it is sent securely directly to Apple and not stored by iloader) + * You may be prompted to put in a code from your iOS device if you have 2FA enabled, do so +2. Plug in your iOS device via USB into your PC. If you're missing a dependency, an error pop-up will tell you to install it + * You will need to hit `Refresh` after plugging it in at this stage so that it can be detected, it does not automatically refresh +3. Leave settings unchanged (the Anisette server should stay Sidestore (.io)) + 3.(a) Installing SideStore directly is not required, but provides you a way to install Dusklight on your phone without being plugged into a computer later +4. Press `Import IPA` and choose your downloaded `Dusklight-v.X.X.X-ios-arm64.ipa`, it will begin installing on your device + +**NOTE:** *At various stages, you may be prompted to trust your device, do so* + +## 3. Getting Dusklight trusted +When installing sideloaded iOS applications, at first you will need to manually trust the app due to Apple's security policies +* Go to **Settings > General > VPN & Device Management** +* Tap the Apple ID you signed into iloader with under "Developer App" and tap **Trust** +* Tap **Allow** on the pop-up + +## 4. Copy Files to Your iPhone + +Transfer the game disc (and optionally, the Dusklight IPA) to your iPhone so they are accessible in the Files app. A few ways to do this: + +- **AirDrop** - Right-click the files on your Mac and choose Share > AirDrop +- **iCloud Drive** - Place files in iCloud Drive on your Mac and they'll sync to Files on your iPhone +- **USB transfer** - Connect your iPhone and drag files via Finder's sidebar +- **Cloud storage** - Upload to Google Drive, Dropbox, etc. and download on your iPhone + +You may now use Dusklight on iOS and iPadOS! \ No newline at end of file diff --git a/extern/aurora b/extern/aurora index 4c0d0feb02..f93b9e5bc2 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 4c0d0feb0240546fc2815af709929c9f3bdcce22 +Subproject commit f93b9e5bc20850198538ccd3abc69ab2b128ecf7 diff --git a/files.cmake b/files.cmake index 7507fb6742..eb8c62bee5 100644 --- a/files.cmake +++ b/files.cmake @@ -1,7 +1,7 @@ set(DOLZEL_FILES src/m_Do/m_Do_main.cpp - src/m_Do/m_Do_printf.cpp + #src/m_Do/m_Do_printf.cpp src/m_Do/m_Do_audio.cpp src/m_Do/m_Do_controller_pad.cpp #src/m_Do/m_Re_controller_pad.cpp @@ -15,7 +15,6 @@ set(DOLZEL_FILES src/m_Do/m_Do_DVDError.cpp src/m_Do/m_Do_MemCard.cpp src/m_Do/m_Do_MemCardRWmng.cpp - src/m_Do/m_Do_machine_exception.cpp src/m_Do/m_Do_hostIO.cpp src/c/c_damagereaction.cpp src/c/c_dylink.cpp @@ -315,7 +314,7 @@ set(SSYSTEM_FILES src/SSystem/SStandard/s_basic.cpp ) -set(JSYSTEM_DEBUG_FILES +add_library(JSystem_JParticle STATIC libs/JSystem/src/JParticle/JPAResourceManager.cpp libs/JSystem/src/JParticle/JPAResource.cpp libs/JSystem/src/JParticle/JPABaseShape.cpp @@ -331,10 +330,19 @@ set(JSYSTEM_DEBUG_FILES libs/JSystem/src/JParticle/JPAEmitter.cpp libs/JSystem/src/JParticle/JPAParticle.cpp libs/JSystem/src/JParticle/JPAMath.cpp +) + +add_library(JSystem_JFramework STATIC libs/JSystem/src/JFramework/JFWSystem.cpp libs/JSystem/src/JFramework/JFWDisplay.cpp +) + +add_library(JSystem_J3DU STATIC libs/JSystem/src/J3DU/J3DUClipper.cpp libs/JSystem/src/J3DU/J3DUDL.cpp +) + +add_library(JSystem_JKernel STATIC libs/JSystem/src/JKernel/JKRHeap.cpp libs/JSystem/src/JKernel/JKRExpHeap.cpp libs/JSystem/src/JKernel/JKRSolidHeap.cpp @@ -360,14 +368,23 @@ set(JSYSTEM_DEBUG_FILES libs/JSystem/src/JKernel/JKRDvdRipper.cpp libs/JSystem/src/JKernel/JKRDvdAramRipper.cpp libs/JSystem/src/JKernel/JKRDecomp.cpp +) + +add_library(JSystem_JMath STATIC libs/JSystem/src/JMath/JMath.cpp libs/JSystem/src/JMath/random.cpp libs/JSystem/src/JMath/JMATrigonometric.cpp +) + +add_library(JSystem_JSupport STATIC libs/JSystem/src/JSupport/JSUList.cpp libs/JSystem/src/JSupport/JSUInputStream.cpp libs/JSystem/src/JSupport/JSUOutputStream.cpp libs/JSystem/src/JSupport/JSUMemoryStream.cpp libs/JSystem/src/JSupport/JSUFileStream.cpp +) + +add_library(JSystem_JUtility STATIC libs/JSystem/src/JUtility/JUTCacheFont.cpp libs/JSystem/src/JUtility/JUTResource.cpp libs/JSystem/src/JUtility/JUTTexture.cpp @@ -388,6 +405,9 @@ set(JSYSTEM_DEBUG_FILES libs/JSystem/src/JUtility/JUTConsole.cpp libs/JSystem/src/JUtility/JUTDirectFile.cpp libs/JSystem/src/JUtility/JUTFontData_Ascfont_fix12.cpp +) + +add_library(JSystem_JStage STATIC libs/JSystem/src/JStage/JSGActor.cpp libs/JSystem/src/JStage/JSGAmbientLight.cpp libs/JSystem/src/JStage/JSGCamera.cpp @@ -395,6 +415,9 @@ set(JSYSTEM_DEBUG_FILES libs/JSystem/src/JStage/JSGLight.cpp libs/JSystem/src/JStage/JSGObject.cpp libs/JSystem/src/JStage/JSGSystem.cpp +) + +add_library(JSystem_J2DGraph STATIC libs/JSystem/src/J2DGraph/J2DGrafContext.cpp libs/JSystem/src/J2DGraph/J2DOrthoGraph.cpp libs/JSystem/src/J2DGraph/J2DTevs.cpp @@ -413,6 +436,9 @@ set(JSYSTEM_DEBUG_FILES libs/JSystem/src/J2DGraph/J2DAnmLoader.cpp libs/JSystem/src/J2DGraph/J2DAnimation.cpp libs/JSystem/src/J2DGraph/J2DManage.cpp +) + +add_library(JSystem_J3DGraphBase STATIC libs/JSystem/src/J3DGraphBase/J3DGD.cpp libs/JSystem/src/J3DGraphBase/J3DSys.cpp libs/JSystem/src/J3DGraphBase/J3DVertex.cpp @@ -427,6 +453,9 @@ set(JSYSTEM_DEBUG_FILES libs/JSystem/src/J3DGraphBase/J3DTevs.cpp libs/JSystem/src/J3DGraphBase/J3DDrawBuffer.cpp libs/JSystem/src/J3DGraphBase/J3DStruct.cpp +) + +add_library(JSystem_J3DGraphAnimator STATIC libs/JSystem/src/J3DGraphAnimator/J3DShapeTable.cpp libs/JSystem/src/J3DGraphAnimator/J3DJointTree.cpp libs/JSystem/src/J3DGraphAnimator/J3DModelData.cpp @@ -438,6 +467,9 @@ set(JSYSTEM_DEBUG_FILES libs/JSystem/src/J3DGraphAnimator/J3DCluster.cpp libs/JSystem/src/J3DGraphAnimator/J3DJoint.cpp libs/JSystem/src/J3DGraphAnimator/J3DMaterialAttach.cpp +) + +add_library(JSystem_J3DGraphLoader STATIC libs/JSystem/src/J3DGraphLoader/J3DMaterialFactory.cpp libs/JSystem/src/J3DGraphLoader/J3DMaterialFactory_v21.cpp libs/JSystem/src/J3DGraphLoader/J3DClusterLoader.cpp @@ -446,6 +478,9 @@ set(JSYSTEM_DEBUG_FILES libs/JSystem/src/J3DGraphLoader/J3DJointFactory.cpp libs/JSystem/src/J3DGraphLoader/J3DShapeFactory.cpp libs/JSystem/src/J3DGraphLoader/J3DAnmLoader.cpp +) + +add_library(JSystem_JStudio STATIC libs/JSystem/src/JStudio/JStudio/ctb.cpp libs/JSystem/src/JStudio/JStudio/ctb-data.cpp libs/JSystem/src/JStudio/JStudio/functionvalue.cpp @@ -460,6 +495,9 @@ set(JSYSTEM_DEBUG_FILES libs/JSystem/src/JStudio/JStudio/stb.cpp libs/JSystem/src/JStudio/JStudio/stb-data-parse.cpp libs/JSystem/src/JStudio/JStudio/stb-data.cpp +) + +add_library(JSystem_JStudio_JStage STATIC libs/JSystem/src/JStudio/JStudio_JStage/control.cpp libs/JSystem/src/JStudio/JStudio_JStage/object.cpp libs/JSystem/src/JStudio/JStudio_JStage/object-actor.cpp @@ -467,10 +505,19 @@ set(JSYSTEM_DEBUG_FILES libs/JSystem/src/JStudio/JStudio_JStage/object-camera.cpp libs/JSystem/src/JStudio/JStudio_JStage/object-fog.cpp libs/JSystem/src/JStudio/JStudio_JStage/object-light.cpp +) + +add_library(JSystem_JStudio_JAudio2 STATIC libs/JSystem/src/JStudio/JStudio_JAudio2/control.cpp libs/JSystem/src/JStudio/JStudio_JAudio2/object-sound.cpp +) + +add_library(JSystem_JStudio_JParticle STATIC libs/JSystem/src/JStudio/JStudio_JParticle/control.cpp libs/JSystem/src/JStudio/JStudio_JParticle/object-particle.cpp +) + +add_library(JSystem_JAudio2 STATIC libs/JSystem/src/JAudio2/JASCalc.cpp libs/JSystem/src/JAudio2/JASTaskThread.cpp libs/JSystem/src/JAudio2/JASDvdThread.cpp @@ -535,22 +582,34 @@ set(JSYSTEM_DEBUG_FILES libs/JSystem/src/JAudio2/JAUSoundAnimator.cpp libs/JSystem/src/JAudio2/JAUSoundTable.cpp libs/JSystem/src/JAudio2/JAUStreamFileTable.cpp +) + +add_library(JSystem_JMessage STATIC libs/JSystem/src/JMessage/control.cpp libs/JSystem/src/JMessage/data.cpp libs/JSystem/src/JMessage/processor.cpp libs/JSystem/src/JMessage/resource.cpp libs/JSystem/src/JMessage/locale.cpp +) + +add_library(JSystem_JGadget STATIC libs/JSystem/src/JGadget/binary.cpp libs/JSystem/src/JGadget/define.cpp libs/JSystem/src/JGadget/linklist.cpp libs/JSystem/src/JGadget/search.cpp libs/JSystem/src/JGadget/std-vector.cpp +) + +add_library(JSystem_JAHostIO STATIC libs/JSystem/src/JAHostIO/JAHFrameNode.cpp libs/JSystem/src/JAHostIO/JAHioMessage.cpp libs/JSystem/src/JAHostIO/JAHioMgr.cpp libs/JSystem/src/JAHostIO/JAHioNode.cpp libs/JSystem/src/JAHostIO/JAHioUtil.cpp libs/JSystem/src/JAHostIO/JAHVirtualNode.cpp +) + +add_library(JSystem_JHostIO STATIC libs/JSystem/src/JHostIO/JORFile.cpp libs/JSystem/src/JHostIO/JORHostInfo.cpp libs/JSystem/src/JHostIO/JORMessageBox.cpp @@ -560,7 +619,28 @@ set(JSYSTEM_DEBUG_FILES libs/JSystem/src/JHostIO/JHIMccBuf.cpp ) -set(JSYSTEM_FILES +set(JSYSTEM_LIBRARIES + JSystem_JParticle + JSystem_JFramework + JSystem_J3DU + JSystem_JKernel + JSystem_JMath + JSystem_JSupport + JSystem_JUtility + JSystem_JStage + JSystem_J2DGraph + JSystem_J3DGraphBase + JSystem_J3DGraphAnimator + JSystem_J3DGraphLoader + JSystem_JStudio + JSystem_JStudio_JStage + JSystem_JStudio_JAudio2 + JSystem_JStudio_JParticle + JSystem_JAudio2 + JSystem_JMessage + JSystem_JGadget + JSystem_JAHostIO + JSystem_JHostIO ) set(REL_FILES @@ -1331,50 +1411,125 @@ set(DOLPHIN_FILES ) set(DUSK_FILES + include/dusk/action_bindings.h include/dusk/endian_gx.hpp include/dusk/config.hpp include/dusk/dvd_asset.hpp + include/dusk/scope_guard.hpp src/dusk/dvd_asset.cpp src/d/actor/d_a_alink_dusk.cpp src/dusk/asserts.cpp src/dusk/config.cpp + src/dusk/crash_reporting.cpp + src/dusk/data.cpp + src/dusk/data.hpp src/dusk/endian.cpp src/dusk/extras.c - src/dusk/extras.cpp + src/dusk/file_select.cpp + src/dusk/file_select.hpp src/dusk/frame_interpolation.cpp + src/dusk/game_clock.cpp src/dusk/globals.cpp - src/dusk/gyro_aim.cpp + src/dusk/gyro.cpp + src/dusk/gamepad_color.cpp + src/dusk/autosave.cpp + src/dusk/http/http.hpp src/dusk/io.cpp src/dusk/layout.cpp src/dusk/logging.cpp src/dusk/settings.cpp + src/dusk/speedrun.cpp src/dusk/stubs.cpp + src/dusk/update_check.cpp + src/dusk/update_check.hpp #src/dusk/m_Do_ext_dusk.cpp src/dusk/imgui/ImGuiConfig.hpp src/dusk/imgui/ImGuiConsole.hpp src/dusk/imgui/ImGuiConsole.cpp src/dusk/imgui/ImGuiEngine.cpp src/dusk/imgui/ImGuiEngine.hpp - src/dusk/imgui/ImGuiMenuGame.cpp - src/dusk/imgui/ImGuiMenuGame.hpp + src/dusk/imgui/ImGuiBloomWindow.cpp + src/dusk/imgui/ImGuiBloomWindow.hpp src/dusk/imgui/ImGuiMenuTools.cpp src/dusk/imgui/ImGuiMenuTools.hpp - src/dusk/imgui/ImGuiMenuEnhancements.cpp - src/dusk/imgui/ImGuiMenuEnhancements.hpp - src/dusk/imgui/ImGuiPreLaunchWindow.cpp - src/dusk/imgui/ImGuiPreLaunchWindow.hpp - src/dusk/imgui/ImGuiFirstRunPreset.hpp - src/dusk/imgui/ImGuiFirstRunPreset.cpp + src/dusk/imgui/ImGuiActorSpawner.cpp src/dusk/imgui/ImGuiProcessOverlay.cpp src/dusk/imgui/ImGuiCameraOverlay.cpp src/dusk/imgui/ImGuiHeapOverlay.cpp - src/dusk/imgui/ImGuiDebugPad.cpp src/dusk/imgui/ImGuiControllerOverlay.cpp src/dusk/imgui/ImGuiStubLog.cpp - src/dusk/imgui/ImGuiMapLoader.cpp src/dusk/imgui/ImGuiSaveEditor.cpp + src/dusk/imgui/ImGuiStateShare.hpp + src/dusk/imgui/ImGuiStateShare.cpp + src/dusk/ui/achievements.cpp + src/dusk/ui/achievements.hpp + src/dusk/ui/bool_button.cpp + src/dusk/ui/bool_button.hpp + src/dusk/ui/button.cpp + src/dusk/ui/button.hpp + src/dusk/ui/component.cpp + src/dusk/ui/component.hpp + src/dusk/ui/controller_config.cpp + src/dusk/ui/controller_config.hpp + src/dusk/ui/document.cpp + src/dusk/ui/document.hpp + src/dusk/ui/editor.cpp + src/dusk/ui/editor.hpp + src/dusk/ui/event.cpp + src/dusk/ui/event.hpp + src/dusk/ui/graphics_tuner.cpp + src/dusk/ui/graphics_tuner.hpp + src/dusk/ui/input.cpp + src/dusk/ui/input.hpp + src/dusk/ui/modal.cpp + src/dusk/ui/modal.hpp + src/dusk/ui/nav_types.hpp + src/dusk/ui/number_button.cpp + src/dusk/ui/number_button.hpp + src/dusk/ui/overlay.cpp + src/dusk/ui/overlay.hpp + src/dusk/ui/pane.cpp + src/dusk/ui/pane.hpp + src/dusk/ui/menu_bar.cpp + src/dusk/ui/menu_bar.hpp + src/dusk/ui/prelaunch.cpp + src/dusk/ui/prelaunch.hpp + src/dusk/ui/preset.cpp + src/dusk/ui/preset.hpp + src/dusk/ui/reporting.cpp + src/dusk/ui/reporting.hpp + src/dusk/ui/select_button.cpp + src/dusk/ui/select_button.hpp + src/dusk/ui/settings.cpp + src/dusk/ui/settings.hpp + src/dusk/ui/string_button.cpp + src/dusk/ui/string_button.hpp + src/dusk/ui/tab_bar.cpp + src/dusk/ui/tab_bar.hpp + src/dusk/ui/ui.cpp + src/dusk/ui/ui.hpp + src/dusk/ui/warp.cpp + src/dusk/ui/warp.hpp + src/dusk/ui/window.cpp + src/dusk/ui/window.hpp + src/dusk/achievements.cpp + src/dusk/iso_validate.cpp + src/dusk/livesplit.cpp src/dusk/offset_ptr.cpp src/dusk/OSContext.cpp + src/dusk/OSReport.cpp src/dusk/OSThread.cpp src/dusk/OSMutex.cpp + src/dusk/discord.cpp + src/dusk/discord.hpp + src/dusk/discord_presence.cpp + src/dusk/version.cpp + src/dusk/action_bindings.cpp +) + +set(DUSK_HTTP_BACKEND_FILES + src/dusk/http/no_backend.cpp + src/dusk/http/curl.cpp + src/dusk/http/winhttp.cpp + src/dusk/http/url_session.mm ) diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..8ec14d2852 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1775710090, + "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "4c1018dae018162ec878d42fec712642d214fdfa", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..29c99b10a5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,219 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + }; + outputs = { self, nixpkgs }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + pkgsFor = system: import nixpkgs { inherit system; }; + + # Dependencies that are not packaged in nixpkgs (used by the Linux package build): + buildSources = pkgs: { + dawn-src = pkgs.fetchzip { + url = "https://github.com/encounter/dawn-build/releases/download/v20260423.175430/dawn-linux-x86_64.tar.gz"; + hash = "sha256-HXfKTLHtMPwupnFnaflCARtXVPuS/0PoCePXidjE5xs="; + stripRoot = false; + }; + nod-src = pkgs.fetchzip { + url = "https://github.com/encounter/nod/releases/download/v2.0.0-alpha.8/libnod-linux-x86_64.tar.gz"; + hash = "sha256-mUqvLsbsqaZ+HAjMmHYPYO+MgtanGRTw7Gzn5uXR5rE="; + stripRoot = false; + }; + # The version of imgui on nixpkgs does not map cleanly. + imgui-src = pkgs.fetchFromGitHub { + owner = "ocornut"; + repo = "imgui"; + rev = "v1.91.9b-docking"; + hash = "sha256-mQOJ6jCN+7VopgZ61yzaCnt4R1QLrW7+47xxMhFRHLQ="; + }; + sqlite-src = pkgs.fetchzip { + url = "https://sqlite.org/2026/sqlite-amalgamation-3510300.zip"; + hash = "sha256-pNMR8zxaaqfAzQ0AQBOXMct4usdjey1Q0Gnitg06UhM="; + }; + rmlui-src = pkgs.fetchzip { + url = "https://github.com/mikke89/RmlUi/archive/f9b8c9e2935d5df2c7dff2c190d3968e99b0c3dc.tar.gz"; + hash = "sha256-g4O/JZUrrcseOz8o2QJRt+2CeuiLnVeuDJc906xvuIg="; + }; + }; + + # Dusklight Actual (Linux x86_64 only — relies on prebuilt dawn/nod binaries) + mkDusklight = pkgs: + let srcs = buildSources pkgs; + versionSuffix = if self ? shortRev && self.shortRev != null + then "nix-${self.shortRev}" + else "nix-dirty"; + in + pkgs.stdenv.mkDerivation { + name = "dusklight"; + src = ./.; + postUnpack = '' + sed -i '/add_subdirectory(tests)/d' $sourceRoot/extern/aurora/CMakeLists.txt + ''; + # Remove last line to re-enable tests + cmakeFlags = [ + "-DDUSK_VERSION_OVERRIDE=${versionSuffix}" + "-DFETCHCONTENT_FULLY_DISCONNECTED=ON" + "-DFETCHCONTENT_SOURCE_DIR_CXXOPTS=${pkgs.cxxopts.src}" + "-DFETCHCONTENT_SOURCE_DIR_JSON=${pkgs.nlohmann_json.src}" + "-DFETCHCONTENT_SOURCE_DIR_DAWN_PREBUILT=${srcs.dawn-src}" + "-DFETCHCONTENT_SOURCE_DIR_XXHASH=${pkgs.xxHash.src}" + "-DFETCHCONTENT_SOURCE_DIR_FMT=${pkgs.fmt.src}" + "-DFETCHCONTENT_SOURCE_DIR_TRACY=${pkgs.tracy.src}" + "-DAURORA_SDL3_PROVIDER=system" + "-DFETCHCONTENT_SOURCE_DIR_NOD_PREBUILT=${srcs.nod-src}" + "-DAURORA_NOD_PROVIDER=package" + "-DFETCHCONTENT_SOURCE_DIR_FREETYPE=${pkgs.freetype.src}" + "-DFETCHCONTENT_SOURCE_DIR_ZSTD=${pkgs.zstd.src}" + "-DFETCHCONTENT_SOURCE_DIR_SQLITE3=${srcs.sqlite-src}" + "-DFETCHCONTENT_SOURCE_DIR_IMGUI=${srcs.imgui-src}" + "-DFETCHCONTENT_SOURCE_DIR_RMLUI=${srcs.rmlui-src}" + "-DCMAKE_CROSSCOMPILING=ON" # Tests are not working as I didn't want to work through getting google's test suite working as well. This is the only guard I could find to disable it. + ]; + installPhase = '' + mkdir -p $out/bin + cp dusklight $out/bin/dusklight + cp -r ./res $out/bin/res + + mkdir -p $out/share/applications + cp $src/platforms/freedesktop/dusklight.desktop $out/share/applications/dusklight.desktop + + for size in 16 32 48 64 128 256 512 1024; do + install -Dm644 $src/platforms/freedesktop/''${size}x''${size}/apps/dusklight.png \ + $out/share/icons/hicolor/''${size}x''${size}/apps/dusklight.png + done + ''; + nativeBuildInputs = [ + pkgs.cmake + pkgs.pkg-config + pkgs.wayland + ]; + buildInputs = [ + pkgs.libGL + pkgs.libX11 + pkgs.libXcursor + pkgs.libxi + pkgs.libxcb + pkgs.libxrandr + pkgs.libxscrnsaver + pkgs.libxtst + pkgs.libjpeg8 + pkgs.libxkbcommon + pkgs.libglvnd + pkgs.cxxopts + pkgs.abseil-cpp + pkgs.sdl3 + pkgs.fmt + pkgs.tracy + pkgs.freetype + pkgs.zstd + ]; + }; + + # Tooling common to every supported host (Linux and macOS). + commonDevTools = pkgs: [ + pkgs.cmake + pkgs.ninja + pkgs.pkg-config + pkgs.git + pkgs.python3 + pkgs.python3Packages.markupsafe + pkgs.rustc + pkgs.cargo + pkgs.sccache + ]; + + # Linux-only system libraries — mirrors the apt deps from .github/workflows/build.yml + # so the cmake presets resolve the same set of headers as CI. + linuxDevDeps = pkgs: [ + # Compilers / linkers + pkgs.clang + pkgs.lld + # C/C++ utilities + pkgs.curl + pkgs.openssl + pkgs.zlib + pkgs.libpng + pkgs.libjpeg_turbo + pkgs.freetype + pkgs.zstd + pkgs.fmt + pkgs.tracy + pkgs.cxxopts + pkgs.abseil-cpp + pkgs.sdl3 + pkgs.ncurses + pkgs.libunwind + pkgs.libusb1 + pkgs.fuse + # Wayland / display server + pkgs.wayland + pkgs.wayland-protocols + pkgs.libxkbcommon + pkgs.libdecor + # OpenGL / Vulkan + pkgs.libGL + pkgs.libGLU + pkgs.libglvnd + pkgs.vulkan-headers + pkgs.vulkan-loader + # X11 + pkgs.libX11 + pkgs.libxcb + pkgs.libXcursor + pkgs.libxi + pkgs.libxrandr + pkgs.libxscrnsaver + pkgs.libxtst + pkgs.libxinerama + # Audio + pkgs.alsa-lib + pkgs.libpulseaudio + pkgs.pipewire + # System integration + pkgs.dbus + pkgs.udev + pkgs.gtk3 + ]; + + # On macOS we deliberately avoid pulling Nix's cc-wrapper so CMake picks up + # Apple Clang and the Xcode SDK directly, matching the macOS CI workflow. + mkDarwinShell = pkgs: + pkgs.mkShellNoCC { + packages = commonDevTools pkgs; + shellHook = '' + echo "Dusklight dev shell (macOS)" + echo "Requires Xcode Command Line Tools for Apple Clang and the macOS SDK." + echo "Configure: cmake --preset macos-default-relwithdebinfo" + echo "Build: cmake --build --preset macos-default-relwithdebinfo" + ''; + }; + + mkLinuxShell = pkgs: + pkgs.mkShell { + packages = (commonDevTools pkgs) ++ (linuxDevDeps pkgs); + shellHook = '' + echo "Dusklight dev shell (Linux)" + echo "Configure: cmake --preset linux-default-relwithdebinfo" + echo " cmake --preset linux-clang-relwithdebinfo" + echo "Build: cmake --build --preset " + ''; + }; + + mkDevShell = pkgs: + if pkgs.stdenv.isDarwin + then mkDarwinShell pkgs + else mkLinuxShell pkgs; + in { + packages.x86_64-linux.default = mkDusklight (pkgsFor "x86_64-linux"); + + devShells = forAllSystems (system: { + default = mkDevShell (pkgsFor system); + }); + }; +} diff --git a/include/Z2AudioLib/Z2WolfHowlMgr.h b/include/Z2AudioLib/Z2WolfHowlMgr.h index 2b2595d1ea..10b746a4c1 100644 --- a/include/Z2AudioLib/Z2WolfHowlMgr.h +++ b/include/Z2AudioLib/Z2WolfHowlMgr.h @@ -39,6 +39,10 @@ enum Z2WolfHowlCurveID { Z2WOLFHOWL_NEWSONG2, Z2WOLFHOWL_NEWSONG3, + #if TARGET_PC + Z2WOLFHOWL_TIMESONG, + #endif + Z2WOLFHOWL_MAX }; diff --git a/include/d/actor/d_a_alink.h b/include/d/actor/d_a_alink.h index b68a4de5a9..107afc1ed3 100644 --- a/include/d/actor/d_a_alink.h +++ b/include/d/actor/d_a_alink.h @@ -4549,8 +4549,21 @@ public: /* 0x03850 */ daAlink_procFunc mpProcFunc; #if TARGET_PC + void handleWolfHowl(); void handleQuickTransform(); - bool checkGyroAimItemContext(); + bool checkGyroAimContext(); + + void onIronBallChainInterpCallback(); + + static const int IRON_BALL_CHAIN_COUNT = 102; + cXyz mIBChainInterpPrevPos[IRON_BALL_CHAIN_COUNT]; + cXyz mIBChainInterpCurrPos[IRON_BALL_CHAIN_COUNT]; + csXyz mIBChainInterpPrevAngle[IRON_BALL_CHAIN_COUNT]; + csXyz mIBChainInterpCurrAngle[IRON_BALL_CHAIN_COUNT]; + cXyz mIBChainInterpPrevHandRoot; + cXyz mIBChainInterpCurrHandRoot; + bool mIBChainInterpPrevValid; + bool mIBChainInterpCurrValid; #endif }; // Size: 0x385C diff --git a/include/d/actor/d_a_b_gnd.h b/include/d/actor/d_a_b_gnd.h index b9c8781ec6..0827102eec 100644 --- a/include/d/actor/d_a_b_gnd.h +++ b/include/d/actor/d_a_b_gnd.h @@ -188,6 +188,15 @@ public: /* 0x273C */ f32 mKankyoBlend; /* 0x2740 */ u8 field_0x2740; /* 0x2744 */ dMsgFlow_c mMsgFlow; +#if TARGET_PC + cXyz mReinsInterpPrev[2][16]; + cXyz mReinsInterpCurr[2][16]; + cXyz mReinsTexInterpPrev[2]; + cXyz mReinsTexInterpCurr[2]; + bool mReinsInterpPrevValid; + bool mReinsInterpCurrValid; + s8 mDemoCamSyncTicks; +#endif }; STATIC_ASSERT(sizeof(b_gnd_class) == 0x2790); diff --git a/include/d/actor/d_a_balloon_2D.h b/include/d/actor/d_a_balloon_2D.h index f2dfa5e99f..193ecadbaa 100644 --- a/include/d/actor/d_a_balloon_2D.h +++ b/include/d/actor/d_a_balloon_2D.h @@ -44,6 +44,9 @@ public: int draw(); int execute(); void drawMeter(); + #if TARGET_PC + void updateOnWide(); + #endif void setComboCount(u8, u8); void setScoreCount(u32); void addScoreCount(cXyz*, u32, u8); diff --git a/include/d/actor/d_a_e_db.h b/include/d/actor/d_a_e_db.h index a95722a6d1..4f10715443 100644 --- a/include/d/actor/d_a_e_db.h +++ b/include/d/actor/d_a_e_db.h @@ -80,6 +80,12 @@ public: /* 0x125C */ u32 field_0x125c; /* 0x1260 */ u8 field_0x1260[0x126C - 0x1260]; /* 0x126C */ u8 HIOInit; +#if TARGET_PC + cXyz mStalkLineInterpPrev[12]; + cXyz mStalkLineInterpCurr[12]; + bool mStalkLineInterpPrevValid; + bool mStalkLineInterpCurrValid; +#endif }; STATIC_ASSERT(sizeof(e_db_class) == 0x1270); diff --git a/include/d/actor/d_a_e_hb.h b/include/d/actor/d_a_e_hb.h index a7e0241007..3069bcd325 100644 --- a/include/d/actor/d_a_e_hb.h +++ b/include/d/actor/d_a_e_hb.h @@ -73,6 +73,12 @@ public: /* 0x124C */ f32 field_0x124c; /* 0x1250 */ u8 field_0x1250[0x1264 - 0x1250]; /* 0x1264 */ u8 HIOInit; +#if TARGET_PC + cXyz mStalkLineInterpPrev[12]; + cXyz mStalkLineInterpCurr[12]; + bool mStalkLineInterpPrevValid; + bool mStalkLineInterpCurrValid; +#endif }; STATIC_ASSERT(sizeof(e_hb_class) == 0x1268); diff --git a/include/d/actor/d_a_e_mb.h b/include/d/actor/d_a_e_mb.h index f36c744879..527cf30eac 100644 --- a/include/d/actor/d_a_e_mb.h +++ b/include/d/actor/d_a_e_mb.h @@ -44,6 +44,12 @@ public: /* 0x88C */ u8 field_0x88C[0x8C8 - 0x88C]; /* 0x8C8 */ s8 field_0x8c8; /* 0x8C9 */ u8 mInitHIO; +#if TARGET_PC + cXyz mRopeInterpPrev[16]; + cXyz mRopeInterpCurr[16]; + bool mRopeInterpPrevValid; + bool mRopeInterpCurrValid; +#endif }; STATIC_ASSERT(sizeof(e_mb_class) == 0x8cc); diff --git a/include/d/actor/d_a_e_s1.h b/include/d/actor/d_a_e_s1.h index a631ba47ac..5cbae84696 100644 --- a/include/d/actor/d_a_e_s1.h +++ b/include/d/actor/d_a_e_s1.h @@ -81,6 +81,15 @@ public: /* 0x306D */ u8 field_0x306D[0x307C - 0x306D]; /* 0x307C */ u32 mBodyEffEmtrID; /* 0x3080 */ u8 mInitHIO; + +#if TARGET_PC + static const int HAIR_STRAND_COUNT = 22; + static const int HAIR_SEGMENT_COUNT = 16; + cXyz mHairInterpPrev[HAIR_STRAND_COUNT * HAIR_SEGMENT_COUNT]; + cXyz mHairInterpCurr[HAIR_STRAND_COUNT * HAIR_SEGMENT_COUNT]; + bool mHairInterpPrevValid; + bool mHairInterpCurrValid; +#endif }; STATIC_ASSERT(sizeof(e_s1_class) == 0x3084); diff --git a/include/d/actor/d_a_e_wb.h b/include/d/actor/d_a_e_wb.h index dd36474025..ba680366fb 100644 --- a/include/d/actor/d_a_e_wb.h +++ b/include/d/actor/d_a_e_wb.h @@ -220,6 +220,15 @@ public: /* 0x17E2 */ s16 wait_roll_angle; ///< @brief Roll angle during wait state. /* 0x17E4 */ u8 field_0x17e4[0x17e8 - 0x17e4]; /* 0x17E8 */ f32 ride_speed_max; ///< @brief Speed rate for riding calculations. +#if TARGET_PC + cXyz himo_mat_interp_prev[2][16]; + cXyz himo_mat_interp_curr[2][16]; + cXyz himo_tex_interp_prev[2]; + cXyz himo_tex_interp_curr[2]; + bool himo_interp_prev_valid; + bool himo_interp_curr_valid; + s8 demo_cam_sync_ticks; +#endif }; STATIC_ASSERT(sizeof(e_wb_class) == 0x17EC); diff --git a/include/d/actor/d_a_e_yd.h b/include/d/actor/d_a_e_yd.h index 188e435ba5..44d6036600 100644 --- a/include/d/actor/d_a_e_yd.h +++ b/include/d/actor/d_a_e_yd.h @@ -74,6 +74,12 @@ public: /* 0x1250 */ f32 field_0x1250; /* 0x1254 */ u8 field_0x1254[0x1268 - 0x1254]; /* 0x1268 */ u8 field_0x1268; +#if TARGET_PC + cXyz mLineMatInterpPrev[12]; + cXyz mLineMatInterpCurr[12]; + bool mLineMatInterpPrevValid; + bool mLineMatInterpCurrValid; +#endif }; STATIC_ASSERT(sizeof(e_yd_class) == 0x126c); diff --git a/include/d/actor/d_a_e_yg.h b/include/d/actor/d_a_e_yg.h index 69a85430a8..6be19933cb 100644 --- a/include/d/actor/d_a_e_yg.h +++ b/include/d/actor/d_a_e_yg.h @@ -63,6 +63,15 @@ public: /* 0x0BB4 */ yg_ke_s mYgKes[13]; /* 0x1880 */ mDoExt_3DlineMat0_c mLineMat; /* 0x189C */ u8 mIsFirstSpawn; + +#if TARGET_PC + static const int TENTACLE_STRAND_COUNT = 13; + static const int TENTACLE_SEGMENT_COUNT = 10; + cXyz mTentacleInterpPrev[TENTACLE_STRAND_COUNT * TENTACLE_SEGMENT_COUNT]; + cXyz mTentacleInterpCurr[TENTACLE_STRAND_COUNT * TENTACLE_SEGMENT_COUNT]; + bool mTentacleInterpPrevValid; + bool mTentacleInterpCurrValid; +#endif }; STATIC_ASSERT(sizeof(e_yg_class) == 0x18a0); diff --git a/include/d/actor/d_a_e_yh.h b/include/d/actor/d_a_e_yh.h index 519e5e2779..63e8ac1882 100644 --- a/include/d/actor/d_a_e_yh.h +++ b/include/d/actor/d_a_e_yh.h @@ -77,6 +77,12 @@ public: /* 0x1260 */ u32 field_0x1260; /* 0x1260 */ u8 field_0x1264[0x1270 - 0x1264]; /* 0x1270 */ bool mIsHIOOwner; +#if TARGET_PC + cXyz mLineInterpPrev[12]; + cXyz mLineInterpCurr[12]; + bool mLineInterpPrevValid; + bool mLineInterpCurrValid; +#endif }; STATIC_ASSERT(sizeof(e_yh_class) == 0x1274); diff --git a/include/d/actor/d_a_mirror.h b/include/d/actor/d_a_mirror.h index fd69c8682e..e2f9a51e30 100644 --- a/include/d/actor/d_a_mirror.h +++ b/include/d/actor/d_a_mirror.h @@ -25,6 +25,10 @@ public: /* 0x164 */ cXyz mMinVal; /* 0x170 */ cXyz mMaxVal; /* 0x17C */ cXyz mViewScale; +#if TARGET_PC + bool mbReset = false; + bool mbHadEntry = false; +#endif }; /** diff --git a/include/d/actor/d_a_movie_player.h b/include/d/actor/d_a_movie_player.h index 0ddc675c24..0e666ae470 100644 --- a/include/d/actor/d_a_movie_player.h +++ b/include/d/actor/d_a_movie_player.h @@ -94,6 +94,12 @@ static void __THPAudioInitialize(THPAudioDecodeInfo* info, u8* ptr); #define THP_TEXTURE_SET_COUNT 3 #endif +#if TARGET_PC +namespace dusk { + void MoviePlayerShutdown(); +} +#endif + struct daMP_THPPlayer { /* 0x000 */ DVDFileInfo fileInfo; /* 0x03C */ THPHeader header; diff --git a/include/d/actor/d_a_obj_fchain.h b/include/d/actor/d_a_obj_fchain.h index 1bd7b9a810..7c4ae74faf 100644 --- a/include/d/actor/d_a_obj_fchain.h +++ b/include/d/actor/d_a_obj_fchain.h @@ -31,6 +31,10 @@ public: csXyz* getAngle() { return field_0x8a4; } J3DModelData* getModelData() { return mModelData; } +#if TARGET_PC + void onInterpCallback(); +#endif + private: /* 0x568 */ request_of_phase_process_class mPhase; /* 0x570 */ J3DModelData* mModelData; @@ -42,6 +46,14 @@ private: /* 0x694 */ cXyz field_0x694[22]; /* 0x79C */ cXyz field_0x79c[22]; /* 0x8A4 */ csXyz field_0x8a4[22]; + +#if TARGET_PC + static const int CHAIN_COUNT = 22; + cXyz mChainInterpPrev[CHAIN_COUNT]; + cXyz mChainInterpCurr[CHAIN_COUNT]; + bool mChainInterpPrevValid; + bool mChainInterpCurrValid; +#endif }; STATIC_ASSERT(sizeof(daObjFchain_c) == 0x928); diff --git a/include/d/actor/d_a_obj_klift00.h b/include/d/actor/d_a_obj_klift00.h index 865fab8c47..ab5403d51b 100644 --- a/include/d/actor/d_a_obj_klift00.h +++ b/include/d/actor/d_a_obj_klift00.h @@ -25,6 +25,10 @@ public: int Draw(); int Delete(); +#if TARGET_PC + void onInterpCallback(); +#endif + enum Param_e { LOCK_e = (1 << 6), NO_BASE_DISP = (1 << 7) }; @@ -50,6 +54,13 @@ private: /* 0x1020 */ dCcD_Cyl mCylinderCollider; /* 0x115C */ s32 mStopSwingingFrames; +#if TARGET_PC + cXyz mChainInterpPrev[64]; + cXyz mChainInterpCurr[64]; + bool mChainInterpPrevValid; + bool mChainInterpCurrValid; +#endif + // Number of chain models u32 getArg0() { return fopAcM_GetParamBit(this, 0, 6); diff --git a/include/d/actor/d_a_obj_lv8Lift.h b/include/d/actor/d_a_obj_lv8Lift.h index c5a8ae9f27..c327f4cf3c 100644 --- a/include/d/actor/d_a_obj_lv8Lift.h +++ b/include/d/actor/d_a_obj_lv8Lift.h @@ -58,6 +58,9 @@ public: void setNextPoint(); int Draw(); int Delete(); +#if TARGET_PC + friend void daL8Lift_interp_callback(bool isSimFrame, void* pUserWork); +#endif u8 getPthID() { return fopAcM_GetParamBit(this, 0, 8); } u8 getMoveSpeed() { return fopAcM_GetParamBit(this, 8, 4); } diff --git a/include/d/actor/d_a_obj_sw.h b/include/d/actor/d_a_obj_sw.h index 64674b0b9c..9e2793755a 100644 --- a/include/d/actor/d_a_obj_sw.h +++ b/include/d/actor/d_a_obj_sw.h @@ -68,10 +68,8 @@ public: /* 0x904 */ cXyz field_0x904[2]; /* 0x91C */ int field_0x91c; /* 0x920 */ cXyz field_0x920[63]; - /* 0xC14 */ f32 field_0xc14[4]; - /* 0xC24 */ u8 field_0xc24[0xd10 - 0xc24]; - /* 0xD10 */ s8 field_0xd10[4]; - /* 0xD14 */ u8 field_0xd14[0xd50 - 0xd14]; + /* 0xC14 */ f32 field_0xc14[63]; + /* 0xD10 */ s8 field_0xd10[64]; /* 0xD50 */ mDoExt_3DlineMat1_c field_0xd50; /* 0xD8C */ int field_0xd8c; }; diff --git a/include/d/d_cam_param.h b/include/d/d_cam_param.h index d6ff7b46d7..867c66e38d 100644 --- a/include/d/d_cam_param.h +++ b/include/d/d_cam_param.h @@ -143,6 +143,12 @@ public: /* 0x20 */ JORFile mFile; #endif +#if TARGET_PC + /* 0x24 */ u8 mManualMode; + /* 0x25 */ f32 freeXAngle; + /* 0x29 */ f32 freeYAngle; +#endif + u32 Id(s32 i_style) { return mCamStyleData[i_style].field_0x0; } int Algorythmn(s32 i_style) { return mCamStyleData[i_style].field_0x4; } int Algorythmn() { return mCurrentStyle->field_0x4; } diff --git a/include/d/d_camera.h b/include/d/d_camera.h index 85e930c395..c481262a46 100644 --- a/include/d/d_camera.h +++ b/include/d/d_camera.h @@ -118,6 +118,18 @@ class camera_class; class dCamera_c; typedef bool (dCamera_c::*engine_fn)(s32); +#if TARGET_PC +struct DebugFlyCam { + bool initialized; + f32 pitch; + f32 yaw; + cXyz savedCenter; + cXyz savedEye; + f32 savedFovy; + cSAngle savedBank; +}; +#endif + class dCamera_c { public: class dCamInfo_c { @@ -273,6 +285,8 @@ public: /* 0xA4 */ f32 field_0xa4; /* 0xA8 */ int field_0xa8; /* 0xAC */ f32 field_0xac; + f32 xAngle; + f32 yAngle; }; struct LockOnData { @@ -1024,6 +1038,11 @@ public: bool colosseumCamera(s32); bool test1Camera(s32); bool test2Camera(s32); + #if TARGET_PC + bool freeCamera(); + bool executeDebugFlyCam(); + void deactivateDebugFlyCam(); + #endif bool towerCamera(s32); bool hookshotCamera(s32); bool railCamera(s32); @@ -1371,6 +1390,10 @@ public: /* 0x970 */ dCamSetup_c mCamSetup; /* 0xAEC */ dCamParam_c mCamParam; /* 0xB0C */ u8 field_0xb0c; + +#if TARGET_PC + DebugFlyCam mDebugFlyCam; +#endif }; // Size: 0xB10 dCamera_c* dCam_getBody(); diff --git a/include/d/d_com_inf_game.h b/include/d/d_com_inf_game.h index d22d08cd10..d81fc5bc1e 100644 --- a/include/d/d_com_inf_game.h +++ b/include/d/d_com_inf_game.h @@ -1845,6 +1845,12 @@ inline void dComIfGs_addDeathCount() { g_dComIfG_gameInfo.info.getPlayer().getPlayerInfo().addDeathCount(); } +#if TARGET_PC +inline u16 dComIfGs_getDeathCount() { + return g_dComIfG_gameInfo.info.getPlayer().getPlayerInfo().getDeathCount(); +} +#endif + inline char* dComIfGs_getPlayerName() { return g_dComIfG_gameInfo.info.getPlayer().getPlayerInfo().getPlayerName(); } @@ -4834,8 +4840,7 @@ inline void dComIfGd_drawXluListDark() { inline void dComIfGd_drawXluListInvisible() { ZoneScoped; #ifdef TARGET_PC - if (dusk::getSettings().game.enableWaterRefraction && - !dusk::getSettings().game.enableFrameInterpolation) { + if (!dusk::getSettings().game.disableWaterRefraction) { #endif g_dComIfG_gameInfo.drawlist.drawXluListInvisible(); #ifdef TARGET_PC @@ -4846,8 +4851,7 @@ inline void dComIfGd_drawXluListInvisible() { inline void dComIfGd_drawOpaListInvisible() { ZoneScoped; #ifdef TARGET_PC - if (dusk::getSettings().game.enableWaterRefraction && - !dusk::getSettings().game.enableFrameInterpolation) { + if (!dusk::getSettings().game.disableWaterRefraction) { #endif g_dComIfG_gameInfo.drawlist.drawOpaListInvisible(); #ifdef TARGET_PC diff --git a/include/d/d_drawlist.h b/include/d/d_drawlist.h index 9eecac2d61..8368c9e92e 100644 --- a/include/d/d_drawlist.h +++ b/include/d/d_drawlist.h @@ -209,6 +209,10 @@ public: /* 0x04 */ TGXTexObj* mpTexObj; /* 0x08 */ Mtx mVolumeMtx; /* 0x38 */ Mtx mMtx; +#if TARGET_PC + const void* mVolumeMtxKey; + const void* mMtxKey; +#endif }; // Size: 0x68 struct cBgD_Vtx_t; @@ -308,8 +312,11 @@ private: /* 0x0000C */ dDlst_shadowSimple_c mSimple[128]; /* 0x0340C */ int mNextID; /* 0x03410 */ dDlst_shadowReal_c mReal[8]; - /* 0x15EB0 */ TGXTexObj field_0x15eb0[2]; - /* 0x15EF0 */ void* field_0x15ef0[2]; + /* 0x15EB0 */ TGXTexObj mShadowTexObj[2]; + /* 0x15EF0 */ void* mShadowTexData[2]; + #if TARGET_PC + int mTexResScale; + #endif }; class dDlst_window_c { diff --git a/include/d/d_event.h b/include/d/d_event.h index 7725897a25..ed0d749db4 100644 --- a/include/d/d_event.h +++ b/include/d/d_event.h @@ -196,7 +196,11 @@ public: /* 0x108 */ int mSkipTimer; /* 0x10C */ int mSkipParameter; /* 0x110 */ BOOL mIsSkipFade; +#if TARGET_PC + /* 0x114 */ char mSkipEventName[21]; +#else /* 0x114 */ char mSkipEventName[20]; +#endif /* 0x128 */ u8 mCompulsory; /* 0x129 */ bool mRoomInfoSet; /* 0x12C */ int mRoomNo; diff --git a/include/d/d_file_select.h b/include/d/d_file_select.h index 72f7cb7f42..cf081a3b10 100644 --- a/include/d/d_file_select.h +++ b/include/d/d_file_select.h @@ -10,6 +10,7 @@ #include "JSystem/J3DGraphLoader/J3DAnmLoader.h" class dFile_info_c; +class J2DPicture; class dDlst_FileSel_c : public dDlst_base_c { public: @@ -113,6 +114,14 @@ public: /* 0x04 */ J2DScreen* Scr3m; }; +class dDlst_FileSelFade_c : public dDlst_base_c { +public: + void draw(); + virtual ~dDlst_FileSelFade_c() {} + + /* 0x04 */ J2DPicture* mpPict; +}; + class dFs_HIO_c : public JORReflexible { public: dFs_HIO_c(); @@ -676,6 +685,9 @@ public: #if PLATFORM_GCN /* 0x2378 */ J2DPicture* mpFadePict; #endif +#ifdef TARGET_PC + dDlst_FileSelFade_c mFadeDlst; +#endif #if PLATFORM_WII || PLATFORM_SHIELD /* 0x2376 */ u8 field_0x2376[SAVEFILE_SIZE]; @@ -684,6 +696,10 @@ public: #endif }; +#ifdef TARGET_PC +STATIC_ASSERT(sizeof(dFile_select_c) == 0x237C + sizeof(dDlst_FileSelFade_c)); +#else STATIC_ASSERT(sizeof(dFile_select_c) == 0x237C); +#endif #endif /* D_FILE_D_FILE_SELECT_H */ diff --git a/include/d/d_map.h b/include/d/d_map.h index 28786e4f94..1acc7e4519 100644 --- a/include/d/d_map.h +++ b/include/d/d_map.h @@ -223,6 +223,9 @@ private: /* 0x8F */ u8 field_0x8f; /* 0x90 */ u8 field_0x90; /* 0x91 */ u8 field_0x91; +#if TARGET_PC + bool previousMirror; +#endif }; // Size: 0x94 class dMap_HIO_list_c : public dMpath_HIO_n::hioList_c { diff --git a/include/d/d_menu_dmap.h b/include/d/d_menu_dmap.h index 78c659e2cf..81baea1446 100644 --- a/include/d/d_menu_dmap.h +++ b/include/d/d_menu_dmap.h @@ -91,6 +91,10 @@ public: void calcCursor(); void drawCursor(); + #if TARGET_PC + void dMapBgWide(); + #endif + void setDPDFloorSelCurPos(s8 i_pos) { field_0xdd6 = i_pos; } f32 getMapWidth() { return mMapWidth; } @@ -103,6 +107,10 @@ public: field_0xd98 = param_1; } +#if TARGET_PC + void resetScrollArrowMask() { field_0xdda = 0; } +#endif + /* 0xC98 */ JKRExpHeap* mpHeap; /* 0xC9C */ JKRExpHeap* mpTalkHeap; /* 0xCA0 */ STControl* mpStick; diff --git a/include/d/d_menu_fmap.h b/include/d/d_menu_fmap.h index 026e98e056..48db37fef3 100644 --- a/include/d/d_menu_fmap.h +++ b/include/d/d_menu_fmap.h @@ -75,7 +75,9 @@ public: /* 0x8 */ BE(u16) mAreaName; /* 0xA */ u8 mCount; #ifdef _MSVC_LANG - u8* __get_mRoomNos() const { return (u8*)(this + 1); } + // Room numbers start at offset 0xB (right after mCount), NOT at sizeof(data)=12. + // (u8*)(this+1) would give offset 12 because MSVC sizeof=12; use &mCount+1 instead. + u8* __get_mRoomNos() const { return (u8*)&mCount + 1; } __declspec(property(get = __get_mRoomNos)) u8* mRoomNos; #else /* 0xB */ u8 mRoomNos[0]; diff --git a/include/d/d_menu_fmap2D.h b/include/d/d_menu_fmap2D.h index 9b237f2771..7df78bb6d0 100644 --- a/include/d/d_menu_fmap2D.h +++ b/include/d/d_menu_fmap2D.h @@ -81,6 +81,10 @@ public: void calcDrawPriority(); void setArrowPosAxis(f32, f32); + #if TARGET_PC + void fMapBackWide(); + #endif + virtual void draw(); virtual ~dMenu_Fmap2DBack_c(); @@ -165,6 +169,12 @@ public: void mapBlink() {} + #if PLATFORM_WII || TARGET_PC + f32 getMirrorPosX(f32 param_0, f32 param_1) { + return (field_0x11dc * 2.0f - (param_0 + param_1)) - param_1; + } + #endif + // Unknown name struct RegionTexData { /* 0x00 */ float mMinX; @@ -330,6 +340,10 @@ public: void setHIO(bool); bool isWarpAccept(); + #if TARGET_PC + void fMapTopWide(); + #endif + virtual void draw(); virtual ~dMenu_Fmap2DTop_c(); diff --git a/include/d/d_menu_map_common.h b/include/d/d_menu_map_common.h index 989aee7d8b..de50d775ca 100644 --- a/include/d/d_menu_map_common.h +++ b/include/d/d_menu_map_common.h @@ -66,6 +66,16 @@ public: _c90 = param_2; } +#if PLATFORM_WII || TARGET_PC + f32 getMirrorCenterPosX(f32 param_0, f32 param_1) { + if (_c90) { + return (mCenterPosX * 2.0f - (param_0 + param_1)) - param_1; + } + + return param_0; + } +#endif + struct Stage_c { // Incomplete class diff --git a/include/d/d_menu_ring.h b/include/d/d_menu_ring.h index f28c20aac2..74624eac80 100644 --- a/include/d/d_menu_ring.h +++ b/include/d/d_menu_ring.h @@ -204,6 +204,18 @@ private: /* 0x6D1 */ u8 field_0x6d1; /* 0x6D2 */ u8 field_0x6d2; /* 0x6D3 */ u8 field_0x6d3; +#if TARGET_PC + f32 mSelectItemSlideElapsed[4]; + f32 mCursorInterpPrevX; + f32 mCursorInterpPrevY; + f32 mCursorInterpCurrX; + f32 mCursorInterpCurrY; + s16 mCursorInterpPrevAngle; + s16 mCursorInterpCurrAngle; + bool mCursorInterpPrevAngular; + bool mCursorInterpCurrAngular; + bool mCursorInterpInit; +#endif }; #endif /* D_MENU_D_MENU_RING_H */ diff --git a/include/d/d_meter_button.h b/include/d/d_meter_button.h index 3028abeab5..0f05d32440 100644 --- a/include/d/d_meter_button.h +++ b/include/d/d_meter_button.h @@ -343,6 +343,11 @@ public: /* 0x624 */ f32 mMidonaPosX; /* 0x628 */ f32 mMidonaPosY; /* 0x62C */ f32 mMidonaScale; + +#ifdef TARGET_PC + bool mWasListen[2]; + bool mWasRepeat[2]; +#endif }; #endif /* D_METER_D_METER_BUTTON_H */ diff --git a/include/d/d_msg_object.h b/include/d/d_msg_object.h index b55ea73904..d2ac4ecb2a 100644 --- a/include/d/d_msg_object.h +++ b/include/d/d_msg_object.h @@ -67,6 +67,9 @@ public: bool isStaffMessage(); bool isSaveMessage(); bool isTalkMessage(); +#if TARGET_PC + bool isShopItemMessage(); +#endif const char* getSmellName(); const char* getPortalName(); const char* getBombName(); diff --git a/include/d/d_msg_scrn_howl.h b/include/d/d_msg_scrn_howl.h index bf28682d42..5db5db0232 100644 --- a/include/d/d_msg_scrn_howl.h +++ b/include/d/d_msg_scrn_howl.h @@ -110,6 +110,10 @@ struct dMsgScrnHowl_c : public dMsgScrnBase_c { /* 0x27A0 */ f32 field_0x27a0; /* 0x27A4 */ f32 field_0x27a4; /* 0x27A8 */ f32 field_0x27a8; + +#if TARGET_PC + u8 showCursor; +#endif }; #endif /* MSG_SCRN_D_MSG_SCRN_HOWL_H */ diff --git a/include/d/d_name.h b/include/d/d_name.h index e685871738..3ac6592457 100644 --- a/include/d/d_name.h +++ b/include/d/d_name.h @@ -89,7 +89,7 @@ public: void MojiSelectAnm3(); int mojiChange(u8); void selectMojiSet(); - #if REGION_JPN + #if TARGET_PC || REGION_JPN int checkDakuon(int, u8); int setDakuon(int, u8); #endif diff --git a/include/d/d_s_logo.h b/include/d/d_s_logo.h index 56d10eef82..ab714a17ae 100644 --- a/include/d/d_s_logo.h +++ b/include/d/d_s_logo.h @@ -79,7 +79,7 @@ public: bool isProgressiveMode(); void setRenderMode(); - #if VERSION == VERSION_GCN_PAL || PLATFORM_WII || PLATFORM_SHIELD + #if TARGET_PC || VERSION == VERSION_GCN_PAL || PLATFORM_WII || PLATFORM_SHIELD u8 getPalLanguage(); #endif @@ -149,7 +149,7 @@ public: /* 0x200 */ dDlst_2D_c* mNvLogo; /* 0x204 */ dDlst_2D_c* mMocImg; #endif -#if VERSION == VERSION_GCN_PAL +#if TARGET_PC || VERSION == VERSION_GCN_PAL /* 0x1FC */ mDoDvdThd_mountArchive_c* mpPalLogoResCommand; #endif /* 0x1FC */ request_of_phase_process_class* m_preLoad_dylPhase; diff --git a/include/d/d_save.h b/include/d/d_save.h index b0682e7ed6..e2719e0a98 100644 --- a/include/d/d_save.h +++ b/include/d/d_save.h @@ -486,20 +486,35 @@ public: mDeathCount++; } } +#if TARGET_PC + u16 getDeathCount() const { return mDeathCount; } +#endif char* getPlayerName() const { return const_cast(mPlayerName); } - void setPlayerName(const char* i_name) { strcpy(mPlayerName, i_name); } + void setPlayerName(const char* i_name) { +#if AVOID_UB + strncpy(mPlayerName, i_name, sizeof(mPlayerName) - 1); + mPlayerName[sizeof(mPlayerName) - 1] = '\0'; +#else + strcpy(mPlayerName, i_name); +#endif + } char* getHorseName() const { return const_cast(mHorseName); } - void setHorseName(const char* i_name) { strcpy(mHorseName, i_name); } + void setHorseName(const char* i_name) { +#if AVOID_UB + strncpy(mHorseName, i_name, sizeof(mHorseName) - 1); + mHorseName[sizeof(mHorseName) - 1] = '\0'; +#else + strcpy(mHorseName, i_name); +#endif + } u8 getClearCount() const { return mClearCount; } /* 0x00 */ BE(u64) unk0; /* 0x08 */ BE(s64) mTotalTime; /* 0x10 */ BE(u16) unk16; /* 0x12 */ BE(u16) mDeathCount; - /* 0x14 */ char mPlayerName[16]; - /* 0x24 */ u8 unk36; - /* 0x25 */ char mHorseName[16]; - /* 0x35 */ u8 unk53; + /* 0x14 */ char mPlayerName[17]; + /* 0x25 */ char mHorseName[17]; /* 0x36 */ u8 mClearCount; /* 0x37 */ u8 unk55[5]; }; // Size: 0x40 diff --git a/include/d/d_select_cursor.h b/include/d/d_select_cursor.h index c77da550f8..1ca226611b 100644 --- a/include/d/d_select_cursor.h +++ b/include/d/d_select_cursor.h @@ -47,6 +47,13 @@ public: mPositionY = y; } +#ifdef TARGET_PC + f32 getPositionX() const { return mPositionX; } + f32 getPositionY() const { return mPositionY; } + + void refreshAspectScale(); +#endif + void onUpdateFlag() { mUpdateFlag = true; } void resetUpdateFlag() { mUpdateFlag = false; } @@ -79,6 +86,9 @@ private: /* 0x58 */ f32 mPositionX; /* 0x5C */ f32 mPositionY; /* 0x60 */ f32 mParam1; +#ifdef TARGET_PC + f32 mBaseParam1; +#endif /* 0x64 */ f32 mParam2; /* 0x68 */ f32 mParam3; /* 0x6C */ f32 mParam4; diff --git a/include/dusk/achievements.h b/include/dusk/achievements.h new file mode 100644 index 0000000000..5c2758b6b7 --- /dev/null +++ b/include/dusk/achievements.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "nlohmann/json.hpp" + +namespace dusk { + +enum class AchievementCategory : uint8_t { + Challenge, + Collection, + Minigame, + Misc, + Glitched +}; + +struct Achievement { + const char* key; + const char* name; + const char* description; + AchievementCategory category; + bool isCounter; + int32_t goal; + int32_t progress; + bool unlocked; +}; + +// Responsible for updating a.progress. +// Use extra for any per-achievement state that must survive across frames or sessions, extra is saved +using AchievementCheckFn = std::function; + +class AchievementSystem { +public: + static AchievementSystem& get(); + + void load(); + void save(); + void tick(); + void clearAll(); + void clearOne(const char* key); + + // Signals are visible to all achievement checks within the same tick, then cleared. + void signal(const char* key); + bool hasSignal(const char* key) const; + + std::vector getAchievements() const; + +private: + struct Entry { + Achievement achievement; + AchievementCheckFn check; + nlohmann::json extra; + }; + + AchievementSystem(); + static std::vector makeEntries(); + void processEntry(Entry& e); + + std::vector m_entries; + std::unordered_set m_signals; + bool m_loaded = false; + bool m_dirty = false; +}; + +} // namespace dusk diff --git a/include/dusk/action_bindings.h b/include/dusk/action_bindings.h new file mode 100644 index 0000000000..7eba412fe8 --- /dev/null +++ b/include/dusk/action_bindings.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include "dusk/config_var.hpp" + +namespace dusk { + +enum class ActionBinds { + FIRST_PERSON_CAMERA, + CALL_MIDNA, + OPEN_DUSKLIGHT_MENU, + TURBO_SPEED_BUTTON, + COUNT, +}; + +struct ActionBindData { + std::array* configVars{}; + std::string actionName{}; +}; + +struct ActionBindPressData { + bool pressedCurFrame{false}; + bool pressedPrevFrame{false}; +}; + +using ActionBindsMap = std::unordered_map; + +ActionBindsMap& getActionBinds(); + +bool isActionBound(ActionBinds action, u32 port); + +void updateActionBindings(); + +bool getActionBindTrig(ActionBinds action, u32 port); + +bool getActionBindHold(ActionBinds action, u32 port); + +bool getActionBindHoldAnyPort(ActionBinds action); + +int getActionBindButton(ActionBinds action, u32 port); + +} diff --git a/include/dusk/app_info.hpp b/include/dusk/app_info.hpp index d5d5f40083..e08fe680d9 100644 --- a/include/dusk/app_info.hpp +++ b/include/dusk/app_info.hpp @@ -7,7 +7,12 @@ namespace dusk { * * This gets used for file paths and such, and cannot be changed! */ - constexpr auto AppName = "Dusk"; + constexpr auto AppName = "Dusklight"; + + /** + * Previous AppName to migrate data from. + */ + constexpr auto LegacyAppName = "Dusk"; /** * \brief The internal organization name for the game. diff --git a/include/dusk/audio/DuskAudioSystem.h b/include/dusk/audio/DuskAudioSystem.h index 724776f5f1..780187f9d2 100644 --- a/include/dusk/audio/DuskAudioSystem.h +++ b/include/dusk/audio/DuskAudioSystem.h @@ -1,8 +1,19 @@ #pragma once +#include #include namespace dusk::audio { + + // Converts a 0-1 volume to a linear amplitude multiplier. + // The curve is -4 dB per 10% step: 100% = 0 dB, 90% = -4 dB, ..., 0% = -inf dB + inline f32 MasterVolumeToLinear(f32 v) { + if (v <= 0.0f) { + return 0.0f; + } + return std::pow(10.0f, (v - 1.0f) * 2.0f); + } + /** * Initialize the audio system and start playing audio. */ @@ -12,6 +23,8 @@ namespace dusk::audio { void SetMasterVolume(f32 value); + void SetPaused(bool paused); + u32 GetResetCount(int channelIdx); f32 VolumeFromU16(u16 value); diff --git a/include/dusk/autosave.h b/include/dusk/autosave.h new file mode 100644 index 0000000000..c32cd1e22b --- /dev/null +++ b/include/dusk/autosave.h @@ -0,0 +1,19 @@ +#pragma once + +#ifndef AUTOSAVE_H +#define AUTOSAVE_H + +#include +#include +#include + +void noAutoSave(); +void triggerAutoSave(); +void updateAutoSave(); +void enterAutoSave(); +void autoSaving(); +void waitingForWrite(); +void endAutoSave(); +void toggleAutoSave(bool enabled); + +#endif \ No newline at end of file diff --git a/include/dusk/config.hpp b/include/dusk/config.hpp index 258e012f2b..382c4c24c6 100644 --- a/include/dusk/config.hpp +++ b/include/dusk/config.hpp @@ -1,6 +1,7 @@ #ifndef DUSK_CONFIG_HPP #define DUSK_CONFIG_HPP +#include #include #include "nlohmann/json.hpp" #include "config_var.hpp" @@ -111,6 +112,18 @@ void Save(); */ ConfigVarBase* GetConfigVar(std::string_view name); +/** + * \brief Resets all custom action bindings for a specific port to nothing + * + * @param port The port to be cleared of action bindings + */ +void ClearAllActionBindings(int port); + +/** + * \brief Call a function on every registered CVar. + */ +void EnumerateRegistered(std::function callback); + template const ConfigImplBase* GetConfigImpl() { static ConfigImpl config; diff --git a/include/dusk/config_var.hpp b/include/dusk/config_var.hpp index 6b78ee2e33..0bae27bfd3 100644 --- a/include/dusk/config_var.hpp +++ b/include/dusk/config_var.hpp @@ -48,6 +48,13 @@ enum class ConfigVarLayer : u8 { * Will not get saved to config. */ Override, + + /** + * The CVar is temporarily overridden by speedrun mode. + * Will not get saved to config. Cleared when speedrun mode is disabled. + * Lower priority than Override, so launch args still win. + */ + Speedrun, }; class ConfigImplBase; @@ -113,6 +120,12 @@ public: * This is necessary to make it legal to access. */ void markRegistered(); + + /** + * Clear a speedrun-mode override if one is active on this CVar. + * Safe to call on any CVar, no-op if not at the Speedrun layer. + */ + virtual void clearSpeedrunOverride() {} }; template @@ -146,6 +159,12 @@ concept ConfigValue = template const ConfigImplBase* GetConfigImpl(); +template +struct ConfigEnumRange { + static constexpr auto min = std::numeric_limits>::min(); + static constexpr auto max = std::numeric_limits>::max(); +}; + /** * \brief A CVar storing values. * @@ -156,6 +175,7 @@ class ConfigVar : public ConfigVarBase { T defaultValue; T value; T overrideValue; + ConfigVarLayer priorLayer = ConfigVarLayer::Default; public: /** @@ -183,12 +203,18 @@ public: case ConfigVarLayer::Value: return value; case ConfigVarLayer::Override: + case ConfigVarLayer::Speedrun: return overrideValue; default: abort(); } } + [[nodiscard]] constexpr const T& getDefaultValue() const noexcept { + checkRegistered(); + return defaultValue; + } + /** * \brief Change the value of a CVar. * @@ -228,8 +254,54 @@ public: overrideValue = std::move(newValue); layer = ConfigVarLayer::Override; } + + /** + * \brief Give a CVar a speedrun-mode override value. + * + * Lower priority than a launch-arg override. Cleared when speedrun mode is disabled. + * The overridden value will not get saved to config. + * + * @param newValue The new value the CVar will get. + */ + void setSpeedrunValue(T newValue) { + checkRegistered(); + if (layer != ConfigVarLayer::Override) { + priorLayer = layer; + overrideValue = std::move(newValue); + layer = ConfigVarLayer::Speedrun; + } + } + + void clearOverride() { + checkRegistered(); + if (layer == ConfigVarLayer::Override) { + overrideValue = {}; + layer = ConfigVarLayer::Value; + } + } + + void clearSpeedrunOverride() override { + checkRegistered(); + if (layer == ConfigVarLayer::Speedrun) { + overrideValue = {}; + layer = priorLayer; + } + } + + /** + * \brief Get the user-persisted value, ignoring any temporary overrides. + * + * Used by Save() to write the correct value even when a speedrun override is active. + */ + [[nodiscard]] constexpr const T& getValueForSave() const noexcept { + checkRegistered(); + const ConfigVarLayer effectiveLayer = (layer == ConfigVarLayer::Speedrun) ? priorLayer : layer; + return effectiveLayer == ConfigVarLayer::Default ? defaultValue : value; + } }; +using ActionBindConfigVar = ConfigVar; + } #endif // DUSK_CONFIG_VAR_HPP diff --git a/include/dusk/crash_reporting.h b/include/dusk/crash_reporting.h new file mode 100644 index 0000000000..c42079a6f6 --- /dev/null +++ b/include/dusk/crash_reporting.h @@ -0,0 +1,17 @@ +#pragma once + +namespace dusk::crash_reporting { + +enum class Consent { + Unavailable, + Unknown, + Given, + Revoked, +}; + +void initialize(); +void shutdown(); +Consent get_consent(); +void set_consent(bool enabled); + +} // namespace dusk::crash_reporting diff --git a/include/dusk/discord_presence.hpp b/include/dusk/discord_presence.hpp new file mode 100644 index 0000000000..ebecc2d07b --- /dev/null +++ b/include/dusk/discord_presence.hpp @@ -0,0 +1,14 @@ +#pragma once + +#ifdef DUSK_DISCORD + +namespace dusk::discord { + +void initialize(); +void run_callbacks(); +void update_presence(); +void shutdown(); + +} // namespace dusk::discord + +#endif // DUSK_DISCORD diff --git a/include/dusk/dusk.h b/include/dusk/dusk.h index b751990d9c..911ddbb535 100644 --- a/include/dusk/dusk.h +++ b/include/dusk/dusk.h @@ -6,7 +6,6 @@ #include "aurora/gfx.h" extern AuroraInfo auroraInfo; -extern const char* configPath; namespace dusk { extern AuroraStats lastFrameAuroraStats; diff --git a/include/dusk/dvd_asset.hpp b/include/dusk/dvd_asset.hpp index 014c683d44..8594d4631c 100644 --- a/include/dusk/dvd_asset.hpp +++ b/include/dusk/dvd_asset.hpp @@ -1,22 +1,31 @@ #pragma once #include "dolphin/types.h" +#include "version.hpp" namespace dusk { +struct OffsetVersion { + version::GameVersion mGameVersion; + u32 mOffset; + + constexpr OffsetVersion(const version::GameVersion gameVersion, const u32 offset) + : mGameVersion(gameVersion), mOffset(offset) {} +}; + /** * Load bytes from the main DOL by GameCube virtual address */ -bool LoadDolAsset(void* dst, u32 virtualAddress, s32 size); +bool LoadDolAsset(void* dst, std::initializer_list virtualAddress, s32 size); /** * Load bytes from a REL file in the ISO filesystem, dst must be 32-byte aligned */ -bool LoadRelAsset(void* dst, const char* dvdPath, s32 offset, s32 size); +bool LoadRelAsset(void* dst, const char* dvdPath, std::initializer_list offset, s32 size); /** * Load bytes from a REL inside RELS.arc */ -bool LoadArchivedRelAsset(void* dst, u32 memType, const char* relFileName, s32 offset, s32 size); +bool LoadArchivedRelAsset(void* dst, u32 memType, const char* relFileName, std::initializer_list offset, s32 size); } // namespace dusk diff --git a/include/dusk/frame_interpolation.h b/include/dusk/frame_interpolation.h index 5c2dc2e1b8..0fa8c208bd 100644 --- a/include/dusk/frame_interpolation.h +++ b/include/dusk/frame_interpolation.h @@ -1,13 +1,12 @@ -#ifndef DUSK_FRAME_INTERP_H -#define DUSK_FRAME_INTERP_H +#pragma once #include #include #include #include -struct cXyz; class camera_process_class; +class view_class; #ifdef __cplusplus namespace dusk { @@ -15,29 +14,40 @@ namespace frame_interp { void ensure_initialized(); -void begin_record_camera(); void begin_record(); void end_record(); -void interpolate(float step); +void begin_sim_tick(); +uint64_t sim_tick_seq(); +void begin_frame(bool enabled, bool is_sim_frame, float step); +void interpolate(); float get_interpolation_step(); -void notify_sim_tick_complete(); -uint32_t begin_presentation_ui_pass(); -uint32_t get_presentation_ui_advance_ticks(); -void end_presentation_ui_pass(); -void open_child(const void* key, int32_t id); -void close_child(); +void request_presentation_sync(); +bool presentation_sync_active(); + +bool is_enabled(); + +// TODO: These should be phased out as UI is progressively updated to use game_clock +void set_ui_tick_pending(bool value); +bool get_ui_tick_pending(); + +bool is_sim_frame(); + void record_camera(::camera_process_class* cam, int camera_id); -void record_final_mtx_raw(const Mtx* dest, const Mtx src); +void interp_view(::view_class* view); +void record_final_mtx(Mtx m, const void *key); +void record_final_mtx(Mtx m); -bool lookup_replacement(const void* source, Mtx out); +bool lookup_replacement(const void* key, Mtx out); bool lookup_concat_replacement(const void* lhs, const void* rhs, Mtx out); -void camera_eye_from_view_mtx(MtxP view_mtx, cXyz* o_eye); -bool build_star_view(Mtx o_view, Mtx o_cam_billboard_base, cXyz* o_anchor_eye, float* o_fovy); +typedef void (*InterpolationCallBack)(bool isSimFrame, void* pUserWork); +// call on a sim tick, will get called during presentation +void add_interpolation_callback(InterpolationCallBack pCallBack, void* pUserWork); + +void begin_presentation_camera(); +void end_presentation_camera(); } // namespace frame_interp } // namespace dusk #endif - -#endif diff --git a/include/dusk/game_clock.h b/include/dusk/game_clock.h new file mode 100644 index 0000000000..8bb277e070 --- /dev/null +++ b/include/dusk/game_clock.h @@ -0,0 +1,26 @@ +#pragma once + +namespace dusk::game_clock { + +void ensure_initialized(); +void reset_frame_timer(); + +constexpr float sim_pace() { return 1.0f / 30.0f; } +constexpr float period_for_original_frames(float frame_count) { return frame_count * sim_pace(); } +constexpr float ui_maximum_dt() { return 0.05f; } +constexpr float ui_initial_dt() { return 1.0f / 60.0f; } + +struct MainLoopPacer { + float presentation_dt_seconds; + bool is_interpolating; + int sim_ticks_to_run; + float sim_pace; +}; + +MainLoopPacer advance_main_loop(); +void commit_sim_tick(); +float sample_interpolation_step(); + +float consume_interval(const void* consumer); + +} // namespace dusk::game_clock diff --git a/include/dusk/gamepad_color.h b/include/dusk/gamepad_color.h new file mode 100644 index 0000000000..c7cfc1e716 --- /dev/null +++ b/include/dusk/gamepad_color.h @@ -0,0 +1,8 @@ +#pragma once + +#ifndef GAMEPAD_COLOR_H +#define GAMEPAD_COLOR_H + +void handleGamepadColor(); + +#endif \ No newline at end of file diff --git a/include/dusk/gyro.h b/include/dusk/gyro.h new file mode 100644 index 0000000000..a206100739 --- /dev/null +++ b/include/dusk/gyro.h @@ -0,0 +1,18 @@ +#ifndef DUSK_GYRO_H +#define DUSK_GYRO_H + +namespace dusk::gyro { +void read(float dt); +void getAimDeltas(float& out_yaw, float& out_pitch); +bool queryGyroAimContext(); + +void rollgoalTick(bool play_active, s16 camera_yaw); +void rollgoalTableOffset(s16& out_ax, s16& out_az); + +extern bool s_sensor_keep_alive; +bool get_sensor_keep_alive(); +void set_sensor_keep_alive(bool value); +bool rollgoal_gyro_enabled(); +} // namespace dusk::gyro + +#endif diff --git a/include/dusk/gyro_aim.h b/include/dusk/gyro_aim.h deleted file mode 100644 index aafc926811..0000000000 --- a/include/dusk/gyro_aim.h +++ /dev/null @@ -1,10 +0,0 @@ -#ifndef DUSK_GYRO_AIM_H -#define DUSK_GYRO_AIM_H - -namespace dusk::gyro_aim { -void read(float dt, bool context_active); -void consumeAimDeltas(float& out_yaw_rad, float& out_pitch_rad); -bool queryGyroAimItemContext(); -} // namespace dusk::gyro_aim - -#endif diff --git a/include/dusk/hotkeys.h b/include/dusk/hotkeys.h index 6e98d62521..7a774bde80 100644 --- a/include/dusk/hotkeys.h +++ b/include/dusk/hotkeys.h @@ -9,14 +9,16 @@ constexpr const char* DO_RESET = "Cmd+R"; constexpr const char* DO_RESET = "Ctrl+R"; #endif -constexpr const char* TOGGLE_FULLSCREEN = "F11"; - constexpr const char* SHOW_PROCESS_MANAGEMENT = "F2"; constexpr const char* SHOW_DEBUG_OVERLAY = "F3"; constexpr const char* SHOW_HEAP_VIEWER = "F4"; -constexpr const char* SHOW_STUB_LOG = "F5"; -constexpr const char* SHOW_CAMERA_DEBUG = "F6"; -constexpr const char* SHOW_AUDIO_DEBUG = "F7"; +constexpr const char* SHOW_PLAYER_INFO = "F5"; +constexpr const char* SHOW_SAVE_EDITOR = "F6"; +constexpr const char* SHOW_STATE_SHARE = "F8"; +constexpr const char* SHOW_DEBUG_CAMERA = "F9"; +constexpr const char* SHOW_AUDIO_DEBUG = "F10"; + +constexpr const char* TOGGLE_FULLSCREEN = "F11"; constexpr const char* TURBO = "Tab"; diff --git a/include/dusk/io.hpp b/include/dusk/io.hpp index fb71a77d68..2efc4a8d3b 100644 --- a/include/dusk/io.hpp +++ b/include/dusk/io.hpp @@ -1,6 +1,7 @@ #ifndef DUSK_IO_HPP #define DUSK_IO_HPP +#include #include // I can't believe it's 2026 and neither SDL (no error codes) nor @@ -15,7 +16,7 @@ namespace dusk::io { * Methods on this class throw appropriate C++ exceptions when an error occurs. */ class FileStream { - void* file; + FILE* file; public: FileStream() noexcept; @@ -23,7 +24,7 @@ public: /** * \brief Take ownership of a FILE* handle. */ - explicit FileStream(void* file); + explicit FileStream(FILE* file); FileStream(const FileStream& other) = delete; FileStream(FileStream&& other) noexcept; @@ -34,6 +35,11 @@ public: */ static FileStream OpenRead(const char* utf8Path); + /** + * \brief Open a file for reading at the given path. + */ + static FileStream OpenRead(const std::filesystem::path& path); + /** * \brief Create a file for writing. * @@ -41,16 +47,33 @@ public: */ static FileStream Create(const char* utf8Path); + /** + * \brief Create a file for writing. + * + * If there is an existing file, its contents are demolished. + */ + static FileStream Create(const std::filesystem::path& path); + /** * \brief Read the byte contents of a file directly into a vector. */ static std::vector ReadAllBytes(const char* utf8Path); + /** + * \brief Read the byte contents of a file directly into a vector. + */ + static std::vector ReadAllBytes(const std::filesystem::path& path); + /** * \brief Read the byte contents of a file directly into a vector. */ static void WriteAllText(const char* utf8Path, std::string_view text); + /** + * \brief Read the byte contents of a file directly into a vector. + */ + static void WriteAllText(const std::filesystem::path& path, std::string_view text); + /** * \brief Read the remaining contents of the file directly into a vector. */ @@ -59,17 +82,24 @@ public: /** * Get direct access to the underlying FILE* handle. */ - [[nodiscard]] void* GetFileHandle() const noexcept { - return file; - } + [[nodiscard]] void* GetFileHandle() const noexcept { return file; } /** * Write data to the file. */ void Write(const char* data, size_t dataLen); + + FILE* ToInner(); }; +/** + * Converts a std::filesystem::path to a std::string, UTF-8, without exploding on Windows. + */ +inline std::string fs_path_to_string(const std::filesystem::path& path) { + const auto u8str = path.u8string(); + return {reinterpret_cast(u8str.c_str())}; } +} // namespace dusk::io #endif // DUSK_IO_HPP diff --git a/include/dusk/livesplit.h b/include/dusk/livesplit.h new file mode 100644 index 0000000000..b283a29af4 --- /dev/null +++ b/include/dusk/livesplit.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace dusk::speedrun { +void onGameFrame(); +uint64_t getFrameCount(); +void start(); +void reset(); +void connectLiveSplit(const char* host = "127.0.0.1", int port = 16834); +void disconnectLiveSplit(); +bool consumeConnectedEvent(); +bool consumeDisconnectedEvent(); +void updateLiveSplit(); +void shutdown(); +} diff --git a/include/dusk/logging.h b/include/dusk/logging.h index 7f239d8b45..9b31b96bf2 100644 --- a/include/dusk/logging.h +++ b/include/dusk/logging.h @@ -4,9 +4,14 @@ #include #include +#include + void aurora_log_callback(AuroraLogLevel level, const char* module, const char* message, unsigned int len); namespace dusk { + void InitializeFileLogging(const std::filesystem::path& configDir, AuroraLogLevel logLevel); + void ShutdownFileLogging(); + const char* GetLogFilePath(); void SendToStubLog(AuroraLogLevel level, const char* module, const char* message); } diff --git a/include/dusk/main.h b/include/dusk/main.h index a9194e2aba..0a2be8734d 100644 --- a/include/dusk/main.h +++ b/include/dusk/main.h @@ -1,10 +1,26 @@ #ifndef DUSK_MAIN_H #define DUSK_MAIN_H +#include + namespace dusk { - extern bool IsRunning; - extern bool IsShuttingDown; - extern bool IsGameLaunched; -} + +extern bool IsRunning; +extern bool IsShuttingDown; +extern bool IsGameLaunched; +extern bool RestartRequested; +extern std::filesystem::path ConfigPath; +extern std::filesystem::path CachePath; + +#if defined(__ANDROID__) || (defined(TARGET_OS_IOS) && TARGET_OS_IOS) || \ + (defined(TARGET_OS_TV) && TARGET_OS_TV) +inline constexpr bool SupportsProcessRestart = false; +#else +inline constexpr bool SupportsProcessRestart = true; +#endif + +void RequestRestart() noexcept; + +} // namespace dusk #endif // DUSK_MAIN_H diff --git a/include/dusk/map_loader_definitions.h b/include/dusk/map_loader_definitions.h index a0f7150d2d..0ffda772ba 100644 --- a/include/dusk/map_loader_definitions.h +++ b/include/dusk/map_loader_definitions.h @@ -1,5 +1,7 @@ #pragma once +#include + struct RoomEntry { u8 roomNo; std::vector roomPoints = {}; diff --git a/include/dusk/math.h b/include/dusk/math.h index fe27b3046b..04f1087670 100644 --- a/include/dusk/math.h +++ b/include/dusk/math.h @@ -2,9 +2,6 @@ #define _SRC_DUSK_MATH_H_ #include -#include -#include -#include #ifndef M_PI #define M_PI 3.14159265358979323846f @@ -19,139 +16,6 @@ inline float i_cosf(float x) { return cos(x); } inline float i_tanf(float x) { return tan(x); } inline float i_acosf(float x) { return acos(x); } - -// frsqrte matching courtesy of Geotale, with reference to https://achurch.org/cpu-tests/ppc750cl.s - -struct BaseAndDec32 { - uint32_t base; - int32_t dec; -}; - -struct BaseAndDec64 { - uint64_t base; - int64_t dec; -}; - -union c32 { - constexpr c32(const float p) { - f = p; - } - - constexpr c32(const uint32_t p) { - u = p; - } - - uint32_t u; - float f; -}; - -union c64 { - constexpr c64(const double p) { - f = p; - } - - constexpr c64(const uint64_t p) { - u = p; - } - - uint64_t u; - double f; -}; - -static constexpr uint64_t EXPONENT_SHIFT_F64 = 52; -static constexpr uint64_t MANTISSA_MASK_F64 = 0x000fffffffffffffULL; -static constexpr uint64_t EXPONENT_MASK_F64 = 0x7ff0000000000000ULL; -static constexpr uint64_t SIGN_MASK_F64 = 0x8000000000000000ULL; - -static constexpr std::array RSQRTE_TABLE = {{ - {0x69fa000000000ULL, -0x15a0000000LL}, - {0x5f2e000000000ULL, -0x13cc000000LL}, - {0x554a000000000ULL, -0x1234000000LL}, - {0x4c30000000000ULL, -0x10d4000000LL}, - {0x43c8000000000ULL, -0x0f9c000000LL}, - {0x3bfc000000000ULL, -0x0e88000000LL}, - {0x34b8000000000ULL, -0x0d94000000LL}, - {0x2df0000000000ULL, -0x0cb8000000LL}, - {0x2794000000000ULL, -0x0bf0000000LL}, - {0x219c000000000ULL, -0x0b40000000LL}, - {0x1bfc000000000ULL, -0x0aa0000000LL}, - {0x16ae000000000ULL, -0x0a0c000000LL}, - {0x11a8000000000ULL, -0x0984000000LL}, - {0x0ce6000000000ULL, -0x090c000000LL}, - {0x0862000000000ULL, -0x0898000000LL}, - {0x0416000000000ULL, -0x082c000000LL}, - {0xffe8000000000ULL, -0x1e90000000LL}, - {0xf0a4000000000ULL, -0x1c00000000LL}, - {0xe2a8000000000ULL, -0x19c0000000LL}, - {0xd5c8000000000ULL, -0x17c8000000LL}, - {0xc9e4000000000ULL, -0x1610000000LL}, - {0xbedc000000000ULL, -0x1490000000LL}, - {0xb498000000000ULL, -0x1330000000LL}, - {0xab00000000000ULL, -0x11f8000000LL}, - {0xa204000000000ULL, -0x10e8000000LL}, - {0x9994000000000ULL, -0x0fe8000000LL}, - {0x91a0000000000ULL, -0x0f08000000LL}, - {0x8a1c000000000ULL, -0x0e38000000LL}, - {0x8304000000000ULL, -0x0d78000000LL}, - {0x7c48000000000ULL, -0x0cc8000000LL}, - {0x75e4000000000ULL, -0x0c28000000LL}, - {0x6fd0000000000ULL, -0x0b98000000LL}, -}}; - -[[nodiscard]] static inline double frsqrte(const double val) { - c64 bits(val); - - uint64_t mantissa = bits.u & MANTISSA_MASK_F64; - int64_t exponent = bits.u & EXPONENT_MASK_F64; - bool sign = (bits.u & SIGN_MASK_F64) != 0; - - // Handle 0 case - if (mantissa == 0 && exponent == 0) { - return std::copysign(std::numeric_limits::infinity(), bits.f); - } - - // Handle NaN-like - if (exponent == EXPONENT_MASK_F64) { - if (mantissa == 0) { - return sign ? std::numeric_limits::quiet_NaN() : 0.0; - } - - return val; - } - - // Handle negative inputs - if (sign) { - return std::numeric_limits::quiet_NaN(); - } - - if (exponent == 0) { - // Shift so one bit goes to where the exponent would be, - // then clear that bit to mimic a not-subnormal number! - // Aka, if there are 12 leading zeroes, shift left once - uint32_t shift = std::countl_zero(mantissa) - static_cast(63 - EXPONENT_SHIFT_F64); - - mantissa <<= shift; - mantissa &= MANTISSA_MASK_F64; - // The shift is subtracted by 1 because denormals by default - // are offset by 1 (exponent 0 doesn't have implied 1 bit) - exponent -= static_cast(shift - 1) << EXPONENT_SHIFT_F64; - } - - // In reality this doesn't get the full exponent -- Only the least significant bit - // Only that's needed because square roots of higher exponent bits simply multiply the - // result by 2!! - uint32_t key = static_cast((static_cast(exponent) | mantissa) >> 37); - uint64_t new_exp = - (static_cast((0xbfcLL << EXPONENT_SHIFT_F64) - exponent) >> 1) & EXPONENT_MASK_F64; - - // Remove the bits relating to anything higher than the LSB of the exponent - const auto &entry = RSQRTE_TABLE[0x1f & (key >> 11)]; - - // The result is given by an estimate then an adjustment based on the original - // key that was computed - uint64_t new_mantissa = static_cast(entry.base + entry.dec * static_cast(key & 0x7ff)); - - return c64(new_exp | new_mantissa).f; -} +#include #endif // _SRC_DUSK_MATH_H_ diff --git a/include/dusk/scope_guard.hpp b/include/dusk/scope_guard.hpp new file mode 100644 index 0000000000..281d23434d --- /dev/null +++ b/include/dusk/scope_guard.hpp @@ -0,0 +1,20 @@ +#ifndef DUSK_SCOPE_GUARD_HPP +#define DUSK_SCOPE_GUARD_HPP + +#include + +class SimpleScopeGuard { +public: + // Store the function in the constructor + explicit SimpleScopeGuard(const std::function& func) : m_func(func) {} + + // Run the function when the object goes out of scope + ~SimpleScopeGuard() { + if (m_func) m_func(); + } + +private: + std::function m_func; +}; + +#endif //DUSK_SCOPE_GUARD_HPP diff --git a/include/dusk/settings.h b/include/dusk/settings.h index 68ea49c752..a6511f3ea6 100644 --- a/include/dusk/settings.h +++ b/include/dusk/settings.h @@ -1,12 +1,65 @@ #ifndef DUSK_CONFIG_H #define DUSK_CONFIG_H +#include + #include "dusk/config_var.hpp" namespace dusk { using namespace config; +enum class BloomMode : int { + Off = 0, + Classic = 1, + Dusk = 2, +}; + +enum class GameLanguage : u8 { + English = OS_LANGUAGE_ENGLISH, + German = OS_LANGUAGE_GERMAN, + French = OS_LANGUAGE_FRENCH, + Spanish = OS_LANGUAGE_SPANISH, + Italian = OS_LANGUAGE_ITALIAN, +}; + +enum class DiscVerificationState : u8 { + Unknown = 0, + Success, + HashMismatch, +}; + +enum class GyroMode : u8 { + Sensor = 0, + Mouse = 1, +}; + +namespace config { +template <> +struct ConfigEnumRange { + static constexpr auto min = BloomMode::Off; + static constexpr auto max = BloomMode::Dusk; +}; + +template <> +struct ConfigEnumRange { + static constexpr auto min = GameLanguage::English; + static constexpr auto max = GameLanguage::Italian; +}; + +template <> +struct ConfigEnumRange { + static constexpr auto min = DiscVerificationState::Unknown; + static constexpr auto max = DiscVerificationState::HashMismatch; +}; + +template <> +struct ConfigEnumRange { + static constexpr auto min = GyroMode::Sensor; + static constexpr auto max = GyroMode::Mouse; +}; +} + // Persistent user settings struct UserSettings { @@ -17,6 +70,8 @@ struct UserSettings { ConfigVar enableFullscreen; ConfigVar enableVsync; ConfigVar lockAspectRatio; + ConfigVar enableFpsOverlay; + ConfigVar fpsOverlayCorner; } video; struct { @@ -27,15 +82,18 @@ struct UserSettings { ConfigVar soundEffectsVolume; ConfigVar fanfareVolume; ConfigVar enableReverb; + ConfigVar enableHrtf; + ConfigVar menuSounds; } audio; // Game settings struct { + ConfigVar language; + // QoL ConfigVar enableQuickTransform; ConfigVar hideTvSettingsScreen; - ConfigVar skipWarningScreen; ConfigVar biggerWallets; ConfigVar noReturnRupees; ConfigVar disableRupeeCutscenes; @@ -46,49 +104,112 @@ struct UserSettings { ConfigVar fastClimbing; ConfigVar noMissClimbing; ConfigVar fastTears; + ConfigVar no2ndFishForCat; ConfigVar instantSaves; + ConfigVar instantText; + ConfigVar sunsSong; + ConfigVar autoSave; // Preferences ConfigVar enableMirrorMode; - ConfigVar invertCameraXAxis; + ConfigVar minimalHUD; + ConfigVar pauseOnFocusLost; + ConfigVar enableLinkDollRotation; + ConfigVar enableAchievementToasts; + ConfigVar enableControllerToasts; + ConfigVar enableDiscordPresence; // Graphics - ConfigVar enableBloom; - ConfigVar enableWaterRefraction; + ConfigVar bloomMode; + ConfigVar bloomMultiplier; + ConfigVar disableWaterRefraction; + ConfigVar enableTextureReplacements; ConfigVar enableFrameInterpolation; + ConfigVar internalResolutionScale; ConfigVar shadowResolutionMultiplier; + ConfigVar enableDepthOfField; + ConfigVar enableMapBackground; + ConfigVar disableCutscenePillarboxing; // Audio ConfigVar noLowHpSound; ConfigVar midnasLamentNonStop; // Input + ConfigVar gyroMode; ConfigVar enableGyroAim; - ConfigVar gyroAimSensitivityX; - ConfigVar gyroAimSensitivityY; - ConfigVar gyroAimInvertPitch; - ConfigVar gyroAimInvertYaw; + ConfigVar enableGyroRollgoal; + ConfigVar gyroSensitivityX; + ConfigVar gyroSensitivityY; + ConfigVar gyroSensitivityRollgoal; + ConfigVar gyroSmoothing; + ConfigVar gyroDeadband; + ConfigVar gyroInvertPitch; + ConfigVar gyroInvertYaw; + ConfigVar freeCamera; + ConfigVar invertCameraXAxis; + ConfigVar invertCameraYAxis; + ConfigVar invertFirstPersonXAxis; + ConfigVar invertFirstPersonYAxis; + ConfigVar freeCameraSensitivity; + ConfigVar debugFlyCam; + ConfigVar debugFlyCamLockEvents; + ConfigVar allowBackgroundInput; // Cheats + ConfigVar infiniteHearts; + ConfigVar infiniteArrows; + ConfigVar infiniteSeeds; + ConfigVar infiniteBombs; + ConfigVar infiniteOil; + ConfigVar infiniteOxygen; + ConfigVar infiniteRupees; + ConfigVar enableIndefiniteItemDrops; + ConfigVar moonJump; + ConfigVar superClawshot; + ConfigVar alwaysGreatspin; ConfigVar enableFastIronBoots; ConfigVar canTransformAnywhere; + ConfigVar fastRoll; ConfigVar fastSpinner; ConfigVar freeMagicArmor; + ConfigVar invincibleEnemies; // Technical ConfigVar restoreWiiGlitches; // Controls ConfigVar enableTurboKeybind; + ConfigVar enableResetKeybind; + + // Tools + ConfigVar speedrunMode; + ConfigVar liveSplitEnabled; + ConfigVar showSpeedrunRTATimer; + ConfigVar recordingMode; + ConfigVar showInputViewer; + ConfigVar showInputViewerGyro; } game; struct { ConfigVar isoPath; + ConfigVar isoVerification; ConfigVar graphicsBackend; ConfigVar skipPreLaunchUI; ConfigVar showPipelineCompilation; ConfigVar wasPresetChosen; + ConfigVar checkForUpdates; + ConfigVar cardFileType; + ConfigVar enableAdvancedSettings; } backend; + + // Arrays of size 4 for 4 ports + struct { + std::array firstPersonCamera; + std::array callMidna; + std::array openDusklightMenu; + std::array turboSpeedButton; + } actionBindings; }; UserSettings& getSettings(); @@ -112,6 +233,7 @@ struct TransientSettings { CollisionViewSettings collisionView; bool skipFrameRateLimit; bool moveLinkActive; + bool stateShareLoadActive; }; TransientSettings& getTransientSettings(); diff --git a/include/dusk/speedrun.h b/include/dusk/speedrun.h new file mode 100644 index 0000000000..c1a92e4a50 --- /dev/null +++ b/include/dusk/speedrun.h @@ -0,0 +1,41 @@ +#pragma once +#include + +namespace dusk { + +struct SpeedrunInfo { + void startRun() { + m_isRunStarted = true; + m_startTimestamp = OSGetTime(); + } + + void stopRun() { + m_isRunStarted = false; + m_endTimestamp = OSGetTime() - m_startTimestamp; + } + + void reset() { + m_isRunStarted = false; + m_startTimestamp = 0; + m_endTimestamp = 0; + m_isPauseIGT = false; + m_loadStartTimestamp = 0; + m_totalLoadTime = 0; + m_igtTimer = 0; + } + + bool m_isRunStarted = false; + OSTime m_startTimestamp = 0; + OSTime m_endTimestamp = 0; + + bool m_isPauseIGT = false; + OSTime m_loadStartTimestamp = 0; + OSTime m_totalLoadTime = 0; + OSTime m_igtTimer = 0; +}; + +extern SpeedrunInfo m_speedrunInfo; + +void resetForSpeedrunMode(); + +} // namespace dusk diff --git a/include/dusk/time.h b/include/dusk/time.h index 948a2fc171..c43437f639 100644 --- a/include/dusk/time.h +++ b/include/dusk/time.h @@ -1,9 +1,10 @@ #ifndef DUSK_TIME_H #define DUSK_TIME_H -#include -#include #include +#include + +#include "SDL3/SDL_timer.h" #ifdef _WIN32 #ifndef WIN32_LEAN_AND_MEAN @@ -15,28 +16,26 @@ #include #include #include -#else -#include "SDL3/SDL_timer.h" #endif class Limiter { - using delta_clock = std::chrono::high_resolution_clock; - using duration_t = std::chrono::nanoseconds; - public: - void Reset() { m_oldTime = delta_clock::now(); } + using duration_t = Uint64; + + void Reset() { m_oldTime = SDL_GetTicksNS(); } void Sleep(duration_t targetFrameTime) { - if (targetFrameTime.count() == 0) { + if (targetFrameTime == 0) { return; } - auto start = delta_clock::now(); + const Uint64 start = SDL_GetTicksNS(); duration_t adjustedSleepTime = SleepTime(targetFrameTime); - if (adjustedSleepTime.count() > 0) { + if (adjustedSleepTime > 0) { NanoSleep(adjustedSleepTime); - duration_t overslept = TimeSince(start) - adjustedSleepTime; - if (overslept < duration_t{targetFrameTime}) { + const duration_t elapsed = TimeSince(start); + const duration_t overslept = elapsed > adjustedSleepTime ? elapsed - adjustedSleepTime : 0; + if (overslept < targetFrameTime) { m_overheadTimes[m_overheadTimeIdx] = overslept; m_overheadTimeIdx = (m_overheadTimeIdx + 1) % m_overheadTimes.size(); } @@ -45,23 +44,23 @@ public: } duration_t SleepTime(duration_t targetFrameTime) { - const auto sleepTime = duration_t{targetFrameTime} - TimeSince(m_oldTime); - m_overhead = std::accumulate(m_overheadTimes.begin(), m_overheadTimes.end(), duration_t{}) / m_overheadTimes.size(); + const duration_t elapsed = TimeSince(m_oldTime); + const duration_t sleepTime = elapsed < targetFrameTime ? targetFrameTime - elapsed : 0; + m_overhead = std::accumulate(m_overheadTimes.begin(), m_overheadTimes.end(), duration_t{0}) / + m_overheadTimes.size(); if (sleepTime > m_overhead) { return sleepTime - m_overhead; } - return duration_t{0}; + return 0; } private: - delta_clock::time_point m_oldTime; + Uint64 m_oldTime = 0; std::array m_overheadTimes{}; size_t m_overheadTimeIdx = 0; - duration_t m_overhead = duration_t{0}; + duration_t m_overhead = 0; - duration_t TimeSince(delta_clock::time_point start) { - return std::chrono::duration_cast(delta_clock::now() - start); - } + duration_t TimeSince(Uint64 start) const { return SDL_GetTicksNS() - start; } #if _WIN32 void NanoSleep(const duration_t duration) { @@ -85,9 +84,10 @@ private: LARGE_INTEGER start, current; QueryPerformanceCounter(&start); - LONGLONG ticksToWait = static_cast(duration.count() * countPerNs); - if (DWORD ms = std::chrono::duration_cast(duration).count(); ms > 1) { - ::Sleep(ms - 1); + const LONGLONG ticksToWait = static_cast(duration * countPerNs); + const Uint64 ms = duration / 1'000'000ULL; + if (ms > 1) { + ::Sleep(static_cast(ms - 1)); } do { QueryPerformanceCounter(¤t); @@ -99,7 +99,7 @@ private: } while (current.QuadPart - start.QuadPart < ticksToWait); } #else - void NanoSleep(const duration_t duration) { SDL_DelayPrecise(duration.count()); } + void NanoSleep(const duration_t duration) { SDL_DelayPrecise(duration); } #endif }; diff --git a/include/dusk/version.hpp b/include/dusk/version.hpp new file mode 100644 index 0000000000..fe393b049b --- /dev/null +++ b/include/dusk/version.hpp @@ -0,0 +1,67 @@ +#ifndef DUSK_VERSION_HPP +#define DUSK_VERSION_HPP + +/** + * Functionality for switching game behavior based on the loaded game version (e.g. PAL/JPN, GC/Wii) + */ +namespace dusk::version { + enum class GameVersion : u8 { + GcnUsa = VERSION_GCN_USA, + GcnPal = VERSION_GCN_PAL, + GcnJpn = VERSION_GCN_JPN, + WiiUsaRev0 = VERSION_WII_USA_R0, + WiiUsa = VERSION_WII_USA_R2, + WiiPal = VERSION_WII_PAL, + WiiJpn = VERSION_WII_JPN, + WiiKor = VERSION_WII_KOR, + }; + + bool isGcn(); + bool isWii(); + bool isPalOrAtLeastWiiR2(); + + bool isRegionPal(); + bool isRegionJpn(); + bool isRegionUsa(); + + GameVersion getGameVersion(); + + const DVDDiskID& getDiskID(); + + void init(); + + template + struct VersionOption { + GameVersion mVersion; + T mValue; + + constexpr VersionOption(GameVersion version, T value) : mVersion(version), mValue(value) {} + }; + + template + const T& versionSelect(const std::initializer_list> options) { + const auto version = getGameVersion(); + for (const auto& opt : options) { + if (opt.mVersion == version) { + return opt.mValue; + } + } + + // Unable to find value. + abort(); + } + + template + const T& versionSelect(const std::initializer_list> options, const T& defaultValue) { + const auto version = getGameVersion(); + for (const auto& opt : options) { + if (opt.mVersion == version) { + return opt.mValue; + } + } + + return defaultValue; + } +} // namespace dusk::version + +#endif // DUSK_VERSION_HPP diff --git a/include/f_op/f_op_actor_mng.h b/include/f_op/f_op_actor_mng.h index 5b11f0e57a..76a9f8517c 100644 --- a/include/f_op/f_op_actor_mng.h +++ b/include/f_op/f_op_actor_mng.h @@ -108,7 +108,7 @@ struct fopAcM_search_prm { struct fOpAcm_HIO_entry_c : public mDoHIO_entry_c { virtual ~fOpAcm_HIO_entry_c() {} - #if DEBUG + #if DEBUG && !TARGET_PC void removeHIO(const fopAc_ac_c* i_this) { removeHIO(*i_this); } void removeHIO(const fopAc_ac_c& i_this) { removeHIO(i_this.base); } void removeHIO(const fopEn_enemy_c& i_this) { removeHIO(i_this.base); } diff --git a/include/f_pc/f_pc_leaf.h b/include/f_pc/f_pc_leaf.h index 33d7e85d29..414d706b6a 100644 --- a/include/f_pc/f_pc_leaf.h +++ b/include/f_pc/f_pc_leaf.h @@ -25,7 +25,7 @@ typedef struct leafdraw_class : base_process_class { #endif /* 0xB8 */ leafdraw_method_class* leaf_methods; /* 0xBC */ s8 unk_0xBC; - /* 0xBD */ u8 unk_0xBD; + /* 0xBD */ u8 draw_interp_frame; /* 0xBE */ draw_priority_class draw_priority; } leafdraw_class; diff --git a/include/f_pc/f_pc_node.h b/include/f_pc/f_pc_node.h index 3be7fe6c8f..0d96fd2de5 100644 --- a/include/f_pc/f_pc_node.h +++ b/include/f_pc/f_pc_node.h @@ -18,6 +18,7 @@ typedef struct process_node_class { /* 0x0BC */ layer_class layer; /* 0x0E8 */ node_list_class layer_nodelist[16]; /* 0x1A8 */ s8 unk_0x1A8; + /* 0x1A9 */ s8 draw_interp_frame; } process_node_class; typedef struct node_process_profile_definition { diff --git a/include/global.h b/include/global.h index d634332797..556c5f6207 100644 --- a/include/global.h +++ b/include/global.h @@ -73,6 +73,9 @@ #endif #ifndef __MWERKS__ +#ifdef __cplusplus +extern "C" { +#endif // Silence clangd errors about MWCC PPC intrinsics by declaring them here. extern int __cntlzw(unsigned int); extern int __rlwimi(int, int, int, int, int); @@ -80,7 +83,14 @@ extern void __dcbf(void*, int); extern void __dcbz(void*, int); extern void __sync(); extern int __abs(int); -void* __memcpy(void*, const void*, int); +#if defined(__has_builtin) && __has_builtin(__builtin_memcpy) +#define __memcpy __builtin_memcpy +#else +#define __memcpy memcpy +#endif +#ifdef __cplusplus +} +#endif #endif #ifndef M_PI @@ -220,10 +230,16 @@ using std::isnan; // Some basic macros that are more convenient than putting down #if blocks for one-line changes. #if TARGET_PC #define IF_DUSK(statement) statement +#define IF_DUSK_BLOCK(cond) if (cond) { +#define IF_DUSK_BLOCK_END } +#define IF_DUSK_ARG(expr) , expr #define IF_NOT_DUSK(statement) #define DUSK_IF_ELSE(dusk, orig) dusk #else #define IF_DUSK(statement) +#define IF_DUSK_ARG(expr) +#define IF_DUSK_BLOCK(cond) +#define IF_DUSK_BLOCK_END #define IF_NOT_DUSK(statement) statement #define DUSK_IF_ELSE(dusk, orig) orig #endif diff --git a/include/m_Do/m_Do_audio.h b/include/m_Do/m_Do_audio.h index 1d58e8d8f2..4bc2f20c94 100644 --- a/include/m_Do/m_Do_audio.h +++ b/include/m_Do/m_Do_audio.h @@ -5,6 +5,7 @@ #include "Z2AudioLib/Z2EnvSeMgr.h" #include "Z2AudioLib/Z2LinkMgr.h" #include "dusk/audio.h" +#include "dusk/settings.h" class mDoAud_zelAudio_c : public Z2AudioMgr { public: @@ -132,6 +133,18 @@ inline void mDoAud_seStart(u32 i_sfxID, const Vec* i_sePos, u32 param_2, s8 i_re -1.0f, -1.0f, 0); } +#if TARGET_PC +inline void mDoAud_seStartMenu(u32 i_sfxID) { + if (!mDoAud_zelAudio_c::isInitFlag()) { + return; + } + if (!dusk::getSettings().audio.menuSounds.getValue()) { + return; + } + mDoAud_seStart(i_sfxID, nullptr, 0, 0); +} +#endif + inline void mDoAud_seStartLevel(u32 i_sfxID, const Vec* i_sePos, u32 param_2, s8 i_reverb) { DUSK_AUDIO_SKIP() Z2AudioMgr::getInterface()->seStartLevel(i_sfxID, i_sePos, param_2, i_reverb, 1.0f, 1.0f, diff --git a/include/m_Do/m_Do_graphic.h b/include/m_Do/m_Do_graphic.h index c6b22654fa..5d29049520 100644 --- a/include/m_Do/m_Do_graphic.h +++ b/include/m_Do/m_Do_graphic.h @@ -125,8 +125,15 @@ public: #if TARGET_PC static f32 hudAspectScaleDown; static f32 hudAspectScaleUp; - static f32 ScaleHUDXLeft(f32 baseX) { return getMinXF() + baseX; } - static f32 ScaleHUDXRight(f32 baseX) { return -getMinXF() + baseX; } + static void updateSafeAreaBounds(); + static f32 getSafeMinXF() { return m_safeMinXF; } + static f32 getSafeMinYF() { return m_safeMinYF; } + static f32 getSafeWidthF() { return m_safeWidthF; } + static f32 getSafeHeightF() { return m_safeHeightF; } + static f32 getSafeMaxXF() { return m_safeMaxXF; } + static f32 getSafeMaxYF() { return m_safeMaxYF; } + static f32 ScaleHUDXLeft(f32 baseX) { return getSafeMinXF() + baseX; } + static f32 ScaleHUDXRight(f32 baseX) { return getSafeMaxXF() - FB_WIDTH_BASE + baseX; } #endif static void setBlureMtx(const Mtx m) { @@ -279,12 +286,7 @@ public: #if WIDESCREEN_SUPPORT static void setTvSize(); - #if TARGET_PC - static void onWide(f32 width, f32 height); - #else static void onWide(); - #endif - static void offWide(); static u8 isWide(); @@ -297,7 +299,7 @@ public: #endif #if TARGET_PC - static void setWindowSize(AuroraWindowSize const& size); + static void updateRenderSize(); #endif static TGXTexObj mFrameBufferTexObj; @@ -369,6 +371,15 @@ public: static int m_height; static f32 m_heightF; static f32 m_widthF; + + #if TARGET_PC + static f32 m_safeMinXF; + static f32 m_safeMinYF; + static f32 m_safeMaxXF; + static f32 m_safeMaxYF; + static f32 m_safeWidthF; + static f32 m_safeHeightF; + #endif #endif }; diff --git a/include/m_Do/m_Do_lib.h b/include/m_Do/m_Do_lib.h index 8130cab8da..14e57ddf5a 100644 --- a/include/m_Do/m_Do_lib.h +++ b/include/m_Do/m_Do_lib.h @@ -43,10 +43,6 @@ struct mDoLib_clipper { }; void mDoLib_project(Vec* src, Vec* dst); -#if TARGET_PC -void mDoLib_project(Vec* src, Vec* dst, JGeometry::TBox2 viewport); -#endif - u32 mDoLib_setResTimgObj(ResTIMG const* res, TGXTexObj* o_texObj, u32 tlut_name, GXTlutObj* o_tlutObj); void mDoLib_pos2camera(Vec* src, Vec* dst); diff --git a/libs/JSystem/include/JSystem/J3DGraphAnimator/J3DModel.h b/libs/JSystem/include/JSystem/J3DGraphAnimator/J3DModel.h index a27db4fa00..2e5b3bd38d 100644 --- a/libs/JSystem/include/JSystem/J3DGraphAnimator/J3DModel.h +++ b/libs/JSystem/include/JSystem/J3DGraphAnimator/J3DModel.h @@ -79,6 +79,10 @@ public: virtual void viewCalc(); virtual ~J3DModel() {} +#if TARGET_PC + static void interp_callback(bool isSimFrame, void* pUserWork); +#endif + J3DModelData* getModelData() { return mModelData; } void onFlag(u32 flag) { mFlags |= flag; } @@ -105,9 +109,7 @@ public: void setAnmMtx(int jointNo, Mtx m) { mMtxBuffer->setAnmMtx(jointNo, m); #ifdef TARGET_PC - dusk::frame_interp::record_final_mtx_raw( - reinterpret_cast(mMtxBuffer->getAnmMtx(jointNo)), - mMtxBuffer->getAnmMtx(jointNo)); + dusk::frame_interp::record_final_mtx(mMtxBuffer->getAnmMtx(jointNo)); #endif } MtxP getAnmMtx(int jointNo) { return mMtxBuffer->getAnmMtx(jointNo); } diff --git a/libs/JSystem/include/JSystem/J3DGraphAnimator/J3DModelData.h b/libs/JSystem/include/JSystem/J3DGraphAnimator/J3DModelData.h index 6e3d54ff45..52e5018c1b 100644 --- a/libs/JSystem/include/JSystem/J3DGraphAnimator/J3DModelData.h +++ b/libs/JSystem/include/JSystem/J3DGraphAnimator/J3DModelData.h @@ -23,6 +23,10 @@ public: void syncJ3DSysPointers() const; void syncJ3DSysFlags() const; +#if TARGET_PC + bool needsInterpCallBack() const; +#endif + virtual ~J3DModelData() {} void simpleCalcMaterial(Mtx mtx) { simpleCalcMaterial(0, mtx); } diff --git a/libs/JSystem/include/JSystem/J3DGraphBase/J3DMaterial.h b/libs/JSystem/include/JSystem/J3DGraphBase/J3DMaterial.h index 829156ad1b..70e368eec4 100644 --- a/libs/JSystem/include/JSystem/J3DGraphBase/J3DMaterial.h +++ b/libs/JSystem/include/JSystem/J3DGraphBase/J3DMaterial.h @@ -33,6 +33,9 @@ public: void copy(J3DMaterial*); s32 newSharedDisplayList(u32); s32 newSingleSharedDisplayList(u32); +#if TARGET_PC + bool needsInterpCallBack() const; +#endif virtual void calc(f32 const (*)[4]); virtual void calcDiffTexMtx(f32 const (*)[4]); @@ -46,7 +49,6 @@ public: virtual void change(); J3DMaterial() { initialize(); } - ~J3DMaterial() {} J3DMaterial* getNext() { return mNext; } J3DShape* getShape() { return mShape; } J3DTevBlock* getTevBlock() { return mTevBlock; } diff --git a/libs/JSystem/include/JSystem/J3DGraphBase/J3DStruct.h b/libs/JSystem/include/JSystem/J3DGraphBase/J3DStruct.h index b565ad78f8..05663fd84e 100644 --- a/libs/JSystem/include/JSystem/J3DGraphBase/J3DStruct.h +++ b/libs/JSystem/include/JSystem/J3DGraphBase/J3DStruct.h @@ -1,9 +1,9 @@ #ifndef J3DSTRUCT_H #define J3DSTRUCT_H +#include #include #include -#include #include "global.h" #include "JSystem/JMath/JMath.h" @@ -11,7 +11,7 @@ /** * @ingroup jsystem-j3d - * + * */ struct J3DLightInfo { bool operator==(J3DLightInfo& other) const; @@ -28,7 +28,7 @@ struct J3DLightInfo { /** * @ingroup jsystem-j3d - * + * */ struct J3DTextureSRTInfo { // NOTE: Big endian when loaded from file! @@ -79,7 +79,7 @@ enum J3DTexMtxMode { /** * @ingroup jsystem-j3d - * + * */ struct J3DTexMtxInfo { bool operator==(J3DTexMtxInfo& other) const; @@ -97,7 +97,7 @@ struct J3DTexMtxInfo { /** * @ingroup jsystem-j3d - * + * */ struct J3DIndTexMtxInfo { J3DIndTexMtxInfo& operator=(J3DIndTexMtxInfo const&); @@ -107,7 +107,7 @@ struct J3DIndTexMtxInfo { /** * @ingroup jsystem-j3d - * + * */ struct J3DFogInfo { bool operator==(J3DFogInfo&) const; @@ -126,7 +126,7 @@ struct J3DFogInfo { /** * @ingroup jsystem-j3d - * + * */ struct J3DNBTScaleInfo { bool operator==(const J3DNBTScaleInfo& other) const; @@ -153,7 +153,7 @@ struct J3DIndTexOrderInfo { /** * @ingroup jsystem-j3d - * + * */ struct J3DTevSwapModeInfo { /* 0x0 */ u8 mRasSel; @@ -164,7 +164,7 @@ struct J3DTevSwapModeInfo { /** * @ingroup jsystem-j3d - * + * */ struct J3DTevSwapModeTableInfo { /* 0x0 */ u8 field_0x0; @@ -175,7 +175,7 @@ struct J3DTevSwapModeTableInfo { /** * @ingroup jsystem-j3d - * + * */ struct J3DTevStageInfo { /* 0x0 */ u8 field_0x0; @@ -202,7 +202,7 @@ struct J3DTevStageInfo { /** * @ingroup jsystem-j3d - * + * */ struct J3DIndTevStageInfo { /* 0x0 */ u8 mIndStage; @@ -219,7 +219,7 @@ struct J3DIndTevStageInfo { /** * @ingroup jsystem-j3d - * + * */ struct J3DTexCoordInfo { /* 0x0 */ u8 mTexGenType; @@ -265,7 +265,7 @@ struct J3DBlendInfo { /** * @ingroup jsystem-j3d - * + * */ struct J3DTevOrderInfo { void operator=(const J3DTevOrderInfo& other) { diff --git a/libs/JSystem/include/JSystem/JAudio2/JAISeqMgr.h b/libs/JSystem/include/JSystem/JAudio2/JAISeqMgr.h index 87c85f316f..6f6d29ca98 100644 --- a/libs/JSystem/include/JSystem/JAudio2/JAISeqMgr.h +++ b/libs/JSystem/include/JSystem/JAudio2/JAISeqMgr.h @@ -59,6 +59,9 @@ public: bool isActive() const { return mSeqList.getNumLinks() != 0; } int getNumActiveSeqs() const { return mSeqList.getNumLinks(); } void pause(bool paused) { mActivity.field_0x0.flags.flag2 = paused; } + #if TARGET_PC + JSUList* getSeqList() { return &mSeqList; } + #endif private: /* 0x08 */ JAIAudience* mAudience; diff --git a/libs/JSystem/include/JSystem/JAudio2/JASCalc.h b/libs/JSystem/include/JSystem/JAudio2/JASCalc.h index 35b8fd5516..19560be367 100644 --- a/libs/JSystem/include/JSystem/JAudio2/JASCalc.h +++ b/libs/JSystem/include/JSystem/JAudio2/JASCalc.h @@ -11,9 +11,17 @@ struct JASCalc { static void imixcopy(const s16*, const s16*, s16*, u32); static void bcopyfast(const void* src, void* dest, u32 size); +#if TARGET_ANDROID + static void _bcopy(const void* src, void* dest, u32 size); +#else static void bcopy(const void* src, void* dest, u32 size); +#endif static void bzerofast(void* dest, u32 size); +#if TARGET_ANDROID + static void _bzero(void* dest, u32 size); +#else static void bzero(void* dest, u32 size); +#endif static f32 pow2(f32); template diff --git a/libs/JSystem/include/JSystem/JFramework/JFWDisplay.h b/libs/JSystem/include/JSystem/JFramework/JFWDisplay.h index fab39a296b..28967cc0a2 100644 --- a/libs/JSystem/include/JSystem/JFramework/JFWDisplay.h +++ b/libs/JSystem/include/JSystem/JFramework/JFWDisplay.h @@ -101,10 +101,6 @@ public: void setDrawDoneMethod(EDrawDone drawDone) { mDrawDoneMethod = drawDone; } void setFader(JUTFader* fader) { mFader = fader; } -#ifdef TARGET_PC - // For frame interpolation - void setFaderSimSteps(u32 steps); -#endif void resetFader() { setFader(NULL); } JUTFader* getFader() const { return mFader; } void setClearColor(JUtility::TColor color) { mClearColor = color; } diff --git a/libs/JSystem/include/JSystem/JParticle/JPABaseShape.h b/libs/JSystem/include/JSystem/JParticle/JPABaseShape.h index d348859ebe..795a5a2628 100644 --- a/libs/JSystem/include/JSystem/JParticle/JPABaseShape.h +++ b/libs/JSystem/include/JSystem/JParticle/JPABaseShape.h @@ -207,4 +207,11 @@ void JPARegistAlphaEnv(JPAEmitterWorkData*, JPABaseParticle*); void JPARegistPrmAlpha(JPAEmitterWorkData*, JPABaseParticle*); void JPARegistPrmAlphaEnv(JPAEmitterWorkData*, JPABaseParticle*); +#if TARGET_PC +void JPAInterpBillboard(JPAEmitterWorkData*, JPABaseParticle*); +void JPAInterpRotBillboard(JPAEmitterWorkData*, JPABaseParticle*); +void JPAInterpDirection(JPAEmitterWorkData*, JPABaseParticle*); +void JPAInterpRotDirection(JPAEmitterWorkData*, JPABaseParticle*); +#endif + #endif /* JPABASESHAPE_H */ diff --git a/libs/JSystem/include/JSystem/JParticle/JPAParticle.h b/libs/JSystem/include/JSystem/JParticle/JPAParticle.h index 842f293286..c864a5cd4a 100644 --- a/libs/JSystem/include/JSystem/JParticle/JPAParticle.h +++ b/libs/JSystem/include/JSystem/JParticle/JPAParticle.h @@ -24,6 +24,9 @@ public: void init_c(JPAEmitterWorkData*, JPABaseParticle*); bool calc_p(JPAEmitterWorkData*); bool calc_c(JPAEmitterWorkData*); +#if TARGET_PC + void interp(JPAEmitterWorkData*, void const* drawFunc); +#endif bool canCreateChild(JPAEmitterWorkData*); f32 getWidth(JPABaseEmitter const*) const; f32 getHeight(JPABaseEmitter const*) const; diff --git a/libs/JSystem/include/JSystem/JUtility/JUTFader.h b/libs/JSystem/include/JSystem/JUtility/JUTFader.h index 6c98a6840f..b85e4a6cd4 100644 --- a/libs/JSystem/include/JSystem/JUtility/JUTFader.h +++ b/libs/JSystem/include/JSystem/JUtility/JUTFader.h @@ -11,8 +11,10 @@ class JUTFader { public: enum EStatus { - UNKSTATUS_M1 = -1, - UNKSTATUS_0 = 0, + None, + Wait, + FadeIn, + FadeOut, }; JUTFader(int, int, int, int, JUtility::TColor); @@ -29,12 +31,12 @@ public: void setColor(JUtility::TColor color) { mColor.set(color); } /* 0x04 */ s32 mStatus; - /* 0x08 */ u16 field_0x8; - /* 0x0A */ u16 field_0xa; + /* 0x08 */ u16 mDuration; + /* 0x0A */ u16 mTimer; /* 0x0C */ JUtility::TColor mColor; /* 0x10 */ JGeometry::TBox2 mBox; - /* 0x20 */ int mEStatus; - /* 0x24 */ u32 field_0x24; + /* 0x20 */ int mStatusTimer; + /* 0x24 */ u32 mNextStatus; }; #endif /* JUTFADER_H */ diff --git a/libs/JSystem/include/JSystem/JUtility/JUTFont.h b/libs/JSystem/include/JSystem/JUtility/JUTFont.h index 2718132048..3c1cd0bf1f 100644 --- a/libs/JSystem/include/JSystem/JUtility/JUTFont.h +++ b/libs/JSystem/include/JSystem/JUtility/JUTFont.h @@ -5,6 +5,17 @@ #include #include "dusk/endian.h" +#if TARGET_PC +struct FontDrawContext { + bool isTextureLoaded = false; +}; +#define FONT_DRAW_CTX , FontDrawContext* context +#define FONT_DRAW_CTX_ARG , context +#else +#define FONT_DRAW_CTX +#define FONT_DRAW_CTX_ARG +#endif + /** * @ingroup jsystem-jutility * @@ -84,7 +95,12 @@ public: /* 0x0C */ virtual void setGX() = 0; /* 0x10 */ virtual void setGX(JUtility::TColor col1, JUtility::TColor col2) { setGX(); } - /* 0x14 */ virtual f32 drawChar_scale(f32 a1, f32 a2, f32 a3, f32 a4, int a5, bool a6) = 0; + /* 0x14 */ virtual f32 drawChar_scale(f32 a1, f32 a2, f32 a3, f32 a4, int a5, bool a6 FONT_DRAW_CTX) = 0; +#if TARGET_PC + f32 drawChar_scale(f32 a1, f32 a2, f32 a3, f32 a4, int a5, bool a6) { + return drawChar_scale(a1, a2, a3, a4, a5, a6, nullptr); + } +#endif /* 0x18 */ virtual int getLeading() const = 0; /* 0x1C */ virtual s32 getAscent() const = 0; /* 0x20 */ virtual s32 getDescent() const = 0; @@ -97,6 +113,11 @@ public: /* 0x3C */ virtual ResFONT* getResFont() const = 0; /* 0x40 */ virtual bool isLeadByte(int a1) const = 0; +#if TARGET_PC + virtual void pushDrawState() = 0; + virtual void popDrawState() = 0; +#endif + static bool isLeadByte_1Byte(int b) { return false; } diff --git a/libs/JSystem/include/JSystem/JUtility/JUTGamePad.h b/libs/JSystem/include/JSystem/JUtility/JUTGamePad.h index 30bf685700..45e7227fe3 100644 --- a/libs/JSystem/include/JSystem/JUtility/JUTGamePad.h +++ b/libs/JSystem/include/JSystem/JUtility/JUTGamePad.h @@ -263,6 +263,9 @@ public: /* 0x9C */ u8 field_0x9c[4]; /* 0xA0 */ OSTime mResetHoldStartTime; /* 0xA8 */ u8 field_0xa8; +#if TARGET_PC + u32 mResetHoldFrameCount; +#endif }; /** diff --git a/libs/JSystem/include/JSystem/JUtility/JUTPalette.h b/libs/JSystem/include/JSystem/JUtility/JUTPalette.h index 017d511d50..5d29005a92 100644 --- a/libs/JSystem/include/JSystem/JUtility/JUTPalette.h +++ b/libs/JSystem/include/JSystem/JUtility/JUTPalette.h @@ -40,6 +40,9 @@ public: JUTTransparency getTransparency() const { return JUTTransparency(mTransparency); } u16 getNumColors() const { return mNumColors; } ResTLUT* getColorTable() const { return mColorTable; } +#if TARGET_PC + void dataUploaded(); +#endif private: /* 0x00 */ GXTlutObj mTlutObj; diff --git a/libs/JSystem/include/JSystem/JUtility/JUTResFont.h b/libs/JSystem/include/JSystem/JUtility/JUTResFont.h index 8709822d7b..29d8a77b92 100644 --- a/libs/JSystem/include/JSystem/JUtility/JUTResFont.h +++ b/libs/JSystem/include/JSystem/JUtility/JUTResFont.h @@ -18,10 +18,6 @@ struct BlockHeader { BE(u32) size; }; -#if TARGET_PC -struct GlyphTextures; -#endif - /** * @ingroup jsystem-jutility * @@ -31,7 +27,7 @@ public: virtual ~JUTResFont(); virtual void setGX(); virtual void setGX(JUtility::TColor, JUtility::TColor); - virtual f32 drawChar_scale(f32, f32, f32, f32, int, bool); + virtual f32 drawChar_scale(f32, f32, f32, f32, int, bool FONT_DRAW_CTX); virtual int getLeading() const; virtual s32 getAscent() const; virtual s32 getDescent() const; @@ -43,7 +39,7 @@ public: virtual int getFontType() const; virtual ResFONT* getResFont() const; virtual bool isLeadByte(int) const; - virtual void loadImage(int, GXTexMapID); + virtual void loadImage(int, GXTexMapID FONT_DRAW_CTX); virtual void setBlock(); JUTResFont(ResFONT const*, JKRHeap*); @@ -53,10 +49,15 @@ public: bool initiate(ResFONT const*, JKRHeap*); bool protected_initiate(ResFONT const*, JKRHeap*); void countBlock(); - void loadFont(int, GXTexMapID, JUTFont::TWidth*); + void loadFont(int, GXTexMapID, JUTFont::TWidth* FONT_DRAW_CTX); int getFontCode(int) const; int convertSjis(int, BE(u16)*) const; +#if TARGET_PC + void pushDrawState() override; + void popDrawState() override; +#endif + inline void delete_and_initialize() { deleteMemBlocks_ResFont(); initialize_state(); @@ -68,11 +69,7 @@ public: // some types uncertain, may need to be fixed /* 0x1C */ int mWidth; /* 0x20 */ int mHeight; -#if TARGET_PC - GlyphTextures* mGlyphTextures; -#else /* 0x24 */ TGXTexObj mTexObj; -#endif /* 0x44 */ int mTexPageIdx; /* 0x48 */ const ResFONT* mResFont; /* 0x4C */ ResFONT::INF1* mInf1Ptr; @@ -86,6 +83,16 @@ public: /* 0x66 */ u16 field_0x66; /* 0x68 */ u16 mMaxCode; /* 0x6C */ const IsLeadByte_func* mIsLeadByte; + +#if TARGET_PC + // Dusk change: we use a single large texture for all characters. + // This enables better draw call merging, ideally enabling entire blocks of + // text to be one draw call. + TGXTexObj mJoinedTextureObject; + u16 mJoinedTextureHeight; + + void initJoinedTexture(); +#endif }; extern u8 const JUTResFONT_Ascfont_fix12[]; diff --git a/libs/JSystem/include/JSystem/JUtility/JUTTexture.h b/libs/JSystem/include/JSystem/JUtility/JUTTexture.h index e0f1e15ff0..6a7a8f9a0e 100644 --- a/libs/JSystem/include/JSystem/JUtility/JUTTexture.h +++ b/libs/JSystem/include/JSystem/JUtility/JUTTexture.h @@ -75,6 +75,7 @@ public: s32 getTransparency() const { return mTexInfo->alphaEnabled; } s32 getWidth() const { return mTexInfo->width; } s32 getHeight() const { return mTexInfo->height; } + JUTPalette* getPalette() const { return mPalette; } void setCaptureFlag(bool flag) { mFlags &= 2 | flag; } bool getCaptureFlag() const { return mFlags & 1; } bool getEmbPaletteDelFlag() const { return mFlags & 2; } @@ -82,7 +83,7 @@ public: int getTlutName() const { return mTlutName; } bool operator==(const JUTTexture& other) { return mTexInfo == other.mTexInfo - && field_0x2c == other.field_0x2c + && mPalette == other.mPalette && mWrapS == other.mWrapS && mWrapT == other.mWrapT && mMinFilter == other.mMinFilter @@ -100,7 +101,7 @@ private: /* 0x20 */ const ResTIMG* mTexInfo; /* 0x24 */ void* mTexData; /* 0x28 */ JUTPalette* mEmbPalette; - /* 0x2C */ JUTPalette* field_0x2c; + /* 0x2C */ JUTPalette* mPalette; /* 0x30 */ u8 mWrapS; /* 0x31 */ u8 mWrapT; /* 0x32 */ u8 mMinFilter; diff --git a/libs/JSystem/include/JSystem/JUtility/JUTVideo.h b/libs/JSystem/include/JSystem/JUtility/JUTVideo.h index 9a23a4dc7e..dfc6fd0b6c 100644 --- a/libs/JSystem/include/JSystem/JUtility/JUTVideo.h +++ b/libs/JSystem/include/JSystem/JUtility/JUTVideo.h @@ -33,24 +33,16 @@ public: static void postRetraceProc(u32); static void drawDoneCallback(); - u16 getFbWidth() const { - #if TARGET_PC - return m_WindowSize.fb_width; - #else - return mRenderObj->fbWidth; - #endif - } - u16 getEfbHeight() const { - #if TARGET_PC - return m_WindowSize.fb_height; - #else - return mRenderObj->efbHeight; - #endif - } + u16 getFbWidth() const { return mRenderObj->fbWidth; } + u16 getEfbHeight() const { return mRenderObj->efbHeight; } void getBounds(u16& width, u16& height) const { width = (u16)getFbWidth(); height = (u16)getEfbHeight(); } +#ifdef TARGET_PC + u32 getRenderWidth() const { return mRenderWidth; } + u32 getRenderHeight() const { return mRenderHeight; } +#endif u16 getXfbHeight() const { return u16(mRenderObj->xfbHeight); } u8 isAntiAliasing() const { return u8(mRenderObj->aa); } Pattern getSamplePattern() const { return mRenderObj->sample_pattern; } @@ -63,7 +55,7 @@ public: GXRenderModeObj* getRenderMode() const { return mRenderObj; } #if TARGET_PC - void setWindowSize(AuroraWindowSize const& size); + void setRenderSize(u32 width, u32 height); #endif private: @@ -89,7 +81,8 @@ private: #if TARGET_PC public: - AuroraWindowSize m_WindowSize; + u32 mRenderWidth; + u32 mRenderHeight; #endif }; diff --git a/libs/JSystem/src/J2DGraph/J2DGrafContext.cpp b/libs/JSystem/src/J2DGraph/J2DGrafContext.cpp index 36f54a23c6..3ac3e5d197 100644 --- a/libs/JSystem/src/J2DGraph/J2DGrafContext.cpp +++ b/libs/JSystem/src/J2DGraph/J2DGrafContext.cpp @@ -64,10 +64,6 @@ void J2DGrafContext::setup2D() { } void J2DGrafContext::setScissor() { -#if TARGET_PC - GXSetScissor(mScissorBounds.i.x, mScissorBounds.i.y, mScissorBounds.getWidth(), - mScissorBounds.getHeight()); -#else JGeometry::TBox2 bounds(0, 0, 1024, 1024); JGeometry::TBox2 curBounds(mScissorBounds); mScissorBounds.intersect(bounds); @@ -81,7 +77,6 @@ void J2DGrafContext::setScissor() { } else { GXSetScissor(0, 0, 0, 0); } -#endif } void J2DGrafContext::scissor(JGeometry::TBox2 const& bounds) { diff --git a/libs/JSystem/src/J2DGraph/J2DPrint.cpp b/libs/JSystem/src/J2DGraph/J2DPrint.cpp index bd7156f706..69d71aa6c5 100644 --- a/libs/JSystem/src/J2DGraph/J2DPrint.cpp +++ b/libs/JSystem/src/J2DGraph/J2DPrint.cpp @@ -211,6 +211,11 @@ f32 J2DPrint::parse(const u8* pString, int length, int param_2, u16* param_3, local_bc.a = local_bc.a * alpha / 0xFF; mFont->setGradColor(local_b8, field_0x22 ? local_bc : local_b8); +#if TARGET_PC + FontDrawContext context; + mFont->pushDrawState(); +#endif + do { bool b2ByteCharacter = false; bool r25; @@ -312,9 +317,9 @@ f32 J2DPrint::parse(const u8* pString, int length, int param_2, u16* param_3, } else { if (param_6) { if (param_3 != NULL) { - mFont->drawChar_scale(mCursorH + (s16)param_3[someIndex], mCursorV, (s32)mScaleX, (s32)mScaleY, iCharacter, true); + mFont->drawChar_scale(mCursorH + (s16)param_3[someIndex], mCursorV, (s32)mScaleX, (s32)mScaleY, iCharacter, true IF_DUSK_ARG(&context)); } else { - mFont->drawChar_scale(mCursorH, mCursorV, (s32)mScaleX, (s32)mScaleY, iCharacter, true); + mFont->drawChar_scale(mCursorH, mCursorV, (s32)mScaleX, (s32)mScaleY, iCharacter, true IF_DUSK_ARG(&context)); } } mCursorH += field_0x34; @@ -353,6 +358,8 @@ f32 J2DPrint::parse(const u8* pString, int length, int param_2, u16* param_3, iCharacter = *(pString++); } while (true); + IF_DUSK(mFont->popDrawState()); + if (param_3 != NULL) { param_3[someIndex] = 0xFFFF; } diff --git a/libs/JSystem/src/J3DGraphAnimator/J3DModel.cpp b/libs/JSystem/src/J3DGraphAnimator/J3DModel.cpp index f2bd737b4f..8c895d5077 100644 --- a/libs/JSystem/src/J3DGraphAnimator/J3DModel.cpp +++ b/libs/JSystem/src/J3DGraphAnimator/J3DModel.cpp @@ -97,6 +97,16 @@ s32 J3DModel::entryModelData(J3DModelData* pModelData, u32 mdlFlags, u32 mtxNum) return kJ3DError_Success; } +#if TARGET_PC +void J3DModel::interp_callback(bool isSimFrame, void* pUserWork) { + J3DModel* i_this = static_cast(pUserWork); + if (!isSimFrame) { + i_this->calcMaterial(); + i_this->diff(); + } +} +#endif + s32 J3DModel::createShapePacket(J3DModelData* pModelData) { J3D_ASSERTMSG(173, pModelData != NULL, "Error : null pointer."); @@ -452,11 +462,11 @@ void J3DModel::calc() { #ifdef TARGET_PC for (u16 i = 0; i < mModelData->getJointNum(); ++i) { - dusk::frame_interp::record_final_mtx_raw(reinterpret_cast(getAnmMtx(i)), getAnmMtx(i)); + dusk::frame_interp::record_final_mtx(getAnmMtx(i)); } for (u16 i = 0; i < mModelData->getWEvlpMtxNum(); ++i) { - dusk::frame_interp::record_final_mtx_raw(reinterpret_cast(getWeightAnmMtx(i)), getWeightAnmMtx(i)); + dusk::frame_interp::record_final_mtx(getWeightAnmMtx(i)); } #endif } @@ -485,6 +495,11 @@ void J3DModel::entry() { joint->entryIn(); } } + +#if TARGET_PC + if (mModelData->needsInterpCallBack()) + dusk::frame_interp::add_interpolation_callback(&J3DModel::interp_callback, this); +#endif } void J3DModel::viewCalc() { @@ -496,7 +511,7 @@ void J3DModel::viewCalc() { J3DCalcViewBaseMtx(j3dSys.getViewMtx(), mBaseScale, mBaseTransformMtx, (MtxP)&mInternalView); #ifdef TARGET_PC - dusk::frame_interp::record_final_mtx_raw(&mInternalView, mInternalView); + dusk::frame_interp::record_final_mtx(mInternalView); #endif } } else if (isCpuSkinningOn()) { @@ -504,7 +519,7 @@ void J3DModel::viewCalc() { J3DCalcViewBaseMtx(j3dSys.getViewMtx(), mBaseScale, mBaseTransformMtx, (MtxP)&mInternalView); #ifdef TARGET_PC - dusk::frame_interp::record_final_mtx_raw(&mInternalView, mInternalView); + dusk::frame_interp::record_final_mtx(mInternalView); #endif } } else if (checkFlag(J3DMdlFlag_SkinPosCpu)) { @@ -528,7 +543,7 @@ void J3DModel::viewCalc() { #ifdef TARGET_PC for (u16 i = 0; i < mModelData->getDrawMtxNum(); ++i) { - dusk::frame_interp::record_final_mtx_raw(&getDrawMtxPtr()[i], getDrawMtxPtr()[i]); + dusk::frame_interp::record_final_mtx(getDrawMtxPtr()[i]); } #endif diff --git a/libs/JSystem/src/J3DGraphAnimator/J3DModelData.cpp b/libs/JSystem/src/J3DGraphAnimator/J3DModelData.cpp index e296676187..3eed051b6c 100644 --- a/libs/JSystem/src/J3DGraphAnimator/J3DModelData.cpp +++ b/libs/JSystem/src/J3DGraphAnimator/J3DModelData.cpp @@ -84,6 +84,15 @@ void J3DModelData::simpleCalcMaterial(u16 idx, Mtx param_1) { } } +#if TARGET_PC +bool J3DModelData::needsInterpCallBack() const { + for (u16 i = 0, n = getMaterialNum(); i < n; i++) + if (getMaterialNodePointer(i)->needsInterpCallBack()) + return true; + return false; +} +#endif + void J3DModelData::syncJ3DSysPointers() const { j3dSys.setTexture(getTexture()); j3dSys.setVtxPos(getVtxPosArray(), getVtxNum()); diff --git a/libs/JSystem/src/J3DGraphBase/J3DMaterial.cpp b/libs/JSystem/src/J3DGraphBase/J3DMaterial.cpp index 0c772305b2..48d4731ed9 100644 --- a/libs/JSystem/src/J3DGraphBase/J3DMaterial.cpp +++ b/libs/JSystem/src/J3DGraphBase/J3DMaterial.cpp @@ -265,7 +265,7 @@ void J3DMaterial::diff(u32 diffFlags) { } void J3DMaterial::calc(f32 const (*param_0)[4]) { - if (j3dSys.checkFlag(0x40000000)) { + if (j3dSys.checkFlag(J3DSysFlag_PostTexMtx)) { mTexGenBlock->calcPostTexMtx(param_0); } else { mTexGenBlock->calc(param_0); @@ -276,7 +276,7 @@ void J3DMaterial::calc(f32 const (*param_0)[4]) { } void J3DMaterial::calcDiffTexMtx(f32 const (*param_0)[4]) { - if (j3dSys.checkFlag(0x40000000)) { + if (j3dSys.checkFlag(J3DSysFlag_PostTexMtx)) { mTexGenBlock->calcPostTexMtxWithoutViewMtx(param_0); } else { mTexGenBlock->calcWithoutViewMtx(param_0); @@ -288,7 +288,7 @@ void J3DMaterial::setCurrentMtx() { } void J3DMaterial::calcCurrentMtx() { - if (!j3dSys.checkFlag(0x40000000)) { + if (!j3dSys.checkFlag(J3DSysFlag_PostTexMtx)) { mCurrentMtx.setCurrentTexMtx( getTexCoord(0)->getTexGenMtx(), getTexCoord(1)->getTexGenMtx(), @@ -371,6 +371,30 @@ s32 J3DMaterial::newSingleSharedDisplayList(u32 dlSize) { return kJ3DError_Success; } +#if TARGET_PC +bool J3DMaterial::needsInterpCallBack() const { + for (int i = 0, n = getTexGenNum(); i < n; i++) { + J3DTexMtx* pTexMtx = mTexGenBlock->getTexMtx(i); + if (pTexMtx != NULL) { + u32 texMtxMode = pTexMtx->getTexMtxInfo().mInfo & 0x3f; + + // uses j3dSys.getViewMtx() + switch (texMtxMode) { + case J3DTexMtxMode_EnvmapBasic: + case J3DTexMtxMode_EnvmapOld: + case J3DTexMtxMode_Envmap: + case J3DTexMtxMode_ProjmapBasic: + case J3DTexMtxMode_Projmap: + case J3DTexMtxMode_ViewProjmap: + case J3DTexMtxMode_ViewProjmapBasic: + return true; + } + } + } + return false; +} +#endif + void J3DPatchedMaterial::initialize() { J3DMaterial::initialize(); } diff --git a/libs/JSystem/src/J3DGraphBase/J3DTevs.cpp b/libs/JSystem/src/J3DGraphBase/J3DTevs.cpp index 09ddc0b405..719976770b 100644 --- a/libs/JSystem/src/J3DGraphBase/J3DTevs.cpp +++ b/libs/JSystem/src/J3DGraphBase/J3DTevs.cpp @@ -37,9 +37,9 @@ void loadTexCoordGens(u32 texGenNum, J3DTexCoord* texCoords) { var_r28 = 61; J3DGDWriteXFCmdHdr(GX_XF_REG_DUALTEX0, texGenNum); - if (j3dSys.checkFlag(0x40000000)) { + if (j3dSys.checkFlag(J3DSysFlag_PostTexMtx)) { for (int i = 0; i < texGenNum; i++) { - if (texCoords[i].getTexGenMtx() != 60) { + if (texCoords[i].getTexGenMtx() != GX_IDENTITY) { var_r28 = i * 3; } else { var_r28 = 61; diff --git a/libs/JSystem/src/J3DGraphLoader/J3DModelLoader.cpp b/libs/JSystem/src/J3DGraphLoader/J3DModelLoader.cpp index 32f0e4eed9..c3c72310a0 100644 --- a/libs/JSystem/src/J3DGraphLoader/J3DModelLoader.cpp +++ b/libs/JSystem/src/J3DGraphLoader/J3DModelLoader.cpp @@ -556,8 +556,8 @@ void J3DModelLoader::readVertexData(const J3DVertexBlock& block, J3DVertexData& if (attr == GX_VA_POS) { // can be a little off due to 0x20 alignment, account for that - u32 expect = ((data.mVtxNum * vertStride) + 0x1F) & ~0x1F; - JUT_ASSERT(1234, expect == addrDiff); + // u32 expect = ((data.mVtxNum * vertStride) + 0x1F) & ~0x1F; + // JUT_ASSERT(1234, expect == addrDiff); } else if (attr == GX_VA_NRM) { data.mNrmNum = num; } else if (attr == GX_VA_CLR0) { diff --git a/libs/JSystem/src/JAudio2/JAISeqMgr.cpp b/libs/JSystem/src/JAudio2/JAISeqMgr.cpp index a1f3554815..1d0e42d47e 100644 --- a/libs/JSystem/src/JAudio2/JAISeqMgr.cpp +++ b/libs/JSystem/src/JAudio2/JAISeqMgr.cpp @@ -120,11 +120,19 @@ void JAISeqMgr::mixOut() { } JAISeq* JAISeqMgr::beginStartSeq_() { - JAISeq* seq = JKR_NEW JAISeq(this, field_0x10); +#ifdef TARGET_PC + if (JAISeq::getFreeMemCount() == 0) { + JUT_WARN(273, "%s", "JASPoolAllocObject::::operator new failed .\n"); + return NULL; + } + return JKR_NEW JAISeq(this, field_0x10); +#else + JAISeq* seq = new JAISeq(this, field_0x10); if (seq == NULL) { JUT_WARN(273, "%s", "JASPoolAllocObject::::operator new failed .\n"); } return seq; +#endif } bool JAISeqMgr::endStartSeq_(JAISeq* seq, JAISoundHandle* handle) { diff --git a/libs/JSystem/src/JAudio2/JASAiCtrl.cpp b/libs/JSystem/src/JAudio2/JASAiCtrl.cpp index 75b475342b..3764700149 100644 --- a/libs/JSystem/src/JAudio2/JASAiCtrl.cpp +++ b/libs/JSystem/src/JAudio2/JASAiCtrl.cpp @@ -55,7 +55,11 @@ void JASDriver::initAI(void (*param_0)(void)) { for (int i = 0; i < 3; i++) { sDmaDacBuffer[i] = JKR_NEW_ARRAY_ARGS(s16, dacSize, JASDram, 0x20); JUT_ASSERT(102, sDmaDacBuffer[i]) +#if TARGET_ANDROID + JASCalc::_bzero(sDmaDacBuffer[i], size); +#else JASCalc::bzero(sDmaDacBuffer[i], size); +#endif DCStoreRange(sDmaDacBuffer[i], size); } sDspDacBuffer = JKR_NEW_ARRAY_ARGS(s16*, data_804507A8, JASDram, 0); @@ -63,7 +67,11 @@ void JASDriver::initAI(void (*param_0)(void)) { for (int i = 0; i < data_804507A8; i++) { sDspDacBuffer[i] = JKR_NEW_ARRAY_ARGS(s16, getDacSize(), JASDram, 0x20); JUT_ASSERT(119, sDspDacBuffer[i]); +#if TARGET_ANDROID + JASCalc::_bzero(sDspDacBuffer[i], size); +#else JASCalc::bzero(sDspDacBuffer[i], size); +#endif DCStoreRange(sDspDacBuffer[i], size); } sDspDacWriteBuffer = data_804507A8 - 1; diff --git a/libs/JSystem/src/JAudio2/JASBNKParser.cpp b/libs/JSystem/src/JAudio2/JASBNKParser.cpp index ddab6d2e38..7898eedd48 100644 --- a/libs/JSystem/src/JAudio2/JASBNKParser.cpp +++ b/libs/JSystem/src/JAudio2/JASBNKParser.cpp @@ -69,7 +69,11 @@ JASBasicBank* JASBNKParser::Ver1::createBasicBank(void const* stream, JKRHeap* h JUT_ASSERT(145, list_chunk); u8* envt = JKR_NEW_ARRAY_ARGS(u8, envt_chunk->mSize, heap, 2); +#if TARGET_ANDROID + JASCalc::_bcopy(envt_chunk->mData, envt, envt_chunk->mSize); +#else JASCalc::bcopy(envt_chunk->mData, envt, envt_chunk->mSize); +#endif BE(u32)* ptr = &osc_chunk->mCount; u32 count = *ptr++; @@ -215,7 +219,11 @@ JASBasicBank* JASBNKParser::Ver0::createBasicBank(void const* stream, JKRHeap* h int size = endPtr - points; JASOscillator::Point* table = JKR_NEW_ARRAY_ARGS(JASOscillator::Point, size, heap, 0); JUT_ASSERT(396, table != NULL); +#if TARGET_ANDROID + JASCalc::_bcopy(points, table, size * sizeof(JASOscillator::Point)); +#else JASCalc::bcopy(points, table, size * sizeof(JASOscillator::Point)); +#endif osc->mTable = table; } else { osc->mTable = NULL; @@ -227,7 +235,11 @@ JASBasicBank* JASBNKParser::Ver0::createBasicBank(void const* stream, JKRHeap* h int size = endPtr - points; JASOscillator::Point* table = JKR_NEW_ARRAY_ARGS(JASOscillator::Point, size, heap, 0); JUT_ASSERT(409, table != NULL); +#if TARGET_ANDROID + JASCalc::_bcopy(points, table, size * sizeof(JASOscillator::Point)); +#else JASCalc::bcopy(points, table, size * sizeof(JASOscillator::Point)); +#endif osc->rel_table = table; } else { osc->rel_table = NULL; diff --git a/libs/JSystem/src/JAudio2/JASBasicBank.cpp b/libs/JSystem/src/JAudio2/JASBasicBank.cpp index 936c7829e8..9b3c032bbb 100644 --- a/libs/JSystem/src/JAudio2/JASBasicBank.cpp +++ b/libs/JSystem/src/JAudio2/JASBasicBank.cpp @@ -13,7 +13,11 @@ void JASBasicBank::newInstTable(u8 num, JKRHeap* heap) { JUT_ASSERT(31, num <= JASBank::PRG_OSC); mInstNumMax = num; mInstTable = JKR_NEW_ARRAY_ARGS(JASInst*, mInstNumMax, heap, 0); +#if TARGET_ANDROID + JASCalc::_bzero(mInstTable, mInstNumMax * 4); +#else JASCalc::bzero(mInstTable, mInstNumMax * 4); +#endif } } diff --git a/libs/JSystem/src/JAudio2/JASBasicInst.cpp b/libs/JSystem/src/JAudio2/JASBasicInst.cpp index 4993e0c744..fba53d010f 100644 --- a/libs/JSystem/src/JAudio2/JASBasicInst.cpp +++ b/libs/JSystem/src/JAudio2/JASBasicInst.cpp @@ -13,7 +13,11 @@ JASBasicInst::JASBasicInst() { mPitch = 1.0; mKeymapCount = 0; mKeymap = NULL; +#if TARGET_ANDROID + JASCalc::_bzero(field_0xc, sizeof(field_0xc)); +#else JASCalc::bzero(field_0xc, sizeof(field_0xc)); +#endif } JASBasicInst::~JASBasicInst() { diff --git a/libs/JSystem/src/JAudio2/JASCalc.cpp b/libs/JSystem/src/JAudio2/JASCalc.cpp index 906fee2c3d..69369192fe 100644 --- a/libs/JSystem/src/JAudio2/JASCalc.cpp +++ b/libs/JSystem/src/JAudio2/JASCalc.cpp @@ -33,7 +33,11 @@ void JASCalc::bcopyfast(const void* src, void* dest, u32 size) { } } +#if TARGET_ANDROID +void JASCalc::_bcopy(const void* src, void* dest, u32 size) { +#else void JASCalc::bcopy(const void* src, void* dest, u32 size) { +#endif u32* usrc; u32* udest; @@ -90,7 +94,11 @@ void JASCalc::bzerofast(void* dest, u32 size) { } } +#if TARGET_ANDROID +void JASCalc::_bzero(void* dest, u32 size) { +#else void JASCalc::bzero(void* dest, u32 size) { +#endif u32* udest; u8* bdest = (u8*)dest; if ((size & 0x1f) == 0 && (reinterpret_cast(dest) & 0x1f) == 0) { diff --git a/libs/JSystem/src/JAudio2/JASChannel.cpp b/libs/JSystem/src/JAudio2/JASChannel.cpp index 61fe80a098..758167cc5c 100644 --- a/libs/JSystem/src/JAudio2/JASChannel.cpp +++ b/libs/JSystem/src/JAudio2/JASChannel.cpp @@ -1,6 +1,9 @@ #include "JSystem/JSystem.h" // IWYU pragma: keep #include "JSystem/JAudio2/JASChannel.h" +#if TARGET_PC +#include "dusk/audio/DuskDsp.hpp" +#endif #include "JSystem/JAudio2/JASAiCtrl.h" #include "JSystem/JAudio2/JASCalc.h" #include "JSystem/JAudio2/JASDriverIF.h" @@ -170,7 +173,12 @@ void JASChannel::updateEffectorParam(JASDsp::TChannel* i_channel, u16* i_mixerVo f32 pan = 0.5f; f32 dolby = 0.0f; - switch (JASDriver::getOutputMode()) { +#if TARGET_PC + u32 effectiveOutputMode = dusk::audio::EnableHrtf ? JAS_OUTPUT_SURROUND : JASDriver::getOutputMode(); +#else + u32 effectiveOutputMode = JASDriver::getOutputMode(); +#endif + switch (effectiveOutputMode) { case JAS_OUTPUT_MONO: break; case JAS_OUTPUT_STEREO: diff --git a/libs/JSystem/src/JAudio2/JASDSPInterface.cpp b/libs/JSystem/src/JAudio2/JASDSPInterface.cpp index 49ca691011..14a723e27d 100644 --- a/libs/JSystem/src/JAudio2/JASDSPInterface.cpp +++ b/libs/JSystem/src/JAudio2/JASDSPInterface.cpp @@ -434,8 +434,14 @@ void JASDsp::initBuffer() { JUT_ASSERT(354, CH_BUF); FX_BUF = JKR_NEW_ARRAY_ARGS(FxBuf, 4, JASDram, 0x20); JUT_ASSERT(356, FX_BUF); +#if TARGET_ANDROID + JASCalc::_bzero(CH_BUF, sizeof(TChannel) * DSP_CHANNELS); + JASCalc::_bzero(FX_BUF, sizeof(FxBuf) * 4); +#else JASCalc::bzero(CH_BUF, sizeof(TChannel) * DSP_CHANNELS); JASCalc::bzero(FX_BUF, sizeof(FxBuf) * 4); +#endif + for (u8 i = 0; i < 4; i++) { setFXLine(i, NULL, NULL); } @@ -459,7 +465,11 @@ int JASDsp::setFXLine(u8 param_0, s16* buffer, JASDsp::FxlineConfig_* param_2) { if (buffer != NULL && param_2 != NULL) { u32 bufsize = param_2->field_0xc * 0xa0; puVar3->field_0x4 = buffer; +#if TARGET_ANDROID + JASCalc::_bzero(buffer, bufsize); +#else JASCalc::bzero(buffer, bufsize); +#endif JUT_ASSERT(420, ((u32)((uintptr_t)buffer) & 0x1f) == 0); JUT_ASSERT(421, (bufsize & 0x1f) == 0); DCFlushRange(buffer, bufsize); diff --git a/libs/JSystem/src/JAudio2/JASDrumSet.cpp b/libs/JSystem/src/JAudio2/JASDrumSet.cpp index 8c8736451d..399cc117ec 100644 --- a/libs/JSystem/src/JAudio2/JASDrumSet.cpp +++ b/libs/JSystem/src/JAudio2/JASDrumSet.cpp @@ -18,7 +18,11 @@ void JASDrumSet::newPercArray(u8 num, JKRHeap* heap) { JUT_ASSERT(39, num <= 128); mPercNumMax = num; mPercArray = JKR_NEW_ARRAY_ARGS(TPerc*, mPercNumMax, heap, 0); +#if TARGET_ANDROID + JASCalc::_bzero(mPercArray, mPercNumMax * sizeof(TPerc*)); +#else JASCalc::bzero(mPercArray, mPercNumMax * sizeof(TPerc*)); +#endif } } diff --git a/libs/JSystem/src/JAudio2/JASHeapCtrl.cpp b/libs/JSystem/src/JAudio2/JASHeapCtrl.cpp index 45568cc29a..e3722e3726 100644 --- a/libs/JSystem/src/JAudio2/JASHeapCtrl.cpp +++ b/libs/JSystem/src/JAudio2/JASHeapCtrl.cpp @@ -302,7 +302,6 @@ void JASKernel::setupRootHeap(JKRSolidHeap* heap, u32 size) { JKRHEAP_NAME(sSystemHeap, "JASKernel::sSystemHeap"); JUT_ASSERT(787, sSystemHeap); sCommandHeap = JKR_NEW_ARGS (heap, 0) JASMemChunkPool<1024, JASThreadingModel::ObjectLevelLockable>; - JKRHEAP_NAME(sSystemHeap, "JASKernel::sCommandHeap"); JUT_ASSERT(790, sCommandHeap); JASDram = heap; } diff --git a/libs/JSystem/src/JAudio2/JASReport.cpp b/libs/JSystem/src/JAudio2/JASReport.cpp index c64c8e32a2..1911d9e617 100644 --- a/libs/JSystem/src/JAudio2/JASReport.cpp +++ b/libs/JSystem/src/JAudio2/JASReport.cpp @@ -53,7 +53,11 @@ int JASReportCopyBuffer(char *param_1,int lines) { r30 = sLineMax - 1; } src = &sBuffer[r30 * 64]; +#if TARGET_ANDROID + JASCalc::_bcopy(src, dest, 64); +#else JASCalc::bcopy(src, dest, 64); +#endif r30--; dest += 64; } diff --git a/libs/JSystem/src/JAudio2/JASTaskThread.cpp b/libs/JSystem/src/JAudio2/JASTaskThread.cpp index d5dfd089ea..6f7222e8d1 100644 --- a/libs/JSystem/src/JAudio2/JASTaskThread.cpp +++ b/libs/JSystem/src/JAudio2/JASTaskThread.cpp @@ -33,7 +33,11 @@ void* JASTaskThread::allocCallStack(JASThreadCallback callback, const void* msg, } callStack->msgType_ = 1; +#if TARGET_ANDROID + JASCalc::_bcopy(msg, callStack->msg.buffer, msgSize); +#else JASCalc::bcopy(msg, callStack->msg.buffer, msgSize); +#endif callStack->callback_ = callback; return callStack; } diff --git a/libs/JSystem/src/JAudio2/JAUSectionHeap.cpp b/libs/JSystem/src/JAudio2/JAUSectionHeap.cpp index e1f45de1ae..cc94097712 100644 --- a/libs/JSystem/src/JAudio2/JAUSectionHeap.cpp +++ b/libs/JSystem/src/JAudio2/JAUSectionHeap.cpp @@ -442,6 +442,7 @@ static JAUSectionHeap* JAUNewSectionHeap(JKRSolidHeap* heap, bool param_1) { JAUSectionHeap* JAUNewSectionHeap(bool param_0) { s32 freeSize = JASDram->getFreeSize(); JKRSolidHeap* sectionHeap = JKRCreateSolidHeap(freeSize, JASDram, true); + JKRHEAP_NAME(sectionHeap, "sectionHeap"); JUT_ASSERT(821, sectionHeap); return JAUNewSectionHeap(sectionHeap, param_0); } diff --git a/libs/JSystem/src/JFramework/JFWDisplay.cpp b/libs/JSystem/src/JFramework/JFWDisplay.cpp index ce4c870064..b8054e2130 100644 --- a/libs/JSystem/src/JFramework/JFWDisplay.cpp +++ b/libs/JSystem/src/JFramework/JFWDisplay.cpp @@ -18,6 +18,7 @@ #include "dusk/logging.h" #include "dusk/settings.h" #include "dusk/time.h" +#include "f_op/f_op_overlap_mng.h" #include "SDL3/SDL_timer.h" #include "tracy/Tracy.hpp" @@ -205,14 +206,6 @@ void JFWDisplay::preGX() { } } -#ifdef TARGET_PC -static s32 s_faderSimSteps = -1; - -void JFWDisplay::setFaderSimSteps(u32 steps) { - s_faderSimSteps = static_cast(steps); -} -#endif - void JFWDisplay::endGX() { s32 bufferNum = JUTXfb::getManager()->getBufferNum(); u16 width = JUTVideo::getManager()->getFbWidth(); @@ -224,17 +217,10 @@ void JFWDisplay::endGX() { if (mFader != NULL) { ortho.setPort(); #ifdef TARGET_PC - u32 advance_count = 1; - if (dusk::getSettings().game.enableFrameInterpolation && s_faderSimSteps >= 0) { - advance_count = static_cast(s_faderSimSteps); - s_faderSimSteps = -1; - } else { - s_faderSimSteps = -1; - } - for (u32 i = 0; i < advance_count; i++) { + if (dusk::frame_interp::get_ui_tick_pending()) { mFader->advance(); } - if (mFader->getStatus() != 1) { + if (mFader->getStatus() != JUTFader::Wait) { mFader->draw(); } #else @@ -383,22 +369,30 @@ constexpr auto FRAME_PERIOD = std::chrono::duration_cast(1001.0 / 30000.0)); constexpr auto RETRACE_PERIOD = FRAME_PERIOD / 2; -static void waitPrecise(Limiter& limiter, Uint64 targetNs) { - const auto sleepTime = limiter.SleepTime(std::chrono::nanoseconds(targetNs)); +static void waitPrecise(Limiter& limiter, Limiter::duration_t targetNs) { + const auto sleepTime = limiter.SleepTime(targetNs); dusk::frameUsagePct = - 100.0f * (1.0f - static_cast(sleepTime.count()) / static_cast(targetNs)); - limiter.Sleep(std::chrono::nanoseconds(targetNs)); + 100.0f * (1.0f - static_cast(sleepTime) / static_cast(targetNs)); + limiter.Sleep(targetNs); } #endif static void waitForTick(u32 p1, u16 p2) { #if TARGET_PC - if (dusk::getSettings().game.enableFrameInterpolation) { + if (dusk::getSettings().game.enableFrameInterpolation && !dusk::getTransientSettings().skipFrameRateLimit) { + dusk::frameUsagePct = 0.f; return; } if (dusk::getTransientSettings().skipFrameRateLimit) { p1 = OS_TIMER_CLOCK / 120; } + + #if TARGET_PC + if (fopOvlpM_IsPeek() && dusk::getTransientSettings().stateShareLoadActive) { + return; + } + #endif + ZoneScopedC(tracy::Color::DimGray); #endif diff --git a/libs/JSystem/src/JKernel/JKRExpHeap.cpp b/libs/JSystem/src/JKernel/JKRExpHeap.cpp index 4a6712cb65..dc88090a47 100644 --- a/libs/JSystem/src/JKernel/JKRExpHeap.cpp +++ b/libs/JSystem/src/JKernel/JKRExpHeap.cpp @@ -222,16 +222,11 @@ void* JKRExpHeap::do_alloc(u32 size, int alignment) { OSReport_Error("Free block list as follows:\n"); OSReport_Error("Start | End | Size \n"); - int i = 0; for (const CMemBlock* block = mHeadFreeList; block; block = block->mNext) { if (block->mMagic) { // Allocated, ignore. continue; } - if (i++ > 10) { - OSReport_Error("\n"); - break; - } auto blockStart = (uintptr_t)block - (uintptr_t)mStart; auto blockEnd = (uintptr_t)block + block->size - (uintptr_t)mStart; @@ -239,6 +234,14 @@ void* JKRExpHeap::do_alloc(u32 size, int alignment) { OSReport_Error("%08X | %08X | %08X\n", (u32) blockStart, (u32) blockEnd, (u32) blockSize); } + OSReport_Error("Child heaps as follows:\n"); + OSReport_Error("Start | End | Name \n"); + + const JSUTree& tree = getHeapTree(); + for (JSUTreeIterator iter(tree.getFirstChild()); iter != tree.getEndChild(); ++iter) { + OSReport_Error("%08X | %08X | %s\n", iter->getStartAddr(), iter->getEndAddr(), iter->getName()); + } + CRASH("Aborting due to allocation failure!"); } #else diff --git a/libs/JSystem/src/JParticle/JPABaseShape.cpp b/libs/JSystem/src/JParticle/JPABaseShape.cpp index 178606a687..ff346984cd 100644 --- a/libs/JSystem/src/JParticle/JPABaseShape.cpp +++ b/libs/JSystem/src/JParticle/JPABaseShape.cpp @@ -9,6 +9,9 @@ #include #include +#if TARGET_PC +#include "dusk/frame_interpolation.h" +#endif #include "tracy/Tracy.hpp" void JPASetPointSize(JPAEmitterWorkData* work) { @@ -418,50 +421,95 @@ static projectionFunc p_prj[3] = { loadPrjAnm, }; -void JPADrawBillboard(JPAEmitterWorkData* work, JPABaseParticle* param_1) { - if (param_1->checkStatus(JPAPtclStts_Invisible)) { +#if TARGET_PC +void JPAInterpBillboard(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { + Mtx ptclPosMtx; + MTXTrans(ptclPosMtx, ptcl->mPosition.x, ptcl->mPosition.y, ptcl->mPosition.z); + dusk::frame_interp::record_final_mtx(ptclPosMtx, ptcl); +} + +void JPAInterpRotBillboard(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { + Mtx ptclPosMtx; + f32 sinRot = JMASSin(ptcl->mRotateAngle); + f32 cosRot = JMASCos(ptcl->mRotateAngle); + MTXTrans(ptclPosMtx, ptcl->mPosition.x, ptcl->mPosition.y, ptcl->mPosition.z); + ptclPosMtx[0][0] = cosRot; + ptclPosMtx[0][1] = -sinRot; + ptclPosMtx[1][0] = sinRot; + ptclPosMtx[1][1] = cosRot; + dusk::frame_interp::record_final_mtx(ptclPosMtx, ptcl); +} +#endif + +void JPADrawBillboard(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { + if (ptcl->checkStatus(JPAPtclStts_Invisible)) { return; } - JGeometry::TVec3 local_48; - MTXMultVec(work->mPosCamMtx, ¶m_1->mPosition, &local_48); - Mtx local_38; - local_38[0][0] = work->mGlobalPtclScl.x * param_1->mParticleScaleX; - local_38[0][3] = local_48.x; - local_38[1][1] = work->mGlobalPtclScl.y * param_1->mParticleScaleY; - local_38[1][3] = local_48.y; - local_38[2][2] = 1.0f; - local_38[2][3] = local_48.z; - local_38[0][1] = local_38[0][2] = local_38[1][0] = local_38[1][2] = local_38[2][0] = local_38[2][1] = 0.0f; - GXLoadPosMtxImm(local_38, 0); - p_prj[work->mPrjType](work, local_38); + JGeometry::TVec3 pos; +#if TARGET_PC + Mtx ptclPosMtx; + if (dusk::frame_interp::lookup_replacement(ptcl, ptclPosMtx)) { + pos.set(ptclPosMtx[0][3], ptclPosMtx[1][3], ptclPosMtx[2][3]); + MTXMultVec(work->mPosCamMtx, &pos, &pos); + } else +#endif + { + MTXMultVec(work->mPosCamMtx, &ptcl->mPosition, &pos); + } + Mtx posMtx; + posMtx[0][0] = work->mGlobalPtclScl.x * ptcl->mParticleScaleX; + posMtx[0][3] = pos.x; + posMtx[1][1] = work->mGlobalPtclScl.y * ptcl->mParticleScaleY; + posMtx[1][3] = pos.y; + posMtx[2][2] = 1.0f; + posMtx[2][3] = pos.z; + posMtx[0][1] = posMtx[0][2] = posMtx[1][0] = posMtx[1][2] = posMtx[2][0] = posMtx[2][1] = 0.0f; + GXLoadPosMtxImm(posMtx, GX_PNMTX0); + p_prj[work->mPrjType](work, posMtx); GXCallDisplayList(jpa_dl, sizeof(jpa_dl)); } -void JPADrawRotBillboard(JPAEmitterWorkData* work, JPABaseParticle* param_1) { - if (param_1->checkStatus(JPAPtclStts_Invisible)) { +void JPADrawRotBillboard(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { + if (ptcl->checkStatus(JPAPtclStts_Invisible)) { return; } - JGeometry::TVec3 local_48; - MTXMultVec(work->mPosCamMtx, ¶m_1->mPosition, &local_48); - f32 sinRot = JMASSin(param_1->mRotateAngle); - f32 cosRot = JMASCos(param_1->mRotateAngle); - f32 particleX = work->mGlobalPtclScl.x * param_1->mParticleScaleX; - f32 particleY = work->mGlobalPtclScl.y * param_1->mParticleScaleY; + if (work->mpRes->getUsrIdx() == 0x89d7) { + int a = 0; + } - Mtx local_38; - local_38[0][0] = cosRot * particleX; - local_38[0][1] = -sinRot * particleY; - local_38[0][3] = local_48.x; - local_38[1][0] = sinRot * particleX; - local_38[1][1] = cosRot * particleY; - local_38[1][3] = local_48.y; - local_38[2][2] = 1.0f; - local_38[2][3] = local_48.z; - local_38[0][2] = local_38[1][2] = local_38[2][0] = local_38[2][1] = 0.0f; - GXLoadPosMtxImm(local_38, 0); - p_prj[work->mPrjType](work, local_38); + JGeometry::TVec3 pos; + f32 sinRot, cosRot; +#if TARGET_PC + Mtx ptclPosMtx; + MTXTrans(ptclPosMtx, ptcl->mPosition.x, ptcl->mPosition.y, ptcl->mPosition.z); + if (dusk::frame_interp::lookup_replacement(ptcl, ptclPosMtx)) { + pos.set(ptclPosMtx[0][3], ptclPosMtx[1][3], ptclPosMtx[2][3]); + sinRot = ptclPosMtx[1][0]; + cosRot = ptclPosMtx[0][0]; + MTXMultVec(work->mPosCamMtx, &pos, &pos); + } else +#endif + { + MTXMultVec(work->mPosCamMtx, &ptcl->mPosition, &pos); + sinRot = JMASSin(ptcl->mRotateAngle); + cosRot = JMASCos(ptcl->mRotateAngle); + } + f32 particleX = work->mGlobalPtclScl.x * ptcl->mParticleScaleX; + f32 particleY = work->mGlobalPtclScl.y * ptcl->mParticleScaleY; + Mtx posMtx; + posMtx[0][0] = cosRot * particleX; + posMtx[0][1] = -sinRot * particleY; + posMtx[0][3] = pos.x; + posMtx[1][0] = sinRot * particleX; + posMtx[1][1] = cosRot * particleY; + posMtx[1][3] = pos.y; + posMtx[2][2] = 1.0f; + posMtx[2][3] = pos.z; + posMtx[0][2] = posMtx[1][2] = posMtx[2][0] = posMtx[2][1] = 0.0f; + GXLoadPosMtxImm(posMtx, GX_PNMTX0); + p_prj[work->mPrjType](work, posMtx); GXCallDisplayList(jpa_dl, sizeof(jpa_dl)); } @@ -484,7 +532,7 @@ void JPADrawYBillboard(JPAEmitterWorkData* work, JPABaseParticle* param_1) { local_38[2][2] = work->mYBBCamMtx[2][2]; local_38[2][3] = local_48.z; local_38[0][1] = local_38[0][2] = local_38[1][0] = local_38[2][0] = 0.0f; - GXLoadPosMtxImm(local_38, 0); + GXLoadPosMtxImm(local_38, GX_PNMTX0); p_prj[work->mPrjType](work, local_38); GXCallDisplayList(jpa_dl, sizeof(jpa_dl)); } @@ -517,7 +565,7 @@ void JPADrawRotYBillboard(JPAEmitterWorkData* work, JPABaseParticle* param_1) { local_38[2][1] = local_94 * fVar1; local_38[2][2] = local_90; local_38[2][3] = local_48.z; - GXLoadPosMtxImm(local_38, 0); + GXLoadPosMtxImm(local_38, GX_PNMTX0); p_prj[work->mPrjType](work, local_38); GXCallDisplayList(jpa_dl, sizeof(jpa_dl)); } @@ -681,103 +729,197 @@ static u8* p_dl[2] = { jpa_dl_x, }; -void JPADrawDirection(JPAEmitterWorkData* param_0, JPABaseParticle* param_1) { - if (param_1->checkStatus(JPAPtclStts_Invisible)) { +#if TARGET_PC +void JPAInterpDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { + JGeometry::TVec3 axisY; + JGeometry::TVec3 axisZ; + p_direction[work->mDirType](work, ptcl, &axisY); + + if (axisY.isZero()) { return; } - ZoneScoped; + axisY.normalize(); + axisZ.cross(ptcl->mBaseAxis, axisY); - JGeometry::TVec3 local_6c; - JGeometry::TVec3 local_78; - p_direction[param_0->mDirType](param_0, param_1, &local_6c); - - if (local_6c.isZero()) { + if (axisZ.isZero()) { return; } - local_6c.normalize(); - local_78.cross(param_1->mBaseAxis, local_6c); - - if (local_78.isZero()) { - return; - } - - local_78.normalize(); - param_1->mBaseAxis.cross(local_6c, local_78); - param_1->mBaseAxis.normalize(); - Mtx local_60; - f32 fVar1 = param_0->mGlobalPtclScl.x * param_1->mParticleScaleX; - f32 fVar2 = param_0->mGlobalPtclScl.y * param_1->mParticleScaleY; - local_60[0][0] = param_1->mBaseAxis.x; - local_60[0][1] = local_6c.x; - local_60[0][2] = local_78.x; - local_60[0][3] = param_1->mPosition.x; - local_60[1][0] = param_1->mBaseAxis.y; - local_60[1][1] = local_6c.y; - local_60[1][2] = local_78.y; - local_60[1][3] = param_1->mPosition.y; - local_60[2][0] = param_1->mBaseAxis.z; - local_60[2][1] = local_6c.z; - local_60[2][2] = local_78.z; - local_60[2][3] = param_1->mPosition.z; - p_plane[param_0->mPlaneType](local_60, fVar1, fVar2); - MTXConcat(param_0->mPosCamMtx, local_60, local_60); - GXLoadPosMtxImm(local_60, 0); - p_prj[param_0->mPrjType](param_0, local_60); - GXCallDisplayList(p_dl[param_0->mDLType], sizeof(jpa_dl)); + axisZ.normalize(); + ptcl->mBaseAxis.cross(axisY, axisZ); + ptcl->mBaseAxis.normalize(); + Mtx posMtx; + f32 scaleX = work->mGlobalPtclScl.x * ptcl->mParticleScaleX; + f32 scaleY = work->mGlobalPtclScl.y * ptcl->mParticleScaleY; + posMtx[0][0] = ptcl->mBaseAxis.x; + posMtx[0][1] = axisY.x; + posMtx[0][2] = axisZ.x; + posMtx[0][3] = ptcl->mPosition.x; + posMtx[1][0] = ptcl->mBaseAxis.y; + posMtx[1][1] = axisY.y; + posMtx[1][2] = axisZ.y; + posMtx[1][3] = ptcl->mPosition.y; + posMtx[2][0] = ptcl->mBaseAxis.z; + posMtx[2][1] = axisY.z; + posMtx[2][2] = axisZ.z; + posMtx[2][3] = ptcl->mPosition.z; + p_plane[work->mPlaneType](posMtx, scaleX, scaleY); + dusk::frame_interp::record_final_mtx(posMtx, ptcl); } -void JPADrawRotDirection(JPAEmitterWorkData* param_0, JPABaseParticle* param_1) { - if (param_1->checkStatus(JPAPtclStts_Invisible)) { +void JPAInterpRotDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { + f32 sinRot = JMASSin(ptcl->mRotateAngle); + f32 cosRot = JMASCos(ptcl->mRotateAngle); + JGeometry::TVec3 axisY; + JGeometry::TVec3 axisZ; + p_direction[work->mDirType](work, ptcl, &axisY); + + if (axisY.isZero()) { + return; + } + + axisY.normalize(); + axisZ.cross(ptcl->mBaseAxis, axisY); + + if (axisZ.isZero()) { + return; + } + + axisZ.normalize(); + ptcl->mBaseAxis.cross(axisY, axisZ); + ptcl->mBaseAxis.normalize(); + f32 scaleX = work->mGlobalPtclScl.x * ptcl->mParticleScaleX; + f32 scaleY = work->mGlobalPtclScl.y * ptcl->mParticleScaleY; + Mtx mtx1; + Mtx mtx2; + p_rot[work->mRotType](sinRot, cosRot, mtx1); + p_plane[work->mPlaneType](mtx1, scaleX, scaleY); + mtx2[0][0] = ptcl->mBaseAxis.x; + mtx2[0][1] = axisY.x; + mtx2[0][2] = axisZ.x; + mtx2[0][3] = ptcl->mPosition.x; + mtx2[1][0] = ptcl->mBaseAxis.y; + mtx2[1][1] = axisY.y; + mtx2[1][2] = axisZ.y; + mtx2[1][3] = ptcl->mPosition.y; + mtx2[2][0] = ptcl->mBaseAxis.z; + mtx2[2][1] = axisY.z; + mtx2[2][2] = axisZ.z; + mtx2[2][3] = ptcl->mPosition.z; + MTXConcat(mtx2, mtx1, mtx1); + dusk::frame_interp::record_final_mtx(mtx1, ptcl); +} +#endif + +void JPADrawDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { + if (ptcl->checkStatus(JPAPtclStts_Invisible)) { return; } ZoneScoped; - f32 sinRot = JMASSin(param_1->mRotateAngle); - f32 cosRot = JMASCos(param_1->mRotateAngle); - JGeometry::TVec3 local_6c; - JGeometry::TVec3 local_78; - p_direction[param_0->mDirType](param_0, param_1, &local_6c); + Mtx posMtx; +#if TARGET_PC + if (!dusk::frame_interp::lookup_replacement(ptcl, posMtx)) +#endif + { + JGeometry::TVec3 axisY; + JGeometry::TVec3 axisZ; + p_direction[work->mDirType](work, ptcl, &axisY); - if (local_6c.isZero()) { + if (axisY.isZero()) { + return; + } + + axisY.normalize(); + axisZ.cross(ptcl->mBaseAxis, axisY); + + if (axisZ.isZero()) { + return; + } + + axisZ.normalize(); + ptcl->mBaseAxis.cross(axisY, axisZ); + ptcl->mBaseAxis.normalize(); + f32 scaleX = work->mGlobalPtclScl.x * ptcl->mParticleScaleX; + f32 scaleY = work->mGlobalPtclScl.y * ptcl->mParticleScaleY; + posMtx[0][0] = ptcl->mBaseAxis.x; + posMtx[0][1] = axisY.x; + posMtx[0][2] = axisZ.x; + posMtx[0][3] = ptcl->mPosition.x; + posMtx[1][0] = ptcl->mBaseAxis.y; + posMtx[1][1] = axisY.y; + posMtx[1][2] = axisZ.y; + posMtx[1][3] = ptcl->mPosition.y; + posMtx[2][0] = ptcl->mBaseAxis.z; + posMtx[2][1] = axisY.z; + posMtx[2][2] = axisZ.z; + posMtx[2][3] = ptcl->mPosition.z; + p_plane[work->mPlaneType](posMtx, scaleX, scaleY); + } + + MTXConcat(work->mPosCamMtx, posMtx, posMtx); + GXLoadPosMtxImm(posMtx, GX_PNMTX0); + p_prj[work->mPrjType](work, posMtx); + GXCallDisplayList(p_dl[work->mDLType], sizeof(jpa_dl)); +} + +void JPADrawRotDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { + if (ptcl->checkStatus(JPAPtclStts_Invisible)) { return; } - local_6c.normalize(); - local_78.cross(param_1->mBaseAxis, local_6c); + ZoneScoped; - if (local_78.isZero()) { - return; + Mtx mtx1; + Mtx mtx2; +#if TARGET_PC + if (!dusk::frame_interp::lookup_replacement(ptcl, mtx1)) +#endif + { + f32 sinRot = JMASSin(ptcl->mRotateAngle); + f32 cosRot = JMASCos(ptcl->mRotateAngle); + JGeometry::TVec3 axisY; + JGeometry::TVec3 axisZ; + p_direction[work->mDirType](work, ptcl, &axisY); + + if (axisY.isZero()) { + return; + } + + axisY.normalize(); + axisZ.cross(ptcl->mBaseAxis, axisY); + + if (axisZ.isZero()) { + return; + } + + axisZ.normalize(); + ptcl->mBaseAxis.cross(axisY, axisZ); + ptcl->mBaseAxis.normalize(); + f32 scaleX = work->mGlobalPtclScl.x * ptcl->mParticleScaleX; + f32 scaleY = work->mGlobalPtclScl.y * ptcl->mParticleScaleY; + p_rot[work->mRotType](sinRot, cosRot, mtx1); + p_plane[work->mPlaneType](mtx1, scaleX, scaleY); + mtx2[0][0] = ptcl->mBaseAxis.x; + mtx2[0][1] = axisY.x; + mtx2[0][2] = axisZ.x; + mtx2[0][3] = ptcl->mPosition.x; + mtx2[1][0] = ptcl->mBaseAxis.y; + mtx2[1][1] = axisY.y; + mtx2[1][2] = axisZ.y; + mtx2[1][3] = ptcl->mPosition.y; + mtx2[2][0] = ptcl->mBaseAxis.z; + mtx2[2][1] = axisY.z; + mtx2[2][2] = axisZ.z; + mtx2[2][3] = ptcl->mPosition.z; + MTXConcat(mtx2, mtx1, mtx1); } - - local_78.normalize(); - param_1->mBaseAxis.cross(local_6c, local_78); - param_1->mBaseAxis.normalize(); - f32 particleX = param_0->mGlobalPtclScl.x * param_1->mParticleScaleX; - f32 particleY = param_0->mGlobalPtclScl.y * param_1->mParticleScaleY; - Mtx auStack_80; - Mtx local_60; - p_rot[param_0->mRotType](sinRot, cosRot, auStack_80); - p_plane[param_0->mPlaneType](auStack_80, particleX, particleY); - local_60[0][0] = param_1->mBaseAxis.x; - local_60[0][1] = local_6c.x; - local_60[0][2] = local_78.x; - local_60[0][3] = param_1->mPosition.x; - local_60[1][0] = param_1->mBaseAxis.y; - local_60[1][1] = local_6c.y; - local_60[1][2] = local_78.y; - local_60[1][3] = param_1->mPosition.y; - local_60[2][0] = param_1->mBaseAxis.z; - local_60[2][1] = local_6c.z; - local_60[2][2] = local_78.z; - local_60[2][3] = param_1->mPosition.z; - MTXConcat(local_60, auStack_80, auStack_80); - MTXConcat(param_0->mPosCamMtx, auStack_80, local_60); - GXLoadPosMtxImm(local_60, 0); - p_prj[param_0->mPrjType](param_0, local_60); - GXCallDisplayList(p_dl[param_0->mDLType], sizeof(jpa_dl)); + MTXConcat(work->mPosCamMtx, mtx1, mtx2); + GXLoadPosMtxImm(mtx2, GX_PNMTX0); + p_prj[work->mPrjType](work, mtx2); + GXCallDisplayList(p_dl[work->mDLType], sizeof(jpa_dl)); } void JPADrawDBillboard(JPAEmitterWorkData* param_0, JPABaseParticle* param_1) { diff --git a/libs/JSystem/src/JParticle/JPAParticle.cpp b/libs/JSystem/src/JParticle/JPAParticle.cpp index a9294e0b4e..d3490ab294 100644 --- a/libs/JSystem/src/JParticle/JPAParticle.cpp +++ b/libs/JSystem/src/JParticle/JPAParticle.cpp @@ -204,6 +204,28 @@ void JPABaseParticle::init_c(JPAEmitterWorkData* work, JPABaseParticle* parent) mTexAnmIdx = 0; } +#if TARGET_PC +void JPABaseParticle::interp(JPAEmitterWorkData* work, void const* drawFunc) { + static bool enable = false; + if (!enable) + return; + + // don't interpolate the first frame + if (mAge == 0) + return; + + if (drawFunc == JPADrawBillboard) { + JPAInterpBillboard(work, this); + } else if (drawFunc == JPADrawRotBillboard) { + JPAInterpRotBillboard(work, this); + } else if (drawFunc == JPADrawDirection) { + JPAInterpDirection(work, this); + } else if (drawFunc == JPADrawRotDirection) { + JPAInterpRotDirection(work, this); + } +} +#endif + bool JPABaseParticle::calc_p(JPAEmitterWorkData* work) { if (++mAge >= mLifeTime) { return true; @@ -247,6 +269,17 @@ bool JPABaseParticle::calc_p(JPAEmitterWorkData* work) { mOffsetPosition.y + mLocalPosition.y * work->mPublicScale.y, mOffsetPosition.z + mLocalPosition.z * work->mPublicScale.z); +#if TARGET_PC + JPABaseShape* pBsp = work->mpRes->getBsp(); + work->mGlobalPtclScl.x = work->mpEmtr->mGlobalPScl.x * pBsp->getBaseSizeX(); + work->mGlobalPtclScl.y = work->mpEmtr->mGlobalPScl.y * pBsp->getBaseSizeY(); + work->mDirType = pBsp->getDirType(); + work->mRotType = pBsp->getRotType(); + work->mDLType = pBsp->getType() == 4 || pBsp->getType() == 8; + work->mPlaneType = work->mDLType ? 2 : pBsp->getBasePlaneType(); + interp(work, (void const*)work->mpRes->mpDrawParticleFuncList[0]); +#endif + return false; } @@ -289,6 +322,23 @@ bool JPABaseParticle::calc_c(JPAEmitterWorkData* work) { mOffsetPosition.y + mLocalPosition.y * work->mPublicScale.y, mOffsetPosition.z + mLocalPosition.z * work->mPublicScale.z); +#if TARGET_PC + JPABaseShape* pBsp = work->mpRes->getBsp(); + JPAChildShape* pCsp = work->mpRes->getCsp(); + if (pCsp->isScaleInherited()) { + work->mGlobalPtclScl.x = work->mpEmtr->mGlobalPScl.x * pBsp->getBaseSizeX(); + work->mGlobalPtclScl.y = work->mpEmtr->mGlobalPScl.y * pBsp->getBaseSizeY(); + } else { + work->mGlobalPtclScl.x = work->mpEmtr->mGlobalPScl.x * pCsp->getScaleX(); + work->mGlobalPtclScl.y = work->mpEmtr->mGlobalPScl.y * pCsp->getScaleY(); + } + work->mDirType = pCsp->getDirType(); + work->mRotType = pCsp->getRotType(); + work->mDLType = pCsp->getType() == 4 || pCsp->getType() == 8; + work->mPlaneType = work->mDLType ? 2 : pCsp->getBasePlaneType(); + interp(work, (void const*)work->mpRes->mpDrawParticleChildFuncList[0]); +#endif + return false; } diff --git a/libs/JSystem/src/JStudio/JStudio/jstudio-object.cpp b/libs/JSystem/src/JStudio/JStudio/jstudio-object.cpp index 3b87a6e34a..ee99429ee2 100644 --- a/libs/JSystem/src/JStudio/JStudio/jstudio-object.cpp +++ b/libs/JSystem/src/JStudio/JStudio/jstudio-object.cpp @@ -2,7 +2,11 @@ #include "JSystem/JStudio/JStudio/jstudio-object.h" +#if TARGET_PC #include "dusk/audio.h" +#include "dusk/frame_interpolation.h" +#include "dusk/settings.h" +#endif namespace JStudio { namespace { @@ -650,10 +654,25 @@ value_or_fun: return; value: +#if TARGET_PC + if (dusk::getSettings().game.enableFrameInterpolation && u <= 5 && + (operation == data::UNK_0x2 || operation == data::UNK_0x3 || operation == data::UNK_0x12)) + { + dusk::frame_interp::request_presentation_sync(); + } +#endif adaptor->adaptor_setVariableValue(control, u, operation, param_2, param_3); return; value_n: +#if TARGET_PC + if (dusk::getSettings().game.enableFrameInterpolation && + (pN == TAdaptor_camera::sauVariableValue_3_POSITION_XYZ || pN == TAdaptor_camera::sauVariableValue_3_TARGET_POSITION_XYZ) && + (operation == data::UNK_0x2 || operation == data::UNK_0x3 || operation == data::UNK_0x12)) + { + dusk::frame_interp::request_presentation_sync(); + } +#endif adaptor->adaptor_setVariableValue_n(control, pN, u, operation, param_2, param_3); return; diff --git a/libs/JSystem/src/JStudio/JStudio_JStage/object-actor.cpp b/libs/JSystem/src/JStudio/JStudio_JStage/object-actor.cpp index dc0c5c56e8..0ee7d53fb7 100644 --- a/libs/JSystem/src/JStudio/JStudio_JStage/object-actor.cpp +++ b/libs/JSystem/src/JStudio/JStudio_JStage/object-actor.cpp @@ -314,10 +314,17 @@ void JStudio_JStage::TAdaptor_actor::getJSG_SRT_(JStudio::TControl const* pContr } void JStudio_JStage::TAdaptor_actor::TVVOutput_ANIMATION_FRAME_::operator()( - f32 param_1, JStudio::TAdaptor* adaptor) const { + f32 param_1, JStudio::TAdaptor* adaptor) const { +#if TARGET_PC + TAdaptor_actor* actor_adaptor = static_cast(adaptor); + JStage::TActor* actor = actor_adaptor->get_pJSG_(); + // field_0x8 is always hardcoded to either 305 or 309 + u32 idx = (field_0x8 == 305) ? actor_adaptor->field_0x130 : actor_adaptor->field_0x134; +#else JStage::TActor* actor = static_cast(adaptor)->get_pJSG_(); // not sure what this bit is u32 idx = *(u32*)(((uintptr_t)adaptor - 1) + field_0x8); +#endif u8 idx_lowBytes = idx; u8 idx_highBytes = idx >> 8; diff --git a/libs/JSystem/src/JUtility/JUTFader.cpp b/libs/JSystem/src/JUtility/JUTFader.cpp index f7fa963e63..e7d33454b7 100644 --- a/libs/JSystem/src/JUtility/JUTFader.cpp +++ b/libs/JSystem/src/JUtility/JUTFader.cpp @@ -8,53 +8,57 @@ #include "JSystem/JUtility/JUTFader.h" #include "JSystem/J2DGraph/J2DOrthoGraph.h" +#ifdef TARGET_PC +#include +#endif + JUTFader::JUTFader(int x, int y, int width, int height, JUtility::TColor pColor) : mColor(pColor), mBox(x, y, x + width, y + height) { - mStatus = 0; - field_0x8 = 0; - field_0xa = 0; - field_0x24 = 0; - mEStatus = UNKSTATUS_M1; + mStatus = None; + mDuration = 0; + mTimer = 0; + mNextStatus = 0; + mStatusTimer = -1; } void JUTFader::advance() { - if (0 <= mEStatus && mEStatus-- == 0) { - mStatus = field_0x24; + if (0 <= mStatusTimer && mStatusTimer-- == 0) { + mStatus = mNextStatus; } - if (mStatus == 1) { + if (mStatus == Wait) { return; } switch (mStatus) { - case 0: + case None: mColor.a = 0xFF; break; - case 2: + case FadeIn: #if AVOID_UB - if (field_0x8 == 0) { - mStatus = 1; + if (mDuration == 0) { + mStatus = Wait; break; } #endif - mColor.a = 0xFF - ((++field_0xa * 0xFF) / field_0x8); + mColor.a = 0xFF - ((++mTimer * 0xFF) / mDuration); - if (field_0xa >= field_0x8) { - mStatus = 1; + if (mTimer >= mDuration) { + mStatus = Wait; } break; - case 3: + case FadeOut: #if AVOID_UB - if (field_0x8 == 0) { - mStatus = 0; + if (mDuration == 0) { + mStatus = None; break; } #endif - mColor.a = ((++field_0xa * 0xFF) / field_0x8); + mColor.a = ((++mTimer * 0xFF) / mDuration); - if (field_0xa >= field_0x8) { - mStatus = 0; + if (mTimer >= mDuration) { + mStatus = None; } break; @@ -63,67 +67,77 @@ void JUTFader::advance() { void JUTFader::control() { advance(); -#ifndef TARGET_PC - // FRAME INTERP NOTE: Draw is called by JFWDisplay when interpolation is active draw(); -#endif } void JUTFader::draw() { if (mColor.a != 0) { +#ifdef TARGET_PC + if (dusk::frame_interp::is_enabled() && mDuration != 0) { + const auto step = dusk::frame_interp::get_interpolation_step(); + const auto progress = static_cast(mTimer) / static_cast(mDuration); + const auto timer = mTimer - 1 + step + progress; + auto alpha = timer / mDuration; + if (mStatus == FadeIn) { + alpha = 1.0f - alpha; + } + alpha = std::clamp(alpha, 0.0f, 1.0f); + mColor.a = static_cast(alpha * 255.0f); + } +#endif J2DOrthoGraph orthograph; orthograph.setColor(mColor); orthograph.fillBox(mBox); } } -bool JUTFader::startFadeIn(int param_0) { +bool JUTFader::startFadeIn(int duration) { bool statusCheck = mStatus == 0; if (statusCheck) { - mStatus = 2; - field_0xa = 0; - field_0x8 = param_0; + mStatus = FadeIn; + mTimer = 0; + mDuration = duration; } return statusCheck; } -bool JUTFader::startFadeOut(int param_0) { +bool JUTFader::startFadeOut(int duration) { bool statusCheck = mStatus == 1; if (statusCheck) { - mStatus = 3; - field_0xa = 0; - field_0x8 = param_0; + mStatus = FadeOut; + mTimer = 0; + mDuration = duration; } return statusCheck; } -void JUTFader::setStatus(JUTFader::EStatus i_status, int param_1) { +void JUTFader::setStatus(JUTFader::EStatus i_status, int timer) { switch (i_status) { - case 0: - if (param_1 != 0) { - field_0x24 = 0; - mEStatus = param_1; + case None: + if (timer != 0) { + mNextStatus = None; + mStatusTimer = timer; break; } - mStatus = 0; - field_0x24 = 0; - mEStatus = 0; + mStatus = None; + mNextStatus = None; + mStatusTimer = 0; break; - case 1: - if (param_1 != 0) { - field_0x24 = 1; - mEStatus = param_1; + case Wait: + if (timer != 0) { + mNextStatus = Wait; + mStatusTimer = timer; break; } - mStatus = 1; - field_0x24 = 1; - mEStatus = 0; + mStatus = Wait; + mNextStatus = Wait; + mStatusTimer = 0; break; } } diff --git a/libs/JSystem/src/JUtility/JUTGamePad.cpp b/libs/JSystem/src/JUtility/JUTGamePad.cpp index 091cc382d1..48ed5f130e 100644 --- a/libs/JSystem/src/JUtility/JUTGamePad.cpp +++ b/libs/JSystem/src/JUtility/JUTGamePad.cpp @@ -4,6 +4,10 @@ #include #include "os_report.h" +#if TARGET_PC +#include "dusk/action_bindings.h" +#endif + u32 JUTGamePad::CRumble::sChannelMask[4] = { PAD_CHAN0_BIT, PAD_CHAN1_BIT, @@ -64,6 +68,9 @@ BOOL JUTGamePad::init() { void JUTGamePad::clear() { mButtonReset.mReset = false; field_0xa8 = 1; +#if TARGET_PC + mResetHoldFrameCount = 0; +#endif } PADStatus JUTGamePad::mPadStatus[4]; @@ -82,6 +89,9 @@ u32 JUTGamePad::sRumbleSupported; u32 JUTGamePad::read() { sRumbleSupported = PADRead(mPadStatus); +#if TARGET_PC + dusk::updateActionBindings(); +#endif switch (sClampMode) { case EClampStick: @@ -219,11 +229,19 @@ void JUTGamePad::update() { mButtonReset.mReset = false; } else if (!JUTGamePad::C3ButtonReset::sResetOccurred) { if (mButtonReset.mReset == true) { +#if TARGET_PC + checkResetCallback(++mResetHoldFrameCount * (OS_TIMER_CLOCK / 30)); +#else OSTime hold_time = OSGetTime() - mResetHoldStartTime; checkResetCallback(hold_time); +#endif } else { mButtonReset.mReset = true; +#if TARGET_PC + mResetHoldFrameCount = 0; +#else mResetHoldStartTime = OSGetTime(); +#endif } } diff --git a/libs/JSystem/src/JUtility/JUTPalette.cpp b/libs/JSystem/src/JUtility/JUTPalette.cpp index d98bf98c2e..9c8a51432c 100644 --- a/libs/JSystem/src/JUtility/JUTPalette.cpp +++ b/libs/JSystem/src/JUtility/JUTPalette.cpp @@ -38,3 +38,9 @@ bool JUTPalette::load() { return check; } + +#if TARGET_PC +void JUTPalette::dataUploaded() { + GXInitTlutObjData(&mTlutObj, (void*)mColorTable); +} +#endif diff --git a/libs/JSystem/src/JUtility/JUTResFont.cpp b/libs/JSystem/src/JUtility/JUTResFont.cpp index 206968f053..3ef83b227e 100644 --- a/libs/JSystem/src/JUtility/JUTResFont.cpp +++ b/libs/JSystem/src/JUtility/JUTResFont.cpp @@ -6,26 +6,13 @@ #include "JSystem/JUtility/JUTAssert.h" #include "JSystem/JUtility/JUTConsole.h" #include -#include "absl/container/node_hash_map.h" - -#if TARGET_PC -struct GlyphTextures { - absl::node_hash_map textures; -}; -#endif JUTResFont::JUTResFont() { -#if TARGET_PC - mGlyphTextures = new GlyphTextures(); -#endif initialize_state(); JUTFont::initialize_state(); } JUTResFont::JUTResFont(const ResFONT* pFont, JKRHeap* pHeap) { -#if TARGET_PC - mGlyphTextures = new GlyphTextures(); -#endif initialize_state(); JUTFont::initialize_state(); initiate(pFont, pHeap); @@ -33,10 +20,7 @@ JUTResFont::JUTResFont(const ResFONT* pFont, JKRHeap* pHeap) { JUTResFont::~JUTResFont() { #if TARGET_PC - for (auto& pair : mGlyphTextures->textures) { - pair.second.reset(); - } - delete mGlyphTextures; + mJoinedTextureObject.reset(); #endif if (mValid) { @@ -70,6 +54,33 @@ bool JUTResFont::initiate(const ResFONT* pFont, JKRHeap* pHeap) { return true; } +#if TARGET_PC +void JUTResFont::initJoinedTexture() { + if (mGly1BlockNum != 1) { + CRASH("mGly1BlockNum must be 1!"); + } + + const auto& block = *mpGlyphBlocks[0]; + if (block.textureWidth % 8 != 0 || block.textureHeight % 8 != 0) { + // Idk how the GameCube's tiling texture format works so this is a safety check. + CRASH("Texture size not divisible!"); + } + + int pageCount = 0; + u32 pageNumCells = block.numRows * block.numColumns; + for (u32 code = block.startCode; code < block.endCode; code += pageNumCells) { + pageCount += 1; + } + + mJoinedTextureHeight = block.textureHeight * pageCount; + GXInitTexObj(&mJoinedTextureObject, block.data, block.textureWidth, + mJoinedTextureHeight, static_cast(block.textureFormat.host()), + GX_CLAMP, GX_CLAMP, false); + + GXInitTexObjLOD(&mJoinedTextureObject, GX_LINEAR, GX_LINEAR, 0.0f, 0.0f, 0.0f, 0U, 0U, GX_ANISO_1); +} +#endif + bool JUTResFont::protected_initiate(const ResFONT* pFont, JKRHeap* pHeap) { void** p; delete_and_initialize(); @@ -100,8 +111,10 @@ bool JUTResFont::protected_initiate(const ResFONT* pFont, JKRHeap* pHeap) { mpMapBlocks = JKR_NEW_ARRAY_ARGS(ResFONT::MAP1*, mMap1BlockNum, p); } setBlock(); - return true; + IF_DUSK(initJoinedTexture()); + + return true; } void JUTResFont::countBlock() { @@ -231,14 +244,14 @@ void JUTResFont::setGX(JUtility::TColor col1, JUtility::TColor col2) { } f32 JUTResFont::drawChar_scale(f32 pos_x, f32 pos_y, f32 scale_x, f32 scale_y, int str_int, - bool flag) { + bool flag FONT_DRAW_CTX) { f32 x1; f32 x2; f32 y1; JUT_ASSERT(378, mValid); JUTFont::TWidth width; - loadFont(str_int, GX_TEXMAP0, &width); + loadFont(str_int, GX_TEXMAP0, &width FONT_DRAW_CTX_ARG); if ((mFixed) || (!flag)) { x1 = pos_x; @@ -258,15 +271,26 @@ f32 JUTResFont::drawChar_scale(f32 pos_x, f32 pos_y, f32 scale_x, f32 scale_y, i f32 y2 = getDescent() * (scale_y / getHeight()) + pos_y; u16 texW = mpGlyphBlocks[field_0x66]->textureWidth; +#if TARGET_PC + u16 texH = mJoinedTextureHeight; +#else u16 texH = mpGlyphBlocks[field_0x66]->textureHeight; +#endif u16 cellW = mpGlyphBlocks[field_0x66]->cellWidth; + u16 cellH = mpGlyphBlocks[field_0x66]->cellHeight; s32 u1 = (mWidth * 0x8000) / texW; s32 v1 = (mHeight * 0x8000) / texH; s32 u2 = ((mWidth + cellW) * 0x8000) / texW; s32 v2 = ((mHeight + cellH) * 0x8000) / texH; +#if TARGET_PC + if (!context) { + pushDrawState(); + } +#else GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_F32, 0); +#endif GXBegin(GX_QUADS, GX_VTXFMT0, 4); // Bottom Left @@ -290,18 +314,33 @@ f32 JUTResFont::drawChar_scale(f32 pos_x, f32 pos_y, f32 scale_x, f32 scale_y, i GXTexCoord2u16(u1, v2); GXEnd(); +#if TARGET_PC + if (!context) { + popDrawState(); + } +#else GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_S16, 0); - +#endif return retval; } -void JUTResFont::loadFont(int code, GXTexMapID texMapID, JUTFont::TWidth* pDstWidth) { +#if TARGET_PC +void JUTResFont::pushDrawState() { + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_F32, 0); +} + +void JUTResFont::popDrawState() { + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_S16, 0); +} +#endif + +void JUTResFont::loadFont(int code, GXTexMapID texMapID, JUTFont::TWidth* pDstWidth FONT_DRAW_CTX) { if (pDstWidth != 0) { getWidthEntry(code, pDstWidth); } int fontCode = getFontCode(code); - loadImage(fontCode, texMapID); + loadImage(fontCode, texMapID FONT_DRAW_CTX_ARG); } void JUTResFont::getWidthEntry(int code, JUTFont::TWidth* i_width) const { @@ -403,7 +442,7 @@ int JUTResFont::getFontCode(int chr) const { return ret; } -void JUTResFont::loadImage(int code, GXTexMapID id){ +void JUTResFont::loadImage(int code, GXTexMapID id FONT_DRAW_CTX){ int i = 0; for (; i < mGly1BlockNum; i++) { @@ -435,22 +474,15 @@ void JUTResFont::loadImage(int code, GXTexMapID id){ mHeight = cellRow * cellH; #if TARGET_PC - const auto found = mGlyphTextures->textures.find(pageIdx); - GXTexObj* texObj; - if (found == mGlyphTextures->textures.end()) { - texObj = &mGlyphTextures->textures[pageIdx]; - void* pImg = &mpGlyphBlocks[i]->data[pageIdx * mpGlyphBlocks[i]->textureSize]; - GXInitTexObj(texObj, pImg, mpGlyphBlocks[i]->textureWidth, - mpGlyphBlocks[i]->textureHeight, (GXTexFmt)(u16)mpGlyphBlocks[i]->textureFormat, - GX_CLAMP, GX_CLAMP, 0); + mHeight += texH * pageIdx; - GXInitTexObjLOD(texObj, GX_LINEAR, GX_LINEAR, 0.0f, 0.0f, 0.0f, 0U, 0U, GX_ANISO_1); - } else { - texObj = &found->second; + if (!context || !context->isTextureLoaded) { + GXLoadTexObj(&mJoinedTextureObject, id); + if (context) { + context->isTextureLoaded = true; + } } - GXLoadTexObj(texObj, id); - // Gets used by some other code. mTexPageIdx = pageIdx; field_0x66 = i; diff --git a/libs/JSystem/src/JUtility/JUTTexture.cpp b/libs/JSystem/src/JUtility/JUTTexture.cpp index 060c581e80..1d799c2820 100644 --- a/libs/JSystem/src/JUtility/JUTTexture.cpp +++ b/libs/JSystem/src/JUtility/JUTTexture.cpp @@ -27,7 +27,7 @@ void JUTTexture::storeTIMG(ResTIMG const* param_0, u8 param_1) { mTexData = (void*)((intptr_t)mTexInfo + 0x20); } - field_0x2c = NULL; + mPalette = NULL; mTlutName = 0; mWrapS = mTexInfo->wrapS; mWrapT = mTexInfo->wrapT; @@ -95,7 +95,7 @@ void JUTTexture::storeTIMG(ResTIMG const* param_0, JUTPalette* param_1, GXTlut p } mEmbPalette = param_1; setEmbPaletteDelFlag(false); - field_0x2c = NULL; + mPalette = NULL; if (param_1 != NULL) { mTlutName = param_2; if (param_2 != param_1->getTlutName()) { @@ -120,11 +120,11 @@ void JUTTexture::storeTIMG(ResTIMG const* param_0, JUTPalette* param_1, GXTlut p void JUTTexture::attachPalette(JUTPalette* param_0) { if (mTexInfo->indexTexture) { if (param_0 == NULL && mEmbPalette != NULL) { - field_0x2c = mEmbPalette; + mPalette = mEmbPalette; } else { - field_0x2c = param_0; + mPalette = param_0; } - initTexObj(field_0x2c->getTlutName()); + initTexObj(mPalette->getTlutName()); } } @@ -133,9 +133,9 @@ void JUTTexture::init() { initTexObj(); } else { if (mEmbPalette != NULL) { - field_0x2c = mEmbPalette; + mPalette = mEmbPalette; - initTexObj(field_0x2c->getTlutName()); + initTexObj(mPalette->getTlutName()); } else { OS_REPORT("This texture is CI-Format, but EmbPalette is NULL.\n"); } @@ -179,8 +179,8 @@ void JUTTexture::initTexObj(GXTlut param_0) { } void JUTTexture::load(GXTexMapID param_0) { - if (field_0x2c) { - field_0x2c->load(); + if (mPalette) { + mPalette->load(); } GXLoadTexObj(&mTexObj, param_0); } diff --git a/libs/JSystem/src/JUtility/JUTVideo.cpp b/libs/JSystem/src/JUtility/JUTVideo.cpp index f718fcac23..5586342f0e 100644 --- a/libs/JSystem/src/JUtility/JUTVideo.cpp +++ b/libs/JSystem/src/JUtility/JUTVideo.cpp @@ -205,7 +205,8 @@ void JUTVideo::setRenderMode(GXRenderModeObj const* pObj) { void JUTVideo::waitRetraceIfNeed() {} #if TARGET_PC -void JUTVideo::setWindowSize(AuroraWindowSize const& size) { - m_WindowSize = size; +void JUTVideo::setRenderSize(u32 width, u32 height) { + mRenderWidth = width; + mRenderHeight = height; } #endif diff --git a/platforms/android/.gitignore b/platforms/android/.gitignore new file mode 100644 index 0000000000..7775e3c7f4 --- /dev/null +++ b/platforms/android/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +app/build/ +local.properties +app/src/main/jniLibs/*/*.so diff --git a/platforms/android/README.md b/platforms/android/README.md new file mode 100644 index 0000000000..f478871392 --- /dev/null +++ b/platforms/android/README.md @@ -0,0 +1,76 @@ +# Android Shell + +This directory contains a minimal SDLActivity-based Android app wrapper for Dusklight. + +## Prerequisites + +- Android SDK installed (`ANDROID_HOME`) +- Android NDK version used by CMake presets (`ANDROID_NDK_VERSION`) +- JDK 17+ + +Example: + +```bash +export ANDROID_HOME="$HOME/Android/Sdk" +export ANDROID_NDK_VERSION="29.0.14206865" +export JAVA_HOME="/usr/lib/jvm/java-17-openjdk" +``` + +## Build Native Libraries + +```bash +cmake --preset android-arm64 +cmake --build --preset android-arm64 + +cmake --preset android-x86_64 +cmake --build --preset android-x86_64 +``` + +These builds produce: + +- `build/android-arm64/Binaries/libmain.so` +- `build/android-x86_64/Binaries/libmain.so` + +## Stage Libraries Into APK Project + +```bash +./android/scripts/stage-jni-libs.sh +``` + +This copies: + +- `libmain.so` -> `android/app/src/main/jniLibs/arm64-v8a/` +- `libmain.so` -> `android/app/src/main/jniLibs/x86_64/` + +## Refresh SDL Java Shim (Optional) + +If you update SDL and want to refresh the embedded Java shim files: + +```bash +./android/scripts/sync-sdl-java.sh +``` + +## Build APK + +```bash +cd android +./gradlew :app:assembleDebug +``` + +Output APK: + +- `android/app/build/outputs/apk/debug/app-debug.apk` + +## Launch With Runtime Args (adb) + +You can pass command-line args through the activity intent: + +```bash +adb shell am start -n dev.twilitrealm.dusk/.DuskActivity \ + --es dusk_args "--backend vulkan" +``` + +Supported extras: + +- `dusk_args`: single shell-like argument string +- `dusk_argv`: string-array argv diff --git a/platforms/android/app/build.gradle b/platforms/android/app/build.gradle new file mode 100644 index 0000000000..2dc8575f86 --- /dev/null +++ b/platforms/android/app/build.gradle @@ -0,0 +1,67 @@ +plugins { + id 'com.android.application' +} + +def duskRepoDir = rootProject.projectDir.parentFile.parentFile +def duskGeneratedAssetsDir = layout.buildDirectory.dir('generated/assets/dusklight').get().asFile +def syncDuskAssets = tasks.register('syncDuskAssets', Sync) { + from(new File(duskRepoDir, 'res')) { + into 'res' + exclude '**/.DS_Store' + } + into duskGeneratedAssetsDir +} + +android { + namespace 'dev.twilitrealm.dusk' + compileSdk 36 + + defaultConfig { + applicationId 'dev.twilitrealm.dusk' + minSdk 26 + targetSdk 36 + versionCode 1 + versionName '0.1.0' + } + + buildTypes { + debug { + minifyEnabled false + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + sourceSets { + main { + jniLibs.srcDirs = ['src/main/jniLibs'] + assets.srcDirs = [duskGeneratedAssetsDir] + } + } + + splits { + abi { + enable true + reset() + include 'arm64-v8a', 'x86_64' + universalApk false + } + } + + lint { + abortOnError false + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) +} + +tasks.configureEach { task -> + if ((task.name.startsWith('merge') && task.name.endsWith('Assets')) || + task.name.toLowerCase().contains('lint')) { + task.dependsOn(syncDuskAssets) + } +} diff --git a/platforms/android/app/proguard-rules.pro b/platforms/android/app/proguard-rules.pro new file mode 100644 index 0000000000..72b7ca16fa --- /dev/null +++ b/platforms/android/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# Keep SDL activity and related JNI bridge methods. +-keep class org.libsdl.app.** { *; } +-keep class dev.twilitrealm.dusk.DuskHttpClient { *; } +-keep class dev.twilitrealm.dusk.DuskHttpClient$Response { *; } diff --git a/platforms/android/app/src/main/AndroidManifest.xml b/platforms/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..f5a0365856 --- /dev/null +++ b/platforms/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java new file mode 100644 index 0000000000..665cda2b5e --- /dev/null +++ b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java @@ -0,0 +1,389 @@ +package dev.twilitrealm.dusk; + +import android.app.ActionBar; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.provider.OpenableColumns; +import android.util.Log; +import android.view.View; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowInsetsController; + +import org.libsdl.app.SDLActivity; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +public class DuskActivity extends SDLActivity { + private static final String TAG = "DuskActivity"; + private static final int FOLDER_DIALOG_REQUEST_CODE = 0x4455; + private static final String EXTERNAL_STORAGE_AUTHORITY = + "com.android.externalstorage.documents"; + + private long folderDialogUserdata = 0; + + private static native void nativeFolderDialogResult(long userdata, String path, String error); + + private static String[] splitArgs(String raw) { + List out = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean inSingle = false; + boolean inDouble = false; + boolean escaped = false; + + for (int i = 0; i < raw.length(); ++i) { + char c = raw.charAt(i); + if (escaped) { + current.append(c); + escaped = false; + continue; + } + if (c == '\\' && !inSingle) { + escaped = true; + continue; + } + if (c == '"' && !inSingle) { + inDouble = !inDouble; + continue; + } + if (c == '\'' && !inDouble) { + inSingle = !inSingle; + continue; + } + if (!inSingle && !inDouble && Character.isWhitespace(c)) { + if (current.length() > 0) { + out.add(current.toString()); + current.setLength(0); + } + continue; + } + current.append(c); + } + + if (escaped) { + current.append('\\'); + } + if (current.length() > 0) { + out.add(current.toString()); + } + return out.toArray(new String[0]); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + hideSystemBars(); + } + + @Override + protected void onResume() { + super.onResume(); + hideSystemBars(); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) { + hideSystemBars(); + } + } + + private void hideSystemBars() { + Window window = getWindow(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.setDecorFitsSystemWindows(false); + WindowInsetsController ctrl = window.getDecorView().getWindowInsetsController(); + if (ctrl != null) { + ctrl.setSystemBarsBehavior( + WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + ctrl.hide(WindowInsets.Type.systemBars()); + } + } else { + View decorView = window.getDecorView(); + int uiOptions = View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE; + decorView.setSystemUiVisibility(uiOptions); + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.hide(); + } + } + } + + @Override + protected String[] getLibraries() { + // SDL3 is statically linked into libmain.so in this build. + return new String[] { + "main" + }; + } + + @Override + protected String[] getArguments() { + Intent intent = getIntent(); + if (intent != null) { + String[] argv = intent.getStringArrayExtra("dusk_argv"); + if (argv != null && argv.length > 0) { + return argv; + } + + String rawArgs = intent.getStringExtra("dusk_args"); + if (rawArgs != null) { + String trimmed = rawArgs.trim(); + if (!trimmed.isEmpty()) { + return splitArgs(trimmed); + } + } + } + return new String[0]; + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (resultCode == RESULT_OK) { + persistUriPermissions(data); + } + if (requestCode == FOLDER_DIALOG_REQUEST_CODE) { + finishFolderDialog(resultCode, data); + return; + } + super.onActivityResult(requestCode, resultCode, data); + } + + public boolean showFolderDialog(long userdata) { + if (userdata == 0 || folderDialogUserdata != 0) { + return false; + } + + folderDialogUserdata = userdata; + runOnUiThread(() -> { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION | + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); + + try { + startActivityForResult(intent, FOLDER_DIALOG_REQUEST_CODE); + } catch (ActivityNotFoundException e) { + Log.w(TAG, "Unable to open folder dialog.", e); + finishFolderDialog(Activity.RESULT_CANCELED, null); + } + }); + return true; + } + + private void finishFolderDialog(int resultCode, Intent data) { + long userdata = folderDialogUserdata; + folderDialogUserdata = 0; + if (userdata == 0) { + return; + } + + if (resultCode == RESULT_OK && data != null && data.getData() != null) { + String path = getRealPathForUri(data.getData()); + if (path != null && !path.isEmpty()) { + nativeFolderDialogResult(userdata, path, null); + } else { + nativeFolderDialogResult( + userdata, null, "Selected folder is not available as a filesystem path"); + } + return; + } + + nativeFolderDialogResult(userdata, null, null); + } + + private String getRealPathForUri(Uri uri) { + if (uri == null) { + return null; + } + + String scheme = uri.getScheme(); + if ("file".equals(scheme)) { + return uri.getPath(); + } + + if (!"content".equals(scheme) || + !EXTERNAL_STORAGE_AUTHORITY.equals(uri.getAuthority()) || + Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) + { + return null; + } + + try { + return getExternalStoragePathForDocumentId(getExternalStorageDocumentId(uri)); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Unable to resolve URI: " + uri, e); + return null; + } + } + + private static String getExternalStorageDocumentId(Uri uri) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && isTreeDocumentUri(uri)) { + return DocumentsContract.getTreeDocumentId(uri); + } + + return DocumentsContract.getDocumentId(uri); + } + + private static boolean isTreeDocumentUri(Uri uri) { + List segments = uri.getPathSegments(); + return segments.size() >= 2 && "tree".equals(segments.get(0)); + } + + private String getExternalStoragePathForDocumentId(String documentId) { + if (documentId == null || documentId.isEmpty()) { + return null; + } + if (documentId.startsWith("raw:")) { + return documentId.substring("raw:".length()); + } + + String[] parts = documentId.split(":", 2); + String volumeId = parts[0]; + String relativePath = parts.length > 1 ? parts[1] : ""; + + File root = getExternalStorageRoot(volumeId); + if (root == null) { + return null; + } + + return relativePath.isEmpty() + ? root.getAbsolutePath() + : new File(root, relativePath).getAbsolutePath(); + } + + private File getExternalStorageRoot(String volumeId) { + if ("primary".equalsIgnoreCase(volumeId)) { + return Environment.getExternalStorageDirectory(); + } + if ("home".equalsIgnoreCase(volumeId)) { + return new File( + Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOCUMENTS); + } + + File[] externalFilesDirs = getExternalFilesDirs(null); + if (externalFilesDirs != null) { + for (File externalFilesDir : externalFilesDirs) { + File root = getStorageRootForExternalFilesDir(externalFilesDir); + if (root != null && volumeId.equalsIgnoreCase(root.getName())) { + return root; + } + } + } + + File fallback = new File("/storage", volumeId); + return fallback.exists() ? fallback : null; + } + + private File getStorageRootForExternalFilesDir(File externalFilesDir) { + if (externalFilesDir == null) { + return null; + } + + String path = externalFilesDir.getAbsolutePath(); + int androidDir = path.indexOf("/Android/"); + if (androidDir <= 0) { + return null; + } + + return new File(path.substring(0, androidDir)); + } + + private void persistUriPermissions(Intent data) { + if (data == null) { + return; + } + + int permissionFlags = + data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if (permissionFlags == 0) { + return; + } + + Uri uri = data.getData(); + if (uri != null) { + persistUriPermission(uri, permissionFlags); + } + + ClipData clipData = data.getClipData(); + if (clipData == null) { + return; + } + for (int i = 0; i < clipData.getItemCount(); ++i) { + Uri itemUri = clipData.getItemAt(i).getUri(); + if (itemUri != null) { + persistUriPermission(itemUri, permissionFlags); + } + } + } + + private void persistUriPermission(Uri uri, int permissionFlags) { + if ((permissionFlags & Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0) { + persistUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION, "read"); + } + if ((permissionFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) { + persistUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION, "write"); + } + } + + private void persistUriPermission(Uri uri, int permissionFlag, String permissionName) { + try { + getContentResolver().takePersistableUriPermission(uri, permissionFlag); + } catch (SecurityException | IllegalArgumentException e) { + Log.w(TAG, "Unable to persist " + permissionName + " URI permission for " + uri, e); + } + } + + public String getDisplayNameForUri(String uriString) { + if (uriString == null || uriString.isEmpty()) { + return ""; + } + + Uri uri = Uri.parse(uriString); + if ("content".equals(uri.getScheme())) { + try (Cursor cursor = getContentResolver().query( + uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)) + { + if (cursor != null && cursor.moveToFirst()) { + int displayNameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (displayNameColumn >= 0) { + String displayName = cursor.getString(displayNameColumn); + if (displayName != null && !displayName.isEmpty()) { + return displayName; + } + } + } + } catch (SecurityException | IllegalArgumentException e) { + Log.w(TAG, "Unable to query display name for " + uri, e); + } + } else if ("file".equals(uri.getScheme())) { + String path = uri.getPath(); + if (path != null && !path.isEmpty()) { + String name = new File(path).getName(); + if (!name.isEmpty()) { + return name; + } + } + } + + String lastSegment = uri.getLastPathSegment(); + return lastSegment != null ? lastSegment : ""; + } +} diff --git a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java new file mode 100644 index 0000000000..d4ed6a3041 --- /dev/null +++ b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java @@ -0,0 +1,467 @@ +package dev.twilitrealm.dusk; + +import android.content.ContentResolver; +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; +import android.provider.DocumentsContract.Document; +import android.provider.DocumentsContract.Root; +import android.provider.DocumentsProvider; +import android.webkit.MimeTypeMap; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public class DuskDocumentsProvider extends DocumentsProvider { + public static final String AUTHORITY = "dev.twilitrealm.dusk.documents"; + + private static final String ROOT_ID = "dusk"; + private static final String ROOT_DOCUMENT_ID = "root"; + private static final String LOCATION_DESCRIPTOR_NAME = "data_location.json"; + private static final String DIRECTORY_MIME_TYPE = Document.MIME_TYPE_DIR; + + private static final String[] DEFAULT_ROOT_PROJECTION = new String[] { + Root.COLUMN_ROOT_ID, + Root.COLUMN_FLAGS, + Root.COLUMN_TITLE, + Root.COLUMN_DOCUMENT_ID, + Root.COLUMN_ICON, + Root.COLUMN_AVAILABLE_BYTES, + Root.COLUMN_SUMMARY + }; + + private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_FLAGS, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_SIZE + }; + + @Override + public boolean onCreate() { + if (!isCustomDataPathEnabled()) { + ensureUserDirectories(); + } + return true; + } + + @Override + public Cursor queryRoots(String[] projection) throws FileNotFoundException { + final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); + if (isCustomDataPathEnabled()) { + return result; + } + + final File root = getRootDirectory(); + final MatrixCursor.RowBuilder row = result.newRow(); + + row.add(Root.COLUMN_ROOT_ID, ROOT_ID); + row.add(Root.COLUMN_FLAGS, + Root.FLAG_LOCAL_ONLY | + Root.FLAG_SUPPORTS_CREATE | + Root.FLAG_SUPPORTS_IS_CHILD); + row.add(Root.COLUMN_TITLE, getContext().getString(R.string.app_name)); + row.add(Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID); + row.add(Root.COLUMN_ICON, R.mipmap.icon); + row.add(Root.COLUMN_AVAILABLE_BYTES, root.getFreeSpace()); + row.add(Root.COLUMN_SUMMARY, getContext().getString(R.string.documents_provider_summary)); + + return result; + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { + final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); + includeDocument(result, documentId, getFileForDocumentId(documentId)); + return result; + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) + throws FileNotFoundException + { + return queryChildDocumentsInternal(parentDocumentId, projection); + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, Bundle queryArgs) + throws FileNotFoundException + { + return queryChildDocumentsInternal(parentDocumentId, projection); + } + + private Cursor queryChildDocumentsInternal(String parentDocumentId, String[] projection) + throws FileNotFoundException + { + final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); + final File parent = getFileForDocumentId(parentDocumentId); + final File[] files = parent.listFiles(); + result.setNotificationUri(getContext().getContentResolver(), getChildDocumentsUri(parentDocumentId)); + + if (files == null) { + return result; + } + + for (File file : files) { + includeDocument(result, getDocumentIdForFile(file), file); + } + + return result; + } + + @Override + public boolean isChildDocument(String parentDocumentId, String documentId) { + try { + final File parent = getFileForDocumentId(parentDocumentId); + final File child = getFileForDocumentId(documentId); + return isInside(parent, child); + } catch (FileNotFoundException e) { + return false; + } + } + + @Override + public String createDocument(String parentDocumentId, String mimeType, String displayName) + throws FileNotFoundException + { + final File parent = getFileForDocumentId(parentDocumentId); + if (!parent.isDirectory()) { + throw new FileNotFoundException("Parent is not a directory: " + parentDocumentId); + } + + final String safeDisplayName = sanitizeDisplayName(displayName); + final File file = buildUniqueFile(parent, safeDisplayName); + final boolean created; + if (DIRECTORY_MIME_TYPE.equals(mimeType)) { + created = file.mkdir(); + } else { + try { + created = file.createNewFile(); + } catch (IOException e) { + throw asFileNotFound("Unable to create document", e); + } + } + + if (!created) { + throw new FileNotFoundException("Unable to create document: " + displayName); + } + + notifyChildrenChanged(parentDocumentId); + return getDocumentIdForFile(file); + } + + @Override + public String renameDocument(String documentId, String displayName) throws FileNotFoundException { + final File file = getFileForDocumentId(documentId); + if (ROOT_DOCUMENT_ID.equals(documentId)) { + throw new FileNotFoundException("Cannot rename root document"); + } + + final File target = buildUniqueFile(file.getParentFile(), sanitizeDisplayName(displayName)); + final String parentDocumentId = getDocumentIdForFile(file.getParentFile()); + if (!file.renameTo(target)) { + throw new FileNotFoundException("Unable to rename document: " + documentId); + } + notifyDocumentChanged(documentId); + notifyDocumentChanged(getDocumentIdForFile(target)); + notifyChildrenChanged(parentDocumentId); + return getDocumentIdForFile(target); + } + + @Override + public void deleteDocument(String documentId) throws FileNotFoundException { + if (ROOT_DOCUMENT_ID.equals(documentId)) { + throw new FileNotFoundException("Cannot delete root document"); + } + + final File file = getFileForDocumentId(documentId); + final String parentDocumentId = getDocumentIdForFile(file.getParentFile()); + deleteRecursively(file); + notifyDocumentChanged(documentId); + notifyChildrenChanged(parentDocumentId); + } + + @Override + public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal) + throws FileNotFoundException + { + return ParcelFileDescriptor.open(getFileForDocumentId(documentId), modeToParcelMode(mode)); + } + + @Override + public AssetFileDescriptor openDocumentThumbnail(String documentId, android.graphics.Point sizeHint, + CancellationSignal signal) throws FileNotFoundException + { + throw new FileNotFoundException("Thumbnails are not supported"); + } + + private void includeDocument(MatrixCursor result, String documentId, File file) throws FileNotFoundException { + final MatrixCursor.RowBuilder row = result.newRow(); + final boolean isDirectory = file.isDirectory(); + final String displayName = ROOT_DOCUMENT_ID.equals(documentId) + ? getContext().getString(R.string.documents_provider_root_name) + : file.getName(); + + int flags = Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_RENAME; + if (isDirectory) { + flags |= Document.FLAG_DIR_SUPPORTS_CREATE; + } else if (file.canWrite()) { + flags |= Document.FLAG_SUPPORTS_WRITE; + } + if (ROOT_DOCUMENT_ID.equals(documentId)) { + flags &= ~(Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_RENAME); + } + + row.add(Document.COLUMN_DOCUMENT_ID, documentId); + row.add(Document.COLUMN_DISPLAY_NAME, displayName); + row.add(Document.COLUMN_FLAGS, flags); + row.add(Document.COLUMN_MIME_TYPE, isDirectory ? DIRECTORY_MIME_TYPE : getMimeType(file)); + row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified()); + row.add(Document.COLUMN_SIZE, isDirectory ? null : file.length()); + } + + private File getRootDirectory() throws FileNotFoundException { + if (isCustomDataPathEnabled()) { + throw new FileNotFoundException( + "Dusk DocumentsProvider is disabled while a custom data path is configured"); + } + + final File root = getContext().getFilesDir(); + if (root == null) { + throw new FileNotFoundException("Dusklight files directory is unavailable"); + } + return root; + } + + private File getFileForDocumentId(String documentId) throws FileNotFoundException { + final File root = getRootDirectory(); + if (ROOT_DOCUMENT_ID.equals(documentId)) { + return root; + } + if (!documentId.startsWith(ROOT_DOCUMENT_ID + "/")) { + throw new FileNotFoundException("Invalid document id: " + documentId); + } + + final String relativePath = documentId.substring(ROOT_DOCUMENT_ID.length() + 1); + final File file = new File(root, relativePath); + if (!isInside(root, file)) { + throw new FileNotFoundException("Document escapes Dusklight files directory: " + documentId); + } + if (!file.exists()) { + throw new FileNotFoundException("Document does not exist: " + documentId); + } + return file; + } + + private String getDocumentIdForFile(File file) throws FileNotFoundException { + final File root = getRootDirectory(); + if (sameFile(root, file)) { + return ROOT_DOCUMENT_ID; + } + if (!isInside(root, file)) { + throw new FileNotFoundException("File escapes Dusklight files directory: " + file); + } + + final String rootPath = canonicalPath(root); + final String filePath = canonicalPath(file); + return ROOT_DOCUMENT_ID + "/" + filePath.substring(rootPath.length() + 1); + } + + private void ensureUserDirectories() { + final File root = getContext().getFilesDir(); + if (root == null) { + return; + } + new File(root, "texture_replacements").mkdirs(); + new File(root, "USA/Card A").mkdirs(); + new File(root, "EUR/Card A").mkdirs(); + } + + private boolean isCustomDataPathEnabled() { + if (getContext() == null) { + return false; + } + + final File filesDir = getContext().getFilesDir(); + if (filesDir == null) { + return false; + } + + final File descriptor = new File(filesDir, LOCATION_DESCRIPTOR_NAME); + if (!descriptor.isFile()) { + return false; + } + + try { + final JSONObject json = new JSONObject(readText(descriptor)); + return "custom".equals(json.optString("mode", "default")); + } catch (IOException | JSONException e) { + return false; + } + } + + private static String readText(File file) throws IOException { + try (FileInputStream input = new FileInputStream(file); + ByteArrayOutputStream output = new ByteArrayOutputStream()) + { + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = input.read(buffer)) != -1) { + output.write(buffer, 0, bytesRead); + } + return output.toString(StandardCharsets.UTF_8.name()); + } + } + + private static String[] resolveRootProjection(String[] projection) { + return projection != null ? projection : DEFAULT_ROOT_PROJECTION; + } + + private static String[] resolveDocumentProjection(String[] projection) { + return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION; + } + + private static String sanitizeDisplayName(String displayName) throws FileNotFoundException { + if (displayName == null) { + throw new FileNotFoundException("Document name is empty"); + } + + final String sanitized = displayName.trim(); + if (sanitized.isEmpty() || ".".equals(sanitized) || "..".equals(sanitized) || + sanitized.contains("/") || sanitized.contains("\\")) + { + throw new FileNotFoundException("Invalid document name: " + displayName); + } + return sanitized; + } + + private static File buildUniqueFile(File parent, String displayName) { + File file = new File(parent, displayName); + if (!file.exists()) { + return file; + } + + final int dot = displayName.lastIndexOf('.'); + final String baseName = dot > 0 ? displayName.substring(0, dot) : displayName; + final String extension = dot > 0 ? displayName.substring(dot) : ""; + for (int i = 1; i < 100; ++i) { + file = new File(parent, baseName + " (" + i + ")" + extension); + if (!file.exists()) { + return file; + } + } + return new File(parent, baseName + " (" + System.currentTimeMillis() + ")" + extension); + } + + private static int modeToParcelMode(String mode) { + if ("r".equals(mode)) { + return ParcelFileDescriptor.MODE_READ_ONLY; + } + if ("w".equals(mode) || "wt".equals(mode)) { + return ParcelFileDescriptor.MODE_WRITE_ONLY | + ParcelFileDescriptor.MODE_CREATE | + ParcelFileDescriptor.MODE_TRUNCATE; + } + if ("wa".equals(mode)) { + return ParcelFileDescriptor.MODE_WRITE_ONLY | + ParcelFileDescriptor.MODE_CREATE | + ParcelFileDescriptor.MODE_APPEND; + } + if ("rw".equals(mode)) { + return ParcelFileDescriptor.MODE_READ_WRITE | + ParcelFileDescriptor.MODE_CREATE; + } + if ("rwt".equals(mode)) { + return ParcelFileDescriptor.MODE_READ_WRITE | + ParcelFileDescriptor.MODE_CREATE | + ParcelFileDescriptor.MODE_TRUNCATE; + } + return ParcelFileDescriptor.MODE_READ_ONLY; + } + + private static String getMimeType(File file) { + final int dot = file.getName().lastIndexOf('.'); + if (dot >= 0) { + final String extension = file.getName().substring(dot + 1).toLowerCase(); + final String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if (mimeType != null) { + return mimeType; + } + } + return "application/octet-stream"; + } + + private Uri getChildDocumentsUri(String parentDocumentId) { + return DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId); + } + + private void notifyChildrenChanged(String parentDocumentId) { + final ContentResolver resolver = getContext().getContentResolver(); + resolver.notifyChange(getChildDocumentsUri(parentDocumentId), null, false); + } + + private void notifyDocumentChanged(String documentId) { + final ContentResolver resolver = getContext().getContentResolver(); + resolver.notifyChange(DocumentsContract.buildDocumentUri(AUTHORITY, documentId), null, false); + } + + private static void deleteRecursively(File file) throws FileNotFoundException { + if (file.isDirectory()) { + final File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + if (!file.delete()) { + throw new FileNotFoundException("Unable to delete document: " + file); + } + } + + private static boolean isInside(File parent, File child) { + try { + final String parentPath = canonicalPath(parent); + final String childPath = canonicalPath(child); + return childPath.equals(parentPath) || childPath.startsWith(parentPath + File.separator); + } catch (FileNotFoundException e) { + return false; + } + } + + private static boolean sameFile(File a, File b) { + try { + return canonicalPath(a).equals(canonicalPath(b)); + } catch (FileNotFoundException e) { + return false; + } + } + + private static String canonicalPath(File file) throws FileNotFoundException { + try { + return file.getCanonicalPath(); + } catch (IOException e) { + throw asFileNotFound("Unable to resolve path", e); + } + } + + private static FileNotFoundException asFileNotFound(String message, IOException cause) { + final FileNotFoundException exception = new FileNotFoundException(message + ": " + cause.getMessage()); + exception.initCause(cause); + return exception; + } +} diff --git a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskHttpClient.java b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskHttpClient.java new file mode 100644 index 0000000000..be160d6a2d --- /dev/null +++ b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskHttpClient.java @@ -0,0 +1,237 @@ +package dev.twilitrealm.dusk; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import javax.net.ssl.HttpsURLConnection; + +public final class DuskHttpClient { + public static final int ERROR_NONE = 0; + public static final int ERROR_INVALID_URL = 1; + public static final int ERROR_UNSUPPORTED_SCHEME = 2; + public static final int ERROR_TIMEOUT = 3; + public static final int ERROR_TOO_LARGE = 4; + public static final int ERROR_NETWORK = 5; + + private static final int MAX_REDIRECTS = 5; + + public static final class Response { + public int error; + public String message; + public int statusCode; + public String[] headerNames; + public String[] headerValues; + public byte[] body; + + Response(int error, String message, int statusCode, String[] headerNames, + String[] headerValues, byte[] body) { + this.error = error; + this.message = message; + this.statusCode = statusCode; + this.headerNames = headerNames != null ? headerNames : new String[0]; + this.headerValues = headerValues != null ? headerValues : new String[0]; + this.body = body != null ? body : new byte[0]; + } + } + + private DuskHttpClient() { + } + + public static Response get(String url, String[] headerNames, String[] headerValues, + int timeoutMs, long maxBodyBytes) { + if (url == null || url.isEmpty()) { + return fail(ERROR_INVALID_URL, "URL is empty"); + } + + try { + URL currentUrl = new URL(url); + if (!isHttps(currentUrl)) { + return fail(ERROR_UNSUPPORTED_SCHEME, "Only https:// URLs are supported"); + } + + for (int redirect = 0; redirect <= MAX_REDIRECTS; ++redirect) { + HttpsURLConnection connection = + (HttpsURLConnection) currentUrl.openConnection(); + try { + connection.setRequestMethod("GET"); + connection.setConnectTimeout(timeoutMs); + connection.setReadTimeout(timeoutMs); + connection.setUseCaches(false); + connection.setInstanceFollowRedirects(false); + applyHeaders(connection, headerNames, headerValues); + + int statusCode = connection.getResponseCode(); + if (isRedirect(statusCode)) { + String location = connection.getHeaderField("Location"); + if (location == null || location.isEmpty()) { + return fail(ERROR_NETWORK, "Redirect response did not include Location", + statusCode, connection, new byte[0]); + } + + URL nextUrl = new URL(currentUrl, location); + if (!isHttps(nextUrl)) { + return fail(ERROR_UNSUPPORTED_SCHEME, + "Only https:// redirects are supported", statusCode, + connection, new byte[0]); + } + currentUrl = nextUrl; + continue; + } + + byte[] body = readBody(connection, statusCode, maxBodyBytes); + return success(statusCode, connection, body); + } catch (ResponseTooLargeException e) { + return fail(ERROR_TOO_LARGE, "Response body exceeded the configured limit", + safeStatusCode(connection), connection, e.partialBody); + } finally { + connection.disconnect(); + } + } + + return fail(ERROR_NETWORK, "Too many redirects"); + } catch (MalformedURLException e) { + return fail(ERROR_INVALID_URL, "Failed to parse URL"); + } catch (SocketTimeoutException e) { + return fail(ERROR_TIMEOUT, "Request timed out"); + } catch (IOException e) { + String message = e.getMessage(); + return fail(ERROR_NETWORK, message != null ? message : e.toString()); + } catch (ClassCastException e) { + return fail(ERROR_UNSUPPORTED_SCHEME, "Only https:// URLs are supported"); + } + } + + private static void applyHeaders(HttpsURLConnection connection, String[] names, + String[] values) { + if (names == null || values == null) { + return; + } + + int count = Math.min(names.length, values.length); + for (int i = 0; i < count; ++i) { + if (names[i] != null && values[i] != null) { + connection.setRequestProperty(names[i], values[i]); + } + } + } + + private static boolean isHttps(URL url) { + return "https".equalsIgnoreCase(url.getProtocol()); + } + + private static boolean isRedirect(int statusCode) { + return statusCode == HttpURLConnection.HTTP_MOVED_PERM || + statusCode == HttpURLConnection.HTTP_MOVED_TEMP || + statusCode == HttpURLConnection.HTTP_SEE_OTHER || + statusCode == 307 || + statusCode == 308; + } + + private static byte[] readBody(HttpsURLConnection connection, int statusCode, + long maxBodyBytes) throws IOException, + ResponseTooLargeException { + InputStream stream = statusCode >= HttpURLConnection.HTTP_BAD_REQUEST ? + connection.getErrorStream() : connection.getInputStream(); + if (stream == null) { + return new byte[0]; + } + + try (InputStream bodyStream = stream; + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + long total = 0; + while (true) { + int read = bodyStream.read(buffer); + if (read < 0) { + return out.toByteArray(); + } + if (read == 0) { + continue; + } + if (read > maxBodyBytes || total > maxBodyBytes - read) { + throw new ResponseTooLargeException(out.toByteArray()); + } + out.write(buffer, 0, read); + total += read; + } + } + } + + private static int safeStatusCode(HttpsURLConnection connection) { + try { + return connection.getResponseCode(); + } catch (IOException e) { + return 0; + } + } + + private static Response success(int statusCode, HttpsURLConnection connection, byte[] body) { + HeaderLists headers = readHeaders(connection); + return new Response(ERROR_NONE, "", statusCode, headers.names, headers.values, body); + } + + private static Response fail(int error, String message) { + return new Response(error, message, 0, null, null, null); + } + + private static Response fail(int error, String message, int statusCode, + HttpsURLConnection connection, byte[] body) { + HeaderLists headers = readHeaders(connection); + return new Response(error, message, statusCode, headers.names, headers.values, body); + } + + private static HeaderLists readHeaders(HttpsURLConnection connection) { + List names = new ArrayList<>(); + List values = new ArrayList<>(); + + Map> headerFields = connection.getHeaderFields(); + if (headerFields == null) { + return new HeaderLists(new String[0], new String[0]); + } + + for (Map.Entry> entry : headerFields.entrySet()) { + String name = entry.getKey(); + if (name == null) { + continue; + } + List entryValues = entry.getValue(); + if (entryValues == null || entryValues.isEmpty()) { + names.add(name); + values.add(""); + continue; + } + for (String value : entryValues) { + names.add(name); + values.add(value != null ? value : ""); + } + } + + return new HeaderLists(names.toArray(new String[0]), values.toArray(new String[0])); + } + + private static final class HeaderLists { + final String[] names; + final String[] values; + + HeaderLists(String[] names, String[] values) { + this.names = names; + this.values = values; + } + } + + private static final class ResponseTooLargeException extends Exception { + final byte[] partialBody; + + ResponseTooLargeException(byte[] partialBody) { + this.partialBody = partialBody; + } + } +} diff --git a/platforms/android/app/src/main/java/org/libsdl/app/HIDDevice.java b/platforms/android/app/src/main/java/org/libsdl/app/HIDDevice.java new file mode 100644 index 0000000000..f96095324b --- /dev/null +++ b/platforms/android/app/src/main/java/org/libsdl/app/HIDDevice.java @@ -0,0 +1,21 @@ +package org.libsdl.app; + +import android.hardware.usb.UsbDevice; + +interface HIDDevice +{ + public int getId(); + public int getVendorId(); + public int getProductId(); + public String getSerialNumber(); + public int getVersion(); + public String getManufacturerName(); + public String getProductName(); + public UsbDevice getDevice(); + public boolean open(); + public int writeReport(byte[] report, boolean feature); + public boolean readReport(byte[] report, boolean feature); + public void setFrozen(boolean frozen); + public void close(); + public void shutdown(); +} diff --git a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java new file mode 100644 index 0000000000..bf1ca2149d --- /dev/null +++ b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java @@ -0,0 +1,655 @@ +package org.libsdl.app; + +import android.content.Context; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.BluetoothGattService; +import android.hardware.usb.UsbDevice; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.os.*; + +//import com.android.internal.util.HexDump; + +import java.lang.Runnable; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.UUID; + +class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice { + + private static final String TAG = "hidapi"; + private HIDDeviceManager mManager; + private BluetoothDevice mDevice; + private int mDeviceId; + private BluetoothGatt mGatt; + private boolean mIsRegistered = false; + private boolean mIsConnected = false; + private boolean mIsChromebook = false; + private boolean mIsReconnecting = false; + private boolean mFrozen = false; + private LinkedList mOperations; + GattOperation mCurrentOperation = null; + private Handler mHandler; + + private static final int TRANSPORT_AUTO = 0; + private static final int TRANSPORT_BREDR = 1; + private static final int TRANSPORT_LE = 2; + + private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000; + + static final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3"); + static final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); + static final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3"); + static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 }; + + static class GattOperation { + private enum Operation { + CHR_READ, + CHR_WRITE, + ENABLE_NOTIFICATION + } + + Operation mOp; + UUID mUuid; + byte[] mValue; + BluetoothGatt mGatt; + boolean mResult = true; + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + } + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + mValue = value; + } + + public void run() { + // This is executed in main thread + BluetoothGattCharacteristic chr; + + switch (mOp) { + case CHR_READ: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Reading characteristic " + chr.getUuid()); + if (!mGatt.readCharacteristic(chr)) { + Log.e(TAG, "Unable to read characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case CHR_WRITE: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value)); + chr.setValue(mValue); + if (!mGatt.writeCharacteristic(chr)) { + Log.e(TAG, "Unable to write characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case ENABLE_NOTIFICATION: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing descriptor of " + chr.getUuid()); + if (chr != null) { + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + int properties = chr.getProperties(); + byte[] value; + if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) { + value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; + } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) { + value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; + } else { + Log.e(TAG, "Unable to start notifications on input characteristic"); + mResult = false; + return; + } + + mGatt.setCharacteristicNotification(chr, true); + cccd.setValue(value); + if (!mGatt.writeDescriptor(cccd)) { + Log.e(TAG, "Unable to write descriptor " + mUuid.toString()); + mResult = false; + return; + } + mResult = true; + } + } + } + } + + public boolean finish() { + return mResult; + } + + private BluetoothGattCharacteristic getCharacteristic(UUID uuid) { + BluetoothGattService valveService = mGatt.getService(steamControllerService); + if (valveService == null) + return null; + return valveService.getCharacteristic(uuid); + } + + static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.CHR_READ, uuid); + } + + static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) { + return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value); + } + + static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid); + } + } + + HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) { + mManager = manager; + mDevice = device; + mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier()); + mIsRegistered = false; + mIsChromebook = SDLActivity.isChromebook(); + mOperations = new LinkedList(); + mHandler = new Handler(Looper.getMainLooper()); + + mGatt = connectGatt(); + // final HIDDeviceBLESteamController finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // void run() { + // finalThis.checkConnectionForChromebookIssue(); + // } + // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + String getIdentifier() { + return String.format("SteamController.%s", mDevice.getAddress()); + } + + BluetoothGatt getGatt() { + return mGatt; + } + + // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead + // of TRANSPORT_LE. Let's force ourselves to connect low energy. + private BluetoothGatt connectGatt(boolean managed) { + if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) { + try { + return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE); + } catch (Exception e) { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } else { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } + + private BluetoothGatt connectGatt() { + return connectGatt(false); + } + + protected int getConnectionState() { + + Context context = mManager.getContext(); + if (context == null) { + // We are lacking any context to get our Bluetooth information. We'll just assume disconnected. + return BluetoothProfile.STATE_DISCONNECTED; + } + + BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE); + if (btManager == null) { + // This device doesn't support Bluetooth. We should never be here, because how did + // we instantiate a device to start with? + return BluetoothProfile.STATE_DISCONNECTED; + } + + return btManager.getConnectionState(mDevice, BluetoothProfile.GATT); + } + + void reconnect() { + + if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) { + mGatt.disconnect(); + mGatt = connectGatt(); + } + + } + + protected void checkConnectionForChromebookIssue() { + if (!mIsChromebook) { + // We only do this on Chromebooks, because otherwise it's really annoying to just attempt + // over and over. + return; + } + + int connectionState = getConnectionState(); + + switch (connectionState) { + case BluetoothProfile.STATE_CONNECTED: + if (!mIsConnected) { + // We are in the Bad Chromebook Place. We can force a disconnect + // to try to recover. + Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + else if (!isRegistered()) { + if (mGatt.getServices().size() > 0) { + Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover."); + probeService(this); + } + else { + Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + } + else { + Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!"); + return; + } + break; + + case BluetoothProfile.STATE_DISCONNECTED: + Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover."); + + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + + case BluetoothProfile.STATE_CONNECTING: + Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer."); + break; + } + + final HIDDeviceBLESteamController finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.checkConnectionForChromebookIssue(); + } + }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + private boolean isRegistered() { + return mIsRegistered; + } + + private void setRegistered() { + mIsRegistered = true; + } + + private boolean probeService(HIDDeviceBLESteamController controller) { + + if (isRegistered()) { + return true; + } + + if (!mIsConnected) { + return false; + } + + Log.v(TAG, "probeService controller=" + controller); + + for (BluetoothGattService service : mGatt.getServices()) { + if (service.getUuid().equals(steamControllerService)) { + Log.v(TAG, "Found Valve steam controller service " + service.getUuid()); + + for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { + if (chr.getUuid().equals(inputCharacteristic)) { + Log.v(TAG, "Found input characteristic"); + // Start notifications + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + enableNotification(chr.getUuid()); + } + } + } + return true; + } + } + + if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) { + Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us."); + mIsConnected = false; + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + } + + return false; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private void finishCurrentGattOperation() { + GattOperation op = null; + synchronized (mOperations) { + if (mCurrentOperation != null) { + op = mCurrentOperation; + mCurrentOperation = null; + } + } + if (op != null) { + boolean result = op.finish(); // TODO: Maybe in main thread as well? + + // Our operation failed, let's add it back to the beginning of our queue. + if (!result) { + mOperations.addFirst(op); + } + } + executeNextGattOperation(); + } + + private void executeNextGattOperation() { + synchronized (mOperations) { + if (mCurrentOperation != null) + return; + + if (mOperations.isEmpty()) + return; + + mCurrentOperation = mOperations.removeFirst(); + } + + // Run in main thread + mHandler.post(new Runnable() { + @Override + public void run() { + synchronized (mOperations) { + if (mCurrentOperation == null) { + Log.e(TAG, "Current operation null in executor?"); + return; + } + + mCurrentOperation.run(); + // now wait for the GATT callback and when it comes, finish this operation + } + } + }); + } + + private void queueGattOperation(GattOperation op) { + synchronized (mOperations) { + mOperations.add(op); + } + executeNextGattOperation(); + } + + private void enableNotification(UUID chrUuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid); + queueGattOperation(op); + } + + void writeCharacteristic(UUID uuid, byte[] value) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value); + queueGattOperation(op); + } + + void readCharacteristic(UUID uuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid); + queueGattOperation(op); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////// BluetoothGattCallback overridden methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { + //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState); + mIsReconnecting = false; + if (newState == 2) { + mIsConnected = true; + // Run directly, without GattOperation + if (!isRegistered()) { + mHandler.post(new Runnable() { + @Override + public void run() { + mGatt.discoverServices(); + } + }); + } + } + else if (newState == 0) { + mIsConnected = false; + } + + // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent. + } + + @Override + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onServicesDiscovered status=" + status); + if (status == 0) { + if (gatt.getServices().size() == 0) { + Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack."); + mIsReconnecting = true; + mIsConnected = false; + gatt.disconnect(); + mGatt = connectGatt(false); + } + else { + probeService(this); + } + } + } + + @Override + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) { + mManager.HIDDeviceReportResponse(getId(), characteristic.getValue()); + } + + finishCurrentGattOperation(); + } + + @Override + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic)) { + // Only register controller with the native side once it has been fully configured + if (!isRegistered()) { + Log.v(TAG, "Registering Steam Controller with ID: " + getId()); + mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true); + setRegistered(); + } + } + + finishCurrentGattOperation(); + } + + @Override + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + // Enable this for verbose logging of controller input reports + //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue())); + + if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) { + mManager.HIDDeviceInputReport(getId(), characteristic.getValue()); + } + } + + @Override + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + //Log.v(TAG, "onDescriptorRead status=" + status); + } + + @Override + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + BluetoothGattCharacteristic chr = descriptor.getCharacteristic(); + //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid()); + + if (chr.getUuid().equals(inputCharacteristic)) { + boolean hasWrittenInputDescriptor = true; + BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic); + if (reportChr != null) { + Log.v(TAG, "Writing report characteristic to enter valve mode"); + reportChr.setValue(enterValveMode); + gatt.writeCharacteristic(reportChr); + } + } + + finishCurrentGattOperation(); + } + + @Override + public void onReliableWriteCompleted(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onReliableWriteCompleted status=" + status); + } + + @Override + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + //Log.v(TAG, "onReadRemoteRssi status=" + status); + } + + @Override + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { + //Log.v(TAG, "onMtuChanged status=" + status); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + //////// Public API + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + // Valve Corporation + final int VALVE_USB_VID = 0x28DE; + return VALVE_USB_VID; + } + + @Override + public int getProductId() { + // We don't have an easy way to query from the Bluetooth device, but we know what it is + final int D0G_BLE2_PID = 0x1106; + return D0G_BLE2_PID; + } + + @Override + public String getSerialNumber() { + // This will be read later via feature report by Steam + return "12345"; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + return "Valve Corporation"; + } + + @Override + public String getProductName() { + return "Steam Controller"; + } + + @Override + public UsbDevice getDevice() { + return null; + } + + @Override + public boolean open() { + return true; + } + + @Override + public int writeReport(byte[] report, boolean feature) { + if (!isRegistered()) { + Log.e(TAG, "Attempted writeReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return -1; + } + + if (feature) { + // We need to skip the first byte, as that doesn't go over the air + byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1); + //Log.v(TAG, "writeFeatureReport " + HexDump.dumpHexString(actual_report)); + writeCharacteristic(reportCharacteristic, actual_report); + return report.length; + } else { + //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report)); + writeCharacteristic(reportCharacteristic, report); + return report.length; + } + } + + @Override + public boolean readReport(byte[] report, boolean feature) { + if (!isRegistered()) { + Log.e(TAG, "Attempted readReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return false; + } + + if (feature) { + readCharacteristic(reportCharacteristic); + return true; + } else { + // Not implemented + return false; + } + } + + @Override + public void close() { + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + @Override + public void shutdown() { + close(); + + BluetoothGatt g = mGatt; + if (g != null) { + g.disconnect(); + g.close(); + mGatt = null; + } + mManager = null; + mIsRegistered = false; + mIsConnected = false; + mOperations.clear(); + } + +} + diff --git a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java new file mode 100644 index 0000000000..1fb2bfb4a7 --- /dev/null +++ b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java @@ -0,0 +1,691 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.PendingIntent; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.os.Build; +import android.util.Log; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.hardware.usb.*; +import android.os.Handler; +import android.os.Looper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; + +public class HIDDeviceManager { + private static final String TAG = "hidapi"; + private static final String ACTION_USB_PERMISSION = "org.libsdl.app.USB_PERMISSION"; + + private static HIDDeviceManager sManager; + private static int sManagerRefCount = 0; + + static public HIDDeviceManager acquire(Context context) { + if (sManagerRefCount == 0) { + sManager = new HIDDeviceManager(context); + } + ++sManagerRefCount; + return sManager; + } + + static public void release(HIDDeviceManager manager) { + if (manager == sManager) { + --sManagerRefCount; + if (sManagerRefCount == 0) { + sManager.close(); + sManager = null; + } + } + } + + private Context mContext; + private HashMap mDevicesById = new HashMap(); + private HashMap mBluetoothDevices = new HashMap(); + private int mNextDeviceId = 0; + private SharedPreferences mSharedPreferences = null; + private boolean mIsChromebook = false; + private UsbManager mUsbManager; + private Handler mHandler; + private BluetoothManager mBluetoothManager; + private List mLastBluetoothDevices; + + private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceAttached(usbDevice); + } else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceDetached(usbDevice); + } else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)); + } + } + }; + + private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + // Bluetooth device was connected. If it was a Steam Controller, handle it + if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device connected: " + device); + + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + } + + // Bluetooth device was disconnected, remove from controller manager (if any) + if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device disconnected: " + device); + + disconnectBluetoothDevice(device); + } + } + }; + + private HIDDeviceManager(final Context context) { + mContext = context; + + HIDDeviceRegisterCallback(); + + mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE); + mIsChromebook = SDLActivity.isChromebook(); + +// if (shouldClear) { +// SharedPreferences.Editor spedit = mSharedPreferences.edit(); +// spedit.clear(); +// spedit.apply(); +// } +// else + { + mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0); + } + } + + Context getContext() { + return mContext; + } + + int getDeviceIDForIdentifier(String identifier) { + SharedPreferences.Editor spedit = mSharedPreferences.edit(); + + int result = mSharedPreferences.getInt(identifier, 0); + if (result == 0) { + result = mNextDeviceId++; + spedit.putInt("next_device_id", mNextDeviceId); + } + + spedit.putInt(identifier, result); + spedit.apply(); + return result; + } + + private void initializeUSB() { + mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE); + if (mUsbManager == null) { + return; + } + + /* + // Logging + for (UsbDevice device : mUsbManager.getDeviceList().values()) { + Log.i(TAG,"Path: " + device.getDeviceName()); + Log.i(TAG,"Manufacturer: " + device.getManufacturerName()); + Log.i(TAG,"Product: " + device.getProductName()); + Log.i(TAG,"ID: " + device.getDeviceId()); + Log.i(TAG,"Class: " + device.getDeviceClass()); + Log.i(TAG,"Protocol: " + device.getDeviceProtocol()); + Log.i(TAG,"Vendor ID " + device.getVendorId()); + Log.i(TAG,"Product ID: " + device.getProductId()); + Log.i(TAG,"Interface count: " + device.getInterfaceCount()); + Log.i(TAG,"---------------------------------------"); + + // Get interface details + for (int index = 0; index < device.getInterfaceCount(); index++) { + UsbInterface mUsbInterface = device.getInterface(index); + Log.i(TAG," ***** *****"); + Log.i(TAG," Interface index: " + index); + Log.i(TAG," Interface ID: " + mUsbInterface.getId()); + Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass()); + Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass()); + Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol()); + Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount()); + + // Get endpoint details + for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++) + { + UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi); + Log.i(TAG," ++++ ++++ ++++"); + Log.i(TAG," Endpoint index: " + epi); + Log.i(TAG," Attributes: " + mEndpoint.getAttributes()); + Log.i(TAG," Direction: " + mEndpoint.getDirection()); + Log.i(TAG," Number: " + mEndpoint.getEndpointNumber()); + Log.i(TAG," Interval: " + mEndpoint.getInterval()); + Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize()); + Log.i(TAG," Type: " + mEndpoint.getType()); + } + } + } + Log.i(TAG," No more devices connected."); + */ + + // Register for USB broadcasts and permission completions + IntentFilter filter = new IntentFilter(); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION); + if (Build.VERSION.SDK_INT >= 33) { /* Android 13.0 (TIRAMISU) */ + mContext.registerReceiver(mUsbBroadcast, filter, Context.RECEIVER_EXPORTED); + } else { + mContext.registerReceiver(mUsbBroadcast, filter); + } + + for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) { + handleUsbDeviceAttached(usbDevice); + } + } + + UsbManager getUSBManager() { + return mUsbManager; + } + + private void shutdownUSB() { + try { + mContext.unregisterReceiver(mUsbBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) { + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) { + return true; + } + if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) { + return true; + } + return false; + } + + private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB360_IFACE_SUBCLASS = 93; + final int XB360_IFACE_PROTOCOL = 1; // Wired + final int XB360W_IFACE_PROTOCOL = 129; // Wireless + final int[] SUPPORTED_VENDORS = { + 0x0079, // GPD Win 2 + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x046d, // Logitech + 0x056e, // Elecom + 0x06a3, // Saitek + 0x0738, // Mad Catz + 0x07ff, // Mad Catz + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x1038, // SteelSeries + 0x11c9, // Nacon + 0x12ab, // Unknown + 0x1430, // RedOctane + 0x146b, // BigBen + 0x1532, // Razer Sabertooth + 0x15e4, // Numark + 0x162e, // Joytech + 0x1689, // Razer Onza + 0x1949, // Lab126, Inc. + 0x1bad, // Harmonix + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2c22, // Qanba + 0x2dc8, // 8BitDo + 0x37d7, // Flydigi + 0x9886, // ASTRO Gaming + }; + + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS && + (usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL || + usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB1_IFACE_SUBCLASS = 71; + final int XB1_IFACE_PROTOCOL = 208; + final int[] SUPPORTED_VENDORS = { + 0x03f0, // HP + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x0738, // Mad Catz + 0x0b05, // ASUS + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x10f5, // Turtle Beach + 0x1532, // Razer Wildcat + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x294b, // Snakebyte + 0x2dc8, // 8BitDo + 0x2e24, // Hyperkin + 0x2e95, // SCUF + 0x3285, // Nacon + 0x3537, // GameSir + 0x366c, // ByoWave + }; + + if (usbInterface.getId() == 0 && + usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS && + usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private void handleUsbDeviceAttached(UsbDevice usbDevice) { + connectHIDDeviceUSB(usbDevice); + } + + private void handleUsbDeviceDetached(UsbDevice usbDevice) { + List devices = new ArrayList(); + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + devices.add(device.getId()); + } + } + for (int id : devices) { + HIDDevice device = mDevicesById.get(id); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) { + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + boolean opened = false; + if (permission_granted) { + opened = device.open(); + } + HIDDeviceOpenResult(device.getId(), opened); + } + } + } + + private void connectHIDDeviceUSB(UsbDevice usbDevice) { + synchronized (this) { + int interface_mask = 0; + for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) { + UsbInterface usbInterface = usbDevice.getInterface(interface_index); + if (isHIDDeviceInterface(usbDevice, usbInterface)) { + // Check to see if we've already added this interface + // This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive + int interface_id = usbInterface.getId(); + if ((interface_mask & (1 << interface_id)) != 0) { + continue; + } + interface_mask |= (1 << interface_id); + + HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index); + int id = device.getId(); + mDevicesById.put(id, device); + HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol(), false); + } + } + } + } + + private void initializeBluetooth() { + Log.d(TAG, "Initializing Bluetooth"); + + if (Build.VERSION.SDK_INT >= 31 /* Android 12 */ && + mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH_CONNECT, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH_CONNECT"); + return; + } + + if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ && + mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH"); + return; + } + + if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { + Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE"); + return; + } + + // Find bonded bluetooth controllers and create SteamControllers for them + mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE); + if (mBluetoothManager == null) { + // This device doesn't support Bluetooth. + return; + } + + BluetoothAdapter btAdapter = mBluetoothManager.getAdapter(); + if (btAdapter == null) { + // This device has Bluetooth support in the codebase, but has no available adapters. + return; + } + + // Get our bonded devices. + for (BluetoothDevice device : btAdapter.getBondedDevices()) { + + Log.d(TAG, "Bluetooth device available: " + device); + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + + } + + // NOTE: These don't work on Chromebooks, to my undying dismay. + IntentFilter filter = new IntentFilter(); + filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED); + filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED); + if (Build.VERSION.SDK_INT >= 33) { /* Android 13.0 (TIRAMISU) */ + mContext.registerReceiver(mBluetoothBroadcast, filter, Context.RECEIVER_EXPORTED); + } else { + mContext.registerReceiver(mBluetoothBroadcast, filter); + } + + if (mIsChromebook) { + mHandler = new Handler(Looper.getMainLooper()); + mLastBluetoothDevices = new ArrayList(); + + // final HIDDeviceManager finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.chromebookConnectionHandler(); + // } + // }, 5000); + } + } + + private void shutdownBluetooth() { + try { + mContext.unregisterReceiver(mBluetoothBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + // Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly. + // This function provides a sort of dummy version of that, watching for changes in the + // connected devices and attempting to add controllers as things change. + void chromebookConnectionHandler() { + if (!mIsChromebook) { + return; + } + + ArrayList disconnected = new ArrayList(); + ArrayList connected = new ArrayList(); + + List currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT); + + for (BluetoothDevice bluetoothDevice : currentConnected) { + if (!mLastBluetoothDevices.contains(bluetoothDevice)) { + connected.add(bluetoothDevice); + } + } + for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) { + if (!currentConnected.contains(bluetoothDevice)) { + disconnected.add(bluetoothDevice); + } + } + + mLastBluetoothDevices = currentConnected; + + for (BluetoothDevice bluetoothDevice : disconnected) { + disconnectBluetoothDevice(bluetoothDevice); + } + for (BluetoothDevice bluetoothDevice : connected) { + connectBluetoothDevice(bluetoothDevice); + } + + final HIDDeviceManager finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.chromebookConnectionHandler(); + } + }, 10000); + } + + boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) { + Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice); + synchronized (this) { + if (mBluetoothDevices.containsKey(bluetoothDevice)) { + Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect"); + + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + device.reconnect(); + + return false; + } + HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice); + int id = device.getId(); + mBluetoothDevices.put(bluetoothDevice, device); + mDevicesById.put(id, device); + + // The Steam Controller will mark itself connected once initialization is complete + } + return true; + } + + void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) { + synchronized (this) { + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + if (device == null) + return; + + int id = device.getId(); + mBluetoothDevices.remove(bluetoothDevice); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + boolean isSteamController(BluetoothDevice bluetoothDevice) { + // Sanity check. If you pass in a null device, by definition it is never a Steam Controller. + if (bluetoothDevice == null) { + return false; + } + + // If the device has no local name, we really don't want to try an equality check against it. + if (bluetoothDevice.getName() == null) { + return false; + } + + return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0); + } + + private void close() { + shutdownUSB(); + shutdownBluetooth(); + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.shutdown(); + } + mDevicesById.clear(); + mBluetoothDevices.clear(); + HIDDeviceReleaseCallback(); + } + } + + public void setFrozen(boolean frozen) { + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.setFrozen(frozen); + } + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private HIDDevice getDevice(int id) { + synchronized (this) { + HIDDevice result = mDevicesById.get(id); + if (result == null) { + Log.v(TAG, "No device for id: " + id); + Log.v(TAG, "Available devices: " + mDevicesById.keySet()); + } + return result; + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////// JNI interface functions + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + boolean initialize(boolean usb, boolean bluetooth) { + Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")"); + + if (usb) { + initializeUSB(); + } + if (bluetooth) { + initializeBluetooth(); + } + return true; + } + + boolean openDevice(int deviceID) { + Log.v(TAG, "openDevice deviceID=" + deviceID); + HIDDevice device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + // Look to see if this is a USB device and we have permission to access it + UsbDevice usbDevice = device.getDevice(); + if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) { + HIDDeviceOpenPending(deviceID); + try { + final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31 + int flags; + if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) { + flags = FLAG_MUTABLE; + } else { + flags = 0; + } + + Intent intent = new Intent(HIDDeviceManager.ACTION_USB_PERMISSION); + intent.setPackage(mContext.getPackageName()); + mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, intent, flags)); + } catch (Exception e) { + Log.v(TAG, "Couldn't request permission for USB device " + usbDevice); + HIDDeviceOpenResult(deviceID, false); + } + return false; + } + + try { + return device.open(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + int writeReport(int deviceID, byte[] report, boolean feature) { + try { + //Log.v(TAG, "writeReport deviceID=" + deviceID + " length=" + report.length); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return -1; + } + + return device.writeReport(report, feature); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return -1; + } + + boolean readReport(int deviceID, byte[] report, boolean feature) { + try { + //Log.v(TAG, "readReport deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + return device.readReport(report, feature); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + void closeDevice(int deviceID) { + try { + Log.v(TAG, "closeDevice deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return; + } + + device.close(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + } + + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /////////////// Native methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private native void HIDDeviceRegisterCallback(); + private native void HIDDeviceReleaseCallback(); + + native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol, boolean bBluetooth); + native void HIDDeviceOpenPending(int deviceID); + native void HIDDeviceOpenResult(int deviceID, boolean opened); + native void HIDDeviceDisconnected(int deviceID); + + native void HIDDeviceInputReport(int deviceID, byte[] report); + native void HIDDeviceReportResponse(int deviceID, byte[] report); +} diff --git a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java new file mode 100644 index 0000000000..f9e9389802 --- /dev/null +++ b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java @@ -0,0 +1,313 @@ +package org.libsdl.app; + +import android.hardware.usb.*; +import android.os.Build; +import android.util.Log; +import java.util.Arrays; +import java.util.Locale; + +class HIDDeviceUSB implements HIDDevice { + + private static final String TAG = "hidapi"; + + protected HIDDeviceManager mManager; + protected UsbDevice mDevice; + protected int mInterfaceIndex; + protected int mInterface; + protected int mDeviceId; + protected UsbDeviceConnection mConnection; + protected UsbEndpoint mInputEndpoint; + protected UsbEndpoint mOutputEndpoint; + protected InputThread mInputThread; + protected boolean mRunning; + protected boolean mFrozen; + + public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) { + mManager = manager; + mDevice = usbDevice; + mInterfaceIndex = interface_index; + mInterface = mDevice.getInterface(mInterfaceIndex).getId(); + mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier()); + mRunning = false; + } + + String getIdentifier() { + return String.format(Locale.ENGLISH, "%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex); + } + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + return mDevice.getVendorId(); + } + + @Override + public int getProductId() { + return mDevice.getProductId(); + } + + @Override + public String getSerialNumber() { + String result = null; + try { + result = mDevice.getSerialNumber(); + } + catch (SecurityException exception) { + //Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage()); + } + if (result == null) { + result = ""; + } + return result; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + String result; + result = mDevice.getManufacturerName(); + if (result == null) { + result = String.format("%x", getVendorId()); + } + return result; + } + + @Override + public String getProductName() { + String result; + result = mDevice.getProductName(); + if (result == null) { + result = String.format("%x", getProductId()); + } + return result; + } + + @Override + public UsbDevice getDevice() { + return mDevice; + } + + String getDeviceName() { + return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")"; + } + + @Override + public boolean open() { + mConnection = mManager.getUSBManager().openDevice(mDevice); + if (mConnection == null) { + Log.w(TAG, "Unable to open USB device " + getDeviceName()); + return false; + } + + // Force claim our interface + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + if (!mConnection.claimInterface(iface, true)) { + Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName()); + close(); + return false; + } + + // Find the endpoints + for (int j = 0; j < iface.getEndpointCount(); j++) { + UsbEndpoint endpt = iface.getEndpoint(j); + switch (endpt.getDirection()) { + case UsbConstants.USB_DIR_IN: + if (mInputEndpoint == null) { + mInputEndpoint = endpt; + } + break; + case UsbConstants.USB_DIR_OUT: + if (mOutputEndpoint == null) { + mOutputEndpoint = endpt; + } + break; + } + } + + // Make sure the required endpoints were present + if (mInputEndpoint == null || mOutputEndpoint == null) { + Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName()); + close(); + return false; + } + + // Start listening for input + mRunning = true; + mInputThread = new InputThread(); + mInputThread.start(); + + return true; + } + + @Override + public int writeReport(byte[] report, boolean feature) { + if (mConnection == null) { + Log.w(TAG, "writeReport() called with no device connection"); + return -1; + } + + if (feature) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (report_number == 0x0) { + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT, + 0x09/*HID set_report*/, + (3/*HID feature*/ << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "writeFeatureReport() returned " + res + " on device " + getDeviceName()); + return -1; + } + + if (skipped_report_id) { + ++length; + } + return length; + } else { + int res = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000); + if (res != report.length) { + Log.w(TAG, "writeOutputReport() returned " + res + " on device " + getDeviceName()); + } + return res; + } + } + + @Override + public boolean readReport(byte[] report, boolean feature) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (mConnection == null) { + Log.w(TAG, "readReport() called with no device connection"); + return false; + } + + if (report_number == 0x0) { + /* Offset the return buffer by 1, so that the report ID + will remain in byte 0. */ + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN, + 0x01/*HID get_report*/, + ((feature ? 3/*HID feature*/ : 1/*HID Input*/) << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName()); + return false; + } + + if (skipped_report_id) { + ++res; + ++length; + } + + byte[] data; + if (res == length) { + data = report; + } else { + data = Arrays.copyOfRange(report, 0, res); + } + mManager.HIDDeviceReportResponse(mDeviceId, data); + + return true; + } + + @Override + public void close() { + mRunning = false; + if (mInputThread != null) { + while (mInputThread.isAlive()) { + mInputThread.interrupt(); + try { + mInputThread.join(); + } catch (InterruptedException e) { + // Keep trying until we're done + } + } + mInputThread = null; + } + if (mConnection != null) { + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + mConnection.releaseInterface(iface); + mConnection.close(); + mConnection = null; + } + } + + @Override + public void shutdown() { + close(); + mManager = null; + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + protected class InputThread extends Thread { + @Override + public void run() { + int packetSize = mInputEndpoint.getMaxPacketSize(); + byte[] packet = new byte[packetSize]; + while (mRunning) { + int r; + try + { + r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000); + } + catch (Exception e) + { + Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e); + break; + } + if (r < 0) { + // Could be a timeout or an I/O error + } + if (r > 0) { + byte[] data; + if (r == packetSize) { + data = packet; + } else { + data = Arrays.copyOfRange(packet, 0, r); + } + + if (!mFrozen) { + mManager.HIDDeviceInputReport(mDeviceId, data); + } + } + } + } + } +} diff --git a/platforms/android/app/src/main/java/org/libsdl/app/SDL.java b/platforms/android/app/src/main/java/org/libsdl/app/SDL.java new file mode 100644 index 0000000000..d9650a72e4 --- /dev/null +++ b/platforms/android/app/src/main/java/org/libsdl/app/SDL.java @@ -0,0 +1,90 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.content.Context; + +import java.lang.reflect.Method; + +/** + SDL library initialization +*/ +public class SDL { + + // This function should be called first and sets up the native code + // so it can call into the Java classes + static public void setupJNI() { + SDLActivity.nativeSetupJNI(); + SDLAudioManager.nativeSetupJNI(); + SDLControllerManager.nativeSetupJNI(); + } + + // This function should be called each time the activity is started + static public void initialize() { + setContext(null); + + SDLActivity.initialize(); + SDLAudioManager.initialize(); + SDLControllerManager.initialize(); + } + + // This function stores the current activity (SDL or not) + static public void setContext(Activity context) { + SDLAudioManager.setContext(context); + mContext = context; + } + + static public Activity getContext() { + return mContext; + } + + static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + loadLibrary(libraryName, mContext); + } + + static void loadLibrary(String libraryName, Context context) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + + if (libraryName == null) { + throw new NullPointerException("No library name provided."); + } + + try { + // Let's see if we have ReLinker available in the project. This is necessary for + // some projects that have huge numbers of local libraries bundled, and thus may + // trip a bug in Android's native library loader which ReLinker works around. (If + // loadLibrary works properly, ReLinker will simply use the normal Android method + // internally.) + // + // To use ReLinker, just add it as a dependency. For more information, see + // https://github.com/KeepSafe/ReLinker for ReLinker's repository. + // + Class relinkClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker"); + Class relinkListenerClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener"); + Class contextClass = context.getClassLoader().loadClass("android.content.Context"); + Class stringClass = context.getClassLoader().loadClass("java.lang.String"); + + // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if + // they've changed during updates. + Method forceMethod = relinkClass.getDeclaredMethod("force"); + Object relinkInstance = forceMethod.invoke(null); + Class relinkInstanceClass = relinkInstance.getClass(); + + // Actually load the library! + Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass); + loadMethod.invoke(relinkInstance, context, libraryName, null, null); + } + catch (final Throwable e) { + // Fall back + try { + System.loadLibrary(libraryName); + } + catch (final UnsatisfiedLinkError ule) { + throw ule; + } + catch (final SecurityException se) { + throw se; + } + } + } + + protected static Activity mContext; +} diff --git a/platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java b/platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java new file mode 100644 index 0000000000..42f5a911f7 --- /dev/null +++ b/platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java @@ -0,0 +1,2229 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.UiModeManager; +import android.content.ActivityNotFoundException; +import android.content.ClipboardManager; +import android.content.ClipData; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.hardware.Sensor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.LocaleList; +import android.os.Message; +import android.os.ParcelFileDescriptor; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.SparseArray; +import android.view.Display; +import android.view.Gravity; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.PointerIcon; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.webkit.MimeTypeMap; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +import java.io.FileNotFoundException; +import java.util.ArrayList; +import java.util.Hashtable; +import java.util.Locale; + + +/** + SDL Activity +*/ +public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener { + private static final String TAG = "SDL"; + private static final int SDL_MAJOR_VERSION = 3; + private static final int SDL_MINOR_VERSION = 4; + private static final int SDL_MICRO_VERSION = 4; +/* + // Display InputType.SOURCE/CLASS of events and devices + // + // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]"); + // SDLActivity.debugSource(event.getSource(), "event"); + public static void debugSource(int sources, String prefix) { + int s = sources; + int s_copy = sources; + String cls = ""; + String src = ""; + int tst = 0; + int FLAG_TAINTED = 0x80000000; + + if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0) cls += " BUTTON"; + if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) cls += " JOYSTICK"; + if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0) cls += " POINTER"; + if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0) cls += " POSITION"; + if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) cls += " TRACKBALL"; + + + int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits + s2 &= ~( InputDevice.SOURCE_CLASS_BUTTON + | InputDevice.SOURCE_CLASS_JOYSTICK + | InputDevice.SOURCE_CLASS_POINTER + | InputDevice.SOURCE_CLASS_POSITION + | InputDevice.SOURCE_CLASS_TRACKBALL); + + if (s2 != 0) cls += "Some_Unknown"; + + s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class; + + if (Build.VERSION.SDK_INT >= 23) { + tst = InputDevice.SOURCE_BLUETOOTH_STYLUS; + if ((s & tst) == tst) src += " BLUETOOTH_STYLUS"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_DPAD; + if ((s & tst) == tst) src += " DPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_GAMEPAD; + if ((s & tst) == tst) src += " GAMEPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_HDMI; + if ((s & tst) == tst) src += " HDMI"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_JOYSTICK; + if ((s & tst) == tst) src += " JOYSTICK"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_KEYBOARD; + if ((s & tst) == tst) src += " KEYBOARD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_MOUSE; + if ((s & tst) == tst) src += " MOUSE"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 26) { + tst = InputDevice.SOURCE_MOUSE_RELATIVE; + if ((s & tst) == tst) src += " MOUSE_RELATIVE"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ROTARY_ENCODER; + if ((s & tst) == tst) src += " ROTARY_ENCODER"; + s2 &= ~tst; + } + tst = InputDevice.SOURCE_STYLUS; + if ((s & tst) == tst) src += " STYLUS"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHPAD; + if ((s & tst) == tst) src += " TOUCHPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHSCREEN; + if ((s & tst) == tst) src += " TOUCHSCREEN"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCH_NAVIGATION; + if ((s & tst) == tst) src += " TOUCH_NAVIGATION"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TRACKBALL; + if ((s & tst) == tst) src += " TRACKBALL"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ANY; + if ((s & tst) == tst) src += " ANY"; + s2 &= ~tst; + + if (s == FLAG_TAINTED) src += " FLAG_TAINTED"; + s2 &= ~FLAG_TAINTED; + + if (s2 != 0) src += " Some_Unknown"; + + Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src); + } +*/ + + public static boolean mIsResumedCalled, mHasFocus; + public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */); + + // Cursor types + // private static final int SDL_SYSTEM_CURSOR_NONE = -1; + private static final int SDL_SYSTEM_CURSOR_ARROW = 0; + private static final int SDL_SYSTEM_CURSOR_IBEAM = 1; + private static final int SDL_SYSTEM_CURSOR_WAIT = 2; + private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3; + private static final int SDL_SYSTEM_CURSOR_WAITARROW = 4; + private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5; + private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6; + private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7; + private static final int SDL_SYSTEM_CURSOR_SIZENS = 8; + private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9; + private static final int SDL_SYSTEM_CURSOR_NO = 10; + private static final int SDL_SYSTEM_CURSOR_HAND = 11; + private static final int SDL_SYSTEM_CURSOR_WINDOW_TOPLEFT = 12; + private static final int SDL_SYSTEM_CURSOR_WINDOW_TOP = 13; + private static final int SDL_SYSTEM_CURSOR_WINDOW_TOPRIGHT = 14; + private static final int SDL_SYSTEM_CURSOR_WINDOW_RIGHT = 15; + private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOMRIGHT = 16; + private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOM = 17; + private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOMLEFT = 18; + private static final int SDL_SYSTEM_CURSOR_WINDOW_LEFT = 19; + + protected static final int SDL_ORIENTATION_UNKNOWN = 0; + protected static final int SDL_ORIENTATION_LANDSCAPE = 1; + protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2; + protected static final int SDL_ORIENTATION_PORTRAIT = 3; + protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4; + + protected static int mCurrentRotation; + protected static Locale mCurrentLocale; + + // Handle the state of the native layer + public enum NativeState { + INIT, RESUMED, PAUSED + } + + public static NativeState mNextNativeState; + public static NativeState mCurrentNativeState; + + /** If shared libraries (e.g. SDL or the native application) could not be loaded. */ + public static boolean mBrokenLibraries = true; + + // Main components + protected static SDLActivity mSingleton; + protected static SDLSurface mSurface; + protected static SDLDummyEdit mTextEdit; + protected static ViewGroup mLayout; + protected static SDLClipboardHandler mClipboardHandler; + protected static Hashtable mCursors; + protected static int mLastCursorID; + protected static SDLGenericMotionListener_API14 mMotionListener; + protected static HIDDeviceManager mHIDDeviceManager; + + // This is what SDL runs in. It invokes SDL_main(), eventually + protected static Thread mSDLThread; + protected static boolean mSDLMainFinished = false; + protected static boolean mActivityCreated = false; + private static SDLFileDialogState mFileDialogState = null; + protected static boolean mDispatchingKeyEvent = false; + + public static SDLGenericMotionListener_API14 getMotionListener() { + if (mMotionListener == null) { + if (Build.VERSION.SDK_INT >= 29 /* Android 10 (Q) */) { + mMotionListener = new SDLGenericMotionListener_API29(); + } else if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) { + mMotionListener = new SDLGenericMotionListener_API26(); + } else if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) { + mMotionListener = new SDLGenericMotionListener_API24(); + } else { + mMotionListener = new SDLGenericMotionListener_API14(); + } + } + + return mMotionListener; + } + + /** + * The application entry point, called on a dedicated thread (SDLThread). + * The default implementation uses the getMainSharedObject() and getMainFunction() methods + * to invoke native code from the specified shared library. + * It can be overridden by derived classes. + */ + protected void main() { + String library = SDLActivity.mSingleton.getMainSharedObject(); + String function = SDLActivity.mSingleton.getMainFunction(); + String[] arguments = SDLActivity.mSingleton.getArguments(); + + Log.v("SDL", "Running main function " + function + " from library " + library); + SDLActivity.nativeRunMain(library, function, arguments); + Log.v("SDL", "Finished main function"); + } + + /** + * This method returns the name of the shared object with the application entry point + * It can be overridden by derived classes. + */ + protected String getMainSharedObject() { + String library; + String[] libraries = SDLActivity.mSingleton.getLibraries(); + if (libraries.length > 0) { + library = "lib" + libraries[libraries.length - 1] + ".so"; + } else { + library = "libmain.so"; + } + return getContext().getApplicationInfo().nativeLibraryDir + "/" + library; + } + + /** + * This method returns the name of the application entry point + * It can be overridden by derived classes. + */ + protected String getMainFunction() { + return "SDL_main"; + } + + /** + * This method is called by SDL before loading the native shared libraries. + * It can be overridden to provide names of shared libraries to be loaded. + * The default implementation returns the defaults. It never returns null. + * An array returned by a new implementation must at least contain "SDL3". + * Also keep in mind that the order the libraries are loaded may matter. + * @return names of shared libraries to be loaded (e.g. "SDL3", "main"). + */ + protected String[] getLibraries() { + return new String[] { + "SDL3", + // "SDL3_image", + // "SDL3_mixer", + // "SDL3_net", + // "SDL3_ttf", + "main" + }; + } + + // Load the .so + public void loadLibraries() { + for (String lib : getLibraries()) { + SDL.loadLibrary(lib, this); + } + } + + /** + * This method is called by SDL before starting the native application thread. + * It can be overridden to provide the arguments after the application name. + * The default implementation returns an empty array. It never returns null. + * @return arguments for the native application. + */ + protected String[] getArguments() { + return new String[0]; + } + + public static void initialize() { + // The static nature of the singleton and Android quirkyness force us to initialize everything here + // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values + mSingleton = null; + mSurface = null; + mTextEdit = null; + mLayout = null; + mClipboardHandler = null; + mCursors = new Hashtable(); + mLastCursorID = 0; + mSDLThread = null; + mIsResumedCalled = false; + mHasFocus = true; + mNextNativeState = NativeState.INIT; + mCurrentNativeState = NativeState.INIT; + } + + protected SDLSurface createSDLSurface(Context context) { + return new SDLSurface(context); + } + + // Setup + @Override + protected void onCreate(Bundle savedInstanceState) { + Log.v(TAG, "Manufacturer: " + Build.MANUFACTURER); + Log.v(TAG, "Device: " + Build.DEVICE); + Log.v(TAG, "Model: " + Build.MODEL); + Log.v(TAG, "onCreate()"); + super.onCreate(savedInstanceState); + + + /* Control activity re-creation */ + if (mSDLMainFinished || mActivityCreated) { + boolean allow_recreate = SDLActivity.nativeAllowRecreateActivity(); + if (mSDLMainFinished) { + Log.v(TAG, "SDL main() finished"); + } + if (allow_recreate) { + Log.v(TAG, "activity re-created"); + } else { + Log.v(TAG, "activity finished"); + System.exit(0); + return; + } + } + + mActivityCreated = true; + + try { + Thread.currentThread().setName("SDLActivity"); + } catch (Exception e) { + Log.v(TAG, "modify thread properties failed " + e.toString()); + } + + // Load shared libraries + String errorMsgBrokenLib = ""; + try { + loadLibraries(); + mBrokenLibraries = false; /* success */ + } catch(UnsatisfiedLinkError e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } catch(Exception e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } + + if (!mBrokenLibraries) { + String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." + + String.valueOf(SDL_MINOR_VERSION) + "." + + String.valueOf(SDL_MICRO_VERSION); + String version = nativeGetVersion(); + if (!version.equals(expected_version)) { + mBrokenLibraries = true; + errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")"; + } + } + + if (mBrokenLibraries) { + mSingleton = this; + AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this); + dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall." + + System.getProperty("line.separator") + + System.getProperty("line.separator") + + "Error: " + errorMsgBrokenLib); + dlgAlert.setTitle("SDL Error"); + dlgAlert.setPositiveButton("Exit", + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog,int id) { + // if this button is clicked, close current activity + SDLActivity.mSingleton.finish(); + } + }); + dlgAlert.setCancelable(false); + dlgAlert.create().show(); + + return; + } + + + /* Control activity re-creation */ + /* Robustness: check that the native code is run for the first time. + * (Maybe Activity was reset, but not the native code.) */ + { + int run_count = SDLActivity.nativeCheckSDLThreadCounter(); /* get and increment a native counter */ + if (run_count != 0) { + boolean allow_recreate = SDLActivity.nativeAllowRecreateActivity(); + if (allow_recreate) { + Log.v(TAG, "activity re-created // run_count: " + run_count); + } else { + Log.v(TAG, "activity finished // run_count: " + run_count); + System.exit(0); + return; + } + } + } + + // Set up JNI + SDL.setupJNI(); + + // Initialize state + SDL.initialize(); + + // So we can call stuff from static callbacks + mSingleton = this; + SDL.setContext(this); + + mClipboardHandler = new SDLClipboardHandler(); + + mHIDDeviceManager = HIDDeviceManager.acquire(this); + + // Set up the surface + mSurface = createSDLSurface(this); + + mLayout = new RelativeLayout(this); + mLayout.addView(mSurface); + + // Get our current screen orientation and pass it down. + SDLActivity.nativeSetNaturalOrientation(SDLActivity.getNaturalOrientation()); + mCurrentRotation = SDLActivity.getCurrentRotation(); + SDLActivity.onNativeRotationChanged(mCurrentRotation); + + try { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + mCurrentLocale = getContext().getResources().getConfiguration().locale; + } else { + mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0); + } + } catch(Exception ignored) { + } + + switch (getContext().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) { + case Configuration.UI_MODE_NIGHT_NO: + SDLActivity.onNativeDarkModeChanged(false); + break; + case Configuration.UI_MODE_NIGHT_YES: + SDLActivity.onNativeDarkModeChanged(true); + break; + } + + setContentView(mLayout); + + setWindowStyle(false); + + getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this); + + // Get filename from "Open with" of another application + Intent intent = getIntent(); + if (intent != null && intent.getData() != null) { + String filename = intent.getData().getPath(); + if (filename != null) { + Log.v(TAG, "Got filename: " + filename); + SDLActivity.onNativeDropFile(filename); + } + } + } + + protected void pauseNativeThread() { + mNextNativeState = NativeState.PAUSED; + mIsResumedCalled = false; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + protected void resumeNativeThread() { + mNextNativeState = NativeState.RESUMED; + mIsResumedCalled = true; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + // Events + @Override + protected void onPause() { + Log.v(TAG, "onPause()"); + super.onPause(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(true); + } + if (!mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onResume() { + Log.v(TAG, "onResume()"); + super.onResume(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(false); + } + if (!mHasMultiWindow) { + resumeNativeThread(); + } + } + + @Override + protected void onStop() { + Log.v(TAG, "onStop()"); + super.onStop(); + if (mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onStart() { + Log.v(TAG, "onStart()"); + super.onStart(); + if (mHasMultiWindow) { + resumeNativeThread(); + } + } + + public static int getNaturalOrientation() { + int result = SDL_ORIENTATION_UNKNOWN; + + Activity activity = (Activity)getContext(); + if (activity != null) { + Configuration config = activity.getResources().getConfiguration(); + Display display = activity.getWindowManager().getDefaultDisplay(); + int rotation = display.getRotation(); + if (((rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) && + config.orientation == Configuration.ORIENTATION_LANDSCAPE) || + ((rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) && + config.orientation == Configuration.ORIENTATION_PORTRAIT)) { + result = SDL_ORIENTATION_LANDSCAPE; + } else { + result = SDL_ORIENTATION_PORTRAIT; + } + } + return result; + } + + public static int getCurrentRotation() { + int result = 0; + + Activity activity = (Activity)getContext(); + if (activity != null) { + Display display = activity.getWindowManager().getDefaultDisplay(); + switch (display.getRotation()) { + case Surface.ROTATION_0: + result = 0; + break; + case Surface.ROTATION_90: + result = 90; + break; + case Surface.ROTATION_180: + result = 180; + break; + case Surface.ROTATION_270: + result = 270; + break; + } + } + return result; + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + Log.v(TAG, "onWindowFocusChanged(): " + hasFocus); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + mHasFocus = hasFocus; + if (hasFocus) { + mNextNativeState = NativeState.RESUMED; + SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded(); + + SDLActivity.handleNativeState(); + nativeFocusChanged(true); + + } else { + nativeFocusChanged(false); + if (!mHasMultiWindow) { + mNextNativeState = NativeState.PAUSED; + SDLActivity.handleNativeState(); + } + } + } + + @Override + public void onTrimMemory(int level) { + Log.v(TAG, "onTrimMemory()"); + super.onTrimMemory(level); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.nativeLowMemory(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.v(TAG, "onConfigurationChanged()"); + super.onConfigurationChanged(newConfig); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) { + mCurrentLocale = newConfig.locale; + SDLActivity.onNativeLocaleChanged(); + } + + switch (newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) { + case Configuration.UI_MODE_NIGHT_NO: + SDLActivity.onNativeDarkModeChanged(false); + break; + case Configuration.UI_MODE_NIGHT_YES: + SDLActivity.onNativeDarkModeChanged(true); + break; + } + } + + @Override + protected void onDestroy() { + Log.v(TAG, "onDestroy()"); + + if (mHIDDeviceManager != null) { + HIDDeviceManager.release(mHIDDeviceManager); + mHIDDeviceManager = null; + } + + SDLAudioManager.release(this); + + if (SDLActivity.mBrokenLibraries) { + super.onDestroy(); + return; + } + + if (SDLActivity.mSDLThread != null) { + + // Send Quit event to "SDLThread" thread + SDLActivity.nativeSendQuit(); + + // Wait for "SDLThread" thread to end + try { + // Use a timeout because: + // C SDLmain() thread might have started (mSDLThread.start() called) + // while the SDL_Init() might not have been called yet, + // and so the previous QUIT event will be discarded by SDL_Init() and app is running, not exiting. + SDLActivity.mSDLThread.join(1000); + } catch(Exception e) { + Log.v(TAG, "Problem stopping SDLThread: " + e); + } + } + + SDLActivity.nativeQuit(); + + super.onDestroy(); + } + + @Override + public void onBackPressed() { + // Check if we want to block the back button in case of mouse right click. + // + // If we do, the normal hardware back button will no longer work and people have to use home, + // but the mouse right click will work. + // + boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false); + if (trapBack) { + // Exit and let the mouse handler handle this button (if appropriate) + return; + } + + // Default system back button behavior. + if (!isFinishing()) { + super.onBackPressed(); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (mFileDialogState != null && mFileDialogState.requestCode == requestCode) { + /* This is our file dialog */ + String[] filelist = null; + + if (data != null) { + Uri singleFileUri = data.getData(); + + if (singleFileUri == null) { + /* Use Intent.getClipData to get multiple choices */ + ClipData clipData = data.getClipData(); + assert clipData != null; + + filelist = new String[clipData.getItemCount()]; + + for (int i = 0; i < filelist.length; i++) { + String uri = clipData.getItemAt(i).getUri().toString(); + filelist[i] = uri; + } + } else { + /* Only one file is selected. */ + filelist = new String[]{singleFileUri.toString()}; + } + } else { + /* User cancelled the request. */ + filelist = new String[0]; + } + + // TODO: Detect the file MIME type and pass the filter value accordingly. + SDLActivity.onNativeFileDialog(requestCode, filelist, -1); + mFileDialogState = null; + } + } + + // Called by JNI from SDL. + public static void manualBackButton() { + mSingleton.pressBackButton(); + } + + // Used to get us onto the activity's main thread + public void pressBackButton() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (!SDLActivity.this.isFinishing()) { + SDLActivity.this.superOnBackPressed(); + } + } + }); + } + + // Used to access the system back behavior. + public void superOnBackPressed() { + super.onBackPressed(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + + if (SDLActivity.mBrokenLibraries) { + return false; + } + + int keyCode = event.getKeyCode(); + // Ignore certain special keys so they're handled by Android + if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_CAMERA || + keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */ + keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */ + ) { + return false; + } + mDispatchingKeyEvent = true; + boolean result = super.dispatchKeyEvent(event); + mDispatchingKeyEvent = false; + return result; + } + + public static boolean dispatchingKeyEvent() { + return mDispatchingKeyEvent; + } + + /* Transition to next state */ + public static void handleNativeState() { + + if (mNextNativeState == mCurrentNativeState) { + // Already in same state, discard. + return; + } + + // Try a transition to init state + if (mNextNativeState == NativeState.INIT) { + + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to paused state + if (mNextNativeState == NativeState.PAUSED) { + if (mSDLThread != null) { + nativePause(); + } + if (mSurface != null) { + mSurface.handlePause(); + } + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to resumed state + if (mNextNativeState == NativeState.RESUMED) { + if (mSurface.mIsSurfaceReady && (mHasFocus || mHasMultiWindow) && mIsResumedCalled) { + if (mSDLThread == null) { + // This is the entry point to the C app. + // Start up the C app thread and enable sensor input for the first time + // FIXME: Why aren't we enabling sensor input at start? + + mSDLThread = new Thread(new SDLMain(), "SDLThread"); + mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true); + mSDLThread.start(); + + // No nativeResume(), don't signal Android_ResumeSem + } else { + nativeResume(); + } + mSurface.handleResume(); + + mCurrentNativeState = mNextNativeState; + } + } + } + + // Messages from the SDLMain thread + protected static final int COMMAND_CHANGE_TITLE = 1; + protected static final int COMMAND_CHANGE_WINDOW_STYLE = 2; + protected static final int COMMAND_TEXTEDIT_HIDE = 3; + protected static final int COMMAND_SET_KEEP_SCREEN_ON = 5; + protected static final int COMMAND_USER = 0x8000; + + protected static boolean mFullscreenModeActive; + + /** + * This method is called by SDL if SDL did not handle a message itself. + * This happens if a received message contains an unsupported command. + * Method can be overwritten to handle Messages in a different class. + * @param command the command of the message. + * @param param the parameter of the message. May be null. + * @return if the message was handled in overridden method. + */ + protected boolean onUnhandledMessage(int command, Object param) { + return false; + } + + /** + * A Handler class for Messages from native SDL applications. + * It uses current Activities as target (e.g. for the title). + * static to prevent implicit references to enclosing object. + */ + protected static class SDLCommandHandler extends Handler { + @Override + public void handleMessage(Message msg) { + Context context = getContext(); + if (context == null) { + Log.e(TAG, "error handling message, getContext() returned null"); + return; + } + switch (msg.arg1) { + case COMMAND_CHANGE_TITLE: + if (context instanceof Activity) { + ((Activity) context).setTitle((String)msg.obj); + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + break; + case COMMAND_CHANGE_WINDOW_STYLE: + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + int flags = View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + SDLActivity.mFullscreenModeActive = true; + } else { + int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + SDLActivity.mFullscreenModeActive = false; + } + if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */) { + window.getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; + } + if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */ && + Build.VERSION.SDK_INT < 35 /* Android 15 */) { + SDLActivity.onNativeInsetsChanged(0, 0, 0, 0); + } + } + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + break; + case COMMAND_TEXTEDIT_HIDE: + if (mTextEdit != null) { + // Note: On some devices setting view to GONE creates a flicker in landscape. + // Setting the View's sizes to 0 is similar to GONE but without the flicker. + // The sizes will be set to useful values when the keyboard is shown again. + mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0)); + + InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0); + + onNativeScreenKeyboardHidden(); + + mSurface.requestFocus(); + } + break; + case COMMAND_SET_KEEP_SCREEN_ON: + { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + } + break; + } + default: + if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) { + Log.e(TAG, "error handling message, command is " + msg.arg1); + } + } + } + } + + // Handler for the messages + Handler commandHandler = new SDLCommandHandler(); + + // Send a message from the SDLMain thread + protected boolean sendCommand(int command, Object data) { + Message msg = commandHandler.obtainMessage(); + msg.arg1 = command; + msg.obj = data; + boolean result = commandHandler.sendMessage(msg); + + if (command == COMMAND_CHANGE_WINDOW_STYLE) { + // Ensure we don't return until the resize has actually happened, + // or 500ms have passed. + + boolean bShouldWait = false; + + if (data instanceof Integer) { + // Let's figure out if we're already laid out fullscreen or not. + Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + DisplayMetrics realMetrics = new DisplayMetrics(); + display.getRealMetrics(realMetrics); + + boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) && + (realMetrics.heightPixels == mSurface.getHeight())); + + if ((Integer) data == 1) { + // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going + // to change size and should wait for surfaceChanged() before we return, so the size + // is right back in native code. If we're already laid out fullscreen, though, we're + // not going to change size even if we change decor modes, so we shouldn't wait for + // surfaceChanged() -- which may not even happen -- and should return immediately. + bShouldWait = !bFullscreenLayout; + } else { + // If we're laid out fullscreen (even if the status bar and nav bar are present), + // or are actively in fullscreen, we're going to change size and should wait for + // surfaceChanged before we return, so the size is right back in native code. + bShouldWait = bFullscreenLayout; + } + } + + if (bShouldWait && (getContext() != null)) { + // We'll wait for the surfaceChanged() method, which will notify us + // when called. That way, we know our current size is really the + // size we need, instead of grabbing a size that's still got + // the navigation and/or status bars before they're hidden. + // + // We'll wait for up to half a second, because some devices + // take a surprisingly long time for the surface resize, but + // then we'll just give up and return. + // + synchronized (getContext()) { + try { + getContext().wait(500); + } catch (InterruptedException ie) { + ie.printStackTrace(); + } + } + } + } + + return result; + } + + // C functions we call + public static native String nativeGetVersion(); + public static native void nativeSetupJNI(); + public static native void nativeInitMainThread(); + public static native void nativeCleanupMainThread(); + public static native int nativeRunMain(String library, String function, Object arguments); + public static native void nativeLowMemory(); + public static native void nativeSendQuit(); + public static native void nativeQuit(); + public static native void nativePause(); + public static native void nativeResume(); + public static native void nativeFocusChanged(boolean hasFocus); + public static native void onNativeDropFile(String filename); + public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float density, float rate); + public static native void onNativeResize(); + public static native void onNativeKeyDown(int keycode); + public static native void onNativeKeyUp(int keycode); + public static native boolean onNativeSoftReturnKey(); + public static native void onNativeKeyboardFocusLost(); + public static native void onNativeMouse(int button, int action, float x, float y, boolean relative); + public static native void onNativeTouch(int touchDevId, int pointerFingerId, + int action, float x, + float y, float p); + public static native void onNativePen(int penId, int device_type, int button, int action, float x, float y, float p); + public static native void onNativeAccel(float x, float y, float z); + public static native void onNativeClipboardChanged(); + public static native void onNativeSurfaceCreated(); + public static native void onNativeSurfaceChanged(); + public static native void onNativeSurfaceDestroyed(); + public static native void onNativeScreenKeyboardShown(); + public static native void onNativeScreenKeyboardHidden(); + public static native String nativeGetHint(String name); + public static native boolean nativeGetHintBoolean(String name, boolean default_value); + public static native void nativeSetenv(String name, String value); + public static native void nativeSetNaturalOrientation(int orientation); + public static native void onNativeRotationChanged(int rotation); + public static native void onNativeInsetsChanged(int left, int right, int top, int bottom); + public static native void nativeAddTouch(int touchId, String name); + public static native void nativePermissionResult(int requestCode, boolean result); + public static native void onNativeLocaleChanged(); + public static native void onNativeDarkModeChanged(boolean enabled); + public static native boolean nativeAllowRecreateActivity(); + public static native int nativeCheckSDLThreadCounter(); + public static native void onNativeFileDialog(int requestCode, String[] filelist, int filter); + public static native void onNativePinchStart(); + public static native void onNativePinchUpdate(float scale); + public static native void onNativePinchEnd(); + + /** + * This method is called by SDL using JNI. + */ + public static boolean setActivityTitle(String title) { + // Called from SDLMain() thread and can't directly affect the view + return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title); + } + + /** + * This method is called by SDL using JNI. + */ + public static void setWindowStyle(boolean fullscreen) { + // Called from SDLMain() thread and can't directly affect the view + mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0); + } + + /** + * This method is called by SDL using JNI. + * This is a static method for JNI convenience, it calls a non-static method + * so that is can be overridden + */ + public static void setOrientation(int w, int h, boolean resizable, String hint) + { + if (mSingleton != null) { + mSingleton.setOrientationBis(w, h, resizable, hint); + } + } + + /** + * This can be overridden + */ + public void setOrientationBis(int w, int h, boolean resizable, String hint) + { + int orientation_landscape = -1; + int orientation_portrait = -1; + + if (w <= 1 || h <= 1) { + // Invalid width/height, ignore this request + return; + } + + /* If set, hint "explicitly controls which UI orientations are allowed". */ + if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE; + } else if (hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + } else if (hint.contains("LandscapeRight")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + } + + /* exact match to 'Portrait' to distinguish with PortraitUpsideDown */ + boolean contains_Portrait = hint.contains("Portrait ") || hint.endsWith("Portrait"); + + if (contains_Portrait && hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT; + } else if (contains_Portrait) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + } else if (hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; + } + + boolean is_landscape_allowed = (orientation_landscape != -1); + boolean is_portrait_allowed = (orientation_portrait != -1); + int req; /* Requested orientation */ + + /* No valid hint, nothing is explicitly allowed */ + if (!is_portrait_allowed && !is_landscape_allowed) { + if (resizable) { + /* All orientations are allowed, respecting user orientation lock setting */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_USER; + } else { + /* Fixed window and nothing specified. Get orientation from w/h of created window */ + req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); + } + } else { + /* At least one orientation is allowed */ + if (resizable) { + if (is_portrait_allowed && is_landscape_allowed) { + /* hint allows both landscape and portrait, promote to full user */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_USER; + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } else { + /* Fixed window and both orientations are allowed. Choose one. */ + if (is_portrait_allowed && is_landscape_allowed) { + req = (w > h ? orientation_landscape : orientation_portrait); + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } + } + + Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint); + mSingleton.setRequestedOrientation(req); + } + + /** + * This method is called by SDL using JNI. + */ + public static void minimizeWindow() { + + if (mSingleton == null) { + return; + } + + Intent startMain = new Intent(Intent.ACTION_MAIN); + startMain.addCategory(Intent.CATEGORY_HOME); + startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mSingleton.startActivity(startMain); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean shouldMinimizeOnFocusLoss() { + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean supportsRelativeMouse() + { + // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under + // Android 7 APIs, and simply returns no data under Android 8 APIs. + // + // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and + // thus SDK version 27. If we are in DeX mode and not API 27 or higher, as a result, + // we should stick to relative mode. + // + if (Build.VERSION.SDK_INT < 27 /* Android 8.1 (O_MR1) */ && isDeXMode()) { + return false; + } + + return SDLActivity.getMotionListener().supportsRelativeMouse(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean setRelativeMouseEnabled(boolean enabled) + { + if (enabled && !supportsRelativeMouse()) { + return false; + } + + return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean sendMessage(int command, int param) { + if (mSingleton == null) { + return false; + } + return mSingleton.sendCommand(command, param); + } + + /** + * This method is called by SDL using JNI. + */ + public static Activity getContext() { + return SDL.getContext(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isAndroidTV() { + UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE); + if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + return true; + } + if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) { + return true; + } + if (Build.MANUFACTURER.equals("Amlogic") && + (Build.MODEL.startsWith("TV") || + Build.MODEL.equals("X96-W") || + Build.MODEL.equals("A95X-R1"))) { + return true; + } + return false; + } + + public static boolean isVRHeadset() { + if (Build.MANUFACTURER.equals("Oculus") && Build.MODEL.startsWith("Quest")) { + return true; + } + if (Build.MANUFACTURER.equals("Pico")) { + return true; + } + return false; + } + + public static double getDiagonal() + { + DisplayMetrics metrics = new DisplayMetrics(); + Activity activity = (Activity)getContext(); + if (activity == null) { + return 0.0; + } + activity.getWindowManager().getDefaultDisplay().getMetrics(metrics); + + double dWidthInches = metrics.widthPixels / (double)metrics.xdpi; + double dHeightInches = metrics.heightPixels / (double)metrics.ydpi; + + return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches)); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isTablet() { + // If our diagonal size is seven inches or greater, we consider ourselves a tablet. + return (getDiagonal() >= 7.0); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isChromebook() { + // https://stackoverflow.com/questions/39784415/how-to-detect-programmatically-if-android-app-is-running-in-chrome-book-or-in + if (getContext() != null) { + if (getContext().getPackageManager().hasSystemFeature("org.chromium.arc") + || getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management")) { + return true; + } + } + + // Running on AVD emulator + boolean isChromebookEmulator = (Build.MODEL != null && Build.MODEL.startsWith("sdk_gpc_")); + return isChromebookEmulator; + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isDeXMode() { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + return false; + } + try { + final Configuration config = getContext().getResources().getConfiguration(); + final Class configClass = config.getClass(); + return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass) + == configClass.getField("semDesktopModeEnabled").getInt(config); + } catch(Exception ignored) { + return false; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean getManifestEnvironmentVariables() { + try { + if (getContext() == null) { + return false; + } + + ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA); + Bundle bundle = applicationInfo.metaData; + if (bundle == null) { + return false; + } + String prefix = "SDL_ENV."; + final int trimLength = prefix.length(); + for (String key : bundle.keySet()) { + if (key.startsWith(prefix)) { + String name = key.substring(trimLength); + String value = bundle.get(key).toString(); + nativeSetenv(name, value); + } + } + /* environment variables set! */ + return true; + } catch (Exception e) { + Log.v(TAG, "exception " + e.toString()); + } + return false; + } + + // This method is called by SDLControllerManager's API 26 Generic Motion Handler. + public static View getContentView() { + return mLayout; + } + + static class ShowTextInputTask implements Runnable { + /* + * This is used to regulate the pan&scan method to have some offset from + * the bottom edge of the input region and the top edge of an input + * method (soft keyboard) + */ + static final int HEIGHT_PADDING = 15; + + public int input_type; + public int x, y, w, h; + + public ShowTextInputTask(int input_type, int x, int y, int w, int h) { + this.input_type = input_type; + this.x = x; + this.y = y; + this.w = w; + this.h = h; + + /* Minimum size of 1 pixel, so it takes focus. */ + if (this.w <= 0) { + this.w = 1; + } + if (this.h + HEIGHT_PADDING <= 0) { + this.h = 1 - HEIGHT_PADDING; + } + } + + @Override + public void run() { + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING); + params.leftMargin = x; + params.topMargin = y; + + if (mTextEdit == null) { + mTextEdit = new SDLDummyEdit(getContext()); + + mLayout.addView(mTextEdit, params); + } else { + mTextEdit.setLayoutParams(params); + } + mTextEdit.setInputType(input_type); + + mTextEdit.setVisibility(View.VISIBLE); + mTextEdit.requestFocus(); + + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mTextEdit, 0); + + if (imm.isAcceptingText()) { + onNativeScreenKeyboardShown(); + } + } + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean showTextInput(int input_type, int x, int y, int w, int h) { + // Transfer the task to the main thread as a Runnable + return mSingleton.commandHandler.post(new ShowTextInputTask(input_type, x, y, w, h)); + } + + public static boolean isTextInputEvent(KeyEvent event) { + + // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT + if (event.isCtrlPressed()) { + return false; + } + + return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE; + } + + public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) { + int deviceId = event.getDeviceId(); + int source = event.getSource(); + + if (source == InputDevice.SOURCE_UNKNOWN) { + InputDevice device = InputDevice.getDevice(deviceId); + if (device != null) { + source = device.getSources(); + } + } + +// if (event.getAction() == KeyEvent.ACTION_DOWN) { +// Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } else if (event.getAction() == KeyEvent.ACTION_UP) { +// Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } + + // Dispatch the different events depending on where they come from + // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD + // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD + // + // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and + // SOURCE_JOYSTICK, while its key events arrive from the keyboard source + // So, retrieve the device itself and check all of its sources + if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) { + // Note that we process events with specific key codes here + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (SDLControllerManager.onNativePadDown(deviceId, keyCode)) { + return true; + } + } else if (event.getAction() == KeyEvent.ACTION_UP) { + if (SDLControllerManager.onNativePadUp(deviceId, keyCode)) { + return true; + } + } + } + + if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) { + if (SDLActivity.isVRHeadset()) { + // The Oculus Quest controller back button comes in as source mouse, so accept that + } else { + // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses + // they are ignored here because sending them as mouse input to SDL is messy + if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + case KeyEvent.ACTION_UP: + // mark the event as handled or it will be handled by system + // handling KEYCODE_BACK by system will call onBackPressed() + return true; + } + } + } + } + + if (event.getAction() == KeyEvent.ACTION_DOWN) { + onNativeKeyDown(keyCode); + + if (isTextInputEvent(event)) { + if (ic != null) { + ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1); + } else { + SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1); + } + } + return true; + } else if (event.getAction() == KeyEvent.ACTION_UP) { + onNativeKeyUp(keyCode); + return true; + } + + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static Surface getNativeSurface() { + if (SDLActivity.mSurface == null) { + return null; + } + return SDLActivity.mSurface.getNativeSurface(); + } + + // Input + + /** + * This method is called by SDL using JNI. + */ + public static void initTouch() { + int[] ids = InputDevice.getDeviceIds(); + + for (int id : ids) { + InputDevice device = InputDevice.getDevice(id); + /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */ + if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN + || device.isVirtual())) { + + nativeAddTouch(device.getId(), device.getName()); + } + } + } + + // Messagebox + + /** Result of current messagebox. Also used for blocking the calling thread. */ + protected final int[] messageboxSelection = new int[1]; + + /** + * This method is called by SDL using JNI. + * Shows the messagebox from UI thread and block calling thread. + * buttonFlags, buttonIds and buttonTexts must have same length. + * @param buttonFlags array containing flags for every button. + * @param buttonIds array containing id for every button. + * @param buttonTexts array containing text for every button. + * @param colors null for default or array of length 5 containing colors. + * @return button id or -1. + */ + public int messageboxShowMessageBox( + final int flags, + final String title, + final String message, + final int[] buttonFlags, + final int[] buttonIds, + final String[] buttonTexts, + final int[] colors) { + + messageboxSelection[0] = -1; + + // sanity checks + + if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) { + return -1; // implementation broken + } + + // collect arguments for Dialog + + final Bundle args = new Bundle(); + args.putInt("flags", flags); + args.putString("title", title); + args.putString("message", message); + args.putIntArray("buttonFlags", buttonFlags); + args.putIntArray("buttonIds", buttonIds); + args.putStringArray("buttonTexts", buttonTexts); + args.putIntArray("colors", colors); + + // trigger Dialog creation on UI thread + + runOnUiThread(new Runnable() { + @Override + public void run() { + messageboxCreateAndShow(args); + } + }); + + // block the calling thread + + synchronized (messageboxSelection) { + try { + messageboxSelection.wait(); + } catch (InterruptedException ex) { + ex.printStackTrace(); + return -1; + } + } + + // return selected value + + return messageboxSelection[0]; + } + + protected void messageboxCreateAndShow(Bundle args) { + + // TODO set values from "flags" to messagebox dialog + + // get colors + + int[] colors = args.getIntArray("colors"); + int backgroundColor; + int textColor; + int buttonBorderColor; + int buttonBackgroundColor; + int buttonSelectedColor; + if (colors != null) { + int i = -1; + backgroundColor = colors[++i]; + textColor = colors[++i]; + buttonBorderColor = colors[++i]; + buttonBackgroundColor = colors[++i]; + buttonSelectedColor = colors[++i]; + } else { + backgroundColor = Color.TRANSPARENT; + textColor = Color.TRANSPARENT; + buttonBorderColor = Color.TRANSPARENT; + buttonBackgroundColor = Color.TRANSPARENT; + buttonSelectedColor = Color.TRANSPARENT; + } + + // create dialog with title and a listener to wake up calling thread + + final AlertDialog dialog = new AlertDialog.Builder(this).create(); + dialog.setTitle(args.getString("title")); + dialog.setCancelable(false); + dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface unused) { + synchronized (messageboxSelection) { + messageboxSelection.notify(); + } + } + }); + + // create text + + TextView message = new TextView(this); + message.setGravity(Gravity.CENTER); + message.setText(args.getString("message")); + if (textColor != Color.TRANSPARENT) { + message.setTextColor(textColor); + } + + // create buttons + + int[] buttonFlags = args.getIntArray("buttonFlags"); + int[] buttonIds = args.getIntArray("buttonIds"); + String[] buttonTexts = args.getStringArray("buttonTexts"); + + final SparseArray + + + + + +)RML"; + +constexpr std::array kDiscFileFilters{{ + {"Game Disc Images", "iso;gcm;ciso;gcz;nfs;rvz;wbfs;wia;tgc"}, + {"All Files", "*"}, +}}; + +struct DiscVerificationResult { + std::string path; + iso::DiscInfo info; + iso::ValidationError validation = iso::ValidationError::Unknown; +}; + +struct DiscVerificationTask { + explicit DiscVerificationTask(std::string discPath) : path(std::move(discPath)) { + worker = std::thread([this] { + try { + validation = iso::validate(path.c_str(), status, info); + } catch (const std::exception& e) { + PrelaunchLog.error( + "Disc verification failed with exception for '{}': {}", path, e.what()); + validation = iso::ValidationError::Unknown; + } catch (...) { + PrelaunchLog.error( + "Disc verification failed with unknown exception for '{}'", path); + validation = iso::ValidationError::Unknown; + } + done.store(true, std::memory_order_release); + }); + } + + ~DiscVerificationTask() { + status.shouldCancel.store(true, std::memory_order_relaxed); + join(); + } + + void join() { + if (worker.joinable()) { + worker.join(); + } + } + + [[nodiscard]] bool finished() const { return done.load(std::memory_order_acquire); } + + std::string path; + iso::DiscInfo info; + iso::VerificationStatus status; + iso::ValidationError validation = iso::ValidationError::Unknown; + std::atomic_bool done = false; + std::thread worker; +}; + +std::unique_ptr sDiscVerificationTask; +bool sDiscVerificationModalPushed = false; + +struct UpdateCheckTask { + UpdateCheckTask() { + worker = std::thread([this] { + try { + result = update_check::check_latest_github_release("TwilitRealm", "dusklight"); + } catch (const std::exception& e) { + result = { + .status = update_check::Status::Failed, + .message = fmt::format("Update check failed with exception: {}", e.what()), + }; + } catch (...) { + result = { + .status = update_check::Status::Failed, + .message = "Update check failed with an unknown exception", + }; + } + done.store(true, std::memory_order_release); + }); + } + + ~UpdateCheckTask() { join(); } + + void join() { + if (worker.joinable()) { + worker.join(); + } + } + + [[nodiscard]] bool finished() const { return done.load(std::memory_order_acquire); } + + update_check::Result result; + std::atomic_bool done = false; + std::thread worker; +}; + +std::unique_ptr sUpdateCheckTask; +std::optional sUpdateCheckResult; + +bool verification_state_allows_launch(iso::ValidationError validation) noexcept { + return validation == iso::ValidationError::Unknown || + validation == iso::ValidationError::Success || + validation == iso::ValidationError::HashMismatch; +} + +iso::ValidationError verification_from_config(DiscVerificationState value) noexcept { + switch (value) { + case DiscVerificationState::Success: + return iso::ValidationError::Success; + case DiscVerificationState::HashMismatch: + return iso::ValidationError::HashMismatch; + default: + return iso::ValidationError::Unknown; + } +} + +DiscVerificationState verification_to_config(iso::ValidationError validation) { + switch (validation) { + case iso::ValidationError::Success: + return DiscVerificationState::Success; + case iso::ValidationError::HashMismatch: + return DiscVerificationState::HashMismatch; + default: + return DiscVerificationState::Unknown; + } +} + +std::string format_bytes(std::size_t bytes) { + constexpr double KiB = 1024.0; + constexpr double MiB = KiB * 1024.0; + constexpr double GiB = MiB * 1024.0; + if (bytes >= static_cast(GiB)) { + return fmt::format("{:.2f} GiB", static_cast(bytes) / GiB); + } + if (bytes >= static_cast(MiB)) { + return fmt::format("{:.0f} MiB", static_cast(bytes) / MiB); + } + if (bytes >= static_cast(KiB)) { + return fmt::format("{:.0f} KiB", static_cast(bytes) / KiB); + } + return fmt::format("{} B", bytes); +} + +void begin_disc_verification(std::string path) noexcept { + if (path.empty()) { + return; + } + if (sDiscVerificationTask != nullptr) { + sDiscVerificationTask->status.shouldCancel.store(true, std::memory_order_relaxed); + sDiscVerificationTask.reset(); + } + sDiscVerificationTask = std::make_unique(std::move(path)); + sDiscVerificationModalPushed = false; +} + +std::optional take_finished_disc_verification() { + if (sDiscVerificationTask == nullptr || !sDiscVerificationTask->finished()) { + return std::nullopt; + } + DiscVerificationResult result{ + .path = sDiscVerificationTask->path, + .info = sDiscVerificationTask->info, + .validation = sDiscVerificationTask->validation, + }; + sDiscVerificationTask->join(); + sDiscVerificationTask.reset(); + sDiscVerificationModalPushed = false; + return result; +} + +void begin_update_check() { + if (!getSettings().backend.checkForUpdates.getValue()) { + return; + } + if (sUpdateCheckTask != nullptr || sUpdateCheckResult.has_value()) { + return; + } + sUpdateCheckTask = std::make_unique(); +} + +std::optional take_finished_update_check() { + if (sUpdateCheckTask == nullptr || !sUpdateCheckTask->finished()) { + return std::nullopt; + } + + sUpdateCheckTask->join(); + auto result = std::move(sUpdateCheckTask->result); + sUpdateCheckTask.reset(); + return result; +} + +std::string update_release_label(const update_check::Release& release) { + std::string_view tagName = release.tagName; + if (!tagName.empty() && tagName.front() == 'v') { + tagName.remove_prefix(1); + } + return std::string(tagName); +} + +void open_update_release() { + if (!sUpdateCheckResult.has_value() || + sUpdateCheckResult->status != update_check::Status::UpdateAvailable) + { + return; + } + + const std::string url = sUpdateCheckResult->latest.htmlUrl; + if (url.empty()) { + PrelaunchLog.warn("Update is available, but the release did not include a download URL"); + return; + } + if (!SDL_OpenURL(url.c_str())) { + PrelaunchLog.warn("Failed to open update URL '{}': {}", url, SDL_GetError()); + } +} + +std::string get_error_msg(iso::ValidationError error) { + switch (error) { + default: + return "The selected disc image could not be validated."; + case iso::ValidationError::IOError: + return "Unable to read the selected file."; + case iso::ValidationError::InvalidImage: + return "The selected file is not a valid disc image."; + case iso::ValidationError::WrongGame: + return "The selected game is not supported by Dusklight."; + case iso::ValidationError::WrongVersion: + return "Dusklight currently supports GameCube USA and PAL disc images only."; + case iso::ValidationError::Canceled: + return "Disc verification was canceled. Dusklight cannot guarantee the selected disc " + "image is compatible."; + case iso::ValidationError::HashMismatch: + return "The selected disc image did not pass hash verification. It may be corrupt or " + "modified."; + case iso::ValidationError::Success: + return "The selected disc image is valid."; + } +} + +void persist_disc_choice(const std::string& path, iso::ValidationError validation) { + const auto previousPath = getSettings().backend.isoPath.getValue(); + const auto previousVerification = getSettings().backend.isoVerification.getValue(); + const auto verification = verification_to_config(validation); + + getSettings().backend.isoPath.setValue(path); + getSettings().backend.isoVerification.setValue(verification); + config::Save(); + + if (previousPath != path || previousVerification != verification) { + iso::log_verification_state(path, verification); + } +} + +void apply_valid_disc_result( + const std::string& path, const iso::DiscInfo& info, iso::ValidationError validation) { + auto& state = prelaunch_state(); + state.configuredDiscPath = path; + state.configuredDiscCanLaunch = true; + state.configuredDiscInfo = info; + state.configuredDiscValidation = validation; + if (state.activeDiscPath.empty() || path == state.activeDiscPath) { + state.activeDiscPath = path; + state.activeDiscInfo = info; + } + persist_disc_choice(path, validation); +} + +void apply_disc_verification_result(const DiscVerificationResult& result) { + auto& state = prelaunch_state(); + + if (result.validation == iso::ValidationError::HashMismatch || + result.validation == iso::ValidationError::Canceled) + { + state.pendingDiscPath = result.path; + state.pendingDiscInfo = result.info; + state.pendingDiscValidation = result.validation; + state.errorString = escape(get_error_msg(result.validation)); + return; + } + + if (result.validation == iso::ValidationError::Success) { + apply_valid_disc_result(result.path, result.info, result.validation); + state.errorString.clear(); + state.pendingDiscPath.clear(); + state.pendingDiscInfo = {}; + state.pendingDiscValidation = iso::ValidationError::Unknown; + return; + } + + state.pendingDiscPath.clear(); + state.pendingDiscInfo = {}; + state.pendingDiscValidation = iso::ValidationError::Unknown; + state.errorString = escape(get_error_msg(result.validation)); +} + +class DiscVerificationModal : public WindowSmall { +public: + DiscVerificationModal() : WindowSmall("modal", "modal-dialog") { + auto* header = append(mDialog, "div"); + header->SetClass("modal-header", true); + + auto* title = append(header, "div"); + title->SetClass("modal-title", true); + title->SetInnerRML("Verifying disc image"); + + auto* icon = append(header, "icon"); + icon->SetClass("verifying", true); + + auto* body = append(mDialog, "div"); + body->SetClass("modal-body", true); + + auto* content = append(body, "div"); + content->SetClass("verification-progress", true); + + mFileName = append(content, "div"); + mFileName->SetClass("verification-file", true); + + mProgress = append(content, "progress"); + mProgress->SetClass("progress-ongoing", true); + mProgress->SetClass("verification-progress-bar", true); + mProgress->SetAttribute("value", 0.f); + + mDetail = append(content, "div"); + mDetail->SetClass("verification-detail", true); + + auto* actions = append(mDialog, "div"); + actions->SetClass("modal-actions", true); + mCancelButton = std::make_unique