diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 37679e3f56..37a0849fd0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,9 +8,9 @@ on: pull_request: env: - # SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + # SENTRY_DSN: ${{ secrets.SENTRY_DSN }} jobs: build-linux: @@ -22,7 +22,7 @@ jobs: matrix: include: - name: GCC x86_64 - runner: [self-hosted, Linux] + runner: ubuntu-latest preset: gcc artifact_arch: x86_64 # - name: GCC aarch64 @@ -41,7 +41,6 @@ jobs: submodules: recursive - name: Install dependencies - if: 'false' # disabled for self-hosted run: | sudo apt-get update sudo apt-get -y install ninja-build clang lld openssl libcurl4-openssl-dev \ @@ -51,8 +50,7 @@ jobs: libxss-dev libfuse2 libusb-1.0-0-dev libdecor-0-dev libpipewire-0.3-dev libunwind-dev - name: Setup sccache - if: 'false' # disabled for self-hosted - uses: mozilla-actions/sccache-action@v0.0.9 + uses: mozilla-actions/sccache-action@v0.0.10 - name: Print sccache stats run: sccache --show-stats @@ -67,7 +65,6 @@ jobs: run: ci/build-appimage.sh - name: Upload artifacts - if: startsWith(github.event.ref, 'refs/tags/v') uses: actions/upload-artifact@v7 with: name: dusk-${{env.DUSK_VERSION}}-linux-${{matrix.preset}}-${{matrix.artifact_arch}} @@ -77,7 +74,7 @@ jobs: build-apple: name: Build Apple (${{matrix.name}}) - runs-on: [self-hosted, macOS] + runs-on: macos-latest strategy: fail-fast: false matrix: @@ -86,14 +83,14 @@ jobs: platform: macos 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 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 @@ -106,7 +103,6 @@ jobs: submodules: recursive - name: Install dependencies - if: 'false' run: brew install cmake ninja - name: Install Rust iOS target @@ -128,7 +124,7 @@ jobs: 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}} @@ -137,7 +133,6 @@ jobs: run: cmake --build --preset ${{matrix.preset}} - name: Upload artifacts - if: startsWith(github.event.ref, 'refs/tags/v') uses: actions/upload-artifact@v7 with: name: dusk-${{env.DUSK_VERSION}}-${{matrix.artifact_name}} @@ -145,6 +140,73 @@ jobs: build/install/Dusk.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 dusk + + - 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: dusk-${{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}} @@ -154,7 +216,7 @@ jobs: matrix: include: - name: MSVC x86_64 - runner: [self-hosted, Windows] + runner: windows-latest preset: msvc msvc_arch: amd64 vcpkg_arch: x64 @@ -191,7 +253,6 @@ jobs: uses: mozilla-actions/sccache-action@v0.0.9 - name: Install dependencies - if: 'false' # disabled for self-hosted run: | choco install ninja vcpkg install freetype:${{matrix.vcpkg_arch}}-windows-static zstd:${{matrix.vcpkg_arch}}-windows-static @@ -203,7 +264,6 @@ jobs: run: cmake --build --preset x-windows-ci-${{matrix.preset}} - name: Upload artifacts - if: startsWith(github.event.ref, 'refs/tags/v') uses: actions/upload-artifact@v7 with: name: dusk-${{env.DUSK_VERSION}}-win32-msvc-${{matrix.artifact_arch}} diff --git a/CMakeLists.txt b/CMakeLists.txt index b00aa1201f..6290972f96 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,13 +48,15 @@ else () message(STATUS "Unable to find git, commit information will not be available") endif () -if (DUSK_WC_DESCRIBE MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)(-([0-9]+).*)?$") +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}") - if (CMAKE_MATCH_5) - set(DUSK_VERSION_STRING "${DUSK_SHORT_VERSION_STRING}.${CMAKE_MATCH_5}") - else () - set(DUSK_VERSION_STRING "${DUSK_SHORT_VERSION_STRING}.0") + 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.0") @@ -113,26 +115,27 @@ 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) -if (ANDROID) - set(DUSK_ENABLE_UPDATE_CHECKER_DEFAULT OFF) -else () - set(DUSK_ENABLE_UPDATE_CHECKER_DEFAULT ON) -endif () -option(DUSK_ENABLE_UPDATE_CHECKER "Enable update checking support" ${DUSK_ENABLE_UPDATE_CHECKER_DEFAULT}) -option(DUSK_TPHD "Enable Twilight Princess HD asset support" ON) +option(DUSK_ENABLE_UPDATE_CHECKER "Enable update checking support" ON) +option(DUSK_TPHD "Enable TPHD asset support" ON) if(ANDROID) set(DUSK_MOVIE_SUPPORT OFF) - set(NOD_COMPRESS_BZIP2 OFF CACHE BOOL "" FORCE) - set(NOD_COMPRESS_LZMA OFF CACHE BOOL "" FORCE) - set(NOD_COMPRESS_ZLIB OFF CACHE BOOL "" FORCE) - set(NOD_COMPRESS_ZSTD OFF CACHE BOOL "" FORCE) endif () 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 3.0 CONFIG QUIET) if (libjpeg-turbo_FOUND) @@ -162,6 +165,7 @@ if (DUSK_MOVIE_SUPPORT) CMAKE_C_COMPILER_LAUNCHER CMAKE_MAKE_PROGRAM CMAKE_MSVC_RUNTIME_LIBRARY + CMAKE_MSVC_DEBUG_INFORMATION_FORMAT CMAKE_OSX_ARCHITECTURES DEPLOYMENT_TARGET ENABLE_ARC @@ -346,6 +350,10 @@ if (DUSK_ENABLE_UPDATE_CHECKER) list(APPEND GAME_LIBS winhttp) list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_WINHTTP=1) message(STATUS "dusk: 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 "dusk: Enabled update checker (Android)") elseif (APPLE) find_library(FOUNDATION_FRAMEWORK Foundation REQUIRED) set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/url_session.mm) @@ -387,16 +395,6 @@ if (DUSK_ENABLE_DISCORD AND NOT ANDROID AND NOT IOS AND NOT TVOS) list(APPEND GAME_COMPILE_DEFS DUSK_DISCORD=1) endif () -# 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(ANDROID) list(APPEND GAME_COMPILE_DEFS TARGET_ANDROID=1) endif () diff --git a/CMakePresets.json b/CMakePresets.json index 92799249a9..ea58a72243 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -30,7 +30,7 @@ "CMAKE_CXX_COMPILER_LAUNCHER": "sccache", "DUSK_ENABLE_SENTRY_NATIVE": { "type": "BOOL", - "value": true + "value": false }, "DUSK_SENTRY_DSN": "$env{SENTRY_DSN}", "DUSK_SENTRY_ENVIRONMENT": "production" @@ -352,6 +352,25 @@ "ANDROID_ABI": "x86_64" } }, + { + "name": "x-android-ci", + "hidden": true, + "inherits": [ + "android-base", + "ci" + ] + }, + { + "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, @@ -412,6 +431,7 @@ "x-macos-ci" ], "cacheVariables": { + "AURORA_DAWN_PROVIDER": "vendor", "CMAKE_OSX_ARCHITECTURES": "x86_64", "Rust_CARGO_TARGET": "x86_64-apple-darwin" } @@ -555,6 +575,15 @@ "dusk" ] }, + { + "name": "x-android-ci-arm64", + "configurePreset": "x-android-ci-arm64", + "description": "(Internal) Android CI arm64-v8a", + "displayName": "(Internal) Android CI arm64-v8a", + "targets": [ + "dusk" + ] + }, { "name": "windows-msvc-debug", "configurePreset": "windows-msvc-debug", diff --git a/README.md b/README.md index 5e7412e6aa..652eae2538 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Official Website • - Discord + Discord

