diff --git a/CMakeLists.txt b/CMakeLists.txt index 5123cbcd79..c2281292bc 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") @@ -69,7 +71,7 @@ message(STATUS "Dusk version set to ${DUSK_WC_DESCRIBE}") message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") project(dusk LANGUAGES C CXX VERSION ${DUSK_VERSION_STRING}) if (APPLE) - enable_language(OBJC) + enable_language(OBJC OBJCXX) endif () if (APPLE AND NOT TVOS AND CMAKE_SYSTEM_NAME STREQUAL tvOS) # ios.toolchain.cmake hack for SDL @@ -109,6 +111,7 @@ add_subdirectory(libs/freeverb) option(DUSK_BUILD_WARNINGS "Enable compiler warnings (off by default)") option(DUSK_SELECTED_OPT "If on, selected parts of the project will be compiled with optimizations on Debug, intending to make the game run at 30 FPS. Note for MSVC: you will need to remove '/RTC1' from your debug flags in CMake.") option(DUSK_MOVIE_SUPPORT "If on, compile against libjpeg-turbo to enable THP file decoding" ON) +option(DUSK_ENABLE_UPDATE_CHECKER "Enable update checking support" ON) if(ANDROID) set(DUSK_MOVIE_SUPPORT OFF) @@ -284,7 +287,7 @@ set(DUSK_PRODUCT_NAME "Dusk") set(DUSK_COPYRIGHT "Copyright (C) Twilit Realm contributors") source_group("dolzel" FILES ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${REL_FILES}) -source_group("dusk" FILES ${DUSK_FILES}) +source_group("dusk" FILES ${DUSK_FILES} ${DUSK_HTTP_BACKEND_FILES}) set(GAME_COMPILE_DEFS TARGET_PC WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 MTX_USE_PS=1) @@ -314,6 +317,41 @@ if (WIN32) list(APPEND GAME_LIBS Ws2_32) endif () +set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/no_backend.cpp) +if (DUSK_ENABLE_UPDATE_CHECKER) + list(APPEND GAME_COMPILE_DEFS DUSK_ENABLE_UPDATE_CHECKER=1) + if (WIN32) + set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/winhttp.cpp) + list(APPEND GAME_LIBS winhttp) + list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_WINHTTP=1) + message(STATUS "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) + set_source_files_properties(src/dusk/http/url_session.mm PROPERTIES COMPILE_FLAGS -fobjc-arc) + list(APPEND GAME_LIBS ${FOUNDATION_FRAMEWORK}) + list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_URLSESSION=1) + message(STATUS "dusk: Enabled update checker (NSURLSession)") + elseif (CMAKE_SYSTEM_NAME STREQUAL Linux) + find_package(CURL QUIET OPTIONAL_COMPONENTS HTTPS SSL) + if (CURL_FOUND AND CURL_HTTPS_FOUND AND CURL_SSL_FOUND) + set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/curl.cpp) + list(APPEND GAME_LIBS CURL::libcurl) + list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_LIBCURL=1) + message(STATUS "dusk: Enabled update checker (libcurl)") + else () + message(STATUS "dusk: Disabled update checker (libcurl + HTTPS/SSL not found)") + endif () + else () + message(STATUS "dusk: Disabled update checker (unsupported platform)") + endif () +endif () +list(APPEND DUSK_FILES ${DUSK_HTTP_BACKEND_SOURCE}) + if (DUSK_MOVIE_SUPPORT) if (TARGET libjpeg-turbo::turbojpeg-static) list(APPEND GAME_LIBS libjpeg-turbo::turbojpeg-static) diff --git a/CMakePresets.json b/CMakePresets.json index 92799249a9..3b2f0cae67 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" diff --git a/extern/aurora b/extern/aurora index 18dc51234f..1eeff98783 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 18dc51234f7b4d5b5b592132630f3fb27d0b71af +Subproject commit 1eeff98783c36fba83970c011783a4fd9deac018 diff --git a/files.cmake b/files.cmake index 54d9541053..1a5779d03a 100644 --- a/files.cmake +++ b/files.cmake @@ -1430,11 +1430,14 @@ set(DUSK_FILES src/dusk/gyro.cpp src/dusk/gamepad_color.cpp src/dusk/autosave.cpp + src/dusk/http/http.hpp src/dusk/io.cpp src/dusk/layout.cpp src/dusk/logging.cpp src/dusk/settings.cpp src/dusk/stubs.cpp + src/dusk/update_check.cpp + src/dusk/update_check.hpp #src/dusk/m_Do_ext_dusk.cpp src/dusk/imgui/ImGuiConfig.hpp src/dusk/imgui/ImGuiConsole.hpp @@ -1602,3 +1605,10 @@ set(DUSK_FILES src/dusk/randomizer/generator/utility/time.hpp src/dusk/randomizer/generator/utility/yaml.hpp ) + +set(DUSK_HTTP_BACKEND_FILES + src/dusk/http/no_backend.cpp + src/dusk/http/curl.cpp + src/dusk/http/winhttp.cpp + src/dusk/http/url_session.mm +) diff --git a/include/d/actor/d_a_alink.h b/include/d/actor/d_a_alink.h index 1d4cc27f23..ff3a387b9a 100644 --- a/include/d/actor/d_a_alink.h +++ b/include/d/actor/d_a_alink.h @@ -4558,6 +4558,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/d_com_inf_game.h b/include/d/d_com_inf_game.h index 5cb2c6e67d..f55f1d35d6 100644 --- a/include/d/d_com_inf_game.h +++ b/include/d/d_com_inf_game.h @@ -1887,6 +1887,12 @@ inline void dComIfGs_addDeathCount() { g_dComIfG_gameInfo.info.getPlayer().getPlayerInfo().addDeathCount(); } +#if TARGET_PC +inline u16 dComIfGs_getDeathCount() { + return g_dComIfG_gameInfo.info.getPlayer().getPlayerInfo().getDeathCount(); +} +#endif + inline char* dComIfGs_getPlayerName() { return g_dComIfG_gameInfo.info.getPlayer().getPlayerInfo().getPlayerName(); } diff --git a/include/d/d_save.h b/include/d/d_save.h index 5e35175d25..942a92f483 100644 --- a/include/d/d_save.h +++ b/include/d/d_save.h @@ -514,6 +514,9 @@ public: mDeathCount++; } } +#if TARGET_PC + u16 getDeathCount() const { return mDeathCount; } +#endif char* getPlayerName() const { return const_cast(mPlayerName); } void setPlayerName(const char* i_name) { #if AVOID_UB diff --git a/include/dusk/achievements.h b/include/dusk/achievements.h index bf0021c9d7..5c2758b6b7 100644 --- a/include/dusk/achievements.h +++ b/include/dusk/achievements.h @@ -12,9 +12,8 @@ namespace dusk { enum class AchievementCategory : uint8_t { - Story, - Collection, Challenge, + Collection, Minigame, Misc, Glitched 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/main.h b/include/dusk/main.h index d6b9c9927f..787c2541e2 100644 --- a/include/dusk/main.h +++ b/include/dusk/main.h @@ -11,7 +11,6 @@ namespace dusk { extern bool IsRunning; extern bool IsShuttingDown; extern bool IsGameLaunched; - extern bool IsFocusPaused; extern bool RestartRequested; extern std::filesystem::path ConfigPath; diff --git a/include/dusk/settings.h b/include/dusk/settings.h index 4a505049c5..f74a4bf61f 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; @@ -172,6 +185,7 @@ struct UserSettings { ConfigVar showPipelineCompilation; ConfigVar wasPresetChosen; ConfigVar enableCrashReporting; + ConfigVar checkForUpdates; ConfigVar cardFileType; ConfigVar enableAdvancedSettings; } backend; diff --git a/libs/JSystem/src/JUtility/JUTFader.cpp b/libs/JSystem/src/JUtility/JUTFader.cpp index 599ae22720..e7d33454b7 100644 --- a/libs/JSystem/src/JUtility/JUTFader.cpp +++ b/libs/JSystem/src/JUtility/JUTFader.cpp @@ -8,6 +8,10 @@ #include "JSystem/JUtility/JUTFader.h" #include "JSystem/J2DGraph/J2DOrthoGraph.h" +#ifdef TARGET_PC +#include +#endif + JUTFader::JUTFader(int x, int y, int width, int height, JUtility::TColor pColor) : mColor(pColor), mBox(x, y, x + width, y + height) { mStatus = None; @@ -63,14 +67,24 @@ void JUTFader::advance() { void JUTFader::control() { advance(); -#ifndef TARGET_PC - // FRAME INTERP NOTE: Draw is called by JFWDisplay when interpolation is active draw(); -#endif } void JUTFader::draw() { if (mColor.a != 0) { +#ifdef TARGET_PC + if (dusk::frame_interp::is_enabled() && mDuration != 0) { + const auto step = dusk::frame_interp::get_interpolation_step(); + const auto progress = static_cast(mTimer) / static_cast(mDuration); + const auto timer = mTimer - 1 + step + progress; + auto alpha = timer / mDuration; + if (mStatus == FadeIn) { + alpha = 1.0f - alpha; + } + alpha = std::clamp(alpha, 0.0f, 1.0f); + mColor.a = static_cast(alpha * 255.0f); + } +#endif J2DOrthoGraph orthograph; orthograph.setColor(mColor); orthograph.fillBox(mBox); diff --git a/platforms/android/app/build.gradle b/platforms/android/app/build.gradle index 9375d06910..01e706a382 100644 --- a/platforms/android/app/build.gradle +++ b/platforms/android/app/build.gradle @@ -2,6 +2,16 @@ 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' compileSdk 36 @@ -27,7 +37,7 @@ android { sourceSets { main { jniLibs.srcDirs = ['src/main/jniLibs'] - assets.srcDirs = ['../../assets'] + assets.srcDirs = [duskGeneratedAssetsDir] } } @@ -48,3 +58,9 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) } + +tasks.configureEach { task -> + if (task.name.startsWith('merge') && task.name.endsWith('Assets')) { + task.dependsOn(syncDuskAssets) + } +} diff --git a/platforms/android/app/proguard-rules.pro b/platforms/android/app/proguard-rules.pro index 952161ca8b..8f2cf4a4e2 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 com.twilitrealm.dusk.DuskHttpClient { *; } +-keep class com.twilitrealm.dusk.DuskHttpClient$Response { *; } diff --git a/platforms/android/app/src/main/AndroidManifest.xml b/platforms/android/app/src/main/AndroidManifest.xml index e8c2bb29a8..a902065475 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(); @@ -63,7 +72,10 @@ public class DuskActivity extends SDLActivity { super.onCreate(savedInstanceState); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - getWindow().getDecorView().getWindowInsetsController().hide(WindowInsets.Type.systemBars()); + WindowInsetsController ctrl = getWindow().getDecorView().getWindowInsetsController(); + if (ctrl != null) { + ctrl.hide(WindowInsets.Type.systemBars()); + } }else { View decorView = getWindow().getDecorView(); // Hide the status bar. @@ -72,7 +84,9 @@ public class DuskActivity extends SDLActivity { // 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(); + } } } @@ -108,4 +122,93 @@ public class DuskActivity extends SDLActivity { } 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/DuskHttpClient.java b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskHttpClient.java new file mode 100644 index 0000000000..6e804e506f --- /dev/null +++ b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskHttpClient.java @@ -0,0 +1,237 @@ +package com.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/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/freedesktop/1024x1024/apps/dusk.png b/platforms/freedesktop/1024x1024/apps/dusk.png index 862cbed0d8..33eceb7c37 100644 Binary files a/platforms/freedesktop/1024x1024/apps/dusk.png and b/platforms/freedesktop/1024x1024/apps/dusk.png differ diff --git a/platforms/freedesktop/128x128/apps/dusk.png b/platforms/freedesktop/128x128/apps/dusk.png index aaf38689d2..7d73dff804 100644 Binary files a/platforms/freedesktop/128x128/apps/dusk.png and b/platforms/freedesktop/128x128/apps/dusk.png differ diff --git a/platforms/freedesktop/16x16/apps/dusk.png b/platforms/freedesktop/16x16/apps/dusk.png index 21b653c63d..3680f3bf6a 100644 Binary files a/platforms/freedesktop/16x16/apps/dusk.png and b/platforms/freedesktop/16x16/apps/dusk.png differ diff --git a/platforms/freedesktop/256x256/apps/dusk.png b/platforms/freedesktop/256x256/apps/dusk.png index 1f4677878a..4fa9995913 100644 Binary files a/platforms/freedesktop/256x256/apps/dusk.png and b/platforms/freedesktop/256x256/apps/dusk.png differ diff --git a/platforms/freedesktop/32x32/apps/dusk.png b/platforms/freedesktop/32x32/apps/dusk.png index b879a01602..3d966b7cbf 100644 Binary files a/platforms/freedesktop/32x32/apps/dusk.png and b/platforms/freedesktop/32x32/apps/dusk.png differ diff --git a/platforms/freedesktop/48x48/apps/dusk.png b/platforms/freedesktop/48x48/apps/dusk.png index 4f3744ab89..e12bca0df9 100644 Binary files a/platforms/freedesktop/48x48/apps/dusk.png and b/platforms/freedesktop/48x48/apps/dusk.png differ diff --git a/platforms/freedesktop/512x512/apps/dusk.png b/platforms/freedesktop/512x512/apps/dusk.png index 6334f80e81..53afadf4a5 100644 Binary files a/platforms/freedesktop/512x512/apps/dusk.png and b/platforms/freedesktop/512x512/apps/dusk.png differ diff --git a/platforms/freedesktop/64x64/apps/dusk.png b/platforms/freedesktop/64x64/apps/dusk.png index 067a83da90..c0dd5502a5 100644 Binary files a/platforms/freedesktop/64x64/apps/dusk.png and b/platforms/freedesktop/64x64/apps/dusk.png differ diff --git a/platforms/freedesktop/dusk.desktop b/platforms/freedesktop/dusk.desktop index 2e19d7ea26..b052eac6ef 100644 --- a/platforms/freedesktop/dusk.desktop +++ b/platforms/freedesktop/dusk.desktop @@ -6,4 +6,4 @@ Exec=dusk Icon=dusk Terminal=false Type=Application -Categories=Graphics;3DGraphics;Game +Categories=Game; 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 16bc7ea4ed..707d8b8234 100644 --- a/res/rml/prelaunch.rcss +++ b/res/rml/prelaunch.rcss @@ -273,20 +273,60 @@ body.mirrored version-info { font-size: 20dp; } -/* TODO: Hidden until an actual update checker is introduced */ .update { display: none; - font-size: 16dp; - font-weight: bold; - cursor: pointer; - color: #D8F999; + color: #A6A09B; + align-items: center; + justify-content: flex-end; + gap: 8dp; + font-size: 20dp; } -.detail, -.update span { +.update[state=checking], +.update[state=failed] { + display: block; +} + +.update[state=available] { + display: flex; +} + +#update-download { + display: none; + margin: 0dp; + padding: 0dp; + border-width: 0dp; + background-color: transparent; + color: #D8F999; + cursor: pointer; + text-transform: uppercase; + font-weight: bold; + decorator: horizontal-gradient(#00000000 #00000000); +} + +.update[state=available] #update-download { + display: flex; + align-items: center; + gap: 2dp; +} + +#update-download icon { + display: block; + width: 18dp; + height: 18dp; + font-family: "Material Symbols Rounded"; + font-weight: normal; + decorator: text("" center center); +} + +.detail { color: #A6A09B; } +body.mirrored .update { + justify-content: flex-start; +} + /* Startup animation */ .intro-item { opacity: 0; @@ -334,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; @@ -346,8 +386,8 @@ body.animate-in .intro-item { } body.mirrored menu { - left: 20dp; - right: 20dp; + left: 32dp; + right: 32dp; flex-direction: row-reverse; } @@ -355,7 +395,7 @@ body.animate-in .intro-item { flex: 1 1 0; min-width: 0; max-width: 48%; - + margin-left: 32dp; } body.mirrored hero { @@ -397,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 817f3dc333..8dd29b55e4 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -17,6 +17,7 @@ body { window { display: flex; flex-flow: column; + position: relative; height: 100%; width: 100%; max-width: 1088dp; @@ -291,6 +292,19 @@ icon.question-mark { decorator: text("" center center); } +.achievement-total { + position: absolute; + top: 0; + right: 64dp; + height: 64dp; + line-height: 64dp; + font-family: "Fira Sans Condensed"; + font-weight: bold; + font-size: 16dp; + color: rgba(224, 219, 200, 55%); + pointer-events: none; +} + .achievement-row { display: flex; align-items: flex-start; diff --git a/src/d/actor/d_a_alink.cpp b/src/d/actor/d_a_alink.cpp index e088319225..59591fc078 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 @@ -14810,6 +14811,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; @@ -19740,6 +19745,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_damage.inc b/src/d/actor/d_a_alink_damage.inc index e095fc0393..bccdbf7381 100644 --- a/src/d/actor/d_a_alink_damage.inc +++ b/src/d/actor/d_a_alink_damage.inc @@ -205,6 +205,9 @@ int daAlink_c::setDamagePoint(int i_dmgAmount, BOOL i_checkZoraMag, BOOL i_setDm dComIfGp_setItemLifeCount(-i_dmgAmount, 0); } +#if TARGET_PC + dusk::AchievementSystem::get().signal("player_damaged"); +#endif onResetFlg1(RFLG1_DAMAGE_IMPACT); mSwordUpTimer = 0; diff --git a/src/d/actor/d_a_alink_hook.inc b/src/d/actor/d_a_alink_hook.inc index 73049af08b..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, @@ -17,11 +19,11 @@ enum { HS_MODE_RETURN_e = 6, }; -void daAlink_c::hsChainShape_c::draw() { - if (dusk::getSettings().game.superClawshot) { - return; - } +#if TARGET_PC +static const int HS_CHAIN_MAX_LINKS = 600; +#endif +void daAlink_c::hsChainShape_c::draw() { static const int dummy = 0; daAlink_c* alink = (daAlink_c*)getUserArea(); @@ -165,7 +167,11 @@ void daAlink_c::hsChainShape_c::draw() { } (void)0; - while (maxDistanceF > var_f30) { +#if TARGET_PC + int chainLinks = 0; +#endif + + while (maxDistanceF > var_f30 IF_DUSK(&&chainLinks < HS_CHAIN_MAX_LINKS)) { temp_f27 = var_f28 * cM_fsin(sp34 * var_f30); s16 spC = cM_atan2s(temp_f27 - var_f26, 5.0f); sp64.x = sp6C.x + spC; @@ -187,6 +193,10 @@ void daAlink_c::hsChainShape_c::draw() { var_f26 = temp_f27; var_f30 += fabsf(cM_scos(spC)) * 5.0f; + +#if TARGET_PC + chainLinks++; +#endif } } @@ -202,7 +212,11 @@ void daAlink_c::hsChainShape_c::draw() { sp98 = subChainTopPos; sp6C.set(maxDistance.atan2sY_XZ(), maxDistance.atan2sX_Z(), 0); - while (maxDistanceF > var_f30) { +#if TARGET_PC + int subChainLinks = 0; +#endif + + while (maxDistanceF > var_f30 IF_DUSK(&&subChainLinks < HS_CHAIN_MAX_LINKS)) { mDoMtx_stack_c::copy(j3dSys.getViewMtx()); mDoMtx_stack_c::transM(sp98); mDoMtx_stack_c::ZXYrotM(sp6C); @@ -215,11 +229,39 @@ void daAlink_c::hsChainShape_c::draw() { sp98 += maxDistance * 5.0f; ANGLE_ADD_2(sp6C.z, 0x3000); var_f30 += 5.0f; +#if TARGET_PC + subChainLinks++; +#endif } } } } +#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_arrow.cpp b/src/d/actor/d_a_arrow.cpp index 769c3273e9..c18d19e149 100644 --- a/src/d/actor/d_a_arrow.cpp +++ b/src/d/actor/d_a_arrow.cpp @@ -17,6 +17,9 @@ #include "d/actor/d_a_e_pz.h" #include "d/actor/d_a_horse.h" #include "d/actor/d_a_hozelda.h" +#if TARGET_PC +#include "dusk/achievements.h" +#endif int daArrow_c::createHeap() { J3DModelData* model_data; @@ -92,7 +95,12 @@ void daArrow_c::atHitCallBack(dCcD_GObjInf* i_atObjInf, fopAc_ac_c* i_tgActor, d if (dist_to_hitpos < field_0x998) { field_0x998 = dist_to_hitpos; mHitAcID = fopAcM_GetID(i_tgActor); - +#if TARGET_PC + if (fopAcM_GetGroup(i_tgActor) == fopAc_ENEMY_e && + current.pos.abs(mStartPos) > 10000.0f) { + dusk::AchievementSystem::get().signal("arrow_hit_100m"); + } +#endif if (mArrowType == 1) { field_0x9a8 = *hit_pos_p; } else if (i_tgObjInf->ChkTgShield()) { diff --git a/src/d/actor/d_a_b_gnd.cpp b/src/d/actor/d_a_b_gnd.cpp index cd6bce78c4..f5b03be1b1 100644 --- a/src/d/actor/d_a_b_gnd.cpp +++ b/src/d/actor/d_a_b_gnd.cpp @@ -19,6 +19,9 @@ #include "dusk/frame_interpolation.h" #include "dusk/settings.h" +#if TARGET_PC +#include "dusk/achievements.h" +#endif class daB_GND_HIO_c : public JORReflexible { public: @@ -1289,6 +1292,9 @@ static void b_gnd_g_wait(b_gnd_class* i_this) { if (i_this->mMoveMode < 5 && i_this->mPlayerDistXZ < 600.0f) { i_this->mMoveMode = 5; i_this->field_0xc44[0] = 10; +#if TARGET_PC + dusk::AchievementSystem::get().signal("ganondorf_fishing_rod"); +#endif } } else if (i_this->mMoveMode == 5) { i_this->mMoveMode = 6; diff --git a/src/d/actor/d_a_e_th.cpp b/src/d/actor/d_a_e_th.cpp index 43ad8ec19b..056e9f565e 100644 --- a/src/d/actor/d_a_e_th.cpp +++ b/src/d/actor/d_a_e_th.cpp @@ -12,6 +12,9 @@ #include "c/c_damagereaction.h" #include "f_op/f_op_actor_enemy.h" #include "f_op/f_op_camera_mng.h" +#if TARGET_PC +#include "dusk/achievements.h" +#endif class daE_TH_HIO_c : public JORReflexible { public: @@ -542,6 +545,7 @@ static void damage_check(e_th_class* i_this) { if (i_this->field_0x6a4 == 0 && i_this->mAction != ACTION_SPIN) { daPy_py_c* player = (daPy_py_c*)dComIfGp_getPlayer(0); OS_REPORT("E_th HP1 %d\n", i_this->health); + s16 prevHealth = i_this->health; cc_at_check(i_this, &i_this->mAtInfo); OS_REPORT("E_th HP2 %d\n", i_this->health); @@ -554,6 +558,11 @@ static void damage_check(e_th_class* i_this) { dComIfGs_onOneZoneSwitch(3, -1); if (i_this->health <= 0) { +#if TARGET_PC + if (prevHealth == i_this->field_0x560) { + dusk::AchievementSystem::get().signal("dark_hammer_one_hit"); + } +#endif i_this->mAction = ACTION_END; i_this->mMode = 0; i_this->field_0x68a |= 4; diff --git a/src/d/actor/d_a_e_wb.cpp b/src/d/actor/d_a_e_wb.cpp index 75031c42bb..47004737cb 100644 --- a/src/d/actor/d_a_e_wb.cpp +++ b/src/d/actor/d_a_e_wb.cpp @@ -5852,6 +5852,8 @@ static int daE_WB_Create(fopAc_ac_c* actor) { daE_WB_Execute(i_this); c_start = 0; + // Note: this flag makes king bulblin 1 instant die when set, as it only requires 2 laps + // for insta-kill to trigger. if (dComIfGs_isEventBit(dSv_event_flag_c::saveBitLabels[88])) { i_this->lap_num = 1; } 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/d_camera.cpp b/src/d/d_camera.cpp index 8e13c6271f..0adf6d4bd6 100644 --- a/src/d/d_camera.cpp +++ b/src/d/d_camera.cpp @@ -11262,6 +11262,26 @@ static int camera_execute(camera_process_class* i_this) { return 1; } +#ifdef TARGET_PC +void set_ar_corrected_trim(dDlst_window_c* window, float trim_height) { + const auto viewport = window->getViewPort(); + + if (mDoGph_gInf_c::isWideZoom()) { + const auto target_ar = FB_WIDTH / (FB_HEIGHT - trim_height * 2.0f); + const auto current_ar = mDoGph_gInf_c::m_safeWidthF / mDoGph_gInf_c::m_safeHeightF; + + if (current_ar < target_ar) { + trim_height = FB_HEIGHT / 2.0f * (1.0f - current_ar / target_ar); + } else { + trim_height = 0.0f; + } + } + + trim_height *= viewport->height / FB_HEIGHT; + window->setScissor(0.0f, trim_height, viewport->width, viewport->height - trim_height * 2.0f); +} +#endif + static int camera_draw(camera_process_class* i_this) { camera_class* a_this = (camera_class*)i_this; dCamera_c* body = &i_this->mCamera; @@ -11315,21 +11335,40 @@ static int camera_draw(camera_process_class* i_this) { #endif #if TARGET_PC - auto trim_height = body->TrimHeight(); + set_ar_corrected_trim(window, body->TrimHeight()); - if (mDoGph_gInf_c::isWideZoom()) { - const auto target_ar = FB_WIDTH / (FB_HEIGHT - trim_height * 2.0f); - const auto current_ar = mDoGph_gInf_c::m_safeWidthF / mDoGph_gInf_c::m_safeHeightF; + if (dusk::getSettings().game.enableFrameInterpolation) { + dusk::frame_interp::add_interpolation_callback([](bool _, void* pUserWork) { + const auto i_this = static_cast(pUserWork); + const auto camera = &i_this->mCamera; - if (current_ar < target_ar) { - trim_height = FB_HEIGHT / 2.0f * (1.0f - current_ar / target_ar); - } else { - trim_height = 0.0f; - } + const auto trim_size = camera->mTrimSize; + + if (camera->mCurState != 2 && trim_size >= 0 && trim_size <= 3) { + // derive trim height at previous tick using current camera state + f32 target; + switch (trim_size) { + case 0: + target = 0.0f; + break; + case 1: + target = camera->mCamSetup.VistaTrimHeight(); + break; + case 2: + case 3: + target = camera->mCamSetup.CinemaScopeTrimHeight(); + break; + } + + const auto step = dusk::frame_interp::get_interpolation_step(); + const auto cur = camera->TrimHeight(); + const auto prev = (4.0f * cur - target) / 3.0f; + const auto trim_height = prev + (cur - prev) * step; + + set_ar_corrected_trim(get_window((camera_class*)i_this), trim_height); + } + }, i_this); } - - trim_height *= viewport->height / FB_HEIGHT; - window->setScissor(0.0f, trim_height, viewport->width, viewport->height - trim_height * 2.0f); #else int trim_height = body->TrimHeight(); diff --git a/src/d/d_cc_uty.cpp b/src/d/d_cc_uty.cpp index 7d71e490ac..ec9a177683 100644 --- a/src/d/d_cc_uty.cpp +++ b/src/d/d_cc_uty.cpp @@ -13,6 +13,9 @@ #include "d/d_s_play.h" #include "d/d_com_inf_game.h" #include "f_op/f_op_actor_mng.h" +#if TARGET_PC +#include "dusk/achievements.h" +#endif static int plCutLRC[58] = { 0, // @@ -434,6 +437,11 @@ fopAc_ac_c* cc_at_check(fopAc_ac_c* i_enemy, dCcU_AtInfo* i_AtInfo) { if (i_AtInfo->mAttackPower != 0 && i_enemy->health <= 0) { i_AtInfo->mHitStatus = 2; i_enemy->health = 0; +#if TARGET_PC + if (fopAcM_GetGroup(i_enemy) == fopAc_ENEMY_e) { + dusk::AchievementSystem::get().signal("enemy_killed"); + } +#endif } int uvar8; 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/dusk/achievements.cpp b/src/dusk/achievements.cpp index a090a40969..cac6f776b6 100644 --- a/src/dusk/achievements.cpp +++ b/src/dusk/achievements.cpp @@ -1,13 +1,21 @@ #include "dusk/achievements.h" -#include "d/actor/d_a_alink.h" -#include "d/actor/d_a_npc4.h" -#include "d/actor/d_a_player.h" -#include "d/d_com_inf_game.h" -#include "d/d_demo.h" -#include "d/d_meter2_info.h" #include "dusk/io.hpp" #include "dusk/main.h" +#include "d/d_com_inf_game.h" +#include "d/d_item_data.h" +#include "d/d_map_path_fmap.h" +#include "d/d_stage.h" +#include "d/d_menu_fmap.h" +#include "JSystem/JKernel/JKRArchive.h" +#include "d/d_meter2_info.h" +#include "d/actor/d_a_alink.h" +#include "d/actor/d_a_ni.h" +#include "d/actor/d_a_npc4.h" +#include "d/actor/d_a_b_ob.h" +#include "d/actor/d_a_player.h" +#include "d/d_demo.h" #include "dusk/ui/ui.hpp" +#include "f_pc/f_pc_name.h" #include "f_op/f_op_actor_mng.h" #include "f_pc/f_pc_name.h" @@ -18,6 +26,14 @@ namespace dusk { using json = nlohmann::json; +static void* s_cucco_play_search(void* i_actor, void*) { + if (!fopAcM_IsActor(i_actor) || fopAcM_GetName((fopAc_ac_c*)i_actor) != fpcNm_NI_e) { + return nullptr; + } + auto* ni = static_cast(i_actor); + return ni->mAction == ACTION_PLAY_e ? i_actor : nullptr; +} + static void checkGoatHerding(Achievement& a, int32_t threshMs) { if (dMeter2Info_getMaxCount() != 20 || dMeter2Info_getNowCount() != 20) { return; @@ -32,12 +48,13 @@ static constexpr auto ACHIEVEMENTS_FILENAME = "achievements.json"; std::vector AchievementSystem::makeEntries() { return { + // Challenge { { "hero_of_twilight", "Hero of Twilight", "Deliver the finishing blow to Ganondorf.", - AchievementCategory::Story, + AchievementCategory::Challenge, false, 0, 0, false }, [](Achievement& a, json&) { @@ -48,6 +65,445 @@ std::vector AchievementSystem::makeEntries() { }, {} }, + { + { + "completionist", + "Completionist", + "Complete the game after collecting all equipment, heart containers, portals, bugs, poes, and hidden skills.", + AchievementCategory::Challenge, + false, 0, 0, false + }, + [](Achievement& a, json&) { + const auto* link = static_cast(daPy_getPlayerActorClass()); + if (link == nullptr || link->mProcID != daAlink_c::PROC_GANON_FINISH) { + return; + } + if (dComIfGs_getMaxLife() < 100) { + return; + } + for (int i = 0; i < 4; ++i) { + if (!dComIfGs_isCollectMirror(i)) { + return; + } + } + for (int i = 0; i < 3; ++i) { + if (!dComIfGs_isCollectCrystal(i)) { + return; + } + } + static const u16 skillBits[] = { + dSv_event_flag_c::F_0338, dSv_event_flag_c::F_0339, + dSv_event_flag_c::F_0340, dSv_event_flag_c::F_0341, + dSv_event_flag_c::F_0342, dSv_event_flag_c::F_0343, + dSv_event_flag_c::F_0344 + }; + for (u16 bit : skillBits) { + if (!dComIfGs_isEventBit(bit)) { + return; + } + } + if (dComIfGs_checkGetInsectNum() < 24) { + return; + } + if (dComIfGs_getPohSpiritNum() < 60) { + return; + } + if (dComIfGs_getWalletSize() < 2) { + return; + } + if (dComIfGs_getArrowMax() < 100) { + return; + } + if (!dComIfGs_isCollectSword(COLLECT_MASTER_SWORD)) { + return; + } + if (!dComIfGs_isCollectShield(COLLECT_HYLIAN_SHIELD)) { + return; + } + if (!dComIfGs_isCollectClothes(KOKIRI_CLOTHES_FLAG)) { + return; + } + if (!dComIfGs_isItemFirstBit(dItemNo_WEAR_ZORA_e)) { + return; + } + if (!dComIfGs_isItemFirstBit(dItemNo_ARMOR_e)) { + return; + } + static const struct { int stage; int sw; } warpPortals[] = { + { dStage_SaveTbl_ORDON, 52 }, // Ordon Spring + { dStage_SaveTbl_FARON, 71 }, // South Faron Woods + { dStage_SaveTbl_FARON, 2 }, // North Faron Woods + { dStage_SaveTbl_GROVE, 100 }, // Sacred Grove + { dStage_SaveTbl_FIELD, 21 }, // Gorge + { dStage_SaveTbl_ELDIN, 31 }, // Kakariko Village + { dStage_SaveTbl_ELDIN, 21 }, // Death Mountain + { dStage_SaveTbl_FIELD, 99 }, // Bridge of Eldin + { dStage_SaveTbl_FIELD, 3 }, // Castle Town + { dStage_SaveTbl_LANAYRU, 10 }, // Lake Hylia + { dStage_SaveTbl_LANAYRU, 2 }, // Zora's Domain + { dStage_SaveTbl_LANAYRU, 21 }, // Upper Zora's River + { dStage_SaveTbl_SNOWPEAK, 21 }, // Snowpeak + { dStage_SaveTbl_DESERT, 21 }, // Gerudo Mesa + { dStage_SaveTbl_DESERT, 40 }, // Mirror Chamber + }; + for (const auto& p : warpPortals) { + if (!g_dComIfG_gameInfo.info.getSavedata().getSave(p.stage).getBit().isSwitch(p.sw)) { + return; + } + } + if (dComIfGs_getCollectSmell() == dItemNo_NONE_e) { + return; + } + + if (dMeter2Info_getRecieveLetterNum() == 0) { + return; + } + + bool hasJournal = false; + for (int fi = 0; fi < 6; ++fi) { + if (dComIfGs_getFishNum(fi) != 0) { + hasJournal = true; + break; + } + } + if (!hasJournal) { + return; + } + + int bottleCount = 0; + for (int i = 0; i < dSv_player_item_c::BOTTLE_MAX; ++i) { + if (dComIfGs_getItem(SLOT_11 + i, false) != dItemNo_NONE_e) { + bottleCount++; + } + } + if (bottleCount < 4) { + return; + } + + int bombBagCount = 0; + for (int i = 0; i < dSv_player_item_c::BOMB_BAG_MAX; ++i) { + if (dComIfGs_getItem(SLOT_15 + i, false) != dItemNo_NONE_e) { + bombBagCount++; + } + } + if (bombBagCount < 3) { + return; + } + + bool hasJewelRod = false; + for (int slot = 0; slot < 24 && !hasJewelRod; ++slot) { + const u8 item = dComIfGs_getItem(slot, false); + if (item == dItemNo_JEWEL_ROD_e || item == dItemNo_JEWEL_BEE_ROD_e || item == dItemNo_JEWEL_WORM_ROD_e) { + hasJewelRod = true; + } + } + if (!hasJewelRod) { + return; + } + + static const u8 requiredWheelItems[] = { + dItemNo_BOOMERANG_e, + dItemNo_BOW_e, + dItemNo_W_HOOKSHOT_e, + dItemNo_SPINNER_e, + dItemNo_IRONBALL_e, + dItemNo_COPY_ROD_e, + dItemNo_HVY_BOOTS_e, + dItemNo_KANTERA_e, + dItemNo_PACHINKO_e, + dItemNo_HAWK_EYE_e, + dItemNo_ANCIENT_DOCUMENT_e, + dItemNo_HORSE_FLUTE_e, + }; + for (u8 required : requiredWheelItems) { + bool found = false; + for (int slot = 0; slot < 24; ++slot) { + if (dComIfGs_getItem(slot, false) == required) { + found = true; + break; + } + } + if (!found) { + return; + } + } + a.progress = 1; + }, + {} + }, + // Collection + { + { + "princess_of_bugs", + "The Princess of Bugs", + "Deliver all 24 golden bugs to Agitha.", + AchievementCategory::Collection, + true, 24, 0, false + }, + [](Achievement& a, json&) { + a.progress = dComIfGs_checkGetInsectNum(); + }, + {} + }, + { + { + "all_poes", + "Poe Collector", + "Collect all 60 Poe Souls.", + AchievementCategory::Collection, + true, 60, 0, false + }, + [](Achievement& a, json&) { + a.progress = dComIfGs_getPohSpiritNum(); + }, + {} + }, + { + { + "hylian_loach", + "Legendary Catch", + "Catch a Hylian Loach.", + AchievementCategory::Collection, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (dComIfGs_getFishNum(1) > 0) { + a.progress = 1; + } + }, + {} + }, + { + { + "all_fish", + "Gone Fishin'", + "Catch all 6 species of fish.", + AchievementCategory::Collection, + true, 6, 0, false + }, + [](Achievement& a, json&) { + int nUniqueFish = 0; + for (int i = 0; i < 6; ++i) { + if (dComIfGs_getFishNum(i) != 0) { + nUniqueFish++; + } + } + a.progress = nUniqueFish; + }, + {} + }, + { + { + "a_big_heart", + "A Big Heart", + "Reach maximum health with all 20 heart containers.", + AchievementCategory::Collection, + true, 20, 0, false + }, + [](Achievement& a, json&) { + a.progress = dComIfGs_getMaxLife() / 5; + }, + {} + }, + { + { + "all_bottles", + "Glassware Guardian", + "Obtain all 4 bottles.", + AchievementCategory::Collection, + true, 4, 0, false + }, + [](Achievement& a, json&) { + if (daPy_getPlayerActorClass() == nullptr) { + return; + } + int count = 0; + for (int i = 0; i < dSv_player_item_c::BOTTLE_MAX; ++i) { + if (dComIfGs_getItem(SLOT_11 + i, false) != dItemNo_NONE_e) { + count++; + } + } + a.progress = count; + }, + {} + }, + { + { + "all_hidden_skills", + "Master of Secrets", + "Learn all 7 Hidden Skills.", + AchievementCategory::Collection, + true, 7, 0, false + }, + [](Achievement& a, json&) { + static const u16 skillBits[] = { + dSv_event_flag_c::F_0338, dSv_event_flag_c::F_0339, + dSv_event_flag_c::F_0340, dSv_event_flag_c::F_0341, + dSv_event_flag_c::F_0342, dSv_event_flag_c::F_0343, + dSv_event_flag_c::F_0344 + }; + int count = 0; + for (u16 bit : skillBits) { + if (dComIfGs_isEventBit(bit)) { + count++; + } + } + a.progress = count; + }, + {} + }, + { + { + "all_letters", + "We Deliver!", + "Collect all 16 postman letters.", + AchievementCategory::Collection, + true, 16, 0, false + }, + [](Achievement& a, json&) { + a.progress = dMeter2Info_getRecieveLetterNum(); + }, + {} + }, + { + { + "cave_of_ordeals", + "Conqueror of Ordeals", + "Clear all 50 floors of the Cave of Ordeals.", + AchievementCategory::Challenge, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (daNpcF_chkEvtBit(0x1F9)) { + a.progress = 1; + } + }, + {} + }, + { + { + "cave_of_ordeals_heartless", + "Indomitable", + "Clear all 50 floors of the Cave of Ordeals with only 3 heart containers.", + AchievementCategory::Challenge, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (daNpcF_chkEvtBit(0x1F9) && dComIfGs_getMaxLife() <= 15) { + a.progress = 1; + } + }, + {} + }, + { + { + "speedrun_12h", + "Been There Done That", + "Defeat Ganondorf with a total save file play time under 12 hours.", + AchievementCategory::Challenge, + false, 0, 0, false + }, + [](Achievement& a, json&) { + const auto* link = static_cast(daPy_getPlayerActorClass()); + if (link == nullptr || link->mProcID != daAlink_c::PROC_GANON_FINISH) { + return; + } + const int64_t ticks = (static_cast(OSGetTime()) - dComIfGs_getSaveStartTime()) + dComIfGs_getSaveTotalTime(); + if (ticks / OS_TIMER_CLOCK < 12 * 3600) { + a.progress = 1; + } + }, + {} + }, + { + { + "speedrun_8h", + "Swift Blade", + "Defeat Ganondorf with a total save file play time under 6 hours.", + AchievementCategory::Challenge, + false, 0, 0, false + }, + [](Achievement& a, json&) { + const auto* link = static_cast(daPy_getPlayerActorClass()); + if (link == nullptr || link->mProcID != daAlink_c::PROC_GANON_FINISH) { + return; + } + const int64_t ticks = (static_cast(OSGetTime()) - dComIfGs_getSaveStartTime()) + dComIfGs_getSaveTotalTime(); + if (ticks / OS_TIMER_CLOCK < 8 * 3600) { + a.progress = 1; + } + }, + {} + }, + { + { + "dark_hammer_one_hit", + "Mortal Edge", + "Defeat Dark Hammer in a single hit.", + AchievementCategory::Misc, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (AchievementSystem::get().hasSignal("dark_hammer_one_hit")) { + a.progress = 1; + } + }, + {} + }, + { + { + "no_deaths_clear", + "Deathless", + "Defeat Ganondorf with 0 deaths on your save file.", + AchievementCategory::Challenge, + false, 0, 0, false + }, + [](Achievement& a, json&) { + const auto* link = static_cast(daPy_getPlayerActorClass()); + if (link == nullptr || link->mProcID != daAlink_c::PROC_GANON_FINISH) { + return; + } + if (dComIfGs_getDeathCount() == 0) { + a.progress = 1; + } + }, + {} + }, + { + { + "untouchable", + "Untouchable", + "Kill 25 enemies in a row without taking damage.", + AchievementCategory::Challenge, + true, 25, 0, false + }, + [](Achievement& a, json&) { + auto& sys = AchievementSystem::get(); + if (sys.hasSignal("player_damaged")) { + a.progress = 0; + } + if (sys.hasSignal("enemy_killed")) { + a.progress++; + } + }, + {} + }, + { + { + "bow_100m_hit", + "Long Shot", + "Hit an enemy from over 100 meters away with the bow.", + AchievementCategory::Misc, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (AchievementSystem::get().hasSignal("arrow_hit_100m")) { + a.progress = 1; + } + }, + {} + }, + // Minigame { { "plumm_max", @@ -134,14 +590,16 @@ std::vector AchievementSystem::makeEntries() { }, { { - "cave_of_ordeals", - "Conqueror of Ordeals", - "Clear all 50 floors of the Cave of Ordeals.", - AchievementCategory::Challenge, + "snowboard_70s", + "Downhill Dash", + "Finish the snowboarding minigame in under 70 seconds.", + AchievementCategory::Minigame, false, 0, 0, false }, [](Achievement& a, json&) { - if (daNpcF_chkEvtBit(0x1F9)) { + const int32_t bestMs = dComIfGs_getRaceGameTime(); + if (dComIfGs_isEventBit(dSv_event_flag_c::F_0481) && + bestMs > 0 && bestMs <= 70000) { a.progress = 1; } }, @@ -149,54 +607,37 @@ std::vector AchievementSystem::makeEntries() { }, { { - "cave_of_ordeals_heartless", - "Indomitable", - "Clear all 50 floors of the Cave of Ordeals with only 3 heart containers.", - AchievementCategory::Challenge, - false, 0, 0, false - }, - [](Achievement& a, json&) { - if (daNpcF_chkEvtBit(0x1F9) && dComIfGs_getMaxLife() <= 15) { - a.progress = 1; - } - }, - {} - }, - { - { - "speedrun_12h", - "Been There Done That", - "Defeat Ganondorf with a total save file play time under 12 hours.", - AchievementCategory::Challenge, + "canoe_perfect", + "River Raider", + "Achieve a perfect score in the canoe minigame.", + AchievementCategory::Minigame, false, 0, 0, false }, [](Achievement& a, json&) { const auto* link = static_cast(daPy_getPlayerActorClass()); - if (link == nullptr || link->mProcID != daAlink_c::PROC_GANON_FINISH) { + if (link == nullptr) { return; } - const int64_t ticks = (static_cast(OSGetTime()) - dComIfGs_getSaveStartTime()) + dComIfGs_getSaveTotalTime(); - if (ticks / OS_TIMER_CLOCK < 12 * 3600) { + static bool wasInCanoe = false; + bool inCanoe = link->mProcID >= daAlink_c::PROC_CANOE_RIDE && + link->mProcID <= daAlink_c::PROC_CANOE_KANDELAAR_POUR; + if (wasInCanoe && !inCanoe && dMeter2Info_getNowCount() >= 30) { a.progress = 1; } + wasInCanoe = inCanoe; }, {} }, { { - "speedrun_8h", - "Swift Blade", - "Defeat Ganondorf with a total save file play time under 6 hours.", - AchievementCategory::Challenge, + "star_2_under_40s", + "Rising Star", + "Complete the STAR Prize 2 minigame in under 40 seconds.", + AchievementCategory::Minigame, false, 0, 0, false }, [](Achievement& a, json&) { - const auto* link = static_cast(daPy_getPlayerActorClass()); - if (link == nullptr || link->mProcID != daAlink_c::PROC_GANON_FINISH) { - return; - } - const int64_t ticks = (static_cast(OSGetTime()) - dComIfGs_getSaveStartTime()) + dComIfGs_getSaveTotalTime(); - if (ticks / OS_TIMER_CLOCK < 8 * 3600) { + if(dComIfGs_getHookGameTime() > 0 && dComIfGs_getHookGameTime() <= 40000) { a.progress = 1; } }, @@ -204,77 +645,20 @@ std::vector AchievementSystem::makeEntries() { }, { { - "princess_of_bugs", - "The Princess of Bugs", - "Deliver all 24 golden bugs to Agitha.", - AchievementCategory::Collection, - true, 24, 0, false - }, - [](Achievement& a, json&) { - a.progress = dComIfGs_checkGetInsectNum(); - }, - {} - }, - { - { - "all_poes", - "Poe Collector", - "Collect all 60 Poe Souls.", - AchievementCategory::Collection, - true, 60, 0, false - }, - [](Achievement& a, json&) { - a.progress = dComIfGs_getPohSpiritNum(); - }, - {} - }, - { - { - "hylian_loach", - "Legendary Catch", - "Catch a Hylian Loach.", - AchievementCategory::Collection, + "star_2_under_30s", + "Shooting Star", + "Complete the STAR Prize 2 minigame in under 30 seconds.", + AchievementCategory::Minigame, false, 0, 0, false }, [](Achievement& a, json&) { - if (dComIfGs_getFishNum(1) > 0) { + if(dComIfGs_getHookGameTime() > 0 && dComIfGs_getHookGameTime() <= 30000) { a.progress = 1; } }, {} }, - { - { - "all_fish", - "Gone Fishin'", - "Catch all 6 species of fish.", - AchievementCategory::Collection, - true, 6, 0, false - }, - [](Achievement& a, json&) { - int nUniqueFish = 0; - for (int i = 0; i < 6; ++i) { - if (dComIfGs_getFishNum(i) != 0) { - nUniqueFish++; - } - } - a.progress = nUniqueFish; - }, - {} - }, - { - { - "a_big_heart", - "A Big Heart", - "Reach maximum health with all 20 heart containers.", - AchievementCategory::Collection, - true, 20, 0, false - }, - [](Achievement& a, json&) { - a.progress = dComIfGs_getMaxLife() / 5; - }, - {} - }, + // Misc { { "friendly_fire", @@ -327,6 +711,87 @@ std::vector AchievementSystem::makeEntries() { }, {} }, + { + { + "email_me", + "Email Me", + "Read a letter during the Dark Beast Ganon fight.", + AchievementCategory::Misc, + false, 0, 0, false + }, + [](Achievement& a, json&) { + void* dbgExists = fopAcM_SearchByName(fpcNm_B_MGN_e); + if (dbgExists && AchievementSystem::get().hasSignal("open_letter")) { + a.progress = 1; + } + }, + {} + }, + { + { + "heavy_hitter", + "Heavy Hitter", + "Wear the Iron Boots during the end credits.", + AchievementCategory::Misc, + false, 0, 0, false + }, + [](Achievement& a, json&) { + const auto* link = static_cast(daPy_getPlayerActorClass()); + if (link == nullptr || link->mProcID != daAlink_c::PROC_GANON_FINISH) { + return; + } + if (daPy_getPlayerActorClass()->checkEquipHeavyBoots()) { + a.progress = 1; + } + }, + {} + }, + { + { + "fishing_rod_ganondorf", + "Here Fishy Fishy", + "Confuse Ganondorf with the fishing rod.", + AchievementCategory::Misc, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (AchievementSystem::get().hasSignal("ganondorf_fishing_rod")) { + a.progress = 1; + } + }, + {} + }, + { + { + "steal_from_trill", + "Petty Theft", + "Steal from Trill.", + AchievementCategory::Misc, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (dComIfGs_isEventBit(dSv_event_flag_c::F_0758)) { + a.progress = 1; + } + }, + {} + }, + { + { + "cucco_control", + "Cucco Whisperer", + "Take control of a cucco.", + AchievementCategory::Misc, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (fopAcM_Search(s_cucco_play_search, nullptr) != nullptr) { + a.progress = 1; + } + }, + {} + }, + // Glitched { { "back_in_time", @@ -377,19 +842,6 @@ std::vector AchievementSystem::makeEntries() { }, {} }, - { - { - "ultimate_delivery", - "The Ultimate Delivery", - "Have all 16 postman letters at the same time.", - AchievementCategory::Glitched, - true, 16, 0, false - }, - [](Achievement& a, json&) { - a.progress = dMeter2Info_getRecieveLetterNum(); - }, - {} - }, { { "speedrun_4h", @@ -412,15 +864,85 @@ std::vector AchievementSystem::makeEntries() { }, { { - "email_me", - "Email Me", - "Read a letter during the Dark Beast Ganon fight.", - AchievementCategory::Misc, + "no_fish_suit", + "No Fish Suit No Problem", + "Defeat Morpheel without equipping Zora Armor.", + AchievementCategory::Glitched, false, 0, 0, false }, [](Achievement& a, json&) { - void* dbgExists = fopAcM_SearchByName(fpcNm_B_MGN_e); - if (dbgExists && AchievementSystem::get().hasSignal("open_letter")) { + static bool prevMorpheelAlive = false; + static bool inArena = false; + static bool zoraWorn = false; + const auto* morpheel = static_cast(fopAcM_SearchByName(fpcNm_B_OB_e)); + const bool morpheelAlive = morpheel != nullptr && morpheel->mAnmID != 0x14; + const bool morpheelDead = morpheel != nullptr && morpheel->mAnmID == 0x14; + const bool lakebedCleared = dComIfGs_isEventBit(dSv_event_flag_c::M_045); + + if (inArena && morpheel == nullptr) { + zoraWorn = false; + } + + if (morpheelAlive && !lakebedCleared) { + inArena = true; + if (daPy_py_c::checkZoraWearFlg()) { + zoraWorn = true; + } + } + + if (prevMorpheelAlive && morpheelDead && inArena && !zoraWorn) { + a.progress = 1; + } + + prevMorpheelAlive = morpheelAlive; + }, + {} + }, + { + { + "null_item", + "Null Item", + "Obtain the mysterious black rupee in the item wheel.", + AchievementCategory::Glitched, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (daPy_getPlayerActorClass() == nullptr) { + return; + } + for (int i = 0; i < 24; ++i) { + if (dComIfGs_getItem(i, false) == 0x00) { + a.progress = 1; + break; + } + } + }, + {} + }, + { + { + "stallord_skip", + "Stallord Skip", + "Leave Stallord's arena through the exit without defeating Stallord.", + AchievementCategory::Glitched, + false, 0, 0, false + }, + [](Achievement& a, json&) { + static bool seenStallord = false; + if (strcmp(dComIfGp_getStartStageName(), "D_MN10A") != 0) { + seenStallord = false; + return; + } + if (dComIfGs_isEventBit(dSv_event_flag_c::F_0265)) { + seenStallord = false; + return; + } + if (fopAcM_SearchByName(fpcNm_B_DS_e) != nullptr) { + seenStallord = true; + } + if (seenStallord && + dComIfGp_isEnableNextStage() && + strcmp(dComIfGp_getNextStageName(), "F_SP125") == 0) { a.progress = 1; } }, @@ -428,22 +950,59 @@ std::vector AchievementSystem::makeEntries() { }, { { - "heavy-hitter", - "Heavy Hitter", - "Wear the Iron Boots during the end credits.", - AchievementCategory::Misc, + "lakebed_before_lanayru", + "White Midna Glitch", + "Clear the Lakebed Temple before clearing Lanayru's Twilight.", + AchievementCategory::Glitched, false, 0, 0, false }, [](Achievement& a, json&) { - const auto* link = static_cast(daPy_getPlayerActorClass()); - if (link == nullptr || link->mProcID != daAlink_c::PROC_GANON_FINISH) { - return; - } - if (daPy_getPlayerActorClass()->checkEquipHeavyBoots()) { + if (dComIfGs_isEventBit(dSv_event_flag_c::M_045) && + !dComIfGs_isDarkClearLV(2)) { a.progress = 1; } }, {} + }, + { + { + "early_hidden_village", + "Quick Detour", + "Rescue the Hidden Village before clearing Goron Mines.", + AchievementCategory::Glitched, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (dComIfGs_isEventBit(dSv_event_flag_c::F_0278) && + !dComIfGs_isEventBit(dSv_event_flag_c::M_031)) { + a.progress = 1; + } + }, + {} + }, + { + { + "forest_temple_no_boomerang", + "Must Have Been The Wind", + "Complete the Forest Temple without obtaining the Gale Boomerang.", + AchievementCategory::Glitched, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (!dComIfGs_isEventBit(dSv_event_flag_c::M_022)) { + return; + } + if (daPy_getPlayerActorClass() == nullptr) { + return; + } + for (int i = 0; i < 24; ++i) { + if (dComIfGs_getItem(i, false) == dItemNo_BOOMERANG_e) { + return; + } + } + a.progress = 1; + }, + {} } }; } @@ -554,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..940797c9ce 100644 --- a/src/dusk/config.cpp +++ b/src/dusk/config.cpp @@ -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) { diff --git a/src/dusk/file_select.cpp b/src/dusk/file_select.cpp index fcda233c56..188d491bf3 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; @@ -88,4 +181,16 @@ void ShowFileSelect(FileCallback callback, void* userdata, SDL_Window* window, default_location, allow_many); #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); +} } // namespace dusk diff --git a/src/dusk/file_select.hpp b/src/dusk/file_select.hpp index 175c5aa086..8bef51cfe6 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,7 +12,9 @@ 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); } // namespace dusk diff --git a/src/dusk/gyro.cpp b/src/dusk/gyro.cpp index abe22909c1..4011cbb721 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.gyroSensitivityX; + s_yaw_rad = -mx_rel * kMousePixelToRad * getSettings().game.gyroSensitivityY; + 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.gyroSensitivityX; + 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.gyroSensitivityY; - 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..7debcc7ce2 --- /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, "com.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)" + "Lcom/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/curl.cpp b/src/dusk/http/curl.cpp new file mode 100644 index 0000000000..5fdaf88a64 --- /dev/null +++ b/src/dusk/http/curl.cpp @@ -0,0 +1,206 @@ +#include "http.hpp" + +#include + +#include +#include +#include +#include + +namespace dusk::http { +namespace { + +struct CurlHeaders { + curl_slist* list = nullptr; + + ~CurlHeaders() { + if (list != nullptr) { + curl_slist_free_all(list); + } + } + + bool append(const std::string& header) { + curl_slist* next = curl_slist_append(list, header.c_str()); + if (next == nullptr) { + return false; + } + list = next; + return true; + } +}; + +struct CurlContext { + Response response; + size_t maxBodyBytes = 0; + bool tooLarge = false; +}; + +void initialize_curl() { + curl_global_init(CURL_GLOBAL_DEFAULT); +} + +std::string trim_header_value(std::string_view value) { + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { + value.remove_prefix(1); + } + while (!value.empty() && + (value.back() == '\r' || value.back() == '\n' || value.back() == ' ' || + value.back() == '\t')) { + value.remove_suffix(1); + } + return std::string(value); +} + +size_t write_body(char* ptr, size_t size, size_t nmemb, void* userdata) { + auto* context = static_cast(userdata); + const size_t bytes = size * nmemb; + if (bytes > context->maxBodyBytes || + context->response.body.size() > context->maxBodyBytes - bytes) { + context->tooLarge = true; + return 0; + } + + context->response.body.append(ptr, bytes); + return bytes; +} + +size_t write_header(char* ptr, size_t size, size_t nmemb, void* userdata) { + auto* context = static_cast(userdata); + const std::string_view line(ptr, size * nmemb); + if (line.starts_with("HTTP/")) { + context->response.headers.clear(); + return size * nmemb; + } + + const size_t colon = line.find(':'); + if (colon == std::string_view::npos) { + return size * nmemb; + } + + context->response.headers.push_back({ + .name = std::string(line.substr(0, colon)), + .value = trim_header_value(line.substr(colon + 1)), + }); + return size * nmemb; +} + +Error map_curl_error(CURLcode code, bool tooLarge) { + if (tooLarge) { + return Error::TooLarge; + } + + switch (code) { + case CURLE_OK: + return Error::None; + case CURLE_URL_MALFORMAT: + return Error::InvalidUrl; + case CURLE_UNSUPPORTED_PROTOCOL: + return Error::UnsupportedScheme; + case CURLE_OPERATION_TIMEDOUT: + return Error::Timeout; + default: + return Error::Network; + } +} + +long timeout_ms(std::chrono::milliseconds timeout) { + return std::max(1, timeout.count()); +} + +} // namespace + +bool available() noexcept { + return true; +} + +Backend backend() noexcept { + return Backend::LibCurl; +} + +const char* backend_name() noexcept { + return "libcurl"; +} + +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", + }; + } + + static std::once_flag initFlag; + std::call_once(initFlag, initialize_curl); + + CURL* curl = curl_easy_init(); + if (curl == nullptr) { + return { + .error = Error::Network, + .message = "Failed to create libcurl request", + }; + } + + CurlHeaders headers; + for (const Header& header : request.headers) { + if (!headers.append(header.name + ": " + header.value)) { + curl_easy_cleanup(curl); + return { + .error = Error::Network, + .message = "Failed to allocate libcurl headers", + }; + } + } + + CurlContext context{ + .maxBodyBytes = request.maxBodyBytes, + }; + + curl_easy_setopt(curl, CURLOPT_URL, request.url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers.list); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms(request.timeout)); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms(request.timeout)); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_body); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &context); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, write_header); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &context); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); +#if CURL_AT_LEAST_VERSION(7, 85, 0) + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); +#else + curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS); +#endif + + const CURLcode code = curl_easy_perform(curl); + long statusCode = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &statusCode); + curl_easy_cleanup(curl); + + context.response.statusCode = static_cast(statusCode); + if (code == CURLE_OK) { + return { + .response = std::move(context.response), + }; + } + + const Error error = map_curl_error(code, context.tooLarge); + return { + .error = error, + .message = error == Error::TooLarge ? "Response body exceeded the configured limit" + : curl_easy_strerror(code), + .response = std::move(context.response), + }; +} + +} // namespace dusk::http diff --git a/src/dusk/http/http.hpp b/src/dusk/http/http.hpp new file mode 100644 index 0000000000..54bef6eec1 --- /dev/null +++ b/src/dusk/http/http.hpp @@ -0,0 +1,60 @@ +#ifndef DUSK_HTTP_HTTP_HPP +#define DUSK_HTTP_HTTP_HPP + +#include +#include +#include +#include + +namespace dusk::http { + +enum class Backend { + None, + WinHttp, + UrlSession, + LibCurl, + Android, +}; + +enum class Error { + None, + NoBackend, + InvalidUrl, + UnsupportedScheme, + Timeout, + TooLarge, + Network, +}; + +struct Header { + std::string name; + std::string value; +}; + +struct Request { + std::string url; + std::vector
headers; + std::chrono::milliseconds timeout{10000}; + size_t maxBodyBytes = 1024 * 1024; +}; + +struct Response { + int statusCode = 0; + std::vector
headers; + std::string body; +}; + +struct Result { + Error error = Error::None; + std::string message; + Response response; +}; + +bool available() noexcept; +Backend backend() noexcept; +const char* backend_name() noexcept; +Result get(const Request& request); + +} // namespace dusk::http + +#endif // DUSK_HTTP_HTTP_HPP diff --git a/src/dusk/http/no_backend.cpp b/src/dusk/http/no_backend.cpp new file mode 100644 index 0000000000..4b42afb3c7 --- /dev/null +++ b/src/dusk/http/no_backend.cpp @@ -0,0 +1,24 @@ +#include "http.hpp" + +namespace dusk::http { + +bool available() noexcept { + return false; +} + +Backend backend() noexcept { + return Backend::None; +} + +const char* backend_name() noexcept { + return "none"; +} + +Result get(const Request&) { + return { + .error = Error::NoBackend, + .message = "No HTTP backend is available", + }; +} + +} // namespace dusk::http diff --git a/src/dusk/http/url_session.mm b/src/dusk/http/url_session.mm new file mode 100644 index 0000000000..01dd699a9c --- /dev/null +++ b/src/dusk/http/url_session.mm @@ -0,0 +1,238 @@ +#include "http.hpp" + +#import + +#include +#include +#include + +@interface DuskHttpRequestDelegate : NSObject +@property(nonatomic) dispatch_semaphore_t semaphore; +@property(nonatomic) size_t maxBodyBytes; +@property(nonatomic, strong) NSMutableData* data; +@property(nonatomic, strong) NSURLResponse* response; +@property(nonatomic, strong) NSError* error; +@property(nonatomic) BOOL tooLarge; +- (instancetype)initWithMaxBodyBytes:(size_t)maxBodyBytes; +@end + +@implementation DuskHttpRequestDelegate + +- (instancetype)initWithMaxBodyBytes:(size_t)maxBodyBytes { + self = [super init]; + if (self != nil) { + _semaphore = dispatch_semaphore_create(0); + _maxBodyBytes = maxBodyBytes; + _data = [NSMutableData data]; + } + return self; +} + +- (void)URLSession:(NSURLSession*)session + task:(NSURLSessionTask*)task + willPerformHTTPRedirection:(NSHTTPURLResponse*)response + newRequest:(NSURLRequest*)request + completionHandler:(void (^)(NSURLRequest*))completionHandler { + if ([[request.URL.scheme lowercaseString] isEqualToString:@"https"]) { + completionHandler(request); + } else { + completionHandler(nil); + } +} + +- (void)URLSession:(NSURLSession*)session + dataTask:(NSURLSessionDataTask*)dataTask +didReceiveResponse:(NSURLResponse*)response + completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler { + self.response = response; + completionHandler(NSURLSessionResponseAllow); +} + +- (void)URLSession:(NSURLSession*)session + dataTask:(NSURLSessionDataTask*)dataTask + didReceiveData:(NSData*)data { + if (data.length > self.maxBodyBytes || + self.data.length > self.maxBodyBytes - data.length) { + self.tooLarge = YES; + [dataTask cancel]; + return; + } + [self.data appendData:data]; +} + +- (void)URLSession:(NSURLSession*)session + task:(NSURLSessionTask*)task + didCompleteWithError:(NSError*)error { + if (error != nil && !self.tooLarge) { + self.error = error; + } + dispatch_semaphore_signal(self.semaphore); +} + +@end + +namespace dusk::http { +namespace { + +NSString* to_nsstring(std::string_view value) { + return [[NSString alloc] initWithBytes:value.data() + length:value.size() + encoding:NSUTF8StringEncoding]; +} + +std::string to_string(NSString* value) { + if (value == nil) { + return {}; + } + + const char* utf8 = [value UTF8String]; + return utf8 == nullptr ? std::string() : std::string(utf8); +} + +Error map_nsurl_error(NSError* error) { + if (error == nil || ![error.domain isEqualToString:NSURLErrorDomain]) { + return Error::Network; + } + + switch (error.code) { + case NSURLErrorTimedOut: + return Error::Timeout; + case NSURLErrorBadURL: + case NSURLErrorUnsupportedURL: + return Error::InvalidUrl; + default: + return Error::Network; + } +} + +dispatch_time_t timeout_deadline(std::chrono::milliseconds timeout) { + const auto milliseconds = std::max(1, timeout.count()); + return dispatch_time(DISPATCH_TIME_NOW, + static_cast(milliseconds) * static_cast(NSEC_PER_MSEC)); +} + +} // namespace + +bool available() noexcept { + return true; +} + +Backend backend() noexcept { + return Backend::UrlSession; +} + +const char* backend_name() noexcept { + return "NSURLSession"; +} + +Result get(const Request& request) { + @autoreleasepool { + 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", + }; + } + + NSString* urlString = to_nsstring(request.url); + if (urlString == nil) { + return { + .error = Error::InvalidUrl, + .message = "URL is not valid UTF-8", + }; + } + + NSURL* url = [NSURL URLWithString:urlString]; + if (url == nil || ![[url.scheme lowercaseString] isEqualToString:@"https"]) { + return { + .error = Error::InvalidUrl, + .message = "Failed to parse URL", + }; + } + + NSMutableURLRequest* urlRequest = [NSMutableURLRequest requestWithURL:url]; + urlRequest.HTTPMethod = @"GET"; + urlRequest.timeoutInterval = request.timeout.count() / 1000.0; + urlRequest.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + for (const Header& header : request.headers) { + NSString* name = to_nsstring(header.name); + NSString* value = to_nsstring(header.value); + if (name == nil || value == nil) { + return { + .error = Error::InvalidUrl, + .message = "Request header is not valid UTF-8", + }; + } + [urlRequest setValue:value forHTTPHeaderField:name]; + } + + NSURLSessionConfiguration* configuration = + [NSURLSessionConfiguration ephemeralSessionConfiguration]; + configuration.timeoutIntervalForRequest = request.timeout.count() / 1000.0; + configuration.timeoutIntervalForResource = request.timeout.count() / 1000.0; + + DuskHttpRequestDelegate* delegate = + [[DuskHttpRequestDelegate alloc] initWithMaxBodyBytes:request.maxBodyBytes]; + NSURLSession* session = [NSURLSession sessionWithConfiguration:configuration + delegate:delegate + delegateQueue:nil]; + NSURLSessionDataTask* task = [session dataTaskWithRequest:urlRequest]; + [task resume]; + + if (dispatch_semaphore_wait(delegate.semaphore, timeout_deadline(request.timeout)) != 0) { + [task cancel]; + [session invalidateAndCancel]; + return { + .error = Error::Timeout, + .message = "Request timed out", + }; + } + + [session finishTasksAndInvalidate]; + + Response response; + if ([delegate.response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)delegate.response; + response.statusCode = static_cast(httpResponse.statusCode); + NSDictionary* headers = httpResponse.allHeaderFields; + for (id key in headers) { + id value = headers[key]; + response.headers.push_back({ + .name = to_string([key description]), + .value = to_string([value description]), + }); + } + } + if (delegate.data != nil && delegate.data.length > 0) { + response.body.assign(static_cast(delegate.data.bytes), + static_cast(delegate.data.length)); + } + + if (delegate.tooLarge) { + return { + .error = Error::TooLarge, + .message = "Response body exceeded the configured limit", + .response = std::move(response), + }; + } + if (delegate.error != nil) { + return { + .error = map_nsurl_error(delegate.error), + .message = to_string(delegate.error.localizedDescription), + .response = std::move(response), + }; + } + + return { + .response = std::move(response), + }; + } +} + +} // namespace dusk::http diff --git a/src/dusk/http/winhttp.cpp b/src/dusk/http/winhttp.cpp new file mode 100644 index 0000000000..937579f076 --- /dev/null +++ b/src/dusk/http/winhttp.cpp @@ -0,0 +1,320 @@ +#include "http.hpp" + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include + +#include +#include +#include +#include +#include + +namespace dusk::http { +namespace { + +struct WinHttpHandle { + HINTERNET handle = nullptr; + + WinHttpHandle() = default; + explicit WinHttpHandle(HINTERNET handle) : handle(handle) {} + WinHttpHandle(const WinHttpHandle&) = delete; + WinHttpHandle& operator=(const WinHttpHandle&) = delete; + + ~WinHttpHandle() { + if (handle != nullptr) { + WinHttpCloseHandle(handle); + } + } + + operator HINTERNET() const { return handle; } +}; + +std::wstring utf8_to_wide(std::string_view value) { + if (value.empty()) { + return {}; + } + + const int required = MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), static_cast(value.size()), nullptr, 0); + if (required <= 0) { + return {}; + } + + std::wstring result(static_cast(required), L'\0'); + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), static_cast(value.size()), + result.data(), required); + return result; +} + +std::string wide_to_utf8(std::wstring_view value) { + if (value.empty()) { + return {}; + } + + const int required = WideCharToMultiByte( + CP_UTF8, 0, value.data(), static_cast(value.size()), nullptr, 0, nullptr, nullptr); + if (required <= 0) { + return {}; + } + + std::string result(static_cast(required), '\0'); + WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(value.size()), result.data(), + required, nullptr, nullptr); + return result; +} + +DWORD timeout_ms(std::chrono::milliseconds timeout) { + const auto count = std::max(1, timeout.count()); + return static_cast( + std::min(count, std::numeric_limits::max())); +} + +Error map_winhttp_error(DWORD error) { + switch (error) { + case ERROR_WINHTTP_TIMEOUT: + return Error::Timeout; + case ERROR_WINHTTP_INVALID_URL: + case ERROR_WINHTTP_UNRECOGNIZED_SCHEME: + return Error::InvalidUrl; + case ERROR_WINHTTP_SECURE_FAILURE: + case ERROR_WINHTTP_CANNOT_CONNECT: + case ERROR_WINHTTP_CONNECTION_ERROR: + default: + return Error::Network; + } +} + +Result fail_from_last_error(const char* message) { + const DWORD error = GetLastError(); + return { + .error = map_winhttp_error(error), + .message = std::string(message) + " (" + std::to_string(error) + ")", + }; +} + +std::string trim_header_value(std::string_view value) { + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { + value.remove_prefix(1); + } + while (!value.empty() && (value.back() == '\r' || value.back() == '\n' || value.back() == ' ' || + value.back() == '\t')) + { + value.remove_suffix(1); + } + return std::string(value); +} + +void parse_headers(std::wstring_view rawHeaders, Response& response) { + size_t start = 0; + bool firstLine = true; + while (start < rawHeaders.size()) { + size_t end = rawHeaders.find(L"\r\n", start); + if (end == std::wstring_view::npos) { + end = rawHeaders.size(); + } + + const std::wstring_view line = rawHeaders.substr(start, end - start); + if (!line.empty() && !firstLine) { + const size_t colon = line.find(L':'); + if (colon != std::wstring_view::npos) { + response.headers.push_back({ + .name = wide_to_utf8(line.substr(0, colon)), + .value = trim_header_value(wide_to_utf8(line.substr(colon + 1))), + }); + } + } + firstLine = false; + + if (end == rawHeaders.size()) { + break; + } + start = end + 2; + } +} + +bool read_status(HINTERNET request, Response& response) { + DWORD statusCode = 0; + DWORD statusCodeSize = sizeof(statusCode); + if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &statusCode, &statusCodeSize, WINHTTP_NO_HEADER_INDEX)) + { + return false; + } + response.statusCode = static_cast(statusCode); + return true; +} + +bool read_headers(HINTERNET request, Response& response) { + DWORD headerBytes = 0; + WinHttpQueryHeaders(request, WINHTTP_QUERY_RAW_HEADERS_CRLF, WINHTTP_HEADER_NAME_BY_INDEX, + WINHTTP_NO_OUTPUT_BUFFER, &headerBytes, WINHTTP_NO_HEADER_INDEX); + if (GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return false; + } + + std::wstring rawHeaders(headerBytes / sizeof(wchar_t), L'\0'); + if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_RAW_HEADERS_CRLF, WINHTTP_HEADER_NAME_BY_INDEX, + rawHeaders.data(), &headerBytes, WINHTTP_NO_HEADER_INDEX)) + { + return false; + } + if (!rawHeaders.empty() && rawHeaders.back() == L'\0') { + rawHeaders.pop_back(); + } + parse_headers(rawHeaders, response); + return true; +} + +} // namespace + +bool available() noexcept { + return true; +} + +Backend backend() noexcept { + return Backend::WinHttp; +} + +const char* backend_name() noexcept { + return "WinHTTP"; +} + +Result get(const Request& request) { + if (request.url.empty()) { + return { + .error = Error::InvalidUrl, + .message = "URL is empty", + }; + } + + std::wstring wideUrl = utf8_to_wide(request.url); + if (wideUrl.empty()) { + return { + .error = Error::InvalidUrl, + .message = "URL is not valid UTF-8", + }; + } + + URL_COMPONENTS components{}; + components.dwStructSize = sizeof(components); + components.dwSchemeLength = static_cast(-1); + components.dwHostNameLength = static_cast(-1); + components.dwUrlPathLength = static_cast(-1); + components.dwExtraInfoLength = static_cast(-1); + if (!WinHttpCrackUrl(wideUrl.c_str(), static_cast(wideUrl.size()), 0, &components)) { + return fail_from_last_error("Failed to parse URL"); + } + if (components.nScheme != INTERNET_SCHEME_HTTPS) { + return { + .error = Error::UnsupportedScheme, + .message = "Only https:// URLs are supported", + }; + } + + const std::wstring host(components.lpszHostName, components.dwHostNameLength); + std::wstring path; + if (components.lpszUrlPath != nullptr && components.dwUrlPathLength > 0) { + path.assign(components.lpszUrlPath, components.dwUrlPathLength); + } + if (components.lpszExtraInfo != nullptr && components.dwExtraInfoLength > 0) { + path.append(components.lpszExtraInfo, components.dwExtraInfoLength); + } + if (path.empty()) { + path = L"/"; + } + + WinHttpHandle session(WinHttpOpen(L"Dusk", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)); + if (session.handle == nullptr) { + return fail_from_last_error("Failed to create WinHTTP session"); + } + + const DWORD timeout = timeout_ms(request.timeout); + WinHttpSetTimeouts(session, timeout, timeout, timeout, timeout); + + WinHttpHandle connection(WinHttpConnect(session, host.c_str(), components.nPort, 0)); + if (connection.handle == nullptr) { + return fail_from_last_error("Failed to connect"); + } + + WinHttpHandle httpRequest(WinHttpOpenRequest(connection, L"GET", path.c_str(), nullptr, + WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE)); + if (httpRequest.handle == nullptr) { + return fail_from_last_error("Failed to create request"); + } + + DWORD redirectPolicy = WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP; + WinHttpSetOption( + httpRequest, WINHTTP_OPTION_REDIRECT_POLICY, &redirectPolicy, sizeof(redirectPolicy)); + DWORD maxRedirects = 5; + WinHttpSetOption(httpRequest, WINHTTP_OPTION_MAX_HTTP_AUTOMATIC_REDIRECTS, &maxRedirects, + sizeof(maxRedirects)); + + for (const Header& header : request.headers) { + const std::wstring wideHeader = utf8_to_wide(header.name + ": " + header.value); + if (wideHeader.empty()) { + return { + .error = Error::InvalidUrl, + .message = "Request header is not valid UTF-8", + }; + } + if (!WinHttpAddRequestHeaders(httpRequest, wideHeader.c_str(), + static_cast(wideHeader.size()), WINHTTP_ADDREQ_FLAG_ADD)) + { + return fail_from_last_error("Failed to add request header"); + } + } + + if (!WinHttpSendRequest( + httpRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0)) + { + return fail_from_last_error("Failed to send request"); + } + if (!WinHttpReceiveResponse(httpRequest, nullptr)) { + return fail_from_last_error("Failed to receive response"); + } + + Response response; + if (!read_status(httpRequest, response)) { + return fail_from_last_error("Failed to read response status"); + } + read_headers(httpRequest, response); + + for (;;) { + DWORD availableBytes = 0; + if (!WinHttpQueryDataAvailable(httpRequest, &availableBytes)) { + return fail_from_last_error("Failed to query response body"); + } + if (availableBytes == 0) { + break; + } + if (availableBytes > request.maxBodyBytes || + response.body.size() > request.maxBodyBytes - availableBytes) + { + return { + .error = Error::TooLarge, + .message = "Response body exceeded the configured limit", + .response = std::move(response), + }; + } + + std::vector buffer(availableBytes); + DWORD bytesRead = 0; + if (!WinHttpReadData(httpRequest, buffer.data(), availableBytes, &bytesRead)) { + return fail_from_last_error("Failed to read response body"); + } + response.body.append(buffer.data(), bytesRead); + } + + return { + .response = std::move(response), + }; +} + +} // namespace dusk::http diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index 31384e6f59..41a60b4c7f 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); diff --git a/src/dusk/imgui/ImGuiMenuGame.cpp b/src/dusk/imgui/ImGuiMenuGame.cpp index 8041621fee..79c032ee35 100644 --- a/src/dusk/imgui/ImGuiMenuGame.cpp +++ b/src/dusk/imgui/ImGuiMenuGame.cpp @@ -45,6 +45,7 @@ namespace dusk { getSettings().game.enableTurboKeybind.setValue(false); getSettings().game.debugFlyCam.setValue(false); + getSettings().game.autoSave.setValue(false); } SpeedrunInfo m_speedrunInfo; diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp index efaf386bd9..f0d10d4226 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}, @@ -119,6 +122,7 @@ UserSettings g_userSettings = { .showPipelineCompilation {"backend.showPipelineCompilation", false}, .wasPresetChosen {"backend.wasPresetChosen", false}, .enableCrashReporting {"backend.enableCrashReporting", true}, + .checkForUpdates {"backend.checkForUpdates", true}, .cardFileType {"backend.cardFileType", static_cast(CARD_GCIFOLDER)}, .enableAdvancedSettings {"backend.enableAdvancedSettings", false}, } @@ -182,7 +186,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); @@ -202,6 +207,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); @@ -214,6 +220,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); @@ -222,6 +229,7 @@ void registerSettings() { Register(g_userSettings.backend.showPipelineCompilation); Register(g_userSettings.backend.wasPresetChosen); Register(g_userSettings.backend.enableCrashReporting); + Register(g_userSettings.backend.checkForUpdates); Register(g_userSettings.backend.cardFileType); Register(g_userSettings.backend.enableAdvancedSettings); } diff --git a/src/dusk/ui/achievements.cpp b/src/dusk/ui/achievements.cpp index 725d662ec1..b631d444f5 100644 --- a/src/dusk/ui/achievements.cpp +++ b/src/dusk/ui/achievements.cpp @@ -16,9 +16,8 @@ struct CategoryInfo { }; constexpr CategoryInfo kCategories[] = { - {AchievementCategory::Story, "Story"}, - {AchievementCategory::Collection, "Collection"}, {AchievementCategory::Challenge, "Challenge"}, + {AchievementCategory::Collection, "Collection"}, {AchievementCategory::Minigame, "Minigame"}, {AchievementCategory::Misc, "Misc"}, {AchievementCategory::Glitched, "Glitched"}, @@ -114,6 +113,13 @@ private: AchievementsWindow::AchievementsWindow() { const auto all = AchievementSystem::get().getAchievements(); + { + auto elem = mDocument->CreateElement("div"); + elem->SetClass("achievement-total", true); + mTotalEl = mRoot->AppendChild(std::move(elem)); + updateTotal(); + } + for (const auto& catInfo : kCategories) { int catTotal = 0; for (const auto& a : all) { @@ -201,8 +207,25 @@ void AchievementsWindow::update() { if (dirty) { mSnapshot = current; refresh_active_tab(); + updateTotal(); } Window::update(); } +void AchievementsWindow::updateTotal() { + if (mTotalEl == nullptr) { + return; + } + const auto all = AchievementSystem::get().getAchievements(); + int total = static_cast(all.size()); + int unlocked = 0; + for (const auto& a : all) { + if (a.unlocked) { + ++unlocked; + } + } + const int pct = total > 0 ? (unlocked * 100 / total) : 0; + mTotalEl->SetInnerRML(fmt::format("{}%", pct)); +} + } // namespace dusk::ui diff --git a/src/dusk/ui/achievements.hpp b/src/dusk/ui/achievements.hpp index 3a65705600..e0c6fe41ad 100644 --- a/src/dusk/ui/achievements.hpp +++ b/src/dusk/ui/achievements.hpp @@ -13,7 +13,9 @@ public: void update() override; private: + void updateTotal(); std::vector mSnapshot; + Rml::Element* mTotalEl = nullptr; }; } // namespace dusk::ui diff --git a/src/dusk/ui/menu_bar.cpp b/src/dusk/ui/menu_bar.cpp index fdd89e6967..81c2eea441 100644 --- a/src/dusk/ui/menu_bar.cpp +++ b/src/dusk/ui/menu_bar.cpp @@ -68,12 +68,17 @@ MenuBar::MenuBar() : Document(kDocumentSource), mRoot(mDocument->GetElementById( { ModalAction{ .label = "Cancel", - .onPressed = dismiss, + .onPressed = + [this, dismiss](Modal& modal) { + mDoAud_seStartMenu(kSoundWindowClose); + dismiss(modal); + }, }, ModalAction{ .label = "Reset", .onPressed = [this, dismiss](Modal& modal) { + mDoAud_seStartMenu(kSoundClick); if (fpcM_SearchByName(fpcNm_LOGO_SCENE_e)) { dismiss(modal); return; @@ -98,12 +103,17 @@ MenuBar::MenuBar() : Document(kDocumentSource), mRoot(mDocument->GetElementById( { ModalAction{ .label = "Cancel", - .onPressed = dismiss, + .onPressed = + [dismiss](Modal& modal) { + mDoAud_seStartMenu(kSoundWindowClose); + dismiss(modal); + }, }, ModalAction{ .label = "Quit", .onPressed = [dismiss](Modal& modal) { + mDoAud_seStartMenu(kSoundClick); dismiss(modal); IsRunning = false; }, diff --git a/src/dusk/ui/modal.cpp b/src/dusk/ui/modal.cpp index 6aa1637de7..b08a333163 100644 --- a/src/dusk/ui/modal.cpp +++ b/src/dusk/ui/modal.cpp @@ -36,6 +36,8 @@ Modal::Modal(Props props) : WindowSmall("modal", "modal-dialog"), mProps(std::mo }); mButtons.push_back(std::move(btn)); } + + mDoAud_seStartMenu(kSoundWindowOpen); } bool Modal::focus() { diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index 42dc9629ac..5c41958409 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -5,12 +5,15 @@ #include "dusk/iso_validate.hpp" #include "dusk/main.h" #include "dusk/settings.h" +#include "dusk/update_check.hpp" #include "modal.hpp" #include "preset.hpp" #include "settings.hpp" #include "version.h" #include +#include +#include #include #include #include @@ -54,7 +57,13 @@ const Rml::String kDocumentSource = R"RML(
Version
-
Update available! Download
+
+ + +
@@ -114,6 +123,44 @@ struct DiscVerificationTask { std::unique_ptr sDiscVerificationTask; bool sDiscVerificationModalPushed = false; +struct UpdateCheckTask { + UpdateCheckTask() { + worker = std::thread([this] { + try { + result = update_check::check_latest_github_release("TwilitRealm", "dusk"); + } catch (const std::exception& e) { + result = { + .status = update_check::Status::Failed, + .message = fmt::format("Update check failed with exception: {}", e.what()), + }; + } catch (...) { + result = { + .status = update_check::Status::Failed, + .message = "Update check failed with an unknown exception", + }; + } + done.store(true, std::memory_order_release); + }); + } + + ~UpdateCheckTask() { join(); } + + void join() { + if (worker.joinable()) { + worker.join(); + } + } + + [[nodiscard]] bool finished() const { return done.load(std::memory_order_acquire); } + + update_check::Result result; + std::atomic_bool done = false; + std::thread worker; +}; + +std::unique_ptr sUpdateCheckTask; +std::optional sUpdateCheckResult; + bool verification_state_allows_launch(iso::ValidationError validation) noexcept { return validation == iso::ValidationError::Unknown || validation == iso::ValidationError::Success || @@ -185,6 +232,52 @@ std::optional take_finished_disc_verification() { return result; } +void begin_update_check() { + if (!getSettings().backend.checkForUpdates.getValue()) { + return; + } + if (sUpdateCheckTask != nullptr || sUpdateCheckResult.has_value()) { + return; + } + sUpdateCheckTask = std::make_unique(); +} + +std::optional take_finished_update_check() { + if (sUpdateCheckTask == nullptr || !sUpdateCheckTask->finished()) { + return std::nullopt; + } + + sUpdateCheckTask->join(); + auto result = std::move(sUpdateCheckTask->result); + sUpdateCheckTask.reset(); + return result; +} + +std::string update_release_label(const update_check::Release& release) { + std::string_view tagName = release.tagName; + if (!tagName.empty() && tagName.front() == 'v') { + tagName.remove_prefix(1); + } + return std::string(tagName); +} + +void open_update_release() { + if (!sUpdateCheckResult.has_value() || + sUpdateCheckResult->status != update_check::Status::UpdateAvailable) + { + return; + } + + const std::string url = sUpdateCheckResult->latest.htmlUrl; + if (url.empty()) { + PrelaunchLog.warn("Update is available, but the release did not include a download URL"); + return; + } + if (!SDL_OpenURL(url.c_str())) { + PrelaunchLog.warn("Failed to open update URL '{}': {}", url, SDL_GetError()); + } +} + std::string get_error_msg(iso::ValidationError error) { switch (error) { default: @@ -352,8 +445,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; } @@ -582,6 +674,7 @@ void try_apply_mirrored_layout(Rml::Element* body) { Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementById("root")) { ensure_initialized(); + begin_update_check(); if (auto* menuList = mDocument->GetElementById("menu-list")) { auto& state = prelaunch_state(); @@ -629,6 +722,23 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB mDiscStatus = mDocument->GetElementById("disc-status"); mDiscDetail = mDocument->GetElementById("disc-version"); mVersion = mDocument->GetElementById("version-text"); + mUpdateStatus = mDocument->GetElementById("update-status"); + mUpdateMessage = mDocument->GetElementById("update-message"); + mUpdateDownload = mDocument->GetElementById("update-download"); + mUpdateDownloadLabel = mDocument->GetElementById("update-download-label"); + + if (mUpdateDownload != nullptr) { + listen(mUpdateDownload, Rml::EventId::Click, [](Rml::Event& event) { + open_update_release(); + event.StopPropagation(); + }); + listen(mUpdateDownload, Rml::EventId::Keydown, [](Rml::Event& event) { + if (map_nav_event(event) == NavCommand::Confirm) { + open_update_release(); + event.StopPropagation(); + } + }); + } try_apply_mirrored_layout(mDocument); @@ -767,6 +877,34 @@ void Prelaunch::update() { } mVersion->SetInnerRML(escape(versionStr)); } + if (mUpdateStatus != nullptr && mUpdateMessage != nullptr) { + if (auto result = take_finished_update_check()) { + if (result->status == update_check::Status::Failed) { + PrelaunchLog.error("Failed to check for updates: {}", result->message); + } + sUpdateCheckResult = std::move(*result); + } + + if (sUpdateCheckTask != nullptr) { + mUpdateStatus->SetAttribute("state", "checking"); + mUpdateMessage->SetInnerRML("Checking for updates..."); + } else if (!sUpdateCheckResult.has_value() || + sUpdateCheckResult->status == update_check::Status::UpToDate) + { + mUpdateStatus->RemoveAttribute("state"); + mUpdateMessage->SetInnerRML(""); + } else if (sUpdateCheckResult->status == update_check::Status::UpdateAvailable) { + mUpdateStatus->SetAttribute("state", "available"); + mUpdateMessage->SetInnerRML("Update available!"); + if (mUpdateDownloadLabel != nullptr) { + mUpdateDownloadLabel->SetInnerRML(escape( + fmt::format("Download {}", update_release_label(sUpdateCheckResult->latest)))); + } + } else { + mUpdateStatus->SetAttribute("state", "failed"); + mUpdateMessage->SetInnerRML("Failed to check for updates"); + } + } Document::update(); } diff --git a/src/dusk/ui/prelaunch.hpp b/src/dusk/ui/prelaunch.hpp index 95ba2238f4..0fbb64ded9 100644 --- a/src/dusk/ui/prelaunch.hpp +++ b/src/dusk/ui/prelaunch.hpp @@ -31,6 +31,10 @@ private: Rml::Element* mDiscStatus = nullptr; Rml::Element* mDiscDetail = nullptr; Rml::Element* mVersion = nullptr; + Rml::Element* mUpdateStatus = nullptr; + Rml::Element* mUpdateMessage = nullptr; + Rml::Element* mUpdateDownload = nullptr; + Rml::Element* mUpdateDownloadLabel = nullptr; }; class PrelaunchOptions; 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 26012139a5..fd7365a39d 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -6,12 +6,14 @@ #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 "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 +43,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; @@ -175,6 +182,8 @@ void reset_for_speedrun_mode() { getSettings().game.freeMagicArmor.setValue(false); getSettings().game.enableTurboKeybind.setValue(false); + getSettings().game.debugFlyCam.setValue(false); + getSettings().game.autoSave.setValue(false); } const Rml::String kInternalResolutionHelpText = @@ -197,7 +206,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 { @@ -315,7 +326,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; } @@ -610,6 +621,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, @@ -625,12 +642,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(); }); @@ -639,7 +694,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(); }); @@ -869,7 +928,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { addCheat( "Moon Jump (R+A)", getSettings().game.moonJump, "Hold R and A to rise into the air."); addCheat("Super Clawshot", getSettings().game.superClawshot, - "Extends clawshot behavior beyond the normal game rules."); + "Extends Clawshot behavior beyond the normal game rules."); addCheat("Always Greatspin", getSettings().game.alwaysGreatspin, "Allows the Great Spin attack without requiring full health."); addCheat("Fast Iron Boots", getSettings().game.enableFastIronBoots, @@ -887,10 +946,69 @@ 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.", + 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, @@ -915,6 +1033,18 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .key = "Show Pipeline Compilation", .helpText = "Show an overlay when shaders are being compiled for your hardware.", }); + config_bool_select(leftPane, rightPane, getSettings().backend.checkForUpdates, + { + .key = "Check for Updates", + .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/ui.cpp b/src/dusk/ui/ui.cpp index a646527ee5..0ee59e7938 100644 --- a/src/dusk/ui/ui.cpp +++ b/src/dusk/ui/ui.cpp @@ -125,43 +125,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 +259,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 new file mode 100644 index 0000000000..c1e332c432 --- /dev/null +++ b/src/dusk/update_check.cpp @@ -0,0 +1,351 @@ +#include "update_check.hpp" + +#include "dusk/http/http.hpp" +#include "fmt/format.h" +#include "nlohmann/json.hpp" +#include "version.h" + +#include +#include +#include +#include +#include +#include + +namespace dusk::update_check { +namespace { + +using json = nlohmann::json; + +constexpr std::string_view GitHubApiVersion = "2026-03-10"; + +struct Version { + int major = 0; + int minor = 0; + int patch = 0; + std::vector prerelease; +}; + +std::string json_string(const json& value, const char* key) { + const auto iter = value.find(key); + if (iter == value.end() || !iter->is_string()) { + return {}; + } + return iter->get(); +} + +std::optional parse_component(std::string_view& value) { + if (value.empty() || value.front() < '0' || value.front() > '9') { + return std::nullopt; + } + + int parsed = 0; + const char* begin = value.data(); + const char* end = value.data() + value.size(); + const auto [ptr, ec] = std::from_chars(begin, end, parsed); + if (ec != std::errc()) { + return std::nullopt; + } + + value.remove_prefix(static_cast(ptr - begin)); + return parsed; +} + +bool consume(std::string_view& value, char expected) { + if (value.empty() || value.front() != expected) { + return false; + } + value.remove_prefix(1); + 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); + } + + Version version; + auto major = parse_component(value); + if (!major || !consume(value, '.')) { + return std::nullopt; + } + auto minor = parse_component(value); + if (!minor || !consume(value, '.')) { + return std::nullopt; + } + auto patch = parse_component(value); + if (!patch) { + 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; +} + +Release parse_release(const json& value) { + Release release{ + .tagName = json_string(value, "tag_name"), + .name = json_string(value, "name"), + .htmlUrl = json_string(value, "html_url"), + .body = json_string(value, "body"), + }; + + const auto assets = value.find("assets"); + if (assets != value.end() && assets->is_array()) { + for (const auto& asset : *assets) { + if (!asset.is_object()) { + continue; + } + release.assets.push_back({ + .name = json_string(asset, "name"), + .browserDownloadUrl = json_string(asset, "browser_download_url"), + .digest = json_string(asset, "digest"), + }); + } + } + + return release; +} + +std::string release_url(std::string_view owner, std::string_view repo) { + return fmt::format("https://api.github.com/repos/{}/{}/releases/latest", owner, repo); +} + +std::string user_agent() { + return fmt::format("Dusk/{}", DUSK_WC_DESCRIBE); +} + +} // namespace + +Result check_latest_github_release(std::string_view owner, std::string_view repo) { + if (!http::available()) { + return { + .status = Status::Disabled, + .message = "No HTTP backend is available", + }; + } + if (owner.empty() || repo.empty()) { + return { + .status = Status::Failed, + .message = "GitHub owner and repo are required", + }; + } + + http::Request request{ + .url = release_url(owner, repo), + .headers = + { + {.name = "User-Agent", .value = user_agent()}, + {.name = "Accept", .value = "application/vnd.github+json"}, + {.name = "X-GitHub-Api-Version", .value = std::string(GitHubApiVersion)}, + }, + }; + + http::Result result = http::get(request); + if (result.error != http::Error::None) { + return { + .status = Status::Failed, + .message = result.message, + }; + } + if (result.response.statusCode != 200) { + return { + .status = Status::Failed, + .message = fmt::format("GitHub returned HTTP {}", result.response.statusCode), + }; + } + + Release latest; + try { + latest = parse_release(json::parse(result.response.body)); + } catch (const std::exception& e) { + return { + .status = Status::Failed, + .message = fmt::format("Failed to parse GitHub release JSON: {}", e.what()), + }; + } + + const std::optional latestVersion = parse_version(latest.tagName); + const std::optional currentVersion = parse_version(DUSK_WC_DESCRIBE); + if (!latestVersion) { + return { + .status = Status::Failed, + .message = fmt::format("Failed to parse release tag '{}'", latest.tagName), + .latest = std::move(latest), + }; + } + if (!currentVersion) { + return { + .status = Status::Failed, + .message = fmt::format("Failed to parse Dusk version '{}'", DUSK_WC_DESCRIBE), + .latest = std::move(latest), + }; + } + + const bool updateAvailable = compare_version(*latestVersion, *currentVersion) > 0; + return { + .status = updateAvailable ? Status::UpdateAvailable : Status::UpToDate, + .message = updateAvailable ? "Update available" : "Dusk is up to date", + .latest = std::move(latest), + }; +} + +} // namespace dusk::update_check diff --git a/src/dusk/update_check.hpp b/src/dusk/update_check.hpp new file mode 100644 index 0000000000..72c66449dc --- /dev/null +++ b/src/dusk/update_check.hpp @@ -0,0 +1,41 @@ +#ifndef DUSK_UPDATE_CHECK_HPP +#define DUSK_UPDATE_CHECK_HPP + +#include +#include +#include + +namespace dusk::update_check { + +enum class Status { + Disabled, + UpToDate, + UpdateAvailable, + Failed, +}; + +struct Asset { + std::string name; + std::string browserDownloadUrl; + std::string digest; +}; + +struct Release { + std::string tagName; + std::string name; + std::string htmlUrl; + std::string body; + std::vector assets; +}; + +struct Result { + Status status = Status::Failed; + std::string message; + Release latest; +}; + +Result check_latest_github_release(std::string_view owner, std::string_view repo); + +} // namespace dusk::update_check + +#endif // DUSK_UPDATE_CHECK_HPP diff --git a/src/f_ap/f_ap_game.cpp b/src/f_ap/f_ap_game.cpp index 68d244ef72..dc14933b50 100644 --- a/src/f_ap/f_ap_game.cpp +++ b/src/f_ap/f_ap_game.cpp @@ -14,6 +14,7 @@ #include "d/actor/d_a_midna.h" #include "d/d_model.h" #include "d/d_tresure.h" +#include "dusk/achievements.h" #include "dusk/frame_interpolation.h" #include "dusk/livesplit.h" #include "dusk/logging.h" @@ -828,6 +829,7 @@ void fapGm_Execute() { cCt_Counter(0); #ifdef TARGET_PC dusk::speedrun::onGameFrame(); + dusk::AchievementSystem::get().tick(); #endif } diff --git a/src/m_Do/m_Do_graphic.cpp b/src/m_Do/m_Do_graphic.cpp index b6a53e15a5..61eaf3bc2d 100644 --- a/src/m_Do/m_Do_graphic.cpp +++ b/src/m_Do/m_Do_graphic.cpp @@ -502,6 +502,14 @@ void mDoGph_gInf_c::calcFade() { } if (mFadeColor.a != 0) { +#ifdef TARGET_PC + if (dusk::frame_interp::is_enabled() && mFade != 0) { + const auto step = dusk::frame_interp::get_interpolation_step(); + const auto progress = mFadeSpeed < 0.0f ? 1.0f - mFadeRate : mFadeRate; + const auto fade_amt = mFadeRate + mFadeSpeed * (step - 1.0f + progress); + mFadeColor.a = 255.0f * std::clamp(fade_amt, 0.0f, 1.0f); + } +#endif darwFilter(mFadeColor); } } diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index c259322579..a2e368f8ca 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -46,7 +46,6 @@ #include #include #include "SSystem/SComponent/c_API.h" -#include "dusk/achievements.h" #include "dusk/app_info.hpp" #include "dusk/crash_reporting.h" #include "dusk/dusk.h" @@ -111,7 +110,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 @@ -234,19 +232,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); @@ -260,19 +255,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(); @@ -288,7 +278,6 @@ void main01(void) { dusk::gyro::read(pacing.sim_pace); fapGm_Execute(); mDoAud_Execute(); - dusk::AchievementSystem::get().tick(); dusk::game_clock::commit_sim_tick(); } } @@ -589,7 +578,8 @@ int game_main(int argc, char* argv[]) { config.logLevel = startupLogLevel; config.mem1Size = 256 * 1024 * 1024; 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; @@ -636,13 +626,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; @@ -654,6 +650,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( @@ -663,10 +660,23 @@ int game_main(int argc, char* argv[]) { } } else { DuskLog.warn("DVD image from command line failed validation: {}, opening prelaunch UI", dvd_path); + forcePreLaunchUI = true; } } 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);