From f0e6b88cfd4afc3ee3b6f5ccb8b4c615bda24078 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 28 Jul 2026 19:26:53 -0600 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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