@@ -19,6 +19,9 @@ It aims to be as accurate as possible to the original while also providing new o > [!IMPORTANT] > Dusk does *not* provide any copyrighted assets. You must provide your own copy of the original game. +> [!IMPORTANT] +> At a minimum, Dusk 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 likelyhood of incompatibility. We are also aware of a number of issues on devices with Adreno GPUs and are working to resolve them. + ### 1. Verify your dump First, make sure your dump of the game is clean and supported by Dusk. You can do this by checking the SHA-1 hash of your dump against this list of supported versions: @@ -28,20 +31,31 @@ First, make sure your dump of the game is clean and supported by Dusk. You can d | GameCube USA | `75edd3ddff41f125d1b4ce1a40378f1b565519e7` | | GameCube EUR | `2601822a488eeb86fb89db16ca8f29c2c953e1ca` | +*Support for other versions of the game is planned in the future. + ### 2. Download [Dusk](https://github.com/TwilitRealm/dusk/releases) ### 3. Setup the game - +**Windows / macOS / Linux** - Extract the .zip file - Launch Dusk -- Press **Select Disc Image** and provide the path to your supported game dump. +- Press **Select Disc Image** and provide the path to your supported game dump +- Press **Play**! + +**iOS** +- Follow the [iOS setup guide](docs/ios-install-altstore.md) + +**Android** +- Install the Dusk apk +- Launch Dusk +- Press **Select Disc Image** and provide the path to your supported game dump - Press **Play**! # Building If you'd like to build Dusk from source, please read the [build instructions](docs/building.md). -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. +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 diff --git a/ci/build-appimage.sh b/ci/build-appimage.sh index 078d6cabf0..cef4ddfaf0 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 -r 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 cd build/install -VERSION="$DUSK_VERSION" NO_STRIP=1 "$RUNNER_WORKSPACE"/linuxdeploy-$(uname -m).AppImage \ - -l /usr/lib/x86_64-linux-gnu/libusb-1.0.so --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/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..fdfc7689d6 --- /dev/null +++ b/docs/ios-install-altstore.md @@ -0,0 +1,46 @@ +# Installing Dusk on iOS via AltStore + +## Prerequisites + +- Mac with Homebrew installed +- iPhone connected via USB +- Dusk IPA file (download the latest `Dusk-vX.X.X-ios-arm64.ipa` from the [releases page](https://github.com/TwilitRealm/dusk/releases)) +- Game disc - `GZ2E01` (Gamecube USA) or `GZ2PE01` (Gamecube PAL) + +## 1. Install AltServer + +```sh +brew install altserver +open -a AltServer +``` + +AltServer will appear in your menu bar. + +## 2. Enable Developer Mode (iOS 16+) + +- On your iPhone, go to **Settings > Privacy & Security > Developer Mode** +- Toggle it on and restart when prompted + +## 3. Install AltStore on Your iPhone + +- Click AltServer in the menu bar +- Click **Install AltStore > [Your iPhone]** +- Enter your Apple ID credentials when prompted +- On your iPhone, go to **Settings > General > VPN & Device Management** +- Tap your Apple ID under "Developer App" and tap **Trust** + +## 4. Copy Files to Your iPhone + +Transfer the IPA and game disc to your iPhone so they're 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 + +## 5. Install via AltStore + +- Open **AltStore** on your iPhone +- Go to the **My Apps** tab +- Tap the **+** button (top left) +- Open the **Files** app and select the `.ipa` file \ No newline at end of file diff --git a/flake.nix b/flake.nix index cd703bda64..c8e38a849b 100644 --- a/flake.nix +++ b/flake.nix @@ -5,9 +5,73 @@ outputs = { self, nixpkgs }: let pkgs = import nixpkgs { system = "x86_64-linux"; }; + + # Dependencies that are not packaged in nixpkgs: + aurora-src = pkgs.fetchFromGitHub { + owner = "encounter"; + repo = "aurora"; + rev = "63606a43265a3bc18dafd500ab4d7a2108f109e6"; + hash = "sha256-xBvnAwGwNzav67Ac6oUz7RqDUwqgL2bsME3OOMn8Tqw="; + }; + 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="; + }; + # Dusk Actual dusk = pkgs.stdenv.mkDerivation { name = "dusk"; src = ./.; + postUnpack = '' + mkdir -p $sourceRoot/extern/aurora + cp -r ${aurora-src}/. $sourceRoot/extern/aurora/ + chmod -R u+w $sourceRoot/extern/aurora + sed -i '/add_subdirectory(tests)/d' $sourceRoot/extern/aurora/CMakeLists.txt + ''; + # Remove last line to re-enable tests + cmakeFlags = [ + "-DFETCHCONTENT_FULLY_DISCONNECTED=ON" + "-DFETCHCONTENT_SOURCE_DIR_CXXOPTS=${pkgs.cxxopts.src}" + "-DFETCHCONTENT_SOURCE_DIR_JSON=${pkgs.nlohmann_json.src}" + "-DFETCHCONTENT_SOURCE_DIR_DAWN_PREBUILT=${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=${nod-src}" + "-DAURORA_NOD_PROVIDER=package" + "-DFETCHCONTENT_SOURCE_DIR_FREETYPE=${pkgs.freetype.src}" + "-DFETCHCONTENT_SOURCE_DIR_ZSTD=${pkgs.zstd.src}" + "-DFETCHCONTENT_SOURCE_DIR_SQLITE3=${sqlite-src}" + "-DFETCHCONTENT_SOURCE_DIR_IMGUI=${imgui-src}" + "-DFETCHCONTENT_SOURCE_DIR_RMLUI=${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 dusk $out/bin/dusk + cp -r ./res $out/bin/res + ''; nativeBuildInputs = [ pkgs.cmake pkgs.pkg-config @@ -25,6 +89,13 @@ pkgs.libjpeg8 pkgs.libxkbcommon pkgs.libglvnd + pkgs.cxxopts + pkgs.abseil-cpp + pkgs.sdl3 + pkgs.fmt + pkgs.tracy + pkgs.freetype + pkgs.zstd ]; }; in { diff --git a/include/d/actor/d_a_alink.h b/include/d/actor/d_a_alink.h index 9980ab776f..107afc1ed3 100644 --- a/include/d/actor/d_a_alink.h +++ b/include/d/actor/d_a_alink.h @@ -4552,6 +4552,18 @@ public: void handleWolfHowl(); void handleQuickTransform(); 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_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_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_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_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/dusk/gyro.h b/include/dusk/gyro.h index 279aeae16e..a206100739 100644 --- a/include/dusk/gyro.h +++ b/include/dusk/gyro.h @@ -12,6 +12,7 @@ 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/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/main.h b/include/dusk/main.h index d6b9c9927f..c5c4fb27d1 100644 --- a/include/dusk/main.h +++ b/include/dusk/main.h @@ -7,11 +7,18 @@ #include +#if defined(_WIN32) || \ + (defined(__APPLE__) && !TARGET_OS_IOS && !TARGET_OS_TV && !TARGET_OS_MACCATALYST) || \ + (defined(__linux__) && !defined(__ANDROID__)) +#define DUSK_CAN_OPEN_DATA_FOLDER 1 +#else +#define DUSK_CAN_OPEN_DATA_FOLDER 0 +#endif + namespace dusk { extern bool IsRunning; extern bool IsShuttingDown; extern bool IsGameLaunched; - extern bool IsFocusPaused; extern bool RestartRequested; extern std::filesystem::path ConfigPath; @@ -23,6 +30,7 @@ namespace dusk { #endif void RequestRestart() noexcept; + bool OpenDataFolder(); } #endif // DUSK_MAIN_H diff --git a/include/dusk/settings.h b/include/dusk/settings.h index 8885c9e3ef..5622b64f96 100644 --- a/include/dusk/settings.h +++ b/include/dusk/settings.h @@ -27,6 +27,11 @@ enum class DiscVerificationState : u8 { HashMismatch, }; +enum class GyroMode : u8 { + Sensor = 0, + Mouse = 1, +}; + namespace config { template <> struct ConfigEnumRange { @@ -45,6 +50,12 @@ 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 @@ -102,8 +113,8 @@ struct UserSettings { ConfigVar minimalHUD; ConfigVar pauseOnFocusLost; ConfigVar enableLinkDollRotation; - ConfigVar enableAchievementNotifications; - + ConfigVar enableAchievementToasts; + ConfigVar enableControllerToasts; // Graphics ConfigVar bloomMode; @@ -120,6 +131,7 @@ struct UserSettings { ConfigVar midnasLamentNonStop; // Input + ConfigVar gyroMode; ConfigVar enableGyroAim; ConfigVar enableGyroRollgoal; ConfigVar gyroSensitivityX; @@ -135,6 +147,7 @@ struct UserSettings { ConfigVar freeCameraSensitivity; ConfigVar debugFlyCam; ConfigVar debugFlyCamLockEvents; + ConfigVar allowBackgroundInput; // Cheats ConfigVar infiniteHearts; diff --git a/platforms/android/README.md b/platforms/android/README.md index c7ecd684c1..f51b37db73 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -66,12 +66,11 @@ Output APK: You can pass command-line args through the activity intent: ```bash -adb shell am start -n com.twilitrealm.dusk/.DuskActivity \ - --es dusk_args "'/sdcard/Download/The Legend of Zelda: Twilight Princess (USA).iso'" +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 -- `dusk_disc`: compatibility shortcut (single ISO path) diff --git a/platforms/android/app/build.gradle b/platforms/android/app/build.gradle index 9375d06910..b7775df8e0 100644 --- a/platforms/android/app/build.gradle +++ b/platforms/android/app/build.gradle @@ -2,12 +2,22 @@ plugins { id 'com.android.application' } +def duskRepoDir = rootProject.projectDir.parentFile.parentFile +def duskGeneratedAssetsDir = layout.buildDirectory.dir('generated/assets/dusk').get().asFile +def syncDuskAssets = tasks.register('syncDuskAssets', Sync) { + from(new File(duskRepoDir, 'res')) { + into 'res' + exclude '**/.DS_Store' + } + into duskGeneratedAssetsDir +} + android { - namespace 'com.twilitrealm.dusk' + namespace 'dev.twilitrealm.dusk' compileSdk 36 defaultConfig { - applicationId 'com.twilitrealm.dusk' + applicationId 'dev.twilitrealm.dusk' minSdk 26 targetSdk 36 versionCode 1 @@ -27,7 +37,7 @@ android { sourceSets { main { jniLibs.srcDirs = ['src/main/jniLibs'] - assets.srcDirs = ['../../assets'] + assets.srcDirs = [duskGeneratedAssetsDir] } } @@ -48,3 +58,10 @@ android { 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 index 952161ca8b..72b7ca16fa 100644 --- a/platforms/android/app/proguard-rules.pro +++ b/platforms/android/app/proguard-rules.pro @@ -1,2 +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 index e8c2bb29a8..f5a0365856 100644 --- a/platforms/android/app/src/main/AndroidManifest.xml +++ b/platforms/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ + + + + + + + out = new ArrayList<>(); StringBuilder current = new StringBuilder(); @@ -61,18 +71,46 @@ public class DuskActivity extends SDLActivity { @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) { - getWindow().getDecorView().getWindowInsetsController().hide(WindowInsets.Type.systemBars()); - }else { - View decorView = getWindow().getDecorView(); - // Hide the status bar. - int uiOptions = View.SYSTEM_UI_FLAG_FULLSCREEN; + 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); - // Remember that you should never show the action bar if the - // status bar is hidden, so hide that too if necessary. ActionBar actionBar = getActionBar(); - actionBar.hide(); + if (actionBar != null) { + actionBar.hide(); + } } } @@ -100,12 +138,96 @@ public class DuskActivity extends SDLActivity { return splitArgs(trimmed); } } - - String discPath = intent.getStringExtra("dusk_disc"); - if (discPath != null && !discPath.isEmpty()) { - return new String[] { discPath }; - } } return new String[0]; } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (resultCode == RESULT_OK) { + persistUriPermissions(data); + } + super.onActivityResult(requestCode, resultCode, data); + } + + 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..79fa8894d0 --- /dev/null +++ b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java @@ -0,0 +1,413 @@ +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 java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +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 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() { + ensureUserDirectories(); + return true; + } + + @Override + public Cursor queryRoots(String[] projection) throws FileNotFoundException { + final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); + 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 { + final File root = getContext().getFilesDir(); + if (root == null) { + throw new FileNotFoundException("Dusk 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 Dusk 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 Dusk 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 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/HIDDeviceManager.java b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java index b91a8211b1..1fb2bfb4a7 100644 --- a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java +++ b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java @@ -256,6 +256,7 @@ public class HIDDeviceManager { 0x24c6, // PowerA 0x2c22, // Qanba 0x2dc8, // 8BitDo + 0x37d7, // Flydigi 0x9886, // ASTRO Gaming }; 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 index 9548c529c0..42f5a911f7 100644 --- a/platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java +++ b/platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java @@ -61,7 +61,7 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh 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 = 2; + private static final int SDL_MICRO_VERSION = 4; /* // Display InputType.SOURCE/CLASS of events and devices // @@ -2032,7 +2032,7 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh try { ParcelFileDescriptor pfd = mSingleton.getContentResolver().openFileDescriptor(Uri.parse(uri), mode); return pfd != null ? pfd.detachFd() : -1; - } catch (FileNotFoundException e) { + } catch (FileNotFoundException | SecurityException e) { e.printStackTrace(); return -1; } @@ -2227,4 +2227,3 @@ class SDLClipboardHandler implements SDLActivity.onNativeClipboardChanged(); } } - diff --git a/platforms/android/app/src/main/java/org/libsdl/app/SDLInputConnection.java b/platforms/android/app/src/main/java/org/libsdl/app/SDLInputConnection.java index 027b8fd3e5..fdc2994c15 100644 --- a/platforms/android/app/src/main/java/org/libsdl/app/SDLInputConnection.java +++ b/platforms/android/app/src/main/java/org/libsdl/app/SDLInputConnection.java @@ -65,17 +65,15 @@ class SDLInputConnection extends BaseInputConnection @Override public boolean deleteSurroundingText(int beforeLength, int afterLength) { - if (Build.VERSION.SDK_INT <= 29 /* Android 10.0 (Q) */) { - // Workaround to capture backspace key. Ref: http://stackoverflow.com/questions>/14560344/android-backspace-in-webview-baseinputconnection - // and https://bugzilla.libsdl.org/show_bug.cgi?id=2265 - if (beforeLength > 0 && afterLength == 0) { - // backspace(s) - while (beforeLength-- > 0) { - nativeGenerateScancodeForUnichar('\b'); - } - return true; - } - } + // Workaround to capture backspace key. Ref: http://stackoverflow.com/questions>/14560344/android-backspace-in-webview-baseinputconnection + // and https://bugzilla.libsdl.org/show_bug.cgi?id=2265 + if (beforeLength > 0 && afterLength == 0) { + // backspace(s) + while (beforeLength-- > 0) { + nativeGenerateScancodeForUnichar('\b'); + } + return true; + } if (!super.deleteSurroundingText(beforeLength, afterLength)) { return false; diff --git a/platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java b/platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java index 1579b73345..5ed335ac39 100644 --- a/platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java +++ b/platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java @@ -35,6 +35,8 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, View.OnApplyWindowInsetsListener, View.OnKeyListener, View.OnTouchListener, SensorEventListener, ScaleGestureDetector.OnScaleGestureListener { + private static native void auroraNativeSetSurfaceReady(boolean ready); + // Sensors protected SensorManager mSensorManager; protected Display mDisplay; @@ -96,6 +98,7 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, @Override public void surfaceCreated(SurfaceHolder holder) { Log.v("SDL", "surfaceCreated()"); + auroraNativeSetSurfaceReady(false); SDLActivity.onNativeSurfaceCreated(); } @@ -103,6 +106,7 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, @Override public void surfaceDestroyed(SurfaceHolder holder) { Log.v("SDL", "surfaceDestroyed()"); + auroraNativeSetSurfaceReady(false); // Transition to pause, if needed SDLActivity.mNextNativeState = SDLActivity.NativeState.PAUSED; @@ -192,6 +196,7 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, /* Surface is ready */ mIsSurfaceReady = true; + auroraNativeSetSurfaceReady(true); SDLActivity.mNextNativeState = SDLActivity.NativeState.RESUMED; SDLActivity.handleNativeState(); diff --git a/platforms/android/app/src/main/res/values/strings.xml b/platforms/android/app/src/main/res/values/strings.xml index 79d8812b82..752dae76bf 100644 --- a/platforms/android/app/src/main/res/values/strings.xml +++ b/platforms/android/app/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ Dusk + Dusk Data + Saves, texture packs, settings, and logs diff --git a/platforms/android/gradlew b/platforms/android/gradlew old mode 100644 new mode 100755 diff --git a/platforms/android/scripts/stage-jni-libs.sh b/platforms/android/scripts/stage-jni-libs.sh old mode 100644 new mode 100755 index ae25522e4d..42b08c13e6 --- a/platforms/android/scripts/stage-jni-libs.sh +++ b/platforms/android/scripts/stage-jni-libs.sh @@ -14,9 +14,22 @@ if [[ -z "$ANDROID_NDK_VER" ]] && [[ -d "$ANDROID_HOME_DIR/ndk" ]]; then fi if [[ -n "$ANDROID_NDK_VER" ]]; then - TOOLCHAIN_BIN="$ANDROID_HOME_DIR/ndk/$ANDROID_NDK_VER/toolchains/llvm/prebuilt/linux-x86_64/bin" - if [[ -x "$TOOLCHAIN_BIN/llvm-strip" ]]; then - STRIP_TOOL="$TOOLCHAIN_BIN/llvm-strip" + case "$(uname -s)" in + Darwin) HOST_TAG="darwin-x86_64" ;; + Linux) HOST_TAG="linux-x86_64" ;; + *) HOST_TAG="" ;; + esac + + PREBUILT_DIR="$ANDROID_HOME_DIR/ndk/$ANDROID_NDK_VER/toolchains/llvm/prebuilt" + if [[ -n "$HOST_TAG" && -x "$PREBUILT_DIR/$HOST_TAG/bin/llvm-strip" ]]; then + STRIP_TOOL="$PREBUILT_DIR/$HOST_TAG/bin/llvm-strip" + else + for candidate in "$PREBUILT_DIR"/*/bin/llvm-strip; do + if [[ -x "$candidate" ]]; then + STRIP_TOOL="$candidate" + break + fi + done fi fi @@ -25,29 +38,35 @@ copy_lib() { local src="$2" local dst_dir="$APP_DIR/$abi" local dst="$dst_dir/libmain.so" + local tmp="$dst_dir/.libmain.so.$$" + if [[ ! -f "$src" ]]; then + echo "Missing native library for $abi: $src" >&2 + exit 1 + fi + mkdir -p "$dst_dir" - cp -f "$src" "$dst" + cp -f "$src" "$tmp" if [[ "$ANDROID_STAGE_STRIP" != "0" ]] && [[ -n "$STRIP_TOOL" ]]; then - "$STRIP_TOOL" --strip-debug "$dst" - echo "Staged and stripped $src -> $dst" + "$STRIP_TOOL" --strip-unneeded "$tmp" + mv -f "$tmp" "$dst" + echo "Stripped and staged $src -> $dst" else + mv -f "$tmp" "$dst" echo "Staged $src -> $dst (strip disabled or strip tool unavailable)" fi } -declare -A ABI_TO_LIB=( - ["arm64-v8a"]="$ROOT_DIR/build/android-arm64/libmain.so" - ["x86_64"]="$ROOT_DIR/build/android-x86_64/libmain.so" -) - # Drop any previously staged ABI directories to avoid stale APK contents. rm -rf "$APP_DIR/x86" "$APP_DIR/arm64-v8a" "$APP_DIR/x86_64" for abi in $ANDROID_STAGE_ABIS; do - src="${ABI_TO_LIB[$abi]:-}" - if [[ -z "$src" ]]; then - echo "Unsupported ABI '$abi'. Supported ABIs: arm64-v8a x86_64" >&2 - exit 1 - fi + case "$abi" in + arm64-v8a) src="$ROOT_DIR/build/android-arm64/libmain.so" ;; + x86_64) src="$ROOT_DIR/build/android-x86_64/libmain.so" ;; + *) + echo "Unsupported ABI '$abi'. Supported ABIs: arm64-v8a x86_64" >&2 + exit 1 + ;; + esac copy_lib "$abi" "$src" done diff --git a/platforms/ios/Info.plist.in b/platforms/ios/Info.plist.in index 881d5aff35..395b29b7d7 100644 --- a/platforms/ios/Info.plist.in +++ b/platforms/ios/Info.plist.in @@ -79,5 +79,11 @@ CADisableMinimumFrameDurationOnPhone + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + LSSupportsGameMode + diff --git a/platforms/macos/Info.plist.in b/platforms/macos/Info.plist.in index b7bc7b706c..aee7393ee8 100644 --- a/platforms/macos/Info.plist.in +++ b/platforms/macos/Info.plist.in @@ -28,5 +28,7 @@ ${MACOSX_BUNDLE_SHORT_VERSION_STRING} NSHighResolutionCapable + LSSupportsGameMode + diff --git a/platforms/tvos/Info.plist.in b/platforms/tvos/Info.plist.in index 841d120cc2..49ed85edd7 100644 --- a/platforms/tvos/Info.plist.in +++ b/platforms/tvos/Info.plist.in @@ -45,5 +45,7 @@ LaunchScreen UIUserInterfaceStyle Automatic + LSSupportsGameMode + diff --git a/res/rml/overlay.rcss b/res/rml/overlay.rcss index 05c8440b55..3ce4d51068 100644 --- a/res/rml/overlay.rcss +++ b/res/rml/overlay.rcss @@ -163,30 +163,30 @@ icon { } icon.arrow-forward { - width: 24dp; - height: 24dp; - font-size: 24dp; + width: 1.2em; + height: 1.2em; + font-size: 1.2em; decorator: text("" center center); } icon.trophy { - width: 24dp; - height: 24dp; - font-size: 24dp; + width: 1.2em; + height: 1.2em; + font-size: 1.2em; decorator: text("" center center); } icon.controller { - width: 24dp; - height: 24dp; - font-size: 24dp; + width: 1.2em; + height: 1.2em; + font-size: 1.2em; decorator: text("" center center); } icon.warning { - width: 24dp; - height: 24dp; - font-size: 24dp; + width: 1.2em; + height: 1.2em; + font-size: 1.2em; decorator: text("" center center); } @@ -274,3 +274,18 @@ logo img.outer { transform: rotate(360deg); } } + +@media (max-height: 640dp) { + toast { + top: 20dp; + right: 20dp; + } + + toast.controller-warning { + bottom: 20dp; + } + + toast.menu-notification { + top: 20dp; + } +} diff --git a/res/rml/prelaunch.rcss b/res/rml/prelaunch.rcss index 9b83db4660..707d8b8234 100644 --- a/res/rml/prelaunch.rcss +++ b/res/rml/prelaunch.rcss @@ -275,7 +275,6 @@ body.mirrored version-info { .update { display: none; - font-size: 16dp; color: #A6A09B; align-items: center; justify-content: flex-end; @@ -375,8 +374,8 @@ body.animate-in .intro-item { } menu { - left: 20dp; - right: 20dp; + left: 32dp; + right: 32dp; width: auto; min-width: 0; max-width: none; @@ -387,8 +386,8 @@ body.animate-in .intro-item { } body.mirrored menu { - left: 20dp; - right: 20dp; + left: 32dp; + right: 32dp; flex-direction: row-reverse; } @@ -396,7 +395,7 @@ body.animate-in .intro-item { flex: 1 1 0; min-width: 0; max-width: 48%; - + margin-left: 32dp; } body.mirrored hero { @@ -438,9 +437,61 @@ body.animate-in .intro-item { decorator: horizontal-gradient(#FEE685FF #FEE68500); } - .eyebrow, - disc-info, - version-info { + .eyebrow { display: none; } + + disc-info { + right: 32dp; + left: auto; + bottom: 32dp; + top: auto; + text-align: right; + font-size: 16dp; + } + + #disc-status { + justify-content: flex-end; + } + + #disc-status icon { + font-size: 20dp; + } + + #disc-version { + font-size: 16dp; + } + + version-info { + right: 32dp; + left: auto; + bottom: auto; + top: 32dp; + text-align: right; + font-size: 16dp; + } + + .update { + font-size: 16dp; + } + + body.mirrored disc-info { + right: auto; + left: 32dp; + bottom: 32dp; + top: auto; + text-align: left; + } + + body.mirrored version-info { + right: auto; + left: 32dp; + bottom: auto; + top: 32dp; + text-align: left; + } + + body.mirrored #disc-status { + justify-content: flex-start; + } } diff --git a/res/rml/window.rcss b/res/rml/window.rcss index 8dd29b55e4..45473f312f 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -498,7 +498,6 @@ progress.verification-progress-bar { display: flex; flex-direction: row; flex-wrap: nowrap; - justify-content: stretch; align-items: stretch; gap: 12dp; width: 100%; diff --git a/src/d/actor/d_a_alink.cpp b/src/d/actor/d_a_alink.cpp index e14f598487..0204278ddc 100644 --- a/src/d/actor/d_a_alink.cpp +++ b/src/d/actor/d_a_alink.cpp @@ -51,6 +51,7 @@ #include "d/actor/d_a_ni.h" #include "d/d_s_play.h" +#include "dusk/frame_interpolation.h" #include "dusk/settings.h" #include "res/Object/Alink.h" #include @@ -14787,6 +14788,10 @@ void daAlink_c::deleteEquipItem(BOOL i_isPlaySound, BOOL i_isDeleteKantera) { mIronBallChainPos = NULL; mIronBallChainAngle = NULL; field_0x3848 = NULL; +#if TARGET_PC + mIBChainInterpPrevValid = false; + mIBChainInterpCurrValid = false; +#endif field_0x0774 = NULL; field_0x0778 = NULL; mpHookshotLinChk = NULL; @@ -19717,6 +19722,27 @@ int daAlink_c::draw() { ) { dComIfGd_getOpaListDark()->entryImm(mpHookChain, 0); + +#if TARGET_PC + if (dusk::getSettings().game.enableFrameInterpolation && + mEquipItem == dItemNo_IRONBALL_e && + mIronBallChainPos != NULL && mIronBallChainAngle != NULL) + { + if (mIBChainInterpCurrValid) { + memcpy(mIBChainInterpPrevPos, mIBChainInterpCurrPos, IRON_BALL_CHAIN_COUNT * sizeof(cXyz)); + memcpy(mIBChainInterpPrevAngle, mIBChainInterpCurrAngle, IRON_BALL_CHAIN_COUNT * sizeof(csXyz)); + mIBChainInterpPrevHandRoot = mIBChainInterpCurrHandRoot; + mIBChainInterpPrevValid = true; + } + + memcpy(mIBChainInterpCurrPos, mIronBallChainPos, IRON_BALL_CHAIN_COUNT * sizeof(cXyz)); + memcpy(mIBChainInterpCurrAngle, mIronBallChainAngle, IRON_BALL_CHAIN_COUNT * sizeof(csXyz)); + mIBChainInterpCurrHandRoot = mHookshotTopPos; + mIBChainInterpCurrValid = true; + + dusk::frame_interp::add_interpolation_callback(&ironBallChainInterpCallback, this); + } +#endif } } diff --git a/src/d/actor/d_a_alink_hook.inc b/src/d/actor/d_a_alink_hook.inc index c960d37a7b..36fba0c6d8 100644 --- a/src/d/actor/d_a_alink_hook.inc +++ b/src/d/actor/d_a_alink_hook.inc @@ -8,6 +8,8 @@ #include "d/actor/d_a_obj_swhang.h" #include "d/actor/d_a_obj_chandelier.h" #include "JSystem/J3DGraphBase/J3DMaterial.h" +#include "dusk/frame_interpolation.h" +#include "dusk/settings.h" enum { HS_MODE_NONE_e, @@ -235,6 +237,31 @@ void daAlink_c::hsChainShape_c::draw() { } } +#if TARGET_PC +static void ironBallChainInterpCallback(bool isSimFrame, void* pUserWork) { + static_cast(pUserWork)->onIronBallChainInterpCallback(); +} + +void daAlink_c::onIronBallChainInterpCallback() { + if (!mIBChainInterpPrevValid || !mIBChainInterpCurrValid) { + return; + } + if (mIronBallChainPos == NULL || mIronBallChainAngle == NULL) { + return; + } + + const f32 alpha = dusk::frame_interp::get_interpolation_step(); + + for (int i = 0; i < IRON_BALL_CHAIN_COUNT; i++) { + mIronBallChainPos[i] = mIBChainInterpPrevPos[i] + (mIBChainInterpCurrPos[i] - mIBChainInterpPrevPos[i]) * alpha; + mIronBallChainAngle[i].x = mIBChainInterpPrevAngle[i].x + (s16)((s16)(mIBChainInterpCurrAngle[i].x - mIBChainInterpPrevAngle[i].x) * alpha); + mIronBallChainAngle[i].y = mIBChainInterpPrevAngle[i].y + (s16)((s16)(mIBChainInterpCurrAngle[i].y - mIBChainInterpPrevAngle[i].y) * alpha); + mIronBallChainAngle[i].z = mIBChainInterpPrevAngle[i].z + (s16)((s16)(mIBChainInterpCurrAngle[i].z - mIBChainInterpPrevAngle[i].z) * alpha); + } + mHookshotTopPos = mIBChainInterpPrevHandRoot + (mIBChainInterpCurrHandRoot - mIBChainInterpPrevHandRoot) * alpha; +} +#endif + void daAlink_c::hookshotAtHitCallBack(dCcD_GObjInf* i_atObjInf, fopAc_ac_c* i_tgActor, dCcD_GObjInf* i_tgObjInf) { if (i_tgActor != NULL && fopAcM_IsActor(i_tgActor) && !i_tgObjInf->ChkTgHookshotThrough()) { diff --git a/src/d/actor/d_a_e_db.cpp b/src/d/actor/d_a_e_db.cpp index cbd21176b9..4d60fbd6f6 100644 --- a/src/d/actor/d_a_e_db.cpp +++ b/src/d/actor/d_a_e_db.cpp @@ -10,6 +10,10 @@ #include "f_op/f_op_kankyo_mng.h" #include "f_op/f_op_actor_enemy.h" +#if TARGET_PC +#include "dusk/frame_interpolation.h" +#endif + class daE_DB_HIO_c : public JORReflexible { public: daE_DB_HIO_c(); @@ -66,6 +70,22 @@ static BOOL leaf_anm_init(e_db_class* i_this, int i_anm, f32 i_morf, u8 i_mode, return FALSE; } +#if TARGET_PC +static void daE_DB_interp_callback(bool isSimFrame, void* pUserWork) { + e_db_class* i_this = (e_db_class*)pUserWork; + if (!i_this->mStalkLineInterpPrevValid || !i_this->mStalkLineInterpCurrValid) { + return; + } + const f32 alpha = dusk::frame_interp::get_interpolation_step(); + cXyz* dst = i_this->stalkLine.getPos(0); + for (int i = 0; i < 12; i++) { + const cXyz& p0 = i_this->mStalkLineInterpPrev[i]; + const cXyz& p1 = i_this->mStalkLineInterpCurr[i]; + dst[i] = p0 + (p1 - p0) * alpha; + } +} +#endif + static int daE_DB_Draw(e_db_class* i_this) { fopAc_ac_c* actor = &i_this->enemy; @@ -95,6 +115,17 @@ static int daE_DB_Draw(e_db_class* i_this) { static GXColor l_color = {0x14, 0x0F, 0x00, 0xFF}; i_this->stalkLine.update(12, l_color, &actor->tevStr); dComIfGd_set3DlineMat(&i_this->stalkLine); +#if TARGET_PC + if (dusk::getSettings().game.enableFrameInterpolation) { + if (i_this->mStalkLineInterpCurrValid) { + memcpy(i_this->mStalkLineInterpPrev, i_this->mStalkLineInterpCurr, sizeof(i_this->mStalkLineInterpCurr)); + i_this->mStalkLineInterpPrevValid = true; + } + memcpy(i_this->mStalkLineInterpCurr, i_this->stalkLine.getPos(0), 12 * sizeof(cXyz)); + i_this->mStalkLineInterpCurrValid = true; + dusk::frame_interp::add_interpolation_callback(&daE_DB_interp_callback, i_this); + } +#endif for (int i = 1; i < 11; i++) { if (i_this->thornModel[i] != NULL) { diff --git a/src/d/actor/d_a_e_hb.cpp b/src/d/actor/d_a_e_hb.cpp index d3177ba0f6..8ed9058c6f 100644 --- a/src/d/actor/d_a_e_hb.cpp +++ b/src/d/actor/d_a_e_hb.cpp @@ -9,6 +9,10 @@ #include "d/actor/d_a_e_hb_leaf.h" #include "f_op/f_op_actor_enemy.h" +#if TARGET_PC +#include "dusk/frame_interpolation.h" +#endif + enum daE_HB_ACTION { ACTION_STAY, ACTION_APPEAR, @@ -64,6 +68,22 @@ static BOOL leaf_anm_init(e_hb_class* i_this, int i_anm, f32 i_morf, u8 i_mode, return FALSE; } +#if TARGET_PC +static void daE_HB_interp_callback(bool isSimFrame, void* pUserWork) { + e_hb_class* i_this = (e_hb_class*)pUserWork; + if (!i_this->mStalkLineInterpPrevValid || !i_this->mStalkLineInterpCurrValid) { + return; + } + const f32 alpha = dusk::frame_interp::get_interpolation_step(); + cXyz* dst = i_this->stalkLine.getPos(0); + for (int i = 0; i < 12; i++) { + const cXyz& p0 = i_this->mStalkLineInterpPrev[i]; + const cXyz& p1 = i_this->mStalkLineInterpCurr[i]; + dst[i] = p0 + (p1 - p0) * alpha; + } +} +#endif + static int daE_HB_Draw(e_hb_class* i_this) { fopAc_ac_c* actor = &i_this->enemy; @@ -82,6 +102,17 @@ static int daE_HB_Draw(e_hb_class* i_this) { static GXColor l_color = {0x14, 0x0F, 0x00, 0xFF}; i_this->stalkLine.update(12, l_color, &actor->tevStr); dComIfGd_set3DlineMat(&i_this->stalkLine); +#if TARGET_PC + if (dusk::getSettings().game.enableFrameInterpolation) { + if (i_this->mStalkLineInterpCurrValid) { + memcpy(i_this->mStalkLineInterpPrev, i_this->mStalkLineInterpCurr, sizeof(i_this->mStalkLineInterpCurr)); + i_this->mStalkLineInterpPrevValid = true; + } + memcpy(i_this->mStalkLineInterpCurr, i_this->stalkLine.getPos(0), 12 * sizeof(cXyz)); + i_this->mStalkLineInterpCurrValid = true; + dusk::frame_interp::add_interpolation_callback(&daE_HB_interp_callback, i_this); + } +#endif for (int i = 1; i < 11; i++) { if (i_this->thornModel[i] != NULL) { diff --git a/src/d/actor/d_a_e_s1.cpp b/src/d/actor/d_a_e_s1.cpp index 48cd7d6800..a5a05fce20 100644 --- a/src/d/actor/d_a_e_s1.cpp +++ b/src/d/actor/d_a_e_s1.cpp @@ -14,6 +14,8 @@ #include "d/d_s_play.h" #include "f_op/f_op_actor_enemy.h" #include "f_op/f_op_camera_mng.h" +#include "dusk/frame_interpolation.h" +#include "dusk/settings.h" #include class daE_S1_HIO_c { @@ -99,6 +101,25 @@ static void anm_init(e_s1_class* i_this, int i_resNo, f32 i_morf, u8 i_attr, f32 i_this->mAnm = i_resNo; } +#if TARGET_PC +static void daE_S1_interp_callback(bool isSimFrame, void* pUserWork) { + e_s1_class* i_this = (e_s1_class*)pUserWork; + if (!i_this->mHairInterpPrevValid || !i_this->mHairInterpCurrValid) { + return; + } + const f32 alpha = dusk::frame_interp::get_interpolation_step(); + for (int s = 0; s < e_s1_class::HAIR_STRAND_COUNT; s++) { + cXyz* dst = i_this->mLineMat.getPos(s); + for (int i = 0; i < e_s1_class::HAIR_SEGMENT_COUNT; i++) { + int idx = s * e_s1_class::HAIR_SEGMENT_COUNT + i; + const cXyz& p0 = i_this->mHairInterpPrev[idx]; + const cXyz& p1 = i_this->mHairInterpCurr[idx]; + dst[i] = p0 + (p1 - p0) * alpha; + } + } +} +#endif + static int daE_S1_Draw(e_s1_class* i_this) { if (i_this->field_0x306c != 0) { return 1; @@ -132,6 +153,22 @@ static int daE_S1_Draw(e_s1_class* i_this) { i_this->mLineMat.update(16, line_color, &i_this->tevStr); dComIfGd_set3DlineMatDark(&i_this->mLineMat); +#if TARGET_PC + if (dusk::getSettings().game.enableFrameInterpolation) { + if (i_this->mHairInterpCurrValid) { + memcpy(i_this->mHairInterpPrev, i_this->mHairInterpCurr, sizeof(i_this->mHairInterpCurr)); + i_this->mHairInterpPrevValid = true; + } + for (int s = 0; s < e_s1_class::HAIR_STRAND_COUNT; s++) { + cXyz* src = i_this->mLineMat.getPos(s); + memcpy(&i_this->mHairInterpCurr[s * e_s1_class::HAIR_SEGMENT_COUNT], src, + e_s1_class::HAIR_SEGMENT_COUNT * sizeof(cXyz)); + } + i_this->mHairInterpCurrValid = true; + dusk::frame_interp::add_interpolation_callback(&daE_S1_interp_callback, i_this); + } +#endif + dComIfGd_setList(); return 1; } @@ -2149,6 +2186,11 @@ static int daE_S1_Create(fopAc_ac_c* i_this) { return cPhs_ERROR_e; } +#if TARGET_PC + a_this->mHairInterpPrevValid = false; + a_this->mHairInterpCurrValid = false; +#endif + OS_REPORT("//////////////E_S1 SET 2 !!\n"); if (path_no != 0xFF) { diff --git a/src/d/actor/d_a_e_yd.cpp b/src/d/actor/d_a_e_yd.cpp index bdcd044eed..18809e05ea 100644 --- a/src/d/actor/d_a_e_yd.cpp +++ b/src/d/actor/d_a_e_yd.cpp @@ -12,6 +12,10 @@ #include "d/d_cc_uty.h" #include "f_op/f_op_actor_enemy.h" +#if TARGET_PC +#include "dusk/frame_interpolation.h" +#endif + class daE_YD_HIO_c { public: daE_YD_HIO_c(); @@ -73,6 +77,22 @@ static s32 leaf_anm_init(e_yd_class* i_this, int param_1, f32 param_2, u8 param_ return false; } +#if TARGET_PC +static void daE_YD_interp_callback(bool isSimFrame, void* pUserWork) { + e_yd_class* i_this = (e_yd_class*)pUserWork; + if (!i_this->mLineMatInterpPrevValid || !i_this->mLineMatInterpCurrValid) { + return; + } + const f32 alpha = dusk::frame_interp::get_interpolation_step(); + cXyz* dst = i_this->mLineMat.getPos(0); + for (int i = 0; i < 12; i++) { + const cXyz& p0 = i_this->mLineMatInterpPrev[i]; + const cXyz& p1 = i_this->mLineMatInterpCurr[i]; + dst[i] = p0 + (p1 - p0) * alpha; + } +} +#endif + static s32 daE_YD_Draw(e_yd_class* i_this) { static GXColor l_color = { 0x14, 0x0F, 0x00, 0xFF }; @@ -86,6 +106,17 @@ static s32 daE_YD_Draw(e_yd_class* i_this) { i_this->mpMorf->entryDL(); i_this->mLineMat.update(12, l_color, &i_this->actor.tevStr); dComIfGd_set3DlineMat(&i_this->mLineMat); +#if TARGET_PC + if (dusk::getSettings().game.enableFrameInterpolation) { + if (i_this->mLineMatInterpCurrValid) { + memcpy(i_this->mLineMatInterpPrev, i_this->mLineMatInterpCurr, sizeof(i_this->mLineMatInterpCurr)); + i_this->mLineMatInterpPrevValid = true; + } + memcpy(i_this->mLineMatInterpCurr, i_this->mLineMat.getPos(0), 12 * sizeof(cXyz)); + i_this->mLineMatInterpCurrValid = true; + dusk::frame_interp::add_interpolation_callback(&daE_YD_interp_callback, i_this); + } +#endif for (s32 i = 1; i < 11; i++) { if (i_this->field_0x77c[i] != 0) { g_env_light.setLightTevColorType_MAJI(i_this->field_0x77c[i], &i_this->actor.tevStr); diff --git a/src/d/actor/d_a_e_yg.cpp b/src/d/actor/d_a_e_yg.cpp index 9ab6c80e6d..44a8aee249 100644 --- a/src/d/actor/d_a_e_yg.cpp +++ b/src/d/actor/d_a_e_yg.cpp @@ -10,6 +10,8 @@ #include "f_op/f_op_kankyo_mng.h" #include "d/actor/d_a_obj_carry.h" #include "Z2AudioLib/Z2Instances.h" +#include "dusk/frame_interpolation.h" +#include "dusk/settings.h" #include "f_op/f_op_actor_enemy.h" enum E_yg_RES_File_ID { @@ -134,7 +136,26 @@ static BOOL pl_check(e_yg_class* i_this, f32 i_dist) { return FALSE; } -static int daE_YG_Draw(e_yg_class* i_this) { +#if TARGET_PC +static void daE_YG_interp_callback(bool isSimFrame, void* pUserWork) { + e_yg_class* i_this = (e_yg_class*)pUserWork; + if (!i_this->mTentacleInterpPrevValid || !i_this->mTentacleInterpCurrValid) { + return; + } + const f32 alpha = dusk::frame_interp::get_interpolation_step(); + for (int s = 0; s < e_yg_class::TENTACLE_STRAND_COUNT; s++) { + cXyz* dst = i_this->mLineMat.getPos(s); + for (int i = 0; i < e_yg_class::TENTACLE_SEGMENT_COUNT; i++) { + int idx = s * e_yg_class::TENTACLE_SEGMENT_COUNT + i; + const cXyz& p0 = i_this->mTentacleInterpPrev[idx]; + const cXyz& p1 = i_this->mTentacleInterpCurr[idx]; + dst[i] = p0 + (p1 - p0) * alpha; + } + } +} +#endif + +static int daE_YG_Draw(e_yg_class* i_this) { if (i_this->mDispFlag) { return 1; } @@ -160,6 +181,23 @@ static int daE_YG_Draw(e_yg_class* i_this) { color.a = 0xFF; i_this->mLineMat.update(10, color, &actor->tevStr); dComIfGd_set3DlineMatDark(&i_this->mLineMat); + +#if TARGET_PC + if (dusk::getSettings().game.enableFrameInterpolation) { + if (i_this->mTentacleInterpCurrValid) { + memcpy(i_this->mTentacleInterpPrev, i_this->mTentacleInterpCurr, sizeof(i_this->mTentacleInterpCurr)); + i_this->mTentacleInterpPrevValid = true; + } + for (int s = 0; s < e_yg_class::TENTACLE_STRAND_COUNT; s++) { + cXyz* src = i_this->mLineMat.getPos(s); + memcpy(&i_this->mTentacleInterpCurr[s * e_yg_class::TENTACLE_SEGMENT_COUNT], src, + e_yg_class::TENTACLE_SEGMENT_COUNT * sizeof(cXyz)); + } + i_this->mTentacleInterpCurrValid = true; + dusk::frame_interp::add_interpolation_callback(&daE_YG_interp_callback, i_this); + } +#endif + dComIfGd_setList(); return 1; @@ -1378,6 +1416,11 @@ static cPhs_Step daE_YG_Create(fopAc_ac_c* actor) { return cPhs_ERROR_e; } +#if TARGET_PC + i_this->mTentacleInterpPrevValid = false; + i_this->mTentacleInterpCurrValid = false; +#endif + if (!hio_set) { i_this->mIsFirstSpawn = 1; hio_set = true; diff --git a/src/d/actor/d_a_e_yh.cpp b/src/d/actor/d_a_e_yh.cpp index bbbd3e5129..cf9955a7db 100644 --- a/src/d/actor/d_a_e_yh.cpp +++ b/src/d/actor/d_a_e_yh.cpp @@ -12,6 +12,10 @@ #include "f_op/f_op_actor_enemy.h" #include "f_op/f_op_kankyo_mng.h" +#if TARGET_PC +#include "dusk/frame_interpolation.h" +#endif + class daE_YH_HIO_c : public JORReflexible { public: daE_YH_HIO_c(); @@ -85,6 +89,22 @@ static BOOL leaf_anm_init(e_yh_class* i_this, int param_2, f32 param_3, u8 param return FALSE; } +#if TARGET_PC +static void daE_YH_interp_callback(bool isSimFrame, void* pUserWork) { + e_yh_class* i_this = (e_yh_class*)pUserWork; + if (!i_this->mLineInterpPrevValid || !i_this->mLineInterpCurrValid) { + return; + } + const f32 alpha = dusk::frame_interp::get_interpolation_step(); + cXyz* dst = i_this->mLine.getPos(0); + for (int i = 0; i < 12; i++) { + const cXyz& p0 = i_this->mLineInterpPrev[i]; + const cXyz& p1 = i_this->mLineInterpCurr[i]; + dst[i] = p0 + (p1 - p0) * alpha; + } +} +#endif + static int daE_YH_Draw(e_yh_class* i_this) { fopAc_ac_c* a_this = (fopAc_ac_c*)i_this; @@ -114,6 +134,17 @@ static int daE_YH_Draw(e_yh_class* i_this) { i_this->mLine.update(12, l_color, &a_this->tevStr); dComIfGd_set3DlineMat(&i_this->mLine); +#if TARGET_PC + if (dusk::getSettings().game.enableFrameInterpolation) { + if (i_this->mLineInterpCurrValid) { + memcpy(i_this->mLineInterpPrev, i_this->mLineInterpCurr, sizeof(i_this->mLineInterpCurr)); + i_this->mLineInterpPrevValid = true; + } + memcpy(i_this->mLineInterpCurr, i_this->mLine.getPos(0), 12 * sizeof(cXyz)); + i_this->mLineInterpCurrValid = true; + dusk::frame_interp::add_interpolation_callback(&daE_YH_interp_callback, i_this); + } +#endif for (int i = 1; i < 11; i++) { if (i_this->mModels[i] != NULL) { diff --git a/src/d/actor/d_a_mg_fshop.cpp b/src/d/actor/d_a_mg_fshop.cpp index f0905efd5d..0d5b6dd2c9 100644 --- a/src/d/actor/d_a_mg_fshop.cpp +++ b/src/d/actor/d_a_mg_fshop.cpp @@ -729,10 +729,12 @@ static void koro2_game(fshop_class* i_this) { cLib_addCalcAngleS2(&i_this->field_0x4020.z, 0, 2, 0x200); case 2: #if TARGET_PC - if (dusk::getSettings().game.enableGyroRollgoal) { + if (dusk::gyro::rollgoal_gyro_enabled()) { if (!dusk::gyro::get_sensor_keep_alive()) { dusk::gyro::set_sensor_keep_alive(true); } + } else if (dusk::gyro::get_sensor_keep_alive()) { + dusk::gyro::set_sensor_keep_alive(false); } #endif @@ -753,7 +755,7 @@ static void koro2_game(fshop_class* i_this) { old_stick_x = mDoCPd_c::getSubStickX(PAD_1); cLib_addCalcAngleS2(&i_this->field_0x4060, i_this->field_0x4062, 4, 0x1000); #if TARGET_PC - if (dusk::getSettings().game.enableGyroRollgoal) { + if (dusk::gyro::rollgoal_gyro_enabled()) { dusk::gyro::rollgoalTick(true, i_this->field_0x4060); } #endif @@ -791,7 +793,7 @@ static void koro2_game(fshop_class* i_this) { s16 gyro_ax = 0; s16 gyro_az = 0; #if TARGET_PC - if (dusk::getSettings().game.enableGyroRollgoal) { + if (dusk::gyro::rollgoal_gyro_enabled()) { dusk::gyro::rollgoalTableOffset(gyro_ax, gyro_az); } #endif diff --git a/src/d/actor/d_a_midna.cpp b/src/d/actor/d_a_midna.cpp index 2356b693be..3bc500991d 100644 --- a/src/d/actor/d_a_midna.cpp +++ b/src/d/actor/d_a_midna.cpp @@ -419,9 +419,30 @@ int daMidna_c::createHeap() { return 0; } +#if TARGET_PC + if (mpDemoFCTongueBmd != NULL) { + if(!daAlink_c::initDemoBck(&mpDemoFCTmpBck, "demo00_Midna_cut00_FC_tmp.bck")) { + return 0; + } + + // Update Midna's eye maxLOD to prevent the eyes from disappearing + J3DTexture* tex = mpDemoFCTongueBmd->getModelData()->getTexture(); + JUTNameTab* nametable = mpDemoFCTongueBmd->getModelData()->getTextureName(); + if (tex != nullptr && nametable != nullptr) { + for (u16 i = 0; i < tex->getNum(); i++) { + const char* tex_name = nametable->getName(i); + if (tex_name != NULL && strcmp(tex_name, "midona_eyeball") == 0) { + ResTIMG* timg = tex->getResTIMG(i); + timg->maxLOD = 0; + } + } + } + } +#else if (mpDemoFCTongueBmd != NULL && !daAlink_c::initDemoBck(&mpDemoFCTmpBck, "demo00_Midna_cut00_FC_tmp.bck")) { return 0; } +#endif modelData = (J3DModelData*)dComIfG_getObjectRes(dStage_roomControl_c::getDemoArcName(), "demo00_Midna_cut00_BD_tmp.bmd"); @@ -433,6 +454,21 @@ int daMidna_c::createHeap() { modelData->getMaterialNodePointer(2)->setMaterialAnm(mpEyeMatAnm[0]); modelData->getMaterialNodePointer(3)->setMaterialAnm(mpEyeMatAnm[1]); + +#if TARGET_PC + // Update Midna's eye maxLOD to prevent the eyes from disappearing + J3DTexture* tex = modelData->getTexture(); + JUTNameTab* nametable = modelData->getTextureName(); + if (tex != nullptr && nametable != nullptr) { + for (u16 i = 0; i < tex->getNum(); i++) { + const char* tex_name = nametable->getName(i); + if (tex_name != NULL && strcmp(tex_name, "midona_eyeball") == 0) { + ResTIMG* timg = tex->getResTIMG(i); + timg->maxLOD = 0; + } + } + } +#endif } if (!initDemoModel(&mpDemoBDMaskBmd, "demo00_Midna_cut00_BD_mask.bmd", 0)) { diff --git a/src/d/actor/d_a_obj_fchain.cpp b/src/d/actor/d_a_obj_fchain.cpp index e681fec4e7..024b4ecf8b 100644 --- a/src/d/actor/d_a_obj_fchain.cpp +++ b/src/d/actor/d_a_obj_fchain.cpp @@ -10,6 +10,8 @@ #include "JSystem/J3DGraphBase/J3DDrawBuffer.h" #include "SSystem/SComponent/c_math.h" #include "d/d_com_inf_game.h" +#include "dusk/frame_interpolation.h" +#include "dusk/settings.h" #include static char const l_arcName[] = "Fchain"; @@ -65,6 +67,10 @@ int daObjFchain_c::create() { local_48++; } rv = cPhs_COMPLEATE_e; +#if TARGET_PC + mChainInterpPrevValid = false; + mChainInterpCurrValid = false; +#endif break; } return rv; @@ -289,6 +295,26 @@ void daObjFchain_shape_c::draw() { } } +#if TARGET_PC +static void fchain_interp_callback(bool isSimFrame, void* pUserWork) { + static_cast(pUserWork)->onInterpCallback(); +} + +void daObjFchain_c::onInterpCallback() { + if (!mChainInterpPrevValid || !mChainInterpCurrValid) { + return; + } + + const f32 alpha = dusk::frame_interp::get_interpolation_step(); + + for (int i = 0; i < CHAIN_COUNT; i++) { + const cXyz& p0 = mChainInterpPrev[i]; + const cXyz& p1 = mChainInterpCurr[i]; + field_0x694[i] = p0 + (p1 - p0) * alpha; + } +} +#endif + int daObjFchain_c::draw() { if (field_0x584 != 0) { g_env_light.settingTevStruct(0, ¤t.pos, &tevStr); @@ -297,6 +323,19 @@ int daObjFchain_c::draw() { return 1; } dComIfGd_getOpaListDark()->entryImm(&mShape, 0); + +#if TARGET_PC + if (dusk::getSettings().game.enableFrameInterpolation) { + if (mChainInterpCurrValid) { + memcpy(mChainInterpPrev, mChainInterpCurr, sizeof(mChainInterpCurr)); + mChainInterpPrevValid = true; + } + + memcpy(mChainInterpCurr, field_0x694, sizeof(mChainInterpCurr)); + mChainInterpCurrValid = true; + dusk::frame_interp::add_interpolation_callback(&fchain_interp_callback, this); + } +#endif } return 1; } diff --git a/src/d/d_menu_letter.cpp b/src/d/d_menu_letter.cpp index 589b2553c0..4bfda4b9cd 100644 --- a/src/d/d_menu_letter.cpp +++ b/src/d/d_menu_letter.cpp @@ -965,7 +965,8 @@ void dMenu_Letter_c::screenSetBase() { } if (field_0x374 > 1) { J2DPane* pJVar6 = mpBaseScreen->search('pi_n'); - f32 dVar18 = field_0x1f0[1]->getBounds().i.x - field_0x1f0[0]->getBounds().i.x; + f32 x1 = field_0x1f0[1]->getBounds().i.x; + f32 dVar18 = x1 - field_0x1f0[0]->getBounds().i.x; f32 dVar17 = dVar18 * (field_0x374 - 1); f32 dVar16 = (pJVar6->getWidth() / 2) - (dVar17 / 2); for (int i = 0; i < 9; i++) { diff --git a/src/d/d_msg_object.cpp b/src/d/d_msg_object.cpp index 7e3be2cb08..44b5c66571 100644 --- a/src/d/d_msg_object.cpp +++ b/src/d/d_msg_object.cpp @@ -427,14 +427,15 @@ static void dummyStrings() { dMsgObject_HIO_c g_MsgObject_HIO_c; int dMsgObject_c::_execute() { -#if TARGET_PC +// TODO: enabling wii message overrides fixes direction text, but gives wrong item control text +/*#if TARGET_PC if (dusk::getSettings().game.enableMirrorMode) { // enable wii message index override g_MsgObject_HIO_c.mMessageDisplay = 1; } else if (!dusk::getSettings().game.enableMirrorMode && g_MsgObject_HIO_c.mMessageDisplay == 1) { g_MsgObject_HIO_c.mMessageDisplay = 0; } -#endif +#endif*/ field_0x4c7 = 0; diff --git a/src/dusk/achievements.cpp b/src/dusk/achievements.cpp index eefcda978f..28e66c891e 100644 --- a/src/dusk/achievements.cpp +++ b/src/dusk/achievements.cpp @@ -1030,7 +1030,7 @@ void AchievementSystem::load() { return; } try { - auto data = io::FileStream::ReadAllBytes(filePath.string().c_str()); + auto data = io::FileStream::ReadAllBytes(filePath); auto j = json::parse(data); if (!j.is_object()) { return; @@ -1067,7 +1067,7 @@ void AchievementSystem::save() { } try { io::FileStream::WriteAllText( - (dusk::ConfigPath / ACHIEVEMENTS_FILENAME).string().c_str(), + dusk::ConfigPath / ACHIEVEMENTS_FILENAME, j.dump(2) ); } catch (const std::exception&) {} @@ -1113,7 +1113,7 @@ void AchievementSystem::processEntry(Entry& e) { if (nowUnlocked) { e.achievement.progress = e.achievement.isCounter ? e.achievement.goal : 1; e.achievement.unlocked = true; - if (getSettings().game.enableAchievementNotifications) { + if (getSettings().game.enableAchievementToasts) { ui::push_toast({ .type = "achievement", .title = "Achievement Unlocked!", diff --git a/src/dusk/config.cpp b/src/dusk/config.cpp index f4af7a2961..8225536d34 100644 --- a/src/dusk/config.cpp +++ b/src/dusk/config.cpp @@ -23,8 +23,8 @@ aurora::Module DuskConfigLog("dusk::config"); static absl::flat_hash_map RegisteredConfigVars; static bool RegistrationDone = false; -static std::string GetConfigJsonPath() { - return (dusk::ConfigPath / ConfigFileName).string(); +static std::u8string GetConfigJsonPath() { + return (dusk::ConfigPath / ConfigFileName).u8string(); } ConfigVarBase::ConfigVarBase(const char* name, const ConfigImplBase* impl) : name(name), registered(false), layer(ConfigVarLayer::Default), impl(impl) { @@ -156,6 +156,7 @@ namespace dusk::config { template class ConfigImpl; template class ConfigImpl; template class ConfigImpl; + template class ConfigImpl; } void dusk::config::Register(ConfigVarBase& configVar) { @@ -188,7 +189,7 @@ void dusk::config::LoadFromUserPreferences() { if (configJsonPath.empty()) { return; } - LoadFromFileName(configJsonPath.c_str()); + LoadFromFileName(reinterpret_cast(configJsonPath.c_str())); } static void LoadFromPath(const char* path) { @@ -240,7 +241,9 @@ void dusk::config::Save() { return; } - DuskConfigLog.info("Saving config to '{}'", configJsonPath); + DuskConfigLog.info( + "Saving config to '{}'", + reinterpret_cast(configJsonPath.c_str())); json j; @@ -250,7 +253,7 @@ void dusk::config::Save() { } } - io::FileStream::WriteAllText(configJsonPath.c_str(), j.dump(4)); + io::FileStream::WriteAllText(reinterpret_cast(configJsonPath.c_str()), j.dump(4)); } ConfigVarBase* dusk::config::GetConfigVar(std::string_view name) { diff --git a/src/dusk/file_select.cpp b/src/dusk/file_select.cpp index ec388f86cd..95df652940 100644 --- a/src/dusk/file_select.cpp +++ b/src/dusk/file_select.cpp @@ -1,9 +1,16 @@ #include "file_select.hpp" #include +#include #include #include +#include + +#if defined(__ANDROID__) || defined(ANDROID) +#include +#include +#endif #if defined(__APPLE__) #include @@ -19,6 +26,92 @@ namespace dusk { namespace { +std::string fallback_display_name(std::string_view path) { + if (path.empty()) { + return {}; + } + + std::string pathString(path); + const std::size_t slash = pathString.find_last_of("/\\"); + if (slash == std::string::npos || slash + 1 >= pathString.size()) { + return pathString; + } + return pathString.substr(slash + 1); +} + +#if defined(__ANDROID__) || defined(ANDROID) +bool clear_pending_exception(JNIEnv* env) { + if (env == nullptr || !env->ExceptionCheck()) { + return false; + } + env->ExceptionClear(); + return true; +} + +std::string to_string(JNIEnv* env, jstring value) { + if (env == nullptr || value == nullptr) { + return {}; + } + + const char* utf8 = env->GetStringUTFChars(value, nullptr); + if (utf8 == nullptr) { + clear_pending_exception(env); + return {}; + } + + std::string result(utf8); + env->ReleaseStringUTFChars(value, utf8); + return result; +} + +std::string android_display_name(std::string_view path) { + auto* env = static_cast(SDL_GetAndroidJNIEnv()); + if (env == nullptr) { + return {}; + } + + jobject activity = static_cast(SDL_GetAndroidActivity()); + if (activity == nullptr || clear_pending_exception(env)) { + if (activity != nullptr) { + env->DeleteLocalRef(activity); + } + return {}; + } + + jclass activityClass = env->GetObjectClass(activity); + if (activityClass == nullptr || clear_pending_exception(env)) { + env->DeleteLocalRef(activity); + return {}; + } + + jmethodID getDisplayName = env->GetMethodID( + activityClass, "getDisplayNameForUri", "(Ljava/lang/String;)Ljava/lang/String;"); + env->DeleteLocalRef(activityClass); + if (getDisplayName == nullptr || clear_pending_exception(env)) { + env->DeleteLocalRef(activity); + return {}; + } + + jstring uri = env->NewStringUTF(std::string(path).c_str()); + if (uri == nullptr || clear_pending_exception(env)) { + env->DeleteLocalRef(activity); + return {}; + } + + auto* displayName = + static_cast(env->CallObjectMethod(activity, getDisplayName, uri)); + env->DeleteLocalRef(uri); + env->DeleteLocalRef(activity); + if (displayName == nullptr || clear_pending_exception(env)) { + return {}; + } + + std::string result = to_string(env, displayName); + env->DeleteLocalRef(displayName); + return result; +} +#endif + #if USE_IOS_DIALOG struct IOSDialogCallbackState { FileCallback callback; @@ -89,6 +182,18 @@ void ShowFileSelect(FileCallback callback, void* userdata, SDL_Window* window, #endif } +std::string display_name_for_path(std::string_view path) { +#if defined(__ANDROID__) || defined(ANDROID) + if (path.starts_with("content:") || path.starts_with("file:")) { + std::string displayName = android_display_name(path); + if (!displayName.empty()) { + return displayName; + } + } +#endif + return fallback_display_name(path); +} + void ShowFolderSelect(FileCallback callback, void* userdata, SDL_Window* window, const char* default_location) { if (callback == nullptr) { diff --git a/src/dusk/file_select.hpp b/src/dusk/file_select.hpp index 97c739b67a..f523ce90a6 100644 --- a/src/dusk/file_select.hpp +++ b/src/dusk/file_select.hpp @@ -2,6 +2,9 @@ #include +#include +#include + struct SDL_Window; namespace dusk { @@ -9,8 +12,10 @@ namespace dusk { using FileCallback = void (*)(void* userdata, const char* path, const char* error); void ShowFileSelect(FileCallback callback, void* userdata, SDL_Window* window, - const SDL_DialogFileFilter* filters, int nfilters, const char* default_location, - bool allow_many); + const SDL_DialogFileFilter* filters, int nfilters, const char* default_location, + bool allow_many); + +std::string display_name_for_path(std::string_view path); void ShowFolderSelect(FileCallback callback, void* userdata, SDL_Window* window, const char* default_location); diff --git a/src/dusk/gyro.cpp b/src/dusk/gyro.cpp index abe22909c1..680d500631 100644 --- a/src/dusk/gyro.cpp +++ b/src/dusk/gyro.cpp @@ -1,5 +1,9 @@ #include "dusk/gyro.h" +#include "dusk/ui/ui.hpp" #include "d/actor/d_a_alink.h" + +#include +#include #include namespace dusk::gyro { @@ -12,11 +16,14 @@ constexpr float kGravityEmaAlpha = 0.1f; constexpr float kMinGravityProjection = 0.2f; // Let roll contribute more strongly as the pad approaches an upright posture. constexpr float kRollAimBoostMax = 2.0f; +constexpr float kMousePixelToRad = 0.0025f; bool s_sensor_enabled = false; bool s_accel_enabled = false; bool s_was_aiming = false; bool s_have_gravity_baseline = false; +bool s_mouse_enabled = false; +bool s_mouse_relative = false; float s_smooth_gx = 0.0f; float s_smooth_gy = 0.0f; float s_smooth_gz = 0.0f; @@ -36,6 +43,7 @@ void reset_filter_state() { s_baseline_gravity_y = s_baseline_gravity_z = 0.0f; s_was_aiming = false; s_have_gravity_baseline = false; + s_mouse_enabled = false; s_yaw_rad = s_pitch_rad = s_roll_rad = 0.0f; s_rollgoal_ax = s_rollgoal_az = 0; } @@ -46,14 +54,29 @@ float apply_deadband(float v, float deadband_rad_s) { } return v; } + +void disable_pad_sensors() { + if (s_sensor_enabled) { + PADSetSensorEnabled(PAD_CHAN0, PAD_SENSOR_GYRO, FALSE); + s_sensor_enabled = false; + } + if (s_accel_enabled) { + PADSetSensorEnabled(PAD_CHAN0, PAD_SENSOR_ACCEL, FALSE); + s_accel_enabled = false; + } +} } // namespace bool s_sensor_keep_alive = false; bool get_sensor_keep_alive() { return s_sensor_keep_alive; } void set_sensor_keep_alive(bool value) { s_sensor_keep_alive = value; } +bool rollgoal_gyro_enabled() { + return getSettings().game.enableGyroRollgoal && getSettings().game.gyroMode.getValue() != GyroMode::Mouse; +} + bool queryGyroAimContext() { - if (!static_cast(dusk::getSettings().game.enableGyroAim)) { + if (!static_cast(getSettings().game.enableGyroAim)) { return false; } @@ -71,15 +94,28 @@ void read(float dt) { const bool aim_just_ended = !aim_active && s_was_aiming; s_was_aiming = aim_active; + const bool mouse_mode = getSettings().game.gyroMode.getValue() == GyroMode::Mouse; + const bool mouse_gyro_active = !ui::any_document_visible() && mouse_mode && (aim_active || s_sensor_keep_alive); + SDL_Window* window = aurora::window::get_sdl_window(); + if (window != nullptr && mouse_gyro_active != s_mouse_relative && + SDL_SetWindowRelativeMouseMode(window, mouse_gyro_active)) + { + s_mouse_relative = mouse_gyro_active; + } + + if (mouse_gyro_active && !s_mouse_enabled && window != nullptr) { + const AuroraWindowSize sz = aurora::window::get_window_size(); + const float cx = static_cast(sz.width) * 0.5f; + const float cy = static_cast(sz.height) * 0.5f; + SDL_WarpMouseInWindow(window, cx, cy); + float discard_x = 0.0f; + float discard_y = 0.0f; + SDL_GetRelativeMouseState(&discard_x, &discard_y); + } + s_mouse_enabled = mouse_gyro_active; + if (!s_sensor_keep_alive && !aim_active) { - if (s_sensor_enabled) { - PADSetSensorEnabled(PAD_CHAN0, PAD_SENSOR_GYRO, FALSE); - s_sensor_enabled = false; - } - if (s_accel_enabled) { - PADSetSensorEnabled(PAD_CHAN0, PAD_SENSOR_ACCEL, FALSE); - s_accel_enabled = false; - } + disable_pad_sensors(); reset_filter_state(); return; } @@ -90,6 +126,31 @@ void read(float dt) { s_have_gravity_baseline = false; } + if (mouse_mode && !mouse_gyro_active) { + s_pitch_rad = 0.0f; + s_yaw_rad = 0.0f; + s_roll_rad = 0.0f; + return; + } + + if (mouse_mode) { + disable_pad_sensors(); + + float mx_rel = 0.0f; + float my_rel = 0.0f; + SDL_GetRelativeMouseState(&mx_rel, &my_rel); + // Convert pixels to radians + s_pitch_rad = my_rel * kMousePixelToRad * getSettings().game.gyroSensitivityY; + s_yaw_rad = -mx_rel * kMousePixelToRad * getSettings().game.gyroSensitivityX; + s_roll_rad = 0.0f; + + s_pitch_rad = getSettings().game.gyroInvertPitch ? -s_pitch_rad : s_pitch_rad; + s_yaw_rad = getSettings().game.gyroInvertYaw ? -s_yaw_rad : s_yaw_rad; + s_yaw_rad = getSettings().game.enableMirrorMode ? -s_yaw_rad : s_yaw_rad; + + return; + } + if (!s_sensor_enabled) { if (!PADHasSensor(PAD_CHAN0, PAD_SENSOR_GYRO)) { return; @@ -112,8 +173,8 @@ void read(float dt) { return; } - const float smooth_alpha = kGyroEmaAlphaMax + dusk::getSettings().game.gyroSmoothing * (kGyroEmaAlphaMin - kGyroEmaAlphaMax); - const float deadband = dusk::getSettings().game.gyroDeadband; + const float smooth_alpha = kGyroEmaAlphaMax + getSettings().game.gyroSmoothing * (kGyroEmaAlphaMin - kGyroEmaAlphaMax); + const float deadband = getSettings().game.gyroDeadband; s_smooth_gx += smooth_alpha * (gyro[0] - s_smooth_gx); s_smooth_gy += smooth_alpha * (gyro[1] - s_smooth_gy); @@ -123,8 +184,8 @@ void read(float dt) { const float yaw_rate = apply_deadband(s_smooth_gy, deadband); const float roll_rate = apply_deadband(s_smooth_gz, deadband); - s_pitch_rad = -pitch_rate * dt * dusk::getSettings().game.gyroSensitivityX; - s_roll_rad = roll_rate * dt * dusk::getSettings().game.gyroSensitivityX; // GYRO NOTE: Exposing Z sensitivity seems unusual, so I'm just using X + s_pitch_rad = -pitch_rate * dt * getSettings().game.gyroSensitivityY; + s_roll_rad = roll_rate * dt * getSettings().game.gyroSensitivityX; // GYRO NOTE: Exposing Z sensitivity seems unusual, so I'm just using X float horizontal_rate = yaw_rate; if (aim_active && s_accel_enabled) { @@ -162,11 +223,11 @@ void read(float dt) { } } - s_yaw_rad = horizontal_rate * dt * dusk::getSettings().game.gyroSensitivityY; + s_yaw_rad = horizontal_rate * dt * getSettings().game.gyroSensitivityX; - s_pitch_rad = dusk::getSettings().game.gyroInvertPitch ? -s_pitch_rad : s_pitch_rad; - s_yaw_rad = dusk::getSettings().game.gyroInvertYaw ? -s_yaw_rad : s_yaw_rad; - s_yaw_rad = dusk::getSettings().game.enableMirrorMode ? -s_yaw_rad : s_yaw_rad; + s_pitch_rad = getSettings().game.gyroInvertPitch ? -s_pitch_rad : s_pitch_rad; + s_yaw_rad = getSettings().game.gyroInvertYaw ? -s_yaw_rad : s_yaw_rad; + s_yaw_rad = getSettings().game.enableMirrorMode ? -s_yaw_rad : s_yaw_rad; } void getAimDeltas(float& out_yaw, float& out_pitch) { @@ -180,9 +241,9 @@ void rollgoalTick(bool play_active, s16 camera_yaw) { return; } - float pitch_rad = -s_pitch_rad * dusk::getSettings().game.gyroSensitivityRollgoal; - float roll_rad = s_roll_rad * dusk::getSettings().game.gyroSensitivityRollgoal; - roll_rad = dusk::getSettings().game.enableMirrorMode ? -roll_rad : roll_rad; + float pitch_rad = -s_pitch_rad * getSettings().game.gyroSensitivityRollgoal; + float roll_rad = s_roll_rad * getSettings().game.gyroSensitivityRollgoal; + roll_rad = getSettings().game.enableMirrorMode ? -roll_rad : roll_rad; s_rollgoal_az += cM_rad2s(roll_rad); cXyz in(roll_rad, 0.0f, pitch_rad); diff --git a/src/dusk/http/android.cpp b/src/dusk/http/android.cpp new file mode 100644 index 0000000000..3f4eef8d84 --- /dev/null +++ b/src/dusk/http/android.cpp @@ -0,0 +1,402 @@ +#include "http.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace dusk::http { +namespace { + +constexpr int JavaErrorNone = 0; +constexpr int JavaErrorInvalidUrl = 1; +constexpr int JavaErrorUnsupportedScheme = 2; +constexpr int JavaErrorTimeout = 3; +constexpr int JavaErrorTooLarge = 4; + +int timeout_ms(std::chrono::milliseconds timeout) { + const auto count = std::max(1, timeout.count()); + return static_cast( + std::min(count, std::numeric_limits::max())); +} + +jlong max_body_bytes(size_t maxBodyBytes) { + return static_cast(std::min( + maxBodyBytes, static_cast(std::numeric_limits::max()))); +} + +bool clear_pending_exception(JNIEnv* env) { + if (env == nullptr || !env->ExceptionCheck()) { + return false; + } + env->ExceptionClear(); + return true; +} + +std::string to_string(JNIEnv* env, jstring value) { + if (env == nullptr || value == nullptr) { + return {}; + } + + const char* utf8 = env->GetStringUTFChars(value, nullptr); + if (utf8 == nullptr) { + clear_pending_exception(env); + return {}; + } + + std::string result(utf8); + env->ReleaseStringUTFChars(value, utf8); + return result; +} + +jstring to_jstring(JNIEnv* env, std::string_view value) { + if (env == nullptr) { + return nullptr; + } + return env->NewStringUTF(std::string(value).c_str()); +} + +Error map_java_error(int error) { + switch (error) { + case JavaErrorNone: + return Error::None; + case JavaErrorInvalidUrl: + return Error::InvalidUrl; + case JavaErrorUnsupportedScheme: + return Error::UnsupportedScheme; + case JavaErrorTimeout: + return Error::Timeout; + case JavaErrorTooLarge: + return Error::TooLarge; + default: + return Error::Network; + } +} + +jclass load_dusk_class(JNIEnv* env, jobject activity, const char* className) { + jclass activityClass = env->GetObjectClass(activity); + if (activityClass == nullptr || clear_pending_exception(env)) { + return nullptr; + } + + jmethodID getClassLoader = + env->GetMethodID(activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;"); + env->DeleteLocalRef(activityClass); + if (getClassLoader == nullptr || clear_pending_exception(env)) { + return nullptr; + } + + jobject classLoader = env->CallObjectMethod(activity, getClassLoader); + if (classLoader == nullptr || clear_pending_exception(env)) { + return nullptr; + } + + jclass classLoaderClass = env->FindClass("java/lang/ClassLoader"); + if (classLoaderClass == nullptr || clear_pending_exception(env)) { + env->DeleteLocalRef(classLoader); + return nullptr; + } + + jmethodID loadClass = env->GetMethodID( + classLoaderClass, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;"); + env->DeleteLocalRef(classLoaderClass); + if (loadClass == nullptr || clear_pending_exception(env)) { + env->DeleteLocalRef(classLoader); + return nullptr; + } + + jstring javaClassName = env->NewStringUTF(className); + if (javaClassName == nullptr || clear_pending_exception(env)) { + env->DeleteLocalRef(classLoader); + return nullptr; + } + + auto* loadedClass = + static_cast(env->CallObjectMethod(classLoader, loadClass, javaClassName)); + env->DeleteLocalRef(javaClassName); + env->DeleteLocalRef(classLoader); + if (loadedClass == nullptr || clear_pending_exception(env)) { + return nullptr; + } + + return loadedClass; +} + +jobjectArray make_string_array(JNIEnv* env, const std::vector
& headers, bool names) { + jclass stringClass = env->FindClass("java/lang/String"); + if (stringClass == nullptr || clear_pending_exception(env)) { + return nullptr; + } + + jobjectArray array = + env->NewObjectArray(static_cast(headers.size()), stringClass, nullptr); + env->DeleteLocalRef(stringClass); + if (array == nullptr || clear_pending_exception(env)) { + return nullptr; + } + + for (jsize i = 0; i < static_cast(headers.size()); ++i) { + const std::string& value = names ? headers[static_cast(i)].name : + headers[static_cast(i)].value; + jstring javaValue = to_jstring(env, value); + if (javaValue == nullptr || clear_pending_exception(env)) { + env->DeleteLocalRef(array); + return nullptr; + } + env->SetObjectArrayElement(array, i, javaValue); + env->DeleteLocalRef(javaValue); + if (clear_pending_exception(env)) { + env->DeleteLocalRef(array); + return nullptr; + } + } + + return array; +} + +std::vector
read_headers(JNIEnv* env, jobjectArray names, jobjectArray values) { + std::vector
headers; + if (names == nullptr || values == nullptr) { + return headers; + } + + const jsize count = std::min(env->GetArrayLength(names), env->GetArrayLength(values)); + headers.reserve(static_cast(count)); + for (jsize i = 0; i < count; ++i) { + auto* name = static_cast(env->GetObjectArrayElement(names, i)); + auto* value = static_cast(env->GetObjectArrayElement(values, i)); + if (clear_pending_exception(env)) { + if (name != nullptr) { + env->DeleteLocalRef(name); + } + if (value != nullptr) { + env->DeleteLocalRef(value); + } + headers.clear(); + return headers; + } + + if (name != nullptr) { + headers.push_back({ + .name = to_string(env, name), + .value = to_string(env, value), + }); + } + + if (name != nullptr) { + env->DeleteLocalRef(name); + } + if (value != nullptr) { + env->DeleteLocalRef(value); + } + } + + return headers; +} + +std::string read_body(JNIEnv* env, jbyteArray body) { + if (body == nullptr) { + return {}; + } + + const jsize bodySize = env->GetArrayLength(body); + std::string result(static_cast(bodySize), '\0'); + if (bodySize > 0) { + env->GetByteArrayRegion(body, 0, bodySize, reinterpret_cast(result.data())); + if (clear_pending_exception(env)) { + return {}; + } + } + return result; +} + +Result result_from_response(JNIEnv* env, jobject response) { + if (response == nullptr) { + return { + .error = Error::Network, + .message = "Android HTTP request did not return a response", + }; + } + + jclass responseClass = env->GetObjectClass(response); + if (responseClass == nullptr || clear_pending_exception(env)) { + return { + .error = Error::Network, + .message = "Failed to inspect Android HTTP response", + }; + } + + jfieldID errorField = env->GetFieldID(responseClass, "error", "I"); + jfieldID messageField = env->GetFieldID(responseClass, "message", "Ljava/lang/String;"); + jfieldID statusField = env->GetFieldID(responseClass, "statusCode", "I"); + jfieldID headerNamesField = + env->GetFieldID(responseClass, "headerNames", "[Ljava/lang/String;"); + jfieldID headerValuesField = + env->GetFieldID(responseClass, "headerValues", "[Ljava/lang/String;"); + jfieldID bodyField = env->GetFieldID(responseClass, "body", "[B"); + env->DeleteLocalRef(responseClass); + if (errorField == nullptr || messageField == nullptr || statusField == nullptr || + headerNamesField == nullptr || headerValuesField == nullptr || bodyField == nullptr || + clear_pending_exception(env)) + { + return { + .error = Error::Network, + .message = "Android HTTP response shape was not recognized", + }; + } + + const int javaError = env->GetIntField(response, errorField); + auto* message = static_cast(env->GetObjectField(response, messageField)); + auto* headerNames = static_cast(env->GetObjectField(response, headerNamesField)); + auto* headerValues = + static_cast(env->GetObjectField(response, headerValuesField)); + auto* body = static_cast(env->GetObjectField(response, bodyField)); + if (clear_pending_exception(env)) { + return { + .error = Error::Network, + .message = "Failed to read Android HTTP response", + }; + } + + Response httpResponse{ + .statusCode = static_cast(env->GetIntField(response, statusField)), + .headers = read_headers(env, headerNames, headerValues), + .body = read_body(env, body), + }; + + std::string messageString = to_string(env, message); + + if (message != nullptr) { + env->DeleteLocalRef(message); + } + if (headerNames != nullptr) { + env->DeleteLocalRef(headerNames); + } + if (headerValues != nullptr) { + env->DeleteLocalRef(headerValues); + } + if (body != nullptr) { + env->DeleteLocalRef(body); + } + + return { + .error = map_java_error(javaError), + .message = std::move(messageString), + .response = std::move(httpResponse), + }; +} + +} // namespace + +bool available() noexcept { + return true; +} + +Backend backend() noexcept { + return Backend::Android; +} + +const char* backend_name() noexcept { + return "Android"; +} + +Result get(const Request& request) { + if (request.url.empty()) { + return { + .error = Error::InvalidUrl, + .message = "URL is empty", + }; + } + if (!request.url.starts_with("https://")) { + return { + .error = Error::UnsupportedScheme, + .message = "Only https:// URLs are supported", + }; + } + + auto* env = static_cast(SDL_GetAndroidJNIEnv()); + if (env == nullptr) { + return { + .error = Error::Network, + .message = "Failed to access Android JNI environment", + }; + } + + jobject activity = static_cast(SDL_GetAndroidActivity()); + if (activity == nullptr || clear_pending_exception(env)) { + if (activity != nullptr) { + env->DeleteLocalRef(activity); + } + return { + .error = Error::Network, + .message = "Failed to access Android activity", + }; + } + + jclass clientClass = + load_dusk_class(env, activity, "dev.twilitrealm.dusk.DuskHttpClient"); + env->DeleteLocalRef(activity); + if (clientClass == nullptr) { + return { + .error = Error::Network, + .message = "Failed to load Android HTTP helper", + }; + } + + jmethodID getMethod = env->GetStaticMethodID(clientClass, "get", + "(Ljava/lang/String;[Ljava/lang/String;[Ljava/lang/String;IJ)" + "Ldev/twilitrealm/dusk/DuskHttpClient$Response;"); + if (getMethod == nullptr || clear_pending_exception(env)) { + env->DeleteLocalRef(clientClass); + return { + .error = Error::Network, + .message = "Failed to find Android HTTP helper method", + }; + } + + jstring url = to_jstring(env, request.url); + jobjectArray headerNames = make_string_array(env, request.headers, true); + jobjectArray headerValues = make_string_array(env, request.headers, false); + if (url == nullptr || headerNames == nullptr || headerValues == nullptr || + clear_pending_exception(env)) + { + if (url != nullptr) { + env->DeleteLocalRef(url); + } + if (headerNames != nullptr) { + env->DeleteLocalRef(headerNames); + } + if (headerValues != nullptr) { + env->DeleteLocalRef(headerValues); + } + env->DeleteLocalRef(clientClass); + return { + .error = Error::Network, + .message = "Failed to prepare Android HTTP request", + }; + } + + jobject response = env->CallStaticObjectMethod(clientClass, getMethod, url, headerNames, + headerValues, timeout_ms(request.timeout), max_body_bytes(request.maxBodyBytes)); + env->DeleteLocalRef(url); + env->DeleteLocalRef(headerNames); + env->DeleteLocalRef(headerValues); + env->DeleteLocalRef(clientClass); + if (clear_pending_exception(env)) { + return { + .error = Error::Network, + .message = "Android HTTP request failed with a Java exception", + }; + } + + Result result = result_from_response(env, response); + if (response != nullptr) { + env->DeleteLocalRef(response); + } + return result; +} + +} // namespace dusk::http diff --git a/src/dusk/http/http.hpp b/src/dusk/http/http.hpp index d62b031fbc..54bef6eec1 100644 --- a/src/dusk/http/http.hpp +++ b/src/dusk/http/http.hpp @@ -13,6 +13,7 @@ enum class Backend { WinHttp, UrlSession, LibCurl, + Android, }; enum class Error { diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index e933e314e4..dca2a88d79 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -253,13 +253,6 @@ namespace dusk { UpdateSettings(); - if (!fpcM_SearchByName(fpcNm_LOGO_SCENE_e) && - (ImGui::IsKeyDown(ImGuiKey_LeftCtrl) || ImGui::IsKeyDown(ImGuiKey_RightCtrl)) && - ImGui::IsKeyPressed(ImGuiKey_R)) - { - JUTGamePad::C3ButtonReset::sResetSwitchPushing = true; - } - if (ImGui::IsKeyPressed(ImGuiKey_F11)) { getSettings().video.enableFullscreen.setValue(!getSettings().video.enableFullscreen); VISetWindowFullscreen(getSettings().video.enableFullscreen); @@ -320,7 +313,9 @@ namespace dusk { ImGui::PopFont(); } ImGui::PushFont(ImGuiEngine::fontLarge); - ImGuiTextCenter("Failed to initialize any graphics backend"); + ImGuiTextCenter("Failed to initialize any graphics backend."); + ImGuiTextCenter("\nYour system may be misconfigured, or your hardware may not support the required versions of any of the available backends."); + ImGuiTextCenter("\nA clean reinstall of Dusk may help. For further assistance, please visit #tech-support on the Twilit Realm Discord server."); const auto& style = ImGui::GetStyle(); const auto retrySize = ImGui::CalcTextSize("Retry (Auto backend)"); const auto quitSize = ImGui::CalcTextSize("Quit"); @@ -383,18 +378,18 @@ namespace dusk { } // Hide mouse cursor if the F1 menu is not open and the cursor is idle for 3 seconds. - ImGuiIO& io = ImGui::GetIO(); - if (showMenu) { - mouseHideTimer = 0.0f; - ImGui::GetIO().ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange; // Imgui will re-show cursor. - } else if (io.MouseDelta.x != 0.0f || io.MouseDelta.y != 0.0f) { - mouseHideTimer = 0.0f; - ImGui::GetIO().ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange; // Imgui will re-show cursor. - } else if (mouseHideTimer <= 3.0f) { - mouseHideTimer += ImGui::GetIO().DeltaTime; - } else { - ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange; - SDL_HideCursor(); + if (dusk::getSettings().game.gyroMode.getValue() != GyroMode::Mouse) + { + ImGuiIO& io = ImGui::GetIO(); + if (io.MouseDelta.x != 0.0f || io.MouseDelta.y != 0.0f) { + mouseHideTimer = 0.0f; + ImGui::GetIO().ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange; // Imgui will re-show cursor. + } else if (mouseHideTimer <= 3.0f) { + mouseHideTimer += ImGui::GetIO().DeltaTime; + } else { + ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange; + SDL_HideCursor(); + } } ShowToasts(); diff --git a/src/dusk/imgui/ImGuiConsole.hpp b/src/dusk/imgui/ImGuiConsole.hpp index 6715846e7b..6362a146b6 100644 --- a/src/dusk/imgui/ImGuiConsole.hpp +++ b/src/dusk/imgui/ImGuiConsole.hpp @@ -2,11 +2,9 @@ #define DUSK_IMGUI_HPP #include -#include #include #include -#include #include #include "ImGuiMenuGame.hpp" @@ -73,24 +71,4 @@ bool ImGuiButtonCenter(std::string_view text); float ImGuiScale(); } // namespace dusk -#if defined(_WIN32) || \ - (defined(__APPLE__) && !TARGET_OS_IOS && !TARGET_OS_TV && !TARGET_OS_MACCATALYST) || \ - (defined(__linux__) && !defined(__ANDROID__)) -#define DUSK_CAN_OPEN_DATA_FOLDER 1 - -namespace fs = std::filesystem; - -static void OpenDataFolder() { - const std::string path = fs::absolute(dusk::ConfigPath).generic_string(); -#if defined(_WIN32) - const std::string url = std::string("file:///") + path; -#else - const std::string url = std::string("file://") + path; -#endif - (void)SDL_OpenURL(url.c_str()); -} -#else -#define DUSK_CAN_OPEN_DATA_FOLDER 0 -#endif - #endif // DUSK_IMGUI_HPP diff --git a/src/dusk/imgui/ImGuiMenuGame.cpp b/src/dusk/imgui/ImGuiMenuGame.cpp index 79c032ee35..783bbbdeec 100644 --- a/src/dusk/imgui/ImGuiMenuGame.cpp +++ b/src/dusk/imgui/ImGuiMenuGame.cpp @@ -56,12 +56,12 @@ namespace dusk { } // L+R+A+Start to reset timer - if (mDoCPd_c::getHoldL(PAD_1) && mDoCPd_c::getHoldR(PAD_1) && mDoCPd_c::getHoldA(PAD_1) && mDoCPd_c::getTrigStart(PAD_1)) { + if (mDoCPd_c::getHoldL(PAD_1) && mDoCPd_c::getHoldR(PAD_1) && mDoCPd_c::getHoldA(PAD_1) && mDoCPd_c::getTrigZ(PAD_1)) { m_speedrunInfo.reset(); } // L+R+A+Z to manually stop timer - if (mDoCPd_c::getHoldL(PAD_1) && mDoCPd_c::getHoldR(PAD_1) && mDoCPd_c::getHoldA(PAD_1) && mDoCPd_c::getTrigZ(PAD_1)) { + if (mDoCPd_c::getHoldL(PAD_1) && mDoCPd_c::getHoldR(PAD_1) && mDoCPd_c::getHoldA(PAD_1) && mDoCPd_c::getTrigY(PAD_1)) { if (m_speedrunInfo.m_isRunStarted) { m_speedrunInfo.m_endTimestamp = OSGetTime() - m_speedrunInfo.m_startTimestamp; m_speedrunInfo.m_isRunStarted = false; diff --git a/src/dusk/imgui/ImGuiStateShare.cpp b/src/dusk/imgui/ImGuiStateShare.cpp index a0bd450984..48849e2bc3 100644 --- a/src/dusk/imgui/ImGuiStateShare.cpp +++ b/src/dusk/imgui/ImGuiStateShare.cpp @@ -48,19 +48,18 @@ void ImGuiStateShare::onMergeFileSelected(void* userdata, const char* path, cons -static std::string GetStatesFilePath() { - return (dusk::ConfigPath / STATES_FILENAME).string(); +static std::filesystem::path GetStatesFilePath() { + return ConfigPath / STATES_FILENAME; } void ImGuiStateShare::loadStatesFile() { m_loaded = true; - const std::filesystem::path filePath = dusk::ConfigPath / STATES_FILENAME; + const std::filesystem::path filePath = GetStatesFilePath(); if (!std::filesystem::exists(filePath)) { return; } try { - const std::string pathStr = filePath.string(); - auto data = io::FileStream::ReadAllBytes(pathStr.c_str()); + auto data = io::FileStream::ReadAllBytes(filePath); auto j = json::parse(data); if (!j.is_array()) { return; @@ -85,7 +84,7 @@ void ImGuiStateShare::saveStatesFile() { j.push_back(json{{"name", s.name}, {"data", s.encoded}}); } try { - io::FileStream::WriteAllText(GetStatesFilePath().c_str(), j.dump(2)); + io::FileStream::WriteAllText(GetStatesFilePath(), j.dump(2)); } catch (const std::exception& e) { m_statusMsg = fmt::format("Failed to save states: {}", e.what()); } diff --git a/src/dusk/io.cpp b/src/dusk/io.cpp index 50b5cab5bd..3c87a66acf 100644 --- a/src/dusk/io.cpp +++ b/src/dusk/io.cpp @@ -1,7 +1,8 @@ -#include "dusk/io.hpp" #include #include +#include "dusk/io.hpp" + using namespace dusk::io; #if _WIN32 @@ -58,7 +59,7 @@ static FILE* OpenCore(const char* path, const MODE_TYPE* mode) { FileStream::FileStream() noexcept : file(nullptr) { } -FileStream::FileStream(void* file) : file(file) { +FileStream::FileStream(FILE* file) : file(file) { if (!file) { CRASH("Invalid file handle"); } @@ -78,10 +79,18 @@ FileStream FileStream::OpenRead(const char* utf8Path) { return FileStream(OpenCore(utf8Path, MODE("rb"))); } +FileStream FileStream::OpenRead(const std::filesystem::path& path) { + return FileStream(OpenCore(path, MODE("rb"))); +} + FileStream FileStream::Create(const char* utf8Path) { return FileStream(OpenCore(utf8Path, MODE("wb"))); } +FileStream FileStream::Create(const std::filesystem::path& path) { + return FileStream(OpenCore(path, MODE("wb"))); +} + std::vector FileStream::ReadFull() { const auto fileHandle = ThrowIfNotOpen(*this); @@ -128,7 +137,11 @@ std::vector FileStream::ReadFull() { } std::vector FileStream::ReadAllBytes(const char* utf8Path) { - auto handle = OpenRead(utf8Path); + return ReadAllBytes(reinterpret_cast(utf8Path)); +} + +std::vector FileStream::ReadAllBytes(const std::filesystem::path& path) { + auto handle = OpenRead(path); return std::move(handle.ReadFull()); } @@ -142,6 +155,16 @@ void FileStream::Write(const char* data, size_t dataLen) { } void FileStream::WriteAllText(const char* utf8Path, const std::string_view text) { - auto handle = Create(utf8Path); + WriteAllText(reinterpret_cast(utf8Path), text); +} + +void FileStream::WriteAllText(const std::filesystem::path& path, const std::string_view text) { + auto handle = Create(path); handle.Write(text.data(), text.size()); } + +FILE* FileStream::ToInner() { + auto handle = file; + file = nullptr; + return handle; +} \ No newline at end of file diff --git a/src/dusk/iso_validate.cpp b/src/dusk/iso_validate.cpp index 4d9c48d826..00cd365ca9 100644 --- a/src/dusk/iso_validate.cpp +++ b/src/dusk/iso_validate.cpp @@ -9,6 +9,8 @@ #include #include +#include "dusk/logging.h" + namespace { constexpr uint8_t hex_nibble_to_u8(char c) { @@ -42,6 +44,18 @@ constexpr XXH128_hash_t parse_xxh128(std::string_view hex) { }; } +const char* verification_state_name(dusk::DiscVerificationState state) noexcept { + switch (state) { + case dusk::DiscVerificationState::Success: + return "verified"; + case dusk::DiscVerificationState::HashMismatch: + return "hash mismatch"; + case dusk::DiscVerificationState::Unknown: + default: + return "unknown"; + } +} + } // namespace namespace dusk::iso { @@ -248,4 +262,10 @@ bool isPal(const char* path) { DiscInfo info{}; return inspect(path, info) == ValidationError::Success && info.isPal; } + +void log_verification_state(std::string_view path, DiscVerificationState state) { + const std::string pathText = path.empty() ? "" : std::string(path); + DuskLog.info( + "Disc verification status: {} (path: {})", verification_state_name(state), pathText); +} } // namespace dusk::iso diff --git a/src/dusk/iso_validate.hpp b/src/dusk/iso_validate.hpp index c737a017d9..176284fd02 100644 --- a/src/dusk/iso_validate.hpp +++ b/src/dusk/iso_validate.hpp @@ -31,6 +31,7 @@ struct DiscInfo { ValidationError inspect(const char* path, DiscInfo& info); ValidationError validate(const char* path, VerificationStatus& status, DiscInfo& info); bool isPal(const char* path); +void log_verification_state(std::string_view path, DiscVerificationState state); } // namespace dusk::iso diff --git a/src/dusk/logging.cpp b/src/dusk/logging.cpp index 8b96fcffd5..1c0a6ecbdb 100644 --- a/src/dusk/logging.cpp +++ b/src/dusk/logging.cpp @@ -8,6 +8,7 @@ #include #include +#include "dusk/io.hpp" #include "tracy/Tracy.hpp" #if TARGET_ANDROID @@ -40,7 +41,7 @@ std::atomic g_logStateAlive(true); struct LogState { std::mutex mutex; FILE* file = nullptr; - std::string filePath; + std::u8string filePath; ~LogState() { CloseFile(); @@ -207,19 +208,19 @@ void dusk::InitializeFileLogging(const std::filesystem::path& configDir, AuroraL std::filesystem::create_directories(logsDir, ec); if (ec) { std::fprintf(stderr, "[WARNING | dusk] Failed to create log directory '%s': %s\n", - logsDir.string().c_str(), ec.message().c_str()); + io::fs_path_to_string(logsDir).c_str(), ec.message().c_str()); return; } const std::filesystem::path logPath = logsDir / MakeTimestampedLogName(); - g_logState.file = std::fopen(logPath.string().c_str(), "wb"); + g_logState.file = io::FileStream::Create(logPath).ToInner(); if (g_logState.file == nullptr) { std::fprintf(stderr, "[WARNING | dusk] Failed to open log file '%s'\n", - logPath.string().c_str()); + io::fs_path_to_string(logPath).c_str()); return; } - g_logState.filePath = logPath.string(); + g_logState.filePath = logPath.u8string(); aurora::g_config.logCallback = &aurora_log_callback; aurora::g_config.logLevel = logLevel; WriteLogLine(g_logState.file, "INFO", "dusk", "File logging initialized", 24); @@ -237,5 +238,6 @@ const char* dusk::GetLogFilePath() { return nullptr; } std::lock_guard lock(g_logState.mutex); - return g_logState.filePath.empty() ? nullptr : g_logState.filePath.c_str(); + return reinterpret_cast( + g_logState.filePath.empty() ? nullptr : g_logState.filePath.c_str()); } diff --git a/src/dusk/main.cpp b/src/dusk/main.cpp index e1b2fd0b6e..d88594248a 100644 --- a/src/dusk/main.cpp +++ b/src/dusk/main.cpp @@ -6,6 +6,7 @@ #include #include "dusk/main.h" +#include "dusk/io.hpp" #include #include @@ -91,7 +92,7 @@ bool RestartProcess(int argc, char* argv[]) { std::vector args; args.reserve(static_cast(std::max(argc, 1))); - args.push_back(executablePath.string()); + args.push_back(dusk::io::fs_path_to_string(executablePath)); for (int i = 1; i < argc; ++i) { args.emplace_back(argv[i] != nullptr ? argv[i] : ""); } diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp index c01ccf5c7c..64673893ad 100644 --- a/src/dusk/settings.cpp +++ b/src/dusk/settings.cpp @@ -50,7 +50,8 @@ UserSettings g_userSettings = { .minimalHUD {"game.minimalHUD", false}, .pauseOnFocusLost {"game.pauseOnFocusLost", false}, .enableLinkDollRotation {"game.enableLinkDollRotation", false}, - .enableAchievementNotifications {"game.enableAchievementNotifications", true}, + .enableAchievementToasts {"game.enableAchievementToasts", true}, + .enableControllerToasts {"game.enableControllerToasts", true}, // Graphics .bloomMode {"game.bloomMode", BloomMode::Dusk}, @@ -67,6 +68,7 @@ UserSettings g_userSettings = { .midnasLamentNonStop {"game.midnasLamentNonStop", false}, // Input + .gyroMode {"game.gyroMode", GyroMode::Sensor}, .enableGyroAim {"game.enableGyroAim", false}, .enableGyroRollgoal {"game.enableGyroRollgoal", false}, .gyroSensitivityX {"game.gyroSensitivityX", 1.0f}, @@ -82,6 +84,7 @@ UserSettings g_userSettings = { .freeCameraSensitivity {"game.freeCameraSensitivity", 1.0f}, .debugFlyCam {"game.debugFlyCam", false}, .debugFlyCamLockEvents {"game.debugFlyCamLockEvents", true}, + .allowBackgroundInput {"game.allowBackgroundInput", true}, // Cheats .infiniteHearts {"game.infiniteHearts", false}, @@ -186,7 +189,8 @@ void registerSettings() { Register(g_userSettings.game.freeMagicArmor); Register(g_userSettings.game.restoreWiiGlitches); Register(g_userSettings.game.enableLinkDollRotation); - Register(g_userSettings.game.enableAchievementNotifications); + Register(g_userSettings.game.enableAchievementToasts); + Register(g_userSettings.game.enableControllerToasts); Register(g_userSettings.game.noMissClimbing); Register(g_userSettings.game.noLowHpSound); Register(g_userSettings.game.midnasLamentNonStop); @@ -206,6 +210,7 @@ void registerSettings() { Register(g_userSettings.game.superClawshot); Register(g_userSettings.game.alwaysGreatspin); Register(g_userSettings.game.enableFrameInterpolation); + Register(g_userSettings.game.gyroMode); Register(g_userSettings.game.enableGyroAim); Register(g_userSettings.game.enableGyroRollgoal); Register(g_userSettings.game.gyroSensitivityX); @@ -218,6 +223,7 @@ void registerSettings() { Register(g_userSettings.game.freeCamera); Register(g_userSettings.game.debugFlyCam); Register(g_userSettings.game.debugFlyCamLockEvents); + Register(g_userSettings.game.allowBackgroundInput); Register(g_userSettings.backend.isoPath); Register(g_userSettings.backend.isoVerification); diff --git a/src/dusk/ui/document.cpp b/src/dusk/ui/document.cpp index a7bcc3f9ed..4296ac27d9 100644 --- a/src/dusk/ui/document.cpp +++ b/src/dusk/ui/document.cpp @@ -5,6 +5,7 @@ #include "Z2AudioLib/Z2SeMgr.h" #include "m_Do/m_Do_audio.h" +#include namespace dusk::ui { namespace { @@ -106,6 +107,7 @@ bool Document::visible() const { bool Document::handle_nav_command(Rml::Event& event, NavCommand cmd) { if (cmd == NavCommand::Menu) { + toggle_cursor_if_gyro(!visible()); mDoAud_seStartMenu(visible() ? kSoundMenuClose : kSoundMenuOpen); toggle(); return true; @@ -113,4 +115,17 @@ bool Document::handle_nav_command(Rml::Event& event, NavCommand cmd) { return false; } +void Document::toggle_cursor_if_gyro(bool cursor_enabled) { + if (dusk::getSettings().game.gyroMode.getValue() == GyroMode::Mouse) + { + if (cursor_enabled) { + ImGui::GetIO().ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange; + SDL_ShowCursor(); + } else { + ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange; + SDL_HideCursor(); + } + } +} + } // namespace dusk::ui diff --git a/src/dusk/ui/document.hpp b/src/dusk/ui/document.hpp index d0f4cae841..eed489beb3 100644 --- a/src/dusk/ui/document.hpp +++ b/src/dusk/ui/document.hpp @@ -43,6 +43,8 @@ public: bool pending_close() const { return mPendingClose; } bool closed() const { return mClosed; } + void toggle_cursor_if_gyro(bool); + protected: virtual bool handle_nav_command(Rml::Event& event, NavCommand cmd); diff --git a/src/dusk/ui/editor.cpp b/src/dusk/ui/editor.cpp index 09216a3b8a..469bb920e3 100644 --- a/src/dusk/ui/editor.cpp +++ b/src/dusk/ui/editor.cpp @@ -1881,9 +1881,9 @@ EditorWindow::EditorWindow() { } }); - add_tab("Flags", [this](Rml::Element* content) { - // TODO - }); + //add_tab("Flags", [this](Rml::Element* content) { + // // TODO + //}); add_tab("Minigame", [this](Rml::Element* content) { auto& leftPane = add_child(content, Pane::Type::Controlled); diff --git a/src/dusk/ui/graphics_tuner.cpp b/src/dusk/ui/graphics_tuner.cpp index 8a11528914..4f68d4a078 100644 --- a/src/dusk/ui/graphics_tuner.cpp +++ b/src/dusk/ui/graphics_tuner.cpp @@ -193,9 +193,9 @@ Rml::String format_graphics_setting_value(GraphicsOption option, int value) { return ""; } -GraphicsTuner::GraphicsTuner(GraphicsTunerProps props) +GraphicsTuner::GraphicsTuner(GraphicsTunerProps props, bool prelaunch) : Document(kDocumentSource), mOption(props.option), mValueMin(props.valueMin), - mValueMax(props.valueMax), mDefaultValue(props.defaultValue) { + mValueMax(props.valueMax), mDefaultValue(props.defaultValue), mPrelaunch(prelaunch) { if (mDocument == nullptr) { return; } @@ -207,7 +207,7 @@ GraphicsTuner::GraphicsTuner(GraphicsTunerProps props) description->SetInnerRML(escape(props.helpText)); } if (auto* carouselParent = mDocument->GetElementById("carousel-container")) { - add_component(carouselParent, + mCarousel = &add_component(carouselParent, SteppedCarousel::Props{ .min = mValueMin, .max = mValueMax, @@ -281,7 +281,12 @@ bool GraphicsTuner::handle_nav_command(Rml::Event& event, NavCommand cmd) { pop(); return true; } - return Document::handle_nav_command(event, cmd); + + if (mCarousel && mCarousel->handle_nav_command(cmd)) { + return true; + } + + return mPrelaunch ? false : Document::handle_nav_command(event, cmd); } void GraphicsTuner::reset_default() { diff --git a/src/dusk/ui/graphics_tuner.hpp b/src/dusk/ui/graphics_tuner.hpp index 3f2418ef30..254aada22b 100644 --- a/src/dusk/ui/graphics_tuner.hpp +++ b/src/dusk/ui/graphics_tuner.hpp @@ -28,9 +28,9 @@ public: bool focus() override; void update() override; + bool handle_nav_command(NavCommand cmd); private: - bool handle_nav_command(NavCommand cmd); void apply(int value); Props mProps; @@ -59,7 +59,7 @@ struct GraphicsTunerProps { class GraphicsTuner : public Document { public: - explicit GraphicsTuner(GraphicsTunerProps props); + explicit GraphicsTuner(GraphicsTunerProps props, bool prelaunch); void show() override; void hide(bool close) override; @@ -86,7 +86,9 @@ private: int mValueMax = 0; int mDefaultValue = 0; std::vector > mComponents; + SteppedCarousel* mCarousel; Rml::Element* mRoot; + bool mPrelaunch; }; } // namespace dusk::ui diff --git a/src/dusk/ui/menu_bar.cpp b/src/dusk/ui/menu_bar.cpp index 81c2eea441..ea791d745b 100644 --- a/src/dusk/ui/menu_bar.cpp +++ b/src/dusk/ui/menu_bar.cpp @@ -42,6 +42,7 @@ MenuBar::MenuBar() : Document(kDocumentSource), mRoot(mDocument->GetElementById( mTabBar = std::make_unique(mRoot, TabBar::Props{ .onClose = [this] { + toggle_cursor_if_gyro(false); mDoAud_seStartMenu(kSoundMenuClose); hide(false); }, @@ -203,6 +204,7 @@ bool MenuBar::handle_nav_command(Rml::Event& event, NavCommand cmd) { return true; } if (cmd == NavCommand::Cancel && visible()) { + toggle_cursor_if_gyro(false); mDoAud_seStartMenu(kSoundMenuClose); hide(false); return true; diff --git a/src/dusk/ui/number_button.cpp b/src/dusk/ui/number_button.cpp index ab5095cf3f..3735855140 100644 --- a/src/dusk/ui/number_button.cpp +++ b/src/dusk/ui/number_button.cpp @@ -54,7 +54,7 @@ void NumberButton::set_value(Rml::String value) { } bool NumberButton::handle_nav_command(NavCommand cmd) { - if (cmd == NavCommand::Left || cmd == NavCommand::Right) { + if (!is_editing() && (cmd == NavCommand::Left || cmd == NavCommand::Right)) { const int newValue = std::clamp( mGetValue() + (cmd == NavCommand::Right ? mStep : -mStep), mMin, mMax); if (newValue != mGetValue()) { @@ -66,4 +66,4 @@ bool NumberButton::handle_nav_command(NavCommand cmd) { return BaseStringButton::handle_nav_command(cmd); } -} // namespace dusk::ui \ No newline at end of file +} // namespace dusk::ui diff --git a/src/dusk/ui/overlay.cpp b/src/dusk/ui/overlay.cpp index 0ce0b904ac..45482230a9 100644 --- a/src/dusk/ui/overlay.cpp +++ b/src/dusk/ui/overlay.cpp @@ -10,6 +10,10 @@ #include #include +#if defined(__APPLE__) +#include +#endif + namespace dusk::ui { namespace { aurora::Module Log{"dusk::ui::overlay"}; @@ -33,7 +37,7 @@ constexpr std::array, 3> kAutoSaveLayers{{ constexpr auto kMenuNotificationDuration = std::chrono::milliseconds(2500); -constexpr std::array kFpsCorners = { "tl", "tr", "bl", "br" }; +constexpr std::array kFpsCorners = {"tl", "tr", "bl", "br"}; Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) { if (toast.type == "autosave") { @@ -130,13 +134,19 @@ Rml::String back_button_name() { return "Back"; } +#if defined(TARGET_ANDROID) || (defined(__APPLE__) && TARGET_OS_IOS && !TARGET_OS_MACCATALYST) +constexpr auto kMenuNotificationPrefix = "3-finger tap or"; +#else +constexpr auto kMenuNotificationPrefix = "Press F1 or"; +#endif + Rml::Element* create_menu_notification(Rml::Element* parent) { auto* elem = append(parent, "toast"); elem->SetClass("menu-notification", true); auto* message = append(elem, "message"); auto* row = append(message, "row"); - append(row, "span")->SetInnerRML("Press F1 or"); + append(row, "span")->SetInnerRML(kMenuNotificationPrefix); auto* icon = append(row, "icon"); icon->SetClass("controller", true); append(row, "span")->SetInnerRML(escape(back_button_name())); @@ -242,7 +252,8 @@ void Overlay::update() { const Uint64 now = SDL_GetPerformanceCounter(); // Limit updates to twice per second - const bool refreshLabel = perfFreq == 0 || mFpsLastUpdate == 0 || + const bool refreshLabel = + perfFreq == 0 || mFpsLastUpdate == 0 || static_cast(now - mFpsLastUpdate) >= 0.5 * static_cast(perfFreq); if (refreshLabel) { mFpsLastUpdate = now; diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index 41db426e6d..69e9bd57eb 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -302,9 +302,17 @@ std::string get_error_msg(iso::ValidationError error) { } 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_to_config(validation)); + getSettings().backend.isoVerification.setValue(verification); config::Save(); + + if (previousPath != path || previousVerification != verification) { + iso::log_verification_state(path, verification); + } } void apply_valid_disc_result( @@ -445,8 +453,7 @@ private: } if (mFileName != nullptr) { - std::string fileName = - std::filesystem::path(sDiscVerificationTask->path).filename().string(); + std::string fileName = display_name_for_path(sDiscVerificationTask->path); if (fileName.empty()) { fileName = sDiscVerificationTask->path; } @@ -712,6 +719,8 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB return; } + toggle_cursor_if_gyro(false); + mDoAud_seStartMenu(kSoundPlay); show_menu_notification(); diff --git a/src/dusk/ui/preset.cpp b/src/dusk/ui/preset.cpp index 6b6bdc2248..1d559972f5 100644 --- a/src/dusk/ui/preset.cpp +++ b/src/dusk/ui/preset.cpp @@ -14,7 +14,8 @@ void applyPresetClassic() { auto& s = getSettings(); s.video.lockAspectRatio.setValue(true); s.game.bloomMode.setValue(BloomMode::Classic); - s.game.enableAchievementNotifications.setValue(false); + s.game.enableAchievementToasts.setValue(false); + s.game.enableControllerToasts.setValue(false); s.game.internalResolutionScale.setValue(1); s.game.shadowResolutionMultiplier.setValue(1); s.game.hideTvSettingsScreen.setValue(false); @@ -33,7 +34,8 @@ void applyPresetDusk() { s.game.biggerWallets.setValue(true); s.game.invertCameraXAxis.setValue(true); s.game.no2ndFishForCat.setValue(true); - s.game.enableAchievementNotifications.setValue(true); + s.game.enableAchievementToasts.setValue(true); + s.game.enableControllerToasts.setValue(true); s.game.enableQuickTransform.setValue(true); s.game.instantSaves.setValue(true); s.game.midnasLamentNonStop.setValue(true); diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index d381aecd31..c3a5f8de4b 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -6,12 +6,15 @@ #include "dusk/audio/DuskAudioSystem.h" #include "dusk/audio/DuskDsp.hpp" #include "dusk/config.hpp" +#include "dusk/file_select.hpp" #include "dusk/imgui/ImGuiEngine.hpp" #include "dusk/livesplit.h" +#include "dusk/main.h" #include "graphics_tuner.hpp" #include "m_Do/m_Do_main.h" #include "menu_bar.hpp" #include "number_button.hpp" +#include "menu_bar.hpp" #include "pane.hpp" #include "prelaunch.hpp" #include "ui.hpp" @@ -41,6 +44,11 @@ constexpr std::array kFpsOverlayCornerNames = { "Bottom Right", }; +constexpr std::array kGyroInputModeLabels = { + "Sensor", + "Mouse", +}; + bool try_parse_backend(std::string_view backend, AuroraBackend& outBackend) { if (backend == "auto") { outBackend = BACKEND_AUTO; @@ -199,7 +207,9 @@ int float_setting_percent(ConfigVar& var) { } bool gyro_enabled() { - return getSettings().game.enableGyroAim || getSettings().game.enableGyroRollgoal; + return getSettings().game.enableGyroAim || + (getSettings().game.enableGyroRollgoal && + getSettings().game.gyroMode.getValue() != GyroMode::Mouse); } struct ConfigBoolProps { @@ -265,7 +275,7 @@ SelectButton& config_percent_select(Pane& leftPane, Pane& rightPane, ConfigVar void graphics_tuner_control(Window& window, Pane& leftPane, Pane& rightPane, ConfigVar& var, - const GraphicsTunerProps& props) { + const GraphicsTunerProps& props, bool prelaunch) { leftPane.register_control( leftPane .add_select_button({ @@ -283,10 +293,10 @@ void graphics_tuner_control(Window& window, Pane& leftPane, Pane& rightPane, Con .isModified = [&var] { return var.getValue() != var.getDefaultValue(); }, .submit = false, }) - .on_nav_command([&window, props](Rml::Event&, NavCommand cmd) { + .on_nav_command([&window, props, prelaunch](Rml::Event&, NavCommand cmd) { if (cmd == NavCommand::Confirm || cmd == NavCommand::Left || cmd == NavCommand::Right) { - window.push(std::make_unique(props)); + window.push(std::make_unique(props, prelaunch)); return true; } return false; @@ -317,7 +327,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { if (path.empty()) { display = "(none)"; } else { - display = std::filesystem::path(path).filename().string(); + display = display_name_for_path(path); if (display.empty()) { display = path; } @@ -574,7 +584,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .valueMin = 0, .valueMax = 12, .defaultValue = 0, - }); + }, mPrelaunch); graphics_tuner_control(*this, leftPane, rightPane, getSettings().game.shadowResolutionMultiplier, GraphicsTunerProps{ @@ -584,7 +594,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .valueMin = 1, .valueMax = 8, .defaultValue = 1, - }); + }, mPrelaunch); leftPane.add_section("Post-Processing"); graphics_tuner_control(*this, leftPane, rightPane, getSettings().game.bloomMode, @@ -595,7 +605,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .valueMin = static_cast(BloomMode::Off), .valueMax = static_cast(BloomMode::Dusk), .defaultValue = static_cast(BloomMode::Classic), - }); + }, mPrelaunch); graphics_tuner_control(*this, leftPane, rightPane, getSettings().game.bloomMultiplier, GraphicsTunerProps{ .option = GraphicsOption::BloomMultiplier, @@ -604,7 +614,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .valueMin = 0, .valueMax = 100, .defaultValue = 100, - }); + }, mPrelaunch); leftPane.add_section("Rendering"); config_bool_select(leftPane, rightPane, getSettings().game.enableFrameInterpolation, @@ -644,6 +654,12 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { pane.clear(); pane.add_text("Open controller binding configuration."); }); + config_bool_select(leftPane, rightPane, getSettings().game.allowBackgroundInput, + { + .key = "Allow Background Input", + .helpText = "Allow controller input even when the game window is not focused.", + .onChange = [](bool value) { aurora_set_background_input(value); }, + }); leftPane.add_section("Camera"); addOption("Free Camera", getSettings().game.freeCamera, @@ -659,12 +675,50 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { [] { return !getSettings().game.freeCamera; }); leftPane.add_section("Gyro"); + leftPane.register_control( + leftPane.add_select_button({ + .key = "Gyro Input Method", + .getValue = + [] { + const auto mode = getSettings().game.gyroMode.getValue(); + const auto idx = static_cast(mode); + return Rml::String{kGyroInputModeLabels[idx]}; + }, + .isModified = + [] { + return getSettings().game.gyroMode.getValue() != + getSettings().game.gyroMode.getDefaultValue(); + }, + }), + rightPane, [](Pane& pane) { + for (size_t i = 0; i < kGyroInputModeLabels.size(); i++) { + pane + .add_button({ + .text = Rml::String{kGyroInputModeLabels[i]}, + .isSelected = + [i] { + return getSettings().game.gyroMode.getValue() == static_cast(i); + }, + }) + .on_pressed([i] { + mDoAud_seStartMenu(kSoundItemChange); + const GyroMode mode = static_cast(i); + getSettings().game.gyroMode.setValue(mode); + config::Save(); + }); + } + pane.add_rml( + "
Sensor reads motion directly from a supported controller's gyro via SDL.
" + "
Mouse treats mouse input as gyro, intended for use with the Steam Deck.
" + "
Mouse input cannot currently be used with Gyro Rollgoal."); + }); addOption("Gyro Aim", getSettings().game.enableGyroAim, "Enables gyro controls while in look mode, aiming a hawk, and aiming " "supported items.

