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/CMakeLists.txt b/CMakeLists.txt index 73d1f1a92b..696ec1af80 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -634,6 +634,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) add_subdirectory(mods/audio_mod) endif () 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/cmake/SymbolManifest.cmake b/cmake/SymbolManifest.cmake index 40584ede19..7ef57f7ced 100644 --- a/cmake/SymbolManifest.cmake +++ b/cmake/SymbolManifest.cmake @@ -2,40 +2,50 @@ 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) -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) @@ -106,6 +117,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 () diff --git a/docs/modding.md b/docs/modding.md index 2600b4721d..86af7ce34c 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`) @@ -409,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. @@ -420,6 +449,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 +500,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 +550,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 +577,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 +588,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 +603,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 +619,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 +659,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 90e9a4de46..af29738867 100644 --- a/files.cmake +++ b/files.cmake @@ -1504,6 +1504,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/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 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..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 { @@ -62,6 +64,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; @@ -70,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; @@ -187,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) { @@ -395,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*) { @@ -407,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; } @@ -440,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) { @@ -595,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; } @@ -653,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)) { @@ -749,19 +927,30 @@ 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(); + 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; @@ -773,6 +962,9 @@ void on_frame_before_hud(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; } @@ -797,7 +989,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)) / @@ -806,15 +998,36 @@ void on_frame_before_hud(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. +void on_frame_before_hud(ModContext*, const GfxStageContext*, void*) { + restore_actual_light_debug(); } void add_control(UiElementHandle pane, const UiControlDesc& desc) { @@ -873,7 +1086,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"); @@ -899,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; } @@ -935,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; } @@ -1000,7 +1227,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 +1268,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 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) @@ -1051,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; @@ -1078,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); @@ -1099,8 +1334,11 @@ 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_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/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 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 07999da84c..6087ff49bb 100644 --- a/sdk/include/mods/hook.hpp +++ b/sdk/include/mods/hook.hpp @@ -1,210 +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. - */ -#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; \ - } \ - } - -#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/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 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..5dd9066421 100644 --- a/sdk/include/mods/svc/ui.h +++ b/sdk/include/mods/svc/ui.h @@ -3,13 +3,17 @@ #include #include +#ifdef __cplusplus +#include +#endif + #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 @@ -208,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; @@ -271,15 +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; -#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 a9d82e0993..6f7e5ed1a9 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, &svc::g_audioResModule, }) diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index a2f597def4..1b86a4d5af 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; extern const ServiceModule g_audioResModule; 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/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/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 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);