From f0e6b88cfd4afc3ee3b6f5ccb8b4c615bda24078 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 28 Jul 2026 19:26:53 -0600 Subject: [PATCH 01/19] GCC mod section fix --- sdk/include/mods/hook.hpp | 9 +++++++++ sdk/include/mods/meta.hpp | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/sdk/include/mods/hook.hpp b/sdk/include/mods/hook.hpp index 07999da84c..a8d91ab68c 100644 --- a/sdk/include/mods/hook.hpp +++ b/sdk/include/mods/hook.hpp @@ -139,6 +139,14 @@ struct NamedHook : HookImpl, R, A...> {}; * leading underscore) or the demangled qualified display name; overloaded display names are * ambiguous and need the mangled form. */ +#if defined(__GNUC__) && !defined(__clang__) && defined(__ELF__) +#define DEFINE_HOOK(target, alias) \ + MOD_META_RECORD static constinit auto mod_meta_hook_##alias = \ + ::mods::detail::make_local_hook_record<(target), ::mods::FixedString{#target}>(); \ + struct alias : ::mods::Hook<(target)> { \ + static void* resolved_target() { return mod_meta_hook_##alias.resolved; } \ + } +#else #define DEFINE_HOOK(target, alias) \ [[maybe_unused]] static const void* const mod_meta_hook_##alias = \ &::mods::detail::HookRecordFor<(target), ::mods::FixedString{#target}>::Holder::record; \ @@ -148,6 +156,7 @@ struct NamedHook : HookImpl, R, A...> {}; ::mods::FixedString{#target}>::Holder::record.resolved; \ } \ } +#endif #define DEFINE_HOOK_SYMBOL(name, sig, alias) \ MOD_META_RECORD static constinit auto mod_meta_hook_##alias = \ diff --git a/sdk/include/mods/meta.hpp b/sdk/include/mods/meta.hpp index 3c6223e6fb..6b1de96ad8 100644 --- a/sdk/include/mods/meta.hpp +++ b/sdk/include/mods/meta.hpp @@ -241,6 +241,48 @@ consteval auto make_hook_mem_names() { return r; } +#if defined(__GNUC__) && !defined(__clang__) && defined(__ELF__) +/* https://gcc.gnu.org/bugzilla/show_bug.cgi?id=41091 prevents inline static template members from + * sharing an explicit ELF section with ordinary variables. GCC can instead constant-evaluate a + * file-local record at each DEFINE_HOOK. */ +template +void materialize_hook_mem(unsigned char* outPmf) { + const auto target = Target; + std::memcpy(outPmf, &target, sizeof(target)); +} + +template +consteval auto make_local_hook_record() { + using F = decltype(Target); + if constexpr (std::is_member_function_pointer_v) { + constexpr auto names = make_hook_mem_names(); + static_assert(sizeof(F) <= MOD_META_HOOK_MEM_EXT_CAPACITY, + "unsupported pointer-to-member representation"); + if constexpr (sizeof(F) > MOD_META_HOOK_MEM_CAPACITY) { + HookMemExtRecord record = { + {sizeof(HookMemExtRecord), MOD_META_HOOK_MEM_EXT, 0}, sizeof(F), + materialize_hook_mem, nullptr, {}}; + for (size_t i = 0; i < names.len; ++i) { + record.names[i] = names.chars[i]; + } + return record; + } else { + HookMemRecord record = { + {sizeof(HookMemRecord), MOD_META_HOOK_MEM, 0}, 0, {Target}, nullptr, + {}}; + for (size_t i = 0; i < names.len; ++i) { + record.names[i] = names.chars[i]; + } + return record; + } + } else { + static_assert(std::is_pointer_v && std::is_function_v>, + "hook target must be a function or member function"); + return HookFnRecord{{sizeof(HookFnRecord), MOD_META_HOOK_FN, 0}, 0, Target, nullptr}; + } +} +#endif + /* * MSVC constant-evaluates a compact pointer-to-member only when every other operand in the * initializer is a literal: no consteval calls, constexpr-object copies, or default member From 64789aa5fc5a8496e99285036e2084ad0d539f1b Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 28 Jul 2026 19:27:35 -0600 Subject: [PATCH 02/19] Update symgen (fixes AppImage builds) --- cmake/SymbolManifest.cmake | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cmake/SymbolManifest.cmake b/cmake/SymbolManifest.cmake index 40584ede19..0e401db669 100644 --- a/cmake/SymbolManifest.cmake +++ b/cmake/SymbolManifest.cmake @@ -2,7 +2,7 @@ include_guard(GLOBAL) get_filename_component(_SYMBOL_MANIFEST_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) -set(_SYMGEN_VERSION "1.3.1") +set(_SYMGEN_VERSION "1.3.2") set(_SYMGEN_RELEASE_BASE_URL "https://github.com/encounter/symgen/releases/download/v${_SYMGEN_VERSION}") set(SYMGEN_PATH "" CACHE FILEPATH "Path to a symgen executable; empty downloads the pinned release") mark_as_advanced(SYMGEN_PATH) @@ -106,6 +106,16 @@ function(setup_symbol_manifest target) endif () add_dependencies(${target} symgen) + # Reserve an ELF program-header entry when the linker supports it (mold). + # symgen can replace the PT_NULL entry without relocating the table, keeping later post-link tools safe. + if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + include(CheckLinkerFlag) + check_linker_flag(CXX "LINKER:--spare-program-headers=1" _linker_supports_spare_program_headers) + if (_linker_supports_spare_program_headers) + target_link_options(${target} PRIVATE "LINKER:--spare-program-headers=1") + endif () + endif () + if (WIN32) set(_input --pdb "$") else () From 4d688a8507f071b6bf7aab92f6e751e5efeb8197 Mon Sep 17 00:00:00 2001 From: Giorgio Mendieta <31053658+GiorgioMendieta@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:25:15 +0200 Subject: [PATCH 03/19] Draw shadows before translucent textures (#2206) * Implement smooth fadeout of dynamic shadows * Add edge fade out configuration for dynamic shadows * Tweak default and max values for edge fade out * Add stage hook for deferred composite after opaque scene draws * Bump mod version --- mods/shadow_mod/mod.json | 2 +- mods/shadow_mod/src/mod.cpp | 25 +++++++++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/mods/shadow_mod/mod.json b/mods/shadow_mod/mod.json index 09fe8a3a84..dadc790f29 100644 --- a/mods/shadow_mod/mod.json +++ b/mods/shadow_mod/mod.json @@ -1,7 +1,7 @@ { "id": "dev.twilitrealm.shadow_mod", "name": "[Demo] Dynamic Shadows", - "version": "1.0.0", + "version": "1.0.1", "author": "encounter", "description": "Demo showcasing dynamic shadow maps: re-renders geometry from the sun (or moon) point of view and composites real-time shadows over the world, with screen-space contact shadows for fine detail." } diff --git a/mods/shadow_mod/src/mod.cpp b/mods/shadow_mod/src/mod.cpp index 97439d7015..b14c5903a5 100644 --- a/mods/shadow_mod/src/mod.cpp +++ b/mods/shadow_mod/src/mod.cpp @@ -62,6 +62,7 @@ ConfigVarHandle g_cvarDebugView = 0; GfxDrawTypeHandle g_drawType = 0; GfxStageHookHandle g_sceneBeginHook = 0; GfxStageHookHandle g_sceneAfterTerrainHook = 0; +GfxStageHookHandle g_sceneAfterOpaqueHook = 0; GfxStageHookHandle g_frameBeforeHudHook = 0; UiWindowHandle g_controlsWindow = 0; ResourceBuffer g_shaderSource = RESOURCE_BUFFER_INIT; @@ -749,8 +750,8 @@ void render_shadow_map( g_mapPass.ready = true; } -// Game thread, after the full 3D scene: deferred composite. -void on_frame_before_hud(ModContext*, const GfxStageContext*, void*) { +// Game thread, after opaque scene draws and before translucent/fog overlays: deferred composite. +void on_scene_after_opaque(ModContext*, const GfxStageContext*, void*) { const int64_t debugMode = get_debug_mode(); restore_actual_light_debug(); @@ -797,7 +798,7 @@ void on_frame_before_hud(ModContext*, const GfxStageContext*, void*) { uniforms.inv_size[0] = 1.0f / uniforms.size[0]; uniforms.inv_size[1] = 1.0f / uniforms.size[1]; uniforms.edge_fade_width = - static_cast(std::clamp(get_int_option(g_cvarEdgeFadeWidth, 32), 0, 128)); + static_cast(std::clamp(get_int_option(g_cvarEdgeFadeWidth, 32), 0, 256)); uniforms.strength = mapPass.fade * static_cast(std::clamp(get_int_option(g_cvarStrength, 45), 0, 100)) / @@ -817,6 +818,11 @@ void on_frame_before_hud(ModContext*, const GfxStageContext*, void*) { svc_gfx->push_draw(mod_ctx, g_drawType, &payload, sizeof(payload)); } +// Frame tail hook: only needed to restore light-view debug camera state before HUD. +void on_frame_before_hud(ModContext*, const GfxStageContext*, void*) { + restore_actual_light_debug(); +} + void add_control(UiElementHandle pane, const UiControlDesc& desc) { svc_ui->pane_add_control(mod_ctx, pane, &desc, nullptr); } @@ -873,7 +879,7 @@ ModResult build_controls_tab( "dynamic shadows. This can be expensive."); add_number(left, "Coverage", g_cvarBoxRadius, 1000, 20000, 500, nullptr, "Radius of the shadowed area around the camera, in world units. Smaller is sharper."); - add_number(left, "Fade Out", g_cvarEdgeFadeWidth, 0, 128, 32, " texels", + add_number(left, "Fade Out", g_cvarEdgeFadeWidth, 0, 256, 32, " texels", "Fade out shadows gradually near the edge of the coverage area."); svc_ui->pane_add_section(mod_ctx, left, "Appearance"); @@ -1000,7 +1006,7 @@ MOD_EXPORT ModResult mod_initialize(ModError* error) { if (result != MOD_OK) { return result; } - result = register_int_option("edgeFadeWidth", 32, g_cvarEdgeFadeWidth, error); + result = register_int_option("edgeFadeWidth", 128, g_cvarEdgeFadeWidth, error); if (result != MOD_OK) { return result; } @@ -1041,6 +1047,12 @@ MOD_EXPORT ModResult mod_initialize(ModError* error) { { return mods::set_error(error, MOD_ERROR, "failed to register stage hook"); } + stageDesc.callback = on_scene_after_opaque; + if (svc_gfx->register_stage_hook( + mod_ctx, GFX_STAGE_SCENE_AFTER_OPAQUE, &stageDesc, &g_sceneAfterOpaqueHook) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register stage hook"); + } stageDesc.callback = on_frame_before_hud; if (svc_gfx->register_stage_hook( mod_ctx, GFX_STAGE_FRAME_BEFORE_HUD, &stageDesc, &g_frameBeforeHudHook) != MOD_OK) @@ -1099,7 +1111,8 @@ MOD_EXPORT ModResult mod_shutdown(ModError*) { g_cvarStrength = 0; g_cvarPcf = g_cvarBias = g_cvarBoxRadius = g_cvarEdgeFadeWidth = g_cvarContactShadows = g_cvarDebugView = 0; - g_drawType = g_sceneBeginHook = g_sceneAfterTerrainHook = g_frameBeforeHudHook = 0; + g_drawType = g_sceneBeginHook = g_sceneAfterTerrainHook = g_sceneAfterOpaqueHook = + g_frameBeforeHudHook = 0; g_controlsWindow = 0; g_mapPass = {}; g_sceneCamera.valid = false; From b6b297f1acfb061a5e333c525ec70fe50b492f5d Mon Sep 17 00:00:00 2001 From: Jack Wines Date: Wed, 29 Jul 2026 01:25:43 -0400 Subject: [PATCH 04/19] Nix: add miniz and fix submodules (#2220) * nix: add miniz to dependencies * nix: enable parallel building * amend! nix: add miniz to dependencies nix: add miniz to dependencies * nix: remove enableParellelBulding, needSubmodules The flake now includes submodules in inputs, so we no longer need to throw an error when they aren't there. EnableParallelBuilding was added in error, cmake already compiles in parallel when under nix. --- flake.nix | 45 ++++++++++++++++++--------------------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/flake.nix b/flake.nix index 6be8c03aef..c470446113 100644 --- a/flake.nix +++ b/flake.nix @@ -2,6 +2,7 @@ description = "Dusklight — native PC port of the Twilight Princess decompilation"; inputs.nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + inputs.self.submodules = true; outputs = { self, nixpkgs }: @@ -58,21 +59,6 @@ hasNodPrebuilt = nodPrebuiltInfo ? ${system}; aurora = builtins.pathExists "${self}/extern/aurora/CMakeLists.txt"; - needSubmodules = '' - dusklight: The aurora submodule is not vendored. Add submodules=1 to build. - - As a flake input: - - dusklight.url = "git+https://github.com/TwilitRealm/dusklight?ref=main&submodules=1"; - - nix command: - - nix run 'git+https://github.com/TwilitRealm/dusklight?submodules=1' - - Local checkout: - - nix run '.?submodules=1#dusklight' - ''; dawn = pkgs.fetchzip { url = "https://github.com/encounter/dawn/releases/download/${dawnVersion}/dawn-${dawnInfo.${system}.triple}.tar.gz"; @@ -140,6 +126,14 @@ JSON = pkgs.nlohmann_json.src; XXHASH = pkgs.xxhash.src; ZSTD = pkgs.zstd.src; + + + MINIZ = pkgs.fetchzip { + url = "https://github.com/richgel999/miniz/releases/download/3.0.2/miniz-3.0.2.zip"; + hash = "sha256-DXysXkQEmoDAMMg1F8KexkwpXNyiHNzLJqXR9SMEkxk="; + stripRoot = false; + }; + FMT = pkgs.fetchzip { url = "https://github.com/fmtlib/fmt/archive/refs/tags/12.1.0.tar.gz"; hash = "sha256-ZmI1Dv0ZabPlxa02OpERI47jp7zFfjpeWCy1WyuPYZ0="; @@ -165,19 +159,16 @@ }; dusklight = - if !aurora then - throw needSubmodules - else - pkgs.stdenv.mkDerivation { - pname = "dusklight"; - version = versionSuffix; - src = ./.; + pkgs.stdenv.mkDerivation { + pname = "dusklight"; + version = versionSuffix; + src = ./.; - postUnpack = '' - chmod -R u+w "$sourceRoot" - substituteInPlace "$sourceRoot/extern/aurora/CMakeLists.txt" \ - --replace-warn "add_subdirectory(tests)" "" - ''; + postUnpack = '' + chmod -R u+w "$sourceRoot" + substituteInPlace "$sourceRoot/extern/aurora/CMakeLists.txt" \ + --replace-warn "add_subdirectory(tests)" "" + ''; nativeBuildInputs = [ pkgs.cmake From 38459a97b3f173d83e52b2484721e59bc8f63803 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 28 Jul 2026 23:38:49 -0600 Subject: [PATCH 05/19] Fix compile error --- mods/shadow_mod/src/mod.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mods/shadow_mod/src/mod.cpp b/mods/shadow_mod/src/mod.cpp index b14c5903a5..840ebdbd91 100644 --- a/mods/shadow_mod/src/mod.cpp +++ b/mods/shadow_mod/src/mod.cpp @@ -1051,7 +1051,7 @@ MOD_EXPORT ModResult mod_initialize(ModError* error) { if (svc_gfx->register_stage_hook( mod_ctx, GFX_STAGE_SCENE_AFTER_OPAQUE, &stageDesc, &g_sceneAfterOpaqueHook) != MOD_OK) { - return dusk::mods::set_error(error, MOD_ERROR, "failed to register stage hook"); + return mods::set_error(error, MOD_ERROR, "failed to register stage hook"); } stageDesc.callback = on_frame_before_hud; if (svc_gfx->register_stage_hook( From 0cf130d83421a632b05374578d74ed822e5e3276 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Wed, 29 Jul 2026 07:39:59 +0200 Subject: [PATCH 06/19] symgen: specify EXPECTED_HASH to cmake to avoid redownload (#2244) --- cmake/SymbolManifest.cmake | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cmake/SymbolManifest.cmake b/cmake/SymbolManifest.cmake index 0e401db669..7ef57f7ced 100644 --- a/cmake/SymbolManifest.cmake +++ b/cmake/SymbolManifest.cmake @@ -7,35 +7,45 @@ set(_SYMGEN_RELEASE_BASE_URL "https://github.com/encounter/symgen/releases/downl set(SYMGEN_PATH "" CACHE FILEPATH "Path to a symgen executable; empty downloads the pinned release") mark_as_advanced(SYMGEN_PATH) -function(symgen_host_asset out_name) +function(symgen_host_asset out_name out_hash) string(TOLOWER "${CMAKE_HOST_SYSTEM_PROCESSOR}" _host_processor) set(_asset "") + set(_asset_hash "") if (CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") if (_host_processor MATCHES "^(arm64|aarch64)$") set(_asset "symgen-macos-arm64") + set(_asset_hash "SHA256=0344838d1674df09c17c3eeddcf26eb89c333fdb85dfd78f68adc436070eccbe") elseif (_host_processor MATCHES "^(x86_64|amd64)$") set(_asset "symgen-macos-x86_64") + set(_asset_hash "SHA256=ae0674f4a1e9d0dedfa02d35939ac28cdd229276b331c1f507df5df809cbec7e") endif () elseif (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") if (_host_processor MATCHES "^(aarch64|arm64)$") set(_asset "symgen-linux-aarch64") + set(_asset_hash "SHA256=af766de2bfaeb0a06f6d7bc17bb2510b4a9c40f44a56e49bc4a4b798a6223042") elseif (_host_processor MATCHES "^(x86_64|amd64)$") set(_asset "symgen-linux-x86_64") + set(_asset_hash "SHA256=ebd62fb9623acc942b6295609e2306f85a73043b0a8a117f2072b761dd08e68f") elseif (_host_processor MATCHES "^(i[3-6]86|x86)$") set(_asset "symgen-linux-i686") + set(_asset_hash "SHA256=07780a4513fd29726578efc4ff2d88736b22f407ed821b32a68e35ff1e9af5f4") endif () elseif (CMAKE_HOST_WIN32) if (_host_processor MATCHES "^(arm64|aarch64)$") set(_asset "symgen-windows-arm64.exe") + set(_asset_hash "SHA256=5bb22b4a4a9b5ad45646af411bfb09b8321a732ff3a077eb4c9de1feaef27d2b") elseif (_host_processor MATCHES "^(x86_64|amd64)$") set(_asset "symgen-windows-x86_64.exe") + set(_asset_hash "SHA256=1d1ac087f991a96932d108969e15998becfec643e88f3e7d182ca97ebfc6a46f") elseif (_host_processor MATCHES "^(i[3-6]86|x86)$") set(_asset "symgen-windows-x86.exe") + set(_asset_hash "SHA256=c113f4cd05f813efe2b1878dbfcf44d6302aee3974271736e0036430b0cced78") endif () endif () set(${out_name} "${_asset}" PARENT_SCOPE) + set(${out_hash} "${_asset_hash}" PARENT_SCOPE) endfunction() function(ensure_symgen required) @@ -54,7 +64,7 @@ function(ensure_symgen required) return() endif () else () - symgen_host_asset(_asset) + symgen_host_asset(_asset _asset_hash) if (_asset STREQUAL "") if (required) message(FATAL_ERROR "symgen: no prebuilt binary for host " @@ -75,7 +85,8 @@ function(ensure_symgen required) file(DOWNLOAD "${_url}" "${_symgen}" TLS_VERIFY ON STATUS _download_status - SHOW_PROGRESS) + SHOW_PROGRESS + EXPECTED_HASH "${_asset_hash}") list(GET _download_status 0 _download_code) if (NOT _download_code EQUAL 0) list(GET _download_status 1 _download_message) From 48de4bcb08d63aff3e2c0cb560d37b1e8b6d287f Mon Sep 17 00:00:00 2001 From: Irastris Date: Wed, 29 Jul 2026 01:47:13 -0400 Subject: [PATCH 07/19] Gradle build revisions (#2229) * Gradle build revisions * Update platforms/android/README.md --- .github/workflows/build.yml | 3 - platforms/android/README.md | 19 +--- platforms/android/app/build.gradle | 111 +++++++++++++++++-- platforms/android/scripts/stage-jni-libs.sh | 115 -------------------- 4 files changed, 101 insertions(+), 147 deletions(-) delete mode 100755 platforms/android/scripts/stage-jni-libs.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4712b36439..f7abef73e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -208,9 +208,6 @@ jobs: - name: Build bundled mods run: cmake --build --preset ${{matrix.preset}} --target dusklight_mods - - name: Stage stripped JNI library - run: ANDROID_STAGE_ABIS="${{matrix.abi}}" platforms/android/scripts/stage-jni-libs.sh - - name: Build APK working-directory: platforms/android run: ./gradlew :app:assembleRelease --rerun-tasks diff --git a/platforms/android/README.md b/platforms/android/README.md index f478871392..7a5d01ee44 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -21,26 +21,9 @@ export JAVA_HOME="/usr/lib/jvm/java-17-openjdk" ```bash cmake --preset android-arm64 cmake --build --preset android-arm64 - -cmake --preset android-x86_64 -cmake --build --preset android-x86_64 ``` -These builds produce: - -- `build/android-arm64/Binaries/libmain.so` -- `build/android-x86_64/Binaries/libmain.so` - -## Stage Libraries Into APK Project - -```bash -./android/scripts/stage-jni-libs.sh -``` - -This copies: - -- `libmain.so` -> `android/app/src/main/jniLibs/arm64-v8a/` -- `libmain.so` -> `android/app/src/main/jniLibs/x86_64/` +This build produces `build/android-arm64/libmain.so` ## Refresh SDL Java Shim (Optional) diff --git a/platforms/android/app/build.gradle b/platforms/android/app/build.gradle index c7a25e732b..08d04bf35f 100644 --- a/platforms/android/app/build.gradle +++ b/platforms/android/app/build.gradle @@ -6,23 +6,107 @@ def versionNameStr = (System.getenv("DUSK_VERSION") ?: "v0.1.0").replaceFirst("^ def versionCodeInt = (System.getenv("DUSK_VERSION_CODE") ?: "100000").toInteger() def duskRepoDir = rootProject.projectDir.parentFile.parentFile -def duskGeneratedAssetsDir = layout.buildDirectory.dir('generated/assets/dusklight').get().asFile +def androidNativeBuildDir = new File(duskRepoDir, 'build/android-arm64') +def nativeLibrary = new File(androidNativeBuildDir, 'libmain.so') +def stageStripValue = providers.gradleProperty('ANDROID_STAGE_STRIP') + .orElse(providers.gradleProperty('androidStageStrip')) + .orElse(providers.environmentVariable('ANDROID_STAGE_STRIP')) + .orElse('1') +def androidHome = { + def sdkPath = System.getenv('ANDROID_HOME') + if (!sdkPath) { + throw new GradleException('ANDROID_HOME is not available') + } + def sdkDir = new File(sdkPath) + if (!sdkDir.isDirectory()) { + throw new GradleException("ANDROID_HOME points to a missing directory: ${sdkDir}") + } + sdkDir +}.memoize() + +def androidNdkVersion = { + def ndkVersion = System.getenv('ANDROID_NDK_VERSION') + if (!ndkVersion) { + throw new GradleException('ANDROID_NDK_VERSION is not available') + } + ndkVersion +}.memoize() + +def androidNdkDir = { + def ndkDir = new File(androidHome(), "ndk/${androidNdkVersion()}") + if (!new File(ndkDir, 'build/cmake/android.toolchain.cmake').isFile()) { + throw new GradleException( + "Android NDK ${androidNdkVersion()} is missing or invalid at ${ndkDir}" + ) + } + ndkDir +}.memoize() + +def llvmPrebuiltDir = { + def prebuiltRoot = new File(androidNdkDir(), 'toolchains/llvm/prebuilt') + def prebuiltDir = (prebuiltRoot.listFiles()?.findAll { it.isDirectory() } ?: []) + .sort { it.name } + .find { candidate -> + new File( + candidate, + 'sysroot/usr/lib/aarch64-linux-android/libc++_shared.so' + ).isFile() + } + if (!prebuiltDir) { + throw new GradleException("Cannot find NDK libc++_shared.so under ${prebuiltRoot}") + } + prebuiltDir +}.memoize() + +def stlLibrary = { + def library = new File( + llvmPrebuiltDir(), + 'sysroot/usr/lib/aarch64-linux-android/libc++_shared.so' + ) + if (!library.isFile()) { + throw new GradleException("libc++_shared.so is missing") + } + library +}.memoize() + +def stagedJniLibsDir = layout.buildDirectory.dir('generated/jniLibs/dusklight') + +def stageJniLibs = tasks.register('stageJniLibs', Sync) { + group = 'build' + from(nativeLibrary) { + rename { 'libmain.so' } + into 'arm64-v8a' + } + from(providers.provider { stlLibrary() }) { + rename { 'libc++_shared.so' } + into 'arm64-v8a' + } + into(stagedJniLibsDir) + + doFirst { + if (!nativeLibrary.isFile()) { + throw new GradleException("Native library is missing") + } + } +} + +def duskGeneratedAssetsDir = layout.buildDirectory.dir('generated/assets/dusklight') def syncDuskAssets = tasks.register('syncDuskAssets', Sync) { from(new File(duskRepoDir, 'res')) { into 'res' exclude '**/.DS_Store' } - // Staged by platforms/android/scripts/stage-jni-libs.sh - from(new File(projectDir, 'src/main/bundled_mods')) { + from(new File(androidNativeBuildDir, 'bundled_mods')) { into 'mods' include '*.dusk' } - into duskGeneratedAssetsDir + into(duskGeneratedAssetsDir) } android { namespace 'dev.twilitrealm.dusk' compileSdk 36 + ndkVersion androidNdkVersion() defaultConfig { applicationId 'dev.twilitrealm.dusk' @@ -44,16 +128,24 @@ android { sourceSets { main { - jniLibs.srcDirs = ['src/main/jniLibs'] + jniLibs.srcDirs = [stagedJniLibsDir] assets.srcDirs = [duskGeneratedAssetsDir] } } + packaging { + jniLibs { + if (stageStripValue.get() == '0') { + keepDebugSymbols += '**/libmain.so' + } + } + } + splits { abi { enable true reset() - include 'arm64-v8a', 'x86_64' + include 'arm64-v8a' universalApk false } } @@ -67,9 +159,6 @@ dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) } -tasks.configureEach { task -> - if ((task.name.startsWith('merge') && task.name.endsWith('Assets')) || - task.name.toLowerCase().contains('lint')) { - task.dependsOn(syncDuskAssets) - } +tasks.named('preBuild').configure { + dependsOn(stageJniLibs, syncDuskAssets) } diff --git a/platforms/android/scripts/stage-jni-libs.sh b/platforms/android/scripts/stage-jni-libs.sh deleted file mode 100755 index 3984cf03ee..0000000000 --- a/platforms/android/scripts/stage-jni-libs.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/../../.." && pwd)" -APP_DIR="$ROOT_DIR/platforms/android/app/src/main/jniLibs" -ANDROID_HOME_DIR="${ANDROID_HOME:-$HOME/Android/Sdk}" -ANDROID_NDK_VER="${ANDROID_NDK_VERSION:-}" -ANDROID_STAGE_ABIS="${ANDROID_STAGE_ABIS:-arm64-v8a x86_64}" -ANDROID_STAGE_STRIP="${ANDROID_STAGE_STRIP:-1}" -STRIP_TOOL="" - -if [[ -z "$ANDROID_NDK_VER" ]] && [[ -d "$ANDROID_HOME_DIR/ndk" ]]; then - ANDROID_NDK_VER="$(ls -1 "$ANDROID_HOME_DIR/ndk" | sort -V | tail -n 1)" -fi - -if [[ -n "$ANDROID_NDK_VER" ]]; then - 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 - -copy_lib() { - local abi="$1" - 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" "$tmp" - if [[ "$ANDROID_STAGE_STRIP" != "0" ]] && [[ -n "$STRIP_TOOL" ]]; then - "$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 -} - -# 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 - case "$abi" in - arm64-v8a) - src="$ROOT_DIR/build/android-arm64/libmain.so" - triple="aarch64-linux-android" - ;; - x86_64) - src="$ROOT_DIR/build/android-x86_64/libmain.so" - triple="x86_64-linux-android" - ;; - *) - echo "Unsupported ABI '$abi'. Supported ABIs: arm64-v8a x86_64" >&2 - exit 1 - ;; - esac - copy_lib "$abi" "$src" - if [[ -n "$STRIP_TOOL" ]]; then - stl="$(dirname "$STRIP_TOOL")/../sysroot/usr/lib/$triple/libc++_shared.so" - if [[ -f "$stl" ]]; then - cp -f "$stl" "$APP_DIR/$abi/libc++_shared.so" - echo "Staged $stl -> $APP_DIR/$abi/libc++_shared.so" - else - echo "Missing libc++_shared.so for $abi at $stl" >&2 - exit 1 - fi - else - echo "Cannot stage libc++_shared.so for $abi (NDK not found)" >&2 - exit 1 - fi -done - -# Stage bundled mod packages into the app's assets source dir. -MODS_STAGING_DIR="$ROOT_DIR/platforms/android/app/src/main/bundled_mods" -rm -rf "$MODS_STAGING_DIR" -mkdir -p "$MODS_STAGING_DIR" -for abi in $ANDROID_STAGE_ABIS; do - case "$abi" in - arm64-v8a) build_dir="$ROOT_DIR/build/android-arm64" ;; - x86_64) build_dir="$ROOT_DIR/build/android-x86_64" ;; - esac - [[ -d "$build_dir/bundled_mods" ]] || continue - for pkg in "$build_dir/bundled_mods"/*.dusk; do - [[ -f "$pkg" ]] || continue - name="$(basename "$pkg")" - if [[ ! -f "$MODS_STAGING_DIR/$name" ]]; then - cp -f "$pkg" "$MODS_STAGING_DIR/$name" - echo "Staged bundled mod $pkg" - else - stage_dir="$build_dir/mods/${name%.dusk}/${name%.dusk}_stage" - (cd "$stage_dir" && zip -q -r "$MODS_STAGING_DIR/$name" lib) - echo "Appended $abi libraries to bundled mod $name" - fi - done -done From 7305ef09b9a021565666b648b67cdcbe367b73f4 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Thu, 30 Jul 2026 00:24:24 -0600 Subject: [PATCH 08/19] Mods: WindowService, log wrappers, external rendering (#2251) --- CMakeLists.txt | 1 + cmake/GameABIConfig.cmake | 4 + cmake/ModSDK.cmake | 28 +- docs/modding.md | 78 +++- extern/aurora | 2 +- files.cmake | 2 + mods/shadow_mod/src/mod.cpp | 289 ++++++++++++-- mods/window_demo/CMakeLists.txt | 20 + mods/window_demo/mod.json | 7 + mods/window_demo/src/logging.cpp | 35 ++ mods/window_demo/src/logging.hpp | 9 + mods/window_demo/src/mod.cpp | 229 ++++++++++++ sdk/CMakeLists.txt | 2 +- sdk/include/mods/api.h | 18 +- sdk/include/mods/hook.hpp | 213 ++--------- sdk/include/mods/service.hpp | 4 +- sdk/include/mods/svc/camera.h | 16 +- sdk/include/mods/svc/config.h | 16 +- sdk/include/mods/svc/game.h | 15 +- sdk/include/mods/svc/gfx.h | 84 ++++- sdk/include/mods/svc/hook.h | 21 +- sdk/include/mods/svc/hook.hpp | 242 ++++++++++++ sdk/include/mods/svc/host.h | 15 +- sdk/include/mods/svc/log.h | 15 +- sdk/include/mods/svc/log.hpp | 46 +++ sdk/include/mods/svc/overlay.h | 16 +- sdk/include/mods/svc/resource.h | 16 +- sdk/include/mods/svc/texture.h | 20 +- sdk/include/mods/svc/ui.h | 15 +- sdk/include/mods/svc/window.h | 103 +++++ src/dusk/mods/svc/gfx.cpp | 623 +++++++++++++++++++++++++++++-- src/dusk/mods/svc/registry.cpp | 1 + src/dusk/mods/svc/registry.hpp | 1 + src/dusk/mods/svc/window.cpp | 352 +++++++++++++++++ src/dusk/mods/svc/window.hpp | 21 ++ src/m_Do/m_Do_main.cpp | 11 +- 36 files changed, 2195 insertions(+), 395 deletions(-) create mode 100644 mods/window_demo/CMakeLists.txt create mode 100644 mods/window_demo/mod.json create mode 100644 mods/window_demo/src/logging.cpp create mode 100644 mods/window_demo/src/logging.hpp create mode 100644 mods/window_demo/src/mod.cpp create mode 100644 sdk/include/mods/svc/hook.hpp create mode 100644 sdk/include/mods/svc/log.hpp create mode 100644 sdk/include/mods/svc/window.h create mode 100644 src/dusk/mods/svc/window.cpp create mode 100644 src/dusk/mods/svc/window.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b1c8986597..a4a3d2cc8f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -610,6 +610,7 @@ if (DUSK_ENABLE_CODE_MODS AND CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR add_subdirectory(mods/template_mod) add_subdirectory(mods/ao_mod) add_subdirectory(mods/shadow_mod) + add_subdirectory(mods/window_demo) endif () if (APPLE) diff --git a/cmake/GameABIConfig.cmake b/cmake/GameABIConfig.cmake index 0b965aa082..ef3b5818dc 100644 --- a/cmake/GameABIConfig.cmake +++ b/cmake/GameABIConfig.cmake @@ -62,3 +62,7 @@ target_sources(dusklight_mod_feature_game INTERFACE add_library(dusklight_mod_feature_webgpu INTERFACE) target_link_libraries(dusklight_mod_feature_webgpu INTERFACE dusklight_mod_api) target_compile_definitions(dusklight_mod_feature_webgpu INTERFACE DUSK_MOD_FEATURE_WEBGPU=1) + +add_library(dusklight_mod_feature_fmt INTERFACE) +target_link_libraries(dusklight_mod_feature_fmt INTERFACE dusklight_mod_api) +target_compile_definitions(dusklight_mod_feature_fmt INTERFACE DUSK_MOD_FEATURE_FMT=1) diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index 0dfe5c48b4..da9aa51f38 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -113,6 +113,29 @@ function(_mod_add_webgpu_headers target_name) endif () endfunction() +function(_mod_add_fmt target_name) + if (NOT TARGET fmt::fmt-header-only) + find_package(fmt 11 CONFIG QUIET GLOBAL) + endif () + + if (NOT TARGET fmt::fmt-header-only) + include(FetchContent) + message(STATUS "Mod SDK: fetching fmt") + # Keep the fallback version in sync with extern/aurora/extern/CMakeLists.txt. + FetchContent_Declare(fmt + URL https://github.com/fmtlib/fmt/archive/refs/tags/12.1.0.tar.gz + URL_HASH SHA256=ea7de4299689e12b6dddd392f9896f08fb0777ac7168897a244a6d6085043fea + DOWNLOAD_EXTRACT_TIMESTAMP FALSE + EXCLUDE_FROM_ALL) + FetchContent_MakeAvailable(fmt) + endif () + + if (NOT TARGET fmt::fmt-header-only) + message(FATAL_ERROR "add_mod: FEATURES fmt could not provide fmt::fmt-header-only") + endif () + target_link_libraries(${target_name} PRIVATE fmt::fmt-header-only) +endfunction() + function(add_mod target_name) cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OVERLAY_DIR;TEXTURES_DIR;OUTPUT_DIR" "SOURCES;RUNTIME_LIBRARIES;FEATURES" ${ARGN}) @@ -127,7 +150,7 @@ function(add_mod target_name) message(FATAL_ERROR "add_mod: MOD_JSON does not exist: ${_mod_json}") endif () - set(_supported_features game webgpu) + set(_supported_features fmt game webgpu) set(_features "") foreach (_feature IN LISTS ARG_FEATURES) list(FIND _supported_features "${_feature}" _feature_index) @@ -167,6 +190,9 @@ function(add_mod target_name) if (_feature STREQUAL "webgpu") _mod_add_webgpu_headers(${target_name}) endif () + if (_feature STREQUAL "fmt") + _mod_add_fmt(${target_name}) + endif () if (_feature STREQUAL "game" OR _feature STREQUAL "webgpu") set(_needs_host_link TRUE) endif () diff --git a/docs/modding.md b/docs/modding.md index 2600b4721d..80e1998ea5 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -54,7 +54,7 @@ include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/FetchDusklight.cmake") add_subdirectory("${DUSKLIGHT_DIR}/sdk" dusklight-sdk EXCLUDE_FROM_ALL) add_mod(my_mod - FEATURES game # remove for service/asset-only mods; add webgpu for GfxService + FEATURES game fmt # remove game for service-only mods; add webgpu for GfxService SOURCES src/mod.cpp MOD_JSON mod.json RES_DIR res # mod resources, including icon.png and banner.png @@ -64,6 +64,8 @@ add_mod(my_mod ``` Available features: + +- `fmt`: Provides the header-only `{fmt}` library and the formatted logging helpers in `mods/svc/log.hpp`. - `game`: Allows calling into and hooking game code. Mods that **only** use services may omit it, providing a wider range of compatibility with Dusklight versions and a slightly faster build process. - `webgpu`: Allows importing the WebGPU API (`webgpu/webgpu.h`). Must be enabled when using @@ -143,6 +145,9 @@ IMPORT_SERVICE_VERSION(LogService, svc_log, 0); // required, minimum minor ver IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null ``` +A service must be imported in only **one** file (usually your `mod.cpp`). Other files may simply use `svc_log` or +`mods::log::` after including the appropriate header. + Each service is individually versioned, and there may be multiple major versions of a service provided at once, allowing backwards compatibility with older mods while still changing services fundamentally if necessary. A **major** bump is a breaking change, treated as a different service entirely. For **additive** changes, a service appends new @@ -179,7 +184,15 @@ svc_log->write(mod_ctx, LOG_LEVEL_DEBUG, "verbose details"); ``` Messages appear in the console prefixed with your mod ID. Messages are plain UTF-8 strings and are copied before the -call returns; use `snprintf` or `fmt::format` for formatting. +call returns. C++ mods can enable `add_mod(... FEATURES fmt)` and use the formatted logging helpers in +`mods/svc/log.hpp`: + +```cpp +#include + +mods::log::info("spawned actor {} at ({}, {})", actorName, x, y); +mods::log::warn("health is down to {:.1f}%", healthPercent); +``` ### ResourceService (`mods/svc/resource.h`) @@ -236,7 +249,7 @@ every service dropped its state. For your own mod's teardown, use `mod_shutdown` ### HookService (`mods/svc/hook.h`) Installs hooks on game functions and resolves symbols by name. You'll rarely call it directly; use the typed helpers in -`mods/hook.hpp` described in [Hooking Game Functions](#hooking-game-functions). +`mods/svc/hook.hpp` described in [Hooking Game Functions](#hooking-game-functions). ### OverlayService (`mods/svc/overlay.h`) @@ -420,6 +433,26 @@ existing documents restyle immediately, and future ones pick it up when created. host styles and may override them. Scope selectors tightly (use `[mod-id="..."]`!), especially for `UI_SCOPE_WINDOW`, unless changing host UI is intentional. +### WindowService (`mods/svc/window.h`) + +Allows creating new windows that can be rendered to via `GfxService`. + +```cpp +IMPORT_SERVICE(WindowService, svc_window); + +WindowDesc desc = WINDOW_DESC_INIT; +desc.title = "My auxiliary view"; +desc.on_event = on_window_event; +WindowHandle window = 0; +svc_window->create_window(mod_ctx, &desc, &window); +``` + +Window callbacks run on the game thread. A close event is only a request; call `destroy_window` when the mod is ready to +close it. A window attached to a GfxService present target cannot be destroyed until that target is unregistered. Only +one present target may be attached to a WindowService window at a time. + +New windows are hidden by default so a mod can finish attaching graphics before calling `show_window`. + ### GfxService (`mods/svc/gfx.h`) **Requires `add_mod(... FEATURES webgpu)`** @@ -451,6 +484,30 @@ registered with `register_compute_type` follow the same worker-thread rule and r All WGPU handles from the service are borrowed. Resolved target views are valid for the current frame only. GPU objects created by a mod are owned by that mod and should be released in `mod_shutdown`. +#### External presentation + +GfxService supports external presentation ("present targets") backed by either a WindowService window (via +`register_window_present_target`) or a plain `WGPUSurface` (via `register_present_target`). + +```cpp +GfxPresentTargetDesc target_desc = GFX_PRESENT_TARGET_DESC_INIT; +target_desc.render = render_auxiliary_view; +GfxPresentTargetHandle target = 0; +svc_gfx->register_window_present_target(mod_ctx, window, &target_desc, &target); + +// From a stage callback: +svc_gfx->push_present(mod_ctx, target, &payload, sizeof(payload)); +``` + +For WindowService windows, the surface is automatically reconfigured on window size changes. +For plain `WBPUSurface`s, `resize_present_target` must be used to resize. + +To create a `WGPUSurface` manually, `GfxDeviceInfo` holds the `WGPUInstance` and `WGPUAdapter` which can be used with +`wgpuInstanceCreateSurface` and a chained `WGPUSurfaceSource*` struct. + +`push_present` must be called every frame from a GfxService stage callback. If surface was lost, `push_present` returns +`MOD_ERROR`. Unregister and re-register the target before trying again. + ### CameraService (`mods/svc/camera.h`) Converts a game view provided by a render callback into WebGPU-convention camera data. Matrix fields are column-major @@ -477,11 +534,10 @@ first in-game frame. Projection matrices match the renderer's WebGPU clip conven **Requires `add_mod(... FEATURES game)`** Mods may hook the vast majority of game functions, including file-local static, private and virtual functions. -`mods/hook.hpp` provides typed helpers over the hook service: +`mods/svc/hook.hpp` provides typed helpers over the hook service: ```cpp -#include "mods/hook.hpp" -#include "mods/svc/hook.h" +#include "mods/svc/hook.hpp" IMPORT_SERVICE(HookService, svc_hook); @@ -505,7 +561,7 @@ HookAction on_pos_move_pre(ModContext*, void* args, void* retval, void* userdata return HOOK_CONTINUE; } -mods::hook_add_pre(svc_hook, on_pos_move_pre); +mods::hook::add_pre(on_pos_move_pre); ``` ### Post-hooks @@ -516,7 +572,7 @@ if any. ```cpp void on_pos_move_post(ModContext*, void* args, void* retval, void* userdata) { ... } -mods::hook_add_post(svc_hook, on_pos_move_post); +mods::hook::add_post(on_pos_move_post); ``` ### Replace-hooks @@ -531,7 +587,7 @@ void on_execute_replace(ModContext*, void* args, void* retval, void*) { } } -mods::hook_replace(svc_hook, on_execute_replace); +mods::hook::replace(on_execute_replace); ``` By default a second replace-hook on the same function is a conflict; `HookOptions` (`replace_policy`, `priority`, @@ -547,7 +603,7 @@ symbol name instead. You must supply the signature along with the name. DEFINE_HOOK_SYMBOL("daAlink_hookshotAtHitCallBack", void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*), HookshotHit); -mods::hook_add_pre(svc_hook, on_hookshot_hit_pre); +mods::hook::add_pre(on_hookshot_hit_pre); ... HookshotHit::g_orig(link, atObjInf, target, tgObjInf); // call through to the original ``` @@ -587,7 +643,7 @@ HookAction on_create_item_pre(ModContext*, void* args, void*, void*) { return HOOK_CONTINUE; } -mods::hook_add_pre(svc_hook, on_create_item_pre); +mods::hook::add_pre(on_create_item_pre); ``` For reference parameters (e.g. `const cXyz& pos`), `arg_ref` yields a direct reference. diff --git a/extern/aurora b/extern/aurora index 81f12f31d2..0bddb86249 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 81f12f31d23ec822d8bde2031c91e94c470911eb +Subproject commit 0bddb8624905d74cc47202a71962f9c9834c0933 diff --git a/files.cmake b/files.cmake index fa96e313c6..7eb8ba6038 100644 --- a/files.cmake +++ b/files.cmake @@ -1500,6 +1500,8 @@ set(DUSK_FILES src/dusk/mods/svc/texture.cpp src/dusk/mods/svc/ui.cpp src/dusk/mods/svc/ui.hpp + src/dusk/mods/svc/window.cpp + src/dusk/mods/svc/window.hpp src/dusk/mouse.cpp src/dusk/scope_guard.hpp src/dusk/settings.cpp diff --git a/mods/shadow_mod/src/mod.cpp b/mods/shadow_mod/src/mod.cpp index 840ebdbd91..36f33de41d 100644 --- a/mods/shadow_mod/src/mod.cpp +++ b/mods/shadow_mod/src/mod.cpp @@ -20,7 +20,7 @@ #include "dolphin/gx/GXPixel.h" #include "dolphin/gx/GXTransform.h" #include "m_Do/m_Do_mtx.h" -#include "mods/hook.hpp" +#include "mods/svc/hook.hpp" #include "mods/service.hpp" #include "mods/svc/camera.h" #include "mods/svc/config.h" @@ -29,6 +29,7 @@ #include "mods/svc/log.h" #include "mods/svc/resource.h" #include "mods/svc/ui.h" +#include "mods/svc/window.h" #include #include @@ -45,6 +46,7 @@ IMPORT_SERVICE(GfxService, svc_gfx); IMPORT_SERVICE(CameraService, svc_camera); IMPORT_SERVICE(HookService, svc_hook); IMPORT_SERVICE(LogService, svc_log); +IMPORT_SERVICE(WindowService, svc_window); namespace { @@ -71,6 +73,11 @@ WGPURenderPipeline g_compositePipeline = nullptr; // multiply blend WGPURenderPipeline g_compositeDebugPipeline = nullptr; // no blend (debug views) WGPUBindGroupLayout g_compositeLayout = nullptr; WGPUBindGroupLayout g_compositeDebugLayout = nullptr; +WGPURenderPipeline g_debugPresentPipeline = nullptr; +WGPUBindGroupLayout g_debugPresentLayout = nullptr; +WGPUTextureFormat g_debugPresentFormat = WGPUTextureFormat_Undefined; +WindowHandle g_debugWindow = 0; +GfxPresentTargetHandle g_debugPresentTarget = 0; struct MapPassOutput { bool ready = false; @@ -188,6 +195,34 @@ int64_t get_debug_mode() { return std::clamp(get_int_option(g_cvarDebugView, 0), 0, 10); } +bool debug_window_open() { + return g_debugWindow != 0 && g_debugPresentTarget != 0; +} + +ModResult close_debug_window() { + if (g_debugPresentTarget != 0) { + const auto result = svc_gfx->unregister_present_target(mod_ctx, g_debugPresentTarget); + if (result != MOD_OK) { + return result; + } + g_debugPresentTarget = 0; + } + if (g_debugWindow != 0) { + const auto result = svc_window->destroy_window(mod_ctx, g_debugWindow); + if (result != MOD_OK) { + return result; + } + g_debugWindow = 0; + } + return MOD_OK; +} + +void on_debug_window_event(ModContext*, WindowHandle, const WindowEvent* event, void*) { + if (event->type == WINDOW_EVENT_CLOSE_REQUESTED && close_debug_window() != MOD_OK) { + svc_log->error(mod_ctx, "failed to close shadow debug window"); + } +} + bool matrix_ready(const Mtx m) { float basis = 0.0f; for (int r = 0; r < 3; ++r) { @@ -396,6 +431,90 @@ bool build_composite_pipeline( return outLayout != nullptr; } +void release_debug_present_pipeline() { + if (g_debugPresentPipeline != nullptr) { + wgpuRenderPipelineRelease(g_debugPresentPipeline); + g_debugPresentPipeline = nullptr; + } + if (g_debugPresentLayout != nullptr) { + wgpuBindGroupLayoutRelease(g_debugPresentLayout); + g_debugPresentLayout = nullptr; + } + g_debugPresentFormat = WGPUTextureFormat_Undefined; +} + +bool ensure_debug_present_pipeline(const GfxPresentContext& ctx) { + if (g_debugPresentPipeline != nullptr && g_debugPresentFormat == ctx.target_format) { + return true; + } + release_debug_present_pipeline(); + + WGPUShaderSourceWGSL wgsl = WGPU_SHADER_SOURCE_WGSL_INIT; + wgsl.code = {static_cast(g_shaderSource.data), g_shaderSource.size}; + WGPUShaderModuleDescriptor moduleDesc = WGPU_SHADER_MODULE_DESCRIPTOR_INIT; + moduleDesc.nextInChain = &wgsl.chain; + moduleDesc.label = {"shadow debug present", WGPU_STRLEN}; + WGPUShaderModule module = wgpuDeviceCreateShaderModule(ctx.device, &moduleDesc); + if (module == nullptr) { + return false; + } + + WGPUColorTargetState colorTarget = WGPU_COLOR_TARGET_STATE_INIT; + colorTarget.format = ctx.target_format; + WGPUFragmentState fragment = WGPU_FRAGMENT_STATE_INIT; + fragment.module = module; + fragment.entryPoint = {"fs_main", WGPU_STRLEN}; + fragment.targetCount = 1; + fragment.targets = &colorTarget; + + WGPURenderPipelineDescriptor pipelineDesc = WGPU_RENDER_PIPELINE_DESCRIPTOR_INIT; + pipelineDesc.label = {"shadow debug present", WGPU_STRLEN}; + pipelineDesc.vertex.module = module; + pipelineDesc.vertex.entryPoint = {"vs_main", WGPU_STRLEN}; + pipelineDesc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + pipelineDesc.multisample.count = 1; + pipelineDesc.fragment = &fragment; + g_debugPresentPipeline = wgpuDeviceCreateRenderPipeline(ctx.device, &pipelineDesc); + wgpuShaderModuleRelease(module); + if (g_debugPresentPipeline == nullptr) { + return false; + } + g_debugPresentLayout = wgpuRenderPipelineGetBindGroupLayout(g_debugPresentPipeline, 0); + if (g_debugPresentLayout == nullptr) { + release_debug_present_pipeline(); + return false; + } + g_debugPresentFormat = ctx.target_format; + return true; +} + +WGPUBindGroup create_composite_bind_group(WGPUDevice device, WGPUBindGroupLayout layout, + WGPUBuffer uniformBuffer, const DrawPayload& data) { + if (data.sceneDepth == nullptr || data.shadowMap == nullptr || data.lightColor == nullptr || + layout == nullptr || uniformBuffer == nullptr) + { + return nullptr; + } + + WGPUBindGroupEntry entries[4] = {WGPU_BIND_GROUP_ENTRY_INIT, WGPU_BIND_GROUP_ENTRY_INIT, + WGPU_BIND_GROUP_ENTRY_INIT, WGPU_BIND_GROUP_ENTRY_INIT}; + entries[0].binding = 0; + entries[0].textureView = data.sceneDepth; + entries[1].binding = 1; + entries[1].textureView = data.shadowMap; + entries[2].binding = 2; + entries[2].buffer = uniformBuffer; + entries[2].offset = data.uniform_offset; + entries[2].size = data.uniform_size; + entries[3].binding = 3; + entries[3].textureView = data.lightColor; + WGPUBindGroupDescriptor bindGroupDesc = WGPU_BIND_GROUP_DESCRIPTOR_INIT; + bindGroupDesc.layout = layout; + bindGroupDesc.entryCount = 4; + bindGroupDesc.entries = entries; + return wgpuDeviceCreateBindGroup(device, &bindGroupDesc); +} + // Render worker thread: fullscreen deferred-shadow composite. void on_draw( ModContext*, const GfxDrawContext* ctx, const void* payload, size_t payloadSize, void*) { @@ -408,29 +527,12 @@ void on_draw( WGPURenderPipeline pipeline = data.debug_mode != 0 ? g_compositeDebugPipeline : g_compositePipeline; WGPUBindGroupLayout layout = data.debug_mode != 0 ? g_compositeDebugLayout : g_compositeLayout; - if (data.sceneDepth == nullptr || data.shadowMap == nullptr || data.lightColor == nullptr || - pipeline == nullptr) - { + if (pipeline == nullptr) { return; } - WGPUBindGroupEntry entries[4] = {WGPU_BIND_GROUP_ENTRY_INIT, WGPU_BIND_GROUP_ENTRY_INIT, - WGPU_BIND_GROUP_ENTRY_INIT, WGPU_BIND_GROUP_ENTRY_INIT}; - entries[0].binding = 0; - entries[0].textureView = data.sceneDepth; - entries[1].binding = 1; - entries[1].textureView = data.shadowMap; - entries[2].binding = 2; - entries[2].buffer = ctx->uniform_buffer; - entries[2].offset = data.uniform_offset; - entries[2].size = data.uniform_size; - entries[3].binding = 3; - entries[3].textureView = data.lightColor; - WGPUBindGroupDescriptor bindGroupDesc = WGPU_BIND_GROUP_DESCRIPTOR_INIT; - bindGroupDesc.layout = layout; - bindGroupDesc.entryCount = 4; - bindGroupDesc.entries = entries; - WGPUBindGroup bindGroup = wgpuDeviceCreateBindGroup(ctx->device, &bindGroupDesc); + WGPUBindGroup bindGroup = + create_composite_bind_group(ctx->device, layout, ctx->uniform_buffer, data); if (bindGroup == nullptr) { return; } @@ -441,6 +543,81 @@ void on_draw( wgpuBindGroupRelease(bindGroup); } +// Render worker thread: draw the selected diagnostic into the auxiliary surface. +void on_debug_present( + ModContext*, const GfxPresentContext* ctx, const void* payload, size_t payloadSize, void*) { + WGPUBindGroup bindGroup = nullptr; + if (payloadSize == sizeof(DrawPayload)) { + DrawPayload data; + std::memcpy(&data, payload, sizeof(data)); + if (ensure_debug_present_pipeline(*ctx)) { + bindGroup = create_composite_bind_group( + ctx->device, g_debugPresentLayout, ctx->uniform_buffer, data); + } + } + + WGPURenderPassColorAttachment colorAttachment = WGPU_RENDER_PASS_COLOR_ATTACHMENT_INIT; + colorAttachment.view = ctx->target_view; + colorAttachment.loadOp = WGPULoadOp_Clear; + colorAttachment.storeOp = WGPUStoreOp_Store; + colorAttachment.clearValue = WGPUColor{0.0, 0.0, 0.0, 1.0}; + WGPURenderPassDescriptor passDesc = WGPU_RENDER_PASS_DESCRIPTOR_INIT; + passDesc.label = {"shadow debug present", WGPU_STRLEN}; + passDesc.colorAttachmentCount = 1; + passDesc.colorAttachments = &colorAttachment; + WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(ctx->encoder, &passDesc); + + if (bindGroup != nullptr) { + wgpuRenderPassEncoderSetPipeline(pass, g_debugPresentPipeline); + wgpuRenderPassEncoderSetBindGroup(pass, 0, bindGroup, 0, nullptr); + wgpuRenderPassEncoderDraw(pass, 3, 1, 0, 0); + wgpuBindGroupRelease(bindGroup); + } + + wgpuRenderPassEncoderEnd(pass); + wgpuRenderPassEncoderRelease(pass); +} + +ModResult open_debug_window() { + if (g_debugWindow != 0) { + return MOD_CONFLICT; + } + + WindowDesc windowDesc = WINDOW_DESC_INIT; + windowDesc.title = "Shadow Debug View"; + windowDesc.width = 720; + windowDesc.height = 480; + windowDesc.on_event = on_debug_window_event; + auto result = svc_window->create_window(mod_ctx, &windowDesc, &g_debugWindow); + if (result != MOD_OK) { + return result; + } + + GfxPresentTargetDesc presentDesc = GFX_PRESENT_TARGET_DESC_INIT; + presentDesc.label = "Shadow debug surface"; + presentDesc.render = on_debug_present; + result = svc_gfx->register_window_present_target( + mod_ctx, g_debugWindow, &presentDesc, &g_debugPresentTarget); + if (result != MOD_OK) { + close_debug_window(); + return result; + } + + result = svc_window->show_window(mod_ctx, g_debugWindow); + if (result != MOD_OK) { + close_debug_window(); + } + return result; +} + +void on_toggle_debug_window(ModContext*, void*) { + const auto result = debug_window_open() ? close_debug_window() : open_debug_window(); + if (result != MOD_OK) { + svc_log->error(mod_ctx, debug_window_open() ? "failed to close shadow debug window" : + "failed to open shadow debug window"); + } +} + // Picks the sun or moon (whichever is above the horizon) and returns the normalized // world-space direction *toward* the light plus a horizon fade factor. False = no light. bool compute_light(float outDirToLight[3], float& outFade) { @@ -596,7 +773,7 @@ void restore_actual_light_debug() { void on_scene_begin(ModContext*, const GfxStageContext* stageCtx, void*) { restore_actual_light_debug(); capture_scene_camera(stageCtx); - if (!get_bool_option(g_cvarEnabled, true) || get_debug_mode() != 9) { + if (!get_bool_option(g_cvarEnabled, true) || get_debug_mode() != 9 || debug_window_open()) { return; } @@ -654,7 +831,7 @@ void render_shadow_map( return; } const int64_t debugMode = get_debug_mode(); - if (debugMode == 9) { + if (debugMode == 9 && !debug_window_open()) { return; } if (!matrix_ready(replayView)) { @@ -753,16 +930,27 @@ void render_shadow_map( // Game thread, after opaque scene draws and before translucent/fog overlays: deferred composite. void on_scene_after_opaque(ModContext*, const GfxStageContext*, void*) { const int64_t debugMode = get_debug_mode(); + const bool presentDebug = debug_window_open(); restore_actual_light_debug(); + if (presentDebug && debugMode == 0) { + svc_gfx->push_present(mod_ctx, g_debugPresentTarget, nullptr, 0); + } + const MapPassOutput mapPass = std::exchange(g_mapPass, {}); - if (debugMode == 9) { + if (debugMode == 9 && !debug_window_open()) { return; } if (!mapPass.ready || mapPass.shadowMap == nullptr || mapPass.lightColor == nullptr) { + if (presentDebug && debugMode != 0) { + svc_gfx->push_present(mod_ctx, g_debugPresentTarget, nullptr, 0); + } return; } if (!g_sceneCamera.valid) { + if (presentDebug && debugMode != 0) { + svc_gfx->push_present(mod_ctx, g_debugPresentTarget, nullptr, 0); + } return; } const CameraInfo& camera = g_sceneCamera.info; @@ -774,6 +962,9 @@ void on_scene_after_opaque(ModContext*, const GfxStageContext*, void*) { if (svc_gfx->resolve_pass(mod_ctx, &resolveDesc, &resolved) != MOD_OK || resolved.depth == nullptr) { + if (presentDebug && debugMode != 0) { + svc_gfx->push_present(mod_ctx, g_debugPresentTarget, nullptr, 0); + } return; } @@ -807,15 +998,31 @@ void on_scene_after_opaque(ModContext*, const GfxStageContext*, void*) { uniforms.contact_enabled = get_bool_option(g_cvarContactShadows, false) ? 1.0f : 0.0f; uniforms.contact_thickness = 25.0f; uniforms.contact_length = 60.0f; - uniforms.debug_mode = static_cast(debugMode); + // Camera Replay intentionally uses the gameplay-camera offscreen pass instead of the light + // shadow map, so it remains diagnostic on both windows. Other external diagnostics leave the + // main window on the normal shadow composite. + uniforms.debug_mode = presentDebug && debugMode != 10 ? 0u : static_cast(debugMode); GfxRange uniformRange{0, 0}; if (svc_gfx->push_uniform(mod_ctx, &uniforms, sizeof(uniforms), &uniformRange) != MOD_OK) { return; } const DrawPayload payload{resolved.depth, mapPass.shadowMap, mapPass.lightColor, - uniformRange.offset, uniformRange.size, static_cast(debugMode)}; + uniformRange.offset, uniformRange.size, uniforms.debug_mode}; svc_gfx->push_draw(mod_ctx, g_drawType, &payload, sizeof(payload)); + + if (presentDebug && debugMode != 0) { + uniforms.debug_mode = static_cast(debugMode); + GfxRange debugUniformRange{0, 0}; + if (svc_gfx->push_uniform(mod_ctx, &uniforms, sizeof(uniforms), &debugUniformRange) != + MOD_OK) + { + return; + } + const DrawPayload debugPayload{resolved.depth, mapPass.shadowMap, mapPass.lightColor, + debugUniformRange.offset, debugUniformRange.size, uniforms.debug_mode}; + svc_gfx->push_present(mod_ctx, g_debugPresentTarget, &debugPayload, sizeof(debugPayload)); + } } // Frame tail hook: only needed to restore light-view debug camera state before HUD. @@ -905,6 +1112,14 @@ ModResult build_controls_tab( "Bounds: valid X in red, valid Y in green, and valid depth in blue
Light View: " "renders the game world directly from the light camera
Camera Replay: " "captures the same draw-list replay from the gameplay camera"); + UiControlDesc debugWindowControl = UI_CONTROL_DESC_INIT; + debugWindowControl.kind = UI_CONTROL_BUTTON; + debugWindowControl.label = "Open / Close Debug Window"; + debugWindowControl.help_rml = + "Shows the selected debug view in an auxiliary WebGPU window. Standard diagnostics leave " + "the main view on the normal shadow composite."; + debugWindowControl.on_pressed = on_toggle_debug_window; + add_control(left, debugWindowControl); return MOD_OK; } @@ -941,6 +1156,12 @@ ModResult build_panel(ModContext*, UiElementHandle panel, void*, ModError*) { control.label = "Open Controls"; control.on_pressed = on_open_controls; add_control(panel, control); + + control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_BUTTON; + control.label = "Open / Close Debug Window"; + control.on_pressed = on_toggle_debug_window; + add_control(panel, control); return MOD_OK; } @@ -1063,18 +1284,18 @@ MOD_EXPORT ModResult mod_initialize(ModError* error) { // Skip the game's own shadow rendering while the dynamic pass is active: the // shadowControl pair covers the actor real/blob shadows, drawCloudShadow the weather // cloud shadows. - if (mods::hook_add_pre(svc_hook, on_game_shadow_pre) != MOD_OK || - mods::hook_add_pre(svc_hook, on_game_shadow_pre) != MOD_OK || - mods::hook_add_pre(svc_hook, on_game_shadow_pre) != MOD_OK) + if (mods::hook::add_pre(on_game_shadow_pre) != MOD_OK || + mods::hook::add_pre(on_game_shadow_pre) != MOD_OK || + mods::hook::add_pre(on_game_shadow_pre) != MOD_OK) { return mods::set_error(error, MOD_ERROR, "failed to hook game shadow rendering"); } - if (mods::hook_add_pre(svc_hook, on_frustum_clip_pre) != MOD_OK || - mods::hook_add_pre(svc_hook, on_frustum_clip_pre) != MOD_OK) + if (mods::hook::add_pre(on_frustum_clip_pre) != MOD_OK || + mods::hook::add_pre(on_frustum_clip_pre) != MOD_OK) { return mods::set_error(error, MOD_ERROR, "failed to hook frustum clipping"); } - if (mods::hook_add_pre(svc_hook, on_copy_tex_pre) != MOD_OK) { + if (mods::hook::add_pre(on_copy_tex_pre) != MOD_OK) { return mods::set_error(error, MOD_ERROR, "failed to hook GXCopyTex"); } UiModsPanelDesc panelDesc = UI_MODS_PANEL_DESC_INIT; @@ -1090,6 +1311,8 @@ MOD_EXPORT ModResult mod_update(ModError*) { MOD_EXPORT ModResult mod_shutdown(ModError*) { restore_actual_light_debug(); + close_debug_window(); + release_debug_present_pipeline(); svc_resource->free(mod_ctx, &g_shaderSource); if (g_compositePipeline != nullptr) { wgpuRenderPipelineRelease(g_compositePipeline); @@ -1114,6 +1337,8 @@ MOD_EXPORT ModResult mod_shutdown(ModError*) { g_drawType = g_sceneBeginHook = g_sceneAfterTerrainHook = g_sceneAfterOpaqueHook = g_frameBeforeHudHook = 0; g_controlsWindow = 0; + g_debugWindow = 0; + g_debugPresentTarget = 0; g_mapPass = {}; g_sceneCamera.valid = false; g_sceneCamera.raw_valid = false; diff --git a/mods/window_demo/CMakeLists.txt b/mods/window_demo/CMakeLists.txt new file mode 100644 index 0000000000..9150ec7de6 --- /dev/null +++ b/mods/window_demo/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.25) +project(window_demo CXX) + +if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to dusk source root") + option(DUSK_MOD_USE_FULL_TREE "Use full build instead of the minimal mod SDK" OFF) + set(CMAKE_POSITION_INDEPENDENT_CODE ON) + if (DUSK_MOD_USE_FULL_TREE) + add_subdirectory("${DUSK_DIR}" dusk EXCLUDE_FROM_ALL) + else () + add_subdirectory("${DUSK_DIR}/sdk" dusk-sdk EXCLUDE_FROM_ALL) + endif () +endif () + +add_mod(window_demo + FEATURES fmt webgpu + SOURCES src/logging.cpp src/mod.cpp + MOD_JSON mod.json + BUNDLE +) diff --git a/mods/window_demo/mod.json b/mods/window_demo/mod.json new file mode 100644 index 0000000000..ff79e62431 --- /dev/null +++ b/mods/window_demo/mod.json @@ -0,0 +1,7 @@ +{ + "id": "dev.twilitrealm.window_demo", + "name": "[Demo] Extra Window", + "version": "1.0.0", + "author": "Twilit Realm", + "description": "Demonstrates creating an extra window through WindowService and rendering to it with GfxService." +} diff --git a/mods/window_demo/src/logging.cpp b/mods/window_demo/src/logging.cpp new file mode 100644 index 0000000000..87015c713a --- /dev/null +++ b/mods/window_demo/src/logging.cpp @@ -0,0 +1,35 @@ +#include "logging.hpp" + +#include "mods/svc/log.hpp" +#include "mods/svc/window.h" + +namespace { + +const char* window_event_name(WindowEventType type) { + switch (type) { + case WINDOW_EVENT_CLOSE_REQUESTED: + return "close requested"; + case WINDOW_EVENT_RESIZED: + return "resized"; + case WINDOW_EVENT_MOVED: + return "moved"; + case WINDOW_EVENT_FOCUS_GAINED: + return "focus gained"; + case WINDOW_EVENT_FOCUS_LOST: + return "focus lost"; + case WINDOW_EVENT_SHOWN: + return "shown"; + case WINDOW_EVENT_HIDDEN: + return "hidden"; + } + return "unknown"; +} + +} // namespace + +void window_demo::log_window_event(const WindowEvent* event) { + mods::log::info( + "window event: {}; position=({}, {}), size={}x{}, pixels={}x{}, scale={:.2f}", + window_event_name(event->type), event->x, event->y, event->width, event->height, + event->pixel_width, event->pixel_height, event->display_scale); +} diff --git a/mods/window_demo/src/logging.hpp b/mods/window_demo/src/logging.hpp new file mode 100644 index 0000000000..3f62800d86 --- /dev/null +++ b/mods/window_demo/src/logging.hpp @@ -0,0 +1,9 @@ +#pragma once + +struct WindowEvent; + +namespace window_demo { + +void log_window_event(const WindowEvent* event); + +} // namespace window_demo diff --git a/mods/window_demo/src/mod.cpp b/mods/window_demo/src/mod.cpp new file mode 100644 index 0000000000..fb3cc31809 --- /dev/null +++ b/mods/window_demo/src/mod.cpp @@ -0,0 +1,229 @@ +#include "logging.hpp" + +#include "mods/service.hpp" +#include "mods/svc/gfx.h" +#include "mods/svc/log.hpp" +#include "mods/svc/ui.h" +#include "mods/svc/window.h" + +#include +#include +#include + +DEFINE_MOD(); +IMPORT_SERVICE(LogService, svc_log); +IMPORT_SERVICE(UiService, svc_ui); +IMPORT_SERVICE(WindowService, svc_window); +IMPORT_SERVICE(GfxService, svc_gfx); + +namespace { + +WindowHandle g_window = 0; +GfxPresentTargetHandle g_presentTarget = 0; +GfxStageHookHandle g_stageHook = 0; +uint32_t g_frame = 0; +bool g_recreatePresentTarget = false; + +struct ClearPayload { + float red; + float green; + float blue; + float alpha; +}; +static_assert(sizeof(ClearPayload) <= GFX_INLINE_DRAW_PAYLOAD_SIZE); + +ModResult close_window() { + g_recreatePresentTarget = false; + if (g_presentTarget != 0) { + const auto result = svc_gfx->unregister_present_target(mod_ctx, g_presentTarget); + if (result != MOD_OK) { + return result; + } + g_presentTarget = 0; + } + if (g_window != 0) { + const auto result = svc_window->destroy_window(mod_ctx, g_window); + if (result != MOD_OK) { + return result; + } + g_window = 0; + } + return MOD_OK; +} + +void on_window_event(ModContext*, WindowHandle, const WindowEvent* event, void*) { + window_demo::log_window_event(event); + + if (event->type == WINDOW_EVENT_CLOSE_REQUESTED) { + if (close_window() != MOD_OK) { + mods::log::error("failed to close auxiliary window"); + } + } +} + +// Render worker thread: record a clear of the acquired auxiliary surface texture. +void on_present( + ModContext*, const GfxPresentContext* ctx, const void* payload, size_t payloadSize, void*) { + if (payloadSize != sizeof(ClearPayload)) { + return; + } + ClearPayload color; + std::memcpy(&color, payload, sizeof(color)); + + WGPURenderPassColorAttachment colorAttachment = WGPU_RENDER_PASS_COLOR_ATTACHMENT_INIT; + colorAttachment.view = ctx->target_view; + colorAttachment.loadOp = WGPULoadOp_Clear; + colorAttachment.storeOp = WGPUStoreOp_Store; + colorAttachment.clearValue = WGPUColor{ + color.red, + color.green, + color.blue, + color.alpha, + }; + + WGPURenderPassDescriptor passDesc = WGPU_RENDER_PASS_DESCRIPTOR_INIT; + passDesc.label = {"Auxiliary window clear", WGPU_STRLEN}; + passDesc.colorAttachmentCount = 1; + passDesc.colorAttachments = &colorAttachment; + WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(ctx->encoder, &passDesc); + wgpuRenderPassEncoderEnd(pass); + wgpuRenderPassEncoderRelease(pass); +} + +ModResult register_present_target() { + if (g_window == 0 || g_presentTarget != 0) { + return MOD_CONFLICT; + } + GfxPresentTargetDesc presentDesc = GFX_PRESENT_TARGET_DESC_INIT; + presentDesc.label = "Auxiliary window surface"; + presentDesc.render = on_present; + presentDesc.preferred_alpha_mode = + WGPUCompositeAlphaMode_Premultiplied; // For transparent window + return svc_gfx->register_window_present_target( + mod_ctx, g_window, &presentDesc, &g_presentTarget); +} + +ModResult open_window() { + if (g_window != 0) { + return MOD_CONFLICT; + } + + WindowDesc windowDesc = WINDOW_DESC_INIT; + windowDesc.title = "Mod window"; + windowDesc.width = 640; + windowDesc.height = 480; + windowDesc.on_event = on_window_event; + windowDesc.flags |= WINDOW_FLAG_TRANSPARENT; // For transparent window + auto result = svc_window->create_window(mod_ctx, &windowDesc, &g_window); + if (result != MOD_OK) { + return result; + } + + result = register_present_target(); + if (result != MOD_OK) { + close_window(); + return result; + } + + result = svc_window->show_window(mod_ctx, g_window); + if (result != MOD_OK) { + close_window(); + } + return result; +} + +void on_frame_after_hud(ModContext*, const GfxStageContext*, void*) { + if (g_presentTarget == 0) { + return; + } + const float phase = static_cast(g_frame++) * 0.015f; + const ClearPayload color{ + .red = 0.08f + 0.06f * (std::sin(phase) + 1.0f), + .green = 0.10f + 0.06f * (std::sin(phase + 2.1f) + 1.0f), + .blue = 0.14f + 0.08f * (std::sin(phase + 4.2f) + 1.0f), + .alpha = 0.5f, + }; + if (svc_gfx->push_present(mod_ctx, g_presentTarget, &color, sizeof(color)) == MOD_ERROR) { + g_recreatePresentTarget = true; + } +} + +void on_toggle_window(ModContext*, void*) { + if (g_window != 0) { + if (close_window() != MOD_OK) { + mods::log::error("failed to close auxiliary window"); + } + return; + } + if (open_window() != MOD_OK) { + mods::log::error("failed to open auxiliary window"); + } +} + +ModResult build_panel(ModContext*, UiElementHandle panel, void*, ModError*) { + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_BUTTON; + control.label = "Open / Close Window"; + control.on_pressed = on_toggle_window; + return svc_ui->pane_add_control(mod_ctx, panel, &control, nullptr); +} + +} // namespace + +extern "C" { + +MOD_EXPORT ModResult mod_initialize(ModError* error) { + GfxStageHookDesc stageDesc = GFX_STAGE_HOOK_DESC_INIT; + stageDesc.callback = on_frame_after_hud; + if (svc_gfx->register_stage_hook( + mod_ctx, GFX_STAGE_FRAME_AFTER_HUD, &stageDesc, &g_stageHook) != MOD_OK) + { + return mods::set_error(error, MOD_ERROR, "failed to register presentation hook"); + } + + UiModsPanelDesc panelDesc = UI_MODS_PANEL_DESC_INIT; + panelDesc.build = build_panel; + if (svc_ui->register_mods_panel(mod_ctx, &panelDesc) != MOD_OK) { + svc_gfx->unregister_stage_hook(mod_ctx, g_stageHook); + g_stageHook = 0; + return mods::set_error(error, MOD_ERROR, "failed to register mod panel"); + } + + if (open_window() != MOD_OK) { + svc_gfx->unregister_stage_hook(mod_ctx, g_stageHook); + g_stageHook = 0; + return mods::set_error(error, MOD_ERROR, "failed to open auxiliary window"); + } + + mods::log::info("auxiliary WebGPU window ready"); + return MOD_OK; +} + +MOD_EXPORT ModResult mod_update(ModError* error) { + if (!g_recreatePresentTarget || g_window == 0) { + return MOD_OK; + } + g_recreatePresentTarget = false; + if (g_presentTarget != 0) { + const auto result = svc_gfx->unregister_present_target(mod_ctx, g_presentTarget); + if (result != MOD_OK) { + return mods::set_error(error, result, "failed to unregister lost present target"); + } + g_presentTarget = 0; + } + const auto result = register_present_target(); + if (result != MOD_OK) { + return mods::set_error(error, result, "failed to recreate present target"); + } + return MOD_OK; +} + +MOD_EXPORT ModResult mod_shutdown(ModError*) { + if (g_stageHook != 0) { + svc_gfx->unregister_stage_hook(mod_ctx, g_stageHook); + g_stageHook = 0; + } + close_window(); + return MOD_OK; +} +} diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt index cd80781eb6..29f1d94a9a 100644 --- a/sdk/CMakeLists.txt +++ b/sdk/CMakeLists.txt @@ -5,7 +5,7 @@ # # Usage (from a mod project): # add_subdirectory(/sdk dusk-sdk EXCLUDE_FROM_ALL) -# add_mod(my_mod FEATURES game webgpu SOURCES ... MOD_JSON mod.json) +# add_mod(my_mod FEATURES fmt game webgpu SOURCES ... MOD_JSON mod.json) # # On platforms where mods link against the game binary (Windows/Apple/Android), a # version-independent link stub is downloaded automatically unless DUSK_GAME_EXE is set. diff --git a/sdk/include/mods/api.h b/sdk/include/mods/api.h index 3a72135388..3fde6c9f6a 100644 --- a/sdk/include/mods/api.h +++ b/sdk/include/mods/api.h @@ -20,7 +20,23 @@ extern "C" { #ifdef __cplusplus #define MOD_EXTERN_C extern "C" #else -#define MOD_EXTERN_C +#define MOD_EXTERN_C extern +#endif + +#ifdef __cplusplus +#define MOD_DECLARE_SERVICE( \ + service_type, variable, service_id_value, major_value, minor_value) \ + MOD_EXTERN_C const service_type* variable; \ + template <> \ + struct mods::ServiceTraits { \ + static constexpr const char* id = service_id_value; \ + static constexpr uint16_t major_version = major_value; \ + static constexpr uint16_t minor_version = minor_value; \ + } +#else +#define MOD_DECLARE_SERVICE( \ + service_type, variable, service_id_value, major_value, minor_value) \ + MOD_EXTERN_C const service_type* variable #endif #define MOD_ABI_VERSION 1u diff --git a/sdk/include/mods/hook.hpp b/sdk/include/mods/hook.hpp index a8d91ab68c..6087ff49bb 100644 --- a/sdk/include/mods/hook.hpp +++ b/sdk/include/mods/hook.hpp @@ -1,219 +1,56 @@ #pragma once -#if !defined(DUSK_BUILDING_GAME) && !defined(DUSK_MOD_FEATURE_GAME) -#error "DEFINE_HOOK requires add_mod(... FEATURES game)" +#if defined(_MSC_VER) +#pragma message("warning: is deprecated; include instead") +#else +#warning " is deprecated; include instead" #endif -#include - -#include -#include +#include namespace mods { -template -T arg(void* argsRaw, int n) noexcept { - void** args = static_cast(argsRaw); - return *static_cast>>(args[n]); -} - -template -std::remove_reference_t& arg_ref(void* argsRaw, int n) noexcept { - void** args = static_cast(argsRaw); - return *static_cast>>(args[n]); -} - -/* - * Trampoline generator + per-target state. Tag makes each hooked target's statics distinct; the - * target address comes from the declaration's metadata record, resolved by the host at mod - * initialization. - */ -template -struct HookImpl { - static inline R (*g_orig)(A...) = nullptr; - static inline const HookService* hooks = nullptr; - static inline void* target = nullptr; - - static bool dispatch_pre(void* args, void* retval) { - if (hooks == nullptr) { - return false; - } - - int skipOriginal = 0; - const ModResult result = hooks->dispatch_pre(mod_ctx, target, args, retval, &skipOriginal); - return result == MOD_OK && skipOriginal != 0; - } - - static void dispatch_post(void* args, void* retval) { - if (hooks != nullptr) { - hooks->dispatch_post(mod_ctx, target, args, retval); - } - } - - static R trampoline(A... args) { - if constexpr (sizeof...(A) == 0) { - if constexpr (std::is_void_v) { - const bool skipOriginal = dispatch_pre(nullptr, nullptr); - if (!skipOriginal) { - g_orig(args...); - } - dispatch_post(nullptr, nullptr); - } else { - R result{}; - const bool skipOriginal = - dispatch_pre(nullptr, static_cast(std::addressof(result))); - if (!skipOriginal) { - result = g_orig(args...); - } - dispatch_post(nullptr, static_cast(std::addressof(result))); - return result; - } - } else { - void* ptrs[] = {static_cast(std::addressof(args))...}; - if constexpr (std::is_void_v) { - const bool skipOriginal = dispatch_pre(static_cast(ptrs), nullptr); - if (!skipOriginal) { - g_orig(args...); - } - dispatch_post(static_cast(ptrs), nullptr); - } else { - R result{}; - const bool skipOriginal = dispatch_pre( - static_cast(ptrs), static_cast(std::addressof(result))); - if (!skipOriginal) { - result = g_orig(args...); - } - dispatch_post(static_cast(ptrs), static_cast(std::addressof(result))); - return result; - } - } - } -}; - -namespace detail { -template -using TargetTag = std::integral_constant; -template -struct NameTag {}; -} // namespace detail - -/* - * Typed base for a hook on a function named at compile time (&daAlink_c::execute, &free_fn). - * Instantiate through DEFINE_HOOK, which pairs it with the metadata record the host resolves. - */ -template -struct Hook; - -template -struct Hook : HookImpl, R, C*, A...> {}; - -template -struct Hook : HookImpl, R, const C*, A...> {}; - -template -struct Hook : HookImpl, R, A...> {}; - -/* - * Typed base for a hook on a function by its symbol name, for targets you can't name in C++: - * file-local statics, private members, or symbols without a header. The signature is written - * free-style with the receiver first and is *not* compiler-checked. Instantiate through - * DEFINE_HOOK_SYMBOL. - */ -template -struct NamedHook; - -template -struct NamedHook : HookImpl, R, A...> {}; - -/* - * Declare a hook target. The declaration emits a metadata record that the host resolves at mod - * initialization. Every hook target must be declared. - * - * DEFINE_HOOK(&daAlink_c::execute, LinkExecute); - * DEFINE_HOOK_SYMBOL("daAlink_hookshotAtHitCallBack", - * void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*), HookshotHit); - * - * mods::hook_add_pre(svc_hook, on_link_execute); - * - * DEFINE_HOOK_SYMBOL names may be the platform mangled name (dlopen convention, no Mach-O - * leading underscore) or the demangled qualified display name; overloaded display names are - * ambiguous and need the mangled form. - */ -#if defined(__GNUC__) && !defined(__clang__) && defined(__ELF__) -#define DEFINE_HOOK(target, alias) \ - MOD_META_RECORD static constinit auto mod_meta_hook_##alias = \ - ::mods::detail::make_local_hook_record<(target), ::mods::FixedString{#target}>(); \ - struct alias : ::mods::Hook<(target)> { \ - static void* resolved_target() { return mod_meta_hook_##alias.resolved; } \ - } -#else -#define DEFINE_HOOK(target, alias) \ - [[maybe_unused]] static const void* const mod_meta_hook_##alias = \ - &::mods::detail::HookRecordFor<(target), ::mods::FixedString{#target}>::Holder::record; \ - struct alias : ::mods::Hook<(target)> { \ - static void* resolved_target() { \ - return ::mods::detail::HookRecordFor<(target), \ - ::mods::FixedString{#target}>::Holder::record.resolved; \ - } \ - } -#endif - -#define DEFINE_HOOK_SYMBOL(name, sig, alias) \ - MOD_META_RECORD static constinit auto mod_meta_hook_##alias = \ - ::mods::detail::make_hook_name_record<::mods::FixedString{name}>(); \ - struct alias : ::mods::NamedHook<::mods::FixedString{name}, sig> { \ - static void* resolved_target() { return mod_meta_hook_##alias.resolved; } \ - } - template ModResult hook_install(const HookService* hooks) { - if (hooks == nullptr) { - return MOD_UNAVAILABLE; - } + return hook::install(hooks); +} - Entry::hooks = hooks; - if (Entry::target == nullptr) { - void* resolved = Entry::resolved_target(); - if (resolved == nullptr) { - return MOD_UNAVAILABLE; - } - Entry::target = resolved; - } - return hooks->install(mod_ctx, Entry::target, reinterpret_cast(Entry::trampoline), - reinterpret_cast(&Entry::g_orig)); +template +ModResult hook_install() { + return hook::install(); } template ModResult hook_add_pre( const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) { - const ModResult installed = hook_install(hooks); - if (installed != MOD_OK) { - return installed; - } + return hook::add_pre(hooks, callback, options); +} - return hooks->add_pre(mod_ctx, Entry::target, callback, options); +template +ModResult hook_add_pre(HookPreFn callback, const HookOptions* options = nullptr) { + return hook::add_pre(callback, options); } template ModResult hook_add_post( const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) { - const ModResult installed = hook_install(hooks); - if (installed != MOD_OK) { - return installed; - } + return hook::add_post(hooks, callback, options); +} - return hooks->add_post(mod_ctx, Entry::target, callback, options); +template +ModResult hook_add_post(HookPostFn callback, const HookOptions* options = nullptr) { + return hook::add_post(callback, options); } template ModResult hook_replace( const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) { - const ModResult installed = hook_install(hooks); - if (installed != MOD_OK) { - return installed; - } + return hook::replace(hooks, callback, options); +} - return hooks->replace(mod_ctx, Entry::target, callback, options); +template +ModResult hook_replace(HookReplaceFn callback, const HookOptions* options = nullptr) { + return hook::replace(callback, options); } } // namespace mods diff --git a/sdk/include/mods/service.hpp b/sdk/include/mods/service.hpp index a6010a375c..ed734ffc15 100644 --- a/sdk/include/mods/service.hpp +++ b/sdk/include/mods/service.hpp @@ -40,13 +40,13 @@ inline ModResult set_error(ModError* outError, ModResult code, const char* messa }; \ } -// Declares `static const service_type* variable`, filled in by the host before mod_initialize. +// Defines `const service_type* variable`, filled in by the host before mod_initialize. // Required imports are guaranteed non-null (the mod fails to load otherwise); optional imports // must be checked against nullptr before use. The unversioned macros use the latest minor version; // set an explicit version to target an older minor version for backwards compatibility. #define IMPORT_SERVICE_EX( \ service_type, variable, service_id_value, major_value, min_minor_value, flags_value) \ - static const service_type* variable = nullptr; \ + const service_type* variable = nullptr; \ MOD_META_RECORD static constinit ModMetaImport mod_meta_import_##variable = { \ {sizeof(ModMetaImport), MOD_META_IMPORT, static_cast(flags_value)}, \ static_cast(major_value), \ diff --git a/sdk/include/mods/svc/camera.h b/sdk/include/mods/svc/camera.h index f6de5b8146..8934340cd6 100644 --- a/sdk/include/mods/svc/camera.h +++ b/sdk/include/mods/svc/camera.h @@ -2,6 +2,10 @@ #include +#ifdef __cplusplus +#include +#endif + #define CAMERA_SERVICE_ID "dev.twilitrealm.dusklight.camera" #define CAMERA_SERVICE_MAJOR 1u #define CAMERA_SERVICE_MINOR 0u @@ -53,13 +57,5 @@ typedef struct CameraService { ModResult (*get_camera)(ModContext* ctx, const void* game_view, CameraInfo* out_info); } CameraService; -#ifdef __cplusplus -#include "mods/service.hpp" - -template <> -struct mods::ServiceTraits { - static constexpr const char* id = CAMERA_SERVICE_ID; - static constexpr uint16_t major_version = CAMERA_SERVICE_MAJOR; - static constexpr uint16_t minor_version = CAMERA_SERVICE_MINOR; -}; -#endif +MOD_DECLARE_SERVICE( + CameraService, svc_camera, CAMERA_SERVICE_ID, CAMERA_SERVICE_MAJOR, CAMERA_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/config.h b/sdk/include/mods/svc/config.h index cd4afdbbdf..fb04f6e9ea 100644 --- a/sdk/include/mods/svc/config.h +++ b/sdk/include/mods/svc/config.h @@ -2,6 +2,10 @@ #include +#ifdef __cplusplus +#include +#endif + #define CONFIG_SERVICE_ID "dev.twilitrealm.dusklight.config" #define CONFIG_SERVICE_MAJOR 1u #define CONFIG_SERVICE_MINOR 0u @@ -96,13 +100,5 @@ typedef struct ConfigService { ModResult (*unsubscribe)(ModContext* ctx, ConfigSubscriptionHandle handle); } ConfigService; -#ifdef __cplusplus -#include "mods/service.hpp" - -template <> -struct mods::ServiceTraits { - static constexpr const char* id = CONFIG_SERVICE_ID; - static constexpr uint16_t major_version = CONFIG_SERVICE_MAJOR; - static constexpr uint16_t minor_version = CONFIG_SERVICE_MINOR; -}; -#endif +MOD_DECLARE_SERVICE( + ConfigService, svc_config, CONFIG_SERVICE_ID, CONFIG_SERVICE_MAJOR, CONFIG_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/game.h b/sdk/include/mods/svc/game.h index 0a3585b912..ecb8bcfeb7 100644 --- a/sdk/include/mods/svc/game.h +++ b/sdk/include/mods/svc/game.h @@ -2,6 +2,10 @@ #include +#ifdef __cplusplus +#include +#endif + /* * The mod SDK imports this service automatically for mods built with FEATURES game; service-only * and asset-only mods do not require it. @@ -19,13 +23,4 @@ typedef struct GameService { ServiceHeader header; } GameService; -#ifdef __cplusplus -#include - -template <> -struct mods::ServiceTraits { - static constexpr const char* id = GAME_SERVICE_ID; - static constexpr uint16_t major_version = GAME_SERVICE_MAJOR; - static constexpr uint16_t minor_version = GAME_SERVICE_MINOR; -}; -#endif +MOD_DECLARE_SERVICE(GameService, svc_game, GAME_SERVICE_ID, GAME_SERVICE_MAJOR, GAME_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/gfx.h b/sdk/include/mods/svc/gfx.h index 03b24ed986..5041c611e3 100644 --- a/sdk/include/mods/svc/gfx.h +++ b/sdk/include/mods/svc/gfx.h @@ -1,6 +1,11 @@ #pragma once #include +#include + +#ifdef __cplusplus +#include +#endif #if !defined(DUSK_BUILDING_GAME) && !defined(DUSK_MOD_FEATURE_WEBGPU) #error "mods/svc/gfx.h requires add_mod(... FEATURES webgpu)" @@ -28,7 +33,7 @@ #define GFX_SERVICE_ID "dev.twilitrealm.dusklight.gfx" #define GFX_SERVICE_MAJOR 1u -#define GFX_SERVICE_MINOR 0u +#define GFX_SERVICE_MINOR 1u /* Maximum size for push_draw payload */ #define GFX_INLINE_DRAW_PAYLOAD_SIZE 128u @@ -37,6 +42,7 @@ typedef uint64_t GfxDrawTypeHandle; typedef uint64_t GfxStageHookHandle; typedef uint64_t GfxComputeTypeHandle; +typedef uint64_t GfxPresentTargetHandle; /* A suballocation in one of the shared per-frame streaming buffers. */ typedef struct GfxRange { @@ -56,11 +62,13 @@ typedef struct GfxDeviceInfo { WGPUTextureFormat depth_format; /* scene depth target format */ uint32_t sample_count; /* scene pass MSAA sample count */ bool uses_reversed_z; /* true means depth 1.0 is near */ + WGPUInstance instance; /* borrowed; added in GfxService 1.1 */ + WGPUAdapter adapter; /* borrowed; added in GfxService 1.1 */ } GfxDeviceInfo; #define GFX_DEVICE_INFO_INIT \ {sizeof(GfxDeviceInfo), NULL, NULL, WGPUTextureFormat_Undefined, WGPUTextureFormat_Undefined, \ - 1u, false} + 1u, false, NULL, NULL} /* * Passed to GfxDrawFn on the render worker thread; valid only during the call. The pass pipeline, @@ -168,6 +176,48 @@ typedef struct GfxComputeTypeDesc { #define GFX_COMPUTE_TYPE_DESC_INIT {sizeof(GfxComputeTypeDesc), NULL, NULL, NULL} +/* + * Invoked on the render worker while the frame encoder is open. The target texture and view have + * been acquired by the host and are borrowed for the callback. Record all target work on encoder, + * leave no pass open, and do not finish, submit, or present it. The host submits the shared command + * buffer and presents the target after submission. The streaming buffers contain data appended on + * the game thread before push_present. + */ +typedef struct GfxPresentContext { + uint32_t struct_size; + WGPUDevice device; + WGPUQueue queue; + WGPUCommandEncoder encoder; + WGPUTexture target_texture; + WGPUTextureView target_view; + WGPUTextureFormat target_format; + uint32_t target_width; + uint32_t target_height; + WGPUBuffer vertex_buffer; + WGPUBuffer index_buffer; + WGPUBuffer uniform_buffer; + WGPUBuffer storage_buffer; +} GfxPresentContext; + +typedef void (*GfxPresentFn)(ModContext* ctx, const GfxPresentContext* present_ctx, + const void* payload, size_t payload_size, void* user_data); + +typedef struct GfxPresentTargetDesc { + uint32_t struct_size; + const char* label; /* optional debug label */ + uint32_t width; /* required for raw surfaces; ignored for WindowService windows */ + uint32_t height; + WGPUTextureUsage usage; /* 0 defaults to RenderAttachment */ + WGPUTextureFormat preferred_format; + WGPUCompositeAlphaMode preferred_alpha_mode; + GfxPresentFn render; + void* user_data; +} GfxPresentTargetDesc; + +#define GFX_PRESENT_TARGET_DESC_INIT \ + {sizeof(GfxPresentTargetDesc), NULL, 0u, 0u, WGPUTextureUsage_None, \ + WGPUTextureFormat_Undefined, WGPUCompositeAlphaMode_Auto, NULL, NULL} + typedef struct GfxService { ServiceHeader header; @@ -200,15 +250,25 @@ typedef struct GfxService { ModResult (*resolve_pass)( ModContext* ctx, const GfxResolveDesc* desc, GfxResolvedTargets* out_targets); ModResult (*create_pass)(ModContext* ctx, uint32_t width, uint32_t height); + + /* Minor version 1 */ + + ModResult (*register_present_target)(ModContext* ctx, WGPUSurface surface, + const GfxPresentTargetDesc* desc, GfxPresentTargetHandle* out_handle); + ModResult (*register_window_present_target)(ModContext* ctx, WindowHandle window, + const GfxPresentTargetDesc* desc, GfxPresentTargetHandle* out_handle); + /* Raw-surface targets only; WindowService target resizes are managed automatically. */ + ModResult (*resize_present_target)( + ModContext* ctx, GfxPresentTargetHandle handle, uint32_t width, uint32_t height); + ModResult (*unregister_present_target)(ModContext* ctx, GfxPresentTargetHandle handle); + /* + * MOD_OK means the task was queued. + * MOD_UNAVAILABLE means no task could be queued now (for example, a window has no pixel size). + * MOD_ERROR means an earlier task found the surface lost or deterministically invalid; + * unregister and recreate the target before pushing again. + */ + ModResult (*push_present)( + ModContext* ctx, GfxPresentTargetHandle handle, const void* payload, size_t payload_size); } GfxService; -#ifdef __cplusplus -#include "mods/service.hpp" - -template <> -struct mods::ServiceTraits { - static constexpr const char* id = GFX_SERVICE_ID; - static constexpr uint16_t major_version = GFX_SERVICE_MAJOR; - static constexpr uint16_t minor_version = GFX_SERVICE_MINOR; -}; -#endif +MOD_DECLARE_SERVICE(GfxService, svc_gfx, GFX_SERVICE_ID, GFX_SERVICE_MAJOR, GFX_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/hook.h b/sdk/include/mods/svc/hook.h index 3d4e9c0472..e9c64b1ca4 100644 --- a/sdk/include/mods/svc/hook.h +++ b/sdk/include/mods/svc/hook.h @@ -2,9 +2,13 @@ #include +#ifdef __cplusplus +#include +#endif + /* - * Intercept game functions by address. Prefer the typed helpers in mods/hook.hpp - * (hook_add_pre/hook_add_post/hook_replace over a &Class::method): they generate the + * Intercept game functions by address. Prefer the typed helpers in mods/svc/hook.hpp + * (mods::hook::add_pre/add_post/replace over a &Class::method): they generate the * trampoline and hide install/dispatch, which are the low-level primitives those helpers * build. resolve() maps a symbol name to an address for targets you can't name at compile time * (file-local statics included). @@ -46,7 +50,7 @@ typedef enum HookReplacePolicy { /* * Hook callbacks. `args` is an array of pointers to the call's arguments (index 0 is `this` * for member functions); `retval` points at the return slot (NULL for void). Read and write - * them through mods::arg / arg_ref from mods/hook.hpp. `userdata` is the pointer + * them through mods::arg / arg_ref from mods/svc/hook.hpp. `userdata` is the pointer * from HookOptions. All run on the game thread, in the hooked call's own stack frame. */ typedef HookAction (*HookPreFn)(ModContext* ctx, void* args, void* retval, void* userdata); @@ -114,13 +118,4 @@ typedef struct HookService { ModContext* ctx, const char* symbol, void** out_addr, HookSymbolFlags* out_flags); } HookService; -#ifdef __cplusplus -#include "mods/service.hpp" - -template <> -struct mods::ServiceTraits { - static constexpr const char* id = HOOK_SERVICE_ID; - static constexpr uint16_t major_version = HOOK_SERVICE_MAJOR; - static constexpr uint16_t minor_version = HOOK_SERVICE_MINOR; -}; -#endif +MOD_DECLARE_SERVICE(HookService, svc_hook, HOOK_SERVICE_ID, HOOK_SERVICE_MAJOR, HOOK_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/hook.hpp b/sdk/include/mods/svc/hook.hpp new file mode 100644 index 0000000000..e61562da36 --- /dev/null +++ b/sdk/include/mods/svc/hook.hpp @@ -0,0 +1,242 @@ +#pragma once + +#if !defined(DUSK_BUILDING_GAME) && !defined(DUSK_MOD_FEATURE_GAME) +#error "DEFINE_HOOK requires add_mod(... FEATURES game)" +#endif + +#include + +#include +#include + +namespace mods { + +template +T arg(void* argsRaw, int n) noexcept { + void** args = static_cast(argsRaw); + return *static_cast>>(args[n]); +} + +template +std::remove_reference_t& arg_ref(void* argsRaw, int n) noexcept { + void** args = static_cast(argsRaw); + return *static_cast>>(args[n]); +} + +/* + * Trampoline generator + per-target state. Tag makes each hooked target's statics distinct; the + * target address comes from the declaration's metadata record, resolved by the host at mod + * initialization. + */ +template +struct HookImpl { + static inline R (*g_orig)(A...) = nullptr; + static inline const HookService* hooks = nullptr; + static inline void* target = nullptr; + + static bool dispatch_pre(void* args, void* retval) { + if (hooks == nullptr) { + return false; + } + + int skipOriginal = 0; + const ModResult result = hooks->dispatch_pre(mod_ctx, target, args, retval, &skipOriginal); + return result == MOD_OK && skipOriginal != 0; + } + + static void dispatch_post(void* args, void* retval) { + if (hooks != nullptr) { + hooks->dispatch_post(mod_ctx, target, args, retval); + } + } + + static R trampoline(A... args) { + if constexpr (sizeof...(A) == 0) { + if constexpr (std::is_void_v) { + const bool skipOriginal = dispatch_pre(nullptr, nullptr); + if (!skipOriginal) { + g_orig(args...); + } + dispatch_post(nullptr, nullptr); + } else { + R result{}; + const bool skipOriginal = + dispatch_pre(nullptr, static_cast(std::addressof(result))); + if (!skipOriginal) { + result = g_orig(args...); + } + dispatch_post(nullptr, static_cast(std::addressof(result))); + return result; + } + } else { + void* ptrs[] = {static_cast(std::addressof(args))...}; + if constexpr (std::is_void_v) { + const bool skipOriginal = dispatch_pre(static_cast(ptrs), nullptr); + if (!skipOriginal) { + g_orig(args...); + } + dispatch_post(static_cast(ptrs), nullptr); + } else { + R result{}; + const bool skipOriginal = dispatch_pre( + static_cast(ptrs), static_cast(std::addressof(result))); + if (!skipOriginal) { + result = g_orig(args...); + } + dispatch_post(static_cast(ptrs), static_cast(std::addressof(result))); + return result; + } + } + } +}; + +namespace detail { +template +using TargetTag = std::integral_constant; +template +struct NameTag {}; +} // namespace detail + +/* + * Typed base for a hook on a function named at compile time (&daAlink_c::execute, &free_fn). + * Instantiate through DEFINE_HOOK, which pairs it with the metadata record the host resolves. + */ +template +struct Hook; + +template +struct Hook : HookImpl, R, C*, A...> {}; + +template +struct Hook : HookImpl, R, const C*, A...> {}; + +template +struct Hook : HookImpl, R, A...> {}; + +/* + * Typed base for a hook on a function by its symbol name, for targets you can't name in C++: + * file-local statics, private members, or symbols without a header. The signature is written + * free-style with the receiver first and is *not* compiler-checked. Instantiate through + * DEFINE_HOOK_SYMBOL. + */ +template +struct NamedHook; + +template +struct NamedHook : HookImpl, R, A...> {}; + +/* + * Declare a hook target. The declaration emits a metadata record that the host resolves at mod + * initialization. Every hook target must be declared. + * + * DEFINE_HOOK(&daAlink_c::execute, LinkExecute); + * DEFINE_HOOK_SYMBOL("daAlink_hookshotAtHitCallBack", + * void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*), HookshotHit); + * + * mods::hook::add_pre(on_link_execute); + * + * DEFINE_HOOK_SYMBOL names may be the platform mangled name (dlopen convention, no Mach-O + * leading underscore) or the demangled qualified display name; overloaded display names are + * ambiguous and need the mangled form. + */ +#if defined(__GNUC__) && !defined(__clang__) && defined(__ELF__) +#define DEFINE_HOOK(target, alias) \ + MOD_META_RECORD static constinit auto mod_meta_hook_##alias = \ + ::mods::detail::make_local_hook_record<(target), ::mods::FixedString{#target}>(); \ + struct alias : ::mods::Hook<(target)> { \ + static void* resolved_target() { return mod_meta_hook_##alias.resolved; } \ + } +#else +#define DEFINE_HOOK(target, alias) \ + [[maybe_unused]] static const void* const mod_meta_hook_##alias = \ + &::mods::detail::HookRecordFor<(target), ::mods::FixedString{#target}>::Holder::record; \ + struct alias : ::mods::Hook<(target)> { \ + static void* resolved_target() { \ + return ::mods::detail::HookRecordFor<(target), \ + ::mods::FixedString{#target}>::Holder::record.resolved; \ + } \ + } +#endif + +#define DEFINE_HOOK_SYMBOL(name, sig, alias) \ + MOD_META_RECORD static constinit auto mod_meta_hook_##alias = \ + ::mods::detail::make_hook_name_record<::mods::FixedString{name}>(); \ + struct alias : ::mods::NamedHook<::mods::FixedString{name}, sig> { \ + static void* resolved_target() { return mod_meta_hook_##alias.resolved; } \ + } + +namespace hook { + +template +ModResult install(const HookService* hooks) { + if (hooks == nullptr) { + return MOD_UNAVAILABLE; + } + + Entry::hooks = hooks; + if (Entry::target == nullptr) { + void* resolved = Entry::resolved_target(); + if (resolved == nullptr) { + return MOD_UNAVAILABLE; + } + Entry::target = resolved; + } + return hooks->install(mod_ctx, Entry::target, reinterpret_cast(Entry::trampoline), + reinterpret_cast(&Entry::g_orig)); +} + +template +ModResult install() { + return install(svc_hook); +} + +template +ModResult add_pre( + const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) { + const ModResult installed = install(hooks); + if (installed != MOD_OK) { + return installed; + } + + return hooks->add_pre(mod_ctx, Entry::target, callback, options); +} + +template +ModResult add_pre(HookPreFn callback, const HookOptions* options = nullptr) { + return add_pre(svc_hook, callback, options); +} + +template +ModResult add_post( + const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) { + const ModResult installed = install(hooks); + if (installed != MOD_OK) { + return installed; + } + + return hooks->add_post(mod_ctx, Entry::target, callback, options); +} + +template +ModResult add_post(HookPostFn callback, const HookOptions* options = nullptr) { + return add_post(svc_hook, callback, options); +} + +template +ModResult replace( + const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) { + const ModResult installed = install(hooks); + if (installed != MOD_OK) { + return installed; + } + + return hooks->replace(mod_ctx, Entry::target, callback, options); +} + +template +ModResult replace(HookReplaceFn callback, const HookOptions* options = nullptr) { + return replace(svc_hook, callback, options); +} + +} // namespace hook +} // namespace mods diff --git a/sdk/include/mods/svc/host.h b/sdk/include/mods/svc/host.h index 9e278d5790..24265cab99 100644 --- a/sdk/include/mods/svc/host.h +++ b/sdk/include/mods/svc/host.h @@ -2,6 +2,10 @@ #include +#ifdef __cplusplus +#include +#endif + /* * The host service: the calling mod's identity and its runtime interface to the loader. * Always available; every other service can be reached from it. @@ -103,13 +107,4 @@ typedef struct HostService { const char* (*native_dir)(ModContext* ctx); } HostService; -#ifdef __cplusplus -#include "mods/service.hpp" - -template <> -struct mods::ServiceTraits { - static constexpr const char* id = HOST_SERVICE_ID; - static constexpr uint16_t major_version = HOST_SERVICE_MAJOR; - static constexpr uint16_t minor_version = HOST_SERVICE_MINOR; -}; -#endif +MOD_DECLARE_SERVICE(HostService, svc_host, HOST_SERVICE_ID, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/log.h b/sdk/include/mods/svc/log.h index 0bb9ca74ed..1040710492 100644 --- a/sdk/include/mods/svc/log.h +++ b/sdk/include/mods/svc/log.h @@ -2,6 +2,10 @@ #include +#ifdef __cplusplus +#include +#endif + /* * Logging into the game's console and log files. Messages are attributed to the calling mod * (prefixed with its ID). @@ -36,13 +40,4 @@ typedef struct LogService { void (*error)(ModContext* ctx, const char* message); } LogService; -#ifdef __cplusplus -#include "mods/service.hpp" - -template <> -struct mods::ServiceTraits { - static constexpr const char* id = LOG_SERVICE_ID; - static constexpr uint16_t major_version = LOG_SERVICE_MAJOR; - static constexpr uint16_t minor_version = LOG_SERVICE_MINOR; -}; -#endif +MOD_DECLARE_SERVICE(LogService, svc_log, LOG_SERVICE_ID, LOG_SERVICE_MAJOR, LOG_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/log.hpp b/sdk/include/mods/svc/log.hpp new file mode 100644 index 0000000000..372cf06d8d --- /dev/null +++ b/sdk/include/mods/svc/log.hpp @@ -0,0 +1,46 @@ +#pragma once + +#if !defined(DUSK_BUILDING_GAME) && !defined(DUSK_MOD_FEATURE_FMT) +#error "mods/svc/log.hpp requires add_mod(... FEATURES fmt)" +#endif + +#include + +#include + +#include + +namespace mods::log { + +template +void write(LogLevel level, fmt::format_string formatString, Args&&... args) { + const auto message = fmt::format(formatString, std::forward(args)...); + svc_log->write(mod_ctx, level, message.c_str()); +} + +template +void trace(fmt::format_string formatString, Args&&... args) { + write(LOG_LEVEL_TRACE, formatString, std::forward(args)...); +} + +template +void debug(fmt::format_string formatString, Args&&... args) { + write(LOG_LEVEL_DEBUG, formatString, std::forward(args)...); +} + +template +void info(fmt::format_string formatString, Args&&... args) { + write(LOG_LEVEL_INFO, formatString, std::forward(args)...); +} + +template +void warn(fmt::format_string formatString, Args&&... args) { + write(LOG_LEVEL_WARN, formatString, std::forward(args)...); +} + +template +void error(fmt::format_string formatString, Args&&... args) { + write(LOG_LEVEL_ERROR, formatString, std::forward(args)...); +} + +} // namespace mods::log diff --git a/sdk/include/mods/svc/overlay.h b/sdk/include/mods/svc/overlay.h index 76e24c878b..ea46bc8798 100644 --- a/sdk/include/mods/svc/overlay.h +++ b/sdk/include/mods/svc/overlay.h @@ -2,6 +2,10 @@ #include +#ifdef __cplusplus +#include +#endif + #define OVERLAY_SERVICE_ID "dev.twilitrealm.dusklight.overlay" #define OVERLAY_SERVICE_MAJOR 1u #define OVERLAY_SERVICE_MINOR 0u @@ -46,13 +50,5 @@ typedef struct OverlayService { ModResult (*remove)(ModContext* ctx, OverlayHandle handle); } OverlayService; -#ifdef __cplusplus -#include "mods/service.hpp" - -template <> -struct mods::ServiceTraits { - static constexpr const char* id = OVERLAY_SERVICE_ID; - static constexpr uint16_t major_version = OVERLAY_SERVICE_MAJOR; - static constexpr uint16_t minor_version = OVERLAY_SERVICE_MINOR; -}; -#endif +MOD_DECLARE_SERVICE( + OverlayService, svc_overlay, OVERLAY_SERVICE_ID, OVERLAY_SERVICE_MAJOR, OVERLAY_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/resource.h b/sdk/include/mods/svc/resource.h index 05a22754a3..57fddc9778 100644 --- a/sdk/include/mods/svc/resource.h +++ b/sdk/include/mods/svc/resource.h @@ -2,6 +2,10 @@ #include +#ifdef __cplusplus +#include +#endif + /* * Read-only access to the res/ tree of the calling mod's own bundle. Reload serves the new * bundle's contents. For writable storage, use HostService::mod_dir. @@ -41,13 +45,5 @@ typedef struct ResourceService { void (*free)(ModContext* ctx, ResourceBuffer* buffer); } ResourceService; -#ifdef __cplusplus -#include "mods/service.hpp" - -template <> -struct mods::ServiceTraits { - static constexpr const char* id = RESOURCE_SERVICE_ID; - static constexpr uint16_t major_version = RESOURCE_SERVICE_MAJOR; - static constexpr uint16_t minor_version = RESOURCE_SERVICE_MINOR; -}; -#endif +MOD_DECLARE_SERVICE(ResourceService, svc_resource, RESOURCE_SERVICE_ID, RESOURCE_SERVICE_MAJOR, + RESOURCE_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/texture.h b/sdk/include/mods/svc/texture.h index cdcfbe7e2a..dac73fbade 100644 --- a/sdk/include/mods/svc/texture.h +++ b/sdk/include/mods/svc/texture.h @@ -2,6 +2,10 @@ #include +#ifdef __cplusplus +#include +#endif + #define TEXTURE_SERVICE_ID "dev.twilitrealm.dusklight.texture" #define TEXTURE_SERVICE_MAJOR 1u #define TEXTURE_SERVICE_MINOR 0u @@ -70,20 +74,12 @@ typedef struct TextureService { * "tex1_{w}x{h}_{hash}_{fmt}.dds"); "_mipN" sidecars next to it are picked up automatically. * The file is decoded lazily on first use by the renderer. */ - ModResult (*register_file)(ModContext* ctx, const char* bundle_path, - TextureReplacementHandle* out_handle); + ModResult (*register_file)( + ModContext* ctx, const char* bundle_path, TextureReplacementHandle* out_handle); /* Remove a replacement previously registered by the calling mod. */ ModResult (*unregister)(ModContext* ctx, TextureReplacementHandle handle); } TextureService; -#ifdef __cplusplus -#include "mods/service.hpp" - -template <> -struct mods::ServiceTraits { - static constexpr const char* id = TEXTURE_SERVICE_ID; - static constexpr uint16_t major_version = TEXTURE_SERVICE_MAJOR; - static constexpr uint16_t minor_version = TEXTURE_SERVICE_MINOR; -}; -#endif +MOD_DECLARE_SERVICE( + TextureService, svc_texture, TEXTURE_SERVICE_ID, TEXTURE_SERVICE_MAJOR, TEXTURE_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/ui.h b/sdk/include/mods/svc/ui.h index e19ec65f6c..1635a96c5f 100644 --- a/sdk/include/mods/svc/ui.h +++ b/sdk/include/mods/svc/ui.h @@ -3,6 +3,10 @@ #include #include +#ifdef __cplusplus +#include +#endif + #define UI_SERVICE_ID "dev.twilitrealm.dusklight.ui" #define UI_SERVICE_MAJOR 1u #define UI_SERVICE_MINOR 0u @@ -273,13 +277,4 @@ typedef struct UiService { ModResult (*unregister_menu_tab)(ModContext* ctx, UiMenuTabHandle tab); } UiService; -#ifdef __cplusplus -#include "mods/service.hpp" - -template <> -struct mods::ServiceTraits { - static constexpr const char* id = UI_SERVICE_ID; - static constexpr uint16_t major_version = UI_SERVICE_MAJOR; - static constexpr uint16_t minor_version = UI_SERVICE_MINOR; -}; -#endif +MOD_DECLARE_SERVICE(UiService, svc_ui, UI_SERVICE_ID, UI_SERVICE_MAJOR, UI_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/window.h b/sdk/include/mods/svc/window.h new file mode 100644 index 0000000000..7cee8a5abe --- /dev/null +++ b/sdk/include/mods/svc/window.h @@ -0,0 +1,103 @@ +#pragma once + +#include + +#ifdef __cplusplus +#include +#endif + +#include + +#define WINDOW_SERVICE_ID "dev.twilitrealm.dusklight.window" +#define WINDOW_SERVICE_MAJOR 1u +#define WINDOW_SERVICE_MINOR 0u + +#define WINDOW_POSITION_UNDEFINED INT32_MIN + +typedef uint64_t WindowHandle; + +typedef enum WindowFlags { + WINDOW_FLAG_NONE = 0u, + WINDOW_FLAG_RESIZABLE = 1u << 0u, + WINDOW_FLAG_HIDDEN = 1u << 1u, + WINDOW_FLAG_BORDERLESS = 1u << 2u, + WINDOW_FLAG_ALWAYS_ON_TOP = 1u << 3u, + WINDOW_FLAG_TRANSPARENT = 1u << 4u, +} WindowFlags; + +typedef enum WindowEventType { + WINDOW_EVENT_CLOSE_REQUESTED = 0, + WINDOW_EVENT_RESIZED = 1, + WINDOW_EVENT_MOVED = 2, + WINDOW_EVENT_FOCUS_GAINED = 3, + WINDOW_EVENT_FOCUS_LOST = 4, + WINDOW_EVENT_SHOWN = 5, + WINDOW_EVENT_HIDDEN = 6, +} WindowEventType; + +typedef struct WindowEvent { + uint32_t struct_size; + WindowEventType type; + int32_t x; + int32_t y; + uint32_t width; + uint32_t height; + uint32_t pixel_width; + uint32_t pixel_height; + float display_scale; +} WindowEvent; + +typedef void (*WindowEventFn)( + ModContext* ctx, WindowHandle window, const WindowEvent* event, void* user_data); + +typedef struct WindowDesc { + uint32_t struct_size; + const char* title; + uint32_t width; + uint32_t height; + int32_t x; + int32_t y; + uint32_t flags; + WindowEventFn on_event; + void* user_data; +} WindowDesc; + +#define WINDOW_DESC_INIT \ + {sizeof(WindowDesc), NULL, 640u, 480u, WINDOW_POSITION_UNDEFINED, WINDOW_POSITION_UNDEFINED, \ + WINDOW_FLAG_RESIZABLE | WINDOW_FLAG_HIDDEN, NULL, NULL} + +typedef struct WindowInfo { + uint32_t struct_size; + int32_t x; + int32_t y; + uint32_t width; + uint32_t height; + uint32_t pixel_width; + uint32_t pixel_height; + float display_scale; + bool visible; + bool focused; +} WindowInfo; + +#define WINDOW_INFO_INIT {sizeof(WindowInfo), 0, 0, 0u, 0u, 0u, 0u, 1.0f, false, false} + +/* + * Auxiliary native windows. All functions and callbacks run on the game thread. Window close + * events are requests; the window remains alive until destroy_window is called. Any graphics + * present target attached to a window must be unregistered before the window can be destroyed; + * at most one present target may be attached to a window at a time. + */ +typedef struct WindowService { + ServiceHeader header; + + ModResult (*create_window)(ModContext* ctx, const WindowDesc* desc, WindowHandle* out_window); + ModResult (*destroy_window)(ModContext* ctx, WindowHandle window); + ModResult (*show_window)(ModContext* ctx, WindowHandle window); + ModResult (*hide_window)(ModContext* ctx, WindowHandle window); + ModResult (*set_title)(ModContext* ctx, WindowHandle window, const char* title); + ModResult (*set_size)(ModContext* ctx, WindowHandle window, uint32_t width, uint32_t height); + ModResult (*get_info)(ModContext* ctx, WindowHandle window, WindowInfo* out_info); +} WindowService; + +MOD_DECLARE_SERVICE( + WindowService, svc_window, WINDOW_SERVICE_ID, WINDOW_SERVICE_MAJOR, WINDOW_SERVICE_MINOR); diff --git a/src/dusk/mods/svc/gfx.cpp b/src/dusk/mods/svc/gfx.cpp index e14b587f5e..f522555e66 100644 --- a/src/dusk/mods/svc/gfx.cpp +++ b/src/dusk/mods/svc/gfx.cpp @@ -1,5 +1,6 @@ #include "registry.hpp" #include "slot_map.hpp" +#include "window.hpp" #include "aurora/lib/logging.hpp" #include "dusk/gfx.hpp" @@ -7,11 +8,16 @@ #include "mods/svc/gfx.h" #include +#include #include +#include +#include #include #include +#include #include +#include #include #include #include @@ -25,6 +31,7 @@ enum class GfxSlotKind : uint8_t { DrawType, StageHook, ComputeType, + PresentTarget, }; enum class GfxStreamBuffer : uint8_t { @@ -34,6 +41,33 @@ enum class GfxStreamBuffer : uint8_t { Storage, }; +enum class PresentTargetStatus : uint8_t { + Pending, + Ready, + TransientUnavailable, + Lost, + Error, +}; + +struct PresentTargetState { + wgpu::Surface surface; + wgpu::SurfaceConfiguration configuration; + wgpu::Texture currentTexture; + wgpu::TextureView currentView; + std::atomic status = PresentTargetStatus::Pending; + bool configured = false; + bool presentPending = false; + bool vsync = true; +}; + +bool present_target_failed(const std::shared_ptr& state) { + if (state == nullptr) { + return true; + } + const auto status = state->status.load(std::memory_order_acquire); + return status == PresentTargetStatus::Lost || status == PresentTargetStatus::Error; +} + struct GfxSlot { GfxSlotKind kind = GfxSlotKind::DrawType; ModContext* ownerContext = nullptr; @@ -48,13 +82,21 @@ struct GfxSlot { GfxComputeFn computeFn = nullptr; aurora::gfx::EncoderTaskId auroraTaskId = aurora::gfx::InvalidEncoderTask; + + GfxPresentFn presentFn = nullptr; + std::shared_ptr presentState; + WindowHandle window = 0; + uint32_t targetWidth = 0; + uint32_t targetHeight = 0; + WGPUTextureUsage targetUsage = WGPUTextureUsage_RenderAttachment; + WGPUTextureFormat preferredFormat = WGPUTextureFormat_Undefined; + WGPUCompositeAlphaMode preferredAlphaMode = WGPUCompositeAlphaMode_Auto; + uint32_t lastPresentFrame = UINT32_MAX; }; struct WorkerFailure { std::string modId; std::string message; - std::vector drawIds; - std::vector taskIds; }; std::mutex s_mutex; @@ -84,20 +126,23 @@ GfxSlot* resolve_owned_slot_locked(LoadedMod& mod, uint64_t handle, GfxSlotKind return &entry->value; } -void collect_mod_slots_locked(LoadedMod& owner, std::vector& drawIds, +void collect_mod_types_locked(LoadedMod& owner, std::vector& drawIds, std::vector& taskIds) { - auto entries = s_slots.take_all(owner); - for (auto& entry : entries) { + s_slots.for_each([&](uint64_t, const auto& entry) { + if (entry.owner != &owner) { + return; + } const auto& slot = entry.value; if (slot.kind == GfxSlotKind::DrawType && slot.auroraDrawId != aurora::gfx::InvalidDrawType) { drawIds.push_back(slot.auroraDrawId); - } else if (slot.kind == GfxSlotKind::ComputeType && + } else if ((slot.kind == GfxSlotKind::ComputeType || + slot.kind == GfxSlotKind::PresentTarget) && slot.auroraTaskId != aurora::gfx::InvalidEncoderTask) { taskIds.push_back(slot.auroraTaskId); } - } + }); } void unregister_aurora_types(const std::vector& drawIds, @@ -116,7 +161,6 @@ void draw_trampoline(const aurora::gfx::DrawContext& ctx, const wgpu::RenderPass GfxDrawFn fn = nullptr; void* userData = nullptr; ModContext* modContext = nullptr; - LoadedMod* owner = nullptr; std::string ownerId; { std::lock_guard lock{s_mutex}; @@ -128,7 +172,6 @@ void draw_trampoline(const aurora::gfx::DrawContext& ctx, const wgpu::RenderPass fn = slot.drawFn; userData = slot.userData; modContext = slot.ownerContext; - owner = entry->owner; ownerId = slot.ownerId; } @@ -164,7 +207,6 @@ void draw_trampoline(const aurora::gfx::DrawContext& ctx, const wgpu::RenderPass .modId = std::move(ownerId), .message = std::move(failure), }; - collect_mod_slots_locked(*owner, record.drawIds, record.taskIds); s_workerFailures.push_back(std::move(record)); } @@ -174,7 +216,6 @@ void compute_trampoline(const aurora::gfx::EncoderTaskContext& ctx, const wgpu:: GfxComputeFn fn = nullptr; void* userData = nullptr; ModContext* modContext = nullptr; - LoadedMod* owner = nullptr; std::string ownerId; { std::lock_guard lock{s_mutex}; @@ -186,7 +227,6 @@ void compute_trampoline(const aurora::gfx::EncoderTaskContext& ctx, const wgpu:: fn = slot.computeFn; userData = slot.userData; modContext = slot.ownerContext; - owner = entry->owner; ownerId = slot.ownerId; } @@ -216,10 +256,235 @@ void compute_trampoline(const aurora::gfx::EncoderTaskContext& ctx, const wgpu:: .modId = std::move(ownerId), .message = std::move(failure), }; - collect_mod_slots_locked(*owner, record.drawIds, record.taskIds); s_workerFailures.push_back(std::move(record)); } +template +bool contains(const T* values, size_t count, T value) { + for (size_t i = 0; i < count; ++i) { + if (values[i] == value) { + return true; + } + } + return false; +} + +bool configure_present_target(const std::shared_ptr& state, + const aurora::gfx::EncoderTaskContext& ctx, uint32_t width, uint32_t height, + WGPUTextureUsage requestedUsage, WGPUTextureFormat preferredFormat, + WGPUCompositeAlphaMode preferredAlphaMode, bool vsync) { + if (width == 0 || height == 0) { + state->status.store(PresentTargetStatus::TransientUnavailable, std::memory_order_release); + return false; + } + if (!state->surface) { + state->status.store(PresentTargetStatus::Error, std::memory_order_release); + return false; + } + + const auto adapter = ctx.device.GetAdapter(); + wgpu::SurfaceCapabilities capabilities; + if (!adapter || + state->surface.GetCapabilities(adapter, &capabilities) != wgpu::Status::Success || + capabilities.formatCount == 0 || capabilities.presentModeCount == 0 || + capabilities.alphaModeCount == 0) + { + state->status.store(PresentTargetStatus::Error, std::memory_order_release); + return false; + } + + auto format = static_cast(preferredFormat); + if (format == wgpu::TextureFormat::Undefined || + !contains(capabilities.formats, capabilities.formatCount, format)) + { + format = aurora::gfx::color_format(); + } + if (format == wgpu::TextureFormat::Undefined || + !contains(capabilities.formats, capabilities.formatCount, format)) + { + format = capabilities.formats[0]; + } + + const auto presentMode = aurora::webgpu::select_present_mode(capabilities); + + auto alphaMode = static_cast(preferredAlphaMode); + if (alphaMode != wgpu::CompositeAlphaMode::Auto && + !contains(capabilities.alphaModes, capabilities.alphaModeCount, alphaMode)) + { + alphaMode = capabilities.alphaModes[0]; + } + + auto usage = static_cast(requestedUsage); + if (usage == wgpu::TextureUsage::None) { + usage = wgpu::TextureUsage::RenderAttachment; + } + if ((usage & wgpu::TextureUsage::RenderAttachment) == wgpu::TextureUsage::None || + (usage & ~capabilities.usages) != wgpu::TextureUsage::None) + { + state->status.store(PresentTargetStatus::Error, std::memory_order_release); + return false; + } + + state->configuration = wgpu::SurfaceConfiguration{ + .device = ctx.device, + .format = format, + .usage = usage, + .width = width, + .height = height, + .alphaMode = alphaMode, + .presentMode = presentMode, + }; + state->surface.Configure(&state->configuration); + state->configured = true; + state->vsync = vsync; + state->status.store(PresentTargetStatus::Pending, std::memory_order_release); + return true; +} + +void present_trampoline(const aurora::gfx::EncoderTaskContext& ctx, const wgpu::CommandEncoder& cmd, + const void* payload, size_t payloadSize, void* userdata) { + const auto handle = static_cast(reinterpret_cast(userdata)); + GfxPresentFn fn = nullptr; + void* userData = nullptr; + ModContext* modContext = nullptr; + std::string ownerId; + std::shared_ptr state; + uint32_t width = 0; + uint32_t height = 0; + WGPUTextureUsage usage = WGPUTextureUsage_RenderAttachment; + WGPUTextureFormat preferredFormat = WGPUTextureFormat_Undefined; + WGPUCompositeAlphaMode preferredAlphaMode = WGPUCompositeAlphaMode_Auto; + { + std::lock_guard lock{s_mutex}; + auto* entry = resolve_entry_locked(handle, GfxSlotKind::PresentTarget); + if (entry == nullptr) { + return; + } + const auto& slot = entry->value; + fn = slot.presentFn; + userData = slot.userData; + modContext = slot.ownerContext; + ownerId = slot.ownerId; + state = slot.presentState; + width = slot.targetWidth; + height = slot.targetHeight; + usage = slot.targetUsage; + preferredFormat = slot.preferredFormat; + preferredAlphaMode = slot.preferredAlphaMode; + } + if (fn == nullptr || state == nullptr || present_target_failed(state)) { + return; + } + + state->presentPending = false; + state->currentView = {}; + state->currentTexture = {}; + const bool vsync = aurora::webgpu::vsync_enabled(); + if (!state->configured || state->configuration.width != width || + state->configuration.height != height || state->vsync != vsync) + { + if (!configure_present_target( + state, ctx, width, height, usage, preferredFormat, preferredAlphaMode, vsync)) + { + return; + } + } + + wgpu::SurfaceTexture surfaceTexture; + state->surface.GetCurrentTexture(&surfaceTexture); + if (surfaceTexture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessOptimal && + surfaceTexture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessSuboptimal) + { + if (surfaceTexture.status == wgpu::SurfaceGetCurrentTextureStatus::Lost) { + state->status.store(PresentTargetStatus::Lost, std::memory_order_release); + state->configured = false; + } else if (surfaceTexture.status == wgpu::SurfaceGetCurrentTextureStatus::Outdated) { + state->status.store( + PresentTargetStatus::TransientUnavailable, std::memory_order_release); + state->configured = false; + } else if (surfaceTexture.status == wgpu::SurfaceGetCurrentTextureStatus::Error) { + state->status.store(PresentTargetStatus::Error, std::memory_order_release); + state->configured = false; + } else { + state->status.store( + PresentTargetStatus::TransientUnavailable, std::memory_order_release); + } + return; + } + + state->currentTexture = std::move(surfaceTexture.texture); + state->currentView = state->currentTexture.CreateView(); + if (!state->currentTexture || !state->currentView) { + state->status.store(PresentTargetStatus::Error, std::memory_order_release); + return; + } + + const GfxPresentContext presentContext{ + .struct_size = sizeof(GfxPresentContext), + .device = ctx.device.Get(), + .queue = ctx.queue.Get(), + .encoder = cmd.Get(), + .target_texture = state->currentTexture.Get(), + .target_view = state->currentView.Get(), + .target_format = static_cast(state->configuration.format), + .target_width = width, + .target_height = height, + .vertex_buffer = ctx.vertexBuffer.Get(), + .index_buffer = ctx.indexBuffer.Get(), + .uniform_buffer = ctx.uniformBuffer.Get(), + .storage_buffer = ctx.storageBuffer.Get(), + }; + + std::string failure; + try { + fn(modContext, &presentContext, payload, payloadSize, userData); + state->presentPending = true; + state->status.store(PresentTargetStatus::Ready, std::memory_order_release); + if (surfaceTexture.status == wgpu::SurfaceGetCurrentTextureStatus::SuccessSuboptimal) { + state->configured = false; + } + return; + } catch (const std::exception& e) { + failure = fmt::format("exception in gfx present callback: {}", e.what()); + } catch (...) { + failure = "unknown exception in gfx present callback"; + } + + state->presentPending = false; + state->status.store(PresentTargetStatus::Error, std::memory_order_release); + std::lock_guard lock{s_mutex}; + s_workerFailures.push_back(WorkerFailure{ + .modId = std::move(ownerId), + .message = std::move(failure), + }); +} + +void present_after_submit_trampoline( + const aurora::gfx::EncoderTaskCompletionContext&, const void*, size_t, void* userdata) { + const auto handle = static_cast(reinterpret_cast(userdata)); + std::shared_ptr state; + { + std::lock_guard lock{s_mutex}; + const auto* entry = resolve_entry_locked(handle, GfxSlotKind::PresentTarget); + if (entry == nullptr) { + return; + } + state = entry->value.presentState; + } + if (state == nullptr || !state->presentPending) { + return; + } + + const bool presented = state->surface.Present(); + state->presentPending = false; + state->currentView = {}; + state->currentTexture = {}; + if (!presented) { + state->configured = false; + state->status.store(PresentTargetStatus::Error, std::memory_order_release); + } +} + } // namespace ModResult gfx_register_draw_type( @@ -449,6 +714,158 @@ ModResult gfx_push_compute( return MOD_OK; } +ModResult gfx_register_present_target(LoadedMod& mod, wgpu::Surface surface, WindowHandle window, + const GfxPresentTargetDesc& desc, uint64_t& outHandle) { + outHandle = 0; + auto state = std::make_shared(); + state->surface = std::move(surface); + + uint64_t handle = 0; + { + std::lock_guard lock{s_mutex}; + handle = s_slots.emplace(mod, GfxSlot{ + .kind = GfxSlotKind::PresentTarget, + .ownerContext = mod.context.get(), + .ownerId = mod.metadata.id, + .userData = desc.user_data, + .presentFn = desc.render, + .presentState = state, + .window = window, + .targetWidth = desc.width, + .targetHeight = desc.height, + .targetUsage = desc.usage == WGPUTextureUsage_None ? + WGPUTextureUsage_RenderAttachment : + desc.usage, + .preferredFormat = desc.preferred_format, + .preferredAlphaMode = desc.preferred_alpha_mode, + }); + } + + const auto auroraId = + aurora::gfx::register_encoder_task_type(aurora::gfx::EncoderTaskDescriptor{ + .label = desc.label, + .callback = present_trampoline, + .userdata = reinterpret_cast(static_cast(handle)), + .afterSubmit = present_after_submit_trampoline, + }); + if (auroraId == aurora::gfx::InvalidEncoderTask) { + std::lock_guard lock{s_mutex}; + s_slots.erase_owned(handle, mod); + return MOD_ERROR; + } + + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::PresentTarget); + if (slot == nullptr) { + aurora::gfx::unregister_encoder_task_type(auroraId); + return MOD_ERROR; + } + slot->auroraTaskId = auroraId; + } + outHandle = handle; + return MOD_OK; +} + +ModResult gfx_unregister_present_target(LoadedMod& mod, uint64_t handle) { + aurora::gfx::EncoderTaskId auroraId = aurora::gfx::InvalidEncoderTask; + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::PresentTarget); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auroraId = slot->auroraTaskId; + } + + aurora::gfx::unregister_encoder_task_type(auroraId); + aurora::gfx::synchronize(); + + std::optional removed; + { + std::lock_guard lock{s_mutex}; + removed = s_slots.take_owned(handle, mod); + } + if (!removed.has_value()) { + return MOD_INVALID_ARGUMENT; + } + if (removed->value.presentState != nullptr && removed->value.presentState->configured) { + removed->value.presentState->surface.Unconfigure(); + } + if (removed->value.window != 0) { + svc::window_release_for_graphics(mod, removed->value.window); + } + return MOD_OK; +} + +ModResult gfx_resize_present_target( + LoadedMod& mod, uint64_t handle, uint32_t width, uint32_t height) { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::PresentTarget); + if (slot == nullptr || width == 0 || height == 0) { + return MOD_INVALID_ARGUMENT; + } + if (slot->window != 0) { + return MOD_UNSUPPORTED; + } + slot->targetWidth = width; + slot->targetHeight = height; + return MOD_OK; +} + +ModResult gfx_push_present( + LoadedMod& mod, uint64_t handle, const void* payload, size_t payloadSize) { + WindowHandle window = 0; + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::PresentTarget); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + if (present_target_failed(slot->presentState)) { + return MOD_ERROR; + } + window = slot->window; + } + + uint32_t width = 0; + uint32_t height = 0; + if (window != 0 && !svc::window_get_pixel_size(mod, window, width, height)) { + return MOD_UNAVAILABLE; + } + + aurora::gfx::EncoderTaskId auroraId = aurora::gfx::InvalidEncoderTask; + const uint32_t frame = aurora::gfx::current_frame(); + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::PresentTarget); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + if (present_target_failed(slot->presentState)) { + return MOD_ERROR; + } + if (slot->lastPresentFrame == frame) { + return MOD_CONFLICT; + } + if (window != 0) { + slot->targetWidth = width; + slot->targetHeight = height; + } + auroraId = slot->auroraTaskId; + } + if (!aurora::gfx::push_encoder_task(auroraId, payload, payloadSize)) { + return MOD_UNAVAILABLE; + } + { + std::lock_guard lock{s_mutex}; + if (auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::PresentTarget)) { + slot->lastPresentFrame = frame; + } + } + return MOD_OK; +} + void gfx_run_stage( GfxStage stage, const view_class* gameView, const view_port_class* gameViewport) { struct StageEntry { @@ -518,6 +935,37 @@ void gfx_run_stage( } } +void gfx_remove_mod(LoadedMod& mod) { + std::vector drawIds; + std::vector taskIds; + { + std::lock_guard lock{s_mutex}; + collect_mod_types_locked(mod, drawIds, taskIds); + } + unregister_aurora_types(drawIds, taskIds); + if (!drawIds.empty() || !taskIds.empty()) { + aurora::gfx::synchronize(); + } + + std::vector entries; + { + std::lock_guard lock{s_mutex}; + entries = s_slots.take_all(mod); + } + for (auto& entry : entries) { + auto& slot = entry.value; + if (slot.kind != GfxSlotKind::PresentTarget) { + continue; + } + if (slot.presentState != nullptr && slot.presentState->configured) { + slot.presentState->surface.Unconfigure(); + } + if (slot.window != 0) { + svc::window_release_for_graphics(mod, slot.window); + } + } +} + void gfx_drain_worker_failures() { std::vector failures; { @@ -528,18 +976,10 @@ void gfx_drain_worker_failures() { return; } - bool needsSynchronize = false; - for (const auto& failure : failures) { - unregister_aurora_types(failure.drawIds, failure.taskIds); - needsSynchronize = needsSynchronize || !failure.drawIds.empty() || !failure.taskIds.empty(); - } - if (needsSynchronize) { - aurora::gfx::synchronize(); - } - for (const auto& failure : failures) { for (auto& mod : ModLoader::instance().mods()) { if (mod.metadata.id == failure.modId && mod.active) { + gfx_remove_mod(mod); fail_mod(mod, MOD_ERROR, failure.message); break; } @@ -547,43 +987,46 @@ void gfx_drain_worker_failures() { } } -void gfx_remove_mod(LoadedMod& mod) { - std::vector drawIds; - std::vector taskIds; - { - std::lock_guard lock{s_mutex}; - collect_mod_slots_locked(mod, drawIds, taskIds); - } - if (drawIds.empty() && taskIds.empty()) { - return; - } - unregister_aurora_types(drawIds, taskIds); - aurora::gfx::synchronize(); -} - } // namespace dusk::mods namespace dusk::mods::svc { namespace { ModResult gfx_get_device_info(ModContext* context, GfxDeviceInfo* outInfo) { - if (outInfo == nullptr || outInfo->struct_size < sizeof(GfxDeviceInfo)) { + constexpr uint32_t v0Size = offsetof(GfxDeviceInfo, instance); + if (outInfo == nullptr || outInfo->struct_size < v0Size) { return MOD_INVALID_ARGUMENT; } const uint32_t structSize = outInfo->struct_size; - *outInfo = GfxDeviceInfo{.struct_size = structSize}; + outInfo->device = nullptr; + outInfo->queue = nullptr; + outInfo->color_format = WGPUTextureFormat_Undefined; + outInfo->depth_format = WGPUTextureFormat_Undefined; + outInfo->sample_count = 1; + outInfo->uses_reversed_z = false; + if (structSize >= sizeof(GfxDeviceInfo)) { + outInfo->instance = nullptr; + outInfo->adapter = nullptr; + } auto* mod = mod_from_context(context); if (mod == nullptr) { return MOD_INVALID_ARGUMENT; } - outInfo->device = aurora::gfx::device().Get(); + const auto device = aurora::gfx::device(); + outInfo->device = device.Get(); outInfo->queue = aurora::gfx::queue().Get(); outInfo->color_format = static_cast(aurora::gfx::color_format()); outInfo->depth_format = static_cast(aurora::gfx::depth_format()); outInfo->sample_count = aurora::gfx::sample_count(); outInfo->uses_reversed_z = aurora::gfx::uses_reversed_z(); + if (structSize >= sizeof(GfxDeviceInfo)) { + const auto adapter = device.GetAdapter(); + const auto instance = adapter ? adapter.GetInstance() : wgpu::Instance{}; + outInfo->instance = instance.Get(); + outInfo->adapter = adapter.Get(); + } return MOD_OK; } @@ -594,6 +1037,103 @@ void* gfx_get_proc_address(ModContext* context, const char* name) { return reinterpret_cast(wgpuGetProcAddress(WGPUStringView{name, WGPU_STRLEN})); } +bool valid_present_desc(const GfxPresentTargetDesc* desc) { + return desc != nullptr && desc->struct_size >= sizeof(GfxPresentTargetDesc) && + desc->render != nullptr; +} + +ModResult gfx_register_present_target_impl(ModContext* context, WGPUSurface surface, + const GfxPresentTargetDesc* desc, GfxPresentTargetHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || surface == nullptr || !valid_present_desc(desc) || outHandle == nullptr || + desc->width == 0 || desc->height == 0) + { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = gfx_register_present_target(*mod, wgpu::Surface{surface}, 0, *desc, handle); + if (result == MOD_OK) { + *outHandle = handle; + } + return result; +} + +ModResult gfx_register_window_present_target_impl(ModContext* context, WindowHandle window, + const GfxPresentTargetDesc* desc, GfxPresentTargetHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || window == 0 || !valid_present_desc(desc) || outHandle == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + SDL_Window* sdlWindow = nullptr; + const auto acquireResult = window_acquire_for_graphics(*mod, window, sdlWindow); + if (acquireResult != MOD_OK) { + return acquireResult; + } + uint32_t width = 0; + uint32_t height = 0; + if (!window_get_pixel_size(*mod, window, width, height)) { + window_release_for_graphics(*mod, window); + return MOD_UNAVAILABLE; + } + + const auto device = aurora::gfx::device(); + const auto adapter = device.GetAdapter(); + const auto instance = adapter ? adapter.GetInstance() : wgpu::Instance{}; + auto surface = aurora::webgpu::create_window_surface(instance, sdlWindow, desc->label); + if (!surface) { + window_release_for_graphics(*mod, window); + return MOD_UNAVAILABLE; + } + + auto windowDesc = *desc; + windowDesc.width = width; + windowDesc.height = height; + uint64_t handle = 0; + const auto result = + gfx_register_present_target(*mod, std::move(surface), window, windowDesc, handle); + if (result != MOD_OK) { + window_release_for_graphics(*mod, window); + return result; + } + *outHandle = handle; + return MOD_OK; +} + +ModResult gfx_resize_present_target_impl( + ModContext* context, GfxPresentTargetHandle handle, uint32_t width, uint32_t height) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0 || width == 0 || height == 0) { + return MOD_INVALID_ARGUMENT; + } + return gfx_resize_present_target(*mod, handle, width, height); +} + +ModResult gfx_unregister_present_target_impl(ModContext* context, GfxPresentTargetHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + return gfx_unregister_present_target(*mod, handle); +} + +ModResult gfx_push_present_impl( + ModContext* context, GfxPresentTargetHandle handle, const void* payload, size_t payloadSize) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0 || payloadSize > GFX_INLINE_DRAW_PAYLOAD_SIZE || + (payloadSize > 0 && payload == nullptr)) + { + return MOD_INVALID_ARGUMENT; + } + return gfx_push_present(*mod, handle, payload, payloadSize); +} + ModResult gfx_register_draw_type_impl( ModContext* context, const GfxDrawTypeDesc* desc, GfxDrawTypeHandle* outHandle) { if (outHandle != nullptr) { @@ -782,6 +1322,11 @@ constexpr GfxService s_gfxService{ .unregister_stage_hook = gfx_unregister_stage_hook_impl, .resolve_pass = gfx_resolve_pass_impl, .create_pass = gfx_create_pass_impl, + .register_present_target = gfx_register_present_target_impl, + .register_window_present_target = gfx_register_window_present_target_impl, + .resize_present_target = gfx_resize_present_target_impl, + .unregister_present_target = gfx_unregister_present_target_impl, + .push_present = gfx_push_present_impl, }; } // namespace diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 2033d47475..622db55da9 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -209,6 +209,7 @@ void ModLoader::init_services() { &svc::g_uiModule, &svc::g_gameModule, &svc::g_cameraModule, + &svc::g_windowModule, &svc::g_gfxModule, }) { diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 191626e0ef..4ba4f695ab 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -71,6 +71,7 @@ extern const ServiceModule g_configModule; extern const ServiceModule g_uiModule; extern const ServiceModule g_gameModule; extern const ServiceModule g_cameraModule; +extern const ServiceModule g_windowModule; extern const ServiceModule g_gfxModule; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/window.cpp b/src/dusk/mods/svc/window.cpp new file mode 100644 index 0000000000..ddc5832843 --- /dev/null +++ b/src/dusk/mods/svc/window.cpp @@ -0,0 +1,352 @@ +#include "window.hpp" + +#include "registry.hpp" +#include "slot_map.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::window"); + +struct WindowSlot { + SDL_Window* window = nullptr; + SDL_WindowID windowId = 0; + WindowEventFn onEvent = nullptr; + void* userData = nullptr; + uint32_t graphicsRefs = 0; +}; + +SlotMap s_windows; +std::unordered_map s_windowsById; + +WindowSlot* resolve_window(LoadedMod& mod, WindowHandle handle) { + auto* entry = s_windows.find_owned(handle, mod); + return entry != nullptr ? &entry->value : nullptr; +} + +bool populate_info(SDL_Window* window, WindowInfo& info) { + int x = 0; + int y = 0; + int width = 0; + int height = 0; + int pixelWidth = 0; + int pixelHeight = 0; + if (!SDL_GetWindowPosition(window, &x, &y) || !SDL_GetWindowSize(window, &width, &height) || + !SDL_GetWindowSizeInPixels(window, &pixelWidth, &pixelHeight)) + { + return false; + } + const auto flags = SDL_GetWindowFlags(window); + info.x = x; + info.y = y; + info.width = static_cast(width); + info.height = static_cast(height); + info.pixel_width = static_cast(pixelWidth); + info.pixel_height = static_cast(pixelHeight); + info.display_scale = SDL_GetWindowDisplayScale(window); + info.visible = (flags & SDL_WINDOW_HIDDEN) == 0u; + info.focused = (flags & SDL_WINDOW_INPUT_FOCUS) != 0u; + return true; +} + +ModResult create_window_impl(ModContext* context, const WindowDesc* desc, WindowHandle* outWindow) { + if (outWindow != nullptr) { + *outWindow = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(WindowDesc) || + outWindow == nullptr || desc->width == 0 || desc->height == 0) + { + return MOD_INVALID_ARGUMENT; + } + + SDL_WindowFlags flags = SDL_WINDOW_HIGH_PIXEL_DENSITY; + if ((desc->flags & WINDOW_FLAG_RESIZABLE) != 0u) { + flags |= SDL_WINDOW_RESIZABLE; + } + if ((desc->flags & WINDOW_FLAG_HIDDEN) != 0u) { + flags |= SDL_WINDOW_HIDDEN; + } + if ((desc->flags & WINDOW_FLAG_BORDERLESS) != 0u) { + flags |= SDL_WINDOW_BORDERLESS; + } + if ((desc->flags & WINDOW_FLAG_ALWAYS_ON_TOP) != 0u) { + flags |= SDL_WINDOW_ALWAYS_ON_TOP; + } + if ((desc->flags & WINDOW_FLAG_TRANSPARENT) != 0u) { + flags |= SDL_WINDOW_TRANSPARENT; + } + + const auto properties = SDL_CreateProperties(); + if (properties == 0) { + Log.error("[{}] create_window: {}", mod->metadata.id, SDL_GetError()); + return MOD_ERROR; + } + + const char* title = desc->title != nullptr ? desc->title : mod->metadata.name.c_str(); + const auto x = desc->x == WINDOW_POSITION_UNDEFINED ? SDL_WINDOWPOS_UNDEFINED : desc->x; + const auto y = desc->y == WINDOW_POSITION_UNDEFINED ? SDL_WINDOWPOS_UNDEFINED : desc->y; + const bool propertiesSet = + SDL_SetStringProperty(properties, SDL_PROP_WINDOW_CREATE_TITLE_STRING, title) && + SDL_SetNumberProperty(properties, SDL_PROP_WINDOW_CREATE_X_NUMBER, x) && + SDL_SetNumberProperty(properties, SDL_PROP_WINDOW_CREATE_Y_NUMBER, y) && + SDL_SetNumberProperty(properties, SDL_PROP_WINDOW_CREATE_WIDTH_NUMBER, desc->width) && + SDL_SetNumberProperty(properties, SDL_PROP_WINDOW_CREATE_HEIGHT_NUMBER, desc->height) && + SDL_SetNumberProperty(properties, SDL_PROP_WINDOW_CREATE_FLAGS_NUMBER, flags) && + SDL_SetBooleanProperty( + properties, SDL_PROP_WINDOW_CREATE_EXTERNAL_GRAPHICS_CONTEXT_BOOLEAN, true); + if (!propertiesSet) { + Log.error("[{}] create_window: {}", mod->metadata.id, SDL_GetError()); + SDL_DestroyProperties(properties); + return MOD_ERROR; + } + + SDL_Window* window = SDL_CreateWindowWithProperties(properties); + SDL_DestroyProperties(properties); + if (window == nullptr) { + Log.error("[{}] create_window: {}", mod->metadata.id, SDL_GetError()); + return MOD_ERROR; + } + + const auto windowId = SDL_GetWindowID(window); + const auto handle = s_windows.emplace(*mod, WindowSlot{ + .window = window, + .windowId = windowId, + .onEvent = desc->on_event, + .userData = desc->user_data, + }); + s_windowsById.emplace(windowId, handle); + *outWindow = handle; + return MOD_OK; +} + +ModResult destroy_window_impl(ModContext* context, WindowHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auto* slot = resolve_window(*mod, handle); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + if (slot->graphicsRefs != 0) { + return MOD_CONFLICT; + } + const auto windowId = slot->windowId; + SDL_Window* window = slot->window; + s_windowsById.erase(windowId); + s_windows.erase_owned(handle, *mod); + SDL_DestroyWindow(window); + return MOD_OK; +} + +ModResult show_window_impl(ModContext* context, WindowHandle handle) { + auto* mod = mod_from_context(context); + auto* slot = mod != nullptr ? resolve_window(*mod, handle) : nullptr; + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return SDL_ShowWindow(slot->window) ? MOD_OK : MOD_ERROR; +} + +ModResult hide_window_impl(ModContext* context, WindowHandle handle) { + auto* mod = mod_from_context(context); + auto* slot = mod != nullptr ? resolve_window(*mod, handle) : nullptr; + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return SDL_HideWindow(slot->window) ? MOD_OK : MOD_ERROR; +} + +ModResult set_title_impl(ModContext* context, WindowHandle handle, const char* title) { + auto* mod = mod_from_context(context); + auto* slot = mod != nullptr ? resolve_window(*mod, handle) : nullptr; + if (slot == nullptr || title == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return SDL_SetWindowTitle(slot->window, title) ? MOD_OK : MOD_ERROR; +} + +ModResult set_size_impl(ModContext* context, WindowHandle handle, uint32_t width, uint32_t height) { + auto* mod = mod_from_context(context); + auto* slot = mod != nullptr ? resolve_window(*mod, handle) : nullptr; + if (slot == nullptr || width == 0 || height == 0) { + return MOD_INVALID_ARGUMENT; + } + return SDL_SetWindowSize(slot->window, static_cast(width), static_cast(height)) ? + MOD_OK : + MOD_ERROR; +} + +ModResult get_info_impl(ModContext* context, WindowHandle handle, WindowInfo* outInfo) { + if (outInfo == nullptr || outInfo->struct_size < sizeof(WindowInfo)) { + return MOD_INVALID_ARGUMENT; + } + const uint32_t structSize = outInfo->struct_size; + *outInfo = WindowInfo{.struct_size = structSize}; + auto* mod = mod_from_context(context); + auto* slot = mod != nullptr ? resolve_window(*mod, handle) : nullptr; + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return populate_info(slot->window, *outInfo) ? MOD_OK : MOD_ERROR; +} + +void remove_mod_windows(LoadedMod& mod) { + auto entries = s_windows.take_all(mod); + for (auto& entry : entries) { + s_windowsById.erase(entry.value.windowId); + SDL_DestroyWindow(entry.value.window); + } +} + +constexpr WindowService s_windowService{ + .header = SERVICE_HEADER(WindowService, WINDOW_SERVICE_MAJOR, WINDOW_SERVICE_MINOR), + .create_window = create_window_impl, + .destroy_window = destroy_window_impl, + .show_window = show_window_impl, + .hide_window = hide_window_impl, + .set_title = set_title_impl, + .set_size = set_size_impl, + .get_info = get_info_impl, +}; + +} // namespace + +bool window_dispatch_event(const SDL_Event& event) { + SDL_Window* eventWindow = SDL_GetWindowFromEvent(&event); + if (eventWindow == nullptr) { + return false; + } + const auto it = s_windowsById.find(SDL_GetWindowID(eventWindow)); + if (it == s_windowsById.end()) { + return false; + } + const auto handle = it->second; + const auto* entry = s_windows.find(handle); + if (entry == nullptr) { + return true; + } + + WindowEventType type; + bool exposed = true; + switch (event.type) { + case SDL_EVENT_WINDOW_CLOSE_REQUESTED: + type = WINDOW_EVENT_CLOSE_REQUESTED; + break; + case SDL_EVENT_WINDOW_RESIZED: + case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: + case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: + type = WINDOW_EVENT_RESIZED; + break; + case SDL_EVENT_WINDOW_MOVED: + type = WINDOW_EVENT_MOVED; + break; + case SDL_EVENT_WINDOW_FOCUS_GAINED: + type = WINDOW_EVENT_FOCUS_GAINED; + break; + case SDL_EVENT_WINDOW_FOCUS_LOST: + type = WINDOW_EVENT_FOCUS_LOST; + break; + case SDL_EVENT_WINDOW_SHOWN: + type = WINDOW_EVENT_SHOWN; + break; + case SDL_EVENT_WINDOW_HIDDEN: + type = WINDOW_EVENT_HIDDEN; + break; + default: + exposed = false; + break; + } + if (!exposed || entry->value.onEvent == nullptr || !entry->owner->active) { + return true; + } + + WindowInfo info = WINDOW_INFO_INIT; + populate_info(entry->value.window, info); + const WindowEvent windowEvent{ + .struct_size = sizeof(WindowEvent), + .type = type, + .x = info.x, + .y = info.y, + .width = info.width, + .height = info.height, + .pixel_width = info.pixel_width, + .pixel_height = info.pixel_height, + .display_scale = info.display_scale, + }; + auto* owner = entry->owner; + const auto callback = entry->value.onEvent; + void* userData = entry->value.userData; + try { + callback(owner->context.get(), handle, &windowEvent, userData); + } catch (const std::exception& e) { + fail_mod( + *owner, MOD_ERROR, fmt::format("Exception in window event callback: {}", e.what())); + } catch (...) { + fail_mod(*owner, MOD_ERROR, "Unknown exception in window event callback"); + } + return true; +} + +ModResult window_acquire_for_graphics( + LoadedMod& mod, WindowHandle handle, SDL_Window*& outWindow) { + outWindow = nullptr; + auto* slot = resolve_window(mod, handle); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + if (slot->graphicsRefs != 0) { + return MOD_CONFLICT; + } + ++slot->graphicsRefs; + outWindow = slot->window; + return MOD_OK; +} + +void window_release_for_graphics(LoadedMod& mod, WindowHandle handle) { + auto* slot = resolve_window(mod, handle); + if (slot != nullptr && slot->graphicsRefs > 0) { + --slot->graphicsRefs; + } +} + +bool window_get_pixel_size(LoadedMod& mod, WindowHandle handle, uint32_t& width, uint32_t& height) { + auto* slot = resolve_window(mod, handle); + if (slot == nullptr) { + return false; + } + int pixelWidth = 0; + int pixelHeight = 0; + if (!SDL_GetWindowSizeInPixels(slot->window, &pixelWidth, &pixelHeight) || pixelWidth <= 0 || + pixelHeight <= 0) + { + return false; + } + width = static_cast(pixelWidth); + height = static_cast(pixelHeight); + return true; +} + +constinit const ServiceModule g_windowModule{ + .id = WINDOW_SERVICE_ID, + .majorVersion = WINDOW_SERVICE_MAJOR, + .minorVersion = WINDOW_SERVICE_MINOR, + .service = &s_windowService, + .modDetached = remove_mod_windows, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/window.hpp b/src/dusk/mods/svc/window.hpp new file mode 100644 index 0000000000..29b48c1b75 --- /dev/null +++ b/src/dusk/mods/svc/window.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "mods/svc/window.h" + +union SDL_Event; +struct SDL_Window; + +namespace dusk::mods { +struct LoadedMod; +} + +namespace dusk::mods::svc { + +// Routes an SDL event for an auxiliary mod window. +// Returns true when the event belongs to one. +bool window_dispatch_event(const SDL_Event& event); +ModResult window_acquire_for_graphics(LoadedMod& mod, WindowHandle handle, SDL_Window*& outWindow); +void window_release_for_graphics(LoadedMod& mod, WindowHandle handle); +bool window_get_pixel_size(LoadedMod& mod, WindowHandle handle, uint32_t& width, uint32_t& height); + +} // namespace dusk::mods::svc diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index d291043d67..89623161c2 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -57,13 +57,14 @@ #include "dusk/frame_interpolation.h" #include "dusk/game_clock.h" #include "dusk/gyro.h" -#include "dusk/mouse.h" #include "dusk/imgui/ImGuiConsole.hpp" #include "dusk/imgui/ImGuiEngine.hpp" #include "dusk/iso_validate.hpp" -#include "dusk/mod_loader.hpp" #include "dusk/logging.h" #include "dusk/main.h" +#include "dusk/mod_loader.hpp" +#include "dusk/mods/svc/window.hpp" +#include "dusk/mouse.h" #include "dusk/os.h" #include "dusk/ui/menu_bar.hpp" #include "dusk/ui/overlay.hpp" @@ -157,6 +158,9 @@ bool launchUILoop() { while (event != nullptr && event->type != AURORA_NONE) { switch (event->type) { case AURORA_SDL_EVENT: + if (dusk::mods::svc::window_dispatch_event(event->sdl)) { + break; + } dusk::mouse::handle_event(event->sdl); dusk::ui::handle_event(event->sdl); dusk::g_imguiConsole.HandleSDLEvent(event->sdl); @@ -244,6 +248,9 @@ void main01(void) { dusk::mouse::on_focus_gained(); break; case AURORA_SDL_EVENT: + if (dusk::mods::svc::window_dispatch_event(event->sdl)) { + break; + } dusk::mouse::handle_event(event->sdl); dusk::ui::handle_event(event->sdl); dusk::g_imguiConsole.HandleSDLEvent(event->sdl); From 4504e5009f22a8d32b2dc4b7470a543a9e2bafe4 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Thu, 30 Jul 2026 00:28:08 -0600 Subject: [PATCH 09/19] Mods: Add toasts to UiService (#2252) --- docs/modding.md | 16 ++++++++++++++++ sdk/include/mods/svc/ui.h | 22 +++++++++++++++++++--- src/dusk/mods/svc/ui.cpp | 23 +++++++++++++++++++++++ src/dusk/ui/overlay.cpp | 3 +++ src/dusk/ui/ui.hpp | 1 + 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 80e1998ea5..86af7ce34c 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -422,6 +422,22 @@ sets `keep_open`. A `keep_open` action can close it later (or immediately) with `on_dismiss` if present and always closes. `dialog_set_body`, `dialog_set_icon`, and `dialog_add_action` mutate a live dialog. +**Toasts:** `push_toast` enqueues a notification. Titles and bodies accept RML. The optional `type` is applied as an +RCSS class; `warning` uses the built-in warning appearance, and mods can define their own types. A duration of 0 uses +the default of 5 seconds. + +Toasts have a `mod-id` attribute, so `UI_SCOPE_OVERLAY` styles can use selectors such as +`toast[mod-id="com.example.randomizer"].success`. + +```cpp +UiToastDesc toast = UI_TOAST_DESC_INIT; +toast.type = "success"; +toast.title_rml = "Randomizer"; +toast.body_rml = "Seed loaded successfully."; +toast.duration_ms = 3000; +svc_ui->push_toast(mod_ctx, &toast); +``` + **Menu bar tabs:** `register_menu_tab` adds a tab to the in-game menu bar. `on_selected` fires when the user activates the tab: typically you'd push a window from it. The tab is removed by `unregister_menu_tab`, or automatically when the mod is disabled. diff --git a/sdk/include/mods/svc/ui.h b/sdk/include/mods/svc/ui.h index 1635a96c5f..5dd9066421 100644 --- a/sdk/include/mods/svc/ui.h +++ b/sdk/include/mods/svc/ui.h @@ -9,11 +9,11 @@ #define UI_SERVICE_ID "dev.twilitrealm.dusklight.ui" #define UI_SERVICE_MAJOR 1u -#define UI_SERVICE_MINOR 0u +#define UI_SERVICE_MINOR 1u /* - * UI primitives: a panel inside the host Mods window, mod-owned windows, dialogs, scoped - * RCSS stylesheets and menu bar tabs. + * UI primitives: a panel inside the host Mods window, mod-owned windows, dialogs, toasts, + * scoped RCSS stylesheets and menu bar tabs. * * All calls must be made on the game thread from mod callbacks (initialize, update, hooks, or UI * callbacks). Handles are opaque, generation-checked ids; a stale or unknown handle fails with @@ -212,6 +212,19 @@ typedef struct UiMenuTabDesc { #define UI_MENU_TAB_DESC_INIT {sizeof(UiMenuTabDesc), NULL, NULL, NULL} +typedef struct UiToastDesc { + uint32_t struct_size; + /* Optional RCSS class, such as "warning" or a custom mod-defined type. */ + const char* type; + /* Optional RML. At least one of title_rml or body_rml must be non-empty. */ + const char* title_rml; + const char* body_rml; + /* How long the toast remains open; 0 uses the default of 5000 ms. */ + uint32_t duration_ms; +} UiToastDesc; + +#define UI_TOAST_DESC_INIT {sizeof(UiToastDesc), NULL, NULL, NULL, 0u} + typedef struct UiService { ServiceHeader header; @@ -275,6 +288,9 @@ typedef struct UiService { ModResult (*register_menu_tab)( ModContext* ctx, const UiMenuTabDesc* desc, UiMenuTabHandle* out_tab); ModResult (*unregister_menu_tab)(ModContext* ctx, UiMenuTabHandle tab); + + /* Enqueue a toast notification. */ + ModResult (*push_toast)(ModContext* ctx, const UiToastDesc* desc); } UiService; MOD_DECLARE_SERVICE(UiService, svc_ui, UI_SERVICE_ID, UI_SERVICE_MAJOR, UI_SERVICE_MINOR); diff --git a/src/dusk/mods/svc/ui.cpp b/src/dusk/mods/svc/ui.cpp index cf4ea86808..6f0ff297fc 100644 --- a/src/dusk/mods/svc/ui.cpp +++ b/src/dusk/mods/svc/ui.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -1321,6 +1322,27 @@ ModResult ui_unregister_menu_tab(ModContext* context, UiMenuTabHandle tab) { return ui_impl::ui_unregister_menu_tab(*mod, tab); } +ModResult ui_push_toast(ModContext* context, const UiToastDesc* desc) { + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(UiToastDesc) || + ((desc->title_rml == nullptr || desc->title_rml[0] == '\0') && + (desc->body_rml == nullptr || desc->body_rml[0] == '\0'))) + { + return MOD_INVALID_ARGUMENT; + } + + constexpr uint32_t kDefaultDurationMs = 5000; + const uint32_t durationMs = desc->duration_ms == 0 ? kDefaultDurationMs : desc->duration_ms; + ui::push_toast({ + .type = desc->type != nullptr ? desc->type : "", + .title = desc->title_rml != nullptr ? desc->title_rml : "", + .content = desc->body_rml != nullptr ? desc->body_rml : "", + .duration = std::chrono::milliseconds{durationMs}, + .modId = mod->metadata.id, + }); + return MOD_OK; +} + ModResult ui_dialog_set_body(ModContext* context, UiDialogHandle dialog, const char* bodyRml) { auto* mod = mod_from_context(context); if (mod == nullptr || dialog == 0 || bodyRml == nullptr) { @@ -1371,6 +1393,7 @@ constexpr UiService s_uiService{ .unregister_styles = ui_unregister_styles, .register_menu_tab = ui_register_menu_tab, .unregister_menu_tab = ui_unregister_menu_tab, + .push_toast = ui_push_toast, }; } // namespace diff --git a/src/dusk/ui/overlay.cpp b/src/dusk/ui/overlay.cpp index d2bd3fe6c1..d0ee19cc6a 100644 --- a/src/dusk/ui/overlay.cpp +++ b/src/dusk/ui/overlay.cpp @@ -71,6 +71,9 @@ Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) { } auto* elem = append(parent, "toast"); + if (!toast.modId.empty()) { + elem->SetAttribute("mod-id", toast.modId); + } if (!toast.type.empty()) { elem->SetClass(toast.type, true); } diff --git a/src/dusk/ui/ui.hpp b/src/dusk/ui/ui.hpp index bd993ea34d..d5db990570 100644 --- a/src/dusk/ui/ui.hpp +++ b/src/dusk/ui/ui.hpp @@ -30,6 +30,7 @@ struct Toast { Rml::String title; Rml::String content; clock::duration duration; + Rml::String modId; }; // Button clicked/pressed From 519305b7d4caec0921823c5dc3820b3cb3109f24 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Tue, 4 Aug 2026 03:31:03 +0200 Subject: [PATCH 10/19] Linux/Apple compile warnings consistency (#2255) This re-organizes the CMakeLists.txt a lil so that some of the warning compile flags on Linux also get applied to Apple platforms. --- CMakeLists.txt | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a4a3d2cc8f..4bd493d884 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -193,24 +193,21 @@ if (DUSK_MOVIE_SUPPORT) endif () endif () -if (CMAKE_SYSTEM_NAME STREQUAL Linux) +if (CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "GNU") # -Wno-multichar: Multi-character constants ('ABCD') are implementation-defined but all compilers # (CW, GCC, Clang, MSVC) encode them identically in big-endian order. # For >4-char literals (which GCC/Clang truncate to int), use the MULTI_CHAR() macro. # -Wdeprecated-declarations: JSystem uses std::iterator, deprecated in C++17 - set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wno-multichar") - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-multichar -Wno-trigraphs -Wno-deprecated-declarations") - set(CMAKE_INSTALL_RPATH "$ORIGIN") - set(CMAKE_BUILD_RPATH "$ORIGIN") -elseif (APPLE) - add_compile_options(-Wno-declaration-after-statement -Wno-non-pod-varargs) - set(CMAKE_INSTALL_RPATH "@loader_path") - set(CMAKE_BUILD_RPATH "@loader_path") -elseif (MSVC) add_compile_options( - $<$:/bigobj> - $<$:/MP> - $<$:/FS> + $<$:-Wno-multichar> + $<$:-Wno-trigraphs> + $<$:-Wno-deprecated-declarations> + ) +elseif (CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + add_compile_options( + $<$:/bigobj> + $<$:/MP> + $<$:/FS> ) if (NOT DUSK_BUILD_WARNINGS) @@ -225,6 +222,15 @@ elseif (MSVC) add_compile_options($<$:/utf-8>) endif () +if (CMAKE_SYSTEM_NAME STREQUAL Linux) + set(CMAKE_INSTALL_RPATH "$ORIGIN") + set(CMAKE_BUILD_RPATH "$ORIGIN") +elseif (APPLE) + add_compile_options(-Wno-declaration-after-statement -Wno-non-pod-varargs) + set(CMAKE_INSTALL_RPATH "@loader_path") + set(CMAKE_BUILD_RPATH "@loader_path") +endif () + include(FetchContent) # Declare all dependencies first so CMake can download them in parallel From 76079b6294e1f912c628908ecf877cba16ed1424 Mon Sep 17 00:00:00 2001 From: TakaRikka <38417346+TakaRikka@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:58:09 -0700 Subject: [PATCH 11/19] mods: save service (#2256) * initial save service updated from encounter's impl * Review cleanup --------- Co-authored-by: Luke Street --- docs/modding.md | 33 +++ extern/aurora | 2 +- files.cmake | 3 + sdk/include/mods/svc/save.h | 63 +++++ src/d/d_file_select.cpp | 17 ++ src/d/d_menu_save.cpp | 5 + src/d/d_s_logo.cpp | 16 +- src/dusk/autosave.cpp | 6 +- src/dusk/mods/svc/registry.cpp | 1 + src/dusk/mods/svc/registry.hpp | 1 + src/dusk/mods/svc/save.cpp | 434 +++++++++++++++++++++++++++++++++ src/dusk/mods/svc/save.hpp | 14 ++ src/dusk/stubs.cpp | 2 +- src/dusk/utilities.cpp | 95 ++++++++ src/dusk/utilities.hpp | 17 ++ 15 files changed, 701 insertions(+), 8 deletions(-) create mode 100644 sdk/include/mods/svc/save.h create mode 100644 src/dusk/mods/svc/save.cpp create mode 100644 src/dusk/mods/svc/save.hpp create mode 100644 src/dusk/utilities.cpp create mode 100644 src/dusk/utilities.hpp diff --git a/docs/modding.md b/docs/modding.md index 86af7ce34c..93c0ac4f14 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -340,6 +340,39 @@ Change callbacks fire on the game thread whenever the value changes at runtime ( Writes that store the same value are silent. Values applied from `config.json` or `--cvar` at registration do **not** fire callbacks; read the value after `register_var` for the starting state. +### SaveService (`mods/svc/save.h`) + +Stores named binary blobs for each save slot. Blob names are scoped to the calling mod, and each mod may store up to +`SAVE_BLOB_BUDGET_BYTES` per slot. The service copies data passed to `set_blob`. + +```cpp +IMPORT_SERVICE(SaveService, svc_save); + +struct MySaveData { + uint32_t version; + uint32_t counter; +}; + +MySaveData state{1, 42}; +svc_save->set_blob(mod_ctx, "state", &state, sizeof(state)); + +MySaveData loaded{}; +size_t loadedSize = sizeof(loaded); +if (svc_save->get_blob(mod_ctx, "state", &loaded, &loadedSize) == MOD_OK && + loadedSize == sizeof(loaded)) { + apply_state(loaded); +} +``` + +`set_blob`, `get_blob`, and `delete_blob` operate on the current slot, which is available after creating or loading a +save and unavailable at file select. Blob changes are written with the next game save. File-select copy and erase +operations update the blob data as well. Use `peek_blob` to read the calling mod's data from any slot; it uses the same +buffer contract as `get_blob`. Pass a `NULL` buffer to either read function to query the blob size. + +`observe_saves` registers callbacks for new, loaded, and written saves. New-save callbacks run after the slot's blobs +are cleared. Observers are removed automatically when the mod is detached, so the output handle is only needed for +manual unregistration. Save callbacks run on the game thread. + ### UiService (`mods/svc/ui.h`) Integrate seamlessly with Dusklight's UI system: add controls and buttons to your mod's detail pane in the Mods window, diff --git a/extern/aurora b/extern/aurora index 0bddb86249..6c4c27f9e8 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 0bddb8624905d74cc47202a71962f9c9834c0933 +Subproject commit 6c4c27f9e8e40f584d27726655d80ec85a5a7d2c diff --git a/files.cmake b/files.cmake index 7eb8ba6038..3fda4fefbe 100644 --- a/files.cmake +++ b/files.cmake @@ -1502,6 +1502,8 @@ set(DUSK_FILES src/dusk/mods/svc/ui.hpp src/dusk/mods/svc/window.cpp src/dusk/mods/svc/window.hpp + src/dusk/mods/svc/save.cpp + src/dusk/mods/svc/save.hpp src/dusk/mouse.cpp src/dusk/scope_guard.hpp src/dusk/settings.cpp @@ -1581,6 +1583,7 @@ set(DUSK_FILES src/dusk/update_check.cpp src/dusk/update_check.hpp src/dusk/version.cpp + src/dusk/utilities.cpp src/helpers/batch.cpp src/helpers/endian.cpp src/helpers/offset_ptr.cpp diff --git a/sdk/include/mods/svc/save.h b/sdk/include/mods/svc/save.h new file mode 100644 index 0000000000..0d6943f96a --- /dev/null +++ b/sdk/include/mods/svc/save.h @@ -0,0 +1,63 @@ +#pragma once + +#include + +#ifdef __cplusplus +#include +#endif + +#define SAVE_SERVICE_ID "dev.twilitrealm.dusklight.save" +#define SAVE_SERVICE_MAJOR 1u +#define SAVE_SERVICE_MINOR 0u + +/* 0 is never a valid handle. */ +typedef uint64_t SaveObserverHandle; + +/* Maximum combined blob size per mod and save slot. */ +#define SAVE_BLOB_BUDGET_BYTES 65536u + +/* + * Per-slot mod storage. + * + * Blobs are scoped to the calling mod and saved alongside each slot. Current-slot calls return + * MOD_UNAVAILABLE when no slot is active. + * + * Callbacks run on the game thread. Observer registrations are removed when the calling mod is + * detached. + */ + +/* slot is the save-file index (0..2). */ +typedef void (*SaveEventFn)(ModContext* ctx, uint32_t slot, void* user_data); + +typedef struct SaveService { + ServiceHeader header; + + /* Store a copy in the current slot. Returns MOD_UNAVAILABLE if the limit would be exceeded. */ + ModResult (*set_blob)(ModContext* ctx, const char* name, const void* data, size_t size); + + /* + * Read a blob from the current slot. Pass NULL for buf to query its size. Otherwise, + * inout_size is the buffer capacity on input and the blob size on success. Returns + * MOD_UNAVAILABLE if the blob does not exist. + */ + ModResult (*get_blob)(ModContext* ctx, const char* name, void* buf, size_t* inout_size); + + ModResult (*delete_blob)(ModContext* ctx, const char* name); + + /* + * Register save lifecycle callbacks. At least one callback is required. on_new_save runs + * after clearing the slot's blobs, on_save_loaded after activating the slot, and + * on_save_written after a successful game save. out_handle may be NULL. + */ + ModResult (*observe_saves)(ModContext* ctx, SaveEventFn on_new_save, SaveEventFn on_save_loaded, + SaveEventFn on_save_written, void* user_data, SaveObserverHandle* out_handle); + + ModResult (*unobserve_saves)(ModContext* ctx, SaveObserverHandle handle); + + /* Read the calling mod's blob from any slot. Uses the get_blob buffer contract. */ + ModResult (*peek_blob)( + ModContext* ctx, uint32_t slot, const char* name, void* buf, size_t* inout_size); + +} SaveService; + +MOD_DECLARE_SERVICE(SaveService, svc_save, SAVE_SERVICE_ID, SAVE_SERVICE_MAJOR, SAVE_SERVICE_MINOR); diff --git a/src/d/d_file_select.cpp b/src/d/d_file_select.cpp index 98d60fa195..936a368f81 100644 --- a/src/d/d_file_select.cpp +++ b/src/d/d_file_select.cpp @@ -28,6 +28,7 @@ #if TARGET_PC #include "dusk/menu_pointer.h" #include "helpers/string.hpp" +#include "dusk/mods/svc/save.hpp" namespace { constexpr u8 pointer_target(u8 group, u8 index) noexcept { @@ -255,6 +256,10 @@ dFile_select_c::~dFile_select_c() { void dFile_select_c::_create() { int i; +#if TARGET_PC + dusk::mods::svc::save_no_slot(); +#endif + mDoGph_gInf_c::setFadeColor(static_cast(g_blackColor)); stick = JKR_NEW STControl(2, 2, 1, 1, 0.9f, 0.5f, 0, 0x2000); @@ -1390,6 +1395,9 @@ void dFile_select_c::menuSelectStart() { mIsSelectEnd = true; mDataSelProc = DATASELPROC_NEXT_MODE_WAIT; dComIfGs_setDataNum(mSelectNum); +#if TARGET_PC + dusk::mods::svc::save_slot_loaded(mSelectNum, &mSaveData[mSelectNum]); +#endif } else if (mSelectMenuNum == 0) { mSelIcon->setAlphaRate(0.0f); yesnoMenuMoveAnmInitSet(0x473, 0x47d); @@ -1740,6 +1748,9 @@ void dFile_select_c::nameInput2() { case 2: dComIfGs_setHorseName(mpName->getInputStrPtr()); mIsSelectEnd = true; +#if TARGET_PC + dusk::mods::svc::save_slot_new(mSelectNum); +#endif mDataSelProc = DATASELPROC_NEXT_MODE_WAIT; } } @@ -2666,6 +2677,9 @@ void dFile_select_c::DataEraseWait2() { mDataSelProc = DATASELPROC_ERROR_MSG_PANE_MOVE; } else if (field_0x03b4 == 1) { mDoAud_seStart(Z2SE_SY_FILE_DELETE_OK, NULL, 0, 0); +#if TARGET_PC + dusk::mods::svc::save_slot_erased(mSelectNum); +#endif field_0x03b1 = 0; mDeleteEfPane[mSelectNum]->alphaAnimeStart(0); mFileInfoNoDatBasePane[mSelectNum]->alphaAnimeStart(0); @@ -2769,6 +2783,9 @@ void dFile_select_c::DataCopyWait2() { mDataSelProc = DATASELPROC_ERROR_MSG_PANE_MOVE; } else if (field_0x03b4 == 1) { mDoAud_seStart(Z2SE_SY_FILE_COPY_OK, NULL, 0, 0); +#if TARGET_PC + dusk::mods::svc::save_slot_copied(mCpDataNum, mCpDataToNum); +#endif field_0x03b1 = 0; mCopyEfPane[mSelectNum]->alphaAnimeStart(0); mCopyEfPane[mCpDataToNum]->alphaAnimeStart(0); diff --git a/src/d/d_menu_save.cpp b/src/d/d_menu_save.cpp index e9be233f6b..48f3f953ea 100644 --- a/src/d/d_menu_save.cpp +++ b/src/d/d_menu_save.cpp @@ -26,6 +26,7 @@ #include "dusk/frame_interpolation.h" #include "dusk/menu_pointer.h" #include "dusk/settings.h" +#include "dusk/mods/svc/save.hpp" #endif static int SelStartFrameTbl[3] = { @@ -1468,6 +1469,10 @@ void dMenu_save_c::memCardDataSaveWait2() { dComIfGs_setDataNum(mSelectedFile); dComIfGs_setNoFile(0); +#if TARGET_PC + dusk::mods::svc::save_slot_written(mSelectedFile, mSaveBuffer + mSelectedFile * QUEST_LOG_SIZE); +#endif + if (mUseType == TYPE_WHITE_EVENT || mUseType == TYPE_BLACK_EVENT) { headerTxtSet(0x530); // Saved. mWarning->closeInit(); diff --git a/src/d/d_s_logo.cpp b/src/d/d_s_logo.cpp index 69087a78c2..366d87c02b 100644 --- a/src/d/d_s_logo.cpp +++ b/src/d/d_s_logo.cpp @@ -25,8 +25,9 @@ #ifdef TARGET_PC #include "dusk/logging.h" -#include "dusk/version.hpp" #include "dusk/main.h" +#include "dusk/mods/svc/save.hpp" +#include "dusk/version.hpp" #include "m_Do/m_Do_MemCard.h" #endif @@ -771,15 +772,20 @@ void dScnLogo_c::nextSceneChange() { status = mDoMemCd_LoadSync(buf, sizeof(buf), 0); // Wait until the card is loaded } while (status == 0); - + + const uint32_t saveSlot = dusk::SaveRequested - 1; if (status == 1) { - dComIfGs_setCardToMemory(buf, dusk::SaveRequested - 1); + dComIfGs_setCardToMemory(buf, saveSlot); } else { dComIfGs_init(); } - + dComIfGs_setNoFile(dusk::SaveRequested); - dComIfGs_setDataNum(dusk::SaveRequested-1); + dComIfGs_setDataNum(saveSlot); + if (status == 1) { + dusk::mods::svc::save_slot_loaded( + saveSlot, buf + saveSlot * SAVEDATA_SIZE); + } dComIfGs_gameStart(); diff --git a/src/dusk/autosave.cpp b/src/dusk/autosave.cpp index 57488a27a1..bae2812a1f 100644 --- a/src/dusk/autosave.cpp +++ b/src/dusk/autosave.cpp @@ -1,6 +1,7 @@ #include "dusk/autosave.h" #include "dusk/ui/ui.hpp" #include "imgui/ImGuiConsole.hpp" +#include "mods/svc/save.hpp" bool shouldAutoSave = false; u8 mSaveBuffer[QUEST_LOG_SIZE * 3]; @@ -105,6 +106,9 @@ void waitingForWrite() { } void endAutoSave() { + const int slot = dComIfGs_getDataNum(); + dusk::mods::svc::save_slot_written(slot, mSaveBuffer + slot * QUEST_LOG_SIZE); + dusk::ui::push_toast({ .type = "autosave", .duration = std::chrono::milliseconds(1500), @@ -114,4 +118,4 @@ void endAutoSave() { void toggleAutoSave(bool enabled) { shouldAutoSave = enabled; -} \ No newline at end of file +} diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 622db55da9..67e1fe01d3 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -211,6 +211,7 @@ void ModLoader::init_services() { &svc::g_cameraModule, &svc::g_windowModule, &svc::g_gfxModule, + &svc::g_saveModule, }) { svc::register_module(*module); diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 4ba4f695ab..165375d29a 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -73,5 +73,6 @@ extern const ServiceModule g_gameModule; extern const ServiceModule g_cameraModule; extern const ServiceModule g_windowModule; extern const ServiceModule g_gfxModule; +extern const ServiceModule g_saveModule; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/save.cpp b/src/dusk/mods/svc/save.cpp new file mode 100644 index 0000000000..9930129be5 --- /dev/null +++ b/src/dusk/mods/svc/save.cpp @@ -0,0 +1,434 @@ +#include "save.hpp" + +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "d/d_save.h" +#include "dusk/main.h" +#include "dusk/mods/loader/loader.hpp" +#include "dusk/utilities.hpp" +#include "mods/svc/save.h" + +#include +#include + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::save"); + +constexpr uint32_t kSlotCount = 3; +constexpr size_t kQuestLogSize = 0xA94; +static_assert(kQuestLogSize == QUEST_LOG_SIZE); +constexpr int kSidecarVersion = 1; +constexpr const char* kSidecarName = "mod_saves.json"; +constexpr size_t kMaxBlobNameLength = 256; + +using BlobMap = std::map>; + +struct SlotStore { + bool snapshotValid = false; + uint32_t snapshotCrc = 0; + std::map mods; +}; + +struct SaveObserverRecord { + uint64_t handle = 0; + LoadedMod* mod = nullptr; + SaveEventFn onNewSave = nullptr; + SaveEventFn onLoaded = nullptr; + SaveEventFn onWritten = nullptr; + void* userData = nullptr; +}; + +std::array s_slots; +int32_t s_currentSlot = -1; +bool s_sidecarLoaded = false; +std::vector s_observers; +uint64_t s_nextHandle = 1; + +std::filesystem::path sidecar_path() { + return dusk::ConfigPath / kSidecarName; +} + +void load_sidecar() { + if (s_sidecarLoaded) { + return; + } + s_sidecarLoaded = true; + std::ifstream in{sidecar_path()}; + if (!in.is_open()) { + return; + } + try { + const auto json = nlohmann::json::parse(in); + if (json.value("version", 0) != kSidecarVersion) { + Log.warn( + "mod save sidecar has unknown version {}; ignoring it", json.value("version", 0)); + return; + } + const auto& slots = json.at("slots"); + for (uint32_t slot = 0; slot < kSlotCount && slot < slots.size(); ++slot) { + auto& store = s_slots[slot]; + const auto& slotJson = slots[slot]; + if (slotJson.contains("snapshot_crc32")) { + store.snapshotValid = true; + store.snapshotCrc = slotJson["snapshot_crc32"].get(); + } + const auto modsJson = slotJson.value("mods", nlohmann::json::object()); + for (const auto& [modId, blobs] : modsJson.items()) { + for (const auto& [name, encoded] : blobs.items()) { + std::vector bytes; + if (!utils::base64_decode(encoded.get(), bytes)) { + Log.warn("mod save sidecar: bad blob '{}/{}' in slot {}; dropped", modId, + name, slot); + continue; + } + s_slots[slot].mods[modId][name] = std::move(bytes); + } + } + } + } catch (const std::exception& e) { + Log.error("failed to read mod save sidecar: {}", e.what()); + } +} + +void flush_sidecar() { + nlohmann::json slots = nlohmann::json::array(); + for (const auto& store : s_slots) { + nlohmann::json slotJson = nlohmann::json::object(); + if (store.snapshotValid) { + slotJson["snapshot_crc32"] = store.snapshotCrc; + } + nlohmann::json mods = nlohmann::json::object(); + for (const auto& [modId, blobs] : store.mods) { + if (blobs.empty()) { + continue; + } + nlohmann::json blobsJson = nlohmann::json::object(); + for (const auto& [name, bytes] : blobs) { + blobsJson[name] = utils::base64_encode(bytes); + } + mods[modId] = std::move(blobsJson); + } + slotJson["mods"] = std::move(mods); + slots.push_back(std::move(slotJson)); + } + const nlohmann::json json{{"version", kSidecarVersion}, {"slots", std::move(slots)}}; + + const auto path = sidecar_path(); + const auto tempPath = path.string() + ".tmp"; + try { + { + std::ofstream out{tempPath, std::ios::trunc}; + out << json.dump(2); + if (!out.good()) { + throw std::runtime_error("write failed"); + } + } + std::filesystem::rename(tempPath, path); + } catch (const std::exception& e) { + Log.error("failed to write mod save sidecar: {}", e.what()); + std::error_code ec; + std::filesystem::remove(tempPath, ec); + } +} + +void notify(uint32_t slot, SaveEventFn SaveObserverRecord::* which, const char* what) { + // Callbacks may unregister observers. + const auto observers = s_observers; + for (const auto& observer : observers) { + if (!observer.mod->active || observer.*which == nullptr) { + continue; + } + try { + (observer.*which)(observer.mod->context.get(), slot, observer.userData); + } catch (const std::exception& e) { + fail_mod(*observer.mod, MOD_ERROR, + fmt::format("exception in {} save callback: {}", what, e.what())); + } catch (...) { + fail_mod(*observer.mod, MOD_ERROR, + fmt::format("unknown exception in {} save callback", what)); + } + } +} + +} // namespace + +void save_slot_new(uint32_t slot) { + if (slot >= kSlotCount) { + return; + } + load_sidecar(); + auto& store = s_slots[slot]; + store.mods.clear(); + store.snapshotValid = false; + s_currentSlot = static_cast(slot); + Log.info("new save in slot {}; mod blob store cleared", slot); + notify(slot, &SaveObserverRecord::onNewSave, "new-save"); +} + +void save_slot_loaded(uint32_t slot, const void* slotData) { + if (slot >= kSlotCount) { + return; + } + load_sidecar(); + auto& store = s_slots[slot]; + if (store.snapshotValid && slotData != nullptr) { + const auto crc = utils::crc32(slotData, kQuestLogSize); + if (crc != store.snapshotCrc) { + Log.warn("slot {} save data does not match the mod sidecar snapshot; mod save " + "data may be stale (card file changed externally?)", + slot); + } + } + s_currentSlot = static_cast(slot); + notify(slot, &SaveObserverRecord::onLoaded, "save-loaded"); +} + +void save_slot_written(uint32_t slot, const void* slotData) { + if (slot >= kSlotCount) { + return; + } + load_sidecar(); + auto& store = s_slots[slot]; + if (slotData != nullptr) { + store.snapshotValid = true; + store.snapshotCrc = utils::crc32(slotData, kQuestLogSize); + } + flush_sidecar(); + notify(slot, &SaveObserverRecord::onWritten, "save-written"); +} + +void save_slot_copied(uint32_t fromSlot, uint32_t toSlot) { + if (fromSlot >= kSlotCount || toSlot >= kSlotCount || fromSlot == toSlot) { + return; + } + load_sidecar(); + s_slots[toSlot] = s_slots[fromSlot]; + flush_sidecar(); + Log.info("mod save data copied with slot {} -> {}", fromSlot, toSlot); +} + +void save_slot_erased(uint32_t slot) { + if (slot >= kSlotCount) { + return; + } + load_sidecar(); + s_slots[slot] = SlotStore{}; + flush_sidecar(); + Log.info("mod save data erased with slot {}", slot); +} + +void save_no_slot() { + s_currentSlot = -1; +} + +namespace { + +BlobMap* current_blobs(const LoadedMod& mod, bool create) { + if (s_currentSlot < 0) { + return nullptr; + } + load_sidecar(); + auto& mods = s_slots[s_currentSlot].mods; + if (!create) { + const auto it = mods.find(mod.metadata.id); + return it != mods.end() ? &it->second : nullptr; + } + return &mods[mod.metadata.id]; +} + +} // namespace + +ModResult save_set_blob(LoadedMod& mod, const char* name, const void* data, size_t size) { + auto* blobs = current_blobs(mod, true); + if (blobs == nullptr) { + return MOD_UNAVAILABLE; + } + size_t total = size; + for (const auto& [blobName, bytes] : *blobs) { + if (blobName != name) { + total += bytes.size(); + } + } + if (total > SAVE_BLOB_BUDGET_BYTES) { + Log.error("[{}] save blob '{}' rejected: {} bytes would exceed the {}-byte budget", + mod.metadata.id, name, total, SAVE_BLOB_BUDGET_BYTES); + return MOD_UNAVAILABLE; + } + const auto* bytes = static_cast(data); + (*blobs)[name] = std::vector{bytes, bytes + size}; + return MOD_OK; +} + +ModResult save_get_blob(LoadedMod& mod, const char* name, void* buf, size_t& inoutSize) { + auto* blobs = current_blobs(mod, false); + if (blobs == nullptr) { + return MOD_UNAVAILABLE; + } + const auto it = blobs->find(name); + if (it == blobs->end()) { + return MOD_UNAVAILABLE; + } + if (buf == nullptr) { + inoutSize = it->second.size(); + return MOD_OK; + } + if (inoutSize < it->second.size()) { + return MOD_INVALID_ARGUMENT; + } + std::memcpy(buf, it->second.data(), it->second.size()); + inoutSize = it->second.size(); + return MOD_OK; +} + +ModResult save_delete_blob(LoadedMod& mod, const char* name) { + auto* blobs = current_blobs(mod, false); + if (blobs == nullptr) { + return MOD_UNAVAILABLE; + } + return blobs->erase(name) != 0 ? MOD_OK : MOD_INVALID_ARGUMENT; +} + +ModResult save_observe(LoadedMod& mod, SaveEventFn onNewSave, SaveEventFn onLoaded, + SaveEventFn onWritten, void* userData, uint64_t& outHandle) { + auto& observer = s_observers.emplace_back(); + observer.handle = s_nextHandle++; + observer.mod = &mod; + observer.onNewSave = onNewSave; + observer.onLoaded = onLoaded; + observer.onWritten = onWritten; + observer.userData = userData; + outHandle = observer.handle; + return MOD_OK; +} + +ModResult save_unobserve(LoadedMod& mod, uint64_t handle) { + const auto removed = std::erase_if(s_observers, + [&](const auto& observer) { return observer.handle == handle && observer.mod == &mod; }); + return removed != 0 ? MOD_OK : MOD_INVALID_ARGUMENT; +} + +ModResult save_peek_blob( + LoadedMod& mod, uint32_t slot, const char* name, void* buf, size_t& inoutSize) { + if (slot >= kSlotCount) { + return MOD_INVALID_ARGUMENT; + } + load_sidecar(); + const auto& mods = s_slots[slot].mods; + const auto modIt = mods.find(mod.metadata.id); + if (modIt == mods.end()) { + return MOD_UNAVAILABLE; + } + const auto it = modIt->second.find(name); + if (it == modIt->second.end()) { + return MOD_UNAVAILABLE; + } + if (buf == nullptr) { + inoutSize = it->second.size(); + return MOD_OK; + } + if (inoutSize < it->second.size()) { + return MOD_INVALID_ARGUMENT; + } + std::memcpy(buf, it->second.data(), it->second.size()); + inoutSize = it->second.size(); + return MOD_OK; +} + +void save_remove_mod(LoadedMod& mod) { + std::erase_if(s_observers, [&](const auto& observer) { return observer.mod == &mod; }); + // Blob data persists across mod reloads. +} + +namespace { +bool is_valid_blob_name(const char* name) { + if (name == nullptr) { + return false; + } + const std::string_view view{name}; + return !view.empty() && view.size() <= kMaxBlobNameLength; +} + +ModResult save_set_blob_(ModContext* context, const char* name, const void* data, size_t size) { + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_blob_name(name) || (data == nullptr && size != 0) || + size > SAVE_BLOB_BUDGET_BYTES) + { + return MOD_INVALID_ARGUMENT; + } + return save_set_blob(*mod, name, data, size); +} + +ModResult save_get_blob_(ModContext* context, const char* name, void* buf, size_t* inoutSize) { + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_blob_name(name) || inoutSize == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return save_get_blob(*mod, name, buf, *inoutSize); +} + +ModResult save_delete_blob_(ModContext* context, const char* name) { + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_blob_name(name)) { + return MOD_INVALID_ARGUMENT; + } + return save_delete_blob(*mod, name); +} + +ModResult save_observe_saves_(ModContext* context, SaveEventFn onNewSave, SaveEventFn onLoaded, + SaveEventFn onWritten, void* userData, SaveObserverHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || (onNewSave == nullptr && onLoaded == nullptr && onWritten == nullptr)) { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = save_observe(*mod, onNewSave, onLoaded, onWritten, userData, handle); + if (outHandle != nullptr) { + *outHandle = handle; + } + return result; +} + +ModResult save_unobserve_saves_(ModContext* context, SaveObserverHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + return save_unobserve(*mod, handle); +} + +ModResult save_peek_blob_( + ModContext* context, uint32_t slot, const char* name, void* buf, size_t* inoutSize) { + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_blob_name(name) || inoutSize == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return save_peek_blob(*mod, slot, name, buf, *inoutSize); +} + +constexpr SaveService s_saveService{ + .header = SERVICE_HEADER(SaveService, SAVE_SERVICE_MAJOR, SAVE_SERVICE_MINOR), + .set_blob = save_set_blob_, + .get_blob = save_get_blob_, + .delete_blob = save_delete_blob_, + .observe_saves = save_observe_saves_, + .unobserve_saves = save_unobserve_saves_, + .peek_blob = save_peek_blob_, +}; + +} // namespace + +constinit const ServiceModule g_saveModule{ + .id = SAVE_SERVICE_ID, + .majorVersion = SAVE_SERVICE_MAJOR, + .minorVersion = SAVE_SERVICE_MINOR, + .service = &s_saveService, + .modDetached = save_remove_mod, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/save.hpp b/src/dusk/mods/svc/save.hpp new file mode 100644 index 0000000000..89460af146 --- /dev/null +++ b/src/dusk/mods/svc/save.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace dusk::mods::svc { + +void save_slot_new(uint32_t slot); +void save_slot_loaded(uint32_t slot, const void* slotData); +void save_slot_written(uint32_t slot, const void* slotData); +void save_slot_copied(uint32_t fromSlot, uint32_t toSlot); +void save_slot_erased(uint32_t slot); +void save_no_slot(); + +} // namespace dusk::mods::svc diff --git a/src/dusk/stubs.cpp b/src/dusk/stubs.cpp index b283eb43bf..ac72f8377d 100644 --- a/src/dusk/stubs.cpp +++ b/src/dusk/stubs.cpp @@ -908,7 +908,7 @@ void AIInit(u8* stack) { // In a real scenario, it would set up the audio interface and prepare it for use. } -void AIInitDMA(u32 start_addr, u32 length) { +void AIInitDMA(uintptr_t start_addr, u32 length) { STUB_LOG(); } diff --git a/src/dusk/utilities.cpp b/src/dusk/utilities.cpp new file mode 100644 index 0000000000..92fe96c086 --- /dev/null +++ b/src/dusk/utilities.cpp @@ -0,0 +1,95 @@ +#include "utilities.hpp" + +#include + +namespace dusk::utils { +namespace { + +constexpr char kBase64Chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +constexpr std::array generate_crc32_table() { + std::array table{}; + for (uint32_t i = 0; i < 256; ++i) { + uint32_t ch = i; + for (size_t j = 0; j < 8; ++j) { + ch = (ch & 1) != 0 ? 0xEDB88320 ^ ch >> 1 : ch >> 1; + } + table[i] = ch; + } + return table; +} + +constexpr std::array kCrc32Table = generate_crc32_table(); + +} // namespace + +std::string base64_encode(const std::vector& data) { + std::string out; + out.reserve((data.size() + 2) / 3 * 4); + for (size_t i = 0; i < data.size(); i += 3) { + const uint32_t rest = data.size() - i; + uint32_t chunk = data[i] << 16; + if (rest > 1) { + chunk |= data[i + 1] << 8; + } + if (rest > 2) { + chunk |= data[i + 2]; + } + out.push_back(kBase64Chars[chunk >> 18 & 0x3F]); + out.push_back(kBase64Chars[chunk >> 12 & 0x3F]); + out.push_back(rest > 1 ? kBase64Chars[chunk >> 6 & 0x3F] : '='); + out.push_back(rest > 2 ? kBase64Chars[chunk & 0x3F] : '='); + } + return out; +} + +bool base64_decode(const std::string& text, std::vector& out) { + if (text.size() % 4 != 0) { + return false; + } + static const auto lookup = [] { + std::array table; + table.fill(-1); + for (int i = 0; i < 64; ++i) { + table[static_cast(kBase64Chars[i])] = static_cast(i); + } + return table; + }(); + out.clear(); + out.reserve(text.size() / 4 * 3); + for (size_t i = 0; i < text.size(); i += 4) { + uint32_t chunk = 0; + int pads = 0; + for (size_t j = 0; j < 4; ++j) { + const char c = text[i + j]; + if (c == '=' && i + 4 == text.size() && j >= 2) { + ++pads; + chunk <<= 6; + continue; + } + const int8_t value = lookup[static_cast(c)]; + if (value < 0 || pads != 0) { + return false; + } + chunk = chunk << 6 | static_cast(value); + } + out.push_back(chunk >> 16 & 0xFF); + if (pads < 2) { + out.push_back(chunk >> 8 & 0xFF); + } + if (pads < 1) { + out.push_back(chunk & 0xFF); + } + } + return true; +} + +uint32_t crc32(const void* data, size_t size) { + const auto* bytes = static_cast(data); + uint32_t crc = ~0u; + for (size_t i = 0; i < size; ++i) { + crc = crc >> 8 ^ kCrc32Table[static_cast(crc ^ bytes[i])]; + } + return ~crc; +} +} // namespace dusk::utils diff --git a/src/dusk/utilities.hpp b/src/dusk/utilities.hpp new file mode 100644 index 0000000000..ab3d9c2198 --- /dev/null +++ b/src/dusk/utilities.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include +#include + +#ifdef crc32 +// miniz defines crc32 as an alias. +#undef crc32 +#endif + +namespace dusk::utils { +std::string base64_encode(const std::vector& data); +bool base64_decode(const std::string& text, std::vector& out); +uint32_t crc32(const void* data, size_t size); +} // namespace dusk::utils From 2c9f814ec6d15db6eb7408bdd5022e9d4e6a4cca Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Tue, 4 Aug 2026 05:37:36 +0200 Subject: [PATCH 12/19] Make JASCalc memory helpers just use memcpy/memset (#2258) --- .../JSystem/include/JSystem/JAudio2/JASCalc.h | 26 +++++++++++++++---- libs/JSystem/src/JAudio2/JASCalc.cpp | 10 ++----- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/libs/JSystem/include/JSystem/JAudio2/JASCalc.h b/libs/JSystem/include/JSystem/JAudio2/JASCalc.h index 118ca1eb6e..0dde666d33 100644 --- a/libs/JSystem/include/JSystem/JAudio2/JASCalc.h +++ b/libs/JSystem/include/JSystem/JAudio2/JASCalc.h @@ -2,6 +2,7 @@ #define JASCALC_H #include +#include #include /** @@ -10,16 +11,31 @@ */ struct JASCalc { static void imixcopy(const s16*, const s16*, s16*, u32); - static void bcopyfast(const void* src, void* dest, u32 size); +#if TARGET_PC + static void bcopyfast(const void* src, void* dest, u32 size) { + std::memcpy(dest, src, size); + } #if TARGET_ANDROID - static void _bcopy(const void* src, void* dest, u32 size); + static void _bcopy(const void* src, void* dest, u32 size) { #else - static void bcopy(const void* src, void* dest, u32 size); + static void bcopy(const void* src, void* dest, u32 size) { #endif - static void bzerofast(void* dest, u32 size); + std::memcpy(dest, src, size); + } + static void bzerofast(void* dest, u32 size) { + std::memset(dest, 0, size); + } #if TARGET_ANDROID - static void _bzero(void* dest, u32 size); + static void _bzero(void* dest, u32 size) { #else + static void bzero(void* dest, u32 size) { +#endif + std::memset(dest, 0, size); + } +#else + static void bcopyfast(const void* src, void* dest, u32 size); + static void bcopy(const void* src, void* dest, u32 size); + static void bzerofast(void* dest, u32 size); static void bzero(void* dest, u32 size); #endif static f32 pow2(f32); diff --git a/libs/JSystem/src/JAudio2/JASCalc.cpp b/libs/JSystem/src/JAudio2/JASCalc.cpp index ba13c983e6..de0cf02295 100644 --- a/libs/JSystem/src/JAudio2/JASCalc.cpp +++ b/libs/JSystem/src/JAudio2/JASCalc.cpp @@ -11,6 +11,7 @@ void JASCalc::imixcopy(const s16* s1, const s16* s2, s16* dst, u32 n) { } } +#if !TARGET_PC void JASCalc::bcopyfast(const void* src, void* dest, u32 size) { JUT_ASSERT(226, (reinterpret_cast(src) & 0x03) == 0); JUT_ASSERT(227, (reinterpret_cast(dest) & 0x03) == 0); @@ -33,11 +34,7 @@ void JASCalc::bcopyfast(const void* src, void* dest, u32 size) { } } -#if TARGET_ANDROID -void JASCalc::_bcopy(const void* src, void* dest, u32 size) { -#else void JASCalc::bcopy(const void* src, void* dest, u32 size) { -#endif u32* usrc; u32* udest; @@ -94,11 +91,7 @@ void JASCalc::bzerofast(void* dest, u32 size) { } } -#if TARGET_ANDROID -void JASCalc::_bzero(void* dest, u32 size) { -#else void JASCalc::bzero(void* dest, u32 size) { -#endif u32* udest; u8* bdest = (u8*)dest; if ((size & 0x1f) == 0 && (reinterpret_cast(dest) & 0x1f) == 0) { @@ -139,6 +132,7 @@ void JASCalc::bzero(void* dest, u32 size) { } } } +#endif #if AVOID_UB DUSK_GAME_DATA s16 const JASCalc::CUTOFF_TO_IIR_TABLE[129][4] = { From 794bb130e29056732dc1f9cfec08487694201829 Mon Sep 17 00:00:00 2001 From: Pwootage Date: Tue, 4 Aug 2026 21:49:33 -0600 Subject: [PATCH 13/19] Fix for Linux distributions that use lib64: force libturbojpeg to use 'lib' (#2269) --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4bd493d884..a929e17022 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -150,6 +150,7 @@ if (DUSK_MOVIE_SUPPORT) -DENABLE_SHARED=OFF -DWITH_TURBOJPEG=ON -DWITH_JAVA=OFF + -DCMAKE_INSTALL_LIBDIR=lib ) if (CMAKE_TOOLCHAIN_FILE) get_filename_component(_jpeg_toolchain_file "${CMAKE_TOOLCHAIN_FILE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}") From 4604304305663477f0de3dc38d3c356f94615bae Mon Sep 17 00:00:00 2001 From: TakaRikka <38417346+TakaRikka@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:51:52 -0700 Subject: [PATCH 14/19] mods: stage service (#2268) * initial stage service ported from encounter's original impl * Review cleanup * initial stage service ported from encounter's original impl --------- Co-authored-by: Luke Street --- docs/modding.md | 49 +++++ files.cmake | 2 + sdk/include/mods/svc/stage.h | 43 +++++ src/d/d_stage.cpp | 53 ++++++ src/dusk/mods/svc/registry.cpp | 1 + src/dusk/mods/svc/registry.hpp | 1 + src/dusk/mods/svc/stage.cpp | 323 +++++++++++++++++++++++++++++++++ src/dusk/mods/svc/stage.hpp | 13 ++ 8 files changed, 485 insertions(+) create mode 100644 sdk/include/mods/svc/stage.h create mode 100644 src/dusk/mods/svc/stage.cpp create mode 100644 src/dusk/mods/svc/stage.hpp diff --git a/docs/modding.md b/docs/modding.md index 93c0ac4f14..c05e736d8b 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -373,6 +373,55 @@ buffer contract as `get_blob`. Pass a `NULL` buffer to either read function to q are cleared. Observers are removed automatically when the mod is detached, so the output handle is only needed for manual unregistration. Save callbacks run on the game thread. +### StageService (`mods/svc/stage.h`) + +Allows making changes to a stage's "stage info" (contents of .dzs/.dzr files). +(Currently only supports editing actor nodes.) + +```cpp +IMPORT_SERVICE(StageService, svc_stage); + +stage_actor_data_class record = { + "carry00", + 0xFF000000, + cXyz(0.0f, 0.0f, 0.0f), + csXyz(0, 0, 0), + 0, +}; + +StageActorHandle handle{}; +svc_stage->patch_actor(mod_ctx, "F_SP102", 0, -1, record_crc, &record, sizeof(record), &handle); +``` + +``` +StageActorHandle handle{}; +svc_stage->delete_actor(mod_ctx, "F_SP102", 0, -1, record_crc, &handle); +``` + +Patch or remove actors from the original actor list as the room loads. +Given records must be of either `stage_actor_data_class` or `stage_tgsc_data_class` types. +`record_crc` is the CRC-32 of the unmodified original record used to identify the record to replace or remove. + +``` +stage_actor_data_class record = { + "carry00", + 0xFF000000, + cXyz(0.0f, 0.0f, 0.0f), + csXyz(0, 0, 0), + 0, +}; + +StageActorHandle handle{}; +svc_stage->add_actor(mod_ctx, "F_SP102", 0, -1, &record, sizeof(record), &handle); +``` + +Add a new actor to the actor list as the room loads. +Given records must be of either `stage_actor_data_class` or `stage_tgsc_data_class` types. + +Stage names may contain up to 8 characters. For patches and deletions, room `0xff` and layer `-1` match any room or +layer; additions require a specific room. Edits are removed when the mod is detached. If multiple mods edit the same +record, the later-loaded mod wins. + ### UiService (`mods/svc/ui.h`) Integrate seamlessly with Dusklight's UI system: add controls and buttons to your mod's detail pane in the Mods window, diff --git a/files.cmake b/files.cmake index 3fda4fefbe..9867b2d39a 100644 --- a/files.cmake +++ b/files.cmake @@ -1504,6 +1504,8 @@ set(DUSK_FILES src/dusk/mods/svc/window.hpp src/dusk/mods/svc/save.cpp src/dusk/mods/svc/save.hpp + src/dusk/mods/svc/stage.cpp + src/dusk/mods/svc/stage.hpp src/dusk/mouse.cpp src/dusk/scope_guard.hpp src/dusk/settings.cpp diff --git a/sdk/include/mods/svc/stage.h b/sdk/include/mods/svc/stage.h new file mode 100644 index 0000000000..a9f8198919 --- /dev/null +++ b/sdk/include/mods/svc/stage.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#ifdef __cplusplus +#include +#endif + +#define STAGE_SERVICE_ID "dev.twilitrealm.dusklight.stage" +#define STAGE_SERVICE_MAJOR 1u +#define STAGE_SERVICE_MINOR 0u + +/* 0 is never a valid handle. */ +typedef uint64_t StageActorHandle; + +/* + * Runtime edits to Stage Info (.dzs/.dzr) data. + */ + +typedef struct StageService { + ServiceHeader header; + + /* + * Actor Node (ACTR/TGSC/SCOB/Door) Editing: + * stage must be a non-empty name of at most 8 characters. room 0xff and layer -1 match any room + * or layer for patch and delete operations; add_actor requires a specific room. Later-loaded mods + * win conflicts. record_crc is the CRC-32 of the unmodified record. Registrations are removed when + * the calling mod is detached. out_handle may be NULL. + */ + ModResult (*patch_actor)(ModContext* ctx, const char* stage, uint8_t room, int8_t layer, + uint32_t record_crc, const void* record, size_t record_size, StageActorHandle* out_handle); + + ModResult (*delete_actor)(ModContext* ctx, const char* stage, uint8_t room, int8_t layer, + uint32_t record_crc, StageActorHandle* out_handle); + + ModResult (*add_actor)(ModContext* ctx, const char* stage, uint8_t room, int8_t layer, + const void* record, size_t record_size, StageActorHandle* out_handle); + + ModResult (*remove_actor_edit)(ModContext* ctx, StageActorHandle handle); +} StageService; + +MOD_DECLARE_SERVICE( + StageService, svc_stage, STAGE_SERVICE_ID, STAGE_SERVICE_MAJOR, STAGE_SERVICE_MINOR); diff --git a/src/d/d_stage.cpp b/src/d/d_stage.cpp index a27f0482f8..c92ce7493b 100644 --- a/src/d/d_stage.cpp +++ b/src/d/d_stage.cpp @@ -25,6 +25,7 @@ #include "dusk/logging.h" #include "helpers/string.hpp" #if TARGET_PC +#include "dusk/mods/svc/stage.hpp" #include #include #endif @@ -1592,7 +1593,18 @@ DUSK_GAME_DATA dStage_roomControl_c::roomDzs_c dStage_roomControl_c::m_roomDzs; u8 dStage_roomControl_c::mNoArcBank; #endif +#if TARGET_PC +static void dStage_actorCreate(stage_actor_data_class* i_actorData, fopAcM_prm_class* i_actorPrm, + size_t recordSize = sizeof(stage_actor_data_class)) { + if (!dusk::mods::svc::stage_apply_actor_edits(i_actorData, i_actorPrm, recordSize, + i_actorPrm->room_no)) + { + JKRFree(i_actorPrm); + return; + } +#else static void dStage_actorCreate(stage_actor_data_class* i_actorData, fopAcM_prm_class* i_actorPrm) { +#endif dStage_objectNameInf* actorInf = dStage_searchName(i_actorData->name); if (actorInf == NULL) { @@ -1992,7 +2004,12 @@ static int dStage_tgscCommonLayerInit(dStage_dt_c* i_stage, void* i_data, int en appen->base = tgsc_data->base; appen->room_no = (int)i_stage->getRoomNo(); appen->scale = tgsc_data->scale; +#if TARGET_PC + dStage_actorCreate(actor_data, appen, + sizeof(stage_actor_data_class) + sizeof(fopAcM_prmScale_class)); +#else dStage_actorCreate(actor_data, appen); +#endif } } tgsc_data++; @@ -2063,7 +2080,12 @@ static int dStage_tgscInfoInit(dStage_dt_c* i_stage, void* i_data, int entryNum, appen->base = actor_data->base; appen->room_no = (int)i_stage->getRoomNo(); appen->scale = tgsc_data->scale; +#if TARGET_PC + dStage_actorCreate(actor_data, appen, + sizeof(stage_actor_data_class) + sizeof(fopAcM_prmScale_class)); +#else dStage_actorCreate(actor_data, appen); +#endif } } tgsc_data++; @@ -2086,7 +2108,12 @@ static int dStage_doorInfoInit(dStage_dt_c* i_stage, void* i_data, int entryNum, appen->base = actor_data->base; appen->room_no = (int)i_stage->getRoomNo(); appen->scale = tgsc_data->scale; +#if TARGET_PC + dStage_actorCreate(actor_data, appen, + sizeof(stage_actor_data_class) + sizeof(fopAcM_prmScale_class)); +#else dStage_actorCreate(actor_data, appen); +#endif } tgsc_data++; } @@ -2511,6 +2538,29 @@ static void dKankyo_create() { fopKyM_fastCreate(fpcNm_ENVSE_e, 0, NULL, NULL, NULL); } +#if TARGET_PC +static void dusk_stage_svc_new_actor_create(dStage_dt_c* i_stage) { + dusk::mods::svc::stage_create_new_actors(i_stage->getRoomNo(), + [](void* user, const void* record, size_t size) { + auto* stage = static_cast(user); + stage_tgsc_data_class object{}; + std::memcpy(&object, record, size); + + fopAcM_prm_class* appen = fopAcM_CreateAppend(); + if (appen != nullptr) { + appen->base = object.base; + appen->room_no = static_cast(stage->getRoomNo()); + if (size > sizeof(stage_actor_data_class)) { + appen->scale = object.scale; + } + dStage_actorCreate( + reinterpret_cast(&object), appen, size); + } + }, + i_stage); +} +#endif + static void layerMemoryInfoLoader(void* i_data, dStage_dt_c* i_stage, int param_2) { UNUSED(param_2); static FuncTable l_layerFuncTable[] = { @@ -2707,6 +2757,9 @@ void dStage_dt_c_roomReLoader(void* i_data, dStage_dt_c* i_stage, int param_2) { }; dStage_dt_c_decode(i_data, i_stage, l_funcTable, ARRAY_SIZEU(l_funcTable)); +#if TARGET_PC + dusk_stage_svc_new_actor_create(i_stage); +#endif layerActorLoader(i_data, i_stage, param_2); } diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 67e1fe01d3..34134c90c9 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -212,6 +212,7 @@ void ModLoader::init_services() { &svc::g_windowModule, &svc::g_gfxModule, &svc::g_saveModule, + &svc::g_stageModule, }) { svc::register_module(*module); diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 165375d29a..df0ca7bdbb 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -74,5 +74,6 @@ extern const ServiceModule g_cameraModule; extern const ServiceModule g_windowModule; extern const ServiceModule g_gfxModule; extern const ServiceModule g_saveModule; +extern const ServiceModule g_stageModule; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/stage.cpp b/src/dusk/mods/svc/stage.cpp new file mode 100644 index 0000000000..10af84a238 --- /dev/null +++ b/src/dusk/mods/svc/stage.cpp @@ -0,0 +1,323 @@ +#include "stage.hpp" + +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/stage.h" + +#include "d/d_com_inf_game.h" +#include "dusk/utilities.hpp" + +#include +#include +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log{"dusk::mods::stage"}; + +constexpr size_t kActrEntrySize = 0x20; +constexpr size_t kTgscEntrySize = 0x23; +constexpr uint8_t kRoomDefault = 0xFF; +constexpr int8_t kLayerDefault = -1; + +enum class EditKind : uint8_t { Patch, Delete, Add }; + +struct ActorEditRecord { + uint64_t handle = 0; + LoadedMod* mod = nullptr; + uint64_t seq = 0; + uint8_t room = 0; + int8_t layer = kLayerDefault; + EditKind kind = EditKind::Patch; + uint32_t crc = 0; + std::vector record; +}; + +std::unordered_map> s_edits; +uint64_t s_nextHandle = 1; +uint64_t s_nextSeq = 1; +std::unordered_set s_warnedRecords; + +int32_t compute_mod_priority(const LoadedMod& mod) { + int32_t index = 0; + for (const auto& other : ModLoader::instance().mods()) { + ++index; + if (&other == &mod) { + return index; + } + } + return index + 1; +} + +const std::vector* current_stage_edits() { + if (s_edits.empty()) { + return nullptr; + } + const char* stageName = dComIfGp_getStartStageName(); + if (stageName == nullptr) { + return nullptr; + } + const auto it = s_edits.find(stageName); + return it != s_edits.end() ? &it->second : nullptr; +} + +bool room_matches(const ActorEditRecord& record, uint8_t roomNo) { + return record.room == roomNo || record.room == kRoomDefault; +} + +bool layer_matches(const ActorEditRecord& record, int8_t layer) { + return record.layer == layer || record.layer == kLayerDefault; +} + +} // namespace + +bool stage_apply_actor_edits(void* actorData, void* actorPrm, size_t recordSize, int8_t roomNo) { + const auto* edits = current_stage_edits(); + if (edits == nullptr) { + return true; + } + + uint8_t room = static_cast(roomNo); + if (roomNo == -1) { + room = static_cast(dComIfGp_getStartStageRoomNo()); + } + + const auto layer = static_cast(dComIfG_play_c::getLayerNo(0)); + const auto crc = utils::crc32(actorData, recordSize); + + const ActorEditRecord* winner = nullptr; + int32_t winnerPriority = 0; + const LoadedMod* firstOwner = nullptr; + for (const auto& record : *edits) { + if (record.kind == EditKind::Add || !record.mod->active || !layer_matches(record, layer) || + !room_matches(record, room)) + { + continue; + } + + const bool matches = record.crc == crc && (record.kind == EditKind::Delete || + record.record.size() == recordSize); + if (!matches) { + continue; + } + + if (firstOwner == nullptr) { + firstOwner = record.mod; + } else if (firstOwner != record.mod && s_warnedRecords.insert(record.crc).second) { + Log.warn("actor record {:#010x} edited by multiple mods; the later-loaded one wins", + record.crc); + } + + const auto priority = compute_mod_priority(*record.mod); + if (winner == nullptr || priority > winnerPriority || + (priority == winnerPriority && record.seq > winner->seq)) + { + winner = &record; + winnerPriority = priority; + } + } + + if (winner == nullptr) { + return true; + } + if (winner->kind == EditKind::Delete) { + return false; + } + + std::memcpy(actorData, winner->record.data(), winner->record.size()); + std::memcpy(actorPrm, winner->record.data() + 8, winner->record.size() - 8); + return true; +} + +void stage_create_new_actors( + int8_t roomNo, void (*createFn)(void* user, const void* record, size_t size), void* user) { + const auto* edits = current_stage_edits(); + if (edits == nullptr) { + return; + } + + const auto layer = static_cast(dComIfG_play_c::getLayerNo(0)); + for (const auto& record : *edits) { + if (record.kind == EditKind::Add && record.mod->active && + record.room == static_cast(roomNo) && layer_matches(record, layer)) + { + createFn(user, record.record.data(), record.record.size()); + } + } +} + +namespace { + +ModResult register_edit( + LoadedMod& mod, const char* stage, ActorEditRecord&& record, uint64_t& outHandle) { + record.handle = s_nextHandle++; + record.mod = &mod; + record.seq = s_nextSeq++; + outHandle = record.handle; + s_edits[stage].push_back(std::move(record)); + return MOD_OK; +} + +} // namespace + +ModResult stage_patch_actor(LoadedMod& mod, const char* stage, uint8_t room, int8_t layer, + uint32_t crc, const void* record, size_t recordSize, uint64_t& outHandle) { + const auto* bytes = static_cast(record); + return register_edit(mod, stage, + ActorEditRecord{.room = room, + .layer = layer, + .kind = EditKind::Patch, + .crc = crc, + .record = {bytes, bytes + recordSize}}, + outHandle); +} + +ModResult stage_delete_actor(LoadedMod& mod, const char* stage, uint8_t room, int8_t layer, + uint32_t crc, uint64_t& outHandle) { + return register_edit(mod, stage, + ActorEditRecord{.room = room, .layer = layer, .kind = EditKind::Delete, .crc = crc}, + outHandle); +} + +ModResult stage_add_actor(LoadedMod& mod, const char* stage, uint8_t room, int8_t layer, + const void* record, size_t recordSize, uint64_t& outHandle) { + const auto* bytes = static_cast(record); + return register_edit(mod, stage, + ActorEditRecord{.room = room, + .layer = layer, + .kind = EditKind::Add, + .record = {bytes, bytes + recordSize}}, + outHandle); +} + +ModResult stage_remove_actor_edit(LoadedMod& mod, uint64_t handle) { + for (auto it = s_edits.begin(); it != s_edits.end(); ++it) { + const auto removed = std::erase_if(it->second, + [&](const auto& record) { return record.handle == handle && record.mod == &mod; }); + if (removed != 0) { + if (it->second.empty()) { + s_edits.erase(it); + } + return MOD_OK; + } + } + return MOD_INVALID_ARGUMENT; +} + +void stage_remove_mod(LoadedMod& mod) { + for (auto it = s_edits.begin(); it != s_edits.end();) { + std::erase_if(it->second, [&](const auto& record) { return record.mod == &mod; }); + it = it->second.empty() ? s_edits.erase(it) : std::next(it); + } +} + +namespace { +constexpr size_t kMaxStageNameLength = 8; + +bool is_valid_stage_name(const char* stage) { + if (stage == nullptr) { + return false; + } + const std::string_view view{stage}; + return !view.empty() && view.size() <= kMaxStageNameLength; +} + +bool is_valid_record_size(size_t size) { + return size == kActrEntrySize || size == kTgscEntrySize; +} + +ModResult stage_patch_actor_(ModContext* context, const char* stage, uint8_t room, int8_t layer, + uint32_t recordCrc, const void* record, size_t recordSize, StageActorHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_stage_name(stage) || record == nullptr || + !is_valid_record_size(recordSize)) + { + return MOD_INVALID_ARGUMENT; + } + + uint64_t handle = 0; + const auto result = + stage_patch_actor(*mod, stage, room, layer, recordCrc, record, recordSize, handle); + if (outHandle != nullptr) { + *outHandle = handle; + } + + return result; +} + +ModResult stage_delete_actor_(ModContext* context, const char* stage, uint8_t room, int8_t layer, + uint32_t recordCrc, StageActorHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_stage_name(stage)) { + return MOD_INVALID_ARGUMENT; + } + + uint64_t handle = 0; + const auto result = stage_delete_actor(*mod, stage, room, layer, recordCrc, handle); + if (outHandle != nullptr) { + *outHandle = handle; + } + return result; +} + +ModResult stage_add_actor_(ModContext* context, const char* stage, uint8_t room, int8_t layer, + const void* record, size_t recordSize, StageActorHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_stage_name(stage) || room == kRoomDefault || + record == nullptr || !is_valid_record_size(recordSize)) + { + return MOD_INVALID_ARGUMENT; + } + + uint64_t handle = 0; + const auto result = stage_add_actor(*mod, stage, room, layer, record, recordSize, handle); + if (outHandle != nullptr) { + *outHandle = handle; + } + return result; +} + +ModResult stage_remove_actor_edit_(ModContext* context, StageActorHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + return stage_remove_actor_edit(*mod, handle); +} + +constexpr StageService s_stageService{ + .header = SERVICE_HEADER(StageService, STAGE_SERVICE_MAJOR, STAGE_SERVICE_MINOR), + .patch_actor = stage_patch_actor_, + .delete_actor = stage_delete_actor_, + .add_actor = stage_add_actor_, + .remove_actor_edit = stage_remove_actor_edit_, +}; + +} // namespace + +constinit const ServiceModule g_stageModule{ + .id = STAGE_SERVICE_ID, + .majorVersion = STAGE_SERVICE_MAJOR, + .minorVersion = STAGE_SERVICE_MINOR, + .service = &s_stageService, + .modDetached = stage_remove_mod, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/stage.hpp b/src/dusk/mods/svc/stage.hpp new file mode 100644 index 0000000000..f27f70e646 --- /dev/null +++ b/src/dusk/mods/svc/stage.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace dusk::mods::svc { + +bool stage_apply_actor_edits(void* actorData, void* actorPrm, size_t recordSize, int8_t roomNo); + +void stage_create_new_actors( + int8_t roomNo, void (*createFn)(void* user, const void* record, size_t size), void* user); + +} // namespace dusk::mods::svc From 13b3b68fe52edab89dfaea0429992dd2c5f2f5d2 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 5 Aug 2026 07:54:31 -0600 Subject: [PATCH 15/19] Migrate to Borealis (#2266) --- .github/workflows/build.yml | 8 +- .gitmodules | 3 + CMakeLists.txt | 139 +- CMakePresets.json | 8 +- ci/build-appimage.sh | 2 +- cmake/DetectVersion.cmake | 121 - extern/aurora | 2 +- extern/borealis | 1 + files.cmake | 20 +- flake.nix | 2 +- platforms/android/README.md | 26 +- platforms/android/app/build.gradle | 175 +- platforms/android/app/proguard-rules.pro | 5 +- .../android/app/src/main/AndroidManifest.xml | 4 +- .../com/twilitrealm/dusk/DuskActivity.java | 545 +--- .../dusk/DuskDocumentsProvider.java | 467 ---- .../com/twilitrealm/dusk/DuskHttpClient.java | 237 -- .../main/java/org/libsdl/app/HIDDevice.java | 21 - .../app/HIDDeviceBLESteamController.java | 829 ------ .../java/org/libsdl/app/HIDDeviceManager.java | 698 ----- .../java/org/libsdl/app/HIDDeviceUSB.java | 354 --- .../app/src/main/java/org/libsdl/app/SDL.java | 90 - .../main/java/org/libsdl/app/SDLActivity.java | 2240 ----------------- .../java/org/libsdl/app/SDLAudioManager.java | 126 - .../org/libsdl/app/SDLControllerManager.java | 1010 -------- .../java/org/libsdl/app/SDLDummyEdit.java | 66 - .../org/libsdl/app/SDLInputConnection.java | 136 - .../java/org/libsdl/app/SDLSensorManager.java | 32 - .../main/java/org/libsdl/app/SDLSurface.java | 469 ---- platforms/android/scripts/sync-sdl-java.sh | 16 - platforms/windows/dusklight.rc.in | 4 +- sdk/CMakeLists.txt | 5 - src/dusk/OSReport.cpp | 4 +- src/dusk/android_frame_rate.cpp | 74 - src/dusk/android_frame_rate.hpp | 7 - src/dusk/app_info.hpp | 22 +- src/dusk/config.cpp | 8 +- src/dusk/crash_handler.cpp | 965 ------- src/dusk/crash_handler.h | 7 - src/dusk/crash_reporting.cpp | 188 -- src/dusk/crash_reporting.h | 17 - src/dusk/data.cpp | 1120 +-------- src/dusk/data.hpp | 16 +- src/dusk/discord.cpp | 867 ------- src/dusk/discord.hpp | 41 - src/dusk/discord_presence.cpp | 50 +- src/dusk/discord_presence.hpp | 4 +- src/dusk/file_select.cpp | 314 --- src/dusk/file_select.hpp | 22 - src/dusk/file_select_macos.mm | 102 - src/dusk/http/android.cpp | 402 --- src/dusk/http/curl.cpp | 206 -- src/dusk/http/http.hpp | 60 - src/dusk/http/no_backend.cpp | 24 - src/dusk/http/url_session.mm | 238 -- src/dusk/http/winhttp.cpp | 320 --- src/dusk/imgui/ImGuiStateShare.cpp | 25 +- src/dusk/imgui/ImGuiStateShare.hpp | 2 - src/dusk/imgui/ImGuiStubLog.cpp | 26 +- src/dusk/io.hpp | 8 - src/dusk/ios/FileSelectDialog.h | 21 - src/dusk/ios/FileSelectDialog.m | 151 -- src/dusk/iso_validate.cpp | 269 +- src/dusk/iso_validate.hpp | 28 +- src/dusk/logging.cpp | 387 +-- src/dusk/logging.h | 23 +- src/dusk/main.cpp | 3 +- src/dusk/mods/loader/bundle_disk.cpp | 3 +- src/dusk/mods/loader/depgraph.cpp | 2 +- src/dusk/mods/loader/loader.cpp | 12 +- src/dusk/mods/loader/prepatch.cpp | 4 +- src/dusk/mods/log_buffer.cpp | 18 +- src/dusk/mods/manifest.cpp | 4 +- src/dusk/mods/svc/config.cpp | 4 +- src/dusk/mods/svc/gfx.cpp | 4 +- src/dusk/mods/svc/host.cpp | 4 +- src/dusk/mods/svc/overlay.cpp | 4 +- src/dusk/mods/svc/resource.cpp | 4 +- src/dusk/mods/svc/texture.cpp | 4 +- src/dusk/mods/svc/ui.cpp | 4 +- src/dusk/mods/svc/window.cpp | 4 +- src/dusk/presentation.cpp | 30 + src/dusk/presentation.hpp | 7 + src/dusk/ui/mod_texture_provider.cpp | 4 +- src/dusk/ui/overlay.cpp | 4 +- src/dusk/ui/prelaunch.cpp | 110 +- src/dusk/ui/reporting.cpp | 8 +- src/dusk/ui/reporting.hpp | 2 +- src/dusk/ui/settings.cpp | 121 +- src/dusk/ui/ui.cpp | 3 +- src/dusk/update_check.cpp | 351 --- src/dusk/update_check.hpp | 41 - src/m_Do/m_Do_main.cpp | 81 +- version.h.in | 26 - 94 files changed, 654 insertions(+), 14091 deletions(-) delete mode 100644 cmake/DetectVersion.cmake create mode 160000 extern/borealis delete mode 100644 platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java delete mode 100644 platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskHttpClient.java delete mode 100644 platforms/android/app/src/main/java/org/libsdl/app/HIDDevice.java delete mode 100644 platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java delete mode 100644 platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java delete mode 100644 platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java delete mode 100644 platforms/android/app/src/main/java/org/libsdl/app/SDL.java delete mode 100644 platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java delete mode 100644 platforms/android/app/src/main/java/org/libsdl/app/SDLAudioManager.java delete mode 100644 platforms/android/app/src/main/java/org/libsdl/app/SDLControllerManager.java delete mode 100644 platforms/android/app/src/main/java/org/libsdl/app/SDLDummyEdit.java delete mode 100644 platforms/android/app/src/main/java/org/libsdl/app/SDLInputConnection.java delete mode 100644 platforms/android/app/src/main/java/org/libsdl/app/SDLSensorManager.java delete mode 100644 platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java delete mode 100644 platforms/android/scripts/sync-sdl-java.sh delete mode 100644 src/dusk/android_frame_rate.cpp delete mode 100644 src/dusk/android_frame_rate.hpp delete mode 100644 src/dusk/crash_handler.cpp delete mode 100644 src/dusk/crash_handler.h delete mode 100644 src/dusk/crash_reporting.cpp delete mode 100644 src/dusk/crash_reporting.h delete mode 100644 src/dusk/discord.cpp delete mode 100644 src/dusk/discord.hpp delete mode 100644 src/dusk/file_select.cpp delete mode 100644 src/dusk/file_select.hpp delete mode 100644 src/dusk/file_select_macos.mm delete mode 100644 src/dusk/http/android.cpp delete mode 100644 src/dusk/http/curl.cpp delete mode 100644 src/dusk/http/http.hpp delete mode 100644 src/dusk/http/no_backend.cpp delete mode 100644 src/dusk/http/url_session.mm delete mode 100644 src/dusk/http/winhttp.cpp delete mode 100644 src/dusk/ios/FileSelectDialog.h delete mode 100644 src/dusk/ios/FileSelectDialog.m create mode 100644 src/dusk/presentation.cpp create mode 100644 src/dusk/presentation.hpp delete mode 100644 src/dusk/update_check.cpp delete mode 100644 src/dusk/update_check.hpp delete mode 100644 version.h.in diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f7abef73e5..41758382cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -76,7 +76,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: dusklight-${{env.DUSK_VERSION}}-linux-${{matrix.preset}}-${{matrix.artifact_arch}} + name: dusklight-${{env.APP_VERSION}}-linux-${{matrix.preset}}-${{matrix.artifact_arch}} path: | build/install/Dusklight-*.AppImage build/install/debug.tar.* @@ -145,7 +145,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: dusklight-${{env.DUSK_VERSION}}-${{matrix.artifact_name}} + name: dusklight-${{env.APP_VERSION}}-${{matrix.artifact_name}} path: | build/install/Dusklight.app build/install/debug.tar.* @@ -221,7 +221,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: dusklight-${{env.DUSK_VERSION}}-android-${{matrix.artifact_arch}} + name: dusklight-${{env.APP_VERSION}}-android-${{matrix.artifact_arch}} path: upload/ build-windows: @@ -283,7 +283,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: dusklight-${{env.DUSK_VERSION}}-win32-msvc-${{matrix.artifact_arch}} + name: dusklight-${{env.APP_VERSION}}-win32-msvc-${{matrix.artifact_arch}} path: | build/install/*.exe build/install/*.dll diff --git a/.gitmodules b/.gitmodules index b386c1754a..f05842fa7b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "extern/aurora"] path = extern/aurora url = https://github.com/encounter/aurora.git +[submodule "extern/borealis"] + path = extern/borealis + url = https://github.com/encounter/borealis.git diff --git a/CMakeLists.txt b/CMakeLists.txt index a929e17022..476190563a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,10 +5,11 @@ if (NOT CMAKE_BUILD_TYPE) "Build type options: Debug Release RelWithDebInfo MinSizeRel" FORCE) endif () -include(cmake/DetectVersion.cmake) -detect_version() +include(extern/borealis/cmake/DetectVersion.cmake) +borealis_detect_version() message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") -project(dusklight LANGUAGES C CXX VERSION ${DUSK_VERSION_STRING}) +project(dusklight LANGUAGES C CXX VERSION ${BOREALIS_APP_VERSION}) + if (APPLE) enable_language(OBJC OBJCXX) endif () @@ -74,6 +75,8 @@ set(AURORA_ENABLE_RMLUI ON CACHE BOOL "Enable RmlUi UI support" FORCE) add_subdirectory(extern/aurora EXCLUDE_FROM_ALL) target_compile_definitions(aurora_mtx PRIVATE MTX_USE_PS=1) +add_subdirectory(extern/borealis EXCLUDE_FROM_ALL) + add_subdirectory(libs/freeverb) if (CMAKE_BUILD_TYPE STREQUAL "Debug") @@ -85,8 +88,6 @@ endif () option(DUSK_BUILD_WARNINGS "Enable compiler warnings (off by default)") option(DUSK_SELECTED_OPT "If on, selected parts of the project will be compiled with optimizations on Debug, intending to make the game run at 30 FPS. Note for MSVC: you will need to remove '/RTC1' from your debug flags in CMake.") option(DUSK_MOVIE_SUPPORT "If on, compile against libjpeg-turbo to enable THP file decoding" ON) -option(DUSK_ENABLE_UPDATE_CHECKER "Enable update checking support" ON) -option(DUSK_ENABLE_SENTRY_NATIVE "Enable sentry-native crash reporting support" OFF) option(DUSK_PACKAGE_INSTALL "Install Dusklight with a Linux-native file structure" OFF) option(DUSK_GFX_DEBUG_GROUPS "Report debug groups to the native graphics API" ${DUSK_GFX_DEBUG_GROUPS_DEFAULT}) option(DUSK_ENABLE_CODE_MODS "Enable code mods" OFF) @@ -235,18 +236,6 @@ endif () include(FetchContent) # Declare all dependencies first so CMake can download them in parallel -message(STATUS "dusklight: Fetching cxxopts") -FetchContent_Declare(cxxopts - URL https://github.com/jarro2783/cxxopts/archive/refs/tags/v3.3.1.tar.gz - URL_HASH SHA256=3bfc70542c521d4b55a46429d808178916a579b28d048bd8c727ee76c39e2072 - DOWNLOAD_EXTRACT_TIMESTAMP FALSE -) -message(STATUS "dusklight: Fetching nlohmann/json") -FetchContent_Declare(json - URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz - URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa - DOWNLOAD_EXTRACT_TIMESTAMP FALSE -) message(STATUS "dusklight: Fetching miniz") FetchContent_Declare(miniz URL https://github.com/richgel999/miniz/releases/download/3.0.2/miniz-3.0.2.zip @@ -254,7 +243,7 @@ FetchContent_Declare(miniz EXCLUDE_FROM_ALL ) -set(_fetch_content_deps cxxopts json miniz) +set(_fetch_content_deps miniz) if (DUSK_HAS_FUNCHOOK) message(STATUS "dusklight: Fetching funchook") # cmake/PatchFunchook.cmake patches funchook's cmake/capstone.cmake.in to inject a @@ -289,40 +278,12 @@ if (DUSK_HAS_FUNCHOOK) endif () FetchContent_MakeAvailable(${_fetch_content_deps}) -if (DUSK_ENABLE_SENTRY_NATIVE) - message(STATUS "dusklight: Fetching sentry-native") - set(SENTRY_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) - set(SENTRY_BACKEND crashpad CACHE STRING "" FORCE) - if (WIN32) - set(SENTRY_TRANSPORT winhttp CACHE STRING "" FORCE) - endif () - set(SENTRY_BUILD_TESTS OFF CACHE BOOL "" FORCE) - set(SENTRY_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) - set(SENTRY_BUILD_BENCHMARKS OFF CACHE BOOL "" FORCE) - FetchContent_Declare(sentry_native - GIT_REPOSITORY https://github.com/getsentry/sentry-native.git - GIT_TAG 0.13.6 - GIT_SHALLOW TRUE - GIT_PROGRESS TRUE - GIT_SUBMODULES_RECURSE TRUE - ) - if (NOT sentry_native_POPULATED) - FetchContent_Populate(sentry_native) - set(_skip_install_rules ${CMAKE_SKIP_INSTALL_RULES}) - set(CMAKE_SKIP_INSTALL_RULES ON) - add_subdirectory(${sentry_native_SOURCE_DIR} ${sentry_native_BINARY_DIR} EXCLUDE_FROM_ALL) - set(CMAKE_SKIP_INSTALL_RULES ${_skip_install_rules}) - endif () -endif () - # Use signed char on ARM to match the original game (and x86) string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _arch) if(_arch MATCHES "^(arm|aarch64)" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "GNU") add_compile_options(-fsigned-char) endif() -configure_version_header() - include(files.cmake) # TODO: version handling for res includes @@ -335,67 +296,23 @@ set(DUSK_PRODUCT_NAME "Dusklight") set(DUSK_COPYRIGHT "Copyright (C) Twilit Realm contributors") source_group("dolzel" FILES ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${REL_FILES}) -source_group("dusklight" FILES ${DUSK_FILES} ${DUSK_HTTP_BACKEND_FILES}) +source_group("dusklight" FILES ${DUSK_FILES}) include(cmake/GameABIConfig.cmake) find_package(Threads REQUIRED) set(GAME_COMPILE_DEFS DUSK_BUILDING_GAME=1) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd - aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt + aurora::card borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::log borealis::presentation borealis::sentry borealis::update freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt Threads::Threads zstd::libzstd dusklight_game_headers) if (DUSK_HAS_FUNCHOOK) list(APPEND GAME_LIBS funchook-static) endif () -if (DUSK_ENABLE_SENTRY_NATIVE) - list(APPEND GAME_LIBS sentry) - list(APPEND GAME_COMPILE_DEFS DUSK_ENABLE_SENTRY_NATIVE=1 SENTRY_BUILD_STATIC=1) -endif () - if (WIN32) list(APPEND GAME_LIBS Ws2_32) - if (CMAKE_BUILD_TYPE STREQUAL Debug) - list(APPEND GAME_LIBS dbghelp) - list(APPEND GAME_COMPILE_DEFS DUSK_CRASH_DBGHELP=1) - endif () endif () -set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/no_backend.cpp) -if (DUSK_ENABLE_UPDATE_CHECKER) - list(APPEND GAME_COMPILE_DEFS DUSK_ENABLE_UPDATE_CHECKER=1) - if (WIN32) - set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/winhttp.cpp) - list(APPEND GAME_LIBS winhttp) - list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_WINHTTP=1) - message(STATUS "dusklight: Enabled update checker (WinHTTP)") - elseif (ANDROID) - set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/android.cpp) - list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_ANDROID=1) - message(STATUS "dusklight: Enabled update checker (Android)") - elseif (APPLE) - find_library(FOUNDATION_FRAMEWORK Foundation REQUIRED) - set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/url_session.mm) - set_source_files_properties(src/dusk/http/url_session.mm PROPERTIES COMPILE_FLAGS -fobjc-arc) - list(APPEND GAME_LIBS ${FOUNDATION_FRAMEWORK}) - list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_URLSESSION=1) - message(STATUS "dusklight: Enabled update checker (NSURLSession)") - elseif (CMAKE_SYSTEM_NAME STREQUAL Linux) - find_package(CURL QUIET OPTIONAL_COMPONENTS HTTPS SSL) - if (CURL_FOUND AND CURL_HTTPS_FOUND AND CURL_SSL_FOUND) - set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/curl.cpp) - list(APPEND GAME_LIBS CURL::libcurl) - list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_LIBCURL=1) - message(STATUS "dusklight: Enabled update checker (libcurl)") - else () - message(STATUS "dusklight: Disabled update checker (libcurl + HTTPS/SSL not found)") - endif () - else () - message(STATUS "dusklight: Disabled update checker (unsupported platform)") - endif () -endif () -list(APPEND DUSK_FILES ${DUSK_HTTP_BACKEND_SOURCE}) - if (DUSK_MOVIE_SUPPORT) if (TARGET libjpeg-turbo::turbojpeg-static) list(APPEND GAME_LIBS libjpeg-turbo::turbojpeg-static) @@ -405,15 +322,6 @@ if (DUSK_MOVIE_SUPPORT) list(APPEND GAME_COMPILE_DEFS MOVIE_SUPPORT=1) endif () -set(DUSK_ENABLE_DISCORD_DEFAULT ON) -if (DEFINED DUSK_ENABLE_DISCORD_RPC AND NOT DEFINED DUSK_ENABLE_DISCORD) - set(DUSK_ENABLE_DISCORD_DEFAULT ${DUSK_ENABLE_DISCORD_RPC}) -endif () -option(DUSK_ENABLE_DISCORD "Enable Discord Rich Presence support" ${DUSK_ENABLE_DISCORD_DEFAULT}) -if (DUSK_ENABLE_DISCORD AND NOT ANDROID AND NOT IOS AND NOT TVOS) - list(APPEND GAME_COMPILE_DEFS DUSK_DISCORD=1) -endif () - if (DUSK_ENABLE_CODE_MODS) list(APPEND GAME_COMPILE_DEFS DUSK_CODE_MODS=1) endif () @@ -478,10 +386,10 @@ endif () set(DUSK_FILES src/dusk/main.cpp ${GAME_BASE_FILES} ${GAME_DEBUG_FILES} ${miniz_SOURCE_DIR}/miniz.c) if(ANDROID) add_library(dusklight SHARED ${DUSK_FILES}) - set_target_properties(dusklight PROPERTIES OUTPUT_NAME main) else () add_executable(dusklight ${DUSK_FILES}) endif () +borealis_configure_android_application(dusklight) if (ENABLE_ASAN) target_sources(dusklight PRIVATE src/dusk/asan_options.c) endif () @@ -559,17 +467,9 @@ if (TARGET crashpad_handler) ) endif () -if (ANDROID) - # SDLActivity loads SDL_main via dlsym on Android. Since aurora::main is a static - # archive, force an undefined reference so the linker keeps the SDL_main object. - target_link_options(dusklight PRIVATE "-Wl,-u,SDL_main") -endif () - if (CMAKE_SYSTEM_NAME STREQUAL Linux) target_link_options(dusklight PRIVATE "-Wl,--build-id=sha1") target_link_libraries(dusklight PRIVATE dl) -elseif (ANDROID) - target_link_options(dusklight PRIVATE "-Wl,--build-id=sha1") endif () if (NOT APPLE) @@ -651,8 +551,8 @@ if (APPLE) MACOSX_BUNDLE TRUE MACOSX_BUNDLE_BUNDLE_NAME ${DUSK_BUNDLE_NAME} MACOSX_BUNDLE_GUI_IDENTIFIER ${DUSK_BUNDLE_IDENTIFIER} - MACOSX_BUNDLE_BUNDLE_VERSION ${DUSK_VERSION_STRING} - MACOSX_BUNDLE_SHORT_VERSION_STRING ${DUSK_SHORT_VERSION_STRING} + MACOSX_BUNDLE_BUNDLE_VERSION ${BOREALIS_APP_VERSION} + MACOSX_BUNDLE_SHORT_VERSION_STRING ${BOREALIS_APP_SHORT_VERSION} MACOSX_BUNDLE_INFO_PLIST ${DUSK_INFO_PLIST} OUTPUT_NAME Dusklight XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES" @@ -685,21 +585,6 @@ if (APPLE) endif () endif () -if (CMAKE_SYSTEM_NAME STREQUAL "Darwin") - find_library(APPKIT_FRAMEWORK AppKit REQUIRED) - target_sources(dusklight PRIVATE src/dusk/file_select_macos.mm) - set_source_files_properties(src/dusk/file_select_macos.mm PROPERTIES COMPILE_FLAGS -fobjc-arc) - target_link_libraries(dusklight PRIVATE ${APPKIT_FRAMEWORK}) -endif () - -if (IOS) - find_library(UIKIT_FRAMEWORK UIKit REQUIRED) - find_library(UNIFORM_TYPE_IDENTIFIERS_FRAMEWORK UniformTypeIdentifiers REQUIRED) - target_sources(dusklight PRIVATE src/dusk/ios/FileSelectDialog.m) - set_source_files_properties(src/dusk/ios/FileSelectDialog.m PROPERTIES COMPILE_FLAGS -fobjc-arc) - target_link_libraries(dusklight PRIVATE ${UIKIT_FRAMEWORK} ${UNIFORM_TYPE_IDENTIFIERS_FRAMEWORK}) -endif () - include(extern/aurora/cmake/AuroraCopyRuntimeDLLs.cmake) aurora_copy_runtime_dlls(dusklight) diff --git a/CMakePresets.json b/CMakePresets.json index 4faccc4448..45a6522625 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -28,12 +28,12 @@ "cacheVariables": { "CMAKE_C_COMPILER_LAUNCHER": "sccache", "CMAKE_CXX_COMPILER_LAUNCHER": "sccache", - "DUSK_ENABLE_SENTRY_NATIVE": { + "BOREALIS_ENABLE_SENTRY": { "type": "BOOL", "value": true }, - "DUSK_SENTRY_DSN": "$env{SENTRY_DSN}", - "DUSK_SENTRY_ENVIRONMENT": "production", + "BOREALIS_SENTRY_DSN": "$env{SENTRY_DSN}", + "BOREALIS_SENTRY_ENVIRONMENT": "production", "Rust_RUSTUP_INSTALL_MISSING_TARGET": { "type": "BOOL", "value": true @@ -448,7 +448,7 @@ "ci" ], "cacheVariables": { - "DUSK_ENABLE_SENTRY_NATIVE": { + "BOREALIS_ENABLE_SENTRY": { "type": "BOOL", "value": false } diff --git a/ci/build-appimage.sh b/ci/build-appimage.sh index e12f1163b9..c4a0e82995 100755 --- a/ci/build-appimage.sh +++ b/ci/build-appimage.sh @@ -23,5 +23,5 @@ cp -r platforms/freedesktop/{16x16,32x32,48x48,64x64,128x128,256x256,512x512,102 cp platforms/freedesktop/dev.twilitrealm.dusk.desktop build/appdir/usr/share/applications cd build/install -VERSION="$DUSK_VERSION" NO_STRIP=1 "$linuxdeploy" \ +VERSION="$APP_VERSION" NO_STRIP=1 "$linuxdeploy" \ -l "$lib_dir/libusb-1.0.so" --appdir "$build_dir/appdir" --output appimage diff --git a/cmake/DetectVersion.cmake b/cmake/DetectVersion.cmake deleted file mode 100644 index ec72a97a2e..0000000000 --- a/cmake/DetectVersion.cmake +++ /dev/null @@ -1,121 +0,0 @@ -# Version detection shared by the main build and the mod SDK (sdk/CMakeLists.txt) -include_guard(GLOBAL) - -get_filename_component(_DUSK_VERSION_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) - -set(DUSK_SENTRY_DSN "" CACHE STRING "Sentry DSN") -set(DUSK_SENTRY_ENVIRONMENT "development" CACHE STRING "Sentry environment") - -set(DUSK_VERSION_OVERRIDE "" CACHE STRING "Override version string (skips git detection and format validation)") - -macro(detect_version) - if (DUSK_VERSION_OVERRIDE) - set(DUSK_WC_DESCRIBE "${DUSK_VERSION_OVERRIDE}") - set(DUSK_VERSION_STRING "0.0.0.0") - set(DUSK_SHORT_VERSION_STRING "0.0.0") - set(DUSK_VERSION_CODE "1") - set(DUSK_WC_REVISION "") - set(DUSK_WC_BRANCH "") - set(DUSK_WC_DATE "") - message(STATUS "Dusklight version overridden to ${DUSK_WC_DESCRIBE}") - else () - # obtain revision info from git - find_package(Git) - if (GIT_FOUND) - # make sure version information gets re-run when the current Git HEAD changes - execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse --git-path HEAD - OUTPUT_VARIABLE dusk_git_head_filename - OUTPUT_STRIP_TRAILING_WHITESPACE) - get_filename_component(dusk_git_head_filename "${dusk_git_head_filename}" ABSOLUTE BASE_DIR "${_DUSK_VERSION_ROOT}") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_filename}") - - execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse --symbolic-full-name HEAD - OUTPUT_VARIABLE dusk_git_head_symbolic - OUTPUT_STRIP_TRAILING_WHITESPACE) - execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} - COMMAND ${GIT_EXECUTABLE} rev-parse --git-path ${dusk_git_head_symbolic} - OUTPUT_VARIABLE dusk_git_head_symbolic_filename - OUTPUT_STRIP_TRAILING_WHITESPACE) - get_filename_component(dusk_git_head_symbolic_filename "${dusk_git_head_symbolic_filename}" ABSOLUTE BASE_DIR "${_DUSK_VERSION_ROOT}") - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_symbolic_filename}") - - # defines DUSK_WC_REVISION - execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse HEAD - OUTPUT_VARIABLE DUSK_WC_REVISION - OUTPUT_STRIP_TRAILING_WHITESPACE) - # defines DUSK_WC_DESCRIBE - execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} describe --tags --long --dirty --match "v*" - OUTPUT_VARIABLE DUSK_WC_DESCRIBE - OUTPUT_STRIP_TRAILING_WHITESPACE) - - # remove the git hash, then collapse a clean "-0" suffix only - string(REGEX REPLACE "-[^-]+(-dirty|)$" "\\1" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") - string(REGEX REPLACE "-0$" "" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") - - # defines DUSK_WC_BRANCH - execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse --abbrev-ref HEAD - OUTPUT_VARIABLE DUSK_WC_BRANCH - OUTPUT_STRIP_TRAILING_WHITESPACE) - # defines DUSK_WC_DATE - execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} log -1 --format=%ad - OUTPUT_VARIABLE DUSK_WC_DATE - OUTPUT_STRIP_TRAILING_WHITESPACE) - 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]+)([-+].*)?$") - set(DUSK_SHORT_VERSION_STRING "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}.${CMAKE_MATCH_3}") - set(_ver_major ${CMAKE_MATCH_1}) - set(_ver_minor ${CMAKE_MATCH_2}) - set(_ver_patch ${CMAKE_MATCH_3}) - set(DUSK_VERSION_TWEAK "0") - if (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-([0-9]+)(-dirty)?$") - set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") - elseif (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-[0-9A-Za-z.-]+-([0-9]+)(-dirty)?$") - set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") - endif () - set(DUSK_VERSION_STRING "${DUSK_SHORT_VERSION_STRING}.${DUSK_VERSION_TWEAK}") - if (DUSK_VERSION_TWEAK GREATER 999) - set(_tweak 999) - else () - set(_tweak ${DUSK_VERSION_TWEAK}) - endif () - # encoding: major*1e7 + minor*1e5 + patch*1e3 + tweak; collision-free for major<210, minor<100, patch<100, tweak<=999 - math(EXPR DUSK_VERSION_CODE - "${_ver_major} * 10000000 + ${_ver_minor} * 100000 + ${_ver_patch} * 1000 + ${_tweak}") - else () - set(DUSK_WC_DESCRIBE "UNKNOWN-VERSION") - set(DUSK_VERSION_STRING "0.0.0.0") - set(DUSK_SHORT_VERSION_STRING "0.0.0") - set(DUSK_VERSION_CODE "1") - endif () - - endif () - - # Add version information to CI environment variables - if (DEFINED ENV{GITHUB_ENV}) - file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION=${DUSK_WC_DESCRIBE}\n") - file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION_CODE=${DUSK_VERSION_CODE}\n") - endif () - message(STATUS "Dusklight version set to ${DUSK_WC_DESCRIBE}") -endmacro() - -# Sets PLATFORM_NAME and configures version.h into the caller's binary dir. -macro(configure_version_header) - if (CMAKE_SYSTEM_NAME STREQUAL Windows) - set(PLATFORM_NAME win32) - elseif (CMAKE_SYSTEM_NAME STREQUAL Darwin) - if (IOS) - set(PLATFORM_NAME ios) - elseif (TVOS) - set(PLATFORM_NAME tvos) - else () - set(PLATFORM_NAME macos) - endif () - else () - string(TOLOWER CMAKE_SYSTEM_NAME PLATFORM_NAME) - endif () - - configure_file(${_DUSK_VERSION_ROOT}/version.h.in ${CMAKE_CURRENT_BINARY_DIR}/version.h) -endmacro() diff --git a/extern/aurora b/extern/aurora index 6c4c27f9e8..5027ed63a7 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 6c4c27f9e8e40f584d27726655d80ec85a5a7d2c +Subproject commit 5027ed63a73dfba28de9eceed00481fb09a19c35 diff --git a/extern/borealis b/extern/borealis new file mode 160000 index 0000000000..eec74b6dec --- /dev/null +++ b/extern/borealis @@ -0,0 +1 @@ +Subproject commit eec74b6decf364354d0a94ad308ea6d0186585a2 diff --git a/files.cmake b/files.cmake index 9867b2d39a..4fddf9b9dd 100644 --- a/files.cmake +++ b/files.cmake @@ -1421,30 +1421,21 @@ set(DUSK_FILES src/dusk/achievements.cpp src/dusk/action_bindings.cpp src/dusk/action_bindings.h - src/dusk/android_frame_rate.cpp - src/dusk/android_frame_rate.hpp src/dusk/asserts.cpp src/dusk/autosave.cpp src/dusk/config.cpp src/dusk/config.hpp - src/dusk/crash_handler.cpp - src/dusk/crash_reporting.cpp src/dusk/data.cpp src/dusk/data.hpp - src/dusk/discord.cpp - src/dusk/discord.hpp src/dusk/discord_presence.cpp src/dusk/dvd_asset.cpp src/dusk/dvd_asset.hpp src/dusk/extras.c - src/dusk/file_select.cpp - src/dusk/file_select.hpp src/dusk/frame_interpolation.cpp src/dusk/game_clock.cpp src/dusk/gamepad_color.cpp src/dusk/globals.cpp src/dusk/gyro.cpp - src/dusk/http/http.hpp src/dusk/imgui/ImGuiActorSpawner.cpp src/dusk/imgui/ImGuiBloomWindow.cpp src/dusk/imgui/ImGuiBloomWindow.hpp @@ -1507,6 +1498,8 @@ set(DUSK_FILES src/dusk/mods/svc/stage.cpp src/dusk/mods/svc/stage.hpp src/dusk/mouse.cpp + src/dusk/presentation.cpp + src/dusk/presentation.hpp src/dusk/scope_guard.hpp src/dusk/settings.cpp src/dusk/speedrun.cpp @@ -1582,8 +1575,6 @@ set(DUSK_FILES src/dusk/ui/warp.hpp src/dusk/ui/window.cpp src/dusk/ui/window.hpp - src/dusk/update_check.cpp - src/dusk/update_check.hpp src/dusk/version.cpp src/dusk/utilities.cpp src/helpers/batch.cpp @@ -1591,10 +1582,3 @@ set(DUSK_FILES src/helpers/offset_ptr.cpp src/helpers/string.cpp ) - -set(DUSK_HTTP_BACKEND_FILES - src/dusk/http/no_backend.cpp - src/dusk/http/curl.cpp - src/dusk/http/winhttp.cpp - src/dusk/http/url_session.mm -) diff --git a/flake.nix b/flake.nix index c470446113..01a20fad2d 100644 --- a/flake.nix +++ b/flake.nix @@ -228,7 +228,7 @@ ninjaFlags = [ "dusklight" ]; cmakeFlags = [ - "-DDUSK_VERSION_OVERRIDE=${versionSuffix}" + "-DBOREALIS_APP_VERSION_OVERRIDE=${versionSuffix}" "-DFETCHCONTENT_FULLY_DISCONNECTED=ON" "-DAURORA_DAWN_PROVIDER=package" "-DAURORA_DAWN_LINKAGE=static" diff --git a/platforms/android/README.md b/platforms/android/README.md index 7a5d01ee44..6ebcac23cd 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -1,6 +1,6 @@ # Android Shell -This directory contains a minimal SDLActivity-based Android app wrapper for Dusklight. +This directory contains Dusklight's Android shell built on top of Borealis. ## Prerequisites @@ -25,24 +25,19 @@ cmake --build --preset android-arm64 This build produces `build/android-arm64/libmain.so` -## Refresh SDL Java Shim (Optional) - -If you update SDL and want to refresh the embedded Java shim files: - -```bash -./android/scripts/sync-sdl-java.sh -``` - ## Build APK ```bash -cd android +cd platforms/android ./gradlew :app:assembleDebug ``` Output APK: -- `android/app/build/outputs/apk/debug/app-debug.apk` +- `app/build/outputs/apk/debug/app-arm64-v8a-debug.apk` + +Aurora needs a hardware-backed graphics adapter. If an AVD has GPU +acceleration disabled, launch it with `-gpu host`. ## Launch With Runtime Args (adb) @@ -50,10 +45,13 @@ You can pass command-line args through the activity intent: ```bash adb shell am start -n dev.twilitrealm.dusk/.DuskActivity \ - --es dusk_args "--backend vulkan" + --es borealis_args "--backend vulkan" ``` Supported extras: -- `dusk_args`: single shell-like argument string -- `dusk_argv`: string-array argv +- `borealis_args`: single shell-like argument string +- `borealis_argv`: string-array argv + +The legacy `dusk_args` and `dusk_argv` names remain accepted during the shell +transition. diff --git a/platforms/android/app/build.gradle b/platforms/android/app/build.gradle index 08d04bf35f..aba966b448 100644 --- a/platforms/android/app/build.gradle +++ b/platforms/android/app/build.gradle @@ -2,163 +2,32 @@ plugins { id 'com.android.application' } -def versionNameStr = (System.getenv("DUSK_VERSION") ?: "v0.1.0").replaceFirst("^v", "") -def versionCodeInt = (System.getenv("DUSK_VERSION_CODE") ?: "100000").toInteger() - def duskRepoDir = rootProject.projectDir.parentFile.parentFile -def androidNativeBuildDir = new File(duskRepoDir, 'build/android-arm64') -def nativeLibrary = new File(androidNativeBuildDir, 'libmain.so') -def stageStripValue = providers.gradleProperty('ANDROID_STAGE_STRIP') - .orElse(providers.gradleProperty('androidStageStrip')) - .orElse(providers.environmentVariable('ANDROID_STAGE_STRIP')) - .orElse('1') -def androidHome = { - def sdkPath = System.getenv('ANDROID_HOME') - if (!sdkPath) { - throw new GradleException('ANDROID_HOME is not available') - } - def sdkDir = new File(sdkPath) - if (!sdkDir.isDirectory()) { - throw new GradleException("ANDROID_HOME points to a missing directory: ${sdkDir}") - } - sdkDir -}.memoize() +def borealisDir = new File(duskRepoDir, 'extern/borealis') -def androidNdkVersion = { - def ndkVersion = System.getenv('ANDROID_NDK_VERSION') - if (!ndkVersion) { - throw new GradleException('ANDROID_NDK_VERSION is not available') - } - ndkVersion -}.memoize() +ext.borealisAndroid = [ + borealisDir: borealisDir, + propertiesFile: new File(duskRepoDir, 'build/android-arm64/borealis-android.properties'), + namespace: 'dev.twilitrealm.dusk', + applicationId: 'dev.twilitrealm.dusk', + abis: ['arm64-v8a'], + assets: [ + [ + from: new File(duskRepoDir, 'res'), + into: 'res', + excludes: ['**/.DS_Store'] + ], + [ + from: new File(duskRepoDir, 'build/android-arm64/bundled_mods'), + into: 'mods', + includes: ['*.dusk'] + ] + ], + proguardRules: file('proguard-rules.pro') +] -def androidNdkDir = { - def ndkDir = new File(androidHome(), "ndk/${androidNdkVersion()}") - if (!new File(ndkDir, 'build/cmake/android.toolchain.cmake').isFile()) { - throw new GradleException( - "Android NDK ${androidNdkVersion()} is missing or invalid at ${ndkDir}" - ) - } - ndkDir -}.memoize() - -def llvmPrebuiltDir = { - def prebuiltRoot = new File(androidNdkDir(), 'toolchains/llvm/prebuilt') - def prebuiltDir = (prebuiltRoot.listFiles()?.findAll { it.isDirectory() } ?: []) - .sort { it.name } - .find { candidate -> - new File( - candidate, - 'sysroot/usr/lib/aarch64-linux-android/libc++_shared.so' - ).isFile() - } - if (!prebuiltDir) { - throw new GradleException("Cannot find NDK libc++_shared.so under ${prebuiltRoot}") - } - prebuiltDir -}.memoize() - -def stlLibrary = { - def library = new File( - llvmPrebuiltDir(), - 'sysroot/usr/lib/aarch64-linux-android/libc++_shared.so' - ) - if (!library.isFile()) { - throw new GradleException("libc++_shared.so is missing") - } - library -}.memoize() - -def stagedJniLibsDir = layout.buildDirectory.dir('generated/jniLibs/dusklight') - -def stageJniLibs = tasks.register('stageJniLibs', Sync) { - group = 'build' - from(nativeLibrary) { - rename { 'libmain.so' } - into 'arm64-v8a' - } - from(providers.provider { stlLibrary() }) { - rename { 'libc++_shared.so' } - into 'arm64-v8a' - } - into(stagedJniLibsDir) - - doFirst { - if (!nativeLibrary.isFile()) { - throw new GradleException("Native library is missing") - } - } -} - -def duskGeneratedAssetsDir = layout.buildDirectory.dir('generated/assets/dusklight') -def syncDuskAssets = tasks.register('syncDuskAssets', Sync) { - from(new File(duskRepoDir, 'res')) { - into 'res' - exclude '**/.DS_Store' - } - from(new File(androidNativeBuildDir, 'bundled_mods')) { - into 'mods' - include '*.dusk' - } - into(duskGeneratedAssetsDir) -} - -android { - namespace 'dev.twilitrealm.dusk' - compileSdk 36 - ndkVersion androidNdkVersion() - - defaultConfig { - applicationId 'dev.twilitrealm.dusk' - minSdk 26 - targetSdk 36 - versionCode versionCodeInt - versionName versionNameStr - } - - buildTypes { - debug { - minifyEnabled false - } - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - sourceSets { - main { - jniLibs.srcDirs = [stagedJniLibsDir] - assets.srcDirs = [duskGeneratedAssetsDir] - } - } - - packaging { - jniLibs { - if (stageStripValue.get() == '0') { - keepDebugSymbols += '**/libmain.so' - } - } - } - - splits { - abi { - enable true - reset() - include 'arm64-v8a' - universalApk false - } - } - - lint { - abortOnError false - } -} +apply from: new File(borealisDir, 'platforms/android/gradle/borealis-application.gradle') dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) } - -tasks.named('preBuild').configure { - dependsOn(stageJniLibs, syncDuskAssets) -} diff --git a/platforms/android/app/proguard-rules.pro b/platforms/android/app/proguard-rules.pro index 72b7ca16fa..308a46ab83 100644 --- a/platforms/android/app/proguard-rules.pro +++ b/platforms/android/app/proguard-rules.pro @@ -1,4 +1 @@ -# Keep SDL activity and related JNI bridge methods. --keep class org.libsdl.app.** { *; } --keep class dev.twilitrealm.dusk.DuskHttpClient { *; } --keep class dev.twilitrealm.dusk.DuskHttpClient$Response { *; } +-keep class dev.twilitrealm.dusk.DuskActivity { *; } diff --git a/platforms/android/app/src/main/AndroidManifest.xml b/platforms/android/app/src/main/AndroidManifest.xml index cd687963e1..b7270ee433 100644 --- a/platforms/android/app/src/main/AndroidManifest.xml +++ b/platforms/android/app/src/main/AndroidManifest.xml @@ -26,8 +26,8 @@ android:resource="@xml/game_mode_config" /> diff --git a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java index 2fa6cbacb3..4c1d8d41eb 100644 --- a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java +++ b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java @@ -1,102 +1,23 @@ package dev.twilitrealm.dusk; -import android.app.ActionBar; -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.ClipData; -import android.content.Context; import android.content.Intent; -import android.database.Cursor; -import android.net.Uri; -import android.os.Build; import android.os.Bundle; -import android.os.Environment; -import android.provider.DocumentsContract; -import android.provider.OpenableColumns; -import android.provider.Settings; import android.util.Log; -import android.view.Display; -import android.view.Surface; -import android.view.SurfaceHolder; -import android.view.View; -import android.view.Window; -import android.view.WindowInsets; -import android.view.WindowInsetsController; -import org.libsdl.app.SDLActivity; -import org.libsdl.app.SDLSurface; +import dev.encounter.borealis.BorealisActivity; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.util.ArrayList; -import java.util.List; -public class DuskActivity extends SDLActivity { +public class DuskActivity extends BorealisActivity { private static final String TAG = "DuskActivity"; - private static final float DEFAULT_SURFACE_FRAME_RATE = 60.0f; - private static final int FOLDER_DIALOG_REQUEST_CODE = 0x4455; - private static final int MANAGE_STORAGE_REQUEST_CODE = 0x4456; - private static final String EXTERNAL_STORAGE_AUTHORITY = - "com.android.externalstorage.documents"; - - private long folderDialogUserdata = 0; - private boolean awaitingManageStoragePermission = false; - - private static native void nativeFolderDialogResult(long userdata, String path, String error); - - private static String[] splitArgs(String raw) { - List out = new ArrayList<>(); - StringBuilder current = new StringBuilder(); - boolean inSingle = false; - boolean inDouble = false; - boolean escaped = false; - - for (int i = 0; i < raw.length(); ++i) { - char c = raw.charAt(i); - if (escaped) { - current.append(c); - escaped = false; - continue; - } - if (c == '\\' && !inSingle) { - escaped = true; - continue; - } - if (c == '"' && !inSingle) { - inDouble = !inDouble; - continue; - } - if (c == '\'' && !inDouble) { - inSingle = !inSingle; - continue; - } - if (!inSingle && !inDouble && Character.isWhitespace(c)) { - if (current.length() > 0) { - out.add(current.toString()); - current.setLength(0); - } - continue; - } - current.append(c); - } - - if (escaped) { - current.append('\\'); - } - if (current.length() > 0) { - out.add(current.toString()); - } - return out.toArray(new String[0]); - } - @Override protected void onCreate(Bundle savedInstanceState) { extractBundledMods(); super.onCreate(savedInstanceState); - hideSystemBars(); } // Bundled mod packages ship as APK assets, which the native loader cannot read directly; @@ -143,459 +64,23 @@ public class DuskActivity extends SDLActivity { file.delete(); } - @Override - protected SDLSurface createSDLSurface(Context context) { - return new DuskSurface(context); - } - - @Override - protected void onResume() { - super.onResume(); - hideSystemBars(); - if (awaitingManageStoragePermission) { - resumeFolderDialogAfterPermissionGrant(); - } - } - - @Override - public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - if (hasFocus) { - hideSystemBars(); - } - } - - private void hideSystemBars() { - Window window = getWindow(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - window.setDecorFitsSystemWindows(false); - WindowInsetsController ctrl = window.getDecorView().getWindowInsetsController(); - if (ctrl != null) { - ctrl.setSystemBarsBehavior( - WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); - ctrl.hide(WindowInsets.Type.systemBars()); - } - } else { - View decorView = window.getDecorView(); - int uiOptions = View.SYSTEM_UI_FLAG_FULLSCREEN | - View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_LAYOUT_STABLE; - decorView.setSystemUiVisibility(uiOptions); - ActionBar actionBar = getActionBar(); - if (actionBar != null) { - actionBar.hide(); - } - } - } - - @Override - protected String[] getLibraries() { - // SDL3 is statically linked into libmain.so in this build. - return new String[] { - "main" - }; - } - - public void setPreferredSurfaceFrameRate(float frameRate) { - runOnUiThread(() -> { - if (mSurface instanceof DuskSurface) { - ((DuskSurface)mSurface).setPreferredFrameRate(frameRate); - } - }); - } - - private static final class DuskSurface extends SDLSurface { - private float preferredFrameRate = DEFAULT_SURFACE_FRAME_RATE; - - DuskSurface(Context context) { - super(context); - } - - @Override - public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { - super.surfaceChanged(holder, format, width, height); - setTargetFrameRate(holder); - } - - void setPreferredFrameRate(float frameRate) { - preferredFrameRate = frameRate; - setTargetFrameRate(getHolder()); - } - - private void setTargetFrameRate(SurfaceHolder holder) { - if (!mIsSurfaceReady || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - return; - } - - Surface surface = holder != null ? holder.getSurface() : getHolder().getSurface(); - if (surface == null || !surface.isValid()) { - return; - } - - float targetFrameRate = getMaxSupportedFrameRate(); - if (preferredFrameRate > 0.0f) { - targetFrameRate = preferredFrameRate; - } - if (targetFrameRate <= 0.0f) { - return; - } - - try { - surface.setFrameRate( - targetFrameRate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT); - Log.v(TAG, "Requested surface frame rate " + targetFrameRate + " fps"); - } catch (RuntimeException e) { - Log.w(TAG, "Failed to request surface frame rate", e); - } - } - - private float getMaxSupportedFrameRate() { - if (mDisplay == null) { - return 0.0f; - } - - float maxFrameRate = mDisplay.getRefreshRate(); - Display.Mode[] modes = mDisplay.getSupportedModes(); - if (modes == null) { - return maxFrameRate; - } - - for (Display.Mode mode : modes) { - maxFrameRate = Math.max(maxFrameRate, mode.getRefreshRate()); - } - return maxFrameRate; - } - } - @Override protected String[] getArguments() { + String[] arguments = super.getArguments(); + if (arguments.length > 0) { + return arguments; + } + Intent intent = getIntent(); - if (intent != null) { - String[] argv = intent.getStringArrayExtra("dusk_argv"); - if (argv != null && argv.length > 0) { - return argv; - } - - String rawArgs = intent.getStringExtra("dusk_args"); - if (rawArgs != null) { - String trimmed = rawArgs.trim(); - if (!trimmed.isEmpty()) { - return splitArgs(trimmed); - } - } + if (intent == null) { + return arguments; } - return new String[0]; + String[] argv = intent.getStringArrayExtra("dusk_argv"); + if (argv != null && argv.length > 0) { + return argv; + } + String rawArgs = intent.getStringExtra("dusk_args"); + return rawArgs == null ? arguments : splitArguments(rawArgs.trim()); } - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (resultCode == RESULT_OK) { - persistUriPermissions(data); - } - if (requestCode == FOLDER_DIALOG_REQUEST_CODE) { - finishFolderDialog(resultCode, data); - return; - } - super.onActivityResult(requestCode, resultCode, data); - } - - public boolean showFolderDialog(long userdata) { - if (userdata == 0 || folderDialogUserdata != 0) { - return false; - } - - folderDialogUserdata = userdata; - if (requiresManageStoragePermission() && !hasManageStoragePermission()) { - if (!requestManageStoragePermission()) { - finishFolderDialogWithError("Unable to request Android file access permission"); - return false; - } - return true; - } - - openFolderDialog(); - return true; - } - - private void openFolderDialog() { - runOnUiThread(() -> { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | - Intent.FLAG_GRANT_WRITE_URI_PERMISSION | - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | - Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); - - try { - startActivityForResult(intent, FOLDER_DIALOG_REQUEST_CODE); - } catch (ActivityNotFoundException e) { - Log.w(TAG, "Unable to open folder dialog.", e); - finishFolderDialog(Activity.RESULT_CANCELED, null); - } - }); - } - - private boolean requiresManageStoragePermission() { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R; - } - - private boolean hasManageStoragePermission() { - return !requiresManageStoragePermission() || Environment.isExternalStorageManager(); - } - - private boolean requestManageStoragePermission() { - if (!requiresManageStoragePermission()) { - return true; - } - - awaitingManageStoragePermission = true; - runOnUiThread(() -> { - if (tryStartManageStorageIntent( - new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) - .setData(Uri.parse("package:" + getPackageName()))) || - tryStartManageStorageIntent( - new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))) - { - return; - } - - finishFolderDialogWithError("Unable to request Android file access permission"); - }); - return true; - } - - private boolean tryStartManageStorageIntent(Intent intent) { - try { - startActivityForResult(intent, MANAGE_STORAGE_REQUEST_CODE); - return true; - } catch (ActivityNotFoundException e) { - Log.w(TAG, "Unable to open all-files access settings.", e); - return false; - } - } - - private void resumeFolderDialogAfterPermissionGrant() { - awaitingManageStoragePermission = false; - if (folderDialogUserdata == 0) { - return; - } - - if (hasManageStoragePermission()) { - openFolderDialog(); - return; - } - - finishFolderDialogWithError( - "Allow \"All files access\" for Dusklight before choosing a custom data folder"); - } - - private void finishFolderDialogWithError(String error) { - long userdata = folderDialogUserdata; - folderDialogUserdata = 0; - awaitingManageStoragePermission = false; - if (userdata != 0) { - nativeFolderDialogResult(userdata, null, error); - } - } - - private void finishFolderDialog(int resultCode, Intent data) { - long userdata = folderDialogUserdata; - folderDialogUserdata = 0; - if (userdata == 0) { - return; - } - - if (resultCode == RESULT_OK && data != null && data.getData() != null) { - String path = getRealPathForUri(data.getData()); - if (path != null && !path.isEmpty()) { - nativeFolderDialogResult(userdata, path, null); - } else { - nativeFolderDialogResult( - userdata, null, "Selected folder is not available as a filesystem path"); - } - return; - } - - nativeFolderDialogResult(userdata, null, null); - } - - private String getRealPathForUri(Uri uri) { - if (uri == null) { - return null; - } - - String scheme = uri.getScheme(); - if ("file".equals(scheme)) { - return uri.getPath(); - } - - if (!"content".equals(scheme) || - !EXTERNAL_STORAGE_AUTHORITY.equals(uri.getAuthority()) || - Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) - { - return null; - } - - try { - return getExternalStoragePathForDocumentId(getExternalStorageDocumentId(uri)); - } catch (IllegalArgumentException e) { - Log.w(TAG, "Unable to resolve URI: " + uri, e); - return null; - } - } - - private static String getExternalStorageDocumentId(Uri uri) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && isTreeDocumentUri(uri)) { - return DocumentsContract.getTreeDocumentId(uri); - } - - return DocumentsContract.getDocumentId(uri); - } - - private static boolean isTreeDocumentUri(Uri uri) { - List segments = uri.getPathSegments(); - return segments.size() >= 2 && "tree".equals(segments.get(0)); - } - - private String getExternalStoragePathForDocumentId(String documentId) { - if (documentId == null || documentId.isEmpty()) { - return null; - } - if (documentId.startsWith("raw:")) { - return documentId.substring("raw:".length()); - } - - String[] parts = documentId.split(":", 2); - String volumeId = parts[0]; - String relativePath = parts.length > 1 ? parts[1] : ""; - - File root = getExternalStorageRoot(volumeId); - if (root == null) { - return null; - } - - return relativePath.isEmpty() - ? root.getAbsolutePath() - : new File(root, relativePath).getAbsolutePath(); - } - - private File getExternalStorageRoot(String volumeId) { - if ("primary".equalsIgnoreCase(volumeId)) { - return Environment.getExternalStorageDirectory(); - } - if ("home".equalsIgnoreCase(volumeId)) { - return new File( - Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOCUMENTS); - } - - File[] externalFilesDirs = getExternalFilesDirs(null); - if (externalFilesDirs != null) { - for (File externalFilesDir : externalFilesDirs) { - File root = getStorageRootForExternalFilesDir(externalFilesDir); - if (root != null && volumeId.equalsIgnoreCase(root.getName())) { - return root; - } - } - } - - File fallback = new File("/storage", volumeId); - return fallback.exists() ? fallback : null; - } - - private File getStorageRootForExternalFilesDir(File externalFilesDir) { - if (externalFilesDir == null) { - return null; - } - - String path = externalFilesDir.getAbsolutePath(); - int androidDir = path.indexOf("/Android/"); - if (androidDir <= 0) { - return null; - } - - return new File(path.substring(0, androidDir)); - } - - private void persistUriPermissions(Intent data) { - if (data == null) { - return; - } - - int permissionFlags = - data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - if (permissionFlags == 0) { - return; - } - - Uri uri = data.getData(); - if (uri != null) { - persistUriPermission(uri, permissionFlags); - } - - ClipData clipData = data.getClipData(); - if (clipData == null) { - return; - } - for (int i = 0; i < clipData.getItemCount(); ++i) { - Uri itemUri = clipData.getItemAt(i).getUri(); - if (itemUri != null) { - persistUriPermission(itemUri, permissionFlags); - } - } - } - - private void persistUriPermission(Uri uri, int permissionFlags) { - if ((permissionFlags & Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0) { - persistUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION, "read"); - } - if ((permissionFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) { - persistUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION, "write"); - } - } - - private void persistUriPermission(Uri uri, int permissionFlag, String permissionName) { - try { - getContentResolver().takePersistableUriPermission(uri, permissionFlag); - } catch (SecurityException | IllegalArgumentException e) { - Log.w(TAG, "Unable to persist " + permissionName + " URI permission for " + uri, e); - } - } - - public String getDisplayNameForUri(String uriString) { - if (uriString == null || uriString.isEmpty()) { - return ""; - } - - Uri uri = Uri.parse(uriString); - if ("content".equals(uri.getScheme())) { - try (Cursor cursor = getContentResolver().query( - uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)) - { - if (cursor != null && cursor.moveToFirst()) { - int displayNameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - if (displayNameColumn >= 0) { - String displayName = cursor.getString(displayNameColumn); - if (displayName != null && !displayName.isEmpty()) { - return displayName; - } - } - } - } catch (SecurityException | IllegalArgumentException e) { - Log.w(TAG, "Unable to query display name for " + uri, e); - } - } else if ("file".equals(uri.getScheme())) { - String path = uri.getPath(); - if (path != null && !path.isEmpty()) { - String name = new File(path).getName(); - if (!name.isEmpty()) { - return name; - } - } - } - - String lastSegment = uri.getLastPathSegment(); - return lastSegment != null ? lastSegment : ""; - } } diff --git a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java deleted file mode 100644 index d4ed6a3041..0000000000 --- a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java +++ /dev/null @@ -1,467 +0,0 @@ -package dev.twilitrealm.dusk; - -import android.content.ContentResolver; -import android.content.res.AssetFileDescriptor; -import android.database.Cursor; -import android.database.MatrixCursor; -import android.net.Uri; -import android.os.Bundle; -import android.os.CancellationSignal; -import android.os.ParcelFileDescriptor; -import android.provider.DocumentsContract; -import android.provider.DocumentsContract.Document; -import android.provider.DocumentsContract.Root; -import android.provider.DocumentsProvider; -import android.webkit.MimeTypeMap; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.nio.charset.StandardCharsets; - -public class DuskDocumentsProvider extends DocumentsProvider { - public static final String AUTHORITY = "dev.twilitrealm.dusk.documents"; - - private static final String ROOT_ID = "dusk"; - private static final String ROOT_DOCUMENT_ID = "root"; - private static final String LOCATION_DESCRIPTOR_NAME = "data_location.json"; - private static final String DIRECTORY_MIME_TYPE = Document.MIME_TYPE_DIR; - - private static final String[] DEFAULT_ROOT_PROJECTION = new String[] { - Root.COLUMN_ROOT_ID, - Root.COLUMN_FLAGS, - Root.COLUMN_TITLE, - Root.COLUMN_DOCUMENT_ID, - Root.COLUMN_ICON, - Root.COLUMN_AVAILABLE_BYTES, - Root.COLUMN_SUMMARY - }; - - private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { - Document.COLUMN_DOCUMENT_ID, - Document.COLUMN_DISPLAY_NAME, - Document.COLUMN_FLAGS, - Document.COLUMN_MIME_TYPE, - Document.COLUMN_LAST_MODIFIED, - Document.COLUMN_SIZE - }; - - @Override - public boolean onCreate() { - if (!isCustomDataPathEnabled()) { - ensureUserDirectories(); - } - return true; - } - - @Override - public Cursor queryRoots(String[] projection) throws FileNotFoundException { - final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); - if (isCustomDataPathEnabled()) { - return result; - } - - final File root = getRootDirectory(); - final MatrixCursor.RowBuilder row = result.newRow(); - - row.add(Root.COLUMN_ROOT_ID, ROOT_ID); - row.add(Root.COLUMN_FLAGS, - Root.FLAG_LOCAL_ONLY | - Root.FLAG_SUPPORTS_CREATE | - Root.FLAG_SUPPORTS_IS_CHILD); - row.add(Root.COLUMN_TITLE, getContext().getString(R.string.app_name)); - row.add(Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID); - row.add(Root.COLUMN_ICON, R.mipmap.icon); - row.add(Root.COLUMN_AVAILABLE_BYTES, root.getFreeSpace()); - row.add(Root.COLUMN_SUMMARY, getContext().getString(R.string.documents_provider_summary)); - - return result; - } - - @Override - public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { - final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); - includeDocument(result, documentId, getFileForDocumentId(documentId)); - return result; - } - - @Override - public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) - throws FileNotFoundException - { - return queryChildDocumentsInternal(parentDocumentId, projection); - } - - @Override - public Cursor queryChildDocuments(String parentDocumentId, String[] projection, Bundle queryArgs) - throws FileNotFoundException - { - return queryChildDocumentsInternal(parentDocumentId, projection); - } - - private Cursor queryChildDocumentsInternal(String parentDocumentId, String[] projection) - throws FileNotFoundException - { - final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); - final File parent = getFileForDocumentId(parentDocumentId); - final File[] files = parent.listFiles(); - result.setNotificationUri(getContext().getContentResolver(), getChildDocumentsUri(parentDocumentId)); - - if (files == null) { - return result; - } - - for (File file : files) { - includeDocument(result, getDocumentIdForFile(file), file); - } - - return result; - } - - @Override - public boolean isChildDocument(String parentDocumentId, String documentId) { - try { - final File parent = getFileForDocumentId(parentDocumentId); - final File child = getFileForDocumentId(documentId); - return isInside(parent, child); - } catch (FileNotFoundException e) { - return false; - } - } - - @Override - public String createDocument(String parentDocumentId, String mimeType, String displayName) - throws FileNotFoundException - { - final File parent = getFileForDocumentId(parentDocumentId); - if (!parent.isDirectory()) { - throw new FileNotFoundException("Parent is not a directory: " + parentDocumentId); - } - - final String safeDisplayName = sanitizeDisplayName(displayName); - final File file = buildUniqueFile(parent, safeDisplayName); - final boolean created; - if (DIRECTORY_MIME_TYPE.equals(mimeType)) { - created = file.mkdir(); - } else { - try { - created = file.createNewFile(); - } catch (IOException e) { - throw asFileNotFound("Unable to create document", e); - } - } - - if (!created) { - throw new FileNotFoundException("Unable to create document: " + displayName); - } - - notifyChildrenChanged(parentDocumentId); - return getDocumentIdForFile(file); - } - - @Override - public String renameDocument(String documentId, String displayName) throws FileNotFoundException { - final File file = getFileForDocumentId(documentId); - if (ROOT_DOCUMENT_ID.equals(documentId)) { - throw new FileNotFoundException("Cannot rename root document"); - } - - final File target = buildUniqueFile(file.getParentFile(), sanitizeDisplayName(displayName)); - final String parentDocumentId = getDocumentIdForFile(file.getParentFile()); - if (!file.renameTo(target)) { - throw new FileNotFoundException("Unable to rename document: " + documentId); - } - notifyDocumentChanged(documentId); - notifyDocumentChanged(getDocumentIdForFile(target)); - notifyChildrenChanged(parentDocumentId); - return getDocumentIdForFile(target); - } - - @Override - public void deleteDocument(String documentId) throws FileNotFoundException { - if (ROOT_DOCUMENT_ID.equals(documentId)) { - throw new FileNotFoundException("Cannot delete root document"); - } - - final File file = getFileForDocumentId(documentId); - final String parentDocumentId = getDocumentIdForFile(file.getParentFile()); - deleteRecursively(file); - notifyDocumentChanged(documentId); - notifyChildrenChanged(parentDocumentId); - } - - @Override - public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal) - throws FileNotFoundException - { - return ParcelFileDescriptor.open(getFileForDocumentId(documentId), modeToParcelMode(mode)); - } - - @Override - public AssetFileDescriptor openDocumentThumbnail(String documentId, android.graphics.Point sizeHint, - CancellationSignal signal) throws FileNotFoundException - { - throw new FileNotFoundException("Thumbnails are not supported"); - } - - private void includeDocument(MatrixCursor result, String documentId, File file) throws FileNotFoundException { - final MatrixCursor.RowBuilder row = result.newRow(); - final boolean isDirectory = file.isDirectory(); - final String displayName = ROOT_DOCUMENT_ID.equals(documentId) - ? getContext().getString(R.string.documents_provider_root_name) - : file.getName(); - - int flags = Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_RENAME; - if (isDirectory) { - flags |= Document.FLAG_DIR_SUPPORTS_CREATE; - } else if (file.canWrite()) { - flags |= Document.FLAG_SUPPORTS_WRITE; - } - if (ROOT_DOCUMENT_ID.equals(documentId)) { - flags &= ~(Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_RENAME); - } - - row.add(Document.COLUMN_DOCUMENT_ID, documentId); - row.add(Document.COLUMN_DISPLAY_NAME, displayName); - row.add(Document.COLUMN_FLAGS, flags); - row.add(Document.COLUMN_MIME_TYPE, isDirectory ? DIRECTORY_MIME_TYPE : getMimeType(file)); - row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified()); - row.add(Document.COLUMN_SIZE, isDirectory ? null : file.length()); - } - - private File getRootDirectory() throws FileNotFoundException { - if (isCustomDataPathEnabled()) { - throw new FileNotFoundException( - "Dusk DocumentsProvider is disabled while a custom data path is configured"); - } - - final File root = getContext().getFilesDir(); - if (root == null) { - throw new FileNotFoundException("Dusklight files directory is unavailable"); - } - return root; - } - - private File getFileForDocumentId(String documentId) throws FileNotFoundException { - final File root = getRootDirectory(); - if (ROOT_DOCUMENT_ID.equals(documentId)) { - return root; - } - if (!documentId.startsWith(ROOT_DOCUMENT_ID + "/")) { - throw new FileNotFoundException("Invalid document id: " + documentId); - } - - final String relativePath = documentId.substring(ROOT_DOCUMENT_ID.length() + 1); - final File file = new File(root, relativePath); - if (!isInside(root, file)) { - throw new FileNotFoundException("Document escapes Dusklight files directory: " + documentId); - } - if (!file.exists()) { - throw new FileNotFoundException("Document does not exist: " + documentId); - } - return file; - } - - private String getDocumentIdForFile(File file) throws FileNotFoundException { - final File root = getRootDirectory(); - if (sameFile(root, file)) { - return ROOT_DOCUMENT_ID; - } - if (!isInside(root, file)) { - throw new FileNotFoundException("File escapes Dusklight files directory: " + file); - } - - final String rootPath = canonicalPath(root); - final String filePath = canonicalPath(file); - return ROOT_DOCUMENT_ID + "/" + filePath.substring(rootPath.length() + 1); - } - - private void ensureUserDirectories() { - final File root = getContext().getFilesDir(); - if (root == null) { - return; - } - new File(root, "texture_replacements").mkdirs(); - new File(root, "USA/Card A").mkdirs(); - new File(root, "EUR/Card A").mkdirs(); - } - - private boolean isCustomDataPathEnabled() { - if (getContext() == null) { - return false; - } - - final File filesDir = getContext().getFilesDir(); - if (filesDir == null) { - return false; - } - - final File descriptor = new File(filesDir, LOCATION_DESCRIPTOR_NAME); - if (!descriptor.isFile()) { - return false; - } - - try { - final JSONObject json = new JSONObject(readText(descriptor)); - return "custom".equals(json.optString("mode", "default")); - } catch (IOException | JSONException e) { - return false; - } - } - - private static String readText(File file) throws IOException { - try (FileInputStream input = new FileInputStream(file); - ByteArrayOutputStream output = new ByteArrayOutputStream()) - { - byte[] buffer = new byte[4096]; - int bytesRead; - while ((bytesRead = input.read(buffer)) != -1) { - output.write(buffer, 0, bytesRead); - } - return output.toString(StandardCharsets.UTF_8.name()); - } - } - - private static String[] resolveRootProjection(String[] projection) { - return projection != null ? projection : DEFAULT_ROOT_PROJECTION; - } - - private static String[] resolveDocumentProjection(String[] projection) { - return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION; - } - - private static String sanitizeDisplayName(String displayName) throws FileNotFoundException { - if (displayName == null) { - throw new FileNotFoundException("Document name is empty"); - } - - final String sanitized = displayName.trim(); - if (sanitized.isEmpty() || ".".equals(sanitized) || "..".equals(sanitized) || - sanitized.contains("/") || sanitized.contains("\\")) - { - throw new FileNotFoundException("Invalid document name: " + displayName); - } - return sanitized; - } - - private static File buildUniqueFile(File parent, String displayName) { - File file = new File(parent, displayName); - if (!file.exists()) { - return file; - } - - final int dot = displayName.lastIndexOf('.'); - final String baseName = dot > 0 ? displayName.substring(0, dot) : displayName; - final String extension = dot > 0 ? displayName.substring(dot) : ""; - for (int i = 1; i < 100; ++i) { - file = new File(parent, baseName + " (" + i + ")" + extension); - if (!file.exists()) { - return file; - } - } - return new File(parent, baseName + " (" + System.currentTimeMillis() + ")" + extension); - } - - private static int modeToParcelMode(String mode) { - if ("r".equals(mode)) { - return ParcelFileDescriptor.MODE_READ_ONLY; - } - if ("w".equals(mode) || "wt".equals(mode)) { - return ParcelFileDescriptor.MODE_WRITE_ONLY | - ParcelFileDescriptor.MODE_CREATE | - ParcelFileDescriptor.MODE_TRUNCATE; - } - if ("wa".equals(mode)) { - return ParcelFileDescriptor.MODE_WRITE_ONLY | - ParcelFileDescriptor.MODE_CREATE | - ParcelFileDescriptor.MODE_APPEND; - } - if ("rw".equals(mode)) { - return ParcelFileDescriptor.MODE_READ_WRITE | - ParcelFileDescriptor.MODE_CREATE; - } - if ("rwt".equals(mode)) { - return ParcelFileDescriptor.MODE_READ_WRITE | - ParcelFileDescriptor.MODE_CREATE | - ParcelFileDescriptor.MODE_TRUNCATE; - } - return ParcelFileDescriptor.MODE_READ_ONLY; - } - - private static String getMimeType(File file) { - final int dot = file.getName().lastIndexOf('.'); - if (dot >= 0) { - final String extension = file.getName().substring(dot + 1).toLowerCase(); - final String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); - if (mimeType != null) { - return mimeType; - } - } - return "application/octet-stream"; - } - - private Uri getChildDocumentsUri(String parentDocumentId) { - return DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId); - } - - private void notifyChildrenChanged(String parentDocumentId) { - final ContentResolver resolver = getContext().getContentResolver(); - resolver.notifyChange(getChildDocumentsUri(parentDocumentId), null, false); - } - - private void notifyDocumentChanged(String documentId) { - final ContentResolver resolver = getContext().getContentResolver(); - resolver.notifyChange(DocumentsContract.buildDocumentUri(AUTHORITY, documentId), null, false); - } - - private static void deleteRecursively(File file) throws FileNotFoundException { - if (file.isDirectory()) { - final File[] children = file.listFiles(); - if (children != null) { - for (File child : children) { - deleteRecursively(child); - } - } - } - if (!file.delete()) { - throw new FileNotFoundException("Unable to delete document: " + file); - } - } - - private static boolean isInside(File parent, File child) { - try { - final String parentPath = canonicalPath(parent); - final String childPath = canonicalPath(child); - return childPath.equals(parentPath) || childPath.startsWith(parentPath + File.separator); - } catch (FileNotFoundException e) { - return false; - } - } - - private static boolean sameFile(File a, File b) { - try { - return canonicalPath(a).equals(canonicalPath(b)); - } catch (FileNotFoundException e) { - return false; - } - } - - private static String canonicalPath(File file) throws FileNotFoundException { - try { - return file.getCanonicalPath(); - } catch (IOException e) { - throw asFileNotFound("Unable to resolve path", e); - } - } - - private static FileNotFoundException asFileNotFound(String message, IOException cause) { - final FileNotFoundException exception = new FileNotFoundException(message + ": " + cause.getMessage()); - exception.initCause(cause); - return exception; - } -} diff --git a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskHttpClient.java b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskHttpClient.java deleted file mode 100644 index be160d6a2d..0000000000 --- a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskHttpClient.java +++ /dev/null @@ -1,237 +0,0 @@ -package dev.twilitrealm.dusk; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.MalformedURLException; -import java.net.SocketTimeoutException; -import java.net.URL; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import javax.net.ssl.HttpsURLConnection; - -public final class DuskHttpClient { - public static final int ERROR_NONE = 0; - public static final int ERROR_INVALID_URL = 1; - public static final int ERROR_UNSUPPORTED_SCHEME = 2; - public static final int ERROR_TIMEOUT = 3; - public static final int ERROR_TOO_LARGE = 4; - public static final int ERROR_NETWORK = 5; - - private static final int MAX_REDIRECTS = 5; - - public static final class Response { - public int error; - public String message; - public int statusCode; - public String[] headerNames; - public String[] headerValues; - public byte[] body; - - Response(int error, String message, int statusCode, String[] headerNames, - String[] headerValues, byte[] body) { - this.error = error; - this.message = message; - this.statusCode = statusCode; - this.headerNames = headerNames != null ? headerNames : new String[0]; - this.headerValues = headerValues != null ? headerValues : new String[0]; - this.body = body != null ? body : new byte[0]; - } - } - - private DuskHttpClient() { - } - - public static Response get(String url, String[] headerNames, String[] headerValues, - int timeoutMs, long maxBodyBytes) { - if (url == null || url.isEmpty()) { - return fail(ERROR_INVALID_URL, "URL is empty"); - } - - try { - URL currentUrl = new URL(url); - if (!isHttps(currentUrl)) { - return fail(ERROR_UNSUPPORTED_SCHEME, "Only https:// URLs are supported"); - } - - for (int redirect = 0; redirect <= MAX_REDIRECTS; ++redirect) { - HttpsURLConnection connection = - (HttpsURLConnection) currentUrl.openConnection(); - try { - connection.setRequestMethod("GET"); - connection.setConnectTimeout(timeoutMs); - connection.setReadTimeout(timeoutMs); - connection.setUseCaches(false); - connection.setInstanceFollowRedirects(false); - applyHeaders(connection, headerNames, headerValues); - - int statusCode = connection.getResponseCode(); - if (isRedirect(statusCode)) { - String location = connection.getHeaderField("Location"); - if (location == null || location.isEmpty()) { - return fail(ERROR_NETWORK, "Redirect response did not include Location", - statusCode, connection, new byte[0]); - } - - URL nextUrl = new URL(currentUrl, location); - if (!isHttps(nextUrl)) { - return fail(ERROR_UNSUPPORTED_SCHEME, - "Only https:// redirects are supported", statusCode, - connection, new byte[0]); - } - currentUrl = nextUrl; - continue; - } - - byte[] body = readBody(connection, statusCode, maxBodyBytes); - return success(statusCode, connection, body); - } catch (ResponseTooLargeException e) { - return fail(ERROR_TOO_LARGE, "Response body exceeded the configured limit", - safeStatusCode(connection), connection, e.partialBody); - } finally { - connection.disconnect(); - } - } - - return fail(ERROR_NETWORK, "Too many redirects"); - } catch (MalformedURLException e) { - return fail(ERROR_INVALID_URL, "Failed to parse URL"); - } catch (SocketTimeoutException e) { - return fail(ERROR_TIMEOUT, "Request timed out"); - } catch (IOException e) { - String message = e.getMessage(); - return fail(ERROR_NETWORK, message != null ? message : e.toString()); - } catch (ClassCastException e) { - return fail(ERROR_UNSUPPORTED_SCHEME, "Only https:// URLs are supported"); - } - } - - private static void applyHeaders(HttpsURLConnection connection, String[] names, - String[] values) { - if (names == null || values == null) { - return; - } - - int count = Math.min(names.length, values.length); - for (int i = 0; i < count; ++i) { - if (names[i] != null && values[i] != null) { - connection.setRequestProperty(names[i], values[i]); - } - } - } - - private static boolean isHttps(URL url) { - return "https".equalsIgnoreCase(url.getProtocol()); - } - - private static boolean isRedirect(int statusCode) { - return statusCode == HttpURLConnection.HTTP_MOVED_PERM || - statusCode == HttpURLConnection.HTTP_MOVED_TEMP || - statusCode == HttpURLConnection.HTTP_SEE_OTHER || - statusCode == 307 || - statusCode == 308; - } - - private static byte[] readBody(HttpsURLConnection connection, int statusCode, - long maxBodyBytes) throws IOException, - ResponseTooLargeException { - InputStream stream = statusCode >= HttpURLConnection.HTTP_BAD_REQUEST ? - connection.getErrorStream() : connection.getInputStream(); - if (stream == null) { - return new byte[0]; - } - - try (InputStream bodyStream = stream; - ByteArrayOutputStream out = new ByteArrayOutputStream()) { - byte[] buffer = new byte[8192]; - long total = 0; - while (true) { - int read = bodyStream.read(buffer); - if (read < 0) { - return out.toByteArray(); - } - if (read == 0) { - continue; - } - if (read > maxBodyBytes || total > maxBodyBytes - read) { - throw new ResponseTooLargeException(out.toByteArray()); - } - out.write(buffer, 0, read); - total += read; - } - } - } - - private static int safeStatusCode(HttpsURLConnection connection) { - try { - return connection.getResponseCode(); - } catch (IOException e) { - return 0; - } - } - - private static Response success(int statusCode, HttpsURLConnection connection, byte[] body) { - HeaderLists headers = readHeaders(connection); - return new Response(ERROR_NONE, "", statusCode, headers.names, headers.values, body); - } - - private static Response fail(int error, String message) { - return new Response(error, message, 0, null, null, null); - } - - private static Response fail(int error, String message, int statusCode, - HttpsURLConnection connection, byte[] body) { - HeaderLists headers = readHeaders(connection); - return new Response(error, message, statusCode, headers.names, headers.values, body); - } - - private static HeaderLists readHeaders(HttpsURLConnection connection) { - List names = new ArrayList<>(); - List values = new ArrayList<>(); - - Map> headerFields = connection.getHeaderFields(); - if (headerFields == null) { - return new HeaderLists(new String[0], new String[0]); - } - - for (Map.Entry> entry : headerFields.entrySet()) { - String name = entry.getKey(); - if (name == null) { - continue; - } - List entryValues = entry.getValue(); - if (entryValues == null || entryValues.isEmpty()) { - names.add(name); - values.add(""); - continue; - } - for (String value : entryValues) { - names.add(name); - values.add(value != null ? value : ""); - } - } - - return new HeaderLists(names.toArray(new String[0]), values.toArray(new String[0])); - } - - private static final class HeaderLists { - final String[] names; - final String[] values; - - HeaderLists(String[] names, String[] values) { - this.names = names; - this.values = values; - } - } - - private static final class ResponseTooLargeException extends Exception { - final byte[] partialBody; - - ResponseTooLargeException(byte[] partialBody) { - this.partialBody = partialBody; - } - } -} diff --git a/platforms/android/app/src/main/java/org/libsdl/app/HIDDevice.java b/platforms/android/app/src/main/java/org/libsdl/app/HIDDevice.java deleted file mode 100644 index f96095324b..0000000000 --- a/platforms/android/app/src/main/java/org/libsdl/app/HIDDevice.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.libsdl.app; - -import android.hardware.usb.UsbDevice; - -interface HIDDevice -{ - public int getId(); - public int getVendorId(); - public int getProductId(); - public String getSerialNumber(); - public int getVersion(); - public String getManufacturerName(); - public String getProductName(); - public UsbDevice getDevice(); - public boolean open(); - public int writeReport(byte[] report, boolean feature); - public boolean readReport(byte[] report, boolean feature); - public void setFrozen(boolean frozen); - public void close(); - public void shutdown(); -} diff --git a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java deleted file mode 100644 index bcd8806c41..0000000000 --- a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java +++ /dev/null @@ -1,829 +0,0 @@ -package org.libsdl.app; - -import android.content.Context; -import android.bluetooth.BluetoothDevice; -import android.bluetooth.BluetoothGatt; -import android.bluetooth.BluetoothGattCallback; -import android.bluetooth.BluetoothGattCharacteristic; -import android.bluetooth.BluetoothGattDescriptor; -import android.bluetooth.BluetoothManager; -import android.bluetooth.BluetoothProfile; -import android.bluetooth.BluetoothGattService; -import android.hardware.usb.UsbDevice; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; -import android.os.*; - -//import com.android.internal.util.HexDump; - -import java.lang.Runnable; -import java.util.Arrays; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.UUID; - -import java.util.regex.Pattern; -import java.util.regex.Matcher; - -class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice { - - private static final String TAG = "hidapi"; - private HIDDeviceManager mManager; - private BluetoothDevice mDevice; - private int mDeviceId; - private BluetoothGatt mGatt; - private boolean mIsRegistered = false; - private boolean mIsConnected = false; - private boolean mIsChromebook = false; - private boolean mIsReconnecting = false; - private boolean mHasEnabledNotifications = false; - private boolean mHasSeenInputUpdate = false; - private boolean mFrozen = false; - private LinkedList mOperations; - GattOperation mCurrentOperation = null; - private Handler mHandler; - private int mProductId = -1; - private int mReportId = 0; - private UUID mInputCharacteristic; - - private static final int D0G_BLE2_PID = 0x1106; - private static final int TRITON_BLE_PID = 0x1303; - - - private static final int TRANSPORT_AUTO = 0; - private static final int TRANSPORT_BREDR = 1; - private static final int TRANSPORT_LE = 2; - - private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000; - - static final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3"); - static final UUID inputCharacteristicD0G = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); - static final UUID inputCharacteristicTriton_0x45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3"); - static final UUID inputCharacteristicTriton_0x47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3"); - static final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3"); - static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 }; - - private HashMap mOutputReportChars = new HashMap(); - - static class GattOperation { - private enum Operation { - CHR_READ, - CHR_WRITE, - ENABLE_NOTIFICATION - } - - Operation mOp; - UUID mUuid; - byte[] mValue; - BluetoothGatt mGatt; - boolean mResult = true; - int mDelayMs = 0; - - private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) { - mGatt = gatt; - mOp = operation; - mUuid = uuid; - } - - private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, int delayMs) { - mGatt = gatt; - mOp = operation; - mUuid = uuid; - mDelayMs = delayMs; - } - - private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) { - mGatt = gatt; - mOp = operation; - mUuid = uuid; - mValue = value; - } - - private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value, int delayMs) { - mGatt = gatt; - mOp = operation; - mUuid = uuid; - mValue = value; - mDelayMs = delayMs; - } - - public void run() { - // This is executed in main thread - BluetoothGattCharacteristic chr; - - switch (mOp) { - case CHR_READ: - chr = getCharacteristic(mUuid); - //Log.v(TAG, "Reading characteristic " + chr.getUuid()); - if (!mGatt.readCharacteristic(chr)) { - Log.e(TAG, "Unable to read characteristic " + mUuid.toString()); - mResult = false; - break; - } - mResult = true; - break; - case CHR_WRITE: - chr = getCharacteristic(mUuid); - //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value)); - chr.setValue(mValue); - if (!mGatt.writeCharacteristic(chr)) { - Log.e(TAG, "Unable to write characteristic " + mUuid.toString()); - mResult = false; - break; - } - mResult = true; - break; - case ENABLE_NOTIFICATION: - chr = getCharacteristic(mUuid); - //Log.v(TAG, "Writing descriptor of " + chr.getUuid()); - if (chr != null) { - BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); - if (cccd != null) { - int properties = chr.getProperties(); - byte[] value; - if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) { - value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; - } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) { - value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; - } else { - Log.e(TAG, "Unable to start notifications on input characteristic"); - mResult = false; - return; - } - - mGatt.setCharacteristicNotification(chr, true); - cccd.setValue(value); - if (!mGatt.writeDescriptor(cccd)) { - Log.e(TAG, "Unable to write descriptor " + mUuid.toString()); - mResult = false; - return; - } - mResult = true; - } - } - } - } - - public boolean finish() { - return mResult; - } - - public int getDelayMs() { return mDelayMs; } - - private BluetoothGattCharacteristic getCharacteristic(UUID uuid) { - BluetoothGattService valveService = mGatt.getService(steamControllerService); - if (valveService == null) - return null; - return valveService.getCharacteristic(uuid); - } - - static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) { - return new GattOperation(gatt, Operation.CHR_READ, uuid); - } - - static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) { - return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value); - } - - static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) { - return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid); - } - - static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid, int delayMs) { - return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid, delayMs); - } - } - - HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) { - mManager = manager; - mDevice = device; - mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier()); - mIsRegistered = false; - mIsChromebook = SDLActivity.isChromebook(); - mOperations = new LinkedList(); - mHandler = new Handler(Looper.getMainLooper()); - - mGatt = connectGatt(); - mHasEnabledNotifications = false; - mHasSeenInputUpdate = false; - // final HIDDeviceBLESteamController finalThis = this; - // mHandler.postDelayed(new Runnable() { - // @Override - // void run() { - // finalThis.checkConnectionForChromebookIssue(); - // } - // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); - } - - String getIdentifier() { - return String.format("SteamController.%s", mDevice.getAddress()); - } - - BluetoothGatt getGatt() { - return mGatt; - } - - // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead - // of TRANSPORT_LE. Let's force ourselves to connect low energy. - private BluetoothGatt connectGatt(boolean managed) { - if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) { - try { - return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE); - } catch (Exception e) { - return mDevice.connectGatt(mManager.getContext(), managed, this); - } - } else { - return mDevice.connectGatt(mManager.getContext(), managed, this); - } - } - - private BluetoothGatt connectGatt() { - return connectGatt(false); - } - - protected int getConnectionState() { - - Context context = mManager.getContext(); - if (context == null) { - // We are lacking any context to get our Bluetooth information. We'll just assume disconnected. - return BluetoothProfile.STATE_DISCONNECTED; - } - - BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE); - if (btManager == null) { - // This device doesn't support Bluetooth. We should never be here, because how did - // we instantiate a device to start with? - return BluetoothProfile.STATE_DISCONNECTED; - } - - return btManager.getConnectionState(mDevice, BluetoothProfile.GATT); - } - - void reconnect() { - - if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) { - mGatt.disconnect(); - mGatt = connectGatt(); - } - - } - - protected void checkConnectionForChromebookIssue() { - if (!mIsChromebook) { - // We only do this on Chromebooks, because otherwise it's really annoying to just attempt - // over and over. - return; - } - - int connectionState = getConnectionState(); - - switch (connectionState) { - case BluetoothProfile.STATE_CONNECTED: - if (!mIsConnected) { - // We are in the Bad Chromebook Place. We can force a disconnect - // to try to recover. - Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect."); - mIsReconnecting = true; - mGatt.disconnect(); - mGatt = connectGatt(false); - break; - } - else if (!isRegistered()) { - if (mGatt.getServices().size() > 0) { - Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover."); - probeService(this); - } - else { - Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover."); - mIsReconnecting = true; - mGatt.disconnect(); - mGatt = connectGatt(false); - break; - } - } - else { - Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!"); - return; - } - break; - - case BluetoothProfile.STATE_DISCONNECTED: - Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover."); - - mIsReconnecting = true; - mGatt.disconnect(); - mGatt = connectGatt(false); - break; - - case BluetoothProfile.STATE_CONNECTING: - Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer."); - break; - } - - final HIDDeviceBLESteamController finalThis = this; - mHandler.postDelayed(new Runnable() { - @Override - public void run() { - finalThis.checkConnectionForChromebookIssue(); - } - }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); - } - - private boolean isRegistered() { - return mIsRegistered; - } - - private void setRegistered() { - mIsRegistered = true; - } - - private boolean probeService(HIDDeviceBLESteamController controller) { - - if (isRegistered()) { - return true; - } - - if (!mIsConnected) { - return false; - } - - Log.v(TAG, "probeService controller=" + controller); - - for (BluetoothGattService service : mGatt.getServices()) { - if (service.getUuid().equals(steamControllerService)) { - Log.v(TAG, "Found Valve steam controller service " + service.getUuid()); - - for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { - if (chr.getUuid().equals(inputCharacteristicTriton_0x45)) { - Log.v(TAG, "Found Triton input characteristic 0x45"); - mProductId = TRITON_BLE_PID; - mReportId = 0x45; - mInputCharacteristic = chr.getUuid(); - } else if (chr.getUuid().equals(inputCharacteristicTriton_0x47)) { - Log.v(TAG, "Found Triton input characteristic 0x47"); - mProductId = TRITON_BLE_PID; - mReportId = 0x47; - mInputCharacteristic = chr.getUuid(); - } else if (chr.getUuid().equals(inputCharacteristicD0G)) { - Log.v(TAG, "Found D0G input characteristic"); - mProductId = D0G_BLE2_PID; - mReportId = 0x03; - mInputCharacteristic = chr.getUuid(); - } else { - Pattern reportPattern = Pattern.compile("100F6C([0-9A-Z]{2})", Pattern.CASE_INSENSITIVE); - Matcher matcher = reportPattern.matcher(chr.getUuid().toString()); - - if (matcher.find()) { - try { - int reportId = Integer.parseInt(matcher.group(1), 16); - - reportId -= 0x35; - if (reportId >= 0x80) { - // This is a Triton output report characteristic that we need to care about. - Log.v(TAG, "Found Triton output report 0x" + Integer.toString(reportId, 16)); - mOutputReportChars.put(reportId, chr); - } - } - catch (NumberFormatException nfe) { - Log.w(TAG, "Could not parse report characteristic " + chr.getUuid().toString() + ": " + nfe.toString()); - } - } - } - } - - for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { - if (chr.getUuid().equals(mInputCharacteristic)) { - // Start notifications - BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); - if (cccd != null) { - enableNotification(chr.getUuid()); - } - } - } - return true; - } - } - - if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) { - Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us."); - mIsConnected = false; - mIsReconnecting = true; - mGatt.disconnect(); - mGatt = connectGatt(false); - } - - return false; - } - - ////////////////////////////////////////////////////////////////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////////////////////////////// - - private void finishCurrentGattOperation() { - GattOperation op = null; - synchronized (mOperations) { - if (mCurrentOperation != null) { - op = mCurrentOperation; - mCurrentOperation = null; - } - } - if (op != null) { - boolean result = op.finish(); // TODO: Maybe in main thread as well? - - // Our operation failed, let's add it back to the beginning of our queue. - if (!result) { - mOperations.addFirst(op); - } - } - executeNextGattOperation(); - } - - private void executeNextGattOperation() { - synchronized (mOperations) { - if (mCurrentOperation != null) - return; - - if (mOperations.isEmpty()) - return; - - mCurrentOperation = mOperations.removeFirst(); - } - - Runnable gattOperationRunnable = new Runnable() { - @Override - public void run() { - synchronized (mOperations) { - if (mCurrentOperation == null) { - Log.e(TAG, "Current operation null in executor?"); - return; - } - - mCurrentOperation.run(); - // now wait for the GATT callback and when it comes, finish this operation - } - } - }; - - if (mCurrentOperation.getDelayMs() == 0) { - // Run in main thread - mHandler.post(gattOperationRunnable); - } - else { - // If we have a delay on this operation, wait before we post it. - mHandler.postDelayed(gattOperationRunnable, mCurrentOperation.getDelayMs()); - } - - } - - private void queueGattOperation(GattOperation op) { - synchronized (mOperations) { - mOperations.add(op); - } - executeNextGattOperation(); - } - - private void enableNotification(UUID chrUuid) { - // Add a 500ms delay to notification write for Amazon Fire TV devices, as otherwise if we do this too quickly after connecting - // it will return success and then silently drop the operation on the floor. - GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid, 500); - queueGattOperation(op); - - // Amazon Fire devices can also silently timeout on writeDescriptor, so - // set up a little delayed check that will attempt to write a second time. - // - // While this only seems to be needed on Amazon Fire TV devices at present, it - // doesn't hurt to have a retry on other devices as well. - // - final HIDDeviceBLESteamController finalThis = this; - final UUID finalUuid = chrUuid; - mHandler.postDelayed(new Runnable() { - @Override - public void run() { - if (!finalThis.mHasEnabledNotifications) { - - if (finalThis.mHasSeenInputUpdate) { - // Amazon Five devices may have enabled notifications on the input characteristic and not given us a callback. If we've seen - // input reports, though, somewhat by definition notifications are enabled. - Log.w(TAG, "WriteDescriptor has never returned, but we've seen input reports. Moving on with controller initialization."); - finalThis.mHasEnabledNotifications = true; - finalThis.enableValveMode(); - return; - } - - // Give one more try. - GattOperation retry = HIDDeviceBLESteamController.GattOperation.enableNotification(finalThis.mGatt, finalUuid, 500); - finalThis.queueGattOperation(retry); - } - } - }, 1000); - } - - void writeCharacteristic(UUID uuid, byte[] value) { - GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value); - queueGattOperation(op); - } - - void readCharacteristic(UUID uuid) { - GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid); - queueGattOperation(op); - } - - ////////////////////////////////////////////////////////////////////////////////////////////////////// - ////////////// BluetoothGattCallback overridden methods - ////////////////////////////////////////////////////////////////////////////////////////////////////// - - @Override - public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { - //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState); - mIsReconnecting = false; - if (newState == 2) { - mIsConnected = true; - // Run directly, without GattOperation - if (!isRegistered()) { - mHandler.post(new Runnable() { - @Override - public void run() { - mGatt.discoverServices(); - } - }); - } - } - else if (newState == 0) { - mIsConnected = false; - } - - // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent. - } - - @Override - public void onServicesDiscovered(BluetoothGatt gatt, int status) { - //Log.v(TAG, "onServicesDiscovered status=" + status); - if (status == 0) { - if (gatt.getServices().size() == 0) { - Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack."); - mIsReconnecting = true; - mIsConnected = false; - gatt.disconnect(); - mGatt = connectGatt(false); - } else { - if (getProductId() == TRITON_BLE_PID) { - // Android will not properly play well with Data Length Extensions without manually requesting a large MTU, - // and Triton controllers require DLE support. - // - // 517 is basically a "magic number" as far as Android's bluetooth code is concerned, so do not change - // this value. It is functionally "please enable data length extensions" on some Android builds. - mGatt.requestMtu(517); - } - - probeService(this); - } - } - } - - @Override - public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { - //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid()); - - if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) { - mManager.HIDDeviceReportResponse(getId(), characteristic.getValue()); - } - - finishCurrentGattOperation(); - } - - @Override - public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { - //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid()); - - if (characteristic.getUuid().equals(reportCharacteristic)) { - // Only register controller with the native side once it has been fully configured - if (!isRegistered()) { - Log.v(TAG, "Registering Steam Controller with ID: " + getId()); - mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true, mReportId); - setRegistered(); - } - } - - finishCurrentGattOperation(); - } - - @Override - public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { - // Enable this for verbose logging of controller input reports - //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue())); - - if (characteristic.getUuid().equals(mInputCharacteristic) && !mFrozen) { - mHasSeenInputUpdate = true; - mManager.HIDDeviceInputReport(getId(), characteristic.getValue()); - } - } - - @Override - public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { - //Log.v(TAG, "onDescriptorRead status=" + status); - } - - private void enableValveMode() - { - BluetoothGattService valveService = mGatt.getService(steamControllerService); - if (valveService == null) - return; - - BluetoothGattCharacteristic reportChr = valveService.getCharacteristic(reportCharacteristic); - if (reportChr != null) { - if (getProductId() == TRITON_BLE_PID) { - // For Triton we just mark things registered. - Log.v(TAG, "Registering Triton Steam Controller with ID: " + getId()); - mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true, mReportId); - setRegistered(); - } else { - // For the original controller, we need to manually enter Valve mode. - Log.v(TAG, "Writing report characteristic to enter valve mode"); - reportChr.setValue(enterValveMode); - mGatt.writeCharacteristic(reportChr); - } - } - } - - @Override - public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { - BluetoothGattCharacteristic chr = descriptor.getCharacteristic(); - //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid()); - - if (chr.getUuid().equals(mInputCharacteristic)) { - mHasEnabledNotifications = true; - enableValveMode(); - } - - finishCurrentGattOperation(); - } - - @Override - public void onReliableWriteCompleted(BluetoothGatt gatt, int status) { - //Log.v(TAG, "onReliableWriteCompleted status=" + status); - } - - @Override - public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { - //Log.v(TAG, "onReadRemoteRssi status=" + status); - } - - @Override - public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { - //Log.v(TAG, "onMtuChanged status=" + status); - } - - ////////////////////////////////////////////////////////////////////////////////////////////////////// - //////// Public API - ////////////////////////////////////////////////////////////////////////////////////////////////////// - - @Override - public int getId() { - return mDeviceId; - } - - @Override - public int getVendorId() { - // Valve Corporation - final int VALVE_USB_VID = 0x28DE; - return VALVE_USB_VID; - } - - @Override - public int getProductId() { - if (mProductId > 0) { - // We've already set a product ID. - return mProductId; - } - - if (mDevice.getName().startsWith("Steam Ctrl")) { - // We're a newer Triton device - mProductId = TRITON_BLE_PID; - } else { - // We're an OG Steam Controller - mProductId = D0G_BLE2_PID; - } - - return mProductId; - } - - @Override - public String getSerialNumber() { - // This will be read later via feature report by Steam - return "12345"; - } - - @Override - public int getVersion() { - return 0; - } - - @Override - public String getManufacturerName() { - return "Valve Corporation"; - } - - @Override - public String getProductName() { - return "Steam Controller"; - } - - @Override - public UsbDevice getDevice() { - return null; - } - - @Override - public boolean open() { - return true; - } - - @Override - public int writeReport(byte[] report, boolean feature) { - if (!isRegistered()) { - Log.e(TAG, "Attempted writeReport before Steam Controller is registered!"); - if (mIsConnected) { - probeService(this); - } - return -1; - } - - if (feature) { - // We need to skip the first byte, as that doesn't go over the air - byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1); - //Log.v(TAG, "writeFeatureReport " + HexDump.dumpHexString(actual_report)); - writeCharacteristic(reportCharacteristic, actual_report); - return report.length; - } else { - // If we're an original-recipe Steam Controller we just write to the characteristic directly. - if (getProductId() == D0G_BLE2_PID) { - //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report)); - writeCharacteristic(reportCharacteristic, report); - return report.length; - } - - // If we're a Triton, we need to find the correct report characteristic. - if (report.length > 0) { - int reportId = report[0] & 0xFF; - BluetoothGattCharacteristic targetedReportCharacteristic = mOutputReportChars.get(reportId); - if (targetedReportCharacteristic != null) { - byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1); - //Log.v(TAG, "writeOutputReport 0x" + Integer.toString(reportId, 16) + " " + HexDump.dumpHexString(report)); - writeCharacteristic(targetedReportCharacteristic.getUuid(), actual_report); - return report.length; - } else { - Log.w(TAG, "Got report write request for unknown report type 0x" + Integer.toString(reportId, 16)); - } - } - } - - return -1; - } - - @Override - public boolean readReport(byte[] report, boolean feature) { - if (!isRegistered()) { - Log.e(TAG, "Attempted readReport before Steam Controller is registered!"); - if (mIsConnected) { - probeService(this); - } - return false; - } - - if (feature) { - readCharacteristic(reportCharacteristic); - return true; - } else { - // Not implemented - return false; - } - } - - @Override - public void close() { - } - - @Override - public void setFrozen(boolean frozen) { - mFrozen = frozen; - } - - @Override - public void shutdown() { - close(); - - BluetoothGatt g = mGatt; - if (g != null) { - g.disconnect(); - g.close(); - mGatt = null; - } - mManager = null; - mIsRegistered = false; - mIsConnected = false; - mOperations.clear(); - } - -} - diff --git a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java deleted file mode 100644 index 691416c1c9..0000000000 --- a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java +++ /dev/null @@ -1,698 +0,0 @@ -package org.libsdl.app; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.PendingIntent; -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothDevice; -import android.bluetooth.BluetoothManager; -import android.bluetooth.BluetoothProfile; -import android.os.Build; -import android.util.Log; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.hardware.usb.*; -import android.os.Handler; -import android.os.Looper; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; - -public class HIDDeviceManager { - private static final String TAG = "hidapi"; - private static final String ACTION_USB_PERMISSION = "org.libsdl.app.USB_PERMISSION"; - - private static HIDDeviceManager sManager; - private static int sManagerRefCount = 0; - - static public HIDDeviceManager acquire(Context context) { - if (sManagerRefCount == 0) { - sManager = new HIDDeviceManager(context); - } - ++sManagerRefCount; - return sManager; - } - - static public void release(HIDDeviceManager manager) { - if (manager == sManager) { - --sManagerRefCount; - if (sManagerRefCount == 0) { - sManager.close(); - sManager = null; - } - } - } - - private Context mContext; - private HashMap mDevicesById = new HashMap(); - private HashMap mBluetoothDevices = new HashMap(); - private int mNextDeviceId = 0; - private SharedPreferences mSharedPreferences = null; - private boolean mIsChromebook = false; - private UsbManager mUsbManager; - private Handler mHandler; - private BluetoothManager mBluetoothManager; - private List mLastBluetoothDevices; - - private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { - UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); - handleUsbDeviceAttached(usbDevice); - } else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) { - UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); - handleUsbDeviceDetached(usbDevice); - } else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) { - UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); - handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)); - } - } - }; - - private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - // Bluetooth device was connected. If it was a Steam Controller, handle it - if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) { - BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); - Log.d(TAG, "Bluetooth device connected: " + device); - - if (isSteamController(device)) { - connectBluetoothDevice(device); - } - } - - // Bluetooth device was disconnected, remove from controller manager (if any) - if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) { - BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); - Log.d(TAG, "Bluetooth device disconnected: " + device); - - disconnectBluetoothDevice(device); - } - } - }; - - private HIDDeviceManager(final Context context) { - mContext = context; - - HIDDeviceRegisterCallback(); - - mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE); - mIsChromebook = SDLActivity.isChromebook(); - -// if (shouldClear) { -// SharedPreferences.Editor spedit = mSharedPreferences.edit(); -// spedit.clear(); -// spedit.apply(); -// } -// else - { - mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0); - } - } - - Context getContext() { - return mContext; - } - - int getDeviceIDForIdentifier(String identifier) { - SharedPreferences.Editor spedit = mSharedPreferences.edit(); - - int result = mSharedPreferences.getInt(identifier, 0); - if (result == 0) { - result = mNextDeviceId++; - spedit.putInt("next_device_id", mNextDeviceId); - } - - spedit.putInt(identifier, result); - spedit.apply(); - return result; - } - - private void initializeUSB() { - mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE); - if (mUsbManager == null) { - return; - } - - /* - // Logging - for (UsbDevice device : mUsbManager.getDeviceList().values()) { - Log.i(TAG,"Path: " + device.getDeviceName()); - Log.i(TAG,"Manufacturer: " + device.getManufacturerName()); - Log.i(TAG,"Product: " + device.getProductName()); - Log.i(TAG,"ID: " + device.getDeviceId()); - Log.i(TAG,"Class: " + device.getDeviceClass()); - Log.i(TAG,"Protocol: " + device.getDeviceProtocol()); - Log.i(TAG,"Vendor ID " + device.getVendorId()); - Log.i(TAG,"Product ID: " + device.getProductId()); - Log.i(TAG,"Interface count: " + device.getInterfaceCount()); - Log.i(TAG,"---------------------------------------"); - - // Get interface details - for (int index = 0; index < device.getInterfaceCount(); index++) { - UsbInterface mUsbInterface = device.getInterface(index); - Log.i(TAG," ***** *****"); - Log.i(TAG," Interface index: " + index); - Log.i(TAG," Interface ID: " + mUsbInterface.getId()); - Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass()); - Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass()); - Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol()); - Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount()); - - // Get endpoint details - for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++) - { - UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi); - Log.i(TAG," ++++ ++++ ++++"); - Log.i(TAG," Endpoint index: " + epi); - Log.i(TAG," Attributes: " + mEndpoint.getAttributes()); - Log.i(TAG," Direction: " + mEndpoint.getDirection()); - Log.i(TAG," Number: " + mEndpoint.getEndpointNumber()); - Log.i(TAG," Interval: " + mEndpoint.getInterval()); - Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize()); - Log.i(TAG," Type: " + mEndpoint.getType()); - } - } - } - Log.i(TAG," No more devices connected."); - */ - - // Register for USB broadcasts and permission completions - IntentFilter filter = new IntentFilter(); - filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); - filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); - filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION); - if (Build.VERSION.SDK_INT >= 33) { /* Android 13.0 (TIRAMISU) */ - mContext.registerReceiver(mUsbBroadcast, filter, Context.RECEIVER_EXPORTED); - } else { - mContext.registerReceiver(mUsbBroadcast, filter); - } - - for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) { - handleUsbDeviceAttached(usbDevice); - } - } - - UsbManager getUSBManager() { - return mUsbManager; - } - - private void shutdownUSB() { - try { - mContext.unregisterReceiver(mUsbBroadcast); - } catch (Exception e) { - // We may not have registered, that's okay - } - } - - private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) { - if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) { - return true; - } - if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) { - return true; - } - return false; - } - - private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) { - final int XB360_IFACE_SUBCLASS = 93; - final int XB360_IFACE_PROTOCOL = 1; // Wired - final int XB360W_IFACE_PROTOCOL = 129; // Wireless - final int[] SUPPORTED_VENDORS = { - 0x0079, // GPD Win 2 - 0x044f, // Thrustmaster - 0x045e, // Microsoft - 0x046d, // Logitech - 0x056e, // Elecom - 0x06a3, // Saitek - 0x0738, // Mad Catz - 0x07ff, // Mad Catz - 0x0e6f, // PDP - 0x0f0d, // Hori - 0x1038, // SteelSeries - 0x11c9, // Nacon - 0x12ab, // Unknown - 0x1430, // RedOctane - 0x146b, // BigBen - 0x1532, // Razer Sabertooth - 0x15e4, // Numark - 0x162e, // Joytech - 0x1689, // Razer Onza - 0x1949, // Lab126, Inc. - 0x1bad, // Harmonix - 0x20d6, // PowerA - 0x24c6, // PowerA - 0x2c22, // Qanba - 0x2dc8, // 8BitDo - 0x3537, // GameSir - 0x37d7, // Flydigi - 0x9886, // ASTRO Gaming - }; - - if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && - usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS && - (usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL || - usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) { - int vendor_id = usbDevice.getVendorId(); - for (int supportedVid : SUPPORTED_VENDORS) { - if (vendor_id == supportedVid) { - return true; - } - } - } - return false; - } - - private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) { - final int XB1_IFACE_SUBCLASS = 71; - final int XB1_IFACE_PROTOCOL = 208; - final int[] SUPPORTED_VENDORS = { - 0x03f0, // HP - 0x044f, // Thrustmaster - 0x045e, // Microsoft - 0x0738, // Mad Catz - 0x0b05, // ASUS - 0x0e6f, // PDP - 0x0f0d, // Hori - 0x10f5, // Turtle Beach - 0x1532, // Razer Wildcat - 0x20d6, // PowerA - 0x24c6, // PowerA - 0x294b, // Snakebyte - 0x2dc8, // 8BitDo - 0x2e24, // Hyperkin - 0x2e95, // SCUF - 0x3285, // Nacon - 0x3537, // GameSir - 0x366c, // ByoWave - }; - - if (usbInterface.getId() == 0 && - usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && - usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS && - usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) { - int vendor_id = usbDevice.getVendorId(); - for (int supportedVid : SUPPORTED_VENDORS) { - if (vendor_id == supportedVid) { - return true; - } - } - } - return false; - } - - private void handleUsbDeviceAttached(UsbDevice usbDevice) { - connectHIDDeviceUSB(usbDevice); - } - - private void handleUsbDeviceDetached(UsbDevice usbDevice) { - List devices = new ArrayList(); - for (HIDDevice device : mDevicesById.values()) { - if (usbDevice.equals(device.getDevice())) { - devices.add(device.getId()); - } - } - for (int id : devices) { - HIDDevice device = mDevicesById.get(id); - mDevicesById.remove(id); - device.shutdown(); - HIDDeviceDisconnected(id); - } - } - - private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) { - for (HIDDevice device : mDevicesById.values()) { - if (usbDevice.equals(device.getDevice())) { - boolean opened = false; - if (permission_granted) { - opened = device.open(); - } - HIDDeviceOpenResult(device.getId(), opened); - } - } - } - - private void connectHIDDeviceUSB(UsbDevice usbDevice) { - synchronized (this) { - int interface_mask = 0; - for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) { - UsbInterface usbInterface = usbDevice.getInterface(interface_index); - if (isHIDDeviceInterface(usbDevice, usbInterface)) { - // Check to see if we've already added this interface - // This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive - int interface_id = usbInterface.getId(); - if ((interface_mask & (1 << interface_id)) != 0) { - continue; - } - interface_mask |= (1 << interface_id); - - HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index); - int id = device.getId(); - mDevicesById.put(id, device); - HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol(), false, 0); - } - } - } - } - - private void initializeBluetooth() { - Log.d(TAG, "Initializing Bluetooth"); - - if (Build.VERSION.SDK_INT >= 31 /* Android 12 */ && - mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH_CONNECT, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { - Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH_CONNECT"); - return; - } - - if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ && - mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { - Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH"); - return; - } - - if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { - Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE"); - return; - } - - // Find bonded bluetooth controllers and create SteamControllers for them - mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE); - if (mBluetoothManager == null) { - // This device doesn't support Bluetooth. - return; - } - - BluetoothAdapter btAdapter = mBluetoothManager.getAdapter(); - if (btAdapter == null) { - // This device has Bluetooth support in the codebase, but has no available adapters. - return; - } - - // Get our bonded devices. - for (BluetoothDevice device : btAdapter.getBondedDevices()) { - - Log.d(TAG, "Bluetooth device available: " + device); - if (isSteamController(device)) { - connectBluetoothDevice(device); - } - - } - - // NOTE: These don't work on Chromebooks, to my undying dismay. - IntentFilter filter = new IntentFilter(); - filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED); - filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED); - if (Build.VERSION.SDK_INT >= 33) { /* Android 13.0 (TIRAMISU) */ - mContext.registerReceiver(mBluetoothBroadcast, filter, Context.RECEIVER_EXPORTED); - } else { - mContext.registerReceiver(mBluetoothBroadcast, filter); - } - - if (mIsChromebook) { - mHandler = new Handler(Looper.getMainLooper()); - mLastBluetoothDevices = new ArrayList(); - - // final HIDDeviceManager finalThis = this; - // mHandler.postDelayed(new Runnable() { - // @Override - // public void run() { - // finalThis.chromebookConnectionHandler(); - // } - // }, 5000); - } - } - - private void shutdownBluetooth() { - try { - mContext.unregisterReceiver(mBluetoothBroadcast); - } catch (Exception e) { - // We may not have registered, that's okay - } - } - - // Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly. - // This function provides a sort of dummy version of that, watching for changes in the - // connected devices and attempting to add controllers as things change. - void chromebookConnectionHandler() { - if (!mIsChromebook) { - return; - } - - ArrayList disconnected = new ArrayList(); - ArrayList connected = new ArrayList(); - - List currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT); - - for (BluetoothDevice bluetoothDevice : currentConnected) { - if (!mLastBluetoothDevices.contains(bluetoothDevice)) { - connected.add(bluetoothDevice); - } - } - for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) { - if (!currentConnected.contains(bluetoothDevice)) { - disconnected.add(bluetoothDevice); - } - } - - mLastBluetoothDevices = currentConnected; - - for (BluetoothDevice bluetoothDevice : disconnected) { - disconnectBluetoothDevice(bluetoothDevice); - } - for (BluetoothDevice bluetoothDevice : connected) { - connectBluetoothDevice(bluetoothDevice); - } - - final HIDDeviceManager finalThis = this; - mHandler.postDelayed(new Runnable() { - @Override - public void run() { - finalThis.chromebookConnectionHandler(); - } - }, 10000); - } - - boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) { - Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice); - synchronized (this) { - if (mBluetoothDevices.containsKey(bluetoothDevice)) { - Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect"); - - HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); - device.reconnect(); - - return false; - } - HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice); - int id = device.getId(); - mBluetoothDevices.put(bluetoothDevice, device); - mDevicesById.put(id, device); - - // The Steam Controller will mark itself connected once initialization is complete - } - return true; - } - - void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) { - synchronized (this) { - HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); - if (device == null) - return; - - int id = device.getId(); - mBluetoothDevices.remove(bluetoothDevice); - mDevicesById.remove(id); - device.shutdown(); - HIDDeviceDisconnected(id); - } - } - - boolean isSteamController(BluetoothDevice bluetoothDevice) { - // Sanity check. If you pass in a null device, by definition it is never a Steam Controller. - if (bluetoothDevice == null) { - return false; - } - - // If the device has no local name, we really don't want to try an equality check against it. - if (bluetoothDevice.getName() == null) { - return false; - } - - // Steam Controllers will always support Bluetooth Low Energy - if ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) == 0) { - return false; - } - - // Match on the name either the original Steam Controller or the new second-generation one advertise with. - return bluetoothDevice.getName().equals("SteamController") || bluetoothDevice.getName().startsWith("Steam Ctrl"); - } - - private void close() { - shutdownUSB(); - shutdownBluetooth(); - synchronized (this) { - for (HIDDevice device : mDevicesById.values()) { - device.shutdown(); - } - mDevicesById.clear(); - mBluetoothDevices.clear(); - HIDDeviceReleaseCallback(); - } - } - - public void setFrozen(boolean frozen) { - synchronized (this) { - for (HIDDevice device : mDevicesById.values()) { - device.setFrozen(frozen); - } - } - } - - ////////////////////////////////////////////////////////////////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////////////////////////////// - - private HIDDevice getDevice(int id) { - synchronized (this) { - HIDDevice result = mDevicesById.get(id); - if (result == null) { - Log.v(TAG, "No device for id: " + id); - Log.v(TAG, "Available devices: " + mDevicesById.keySet()); - } - return result; - } - } - - ////////////////////////////////////////////////////////////////////////////////////////////////////// - ////////// JNI interface functions - ////////////////////////////////////////////////////////////////////////////////////////////////////// - - boolean initialize(boolean usb, boolean bluetooth) { - Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")"); - - if (usb) { - initializeUSB(); - } - if (bluetooth) { - initializeBluetooth(); - } - return true; - } - - boolean openDevice(int deviceID) { - Log.v(TAG, "openDevice deviceID=" + deviceID); - HIDDevice device = getDevice(deviceID); - if (device == null) { - HIDDeviceDisconnected(deviceID); - return false; - } - - // Look to see if this is a USB device and we have permission to access it - UsbDevice usbDevice = device.getDevice(); - if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) { - HIDDeviceOpenPending(deviceID); - try { - final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31 - int flags; - if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) { - flags = FLAG_MUTABLE; - } else { - flags = 0; - } - - Intent intent = new Intent(HIDDeviceManager.ACTION_USB_PERMISSION); - intent.setPackage(mContext.getPackageName()); - mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, intent, flags)); - } catch (Exception e) { - Log.v(TAG, "Couldn't request permission for USB device " + usbDevice); - HIDDeviceOpenResult(deviceID, false); - } - return false; - } - - try { - return device.open(); - } catch (Exception e) { - Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); - } - return false; - } - - int writeReport(int deviceID, byte[] report, boolean feature) { - try { - //Log.v(TAG, "writeReport deviceID=" + deviceID + " length=" + report.length); - HIDDevice device; - device = getDevice(deviceID); - if (device == null) { - HIDDeviceDisconnected(deviceID); - return -1; - } - - return device.writeReport(report, feature); - } catch (Exception e) { - Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); - } - return -1; - } - - boolean readReport(int deviceID, byte[] report, boolean feature) { - try { - //Log.v(TAG, "readReport deviceID=" + deviceID); - HIDDevice device; - device = getDevice(deviceID); - if (device == null) { - HIDDeviceDisconnected(deviceID); - return false; - } - - return device.readReport(report, feature); - } catch (Exception e) { - Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); - } - return false; - } - - void closeDevice(int deviceID) { - try { - Log.v(TAG, "closeDevice deviceID=" + deviceID); - HIDDevice device; - device = getDevice(deviceID); - if (device == null) { - HIDDeviceDisconnected(deviceID); - return; - } - - device.close(); - } catch (Exception e) { - Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); - } - } - - - ////////////////////////////////////////////////////////////////////////////////////////////////////// - /////////////// Native methods - ////////////////////////////////////////////////////////////////////////////////////////////////////// - - private native void HIDDeviceRegisterCallback(); - private native void HIDDeviceReleaseCallback(); - - native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol, boolean bBluetooth, int reportID); - native void HIDDeviceOpenPending(int deviceID); - native void HIDDeviceOpenResult(int deviceID, boolean opened); - native void HIDDeviceDisconnected(int deviceID); - - native void HIDDeviceInputReport(int deviceID, byte[] report); - native void HIDDeviceReportResponse(int deviceID, byte[] report); -} diff --git a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java deleted file mode 100644 index 8954639733..0000000000 --- a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java +++ /dev/null @@ -1,354 +0,0 @@ -package org.libsdl.app; - -import android.hardware.usb.*; -import android.os.Build; -import android.util.Log; -import java.util.Arrays; -import java.util.Locale; - -class HIDDeviceUSB implements HIDDevice { - - private static final String TAG = "hidapi"; - - protected HIDDeviceManager mManager; - protected UsbDevice mDevice; - protected int mInterfaceIndex; - protected int mInterface; - protected int mDeviceId; - protected UsbDeviceConnection mConnection; - protected UsbEndpoint mInputEndpoint; - protected UsbEndpoint mOutputEndpoint; - protected InputThread mInputThread; - protected boolean mRunning; - protected boolean mFrozen; - protected boolean mClaimed; - - public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) { - mManager = manager; - mDevice = usbDevice; - mInterfaceIndex = interface_index; - mInterface = mDevice.getInterface(mInterfaceIndex).getId(); - mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier()); - mRunning = false; - mClaimed = false; - } - - String getIdentifier() { - return String.format(Locale.ENGLISH, "%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex); - } - - @Override - public int getId() { - return mDeviceId; - } - - @Override - public int getVendorId() { - return mDevice.getVendorId(); - } - - @Override - public int getProductId() { - return mDevice.getProductId(); - } - - @Override - public String getSerialNumber() { - String result = null; - try { - result = mDevice.getSerialNumber(); - } - catch (SecurityException exception) { - //Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage()); - } - if (result == null) { - result = ""; - } - return result; - } - - @Override - public int getVersion() { - return 0; - } - - @Override - public String getManufacturerName() { - String result; - result = mDevice.getManufacturerName(); - if (result == null) { - result = String.format("%x", getVendorId()); - } - return result; - } - - @Override - public String getProductName() { - String result; - result = mDevice.getProductName(); - if (result == null) { - result = String.format("%x", getProductId()); - } - return result; - } - - @Override - public UsbDevice getDevice() { - return mDevice; - } - - String getDeviceName() { - return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")"; - } - - @Override - public boolean open() { - mConnection = mManager.getUSBManager().openDevice(mDevice); - if (mConnection == null) { - Log.w(TAG, "Unable to open USB device " + getDeviceName()); - return false; - } - - // Force claim our interface - UsbInterface iface = mDevice.getInterface(mInterfaceIndex); - if (!mConnection.claimInterface(iface, true)) { - Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName()); - close(); - return false; - } - mClaimed = true; - - // Find the endpoints - for (int j = 0; j < iface.getEndpointCount(); j++) { - UsbEndpoint endpt = iface.getEndpoint(j); - switch (endpt.getDirection()) { - case UsbConstants.USB_DIR_IN: - if (mInputEndpoint == null) { - mInputEndpoint = endpt; - } - break; - case UsbConstants.USB_DIR_OUT: - if (mOutputEndpoint == null) { - mOutputEndpoint = endpt; - } - break; - } - } - - // Make sure the required endpoints were present. The original Steam Controller and the wireless dongle for it do NOT - // actually have -- or require -- output endpoints, so we need to accept only an input one for them or else we'll fall - // back to the Android system gamepad functionality (and lose our paddles et al). - if (mInputEndpoint == null) { - Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName()); - mConnection.releaseInterface(iface); - close(); - return false; - } - - // Start listening for input - mRunning = true; - mInputThread = new InputThread(); - mInputThread.start(); - - return true; - } - - @Override - public int writeReport(byte[] report, boolean feature) { - if (mConnection == null) { - Log.w(TAG, "writeReport() called with no device connection"); - return -1; - } - - if (!mClaimed) { - Log.w(TAG, "writeReport() called but some other process currently owns the USB device"); - return -1; - } - - if (feature) { - int res = -1; - int offset = 0; - int length = report.length; - boolean skipped_report_id = false; - byte report_number = report[0]; - - if (report_number == 0x0) { - ++offset; - --length; - skipped_report_id = true; - } - - res = mConnection.controlTransfer( - UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT, - 0x09/*HID set_report*/, - (3/*HID feature*/ << 8) | report_number, - mInterface, - report, offset, length, - 1000/*timeout millis*/); - - if (res < 0) { - Log.w(TAG, "writeFeatureReport() returned " + res + " on device " + getDeviceName()); - return -1; - } - - if (skipped_report_id) { - ++length; - } - return length; - } else { - if (mOutputEndpoint == null) - { - Log.e(TAG, "Tried to write an output report to an interface with no output endpoint!"); - return -1; - } - int res = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000); - if (res != report.length) { - Log.w(TAG, "writeOutputReport() returned " + res + " on device " + getDeviceName()); - } - return res; - } - } - - @Override - public boolean readReport(byte[] report, boolean feature) { - int res = -1; - int offset = 0; - int length = report.length; - boolean skipped_report_id = false; - byte report_number = report[0]; - - if (mConnection == null) { - Log.w(TAG, "readReport() called with no device connection"); - return false; - } - if (!mClaimed) { - if (feature) { - return false; - } - return true; - } - - if (report_number == 0x0) { - /* Offset the return buffer by 1, so that the report ID - will remain in byte 0. */ - ++offset; - --length; - skipped_report_id = true; - } - - res = mConnection.controlTransfer( - UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN, - 0x01/*HID get_report*/, - ((feature ? 3/*HID feature*/ : 1/*HID Input*/) << 8) | report_number, - mInterface, - report, offset, length, - 1000/*timeout millis*/); - - if (res < 0) { - Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName()); - return false; - } - - if (skipped_report_id) { - ++res; - ++length; - } - - byte[] data; - if (res == length) { - data = report; - } else { - data = Arrays.copyOfRange(report, 0, res); - } - mManager.HIDDeviceReportResponse(mDeviceId, data); - - return true; - } - - @Override - public void close() { - mRunning = false; - if (mInputThread != null) { - while (mInputThread.isAlive()) { - mInputThread.interrupt(); - try { - mInputThread.join(); - } catch (InterruptedException e) { - // Keep trying until we're done - } - } - mInputThread = null; - } - if (mConnection != null) { - if (mClaimed) { - UsbInterface iface = mDevice.getInterface(mInterfaceIndex); - mConnection.releaseInterface(iface); - } - mConnection.close(); - mConnection = null; - mClaimed = false; - } - } - - @Override - public void shutdown() { - close(); - mManager = null; - } - - @Override - public void setFrozen(boolean frozen) { - mFrozen = frozen; - - /* If we have a valid device connection and the claim state doesn't match what we want, try to correct that. */ - if (mConnection != null && mClaimed == mFrozen) { - UsbInterface iface = mDevice.getInterface(mInterfaceIndex); - if (frozen) { - mClaimed = !mConnection.releaseInterface(iface); - if (mClaimed) { - Log.e(TAG, "Tried to release claim on USB device, but failed!"); - } - } else { - mClaimed = mConnection.claimInterface(iface, true); - if (!mClaimed) { - Log.e(TAG, "Tried to regain claim on USB device, but failed!"); - } - } - } - } - - protected class InputThread extends Thread { - @Override - public void run() { - int packetSize = mInputEndpoint.getMaxPacketSize(); - byte[] packet = new byte[packetSize]; - while (mRunning) { - int r; - try - { - r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000); - } - catch (Exception e) - { - Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e); - break; - } - if (r < 0) { - // Could be a timeout or an I/O error - } - if (r > 0) { - byte[] data; - if (r == packetSize) { - data = packet; - } else { - data = Arrays.copyOfRange(packet, 0, r); - } - - if (!mFrozen) { - mManager.HIDDeviceInputReport(mDeviceId, data); - } - } - } - } - } -} diff --git a/platforms/android/app/src/main/java/org/libsdl/app/SDL.java b/platforms/android/app/src/main/java/org/libsdl/app/SDL.java deleted file mode 100644 index d9650a72e4..0000000000 --- a/platforms/android/app/src/main/java/org/libsdl/app/SDL.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.libsdl.app; - -import android.app.Activity; -import android.content.Context; - -import java.lang.reflect.Method; - -/** - SDL library initialization -*/ -public class SDL { - - // This function should be called first and sets up the native code - // so it can call into the Java classes - static public void setupJNI() { - SDLActivity.nativeSetupJNI(); - SDLAudioManager.nativeSetupJNI(); - SDLControllerManager.nativeSetupJNI(); - } - - // This function should be called each time the activity is started - static public void initialize() { - setContext(null); - - SDLActivity.initialize(); - SDLAudioManager.initialize(); - SDLControllerManager.initialize(); - } - - // This function stores the current activity (SDL or not) - static public void setContext(Activity context) { - SDLAudioManager.setContext(context); - mContext = context; - } - - static public Activity getContext() { - return mContext; - } - - static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException { - loadLibrary(libraryName, mContext); - } - - static void loadLibrary(String libraryName, Context context) throws UnsatisfiedLinkError, SecurityException, NullPointerException { - - if (libraryName == null) { - throw new NullPointerException("No library name provided."); - } - - try { - // Let's see if we have ReLinker available in the project. This is necessary for - // some projects that have huge numbers of local libraries bundled, and thus may - // trip a bug in Android's native library loader which ReLinker works around. (If - // loadLibrary works properly, ReLinker will simply use the normal Android method - // internally.) - // - // To use ReLinker, just add it as a dependency. For more information, see - // https://github.com/KeepSafe/ReLinker for ReLinker's repository. - // - Class relinkClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker"); - Class relinkListenerClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener"); - Class contextClass = context.getClassLoader().loadClass("android.content.Context"); - Class stringClass = context.getClassLoader().loadClass("java.lang.String"); - - // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if - // they've changed during updates. - Method forceMethod = relinkClass.getDeclaredMethod("force"); - Object relinkInstance = forceMethod.invoke(null); - Class relinkInstanceClass = relinkInstance.getClass(); - - // Actually load the library! - Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass); - loadMethod.invoke(relinkInstance, context, libraryName, null, null); - } - catch (final Throwable e) { - // Fall back - try { - System.loadLibrary(libraryName); - } - catch (final UnsatisfiedLinkError ule) { - throw ule; - } - catch (final SecurityException se) { - throw se; - } - } - } - - protected static Activity mContext; -} diff --git a/platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java b/platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java deleted file mode 100644 index dcc49852ac..0000000000 --- a/platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java +++ /dev/null @@ -1,2240 +0,0 @@ -package org.libsdl.app; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.Dialog; -import android.app.UiModeManager; -import android.content.ActivityNotFoundException; -import android.content.ClipboardManager; -import android.content.ClipData; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.drawable.Drawable; -import android.hardware.Sensor; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.LocaleList; -import android.os.Message; -import android.os.ParcelFileDescriptor; -import android.util.DisplayMetrics; -import android.util.Log; -import android.util.SparseArray; -import android.view.Display; -import android.view.Gravity; -import android.view.InputDevice; -import android.view.KeyEvent; -import android.view.PointerIcon; -import android.view.Surface; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; -import android.view.inputmethod.InputConnection; -import android.view.inputmethod.InputMethodManager; -import android.webkit.MimeTypeMap; -import android.widget.Button; -import android.widget.LinearLayout; -import android.widget.RelativeLayout; -import android.widget.TextView; -import android.widget.Toast; - -import java.io.FileNotFoundException; -import java.util.ArrayList; -import java.util.Hashtable; -import java.util.Locale; - - -/** - SDL Activity -*/ -public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener { - private static final String TAG = "SDL"; - private static final int SDL_MAJOR_VERSION = 3; - private static final int SDL_MINOR_VERSION = 4; - private static final int SDL_MICRO_VERSION = 10; -/* - // Display InputType.SOURCE/CLASS of events and devices - // - // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]"); - // SDLActivity.debugSource(event.getSource(), "event"); - public static void debugSource(int sources, String prefix) { - int s = sources; - int s_copy = sources; - String cls = ""; - String src = ""; - int tst = 0; - int FLAG_TAINTED = 0x80000000; - - if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0) cls += " BUTTON"; - if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) cls += " JOYSTICK"; - if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0) cls += " POINTER"; - if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0) cls += " POSITION"; - if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) cls += " TRACKBALL"; - - - int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits - s2 &= ~( InputDevice.SOURCE_CLASS_BUTTON - | InputDevice.SOURCE_CLASS_JOYSTICK - | InputDevice.SOURCE_CLASS_POINTER - | InputDevice.SOURCE_CLASS_POSITION - | InputDevice.SOURCE_CLASS_TRACKBALL); - - if (s2 != 0) cls += "Some_Unknown"; - - s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class; - - if (Build.VERSION.SDK_INT >= 23) { - tst = InputDevice.SOURCE_BLUETOOTH_STYLUS; - if ((s & tst) == tst) src += " BLUETOOTH_STYLUS"; - s2 &= ~tst; - } - - tst = InputDevice.SOURCE_DPAD; - if ((s & tst) == tst) src += " DPAD"; - s2 &= ~tst; - - tst = InputDevice.SOURCE_GAMEPAD; - if ((s & tst) == tst) src += " GAMEPAD"; - s2 &= ~tst; - - tst = InputDevice.SOURCE_HDMI; - if ((s & tst) == tst) src += " HDMI"; - s2 &= ~tst; - - tst = InputDevice.SOURCE_JOYSTICK; - if ((s & tst) == tst) src += " JOYSTICK"; - s2 &= ~tst; - - tst = InputDevice.SOURCE_KEYBOARD; - if ((s & tst) == tst) src += " KEYBOARD"; - s2 &= ~tst; - - tst = InputDevice.SOURCE_MOUSE; - if ((s & tst) == tst) src += " MOUSE"; - s2 &= ~tst; - - if (Build.VERSION.SDK_INT >= 26) { - tst = InputDevice.SOURCE_MOUSE_RELATIVE; - if ((s & tst) == tst) src += " MOUSE_RELATIVE"; - s2 &= ~tst; - - tst = InputDevice.SOURCE_ROTARY_ENCODER; - if ((s & tst) == tst) src += " ROTARY_ENCODER"; - s2 &= ~tst; - } - tst = InputDevice.SOURCE_STYLUS; - if ((s & tst) == tst) src += " STYLUS"; - s2 &= ~tst; - - tst = InputDevice.SOURCE_TOUCHPAD; - if ((s & tst) == tst) src += " TOUCHPAD"; - s2 &= ~tst; - - tst = InputDevice.SOURCE_TOUCHSCREEN; - if ((s & tst) == tst) src += " TOUCHSCREEN"; - s2 &= ~tst; - - tst = InputDevice.SOURCE_TOUCH_NAVIGATION; - if ((s & tst) == tst) src += " TOUCH_NAVIGATION"; - s2 &= ~tst; - - tst = InputDevice.SOURCE_TRACKBALL; - if ((s & tst) == tst) src += " TRACKBALL"; - s2 &= ~tst; - - tst = InputDevice.SOURCE_ANY; - if ((s & tst) == tst) src += " ANY"; - s2 &= ~tst; - - if (s == FLAG_TAINTED) src += " FLAG_TAINTED"; - s2 &= ~FLAG_TAINTED; - - if (s2 != 0) src += " Some_Unknown"; - - Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src); - } -*/ - - public static boolean mIsResumedCalled, mHasFocus; - public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */); - - // Cursor types - // private static final int SDL_SYSTEM_CURSOR_NONE = -1; - private static final int SDL_SYSTEM_CURSOR_ARROW = 0; - private static final int SDL_SYSTEM_CURSOR_IBEAM = 1; - private static final int SDL_SYSTEM_CURSOR_WAIT = 2; - private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3; - private static final int SDL_SYSTEM_CURSOR_WAITARROW = 4; - private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5; - private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6; - private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7; - private static final int SDL_SYSTEM_CURSOR_SIZENS = 8; - private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9; - private static final int SDL_SYSTEM_CURSOR_NO = 10; - private static final int SDL_SYSTEM_CURSOR_HAND = 11; - private static final int SDL_SYSTEM_CURSOR_WINDOW_TOPLEFT = 12; - private static final int SDL_SYSTEM_CURSOR_WINDOW_TOP = 13; - private static final int SDL_SYSTEM_CURSOR_WINDOW_TOPRIGHT = 14; - private static final int SDL_SYSTEM_CURSOR_WINDOW_RIGHT = 15; - private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOMRIGHT = 16; - private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOM = 17; - private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOMLEFT = 18; - private static final int SDL_SYSTEM_CURSOR_WINDOW_LEFT = 19; - - protected static final int SDL_ORIENTATION_UNKNOWN = 0; - protected static final int SDL_ORIENTATION_LANDSCAPE = 1; - protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2; - protected static final int SDL_ORIENTATION_PORTRAIT = 3; - protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4; - - protected static int mCurrentRotation; - protected static Locale mCurrentLocale; - - // Handle the state of the native layer - public enum NativeState { - INIT, RESUMED, PAUSED - } - - public static NativeState mNextNativeState; - public static NativeState mCurrentNativeState; - - /** If shared libraries (e.g. SDL or the native application) could not be loaded. */ - public static boolean mBrokenLibraries = true; - - // Main components - protected static SDLActivity mSingleton; - protected static SDLSurface mSurface; - protected static SDLDummyEdit mTextEdit; - protected static ViewGroup mLayout; - protected static SDLClipboardHandler mClipboardHandler; - protected static Hashtable mCursors; - protected static int mLastCursorID; - protected static SDLGenericMotionListener_API14 mMotionListener; - protected static HIDDeviceManager mHIDDeviceManager; - - // This is what SDL runs in. It invokes SDL_main(), eventually - protected static Thread mSDLThread; - protected static boolean mSDLMainFinished = false; - protected static boolean mActivityCreated = false; - private static SDLFileDialogState mFileDialogState = null; - protected static boolean mDispatchingKeyEvent = false; - - public static SDLGenericMotionListener_API14 getMotionListener() { - if (mMotionListener == null) { - if (Build.VERSION.SDK_INT >= 29 /* Android 10 (Q) */) { - mMotionListener = new SDLGenericMotionListener_API29(); - } else if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) { - mMotionListener = new SDLGenericMotionListener_API26(); - } else if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) { - mMotionListener = new SDLGenericMotionListener_API24(); - } else { - mMotionListener = new SDLGenericMotionListener_API14(); - } - } - - return mMotionListener; - } - - /** - * The application entry point, called on a dedicated thread (SDLThread). - * The default implementation uses the getMainSharedObject() and getMainFunction() methods - * to invoke native code from the specified shared library. - * It can be overridden by derived classes. - */ - protected void main() { - String library = SDLActivity.mSingleton.getMainSharedObject(); - String function = SDLActivity.mSingleton.getMainFunction(); - String[] arguments = SDLActivity.mSingleton.getArguments(); - - Log.v("SDL", "Running main function " + function + " from library " + library); - SDLActivity.nativeRunMain(library, function, arguments); - Log.v("SDL", "Finished main function"); - } - - /** - * This method returns the name of the shared object with the application entry point - * It can be overridden by derived classes. - */ - protected String getMainSharedObject() { - String library; - String[] libraries = SDLActivity.mSingleton.getLibraries(); - if (libraries.length > 0) { - library = "lib" + libraries[libraries.length - 1] + ".so"; - } else { - library = "libmain.so"; - } - return getContext().getApplicationInfo().nativeLibraryDir + "/" + library; - } - - /** - * This method returns the name of the application entry point - * It can be overridden by derived classes. - */ - protected String getMainFunction() { - return "SDL_main"; - } - - /** - * This method is called by SDL before loading the native shared libraries. - * It can be overridden to provide names of shared libraries to be loaded. - * The default implementation returns the defaults. It never returns null. - * An array returned by a new implementation must at least contain "SDL3". - * Also keep in mind that the order the libraries are loaded may matter. - * @return names of shared libraries to be loaded (e.g. "SDL3", "main"). - */ - protected String[] getLibraries() { - return new String[] { - "SDL3", - // "SDL3_image", - // "SDL3_mixer", - // "SDL3_net", - // "SDL3_ttf", - "main" - }; - } - - // Load the .so - public void loadLibraries() { - for (String lib : getLibraries()) { - SDL.loadLibrary(lib, this); - } - } - - /** - * This method is called by SDL before starting the native application thread. - * It can be overridden to provide the arguments after the application name. - * The default implementation returns an empty array. It never returns null. - * @return arguments for the native application. - */ - protected String[] getArguments() { - return new String[0]; - } - - public static void initialize() { - // The static nature of the singleton and Android quirkyness force us to initialize everything here - // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values - mSingleton = null; - mSurface = null; - mTextEdit = null; - mLayout = null; - mClipboardHandler = null; - mCursors = new Hashtable(); - mLastCursorID = 0; - mSDLThread = null; - mIsResumedCalled = false; - mHasFocus = true; - mNextNativeState = NativeState.INIT; - mCurrentNativeState = NativeState.INIT; - } - - protected SDLSurface createSDLSurface(Context context) { - return new SDLSurface(context); - } - - // Setup - @Override - protected void onCreate(Bundle savedInstanceState) { - Log.v(TAG, "Manufacturer: " + Build.MANUFACTURER); - Log.v(TAG, "Device: " + Build.DEVICE); - Log.v(TAG, "Model: " + Build.MODEL); - Log.v(TAG, "onCreate()"); - super.onCreate(savedInstanceState); - - - /* Control activity re-creation */ - if (mSDLMainFinished || mActivityCreated) { - boolean allow_recreate = SDLActivity.nativeAllowRecreateActivity(); - if (mSDLMainFinished) { - Log.v(TAG, "SDL main() finished"); - } - if (allow_recreate) { - Log.v(TAG, "activity re-created"); - } else { - Log.v(TAG, "activity finished"); - System.exit(0); - return; - } - } - - mActivityCreated = true; - - try { - Thread.currentThread().setName("SDLActivity"); - } catch (Exception e) { - Log.v(TAG, "modify thread properties failed " + e.toString()); - } - - // Load shared libraries - String errorMsgBrokenLib = ""; - try { - loadLibraries(); - mBrokenLibraries = false; /* success */ - } catch(UnsatisfiedLinkError e) { - System.err.println(e.getMessage()); - mBrokenLibraries = true; - errorMsgBrokenLib = e.getMessage(); - } catch(Exception e) { - System.err.println(e.getMessage()); - mBrokenLibraries = true; - errorMsgBrokenLib = e.getMessage(); - } - - if (!mBrokenLibraries) { - String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." + - String.valueOf(SDL_MINOR_VERSION) + "." + - String.valueOf(SDL_MICRO_VERSION); - String version = nativeGetVersion(); - if (!version.equals(expected_version)) { - mBrokenLibraries = true; - errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")"; - } - } - - if (mBrokenLibraries) { - mSingleton = this; - AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this); - dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall." - + System.getProperty("line.separator") - + System.getProperty("line.separator") - + "Error: " + errorMsgBrokenLib); - dlgAlert.setTitle("SDL Error"); - dlgAlert.setPositiveButton("Exit", - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog,int id) { - // if this button is clicked, close current activity - SDLActivity.mSingleton.finish(); - } - }); - dlgAlert.setCancelable(false); - dlgAlert.create().show(); - - return; - } - - - /* Control activity re-creation */ - /* Robustness: check that the native code is run for the first time. - * (Maybe Activity was reset, but not the native code.) */ - { - int run_count = SDLActivity.nativeCheckSDLThreadCounter(); /* get and increment a native counter */ - if (run_count != 0) { - boolean allow_recreate = SDLActivity.nativeAllowRecreateActivity(); - if (allow_recreate) { - Log.v(TAG, "activity re-created // run_count: " + run_count); - } else { - Log.v(TAG, "activity finished // run_count: " + run_count); - System.exit(0); - return; - } - } - } - - // Set up JNI - SDL.setupJNI(); - - // Initialize state - SDL.initialize(); - - // So we can call stuff from static callbacks - mSingleton = this; - SDL.setContext(this); - - mClipboardHandler = new SDLClipboardHandler(); - - mHIDDeviceManager = HIDDeviceManager.acquire(this); - - // Set up the surface - mSurface = createSDLSurface(this); - - mLayout = new RelativeLayout(this); - mLayout.addView(mSurface); - - // Get our current screen orientation and pass it down. - SDLActivity.nativeSetNaturalOrientation(SDLActivity.getNaturalOrientation()); - mCurrentRotation = SDLActivity.getCurrentRotation(); - SDLActivity.onNativeRotationChanged(mCurrentRotation); - - try { - if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { - mCurrentLocale = getContext().getResources().getConfiguration().locale; - } else { - mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0); - } - } catch(Exception ignored) { - } - - switch (getContext().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) { - case Configuration.UI_MODE_NIGHT_NO: - SDLActivity.onNativeDarkModeChanged(false); - break; - case Configuration.UI_MODE_NIGHT_YES: - SDLActivity.onNativeDarkModeChanged(true); - break; - } - - setContentView(mLayout); - - setWindowStyle(false); - - getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this); - - // Get filename from "Open with" of another application - Intent intent = getIntent(); - if (intent != null && intent.getData() != null) { - String filename = intent.getData().getPath(); - if (filename != null) { - Log.v(TAG, "Got filename: " + filename); - SDLActivity.onNativeDropFile(filename); - } - } - } - - protected void pauseNativeThread() { - mNextNativeState = NativeState.PAUSED; - mIsResumedCalled = false; - - if (SDLActivity.mBrokenLibraries) { - return; - } - - SDLActivity.handleNativeState(); - } - - protected void resumeNativeThread() { - mNextNativeState = NativeState.RESUMED; - mIsResumedCalled = true; - - if (SDLActivity.mBrokenLibraries) { - return; - } - - SDLActivity.handleNativeState(); - } - - // Events - @Override - protected void onPause() { - Log.v(TAG, "onPause()"); - super.onPause(); - - if (mHIDDeviceManager != null) { - mHIDDeviceManager.setFrozen(true); - } - - if (!mHasMultiWindow) { - pauseNativeThread(); - } - } - - @Override - protected void onResume() { - Log.v(TAG, "onResume()"); - super.onResume(); - - if (mHIDDeviceManager != null) { - mHIDDeviceManager.setFrozen(false); - } - - if (!mHasMultiWindow) { - resumeNativeThread(); - } - } - - @Override - protected void onStop() { - Log.v(TAG, "onStop()"); - super.onStop(); - if (mHasMultiWindow) { - pauseNativeThread(); - } - } - - @Override - protected void onStart() { - Log.v(TAG, "onStart()"); - super.onStart(); - if (mHasMultiWindow) { - resumeNativeThread(); - } - } - - public static int getNaturalOrientation() { - int result = SDL_ORIENTATION_UNKNOWN; - - Activity activity = getContext(); - if (activity != null) { - Configuration config = activity.getResources().getConfiguration(); - Display display = activity.getWindowManager().getDefaultDisplay(); - int rotation = display.getRotation(); - if (((rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) && - config.orientation == Configuration.ORIENTATION_LANDSCAPE) || - ((rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) && - config.orientation == Configuration.ORIENTATION_PORTRAIT)) { - result = SDL_ORIENTATION_LANDSCAPE; - } else { - result = SDL_ORIENTATION_PORTRAIT; - } - } - return result; - } - - public static int getCurrentRotation() { - int result = 0; - - Activity activity = getContext(); - if (activity != null) { - Display display = activity.getWindowManager().getDefaultDisplay(); - switch (display.getRotation()) { - case Surface.ROTATION_0: - result = 0; - break; - case Surface.ROTATION_90: - result = 90; - break; - case Surface.ROTATION_180: - result = 180; - break; - case Surface.ROTATION_270: - result = 270; - break; - } - } - return result; - } - - @Override - public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - Log.v(TAG, "onWindowFocusChanged(): " + hasFocus); - - // If we are gaining focus, we can always try to restore our USB devices. If we are losing focus, - // only try to relinquish them if we don't have background events allowed (for multi-window Android setups). - if (hasFocus || !SDLActivity.nativeGetHintBoolean("SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS", false)) { - if (mHIDDeviceManager != null) { - mHIDDeviceManager.setFrozen(!hasFocus); - } - } - - if (SDLActivity.mBrokenLibraries) { - return; - } - - mHasFocus = hasFocus; - if (hasFocus) { - mNextNativeState = NativeState.RESUMED; - SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded(); - - SDLActivity.handleNativeState(); - nativeFocusChanged(true); - - } else { - nativeFocusChanged(false); - if (!mHasMultiWindow) { - mNextNativeState = NativeState.PAUSED; - SDLActivity.handleNativeState(); - } - } - } - - @Override - public void onTrimMemory(int level) { - Log.v(TAG, "onTrimMemory()"); - super.onTrimMemory(level); - - if (SDLActivity.mBrokenLibraries) { - return; - } - - SDLActivity.nativeLowMemory(); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - Log.v(TAG, "onConfigurationChanged()"); - super.onConfigurationChanged(newConfig); - - if (SDLActivity.mBrokenLibraries) { - return; - } - - if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) { - mCurrentLocale = newConfig.locale; - SDLActivity.onNativeLocaleChanged(); - } - - switch (newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) { - case Configuration.UI_MODE_NIGHT_NO: - SDLActivity.onNativeDarkModeChanged(false); - break; - case Configuration.UI_MODE_NIGHT_YES: - SDLActivity.onNativeDarkModeChanged(true); - break; - } - } - - @Override - protected void onDestroy() { - Log.v(TAG, "onDestroy()"); - - if (mHIDDeviceManager != null) { - HIDDeviceManager.release(mHIDDeviceManager); - mHIDDeviceManager = null; - } - - SDLAudioManager.release(this); - - if (SDLActivity.mBrokenLibraries) { - super.onDestroy(); - return; - } - - if (SDLActivity.mSDLThread != null) { - - // Send Quit event to "SDLThread" thread - SDLActivity.nativeSendQuit(); - - // Wait for "SDLThread" thread to end - try { - // Use a timeout because: - // C SDLmain() thread might have started (mSDLThread.start() called) - // while the SDL_Init() might not have been called yet, - // and so the previous QUIT event will be discarded by SDL_Init() and app is running, not exiting. - SDLActivity.mSDLThread.join(1000); - } catch(Exception e) { - Log.v(TAG, "Problem stopping SDLThread: " + e); - } - } - - SDLActivity.nativeQuit(); - - super.onDestroy(); - } - - @Override - public void onBackPressed() { - // Check if we want to block the back button in case of mouse right click. - // - // If we do, the normal hardware back button will no longer work and people have to use home, - // but the mouse right click will work. - // - boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false); - if (trapBack) { - // Exit and let the mouse handler handle this button (if appropriate) - return; - } - - // Default system back button behavior. - if (!isFinishing()) { - super.onBackPressed(); - } - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - - if (mFileDialogState != null && mFileDialogState.requestCode == requestCode) { - /* This is our file dialog */ - String[] filelist = null; - - if (data != null) { - Uri singleFileUri = data.getData(); - - if (singleFileUri == null) { - /* Use Intent.getClipData to get multiple choices */ - ClipData clipData = data.getClipData(); - assert clipData != null; - - filelist = new String[clipData.getItemCount()]; - - for (int i = 0; i < filelist.length; i++) { - String uri = clipData.getItemAt(i).getUri().toString(); - filelist[i] = uri; - } - } else { - /* Only one file is selected. */ - filelist = new String[]{singleFileUri.toString()}; - } - } else { - /* User cancelled the request. */ - filelist = new String[0]; - } - - // TODO: Detect the file MIME type and pass the filter value accordingly. - SDLActivity.onNativeFileDialog(requestCode, filelist, -1); - mFileDialogState = null; - } - } - - // Called by JNI from SDL. - public static void manualBackButton() { - mSingleton.pressBackButton(); - } - - // Used to get us onto the activity's main thread - public void pressBackButton() { - runOnUiThread(new Runnable() { - @Override - public void run() { - if (!SDLActivity.this.isFinishing()) { - SDLActivity.this.superOnBackPressed(); - } - } - }); - } - - // Used to access the system back behavior. - public void superOnBackPressed() { - super.onBackPressed(); - } - - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - - if (SDLActivity.mBrokenLibraries) { - return false; - } - - int keyCode = event.getKeyCode(); - // Ignore certain special keys so they're handled by Android - if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || - keyCode == KeyEvent.KEYCODE_VOLUME_UP || - keyCode == KeyEvent.KEYCODE_CAMERA || - keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */ - keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */ - ) { - return false; - } - mDispatchingKeyEvent = true; - boolean result = super.dispatchKeyEvent(event); - mDispatchingKeyEvent = false; - return result; - } - - public static boolean dispatchingKeyEvent() { - return mDispatchingKeyEvent; - } - - /* Transition to next state */ - public static void handleNativeState() { - - if (mNextNativeState == mCurrentNativeState) { - // Already in same state, discard. - return; - } - - // Try a transition to init state - if (mNextNativeState == NativeState.INIT) { - - mCurrentNativeState = mNextNativeState; - return; - } - - // Try a transition to paused state - if (mNextNativeState == NativeState.PAUSED) { - if (mSDLThread != null) { - nativePause(); - } - if (mSurface != null) { - mSurface.handlePause(); - } - mCurrentNativeState = mNextNativeState; - return; - } - - // Try a transition to resumed state - if (mNextNativeState == NativeState.RESUMED) { - if (mSurface.mIsSurfaceReady && (mHasFocus || mHasMultiWindow) && mIsResumedCalled) { - if (mSDLThread == null) { - // This is the entry point to the C app. - // Start up the C app thread and enable sensor input for the first time - // FIXME: Why aren't we enabling sensor input at start? - - mSDLThread = new Thread(new SDLMain(), "SDLThread"); - mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true); - mSDLThread.start(); - - // No nativeResume(), don't signal Android_ResumeSem - } else { - nativeResume(); - } - mSurface.handleResume(); - - mCurrentNativeState = mNextNativeState; - } - } - } - - // Messages from the SDLMain thread - protected static final int COMMAND_CHANGE_TITLE = 1; - protected static final int COMMAND_CHANGE_WINDOW_STYLE = 2; - protected static final int COMMAND_TEXTEDIT_HIDE = 3; - protected static final int COMMAND_SET_KEEP_SCREEN_ON = 5; - protected static final int COMMAND_USER = 0x8000; - - protected static boolean mFullscreenModeActive; - - /** - * This method is called by SDL if SDL did not handle a message itself. - * This happens if a received message contains an unsupported command. - * Method can be overwritten to handle Messages in a different class. - * @param command the command of the message. - * @param param the parameter of the message. May be null. - * @return if the message was handled in overridden method. - */ - protected boolean onUnhandledMessage(int command, Object param) { - return false; - } - - /** - * A Handler class for Messages from native SDL applications. - * It uses current Activities as target (e.g. for the title). - * static to prevent implicit references to enclosing object. - */ - protected static class SDLCommandHandler extends Handler { - @Override - public void handleMessage(Message msg) { - Context context = getContext(); - if (context == null) { - Log.e(TAG, "error handling message, getContext() returned null"); - return; - } - switch (msg.arg1) { - case COMMAND_CHANGE_TITLE: - if (context instanceof Activity) { - ((Activity) context).setTitle((String)msg.obj); - } else { - Log.e(TAG, "error handling message, getContext() returned no Activity"); - } - break; - case COMMAND_CHANGE_WINDOW_STYLE: - if (context instanceof Activity) { - Window window = ((Activity) context).getWindow(); - if (window != null) { - if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { - int flags = View.SYSTEM_UI_FLAG_FULLSCREEN | - View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE; - window.getDecorView().setSystemUiVisibility(flags); - window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); - SDLActivity.mFullscreenModeActive = true; - } else { - int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; - window.getDecorView().setSystemUiVisibility(flags); - window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); - window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - SDLActivity.mFullscreenModeActive = false; - } - if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */) { - window.getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; - } - if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */ && - Build.VERSION.SDK_INT < 35 /* Android 15 */) { - SDLActivity.onNativeInsetsChanged(0, 0, 0, 0); - } - } - } else { - Log.e(TAG, "error handling message, getContext() returned no Activity"); - } - break; - case COMMAND_TEXTEDIT_HIDE: - if (mTextEdit != null) { - // Note: On some devices setting view to GONE creates a flicker in landscape. - // Setting the View's sizes to 0 is similar to GONE but without the flicker. - // The sizes will be set to useful values when the keyboard is shown again. - mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0)); - - InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); - imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0); - - onNativeScreenKeyboardHidden(); - - mSurface.requestFocus(); - } - break; - case COMMAND_SET_KEEP_SCREEN_ON: - { - if (context instanceof Activity) { - Window window = ((Activity) context).getWindow(); - if (window != null) { - if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } - } - } - break; - } - default: - if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) { - Log.e(TAG, "error handling message, command is " + msg.arg1); - } - } - } - } - - // Handler for the messages - Handler commandHandler = new SDLCommandHandler(); - - // Send a message from the SDLMain thread - protected boolean sendCommand(int command, Object data) { - Message msg = commandHandler.obtainMessage(); - msg.arg1 = command; - msg.obj = data; - boolean result = commandHandler.sendMessage(msg); - - if (command == COMMAND_CHANGE_WINDOW_STYLE) { - // Ensure we don't return until the resize has actually happened, - // or 500ms have passed. - - boolean bShouldWait = false; - - if (data instanceof Integer) { - // Let's figure out if we're already laid out fullscreen or not. - Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); - DisplayMetrics realMetrics = new DisplayMetrics(); - display.getRealMetrics(realMetrics); - - boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) && - (realMetrics.heightPixels == mSurface.getHeight())); - - if ((Integer) data == 1) { - // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going - // to change size and should wait for surfaceChanged() before we return, so the size - // is right back in native code. If we're already laid out fullscreen, though, we're - // not going to change size even if we change decor modes, so we shouldn't wait for - // surfaceChanged() -- which may not even happen -- and should return immediately. - bShouldWait = !bFullscreenLayout; - } else { - // If we're laid out fullscreen (even if the status bar and nav bar are present), - // or are actively in fullscreen, we're going to change size and should wait for - // surfaceChanged before we return, so the size is right back in native code. - bShouldWait = bFullscreenLayout; - } - } - - if (bShouldWait && (getContext() != null)) { - // We'll wait for the surfaceChanged() method, which will notify us - // when called. That way, we know our current size is really the - // size we need, instead of grabbing a size that's still got - // the navigation and/or status bars before they're hidden. - // - // We'll wait for up to half a second, because some devices - // take a surprisingly long time for the surface resize, but - // then we'll just give up and return. - // - synchronized (getContext()) { - try { - getContext().wait(500); - } catch (InterruptedException ie) { - ie.printStackTrace(); - } - } - } - } - - return result; - } - - // C functions we call - public static native String nativeGetVersion(); - public static native void nativeSetupJNI(); - public static native void nativeInitMainThread(); - public static native void nativeCleanupMainThread(); - public static native int nativeRunMain(String library, String function, Object arguments); - public static native void nativeLowMemory(); - public static native void nativeSendQuit(); - public static native void nativeQuit(); - public static native void nativePause(); - public static native void nativeResume(); - public static native void nativeFocusChanged(boolean hasFocus); - public static native void onNativeDropFile(String filename); - public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float density, float rate); - public static native void onNativeResize(); - public static native void onNativeKeyDown(int keycode); - public static native void onNativeKeyUp(int keycode); - public static native boolean onNativeSoftReturnKey(); - public static native void onNativeKeyboardFocusLost(); - public static native void onNativeMouse(int button, int action, float x, float y, boolean relative); - public static native void onNativeTouch(int touchDevId, int pointerFingerId, - int action, float x, - float y, float p); - public static native void onNativePen(int penId, int device_type, int button, int action, float x, float y, float p); - public static native void onNativeAccel(float x, float y, float z); - public static native void onNativeClipboardChanged(); - public static native void onNativeSurfaceCreated(); - public static native void onNativeSurfaceChanged(); - public static native void onNativeSurfaceDestroyed(); - public static native void onNativeScreenKeyboardShown(); - public static native void onNativeScreenKeyboardHidden(); - public static native String nativeGetHint(String name); - public static native boolean nativeGetHintBoolean(String name, boolean default_value); - public static native void nativeSetenv(String name, String value); - public static native void nativeSetNaturalOrientation(int orientation); - public static native void onNativeRotationChanged(int rotation); - public static native void onNativeInsetsChanged(int left, int right, int top, int bottom); - public static native void nativeAddTouch(int touchId, String name); - public static native void nativePermissionResult(int requestCode, boolean result); - public static native void onNativeLocaleChanged(); - public static native void onNativeDarkModeChanged(boolean enabled); - public static native boolean nativeAllowRecreateActivity(); - public static native int nativeCheckSDLThreadCounter(); - public static native void onNativeFileDialog(int requestCode, String[] filelist, int filter); - public static native void onNativePinchStart(); - public static native void onNativePinchUpdate(float scale); - public static native void onNativePinchEnd(); - - /** - * This method is called by SDL using JNI. - */ - public static boolean setActivityTitle(String title) { - // Called from SDLMain() thread and can't directly affect the view - return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title); - } - - /** - * This method is called by SDL using JNI. - */ - public static void setWindowStyle(boolean fullscreen) { - // Called from SDLMain() thread and can't directly affect the view - mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0); - } - - /** - * This method is called by SDL using JNI. - * This is a static method for JNI convenience, it calls a non-static method - * so that is can be overridden - */ - public static void setOrientation(int w, int h, boolean resizable, String hint) - { - if (mSingleton != null) { - mSingleton.setOrientationBis(w, h, resizable, hint); - } - } - - /** - * This can be overridden - */ - public void setOrientationBis(int w, int h, boolean resizable, String hint) - { - int orientation_landscape = -1; - int orientation_portrait = -1; - - if (w <= 1 || h <= 1) { - // Invalid width/height, ignore this request - return; - } - - /* If set, hint "explicitly controls which UI orientations are allowed". */ - if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) { - orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE; - } else if (hint.contains("LandscapeLeft")) { - orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; - } else if (hint.contains("LandscapeRight")) { - orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; - } - - /* exact match to 'Portrait' to distinguish with PortraitUpsideDown */ - boolean contains_Portrait = hint.contains("Portrait ") || hint.endsWith("Portrait"); - - if (contains_Portrait && hint.contains("PortraitUpsideDown")) { - orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT; - } else if (contains_Portrait) { - orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; - } else if (hint.contains("PortraitUpsideDown")) { - orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; - } - - boolean is_landscape_allowed = (orientation_landscape != -1); - boolean is_portrait_allowed = (orientation_portrait != -1); - int req; /* Requested orientation */ - - /* No valid hint, nothing is explicitly allowed */ - if (!is_portrait_allowed && !is_landscape_allowed) { - if (resizable) { - /* All orientations are allowed, respecting user orientation lock setting */ - req = ActivityInfo.SCREEN_ORIENTATION_FULL_USER; - } else { - /* Fixed window and nothing specified. Get orientation from w/h of created window */ - req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); - } - } else { - /* At least one orientation is allowed */ - if (resizable) { - if (is_portrait_allowed && is_landscape_allowed) { - /* hint allows both landscape and portrait, promote to full user */ - req = ActivityInfo.SCREEN_ORIENTATION_FULL_USER; - } else { - /* Use the only one allowed "orientation" */ - req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); - } - } else { - /* Fixed window and both orientations are allowed. Choose one. */ - if (is_portrait_allowed && is_landscape_allowed) { - req = (w > h ? orientation_landscape : orientation_portrait); - } else { - /* Use the only one allowed "orientation" */ - req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); - } - } - } - - Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint); - mSingleton.setRequestedOrientation(req); - } - - /** - * This method is called by SDL using JNI. - */ - public static void minimizeWindow() { - - if (mSingleton == null) { - return; - } - - Intent startMain = new Intent(Intent.ACTION_MAIN); - startMain.addCategory(Intent.CATEGORY_HOME); - startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - mSingleton.startActivity(startMain); - } - - /** - * This method is called by SDL using JNI. - */ - public static boolean shouldMinimizeOnFocusLoss() { - return false; - } - - /** - * This method is called by SDL using JNI. - */ - public static boolean supportsRelativeMouse() - { - // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under - // Android 7 APIs, and simply returns no data under Android 8 APIs. - // - // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and - // thus SDK version 27. If we are in DeX mode and not API 27 or higher, as a result, - // we should stick to relative mode. - // - if (Build.VERSION.SDK_INT < 27 /* Android 8.1 (O_MR1) */ && isDeXMode()) { - return false; - } - - return SDLActivity.getMotionListener().supportsRelativeMouse(); - } - - /** - * This method is called by SDL using JNI. - */ - public static boolean setRelativeMouseEnabled(boolean enabled) - { - if (enabled && !supportsRelativeMouse()) { - return false; - } - - return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled); - } - - /** - * This method is called by SDL using JNI. - */ - public static boolean sendMessage(int command, int param) { - if (mSingleton == null) { - return false; - } - return mSingleton.sendCommand(command, param); - } - - /** - * This method is called by SDL using JNI. - */ - public static Activity getContext() { - return SDL.getContext(); - } - - /** - * This method is called by SDL using JNI. - */ - public static boolean isAndroidTV() { - UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE); - if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { - return true; - } - if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) { - return true; - } - if (Build.MANUFACTURER.equals("Amlogic") && - (Build.MODEL.startsWith("TV") || - Build.MODEL.equals("X96-W") || - Build.MODEL.equals("A95X-R1"))) { - return true; - } - return false; - } - - public static boolean isVRHeadset() { - if (Build.MANUFACTURER.equals("Oculus") && Build.MODEL.startsWith("Quest")) { - return true; - } - if (Build.MANUFACTURER.equals("Pico")) { - return true; - } - return false; - } - - public static double getDiagonal() - { - DisplayMetrics metrics = new DisplayMetrics(); - Activity activity = getContext(); - if (activity == null) { - return 0.0; - } - activity.getWindowManager().getDefaultDisplay().getMetrics(metrics); - - double dWidthInches = metrics.widthPixels / (double)metrics.xdpi; - double dHeightInches = metrics.heightPixels / (double)metrics.ydpi; - - return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches)); - } - - /** - * This method is called by SDL using JNI. - */ - public static boolean isTablet() { - // If our diagonal size is seven inches or greater, we consider ourselves a tablet. - return (getDiagonal() >= 7.0); - } - - /** - * This method is called by SDL using JNI. - */ - public static boolean isChromebook() { - // https://stackoverflow.com/questions/39784415/how-to-detect-programmatically-if-android-app-is-running-in-chrome-book-or-in - if (getContext() != null) { - if (getContext().getPackageManager().hasSystemFeature("org.chromium.arc") - || getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management")) { - return true; - } - } - - // Running on AVD emulator - boolean isChromebookEmulator = (Build.MODEL != null && Build.MODEL.startsWith("sdk_gpc_")); - return isChromebookEmulator; - } - - /** - * This method is called by SDL using JNI. - */ - public static boolean isDeXMode() { - if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { - return false; - } - try { - final Configuration config = getContext().getResources().getConfiguration(); - final Class configClass = config.getClass(); - return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass) - == configClass.getField("semDesktopModeEnabled").getInt(config); - } catch(Exception ignored) { - return false; - } - } - - /** - * This method is called by SDL using JNI. - */ - public static boolean getManifestEnvironmentVariables() { - try { - if (getContext() == null) { - return false; - } - - ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA); - Bundle bundle = applicationInfo.metaData; - if (bundle == null) { - return false; - } - String prefix = "SDL_ENV."; - final int trimLength = prefix.length(); - for (String key : bundle.keySet()) { - if (key.startsWith(prefix)) { - String name = key.substring(trimLength); - String value = bundle.get(key).toString(); - nativeSetenv(name, value); - } - } - /* environment variables set! */ - return true; - } catch (Exception e) { - Log.v(TAG, "exception " + e.toString()); - } - return false; - } - - // This method is called by SDLControllerManager's API 26 Generic Motion Handler. - public static View getContentView() { - return mLayout; - } - - static class ShowTextInputTask implements Runnable { - /* - * This is used to regulate the pan&scan method to have some offset from - * the bottom edge of the input region and the top edge of an input - * method (soft keyboard) - */ - static final int HEIGHT_PADDING = 15; - - public int input_type; - public int x, y, w, h; - - public ShowTextInputTask(int input_type, int x, int y, int w, int h) { - this.input_type = input_type; - this.x = x; - this.y = y; - this.w = w; - this.h = h; - - /* Minimum size of 1 pixel, so it takes focus. */ - if (this.w <= 0) { - this.w = 1; - } - if (this.h + HEIGHT_PADDING <= 0) { - this.h = 1 - HEIGHT_PADDING; - } - } - - @Override - public void run() { - RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING); - params.leftMargin = x; - params.topMargin = y; - - if (mTextEdit == null) { - mTextEdit = new SDLDummyEdit(getContext()); - - mLayout.addView(mTextEdit, params); - } else { - mTextEdit.setLayoutParams(params); - } - mTextEdit.setInputType(input_type); - - mTextEdit.setVisibility(View.VISIBLE); - mTextEdit.requestFocus(); - - InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - imm.showSoftInput(mTextEdit, 0); - - if (imm.isAcceptingText()) { - onNativeScreenKeyboardShown(); - } - } - } - - /** - * This method is called by SDL using JNI. - */ - public static boolean showTextInput(int input_type, int x, int y, int w, int h) { - // Transfer the task to the main thread as a Runnable - return mSingleton.commandHandler.post(new ShowTextInputTask(input_type, x, y, w, h)); - } - - public static boolean isTextInputEvent(KeyEvent event) { - - // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT - if (event.isCtrlPressed()) { - return false; - } - - return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE; - } - - public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) { - int deviceId = event.getDeviceId(); - int source = event.getSource(); - - if (source == InputDevice.SOURCE_UNKNOWN) { - InputDevice device = InputDevice.getDevice(deviceId); - if (device != null) { - source = device.getSources(); - } - } - -// if (event.getAction() == KeyEvent.ACTION_DOWN) { -// Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); -// } else if (event.getAction() == KeyEvent.ACTION_UP) { -// Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); -// } - - // Dispatch the different events depending on where they come from - // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD - // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD - // - // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and - // SOURCE_JOYSTICK, while its key events arrive from the keyboard source - // So, retrieve the device itself and check all of its sources - if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) { - // Note that we process events with specific key codes here - if (event.getAction() == KeyEvent.ACTION_DOWN) { - if (SDLControllerManager.onNativePadDown(deviceId, keyCode, event.getScanCode())) { - return true; - } - } else if (event.getAction() == KeyEvent.ACTION_UP) { - if (SDLControllerManager.onNativePadUp(deviceId, keyCode, event.getScanCode())) { - return true; - } - } - } - - if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) { - if (SDLActivity.isVRHeadset()) { - // The Oculus Quest controller back button comes in as source mouse, so accept that - } else { - // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses - // they are ignored here because sending them as mouse input to SDL is messy - if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) { - switch (event.getAction()) { - case KeyEvent.ACTION_DOWN: - case KeyEvent.ACTION_UP: - // mark the event as handled or it will be handled by system - // handling KEYCODE_BACK by system will call onBackPressed() - return true; - } - } - } - } - - if (event.getAction() == KeyEvent.ACTION_DOWN) { - onNativeKeyDown(keyCode); - - if (isTextInputEvent(event)) { - if (ic != null) { - ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1); - } else { - SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1); - } - } - return true; - } else if (event.getAction() == KeyEvent.ACTION_UP) { - onNativeKeyUp(keyCode); - return true; - } - - return false; - } - - /** - * This method is called by SDL using JNI. - */ - public static Surface getNativeSurface() { - if (SDLActivity.mSurface == null) { - return null; - } - return SDLActivity.mSurface.getNativeSurface(); - } - - // Input - - /** - * This method is called by SDL using JNI. - */ - public static void initTouch() { - int[] ids = InputDevice.getDeviceIds(); - - for (int id : ids) { - InputDevice device = InputDevice.getDevice(id); - /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */ - if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN - || device.isVirtual())) { - - nativeAddTouch(device.getId(), device.getName()); - } - } - } - - // Messagebox - - /** Result of current messagebox. Also used for blocking the calling thread. */ - protected final int[] messageboxSelection = new int[1]; - - /** - * This method is called by SDL using JNI. - * Shows the messagebox from UI thread and block calling thread. - * buttonFlags, buttonIds and buttonTexts must have same length. - * @param buttonFlags array containing flags for every button. - * @param buttonIds array containing id for every button. - * @param buttonTexts array containing text for every button. - * @param colors null for default or array of length 5 containing colors. - * @return button id or -1. - */ - public int messageboxShowMessageBox( - final int flags, - final String title, - final String message, - final int[] buttonFlags, - final int[] buttonIds, - final String[] buttonTexts, - final int[] colors) { - - messageboxSelection[0] = -1; - - // sanity checks - - if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) { - return -1; // implementation broken - } - - // collect arguments for Dialog - - final Bundle args = new Bundle(); - args.putInt("flags", flags); - args.putString("title", title); - args.putString("message", message); - args.putIntArray("buttonFlags", buttonFlags); - args.putIntArray("buttonIds", buttonIds); - args.putStringArray("buttonTexts", buttonTexts); - args.putIntArray("colors", colors); - - // trigger Dialog creation on UI thread - - runOnUiThread(new Runnable() { - @Override - public void run() { - messageboxCreateAndShow(args); - } - }); - - // block the calling thread - - synchronized (messageboxSelection) { - try { - messageboxSelection.wait(); - } catch (InterruptedException ex) { - ex.printStackTrace(); - return -1; - } - } - - // return selected value - - return messageboxSelection[0]; - } - - protected void messageboxCreateAndShow(Bundle args) { - - // TODO set values from "flags" to messagebox dialog - - // get colors - - int[] colors = args.getIntArray("colors"); - int backgroundColor; - int textColor; - int buttonBorderColor; - int buttonBackgroundColor; - int buttonSelectedColor; - if (colors != null) { - int i = -1; - backgroundColor = colors[++i]; - textColor = colors[++i]; - buttonBorderColor = colors[++i]; - buttonBackgroundColor = colors[++i]; - buttonSelectedColor = colors[++i]; - } else { - backgroundColor = Color.TRANSPARENT; - textColor = Color.TRANSPARENT; - buttonBorderColor = Color.TRANSPARENT; - buttonBackgroundColor = Color.TRANSPARENT; - buttonSelectedColor = Color.TRANSPARENT; - } - - // create dialog with title and a listener to wake up calling thread - - final AlertDialog dialog = new AlertDialog.Builder(this).create(); - dialog.setTitle(args.getString("title")); - dialog.setCancelable(false); - dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { - @Override - public void onDismiss(DialogInterface unused) { - synchronized (messageboxSelection) { - messageboxSelection.notify(); - } - } - }); - - // create text - - TextView message = new TextView(this); - message.setGravity(Gravity.CENTER); - message.setText(args.getString("message")); - if (textColor != Color.TRANSPARENT) { - message.setTextColor(textColor); - } - - // create buttons - - int[] buttonFlags = args.getIntArray("buttonFlags"); - int[] buttonIds = args.getIntArray("buttonIds"); - String[] buttonTexts = args.getStringArray("buttonTexts"); - - final SparseArray