Supported items include the Slingshot, Gale Boomerang, " "Hero's Bow, Clawshot(s), Ball and Chain, and Dominion Rod."); addOption("Gyro Rollgoal", getSettings().game.enableGyroRollgoal, - "Enables gyro controls for Rollgoal in Hena's Cabin."); + "Enables gyro controls for Rollgoal in Hena's Cabin.", + [] { return getSettings().game.gyroMode.getValue() == GyroMode::Mouse; }); config_percent_select(leftPane, rightPane, getSettings().game.gyroSensitivityY, "Gyro Pitch Sensitivity", "Controls vertical gyro aiming sensitivity.", 25, 400, 5, [] { return !gyro_enabled(); }); @@ -673,7 +727,11 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { [] { return !gyro_enabled(); }); config_percent_select(leftPane, rightPane, getSettings().game.gyroSensitivityRollgoal, "Rollgoal Sensitivity", "Controls how strongly gyro input tilts the Rollgoal table.", - 25, 400, 5, [] { return !getSettings().game.enableGyroRollgoal; }); + 25, 400, 5, + [] { + return !getSettings().game.enableGyroRollgoal || + getSettings().game.gyroMode.getValue() == GyroMode::Mouse; + }); config_percent_select(leftPane, rightPane, getSettings().game.gyroDeadband, "Gyro Deadband", "Ignores small gyro movement to reduce drift and jitter.", 0, 50, 1, [] { return !gyro_enabled(); }); @@ -921,10 +979,81 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { auto& rightPane = add_child(content, Pane::Type::Uncontrolled); leftPane.add_section("Dusk"); - config_bool_select(leftPane, rightPane, getSettings().game.enableAchievementNotifications, - { - .key = "Achievement Notifications", - .helpText = "Display a toast when an achievement is unlocked.", +#if DUSK_CAN_OPEN_DATA_FOLDER + leftPane.register_control( + leftPane.add_button("Open Data Folder").on_pressed([] { + mDoAud_seStartMenu(kSoundClick); + dusk::OpenDataFolder(); + }), + rightPane, [](Pane& pane) { + pane.add_text( + "Open the folder where Dusk stores settings, saves, logs, texture " + "replacements, and other app data."); + }); +#endif + leftPane.register_control( + leftPane.add_select_button({ + .key = "Notifications", + .getValue = [] { + const bool ach = getSettings().game.enableAchievementToasts.getValue(); + const bool ctl = getSettings().game.enableControllerToasts.getValue(); + if (!ach && !ctl) { + return Rml::String{"Off"}; + } + if (ach && ctl) { + return Rml::String{"All"}; + } + return Rml::String{"Some"}; + }, + .isModified = [] { + const auto& ach = getSettings().game.enableAchievementToasts; + const auto& ctl = getSettings().game.enableControllerToasts; + return ach.getValue() != ach.getDefaultValue() || ctl.getValue() != ctl.getDefaultValue(); + }, + }), + rightPane, [](Pane& pane) { + pane.clear(); + pane.add_button("Select All").on_pressed([] { + mDoAud_seStartMenu(kSoundItemChange); + getSettings().game.enableAchievementToasts.setValue(true); + getSettings().game.enableControllerToasts.setValue(true); + config::Save(); + }); + pane.add_button("Select None").on_pressed([] { + mDoAud_seStartMenu(kSoundItemChange); + getSettings().game.enableAchievementToasts.setValue(false); + getSettings().game.enableControllerToasts.setValue(false); + config::Save(); + }); + + pane.add_section("Types"); + pane.add_button( + { + .text = "Achievements", + .isSelected = + [] { + return getSettings().game.enableAchievementToasts.getValue(); + }, + }) + .on_pressed([] { + mDoAud_seStartMenu(kSoundItemChange); + auto& v = getSettings().game.enableAchievementToasts; + v.setValue(!v.getValue()); + config::Save(); + }); + pane.add_button( + { + .text = "Controller", + .isSelected = + [] { return getSettings().game.enableControllerToasts.getValue(); }, + }) + .on_pressed([] { + mDoAud_seStartMenu(kSoundItemChange); + auto& v = getSettings().game.enableControllerToasts; + v.setValue(!v.getValue()); + config::Save(); + }); + pane.add_rml("
Choose which notifications can be displayed."); }); #if DUSK_ENABLE_SENTRY_NATIVE config_bool_select(leftPane, rightPane, getSettings().backend.enableCrashReporting, @@ -955,6 +1084,12 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .helpText = "Checks GitHub releases for a new Dusk version on startup.

" "No personal information is transmitted or collected.", }); + config_bool_select(leftPane, rightPane, getSettings().game.pauseOnFocusLost, + { + .key = "Pause On Focus Lost", + .helpText = "Pause the game when window focus is lost.", + .onChange = [](bool value) { aurora_set_pause_on_focus_lost(value); }, + }); config_bool_select(leftPane, rightPane, getSettings().backend.enableAdvancedSettings, { .key = "Enable Advanced Settings", diff --git a/src/dusk/ui/string_button.cpp b/src/dusk/ui/string_button.cpp index a267e5b0e0..7210e87803 100644 --- a/src/dusk/ui/string_button.cpp +++ b/src/dusk/ui/string_button.cpp @@ -24,7 +24,7 @@ void BaseStringButton::update() { } void BaseStringButton::start_editing() { - if (mInputElem != nullptr) { + if (is_editing()) { return; } @@ -79,14 +79,14 @@ void BaseStringButton::request_stop_editing(bool commit, bool refocusRoot) { bool BaseStringButton::handle_nav_command(NavCommand cmd) { if (cmd == NavCommand::Confirm) { - if (mInputElem == nullptr) { + if (!is_editing()) { start_editing(); } else { request_stop_editing(true, true); } return true; } else if (cmd == NavCommand::Cancel) { - if (mInputElem != nullptr) { + if (is_editing()) { request_stop_editing(false, true); return true; } @@ -95,7 +95,7 @@ bool BaseStringButton::handle_nav_command(NavCommand cmd) { } void BaseStringButton::focus_input() { - if (mInputElem == nullptr) { + if (!is_editing()) { return; } @@ -111,7 +111,7 @@ void BaseStringButton::focus_input() { void BaseStringButton::stop_editing(bool commit, bool refocusRoot) { mPendingStopEditing = false; mPendingInputFocusFrames = 0; - if (mInputElem == nullptr) { + if (!is_editing()) { return; } if (commit) { diff --git a/src/dusk/ui/string_button.hpp b/src/dusk/ui/string_button.hpp index d84b977f37..94f2cebcbe 100644 --- a/src/dusk/ui/string_button.hpp +++ b/src/dusk/ui/string_button.hpp @@ -20,6 +20,7 @@ public: void request_stop_editing(bool commit, bool refocusRoot); protected: + bool is_editing() { return mInputElem != nullptr; } bool handle_nav_command(NavCommand cmd) override; virtual void set_value(Rml::String value) = 0; virtual Rml::String input_value() { return format_value(); } diff --git a/src/dusk/ui/ui.cpp b/src/dusk/ui/ui.cpp index a646527ee5..5ef15a6c41 100644 --- a/src/dusk/ui/ui.cpp +++ b/src/dusk/ui/ui.cpp @@ -11,6 +11,7 @@ #include #include "aurora/lib/window.hpp" +#include "dusk/io.hpp" #include "input.hpp" #include "prelaunch.hpp" #include "window.hpp" @@ -19,7 +20,7 @@ namespace dusk::ui { namespace { void load_font(const char* filename, bool fallback = false) { - Rml::LoadFontFace(resource_path(filename).string(), fallback); + Rml::LoadFontFace(io::fs_path_to_string(resource_path(filename)), fallback); } bool sInitialized = false; @@ -125,43 +126,47 @@ void handle_event(const SDL_Event& event) noexcept { if (event.type == SDL_EVENT_GAMEPAD_ADDED) { auto* gamepad = SDL_GetGamepadFromID(event.gdevice.which); if (SDL_GamepadConnected(gamepad)) { - const char* name = SDL_GetGamepadName(gamepad); - Rml::String content = fmt::format("{}", name ? name : "[Unknown]"); - Rml::String title = "Controller connected"; - if (const char* icon = connection_state_icon(SDL_GetGamepadConnectionState(gamepad))) { - title = fmt::format( - "{} &#x{};", title, - icon); - } - int batteryLevel = -1; - const auto powerState = SDL_GetGamepadPowerInfo(gamepad, &batteryLevel); - if (powerState != SDL_POWERSTATE_UNKNOWN) { - content = fmt::format( - "{}&#x{};", - content, battery_icon(powerState, batteryLevel)); - if (batteryLevel > -1) { - content = fmt::format("{} {}%", content, batteryLevel); + if (getSettings().game.enableControllerToasts) { + const char* name = SDL_GetGamepadName(gamepad); + Rml::String content = fmt::format("{}", name ? name : "[Unknown]"); + Rml::String title = "Controller connected"; + if (const char* icon = connection_state_icon(SDL_GetGamepadConnectionState(gamepad))) { + title = fmt::format( + "{} &#x{};", title, + icon); } - content += ""; + int batteryLevel = -1; + const auto powerState = SDL_GetGamepadPowerInfo(gamepad, &batteryLevel); + if (powerState != SDL_POWERSTATE_UNKNOWN) { + content = fmt::format( + "{}&#x{};", + content, battery_icon(powerState, batteryLevel)); + if (batteryLevel > -1) { + content = fmt::format("{} {}%", content, batteryLevel); + } + content += ""; + } + push_toast({ + .type = "controller", + .title = title, + .content = content, + .duration = std::chrono::seconds(4), + }); } - push_toast({ - .type = "controller", - .title = title, - .content = content, - .duration = std::chrono::seconds(4), - }); sConnectedGamepads.insert(event.gdevice.which); } } else if (event.type == SDL_EVENT_GAMEPAD_REMOVED && sConnectedGamepads.contains(event.gdevice.which)) { - const char* name = SDL_GetGamepadNameForID(event.gdevice.which); - push_toast({ - .type = "controller", - .title = "Controller disconnected", - .content = name ? name : "[Unknown]", - .duration = std::chrono::seconds(4), - }); + if (getSettings().game.enableControllerToasts) { + const char* name = SDL_GetGamepadNameForID(event.gdevice.which); + push_toast({ + .type = "controller", + .title = "Controller disconnected", + .content = name ? name : "[Unknown]", + .duration = std::chrono::seconds(4), + }); + } sConnectedGamepads.erase(event.gdevice.which); } input::handle_event(event); @@ -255,11 +260,7 @@ void update() noexcept { } std::filesystem::path resource_path(const std::filesystem::path& filename) noexcept { - const char* basePath = SDL_GetBasePath(); - if (basePath == nullptr) { - return std::filesystem::path("res") / filename; - } - return std::filesystem::path(basePath) / "res" / filename; + return std::filesystem::path("res") / filename; } std::string escape(std::string_view str) noexcept { diff --git a/src/dusk/ui/window.cpp b/src/dusk/ui/window.cpp index 55ce96c69f..41080ff287 100644 --- a/src/dusk/ui/window.cpp +++ b/src/dusk/ui/window.cpp @@ -16,10 +16,7 @@ namespace dusk::ui { namespace { float base_body_padding(Rml::Context* context) noexcept { - if (context == nullptr) { - return 64.0f; - } - const float dpRatio = std::max(context->GetDensityIndependentPixelRatio(), 0.001f); + const float dpRatio = context->GetDensityIndependentPixelRatio(); const float heightDp = static_cast(context->GetDimensions().y) / dpRatio; if (heightDp <= 640.0f) { return 16.0f * dpRatio; diff --git a/src/dusk/update_check.cpp b/src/dusk/update_check.cpp index 11c11795af..c1e332c432 100644 --- a/src/dusk/update_check.cpp +++ b/src/dusk/update_check.cpp @@ -5,9 +5,12 @@ #include "nlohmann/json.hpp" #include "version.h" +#include #include #include +#include #include +#include namespace dusk::update_check { namespace { @@ -20,8 +23,7 @@ struct Version { int major = 0; int minor = 0; int patch = 0; - - friend auto operator<=>(const Version&, const Version&) = default; + std::vector prerelease; }; std::string json_string(const json& value, const char* key) { @@ -57,6 +59,134 @@ bool consume(std::string_view& value, char expected) { return true; } +bool is_digit(char value) { + return value >= '0' && value <= '9'; +} + +bool is_identifier_char(char value) { + return is_digit(value) || (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z') || value == '-'; +} + +bool is_numeric_identifier(std::string_view value) { + if (value.empty()) { + return false; + } + for (const char c : value) { + if (!is_digit(c)) { + return false; + } + } + return true; +} + +bool is_identifier_list(std::string_view value) { + if (value.empty()) { + return false; + } + + bool expectingIdentifier = true; + for (const char c : value) { + if (c == '.') { + if (expectingIdentifier) { + return false; + } + expectingIdentifier = true; + continue; + } + if (!is_identifier_char(c)) { + return false; + } + expectingIdentifier = false; + } + + return !expectingIdentifier; +} + +std::string_view trim_git_describe_suffix(std::string_view value) { + if (value.ends_with("-dirty")) { + value.remove_suffix(6); + } + if (is_numeric_identifier(value)) { + return {}; + } + + const size_t suffixStart = value.rfind('-'); + if (suffixStart != std::string_view::npos && value.substr(0, suffixStart).find('.') != std::string_view::npos + && is_numeric_identifier(value.substr(suffixStart + 1))) { + value.remove_suffix(value.size() - suffixStart); + } + return value; +} + +void split_identifiers(std::string_view value, std::vector& identifiers) { + while (!value.empty()) { + const size_t separator = value.find('.'); + if (separator == std::string_view::npos) { + identifiers.push_back(value); + return; + } + identifiers.push_back(value.substr(0, separator)); + value.remove_prefix(separator + 1); + } +} + +std::string_view trim_leading_zeroes(std::string_view value) { + while (value.size() > 1 && value.front() == '0') { + value.remove_prefix(1); + } + return value; +} + +int compare_identifier(std::string_view lhs, std::string_view rhs) { + const bool lhsNumeric = is_numeric_identifier(lhs); + const bool rhsNumeric = is_numeric_identifier(rhs); + if (lhsNumeric && rhsNumeric) { + lhs = trim_leading_zeroes(lhs); + rhs = trim_leading_zeroes(rhs); + if (lhs.size() != rhs.size()) { + return lhs.size() < rhs.size() ? -1 : 1; + } + } else if (lhsNumeric != rhsNumeric) { + return lhsNumeric ? -1 : 1; + } + + const int result = lhs.compare(rhs); + if (result < 0) { + return -1; + } + if (result > 0) { + return 1; + } + return 0; +} + +int compare_version(const Version& lhs, const Version& rhs) { + if (lhs.major != rhs.major) { + return lhs.major < rhs.major ? -1 : 1; + } + if (lhs.minor != rhs.minor) { + return lhs.minor < rhs.minor ? -1 : 1; + } + if (lhs.patch != rhs.patch) { + return lhs.patch < rhs.patch ? -1 : 1; + } + if (lhs.prerelease.empty() != rhs.prerelease.empty()) { + return lhs.prerelease.empty() ? 1 : -1; + } + + const size_t commonSize = std::min(lhs.prerelease.size(), rhs.prerelease.size()); + for (size_t i = 0; i < commonSize; ++i) { + const int result = compare_identifier(lhs.prerelease[i], rhs.prerelease[i]); + if (result != 0) { + return result; + } + } + if (lhs.prerelease.size() != rhs.prerelease.size()) { + return lhs.prerelease.size() < rhs.prerelease.size() ? -1 : 1; + } + return 0; +} + std::optional parse_version(std::string_view value) { if (!value.empty() && value.front() == 'v') { value.remove_prefix(1); @@ -75,13 +205,38 @@ std::optional parse_version(std::string_view value) { if (!patch) { return std::nullopt; } - if (!value.empty() && value.front() != '-' && value.front() != '+') { - return std::nullopt; - } version.major = *major; version.minor = *minor; version.patch = *patch; + + if (value.empty()) { + return version; + } + if (value.front() == '+') { + value.remove_prefix(1); + if (!is_identifier_list(value)) { + return std::nullopt; + } + return version; + } + if (!consume(value, '-')) { + return std::nullopt; + } + + const size_t buildStart = value.find('+'); + std::string_view prerelease = value.substr(0, buildStart); + if (!is_identifier_list(prerelease)) { + return std::nullopt; + } + if (buildStart != std::string_view::npos && !is_identifier_list(value.substr(buildStart + 1))) { + return std::nullopt; + } + + prerelease = trim_git_describe_suffix(prerelease); + if (!prerelease.empty()) { + split_identifiers(prerelease, version.prerelease); + } return version; } @@ -185,7 +340,7 @@ Result check_latest_github_release(std::string_view owner, std::string_view repo }; } - const bool updateAvailable = *latestVersion > *currentVersion; + const bool updateAvailable = compare_version(*latestVersion, *currentVersion) > 0; return { .status = updateAvailable ? Status::UpdateAvailable : Status::UpToDate, .message = updateAvailable ? "Update available" : "Dusk is up to date", diff --git a/src/f_ap/f_ap_game.cpp b/src/f_ap/f_ap_game.cpp index dc14933b50..55ef94f8a3 100644 --- a/src/f_ap/f_ap_game.cpp +++ b/src/f_ap/f_ap_game.cpp @@ -740,12 +740,20 @@ static void fapGm_AfterRecord() { fapGm_After(); } +BOOL isRecording = false; + static void duskExecute() { handleGamepadColor(); updateAutoSave(); if (dusk::getSettings().game.recordingMode) { - Z2GetSeqMgr()->bgmAllMute(0, 0); + Z2GetSoundMgr()->getSeqMgr()->getParams()->moveVolume(0.0f, 0); + Z2GetSoundMgr()->getStreamMgr()->getParams()->moveVolume(0.0f, 0); + isRecording = true; + } else if (isRecording) { + Z2GetSoundMgr()->getSeqMgr()->getParams()->moveVolume(1.0f, 0); + Z2GetSoundMgr()->getStreamMgr()->getParams()->moveVolume(1.0f, 0); + isRecording = false; } if (mDoCPd_c::getHoldR(PAD_1) && mDoCPd_c::getTrigX(PAD_1)) { diff --git a/src/m_Do/m_Do_graphic.cpp b/src/m_Do/m_Do_graphic.cpp index 61eaf3bc2d..424f69cf4b 100644 --- a/src/m_Do/m_Do_graphic.cpp +++ b/src/m_Do/m_Do_graphic.cpp @@ -1194,8 +1194,13 @@ static void trimming(view_class* param_0, view_port_class* param_1) { if ((y_orig_pos == 0) && (param_1->scissor.y_orig != param_1->y_orig || (param_1->scissor.height != param_1->height))) { + #if TARGET_PC + f32 sc_top = param_1->scissor.y_orig; + f32 sc_bottom = param_1->scissor.y_orig + param_1->scissor.height; + #else s32 sc_top = (int)param_1->scissor.y_orig; s32 sc_bottom = param_1->scissor.y_orig + param_1->scissor.height; + #endif GXSetNumChans(1); GXSetChanCtrl(GX_ALPHA0, GX_FALSE, GX_SRC_REG, GX_SRC_REG, 0, GX_DF_NONE, GX_AF_NONE); GXSetNumTexGens(0); @@ -1224,20 +1229,20 @@ static void trimming(view_class* param_0, view_port_class* param_1) { GXLoadPosMtxImm(cMtx_getIdentity(), 0); GXClearVtxDesc(); GXSetVtxDesc(GX_VA_POS, GX_DIRECT); - GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_CLR_RGBA, GX_RGBA4, 0); + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_CLR_RGBA, DUSK_IF_ELSE(GX_F32, GX_RGBA4), 0); GXSetProjection(ortho, GX_ORTHOGRAPHIC); GXSetCurrentMtx(0); GXBegin(GX_QUADS, GX_VTXFMT0, 8); #if TARGET_PC - GXPosition3s16(0, 0, -5); - GXPosition3s16(param_1->width, 0, -5); - GXPosition3s16(param_1->width, sc_top, -5); - GXPosition3s16(0, sc_top, -5); - GXPosition3s16(0, sc_bottom, -5); - GXPosition3s16(param_1->width, sc_bottom, -5); - GXPosition3s16(param_1->width, param_1->height, -5); - GXPosition3s16(0, param_1->height, -5); + GXPosition3f32(0, 0, -5); + GXPosition3f32(param_1->width, 0, -5); + GXPosition3f32(param_1->width, sc_top, -5); + GXPosition3f32(0, sc_top, -5); + GXPosition3f32(0, sc_bottom, -5); + GXPosition3f32(param_1->width, sc_bottom, -5); + GXPosition3f32(param_1->width, param_1->height, -5); + GXPosition3f32(0, param_1->height, -5); #else GXPosition3s16(0, 0, -5); GXPosition3s16(FB_WIDTH, 0, -5); diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index ad6604e9c7..500a219f17 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -71,12 +71,15 @@ #include #include "SDL3/SDL_filesystem.h" +#include "SDL3/SDL_iostream.h" +#include "SDL3/SDL_misc.h" #include "cxxopts.hpp" #include "d/actor/d_a_movie_player.h" #include "dusk/audio/DuskAudioSystem.h" #include "dusk/audio/DuskDsp.hpp" #include "dusk/config.hpp" #include "dusk/settings.h" +#include "dusk/io.hpp" #include "dusk/version.hpp" #include "dusk/discord_presence.hpp" #if DUSK_TPHD @@ -87,6 +90,9 @@ #include "f_pc/f_pc_draw.h" #include "tracy/Tracy.hpp" #include +#ifdef __APPLE__ +#include +#endif // --- GLOBALS --- s8 mDoMain::developmentMode = -1; @@ -109,7 +115,6 @@ const int audioHeapSize = 0x14D800; bool dusk::IsRunning = true; bool dusk::IsShuttingDown = false; bool dusk::IsGameLaunched = false; -bool dusk::IsFocusPaused = false; bool dusk::RestartRequested = false; std::filesystem::path dusk::ConfigPath; #endif @@ -119,6 +124,32 @@ void dusk::RequestRestart() noexcept { IsRunning = false; } +bool dusk::OpenDataFolder() { +#if DUSK_CAN_OPEN_DATA_FOLDER + std::error_code ec; + std::filesystem::path path = std::filesystem::absolute(ConfigPath, ec); + if (ec) { + DuskLog.warn("Failed to resolve absolute data folder path '{}': {}", + io::fs_path_to_string(ConfigPath), ec.message()); + path = ConfigPath; + } + +#if defined(_WIN32) + const std::string url = "file:///" + path.generic_string(); +#else + const std::string url = "file://" + path.generic_string(); +#endif + if (!SDL_OpenURL(url.c_str())) { + DuskLog.warn( + "Failed to open data folder '{}': {}", io::fs_path_to_string(path), SDL_GetError()); + return false; + } + return true; +#else + return false; +#endif +} + s32 LOAD_COPYDATE(void*) { char buffer[32]; memset(buffer, 0, sizeof(buffer)); @@ -232,19 +263,16 @@ void main01(void) { switch (event->type) { case AURORA_NONE: goto eventsDone; + case AURORA_PAUSED: + dusk::audio::SetPaused(true); + break; + case AURORA_UNPAUSED: + dusk::audio::SetPaused(false); + dusk::game_clock::reset_frame_timer(); + break; case AURORA_SDL_EVENT: dusk::ui::handle_event(event->sdl); dusk::g_imguiConsole.HandleSDLEvent(event->sdl); - if (event->sdl.type == SDL_EVENT_WINDOW_FOCUS_LOST && - dusk::getSettings().game.pauseOnFocusLost) { - dusk::IsFocusPaused = true; - dusk::audio::SetPaused(true); - } else if (event->sdl.type == SDL_EVENT_WINDOW_FOCUS_GAINED && - dusk::IsFocusPaused) { - dusk::IsFocusPaused = false; - dusk::audio::SetPaused(false); - dusk::game_clock::reset_frame_timer(); - } break; case AURORA_DISPLAY_SCALE_CHANGED: dusk::ImGuiEngine_Initialize(event->windowSize.scale); @@ -258,19 +286,14 @@ void main01(void) { eventsDone:; - if (dusk::IsFocusPaused) { - std::this_thread::sleep_for(std::chrono::milliseconds(16)); + if (!aurora_begin_frame()) { + DuskLog.debug("aurora_begin_frame returned false, skipping draw this frame"); continue; } VIWaitForRetrace(); dusk::lastFrameAuroraStats = *aurora_get_stats(); - if (!aurora_begin_frame()) { - DuskLog.debug("aurora_begin_frame returned false, skipping draw this frame"); - continue; - } - mDoGph_gInf_c::updateRenderSize(); dusk::ui::update(); @@ -405,13 +428,77 @@ static void ApplyCVarOverrides(const cxxopts::OptionValue& option) { } } -static std::filesystem::path CalculateConfigPath() { +static void migrate_directory(const std::filesystem::path& from, const std::filesystem::path& to) { + std::error_code ec; + std::filesystem::create_directories(to, ec); + if (ec) { + return; + } + + for (std::filesystem::recursive_directory_iterator it( + from, std::filesystem::directory_options::skip_permission_denied, ec); + it != std::filesystem::recursive_directory_iterator(); it.increment(ec)) + { + if (ec) { + return; + } + + const auto relativePath = std::filesystem::relative(it->path(), from, ec); + if (ec) { + return; + } + + const auto targetPath = to / relativePath; + if (it->is_directory(ec)) { + std::filesystem::create_directories(targetPath, ec); + if (ec) { + return; + } + } else if (it->is_regular_file(ec) && !std::filesystem::exists(targetPath, ec)) { + std::filesystem::create_directories(targetPath.parent_path(), ec); + if (ec) { + return; + } + std::filesystem::copy_file( + it->path(), targetPath, std::filesystem::copy_options::skip_existing, ec); + if (ec) { + return; + } + } + } +} + +static std::filesystem::path calculate_config_path() { +#ifdef __APPLE__ +#if TARGET_OS_IOS && !TARGET_OS_TV + const char* documentsPath = SDL_GetUserFolder(SDL_FOLDER_DOCUMENTS); + if (!documentsPath) { + DuskLog.fatal("Unable to get iOS Documents path: {}", SDL_GetError()); + } + + std::filesystem::path configPath = reinterpret_cast(documentsPath); + + char* oldPrefPath = SDL_GetPrefPath(dusk::OrgName, dusk::AppName); + if (oldPrefPath) { + const std::filesystem::path oldConfigPath = reinterpret_cast(oldPrefPath); + SDL_free(oldPrefPath); + + std::error_code ec; + if (oldConfigPath != configPath && std::filesystem::exists(oldConfigPath, ec)) { + migrate_directory(oldConfigPath, configPath); + } + } + + return configPath; +#endif +#endif + const auto result = SDL_GetPrefPath(dusk::OrgName, dusk::AppName); if (!result) { DuskLog.fatal("Unable to get PrefPath: {}", SDL_GetError()); } - return result; + return reinterpret_cast(result); } static void EnsureInitialPipelineCache(const std::filesystem::path& configDir) { @@ -424,16 +511,21 @@ static void EnsureInitialPipelineCache(const std::filesystem::path& configDir) { return; } - const char* basePath = SDL_GetBasePath(); - if (basePath == nullptr) { - DuskLog.warn("Unable to resolve base path while seeding pipeline cache: {}", SDL_GetError()); - return; - } + std::string sourcePathString; + SDL_IOStream* source = nullptr; - const std::filesystem::path initialPipelineCachePath = - std::filesystem::path(basePath) / "initial_pipeline_cache.db"; - if (!std::filesystem::exists(initialPipelineCachePath)) { - DuskLog.info("No bundled initial pipeline cache found at '{}'", initialPipelineCachePath.string()); + const char* basePath = SDL_GetBasePath(); + if (basePath != nullptr) { + sourcePathString = dusk::io::fs_path_to_string( + std::filesystem::path(basePath) / "initial_pipeline_cache.db"); + source = SDL_IOFromFile(sourcePathString.c_str(), "rb"); + } + if (source == nullptr) { + sourcePathString = "initial_pipeline_cache.db"; + source = SDL_IOFromFile(sourcePathString.c_str(), "rb"); + } + if (source == nullptr) { + DuskLog.info("No bundled initial pipeline cache found"); return; } @@ -441,18 +533,68 @@ static void EnsureInitialPipelineCache(const std::filesystem::path& configDir) { std::filesystem::create_directories(configDir, ec); if (ec) { DuskLog.warn("Failed to create config directory '{}' for pipeline cache: {}", - configDir.string(), ec.message()); + dusk::io::fs_path_to_string(configDir), ec.message()); + SDL_CloseIO(source); return; } - std::filesystem::copy_file(initialPipelineCachePath, pipelineCachePath, std::filesystem::copy_options::none, ec); - if (ec) { - DuskLog.warn("Failed to seed pipeline cache from '{}' to '{}': {}", - initialPipelineCachePath.string(), pipelineCachePath.string(), ec.message()); + const auto pipelineCacheString = dusk::io::fs_path_to_string(pipelineCachePath); + SDL_IOStream* destination = SDL_IOFromFile(pipelineCacheString.c_str(), "wb"); + if (destination == nullptr) { + DuskLog.warn("Failed to open '{}' for seeded pipeline cache: {}", pipelineCacheString, + SDL_GetError()); + SDL_CloseIO(source); return; } - DuskLog.info("Seeded pipeline cache from '{}'", initialPipelineCachePath.string()); + bool copied = true; + std::array buffer{}; + while (true) { + const size_t bytesRead = SDL_ReadIO(source, buffer.data(), buffer.size()); + if (bytesRead > 0) { + size_t bytesWritten = 0; + while (bytesWritten < bytesRead) { + const size_t written = SDL_WriteIO( + destination, buffer.data() + bytesWritten, bytesRead - bytesWritten); + if (written == 0) { + DuskLog.warn("Failed to write seeded pipeline cache '{}': {}", + pipelineCacheString, SDL_GetError()); + copied = false; + break; + } + bytesWritten += written; + } + } + + if (!copied) { + break; + } + + if (bytesRead < buffer.size()) { + if (SDL_GetIOStatus(source) == SDL_IO_STATUS_EOF) { + break; + } + + DuskLog.warn( + "Failed to read bundled pipeline cache '{}': {}", sourcePathString, SDL_GetError()); + copied = false; + break; + } + } + + if (!SDL_CloseIO(destination)) { + DuskLog.warn("Failed to close seeded pipeline cache '{}': {}", + dusk::io::fs_path_to_string(pipelineCachePath), SDL_GetError()); + copied = false; + } + SDL_CloseIO(source); + + if (!copied) { + std::filesystem::remove(pipelineCachePath, ec); + return; + } + + DuskLog.info("Seeded pipeline cache from '{}'", sourcePathString); } static constexpr PADDefaultMapping defaultPadMapping = { @@ -549,7 +691,7 @@ int game_main(int argc, char* argv[]) { exit(1); } - dusk::ConfigPath = CalculateConfigPath(); + dusk::ConfigPath = calculate_config_path(); const auto startupLogLevel = static_cast(parsed_arg_options["log-level"].as()); dusk::InitializeFileLogging(dusk::ConfigPath, startupLogLevel); @@ -557,13 +699,14 @@ int game_main(int argc, char* argv[]) { ApplyCVarOverrides(parsed_arg_options["cvar"]); dusk::InitializeCrashReporting(); EnsureInitialPipelineCache(dusk::ConfigPath); - PADSetDefaultMapping(&defaultPadMapping); + // TODO: How to handle this? + //PADSetDefaultMapping(&defaultPadMapping, PAD_TYPE_STANDARD); { - const auto configPathString = dusk::ConfigPath.string(); + const auto configPathString = dusk::ConfigPath.u8string(); AuroraConfig config{}; config.appName = dusk::AppName; - config.configPath = configPathString.c_str(); + config.configPath = reinterpret_cast(configPathString.c_str()); config.vsync = dusk::getSettings().video.enableVsync; config.startFullscreen = dusk::getSettings().video.enableFullscreen; config.windowPosX = -1; @@ -582,7 +725,8 @@ int game_main(int argc, char* argv[]) { config.mem1Size = 256 * 1024 * 1024; #endif config.mem2Size = 24 * 1024 * 1024; - config.allowJoystickBackgroundEvents = true; + config.allowJoystickBackgroundEvents = dusk::getSettings().game.allowBackgroundInput; + config.pauseOnFocusLost = dusk::getSettings().game.pauseOnFocusLost; config.imGuiInitCallback = &aurora_imgui_init_callback; config.allowTextureReplacements = true; config.allowTextureDumps = false; @@ -629,13 +773,19 @@ int game_main(int argc, char* argv[]) { // Invalidate a bad saved isoPath so that Dusk can't get blocked from starting up. // This is only a metadata check; full hash verification is handled by the prelaunch UI. + bool forcePreLaunchUI = false; + bool saveConfigBeforePrelaunch = false; + const std::string p = dusk::getSettings().backend.isoPath; dusk::iso::DiscInfo discInfo{}; if (!p.empty() && dusk::iso::inspect(p.c_str(), discInfo) != dusk::iso::ValidationError::Success) { + DuskLog.warn("Saved DVD image path failed validation, clearing configured path: {}", p); dusk::getSettings().backend.isoPath.setValue(""); dusk::getSettings().backend.isoVerification.setValue(dusk::DiscVerificationState::Unknown); + forcePreLaunchUI = true; + saveConfigBeforePrelaunch = true; } std::string dvd_path; @@ -647,6 +797,7 @@ int game_main(int argc, char* argv[]) { dvd_opened = aurora_dvd_open(dvd_path.c_str()); if (!dvd_opened) { DuskLog.warn("Failed to open DVD image from command line: {}, opening prelaunch UI", dvd_path); + forcePreLaunchUI = true; } else { dusk::getSettings().backend.isoPath.setValue(dvd_path); dusk::getSettings().backend.isoVerification.setValue( @@ -656,10 +807,27 @@ int game_main(int argc, char* argv[]) { } } else { DuskLog.warn("DVD image from command line failed validation: {}, opening prelaunch UI", dvd_path); + forcePreLaunchUI = true; } } + dusk::iso::log_verification_state( + dusk::getSettings().backend.isoPath.getValue(), + dusk::getSettings().backend.isoVerification.getValue()); + if (!dvd_opened) { + if (dusk::getSettings().backend.isoPath.getValue().empty()) { + forcePreLaunchUI = true; + } + if (forcePreLaunchUI && dusk::getSettings().backend.skipPreLaunchUI.getValue()) { + DuskLog.warn("Prelaunch UI was disabled with no usable DVD image, enabling prelaunch UI"); + dusk::getSettings().backend.skipPreLaunchUI.setValue(false); + saveConfigBeforePrelaunch = true; + } + if (saveConfigBeforePrelaunch) { + dusk::config::Save(); + } + if (!dusk::getSettings().backend.skipPreLaunchUI) { dusk::ui::push_document(std::make_unique(), true);