diff --git a/.clang-format b/.clang-format index 8ffd4ebe96..1cf581604c 100644 --- a/.clang-format +++ b/.clang-format @@ -1,6 +1,6 @@ --- Language: Cpp -Standard: C++03 +Standard: c++20 AccessModifierOffset: -4 AlignAfterOpenBracket: DontAlign AlignConsecutiveAssignments: false diff --git a/CMakeLists.txt b/CMakeLists.txt index aa04dec7bc..049d56b14e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,96 +5,8 @@ if (NOT CMAKE_BUILD_TYPE) "Build type options: Debug Release RelWithDebInfo MinSizeRel" FORCE) endif () -set(DUSK_VERSION_OVERRIDE "" CACHE STRING "Override version string (skips git detection and format validation)") - -if (DUSK_VERSION_OVERRIDE) - set(DUSK_WC_DESCRIBE "${DUSK_VERSION_OVERRIDE}") - set(DUSK_VERSION_STRING "0.0.0.0") - set(DUSK_SHORT_VERSION_STRING "0.0.0") - set(DUSK_VERSION_CODE "1") - set(DUSK_WC_REVISION "") - set(DUSK_WC_BRANCH "") - set(DUSK_WC_DATE "") - message(STATUS "Dusklight version overridden to ${DUSK_WC_DESCRIBE}") -else () - # obtain revision info from git - find_package(Git) -if (GIT_FOUND) - # make sure version information gets re-run when the current Git HEAD changes - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --git-path HEAD - OUTPUT_VARIABLE dusk_git_head_filename - OUTPUT_STRIP_TRAILING_WHITESPACE) - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_filename}") - - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --symbolic-full-name HEAD - OUTPUT_VARIABLE dusk_git_head_symbolic - OUTPUT_STRIP_TRAILING_WHITESPACE) - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - COMMAND ${GIT_EXECUTABLE} rev-parse --git-path ${dusk_git_head_symbolic} - OUTPUT_VARIABLE dusk_git_head_symbolic_filename - OUTPUT_STRIP_TRAILING_WHITESPACE) - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_symbolic_filename}") - - # defines DUSK_WC_REVISION - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse HEAD - OUTPUT_VARIABLE DUSK_WC_REVISION - OUTPUT_STRIP_TRAILING_WHITESPACE) - # defines DUSK_WC_DESCRIBE - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} describe --tags --long --dirty --match "v*" - OUTPUT_VARIABLE DUSK_WC_DESCRIBE - OUTPUT_STRIP_TRAILING_WHITESPACE) - - # remove the git hash, then collapse a clean "-0" suffix only - string(REGEX REPLACE "-[^-]+(-dirty|)$" "\\1" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") - string(REGEX REPLACE "-0$" "" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") - - # defines DUSK_WC_BRANCH - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --abbrev-ref HEAD - OUTPUT_VARIABLE DUSK_WC_BRANCH - OUTPUT_STRIP_TRAILING_WHITESPACE) - # defines DUSK_WC_DATE - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} log -1 --format=%ad - OUTPUT_VARIABLE DUSK_WC_DATE - OUTPUT_STRIP_TRAILING_WHITESPACE) -else () - message(STATUS "Unable to find git, commit information will not be available") -endif () - -if (DUSK_WC_DESCRIBE MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)([-+].*)?$") - set(DUSK_SHORT_VERSION_STRING "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}.${CMAKE_MATCH_3}") - set(_ver_major ${CMAKE_MATCH_1}) - set(_ver_minor ${CMAKE_MATCH_2}) - set(_ver_patch ${CMAKE_MATCH_3}) - set(DUSK_VERSION_TWEAK "0") - if (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-([0-9]+)(-dirty)?$") - set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") - elseif (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-[0-9A-Za-z.-]+-([0-9]+)(-dirty)?$") - set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") - endif () - set(DUSK_VERSION_STRING "${DUSK_SHORT_VERSION_STRING}.${DUSK_VERSION_TWEAK}") - if(DUSK_VERSION_TWEAK GREATER 999) - set(_tweak 999) - else() - set(_tweak ${DUSK_VERSION_TWEAK}) - endif() - # encoding: major*1e7 + minor*1e5 + patch*1e3 + tweak; collision-free for major<210, minor<100, patch<100, tweak<=999 - math(EXPR DUSK_VERSION_CODE - "${_ver_major} * 10000000 + ${_ver_minor} * 100000 + ${_ver_patch} * 1000 + ${_tweak}") -else () - set(DUSK_WC_DESCRIBE "UNKNOWN-VERSION") - set(DUSK_VERSION_STRING "0.0.0.0") - set(DUSK_SHORT_VERSION_STRING "0.0.0") - set(DUSK_VERSION_CODE "1") -endif () - -endif () - -# Add version information to CI environment variables -if(DEFINED ENV{GITHUB_ENV}) - file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION=${DUSK_WC_DESCRIBE}\n") - file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION_CODE=${DUSK_VERSION_CODE}\n") -endif() -message(STATUS "Dusklight version set to ${DUSK_WC_DESCRIBE}") +include(cmake/DetectVersion.cmake) +detect_version() message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") project(dusklight LANGUAGES C CXX VERSION ${DUSK_VERSION_STRING}) if (APPLE) @@ -177,12 +89,12 @@ option(DUSK_ENABLE_UPDATE_CHECKER "Enable update checking support" ON) option(DUSK_ENABLE_SENTRY_NATIVE "Enable sentry-native crash reporting support" OFF) option(DUSK_PACKAGE_INSTALL "Install Dusklight with a Linux-native file structure" OFF) option(DUSK_GFX_DEBUG_GROUPS "Report debug groups to the native graphics API" ${DUSK_GFX_DEBUG_GROUPS_DEFAULT}) -set(DUSK_SENTRY_DSN "" CACHE STRING "Sentry DSN") -set(DUSK_SENTRY_ENVIRONMENT "development" CACHE STRING "Sentry environment") +option(DUSK_ENABLE_CODE_MODS "Enable code mods" OFF) # Edit & Continue if (MSVC) - if ("${CMAKE_MSVC_DEBUG_INFORMATION_FORMAT}" STREQUAL "" AND CMAKE_BUILD_TYPE STREQUAL "Debug") + if ("${CMAKE_MSVC_DEBUG_INFORMATION_FORMAT}" STREQUAL "" AND CMAKE_BUILD_TYPE STREQUAL "Debug" + AND CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "EditAndContinue") endif () if (CMAKE_MSVC_DEBUG_INFORMATION_FORMAT STREQUAL "EditAndContinue") @@ -299,7 +211,47 @@ FetchContent_Declare(json URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa DOWNLOAD_EXTRACT_TIMESTAMP FALSE ) -FetchContent_MakeAvailable(cxxopts json) +message(STATUS "dusklight: Fetching miniz") +FetchContent_Declare(miniz + URL https://github.com/richgel999/miniz/releases/download/3.0.2/miniz-3.0.2.zip + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + EXCLUDE_FROM_ALL +) + +set(_fetch_content_deps cxxopts json miniz) +if (DUSK_ENABLE_CODE_MODS) + message(STATUS "dusklight: Fetching funchook") + # cmake/PatchFunchook.cmake patches funchook's cmake/capstone.cmake.in to inject a + # PATCH_COMMAND into capstone's inner ExternalProject. That PATCH_COMMAND runs + # cmake/PatchCapstone.cmake after capstone is cloned, which removes the + # cmake_policy(SET CMP0048 OLD) line that CMake >= 3.27 rejects. + # This is incredibly scuffed and we should probably think of a better way to do this + set(CAPSTONE_FIX_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/cmake/PatchCapstone.cmake") + FetchContent_Declare(funchook + GIT_REPOSITORY https://github.com/kubo/funchook.git + GIT_TAG v1.1.3 + GIT_SHALLOW TRUE + GIT_PROGRESS TRUE + PATCH_COMMAND ${CMAKE_COMMAND} -DSOURCE_DIR= -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/PatchFunchook.cmake + EXCLUDE_FROM_ALL + ) + set(FUNCHOOK_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(FUNCHOOK_BUILD_SHARED OFF CACHE BOOL "" FORCE) + set(FUNCHOOK_INSTALL OFF CACHE BOOL "" FORCE) + if (APPLE AND CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES _osx_arch_count) + if (_osx_arch_count EQUAL 1) + list(GET CMAKE_OSX_ARCHITECTURES 0 _osx_arch) + if (_osx_arch MATCHES "^(arm64|aarch64|ARM64)$") + set(FUNCHOOK_CPU arm64 CACHE STRING "" FORCE) + elseif (_osx_arch MATCHES "^(x86_64|AMD64|amd64|i[3-6]86|x86)$") + set(FUNCHOOK_CPU x86 CACHE STRING "" FORCE) + endif () + endif () + endif () + list(APPEND _fetch_content_deps funchook) +endif () +FetchContent_MakeAvailable(${_fetch_content_deps}) if (DUSK_ENABLE_SENTRY_NATIVE) message(STATUS "dusklight: Fetching sentry-native") @@ -333,21 +285,7 @@ if(_arch MATCHES "^(arm|aarch64)" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQU add_compile_options(-fsigned-char) endif() -if (CMAKE_SYSTEM_NAME STREQUAL Windows) - set(PLATFORM_NAME win32) -elseif (CMAKE_SYSTEM_NAME STREQUAL Darwin) - if (IOS) - set(PLATFORM_NAME ios) - elseif (TVOS) - set(PLATFORM_NAME tvos) - else () - set(PLATFORM_NAME macos) - endif () -else () - string(TOLOWER CMAKE_SYSTEM_NAME PLATFORM_NAME) -endif () - -configure_file(${CMAKE_SOURCE_DIR}/version.h.in ${CMAKE_BINARY_DIR}/version.h) +configure_version_header() include(files.cmake) @@ -363,22 +301,16 @@ set(DUSK_COPYRIGHT "Copyright (C) Twilit Realm contributors") source_group("dolzel" FILES ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${REL_FILES}) source_group("dusklight" FILES ${DUSK_FILES} ${DUSK_HTTP_BACKEND_FILES}) -set(GAME_COMPILE_DEFS TARGET_PC WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 MTX_USE_PS=1) - -set(GAME_INCLUDE_DIRS - include - src - assets/GZ2E01 # TODO: make this dynamic if needed? - libs/JSystem/include - libs - extern/aurora/include/dolphin - extern - ${CMAKE_BINARY_DIR}) +include(cmake/GameABIConfig.cmake) find_package(Threads REQUIRED) +set(GAME_COMPILE_DEFS DUSK_BUILDING_GAME=1) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt - Threads::Threads zstd::libzstd) + Threads::Threads zstd::libzstd dusklight_game_headers) +if (DUSK_ENABLE_CODE_MODS) + list(APPEND GAME_LIBS funchook-static) +endif () if (DUSK_ENABLE_SENTRY_NATIVE) list(APPEND GAME_LIBS sentry) @@ -448,8 +380,8 @@ if (DUSK_ENABLE_DISCORD AND NOT ANDROID AND NOT IOS AND NOT TVOS) list(APPEND GAME_COMPILE_DEFS DUSK_DISCORD=1) endif () -if(ANDROID) - list(APPEND GAME_COMPILE_DEFS TARGET_ANDROID=1) +if (DUSK_ENABLE_CODE_MODS) + list(APPEND GAME_COMPILE_DEFS DUSK_CODE_MODS=1) endif () if (DUSK_PACKAGE_INSTALL) @@ -476,7 +408,7 @@ set(GAME_DEBUG_FILES set_source_files_properties( ${GAME_DEBUG_FILES} PROPERTIES - COMPILE_DEFINITIONS "$<$:DEBUG=1>;$<$:PARTIAL_DEBUG=1>" + COMPILE_DEFINITIONS "$<$:DEBUG=1>" ) # game_base is for all other game code files @@ -490,16 +422,11 @@ set(GAME_BASE_FILES set_source_files_properties( ${GAME_BASE_FILES} PROPERTIES - COMPILE_DEFINITIONS "NDEBUG=1;NDEBUG_DEFINED=1;DEBUG_DEFINED=0;$<$:PARTIAL_DEBUG=1>" + COMPILE_DEFINITIONS "NDEBUG=1;NDEBUG_DEFINED=1;DEBUG_DEFINED=0" ) foreach(jsystem_lib IN LISTS JSYSTEM_LIBRARIES) - target_compile_definitions(${jsystem_lib} PRIVATE - ${GAME_COMPILE_DEFS} - $<$:DEBUG=1> - $<$:PARTIAL_DEBUG=1> - ) - target_include_directories(${jsystem_lib} PRIVATE ${GAME_INCLUDE_DIRS}) + target_compile_definitions(${jsystem_lib} PRIVATE ${GAME_COMPILE_DEFS} $<$:DEBUG=1>) target_link_libraries(${jsystem_lib} PRIVATE ${GAME_LIBS}) set_target_properties(${jsystem_lib} PROPERTIES FOLDER "JSystem") endforeach() @@ -511,7 +438,7 @@ if (CMAKE_CXX_LINK_GROUP_USING_RESCAN_SUPPORTED OR CMAKE_LINK_GROUP_USING_RESCAN set(JSYSTEM_LINK_LIBRARIES "$") endif () -set(DUSK_FILES src/dusk/main.cpp ${GAME_BASE_FILES} ${GAME_DEBUG_FILES}) +set(DUSK_FILES src/dusk/main.cpp ${GAME_BASE_FILES} ${GAME_DEBUG_FILES} ${miniz_SOURCE_DIR}/miniz.c) if(ANDROID) add_library(dusklight SHARED ${DUSK_FILES}) set_target_properties(dusklight PROPERTIES OUTPUT_NAME main) @@ -541,9 +468,55 @@ foreach (RANDOMIZER_FILE IN LISTS RANDOMIZER_DATA) endforeach () target_compile_definitions(dusklight PRIVATE ${GAME_COMPILE_DEFS}) -target_include_directories(dusklight PRIVATE ${GAME_INCLUDE_DIRS}) +target_include_directories(dusklight PRIVATE ${miniz_SOURCE_DIR}) target_link_libraries(dusklight PRIVATE aurora::main ${GAME_LIBS} ${JSYSTEM_LINK_LIBRARIES}) target_precompile_headers(dusklight PRIVATE "$<$:${CMAKE_SOURCE_DIR}/include/dusk_pch.hpp>") + +if (DUSK_ENABLE_CODE_MODS) + include(cmake/SymbolManifest.cmake) + if (WIN32) + # Game ABI exports & import library for mod linking + include(cmake/WindowsExports.cmake) + setup_windows_exports(dusklight) + endif () + # Post-link symbol manifest: hookable-surface name->address map keyed to the build. + setup_symbol_manifest(dusklight) +endif () + +# Hook reliability: guaranteed patchable entries on the game ABI surface, and no identical-code folding. +if (MSVC) + if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + set(DUSK_PATCHABLE_ENTRY_FLAG $<$:/hotpatch>) + endif () + if (CMAKE_SYSTEM_PROCESSOR STREQUAL "ARM64") + target_link_options(dusklight PRIVATE /FUNCTIONPADMIN:16 /OPT:NOICF) + else () + target_link_options(dusklight PRIVATE /FUNCTIONPADMIN /OPT:NOICF) + endif () +elseif (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + if (CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64" OR CMAKE_OSX_ARCHITECTURES MATCHES "arm64") + set(DUSK_PATCHABLE_ENTRY_FLAG $<$:-fpatchable-function-entry=2,1>) + else () + set(DUSK_PATCHABLE_ENTRY_FLAG $<$:-fpatchable-function-entry=10,5>) + endif () +endif () +if (DEFINED DUSK_PATCHABLE_ENTRY_FLAG) + target_compile_options(dusklight PRIVATE ${DUSK_PATCHABLE_ENTRY_FLAG}) + foreach(jsystem_lib IN LISTS JSYSTEM_LIBRARIES) + target_compile_options(${jsystem_lib} PRIVATE ${DUSK_PATCHABLE_ENTRY_FLAG}) + endforeach() +endif () + +if (WIN32) + target_link_libraries(dusklight PRIVATE Psapi) +endif () +if (APPLE) + # Mods resolve game symbols from the executable at dlopen time. + target_link_options(dusklight PRIVATE -Wl,-export_dynamic) +elseif (UNIX AND NOT ANDROID) + target_link_options(dusklight PRIVATE -rdynamic) +endif () + if (TARGET crashpad_handler) add_dependencies(dusklight crashpad_handler) add_custom_command(TARGET dusklight POST_BUILD @@ -562,6 +535,7 @@ endif () if (CMAKE_SYSTEM_NAME STREQUAL Linux) target_link_options(dusklight PRIVATE "-Wl,--build-id=sha1") + target_link_libraries(dusklight PRIVATE dl) endif () if (NOT APPLE) @@ -602,6 +576,15 @@ if (WIN32) endif () endif () +include(cmake/ModSDK.cmake) + +if (DUSK_ENABLE_CODE_MODS AND CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + add_custom_target(dusklight_mods) # Aggregate target for all in-tree mods + add_subdirectory(mods/template_mod) + add_subdirectory(mods/ao_mod) + add_subdirectory(mods/shadow_mod) +endif () + if (APPLE) if (IOS) set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios) @@ -609,6 +592,7 @@ if (APPLE) set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/tvos) else () set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/macos) + set(DUSK_ENTITLEMENTS ${DUSK_RESOURCE_DIR}/Dusklight.entitlements) endif () set(DUSK_INFO_PLIST ${DUSK_RESOURCE_DIR}/Info.plist.in) file(GLOB_RECURSE DUSK_RESOURCE_FILES @@ -628,8 +612,7 @@ if (APPLE) get_filename_component(NEW_FILE_PATH ${NEW_FILE} DIRECTORY) set_property(SOURCE ${FILE} PROPERTY MACOSX_PACKAGE_LOCATION "Resources/${NEW_FILE_PATH}") endforeach () - set_target_properties( - dusklight PROPERTIES + set(_apple_bundle_properties MACOSX_BUNDLE TRUE MACOSX_BUNDLE_BUNDLE_NAME ${DUSK_BUNDLE_NAME} MACOSX_BUNDLE_GUI_IDENTIFIER ${DUSK_BUNDLE_IDENTIFIER} @@ -640,6 +623,31 @@ if (APPLE) XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES" XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "YES" ) + if (DUSK_ENTITLEMENTS) + list(APPEND _apple_bundle_properties + XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS ${DUSK_ENTITLEMENTS}) + endif () + if (NOT IOS AND NOT TVOS) + list(APPEND _apple_bundle_properties + XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME "YES") + endif () + set_target_properties(dusklight PROPERTIES ${_apple_bundle_properties}) + + if (NOT IOS AND NOT TVOS AND NOT "${CMAKE_GENERATOR}" STREQUAL "Xcode") + set(_sign_nested_commands) + if (TARGET crashpad_handler) + list(APPEND _sign_nested_commands + COMMAND /usr/bin/codesign --force --sign - + "$/crashpad_handler") + endif () + add_custom_command(TARGET dusklight POST_BUILD + ${_sign_nested_commands} + COMMAND /usr/bin/codesign --force --sign - --entitlements + "${DUSK_ENTITLEMENTS}" "$" + COMMENT "Signing Dusklight.app with entitlements" + VERBATIM + ) + endif () endif () if (APPLE AND NOT IOS AND NOT TVOS) @@ -763,3 +771,5 @@ foreach (target IN LISTS BINARY_TARGETS) endif () endforeach () endforeach () + +install_bundled_mods() diff --git a/CMakePresets.json b/CMakePresets.json index b7cb18c2d1..394a1ddef5 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -72,7 +72,11 @@ "type": "BOOL", "value": false }, - "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install" + "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install", + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + } }, "vendor": { "microsoft.com/VisualStudioSettings/CMake/1.0": { @@ -159,6 +163,10 @@ "CMAKE_C_COMPILER": "cl", "CMAKE_CXX_COMPILER": "cl", "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install", + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + }, "CMAKE_DISABLE_FIND_PACKAGE_PkgConfig": { "type": "BOOL", "value": true @@ -262,7 +270,11 @@ "type": "BOOL", "value": false }, - "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install" + "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install", + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + } }, "vendor": { "microsoft.com/VisualStudioSettings/CMake/1.0": { @@ -394,6 +406,10 @@ "type": "BOOL", "value": false }, + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + }, "CMAKE_DISABLE_FIND_PACKAGE_PkgConfig": { "type": "BOOL", "value": true diff --git a/cmake/DetectVersion.cmake b/cmake/DetectVersion.cmake new file mode 100644 index 0000000000..ec72a97a2e --- /dev/null +++ b/cmake/DetectVersion.cmake @@ -0,0 +1,121 @@ +# Version detection shared by the main build and the mod SDK (sdk/CMakeLists.txt) +include_guard(GLOBAL) + +get_filename_component(_DUSK_VERSION_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) + +set(DUSK_SENTRY_DSN "" CACHE STRING "Sentry DSN") +set(DUSK_SENTRY_ENVIRONMENT "development" CACHE STRING "Sentry environment") + +set(DUSK_VERSION_OVERRIDE "" CACHE STRING "Override version string (skips git detection and format validation)") + +macro(detect_version) + if (DUSK_VERSION_OVERRIDE) + set(DUSK_WC_DESCRIBE "${DUSK_VERSION_OVERRIDE}") + set(DUSK_VERSION_STRING "0.0.0.0") + set(DUSK_SHORT_VERSION_STRING "0.0.0") + set(DUSK_VERSION_CODE "1") + set(DUSK_WC_REVISION "") + set(DUSK_WC_BRANCH "") + set(DUSK_WC_DATE "") + message(STATUS "Dusklight version overridden to ${DUSK_WC_DESCRIBE}") + else () + # obtain revision info from git + find_package(Git) + if (GIT_FOUND) + # make sure version information gets re-run when the current Git HEAD changes + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse --git-path HEAD + OUTPUT_VARIABLE dusk_git_head_filename + OUTPUT_STRIP_TRAILING_WHITESPACE) + get_filename_component(dusk_git_head_filename "${dusk_git_head_filename}" ABSOLUTE BASE_DIR "${_DUSK_VERSION_ROOT}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_filename}") + + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse --symbolic-full-name HEAD + OUTPUT_VARIABLE dusk_git_head_symbolic + OUTPUT_STRIP_TRAILING_WHITESPACE) + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} + COMMAND ${GIT_EXECUTABLE} rev-parse --git-path ${dusk_git_head_symbolic} + OUTPUT_VARIABLE dusk_git_head_symbolic_filename + OUTPUT_STRIP_TRAILING_WHITESPACE) + get_filename_component(dusk_git_head_symbolic_filename "${dusk_git_head_symbolic_filename}" ABSOLUTE BASE_DIR "${_DUSK_VERSION_ROOT}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_symbolic_filename}") + + # defines DUSK_WC_REVISION + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse HEAD + OUTPUT_VARIABLE DUSK_WC_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE) + # defines DUSK_WC_DESCRIBE + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} describe --tags --long --dirty --match "v*" + OUTPUT_VARIABLE DUSK_WC_DESCRIBE + OUTPUT_STRIP_TRAILING_WHITESPACE) + + # remove the git hash, then collapse a clean "-0" suffix only + string(REGEX REPLACE "-[^-]+(-dirty|)$" "\\1" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") + string(REGEX REPLACE "-0$" "" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") + + # defines DUSK_WC_BRANCH + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse --abbrev-ref HEAD + OUTPUT_VARIABLE DUSK_WC_BRANCH + OUTPUT_STRIP_TRAILING_WHITESPACE) + # defines DUSK_WC_DATE + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} log -1 --format=%ad + OUTPUT_VARIABLE DUSK_WC_DATE + OUTPUT_STRIP_TRAILING_WHITESPACE) + else () + message(STATUS "Unable to find git, commit information will not be available") + endif () + + if (DUSK_WC_DESCRIBE MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)([-+].*)?$") + set(DUSK_SHORT_VERSION_STRING "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}.${CMAKE_MATCH_3}") + set(_ver_major ${CMAKE_MATCH_1}) + set(_ver_minor ${CMAKE_MATCH_2}) + set(_ver_patch ${CMAKE_MATCH_3}) + set(DUSK_VERSION_TWEAK "0") + if (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-([0-9]+)(-dirty)?$") + set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") + elseif (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-[0-9A-Za-z.-]+-([0-9]+)(-dirty)?$") + set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") + endif () + set(DUSK_VERSION_STRING "${DUSK_SHORT_VERSION_STRING}.${DUSK_VERSION_TWEAK}") + if (DUSK_VERSION_TWEAK GREATER 999) + set(_tweak 999) + else () + set(_tweak ${DUSK_VERSION_TWEAK}) + endif () + # encoding: major*1e7 + minor*1e5 + patch*1e3 + tweak; collision-free for major<210, minor<100, patch<100, tweak<=999 + math(EXPR DUSK_VERSION_CODE + "${_ver_major} * 10000000 + ${_ver_minor} * 100000 + ${_ver_patch} * 1000 + ${_tweak}") + else () + set(DUSK_WC_DESCRIBE "UNKNOWN-VERSION") + set(DUSK_VERSION_STRING "0.0.0.0") + set(DUSK_SHORT_VERSION_STRING "0.0.0") + set(DUSK_VERSION_CODE "1") + endif () + + endif () + + # Add version information to CI environment variables + if (DEFINED ENV{GITHUB_ENV}) + file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION=${DUSK_WC_DESCRIBE}\n") + file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION_CODE=${DUSK_VERSION_CODE}\n") + endif () + message(STATUS "Dusklight version set to ${DUSK_WC_DESCRIBE}") +endmacro() + +# Sets PLATFORM_NAME and configures version.h into the caller's binary dir. +macro(configure_version_header) + if (CMAKE_SYSTEM_NAME STREQUAL Windows) + set(PLATFORM_NAME win32) + elseif (CMAKE_SYSTEM_NAME STREQUAL Darwin) + if (IOS) + set(PLATFORM_NAME ios) + elseif (TVOS) + set(PLATFORM_NAME tvos) + else () + set(PLATFORM_NAME macos) + endif () + else () + string(TOLOWER CMAKE_SYSTEM_NAME PLATFORM_NAME) + endif () + + configure_file(${_DUSK_VERSION_ROOT}/version.h.in ${CMAKE_CURRENT_BINARY_DIR}/version.h) +endmacro() diff --git a/cmake/GameABIConfig.cmake b/cmake/GameABIConfig.cmake new file mode 100644 index 0000000000..d48be445a2 --- /dev/null +++ b/cmake/GameABIConfig.cmake @@ -0,0 +1,32 @@ +# The game ABI surface shared by the main build and the mod SDK (sdk/CMakeLists.txt) +include_guard(GLOBAL) + +get_filename_component(_game_root "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) + +# PARTIAL_DEBUG makes debug and release share one struct/vtable ABI so a mod binary loads into either +set(_game_compile_defs TARGET_PC=1 WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 MTX_USE_PS=1 PARTIAL_DEBUG=1) +if (ANDROID) + list(APPEND _game_compile_defs TARGET_ANDROID=1) +endif () + +set(_game_include_dirs + ${_game_root}/include + ${_game_root}/src + ${_game_root}/assets/GZ2E01 # TODO: make this dynamic if needed? + ${_game_root}/libs/JSystem/include + ${_game_root}/libs + ${_game_root}/extern/aurora/include/dolphin + ${_game_root}/extern/aurora/include + ${_game_root}/extern + ${CMAKE_CURRENT_BINARY_DIR} +) + +# Interface target for mods and sub-projects to inherit game headers/defines. +add_library(dusklight_game_headers INTERFACE) +target_include_directories(dusklight_game_headers INTERFACE ${_game_include_dirs}) +target_compile_definitions(dusklight_game_headers INTERFACE ${_game_compile_defs}) +if (TARGET dawn::dawncpp_headers) + target_link_libraries(dusklight_game_headers INTERFACE dawn::dawncpp_headers) +elseif (TARGET dawn::webgpu_dawn) + target_link_libraries(dusklight_game_headers INTERFACE dawn::webgpu_dawn) +endif () diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake new file mode 100644 index 0000000000..833e433360 --- /dev/null +++ b/cmake/ModSDK.cmake @@ -0,0 +1,295 @@ +# add_mod( SOURCES ... MOD_JSON [RES_DIR ] [OVERLAY_DIR ] +# [TEXTURES_DIR ] [OUTPUT_DIR ] [BUNDLE]) +set(DUSK_MODS_OUTPUT_DIR "${CMAKE_BINARY_DIR}/mods" CACHE PATH "Directory to write mod packages into") + +function(_mod_lib_name out_var) + set(_arch "${CMAKE_SYSTEM_PROCESSOR}") + if (APPLE AND CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES _count) + if (_count GREATER 1) + message(FATAL_ERROR "add_mod: universal binaries are not supported") + endif () + set(_arch "${CMAKE_OSX_ARCHITECTURES}") + endif () + string(TOLOWER "${CMAKE_SYSTEM_NAME}" _platform) + string(TOLOWER "${_arch}" _arch) + if (_arch MATCHES "^(i[3-6]86|x86)$") + set(_arch "x86") + endif () + if (WIN32) + set(_ext ".dll") + elseif (APPLE) + set(_ext ".dylib") + else () + set(_ext ".so") + endif () + set(${out_var} "${_platform}-${_arch}${_ext}" PARENT_SCOPE) +endfunction() + +function(_mod_resolve_source_path out_var path) + if (IS_ABSOLUTE "${path}") + set(_path "${path}") + else () + set(_path "${CMAKE_CURRENT_SOURCE_DIR}/${path}") + endif () + set(${out_var} "${_path}" PARENT_SCOPE) +endfunction() + +function(_mod_collect_assets out_var dir) + if (NOT IS_DIRECTORY "${dir}") + message(FATAL_ERROR "add_mod: asset directory does not exist: ${dir}") + endif () + + file(GLOB_RECURSE _files CONFIGURE_DEPENDS LIST_DIRECTORIES false "${dir}/*") + set(${out_var} ${_files} PARENT_SCOPE) +endfunction() + +function(add_mod target_name) + cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OVERLAY_DIR;TEXTURES_DIR;OUTPUT_DIR" "SOURCES" ${ARGN}) + if (NOT ARG_MOD_JSON) + message(FATAL_ERROR "add_mod: MOD_JSON is required") + endif () + _mod_resolve_source_path(_mod_json "${ARG_MOD_JSON}") + if (NOT EXISTS "${_mod_json}") + message(FATAL_ERROR "add_mod: MOD_JSON does not exist: ${_mod_json}") + endif () + + set(_has_lib FALSE) + set(_lib_name "") + if (ARG_SOURCES) + set(_has_lib TRUE) + add_library(${target_name} SHARED ${ARG_SOURCES}) + _mod_lib_name(_lib_name) + set_target_properties(${target_name} PROPERTIES + PREFIX "" + C_VISIBILITY_PRESET hidden + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON + WINDOWS_EXPORT_ALL_SYMBOLS OFF) + target_compile_features(${target_name} PRIVATE cxx_std_20) + target_link_libraries(${target_name} PRIVATE dusklight_game_headers) + + if (NOT TARGET dusklight) + # Apply global compile options for out-of-tree mod builds + if (CMAKE_SYSTEM_NAME STREQUAL Linux) + target_compile_options(${target_name} PRIVATE + -Wno-multichar -Wno-trigraphs -Wno-deprecated-declarations) + elseif (APPLE) + target_compile_options(${target_name} PRIVATE + -Wno-declaration-after-statement -Wno-non-pod-varargs) + elseif (MSVC) + target_compile_options(${target_name} PRIVATE + "$<$:/bigobj>" + "$<$:/utf-8>") + endif () + # Use signed char on ARM to match the original game (and x86) + string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _mod_arch) + if (_mod_arch MATCHES "^(arm|aarch64)" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "GNU") + target_compile_options(${target_name} PRIVATE -fsigned-char) + endif () + endif () + + if (APPLE) + # Game symbols resolve against the host executable at dlopen time. + target_link_options(${target_name} PRIVATE -undefined dynamic_lookup) + elseif (ANDROID) + if (TARGET dusklight) + target_link_libraries(${target_name} PRIVATE dusklight) + elseif (DUSK_GAME_SOLIB) + target_link_libraries(${target_name} PRIVATE "${DUSK_GAME_SOLIB}") + else () + message(FATAL_ERROR "add_mod: DUSK_GAME_SOLIB is not set (libmain.so)") + endif () + elseif (UNIX) + target_link_options(${target_name} PRIVATE -Wl,--allow-shlib-undefined) + elseif (WIN32) + # Link against the generated import library (game ABI surface). Function calls + # resolve through import thunks. Data is toolchain dependent: + # - clang-cl: lld's mingw mode auto-imports data references, fixed up at load by + # the mod SDK's pseudo-relocation runtime (pseudo_reloc.cpp). + # - cl (MSVC): only DUSK_GAME_DATA-annotated data is reachable. Un-annotated + # references fail to link. + if (NOT DUSK_GAME_IMPLIB) + message(FATAL_ERROR "add_mod: DUSK_GAME_IMPLIB is not set.") + endif () + target_link_libraries(${target_name} PRIVATE "${DUSK_GAME_IMPLIB}") + set_target_properties(${target_name} PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreadedDLL") + target_compile_definitions(${target_name} PRIVATE _ITERATOR_DEBUG_LEVEL=0) + if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + target_compile_options(${target_name} PRIVATE "$<$:/clang:-mcmodel=large>") + target_sources(${target_name} PRIVATE "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../sdk/pseudo_reloc.cpp") + # lld mingw mode rewrites /DEFAULTLIB directives to -l style and skips %LIB%, so + # the CRT libraries and search paths are spelled out explicitly. + target_link_options(${target_name} PRIVATE -lldmingw /nodefaultlib /INCREMENTAL:NO) + target_link_libraries(${target_name} PRIVATE + msvcrt.lib msvcprt.lib vcruntime.lib ucrt.lib + oldnames.lib uuid.lib kernel32.lib user32.lib) + set(_lib_dirs "$ENV{LIB}") + if ("${_lib_dirs}" STREQUAL "") + message(FATAL_ERROR "add_mod: %LIB% is empty; configure from a VS dev shell") + endif () + foreach (_libdir IN LISTS _lib_dirs) + target_link_options(${target_name} PRIVATE "/libpath:${_libdir}") + endforeach () + endif () + endif () + endif () + + set(_output_dir "${DUSK_MODS_OUTPUT_DIR}") + if (ARG_OUTPUT_DIR) + set(_output_dir "${ARG_OUTPUT_DIR}") + endif () + set(_stage "${CMAKE_CURRENT_BINARY_DIR}/${target_name}_stage") + set(_out "${_output_dir}/${target_name}.dusk") + + set(_zip_args "${_lib_name}" mod.json) + set(_package_deps "${_mod_json}") + set(_package_inputs "${_mod_json}") + set(_extra_cmds "") + set(_lib_copy_cmd "") + set(_target_depend "") + if (_has_lib) + list(APPEND _zip_args "${_lib_name}") + set(_lib_copy_cmd COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" "${_stage}/${_lib_name}") + set(_target_depend ${target_name}) + endif () + if (ARG_RES_DIR) + _mod_resolve_source_path(_res_dir "${ARG_RES_DIR}") + _mod_collect_assets(_res_deps "${_res_dir}") + list(APPEND _package_deps ${_res_deps}) + list(APPEND _package_inputs "${_res_dir}" ${_res_deps}) + list(APPEND _zip_args res) + list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_res_dir}" "${_stage}/res") + endif () + if (ARG_OVERLAY_DIR) + _mod_resolve_source_path(_overlay_dir "${ARG_OVERLAY_DIR}") + _mod_collect_assets(_overlay_deps "${_overlay_dir}") + list(APPEND _package_deps ${_overlay_deps}) + list(APPEND _package_inputs "${_overlay_dir}" ${_overlay_deps}) + list(APPEND _zip_args overlay) + list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_overlay_dir}" "${_stage}/overlay") + endif () + if (ARG_TEXTURES_DIR) + _mod_resolve_source_path(_textures_dir "${ARG_TEXTURES_DIR}") + _mod_collect_assets(_textures_deps "${_textures_dir}") + list(APPEND _package_deps ${_textures_deps}) + list(APPEND _package_inputs "${_textures_dir}" ${_textures_deps}) + list(APPEND _zip_args textures) + list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_textures_dir}" "${_stage}/textures") + endif () + + set(_bundle_cmds "") + if (ARG_BUNDLE AND TARGET dusklight) + file(READ "${_mod_json}" _mod_json_text) + string(JSON _mod_id GET "${_mod_json_text}" id) + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_TARGETS "${target_name}") + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_IDS "${_mod_id}") + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_STAGES "${_stage}") + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_PACKAGES "${_out}") + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_LIB_NAMES "${_lib_name}") + set(_bundle_cmds + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/bundled_mods" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_out}" "${CMAKE_BINARY_DIR}/bundled_mods/${target_name}.dusk") + endif () + + set(_package_target "${target_name}_package") + set(_package_inputs_file "${CMAKE_CURRENT_BINARY_DIR}/${target_name}_package_inputs.txt") + list(SORT _package_inputs) + set(_package_inputs_text "") + foreach (_package_input IN LISTS _package_inputs) + string(APPEND _package_inputs_text "${_package_input}\n") + endforeach () + file(GENERATE OUTPUT "${_package_inputs_file}" CONTENT "${_package_inputs_text}") + add_custom_command(OUTPUT "${_out}" + COMMAND ${CMAKE_COMMAND} -E rm -rf "${_stage}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${_stage}" "${_output_dir}" + ${_lib_copy_cmd} + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_mod_json}" "${_stage}/mod.json" + ${_extra_cmds} + COMMAND ${CMAKE_COMMAND} -E chdir "${_stage}" ${CMAKE_COMMAND} -E tar cvf "${_out}" --format=zip ${_zip_args} + ${_bundle_cmds} + DEPENDS ${_target_depend} ${_package_deps} "${_package_inputs_file}" + COMMENT "Packaging ${target_name} -> ${_out}" + COMMAND_EXPAND_LISTS + VERBATIM + ) + add_custom_target(${_package_target} ALL DEPENDS "${_out}") + if (TARGET dusklight_mods) + add_dependencies(dusklight_mods ${_package_target}) + endif () +endfunction() + +# Install rules for BUNDLE mods. +# - Windows: the .dusk archives into /mods (the loader extracts native libs to the +# user cache). +# - Linux: pre-extracted stage dirs into /mods so native libs dlopen in place from +# read-only installs. +# - macOS: pre-extracted stage dirs into the installed app's Contents/Resources/mods, dylibs +# ad-hoc signed in place, then the whole bundle re-signed. +# - iOS/tvOS: assets into /mods/ and the dylib into Frameworks/.dylib. +# - Android: nothing here; gradle packs ${CMAKE_BINARY_DIR}/bundled_mods into APK assets. +function(install_bundled_mods) + get_property(_targets GLOBAL PROPERTY DUSK_BUNDLED_MOD_TARGETS) + if (NOT _targets OR ANDROID) + return () + endif () + get_property(_ids GLOBAL PROPERTY DUSK_BUNDLED_MOD_IDS) + get_property(_stages GLOBAL PROPERTY DUSK_BUNDLED_MOD_STAGES) + get_property(_lib_names GLOBAL PROPERTY DUSK_BUNDLED_MOD_LIB_NAMES) + list(LENGTH _targets _count) + math(EXPR _last "${_count} - 1") + + if (APPLE) + get_target_property(_app_name dusklight OUTPUT_NAME) + if (NOT _app_name) + set(_app_name dusklight) + endif () + set(_bundle_dir "${CMAKE_INSTALL_PREFIX}/${_app_name}.app") + if (IOS OR TVOS) + foreach (_i RANGE ${_last}) + list(GET _targets ${_i} _target) + list(GET _ids ${_i} _id) + list(GET _stages ${_i} _stage) + list(GET _lib_names ${_i} _lib_name) + install(DIRECTORY "${_stage}/" DESTINATION "${_bundle_dir}/mods/${_id}" + PATTERN "${_lib_name}" EXCLUDE) + install(PROGRAMS "$" + DESTINATION "${_bundle_dir}/Frameworks" RENAME "${_id}.dylib") + endforeach () + else () + foreach (_i RANGE ${_last}) + list(GET _ids ${_i} _id) + list(GET _stages ${_i} _stage) + list(GET _lib_names ${_i} _lib_name) + install(DIRECTORY "${_stage}/" DESTINATION "${_bundle_dir}/Contents/Resources/mods/${_id}") + install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - \"${_bundle_dir}/Contents/Resources/mods/${_id}/${_lib_name}\" COMMAND_ERROR_IS_FATAL ANY)") + endforeach () + if (TARGET crashpad_handler) + install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - \"${_bundle_dir}/Contents/MacOS/$\" COMMAND_ERROR_IS_FATAL ANY)") + endif () + install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - --entitlements \"${DUSK_ENTITLEMENTS}\" \"${_bundle_dir}\" COMMAND_ERROR_IS_FATAL ANY)") + endif () + return () + endif () + + if (DUSK_PACKAGE_INSTALL) + set(_mods_dest "${CMAKE_INSTALL_DATAROOTDIR}/dusklight/mods") + else () + set(_mods_dest "${CMAKE_INSTALL_PREFIX}/mods") + endif () + if (WIN32) + foreach (_target IN LISTS _targets) + install(FILES "${CMAKE_BINARY_DIR}/bundled_mods/${_target}.dusk" DESTINATION "${_mods_dest}") + endforeach () + else () + foreach (_i RANGE ${_last}) + list(GET _ids ${_i} _id) + list(GET _stages ${_i} _stage) + install(DIRECTORY "${_stage}/" DESTINATION "${_mods_dest}/${_id}") + endforeach () + endif () +endfunction() diff --git a/cmake/PatchCapstone.cmake b/cmake/PatchCapstone.cmake new file mode 100644 index 0000000000..fa8a1dd160 --- /dev/null +++ b/cmake/PatchCapstone.cmake @@ -0,0 +1,13 @@ +# Patches capstone's CMakeLists.txt for compatibility with CMake >= 4.0: +# - Bumps cmake_minimum_required to 3.10 (CMake >= 4.0 dropped < 3.5 support; < 3.10 warns) +# - Removes cmake_policy(SET CMP0048 OLD) (rejected by CMake >= 3.27) +file(READ "${DIR}/CMakeLists.txt" _content) +string(REGEX REPLACE + "cmake_minimum_required[ \t]*\\([ \t]*VERSION[ \t]+[0-9]+\\.[0-9]+(\\.[0-9]+)?[ \t]*\\)" + "cmake_minimum_required(VERSION 3.10)" + _content "${_content}") +string(REGEX REPLACE + "cmake_policy[ \t]*\\([ \t]*SET[ \t]+CMP0048[ \t]+OLD[ \t]*\\)" + "# cmake_policy(SET CMP0048 OLD)" + _content "${_content}") +file(WRITE "${DIR}/CMakeLists.txt" "${_content}") diff --git a/cmake/PatchFunchook.cmake b/cmake/PatchFunchook.cmake new file mode 100644 index 0000000000..6d5a6e36b4 --- /dev/null +++ b/cmake/PatchFunchook.cmake @@ -0,0 +1,60 @@ +file(READ "${SOURCE_DIR}/cmake/capstone.cmake.in" _content) + +# Insert PATCH_COMMAND before CONFIGURE_COMMAND in the ExternalProject_Add. +# Bracket args prevent cmake from substituting ${...} while writing this file. +string(REPLACE + " CONFIGURE_COMMAND \"\"" + [=[ PATCH_COMMAND "${CMAKE_COMMAND}" -DDIR=${CMAKE_CURRENT_BINARY_DIR}/capstone-src -P "${CAPSTONE_FIX_SCRIPT}" + CONFIGURE_COMMAND ""]=] + _content "${_content}") + +file(WRITE "${SOURCE_DIR}/cmake/capstone.cmake.in" "${_content}") + +file(READ "${SOURCE_DIR}/src/funchook_unix.c" _unix_content) + +# macOS rejects the POSIX mprotect RWX/RW transition for executable image pages on arm64. +# Use Mach VM_PROT_COPY for the short patch window, then restore RX permissions. +if (NOT _unix_content MATCHES "VM_PROT_READ \\| VM_PROT_WRITE \\| VM_PROT_COPY") + string(REPLACE + [=[ rv = mprotect(mstate->addr, mstate->size, prot);]=] + [=[#ifdef __APPLE__ + kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)mstate->addr, + (vm_size_t)mstate->size, FALSE, + VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY); + if (kr == KERN_SUCCESS) { + funchook_log(funchook, " unprotect memory %p (size=%"PRIuPTR", prot=read,write,copy) <- %p (size=%"PRIuPTR")\n", + mstate->addr, mstate->size, start, len); + return 0; + } + funchook_set_error_message(funchook, "Failed to unprotect memory %p (size=%"PRIuPTR", prot=read,write,copy) <- %p (size=%"PRIuPTR", error=%s)", + mstate->addr, mstate->size, start, len, + mach_error_string(kr)); + return FUNCHOOK_ERROR_MEMORY_FUNCTION; +#endif + rv = mprotect(mstate->addr, mstate->size, prot);]=] + _unix_content "${_unix_content}") + + string(REPLACE + [=[ char errbuf[128]; + int rv = mprotect(mstate->addr, mstate->size, PROT_READ | PROT_EXEC);]=] + [=[ char errbuf[128]; +#ifdef __APPLE__ + kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)mstate->addr, + (vm_size_t)mstate->size, FALSE, + VM_PROT_READ | VM_PROT_EXECUTE); + + if (kr == KERN_SUCCESS) { + funchook_log(funchook, " protect memory %p (size=%"PRIuPTR", prot=read,exec)\n", + mstate->addr, mstate->size); + return 0; + } + funchook_set_error_message(funchook, "Failed to protect memory %p (size=%"PRIuPTR", prot=read,exec, error=%s)", + mstate->addr, mstate->size, + mach_error_string(kr)); + return FUNCHOOK_ERROR_MEMORY_FUNCTION; +#endif + int rv = mprotect(mstate->addr, mstate->size, PROT_READ | PROT_EXEC);]=] + _unix_content "${_unix_content}") +endif () + +file(WRITE "${SOURCE_DIR}/src/funchook_unix.c" "${_unix_content}") diff --git a/cmake/SymbolManifest.cmake b/cmake/SymbolManifest.cmake new file mode 100644 index 0000000000..927040a47f --- /dev/null +++ b/cmake/SymbolManifest.cmake @@ -0,0 +1,125 @@ +include_guard(GLOBAL) + +get_filename_component(_SYMBOL_MANIFEST_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + +set(_SYMGEN_VERSION "1.1.1") +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) + string(TOLOWER "${CMAKE_HOST_SYSTEM_PROCESSOR}" _host_processor) + set(_asset "") + + if (CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + if (_host_processor MATCHES "^(arm64|aarch64)$") + set(_asset "symgen-macos-arm64") + elseif (_host_processor MATCHES "^(x86_64|amd64)$") + set(_asset "symgen-macos-x86_64") + endif () + elseif (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + if (_host_processor MATCHES "^(aarch64|arm64)$") + set(_asset "symgen-linux-aarch64") + elseif (_host_processor MATCHES "^(x86_64|amd64)$") + set(_asset "symgen-linux-x86_64") + elseif (_host_processor MATCHES "^(i[3-6]86|x86)$") + set(_asset "symgen-linux-i686") + endif () + elseif (CMAKE_HOST_WIN32) + if (_host_processor MATCHES "^(arm64|aarch64)$") + set(_asset "symgen-windows-arm64.exe") + elseif (_host_processor MATCHES "^(x86_64|amd64)$") + set(_asset "symgen-windows-x86_64.exe") + elseif (_host_processor MATCHES "^(i[3-6]86|x86)$") + set(_asset "symgen-windows-x86.exe") + endif () + endif () + + set(${out_name} "${_asset}" PARENT_SCOPE) +endfunction() + +function(ensure_symgen required) + if (TARGET symgen) + return() + endif () + + if (SYMGEN_PATH) + get_filename_component(_symgen "${SYMGEN_PATH}" ABSOLUTE) + if (NOT EXISTS "${_symgen}") + if (required) + message(FATAL_ERROR "symgen: SYMGEN_PATH does not exist: ${_symgen}") + endif () + message(STATUS "symgen: SYMGEN_PATH does not exist, symbol manifest generation " + "skipped (by-name hook resolution will be unavailable)") + return() + endif () + else () + symgen_host_asset(_asset) + if (_asset STREQUAL "") + if (required) + message(FATAL_ERROR "symgen: no prebuilt binary for host " + "${CMAKE_HOST_SYSTEM_NAME}/${CMAKE_HOST_SYSTEM_PROCESSOR} " + "(configure with -DDUSK_ENABLE_CODE_MODS=OFF)") + endif () + message(STATUS "symgen: no prebuilt binary for host " + "${CMAKE_HOST_SYSTEM_NAME}/${CMAKE_HOST_SYSTEM_PROCESSOR}; " + "symbol manifest generation skipped (by-name hook resolution will be unavailable)") + return() + endif () + + set(_symgen_dir "${CMAKE_BINARY_DIR}/_deps/symgen") + set(_symgen "${_symgen_dir}/${_asset}") + set(_url "${_SYMGEN_RELEASE_BASE_URL}/${_asset}") + message(STATUS "dusklight: Fetching symgen ${_SYMGEN_VERSION} (${_asset})") + file(MAKE_DIRECTORY "${_symgen_dir}") + file(DOWNLOAD "${_url}" "${_symgen}" + TLS_VERIFY ON + STATUS _download_status + SHOW_PROGRESS) + list(GET _download_status 0 _download_code) + if (NOT _download_code EQUAL 0) + list(GET _download_status 1 _download_message) + file(REMOVE "${_symgen}") + if (required) + message(FATAL_ERROR "symgen: failed to download ${_url}: ${_download_message}") + endif () + message(STATUS "symgen: failed to download ${_url}: ${_download_message}; " + "symbol manifest generation skipped (by-name hook resolution will be unavailable)") + return() + endif () + if (NOT CMAKE_HOST_WIN32) + file(CHMOD "${_symgen}" PERMISSIONS + OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE) + endif () + endif () + + add_custom_target(symgen DEPENDS "${_symgen}") + set(SYMGEN_EXE "${_symgen}" CACHE INTERNAL "symgen executable" FORCE) +endfunction() + +function(setup_symbol_manifest target) + ensure_symgen(TRUE) + if (NOT TARGET symgen) + return() + endif () + add_dependencies(${target} symgen) + + if (WIN32) + set(_input --pdb "$") + set(_out "$/dusklight.symdb") + else () + set(_input --binary "$") + if (APPLE) + set(_out "$/Resources/dusklight.symdb") + else () + set(_out "$/dusklight.symdb") + endif () + endif () + + add_custom_command(TARGET ${target} POST_BUILD + COMMAND "${SYMGEN_EXE}" manifest ${_input} --out "${_out}" + COMMENT "Generating symbol manifest" + VERBATIM) +endfunction() diff --git a/cmake/WindowsExports.cmake b/cmake/WindowsExports.cmake new file mode 100644 index 0000000000..3010c82f25 --- /dev/null +++ b/cmake/WindowsExports.cmake @@ -0,0 +1,90 @@ +include_guard(GLOBAL) + +get_filename_component(_DUSK_WINDOWS_EXPORTS_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + +# Windows mod linking: generate the curated export surface for the game executable and the +# import library mods link against. symgen scans the built objects, filters by source, and +# writes a .def used by the main link and import library generation. +function(setup_windows_exports target) + if (NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(WARNING "dusklight: Windows code-mod exports are x64-only for now; skipping") + return() + endif () + + include("${_DUSK_WINDOWS_EXPORTS_CMAKE_DIR}/SymbolManifest.cmake") + ensure_symgen(TRUE) + set(_symgen "${SYMGEN_EXE}") + add_dependencies(${target} symgen) + + set(_config_subdir "") + if (CMAKE_CONFIGURATION_TYPES) + set(_config_subdir "$/") + endif () + + set(_rsp_lines "$") + foreach (_lib IN LISTS JSYSTEM_LIBRARIES) + list(APPEND _rsp_lines "$") + endforeach () + list(JOIN _rsp_lines "\n" _rsp_content) + set(_rsp "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight_exports_input.rsp") + file(GENERATE OUTPUT "${_rsp}" CONTENT "${_rsp_content}") + + set(_sdk_args) + foreach (_lib aurora_card aurora_core aurora_dvd aurora_gd aurora_gx aurora_mtx + aurora_os aurora_pad aurora_si aurora_vi) + if (TARGET ${_lib}) + list(APPEND _sdk_args --sdk-lib "$") + endif () + endforeach () + + set(_forward_args) + if (TARGET dawn::webgpu_dawn) + get_target_property(_dawn_type dawn::webgpu_dawn TYPE) + if (_dawn_type STREQUAL "SHARED_LIBRARY") + list(APPEND _forward_args + --forward-dll "$" + --forward-sym-prefix wgpu) + endif () + endif () + + # Generate curated exports list from the main binary + set(_def "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight_exports.def") + add_custom_command(TARGET ${target} PRE_LINK + # TODO: src/dusk/ is NOT excluded: inline code in game headers + # currently call into it (e.g. dusk::frame_interp::lookup_replacement). + COMMAND "${_symgen}" def + --rsp "${_rsp}" + --out "${_def}" + --exclude cmake_pch + --exclude miniz + --exclude asan_options + --max-exports 58000 + ${_sdk_args} + ${_forward_args} + COMMENT "Generating dusklight exports" + VERBATIM) + target_link_options(${target} PRIVATE "/DEF:${_def}") + + # Generate import library for mods to link against. + set(_implib "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight_imports.lib") + get_filename_component(_compiler_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) + find_program(DUSK_LLVM_DLLTOOL llvm-dlltool HINTS "${_compiler_dir}") + if (DUSK_LLVM_DLLTOOL) + set(_implib_cmd "${DUSK_LLVM_DLLTOOL}" -d "${_def}" -D dusklight.exe -m i386:x86-64 + -l "${_implib}") + else () + set(_implib_cmd "${CMAKE_AR}" /nologo "/def:${_def}" /machine:x64 /name:dusklight.exe + "/out:${_implib}") + endif () + add_custom_command(TARGET ${target} POST_BUILD + COMMAND ${_implib_cmd} + BYPRODUCTS "${_implib}" + COMMENT "Generating dusklight import library" + VERBATIM) + set(DUSK_GAME_IMPLIB "${_implib}" CACHE INTERNAL "Import library for Windows mod linking") + set(DUSK_GAME_DEF "${_def}" CACHE INTERNAL "Curated export .def for the game executable") + + # Ship the import library as sdk/dusklight.lib in the install tree: mods may use it to + # compile against Dusklight without having to build the whole game. (See DUSK_GAME_IMPLIB) + install(FILES "${_implib}" DESTINATION sdk RENAME dusklight.lib) +endfunction() diff --git a/docs/modding.md b/docs/modding.md new file mode 100644 index 0000000000..6f2d1a8580 --- /dev/null +++ b/docs/modding.md @@ -0,0 +1,764 @@ +# Dusklight Mod API + +Mods are `.dusk` bundles: zip archives that can contain code (in the form of native libraries), resources, DVD overlay +files, and texture replacements. Mods may be enabled, disabled and reloaded at runtime. + +When code mods are loaded, they get dynamically linked by the operating system to the running game process. The mod +exports lifecycle functions that Dusklight calls into (`mod_initialize`, `mod_update`, `mod_shutdown`), and the mod +communicates with the host via **services**: plain C APIs, individually versioned. Dusklight exports several built-in +services, and mods may export services of their own, permitting framework mods and cross-mod integration. + +Beyond services, mods have full access to the original game's code: include game headers, call directly into any public +function, read and write data fields, and hook the vast majority of game functions. + +## Table of Contents + +1. [Getting Started](#getting-started) +2. [mod.json](#modjson) +3. [Anatomy of a Code Mod](#anatomy-of-a-code-mod) +4. [Services](#services) +5. [Built-in Services](#built-in-services) +6. [Hooking Game Functions](#hooking-game-functions) +7. [Asset Overlays](#asset-overlays) +8. [Runtime Lifecycle](#runtime-lifecycle) +9. [Error Handling](#error-handling) +10. [Advanced: Exporting Services](#advanced-exporting-services) + +--- + +## Getting Started + +Fork the [mod template](../mods/template_mod/), a self-contained CMake project that uses the Dusklight mod SDK. + +``` +my_mod/ +├── CMakeLists.txt +├── mod.json +├── src/mod.cpp +├── res/ (optional bundled resources) +├── overlay/ (optional game file overrides) +└── textures/ (optional texture replacements) +``` + +**CMakeLists.txt:** + +```cmake +cmake_minimum_required(VERSION 3.25) +project(my_mod CXX) + +set(DUSKLIGHT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dusklight" CACHE PATH "Path to dusklight source root") +add_subdirectory("${DUSKLIGHT_DIR}/sdk" dusklight-sdk EXCLUDE_FROM_ALL) + +add_mod(my_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res # optional + OVERLAY_DIR overlay # optional + TEXTURES_DIR textures # optional +) +``` + +Building produces `my_mod.dusk` in `build//mods/` (configurable via the `DUSK_MODS_OUTPUT_DIR` cache variable). +Dusklight searches a `mods/` directory next to the app in addition to the user directory, so a dev build launched from +`build//` picks these up automatically: rebuild, relaunch (or click Reload), done. + +For a regular game install, copy the `.dusk` into the user mods folder: + +- Windows: `%APPDATA%\TwilitRealm\Dusklight\mods` +- Linux: `~/.local/share/TwilitRealm/Dusklight/mods` +- macOS: `~/Library/Application Support/TwilitRealm/Dusklight/mods` + +Passing `--mods ` on the command line replaces the user directory with one of your choosing. + +--- + +## mod.json + +```json +{ + "id": "com.example.my_mod", + "name": "My Mod", + "version": "1.0.0", + "author": "Your Name", + "description": "A short description shown in the mod manager.", + "icon": "res/my_icon.png", + "banner": "res/my_banner.png" +} +``` + +`id` is required: a unique, stable identifier (reverse-DNS style; periods, underscores, and alphanumerics). Everything +else is optional but recommended. + +`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g. +512x512), the banner (~3.5:1). If omitted, `res/icon.png` and `res/banner.png` are used automatically when present. + +--- + +## Anatomy of a Code Mod + +```cpp +#include "mods/service.hpp" +#include "mods/svc/log.h" + +DEFINE_MOD(); // once, in exactly one translation unit +IMPORT_SERVICE(LogService, svc_log); // resolved by the loader before mod_initialize + +extern "C" { + +MOD_EXPORT ModResult mod_initialize(ModError* error) { + svc_log->info(mod_ctx, "hello from my_mod"); + return MOD_OK; +} + +MOD_EXPORT ModResult mod_update(ModError* error) { // called every frame + return MOD_OK; +} + +MOD_EXPORT ModResult mod_shutdown(ModError* error) { + return MOD_OK; +} +} +``` + +All three lifecycle exports are required. `mod_ctx` is your mod's identity token, set by the loader before +`mod_initialize` runs. Pass it as the first argument to every service call. + +--- + +## Services + +A service is a struct of C function pointers with a version header. You declare what you use at file scope, and the +loader resolves it before your mod initializes: + +```cpp +IMPORT_SERVICE(LogService, svc_log); // required, any minor version +IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor version >= 2 +IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null +``` + +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 +functions to the end of the struct without breaking existing callers and simply bumps the minor version. Mods that +want the newer functions may use `IMPORT_SERVICE_VERSION` to require that minor at **load time**, or `SERVICE_HAS` to +check at **runtime** whether a specific function is available. + +The contract (see `include/mods/api.h` for the full version): + +- **A required import is guaranteed valid.** If the service is missing or too old, the mod fails to load with a clear + error. No need to null check at call sites. +- **Anything at or below the minor version you imported can be called unconditionally.** +- Optional imports may be null; check once in `mod_initialize`. +- Fields newer than your imported minor must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a null + check. + +--- + +## Built-in Services + +### LogService (`mods/svc/log.h`) + +```cpp +IMPORT_SERVICE(LogService, svc_log); + +svc_log->info(mod_ctx, "spawned the thing"); +svc_log->warn(mod_ctx, "that looks wrong"); +svc_log->error(mod_ctx, "very bad"); +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. + +### ResourceService (`mods/svc/resource.h`) + +Loads files from the `res/` tree of your `.dusk` archive. Paths are relative to `res/` (pass `"config.txt"`, not +`"res/config.txt"`); absolute paths and `..` are rejected. + +```cpp +IMPORT_SERVICE(ResourceService, svc_resource); + +ResourceBuffer buf = RESOURCE_BUFFER_INIT; +if (svc_resource->load(mod_ctx, "config.txt", &buf) == MOD_OK) { + // buf.data / buf.size + svc_resource->free(mod_ctx, &buf); +} +``` + +Missing files return `MOD_UNAVAILABLE`. Always `free` what you `load`. Note that the bundle is read-only; for writable +storage, use the directory from `svc_host->mod_dir(mod_ctx)`. + +### HostService (`mods/svc/host.h`) + +Mod metadata and runtime interaction with the loader: + +```cpp +IMPORT_SERVICE(HostService, svc_host); + +const char* id = svc_host->mod_id(mod_ctx); +const char* dir = svc_host->mod_dir(mod_ctx); // writable per-mod directory +svc_host->fail(mod_ctx, MOD_ERROR, "something unrecoverable happened"); // disables the mod +``` + +`get_service`/`publish_service` provide dynamic service lookup; see [Advanced](#advanced-exporting-services). + +**Lifecycle watches.** If your mod provides a service that hands out per-caller state (registrations, callbacks, +handles), watch other mods' lifecycle and drop what you hold for a mod when it detaches. + +```cpp +IMPORT_SERVICE_VERSION(HostService, svc_host, 1); + +void on_mod_lifecycle(ModContext* ctx, ModContext* subject, const char* subject_id, + ModLifecycleEvent event, void* user_data) { + if (event == MOD_LIFECYCLE_DETACHED) { + drop_state_for(subject); // same ModContext* the subject passed into your service + } +} + +uint64_t watch = 0; +svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch); +``` + +`MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and +every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead. + +### 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). + +### OverlayService (`mods/svc/overlay.h`) + +Registers DVD file overlays at runtime: the dynamic counterpart to the static `overlay/` directory (see +[Asset Overlays](#asset-overlays)). Overlay a disc path with a file from your bundle, or with a caller-owned buffer +(copied on registration): + +```cpp +IMPORT_SERVICE(OverlayService, svc_overlay); + +OverlayHandle handle = 0; +svc_overlay->add_file(mod_ctx, "/res/Msgus.arc", "res/replacement.arc", &handle); +svc_overlay->add_buffer(mod_ctx, "/generated.txt", data, size, nullptr); +svc_overlay->remove(mod_ctx, handle); +``` + +`disc_path` must be absolute (leading `/`) and is matched against the disc case-insensitively. Paths that don't exist +on the disc are added as new files. Changes are applied at the next frame boundary, and data the game already read +stays in memory until the file is re-read: sometimes a scene reload, and in the worst case, a full restart. + +See [Asset Overlays](#asset-overlays) for priority and conflict handling. + +### TextureService (`mods/svc/texture.h`) + +Registers texture replacements at runtime: the dynamic counterpart to the static `textures/` directory (see +[Asset Overlays](#asset-overlays)). Two forms: raw texel data with an explicit key, or an encoded `.dds`/`.png` from +your bundle whose filename encodes the key: + +```cpp +IMPORT_SERVICE(TextureService, svc_texture); + +// Encoded file; filename follows the replacement naming convention. +TextureReplacementHandle handle = 0; +svc_texture->register_file(mod_ctx, "res/tex1_32x32_$_6.png", &handle); + +// Raw data: match by texel-data pointer or by content hash (TEXTURE_KEY_SOURCE). +TextureKey key = TEXTURE_KEY_INIT; +key.kind = TEXTURE_KEY_POINTER; +key.pointer = someTexObj.data; +TextureData data = TEXTURE_DATA_INIT; +data.data = pixels; data.size = pixelsSize; +data.width = 32; data.height = 32; data.gx_format = GX_TF_RGBA8_PC; +svc_texture->register_data(mod_ctx, &key, &data, nullptr); + +svc_texture->unregister(mod_ctx, handle); +``` + +Filenames use the same Dolphin-style convention as the user's `texture_replacements` directory: +`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, where hashes may be `$` (wildcard). `_mipN` sidecar files next to +a registered file are picked up automatically. Files are decoded lazily on first use by the renderer; raw data is copied +at registration. Registrations follow your mod's lifecycle. + +See [Asset Overlays](#asset-overlays) for priority and conflict handling. + +### ConfigService (`mods/svc/config.h`) + +Persistent, mod-scoped configuration variables. Each var is stored in the user's `config.json` under +`mod..` (escaping: `.` → `_`, `_` → `__`, so `com.example.my_mod` becomes `com_example_my__mod`), +next to the host's own settings: + +```cpp +IMPORT_SERVICE(ConfigService, svc_config); + +ConfigVarDesc desc = CONFIG_VAR_DESC_INIT; +desc.name = "speedMultiplier"; // 1-64 chars from [A-Za-z0-9_-]; "enabled" is reserved +desc.type = CONFIG_VAR_FLOAT; +desc.default_float = 1.0; +ConfigVarHandle var = 0; +svc_config->register_var(mod_ctx, &desc, &var); + +double speed = 1.0; +svc_config->get_float(mod_ctx, var, &speed); +svc_config->set_float(mod_ctx, var, 2.0); + +// Optional: get notified when the value changes. +void on_speed_changed(ModContext* ctx, ConfigVarHandle var, const ConfigVarValue* value, + const ConfigVarValue* previous, void* user_data) { + /* value->float_value is the new value, previous->float_value the old one */ +} +svc_config->subscribe(mod_ctx, var, on_speed_changed, nullptr, nullptr); +``` + +Types: `CONFIG_VAR_BOOL` (`bool`), `CONFIG_VAR_INT` (`int64_t`), `CONFIG_VAR_FLOAT` (`double`), `CONFIG_VAR_STRING` +(UTF-8; `get_string` copies into a caller buffer, pass a `NULL` buffer with size 0 to query the length). Accessors are +typed and must match the registration. + +Change callbacks fire on the game thread whenever the value changes at runtime (your own `set_*` calls included). +Writes that store the same value are silent. Values applied from `config.json` or `--cvar` at registration do +**not** fire callbacks; read the value after `register_var` for the starting state. + +### UiService (`mods/svc/ui.h`) + +Integrate seamlessly with Dusklight's UI system: add controls and buttons to your mod's detail pane in the Mods window, +create custom windows and modal dialogs, apply custom RCSS stylesheets (anywhere!), and add menu bar tabs. + +**Mod panel:** Registers or replaces the panel rendered in your mod's detail pane; `build` runs every time the detail +content is rebuilt, and `update` runs every frame while that mod is selected. While your mod is selected, the detail +pane carries your mod's id as a `mod-id` attribute (like custom window roots), so scoped RCSS can target it (e.g. +`[mod-id="com.example.mod"]`). + +```cpp +IMPORT_SERVICE(UiService, svc_ui); + +UiElementHandle statusText = 0; + +ModResult build(ModContext*, UiElementHandle panel, void*, ModError*) { + svc_ui->pane_add_section(mod_ctx, panel, "Status"); + svc_ui->pane_add_text(mod_ctx, panel, "starting...", &statusText); + svc_ui->pane_add_progress(mod_ctx, panel, 0.5f, nullptr); + return MOD_OK; +} + +ModResult update(ModContext*, void*, ModError*) { + svc_ui->elem_set_text(mod_ctx, statusText, "running"); + return MOD_OK; +} + +UiModsPanelDesc panel = UI_MODS_PANEL_DESC_INIT; +panel.build = build; +panel.update = update; +svc_ui->register_mods_panel(mod_ctx, &panel); +``` + +Element setters must match the element kind: `elem_set_text`/`elem_set_rml` on text rows, and `elem_set_progress` on +progress bars. `elem_set_class` sets or clears an RCSS class on any element handle, for styling via scoped or +per-window RCSS. A non-`MOD_OK` result from `build`/`update` fails your mod, as do exceptions thrown from any UI +callback. + +**Controls:** `pane_add_control` adds an input row described by a `UiControlDesc`: `UI_CONTROL_BUTTON`, +`UI_CONTROL_TOGGLE`, `UI_CONTROL_NUMBER`, `UI_CONTROL_STRING`, or `UI_CONTROL_SELECT`. Values bind with callbacks or +directly to a config var. + +```cpp +UiControlDesc control = UI_CONTROL_DESC_INIT; +control.kind = UI_CONTROL_TOGGLE; +control.label = "Enable rainbows"; +control.help_rml = "Shown in the help pane while focused."; +control.binding = UI_BINDING_CONFIG_VAR; +control.config_var = myBoolVar; // from svc_config->register_var +svc_ui->pane_add_control(mod_ctx, leftPane, &control, nullptr); +``` + +`UI_BINDING_CONFIG_VAR` wires persistence, change notifications, and the modified indicator automatically. The var +type must match the control: `TOGGLE` = bool, `NUMBER` and `SELECT` = int, `STRING` = string. Float vars are not +bindable; use callbacks and convert. `help_rml` and `SELECT` option lists render in a help pane, so `SELECT` controls +are only available inside window tabs. + +**Windows:** `window_push` pushes a tabbed two-pane window onto the document stack and shows it. Each tab's `build` +receives the window handle plus fresh left and right pane handles on every activation. The optional per-tab `update` +runs each frame while that tab is active. `on_closed` fires when the window is destroyed. `desc.rcss` optionally styles +that window's document only; custom windows carry the owning mod's id as a `mod-id` attribute on the window root, so +scoped RCSS can target your specific mod's windows (e.g. `window[mod-id="com.example.mod"]`). + +```cpp +UiTabDesc tabs[1] = {UI_TAB_DESC_INIT}; +tabs[0].title = "Options"; +tabs[0].build = build_options_tab; + +UiWindowDesc desc = UI_WINDOW_DESC_INIT; +desc.tabs = tabs; +desc.tab_count = 1; +desc.on_closed = options_window_closed; +UiWindowHandle window = 0; +svc_ui->window_push(mod_ctx, &desc, &window); +``` + +**Dialogs:** `dialog_push` shows a modal dialog. `variant` picks the style, `icon` optionally overrides the variant's +default icon, and actions become buttons. After an action's `on_pressed` returns, the dialog closes unless the action +sets `keep_open`. A `keep_open` action can close it later (or immediately) with `dialog_close`. Cancel fires +`on_dismiss` if present and always closes. `dialog_set_body`, `dialog_set_icon`, and `dialog_add_action` mutate a live +dialog. + +**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. + +**Custom styles:** `register_styles(scope, rcss, &handle)` applies an RCSS stylesheet to every document of a scope: +existing documents restyle immediately, and future ones pick it up when created. `register_styles_file(scope, path, +&handle)` reads the sheet from your bundle's `res/` directory. Scopes are `UI_SCOPE_PRELAUNCH`, `UI_SCOPE_WINDOW`, +`UI_SCOPE_MENU_BAR`, `UI_SCOPE_OVERLAY`, `UI_SCOPE_TOUCH_CONTROLS`, and `UI_SCOPE_GRAPHICS_TUNER`. Sheets apply after +host styles and may override them. Scope selectors tightly (use `[mod-id="..."]`!), especially for `UI_SCOPE_WINDOW`, +unless changing host UI is intentional. + +### GfxService (`mods/svc/gfx.h`) + +Direct WebGPU access at various stages of the rendering pipeline. Mods use the `wgpu*` C API (via `webgpu/webgpu.h`) for +custom draws and compute dispatches. Mods must manage their own WebGPU state, including pipelines and bind groups. + +```cpp +IMPORT_SERVICE(GfxService, svc_gfx); + +GfxDeviceInfo info = GFX_DEVICE_INFO_INIT; +svc_gfx->get_device_info(mod_ctx, &info); +``` + +`register_stage_hook` runs a game-thread callback during frame recording. The public stages are: + +- `GFX_STAGE_SCENE_BEGIN`: world camera window after camera/projection/light setup +- `GFX_STAGE_SCENE_AFTER_TERRAIN`: after terrain/shadow lists, before object and translucent lists +- `GFX_STAGE_SCENE_AFTER_OPAQUE`: after sky/terrain/object opaque lists, before translucent lists +- `GFX_STAGE_FRAME_BEFORE_HUD`: 3D scene and wipe are complete, before 2D/HUD lists +- `GFX_STAGE_FRAME_AFTER_HUD`: full game scene, including HUD + +Inside a stage callback, record work with `push_draw`, stream per-frame data with `push_verts`, `push_indices`, +`push_uniform`, or `push_storage`, snapshot the current frame with `resolve_pass`, and use `create_pass`/`resolve_pass` +for temporary offscreen passes. Draw callbacks run later on the render worker thread with the live +`WGPURenderPassEncoder`; they may use only their `GfxDrawContext` handles and raw `wgpu*` calls. Compute callbacks +registered with `register_compute_type` follow the same worker-thread rule and run on the frame command encoder. + +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`. + +### CameraService (`mods/svc/camera.h`) + +Converts a game view provided by a render callback into WebGPU-convention camera data. Matrix fields are column-major +`float[16]` values using the matrix * column-vector convention (transpose of the game's row-major `Mtx`/`Mtx44` layout), +ready to copy into WGSL `mat4x4f` uniforms. + +```cpp +IMPORT_SERVICE(CameraService, svc_camera); + +CameraInfo camera = CAMERA_INFO_INIT; +if (svc_camera->get_camera(mod_ctx, game_view, &camera) == MOD_OK) { + // camera.view_from_world, camera.proj_from_view, camera.eye, ... +} +``` + +`get_camera` returns `MOD_UNAVAILABLE` while the view is not a valid perspective camera, such as before the +first in-game frame. Projection matrices match the renderer's WebGPU clip convention and renderer depth convention +(reversed-Z by default). + +--- + +## Hooking Game Functions + +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: + +```cpp +#include "mods/hook.hpp" +#include "mods/svc/hook.h" + +IMPORT_SERVICE(HookService, svc_hook); +``` + +### Pre-hooks + +Run before the original. Return `HOOK_SKIP_ORIGINAL` to cancel it (post-hooks still run). + +```cpp +HookAction on_pos_move_pre(ModContext*, void* args, void* retval, void* userdata) { + daAlink_c* link = dusk::mods::arg(args, 0); // arg 0 is `this` + if (link->shape_angle.y > 10000) { + return HOOK_SKIP_ORIGINAL; + } + return HOOK_CONTINUE; +} + +dusk::mods::hook_add_pre<&daAlink_c::posMove>(svc_hook, on_pos_move_pre); +``` + +### Post-hooks + +Run after the original (or after a replace-hook, or after a cancelled original). `retval` points to the return value, +if any. + +```cpp +void on_pos_move_post(ModContext*, void* args, void* retval, void* userdata) { ... } + +dusk::mods::hook_add_post<&daAlink_c::posMove>(svc_hook, on_pos_move_post); +``` + +### Replace-hooks + +Substitute the original entirely. Call through to it via `Hook<...>::g_orig` if needed: + +```cpp +using ExecuteEntry = dusk::mods::Hook<&daAlink_c::execute>; + +void on_execute_replace(ModContext*, void* args, void* retval, void*) { + int result = ExecuteEntry::g_orig(dusk::mods::arg(args, 0)); + if (retval != nullptr) { + *static_cast(retval) = result; + } +} + +dusk::mods::hook_replace<&daAlink_c::execute>(svc_hook, on_execute_replace); +``` + +By default a second replace-hook on the same function is a conflict; `HookOptions` (`replace_policy`, `priority`, +`userdata`) controls this and callback ordering. Multiple mods can attach pre/post hooks to the same function +independently. + +### Hooking by name + +Functions you can't name in C++ (file-local statics, private class members, anything not in a header) can be hooked by +symbol name instead. You must supply the signature along with the name. + +```cpp +// static callback used by Link's hookshot collider in d_a_alink_hook.inc +using HookshotHit = dusk::mods::NamedHook< + "daAlink_hookshotAtHitCallBack", + void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*)>; + +dusk::mods::hook_add_pre(svc_hook, on_hookshot_hit_pre); +... +HookshotHit::g_orig(link, atObjInf, target, tgObjInf); // call through to the original +``` + +Class member functions must include `Class*` as the first argument. + +The install fails with the resolve error when the name is missing (`MOD_UNAVAILABLE`), ambiguous (`MOD_CONFLICT`), +or the manifest is absent (`MOD_UNSUPPORTED`). Unlike `Hook<&Class::method>`, the signature is **not** +compiler-checked: a mismatched signature will corrupt the call. + +### Reading and writing arguments + +`args` is an array of pointers to the arguments. For member functions, index 0 is `this`; parameters follow in +declaration order. + +```cpp +T value = dusk::mods::arg(args, n); // copy +T& ref = dusk::mods::arg_ref(args, n); // read/write reference +``` + +```cpp +// fpc_ProcID fopAcM_createItem(..., int itemNo, ...): turn heart drops into green rupees +HookAction on_create_item_pre(ModContext*, void* args, void*, void*) { + int& itemNo = dusk::mods::arg_ref(args, 1); + if (itemNo == dItemNo_HEART_e) { + itemNo = dItemNo_GREEN_RUPEE_e; + } + return HOOK_CONTINUE; +} + +dusk::mods::hook_add_pre<&fopAcM_createItem>(svc_hook, on_create_item_pre); +``` + +For reference parameters (e.g. `const cXyz& pos`), `arg_ref` yields a direct reference. + +### Resolving symbols by name + +`resolve` looks a symbol up in the **symbol manifest**: a name→address map generated alongside every game build and +keyed to that exact binary. It covers the whole image, including functions that aren't exported (file-local statics), +which makes them hookable: + +```cpp +IMPORT_SERVICE(HookService, svc_hook); + +void* addr = nullptr; +uint32_t flags = 0; +if (svc_hook->resolve(mod_ctx, "GXSetProjection", &addr, &flags) == MOD_OK) { + // addr is the function's real address in the running game; hook or call it. +} +``` + +Two spellings work on every platform: + +- **Display names** (`daAlink_c::posMove`, `fapGm_Before`): the qualified name with no parameter list. They carry no + signature, so overload sets (and file-local statics sharing a name) return `MOD_CONFLICT`. +- **Decorated names** (`_ZN9daAlink_c7posMoveEv` / `?posMove@daAlink_c@@...`): the platform's mangled spelling in + dlsym convention (no Mach-O leading underscore). The escape hatch for overloads. + +`MOD_UNSUPPORTED` means the manifest is missing or was built for a different game binary. + +### Game code ABI contract + +A primary consideration when letting mods link against the game is maintaining ABI stability across Dusklight +versions. If your mod calls or hooks game code directly (anything beyond the service APIs), import `GameService` +(`mods/svc/game.h`): + +```cpp +IMPORT_SERVICE(GameService, svc_game); +``` + +Its major version is the game code ABI epoch: it's bumped when game struct or vtable layouts change incompatibly, and +the ordinary service version check then rejects your mod with a clear error instead of letting it corrupt memory in a +version it wasn't built for. Service-only and asset-only mods should *not* import it and will continue to work across +game ABI changes. + +The more you can do through services, the better: a mod that avoids touching game code directly sidesteps future ABI +breaks entirely and plays nicer with other enabled mods. + +--- + +## Asset Overlays + +Files placed under `overlay/` in the `.dusk` archive override game files at the corresponding path, equivalent to +replacing files in the .iso. This requires no code: an archive with just `mod.json` and `overlay/` is a complete mod. + +Files placed under `textures/` register as texture replacements, and act just like the user's general +`texture_replacements/` directory: Dolphin-style naming, matched by texture hash +(`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, `$` as a hash wildcard). Subdirectories are scanned recursively; +only the filename needs to match. + +Both mechanisms are tied to the mod's lifecycle: disabling the mod removes its overrides (files revert to the disc +contents on their next open; added files stop existing), and reloading serves the new bundle's content. However, game +data the engine already read stays as-is until it is loaded again, which may require a scene change or, in the worst +case, a full restart. Texture replacements usually take effect immediately. + +If multiple sources replace the same file or texture, the last one wins: runtime registrations override static +`textures/` or `overlay/` files, and later-loaded mods override earlier ones. Cross-mod conflicts log warnings. +**All** mod-provided texture replacements override the user's `texture_replacements/`. + +To configure overlays and texture replacements at runtime instead, see [OverlayService](#overlayservice-modssvcoverlayh) +and [TextureService](#textureservice-modssvctextureh). + +--- + +## Runtime Lifecycle + +Mods can be disabled, re-enabled, and reloaded at runtime without restarting the game (the enabled state persists as the +`mod..enabled` config var). Write your mod assuming this happens: + +- **Disable** calls `mod_shutdown`, removes your hooks, services, overlays, and texture replacements (both static and + runtime-registered), and unloads your library. +- **Enable** and **Reload** load a *fresh copy* of your library, imports are re-resolved, and `mod_initialize` runs + again. You never see a second `mod_initialize` on the same image, so just make `mod_shutdown` release anything the + loader doesn't manage for you (threads, files, game-side state you mutated). +- **Reload** additionally re-reads the `.dusk` from disk, picking up a rebuilt library and changed assets. This is the + fast iteration loop during development: rebuild, click Reload. + +**Dependents restart too.** Disabling or reloading a mod that exports services shuts down the mods importing them +first (in reverse dependency order) and brings them back afterward. A mod whose *required* provider is disabled stays +suspended and resumes automatically when the provider returns. Mods with an *optional* import of a disabled provider +restart with that import null. + +**One caution for hooks:** lifecycle changes are applied between frames, which is safe for hooks on functions +that return every frame (effectively everything you'd normally hook). Avoid hooking a function that stays on +the stack for the whole session (e.g. the outermost main loop); a mod that does cannot be safely unloaded. + +--- + +## Error Handling + +Service calls report failure through `ModResult` return values (`MOD_OK`, `MOD_UNAVAILABLE`, +`MOD_INVALID_ARGUMENT`, ...). Lifecycle exports additionally receive a `ModError*`: fill it (e.g. with +`dusk::mods::set_error(error, code, "message")`) and return the code, and the loader disables the mod and shows the +message to the user. + +```cpp +MOD_EXPORT ModResult mod_initialize(ModError* error) { + if (!load_my_data()) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to load data"); + } + return MOD_OK; +} +``` + +Throwing exceptions out of lifecycle functions also disables the mod (they are caught by the loader), but prefer +explicit results. + +--- + +## Advanced: Exporting Services + +Mods may export services of their own, permitting framework mods and cross-mod integration. Define the interface in a +header both mods share: + +```cpp +// my_mod_api.h +#include "mods/api.h" + +#define MY_MOD_SERVICE_ID "com.example.my_mod.api" +#define MY_MOD_SERVICE_MAJOR 1u +#define MY_MOD_SERVICE_MINOR 0u + +typedef struct MyModService { + ServiceHeader header; + ModResult (*do_thing)(ModContext* ctx, int value); +} MyModService; + +#ifdef __cplusplus +#include "mods/service.hpp" +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = MY_MOD_SERVICE_ID; + static constexpr uint16_t major_version = MY_MOD_SERVICE_MAJOR; +}; +#endif +``` + +**Provider:** + +```cpp +ModResult do_thing(ModContext* ctx, int value) { ... } + +constexpr MyModService g_service{ + .header = SERVICE_HEADER(MyModService, MY_MOD_SERVICE_MAJOR, MY_MOD_SERVICE_MINOR), + .do_thing = do_thing, +}; +EXPORT_SERVICE(g_service); +``` + +**Consumer:** + +```cpp +IMPORT_SERVICE(MyModService, svc_my_mod); +// or IMPORT_OPTIONAL_SERVICE if the dependency is optional + +svc_my_mod->do_thing(mod_ctx, 42); +``` + +The loader registers all exports before resolving any imports, so declaration order between mods doesn't matter. Note +that the `ctx` a provider receives identifies the *calling* mod. + +### Dependencies between mods + +Service imports are also dependency declarations: the loader initializes mods in dependency order, so by the time your +`mod_initialize` runs, every mod you import services from (required *or* optional) has already finished its own +`mod_initialize`. This includes deferred services: a service the provider publishes during its initialization resolves +into your import slot just like a static export. + +Consequences of that contract: + +- If a provider fails to load, every mod that *requires* one of its services is disabled too, with an error naming the + provider. Optional imports of a failed provider simply resolve to `NULL`. +- Mods whose **required** imports form a cycle all fail to load. If the cycle runs through an **optional** import, the + loader breaks it there: the optional import still resolves, but its provider may not be initialized yet when you run. +- `svc_host->get_service(...)` is outside this system. It sees whatever is published at call time and gives no + initialization-order guarantee, which also makes it the escape hatch for intentionally cyclic designs. + +Mods shut down in reverse initialization order, so services you import remain safe to call from `mod_shutdown`. + +Rules for providers: + +- Service IDs are global and use reverse-DNS names (e.g. `com.mydomain.mod.service`) +- Every function pointer covered by your declared minor version must be populated. +- Within a major version, only append fields; never reorder, remove, or repurpose them. Breaking changes require a major + bump (which is, in effect, a new service). +- Only one provider per `(id, major)` pair may be registered; duplicates are load errors. + +For services whose construction can't happen at static-init time, declare the export with `EXPORT_DEFERRED_SERVICE(...)` +and publish the pointer later via `svc_host->publish_service(...)`. Consumers can fetch services dynamically with +`svc_host->get_service(...)`; prefer manifest imports whenever possible, since they give the loader dependency +information and fail fast with good errors. diff --git a/extern/aurora b/extern/aurora index 9087a409da..1dde08fa0d 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 9087a409da35b17446af12d7456ec6563cf2dd43 +Subproject commit 1dde08fa0d0030133788a6250a81c8b9c44f246f diff --git a/files.cmake b/files.cmake index 730458e61d..52b33a3e5a 100644 --- a/files.cmake +++ b/files.cmake @@ -1500,6 +1500,12 @@ set(DUSK_FILES src/dusk/ui/input.hpp src/dusk/ui/icon_provider.cpp src/dusk/ui/icon_provider.hpp + src/dusk/ui/logs_window.cpp + src/dusk/ui/logs_window.hpp + src/dusk/ui/mod_texture_provider.cpp + src/dusk/ui/mod_texture_provider.hpp + src/dusk/ui/mod_window.cpp + src/dusk/ui/mod_window.hpp src/dusk/ui/modal.cpp src/dusk/ui/modal.hpp src/dusk/ui/nav_types.hpp @@ -1511,6 +1517,8 @@ set(DUSK_FILES src/dusk/ui/pane.hpp src/dusk/ui/menu_bar.cpp src/dusk/ui/menu_bar.hpp + src/dusk/ui/mods_window.cpp + src/dusk/ui/mods_window.hpp src/dusk/ui/prelaunch.cpp src/dusk/ui/prelaunch.hpp src/dusk/ui/preset.cpp @@ -1549,6 +1557,34 @@ set(DUSK_FILES src/dusk/OSReport.cpp src/dusk/OSThread.cpp src/dusk/OSMutex.cpp + src/dusk/mods/log_buffer.cpp + src/dusk/mods/log_buffer.hpp + src/dusk/mods/manifest.cpp + src/dusk/mods/manifest.hpp + src/dusk/mods/loader/bundle_disk.cpp + src/dusk/mods/loader/bundle_zip.cpp + src/dusk/mods/loader/context.cpp + src/dusk/mods/loader/depgraph.cpp + src/dusk/mods/loader/depgraph.hpp + src/dusk/mods/loader/loader.cpp + src/dusk/mods/loader/loader.hpp + src/dusk/mods/loader/native_module.cpp + src/dusk/mods/loader/native_module.hpp + src/dusk/mods/svc/camera.cpp + src/dusk/mods/svc/config.cpp + src/dusk/mods/svc/config.hpp + src/dusk/mods/svc/game.cpp + src/dusk/mods/svc/gfx.cpp + src/dusk/mods/svc/hook.cpp + src/dusk/mods/svc/host.cpp + src/dusk/mods/svc/log.cpp + src/dusk/mods/svc/overlay.cpp + src/dusk/mods/svc/resource.cpp + src/dusk/mods/svc/texture.cpp + src/dusk/mods/svc/ui.cpp + src/dusk/mods/svc/ui.hpp + src/dusk/mods/svc/registry.cpp + src/dusk/mods/svc/registry.hpp src/dusk/discord.cpp src/dusk/discord.hpp src/dusk/discord_presence.cpp diff --git a/include/SSystem/SComponent/c_bg_s_shdw_draw.h b/include/SSystem/SComponent/c_bg_s_shdw_draw.h index 9ec3023306..55792f8410 100644 --- a/include/SSystem/SComponent/c_bg_s_shdw_draw.h +++ b/include/SSystem/SComponent/c_bg_s_shdw_draw.h @@ -20,7 +20,7 @@ public: /* 0x14 */ cM3dGAab mM3dGAab; /* 0x30 */ cBgS_ShdwDraw_Callback mCallbackFun; - #if DEBUG + #if PARTIAL_DEBUG || DEBUG /* 0x34 */ int field_0x34; #endif }; diff --git a/include/Z2AudioLib/Z2AudioMgr.h b/include/Z2AudioLib/Z2AudioMgr.h index e543bf6061..0f8e5a17ea 100644 --- a/include/Z2AudioLib/Z2AudioMgr.h +++ b/include/Z2AudioLib/Z2AudioMgr.h @@ -48,6 +48,8 @@ public: /* 0x1370 */ Z2FxLineMgr mFxLineMgr; #if DEBUG /* 0x13BC */ Z2DebugSys mDebugSys; + #elif PARTIAL_DEBUG + alignas(Z2DebugSys) u8 mDebugSys[sizeof(Z2DebugSys)]; #endif }; // Size: 0x138C diff --git a/include/Z2AudioLib/Z2SeqMgr.h b/include/Z2AudioLib/Z2SeqMgr.h index 76c2674697..94191a1318 100644 --- a/include/Z2AudioLib/Z2SeqMgr.h +++ b/include/Z2AudioLib/Z2SeqMgr.h @@ -194,7 +194,7 @@ public: JAISoundHandle* getMainBgmHandle() { return &mMainBgmHandle; } JAISoundHandle* getSubBgmHandle() { return &mSubBgmHandle; } - #if DEBUG + #if PARTIAL_DEBUG || DEBUG f32 field_0x00_debug; u8 field_0x04_debug; #endif diff --git a/include/Z2AudioLib/Z2SoundObjMgr.h b/include/Z2AudioLib/Z2SoundObjMgr.h index c0b4ed778c..687f9eaf0a 100644 --- a/include/Z2AudioLib/Z2SoundObjMgr.h +++ b/include/Z2AudioLib/Z2SoundObjMgr.h @@ -100,13 +100,13 @@ public: bool isForceBattle() { return forceBattle_; } JSUList* getEnemyList() { return &field_0x0; } - #if DEBUG + #if PARTIAL_DEBUG || DEBUG JSUList* getAllList() { return &allList_; } #endif private: /* 0x00 */ JSUList field_0x0; - #if DEBUG + #if PARTIAL_DEBUG || DEBUG /* 0x0C */ JSUList allList_; #endif /* 0x0C */ Z2EnemyArea enemyArea_; diff --git a/include/Z2AudioLib/Z2SoundObject.h b/include/Z2AudioLib/Z2SoundObject.h index ca4b69a520..2d85db34c0 100644 --- a/include/Z2AudioLib/Z2SoundObject.h +++ b/include/Z2AudioLib/Z2SoundObject.h @@ -7,7 +7,7 @@ struct Z2SoundStarter; class Z2SoundObjBase : public Z2SoundHandles -#if DEBUG +#if PARTIAL_DEBUG || DEBUG , public JSULink #endif { diff --git a/include/d/d_attention.h b/include/d/d_attention.h index 455516e54f..5b8930b5dd 100644 --- a/include/d/d_attention.h +++ b/include/d/d_attention.h @@ -45,7 +45,7 @@ private: class dAttParam_c : public JORReflexible { public: -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x04 */ s8 mHIOChildNo; #endif @@ -66,7 +66,7 @@ public: /* 0x35 */ u8 mAttnCursorDisappearFrames; /* 0x38 */ f32 field_0x38; /* 0x3C */ f32 field_0x3c; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x44 */ s32 mDebugDispPosX; /* 0x48 */ s32 mDebugDispPosY; #endif diff --git a/include/d/d_bg_s_acch.h b/include/d/d_bg_s_acch.h index 33bca24c0a..04a388c61b 100644 --- a/include/d/d_bg_s_acch.h +++ b/include/d/d_bg_s_acch.h @@ -201,7 +201,7 @@ private: /* 0x02C */ u32 m_flags; /* 0x030 */ cXyz* pm_pos; /* 0x034 */ cXyz* pm_old_pos; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x038 */ cXyz unk_0x38; #endif /* 0x038 */ cXyz* pm_speed; @@ -229,7 +229,7 @@ private: /* 0x0CC */ f32 field_0xcc; /* 0x0D0 */ f32 m_wtr_chk_offset; /* 0x0D4 */ cBgS_PolyInfo* pm_out_poly_info; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x0E4 */ cXyz unk_0xe4; #endif /* 0x0D8 */ f32 field_0xd8; diff --git a/include/d/d_cc_s.h b/include/d/d_cc_s.h index b4d90787ae..d156867cce 100644 --- a/include/d/d_cc_s.h +++ b/include/d/d_cc_s.h @@ -79,7 +79,7 @@ public: // /* 0x0000 */ cCcS mCCcS; /* 0x284C */ dCcMassS_Mng mMass_Mng; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x2AD0 */ u8 m_is_mass_all_timer; #endif }; // Size = 0x2AC4 diff --git a/include/d/d_com_inf_game.h b/include/d/d_com_inf_game.h index 3bbdb4f449..4b8ced6a98 100644 --- a/include/d/d_com_inf_game.h +++ b/include/d/d_com_inf_game.h @@ -21,7 +21,7 @@ #include "m_Do/m_Do_graphic.h" #include -#include "tracy/Tracy.hpp" +#include "dusk/profiling.hpp" enum dComIfG_ButtonStatus { /* 0x00 */ BUTTON_STATUS_NONE, @@ -1044,7 +1044,7 @@ public: /* 0x1DE09 */ u8 field_0x1de09; /* 0x1DE0A */ u8 field_0x1de0a; /* 0x1DE0B */ u8 mIsDebugMode; - #if DEBUG + #if PARTIAL_DEBUG || DEBUG /* 0x1DE0C */ OSStopwatch mStopwatch; #endif @@ -1056,7 +1056,7 @@ public: STATIC_ASSERT(122384 == sizeof(dComIfG_inf_c)); -extern dComIfG_inf_c g_dComIfG_gameInfo; +DUSK_GAME_EXTERN dComIfG_inf_c g_dComIfG_gameInfo; extern GXColor g_blackColor; extern GXColor g_clearColor; extern GXColor g_whiteColor; diff --git a/include/d/d_event_manager.h b/include/d/d_event_manager.h index 03a8ef7ac3..de046797ff 100644 --- a/include/d/d_event_manager.h +++ b/include/d/d_event_manager.h @@ -41,7 +41,7 @@ public: BASE_ROOM5, BASE_DEMO, - #if DEBUG + #if PARTIAL_DEBUG || DEBUG BASE_DEBUG, #endif diff --git a/include/d/d_kankyo.h b/include/d/d_kankyo.h index 3c81f08d10..fb3d0435b0 100644 --- a/include/d/d_kankyo.h +++ b/include/d/d_kankyo.h @@ -259,7 +259,7 @@ public: /* 0x09B8 */ DUNGEON_LIGHT dungeonlight[8]; /* 0x0C18 */ BOSS_LIGHT field_0x0c18[8]; /* 0x0D58 */ BOSS_LIGHT field_0x0d58[6]; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x0E48 */ NAVYCHAN navy; /* 0x0E58 */ u8 field_0xe58[0xE68 - 0xE58]; // part of NAVYCHAN? #endif @@ -471,7 +471,7 @@ public: /* 0x130C */ u8 staffroll_next_timer; }; // Size: 0x1310 -extern dScnKy_env_light_c g_env_light; +DUSK_GAME_EXTERN dScnKy_env_light_c g_env_light; STATIC_ASSERT(sizeof(dScnKy_env_light_c) == 4880); diff --git a/include/d/d_particle.h b/include/d/d_particle.h index c9db174fcd..3098dbd521 100644 --- a/include/d/d_particle.h +++ b/include/d/d_particle.h @@ -521,13 +521,13 @@ private: /* 0x019 */ u8 field_0x19; /* 0x01A */ u8 field_0x1a; /* 0x01B */ u8 field_0x1b; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x01C */ dPa_simpleEcallBack field_0x1c[48]; #else /* 0x01C */ dPa_simpleEcallBack field_0x1c[25]; #endif /* 0x210 */ level_c field_0x210; - #if DEBUG + #if PARTIAL_DEBUG || DEBUG u8 mSceneCount; #endif }; diff --git a/include/d/d_resorce.h b/include/d/d_resorce.h index 31d028a082..b70d2d5452 100644 --- a/include/d/d_resorce.h +++ b/include/d/d_resorce.h @@ -59,7 +59,7 @@ private: /* 0x18 */ JKRHeap* heap; /* 0x1C */ JKRSolidHeap* mDataHeap; /* 0x20 */ void** mRes; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x24 */ int mSize; #endif }; // Size: 0x24 diff --git a/include/d/d_save.h b/include/d/d_save.h index be09b95f6e..7182e151b2 100644 --- a/include/d/d_save.h +++ b/include/d/d_save.h @@ -1041,7 +1041,7 @@ public: static const int ZONE_MAX = 0x20; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x000 */ u8 unk_0x0; /* 0x001 */ char unk_0x1; /* 0x000 */ u8 unk_0x2[0x48 - 0x2]; @@ -1062,6 +1062,9 @@ public: /* 0xF30 */ s64 mSaveTotalTime; #if DEBUG /* 0xF80 */ flagFile_c mFlagFile; +#elif PARTIAL_DEBUG + // flagFile_c's ctor/virtuals are only defined under #if DEBUG (d_save.cpp) + alignas(flagFile_c) u8 mFlagFile[sizeof(flagFile_c)]; #endif }; // Size: 0xF38 diff --git a/include/d/d_stage.h b/include/d/d_stage.h index 1298a2d910..19aaf490b5 100644 --- a/include/d/d_stage.h +++ b/include/d/d_stage.h @@ -538,7 +538,7 @@ public: /* vt[86] */ virtual stage_tgsc_class* getDrTg(void) const = 0; /* vt[87] */ virtual void setDoor(stage_tgsc_class*) = 0; /* vt[88] */ virtual stage_tgsc_class* getDoor(void) const = 0; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG virtual void setUnit(void*) = 0; virtual void* getUnit() = 0; #endif @@ -796,7 +796,7 @@ public: virtual stage_tgsc_class* getDrTg(void) const { return mDrTg; } virtual void setDoor(stage_tgsc_class* i_Door) { mDoor = i_Door; } virtual stage_tgsc_class* getDoor(void) const { return mDoor; } -#if DEBUG +#if PARTIAL_DEBUG || DEBUG virtual void setUnit(void* i_Unit) { mUnit = i_Unit; } virtual void* getUnit() { return mUnit; } #endif @@ -845,7 +845,7 @@ public: /* 0x54 */ stage_tgsc_class* mDrTg; /* 0x58 */ stage_tgsc_class* mDoor; /* 0x5C */ dStage_FloorInfo_c* mFloorInfo; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x60 */ void* mUnit; #endif /* 0x60 */ u16 mPlayerNum; @@ -990,7 +990,7 @@ public: /* vt[86] */ virtual stage_tgsc_class* getDrTg(void) const { return mDrTg; } /* vt[87] */ virtual void setDoor(stage_tgsc_class* i_Door) { mDoor = i_Door; } /* vt[88] */ virtual stage_tgsc_class* getDoor(void) const { return mDoor; } -#if DEBUG +#if PARTIAL_DEBUG || DEBUG virtual void setUnit(void* i_Unit) { UNUSED(i_Unit); OSReport("stage non unit list data !!\n"); diff --git a/include/d/d_vibration.h b/include/d/d_vibration.h index 4018acea50..ee87f7dcc4 100644 --- a/include/d/d_vibration.h +++ b/include/d/d_vibration.h @@ -97,6 +97,9 @@ public: private: #if DEBUG /* 0x00 */ dVibTest_c mVibTest; +#elif PARTIAL_DEBUG + // dVibTest_c's ctor/virtuals are only defined under #if DEBUG (d_vibration.cpp) + alignas(dVibTest_c) u8 mVibTest[sizeof(dVibTest_c)]; #endif class { diff --git a/include/dusk/config.hpp b/include/dusk/config.hpp index 382c4c24c6..6d36dff1f3 100644 --- a/include/dusk/config.hpp +++ b/include/dusk/config.hpp @@ -1,9 +1,11 @@ #ifndef DUSK_CONFIG_HPP #define DUSK_CONFIG_HPP +#include #include +#include #include -#include "nlohmann/json.hpp" + #include "config_var.hpp" namespace dusk::config { @@ -40,7 +42,7 @@ public: [[nodiscard]] virtual nlohmann::json dumpToJson(const ConfigVarBase& cVar) const = 0; }; -template +template class ConfigImpl : public ConfigImplBase { // Just downcasting the references... void loadFromJson(ConfigVarBase& cVar, const nlohmann::json& jsonValue) const final { @@ -90,20 +92,28 @@ public: void Register(ConfigVarBase& configVar); /** - * \brief Indicate that all registrations have happened and everything should lock in. + * \brief Unregister a CVar, detaching it from the config system. + * + * If the CVar carries a user-set value (Value or Speedrun layer), it is stashed as an + * unregistered key: Save() keeps writing it, and a later Register() of the same name restores + * it through the normal back-fill path. The CVar may be destroyed after this returns. */ -void FinishRegistration(); +void unregister(ConfigVarBase& configVar); /** * \brief Load config from the standard user preferences location. */ -void LoadFromUserPreferences(); -void LoadFromFileName(const char* path); +void load_from_user_preferences(); +void load_from_file_name(const char* path); + +void load_arg_override(std::string_view name, std::string_view value); + +void shutdown(); /** * \brief Save the config to file. */ -void Save(); +void save(); /** * \brief Get a registered CVar by name. @@ -124,6 +134,58 @@ void ClearAllActionBindings(int port); */ void EnumerateRegistered(std::function callback); +/** + * \brief Type-erased change callback. previousValue points at the value before the mutation + * (a `const T*` for a `ConfigVar`) and is valid only for the duration of the call. + */ +using ChangeCallback = std::function; + +/** + * \brief Token identifying a change subscription. 0 is never a valid token. + */ +using Subscription = u64; + +/** + * \brief Subscribe to changes of the named CVar (registered or not yet registered). + * + * Fired synchronously on the mutating thread (in practice the game thread) whenever the CVar's + * effective value changes at runtime: setValue, override/speedrun setters and clears. Values + * applied by config load or launch arguments do *not* notify: loads happen during startup + * before the subsystems callbacks push values into are initialized, and each subsystem reads + * its initial value itself at its own init. Callbacks may mutate other CVars; a nested + * mutation of the same CVar applies but does not re-notify. + */ +Subscription subscribe(std::string_view name, ChangeCallback callback); + +/** + * \brief Typed convenience overload: the callback receives the current and previous values. + */ +template +requires std::invocable Subscription subscribe( + ConfigVar& cVar, Callback&& callback) { + return subscribe(cVar.getName(), + [&cVar, cb = std::forward(callback)](ConfigVarBase&, const void* previousValue) { + cb(cVar.getValue(), *static_cast(previousValue)); + }); +} + +void unsubscribe(Subscription token); + +/** + * \brief Register a CVar and attach a change callback in one step. +* + * Useful for pushing settings into external systems (e.g. aurora) from one place instead of + * every UI setter. The callback fires only for runtime changes (see subscribe); not when + * loaded from config or launch arguments. + */ +template +requires std::invocable Subscription Register( + ConfigVar& cVar, Callback&& onChange) { + auto subscription = subscribe(cVar, std::forward(onChange)); + Register(static_cast(cVar)); + return subscription; +} + template const ConfigImplBase* GetConfigImpl() { static ConfigImpl config; diff --git a/include/dusk/config_var.hpp b/include/dusk/config_var.hpp index 258bf4143c..52e62172cb 100644 --- a/include/dusk/config_var.hpp +++ b/include/dusk/config_var.hpp @@ -2,10 +2,12 @@ #define DUSK_CONFIG_VAR_HPP #include "dolphin/types.h" -#include +#include #include #include +#include #include +#include /** * The configuration system. @@ -69,7 +71,7 @@ protected: /** * The name of this CVar, used in the configuration file. */ - const char* name; + std::string name; /** * Whether this CVar has been registered with the global managing logic. @@ -87,8 +89,8 @@ protected: */ const ConfigImplBase* impl; - ConfigVarBase(const char* name, const ConfigImplBase* impl); - virtual ~ConfigVarBase() = default; + ConfigVarBase(const ConfigVarBase&) = delete; + ConfigVarBase(std::string name, const ConfigImplBase* impl); /** * Check that the CVar is registered, aborting if this is not the case. @@ -98,7 +100,22 @@ protected: abort(); } + /** + * Whether any change subscriber (see config::subscribe) is attached to this CVar's name. + */ + [[nodiscard]] bool has_subscribers() const; + + /** + * Notify change subscribers (see config::subscribe) that the effective value of this CVar + * changed. Called by mutators after the change has been applied; previousValue points at + * the old value (a `const T*` for a `ConfigVar`), valid only for the duration of the + * call. + */ + void notify_changed(const void* previousValue); + public: + virtual ~ConfigVarBase(); + /** * Get the name of this CVar, used in the configuration file. */ @@ -121,6 +138,7 @@ public: * This is necessary to make it legal to access. */ void markRegistered(); + void unmarkRegistered(); /** * Clear a speedrun-mode override if one is active on this CVar. @@ -155,6 +173,7 @@ template concept ConfigValue = !std::is_const_v && !std::is_volatile_v + && std::equality_comparable && (std::is_same_v || ConfigValueInteger || std::is_same_v @@ -166,6 +185,9 @@ concept ConfigValue = template const ConfigImplBase* GetConfigImpl(); +template +class ConfigImpl; + template struct ConfigEnumRange { static constexpr auto min = std::numeric_limits>::min(); @@ -192,10 +214,12 @@ public: * @param arg Arguments to forward to construct the default value. */ template - ConfigVar(const char* name, Args&&... arg) - : ConfigVarBase(name, GetConfigImpl()), defaultValue(std::forward(arg)...), + ConfigVar(std::string name, Args&&... arg) + : ConfigVarBase(std::move(name), GetConfigImpl()), defaultValue(std::forward(arg)...), value(), overrideValue() {} + ConfigVar(ConfigVar const&) = delete; + /** * \brief Get the current value of the CVar. * @@ -234,6 +258,7 @@ public: */ void setValue(T newValue, bool replaceOverride = true) { checkRegistered(); + const auto previous = previous_for_notify(); value = std::move(newValue); if (replaceOverride) { @@ -242,6 +267,7 @@ public: } else if (layer != ConfigVarLayer::Override) { layer = ConfigVarLayer::Value; } + notify_if_changed(previous); } operator const T&() { @@ -258,8 +284,10 @@ public: */ void setOverrideValue(T newValue) { checkRegistered(); + const auto previous = previous_for_notify(); overrideValue = std::move(newValue); layer = ConfigVarLayer::Override; + notify_if_changed(previous); } /** @@ -273,25 +301,31 @@ public: void setSpeedrunValue(T newValue) { checkRegistered(); if (layer != ConfigVarLayer::Override) { + const auto previous = previous_for_notify(); priorLayer = layer; overrideValue = std::move(newValue); layer = ConfigVarLayer::Speedrun; + notify_if_changed(previous); } } void clearOverride() { checkRegistered(); if (layer == ConfigVarLayer::Override) { + const auto previous = previous_for_notify(); overrideValue = {}; layer = ConfigVarLayer::Value; + notify_if_changed(previous); } } void clearSpeedrunOverride() override { checkRegistered(); if (layer == ConfigVarLayer::Speedrun) { + const auto previous = previous_for_notify(); overrideValue = {}; layer = priorLayer; + notify_if_changed(previous); } } @@ -305,6 +339,48 @@ public: const ConfigVarLayer effectiveLayer = (layer == ConfigVarLayer::Speedrun) ? priorLayer : layer; return effectiveLayer == ConfigVarLayer::Default ? defaultValue : value; } + +private: + // The config loader applies values through the silent load_* methods below. + friend class ConfigImpl; + + /** + * Copy of the effective value before a mutation, taken only when someone is subscribed. + */ + [[nodiscard]] std::optional previous_for_notify() const { + return has_subscribers() ? std::optional{getValue()} : std::nullopt; + } + + /** + * Notify subscribers if the effective value actually changed across a mutation. + */ + void notify_if_changed(const std::optional& previous) { + if (previous.has_value() && !(getValue() == *previous)) { + notify_changed(&*previous); + } + } + + /** + * setValue(newValue, false) without notifying change subscribers. Used when loading config: + * loads happen during startup before the subsystems change callbacks push values into are + * initialized, and each subsystem applies the loaded value itself at its own init. + */ + void load_value(T newValue) { + checkRegistered(); + value = std::move(newValue); + if (layer != ConfigVarLayer::Override) { + layer = ConfigVarLayer::Value; + } + } + + /** + * setOverrideValue without notifying change subscribers (see load_value). + */ + void load_override_value(T newValue) { + checkRegistered(); + overrideValue = std::move(newValue); + layer = ConfigVarLayer::Override; + } }; using ActionBindConfigVar = ConfigVar; diff --git a/include/dusk/gfx.hpp b/include/dusk/gfx.hpp new file mode 100644 index 0000000000..adb04bee51 --- /dev/null +++ b/include/dusk/gfx.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include "mods/svc/gfx.h" + +namespace dusk::mods { + +void gfx_run_stage(GfxStage stage, const view_class* gameView = nullptr, + const view_port_class* gameViewport = nullptr); + +} // namespace dusk::mods diff --git a/include/dusk/gx_helper.h b/include/dusk/gx_helper.h index bf5f3c4d3a..02a36fc6db 100644 --- a/include/dusk/gx_helper.h +++ b/include/dusk/gx_helper.h @@ -5,7 +5,8 @@ #include #include -#include "tracy/Tracy.hpp" + +#include "profiling.hpp" #if DUSK_GFX_DEBUG_GROUPS #define GX_DEBUG_GROUP(name, ...) \ diff --git a/include/dusk/main.h b/include/dusk/main.h index 0a2be8734d..a16291a8a7 100644 --- a/include/dusk/main.h +++ b/include/dusk/main.h @@ -12,6 +12,18 @@ extern bool RestartRequested; extern std::filesystem::path ConfigPath; extern std::filesystem::path CachePath; +extern uint8_t SaveRequested; +struct StageRequest { + std::string stage; + bool set; + s8 room; + s16 point; + s8 layer; +}; +extern StageRequest StageRequested; + + + #if defined(__ANDROID__) || (defined(TARGET_OS_IOS) && TARGET_OS_IOS) || \ (defined(TARGET_OS_TV) && TARGET_OS_TV) inline constexpr bool SupportsProcessRestart = false; diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp new file mode 100644 index 0000000000..9d77f6984a --- /dev/null +++ b/include/dusk/mod_loader.hpp @@ -0,0 +1,245 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "dusk/config.hpp" +#include "dusk/config_var.hpp" +#include "mods/api.h" + +namespace dusk::mods { +struct LoadedMod; +class ModBundle; +} // namespace dusk::mods + +struct ModContext { + dusk::mods::LoadedMod* mod = nullptr; +}; + +namespace dusk::mods::loader { +class NativeModule; +} // namespace dusk::mods::loader + +namespace dusk::mods { + +struct ModDependencyEdge { + LoadedMod* mod = nullptr; + bool required = false; +}; + +struct ModManifestInfo { + struct Import { + std::string id; + uint16_t major = 0; + bool required = false; + bool operator==(const Import&) const = default; + }; + struct Export { + std::string id; + uint16_t major = 0; + bool operator==(const Export&) const = default; + }; + std::vector imports; + std::vector exports; + bool operator==(const ModManifestInfo&) const = default; +}; + +struct ModMetadata { + std::string id; + std::string name; + std::string version; + std::string author; + std::string description; + std::string iconPath; + std::string bannerPath; +}; + +struct ModSearchDir { + std::filesystem::path path; + // Directory bundles dlopen their native lib in place instead of extracting it to the cache. + // Required where extracted code cannot run (iOS), desirable for signed/read-only installs. + bool inPlaceNative = false; + // Native library location for platforms that restrict placement (e.g. iOS/tvOS Frameworks/) + std::filesystem::path nativeLibDir; +}; + +struct NativeMod { + std::unique_ptr handle; + const ModManifest* manifest = nullptr; + ModContext** contextSymbol = nullptr; + + ModInitializeFn fn_initialize = nullptr; + ModUpdateFn fn_update = nullptr; + ModShutdownFn fn_shutdown = nullptr; +}; + +enum class NativeModStatus : u8 { + /** + * Mod does not have native code included. + */ + None, + + /** + * Native code mod loaded successfully. + * + * Note that this only indicates load status of the native library. If the native lib throws in + * its init function, it will still be disabled! + */ + Loaded, + + /** + * This build was compiled without native mod support! + */ + BuildDisabled, + + /** + * Mod ships native libraries, but none matches this build's platform and architecture. + */ + ModMissingPlatform, + + /** + * Mod is built for a different ABI version than this build of the game. + */ + ApiVersionMismatch, + + /** + * Mod is missing a required native API export. + */ + MissingExport, + + /** + * Unknown error loading the native mod. + */ + Unknown, +}; + +struct LoadedMod { + ModMetadata metadata; + std::string modPath; + std::string dir; + + uint32_t searchDirIndex = 0; + // Native lib is dlopen'd in place and stays resident for the session. Reload is unsupported. + bool inPlace = false; + + std::unique_ptr > cvarIsEnabled; + config::Subscription enabledSubscription = 0; + + bool active = false; + bool loadFailed = false; + std::string failureReason; + + // mod_initialize succeeded; a mod_shutdown is owed on deactivation. + bool initialized = false; + // Static service exports are currently present in the registry. + bool servicesRegistered = false; + // Lifecycle state last applied by the loader; diffed against cvarIsEnabled to pick up + // runtime enable/disable requests. + bool enabledApplied = false; + // Deactivated because a provider it imports from was disabled, not by its own cvar. + bool suspendedByProvider = false; + // Bumped per native lib extraction so every dlopen sees a fresh path (and thus a fresh + // image with fresh statics; a previous dlclose may not fully unmap). Also bumped by + // asset-only reloads, so it doubles as a generation for anything caching per-mod content. + uint32_t cacheGeneration = 0; + // Currently extracted native library, empty if none. + std::string nativePath; + + NativeModStatus nativeStatus = NativeModStatus::None; + std::unique_ptr native; + std::unique_ptr context; + + // Shared with overlay file registrations so in-flight DVD reads survive disable/reload. + std::shared_ptr bundle; + + ModManifestInfo manifestInfo; + + // Mods this mod imports services from, and mods importing services from this mod. + std::vector dependencies; + std::vector dependents; +}; + +class ModLoader { +public: + static ModLoader& instance(); + + void set_search_dirs(std::vector dirs) { m_searchDirs = std::move(dirs); } + void set_cache_dir(std::filesystem::path dir) { m_cacheDir = std::move(dir); } + void init(); + void tick(); + void shutdown(); + + void request_enable(std::string_view id); + void request_disable(std::string_view id); + void request_reload(std::string_view id); + void notify_mod_failure(LoadedMod& mod, bool firstFailure); + + [[nodiscard]] auto mods() const { + return m_mods | std::views::transform([](const auto& m) -> LoadedMod& { return *m; }); + } + + [[nodiscard]] auto active_mods() const { + return mods() | std::views::filter([](const auto& m) { return m.active; }); + } + +private: + enum class RequestKind : u8 { Enable, Disable, Reload }; + struct Request { + std::string modId; + RequestKind kind; + }; + // ModLoader::tick runs inside fapGm_Execute, so code from an unloading mod can still be + // live on the stack (its frame unwinds after the tick). dlclose is therefore deferred to + // the next tick, by which point every per-frame entry into the mod should have returned. + struct RetiredNative { + std::unique_ptr native; + std::string path; + }; + + std::vector > m_mods; + std::vector m_searchDirs; + std::filesystem::path m_cacheDir; + std::vector m_pendingRequests; + std::vector m_pendingFailures; + std::vector m_retiredNatives; + bool m_initialized = false; + bool m_startupComplete = false; + + void try_load_mod(const std::filesystem::path& modPath, bool fromDir, uint32_t searchDirIndex); + void load_native(LoadedMod& mod, const std::string& dllEntry); + // Resolved / if it exists on disk, empty otherwise. + [[nodiscard]] std::filesystem::path external_native_lib_path(const LoadedMod& mod) const; + void unload_native(LoadedMod& mod); + // Registers exports (if needed), resolves imports and runs mod_initialize. + // Returns whether the mod ended up active; failures go through fail_mod. + bool activate_mod(LoadedMod& mod); + // Runs mod_shutdown (if needed), detaches the mod from every service, and unloads the + // native lib. Must only run with no mod code on the stack (startup, shutdown, or top of tick). + void deactivate_mod(LoadedMod& mod); + void init_services(); + bool register_static_service_exports(LoadedMod& mod); + bool resolve_service_imports(LoadedMod& mod); + [[nodiscard]] std::string describe_missing_import( + const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion) const; + + LoadedMod* find_mod(std::string_view id) const; + void drain_retired_natives(); + void apply_pending_requests(); + void flush_toasts(); + void on_enabled_changed(LoadedMod& mod); + // Deactivates `target` (if needed) and its transitive dependents, optionally re-reads the + // bundle from disk, then reactivates whatever the current cvar/provider state allows. + void apply_lifecycle_change(LoadedMod& target, bool reload); + // `target` plus transitive active/suspended dependents, in m_mods (init) order. + std::vector collect_lifecycle_set(LoadedMod& target); + bool reload_bundle(LoadedMod& mod); + bool ensure_native_loaded(LoadedMod& mod); +}; + +using ModIndex = std::ranges::range_difference_t().mods())>; + +} // namespace dusk::mods diff --git a/include/dusk/profiling.hpp b/include/dusk/profiling.hpp new file mode 100644 index 0000000000..82dab45216 --- /dev/null +++ b/include/dusk/profiling.hpp @@ -0,0 +1,12 @@ +#pragma once + +#if defined(__has_include) +#if __has_include() +#include +#endif +#endif + +#ifndef ZoneScoped +#define ZoneScoped +#define ZoneScopedN(name) +#endif diff --git a/include/dusk/settings.h b/include/dusk/settings.h index 99fe3d5ceb..11efea293b 100644 --- a/include/dusk/settings.h +++ b/include/dusk/settings.h @@ -7,7 +7,8 @@ namespace dusk { -using namespace config; +using config::ConfigVar; +using config::ActionBindConfigVar; enum class BloomMode : int { Off = 0, diff --git a/include/dusk/speedrun.h b/include/dusk/speedrun.h index c1a92e4a50..d9e43c3ed7 100644 --- a/include/dusk/speedrun.h +++ b/include/dusk/speedrun.h @@ -37,5 +37,6 @@ struct SpeedrunInfo { extern SpeedrunInfo m_speedrunInfo; void resetForSpeedrunMode(); +void restoreFromSpeedrunMode(); } // namespace dusk diff --git a/include/dusk/texture_replacements.hpp b/include/dusk/texture_replacements.hpp index ffb3fe8d62..85cb59b001 100644 --- a/include/dusk/texture_replacements.hpp +++ b/include/dusk/texture_replacements.hpp @@ -1,8 +1,13 @@ #ifndef DUSK_TEXTURE_REPLACEMENTS_HPP #define DUSK_TEXTURE_REPLACEMENTS_HPP +#include + namespace dusk::texture_replacements { +// Mod replacements are prioritized *over* user replacements (/texture_replacements/) +inline constexpr int32_t kUserTextureReplacementPriority = -1'000'000; + void reload(); void set_enabled(bool enabled); void shutdown(); diff --git a/include/f_pc/f_pc_node_req.h b/include/f_pc/f_pc_node_req.h index d710379c89..880e733ac9 100644 --- a/include/f_pc/f_pc_node_req.h +++ b/include/f_pc/f_pc_node_req.h @@ -36,7 +36,7 @@ typedef struct node_create_request { /* 0x58 */ s16 name; /* 0x5C */ void* data; /* 0x60 */ s16 unk_0x60; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x64 */ int unk_0x64; /* 0x68 */ int unk_0x68; #endif diff --git a/include/global.h b/include/global.h index 2a8e182bfa..5add912748 100644 --- a/include/global.h +++ b/include/global.h @@ -114,6 +114,19 @@ inline int __builtin_clz(unsigned int v) { #endif +// Data symbols exported from the main exe need dllimport on the mod side. +// DUSK_BUILDING_GAME is defined for the game build so the same headers work in both. +#if defined(TARGET_PC) && defined(_WIN32) && !defined(DUSK_BUILDING_GAME) +#define DUSK_GAME_EXTERN extern __declspec(dllimport) +#define DUSK_GAME_DATA __declspec(dllimport) +#elif defined(TARGET_PC) && defined(_WIN32) && defined(DUSK_BUILDING_GAME) +#define DUSK_GAME_EXTERN extern __declspec(dllexport) +#define DUSK_GAME_DATA __declspec(dllexport) +#else +#define DUSK_GAME_EXTERN extern +#define DUSK_GAME_DATA +#endif + #define FAST_DIV(x, n) (x >> (n / 2)) #define SQUARE(x) ((x) * (x)) diff --git a/include/m_Do/m_Do_audio.h b/include/m_Do/m_Do_audio.h index 68550b37ec..4917dc5166 100644 --- a/include/m_Do/m_Do_audio.h +++ b/include/m_Do/m_Do_audio.h @@ -11,13 +11,13 @@ class mDoAud_zelAudio_c : public Z2AudioMgr { public: void reset(); mDoAud_zelAudio_c() { -#if DEBUG +#if PARTIAL_DEBUG || DEBUG setMode(2); #endif } ~mDoAud_zelAudio_c() {} -#if DEBUG +#if PARTIAL_DEBUG || DEBUG u8 getMode() { return field_0x13bd; } void setMode(u8 mode) { field_0x13bd = mode; } diff --git a/include/m_Do/m_Do_hostIO.h b/include/m_Do/m_Do_hostIO.h index fa4eb8b1af..d88a0a9853 100644 --- a/include/m_Do/m_Do_hostIO.h +++ b/include/m_Do/m_Do_hostIO.h @@ -35,6 +35,11 @@ public: /* 0x4 */ s8 mNo; /* 0x5 */ u8 mCount; #else +#if PARTIAL_DEBUG + // Initialized here since the DEBUG ctor doesn't run. + /* 0x4 */ s8 mNo = -1; + /* 0x5 */ u8 mCount = 0; +#endif virtual ~mDoHIO_entry_c() {} #endif }; diff --git a/include/mods/api.h b/include/mods/api.h new file mode 100644 index 0000000000..85e2f76545 --- /dev/null +++ b/include/mods/api.h @@ -0,0 +1,152 @@ +#pragma once + +#ifdef __cplusplus +#include +#include +extern "C" { +#else +#include +#include +#include +#include +#endif + +#if defined(_WIN32) +#define MOD_EXPORT __declspec(dllexport) +#else +#define MOD_EXPORT __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +#define MOD_EXTERN_C extern "C" +#else +#define MOD_EXTERN_C +#endif + +#define MOD_ABI_VERSION 5u +#define MOD_ERROR_MESSAGE_SIZE 512u + +typedef struct ModContext ModContext; + +typedef enum ModResult { + MOD_OK = 0, + MOD_ERROR = 1, + MOD_UNAVAILABLE = 2, + MOD_UNSUPPORTED = 3, + MOD_CONFLICT = 4, + MOD_INVALID_ARGUMENT = 5, +} ModResult; + +static_assert(sizeof(ModResult) == 4, "mod SDK enums must be int-sized; do not build mods with -fshort-enums"); + +typedef struct ModError { + uint32_t struct_size; + ModResult code; + char message[MOD_ERROR_MESSAGE_SIZE]; +} ModError; + +#define MOD_ERROR_INIT {sizeof(ModError), MOD_OK, {0}} + +/* + * Opaque per-mod context, populated by the host before mod_initialize is called. + * Pass it as the first argument to every service call; it identifies the calling + * mod for attribution (logging, resource lookup, hook ownership, etc.). + */ +MOD_EXPORT extern ModContext* mod_ctx; + +/* + * Service versioning contract: + * + * A service is a struct of function pointers, beginning with a ServiceHeader. + * Compatibility is tracked with a major/minor version pair: + * + * - A major version bump is a breaking change. Different majors are distinct + * services; the registry never matches an import against a different major. + * - A minor version bump may only append fields to the end of the struct. + * Existing fields must keep their offsets and semantics. + * + * Providers: exporting minor N means every function pointer introduced at or + * below N is populated (non-NULL). struct_size reflects the compiled struct. + * + * Importers: importing with min_minor_version N guarantees (enforced at load + * time) that the resolved service is at least minor N, so any field introduced + * at or below N may be used unconditionally, with no availability checks. + * Fields newer than the declared min_minor_version must be gated behind + * SERVICE_HAS plus a NULL check on the pointer itself. + * + * Load ordering: a manifest import of another mod's service (required or + * optional) guarantees that the provider's mod_initialize completed before the + * importer's runs, and deferred services published during the provider's + * initialization resolve into import slots just like static exports. If a + * provider fails to load, mods that required its services fail in turn. Mods + * whose required imports form a cycle all fail to load; a cycle involving an + * optional import is broken by dropping the ordering guarantee (not the + * resolution) of that optional import. Dynamic lookups via + * HostService::get_service carry no ordering guarantee: they see whatever has + * been published at call time. + */ +typedef struct ServiceHeader { + uint32_t struct_size; + uint16_t major_version; + uint16_t minor_version; +} ServiceHeader; + +#define SERVICE_HEADER(service_type, major, minor) {sizeof(service_type), (major), (minor)} + +#define SERVICE_HAS(service, service_type, field) \ + ((service) != NULL && \ + (service)->header.struct_size >= \ + (uint32_t)(offsetof(service_type, field) + sizeof(((service_type*)0)->field))) + +typedef enum ServiceImportFlags { + SERVICE_IMPORT_REQUIRED = 0u, + SERVICE_IMPORT_OPTIONAL = 1u << 0u, +} ServiceImportFlags; + +typedef enum ServiceExportFlags { + SERVICE_EXPORT_STATIC = 0u, + SERVICE_EXPORT_DEFERRED = 1u << 0u, +} ServiceExportFlags; + +typedef struct ServiceImport { + uint32_t struct_size; + const char* service_id; + uint16_t major_version; + uint16_t min_minor_version; + uint32_t flags; + void* slot; +} ServiceImport; + +typedef struct ServiceExport { + uint32_t struct_size; + const char* service_id; + uint16_t major_version; + uint16_t minor_version; + uint32_t flags; + const void* service; +} ServiceExport; + +typedef struct ModManifest { + uint32_t struct_size; + uint32_t abi_version; + const ServiceImport* imports; + size_t import_count; + const ServiceExport* exports; + size_t export_count; +} ModManifest; + +typedef const ModManifest* (*ModGetManifestFn)(void); + +typedef ModResult (*ModInitializeFn)(ModError* out_error); +typedef ModResult (*ModUpdateFn)(ModError* out_error); +typedef ModResult (*ModShutdownFn)(ModError* out_error); + +MOD_EXPORT const ModManifest* mod_get_manifest(void); + +MOD_EXPORT ModResult mod_initialize(ModError* out_error); +MOD_EXPORT ModResult mod_update(ModError* out_error); +MOD_EXPORT ModResult mod_shutdown(ModError* out_error); + +#ifdef __cplusplus +} +#endif diff --git a/include/mods/hook.hpp b/include/mods/hook.hpp new file mode 100644 index 0000000000..c58bd719c9 --- /dev/null +++ b/include/mods/hook.hpp @@ -0,0 +1,464 @@ +#pragma once + +#include "mods/svc/hook.h" + +#include +#include +#include +#include +#include +#include + +namespace dusk::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]); +} + +template +void* mfp_addr(F fn) noexcept { + void* p = nullptr; + static_assert(sizeof(fn) >= sizeof(void*), "unexpected function pointer size"); + std::memcpy(&p, &fn, sizeof(void*)); + return p; +} + +/* A string usable as a template argument: carries the hook target's symbol name and + * makes each NamedHook instantiation's static state unique. */ +template +struct FixedString { + char chars[N]{}; + constexpr FixedString(const char (&s)[N]) noexcept { + for (size_t i = 0; i < N; ++i) { + chars[i] = s[i]; + } + } +}; + +namespace detail { + +template +constexpr std::string_view class_name() { +#if defined(__clang__) || defined(__GNUC__) + // "... class_name() [T = daAlink_c]" / "... [with T = daAlink_c; ...]" + constexpr std::string_view fn = __PRETTY_FUNCTION__; + constexpr size_t start = fn.find("T = ") + 4; + return fn.substr(start, fn.find_first_of(";]", start) - start); +#elif defined(_MSC_VER) + // "... class_name(void)" + constexpr std::string_view fn = __FUNCSIG__; + constexpr size_t start = fn.find("class_name<") + 11; + constexpr std::string_view name = fn.substr(start, fn.rfind(">(") - start); + if constexpr (name.starts_with("class ")) { + return name.substr(6); + } else if constexpr (name.starts_with("struct ")) { + return name.substr(7); + } else { + return name; + } +#else +#error "unsupported compiler" +#endif +} + +/* The manifest name of C's vtable. Only unscoped, non-template class names are + * supported (an empty result fails resolution and the install reports it). */ +template +constexpr auto vtable_symbol() { + constexpr std::string_view name = class_name(); + constexpr bool simple = name.find_first_of(":<> ") == std::string_view::npos; + // "_ZTV" + decimal length + name / "??_7" + name + "@@6B@", NUL-terminated + std::array out{}; + if constexpr (!simple) { + return out; + } + size_t n = 0; +#if defined(_WIN32) + for (char c : {'?', '?', '_', '7'}) { + out[n++] = c; + } + for (char c : name) { + out[n++] = c; + } + for (char c : {'@', '@', '6', 'B', '@'}) { + out[n++] = c; + } +#else + for (char c : {'_', 'Z', 'T', 'V'}) { + out[n++] = c; + } + size_t len = name.size(); + char digits[8]{}; + size_t d = 0; + while (len != 0) { + digits[d++] = static_cast('0' + len % 10); + len /= 10; + } + while (d != 0) { + out[n++] = digits[--d]; + } + for (char c : name) { + out[n++] = c; + } +#endif + return out; +} + +#if defined(_WIN32) +/* Follow jump stubs, then match the MSVC vcall thunk a virtual mfp points at. + * Returns the vtable slot's byte offset, or npos when fn is not a vcall thunk. */ +inline size_t vcall_slot_offset(const void*& fn) noexcept { + constexpr size_t npos = static_cast(-1); +#if defined(_M_X64) || defined(__x86_64__) + const auto* p = static_cast(fn); + for (int i = 0; i < 8 && p[0] == 0xE9; ++i) { // incremental-link stubs + int32_t rel; + std::memcpy(&rel, p + 1, 4); + p += 5 + rel; + } + fn = p; + // The vptr load. Unoptimized clang-cl thunks spill/reload rcx first + // (push rax; mov [rsp], rcx; mov rcx, [rsp]), so scan a short window. + const uint8_t* q = nullptr; + for (int i = 0; i <= 12; ++i) { + if (p[i] == 0x48 && p[i + 1] == 0x8B && p[i + 2] == 0x01) { // mov rax, [rcx] + q = p + i + 3; + break; + } + } + if (q == nullptr) { + return npos; + } + if (q[0] == 0xFF && q[1] == 0x20) { // jmp [rax] (MSVC) + return 0; + } + if (q[0] == 0xFF && q[1] == 0x60) { // jmp [rax + imm8] + return static_cast(q[2]); + } + if (q[0] == 0xFF && q[1] == 0xA0) { // jmp [rax + imm32] + int32_t off; + std::memcpy(&off, q + 2, 4); + return off; + } + // clang-cl: mov rax, [rax + off]; (pop r10;) jmp rax. Requiring the jmp rax + // distinguishes the thunk from an ordinary getter that begins the same way. + if (q[0] == 0x48 && q[1] == 0x8B && (q[2] == 0x00 || q[2] == 0x40 || q[2] == 0x80)) { + size_t off = 0; + const uint8_t* r = q + 3; + if (q[2] == 0x40) { + off = static_cast(q[3]); + r = q + 4; + } else if (q[2] == 0x80) { + int32_t off32; + std::memcpy(&off32, q + 3, 4); + off = off32; + r = q + 7; + } + for (int i = 0; i <= 8; ++i) { + if (r[i] == 0xFF && r[i + 1] == 0xE0) { // jmp rax (48 REX optional) + return off; + } + } + } + return npos; +#elif defined(_M_ARM64) || defined(__aarch64__) + const auto* p = static_cast(fn); + uint32_t insn[3]; + for (int i = 0; i < 8; ++i) { // incremental-link `b` stubs + std::memcpy(insn, p, 4); + if ((insn[0] & 0xFC000000u) != 0x14000000u) { + break; + } + const auto imm26 = static_cast(insn[0] << 6) >> 6; + p += static_cast(imm26) * 4; + } + fn = p; + std::memcpy(insn, p, 12); + // ldr Xt, [x0]; ldr Xs, [Xt, #imm12*8]; br Xs + if ((insn[0] & 0xFFFFFFE0u) != 0xF9400000u) { + return npos; + } + const uint32_t t = insn[0] & 0x1Fu; + if ((insn[1] & 0xFFC003E0u) != (0xF9400000u | (t << 5))) { + return npos; + } + const uint32_t s = insn[1] & 0x1Fu; + if (insn[2] != (0xD61F0000u | (s << 5))) { + return npos; + } + return ((insn[1] >> 10) & 0xFFFu) * 8; +#else + (void)fn; + return npos; +#endif +} +#endif + +/* Code address of the member function a mfp designates. Virtual mfps don't carry + * one; recover it from the class's vtable (resolved from the symbol manifest), so + * Hook works uniformly on virtual and non-virtual members. */ +template +ModResult member_target(const HookService* hooks, F mfp, void** out) { + *out = nullptr; + uintptr_t words[sizeof(F) > sizeof(uintptr_t) ? 2 : 1] = {}; + std::memcpy(words, &mfp, sizeof(words) < sizeof(F) ? sizeof(words) : sizeof(F)); + +#if defined(_WIN32) + const void* fn = reinterpret_cast(words[0]); + const size_t slot = vcall_slot_offset(fn); + if (slot == static_cast(-1)) { // not a vcall thunk: direct address + *out = const_cast(fn); + return MOD_OK; + } + void* vtable = nullptr; + const ModResult resolved = hooks->resolve(mod_ctx, vtable_symbol().data(), &vtable, nullptr); + if (resolved != MOD_OK) { + return resolved; + } + // ??_7 points at the first slot. + *out = *reinterpret_cast(static_cast(vtable) + slot); +#else +#if defined(__aarch64__) || defined(__arm__) + // AAPCS C++ ABI: the virtual flag is bit 0 of the adjustment word (function + // addresses can't spare their low bit), and ptr holds the slot offset directly. + const bool isVirtual = (words[1] & 1) != 0; + const uintptr_t thisAdjust = words[1] >> 1; + const uintptr_t slotOffset = words[0]; +#else + // Itanium C++ ABI: virtual mfps set bit 0 of ptr; the slot offset is ptr - 1. + const bool isVirtual = (words[0] & 1) != 0; + const uintptr_t thisAdjust = words[1]; + const uintptr_t slotOffset = words[0] - 1; +#endif + if (!isVirtual) { // non-virtual: the address itself + *out = reinterpret_cast(words[0]); + return MOD_OK; + } + if (thisAdjust != 0) { + // this-adjusting mfp (member of a secondary base): the slot offset is + // relative to a vtable we can't locate. Hook the overrider by name instead. + return MOD_UNSUPPORTED; + } + void* vtable = nullptr; + const ModResult resolved = hooks->resolve(mod_ctx, vtable_symbol().data(), &vtable, nullptr); + if (resolved != MOD_OK) { + return resolved; + } + // _ZTV points at the offset-to-top slot; the address point mfps index from is + // two pointers in (past offset-to-top and the typeinfo pointer). + *out = *reinterpret_cast(static_cast(vtable) + 2 * sizeof(void*) + slotOffset); +#endif + return *out != nullptr ? MOD_OK : MOD_UNAVAILABLE; +} + +} // namespace detail + +/* Trampoline generator + per-target state shared by Hook and NamedHook. Tag makes + * each hooked target's statics distinct; the target address is filled in at install. */ +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 hook on a function named at compile time (&daAlink_c::execute, &free_fn). + * Member functions may be virtual: the install decodes the member function pointer and hooks the + * class's own overrider. + */ +template +struct Hook; + +template +struct Hook : HookImpl, R, C*, A...> { + static ModResult resolve_target(const HookService* hooks, void** out) { + return detail::member_target(hooks, Target, out); + } +}; + +template +struct Hook : HookImpl, R, const C*, A...> { + static ModResult resolve_target(const HookService* hooks, void** out) { + return detail::member_target(hooks, Target, out); + } +}; + +template +struct Hook : HookImpl, R, A...> { + static ModResult resolve_target(const HookService*, void** out) { + *out = mfp_addr(Target); + return MOD_OK; + } +}; + +/* + * Typed 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. + * + * using HookshotHit = dusk::mods::NamedHook< + * "daAlink_hookshotAtHitCallBack", + * void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*)>; + * dusk::mods::hook_add_pre(svc_hook, on_hookshot_hit); + */ +template +struct NamedHook; + +template +struct NamedHook : HookImpl, R, A...> { + static ModResult resolve_target(const HookService* hooks, void** out) { + HookSymbolFlags flags{}; + const ModResult resolved = hooks->resolve(mod_ctx, Name.chars, out, &flags); + if (resolved == MOD_OK && (flags & HOOK_SYMBOL_CODE) == 0) { + *out = nullptr; + return MOD_INVALID_ARGUMENT; + } + return resolved; + } +}; + +template +ModResult hook_install(const HookService* hooks) { + if (hooks == nullptr) { + return MOD_UNAVAILABLE; + } + + Entry::hooks = hooks; + if (Entry::target == nullptr) { + const ModResult resolved = Entry::resolve_target(hooks, &Entry::target); + if (resolved != MOD_OK) { + return resolved; + } + } + return hooks->install(mod_ctx, Entry::target, reinterpret_cast(Entry::trampoline), + reinterpret_cast(&Entry::g_orig)); +} + +template +ModResult hook_install(const HookService* hooks) { + return hook_install >(hooks); +} + +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 hooks->add_pre(mod_ctx, Entry::target, callback, options); +} + +template +ModResult hook_add_pre( + const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) { + return hook_add_pre >(hooks, 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 hooks->add_post(mod_ctx, Entry::target, callback, options); +} + +template +ModResult hook_add_post( + const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) { + return hook_add_post >(hooks, 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 hooks->replace(mod_ctx, Entry::target, callback, options); +} + +template +ModResult hook_replace( + const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) { + return hook_replace >(hooks, callback, options); +} + +} // namespace dusk::mods diff --git a/include/mods/service.hpp b/include/mods/service.hpp new file mode 100644 index 0000000000..d79b53f24d --- /dev/null +++ b/include/mods/service.hpp @@ -0,0 +1,138 @@ +#pragma once + +#include "mods/api.h" + +#include +#include +#include +#include + +namespace dusk::mods { + +template +struct ServiceTraits; + +namespace detail { + +inline std::vector& imports() { + static std::vector entries; + return entries; +} + +inline std::vector& exports() { + static std::vector entries; + return entries; +} + +inline int register_import(ServiceImport entry) { + imports().push_back(entry); + return 0; +} + +inline int register_export(ServiceExport entry) { + exports().push_back(entry); + return 0; +} + +inline const ModManifest* manifest() { + static ModManifest manifest{ + sizeof(ModManifest), + MOD_ABI_VERSION, + nullptr, + 0, + nullptr, + 0, + }; + + auto& importEntries = imports(); + auto& exportEntries = exports(); + manifest.imports = importEntries.data(); + manifest.import_count = importEntries.size(); + manifest.exports = exportEntries.data(); + manifest.export_count = exportEntries.size(); + return &manifest; +} + +} // namespace detail + +inline ModResult set_error(ModError* outError, ModResult code, const char* message) { + if (outError != nullptr && outError->struct_size >= sizeof(ModError)) { + outError->code = code; + outError->message[0] = '\0'; + if (message != nullptr) { + std::snprintf(outError->message, sizeof(outError->message), "%s", message); + } + } + return code; +} + +} // namespace dusk::mods + +#define DEFINE_MOD() \ + extern "C" { \ + MOD_EXPORT ModContext* mod_ctx = nullptr; \ + MOD_EXPORT const ModManifest* mod_get_manifest(void) { \ + return ::dusk::mods::detail::manifest(); \ + } \ + } + +// Declares `static 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. +#define IMPORT_SERVICE_EX( \ + service_type, variable, service_id_value, major_value, min_minor_value, flags_value) \ + static const service_type* variable = nullptr; \ + [[maybe_unused]] static const int mod_import_registration_##variable = \ + ::dusk::mods::detail::register_import(ServiceImport{ \ + sizeof(ServiceImport), \ + (service_id_value), \ + static_cast(major_value), \ + static_cast(min_minor_value), \ + static_cast(flags_value), \ + &(variable), \ + }) + +#define IMPORT_SERVICE_VERSION(service_type, variable, min_minor_value) \ + IMPORT_SERVICE_EX(service_type, variable, ::dusk::mods::ServiceTraits::id, \ + ::dusk::mods::ServiceTraits::major_version, min_minor_value, \ + SERVICE_IMPORT_REQUIRED) + +#define IMPORT_SERVICE(service_type, variable) IMPORT_SERVICE_VERSION(service_type, variable, 0) + +#define IMPORT_OPTIONAL_SERVICE_VERSION(service_type, variable, min_minor_value) \ + IMPORT_SERVICE_EX(service_type, variable, ::dusk::mods::ServiceTraits::id, \ + ::dusk::mods::ServiceTraits::major_version, min_minor_value, \ + SERVICE_IMPORT_OPTIONAL) + +#define IMPORT_OPTIONAL_SERVICE(service_type, variable) \ + IMPORT_OPTIONAL_SERVICE_VERSION(service_type, variable, 0) + +#define EXPORT_SERVICE_AS(instance, service_id_value) \ + namespace { \ + const int mod_export_registration_##instance = \ + ::dusk::mods::detail::register_export(ServiceExport{ \ + sizeof(ServiceExport), \ + (service_id_value), \ + (instance).header.major_version, \ + (instance).header.minor_version, \ + SERVICE_EXPORT_STATIC, \ + &(instance), \ + }); \ + } + +#define EXPORT_SERVICE(instance) \ + EXPORT_SERVICE_AS( \ + instance, ::dusk::mods::ServiceTraits >::id) + +#define EXPORT_DEFERRED_SERVICE(token, service_id_value, major_value, minor_value) \ + namespace { \ + const int mod_deferred_export_registration_##token = \ + ::dusk::mods::detail::register_export(ServiceExport{ \ + sizeof(ServiceExport), \ + (service_id_value), \ + static_cast(major_value), \ + static_cast(minor_value), \ + SERVICE_EXPORT_DEFERRED, \ + nullptr, \ + }); \ + } diff --git a/include/mods/svc/camera.h b/include/mods/svc/camera.h new file mode 100644 index 0000000000..f974cec152 --- /dev/null +++ b/include/mods/svc/camera.h @@ -0,0 +1,64 @@ +#pragma once + +#include "mods/api.h" + +#define CAMERA_SERVICE_ID "dev.twilitrealm.dusklight.camera" +#define CAMERA_SERVICE_MAJOR 1u +#define CAMERA_SERVICE_MINOR 0u + +/* + * Snapshot of a game camera for the frame currently being recorded. + * + * Matrix conventions: every matrix is a column-major float[16] using the matrix * column-vector + * convention, ready to memcpy into a WGSL mat4x4f uniform. NOTE: this is the TRANSPOSE of the + * game's row-major Mtx/Mtx44 layout; mods that want the raw game matrices should read the + * view_class directly instead. + * + * View space is right-handed with -Z forward. Projection matrices are in WebGPU clip convention + * and follow the renderer's depth mode: reversed-Z by default (depth 1.0 at the near plane, + * 0.0 at far). + * + * Unprojecting a depth-buffer texel at uv with sampled depth d: + * let ndc = vec3f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, d); // WebGPU framebuffer y is down + * let world4 = world_from_proj * vec4f(ndc, 1.0); + * let world = world4.xyz / world4.w; + */ +typedef struct CameraInfo { + uint32_t struct_size; + + float view_from_world[16]; /* the view matrix */ + float world_from_view[16]; /* its inverse; column 3 is the camera position */ + float proj_from_view[16]; /* WebGPU-convention projection (+ Aurora reversed-Z) */ + float view_from_proj[16]; /* its inverse */ + float proj_from_world[16]; /* proj_from_view * view_from_world */ + float world_from_proj[16]; /* one-step depth-buffer -> world unproject */ + + float eye[3]; /* camera position in world space */ + float fovy; /* vertical field of view, degrees */ + float aspect; + float near_plane; + float far_plane; +} CameraInfo; + +#define CAMERA_INFO_INIT {sizeof(CameraInfo)} + +typedef struct CameraService { + ServiceHeader header; + + /* + * Snapshots a camera. game_view must be a view_class pointer, such as from a render stage + * callback's game view. Game thread only. Returns MOD_UNAVAILABLE when the view is not a valid + * perspective camera. + */ + ModResult (*get_camera)(ModContext* ctx, const void* game_view, CameraInfo* out_info); +} CameraService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = CAMERA_SERVICE_ID; + static constexpr uint16_t major_version = CAMERA_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/config.h b/include/mods/svc/config.h new file mode 100644 index 0000000000..2a26210295 --- /dev/null +++ b/include/mods/svc/config.h @@ -0,0 +1,107 @@ +#pragma once + +#include "mods/api.h" + +#define CONFIG_SERVICE_ID "dev.twilitrealm.dusklight.config" +#define CONFIG_SERVICE_MAJOR 1u +#define CONFIG_SERVICE_MINOR 0u + +/* Handle for a config var registered by the calling mod. 0 is never a valid handle. */ +typedef uint64_t ConfigVarHandle; +/* Handle for a change subscription. 0 is never a valid handle. */ +typedef uint64_t ConfigSubscriptionHandle; + +typedef enum ConfigVarType { + CONFIG_VAR_BOOL = 0, /* bool */ + CONFIG_VAR_INT = 1, /* int64_t */ + CONFIG_VAR_FLOAT = 2, /* double */ + CONFIG_VAR_STRING = 3, /* UTF-8 */ +} ConfigVarType; + +typedef struct ConfigVarDesc { + uint32_t struct_size; + /* Name fragment: 1-64 characters from [A-Za-z0-9_-]. The full config key is + * "mod..", persisted in config.json alongside host settings. + * "enabled" is reserved by the loader. */ + const char* name; + ConfigVarType type; + /* Default value; only the field matching `type` is read. */ + bool default_bool; + int64_t default_int; + double default_float; + const char* default_string; /* NULL means "" */ +} ConfigVarDesc; + +#define CONFIG_VAR_DESC_INIT {sizeof(ConfigVarDesc), NULL, CONFIG_VAR_BOOL, false, 0, 0.0, NULL} + +/* Snapshot of a var's value; only the field matching `type` is meaningful. */ +typedef struct ConfigVarValue { + uint32_t struct_size; + ConfigVarType type; + bool bool_value; + int64_t int_value; + double float_value; + const char* string_value; /* NUL-terminated; NULL for non-string vars */ + size_t string_length; /* excludes the NUL */ +} ConfigVarValue; + +/* + * Fired on the game thread whenever the var's effective value changes at runtime: the calling mod's + * own set_* calls and any other runtime writer. Writes that leave the value unchanged do not fire, + * and neither do values applied from config.json or --cvar during registration. `value` holds the + * new (current) value and `previous` the one it replaced; both snapshots are valid only for the + * duration of the call (copy string_value if you need to keep it). Setting the same var from inside + * its own callback applies the write but is not re-notified. + */ +typedef void (*ConfigChangedFn)(ModContext* ctx, ConfigVarHandle var, const ConfigVarValue* value, + const ConfigVarValue* previous, void* user_data); + +/* + * Scoped configuration variables. + * + * Registrations are owned by the calling mod and removed automatically (subscriptions included) + * when it is disabled, reloaded, or fails. Values are saved to config.json. Writes are debounced, + * not flushed per set. + */ +typedef struct ConfigService { + ServiceHeader header; + + /* Register a config var. If a value for the full key was saved earlier (or set via --cvar), + * it takes effect immediately; otherwise the var starts at the default. Registering a name + * that is already live is MOD_CONFLICT. */ + ModResult (*register_var)( + ModContext* ctx, const ConfigVarDesc* desc, ConfigVarHandle* out_handle); + /* Unregister a var previously registered by the calling mod. Its persisted value is kept. */ + ModResult (*unregister_var)(ModContext* ctx, ConfigVarHandle var); + + /* Typed accessors; the type must match the registration (MOD_INVALID_ARGUMENT otherwise). */ + ModResult (*get_bool)(ModContext* ctx, ConfigVarHandle var, bool* out_value); + ModResult (*set_bool)(ModContext* ctx, ConfigVarHandle var, bool value); + ModResult (*get_int)(ModContext* ctx, ConfigVarHandle var, int64_t* out_value); + ModResult (*set_int)(ModContext* ctx, ConfigVarHandle var, int64_t value); + ModResult (*get_float)(ModContext* ctx, ConfigVarHandle var, double* out_value); + ModResult (*set_float)(ModContext* ctx, ConfigVarHandle var, double value); + /* Copies the NUL-terminated value into buffer. out_length (optional) receives the full + * length excluding the NUL regardless of buffer size; call with buffer == NULL and + * buffer_size == 0 to query the length. A non-NULL buffer that is too small fails with + * MOD_INVALID_ARGUMENT and writes nothing. */ + ModResult (*get_string)( + ModContext* ctx, ConfigVarHandle var, char* buffer, size_t buffer_size, size_t* out_length); + ModResult (*set_string)(ModContext* ctx, ConfigVarHandle var, const char* value); + + /* Subscribe to changes of a var registered by the calling mod. out_handle may be NULL if + * the subscription is never removed manually (cleanup on mod teardown is automatic). */ + ModResult (*subscribe)(ModContext* ctx, ConfigVarHandle var, ConfigChangedFn callback, + void* user_data, ConfigSubscriptionHandle* out_handle); + ModResult (*unsubscribe)(ModContext* ctx, ConfigSubscriptionHandle handle); +} ConfigService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = CONFIG_SERVICE_ID; + static constexpr uint16_t major_version = CONFIG_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/game.h b/include/mods/svc/game.h new file mode 100644 index 0000000000..fbd914d4bc --- /dev/null +++ b/include/mods/svc/game.h @@ -0,0 +1,30 @@ +#pragma once + +#include "mods/api.h" + +/* + * Mods that link or hook game code directly must import this service; service-only and asset-only + * mods must not. + * + * Major version is the game-code ABI epoch: it is bumped when game-visible struct or vtable layouts + * change incompatibly (e.g. a TARGET_PC field added to an existing game struct). The loader's + * ordinary version check then fails mods built against the old epoch with a clear message instead + * of letting them corrupt memory. + */ +#define GAME_SERVICE_ID "dev.twilitrealm.dusklight.game" +#define GAME_SERVICE_MAJOR 1u +#define GAME_SERVICE_MINOR 0u + +typedef struct GameService { + ServiceHeader header; +} GameService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = GAME_SERVICE_ID; + static constexpr uint16_t major_version = GAME_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/gfx.h b/include/mods/svc/gfx.h new file mode 100644 index 0000000000..a9812fd044 --- /dev/null +++ b/include/mods/svc/gfx.h @@ -0,0 +1,209 @@ +#pragma once + +#include "mods/api.h" + +#include + +/* + * Direct WebGPU access at various stages of the rendering pipeline. Mods use the wgpu* C API + * (via webgpu/webgpu.h) for custom draws and compute dispatches. + * + * Every service function must be called on the game thread. GfxStageFn callbacks run on the game + * thread during frame recording. push_draw, push_* and pass functions are valid from a stage + * callback and anywhere else GX commands are being recorded. + * + * GfxDrawFn and GfxComputeFn callbacks run on the render worker thread while the frame is encoded. + * They may use only the handles in their context struct and raw wgpu* calls; no other service may + * be called from them. + * + * All WGPU handles provided by this service are borrowed. Handles in callback contexts are valid + * only for the duration of the callback; views in GfxResolvedTargets are valid for the current + * frame only. GPU objects a mod creates through raw wgpu calls are its own responsibility and + * should be released in mod_shutdown. The device outlives all mods. + */ + +#define GFX_SERVICE_ID "dev.twilitrealm.dusklight.gfx" +#define GFX_SERVICE_MAJOR 1u +#define GFX_SERVICE_MINOR 0u + +/* Maximum size for push_draw payload */ +#define GFX_INLINE_DRAW_PAYLOAD_SIZE 128u + +/* 0 is never a valid handle. */ +typedef uint64_t GfxDrawTypeHandle; +typedef uint64_t GfxStageHookHandle; +typedef uint64_t GfxComputeTypeHandle; + +/* A suballocation in one of the shared per-frame streaming buffers. */ +typedef struct GfxRange { + uint32_t offset; + uint32_t size; +} GfxRange; + +/* + * Device and scene pass configuration. Valid from mod_initialize onward and stable for the + * session. Offscreen passes from create_pass are always single-sample. + */ +typedef struct GfxDeviceInfo { + uint32_t struct_size; + WGPUDevice device; /* borrowed */ + WGPUQueue queue; /* borrowed */ + WGPUTextureFormat color_format; /* scene color target format */ + 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 */ +} GfxDeviceInfo; + +#define GFX_DEVICE_INFO_INIT \ + {sizeof(GfxDeviceInfo), NULL, NULL, WGPUTextureFormat_Undefined, WGPUTextureFormat_Undefined, \ + 1u, false} + +/* + * Passed to GfxDrawFn on the render worker thread; valid only during the call. The pass pipeline, + * bind group, viewport, and scissor state is restored by the host after the callback returns. + */ +typedef struct GfxDrawContext { + uint32_t struct_size; + WGPUDevice device; + WGPUQueue queue; + WGPURenderPassEncoder pass; + WGPUBuffer vertex_buffer; + WGPUBuffer index_buffer; + WGPUBuffer uniform_buffer; + WGPUBuffer storage_buffer; + WGPUTextureFormat color_format; + WGPUTextureFormat depth_format; + uint32_t sample_count; + uint32_t target_width; + uint32_t target_height; + bool uses_reversed_z; +} GfxDrawContext; + +typedef void (*GfxDrawFn)(ModContext* ctx, const GfxDrawContext* draw_ctx, const void* payload, + size_t payload_size, void* user_data); + +typedef struct GfxDrawTypeDesc { + uint32_t struct_size; + const char* label; /* optional debug label */ + GfxDrawFn draw; /* required; called from the render worker thread */ + void* user_data; +} GfxDrawTypeDesc; + +#define GFX_DRAW_TYPE_DESC_INIT {sizeof(GfxDrawTypeDesc), NULL, NULL, NULL} + +typedef enum GfxStage { + GFX_STAGE_SCENE_AFTER_TERRAIN = 0, + GFX_STAGE_FRAME_BEFORE_HUD = 1, + GFX_STAGE_FRAME_AFTER_HUD = 2, + GFX_STAGE_SCENE_BEGIN = 3, + GFX_STAGE_SCENE_AFTER_OPAQUE = 4, +} GfxStage; + +typedef struct GfxStageContext { + uint32_t struct_size; + GfxStage stage; + const void* game_view; /* view_class* for world-camera stages; NULL otherwise */ + const void* game_viewport; /* view_port_class* for world-camera stages; NULL otherwise */ +} GfxStageContext; + +typedef void (*GfxStageFn)(ModContext* ctx, const GfxStageContext* stage_ctx, void* user_data); + +typedef struct GfxStageHookDesc { + uint32_t struct_size; + GfxStageFn callback; /* required */ + void* user_data; +} GfxStageHookDesc; + +#define GFX_STAGE_HOOK_DESC_INIT {sizeof(GfxStageHookDesc), NULL, NULL} + +typedef struct GfxResolveDesc { + uint32_t struct_size; + bool color; + bool depth; +} GfxResolveDesc; + +#define GFX_RESOLVE_DESC_INIT {sizeof(GfxResolveDesc), true, false} + +typedef struct GfxResolvedTargets { + uint32_t struct_size; + WGPUTextureView color; /* single-sample snapshot in color_format */ + WGPUTextureView depth; /* single-sample raw depth snapshot, R32Float when available */ + WGPUTextureFormat color_format; + uint32_t width; + uint32_t height; +} GfxResolvedTargets; + +#define GFX_RESOLVED_TARGETS_INIT \ + {sizeof(GfxResolvedTargets), NULL, NULL, WGPUTextureFormat_Undefined, 0u, 0u} + +/* + * Passed to GfxComputeFn on the render worker thread; valid only during the call. The encoder is + * the frame command encoder between scene render passes. Leave no pass open and never finish or + * release the encoder. + */ +typedef struct GfxComputeContext { + uint32_t struct_size; + WGPUDevice device; + WGPUQueue queue; + WGPUCommandEncoder encoder; + WGPUBuffer vertex_buffer; + WGPUBuffer index_buffer; + WGPUBuffer uniform_buffer; + WGPUBuffer storage_buffer; +} GfxComputeContext; + +typedef void (*GfxComputeFn)(ModContext* ctx, const GfxComputeContext* compute_ctx, + const void* payload, size_t payload_size, void* user_data); + +typedef struct GfxComputeTypeDesc { + uint32_t struct_size; + const char* label; /* optional debug label */ + GfxComputeFn callback; /* required; called from the render worker thread */ + void* user_data; +} GfxComputeTypeDesc; + +#define GFX_COMPUTE_TYPE_DESC_INIT {sizeof(GfxComputeTypeDesc), NULL, NULL, NULL} + +typedef struct GfxService { + ServiceHeader header; + + ModResult (*get_device_info)(ModContext* ctx, GfxDeviceInfo* out_info); + void* (*get_proc_address)(ModContext* ctx, const char* name); + + ModResult (*register_draw_type)( + ModContext* ctx, const GfxDrawTypeDesc* desc, GfxDrawTypeHandle* out_handle); + ModResult (*unregister_draw_type)(ModContext* ctx, GfxDrawTypeHandle handle); + ModResult (*push_draw)( + ModContext* ctx, GfxDrawTypeHandle handle, const void* payload, size_t payload_size); + + ModResult (*register_compute_type)( + ModContext* ctx, const GfxComputeTypeDesc* desc, GfxComputeTypeHandle* out_handle); + ModResult (*unregister_compute_type)(ModContext* ctx, GfxComputeTypeHandle handle); + ModResult (*push_compute)( + ModContext* ctx, GfxComputeTypeHandle handle, const void* payload, size_t payload_size); + + ModResult (*push_verts)( + ModContext* ctx, const void* data, size_t size, size_t alignment, GfxRange* out_range); + ModResult (*push_indices)( + ModContext* ctx, const void* data, size_t size, size_t alignment, GfxRange* out_range); + ModResult (*push_uniform)(ModContext* ctx, const void* data, size_t size, GfxRange* out_range); + ModResult (*push_storage)(ModContext* ctx, const void* data, size_t size, GfxRange* out_range); + + ModResult (*register_stage_hook)(ModContext* ctx, GfxStage stage, const GfxStageHookDesc* desc, + GfxStageHookHandle* out_handle); + ModResult (*unregister_stage_hook)(ModContext* ctx, GfxStageHookHandle handle); + + ModResult (*resolve_pass)( + ModContext* ctx, const GfxResolveDesc* desc, GfxResolvedTargets* out_targets); + ModResult (*create_pass)(ModContext* ctx, uint32_t width, uint32_t height); +} GfxService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = GFX_SERVICE_ID; + static constexpr uint16_t major_version = GFX_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/hook.h b/include/mods/svc/hook.h new file mode 100644 index 0000000000..d1a75fa6cc --- /dev/null +++ b/include/mods/svc/hook.h @@ -0,0 +1,125 @@ +#pragma once + +#include "mods/api.h" + +/* + * 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 + * 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). + * + * Every call is game-thread-only. Install and removal must run with no hooked function on the + * stack; the loader guarantees this by applying mod lifecycle changes between frames, which is + * why hooking a function that never returns (the outermost loop) makes a mod un-unloadable. + */ + +#define HOOK_SERVICE_ID "dev.twilitrealm.dusklight.hook" +#define HOOK_SERVICE_MAJOR 1u +#define HOOK_SERVICE_MINOR 0u + +/* Symbol flags reported by resolve() */ +typedef enum HookSymbolFlags { + HOOK_SYMBOL_CODE = 1u << 0u, + HOOK_SYMBOL_DATA = 1u << 1u, + /* Not exported/dynamically visible: hookable, but never linkable. */ + HOOK_SYMBOL_LOCAL = 1u << 2u, + /* Other names share this address (ICF fold/alias): a hook intercepts them all. */ + HOOK_SYMBOL_MULTI_NAME = 1u << 3u, + /* Resolved through a demangled display-name alias rather than the real symbol. */ + HOOK_SYMBOL_DISPLAY = 1u << 6u, +} HookSymbolFlags; + +/* A pre-hook's return value: whether to run the original function. */ +typedef enum HookAction { + HOOK_CONTINUE = 0, /* run the original (and any lower-priority pre-hooks) */ + HOOK_SKIP_ORIGINAL = 1, /* cancel the original and remaining pre-hooks; post-hooks still run */ +} HookAction; + +/* How replace resolves a second replace-hook on a target that already has one. */ +typedef enum HookReplacePolicy { + HOOK_REPLACE_CONFLICT = 0, /* refuse with MOD_CONFLICT (the default) */ + HOOK_REPLACE_PRIORITY = 1, /* take over only if this options.priority is strictly higher */ + HOOK_REPLACE_OVERRIDE = 2, /* take over unconditionally */ +} 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 dusk::mods::arg / arg_ref from mods/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); +typedef void (*HookPostFn)(ModContext* ctx, void* args, void* retval, void* userdata); +typedef void (*HookReplaceFn)(ModContext* ctx, void* args, void* retval, void* userdata); + +typedef struct HookOptions { + uint32_t struct_size; + /* Higher runs first; ties break by registration order. Applies to pre/post ordering and, + * with HOOK_REPLACE_PRIORITY, to replace-hook takeover. */ + int32_t priority; + HookReplacePolicy replace_policy; + void* userdata; /* passed back to the callback */ +} HookOptions; + +#define HOOK_OPTIONS_INIT {sizeof(HookOptions), 0, HOOK_REPLACE_CONFLICT, NULL} + +typedef struct HookService { + ServiceHeader header; + + /* + * Install a trampoline detour on fn_addr and return the address to call the original through in + * *out_original_fn. The typed helpers generate the trampoline and call this; mods normally + * don't. The first mod to install a given target owns the live detour; later mods register as + * candidates so a hook survives the owner unloading (the detour is handed off and every + * original pointer is rewritten). Idempotent per (mod, out slot). + */ + ModResult (*install)( + ModContext* ctx, void* fn_addr, void* trampoline_fn, void** out_original_fn); + + /* + * Register a callback on an already-installed target. Pre runs before the original (and can + * cancel it), post runs after (even if cancelled). Any number of mods may add pre/post to the + * same target; they run in priority then registration order. replace installs a single + * substitute for the original, managed by options.replace_policy, MOD_CONFLICT if refused. + */ + ModResult (*add_pre)( + ModContext* ctx, void* fn_addr, HookPreFn callback, const HookOptions* options); + ModResult (*add_post)( + ModContext* ctx, void* fn_addr, HookPostFn callback, const HookOptions* options); + ModResult (*replace)( + ModContext* ctx, void* fn_addr, HookReplaceFn callback, const HookOptions* options); + + /* + * Run the registered callbacks for a target. The generated trampoline calls these; they + * are not a mod-facing entry point. dispatch_pre reports through *out_skip_original + * whether the original should be skipped (a pre-hook returned HOOK_SKIP_ORIGINAL, or a + * replace-hook ran). + */ + ModResult (*dispatch_pre)( + ModContext* ctx, void* fn_addr, void* args, void* retval, int* out_skip_original); + ModResult (*dispatch_post)(ModContext* ctx, void* fn_addr, void* args, void* retval); + + /* + * Resolve a game symbol by name from the symbol manifest, including non-exported (static) + * functions. Names can be either the platform's mangled name (i.e. the name passed to dlopen; + * no Mach-O leading underscore) or the qualified function name without parameters (e.g. + * "daAlink_c::execute"). out_flags (optional) receives HookSymbolFlags. + * + * Results: MOD_OK; MOD_UNSUPPORTED (no manifest for this build, missing or stale); + * MOD_UNAVAILABLE (symbol not found); MOD_CONFLICT (name maps to more than one address: C++ + * overloads or per-TU statics; use the mangled name). + */ + ModResult (*resolve)( + ModContext* ctx, const char* symbol, void** out_addr, HookSymbolFlags* out_flags); +} HookService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = HOOK_SERVICE_ID; + static constexpr uint16_t major_version = HOOK_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/host.h b/include/mods/svc/host.h new file mode 100644 index 0000000000..43c19cadfb --- /dev/null +++ b/include/mods/svc/host.h @@ -0,0 +1,104 @@ +#pragma once + +#include "mods/api.h" + +/* + * 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. + */ + +#define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" +#define HOST_SERVICE_MAJOR 2u +#define HOST_SERVICE_MINOR 0u + +/* + * Ignore unknown values: later service minors may add events. + */ +typedef enum ModLifecycleEvent { + /* + * The subject mod is gone: its mod_shutdown has run (when it initialized at all) and + * every service has already dropped the state it held for it. The subject's library is + * still mapped, so pointers into it are valid to compare against, but they must not be + * called or dereferenced after the callback returns. Drop everything keyed to the + * mod: callbacks it registered, its ModContext*, state indexed by it. + */ + MOD_LIFECYCLE_DETACHED = 0, +} ModLifecycleEvent; + +/* + * ctx is the watching mod's own context; subject identifies the mod the event is about. + * subject_id is valid only for the duration of the call. + */ +typedef void (*ModLifecycleFn)(ModContext* ctx, ModContext* subject, const char* subject_id, + ModLifecycleEvent event, void* user_data); + +typedef struct HostService { + ServiceHeader header; + + /* Version string of the current Dusklight build. (e.g. "1.4.2") */ + const char* version; + + /* Build id of the running game binary: PDB GUID+age on Windows, LC_UUID on macOS, GNU build-id + * on Linux. May be empty (len 0) if the identity could not be determined. */ + const uint8_t* build_id; + uint32_t build_id_len; + + /* + * Look up a service by id at call time. Unlike a manifest import, this sees whatever is + * currently published and carries no initialization-order guarantee (see mods/api.h). + * MOD_UNAVAILABLE if no matching service is published; *out_service is null on failure. + */ + ModResult (*get_service)(ModContext* ctx, const char* service_id, uint16_t major_version, + uint16_t min_minor_version, const void** out_service); + + /* + * Publish a service the calling mod declared as a deferred export in its manifest. + * Must happen during mod_initialize so importers can resolve it; `service` must stay + * valid until the mod shuts down. + */ + ModResult (*publish_service)( + ModContext* ctx, const char* service_id, uint16_t major_version, const void* service); + + /* + * Report an unrecoverable failure. The calling mod's services stop resolving immediately + * and the loader fully disables it at the next safe point; `message` is shown to the user. + * Safe to call from any mod callback. + */ + void (*fail)(ModContext* ctx, ModResult code, const char* message); + + /* + * The calling mod's manifest metadata. Returned strings remain valid while the mod is + * loaded. + */ + const char* (*mod_id)(ModContext* ctx); + const char* (*mod_name)(ModContext* ctx); + const char* (*mod_version)(ModContext* ctx); + + /* + * A writable scratch directory reserved for the calling mod. Contents survive disable + * and reload within a session, but the directory is wiped at game startup. + */ + const char* (*mod_dir)(ModContext* ctx); + + /* + * Observe other mods' lifecycle events. Any mod whose service hands out per-caller state + * (registrations, callbacks, handles) should watch for MOD_LIFECYCLE_DETACHED and drop what it + * holds for the subject. + * + * Callbacks fire on the game thread at a lifecycle safe point (never mid-frame), for + * every mod but the watcher itself (use mod_shutdown for self-cleanup). + */ + ModResult (*watch_mod_lifecycle)( + ModContext* ctx, ModLifecycleFn fn, void* user_data, uint64_t* out_handle); + ModResult (*unwatch_mod_lifecycle)(ModContext* ctx, uint64_t handle); +} HostService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = HOST_SERVICE_ID; + static constexpr uint16_t major_version = HOST_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/log.h b/include/mods/svc/log.h new file mode 100644 index 0000000000..bad94f9503 --- /dev/null +++ b/include/mods/svc/log.h @@ -0,0 +1,47 @@ +#pragma once + +#include "mods/api.h" + +/* + * Logging into the game's console and log files. Messages are attributed to the calling mod + * (prefixed with its ID). + */ + +#define LOG_SERVICE_ID "dev.twilitrealm.dusklight.log" +#define LOG_SERVICE_MAJOR 1u +#define LOG_SERVICE_MINOR 0u + +typedef enum LogLevel { + LOG_LEVEL_TRACE = 0, + LOG_LEVEL_DEBUG = 1, + LOG_LEVEL_INFO = 2, + LOG_LEVEL_WARN = 3, + LOG_LEVEL_ERROR = 4, +} LogLevel; + +typedef struct LogService { + ServiceHeader header; + + /* + * Write a log message at the given level. + * `message` is a plain UTF-8 string and is copied before returning. + */ + void (*write)(ModContext* ctx, LogLevel level, const char* message); + + /* Per-level shorthands for write. */ + void (*trace)(ModContext* ctx, const char* message); + void (*debug)(ModContext* ctx, const char* message); + void (*info)(ModContext* ctx, const char* message); + void (*warn)(ModContext* ctx, const char* message); + void (*error)(ModContext* ctx, const char* message); +} LogService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = LOG_SERVICE_ID; + static constexpr uint16_t major_version = LOG_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/overlay.h b/include/mods/svc/overlay.h new file mode 100644 index 0000000000..9fc86fc850 --- /dev/null +++ b/include/mods/svc/overlay.h @@ -0,0 +1,57 @@ +#pragma once + +#include "mods/api.h" + +#define OVERLAY_SERVICE_ID "dev.twilitrealm.dusklight.overlay" +#define OVERLAY_SERVICE_MAJOR 1u +#define OVERLAY_SERVICE_MINOR 0u + +/* Handle for a runtime overlay registration. 0 is never a valid handle. */ +typedef uint64_t OverlayHandle; + +/* + * Runtime DVD file overlays. + * + * Registrations are owned by the calling mod and removed automatically when it is disabled, + * reloaded, or fails. Changes are applied at the next frame boundary; data the game has already + * read stays in memory until it re-reads the file (sometimes on scene reload, sometimes on + * restart). + * + * disc_path names the file to overlay: absolute with a leading '/', matched against the disc + * case-insensitively (e.g. "/res/Stage/R04_00.arc"). Paths that do not exist on the disc are added + * as new files. + * + * If multiple sources overlay the same path, the last one wins: a mod's runtime registrations beat + * its static overlay/ files, and later-loaded mods beat earlier ones. + */ +typedef struct OverlayService { + ServiceHeader header; + + /* + * Overlay disc_path with a file from the calling mod's bundle (bundle-relative path, e.g. + * "res/replacement.arc"). The file's contents are read lazily on each open, so the bundle + * file must not change size while registered. + */ + ModResult (*add_file)( + ModContext* ctx, const char* disc_path, const char* bundle_path, OverlayHandle* out_handle); + + /* + * Overlay disc_path with a caller-owned buffer. The data is copied; the caller may free it + * as soon as this returns. + */ + ModResult (*add_buffer)(ModContext* ctx, const char* disc_path, const void* data, size_t size, + OverlayHandle* out_handle); + + /* Remove a runtime overlay previously added by the calling mod. */ + ModResult (*remove)(ModContext* ctx, OverlayHandle handle); +} OverlayService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = OVERLAY_SERVICE_ID; + static constexpr uint16_t major_version = OVERLAY_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/resource.h b/include/mods/svc/resource.h new file mode 100644 index 0000000000..1173b2ab1d --- /dev/null +++ b/include/mods/svc/resource.h @@ -0,0 +1,52 @@ +#pragma once + +#include "mods/api.h" + +/* + * 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. + */ + +#define RESOURCE_SERVICE_ID "dev.twilitrealm.dusklight.resource" +#define RESOURCE_SERVICE_MAJOR 1u +#define RESOURCE_SERVICE_MINOR 0u + +/* + * A loaded resource, allocated by the service. Return every successful load with free; + * buffers still live when the mod is disabled or reloaded are reclaimed with a warning. + */ +typedef struct ResourceBuffer { + uint32_t struct_size; + void* data; + size_t size; +} ResourceBuffer; + +#define RESOURCE_BUFFER_INIT {sizeof(ResourceBuffer), NULL, 0u} + +typedef struct ResourceService { + ServiceHeader header; + + /* + * Load a file into a fresh allocation. `relative_path` is resolved against the bundle's + * res/ directory. Absolute paths and ".." are rejected. MOD_UNAVAILABLE if the file does not + * exist. An empty file loads as data == NULL with size 0. Previous contents of `out_buffer` are + * overwritten, not freed. + */ + ModResult (*load)(ModContext* ctx, const char* relative_path, ResourceBuffer* out_buffer); + + /* + * Release a loaded buffer and reset it to the empty state. Safe to call on an empty or + * already-freed buffer. + */ + void (*free)(ModContext* ctx, ResourceBuffer* buffer); +} ResourceService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = RESOURCE_SERVICE_ID; + static constexpr uint16_t major_version = RESOURCE_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/texture.h b/include/mods/svc/texture.h new file mode 100644 index 0000000000..d664c5f99d --- /dev/null +++ b/include/mods/svc/texture.h @@ -0,0 +1,88 @@ +#pragma once + +#include "mods/api.h" + +#define TEXTURE_SERVICE_ID "dev.twilitrealm.dusklight.texture" +#define TEXTURE_SERVICE_MAJOR 1u +#define TEXTURE_SERVICE_MINOR 0u + +/* Handle for a runtime texture replacement registration. 0 is never a valid handle. */ +typedef uint64_t TextureReplacementHandle; + +typedef enum TextureKeyKind { + /* Match a texture by the address of its in-memory GX texel data. */ + TEXTURE_KEY_POINTER = 0, + /* Match by content: XXH64 of the base mip level (and of the referenced TLUT range for + * palette formats), as encoded in replacement filenames / texture dumps. */ + TEXTURE_KEY_SOURCE = 1, +} TextureKeyKind; + +/* Wildcard values for TEXTURE_KEY_SOURCE hashes ("$" in the filename convention). */ +#define TEXTURE_HASH_WILDCARD UINT64_C(0xFFFFFFFFFFFFFFFF) +#define TEXTURE_TLUT_WILDCARD UINT64_C(0xFFFFFFFFFFFFFFFE) + +typedef struct TextureKey { + uint32_t struct_size; + TextureKeyKind kind; + const void* pointer; /* TEXTURE_KEY_POINTER only */ + uint64_t texture_hash; /* TEXTURE_KEY_SOURCE */ + uint64_t tlut_hash; /* TEXTURE_KEY_SOURCE, palette formats only */ + uint32_t width; + uint32_t height; + uint32_t gx_format; + bool has_tlut; +} TextureKey; + +#define TEXTURE_KEY_INIT {sizeof(TextureKey), TEXTURE_KEY_POINTER, NULL, 0u, 0u, 0u, 0u, 0u, false} + +typedef struct TextureData { + uint32_t struct_size; + const void* data; /* texel data laid out in gx_format; copied by the service */ + size_t size; + uint32_t width; + uint32_t height; + uint32_t mip_count; + uint32_t gx_format; /* any GX texture format supported by Aurora's converter */ +} TextureData; + +#define TEXTURE_DATA_INIT {sizeof(TextureData), NULL, 0u, 0u, 0u, 1u, 0u} + +/* + * Runtime texture replacements. + * + * Registrations are owned by the calling mod and removed automatically when it is disabled, + * reloaded, or fails. When multiple sources replace the same texture, the highest priority wins: + * later-loaded mods beat earlier ones, and any mod beats the user's texture_replacements config + * directory. Files shipped in a mod's textures/ directory register automatically with the same + * ownership and priority; this service is for replacements decided at runtime. + */ +typedef struct TextureService { + ServiceHeader header; + + /* Register a replacement from raw texel data. The data is copied; the caller may free it as + * soon as this returns. */ + ModResult (*register_data)(ModContext* ctx, const TextureKey* key, const TextureData* data, + TextureReplacementHandle* out_handle); + + /* + * Register a replacement from an encoded .dds/.png inside the calling mod's bundle. The + * filename encodes the key (same convention as the texture_replacements directory, e.g. + * "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); + + /* Remove a replacement previously registered by the calling mod. */ + ModResult (*unregister)(ModContext* ctx, TextureReplacementHandle handle); +} TextureService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = TEXTURE_SERVICE_ID; + static constexpr uint16_t major_version = TEXTURE_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/ui.h b/include/mods/svc/ui.h new file mode 100644 index 0000000000..d9f2ac669e --- /dev/null +++ b/include/mods/svc/ui.h @@ -0,0 +1,284 @@ +#pragma once + +#include "mods/api.h" +#include "mods/svc/config.h" + +#define UI_SERVICE_ID "dev.twilitrealm.dusklight.ui" +#define UI_SERVICE_MAJOR 1u +#define UI_SERVICE_MINOR 0u + +/* + * UI primitives: a panel inside the host Mods window, mod-owned windows, dialogs, 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 + * MOD_INVALID_ARGUMENT. Element handles die with the content that owns them: a panel or tab rebuild + * destroys the previous build's elements, so re-acquire handles inside the build callback rather + * than caching them. Strings are UTF-8 and, in both directions, only valid for the duration of the + * call. + */ + +/* 0 is never a valid handle. */ +typedef uint64_t UiWindowHandle; +typedef uint64_t UiDialogHandle; +typedef uint64_t UiElementHandle; +typedef uint64_t UiStyleHandle; +typedef uint64_t UiMenuTabHandle; + +typedef enum UiStyleScope { + UI_SCOPE_PRELAUNCH = 0, /* the pre-launch menu */ + UI_SCOPE_WINDOW = 1, /* every tabbed/small window, host and mod alike */ + UI_SCOPE_MENU_BAR = 2, /* the in-game menu bar */ + UI_SCOPE_OVERLAY = 3, /* the passive overlay (toasts, FPS counter, timers) */ + UI_SCOPE_TOUCH_CONTROLS = 4, /* touch controls and their editor */ + UI_SCOPE_GRAPHICS_TUNER = 5, /* the graphics tuner overlay window */ +} UiStyleScope; + +typedef enum UiDialogVariant { + UI_DIALOG_NORMAL = 0, + UI_DIALOG_WARNING = 1, /* warning icon by default */ + UI_DIALOG_DANGER = 2, /* red styling, error icon by default */ +} UiDialogVariant; + +typedef enum UiControlKind { + UI_CONTROL_BUTTON = 0, /* action button (on_pressed) */ + UI_CONTROL_TOGGLE = 1, /* boolean on/off */ + UI_CONTROL_NUMBER = 2, /* integer stepper with min/max/step */ + UI_CONTROL_STRING = 3, /* text input */ + UI_CONTROL_SELECT = 4, /* one of `options`; the value is the option index */ +} UiControlKind; + +typedef enum UiControlBinding { + /* Values flow through the `get`/`set` callbacks. Getters are polled every frame while the + control is visible and must be cheap. */ + UI_BINDING_CALLBACKS = 0, + /* The control reads and writes `config_var` (a ConfigService handle owned by the calling mod) + * directly: persistence, change notifications and the modified indicator (value != default) are + * wired automatically. The var type must match the control kind: TOGGLE = bool, NUMBER and + * SELECT = int, STRING = string. Float vars are not bindable; use callbacks. */ + UI_BINDING_CONFIG_VAR = 1, +} UiControlBinding; + +/* Tagged by the control's kind: TOGGLE reads bool_value, NUMBER and SELECT read int_value, STRING + * reads string_value. string_value passed to a setter is only valid during the call; a getter + * should point it at storage owned by the mod (e.g. a static buffer) that stays valid until the + * next call into the mod — the host copies it right after the getter returns. */ +typedef struct UiControlValue { + uint32_t struct_size; + bool bool_value; + int64_t int_value; + const char* string_value; +} UiControlValue; + +#define UI_CONTROL_VALUE_INIT {sizeof(UiControlValue), false, 0, NULL} + +typedef void (*UiControlGetFn)(ModContext* ctx, void* user_data, UiControlValue* out_value); +typedef void (*UiControlSetFn)(ModContext* ctx, void* user_data, const UiControlValue* value); +/* Polled every frame while the control is visible. */ +typedef bool (*UiPredicateFn)(ModContext* ctx, void* user_data); +typedef void (*UiPressedFn)(ModContext* ctx, void* user_data); + +typedef struct UiControlDesc { + uint32_t struct_size; + UiControlKind kind; + /* Row label (plain text). Required. */ + const char* label; + /* Optional RML shown as contextual help when the control is focused or hovered. Only rendered + * where a help pane exists (mod window tabs). */ + const char* help_rml; + UiControlBinding binding; /* ignored for BUTTON */ + ConfigVarHandle config_var; /* UI_BINDING_CONFIG_VAR */ + UiControlGetFn get; /* UI_BINDING_CALLBACKS (all kinds but BUTTON) */ + UiControlSetFn set; /* UI_BINDING_CALLBACKS (all kinds but BUTTON) */ + UiPressedFn on_pressed; /* BUTTON only. Required for BUTTON. */ + UiPredicateFn is_disabled; /* optional */ + /* Optional override for the modified indicator. CONFIG_VAR controls derive it from value != + * default when this is NULL. */ + UiPredicateFn is_modified; + /* Passed to every callback above. */ + void* user_data; + /* NUMBER: inclusive clamp range and step. min == max means the defaults (0 .. INT32_MAX); step + * < 1 means 1. */ + int64_t min; + int64_t max; + int64_t step; + const char* prefix; /* NUMBER: optional text before the value */ + const char* suffix; /* NUMBER: optional text after the value */ + /* SELECT: option labels (plain text). Required for SELECT. SELECT controls + * present their options in the help pane, so they are only available where + * one exists (mod window tabs); MOD_UNSUPPORTED elsewhere. */ + const char* const* options; + size_t option_count; + int32_t max_length; /* STRING: maximum input length; < 1 means unlimited */ +} UiControlDesc; + +#define UI_CONTROL_DESC_INIT \ + {sizeof(UiControlDesc), UI_CONTROL_BUTTON, NULL, NULL, UI_BINDING_CALLBACKS, 0u, NULL, NULL, \ + NULL, NULL, NULL, NULL, 0, 0, 1, NULL, NULL, NULL, 0u, 0} + +/* Build the panel contents. `panel` accepts the pane_add_* functions; it and + * every element created in it are destroyed (handles invalidated) whenever the + * panel is rebuilt, e.g. on tab switches. A non-MOD_OK result fails the mod. */ +typedef ModResult (*UiPanelBuildFn)( + ModContext* ctx, UiElementHandle panel, void* user_data, ModError* out_error); +/* Called every frame while the panel is the visible tab. */ +typedef ModResult (*UiPanelUpdateFn)(ModContext* ctx, void* user_data, ModError* out_error); + +/* A panel rendered inside the calling mod's tab of the host Mods window. */ +typedef struct UiModsPanelDesc { + uint32_t struct_size; + UiPanelBuildFn build; /* required */ + UiPanelUpdateFn update; + void* user_data; +} UiModsPanelDesc; + +#define UI_MODS_PANEL_DESC_INIT {sizeof(UiModsPanelDesc), NULL, NULL, NULL} + +/* Build one tab of a mod window. `left_pane` is the interactive column, + * `right_pane` shows contextual help (controls' help_rml and SELECT options + * render there). Rebuilt on every tab activation. */ +typedef ModResult (*UiTabBuildFn)(ModContext* ctx, UiWindowHandle window, UiElementHandle left_pane, + UiElementHandle right_pane, void* user_data, ModError* out_error); + +typedef struct UiTabDesc { + uint32_t struct_size; + const char* title; /* required */ + UiTabBuildFn build; /* required */ + UiPanelUpdateFn update; + void* user_data; +} UiTabDesc; + +#define UI_TAB_DESC_INIT {sizeof(UiTabDesc), NULL, NULL, NULL, NULL} + +typedef void (*UiWindowClosedFn)(ModContext* ctx, UiWindowHandle window, void* user_data); + +typedef struct UiWindowDesc { + uint32_t struct_size; + const UiTabDesc* tabs; /* at least one */ + size_t tab_count; + /* Optional RCSS applied to this window's document only (on top of any + * UI_SCOPE_WINDOW sheets, which apply automatically). */ + const char* rcss; + /* Fired when the window is destroyed by user close or window_close. Not + * fired during owning-mod teardown/shutdown. The handle is already invalid. */ + UiWindowClosedFn on_closed; + void* user_data; +} UiWindowDesc; + +#define UI_WINDOW_DESC_INIT {sizeof(UiWindowDesc), NULL, 0u, NULL, NULL, NULL} + +typedef void (*UiDialogActionFn)(ModContext* ctx, UiDialogHandle dialog, void* user_data); + +/* Note: array element without struct_size; a future change requires appending + * a v2 desc struct rather than growing this one. */ +typedef struct UiDialogAction { + const char* label; /* required */ + UiDialogActionFn on_pressed; + void* user_data; + bool keep_open; /* false = the dialog closes after on_pressed returns */ +} UiDialogAction; + +typedef struct UiDialogDesc { + uint32_t struct_size; + const char* title; /* plain text; required */ + const char* body_rml; /* RML body; required */ + UiDialogVariant variant; + /* Optional icon name overriding the variant default: "warning", "error", + * "question-mark", "verifying", "celebration". */ + const char* icon; + const UiDialogAction* actions; /* at least one */ + size_t action_count; + /* Fired on cancel (B/Escape) before the dialog closes; the dialog always + * closes on dismiss. */ + UiDialogActionFn on_dismiss; + void* user_data; /* passed to on_dismiss */ +} UiDialogDesc; + +#define UI_DIALOG_DESC_INIT \ + {sizeof(UiDialogDesc), NULL, NULL, UI_DIALOG_NORMAL, NULL, NULL, 0u, NULL, NULL} + +/* A tab added to the in-game menu bar. */ +typedef struct UiMenuTabDesc { + uint32_t struct_size; + const char* label; /* plain text; required */ + UiPressedFn on_selected; /* fired when the user activates the tab; required */ + void* user_data; +} UiMenuTabDesc; + +#define UI_MENU_TAB_DESC_INIT {sizeof(UiMenuTabDesc), NULL, NULL, NULL} + +typedef struct UiService { + ServiceHeader header; + + /* Register or replace the panel shown in the calling mod's Mods-window tab. */ + ModResult (*register_mods_panel)(ModContext* ctx, const UiModsPanelDesc* desc); + + /* Content builders. `pane` is a panel or tab pane handle; out_elem (where + * present, optional) receives a handle for later elem_set_* updates. */ + ModResult (*pane_add_section)(ModContext* ctx, UiElementHandle pane, const char* title); + ModResult (*pane_add_text)( + ModContext* ctx, UiElementHandle pane, const char* text, UiElementHandle* out_elem); + ModResult (*pane_add_rml)( + ModContext* ctx, UiElementHandle pane, const char* rml, UiElementHandle* out_elem); + ModResult (*pane_add_progress)( + ModContext* ctx, UiElementHandle pane, float value, UiElementHandle* out_elem); + ModResult (*pane_add_control)(ModContext* ctx, UiElementHandle pane, const UiControlDesc* desc, + UiElementHandle* out_elem); + + /* Element updates. The handle kind must match the setter (text/rml on text + * rows, progress on progress bars). */ + ModResult (*elem_set_text)(ModContext* ctx, UiElementHandle elem, const char* text); + ModResult (*elem_set_rml)(ModContext* ctx, UiElementHandle elem, const char* rml); + ModResult (*elem_set_progress)(ModContext* ctx, UiElementHandle elem, float value); + /* Set or clear an RCSS class on any element handle (rows, progress bars, + * controls, panes), for styling via scoped or window RCSS. */ + ModResult (*elem_set_class)( + ModContext* ctx, UiElementHandle elem, const char* name, bool active); + + /* Push a tabbed two-pane window onto the document stack and show it. */ + ModResult (*window_push)(ModContext* ctx, const UiWindowDesc* desc, UiWindowHandle* out_window); + ModResult (*window_close)(ModContext* ctx, UiWindowHandle window); + + /* Push a modal dialog onto the document stack and show it. */ + ModResult (*dialog_push)(ModContext* ctx, const UiDialogDesc* desc, UiDialogHandle* out_dialog); + ModResult (*dialog_close)(ModContext* ctx, UiDialogHandle dialog); + ModResult (*dialog_set_body)(ModContext* ctx, UiDialogHandle dialog, const char* body_rml); + /* Replace the dialog icon ("" removes it; names as in UiDialogDesc.icon). */ + ModResult (*dialog_set_icon)(ModContext* ctx, UiDialogHandle dialog, const char* icon); + /* Append one action button (same callback rules as at push). */ + ModResult (*dialog_add_action)( + ModContext* ctx, UiDialogHandle dialog, const UiDialogAction* action); + + /* Whether any focus-stack document is currently visible (visible documents block gamepad + * input). */ + ModResult (*is_any_document_visible)(ModContext* ctx, bool* out_visible); + + /* Register an RCSS sheet applied to every document of `scope`, now and in the future, until + * unregistered or the calling mod is torn down. Sheets apply in registration order after the + * host styles. Fails with MOD_INVALID_ARGUMENT if the RCSS does not parse. */ + ModResult (*register_styles)( + ModContext* ctx, UiStyleScope scope, const char* rcss, UiStyleHandle* out_style); + /* Like register_styles, but reads the RCSS from the mod bundle's res/ directory (same path + * rules as ResourceService::load). MOD_UNAVAILABLE if the file does not exist. */ + ModResult (*register_styles_file)( + ModContext* ctx, UiStyleScope scope, const char* path, UiStyleHandle* out_style); + ModResult (*unregister_styles)(ModContext* ctx, UiStyleHandle style); + + /* Add a tab to the in-game menu bar, on_selected runs with the menu open; pushing a window from + * it stacks the window over the menu like the host tabs do. The tab is removed when + * unregistered or the mod is torn down. */ + ModResult (*register_menu_tab)( + ModContext* ctx, const UiMenuTabDesc* desc, UiMenuTabHandle* out_tab); + ModResult (*unregister_menu_tab)(ModContext* ctx, UiMenuTabHandle tab); +} UiService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = UI_SERVICE_ID; + static constexpr uint16_t major_version = UI_SERVICE_MAJOR; +}; +#endif diff --git a/libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h b/libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h index 5477868596..ea13c0600c 100644 --- a/libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h +++ b/libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h @@ -4,6 +4,8 @@ #include "JSystem/J3DGraphBase/J3DShapeDraw.h" #include "JSystem/J3DAssert.h" #include "JSystem/J3DGraphBase/J3DFifo.h" +#include "JSystem/JMath/JMath.h" +#include "global.h" #include #include "dusk/endian_gx.hpp" @@ -202,7 +204,7 @@ public: static void resetVcdVatCache() { sOldVcdVatCmd = NULL; } - static void* sOldVcdVatCmd; + static DUSK_GAME_DATA void* sOldVcdVatCmd; static bool sEnvelopeFlag; private: diff --git a/libs/JSystem/include/JSystem/J3DGraphBase/J3DSys.h b/libs/JSystem/include/JSystem/J3DGraphBase/J3DSys.h index 8c4bbd06e3..ddc961f73f 100644 --- a/libs/JSystem/include/JSystem/J3DGraphBase/J3DSys.h +++ b/libs/JSystem/include/JSystem/J3DGraphBase/J3DSys.h @@ -8,6 +8,7 @@ #include "JSystem/JMath/JMath.h" #include "dusk/frame_interpolation.h" #include "dusk/endian.h" +#include "global.h" enum J3DSysDrawBuf { /* 0x0 */ J3DSysDrawBuf_Opa, @@ -208,6 +209,6 @@ struct J3DSys { }; extern u32 j3dDefaultViewNo; -extern J3DSys j3dSys; +DUSK_GAME_EXTERN J3DSys j3dSys; #endif /* J3DSYS_H */ diff --git a/libs/JSystem/include/JSystem/JHostIO/JORReflexible.h b/libs/JSystem/include/JSystem/JHostIO/JORReflexible.h index 533a3184ba..84894de275 100644 --- a/libs/JSystem/include/JSystem/JHostIO/JORReflexible.h +++ b/libs/JSystem/include/JSystem/JHostIO/JORReflexible.h @@ -12,12 +12,23 @@ struct JORNodeEvent; class JORMContext; class JORServer; +// NOTE (stable game ABI): these classes stay non-polymorphic outside DEBUG +// on purpose. Making them polymorphic under PARTIAL_DEBUG would give every one of the ~250 +// derived HIO classes a vptr and turn their plain `void genMessage(JORMContext*);` +// declarations into implicit virtual overrides whose definitions are #if DEBUG-gated; every +// instantiated one then fails to link (missing vtable). Closure types shared with DEBUG TUs +// either declare their own unconditional virtuals (vptr in all TUs anyway) or add a +// PARTIAL_DEBUG-only virtual dtor for vptr parity (see dAttParam_c). class JOREventListener { public: #if DEBUG JOREventListener() {} +#if TARGET_PC + virtual void listenPropertyEvent(const JORPropertyEvent*) {} +#else virtual void listenPropertyEvent(const JORPropertyEvent*) = 0; #endif +#endif }; class JORReflexible : public JOREventListener { @@ -30,7 +41,11 @@ public: virtual void listenPropertyEvent(const JORPropertyEvent*); virtual void listen(u32, const JOREvent*); virtual void genObjectInfo(const JORGenEvent*); +#if TARGET_PC + virtual void genMessage(JORMContext*) {} +#else virtual void genMessage(JORMContext*) = 0; +#endif virtual void listenNodeEvent(const JORNodeEvent*); #endif }; diff --git a/libs/JSystem/include/JSystem/JStudio/JStudio/functionvalue.h b/libs/JSystem/include/JSystem/JStudio/JStudio/functionvalue.h index 7b2c935272..8acdbe1623 100644 --- a/libs/JSystem/include/JSystem/JStudio/JStudio/functionvalue.h +++ b/libs/JSystem/include/JSystem/JStudio/JStudio/functionvalue.h @@ -314,7 +314,7 @@ public: > { TIterator_data_(const TFunctionValue_list_parameter& rParent, const f32* value) { -#if DEBUG +#if PARTIAL_DEBUG || DEBUG pOwn_ = &rParent; #endif pf_ = value; @@ -372,7 +372,7 @@ public: return (r1.pf_ - r2.pf_) / suData_size; } -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x00 */ const TFunctionValue_list_parameter* pOwn_; #endif /* 0x00 */ const f32* pf_; @@ -425,7 +425,7 @@ public: > { TIterator_data_(const TFunctionValue_hermite& rParent, const f32* value) { -#if DEBUG +#if PARTIAL_DEBUG || DEBUG pOwn_ = &rParent; #endif pf_ = value; @@ -491,7 +491,7 @@ public: return (r1.pf_ - r2.pf_) / r1.uSize_; } -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x00 */ const TFunctionValue_hermite* pOwn_; /* 0x04 */ const f32* pf_; /* 0x08 */ u32 uSize_; diff --git a/mods/ao_mod/CMakeLists.txt b/mods/ao_mod/CMakeLists.txt new file mode 100644 index 0000000000..13590923f0 --- /dev/null +++ b/mods/ao_mod/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.25) +project(ao_mod 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(ao_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res + BUNDLE +) diff --git a/mods/ao_mod/mod.json b/mods/ao_mod/mod.json new file mode 100644 index 0000000000..9019cf335d --- /dev/null +++ b/mods/ao_mod/mod.json @@ -0,0 +1,7 @@ +{ + "id": "dev.twilitrealm.ao_mod", + "name": "[Demo] Ambient Occlusion", + "version": "1.0.0", + "author": "Twilit Realm", + "description": "Ground-truth ambient occlusion (GTAO) computed from the scene depth buffer and composited over the game. Ported from Bevy Engine's SSAO and Intel XeGTAO." +} diff --git a/mods/ao_mod/res/composite.wgsl b/mods/ao_mod/res/composite.wgsl new file mode 100644 index 0000000000..fa3a0101b8 --- /dev/null +++ b/mods/ao_mod/res/composite.wgsl @@ -0,0 +1,161 @@ +// Fullscreen composite: multiplies the denoised ambient-occlusion visibility over the scene. +// +// Debug views: +// 1 = raw AO visibility as grayscale +// 2 = view-space normals reconstructed from depth (keep in sync with gtao.wgsl) +// 3 = the preprocessed depth input +// 4 = depth staircase detector + +struct Uniforms { + projection: mat4x4f, + inverse_projection: mat4x4f, + size: vec2f, // AO texture size in pixels (may be half the render size) + inv_size: vec2f, + depth_scale: vec2f, + effect_radius: f32, + intensity: f32, + slice_count: f32, + samples_per_slice_side: f32, + debug_view: u32, + _pad: f32, +} + +@group(0) @binding(0) var ambient_occlusion: texture_2d; +@group(0) @binding(1) var preprocessed_depth: texture_2d; +@group(0) @binding(2) var scene_depth_raw: texture_2d; +@group(0) @binding(3) var uniforms: Uniforms; + +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +} + +@vertex +fn vs_main(@builtin(vertex_index) index: u32) -> VertexOutput { + // Fullscreen triangle + var out: VertexOutput; + let uv = vec2f(f32((index << 1u) & 2u), f32(index & 2u)); + out.position = vec4f(uv * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0), 0.0, 1.0); + out.uv = uv; + return out; +} + +// Manual bilinear sample (r32float is unfilterable without optional device features) +fn sample_visibility(uv: vec2f) -> f32 { + let coordinates = uv * uniforms.size - 0.5; + let base = floor(coordinates); + let fraction = coordinates - base; + let max_coordinates = vec2i(uniforms.size) - 1i; + let p00 = clamp(vec2i(base), vec2i(0i), max_coordinates); + let p11 = clamp(vec2i(base) + 1i, vec2i(0i), max_coordinates); + let v00 = textureLoad(ambient_occlusion, vec2i(p00.x, p00.y), 0i).r; + let v10 = textureLoad(ambient_occlusion, vec2i(p11.x, p00.y), 0i).r; + let v01 = textureLoad(ambient_occlusion, vec2i(p00.x, p11.y), 0i).r; + let v11 = textureLoad(ambient_occlusion, vec2i(p11.x, p11.y), 0i).r; + let top = mix(v00, v10, fraction.x); + let bottom = mix(v01, v11, fraction.x); + return mix(top, bottom, fraction.y); +} + +fn load_depth(pixel_coordinates: vec2) -> f32 { + let coordinates = clamp(pixel_coordinates, vec2(0i), vec2(uniforms.size) - 1i); + return textureLoad(preprocessed_depth, coordinates, 0i).r; +} + +fn reconstruct_view_space_position(depth: f32, uv: vec2f) -> vec3f { + let clip_xy = vec2f(uv.x * 2.0 - 1.0, 1.0 - 2.0 * uv.y); + let t = uniforms.inverse_projection * vec4f(clip_xy, depth, 1.0); + return t.xyz / t.w; +} + +fn view_position_at(pixel_coordinates: vec2) -> vec3f { + let depth = load_depth(pixel_coordinates); + let uv = (vec2f(pixel_coordinates) + 0.5) * uniforms.inv_size; + return reconstruct_view_space_position(depth, uv); +} + +fn reconstruct_normal(pixel_coordinates: vec2, pixel_position: vec3f, depth_center: f32) -> vec3f { + let depth_left1 = load_depth(pixel_coordinates + vec2(-1i, 0i)); + let depth_left2 = load_depth(pixel_coordinates + vec2(-2i, 0i)); + let depth_right1 = load_depth(pixel_coordinates + vec2(1i, 0i)); + let depth_right2 = load_depth(pixel_coordinates + vec2(2i, 0i)); + let depth_top1 = load_depth(pixel_coordinates + vec2(0i, -1i)); + let depth_top2 = load_depth(pixel_coordinates + vec2(0i, -2i)); + let depth_bottom1 = load_depth(pixel_coordinates + vec2(0i, 1i)); + let depth_bottom2 = load_depth(pixel_coordinates + vec2(0i, 2i)); + + let use_left = abs(2.0 * depth_left1 - depth_left2 - depth_center) < + abs(2.0 * depth_right1 - depth_right2 - depth_center); + let use_top = abs(2.0 * depth_top1 - depth_top2 - depth_center) < + abs(2.0 * depth_bottom1 - depth_bottom2 - depth_center); + + var ddx: vec3f; + if use_left { + ddx = pixel_position - view_position_at(pixel_coordinates + vec2(-1i, 0i)); + } else { + ddx = view_position_at(pixel_coordinates + vec2(1i, 0i)) - pixel_position; + } + var ddy: vec3f; + if use_top { + ddy = pixel_position - view_position_at(pixel_coordinates + vec2(0i, -1i)); + } else { + ddy = view_position_at(pixel_coordinates + vec2(0i, 1i)) - pixel_position; + } + + var normal = normalize(cross(ddy, ddx)); + if dot(normal, pixel_position) > 0.0 { + normal = -normal; + } + return normal; +} + +// Raw-snapshot variant of load_depth for the staircase view +fn load_raw_depth(pixel_coordinates: vec2) -> f32 { + let size = vec2(textureDimensions(scene_depth_raw)); + let coordinates = clamp(pixel_coordinates, vec2(0i), size - 1i); + return textureLoad(scene_depth_raw, coordinates, 0i).r; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4f { + if uniforms.debug_view == 2u { + // Reconstructed view-space normals, [-1,1] -> RGB + let pixel = vec2(in.uv * uniforms.size); + let depth = load_depth(pixel); + let uv = (vec2f(pixel) + 0.5) * uniforms.inv_size; + let position = reconstruct_view_space_position(depth, uv); + let normal = reconstruct_normal(pixel, position, depth); + return vec4f(normal * 0.5 + 0.5, 1.0); + } + if uniforms.debug_view == 3u { + // Preprocessed depth as an exponential distance gradient (white = near, black = far) + let pixel = vec2(in.uv * uniforms.size); + let position = view_position_at(pixel); + let value = exp(-max(-position.z, 0.0) * 0.0003); + return vec4f(value, value, value, 1.0); + } + if uniforms.debug_view == 4u { + // Staircase detector on the raw snapshot depth + let size = vec2f(textureDimensions(scene_depth_raw)); + let pixel = vec2(in.uv * size); + let d_center = load_raw_depth(pixel); + let d_left = load_raw_depth(pixel + vec2(-1i, 0i)); + let d_right = load_raw_depth(pixel + vec2(1i, 0i)); + let d_top = load_raw_depth(pixel + vec2(0i, -1i)); + let d_bottom = load_raw_depth(pixel + vec2(0i, 1i)); + let gradient_x = abs(d_right - d_left) * 0.5; + let curvature_x = abs(d_right - 2.0 * d_center + d_left); + let gradient_y = abs(d_bottom - d_top) * 0.5; + let curvature_y = abs(d_bottom - 2.0 * d_center + d_top); + let ratio_x = curvature_x / max(gradient_x, 1e-12); + let ratio_y = curvature_y / max(gradient_y, 1e-12); + return vec4f(saturate(ratio_x), saturate(ratio_y), 0.0, 1.0); + } + + let visibility = sample_visibility(in.uv); + if uniforms.debug_view == 1u { + return vec4f(visibility, visibility, visibility, 1.0); + } + let value = mix(1.0, visibility, uniforms.intensity); + return vec4f(value, value, value, 1.0); +} diff --git a/mods/ao_mod/res/denoise.wgsl b/mods/ao_mod/res/denoise.wgsl new file mode 100644 index 0000000000..b947b1bd04 --- /dev/null +++ b/mods/ao_mod/res/denoise.wgsl @@ -0,0 +1,108 @@ +// 3x3 bilaterial filter (edge-preserving blur) +// https://people.csail.mit.edu/sparis/bf_course/course_notes.pdf +// +// Note: Does not use the Gaussian kernel part of a typical bilateral blur +// From the paper: "use the information gathered on a neighborhood of 4 x 4 using a bilateral filter for +// reconstruction, using _uniform_ convolution weights" +// +// Note: The paper does a 4x4 (not quite centered) filter, offset by +/- 1 pixel every other frame +// XeGTAO does a 3x3 filter, on two pixels at a time per compute thread, applied twice +// We do a 3x3 filter, on 1 pixel per compute thread, applied once +// +// Ported from Bevy Engine, crates/bevy_pbr/src/ssao/spatial_denoise.wgsl (v0.13.2), licensed +// MIT OR Apache-2.0 (see res/licenses/), itself derived from Intel XeGTAO (MIT). +// +// PORT: the textureGather calls are rewritten as explicit per-neighbor textureLoads (r32float +// and r32uint are unfilterable); Bevy view uniforms -> the mod's uniform block; r16float -> r32float. + +struct Uniforms { + projection: mat4x4f, + inverse_projection: mat4x4f, + size: vec2f, + inv_size: vec2f, + depth_scale: vec2f, + effect_radius: f32, + intensity: f32, + slice_count: f32, + samples_per_slice_side: f32, + debug_view: u32, + _pad: f32, +} + +@group(0) @binding(0) var ambient_occlusion_noisy: texture_2d; +@group(0) @binding(1) var depth_differences: texture_2d; +@group(0) @binding(2) var ambient_occlusion: texture_storage_2d; +@group(0) @binding(3) var uniforms: Uniforms; + +fn clamp_coordinates(pixel_coordinates: vec2) -> vec2 { + return clamp(pixel_coordinates, vec2(0i), vec2(uniforms.size) - 1i); +} + +// Each pixel's packed edge info is (left, right, top, bottom) weights, packed by the GTAO pass. +fn load_edges(pixel_coordinates: vec2) -> vec4 { + return unpack4x8unorm(textureLoad(depth_differences, clamp_coordinates(pixel_coordinates), 0i).r); +} + +fn load_visibility(pixel_coordinates: vec2) -> f32 { + return textureLoad(ambient_occlusion_noisy, clamp_coordinates(pixel_coordinates), 0i).r; +} + +@compute +@workgroup_size(8, 8, 1) +fn spatial_denoise(@builtin(global_invocation_id) global_id: vec3) { + let pixel_coordinates = vec2(global_id.xy); + + let left_edges = load_edges(pixel_coordinates + vec2(-1i, 0i)); + let right_edges = load_edges(pixel_coordinates + vec2(1i, 0i)); + let top_edges = load_edges(pixel_coordinates + vec2(0i, -1i)); + let bottom_edges = load_edges(pixel_coordinates + vec2(0i, 1i)); + var center_edges = load_edges(pixel_coordinates); + // Cross-check each edge against the neighbor's opposing edge weight. + center_edges *= vec4(left_edges.y, right_edges.x, top_edges.w, bottom_edges.z); + + let center_weight = 1.2; + let left_weight = center_edges.x; + let right_weight = center_edges.y; + let top_weight = center_edges.z; + let bottom_weight = center_edges.w; + let top_left_weight = 0.425 * (top_weight * top_edges.x + left_weight * left_edges.z); + let top_right_weight = 0.425 * (top_weight * top_edges.y + right_weight * right_edges.z); + let bottom_left_weight = 0.425 * (bottom_weight * bottom_edges.x + left_weight * left_edges.w); + let bottom_right_weight = 0.425 * (bottom_weight * bottom_edges.y + right_weight * right_edges.w); + + let center_visibility = load_visibility(pixel_coordinates); + let left_visibility = load_visibility(pixel_coordinates + vec2(-1i, 0i)); + let right_visibility = load_visibility(pixel_coordinates + vec2(1i, 0i)); + let top_visibility = load_visibility(pixel_coordinates + vec2(0i, -1i)); + let bottom_visibility = load_visibility(pixel_coordinates + vec2(0i, 1i)); + let top_left_visibility = load_visibility(pixel_coordinates + vec2(-1i, -1i)); + let top_right_visibility = load_visibility(pixel_coordinates + vec2(1i, -1i)); + let bottom_left_visibility = load_visibility(pixel_coordinates + vec2(-1i, 1i)); + let bottom_right_visibility = load_visibility(pixel_coordinates + vec2(1i, 1i)); + + // PORT: Bevy sums the center sample unweighted while still counting center_weight in the + // denominator; XeGTAO's original weights the value too, which is what we do here. + var sum = center_visibility * center_weight; + sum += left_visibility * left_weight; + sum += right_visibility * right_weight; + sum += top_visibility * top_weight; + sum += bottom_visibility * bottom_weight; + sum += top_left_visibility * top_left_weight; + sum += top_right_visibility * top_right_weight; + sum += bottom_left_visibility * bottom_left_weight; + sum += bottom_right_visibility * bottom_right_weight; + + var sum_weight = center_weight; + sum_weight += left_weight; + sum_weight += right_weight; + sum_weight += top_weight; + sum_weight += bottom_weight; + sum_weight += top_left_weight; + sum_weight += top_right_weight; + sum_weight += bottom_left_weight; + sum_weight += bottom_right_weight; + + let denoised_visibility = sum / sum_weight; + + textureStore(ambient_occlusion, pixel_coordinates, vec4(denoised_visibility, 0.0, 0.0, 0.0)); +} diff --git a/mods/ao_mod/res/gtao.wgsl b/mods/ao_mod/res/gtao.wgsl new file mode 100644 index 0000000000..ee23774681 --- /dev/null +++ b/mods/ao_mod/res/gtao.wgsl @@ -0,0 +1,247 @@ +// Ground Truth-based Ambient Occlusion (GTAO) +// Paper: https://www.activision.com/cdn/research/Practical_Real_Time_Strategies_for_Accurate_Indirect_Occlusion_NEW%20VERSION_COLOR.pdf +// Presentation: https://blog.selfshadow.com/publications/s2016-shading-course/activision/s2016_pbs_activision_occlusion.pdf +// +// Ported from Bevy Engine, crates/bevy_pbr/src/ssao/gtao.wgsl (v0.13.2), licensed +// MIT OR Apache-2.0 (see res/licenses/), itself heavily based on XeGTAO v1.30 from Intel (MIT): +// https://github.com/GameTechDev/XeGTAO/blob/0d177ce06bfa642f64d8af4de1197ad1bcb862d4/Source/Rendering/Shaders/XeGTAO.hlsli +// +// PORT: +// - Bevy view/globals bindings -> the mod's own uniform block (matrices from Dusklight's +// CameraService, WebGPU clip convention, reversed-Z - the same convention Bevy uses). +// - Prepass normals -> normals reconstructed from depth (atyuwen's accurate 5-tap method, +// https://atyuwen.github.io/posts/normal-reconstruction/). +// - Sampler-based reads -> textureLoad (r32float is unfilterable without optional features); +// the mip level for the XeGTAO bandwidth optimization is selected explicitly per load. +// - effect_radius and slice/sample counts come from uniforms instead of constants/shader defs +// (game world units are ~100x larger than Bevy's meters, and quality is a live setting). +// - No TEMPORAL_JITTER: the noise index is pinned (no TAA; the spatial denoiser is the only +// filter, a configuration XeGTAO supports). +// - Storage format r16float -> r32float (core WebGPU storage format). + +struct Uniforms { + projection: mat4x4f, + inverse_projection: mat4x4f, + size: vec2f, + inv_size: vec2f, + depth_scale: vec2f, + effect_radius: f32, + intensity: f32, + slice_count: f32, + samples_per_slice_side: f32, + debug_view: u32, + _pad: f32, +} + +@group(0) @binding(0) var preprocessed_depth: texture_2d; +@group(0) @binding(1) var hilbert_index_lut: texture_2d; +@group(0) @binding(2) var ambient_occlusion: texture_storage_2d; +@group(0) @binding(3) var depth_differences: texture_storage_2d; +@group(0) @binding(4) var uniforms: Uniforms; + +const PI: f32 = 3.141592653589793; +const HALF_PI: f32 = 1.5707963267948966; + +fn fast_sqrt(x: f32) -> f32 { + return bitcast(0x1fbd1df5 + (bitcast(x) >> 1u)); +} + +fn fast_acos(in_x: f32) -> f32 { + let x = abs(in_x); + var res = -0.156583 * x + HALF_PI; + res *= fast_sqrt(1.0 - x); + return select(PI - res, res, in_x >= 0.0); +} + +fn load_noise(pixel_coordinates: vec2) -> vec2 { + let index = textureLoad(hilbert_index_lut, pixel_coordinates % 64, 0).r; + // R2 sequence - http://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences + return fract(0.5 + f32(index) * vec2(0.75487766624669276005, 0.5698402909980532659114)); +} + +fn load_depth(pixel_coordinates: vec2, mip_level: i32) -> f32 { + let mip_size = max(vec2(uniforms.size) >> vec2(u32(mip_level)), vec2(1i)); + let coordinates = clamp(pixel_coordinates, vec2(0i), mip_size - 1i); + return textureLoad(preprocessed_depth, coordinates, mip_level).r; +} + +// Calculate differences in depth between neighbor pixels (later used by the spatial denoiser pass to preserve object edges) +fn calculate_neighboring_depth_differences(pixel_coordinates: vec2) -> f32 { + // Sample the pixel's depth and 4 depths around it + // PORT: explicit loads instead of two textureGathers. + let depth_center = load_depth(pixel_coordinates, 0i); + let depth_left = load_depth(pixel_coordinates + vec2(-1i, 0i), 0i); + let depth_top = load_depth(pixel_coordinates + vec2(0i, -1i), 0i); + let depth_bottom = load_depth(pixel_coordinates + vec2(0i, 1i), 0i); + let depth_right = load_depth(pixel_coordinates + vec2(1i, 0i), 0i); + + // Calculate the depth differences (large differences represent object edges) + var edge_info = vec4(depth_left, depth_right, depth_top, depth_bottom) - depth_center; + let slope_left_right = (edge_info.y - edge_info.x) * 0.5; + let slope_top_bottom = (edge_info.w - edge_info.z) * 0.5; + let edge_info_slope_adjusted = edge_info + vec4(slope_left_right, -slope_left_right, slope_top_bottom, -slope_top_bottom); + edge_info = min(abs(edge_info), abs(edge_info_slope_adjusted)); + let bias = 0.25; // Using the bias and then saturating nudges the values a bit + let scale = depth_center * 0.011; // Weight the edges by their distance from the camera + edge_info = saturate((1.0 + bias) - edge_info / scale); // Apply the bias and scale, and invert edge_info so that small values become large, and vice versa + + // Pack the edge info into the texture + let edge_info_packed = vec4(pack4x8unorm(edge_info), 0u, 0u, 0u); + textureStore(depth_differences, pixel_coordinates, edge_info_packed); + + return depth_center; +} + +fn reconstruct_view_space_position(depth: f32, uv: vec2) -> vec3 { + let clip_xy = vec2(uv.x * 2.0 - 1.0, 1.0 - 2.0 * uv.y); + let t = uniforms.inverse_projection * vec4(clip_xy, depth, 1.0); + let view_xyz = t.xyz / t.w; + return view_xyz; +} + +fn view_position_at(pixel_coordinates: vec2) -> vec3 { + let depth = load_depth(pixel_coordinates, 0i); + let uv = (vec2(pixel_coordinates) + 0.5) * uniforms.inv_size; + return reconstruct_view_space_position(depth, uv); +} + +// PORT: replaces Bevy's load_normal_view_space (which reads a prepass normal texture we do +// not have). Accurate view-space normal reconstruction from depth, atyuwen's 5-tap method: +// for each axis, extrapolate the center depth from the two taps on each side and derive the +// tangent from whichever side predicts it better. This keeps normals stable across depth +// discontinuities where naive derivatives smear. +fn reconstruct_normal(pixel_coordinates: vec2, pixel_position: vec3, depth_center: f32) -> vec3 { + let depth_left1 = load_depth(pixel_coordinates + vec2(-1i, 0i), 0i); + let depth_left2 = load_depth(pixel_coordinates + vec2(-2i, 0i), 0i); + let depth_right1 = load_depth(pixel_coordinates + vec2(1i, 0i), 0i); + let depth_right2 = load_depth(pixel_coordinates + vec2(2i, 0i), 0i); + let depth_top1 = load_depth(pixel_coordinates + vec2(0i, -1i), 0i); + let depth_top2 = load_depth(pixel_coordinates + vec2(0i, -2i), 0i); + let depth_bottom1 = load_depth(pixel_coordinates + vec2(0i, 1i), 0i); + let depth_bottom2 = load_depth(pixel_coordinates + vec2(0i, 2i), 0i); + + let use_left = abs(2.0 * depth_left1 - depth_left2 - depth_center) < + abs(2.0 * depth_right1 - depth_right2 - depth_center); + let use_top = abs(2.0 * depth_top1 - depth_top2 - depth_center) < + abs(2.0 * depth_bottom1 - depth_bottom2 - depth_center); + + var ddx: vec3; + if use_left { + ddx = pixel_position - view_position_at(pixel_coordinates + vec2(-1i, 0i)); + } else { + ddx = view_position_at(pixel_coordinates + vec2(1i, 0i)) - pixel_position; + } + var ddy: vec3; + if use_top { + ddy = pixel_position - view_position_at(pixel_coordinates + vec2(0i, -1i)); + } else { + ddy = view_position_at(pixel_coordinates + vec2(0i, 1i)) - pixel_position; + } + + var normal = normalize(cross(ddy, ddx)); + // Guard the orientation: the normal must face the camera. + if dot(normal, pixel_position) > 0.0 { + normal = -normal; + } + return normal; +} + +fn load_and_reconstruct_view_space_position(uv: vec2, sample_mip_level: f32) -> vec3 { + // PORT: point-sample the selected mip explicitly instead of textureSampleLevel. + let mip_level = i32(sample_mip_level + 0.5); + let mip_size = max(vec2(uniforms.size) >> vec2(u32(mip_level)), vec2(1i)); + let depth = load_depth(vec2(uv * vec2(mip_size)), mip_level); + return reconstruct_view_space_position(depth, uv); +} + +@compute +@workgroup_size(8, 8, 1) +fn gtao(@builtin(global_invocation_id) global_id: vec3) { + let slice_count = uniforms.slice_count; + let samples_per_slice_side = uniforms.samples_per_slice_side; + let effect_radius = uniforms.effect_radius; + let falloff_range = 0.615 * effect_radius; + let falloff_from = effect_radius * (1.0 - 0.615); + let falloff_mul = -1.0 / falloff_range; + let falloff_add = falloff_from / falloff_range + 1.0; + + let pixel_coordinates = vec2(global_id.xy); + let uv = (vec2(pixel_coordinates) + 0.5) * uniforms.inv_size; + + var pixel_depth = calculate_neighboring_depth_differences(pixel_coordinates); + let raw_depth = pixel_depth; + pixel_depth += 0.00001; // Avoid depth precision issues + + let pixel_position = reconstruct_view_space_position(pixel_depth, uv); + // PORT: the reconstruction differences the center position against neighbor positions + // built from unbiased depths, so its center must use the raw depth too: at this game's + // depth scale (far plane 200000 -> depth ~5e-3) Bevy's +0.00001 bias is comparable to a + // one-pixel depth step, and a biased center corrupts both tangents. + let pixel_normal = reconstruct_normal( + pixel_coordinates, reconstruct_view_space_position(raw_depth, uv), raw_depth); + let view_vec = normalize(-pixel_position); + + let noise = load_noise(pixel_coordinates); + let sample_scale = (-0.5 * effect_radius * uniforms.projection[0][0]) / pixel_position.z; + + var visibility = 0.0; + for (var slice_t = 0.0; slice_t < slice_count; slice_t += 1.0) { + let slice = slice_t + noise.x; + let phi = (PI / slice_count) * slice; + let omega = vec2(cos(phi), sin(phi)); + + let direction = vec3(omega.xy, 0.0); + let orthographic_direction = direction - (dot(direction, view_vec) * view_vec); + let axis = cross(direction, view_vec); + let projected_normal = pixel_normal - axis * dot(pixel_normal, axis); + let projected_normal_length = length(projected_normal); + + let sign_norm = sign(dot(orthographic_direction, projected_normal)); + let cos_norm = saturate(dot(projected_normal, view_vec) / projected_normal_length); + let n = sign_norm * fast_acos(cos_norm); + + let min_cos_horizon_1 = cos(n + HALF_PI); + let min_cos_horizon_2 = cos(n - HALF_PI); + var cos_horizon_1 = min_cos_horizon_1; + var cos_horizon_2 = min_cos_horizon_2; + let sample_mul = vec2(omega.x, -omega.y) * sample_scale; + for (var sample_t = 0.0; sample_t < samples_per_slice_side; sample_t += 1.0) { + var sample_noise = (slice_t + sample_t * samples_per_slice_side) * 0.6180339887498948482; + sample_noise = fract(noise.y + sample_noise); + + var s = (sample_t + sample_noise) / samples_per_slice_side; + s *= s; // https://github.com/GameTechDev/XeGTAO#sample-distribution + let sample = s * sample_mul; + + // * uniforms.size gets us from [0, 1] to [0, viewport_size], which is needed for this to get the correct mip levels + let sample_mip_level = clamp(log2(length(sample * uniforms.size)) - 3.3, 0.0, 4.0); // https://github.com/GameTechDev/XeGTAO#memory-bandwidth-bottleneck + let sample_position_1 = load_and_reconstruct_view_space_position(uv + sample, sample_mip_level); + let sample_position_2 = load_and_reconstruct_view_space_position(uv - sample, sample_mip_level); + + let sample_difference_1 = sample_position_1 - pixel_position; + let sample_difference_2 = sample_position_2 - pixel_position; + let sample_distance_1 = length(sample_difference_1); + let sample_distance_2 = length(sample_difference_2); + var sample_cos_horizon_1 = dot(sample_difference_1 / sample_distance_1, view_vec); + var sample_cos_horizon_2 = dot(sample_difference_2 / sample_distance_2, view_vec); + + let weight_1 = saturate(sample_distance_1 * falloff_mul + falloff_add); + let weight_2 = saturate(sample_distance_2 * falloff_mul + falloff_add); + sample_cos_horizon_1 = mix(min_cos_horizon_1, sample_cos_horizon_1, weight_1); + sample_cos_horizon_2 = mix(min_cos_horizon_2, sample_cos_horizon_2, weight_2); + + cos_horizon_1 = max(cos_horizon_1, sample_cos_horizon_1); + cos_horizon_2 = max(cos_horizon_2, sample_cos_horizon_2); + } + + let horizon_1 = fast_acos(cos_horizon_1); + let horizon_2 = -fast_acos(cos_horizon_2); + let v1 = (cos_norm + 2.0 * horizon_1 * sin(n) - cos(2.0 * horizon_1 - n)) / 4.0; + let v2 = (cos_norm + 2.0 * horizon_2 * sin(n) - cos(2.0 * horizon_2 - n)) / 4.0; + visibility += projected_normal_length * (v1 + v2); + } + visibility /= slice_count; + visibility = clamp(visibility, 0.03, 1.0); + + textureStore(ambient_occlusion, pixel_coordinates, vec4(visibility, 0.0, 0.0, 0.0)); +} diff --git a/mods/ao_mod/res/licenses/BEVY-APACHE-2.0.txt b/mods/ao_mod/res/licenses/BEVY-APACHE-2.0.txt new file mode 100644 index 0000000000..d9a10c0d8e --- /dev/null +++ b/mods/ao_mod/res/licenses/BEVY-APACHE-2.0.txt @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/mods/ao_mod/res/licenses/BEVY-MIT.txt b/mods/ao_mod/res/licenses/BEVY-MIT.txt new file mode 100644 index 0000000000..9cf106272a --- /dev/null +++ b/mods/ao_mod/res/licenses/BEVY-MIT.txt @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mods/ao_mod/res/licenses/XEGTAO-MIT.txt b/mods/ao_mod/res/licenses/XEGTAO-MIT.txt new file mode 100644 index 0000000000..2b1bd1561b --- /dev/null +++ b/mods/ao_mod/res/licenses/XEGTAO-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (C) 2016-2021, Intel Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mods/ao_mod/res/preprocess_depth.wgsl b/mods/ao_mod/res/preprocess_depth.wgsl new file mode 100644 index 0000000000..98fe1d5400 --- /dev/null +++ b/mods/ao_mod/res/preprocess_depth.wgsl @@ -0,0 +1,138 @@ +// Inputs a depth texture and outputs a MIP-chain of depths. +// +// Because SSAO's performance is bound by texture reads, this increases +// performance over using the full resolution depth for every sample. +// +// Reference: https://research.nvidia.com/sites/default/files/pubs/2012-06_Scalable-Ambient-Obscurance/McGuire12SAO.pdf, section 2.2 +// +// Ported from Bevy Engine, crates/bevy_pbr/src/ssao/preprocess_depth.wgsl (v0.13.2), +// licensed MIT OR Apache-2.0 (see res/licenses/), itself derived from Intel XeGTAO (MIT). +// +// PORT: sampler-based gathers replaced with textureLoad (r32float is not filterable without +// optional device features), Bevy view uniforms replaced with the mod's own uniform block, +// storage format r16float -> r32float (core WebGPU storage format). MIP 4 moved into its own +// entry point (core WebGPU limit is 4 storage textures per stage). + +struct Uniforms { + projection: mat4x4f, + inverse_projection: mat4x4f, + size: vec2f, // AO chain size in pixels (MIP 0 of the preprocessed depth) + inv_size: vec2f, + depth_scale: vec2f, // input depth snapshot pixels per chain pixel (1 or 2) + effect_radius: f32, // view-space units + intensity: f32, + slice_count: f32, + samples_per_slice_side: f32, + debug_view: u32, + _pad: f32, +} + +@group(0) @binding(0) var input_depth: texture_2d; +@group(0) @binding(1) var preprocessed_depth_mip0: texture_storage_2d; +@group(0) @binding(2) var preprocessed_depth_mip1: texture_storage_2d; +@group(0) @binding(3) var preprocessed_depth_mip2: texture_storage_2d; +@group(0) @binding(4) var preprocessed_depth_mip3: texture_storage_2d; +@group(0) @binding(5) var uniforms: Uniforms; +// downsample_mip4 entry point only (disjoint subresources of the same texture). +@group(0) @binding(6) var preprocessed_depth_mip3_in: texture_2d; +@group(0) @binding(7) var preprocessed_depth_mip4: texture_storage_2d; + +// PORT: replaces the textureGather of the input depth with explicit loads (also handles the +// half-resolution case, where one chain texel covers depth_scale snapshot texels). +fn load_input_depth(pixel_coordinates: vec2) -> f32 { + let input_size = vec2(uniforms.size * uniforms.depth_scale); + let coordinates = clamp(vec2(vec2(pixel_coordinates) * uniforms.depth_scale), + vec2(0i), input_size - 1i); + return textureLoad(input_depth, coordinates, 0i).r; +} + +// Using 4 depths from the previous MIP, compute a weighted average for the depth of the current MIP +fn weighted_average(depth0: f32, depth1: f32, depth2: f32, depth3: f32) -> f32 { + let depth_range_scale_factor = 0.75; + let effect_radius = depth_range_scale_factor * 0.5 * 1.457; + let falloff_range = 0.615 * effect_radius; + let falloff_from = effect_radius * (1.0 - 0.615); + let falloff_mul = -1.0 / falloff_range; + let falloff_add = falloff_from / falloff_range + 1.0; + + let min_depth = min(min(depth0, depth1), min(depth2, depth3)); + let weight0 = saturate((depth0 - min_depth) * falloff_mul + falloff_add); + let weight1 = saturate((depth1 - min_depth) * falloff_mul + falloff_add); + let weight2 = saturate((depth2 - min_depth) * falloff_mul + falloff_add); + let weight3 = saturate((depth3 - min_depth) * falloff_mul + falloff_add); + let weight_total = weight0 + weight1 + weight2 + weight3; + + return ((weight0 * depth0) + (weight1 * depth1) + (weight2 * depth2) + (weight3 * depth3)) / weight_total; +} + +// Used to share the depths from the previous MIP level between all invocations in a workgroup +var previous_mip_depth: array, 8>; + +@compute +@workgroup_size(8, 8, 1) +fn preprocess_depth(@builtin(global_invocation_id) global_id: vec3, @builtin(local_invocation_id) local_id: vec3) { + let base_coordinates = vec2(global_id.xy); + + // MIP 0 - Copy 4 texels from the input depth (per invocation, 8x8 invocations per workgroup) + let pixel_coordinates0 = base_coordinates * 2i; + let pixel_coordinates1 = pixel_coordinates0 + vec2(1i, 0i); + let pixel_coordinates2 = pixel_coordinates0 + vec2(0i, 1i); + let pixel_coordinates3 = pixel_coordinates0 + vec2(1i, 1i); + let depth0 = load_input_depth(pixel_coordinates0); + let depth1 = load_input_depth(pixel_coordinates1); + let depth2 = load_input_depth(pixel_coordinates2); + let depth3 = load_input_depth(pixel_coordinates3); + textureStore(preprocessed_depth_mip0, pixel_coordinates0, vec4(depth0, 0.0, 0.0, 0.0)); + textureStore(preprocessed_depth_mip0, pixel_coordinates1, vec4(depth1, 0.0, 0.0, 0.0)); + textureStore(preprocessed_depth_mip0, pixel_coordinates2, vec4(depth2, 0.0, 0.0, 0.0)); + textureStore(preprocessed_depth_mip0, pixel_coordinates3, vec4(depth3, 0.0, 0.0, 0.0)); + + // MIP 1 - Weighted average of MIP 0's depth values (per invocation, 8x8 invocations per workgroup) + let depth_mip1 = weighted_average(depth0, depth1, depth2, depth3); + textureStore(preprocessed_depth_mip1, base_coordinates, vec4(depth_mip1, 0.0, 0.0, 0.0)); + previous_mip_depth[local_id.x][local_id.y] = depth_mip1; + + workgroupBarrier(); + + // MIP 2 - Weighted average of MIP 1's depth values (per invocation, 4x4 invocations per workgroup) + if all(local_id.xy % vec2(2u) == vec2(0u)) { + let mip2_depth0 = previous_mip_depth[local_id.x + 0u][local_id.y + 0u]; + let mip2_depth1 = previous_mip_depth[local_id.x + 1u][local_id.y + 0u]; + let mip2_depth2 = previous_mip_depth[local_id.x + 0u][local_id.y + 1u]; + let mip2_depth3 = previous_mip_depth[local_id.x + 1u][local_id.y + 1u]; + let depth_mip2 = weighted_average(mip2_depth0, mip2_depth1, mip2_depth2, mip2_depth3); + textureStore(preprocessed_depth_mip2, base_coordinates / 2i, vec4(depth_mip2, 0.0, 0.0, 0.0)); + previous_mip_depth[local_id.x][local_id.y] = depth_mip2; + } + + workgroupBarrier(); + + // MIP 3 - Weighted average of MIP 2's depth values (per invocation, 2x2 invocations per workgroup) + if all(local_id.xy % vec2(4u) == vec2(0u)) { + let mip3_depth0 = previous_mip_depth[local_id.x + 0u][local_id.y + 0u]; + let mip3_depth1 = previous_mip_depth[local_id.x + 2u][local_id.y + 0u]; + let mip3_depth2 = previous_mip_depth[local_id.x + 0u][local_id.y + 2u]; + let mip3_depth3 = previous_mip_depth[local_id.x + 2u][local_id.y + 2u]; + let depth_mip3 = weighted_average(mip3_depth0, mip3_depth1, mip3_depth2, mip3_depth3); + textureStore(preprocessed_depth_mip3, base_coordinates / 4i, vec4(depth_mip3, 0.0, 0.0, 0.0)); + previous_mip_depth[local_id.x][local_id.y] = depth_mip3; + } +} + +// MIP 4: weighted average of MIP 3's depth values, as a second (tiny) dispatch. +@compute +@workgroup_size(8, 8, 1) +fn downsample_mip4(@builtin(global_invocation_id) global_id: vec3) { + let base_coordinates = vec2(global_id.xy); + let mip3_size = max(vec2(textureDimensions(preprocessed_depth_mip3_in)), vec2(1i)); + let coordinates0 = clamp(base_coordinates * 2i, vec2(0i), mip3_size - 1i); + let coordinates1 = clamp(base_coordinates * 2i + vec2(1i, 0i), vec2(0i), mip3_size - 1i); + let coordinates2 = clamp(base_coordinates * 2i + vec2(0i, 1i), vec2(0i), mip3_size - 1i); + let coordinates3 = clamp(base_coordinates * 2i + vec2(1i, 1i), vec2(0i), mip3_size - 1i); + let depth0 = textureLoad(preprocessed_depth_mip3_in, coordinates0, 0i).r; + let depth1 = textureLoad(preprocessed_depth_mip3_in, coordinates1, 0i).r; + let depth2 = textureLoad(preprocessed_depth_mip3_in, coordinates2, 0i).r; + let depth3 = textureLoad(preprocessed_depth_mip3_in, coordinates3, 0i).r; + let depth_mip4 = weighted_average(depth0, depth1, depth2, depth3); + textureStore(preprocessed_depth_mip4, base_coordinates, vec4(depth_mip4, 0.0, 0.0, 0.0)); +} diff --git a/mods/ao_mod/src/mod.cpp b/mods/ao_mod/src/mod.cpp new file mode 100644 index 0000000000..a7eb4f4028 --- /dev/null +++ b/mods/ao_mod/src/mod.cpp @@ -0,0 +1,931 @@ +// Ambient occlusion (GTAO) example mod. +// +// Showcases the gfx service's compute tasks and the camera service: after opaque scene draws, +// before translucent/fog overlays, the scene depth is resolved and a three-dispatch compute +// chain (depth MIP prefilter, GTAO, spatial denoise) produces a visibility texture that a +// fullscreen draw multiplies over the world. +// +// The WGSL in res/ is ported from Bevy Engine's SSAO implementation (MIT OR Apache-2.0), +// itself based on Intel XeGTAO (MIT); see res/licenses/ and the `PORT:` notes in the shaders. + +#include "mods/service.hpp" +#include "mods/svc/camera.h" +#include "mods/svc/config.h" +#include "mods/svc/gfx.h" +#include "mods/svc/log.h" +#include "mods/svc/resource.h" +#include "mods/svc/ui.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +DEFINE_MOD(); +IMPORT_SERVICE(LogService, svc_log); +IMPORT_SERVICE(ConfigService, svc_config); +IMPORT_SERVICE(ResourceService, svc_resource); +IMPORT_SERVICE(UiService, svc_ui); +IMPORT_SERVICE(GfxService, svc_gfx); +IMPORT_SERVICE(CameraService, svc_camera); + +namespace { + +ConfigVarHandle g_cvarEnabled = 0; +ConfigVarHandle g_cvarQuality = 0; +ConfigVarHandle g_cvarRadius = 0; +ConfigVarHandle g_cvarIntensity = 0; +ConfigVarHandle g_cvarHalfRes = 0; +ConfigVarHandle g_cvarDebugView = 0; + +GfxComputeTypeHandle g_computeType = 0; +GfxDrawTypeHandle g_drawType = 0; +GfxStageHookHandle g_afterOpaqueHook = 0; +UiWindowHandle g_controlsWindow = 0; + +ResourceBuffer g_preprocessSource = RESOURCE_BUFFER_INIT; +ResourceBuffer g_gtaoSource = RESOURCE_BUFFER_INIT; +ResourceBuffer g_denoiseSource = RESOURCE_BUFFER_INIT; +ResourceBuffer g_compositeSource = RESOURCE_BUFFER_INIT; + +GfxDeviceInfo g_deviceInfo = GFX_DEVICE_INFO_INIT; +WGPUComputePipeline g_preprocessPipeline = nullptr; +WGPUComputePipeline g_mip4Pipeline = nullptr; +WGPUComputePipeline g_gtaoPipeline = nullptr; +WGPUComputePipeline g_denoisePipeline = nullptr; +WGPUBindGroupLayout g_preprocessLayout = nullptr; +WGPUBindGroupLayout g_mip4Layout = nullptr; +WGPUBindGroupLayout g_gtaoLayout = nullptr; +WGPUBindGroupLayout g_denoiseLayout = nullptr; +WGPURenderPipeline g_compositePipeline = nullptr; +WGPURenderPipeline g_compositeDebugPipeline = nullptr; +WGPUBindGroupLayout g_compositeLayout = nullptr; +WGPUBindGroupLayout g_compositeDebugLayout = nullptr; +WGPUTexture g_hilbertLut = nullptr; +WGPUTextureView g_hilbertLutView = nullptr; + +// AO chain targets, recreated when the render size (or halfRes) changes. Old sets are retired +// for a few frames instead of released immediately: payloads embedding their views may still +// be in flight on the render worker. +struct AoTargets { + uint32_t width = 0; + uint32_t height = 0; + WGPUTexture preprocessedDepth = nullptr; + WGPUTextureView preprocessedDepthMips[5] = {}; + WGPUTextureView preprocessedDepthAll = nullptr; + WGPUTexture aoNoisy = nullptr; + WGPUTextureView aoNoisyView = nullptr; + WGPUTexture depthDifferences = nullptr; + WGPUTextureView depthDifferencesView = nullptr; + WGPUTexture aoFinal = nullptr; + WGPUTextureView aoFinalView = nullptr; +}; +AoTargets g_targets; +struct RetiredTargets { + AoTargets targets; + int framesLeft = 0; +}; +std::vector g_retiredTargets; + +bool g_warnedNoDepth = false; +bool g_loggedChain = false; +std::atomic g_chainExecuted{false}; + +// Mirror of the WGSL Uniforms struct (keep in sync with res/*.wgsl). +struct AoUniforms { + float projection[16]; + float inverse_projection[16]; + float size[2]; + float inv_size[2]; + float depth_scale[2]; + float effect_radius; + float intensity; + float slice_count; + float samples_per_slice_side; + uint32_t debug_view; + float _pad; +}; +static_assert(sizeof(AoUniforms) % 16 == 0); + +struct ComputePayload { + WGPUTextureView depth; // frame-pooled scene depth snapshot + WGPUTextureView preprocessedDepthMips[5]; + WGPUTextureView preprocessedDepthAll; + WGPUTextureView aoNoisy; + WGPUTextureView depthDifferences; + WGPUTextureView aoFinal; + uint32_t uniform_offset; + uint32_t uniform_size; + uint32_t width; + uint32_t height; +}; +static_assert(sizeof(ComputePayload) <= GFX_INLINE_DRAW_PAYLOAD_SIZE); +static_assert(std::is_trivially_copyable_v); + +struct CompositePayload { + WGPUTextureView aoFinal; + WGPUTextureView preprocessedDepth; // debug views reconstruct normals/depth from it + WGPUTextureView sceneDepth; // raw snapshot, for the bypass debug views + uint32_t uniform_offset; + uint32_t uniform_size; + uint32_t debug_view; +}; +static_assert(sizeof(CompositePayload) <= GFX_INLINE_DRAW_PAYLOAD_SIZE); +static_assert(std::is_trivially_copyable_v); + +int64_t get_int_option(ConfigVarHandle handle, int64_t fallback) { + int64_t value = fallback; + if (handle == 0 || svc_config->get_int(mod_ctx, handle, &value) != MOD_OK) { + return fallback; + } + return value; +} + +bool get_bool_option(ConfigVarHandle handle, bool fallback) { + bool value = fallback; + if (handle == 0 || svc_config->get_bool(mod_ctx, handle, &value) != MOD_OK) { + return fallback; + } + return value; +} + +// XeGTAO/Bevy quality presets: slices x (samples per slice side * 2). +void quality_counts(int64_t quality, float& sliceCount, float& samplesPerSliceSide) { + switch (std::clamp(quality, 0, 3)) { + case 0: + sliceCount = 1.0f; + samplesPerSliceSide = 2.0f; + break; + case 1: + sliceCount = 2.0f; + samplesPerSliceSide = 2.0f; + break; + default: + case 2: + sliceCount = 3.0f; + samplesPerSliceSide = 3.0f; + break; + case 3: + sliceCount = 9.0f; + samplesPerSliceSide = 3.0f; + break; + } +} + +WGPUShaderModule create_shader_module(const char* label, const ResourceBuffer& source) { + WGPUShaderSourceWGSL wgsl = WGPU_SHADER_SOURCE_WGSL_INIT; + wgsl.code = {static_cast(source.data), source.size}; + WGPUShaderModuleDescriptor moduleDesc = WGPU_SHADER_MODULE_DESCRIPTOR_INIT; + moduleDesc.nextInChain = &wgsl.chain; + moduleDesc.label = {label, WGPU_STRLEN}; + return wgpuDeviceCreateShaderModule(g_deviceInfo.device, &moduleDesc); +} + +bool build_compute_pipeline(const char* label, const ResourceBuffer& source, const char* entry, + WGPUComputePipeline& outPipeline, WGPUBindGroupLayout& outLayout) { + WGPUShaderModule module = create_shader_module(label, source); + if (module == nullptr) { + return false; + } + WGPUComputePipelineDescriptor pipelineDesc = WGPU_COMPUTE_PIPELINE_DESCRIPTOR_INIT; + pipelineDesc.label = {label, WGPU_STRLEN}; + pipelineDesc.compute.module = module; + pipelineDesc.compute.entryPoint = {entry, WGPU_STRLEN}; + outPipeline = wgpuDeviceCreateComputePipeline(g_deviceInfo.device, &pipelineDesc); + wgpuShaderModuleRelease(module); + if (outPipeline == nullptr) { + return false; + } + outLayout = wgpuComputePipelineGetBindGroupLayout(outPipeline, 0); + return outLayout != nullptr; +} + +bool build_composite_pipeline( + bool blend, WGPURenderPipeline& outPipeline, WGPUBindGroupLayout& outLayout) { + WGPUShaderModule module = create_shader_module("AO composite", g_compositeSource); + if (module == nullptr) { + return false; + } + + // Multiply blend + WGPUBlendState blendState{ + .color = + { + .operation = WGPUBlendOperation_Add, + .srcFactor = WGPUBlendFactor_Dst, + .dstFactor = WGPUBlendFactor_Zero, + }, + .alpha = + { + .operation = WGPUBlendOperation_Add, + .srcFactor = WGPUBlendFactor_Zero, + .dstFactor = WGPUBlendFactor_One, + }, + }; + WGPUColorTargetState colorTarget = WGPU_COLOR_TARGET_STATE_INIT; + colorTarget.format = g_deviceInfo.color_format; + if (blend) { + colorTarget.blend = &blendState; + } + WGPUFragmentState fragment = WGPU_FRAGMENT_STATE_INIT; + fragment.module = module; + fragment.entryPoint = {"fs_main", WGPU_STRLEN}; + fragment.targetCount = 1; + fragment.targets = &colorTarget; + // Depth state must match the EFB pass despite never touching depth. + WGPUDepthStencilState depthStencil = WGPU_DEPTH_STENCIL_STATE_INIT; + depthStencil.format = g_deviceInfo.depth_format; + depthStencil.depthWriteEnabled = WGPUOptionalBool_False; + depthStencil.depthCompare = WGPUCompareFunction_Always; + + WGPURenderPipelineDescriptor pipelineDesc = WGPU_RENDER_PIPELINE_DESCRIPTOR_INIT; + pipelineDesc.label = {blend ? "AO composite" : "AO composite (debug)", WGPU_STRLEN}; + pipelineDesc.vertex.module = module; + pipelineDesc.vertex.entryPoint = {"vs_main", WGPU_STRLEN}; + pipelineDesc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + pipelineDesc.depthStencil = &depthStencil; + pipelineDesc.multisample.count = g_deviceInfo.sample_count; + pipelineDesc.fragment = &fragment; + outPipeline = wgpuDeviceCreateRenderPipeline(g_deviceInfo.device, &pipelineDesc); + wgpuShaderModuleRelease(module); + if (outPipeline == nullptr) { + return false; + } + outLayout = wgpuRenderPipelineGetBindGroupLayout(outPipeline, 0); + return outLayout != nullptr; +} + +// Hilbert curve index LUT for the R2 noise sequence, generated once at init. +// Ported from Bevy's generate_hilbert_index_lut (https://www.shadertoy.com/view/3tB3z3). +uint16_t hilbert_index(uint16_t x, uint16_t y) { + uint16_t index = 0; + for (uint16_t level = 32; level > 0; level /= 2) { + const uint16_t regionX = (x & level) > 0 ? 1 : 0; + const uint16_t regionY = (y & level) > 0 ? 1 : 0; + index += level * level * ((3 * regionX) ^ regionY); + if (regionY == 0) { + if (regionX == 1) { + x = 63 - x; + y = 63 - y; + } + std::swap(x, y); + } + } + return index; +} + +bool build_hilbert_lut() { + WGPUTextureDescriptor texDesc = WGPU_TEXTURE_DESCRIPTOR_INIT; + texDesc.label = {"AO hilbert LUT", WGPU_STRLEN}; + texDesc.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_CopyDst; + texDesc.size = {64, 64, 1}; + texDesc.format = WGPUTextureFormat_R16Uint; + g_hilbertLut = wgpuDeviceCreateTexture(g_deviceInfo.device, &texDesc); + if (g_hilbertLut == nullptr) { + return false; + } + g_hilbertLutView = wgpuTextureCreateView(g_hilbertLut, nullptr); + if (g_hilbertLutView == nullptr) { + return false; + } + + uint16_t lut[64 * 64]; + for (uint16_t y = 0; y < 64; ++y) { + for (uint16_t x = 0; x < 64; ++x) { + lut[y * 64 + x] = hilbert_index(x, y); + } + } + WGPUTexelCopyTextureInfo dst = WGPU_TEXEL_COPY_TEXTURE_INFO_INIT; + dst.texture = g_hilbertLut; + WGPUTexelCopyBufferLayout layout{.offset = 0, .bytesPerRow = 64 * 2, .rowsPerImage = 64}; + WGPUExtent3D extent{64, 64, 1}; + wgpuQueueWriteTexture(g_deviceInfo.queue, &dst, lut, sizeof(lut), &layout, &extent); + return true; +} + +void release_targets(AoTargets& targets) { + for (auto*& view : targets.preprocessedDepthMips) { + if (view != nullptr) { + wgpuTextureViewRelease(view); + view = nullptr; + } + } + const auto releaseView = [](WGPUTextureView& view) { + if (view != nullptr) { + wgpuTextureViewRelease(view); + view = nullptr; + } + }; + const auto releaseTexture = [](WGPUTexture& texture) { + if (texture != nullptr) { + wgpuTextureRelease(texture); + texture = nullptr; + } + }; + releaseView(targets.preprocessedDepthAll); + releaseView(targets.aoNoisyView); + releaseView(targets.depthDifferencesView); + releaseView(targets.aoFinalView); + releaseTexture(targets.preprocessedDepth); + releaseTexture(targets.aoNoisy); + releaseTexture(targets.depthDifferences); + releaseTexture(targets.aoFinal); + targets.width = targets.height = 0; +} + +void tick_retired_targets() { + for (auto it = g_retiredTargets.begin(); it != g_retiredTargets.end();) { + if (--it->framesLeft <= 0) { + release_targets(it->targets); + it = g_retiredTargets.erase(it); + } else { + ++it; + } + } +} + +bool ensure_targets(uint32_t width, uint32_t height) { + if (g_targets.width == width && g_targets.height == height) { + return true; + } + if (g_targets.width != 0) { + g_retiredTargets.push_back(RetiredTargets{std::exchange(g_targets, AoTargets{}), 4}); + } + + const auto createStorageTexture = [&](const char* label, WGPUTextureFormat format, + uint32_t mipCount, WGPUTexture& outTexture) { + WGPUTextureDescriptor texDesc = WGPU_TEXTURE_DESCRIPTOR_INIT; + texDesc.label = {label, WGPU_STRLEN}; + texDesc.usage = WGPUTextureUsage_StorageBinding | WGPUTextureUsage_TextureBinding; + texDesc.size = {width, height, 1}; + texDesc.format = format; + texDesc.mipLevelCount = mipCount; + outTexture = wgpuDeviceCreateTexture(g_deviceInfo.device, &texDesc); + return outTexture != nullptr; + }; + + bool ok = createStorageTexture("AO preprocessed depth", WGPUTextureFormat_R32Float, 5, + g_targets.preprocessedDepth) && + createStorageTexture("AO noisy", WGPUTextureFormat_R32Float, 1, g_targets.aoNoisy) && + createStorageTexture("AO depth differences", WGPUTextureFormat_R32Uint, 1, + g_targets.depthDifferences) && + createStorageTexture("AO final", WGPUTextureFormat_R32Float, 1, g_targets.aoFinal); + if (ok) { + for (uint32_t mip = 0; mip < 5 && ok; ++mip) { + WGPUTextureViewDescriptor viewDesc = WGPU_TEXTURE_VIEW_DESCRIPTOR_INIT; + viewDesc.baseMipLevel = mip; + viewDesc.mipLevelCount = 1; + g_targets.preprocessedDepthMips[mip] = + wgpuTextureCreateView(g_targets.preprocessedDepth, &viewDesc); + ok = g_targets.preprocessedDepthMips[mip] != nullptr; + } + } + if (ok) { + g_targets.preprocessedDepthAll = + wgpuTextureCreateView(g_targets.preprocessedDepth, nullptr); + g_targets.aoNoisyView = wgpuTextureCreateView(g_targets.aoNoisy, nullptr); + g_targets.depthDifferencesView = wgpuTextureCreateView(g_targets.depthDifferences, nullptr); + g_targets.aoFinalView = wgpuTextureCreateView(g_targets.aoFinal, nullptr); + ok = g_targets.preprocessedDepthAll != nullptr && g_targets.aoNoisyView != nullptr && + g_targets.depthDifferencesView != nullptr && g_targets.aoFinalView != nullptr; + } + if (!ok) { + release_targets(g_targets); + return false; + } + g_targets.width = width; + g_targets.height = height; + return true; +} + +constexpr uint32_t div_ceil(uint32_t numerator, uint32_t denominator) { + return (numerator + denominator - 1) / denominator; +} + +// Render worker thread: the AO chain as one compute pass with three dispatches. +void on_compute( + ModContext*, const GfxComputeContext* ctx, const void* payload, size_t payloadSize, void*) { + if (payloadSize != sizeof(ComputePayload)) { + return; + } + ComputePayload data; + std::memcpy(&data, payload, sizeof(data)); + if (data.depth == nullptr || g_preprocessPipeline == nullptr) { + return; + } + + const auto makeBindGroup = [&](WGPUBindGroupLayout layout, + std::initializer_list entries) { + WGPUBindGroupDescriptor bindGroupDesc = WGPU_BIND_GROUP_DESCRIPTOR_INIT; + bindGroupDesc.layout = layout; + bindGroupDesc.entryCount = entries.size(); + bindGroupDesc.entries = entries.begin(); + return wgpuDeviceCreateBindGroup(ctx->device, &bindGroupDesc); + }; + const auto textureEntry = [](uint32_t binding, WGPUTextureView view) { + WGPUBindGroupEntry entry = WGPU_BIND_GROUP_ENTRY_INIT; + entry.binding = binding; + entry.textureView = view; + return entry; + }; + const auto uniformEntry = [&](uint32_t binding) { + WGPUBindGroupEntry entry = WGPU_BIND_GROUP_ENTRY_INIT; + entry.binding = binding; + entry.buffer = ctx->uniform_buffer; + entry.offset = data.uniform_offset; + entry.size = data.uniform_size; + return entry; + }; + + WGPUBindGroup preprocessGroup = makeBindGroup(g_preprocessLayout, + {textureEntry(0, data.depth), textureEntry(1, data.preprocessedDepthMips[0]), + textureEntry(2, data.preprocessedDepthMips[1]), + textureEntry(3, data.preprocessedDepthMips[2]), + textureEntry(4, data.preprocessedDepthMips[3]), uniformEntry(5)}); + WGPUBindGroup mip4Group = + makeBindGroup(g_mip4Layout, {textureEntry(6, data.preprocessedDepthMips[3]), + textureEntry(7, data.preprocessedDepthMips[4])}); + WGPUBindGroup gtaoGroup = makeBindGroup( + g_gtaoLayout, {textureEntry(0, data.preprocessedDepthAll), + textureEntry(1, g_hilbertLutView), textureEntry(2, data.aoNoisy), + textureEntry(3, data.depthDifferences), uniformEntry(4)}); + WGPUBindGroup denoiseGroup = makeBindGroup( + g_denoiseLayout, {textureEntry(0, data.aoNoisy), textureEntry(1, data.depthDifferences), + textureEntry(2, data.aoFinal), uniformEntry(3)}); + if (preprocessGroup == nullptr || mip4Group == nullptr || gtaoGroup == nullptr || + denoiseGroup == nullptr) + { + const auto release = [](WGPUBindGroup group) { + if (group != nullptr) { + wgpuBindGroupRelease(group); + } + }; + release(preprocessGroup); + release(mip4Group); + release(gtaoGroup); + release(denoiseGroup); + return; + } + + WGPUComputePassDescriptor passDesc = WGPU_COMPUTE_PASS_DESCRIPTOR_INIT; + passDesc.label = {"AO chain", WGPU_STRLEN}; + WGPUComputePassEncoder pass = wgpuCommandEncoderBeginComputePass(ctx->encoder, &passDesc); + // Each preprocess workgroup covers 16x16 MIP-0 texels (8x8 invocations, 2x2 texels each). + wgpuComputePassEncoderSetPipeline(pass, g_preprocessPipeline); + wgpuComputePassEncoderSetBindGroup(pass, 0, preprocessGroup, 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups( + pass, div_ceil(data.width, 16), div_ceil(data.height, 16), 1); + wgpuComputePassEncoderSetPipeline(pass, g_mip4Pipeline); + wgpuComputePassEncoderSetBindGroup(pass, 0, mip4Group, 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups(pass, div_ceil(std::max(data.width >> 4, 1u), 8), + div_ceil(std::max(data.height >> 4, 1u), 8), 1); + wgpuComputePassEncoderSetPipeline(pass, g_gtaoPipeline); + wgpuComputePassEncoderSetBindGroup(pass, 0, gtaoGroup, 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups( + pass, div_ceil(data.width, 8), div_ceil(data.height, 8), 1); + wgpuComputePassEncoderSetPipeline(pass, g_denoisePipeline); + wgpuComputePassEncoderSetBindGroup(pass, 0, denoiseGroup, 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups( + pass, div_ceil(data.width, 8), div_ceil(data.height, 8), 1); + wgpuComputePassEncoderEnd(pass); + wgpuComputePassEncoderRelease(pass); + + wgpuBindGroupRelease(preprocessGroup); + wgpuBindGroupRelease(mip4Group); + wgpuBindGroupRelease(gtaoGroup); + wgpuBindGroupRelease(denoiseGroup); + g_chainExecuted.store(true, std::memory_order_release); +} + +// Render worker thread: composite the AO over the scene (or show it, in debug view). +void on_draw( + ModContext*, const GfxDrawContext* ctx, const void* payload, size_t payloadSize, void*) { + if (payloadSize != sizeof(CompositePayload)) { + return; + } + CompositePayload data; + std::memcpy(&data, payload, sizeof(data)); + WGPURenderPipeline pipeline = + data.debug_view != 0 ? g_compositeDebugPipeline : g_compositePipeline; + WGPUBindGroupLayout layout = data.debug_view != 0 ? g_compositeDebugLayout : g_compositeLayout; + if (data.aoFinal == nullptr || data.preprocessedDepth == nullptr || + data.sceneDepth == nullptr || 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.aoFinal; + entries[1].binding = 1; + entries[1].textureView = data.preprocessedDepth; + entries[2].binding = 2; + entries[2].textureView = data.sceneDepth; + entries[3].binding = 3; + entries[3].buffer = ctx->uniform_buffer; + entries[3].offset = data.uniform_offset; + entries[3].size = data.uniform_size; + WGPUBindGroupDescriptor bindGroupDesc = WGPU_BIND_GROUP_DESCRIPTOR_INIT; + bindGroupDesc.layout = layout; + bindGroupDesc.entryCount = 4; + bindGroupDesc.entries = entries; + WGPUBindGroup bindGroup = wgpuDeviceCreateBindGroup(ctx->device, &bindGroupDesc); + if (bindGroup == nullptr) { + return; + } + + wgpuRenderPassEncoderSetPipeline(ctx->pass, pipeline); + wgpuRenderPassEncoderSetBindGroup(ctx->pass, 0, bindGroup, 0, nullptr); + wgpuRenderPassEncoderDraw(ctx->pass, 3, 1, 0, 0); + wgpuBindGroupRelease(bindGroup); +} + +// Game thread, after opaque scene draws and before translucent/fog overlay lists. +void on_scene_after_opaque(ModContext*, const GfxStageContext* stageCtx, void*) { + tick_retired_targets(); + if (!get_bool_option(g_cvarEnabled, true)) { + return; + } + if (stageCtx == nullptr || stageCtx->struct_size < sizeof(GfxStageContext) || + stageCtx->game_view == nullptr) + { + return; + } + + CameraInfo camera = CAMERA_INFO_INIT; + if (svc_camera->get_camera(mod_ctx, stageCtx->game_view, &camera) != MOD_OK) { + return; + } + + GfxResolveDesc resolveDesc = GFX_RESOLVE_DESC_INIT; + resolveDesc.color = false; + resolveDesc.depth = true; + GfxResolvedTargets resolved = GFX_RESOLVED_TARGETS_INIT; + if (svc_gfx->resolve_pass(mod_ctx, &resolveDesc, &resolved) != MOD_OK || + resolved.depth == nullptr) + { + if (!g_warnedNoDepth) { + g_warnedNoDepth = true; + svc_log->warn(mod_ctx, "depth snapshots unavailable; AO disabled"); + } + return; + } + + const bool halfRes = get_bool_option(g_cvarHalfRes, false); + const uint32_t divisor = halfRes ? 2 : 1; + const uint32_t width = resolved.width / divisor; + const uint32_t height = resolved.height / divisor; + if (width < 32 || height < 32 || !ensure_targets(width, height)) { + return; + } + + AoUniforms uniforms{}; + std::memcpy(uniforms.projection, camera.proj_from_view, sizeof(uniforms.projection)); + std::memcpy( + uniforms.inverse_projection, camera.view_from_proj, sizeof(uniforms.inverse_projection)); + uniforms.size[0] = static_cast(width); + uniforms.size[1] = static_cast(height); + uniforms.inv_size[0] = 1.0f / uniforms.size[0]; + uniforms.inv_size[1] = 1.0f / uniforms.size[1]; + uniforms.depth_scale[0] = static_cast(resolved.width) / uniforms.size[0]; + uniforms.depth_scale[1] = static_cast(resolved.height) / uniforms.size[1]; + uniforms.effect_radius = + static_cast(std::clamp(get_int_option(g_cvarRadius, 70), 10, 500)); + uniforms.intensity = + static_cast(std::clamp(get_int_option(g_cvarIntensity, 100), 0, 100)) / + 100.0f; + quality_counts( + get_int_option(g_cvarQuality, 2), uniforms.slice_count, uniforms.samples_per_slice_side); + const uint32_t debugMode = + static_cast(std::clamp(get_int_option(g_cvarDebugView, 0), 0, 4)); + uniforms.debug_view = debugMode; + + GfxRange uniformRange{0, 0}; + if (svc_gfx->push_uniform(mod_ctx, &uniforms, sizeof(uniforms), &uniformRange) != MOD_OK) { + return; + } + + ComputePayload computePayload{}; + computePayload.depth = resolved.depth; + for (int mip = 0; mip < 5; ++mip) { + computePayload.preprocessedDepthMips[mip] = g_targets.preprocessedDepthMips[mip]; + } + computePayload.preprocessedDepthAll = g_targets.preprocessedDepthAll; + computePayload.aoNoisy = g_targets.aoNoisyView; + computePayload.depthDifferences = g_targets.depthDifferencesView; + computePayload.aoFinal = g_targets.aoFinalView; + computePayload.uniform_offset = uniformRange.offset; + computePayload.uniform_size = uniformRange.size; + computePayload.width = width; + computePayload.height = height; + if (svc_gfx->push_compute(mod_ctx, g_computeType, &computePayload, sizeof(computePayload)) != + MOD_OK) + { + return; + } + + const CompositePayload drawPayload{g_targets.aoFinalView, g_targets.preprocessedDepthAll, + resolved.depth, uniformRange.offset, uniformRange.size, debugMode}; + svc_gfx->push_draw(mod_ctx, g_drawType, &drawPayload, sizeof(drawPayload)); +} + +void add_control(UiElementHandle pane, const UiControlDesc& desc) { + svc_ui->pane_add_control(mod_ctx, pane, &desc, nullptr); +} + +void add_toggle(UiElementHandle pane, const char* label, ConfigVarHandle cvar, const char* help) { + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_TOGGLE; + control.label = label; + control.help_rml = help; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = cvar; + add_control(pane, control); +} + +ModResult build_controls_tab( + ModContext*, UiWindowHandle, UiElementHandle left, UiElementHandle right, void*, ModError*) { + (void)right; + + svc_ui->pane_add_section(mod_ctx, left, "Ambient Occlusion"); + add_toggle(left, "Enabled", g_cvarEnabled, "Enables the GTAO pass."); + + static const char* kQualityOptions[] = {"Low", "Medium", "High", "Ultra"}; + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_SELECT; + control.label = "Quality"; + control.help_rml = "Horizon slices and samples per pixel (XeGTAO presets: 4/8/18/54 spp)."; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = g_cvarQuality; + control.options = kQualityOptions; + control.option_count = 4; + add_control(left, control); + + control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_NUMBER; + control.label = "Radius"; + control.help_rml = "Occlusion sampling radius in world units."; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = g_cvarRadius; + control.min = 10; + control.max = 500; + control.step = 10; + add_control(left, control); + + control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_NUMBER; + control.label = "Intensity"; + control.help_rml = "How strongly occlusion darkens the scene."; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = g_cvarIntensity; + control.min = 0; + control.max = 100; + control.step = 5; + control.suffix = "%"; + add_control(left, control); + + add_toggle(left, "Half Resolution", g_cvarHalfRes, + "Computes AO at half resolution and upscales; faster, slightly softer."); + + static const char* kDebugOptions[] = {"Off", "AO", "Normals", "Depth", "Staircase"}; + control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_SELECT; + control.label = "Debug View"; + control.help_rml = "AO: raw visibility as grayscale.
Normals: the view-space " + "normals the GTAO pass consumes.
Depth: the preprocessed depth " + "as a distance gradient.
Staircase: detects quantized depth - smooth " + "depth is near-black with thin triangle edges, quantized depth lights " + "up across surfaces."; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = g_cvarDebugView; + control.options = kDebugOptions; + control.option_count = 5; + add_control(left, control); + return MOD_OK; +} + +void on_controls_window_closed(ModContext*, UiWindowHandle, void*) { + g_controlsWindow = 0; +} + +void on_open_controls(ModContext*, void*) { + if (g_controlsWindow != 0) { + return; + } + UiTabDesc tabs[1] = {UI_TAB_DESC_INIT}; + tabs[0].title = "Controls"; + tabs[0].build = build_controls_tab; + UiWindowDesc desc = UI_WINDOW_DESC_INIT; + desc.tabs = tabs; + desc.tab_count = 1; + desc.on_closed = on_controls_window_closed; + if (svc_ui->window_push(mod_ctx, &desc, &g_controlsWindow) != MOD_OK) { + svc_log->error(mod_ctx, "failed to open AO controls window"); + } +} + +ModResult build_panel(ModContext*, UiElementHandle panel, void*, ModError*) { + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_TOGGLE; + control.label = "Enabled"; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = g_cvarEnabled; + add_control(panel, control); + + control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_BUTTON; + control.label = "Open Controls"; + control.on_pressed = on_open_controls; + add_control(panel, control); + return MOD_OK; +} + +ModResult register_bool_option( + const char* name, bool defaultValue, ConfigVarHandle& outHandle, ModError* error) { + ConfigVarDesc cvarDesc = CONFIG_VAR_DESC_INIT; + cvarDesc.name = name; + cvarDesc.type = CONFIG_VAR_BOOL; + cvarDesc.default_bool = defaultValue; + if (svc_config->register_var(mod_ctx, &cvarDesc, &outHandle) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register AO option"); + } + return MOD_OK; +} + +ModResult register_int_option( + const char* name, int64_t defaultValue, ConfigVarHandle& outHandle, ModError* error) { + ConfigVarDesc cvarDesc = CONFIG_VAR_DESC_INIT; + cvarDesc.name = name; + cvarDesc.type = CONFIG_VAR_INT; + cvarDesc.default_int = defaultValue; + if (svc_config->register_var(mod_ctx, &cvarDesc, &outHandle) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register AO option"); + } + return MOD_OK; +} + +} // namespace + +extern "C" { + +MOD_EXPORT ModResult mod_initialize(ModError* error) { + ModResult result = svc_resource->load(mod_ctx, "preprocess_depth.wgsl", &g_preprocessSource); + if (result == MOD_OK) { + result = svc_resource->load(mod_ctx, "gtao.wgsl", &g_gtaoSource); + } + if (result == MOD_OK) { + result = svc_resource->load(mod_ctx, "denoise.wgsl", &g_denoiseSource); + } + if (result == MOD_OK) { + result = svc_resource->load(mod_ctx, "composite.wgsl", &g_compositeSource); + } + if (result != MOD_OK) { + return dusk::mods::set_error(error, result, "failed to load AO shaders"); + } + + result = register_bool_option("effectEnabled", false, g_cvarEnabled, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("quality", 2, g_cvarQuality, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("radius", 70, g_cvarRadius, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("intensity", 100, g_cvarIntensity, error); + if (result != MOD_OK) { + return result; + } + result = register_bool_option("halfRes", false, g_cvarHalfRes, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("debugMode", 0, g_cvarDebugView, error); + if (result != MOD_OK) { + return result; + } + + if (svc_gfx->get_device_info(mod_ctx, &g_deviceInfo) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to query device info"); + } + if (!build_compute_pipeline("AO preprocess depth", g_preprocessSource, "preprocess_depth", + g_preprocessPipeline, g_preprocessLayout) || + !build_compute_pipeline("AO downsample mip4", g_preprocessSource, "downsample_mip4", + g_mip4Pipeline, g_mip4Layout) || + !build_compute_pipeline("AO gtao", g_gtaoSource, "gtao", g_gtaoPipeline, g_gtaoLayout) || + !build_compute_pipeline( + "AO denoise", g_denoiseSource, "spatial_denoise", g_denoisePipeline, g_denoiseLayout)) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to create AO compute pipelines"); + } + if (!build_composite_pipeline(true, g_compositePipeline, g_compositeLayout) || + !build_composite_pipeline(false, g_compositeDebugPipeline, g_compositeDebugLayout)) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to create AO composite pipeline"); + } + if (!build_hilbert_lut()) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to create AO noise LUT"); + } + + GfxComputeTypeDesc computeDesc = GFX_COMPUTE_TYPE_DESC_INIT; + computeDesc.label = "AO chain"; + computeDesc.callback = on_compute; + if (svc_gfx->register_compute_type(mod_ctx, &computeDesc, &g_computeType) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register compute type"); + } + GfxDrawTypeDesc drawDesc = GFX_DRAW_TYPE_DESC_INIT; + drawDesc.label = "AO composite"; + drawDesc.draw = on_draw; + if (svc_gfx->register_draw_type(mod_ctx, &drawDesc, &g_drawType) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register draw type"); + } + GfxStageHookDesc stageDesc = GFX_STAGE_HOOK_DESC_INIT; + stageDesc.callback = on_scene_after_opaque; + if (svc_gfx->register_stage_hook( + mod_ctx, GFX_STAGE_SCENE_AFTER_OPAQUE, &stageDesc, &g_afterOpaqueHook) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register stage hook"); + } + + UiModsPanelDesc panelDesc = UI_MODS_PANEL_DESC_INIT; + panelDesc.build = build_panel; + svc_ui->register_mods_panel(mod_ctx, &panelDesc); + + svc_log->info(mod_ctx, "ao_mod ready"); + return MOD_OK; +} + +MOD_EXPORT ModResult mod_update(ModError*) { + if (!g_loggedChain && g_chainExecuted.load(std::memory_order_acquire)) { + g_loggedChain = true; + svc_log->info(mod_ctx, "AO chain executed OK"); + } + return MOD_OK; +} + +MOD_EXPORT ModResult mod_shutdown(ModError*) { + svc_resource->free(mod_ctx, &g_preprocessSource); + svc_resource->free(mod_ctx, &g_gtaoSource); + svc_resource->free(mod_ctx, &g_denoiseSource); + svc_resource->free(mod_ctx, &g_compositeSource); + + release_targets(g_targets); + for (auto& retired : g_retiredTargets) { + release_targets(retired.targets); + } + g_retiredTargets.clear(); + + const auto releasePipeline = [](WGPUComputePipeline& pipeline) { + if (pipeline != nullptr) { + wgpuComputePipelineRelease(pipeline); + pipeline = nullptr; + } + }; + const auto releaseLayout = [](WGPUBindGroupLayout& layout) { + if (layout != nullptr) { + wgpuBindGroupLayoutRelease(layout); + layout = nullptr; + } + }; + releasePipeline(g_preprocessPipeline); + releasePipeline(g_mip4Pipeline); + releasePipeline(g_gtaoPipeline); + releasePipeline(g_denoisePipeline); + releaseLayout(g_preprocessLayout); + releaseLayout(g_mip4Layout); + releaseLayout(g_gtaoLayout); + releaseLayout(g_denoiseLayout); + if (g_compositePipeline != nullptr) { + wgpuRenderPipelineRelease(g_compositePipeline); + g_compositePipeline = nullptr; + } + if (g_compositeDebugPipeline != nullptr) { + wgpuRenderPipelineRelease(g_compositeDebugPipeline); + g_compositeDebugPipeline = nullptr; + } + releaseLayout(g_compositeLayout); + releaseLayout(g_compositeDebugLayout); + if (g_hilbertLutView != nullptr) { + wgpuTextureViewRelease(g_hilbertLutView); + g_hilbertLutView = nullptr; + } + if (g_hilbertLut != nullptr) { + wgpuTextureRelease(g_hilbertLut); + g_hilbertLut = nullptr; + } + g_cvarEnabled = g_cvarQuality = g_cvarRadius = g_cvarIntensity = 0; + g_cvarHalfRes = g_cvarDebugView = 0; + g_computeType = g_drawType = 0; + g_afterOpaqueHook = 0; + g_controlsWindow = 0; + return MOD_OK; +} +} diff --git a/mods/shadow_mod/CMakeLists.txt b/mods/shadow_mod/CMakeLists.txt new file mode 100644 index 0000000000..4479d0fcc4 --- /dev/null +++ b/mods/shadow_mod/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.25) +project(shadow_mod 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(shadow_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res + BUNDLE +) diff --git a/mods/shadow_mod/mod.json b/mods/shadow_mod/mod.json new file mode 100644 index 0000000000..09fe8a3a84 --- /dev/null +++ b/mods/shadow_mod/mod.json @@ -0,0 +1,7 @@ +{ + "id": "dev.twilitrealm.shadow_mod", + "name": "[Demo] Dynamic Shadows", + "version": "1.0.0", + "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/res/shadow.wgsl b/mods/shadow_mod/res/shadow.wgsl new file mode 100644 index 0000000000..fe69b5bc3a --- /dev/null +++ b/mods/shadow_mod/res/shadow.wgsl @@ -0,0 +1,273 @@ +// Deferred shadow composite: reconstructs the world position of every scene pixel from the +// depth snapshot (CameraService matrices), transforms it into the light's clip space, and +// PCF-compares against the shadow map rendered earlier this frame. Drawn as a fullscreen +// triangle with multiply blending (srcFactor = Dst, dstFactor = Zero) before the HUD. +// +// Depth conventions (both reversed-Z): the scene snapshot has 1.0 at the camera near plane; +// the shadow map, rendered through the game's GX pipeline with a GC-convention light matrix, +// stores clip.z, i.e. 1.0 nearest to the light and 0.0 at the light frustum far plane. +// +// The optional contact-shadow raymarch follows Panos Karabelas' screen-space shadows +// (https://panoskarabelas.com/blog/posts/screen_space_shadows/, MIT via Spartan Engine): +// march from the pixel toward the light in view space and mark occlusion when the ray dips +// behind the depth buffer within a thickness threshold. + +struct Uniforms { + world_from_proj: mat4x4f, // scene depth unproject (camera) + view_from_proj: mat4x4f, // scene depth -> view space (contact shadows) + proj_from_view: mat4x4f, // view -> clip (contact shadows re-projection) + light_vp: mat4x4f, // world -> light receiver projection (UV/depth basis) + light_dir_view: vec3f, // direction *toward* the light, view space, normalized + bias: f32, // shadow-map depth bias (reversed-depth units) + size: vec2f, // shadow map size in texels + inv_size: vec2f, + edge_fade_width: f32, + strength: f32, // final darkening amount, horizon fade baked in + pcf_taps: f32, // 0 = single tap, 1 = 3x3, 2 = 5x5 + contact_enabled: f32, + contact_thickness: f32, // view-space thickness threshold + contact_length: f32, // view-space march distance + debug_mode: u32, // 0 = composite; nonzero modes are diagnostic views + _pad0: f32, +} + +@group(0) @binding(0) var scene_depth: texture_2d; +@group(0) @binding(1) var shadow_map: texture_2d; +@group(0) @binding(2) var uniforms: Uniforms; +@group(0) @binding(3) var light_color: texture_2d; + +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +} + +@vertex +fn vs_main(@builtin(vertex_index) index: u32) -> VertexOutput { + var out: VertexOutput; + let uv = vec2f(f32((index << 1u) & 2u), f32(index & 2u)); + out.position = vec4f(uv * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0), 0.0, 1.0); + out.uv = uv; + return out; +} + +fn load_shadow(texel: vec2) -> f32 { + let clamped = clamp(texel, vec2(0i), vec2(uniforms.size) - 1i); + return textureLoad(shadow_map, clamped, 0i).r; +} + +// Returns 1.0 when the pixel at light-space depth `receiver` is shadowed by the map texel. +fn shadow_test(texel: vec2, receiver: f32) -> f32 { + // Reversed depth: a larger stored value is closer to the light, i.e. an occluder. + return select(0.0, 1.0, load_shadow(texel) > receiver + uniforms.bias); +} + +// Bilinearly weighted comparison (what a hardware comparison sampler would do): filter the +// four *comparison results*, never the depths themselves. This is what turns per-texel +// staircases into smooth penumbra edges. +fn shadow_compare_bilinear(light_uv: vec2f, receiver: f32) -> f32 { + let coordinates = light_uv * uniforms.size - 0.5; + let base = floor(coordinates); + let fraction = coordinates - base; + let texel = vec2(base); + let s00 = shadow_test(texel, receiver); + let s10 = shadow_test(texel + vec2(1i, 0i), receiver); + let s01 = shadow_test(texel + vec2(0i, 1i), receiver); + let s11 = shadow_test(texel + vec2(1i, 1i), receiver); + let top = mix(s00, s10, fraction.x); + let bottom = mix(s01, s11, fraction.x); + return mix(top, bottom, fraction.y); +} + +fn sample_shadow_pcf(light_uv: vec2f, receiver: f32) -> f32 { + let radius = i32(uniforms.pcf_taps); + var sum = 0.0; + var count = 0.0; + for (var y = -radius; y <= radius; y += 1i) { + for (var x = -radius; x <= radius; x += 1i) { + let offset = vec2f(f32(x), f32(y)) * uniforms.inv_size; + sum += shadow_compare_bilinear(light_uv + offset, receiver); + count += 1.0; + } + } + return sum / count; +} + +// Softly fades shadows out over a small band near the shadow-map edge so receivers do not +// disappear abruptly when they leave the light's coverage area. +fn shadow_edge_fade(light_uv: vec2f) -> f32 { + let edge_texels = uniforms.edge_fade_width; + let edge_uv = edge_texels * max(uniforms.inv_size.x, uniforms.inv_size.y); + let distance_to_edge = min(min(light_uv.x, 1.0 - light_uv.x), min(light_uv.y, 1.0 - light_uv.y)); + // Avoid division by zero when the fade width is zero (no fade). + return select(1.0, saturate(distance_to_edge / edge_uv), edge_uv > 0.0); +} + +fn scene_depth_at(uv: vec2f) -> f32 { + let size = vec2(textureDimensions(scene_depth)); + let texel = clamp(vec2(uv * vec2f(size)), vec2(0i), size - 1i); + return textureLoad(scene_depth, texel, 0i).r; +} + +fn light_color_at(uv: vec2f) -> vec4f { + let size = vec2(textureDimensions(light_color)); + let texel = clamp(vec2(uv * vec2f(size)), vec2(0i), size - 1i); + return textureLoad(light_color, texel, 0i); +} + +fn light_depth_debug_at(uv: vec2f) -> vec3f { + let texel = vec2(uv * uniforms.size); + let depth = load_shadow(texel); + if depth <= 0.0 { + return vec3f(0.0); + } + + let dx = abs(depth - load_shadow(texel + vec2(1i, 0i))); + let dy = abs(depth - load_shadow(texel + vec2(0i, 1i))); + let edge = saturate((dx + dy) * 500.0); + let shade = saturate(depth * 1.5); + let bands = 0.08 * (0.5 + 0.5 * cos(depth * 96.0)); + return vec3f(saturate(shade + bands + edge)); +} + +fn view_position(uv: vec2f, depth: f32) -> vec3f { + let ndc = vec4f(uv.x * 2.0 - 1.0, 1.0 - 2.0 * uv.y, depth, 1.0); + let position = uniforms.view_from_proj * ndc; + return position.xyz / position.w; +} + +// Interleaved gradient noise (Jimenez); fixed per-pixel dither, no temporal rotation. +fn ign(pixel: vec2f) -> f32 { + return fract(52.9829189 * fract(dot(pixel, vec2f(0.06711056, 0.00583715)))); +} + +// Screen-space contact shadows: march toward the light in view space; occluded when the ray +// passes behind the depth buffer by less than the thickness threshold. Faded out with view +// distance: position reconstruction error grows with distance while the thickness threshold +// is fixed, so far surfaces (and anything translucent composited over them - clouds, fog) +// would otherwise pick up dithered false occlusion. Contact shadows are a near-field effect. +fn contact_shadow_fade(view_distance: f32) -> f32 { + return saturate(1.0 - (view_distance - 3000.0) / 5000.0); +} + +fn contact_shadow(origin: vec3f, pixel: vec2f) -> f32 { + let steps = 24; + let step_vec = uniforms.light_dir_view * (uniforms.contact_length / f32(steps)); + var ray = origin + step_vec * ign(pixel); + for (var i = 0; i < steps; i += 1) { + ray += step_vec; + // Project the ray position back to screen space. + let clip = uniforms.proj_from_view * vec4f(ray, 1.0); + if clip.w <= 0.0 { + break; + } + let ray_ndc = clip.xyz / clip.w; + let ray_uv = vec2f(0.5 + 0.5 * ray_ndc.x, 0.5 - 0.5 * ray_ndc.y); + if any(ray_uv < vec2f(0.0)) || any(ray_uv > vec2f(1.0)) { + break; + } + let scene = scene_depth_at(ray_uv); + if scene <= 0.0 { + continue; + } + // Compare in view space: positive delta = the ray is behind the scene surface. + let scene_z = view_position(ray_uv, scene).z; + let delta = scene_z - ray.z; // view space looks down -z; larger z = closer + if delta > 0.0 && delta < uniforms.contact_thickness { + return 1.0; + } + } + return 0.0; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4f { + let depth = scene_depth_at(in.uv); + if uniforms.debug_mode == 1u { + let value = load_shadow(vec2(in.uv * uniforms.size)); + return vec4f(value, value, value, 1.0); + } + if uniforms.debug_mode == 9u || uniforms.debug_mode == 10u { + let color = light_color_at(in.uv); + let color_luma = max(color.r, max(color.g, color.b)); + let depth_color = light_depth_debug_at(in.uv); + let rgb = select(depth_color, color.rgb, color_luma > (1.0 / 255.0)); + return vec4f(rgb, 1.0); + } + + if depth <= 0.0 { + // Sky / cleared pixels receive no shadow. + if uniforms.debug_mode >= 3u { + return vec4f(0.0, 0.0, 0.0, 1.0); + } + return vec4f(1.0); + } + + let ndc = vec4f(in.uv.x * 2.0 - 1.0, 1.0 - 2.0 * in.uv.y, depth, 1.0); + let world4 = uniforms.world_from_proj * ndc; + let world = world4.xyz / world4.w; + + let light_clip = uniforms.light_vp * vec4f(world, 1.0); + let light_ndc = light_clip.xyz / light_clip.w; + let receiver = light_ndc.z; // reversed light depth, 1 = nearest to the light + let light_uv = vec2f(0.5 + 0.5 * light_ndc.x, 0.5 - 0.5 * light_ndc.y); + let in_shadow_bounds = all(light_uv >= vec2f(0.0)) && all(light_uv <= vec2f(1.0)) && + receiver > 0.0 && receiver <= 1.0; + let shadow_depth = load_shadow(vec2(light_uv * uniforms.size)); + + if uniforms.debug_mode == 4u { + let valid = select(0.0, 1.0, in_shadow_bounds); + return vec4f(saturate(light_uv.x), saturate(light_uv.y), valid, 1.0); + } + + if uniforms.debug_mode == 5u { + if !in_shadow_bounds { + return vec4f(0.0, 0.0, 0.0, 1.0); + } + let current_compare = select(0.0, 1.0, shadow_depth > receiver + uniforms.bias); + let opposite_compare = select(0.0, 1.0, shadow_depth < receiver - uniforms.bias); + return vec4f(current_compare, 0.0, opposite_compare, 1.0); + } + + if uniforms.debug_mode == 6u { + let valid = select(0.0, 1.0, in_shadow_bounds); + return vec4f(saturate(receiver), shadow_depth, valid, 1.0); + } + + if uniforms.debug_mode == 7u { + let beyond_far = select(0.0, 1.0, receiver <= 0.0); + let valid_depth = select(0.0, 1.0, receiver > 0.0 && receiver <= 1.0); + let before_near = select(0.0, 1.0, receiver > 1.0); + return vec4f(beyond_far, valid_depth, before_near, 1.0); + } + + if uniforms.debug_mode == 8u { + let valid_x = select(0.0, 1.0, light_uv.x >= 0.0 && light_uv.x <= 1.0); + let valid_y = select(0.0, 1.0, light_uv.y >= 0.0 && light_uv.y <= 1.0); + let valid_depth = select(0.0, 1.0, receiver > 0.0 && receiver <= 1.0); + return vec4f(valid_x, valid_y, valid_depth, 1.0); + } + + var occlusion = 0.0; + if in_shadow_bounds { + occlusion = sample_shadow_pcf(light_uv, receiver); + occlusion *= shadow_edge_fade(light_uv); + } + + if uniforms.debug_mode == 3u { + return vec4f(occlusion, occlusion, occlusion, 1.0); + } + + if uniforms.contact_enabled != 0.0 && occlusion < 1.0 { + let origin = view_position(in.uv, depth); + let fade = contact_shadow_fade(-origin.z); + if fade > 0.0 { + occlusion = max(occlusion, fade * contact_shadow(origin, in.position.xy)); + } + } + + let value = 1.0 - uniforms.strength * occlusion; + if uniforms.debug_mode == 2u { + return vec4f(value, value, value, 1.0); + } + return vec4f(value, value, value, 1.0); +} diff --git a/mods/shadow_mod/src/mod.cpp b/mods/shadow_mod/src/mod.cpp new file mode 100644 index 0000000000..0eec4fecef --- /dev/null +++ b/mods/shadow_mod/src/mod.cpp @@ -0,0 +1,1108 @@ +// Dynamic shadows example mod. +// +// Replays the game's populated opaque scene draw lists into an offscreen pass with a light-space +// projection to produce a shadow map of the live scene, then composites deferred shadows over the +// world (scene depth + CameraService unproject + PCF against the map). +// +// The optional contact-shadow raymarch in the composite is reimplemented from Panos Karabelas' +// screen-space shadows (MIT, via Spartan Engine); see res/shadow.wgsl. + +#include "global.h" + +#include "JSystem/J3DGraphBase/J3DShape.h" +#include "JSystem/J3DU/J3DUClipper.h" +#include "JSystem/JMath/JMath.h" +#include "d/d_com_inf_game.h" +#include "d/d_kankyo.h" +#include "d/d_kankyo_rain.h" +#include "dolphin/gx/GXAurora.h" +#include "dolphin/gx/GXGet.h" +#include "dolphin/gx/GXPixel.h" +#include "dolphin/gx/GXTransform.h" +#include "m_Do/m_Do_mtx.h" +#include "mods/hook.hpp" +#include "mods/service.hpp" +#include "mods/svc/camera.h" +#include "mods/svc/config.h" +#include "mods/svc/game.h" +#include "mods/svc/gfx.h" +#include "mods/svc/hook.h" +#include "mods/svc/log.h" +#include "mods/svc/resource.h" +#include "mods/svc/ui.h" + +#include +#include +#include +#include +#include +#include + +DEFINE_MOD(); +IMPORT_SERVICE(ConfigService, svc_config); +IMPORT_SERVICE(ResourceService, svc_resource); +IMPORT_SERVICE(UiService, svc_ui); +IMPORT_SERVICE(GfxService, svc_gfx); +IMPORT_SERVICE(CameraService, svc_camera); +IMPORT_SERVICE(HookService, svc_hook); +IMPORT_SERVICE(GameService, svc_game); +IMPORT_SERVICE(LogService, svc_log); + +namespace { + +ConfigVarHandle g_cvarEnabled = 0; +ConfigVarHandle g_cvarMapSize = 0; +ConfigVarHandle g_cvarNoFrustumClipping = 0; +ConfigVarHandle g_cvarStrength = 0; +ConfigVarHandle g_cvarPcf = 0; +ConfigVarHandle g_cvarBias = 0; +ConfigVarHandle g_cvarBoxRadius = 0; +ConfigVarHandle g_cvarEdgeFadeWidth = 0; +ConfigVarHandle g_cvarContactShadows = 0; +ConfigVarHandle g_cvarDebugView = 0; + +GfxDrawTypeHandle g_drawType = 0; +GfxStageHookHandle g_sceneBeginHook = 0; +GfxStageHookHandle g_sceneAfterTerrainHook = 0; +GfxStageHookHandle g_frameBeforeHudHook = 0; +UiWindowHandle g_controlsWindow = 0; +ResourceBuffer g_shaderSource = RESOURCE_BUFFER_INIT; +GfxDeviceInfo g_deviceInfo = GFX_DEVICE_INFO_INIT; +WGPURenderPipeline g_compositePipeline = nullptr; // multiply blend +WGPURenderPipeline g_compositeDebugPipeline = nullptr; // no blend (debug views) +WGPUBindGroupLayout g_compositeLayout = nullptr; +WGPUBindGroupLayout g_compositeDebugLayout = nullptr; + +struct MapPassOutput { + bool ready = false; + WGPUTextureView shadowMap = nullptr; // frame-pooled + WGPUTextureView lightColor = nullptr; // frame-pooled + uint32_t mapSize = 0; + Mtx44 lightVp; // world -> light receiver projection, row-major game convention + float dirToLightWorld[3]; // toward the light, normalized + float fade = 0.0f; +}; + +MapPassOutput g_mapPass; +bool g_replayingSceneLists = false; + +constexpr float kLightDistance = 30000.0f; +constexpr float kLightNear = 100.0f; +constexpr float kLightFar = 60000.0f; +constexpr float kMaxLightLookahead = 10000.0f; +constexpr float kSunMoonDistance = 80000.0f; +constexpr float kSunMoonZDistance = -48000.0f; + +using ClipperSphereClip = int (J3DUClipper::*)(f32 const (*)[4], Vec, f32) const; +using ClipperBoxClip = int (J3DUClipper::*)(f32 const (*)[4], Vec*, Vec*) const; +constexpr ClipperSphereClip kClipperSphereClip = static_cast(&J3DUClipper::clip); +constexpr ClipperBoxClip kClipperBoxClip = static_cast(&J3DUClipper::clip); + +// Mirror of the WGSL Uniforms struct (keep in sync with res/shadow.wgsl). +struct ShadowUniforms { + float world_from_proj[16]; + float view_from_proj[16]; + float proj_from_view[16]; + float light_vp[16]; + float light_dir_view[3]; + float bias; + float size[2]; + float inv_size[2]; + float edge_fade_width; + float strength; + float pcf_taps; + float contact_enabled; + float contact_thickness; + float contact_length; + uint32_t debug_mode; + float _pad0; +}; +static_assert(sizeof(ShadowUniforms) % 16 == 0); + +struct DrawPayload { + WGPUTextureView sceneDepth; // frame-pooled + WGPUTextureView shadowMap; // frame-pooled + WGPUTextureView lightColor; // frame-pooled + uint32_t uniform_offset; + uint32_t uniform_size; + uint32_t debug_mode; +}; +static_assert(sizeof(DrawPayload) <= GFX_INLINE_DRAW_PAYLOAD_SIZE); +static_assert(std::is_trivially_copyable_v); + +struct LightCamera { + Mtx view; + Mtx44 ortho; + Mtx44 vp; + float dirToLight[3]; + float fade = 0.0f; +}; + +struct SceneCamera { + bool valid = false; + bool raw_valid = false; + CameraInfo info = CAMERA_INFO_INIT; + Mtx raw_view; + f32 raw_projection[7]{}; + Mtx44 raw_projection_mtx; +}; + +SceneCamera g_sceneCamera; + +struct ActualLightDebugState { + bool active = false; + Mtx savedView; + f32 savedProjection[7]; + f32 savedViewport[6]; + u32 savedScissor[4]; +}; + +ActualLightDebugState g_actualLightDebug; + +struct replay_scope { + replay_scope() { g_replayingSceneLists = true; } + ~replay_scope() { g_replayingSceneLists = false; } +}; + +int64_t get_int_option(ConfigVarHandle handle, int64_t fallback) { + int64_t value = fallback; + if (handle == 0 || svc_config->get_int(mod_ctx, handle, &value) != MOD_OK) { + return fallback; + } + return value; +} + +bool get_bool_option(ConfigVarHandle handle, bool fallback) { + bool value = fallback; + if (handle == 0 || svc_config->get_bool(mod_ctx, handle, &value) != MOD_OK) { + return fallback; + } + return value; +} + +int64_t get_debug_mode() { + return std::clamp(get_int_option(g_cvarDebugView, 0), 0, 10); +} + +bool matrix_ready(const Mtx m) { + float basis = 0.0f; + for (int r = 0; r < 3; ++r) { + for (int c = 0; c < 4; ++c) { + if (!std::isfinite(m[r][c])) { + return false; + } + if (c < 3) { + basis += std::fabs(m[r][c]); + } + } + } + return basis > 0.001f; +} + +bool projection_vector_ready(const f32 projection[7]) { + if (projection[0] != 0.0f) { + return false; + } + for (int i = 1; i < 7; ++i) { + if (!std::isfinite(projection[i])) { + return false; + } + } + return std::fabs(projection[1]) > 0.001f && std::fabs(projection[3]) > 0.001f && + std::fabs(projection[6]) > 0.001f; +} + +// Row-major game matrix -> column-major WGSL layout (matching CameraService). +void store_column_major(const Mtx44 in, float out[16]) { + for (int c = 0; c < 4; ++c) { + for (int r = 0; r < 4; ++r) { + out[c * 4 + r] = in[r][c]; + } + } +} + +void copy_projection(const Mtx44 in, Mtx44 out) { + std::memcpy(out, in, sizeof(Mtx44)); + // TODO: check GfxDeviceInfo.uses_reversed_z + for (int c = 0; c < 4; ++c) { + out[2][c] = -out[2][c]; + } +} + +void projection_vector_from_perspective(const Mtx44 projection, f32 out[7]) { + out[0] = 0.0f; + out[1] = projection[0][0]; + out[2] = projection[0][2]; + out[3] = projection[1][1]; + out[4] = projection[1][2]; + out[5] = projection[2][2]; + out[6] = projection[2][3]; +} + +const view_class* stage_game_view(const GfxStageContext* stageCtx) { + if (stageCtx == nullptr || stageCtx->struct_size < sizeof(GfxStageContext) || + stageCtx->game_view == nullptr) + { + return nullptr; + } + return static_cast(stageCtx->game_view); +} + +bool capture_raw_camera( + const view_class* gameView, Mtx outView, Mtx44 outProjectionMtx, f32 outProjection[7]) { + if (gameView == nullptr || !matrix_ready(gameView->viewMtx)) { + return false; + } + std::memcpy(outProjectionMtx, gameView->projMtx, sizeof(Mtx44)); + projection_vector_from_perspective(outProjectionMtx, outProjection); + if (!projection_vector_ready(outProjection)) { + return false; + } + cMtx_copy(gameView->viewMtx, outView); + return true; +} + +bool capture_scene_camera(const GfxStageContext* stageCtx) { + g_sceneCamera.valid = false; + g_sceneCamera.raw_valid = false; + const view_class* gameView = stage_game_view(stageCtx); + if (gameView == nullptr) { + return false; + } + CameraInfo info = CAMERA_INFO_INIT; + if (svc_camera->get_camera(mod_ctx, stageCtx->game_view, &info) != MOD_OK) { + return false; + } + g_sceneCamera.info = info; + g_sceneCamera.valid = true; + g_sceneCamera.raw_valid = capture_raw_camera(gameView, g_sceneCamera.raw_view, + g_sceneCamera.raw_projection_mtx, g_sceneCamera.raw_projection); + return true; +} + +bool get_replay_camera(Mtx outView, Mtx44 outProjectionMtx, f32 outProjection[7]) { + if (g_sceneCamera.raw_valid && matrix_ready(g_sceneCamera.raw_view)) { + cMtx_copy(g_sceneCamera.raw_view, outView); + std::memcpy(outProjectionMtx, g_sceneCamera.raw_projection_mtx, + sizeof(g_sceneCamera.raw_projection_mtx)); + std::memcpy( + outProjection, g_sceneCamera.raw_projection, sizeof(g_sceneCamera.raw_projection)); + return projection_vector_ready(outProjection); + } + + return false; +} + +float wrap_daytime(float daytime) { + if (!std::isfinite(daytime)) { + return 180.0f; + } + float wrapped = std::fmod(daytime, 360.0f); + if (wrapped < 0.0f) { + wrapped += 360.0f; + } + return wrapped; +} + +float daytime_percent(float max, float min, float value) { + const float range = max - min; + if (range == 0.0f) { + return 1.0f; + } + const float percent = 1.0f - ((max - value) / range); + return percent < 1.0f ? percent : 1.0f; +} + +float sun_moon_angle(float daytime) { + daytime = wrap_daytime(daytime); + if (daytime >= 90.0f && daytime <= 270.0f) { + return daytime_percent(270.0f, 90.0f, daytime) * 150.0f + 105.0f; + } + + float angle = daytime; + if (angle < 90.0f) { + angle += 360.0f; + } + + angle = daytime_percent(450.0f, 270.0f, angle) * 210.0f + 255.0f; + if (angle > 360.0f) { + angle -= 360.0f; + } + return angle; +} + +cXyz sun_moon_offset(float daytime) { + const float angle = DEG_TO_RAD(sun_moon_angle(daytime)); + const float angleSin = sinf(angle); + const float angleCos = cosf(angle); + return cXyz{ + angleSin * kSunMoonDistance, -angleCos * kSunMoonDistance, angleCos * kSunMoonZDistance}; +} + +bool build_composite_pipeline( + bool blend, WGPURenderPipeline& outPipeline, WGPUBindGroupLayout& outLayout) { + 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 composite", WGPU_STRLEN}; + WGPUShaderModule module = wgpuDeviceCreateShaderModule(g_deviceInfo.device, &moduleDesc); + if (module == nullptr) { + return false; + } + + // Multiply blend: fragment output is the darkening multiplier (result = dst * src). + WGPUBlendState blendState{ + .color = {.operation = WGPUBlendOperation_Add, + .srcFactor = WGPUBlendFactor_Dst, + .dstFactor = WGPUBlendFactor_Zero}, + .alpha = {.operation = WGPUBlendOperation_Add, + .srcFactor = WGPUBlendFactor_Zero, + .dstFactor = WGPUBlendFactor_One}, + }; + WGPUColorTargetState colorTarget = WGPU_COLOR_TARGET_STATE_INIT; + colorTarget.format = g_deviceInfo.color_format; + if (blend) { + colorTarget.blend = &blendState; + } + WGPUFragmentState fragment = WGPU_FRAGMENT_STATE_INIT; + fragment.module = module; + fragment.entryPoint = {"fs_main", WGPU_STRLEN}; + fragment.targetCount = 1; + fragment.targets = &colorTarget; + WGPUDepthStencilState depthStencil = WGPU_DEPTH_STENCIL_STATE_INIT; + depthStencil.format = g_deviceInfo.depth_format; + depthStencil.depthWriteEnabled = WGPUOptionalBool_False; + depthStencil.depthCompare = WGPUCompareFunction_Always; + + WGPURenderPipelineDescriptor pipelineDesc = WGPU_RENDER_PIPELINE_DESCRIPTOR_INIT; + pipelineDesc.label = {blend ? "shadow composite" : "shadow composite (debug)", WGPU_STRLEN}; + pipelineDesc.vertex.module = module; + pipelineDesc.vertex.entryPoint = {"vs_main", WGPU_STRLEN}; + pipelineDesc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + pipelineDesc.depthStencil = &depthStencil; + pipelineDesc.multisample.count = g_deviceInfo.sample_count; + pipelineDesc.fragment = &fragment; + outPipeline = wgpuDeviceCreateRenderPipeline(g_deviceInfo.device, &pipelineDesc); + wgpuShaderModuleRelease(module); + if (outPipeline == nullptr) { + return false; + } + outLayout = wgpuRenderPipelineGetBindGroupLayout(outPipeline, 0); + return outLayout != nullptr; +} + +// Render worker thread: fullscreen deferred-shadow composite. +void on_draw( + ModContext*, const GfxDrawContext* ctx, const void* payload, size_t payloadSize, void*) { + if (payloadSize != sizeof(DrawPayload)) { + return; + } + DrawPayload data; + std::memcpy(&data, payload, sizeof(data)); + + 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) + { + 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); + if (bindGroup == nullptr) { + return; + } + + wgpuRenderPassEncoderSetPipeline(ctx->pass, pipeline); + wgpuRenderPassEncoderSetBindGroup(ctx->pass, 0, bindGroup, 0, nullptr); + wgpuRenderPassEncoderDraw(ctx->pass, 3, 1, 0, 0); + wgpuBindGroupRelease(bindGroup); +} + +// 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) { + dScnKy_env_light_c* envLight = dKy_getEnvlight(); + if (envLight == nullptr) { + return false; + } + + // The packet positions can be stale when this runs before the world lists are consumed. + // Mirror dScnKy_env_light_c::setSunpos() so --time-of-day directly moves the debug light. + const float daytime = wrap_daytime(dComIfGs_getTime()); + cXyz offset = sun_moon_offset(daytime); + if (offset.y <= 0.0f) { + offset = sun_moon_offset(daytime + 180.0f); + } + const float length = std::sqrt(offset.x * offset.x + offset.y * offset.y + offset.z * offset.z); + if (length < 1.0f) { + return false; + } + outDirToLight[0] = offset.x / length; + outDirToLight[1] = offset.y / length; + outDirToLight[2] = offset.z / length; + // Fade shadows out as the light approaches the horizon (elevation below ~11 degrees). + outFade = std::clamp((outDirToLight[1] - 0.05f) / 0.15f, 0.0f, 1.0f); + return outFade > 0.0f; +} + +bool build_light_camera(const Mtx cameraView, uint32_t mapSize, float radius, LightCamera& out) { + Mtx cameraInvView; + cMtx_inverse(cameraView, cameraInvView); + if (!matrix_ready(cameraInvView)) { + return false; + } + if (!compute_light(out.dirToLight, out.fade)) { + return false; + } + + // Fit a fixed-radius ortho box around the visible play space. The camera target alone can sit + // behind the receiver field, while a far-horizon center drops foreground receivers. + const cXyz eye{cameraInvView[0][3], cameraInvView[1][3], cameraInvView[2][3]}; + cXyz forward{-cameraInvView[0][2], -cameraInvView[1][2], -cameraInvView[2][2]}; + const float forwardLength = + std::sqrt(forward.x * forward.x + forward.y * forward.y + forward.z * forward.z); + if (forwardLength > 0.001f) { + forward = forward / forwardLength; + } else { + forward = cXyz{0.0f, 0.0f, -1.0f}; + } + const float lookahead = std::min(radius * 0.75f, kMaxLightLookahead); + const cXyz center = eye + forward * lookahead; + const cXyz lightEye{center.x + out.dirToLight[0] * kLightDistance, + center.y + out.dirToLight[1] * kLightDistance, + center.z + out.dirToLight[2] * kLightDistance}; + const bool nearlyVertical = std::fabs(out.dirToLight[1]) > 0.99f; + cXyz up = nearlyVertical ? cXyz{0.0f, 0.0f, 1.0f} : cXyz{0.0f, 1.0f, 0.0f}; + + cMtx_lookAt(out.view, &lightEye, ¢er, &up, 0); + const float unitsPerTexel = (2.0f * radius) / static_cast(mapSize); + out.view[0][3] = std::round(out.view[0][3] / unitsPerTexel) * unitsPerTexel; + out.view[1][3] = std::round(out.view[1][3] / unitsPerTexel) * unitsPerTexel; + + C_MTXOrtho(out.ortho, radius, -radius, -radius, radius, kLightNear, kLightFar); + cMtx_concatProjView(out.ortho, out.view, out.vp); + return true; +} + +bool build_light_replay_projection( + const LightCamera& lightCamera, const Mtx cameraView, Mtx44 out) { + Mtx cameraInvView; + cMtx_inverse(cameraView, cameraInvView); + if (!matrix_ready(cameraInvView)) { + return false; + } + + Mtx lightFromCamera; + cMtx_concat(lightCamera.view, cameraInvView, lightFromCamera); + cMtx_concatProjView(lightCamera.ortho, lightFromCamera, out); + return true; +} + +// True when the dynamic shadow pass will run this frame: enabled, a camera exists, and the +// sun or moon is above the horizon. Also gates the game-shadow skip hooks, which fire earlier +// in the painter than our SCENE_AFTER_TERRAIN hook. +bool dynamic_shadows_wanted() { + if (!get_bool_option(g_cvarEnabled, true)) { + return false; + } + if (!g_sceneCamera.raw_valid) { + return false; + } + float dirToLight[3]; + float fade = 0.0f; + return compute_light(dirToLight, fade); +} + +HookAction on_game_shadow_pre(ModContext*, void*, void*, void*) { + if (!dynamic_shadows_wanted()) { + return HOOK_CONTINUE; + } + return HOOK_SKIP_ORIGINAL; +} + +HookAction on_frustum_clip_pre(ModContext*, void*, void* retval, void*) { + if (!get_bool_option(g_cvarNoFrustumClipping, false) || !dynamic_shadows_wanted()) { + return HOOK_CONTINUE; + } + if (retval != nullptr) { + *static_cast(retval) = 0; + } + return HOOK_SKIP_ORIGINAL; +} + +HookAction on_copy_tex_pre(ModContext*, void*, void*, void*) { + return g_replayingSceneLists ? HOOK_SKIP_ORIGINAL : HOOK_CONTINUE; +} + +void draw_opaque_scene_lists() { + dComIfGd_drawOpaListBG(); + dComIfGd_drawOpaListDarkBG(); + dComIfGd_drawOpaListMiddle(); + dComIfGd_drawOpaList(); + dComIfGd_drawOpaListDark(); + dComIfGd_drawOpaListPacket(); +} + +bool draw_lists_ready() { + return dComIfGd_getOpaListBG() != nullptr && dComIfGd_getOpaList() != nullptr && + dComIfGd_getOpaListDark() != nullptr && dComIfGd_getXluListBG() != nullptr && + dComIfGd_getListPacket() != nullptr; +} + +void render_shadow_map( + const Mtx replayView, const Mtx44 replayProjectionMtx, const f32 replayProjection[7]); + +void restore_actual_light_debug() { + if (!g_actualLightDebug.active) { + return; + } + + j3dSys.setViewMtx(g_actualLightDebug.savedView); + GXSetProjectionv(g_actualLightDebug.savedProjection); + GXSetViewport(g_actualLightDebug.savedViewport[0], g_actualLightDebug.savedViewport[1], + g_actualLightDebug.savedViewport[2], g_actualLightDebug.savedViewport[3], + g_actualLightDebug.savedViewport[4], g_actualLightDebug.savedViewport[5]); + GXSetScissor(g_actualLightDebug.savedScissor[0], g_actualLightDebug.savedScissor[1], + g_actualLightDebug.savedScissor[2], g_actualLightDebug.savedScissor[3]); + dKy_setLight(); + J3DShape::resetVcdVatCache(); + + g_actualLightDebug.active = false; +} + +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) { + return; + } + + Mtx cameraView; + if (!g_sceneCamera.raw_valid || !matrix_ready(g_sceneCamera.raw_view)) { + return; + } + cMtx_copy(g_sceneCamera.raw_view, cameraView); + + const uint32_t mapSize = 1024u << std::clamp(get_int_option(g_cvarMapSize, 1), 0, 2); + const float radius = + static_cast(std::clamp(get_int_option(g_cvarBoxRadius, 6000), 1000, 20000)); + LightCamera lightCamera{}; + if (!build_light_camera(cameraView, mapSize, radius, lightCamera)) { + return; + } + Mtx44 lightProjection; + if (!build_light_replay_projection(lightCamera, cameraView, lightProjection)) { + return; + } + + cMtx_copy(cameraView, g_actualLightDebug.savedView); + GXGetProjectionv(g_actualLightDebug.savedProjection); + GXGetViewportv(g_actualLightDebug.savedViewport); + GXGetScissor(&g_actualLightDebug.savedScissor[0], &g_actualLightDebug.savedScissor[1], + &g_actualLightDebug.savedScissor[2], &g_actualLightDebug.savedScissor[3]); + g_actualLightDebug.active = true; + + j3dSys.setViewMtx(g_actualLightDebug.savedView); + GXSetProjectionFull(lightProjection); + dKy_setLight(); + J3DShape::resetVcdVatCache(); +} + +void on_scene_after_terrain(ModContext*, const GfxStageContext* stageCtx, void*) { + if (g_mapPass.ready) { + return; + } + + const view_class* gameView = stage_game_view(stageCtx); + Mtx replayView; + Mtx44 replayProjectionMtx; + f32 replayProjection[7]; + if (!capture_raw_camera(gameView, replayView, replayProjectionMtx, replayProjection)) { + return; + } + render_shadow_map(replayView, replayProjectionMtx, replayProjection); +} + +// Game thread, after the draw handlers have populated next frame's scene lists: replay opaque scene +// geometry from the light's point of view. +void render_shadow_map( + const Mtx replayView, const Mtx44 replayProjectionMtx, const f32 replayProjection[7]) { + if (g_mapPass.ready || !get_bool_option(g_cvarEnabled, true)) { + return; + } + const int64_t debugMode = get_debug_mode(); + if (debugMode == 9) { + return; + } + if (!matrix_ready(replayView)) { + return; + } + Mtx replayViewMtx; + cMtx_copy(replayView, replayViewMtx); + + const uint32_t mapSize = 1024u << std::clamp(get_int_option(g_cvarMapSize, 1), 0, 2); + const bool cameraReplayDebug = debugMode == 10; + const float radius = + static_cast(std::clamp(get_int_option(g_cvarBoxRadius, 6000), 1000, 20000)); + LightCamera lightCamera{}; + if (!build_light_camera(replayViewMtx, mapSize, radius, lightCamera)) { + return; + } + Mtx44 lightReplayProjection; + if (!build_light_replay_projection(lightCamera, replayViewMtx, lightReplayProjection)) { + return; + } + f32 savedProjection[7]; + GXGetProjectionv(savedProjection); + f32 savedViewport[6]; + GXGetViewportv(savedViewport); + u32 savedScissor[4]; + GXGetScissor(&savedScissor[0], &savedScissor[1], &savedScissor[2], &savedScissor[3]); + Mtx savedView; + cMtx_copy(j3dSys.getViewMtx(), savedView); + + auto restore_game_camera = [&]() { + j3dSys.setViewMtx(savedView); + GXSetProjectionv(savedProjection); + GXSetViewport(savedViewport[0], savedViewport[1], savedViewport[2], savedViewport[3], + savedViewport[4], savedViewport[5]); + GXSetScissor(savedScissor[0], savedScissor[1], savedScissor[2], savedScissor[3]); + dKy_setLight(); + }; + auto set_replay_camera = [&]() { + j3dSys.setViewMtx(replayViewMtx); + if (cameraReplayDebug) { + GXSetProjectionv(replayProjection); + } else { + GXSetProjectionFull(lightReplayProjection); + } + dKy_setLight(); + }; + if (!draw_lists_ready()) { + return; + } + if (svc_gfx->create_pass(mod_ctx, mapSize, mapSize) != MOD_OK) { + return; + } + J3DShape::resetVcdVatCache(); + + set_replay_camera(); + GXSetViewport(0.0f, 0.0f, static_cast(mapSize), static_cast(mapSize), 0.0f, 1.0f); + GXSetViewportRender( + 0.0f, 0.0f, static_cast(mapSize), static_cast(mapSize), 0.0f, 1.0f); + GXSetScissorRender(0, 0, mapSize, mapSize); + dKy_setLight(); + GXSetColorUpdate(GX_TRUE); + GXSetAlphaUpdate(GX_TRUE); + GXSetZMode(GX_TRUE, GX_LEQUAL, GX_TRUE); + { + replay_scope replay; + draw_opaque_scene_lists(); + } + j3dSys.reinitGX(); + J3DShape::resetVcdVatCache(); + restore_game_camera(); + + GfxResolveDesc resolveDesc = GFX_RESOLVE_DESC_INIT; + resolveDesc.color = true; + resolveDesc.depth = true; + GfxResolvedTargets resolved = GFX_RESOLVED_TARGETS_INIT; + if (svc_gfx->resolve_pass(mod_ctx, &resolveDesc, &resolved) != MOD_OK || + resolved.color == nullptr || resolved.depth == nullptr) + { + return; + } + + j3dSys.reinitGX(); + J3DShape::resetVcdVatCache(); + restore_game_camera(); + + g_mapPass.lightColor = resolved.color; + g_mapPass.shadowMap = resolved.depth; + g_mapPass.mapSize = mapSize; + copy_projection(lightCamera.vp, g_mapPass.lightVp); + std::memcpy( + g_mapPass.dirToLightWorld, lightCamera.dirToLight, sizeof(g_mapPass.dirToLightWorld)); + g_mapPass.fade = lightCamera.fade; + g_mapPass.ready = true; +} + +// Game thread, after the full 3D scene: deferred composite. +void on_frame_before_hud(ModContext*, const GfxStageContext*, void*) { + const int64_t debugMode = get_debug_mode(); + restore_actual_light_debug(); + + const MapPassOutput mapPass = std::exchange(g_mapPass, {}); + if (debugMode == 9) { + return; + } + if (!mapPass.ready || mapPass.shadowMap == nullptr || mapPass.lightColor == nullptr) { + return; + } + if (!g_sceneCamera.valid) { + return; + } + const CameraInfo& camera = g_sceneCamera.info; + + GfxResolveDesc resolveDesc = GFX_RESOLVE_DESC_INIT; + resolveDesc.color = false; + resolveDesc.depth = true; + GfxResolvedTargets resolved = GFX_RESOLVED_TARGETS_INIT; + if (svc_gfx->resolve_pass(mod_ctx, &resolveDesc, &resolved) != MOD_OK || + resolved.depth == nullptr) + { + return; + } + + ShadowUniforms uniforms{}; + std::memcpy(uniforms.world_from_proj, camera.world_from_proj, sizeof(uniforms.world_from_proj)); + std::memcpy(uniforms.view_from_proj, camera.view_from_proj, sizeof(uniforms.view_from_proj)); + std::memcpy(uniforms.proj_from_view, camera.proj_from_view, sizeof(uniforms.proj_from_view)); + store_column_major(mapPass.lightVp, uniforms.light_vp); + // Rotate the world-space light direction into view space (w = 0). + for (int r = 0; r < 3; ++r) { + uniforms.light_dir_view[r] = + camera.view_from_world[0 * 4 + r] * mapPass.dirToLightWorld[0] + + camera.view_from_world[1 * 4 + r] * mapPass.dirToLightWorld[1] + + camera.view_from_world[2 * 4 + r] * mapPass.dirToLightWorld[2]; + } + // Bias is configured in world units along the light direction. + uniforms.bias = + static_cast(std::clamp(get_int_option(g_cvarBias, 15), 0, 200)) / + (kLightFar - kLightNear); + uniforms.size[0] = static_cast(mapPass.mapSize); + uniforms.size[1] = static_cast(mapPass.mapSize); + 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)); + uniforms.strength = + mapPass.fade * + static_cast(std::clamp(get_int_option(g_cvarStrength, 45), 0, 100)) / + 100.0f; + uniforms.pcf_taps = static_cast(std::clamp(get_int_option(g_cvarPcf, 1), 0, 2)); + 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); + + 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)}; + svc_gfx->push_draw(mod_ctx, g_drawType, &payload, sizeof(payload)); +} + +void add_control(UiElementHandle pane, const UiControlDesc& desc) { + svc_ui->pane_add_control(mod_ctx, pane, &desc, nullptr); +} + +void add_toggle(UiElementHandle pane, const char* label, ConfigVarHandle cvar, const char* help) { + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_TOGGLE; + control.label = label; + control.help_rml = help; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = cvar; + add_control(pane, control); +} + +void add_select(UiElementHandle pane, const char* label, ConfigVarHandle cvar, const char** options, + uint32_t optionCount, const char* help) { + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_SELECT; + control.label = label; + control.help_rml = help; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = cvar; + control.options = options; + control.option_count = optionCount; + add_control(pane, control); +} + +void add_number(UiElementHandle pane, const char* label, ConfigVarHandle cvar, int64_t min, + int64_t max, int64_t step, const char* suffix, const char* help) { + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_NUMBER; + control.label = label; + control.help_rml = help; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = cvar; + control.min = min; + control.max = max; + control.step = step; + control.suffix = suffix; + add_control(pane, control); +} + +ModResult build_controls_tab( + ModContext*, UiWindowHandle, UiElementHandle left, UiElementHandle right, void*, ModError*) { + (void)right; + + svc_ui->pane_add_section(mod_ctx, left, "Shadow Map"); + add_toggle(left, "Enabled", g_cvarEnabled, "Enables dynamic shadows."); + static const char* kMapSizes[] = {"1024", "2048", "4096"}; + add_select(left, "Map Size", g_cvarMapSize, kMapSizes, 3, + "Shadow map resolution. Larger is sharper and slower."); + add_toggle(left, "No Frustum Clipping", g_cvarNoFrustumClipping, + "Keeps camera-frustum-culled objects in draw lists so off-screen objects can cast " + "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", + "Fade out shadows gradually near the edge of the coverage area."); + + svc_ui->pane_add_section(mod_ctx, left, "Appearance"); + add_number(left, "Strength", g_cvarStrength, 0, 100, 5, "%", "How dark shadowed areas become."); + static const char* kPcfOptions[] = {"Off", "3x3", "5x5"}; + add_select(left, "Soft Shadows", g_cvarPcf, kPcfOptions, 3, + "Percentage-closer filtering tap pattern; softens shadow edges."); + add_number(left, "Bias", g_cvarBias, 0, 200, 5, nullptr, + "Depth bias in world units. Raise to remove shadow acne; lower to reduce peter-panning."); + add_toggle(left, "Contact Shadows", g_cvarContactShadows, + "Adds a screen-space raymarch for small-scale contact darkening the map misses."); + + svc_ui->pane_add_section(mod_ctx, left, "Debug"); + static const char* kDebugOptions[] = {"Off", "Shadow Map", "Shadow Factor", "Occlusion", + "Light UV", "Compare Sign", "Depth Values", "Receiver Range", "Bounds", "Light View", + "Camera Replay"}; + add_select(left, "Debug View", g_cvarDebugView, kDebugOptions, 11, + "Shadow Map: light-space depth buffer
Shadow Factor: final " + "darkening term
Occlusion: map comparison result
Light UV: receiver " + "projection coverage
Compare Sign: current comparison in red and opposite " + "comparison in blue
Depth Values: receiver depth in red and map depth in green
" + "Receiver Range: beyond-far in red, valid depth in green, and before-near in blue
" + "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"); + return MOD_OK; +} + +void on_controls_window_closed(ModContext*, UiWindowHandle, void*) { + g_controlsWindow = 0; +} + +void on_open_controls(ModContext*, void*) { + if (g_controlsWindow != 0) { + return; + } + UiTabDesc tabs[1] = {UI_TAB_DESC_INIT}; + tabs[0].title = "Controls"; + tabs[0].build = build_controls_tab; + UiWindowDesc desc = UI_WINDOW_DESC_INIT; + desc.tabs = tabs; + desc.tab_count = 1; + desc.on_closed = on_controls_window_closed; + if (svc_ui->window_push(mod_ctx, &desc, &g_controlsWindow) != MOD_OK) { + svc_log->error(mod_ctx, "failed to open shadow controls window"); + } +} + +ModResult build_panel(ModContext*, UiElementHandle panel, void*, ModError*) { + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_TOGGLE; + control.label = "Enabled"; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = g_cvarEnabled; + add_control(panel, control); + + control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_BUTTON; + control.label = "Open Controls"; + control.on_pressed = on_open_controls; + add_control(panel, control); + return MOD_OK; +} + +ModResult register_bool_option( + const char* name, bool defaultValue, ConfigVarHandle& outHandle, ModError* error) { + ConfigVarDesc cvarDesc = CONFIG_VAR_DESC_INIT; + cvarDesc.name = name; + cvarDesc.type = CONFIG_VAR_BOOL; + cvarDesc.default_bool = defaultValue; + if (svc_config->register_var(mod_ctx, &cvarDesc, &outHandle) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register shadow option"); + } + return MOD_OK; +} + +ModResult register_int_option( + const char* name, int64_t defaultValue, ConfigVarHandle& outHandle, ModError* error) { + ConfigVarDesc cvarDesc = CONFIG_VAR_DESC_INIT; + cvarDesc.name = name; + cvarDesc.type = CONFIG_VAR_INT; + cvarDesc.default_int = defaultValue; + if (svc_config->register_var(mod_ctx, &cvarDesc, &outHandle) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register shadow option"); + } + return MOD_OK; +} + +} // namespace + +extern "C" { + +MOD_EXPORT ModResult mod_initialize(ModError* error) { + ModResult result = svc_resource->load(mod_ctx, "shadow.wgsl", &g_shaderSource); + if (result != MOD_OK || g_shaderSource.data == nullptr) { + return dusk::mods::set_error(error, result, "failed to load shadow.wgsl"); + } + + result = register_bool_option("effectEnabled", false, g_cvarEnabled, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("mapSize", 2, g_cvarMapSize, error); + if (result != MOD_OK) { + return result; + } + result = register_bool_option("noFrustumClipping", true, g_cvarNoFrustumClipping, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("strength", 45, g_cvarStrength, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("pcf", 2, g_cvarPcf, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("bias", 55, g_cvarBias, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("boxRadius", 6000, g_cvarBoxRadius, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("edgeFadeWidth", 32, g_cvarEdgeFadeWidth, error); + if (result != MOD_OK) { + return result; + } + result = register_bool_option("contactShadows", true, g_cvarContactShadows, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("debugView", 0, g_cvarDebugView, error); + if (result != MOD_OK) { + return result; + } + + if (svc_gfx->get_device_info(mod_ctx, &g_deviceInfo) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to query device info"); + } + if (!build_composite_pipeline(true, g_compositePipeline, g_compositeLayout) || + !build_composite_pipeline(false, g_compositeDebugPipeline, g_compositeDebugLayout)) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to create composite pipeline"); + } + + GfxDrawTypeDesc drawDesc = GFX_DRAW_TYPE_DESC_INIT; + drawDesc.label = "shadow composite"; + drawDesc.draw = on_draw; + if (svc_gfx->register_draw_type(mod_ctx, &drawDesc, &g_drawType) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register draw type"); + } + GfxStageHookDesc stageDesc = GFX_STAGE_HOOK_DESC_INIT; + stageDesc.callback = on_scene_begin; + if (svc_gfx->register_stage_hook( + mod_ctx, GFX_STAGE_SCENE_BEGIN, &stageDesc, &g_sceneBeginHook) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register stage hook"); + } + stageDesc.callback = on_scene_after_terrain; + if (svc_gfx->register_stage_hook( + mod_ctx, GFX_STAGE_SCENE_AFTER_TERRAIN, &stageDesc, &g_sceneAfterTerrainHook) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register stage hook"); + } + stageDesc.callback = on_frame_before_hud; + if (svc_gfx->register_stage_hook( + mod_ctx, GFX_STAGE_FRAME_BEFORE_HUD, &stageDesc, &g_frameBeforeHudHook) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register stage hook"); + } + + // 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 (dusk::mods::hook_add_pre<&dDlst_shadowControl_c::imageDraw>(svc_hook, on_game_shadow_pre) != + MOD_OK || + dusk::mods::hook_add_pre<&dDlst_shadowControl_c::draw>(svc_hook, on_game_shadow_pre) != + MOD_OK || + dusk::mods::hook_add_pre<&drawCloudShadow>(svc_hook, on_game_shadow_pre) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to hook game shadow rendering"); + } + if (dusk::mods::hook_add_pre(svc_hook, on_frustum_clip_pre) != MOD_OK || + dusk::mods::hook_add_pre(svc_hook, on_frustum_clip_pre) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to hook frustum clipping"); + } + if (dusk::mods::hook_add_pre(svc_hook, on_copy_tex_pre) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to hook GXCopyTex"); + } + UiModsPanelDesc panelDesc = UI_MODS_PANEL_DESC_INIT; + panelDesc.build = build_panel; + svc_ui->register_mods_panel(mod_ctx, &panelDesc); + + return MOD_OK; +} + +MOD_EXPORT ModResult mod_update(ModError*) { + return MOD_OK; +} + +MOD_EXPORT ModResult mod_shutdown(ModError*) { + restore_actual_light_debug(); + svc_resource->free(mod_ctx, &g_shaderSource); + if (g_compositePipeline != nullptr) { + wgpuRenderPipelineRelease(g_compositePipeline); + g_compositePipeline = nullptr; + } + if (g_compositeDebugPipeline != nullptr) { + wgpuRenderPipelineRelease(g_compositeDebugPipeline); + g_compositeDebugPipeline = nullptr; + } + if (g_compositeLayout != nullptr) { + wgpuBindGroupLayoutRelease(g_compositeLayout); + g_compositeLayout = nullptr; + } + if (g_compositeDebugLayout != nullptr) { + wgpuBindGroupLayoutRelease(g_compositeDebugLayout); + g_compositeDebugLayout = nullptr; + } + g_cvarEnabled = g_cvarMapSize = g_cvarNoFrustumClipping = 0; + 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_controlsWindow = 0; + g_mapPass = {}; + g_sceneCamera.valid = false; + g_sceneCamera.raw_valid = false; + return MOD_OK; +} +} diff --git a/mods/template_mod/CMakeLists.txt b/mods/template_mod/CMakeLists.txt new file mode 100644 index 0000000000..3d40c3c5c4 --- /dev/null +++ b/mods/template_mod/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.25) +project(template_mod 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(template_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res +) diff --git a/mods/template_mod/mod.json b/mods/template_mod/mod.json new file mode 100644 index 0000000000..e60714edf5 --- /dev/null +++ b/mods/template_mod/mod.json @@ -0,0 +1,7 @@ +{ + "id": "com.example.mod", + "name": "Template Mod", + "version": "1.0.0", + "author": "You", + "description": "An example Dusklight mod" +} diff --git a/mods/template_mod/res/.gitkeep b/mods/template_mod/res/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mods/template_mod/src/mod.cpp b/mods/template_mod/src/mod.cpp new file mode 100644 index 0000000000..35d19331fd --- /dev/null +++ b/mods/template_mod/src/mod.cpp @@ -0,0 +1,22 @@ +#include "mods/service.hpp" +#include "mods/svc/log.h" + +DEFINE_MOD(); +IMPORT_SERVICE(LogService, svc_log); + +extern "C" { + +MOD_EXPORT ModResult mod_initialize(ModError*) { + svc_log->info(mod_ctx, "template_mod initialized"); + return MOD_OK; +} + +MOD_EXPORT ModResult mod_update(ModError*) { + return MOD_OK; +} + +MOD_EXPORT ModResult mod_shutdown(ModError*) { + svc_log->info(mod_ctx, "template_mod unloaded"); + return MOD_OK; +} +} diff --git a/platforms/macos/Dusklight.entitlements b/platforms/macos/Dusklight.entitlements new file mode 100644 index 0000000000..123d12a53e --- /dev/null +++ b/platforms/macos/Dusklight.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/res/rml/logs.rcss b/res/rml/logs.rcss new file mode 100644 index 0000000000..126684f9d2 --- /dev/null +++ b/res/rml/logs.rcss @@ -0,0 +1,97 @@ +window.logs content { + flex-flow: column; +} + +window.logs .log-toolbar { + display: flex; + flex-flow: row; + flex: 0 0 64dp; + height: 64dp; + align-items: center; + gap: 8dp; + padding-right: 72dp; + background-color: rgba(217, 217, 217, 10%); + border-bottom: 2dp #92875B; + font-family: "Fira Sans Condensed"; + font-weight: bold; + font-size: 18dp; +} + +window.logs > close { + top: 8dp; + right: 8dp; +} + +window.logs .log-title { + align-self: stretch; + flex: 0 0 auto; + padding: 0 24dp; + line-height: 64dp; + text-transform: uppercase; + border-bottom: 4dp #C2A42D; + font-effect: glow(0dp 4dp 0dp 4dp black); +} + +window.logs .log-title-mod { + flex: 0 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + font-family: "Fira Sans"; + font-weight: normal; + font-size: 15dp; + color: rgba(224, 219, 200, 55%); +} + +.log-toolbar-spacer { + flex: 1 1 0; +} + +.log-toolbar button { + flex: 0 0 auto; + font-family: "Fira Sans"; + font-weight: normal; + font-size: 15dp; + padding: 5dp 12dp; +} + +window.logs content pane.log-view { + flex: 1 1 0; + padding: 12dp 16dp; + padding-bottom: 0dp; + gap: 0dp; +} + +.log-lines { + display: block; +} + +.log-line { + display: block; + font-family: "Noto Mono"; + font-size: 13dp; + line-height: 1.5; + word-break: break-word; + white-space: pre-wrap; +} + +.log-line .log-time { + color: rgba(224, 219, 200, 45%); +} + +.log-line .log-mod { + color: rgba(194, 164, 45, 80%); +} + +.log-line.lvl-trace, +.log-line.lvl-debug { + opacity: 0.55; +} + +.log-line.lvl-warn .log-msg { + color: #ffa826; +} + +.log-line.lvl-error .log-msg { + color: #cc4444; +} diff --git a/res/rml/mods.rcss b/res/rml/mods.rcss new file mode 100644 index 0000000000..6042e35c0f --- /dev/null +++ b/res/rml/mods.rcss @@ -0,0 +1,219 @@ +window.mods content pane.mod-list { + flex: 0 0 360dp; + padding: 16dp; + padding-bottom: 0dp; + gap: 4dp; +} + +@media (max-height: 640dp) { + window.mods content pane.mod-list { + flex: 1 1 0; + } +} + +window.mods content pane.mod-detail { + gap: 12dp; +} + +.mod-info-row { + display: flex; + align-items: center; + gap: 12dp; + padding: 4dp 0; +} + +.mod-info-label { + font-family: "Fira Sans Condensed"; + font-weight: bold; + opacity: 0.55; + flex: 0 0 auto; +} + +.mod-info-value { + flex: 1 1 0; +} + +.mod-path { + font-size: 14dp; + word-break: break-all; + opacity: 0.7; +} + +mod-entry { + display: flex; + flex-flow: row; + gap: 12dp; + padding: 10dp; + border-radius: 10dp; + decorator: vertical-gradient(#c2a42d00 #c2a42d00); + transition: decorator 0.1s linear-in-out; + cursor: pointer; + focus: auto; +} + +mod-entry.current { + box-shadow: rgba(146, 135, 91, 40%) 0 0 0 1dp; +} + +mod-entry:hover, +mod-entry:focus-visible { + decorator: vertical-gradient(#c2a42d00 #c2a42d26); +} + +mod-entry:selected { + decorator: vertical-gradient(#c2a42d10 #c2a42d40); +} + +mod-entry .mod-icon { + flex: 0 0 auto; + width: 56dp; + height: 56dp; + border-radius: 8dp; +} + +mod-entry icon.mod-icon { + font-size: 36dp; + background-color: rgba(17, 16, 10, 20%); + color: rgba(224, 219, 200, 45%); + decorator: text("" center center); +} + +mod-entry .mod-entry-info { + display: flex; + flex-flow: column; + flex: 1 1 0; + min-width: 0; + gap: 2dp; +} + +mod-entry .mod-entry-name { + display: flex; + flex-flow: row; + align-items: baseline; + gap: 6dp; +} + +mod-entry .mod-entry-name-text { + flex: 0 1 auto; + min-width: 0; + font-weight: bold; + white-space: nowrap; + overflow: hidden; +} + +mod-entry .mod-entry-version { + flex: 0 0 auto; + font-size: 13dp; + color: rgba(224, 219, 200, 50%); +} + +mod-entry .mod-entry-status.active { + color: #44cc55; +} + +mod-entry .mod-entry-status.failed { + color: #cc4444; +} + +mod-entry .mod-entry-desc { + font-size: 14dp; + line-height: 1.3; + color: rgba(224, 219, 200, 65%); + max-height: 2.6em; + overflow: hidden; + white-space: pre-wrap; +} + +mod-entry .mod-entry-sub { + font-size: 13dp; + color: rgba(224, 219, 200, 50%); +} + +mod-entry.inactive .mod-icon { + filter: grayscale(1); +} + +mod-entry.inactive .mod-entry-info { + opacity: 0.5; +} + +mod-header { + position: relative; + flex: 0 0 auto; +} + +mod-header.has-banner { + height: 180dp; + margin: -24dp -24dp 0dp -24dp; +} + +mod-header .mod-actions { + position: absolute; + top: 24dp; + left: 24dp; + display: flex; + flex-flow: row; + gap: 8dp; +} + +mod-header .mod-actions button { + font-size: 16dp; + padding: 6dp 14dp; + background-color: rgba(21, 22, 16, 80%); + box-shadow: rgba(146, 135, 91, 60%) 0 0 0 1dp; +} + +mod-header.no-banner { + display: flex; + flex-flow: row; + align-items: center; +} + +mod-header.no-banner .mod-actions { + position: static; +} + +window.mods .mod-title { + display: block; + font-size: 28dp; + font-weight: bold; +} + +window.mods .mod-title .mod-title-version { + font-weight: normal; + font-size: 16dp; + color: rgba(224, 219, 200, 55%); +} + +window.mods .mod-author { + display: block; + font-size: 15dp; + color: rgba(224, 219, 200, 55%); +} + +window.mods .mod-restart-note { + font-size: 15dp; + color: #ffa826; + opacity: 0.85; +} + +window.mods .mod-description { + line-height: 1.5; +} + +.status-badge { + font-size: 14dp; + opacity: 0.7; +} + +.status-badge.active, +.mod-info-label.active { + color: #44cc55; + opacity: 1; +} + +.status-badge.failed, +.mod-info-label.failed { + color: #cc4444; + opacity: 1; +} \ No newline at end of file diff --git a/res/rml/overlay.rcss b/res/rml/overlay.rcss index 384c7a2a9b..98f8bfa27a 100644 --- a/res/rml/overlay.rcss +++ b/res/rml/overlay.rcss @@ -159,6 +159,14 @@ toast.achievement heading { color: #C2A42D; } +toast.warning { + border: 1dp #C2A42D; +} + +toast.warning heading { + color: #C2A42D; +} + toast.controller-warning { top: auto; right: auto; diff --git a/res/rml/prelaunch.rcss b/res/rml/prelaunch.rcss index 73efc18086..054b6e0183 100644 --- a/res/rml/prelaunch.rcss +++ b/res/rml/prelaunch.rcss @@ -362,6 +362,10 @@ body.animate-in .intro-item { transition: opacity transform 0.3s 0.6s cubic-in-out; } +.delay-6 { + transition: opacity transform 0.3s 0.7s cubic-in-out; +} + /* Mobile layout */ @media (max-height: 640dp) { .gradient { diff --git a/res/rml/tabbing.rcss b/res/rml/tabbing.rcss index 194ca89f05..8f42dd84d5 100644 --- a/res/rml/tabbing.rcss +++ b/res/rml/tabbing.rcss @@ -50,7 +50,8 @@ tab-bar[closable] tab-end-spacer { pointer-events: none; } -tab-bar[closable] close { +tab-bar[closable] close, +window > close { display: block; position: fixed; top: 8dp; @@ -70,12 +71,20 @@ tab-bar[closable] close { } tab-bar[closable] close:hover, -tab-bar[closable] close:focus-visible { +tab-bar[closable] close:focus-visible, +window > close:hover, +window > close:focus-visible { color: #fff; background-color: rgba(194, 164, 45, 24%); } -tab-bar[closable] close:active { +window > close { + top: 16dp; + right: 16dp; +} + +tab-bar[closable] close:active, +window > close:active { color: #fff; background-color: rgba(194, 164, 45, 40%); } diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt new file mode 100644 index 0000000000..8670603866 --- /dev/null +++ b/sdk/CMakeLists.txt @@ -0,0 +1,48 @@ +# Dusklight Mod SDK entry point +# +# Provides game/service headers, compile definitions, version.h and WebGPU +# headers without configuring the full game tree. +# +# Usage (from a mod project): +# add_subdirectory(/sdk dusk-sdk EXCLUDE_FROM_ALL) +# add_mod(my_mod SOURCES ... MOD_JSON mod.json) +# +# On Windows, pass -DDUSK_GAME_IMPLIB= from the +# matching game release. TODO: auto-download from tag + +cmake_minimum_required(VERSION 3.25) + +if (TARGET dusklight_game_headers) + message(FATAL_ERROR "Mod SDK already configured") +endif () + +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/WindowsTargetProcessor.cmake") + +if (NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE "RelWithDebInfo" CACHE STRING + "Build type options: Debug Release RelWithDebInfo MinSizeRel" FORCE) +endif () + +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +# Version detection & version.h +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/DetectVersion.cmake") +detect_version() +configure_version_header() + +# Provides dawn::webgpu_dawn and dawn::dawncpp_headers for public gfx service headers. +include("${CMAKE_CURRENT_SOURCE_DIR}/../extern/aurora/cmake/AuroraDependencyVersions.cmake") +set(AURORA_DAWN_PROVIDER "package" CACHE STRING "How to provide Dawn for the mod SDK") +include("${CMAKE_CURRENT_SOURCE_DIR}/../extern/aurora/cmake/AuroraDawnProvider.cmake") + +# Game ABI headers & compile definitions +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/GameABIConfig.cmake") + +if (WIN32) + set(DUSK_GAME_IMPLIB "" CACHE FILEPATH "Path to the dusklight import library (sdk/dusklight.lib)") + if (DUSK_GAME_IMPLIB AND NOT EXISTS "${DUSK_GAME_IMPLIB}") + message(FATAL_ERROR "Mod SDK: DUSK_GAME_IMPLIB does not exist: ${DUSK_GAME_IMPLIB}") + endif () +endif () + +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/ModSDK.cmake") diff --git a/sdk/pseudo_reloc.cpp b/sdk/pseudo_reloc.cpp new file mode 100644 index 0000000000..f86d783f2d --- /dev/null +++ b/sdk/pseudo_reloc.cpp @@ -0,0 +1,139 @@ +// Mod SDK runtime (Windows): applies lld's MinGW-style runtime pseudo-relocations on +// the plain MSVC CRT. This is what lets mods reference game data (`extern` globals) with no +// __declspec(dllimport) annotations: `lld-link -lldmingw` auto-imports the data through IAT +// slots and records fixups, and this module applies them at load time. +// +// The fixup pass runs as an early CRT initializer, and the IAT slots it reads were already +// bound by the OS loader, so even mod static initializers observe patched references. + +#include + +#include +#include +#include + +extern "C" char __RUNTIME_PSEUDO_RELOC_LIST__; +extern "C" char __RUNTIME_PSEUDO_RELOC_LIST_END__; +extern "C" IMAGE_DOS_HEADER __ImageBase; + +namespace { + +struct HdrV2 { + uint32_t magic1; + uint32_t magic2; + uint32_t version; +}; +struct ItemV2 { + uint32_t sym; // RVA of the __imp_ slot (OS-bound IAT entry) + uint32_t target; // RVA of the reference to patch + uint32_t flags; // low 8 bits: bit width of the reference +}; + +bool g_relocsFailed = false; + +void report(const char* fmt, ...) { + char buf[512]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + fprintf(stderr, "%s\n", buf); + OutputDebugStringA(buf); +} + +bool compute_fixup(const ItemV2& item, char* base, intptr_t* out) { + char* impSlot = base + item.sym; + const intptr_t real = *reinterpret_cast(impSlot); + char* target = base + item.target; + const int bits = static_cast(item.flags & 0xff); + + intptr_t reldata; + switch (bits) { + case 8: + reldata = *reinterpret_cast(target); + break; + case 16: + reldata = *reinterpret_cast(target); + break; + case 32: + reldata = *reinterpret_cast(target); + break; + case 64: + reldata = *reinterpret_cast(target); + break; + default: + report("unsupported %d-bit pseudo-relocation at RVA 0x%x", bits, item.target); + return false; + } + reldata -= reinterpret_cast(impSlot); + reldata += real; + + if (bits < 64) { + const intptr_t maxUnsigned = (intptr_t{1} << bits) - 1; + const intptr_t minSigned = -(intptr_t{1} << (bits - 1)); + if (reldata > maxUnsigned || reldata < minSigned) { + report("%d-bit data fixup at RVA 0x%x is out of range (delta %+lld)", bits, item.target, + static_cast(reldata)); + return false; + } + } + *out = reldata; + return true; +} + +} // namespace + +// lld refuses to emit runtime pseudo-relocs unless a function with exactly this (mingw CRT) +// name exists in the image. It is also our actual entry point. +extern "C" void _pei386_runtime_relocator() { + char* base = reinterpret_cast(&__ImageBase); + char* start = &__RUNTIME_PSEUDO_RELOC_LIST__; + char* end = &__RUNTIME_PSEUDO_RELOC_LIST_END__; + if (end - start < static_cast(sizeof(HdrV2))) { + return; // no data auto-imports in this mod + } + const HdrV2* hdr = reinterpret_cast(start); + if (hdr->magic1 != 0 || hdr->magic2 != 0 || hdr->version != 1) { + report("unexpected pseudo-relocation list format"); + g_relocsFailed = true; + return; + } + const ItemV2* items = reinterpret_cast(hdr + 1); + const ItemV2* itemsEnd = reinterpret_cast(end); + + // Validate everything before writing anything, so a bad fixup can't leave the image + // half-patched. + intptr_t scratch; + for (const ItemV2* it = items; it < itemsEnd; ++it) { + if (!compute_fixup(*it, base, &scratch)) { + g_relocsFailed = true; + return; + } + } + for (const ItemV2* it = items; it < itemsEnd; ++it) { + intptr_t reldata; + compute_fixup(*it, base, &reldata); + char* target = base + it->target; + const size_t len = static_cast(it->flags & 0xff) / 8; + DWORD old = 0; + if (!VirtualProtect(target, len, PAGE_EXECUTE_READWRITE, &old)) { + report("VirtualProtect failed at RVA 0x%x", it->target); + g_relocsFailed = true; + return; + } + std::memcpy(target, &reldata, len); + VirtualProtect(target, len, old, &old); + } +} + +using PVFV = void (*)(); +#pragma section(".CRT$XCB", read) +extern "C" __declspec(allocate(".CRT$XCB")) PVFV dusk_mod_pseudo_reloc_init = + _pei386_runtime_relocator; + +BOOL WINAPI DllMain(HINSTANCE, DWORD reason, LPVOID) { + if (reason == DLL_PROCESS_ATTACH && g_relocsFailed) { + return FALSE; + } + return TRUE; +} diff --git a/src/Z2AudioLib/Z2SoundObject.cpp b/src/Z2AudioLib/Z2SoundObject.cpp index 1c81be0f17..23e8171736 100644 --- a/src/Z2AudioLib/Z2SoundObject.cpp +++ b/src/Z2AudioLib/Z2SoundObject.cpp @@ -22,7 +22,7 @@ #include "os_report.h" Z2SoundObjBase::Z2SoundObjBase() -#if DEBUG +#if PARTIAL_DEBUG || DEBUG : JSULink(this) #endif { diff --git a/src/d/d_kankyo.cpp b/src/d/d_kankyo.cpp index 36949adc76..048b8b844d 100644 --- a/src/d/d_kankyo.cpp +++ b/src/d/d_kankyo.cpp @@ -37,6 +37,7 @@ #include "dusk/settings.h" #include "dusk/frame_interpolation.h" #include "dusk/game_clock.h" +static f32 timeScale = 1.0f; #endif static void GxXFog_set(); @@ -1799,6 +1800,9 @@ void dScnKy_env_light_c::setLight_palno_get(u8* prev_envr_id_p, u8* next_envr_id u8 psel_idx = 0; int i; int sp14 = 0; +#if TARGET_PC + const f32 timeScale = (pattern_ratio_p == &g_env_light.pat_ratio) ? ::timeScale : 1.0f; +#endif if (*init_timer_p != 0) { (*init_timer_p)++; @@ -2132,14 +2136,22 @@ void dScnKy_env_light_c::setLight_palno_get(u8* prev_envr_id_p, u8* next_envr_id if (g_env_light.mColPatMode == 0) { if (pselect_p->change_rate > 0.0f) { +#if TARGET_PC + *pattern_ratio_p += timeScale * ((1.0f / 30) / pselect_p->change_rate); +#else *pattern_ratio_p += (1.0f / 30) / pselect_p->change_rate; +#endif } // pattern change rate is faster in hyrule field if (strcmp(dComIfGp_getStartStageName(), "F_SP121") == 0 && *prev_pat_p == *next_pat_p) { +#if TARGET_PC + *pattern_ratio_p += timeScale * (1.0f / 15); +#else *pattern_ratio_p += (1.0f / 15); +#endif } if (*pattern_ratio_p >= 1.0f) { @@ -2332,6 +2344,10 @@ void dScnKy_env_light_c::setLight() { u8 next_pal_start_id; u8 prev_pal_end_id; u8 next_pal_end_id; +#if TARGET_PC + const f32 deltaTime = dusk::game_clock::consume_interval(this); + timeScale = deltaTime / dusk::game_clock::period_for_original_frames(1.0f); +#endif setLight_palno_get(&g_env_light.PrevCol, &g_env_light.UseCol, &g_env_light.wether_pat0, &g_env_light.wether_pat1, &prev_pal_start_id, &prev_pal_end_id, &next_pal_start_id, &next_pal_end_id, &color_ratio, &start_pat_pal_id, @@ -2517,8 +2533,6 @@ void dScnKy_env_light_c::setLight() { f32 sin = cM_ssin(S_fuwan_sin); #if TARGET_PC - const f32 deltaTime = dusk::game_clock::consume_interval(this); - const f32 timeScale = deltaTime / dusk::game_clock::period_for_original_frames(1.0f); S_fuwan_sin += (s16)((cM_rndF(2000.0f) + 500) * timeScale); #else S_fuwan_sin += (s16)cM_rndF(2000.0f) + 500; diff --git a/src/d/d_map.cpp b/src/d/d_map.cpp index 765facc335..404123f594 100644 --- a/src/d/d_map.cpp +++ b/src/d/d_map.cpp @@ -1640,7 +1640,7 @@ void dMap_c::_move(f32 i_centerX, f32 i_centerZ, int i_roomNo, f32 param_3) { mCenterX += IF_DUSK(dusk::getSettings().game.enableMirrorMode ? -mPackX :) mPackX; mCenterZ -= mPackZ; - mCenterX += field_0x64; + mCenterX += IF_DUSK(dusk::getSettings().game.enableMirrorMode ? -field_0x64 : ) field_0x64; mCenterZ += mPackPlusZ; } @@ -1649,6 +1649,8 @@ void dMap_c::_move(f32 i_centerX, f32 i_centerZ, int i_roomNo, f32 param_3) { { #if DEBUG field_0x64 = 33830.0f; +#elif TARGET_PC + field_0x64 = dusk::getSettings().game.enableMirrorMode ? 33830.0f : 0.0f; #else field_0x64 = 0.0f; #endif @@ -1657,6 +1659,9 @@ void dMap_c::_move(f32 i_centerX, f32 i_centerZ, int i_roomNo, f32 param_3) { f32 temp = (field_0x58 * (f32)(field_0x74 + 4)) * 0.5f; #if DEBUG mRightEdgePlus = -(((dMpath_c::getMinZ() - (-127103.67f)) - temp) / field_0x58); + +#elif TARGET_PC + mRightEdgePlus = dusk::getSettings().game.enableMirrorMode ? -(((dMpath_c::getMinX() - (-127103.67f)) - temp) / field_0x58) : 0.0f; #else mRightEdgePlus = 0.0f; #endif diff --git a/src/d/d_menu_dmap.cpp b/src/d/d_menu_dmap.cpp index 609d8ef6be..ec046959d6 100644 --- a/src/d/d_menu_dmap.cpp +++ b/src/d/d_menu_dmap.cpp @@ -914,6 +914,20 @@ void dMenu_DmapBg_c::dMapBgWide() { void dMenu_DmapBg_c::draw() { #if TARGET_PC dMapBgWide(); + + static bool prevMirror = false; // default state of panes is not mirrored + if(prevMirror != dusk::getSettings().game.enableMirrorMode) { + if(dusk::getSettings().game.enableMirrorMode) { + static_cast(mFloorScreen->search(MULTI_CHAR('rink')))->setMirror(J2DMirror_X); + static_cast(mBaseScreen->search(MULTI_CHAR('map000')))->setMirror(J2DMirror_X); + } + else { + static_cast(mFloorScreen->search(MULTI_CHAR('rink')))->setMirror(MIRROR0); + static_cast(mBaseScreen->search(MULTI_CHAR('map000')))->setMirror(MIRROR0); + } + + prevMirror = dusk::getSettings().game.enableMirrorMode; + } #endif u32 scissor_left; @@ -960,6 +974,15 @@ void dMenu_DmapBg_c::draw() { mpBackTexture->setAlpha(dVar17 * (field_0xdbc * field_0xd9c)); f32 local_28c = mpBackTexture->getBounds().i.x; + + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + CPaneMgr mgr; + Vec local_94 = mgr.getGlobalVtxCenter(mMapPane, true, 0); + local_28c = (local_94.x * 2.0f) - (local_28c + 0.5f * mpBackTexture->getWidth()) - 0.5f * mpBackTexture->getWidth(); + } + #endif + mpBackTexture->setBlackWhite(color_black, color_white); mpBackTexture->draw(local_28c, field_0xd94 + mpBackTexture->getBounds().i.y, mpBackTexture->getWidth(), mpBackTexture->getHeight(), @@ -1981,7 +2004,7 @@ void dMenu_Dmap_c::mapControl() { f32 temp_f28 = (var_f29 / 100.0f) * var_f31; f32 sp18 = temp_f28 * cM_ssin(stick_angle); f32 sp14 = temp_f28 * cM_scos(stick_angle); - mMapCtrl->setPlusZoomCenterX(sp18); + mMapCtrl->setPlusZoomCenterX(IF_DUSK(dusk::getSettings().game.enableMirrorMode ? -sp18 :) sp18); mMapCtrl->setPlusZoomCenterZ(sp14); } diff --git a/src/d/d_menu_dmap_map.cpp b/src/d/d_menu_dmap_map.cpp index c4029b766e..e3239d3678 100644 --- a/src/d/d_menu_dmap_map.cpp +++ b/src/d/d_menu_dmap_map.cpp @@ -359,14 +359,7 @@ f32 dMenu_StageMapCtrl_c::getPixelStageSizeZ() const { f32 dMenu_StageMapCtrl_c::getPixelCenterX() const { f32 var_f31 = dMpath_c::getCenterX(); - #if TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - return (1.0f / field_0xbc) * (field_0x9c + var_f31); - } - else return (1.0f / field_0xbc) * (field_0x9c - var_f31); - #else return (1.0f / field_0xbc) * (field_0x9c - var_f31); - #endif } f32 dMenu_StageMapCtrl_c::getPixelCenterZ() const { @@ -425,18 +418,7 @@ inline static f32 rightModeCnvPos(f32 param_0) { void dMenu_StageMapCtrl_c::cnvPosTo2Dpos(f32 param_0, f32 param_1, f32* param_2, f32* param_3) const { if (param_2 != NULL) { - #if TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - *param_2 = - (0.5f * field_0x94) + rightModeCnvPos((1.0f / field_0xbc) * (field_0x9c + param_0)); - } else { - *param_2 = - (0.5f * field_0x94) + rightModeCnvPos((1.0f / field_0xbc) * (param_0 - field_0x9c)); - } - #else *param_2 = (0.5f * field_0x94) + rightModeCnvPos((1.0f / field_0xbc) * (param_0 - field_0x9c)); - #endif - } if (param_3 != NULL) { @@ -933,8 +915,7 @@ void dMenu_StageMapCtrl_c::move() { void dMenu_DmapMapCtrl_c::draw() { if (field_0xef != 0) { - setPos(field_0xeb, field_0xec, - IF_DUSK(dusk::getSettings().game.enableMirrorMode ? -field_0x9c :) field_0x9c, + setPos(field_0xeb, field_0xec, field_0x9c, field_0xa0, field_0xbc, true, field_0xd8); } } diff --git a/src/d/d_menu_fmap.cpp b/src/d/d_menu_fmap.cpp index 813427f35e..64cd70cc0f 100644 --- a/src/d/d_menu_fmap.cpp +++ b/src/d/d_menu_fmap.cpp @@ -931,17 +931,8 @@ void dMenu_Fmap_c::region_map_proc() { mpDraw2DBack->regionMapMove(mpStick); int stage_no, room_no; -#if TARGET_PC - f32 arrow_pos_x = mpDraw2DBack->getArrowPos2DX(); - if (dusk::getSettings().game.enableMirrorMode) { - arrow_pos_x = mpDraw2DBack->getMirrorPosX(arrow_pos_x, 0.0f); - } - - f32 pos_x = arrow_pos_x - mDoGph_gInf_c::getMinXF() - mDoGph_gInf_c::getWidthF() * 0.5f; -#else f32 pos_x = mpDraw2DBack->getArrowPos2DX() - mDoGph_gInf_c::getMinXF() - mDoGph_gInf_c::getWidthF() * 0.5f; -#endif f32 pos_y = mpDraw2DBack->getArrowPos2DY() - mDoGph_gInf_c::getHeightF() * 0.5f; mpMenuFmapMap->getPointStagePathInnerNo(getNowFmapRegionData(), pos_x, pos_y, @@ -2486,12 +2477,6 @@ void dMenu_Fmap_c::portalWarpMapMove(STControl* i_stick) { f32 arrow_y = mpDraw2DBack->getArrowPos2DY(); u8 uVar6 = 0xff; -#if TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - arrow_x = mpDraw2DBack->getMirrorPosX(arrow_x, 0.0f); - } -#endif - for (int i = 0; i < portal_dat->mCount; i++) { if (portals[i].mRegionNo == mpDraw2DBack->getRegionCursor() + 1 @@ -2561,6 +2546,11 @@ void dMenu_Fmap_c::drawIcon(f32 param_0, bool param_1) { if (mProcess == PROC_PORTAL_DEMO1) { is_portal_demo1 = 1; } + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + angle = 0x10000 - angle; + } + #endif mpDraw2DBack->setIcon2DPos(0x11, stage_name, pos.x, pos.z, cM_sht2d(angle), is_portal_demo1, param_1); @@ -2649,6 +2639,11 @@ void dMenu_Fmap_c::drawPlayEnterIcon() { angle = dComIfGs_getPlayerFieldLastStayAngleY(); SAFE_STRCPY(stage_name, dComIfGs_getPlayerFieldLastStayName()); } + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + angle = 0x10000 - angle; + } + #endif mpDraw2DBack->setIcon2DPos(0x15, stage_name, pos.x, pos.z, cM_sht2d(angle), 0, false); } } diff --git a/src/d/d_menu_fmap2D.cpp b/src/d/d_menu_fmap2D.cpp index 6483fda7db..8b4e4b59b9 100644 --- a/src/d/d_menu_fmap2D.cpp +++ b/src/d/d_menu_fmap2D.cpp @@ -1013,11 +1013,6 @@ void dMenu_Fmap2DBack_c::allmap_move2(STControl* param_0) { f32 stickValue = param_0->getValueStick(); if (stickValue >= spC) { s16 angle = param_0->getAngleStick(); -#ifdef TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - angle = -angle; - } -#endif f32 local_68 = (mTexMaxX - mTexMinX); f32 zoomRate = local_68 / getAllMapZoomRate(); f32 sp24; @@ -1031,6 +1026,11 @@ void dMenu_Fmap2DBack_c::allmap_move2(STControl* param_0) { f32 delta_y = speed * cM_ssin(angle); f32 delta_x = speed * cM_scos(angle); +#ifdef TARGET_PC + if (dusk::getSettings().game.enableMirrorMode) { + delta_y = -delta_y; + } +#endif control_xpos = control_xpos + delta_y; control_ypos = control_ypos + delta_x; } @@ -1473,6 +1473,12 @@ void dMenu_Fmap2DBack_c::worldGridDraw() { f32 dVar8 = -mStageTransZ; calcAllMapPos2D(dVar9, dVar8, &local_74, &local_78); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + local_74 = getMirrorPosX(local_74, 0.0f); + } + #endif + J2DDrawLine(local_74, mDoGph_gInf_c::getMinYF(), local_74, mDoGph_gInf_c::getMinYF() + mDoGph_gInf_c::getHeightF(), JUtility::TColor(255, 255, 255, 255), 6); @@ -1481,6 +1487,11 @@ void dMenu_Fmap2DBack_c::worldGridDraw() { while (true) { calcAllMapPos2D(xPos, dVar8, &local_74, &local_78); if (local_74 >= getMapScissorAreaLX()) { + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + local_74 = getMirrorPosX(local_74, 0.0f); + } + #endif J2DDrawLine(local_74, mDoGph_gInf_c::getMinYF(), local_74, mDoGph_gInf_c::getMinYF() + mDoGph_gInf_c::getHeightF(), JUtility::TColor(255, 255, 255, 255), 6); @@ -1542,6 +1553,12 @@ void dMenu_Fmap2DBack_c::regionGridDraw() { f32 dVar8 = mRegionOriginZ[mRegionCursor] - mStageTransZ; calcAllMapPos2D(dVar9, dVar8, &local_74, &local_78); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + local_74 = getMirrorPosX(local_74, 0.0f); + } + #endif + J2DDrawLine(local_74, mDoGph_gInf_c::getMinYF(), local_74, mDoGph_gInf_c::getMinYF() + mDoGph_gInf_c::getHeightF(), JUtility::TColor(180, 0, 0, 255), 6); @@ -1550,6 +1567,12 @@ void dMenu_Fmap2DBack_c::regionGridDraw() { while (true) { calcAllMapPos2D(xPos, dVar8, &local_74, &local_78); if (local_74 >= getMapScissorAreaLX()) { + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + local_74 = getMirrorPosX(local_74, 0.0f); + } + #endif + J2DDrawLine(local_74, mDoGph_gInf_c::getMinYF(), local_74, mDoGph_gInf_c::getMinYF() + mDoGph_gInf_c::getHeightF(), JUtility::TColor(180, 0, 0, 255), 6); @@ -1612,6 +1635,12 @@ void dMenu_Fmap2DBack_c::worldOriginDraw() { f32 local_44, local_48; calcAllMapPos2D(-mStageTransX, -mStageTransZ, &local_44, &local_48); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + local_44 = getMirrorPosX(local_44, 0.0f); + } + #endif + J2DDrawLine(mDoGph_gInf_c::getMinXF(), local_48 - local_44 + mDoGph_gInf_c::getMinXF(), mDoGph_gInf_c::getMinXF() + mDoGph_gInf_c::getWidthF(), local_48 - local_44 + (mDoGph_gInf_c::getMinXF() + mDoGph_gInf_c::getWidthF()), @@ -1646,6 +1675,13 @@ void dMenu_Fmap2DBack_c::scrollAreaDraw() { calcAllMapPos2D(x_min - mStageTransX, z_min - mStageTransZ, &local_4c, &local_50); calcAllMapPos2D(x_max - mStageTransX, z_max - mStageTransZ, &local_54, &local_58); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + local_4c = getMirrorPosX(local_4c, 0.0f); + local_54 = getMirrorPosX(local_54, 0.0f); + } + #endif + J2DDrawLine(local_4c, local_50, local_4c, local_58, JUtility::TColor(255, 255, 255, 255), 6); J2DDrawLine(local_54, local_50, local_54, local_58, @@ -1666,6 +1702,11 @@ void dMenu_Fmap2DBack_c::regionOriginDraw() { f32 center_x, center_y; calcAllMapPos2D(mRegionOriginX[i] - mStageTransX, mRegionOriginZ[i] - mStageTransZ, ¢er_x, ¢er_y); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + center_x = getMirrorPosX(center_x - 3.0f, 3.0f); + } + #endif J2DFillBox(center_x - 3.0f, center_y - 3.0f, 6.0f, 6.0f, JUtility::TColor(255, 0, 0, 255)); } } @@ -1683,6 +1724,11 @@ void dMenu_Fmap2DBack_c::stageOriginDraw() { f32 v1 = mRegionOriginX[mRegionCursor] + stage_data[i].mOffsetX - mStageTransX; f32 v2 = mRegionOriginZ[mRegionCursor] + stage_data[i].mOffsetZ - mStageTransZ; calcAllMapPos2D(v1, v2, ¢er_x, ¢er_y); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + center_x = getMirrorPosX(center_x - 3.0f, 3.0f); + } + #endif J2DFillBox(center_x - 3.0f, center_y - 3.0f, 6.0f, 6.0f, JUtility::TColor(0, 0, 255, 255)); } @@ -1915,11 +1961,6 @@ void dMenu_Fmap2DBack_c::regionMapMove(STControl* i_stick) { f32 stick_value = i_stick->getValueStick(); if (stick_value >= slow_bound) { s16 angle = i_stick->getAngleStick(); - #ifdef TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - angle = -angle; - } - #endif f32 local_68 = mTexMaxX - mTexMinX; f32 spot_zoom = getSpotMapZoomRate(); f32 region_zoom = getRegionMapZoomRate(mRegionCursor); @@ -1934,7 +1975,7 @@ void dMenu_Fmap2DBack_c::regionMapMove(STControl* i_stick) { f32 speed = base_speed / 100.0f * local_78; f32 speed_y = speed * cM_ssin(angle); f32 speed_x = speed * cM_scos(angle); - control_xpos += speed_y; + control_xpos += IF_DUSK(dusk::getSettings().game.enableMirrorMode ? -speed_y :) speed_y; control_ypos += speed_x; } } @@ -1990,11 +2031,6 @@ void dMenu_Fmap2DBack_c::stageMapMove(STControl* i_stick, u8 param_1, bool param if (stick_value >= slow_bound && param_2 && field_0x1238 != 2) { bVar6 = true; s16 angle = i_stick->getAngleStick(); -#if TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - angle = -angle; - } -#endif f32 local_68 = mTexMaxX - mTexMinX; f32 spot_zoom = getSpotMapZoomRate(); f32 region_zoom = getRegionMapZoomRate(mRegionCursor); @@ -2009,7 +2045,7 @@ void dMenu_Fmap2DBack_c::stageMapMove(STControl* i_stick, u8 param_1, bool param f32 speed = base_speed / 100.0f * local_78; f32 speed_x = speed * cM_ssin(angle); f32 speed_z = speed * cM_scos(angle); - mStageTransX += speed_x; + mStageTransX += IF_DUSK(dusk::getSettings().game.enableMirrorMode ? -speed_x :) speed_x; mStageTransZ += speed_z; } else if (!param_2) { return; @@ -2103,6 +2139,11 @@ void dMenu_Fmap2DBack_c::drawDebugStageArea() { if (stage_no >= 0) { f32 v = i + mDoGph_gInf_c::getMinXF(); f32 v2 = j; + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + v = getMirrorPosX(v - 3.0f, 3.0f); + } + #endif J2DFillBox(v - 3.0f, v2 - 3.0f, 6.0f, 6.0f, colors[stage_no % 6]); } } @@ -2138,6 +2179,11 @@ void dMenu_Fmap2DBack_c::drawDebugRegionArea() { mRegionMapSizeX[region] * mZoom, mRegionMapSizeY[region] * mZoom, mpAreaTex[region]->getTexture(0)->getTexInfo()); if (u) { + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + pos_x = getMirrorPosX(pos_x - 3.0f, 3.0f); + } + #endif J2DFillBox(pos_x - 3.0f, pos_y - 3.0f, 6.0f, 6.0f, colors[region]); break; } diff --git a/src/d/d_menu_map_common.cpp b/src/d/d_menu_map_common.cpp index 5ae820931a..c18f5825ff 100644 --- a/src/d/d_menu_map_common.cpp +++ b/src/d/d_menu_map_common.cpp @@ -394,21 +394,8 @@ void dMenuMapCommon_c::drawIcon(f32 i_posX, f32 i_posY, f32 param_3, f32 param_4 icon_size_y *= _c7c; } -#if TARGET_PC - f32 rotation = mIconInfo[info_idx].rotation; - if (dusk::getSettings().game.enableMirrorMode && - (mIconInfo[info_idx].icon_no == ICON_LINK_e || - mIconInfo[info_idx].icon_no == ICON_LINK_ENTER_e)) - { - rotation = -rotation; - } - - mPictures[mIconInfo[info_idx].icon_no]->rotate(icon_size_x / 2, icon_size_y / 2, ROTATE_Z, - rotation); -#else mPictures[mIconInfo[info_idx].icon_no]->rotate(icon_size_x / 2, icon_size_y / 2, ROTATE_Z, mIconInfo[info_idx].rotation); -#endif if (mIconInfo[info_idx].icon_no == ICON_LIGHT_DROP_e) { mPictures[mIconInfo[info_idx].icon_no]->setAlpha((180.0f * _c80) / 255.0f); @@ -423,10 +410,15 @@ void dMenuMapCommon_c::drawIcon(f32 i_posX, f32 i_posY, f32 param_3, f32 param_4 } f32 pos_x = i_posX + (icon_pos_x - (icon_size_x / 2)); + bool r4 = false; #if TARGET_PC if (dusk::getSettings().game.enableMirrorMode) { pos_x = getMirrorCenterPosX(i_posX + (icon_pos_x - (icon_size_x / 2)), icon_size_x / 2); } + + if(mIconInfo[info_idx].icon_no == ICON_GOLD_WOLF_e) { + r4 = true; + } #endif mPictures[mIconInfo[info_idx].icon_no]->draw(pos_x, (i_posY + (icon_pos_y - icon_size_y / 2)), @@ -435,7 +427,7 @@ void dMenuMapCommon_c::drawIcon(f32 i_posX, f32 i_posY, f32 param_3, f32 param_4 if (mIconInfo[info_idx].icon_no == ICON_LIGHT_DROP_e) { mLightDropPic->draw((pos_x + (icon_size_x / 2)) - (var_f29 / 2), ((icon_size_y / 2) + (i_posY + (icon_pos_y - icon_size_y / 2))) - (var_f28 / 2), - var_f29, var_f28, false, false, false); + var_f29, var_f28, r4, false, false); } } } @@ -869,4 +861,4 @@ void dMenuMapCommon_c::getFmapPoeCount(const int regionNo, int& nowCount, int& t } } } -#endif \ No newline at end of file +#endif diff --git a/src/d/d_meter2.cpp b/src/d/d_meter2.cpp index e0855461d4..8d396e0185 100644 --- a/src/d/d_meter2.cpp +++ b/src/d/d_meter2.cpp @@ -27,6 +27,15 @@ #if TARGET_PC #include "dusk/memory.h" #include "dusk/settings.h" + +namespace { + +// Reads the user HUD scale setting, clamped to a safe range. +f32 dGetUserHudScale() { + return std::clamp(dusk::getSettings().game.hudScale.getValue(), 0.5f, 2.0f); +} + +} // namespace #endif int dMeter2_c::_create() { @@ -669,9 +678,7 @@ void dMeter2_c::moveLife() { } #if TARGET_PC - const f32 lifeGaugeScale = - g_drawHIO.mLifeParentScale * - std::clamp(dusk::getSettings().game.hudScale.getValue(), 0.5f, 2.0f); + const f32 lifeGaugeScale = g_drawHIO.mLifeParentScale * dGetUserHudScale(); #else const f32 lifeGaugeScale = g_drawHIO.mLifeParentScale; #endif @@ -1108,8 +1115,13 @@ void dMeter2_c::moveRupee() { } } - if (mRupeeKeyScale != g_drawHIO.mRupeeKeyScale) { - mRupeeKeyScale = g_drawHIO.mRupeeKeyScale; +#if TARGET_PC + const f32 rupeeKeyScale = g_drawHIO.mRupeeKeyScale * dGetUserHudScale(); +#else + const f32 rupeeKeyScale = g_drawHIO.mRupeeKeyScale; +#endif + if (mRupeeKeyScale != rupeeKeyScale) { + mRupeeKeyScale = rupeeKeyScale; draw_rupee = true; } @@ -1207,8 +1219,13 @@ void dMeter2_c::moveKey() { } } - if (mKeyScale != g_drawHIO.mKeyScale) { - mKeyScale = g_drawHIO.mKeyScale; +#if TARGET_PC + const f32 keyScale = g_drawHIO.mKeyScale * dGetUserHudScale(); +#else + const f32 keyScale = g_drawHIO.mKeyScale; +#endif + if (mKeyScale != keyScale) { + mKeyScale = keyScale; draw_key = true; } @@ -2139,8 +2156,13 @@ void dMeter2_c::moveButtonCross() { draw_cross = true; } - if (mButtonCrossScale != g_drawHIO.mButtonCrossScale) { - mButtonCrossScale = g_drawHIO.mButtonCrossScale; +#if TARGET_PC + const f32 buttonCrossScale = g_drawHIO.mButtonCrossScale * dGetUserHudScale(); +#else + const f32 buttonCrossScale = g_drawHIO.mButtonCrossScale; +#endif + if (mButtonCrossScale != buttonCrossScale) { + mButtonCrossScale = buttonCrossScale; draw_cross = true; } diff --git a/src/d/d_meter2_draw.cpp b/src/d/d_meter2_draw.cpp index 7be314d364..590a8d2edc 100644 --- a/src/d/d_meter2_draw.cpp +++ b/src/d/d_meter2_draw.cpp @@ -58,6 +58,7 @@ void dAnchorHudScale(CPaneMgr* i_pane, HudCorner i_corner, f32* io_x, f32* io_y, } // namespace #endif + dMeter2Draw_c::dMeter2Draw_c(JKRExpHeap* mp_heap) { OS_REPORT("enter dMeter2Draw_c::dMeter2Draw_c(JKRExpHeap *mp_heap)\n"); @@ -2813,7 +2814,7 @@ void dMeter2Draw_c::drawButtonCross(f32 i_posX, f32 i_posY) { #if TARGET_PC f32 buttonCrossPosX = i_posX; f32 buttonCrossPosY = i_posY; - dAnchorHudScale(mpButtonCrossParent, HudCorner::TopLeft, &buttonCrossPosX, &buttonCrossPosY); + dAnchorHudScale(mpButtonCrossParent, HudCorner::BottomLeft, &buttonCrossPosX, &buttonCrossPosY); mpButtonCrossParent->paneTrans(buttonCrossPosX, buttonCrossPosY); #else mpButtonCrossParent->paneTrans(i_posX, i_posY); diff --git a/src/d/d_meter_map.cpp b/src/d/d_meter_map.cpp index bffdbb8931..d526d7877f 100644 --- a/src/d/d_meter_map.cpp +++ b/src/d/d_meter_map.cpp @@ -24,6 +24,15 @@ #if TARGET_PC #include "dusk/action_bindings.h" + +namespace { + +// Reads the user HUD scale setting, clamped to a safe range. +f32 dGetUserHudScale() { + return std::clamp(dusk::getSettings().game.hudScale.getValue(), 0.5f, 2.0f); +} + +} // namespace #endif #if (PLATFORM_WII || PLATFORM_SHIELD) @@ -326,7 +335,7 @@ f32 dMeterMap_c::getMapDispEdgeTop() { mMap->getTexelPerCm() * (mMap->getPackZ() + -mMap->getPackPlusZ()) - mMap->getTopEdgePlus(); } - f32 rv = getMapDispEdgeBottomY_Layout() - tmp; + f32 rv = getMapDispEdgeBottomY_Layout() - tmp IF_DUSK(* dGetUserHudScale()); return rv; } @@ -637,8 +646,7 @@ void dMeterMap_c::draw() { #if TARGET_PC // Scale the minimap with the user HUD scale and shift down so its bottom-left // corner stays anchored to the same screen position as at scale 1.0. - const f32 userHudScale = - std::clamp(dusk::getSettings().game.hudScale.getValue(), 0.5f, 2.0f); + const f32 userHudScale = dGetUserHudScale(); const f32 scaledSizeX = sizeX * userHudScale; const f32 scaledSizeY = sizeY * userHudScale; const f32 mapBottomShift = sizeY - scaledSizeY; diff --git a/src/d/d_msg_object.cpp b/src/d/d_msg_object.cpp index e21c2efd85..8227ddd140 100644 --- a/src/d/d_msg_object.cpp +++ b/src/d/d_msg_object.cpp @@ -434,17 +434,6 @@ static void dummyStrings() { dMsgObject_HIO_c g_MsgObject_HIO_c; int dMsgObject_c::_execute() { -// TODO: enabling wii message overrides fixes direction text, but gives wrong item control text -/*#if TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - // enable wii message index override - g_MsgObject_HIO_c.mMessageDisplay = 1; - } else if (!dusk::getSettings().game.enableMirrorMode && g_MsgObject_HIO_c.mMessageDisplay == 1) { - g_MsgObject_HIO_c.mMessageDisplay = 0; - } -#endif*/ - - field_0x4c7 = 0; if (mpTalkHeap != NULL) { field_0x148 = mDoExt_setCurrentHeap(mpTalkHeap); @@ -661,8 +650,21 @@ static const MirrorMsgOverride mirrorMsgOverrides[] = { {0x17e2, 0x3ef2}, {0x1dae, 0x44be}, {0x14ca, 0x3bda}, - {0x470, 0x493}, + {0x470, 0x493}, {0x473, 0x492}, + {0x1f41, 0x4651}, + {0x1f42, 0x4652}, + {0x0847, 0x0870}, + {0x0d5c, 0x0d65}, + {0x0a97, 0x0a98}, + {0x0327, 0x12ba}, + {0x0328, 0x12bb}, + {0x1534, 0x3c44}, + {0x1536, 0x3c46}, + {0x1557, 0x3c67}, + {0x1b88, 0x4298}, + {0x14c8, 0x3bd8}, + {0x151b, 0x3c2b}, }; static u32 getMirrorMsgOverride(u32 msgId) { diff --git a/src/d/d_s_logo.cpp b/src/d/d_s_logo.cpp index 570dfac577..d338775182 100644 --- a/src/d/d_s_logo.cpp +++ b/src/d/d_s_logo.cpp @@ -23,8 +23,12 @@ #include "m_Do/m_Do_main.h" #include "JSystem/JUtility/JUTConsole.h" +#ifdef TARGET_PC #include "dusk/logging.h" #include "dusk/version.hpp" +#include "dusk/main.h" +#include "m_Do/m_Do_MemCard.h" +#endif #if !PLATFORM_GCN #include @@ -757,7 +761,63 @@ void dScnLogo_c::nextSceneChange() { if (!mDoRst::isReset()) { if (!isOpeningCut()) { - dComIfG_changeOpeningScene(this, fpcNm_OPENING_SCENE_e); +#ifdef TARGET_PC + // If we are requesting a save from the command line, load it here and set the scene to play instead of loading the LOGO SCENE + if (dusk::SaveRequested >= 1 && dusk::SaveRequested <= 3) { + u8 buf[SAVEDATA_SIZE * 3]; + mDoMemCd_Load(); + uint8_t status; + do { + status = mDoMemCd_LoadSync(buf, sizeof(buf), 0); + // Wait until the card is loaded + } while (status == 0); + + if (status == 1) { + dComIfGs_setCardToMemory(buf, dusk::SaveRequested - 1); + } else { + dComIfGs_init(); + } + + dComIfGs_setNoFile(dusk::SaveRequested); + dComIfGs_setDataNum(dusk::SaveRequested-1); + + dComIfGs_gameStart(); + + fopScnM_ChangeReq(this, fpcNm_PLAY_SCENE_e, 0, 30); + + dKy_clear_game_init(); + dComIfGs_resetDan(); + dComIfGs_setRestartRoomParam(0); + + DuskLog.info("Loaded Save From Slot {}",dusk::SaveRequested); + dusk::SaveRequested = 0xff; + } else if (dusk::SaveRequested == 0xff) { + // This indicates that the save has loaded, but we are waiting for the scene + // manager to change to play + } else if (dusk::StageRequested.set) { + // Do nothing if we need to request a stage to load later in the function + } else{ +#endif + dComIfG_changeOpeningScene(this, fpcNm_OPENING_SCENE_e); +#ifdef TARGET_PC + } + + if (dusk::StageRequested.set) { + // If we aren't loading a save, initialize a blank save file and request the correct scene to load + if (dusk::SaveRequested == 0) { + dComIfGs_init(); + + fopScnM_ChangeReq(this, fpcNm_PLAY_SCENE_e, 0, 30); + dusk::SaveRequested = 0xff; //Skip requesting the scene from above + } + + // Use both to force-set start stage + dComIfGp_setNextStage(dusk::StageRequested.stage.c_str(), dusk::StageRequested.point, dusk::StageRequested.room, dusk::StageRequested.layer); + g_dComIfG_gameInfo.play.mNextStage.getStartStage()->set(dusk::StageRequested.stage.c_str(), dusk::StageRequested.room, dusk::StageRequested.point, dusk::StageRequested.layer); + + dusk::StageRequested.set = false; // Setting the stage should only happen once + } +#endif } else { #if DEBUG fopScnM_ChangeReq(this, fpcNm_MENU_SCENE_e, 0, 30); diff --git a/src/dusk/config.cpp b/src/dusk/config.cpp index 43d3f19dad..a2b79eaac5 100644 --- a/src/dusk/config.cpp +++ b/src/dusk/config.cpp @@ -7,6 +7,7 @@ #include "dusk/io.hpp" #include "dusk/settings.h" +#include #include #include #include @@ -15,77 +16,90 @@ #include #include #include +#include #include "dusk/action_bindings.h" +#include "dusk/logging.h" #include "dusk/main.h" -using namespace dusk::config; - +namespace dusk::config { +namespace { constexpr auto ConfigFileName = "config.json"; using json = nlohmann::json; aurora::Module DuskConfigLog("dusk::config"); -static absl::flat_hash_map RegisteredConfigVars; -static bool RegistrationDone = false; +absl::flat_hash_map RegisteredConfigVars; +absl::flat_hash_map UnregisteredConfigVars; +absl::flat_hash_map UnregisteredConfigVarOverrides; -static std::optional parse_control_anchor(std::string_view value) { +struct ChangeSubscription { + Subscription token; + ChangeCallback callback; +}; +absl::flat_hash_map > s_changeSubscriptions; +absl::flat_hash_map s_changeTokenNames; +Subscription s_nextChangeToken = 1; +// Names currently being notified; guards against a callback re-notifying its own CVar. +std::vector s_activeChangeNotifications; + +std::optional parse_control_anchor(std::string_view value) { if (value == "none") { - return dusk::ui::ControlAnchor::None; + return ui::ControlAnchor::None; } if (value == "top") { - return dusk::ui::ControlAnchor::Top; + return ui::ControlAnchor::Top; } if (value == "left") { - return dusk::ui::ControlAnchor::Left; + return ui::ControlAnchor::Left; } if (value == "bottom") { - return dusk::ui::ControlAnchor::Bottom; + return ui::ControlAnchor::Bottom; } if (value == "right") { - return dusk::ui::ControlAnchor::Right; + return ui::ControlAnchor::Right; } if (value == "topLeft") { - return dusk::ui::ControlAnchor::TopLeft; + return ui::ControlAnchor::TopLeft; } if (value == "topRight") { - return dusk::ui::ControlAnchor::TopRight; + return ui::ControlAnchor::TopRight; } if (value == "bottomLeft") { - return dusk::ui::ControlAnchor::BottomLeft; + return ui::ControlAnchor::BottomLeft; } if (value == "bottomRight") { - return dusk::ui::ControlAnchor::BottomRight; + return ui::ControlAnchor::BottomRight; } return std::nullopt; } -static const char* control_anchor_value(dusk::ui::ControlAnchor anchor) { +const char* control_anchor_value(ui::ControlAnchor anchor) { switch (anchor) { - case dusk::ui::ControlAnchor::None: + case ui::ControlAnchor::None: return "none"; - case dusk::ui::ControlAnchor::Top: + case ui::ControlAnchor::Top: return "top"; - case dusk::ui::ControlAnchor::Left: + case ui::ControlAnchor::Left: return "left"; - case dusk::ui::ControlAnchor::Bottom: + case ui::ControlAnchor::Bottom: return "bottom"; - case dusk::ui::ControlAnchor::Right: + case ui::ControlAnchor::Right: return "right"; - case dusk::ui::ControlAnchor::TopLeft: + case ui::ControlAnchor::TopLeft: return "topLeft"; - case dusk::ui::ControlAnchor::TopRight: + case ui::ControlAnchor::TopRight: return "topRight"; - case dusk::ui::ControlAnchor::BottomLeft: + case ui::ControlAnchor::BottomLeft: return "bottomLeft"; - case dusk::ui::ControlAnchor::BottomRight: + case ui::ControlAnchor::BottomRight: return "bottomRight"; } return "none"; } -static std::optional json_finite_float(const json& object, const char* key) { +std::optional json_finite_float(const json& object, const char* key) { const auto iter = object.find(key); if (iter == object.end() || !iter->is_number()) { return std::nullopt; @@ -99,7 +113,7 @@ static std::optional json_finite_float(const json& object, const char* ke return value; } -static std::optional parse_control_props(const json& value) { +std::optional parse_control_props(const json& value) { if (!value.is_object()) { return std::nullopt; } @@ -118,7 +132,7 @@ static std::optional parse_control_props(const json& val if (!anchor || *w <= 0.0f || *h <= 0.0f || *scale <= 0.0f) { return std::nullopt; } - return dusk::ui::ControlProps{ + return ui::ControlProps{ .x = *x, .y = *y, .w = *w, @@ -128,17 +142,17 @@ static std::optional parse_control_props(const json& val }; } -static std::filesystem::path GetConfigJsonPath() { - return dusk::ConfigPath / ConfigFileName; +std::filesystem::path GetConfigJsonPath() { + return ConfigPath / ConfigFileName; } -static std::filesystem::path GetTempConfigJsonPath(const std::filesystem::path& configJsonPath) { +std::filesystem::path GetTempConfigJsonPath(const std::filesystem::path& configJsonPath) { auto tempPath = configJsonPath; tempPath.replace_filename(fmt::format(".{}.tmp", configJsonPath.filename().string())); return tempPath; } -static void ReplaceFile(const std::filesystem::path& source, const std::filesystem::path& target) { +void ReplaceFile(const std::filesystem::path& source, const std::filesystem::path& target) { std::error_code ec; std::filesystem::rename(source, target, ec); if (ec) { @@ -148,19 +162,8 @@ static void ReplaceFile(const std::filesystem::path& source, const std::filesyst } } -ConfigVarBase::ConfigVarBase(const char* name, const ConfigImplBase* impl) - : name(name), registered(false), layer(ConfigVarLayer::Default), impl(impl) {} - -const char* ConfigVarBase::getName() const noexcept { - return name; -} - -const ConfigImplBase* ConfigVarBase::getImpl() const noexcept { - return impl; -} - template -static T sanitizeEnumValue(const ConfigVar& cVar, T value) { +T sanitizeEnumValue(const ConfigVar& cVar, T value) { if constexpr (std::is_enum_v) { using Underlying = std::underlying_type_t; const Underlying raw = static_cast(value); @@ -174,6 +177,83 @@ static T sanitizeEnumValue(const ConfigVar& cVar, T value) { return value; } +template +requires std::is_integral_v&& std::is_signed_v T parse_arg_value( + const ConfigVar&, const std::string_view stringValue) { + const std::string str(stringValue); + const auto result = std::stoll(str); + if (result >= std::numeric_limits::min() && result <= std::numeric_limits::max()) { + return static_cast(result); + } + throw std::out_of_range("Value is too large"); +} + +template +requires std::is_integral_v&& std::is_unsigned_v T parse_arg_value( + const ConfigVar&, const std::string_view stringValue) { + const std::string str(stringValue); + const auto result = std::stoull(str); + if (result <= std::numeric_limits::max()) { + return static_cast(result); + } + throw std::out_of_range("Value is too large"); +} + +f32 parse_arg_value(const ConfigVar&, const std::string_view stringValue) { + const std::string str(stringValue); + return std::stof(str); +} + +f64 parse_arg_value(const ConfigVar&, const std::string_view stringValue) { + const std::string str(stringValue); + return std::stod(str); +} + +std::string parse_arg_value(const ConfigVar&, const std::string_view stringValue) { + return std::string(stringValue); +} + +template +requires std::is_enum_v T parse_arg_value( + const ConfigVar& cVar, const std::string_view stringValue) { + using Underlying = std::underlying_type_t; + const std::string str(stringValue); + + if constexpr (std::is_signed_v) { + const auto result = std::stoll(str); + if (result >= std::numeric_limits::min() && + result <= std::numeric_limits::max()) + { + return sanitizeEnumValue(cVar, static_cast(result)); + } + throw std::out_of_range("Value is too large"); + } else { + const auto result = std::stoull(str); + if (result <= std::numeric_limits::max()) { + return sanitizeEnumValue(cVar, static_cast(result)); + } + throw std::out_of_range("Value is too large"); + } +} +} // namespace + +ConfigVarBase::ConfigVarBase(std::string name, const ConfigImplBase* impl) + : name(std::move(name)), registered(false), layer(ConfigVarLayer::Default), impl(impl) {} + +const char* ConfigVarBase::getName() const noexcept { + return name.c_str(); +} + +const ConfigImplBase* ConfigVarBase::getImpl() const noexcept { + return impl; +} + +ConfigVarBase::~ConfigVarBase() { + if (registered) { + DuskLog.fatal("CVar '{}' was destroyed while still registered!", name); + } +} + template void ConfigImpl::loadFromJson(ConfigVar& cVar, const json& jsonValue) { if constexpr (std::is_enum_v) { @@ -187,12 +267,12 @@ void ConfigImpl::loadFromJson(ConfigVar& cVar, const json& jsonValue) { const Underlying raw = b ? static_cast(1) : static_cast(0); - cVar.setValue(sanitizeEnumValue(cVar, static_cast(raw)), false); + cVar.load_value(sanitizeEnumValue(cVar, static_cast(raw))); return; } } - cVar.setValue(sanitizeEnumValue(cVar, jsonValue.get()), false); + cVar.load_value(sanitizeEnumValue(cVar, jsonValue.get())); } template @@ -200,74 +280,9 @@ nlohmann::json ConfigImpl::dumpToJson(const ConfigVar& cVar) { return cVar.getValueForSave(); } -template -requires std::is_integral_v&& std::is_signed_v static void loadFromArgImpl( - ConfigVar& cVar, const std::string_view stringValue) { - const std::string str(stringValue); - const auto result = std::stoll(str); - if (result >= std::numeric_limits::min() && result <= std::numeric_limits::max()) { - cVar.setOverrideValue(result); - } else { - throw std::out_of_range("Value is too large"); - } -} - -template -requires std::is_integral_v&& std::is_unsigned_v static void loadFromArgImpl( - ConfigVar& cVar, const std::string_view stringValue) { - const std::string str(stringValue); - const auto result = std::stoull(str); - if (result <= std::numeric_limits::max()) { - cVar.setOverrideValue(result); - } else { - throw std::out_of_range("Value is too large"); - } -} - -static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { - const std::string str(stringValue); - const auto result = std::stof(str); - cVar.setOverrideValue(result); -} - -static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { - const std::string str(stringValue); - const auto result = std::stod(str); - cVar.setOverrideValue(result); -} - -static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { - cVar.setOverrideValue(std::string(stringValue)); -} - -template -requires std::is_enum_v static void loadFromArgImpl( - ConfigVar& cVar, const std::string_view stringValue) { - using Underlying = std::underlying_type_t; - const std::string str(stringValue); - - if constexpr (std::is_signed_v) { - const auto result = std::stoll(str); - if (result >= std::numeric_limits::min() && - result <= std::numeric_limits::max()) - { - cVar.setOverrideValue(sanitizeEnumValue(cVar, static_cast(result))); - } else { - throw std::out_of_range("Value is too large"); - } - } else { - const auto result = std::stoull(str); - if (result <= std::numeric_limits::max()) { - cVar.setOverrideValue(sanitizeEnumValue(cVar, static_cast(result))); - } else { - throw std::out_of_range("Value is too large"); - } - } -} - template void ConfigImpl::loadFromArg(ConfigVar& cVar, const std::string_view stringValue) { - loadFromArgImpl(cVar, stringValue); + cVar.load_override_value(parse_arg_value(cVar, stringValue)); } template <> @@ -275,18 +290,16 @@ void ConfigImpl::loadFromArg(ConfigVar& cVar, const std::string_view if (stringValue == "1" || stringValue == "TRUE" || stringValue == "true" || stringValue == "True") { - cVar.setOverrideValue(true); + cVar.load_override_value(true); } else if (stringValue == "0" || stringValue == "FALSE" || stringValue == "false" || stringValue == "False") { - cVar.setOverrideValue(false); + cVar.load_override_value(false); } else { throw InvalidConfigError("Value cannot be parsed as boolean"); } } -// My IDE is convinced this namespace is necessary. It shouldn't be AFAICT? -namespace dusk::config { template class ConfigImpl; template class ConfigImpl; template class ConfigImpl; @@ -299,10 +312,10 @@ template class ConfigImpl; template class ConfigImpl; template class ConfigImpl; template class ConfigImpl; -template class ConfigImpl; -template class ConfigImpl; -template class ConfigImpl; -template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; template <> void ConfigImpl::loadFromJson( @@ -312,11 +325,11 @@ void ConfigImpl::loadFromJson( const FrameInterpMode mode = b ? FrameInterpMode::Unlimited : FrameInterpMode::Off; - cVar.setValue(sanitizeEnumValue(cVar, mode), false); + cVar.load_value(sanitizeEnumValue(cVar, mode)); return; } - cVar.setValue(sanitizeEnumValue(cVar, jsonValue.get()), false); + cVar.load_value(sanitizeEnumValue(cVar, jsonValue.get())); } template <> @@ -347,7 +360,7 @@ void ConfigImpl::loadFromJson( } } - cVar.setValue(std::move(layout), false); + cVar.load_value(std::move(layout)); } template <> @@ -377,26 +390,59 @@ nlohmann::json ConfigImpl::dumpToJson(const ConfigVar; -template class ConfigImpl; -template class ConfigImpl; -template class ConfigImpl; -template class ConfigImpl; -template class ConfigImpl; -} // namespace dusk::config - -void dusk::config::Register(ConfigVarBase& configVar) { - const auto& name = configVar.getName(); - if (RegistrationDone) { - DuskConfigLog.fatal("Tried to register CVar {} after registrations closed!", name); - } +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +void Register(ConfigVarBase& configVar) { + const std::string_view name = configVar.getName(); if (RegisteredConfigVars.contains(name)) { DuskConfigLog.fatal("Tried to register CVar {} twice!", name); } RegisteredConfigVars[name] = &configVar; configVar.markRegistered(); + + const auto unregPair = UnregisteredConfigVars.find(name); + if (unregPair != UnregisteredConfigVars.end()) { + const auto value = std::move(unregPair->second); + UnregisteredConfigVars.erase(name); + + try { + configVar.getImpl()->loadFromJson(configVar, value); + } catch (std::exception& e) { + DuskConfigLog.error("Failed to load key '{}' from config value: {}", name, e.what()); + } + } + + const auto overridePair = UnregisteredConfigVarOverrides.find(name); + if (overridePair != UnregisteredConfigVarOverrides.end()) { + try { + configVar.getImpl()->loadFromArg(configVar, overridePair->second); + } catch (std::exception& e) { + DuskConfigLog.error("Failed to load key '{}' from override arg: {}", name, e.what()); + } + } +} + +void unregister(ConfigVarBase& configVar) { + const std::string_view name = configVar.getName(); + const auto it = RegisteredConfigVars.find(name); + if (it == RegisteredConfigVars.end() || it->second != &configVar) { + DuskConfigLog.fatal("Tried to unregister CVar '{}' that is not registered!", name); + } + + const auto layer = configVar.getLayer(); + if (layer == ConfigVarLayer::Value || layer == ConfigVarLayer::Speedrun) { + UnregisteredConfigVars.insert_or_assign( + std::string{name}, configVar.getImpl()->dumpToJson(configVar)); + } + + RegisteredConfigVars.erase(it); + configVar.unmarkRegistered(); } void ConfigVarBase::markRegistered() { @@ -406,21 +452,24 @@ void ConfigVarBase::markRegistered() { registered = true; } -void dusk::config::FinishRegistration() { - RegistrationDone = true; +void ConfigVarBase::unmarkRegistered() { + if (!registered) + abort(); + + registered = false; } -void dusk::config::LoadFromUserPreferences() { +void load_from_user_preferences() { const auto configJsonPath = GetConfigJsonPath(); if (configJsonPath.empty()) { return; } const auto configPathString = io::fs_path_to_string(configJsonPath); - LoadFromFileName(configPathString.c_str()); + load_from_file_name(configPathString.c_str()); } static void LoadFromPath(const char* path) { - auto data = dusk::io::FileStream::ReadAllBytes(path); + auto data = io::FileStream::ReadAllBytes(path); json j = json::parse(data); if (!j.is_object()) { @@ -428,11 +477,13 @@ static void LoadFromPath(const char* path) { return; } + UnregisteredConfigVars.clear(); + for (const auto& el : j.items()) { const auto& key = el.key(); auto configVar = RegisteredConfigVars.find(key); if (configVar == RegisteredConfigVars.end()) { - DuskConfigLog.error("Unknown key '{}' found in config!", key); + UnregisteredConfigVars.emplace(key, el.value()); continue; } @@ -444,11 +495,7 @@ static void LoadFromPath(const char* path) { } } -void dusk::config::LoadFromFileName(const char* path) { - if (!RegistrationDone) { - DuskConfigLog.fatal("Registration not finished yet!"); - } - +void load_from_file_name(const char* path) { DuskConfigLog.info("Loading config from '{}'", path); try { @@ -466,7 +513,21 @@ void dusk::config::LoadFromFileName(const char* path) { } } -void dusk::config::Save() { +void load_arg_override(std::string_view name, std::string_view value) { + const auto cVar = GetConfigVar(name); + if (!cVar) { + UnregisteredConfigVarOverrides.emplace(name, value); + return; + } + + try { + cVar->getImpl()->loadFromArg(*cVar, value); + } catch (const std::exception& e) { + DuskLog.fatal("Unable to parse: '{}': {}", value, e.what()); + } +} + +void save() { const auto configJsonPath = GetConfigJsonPath(); if (configJsonPath.empty()) { return; @@ -484,6 +545,10 @@ void dusk::config::Save() { } } + for (const auto& pair : UnregisteredConfigVars) { + j[pair.first] = pair.second; + } + try { const auto tempConfigJsonPath = GetTempConfigJsonPath(configJsonPath); io::FileStream::WriteAllText(tempConfigJsonPath, j.dump(4)); @@ -493,14 +558,14 @@ void dusk::config::Save() { } } -void dusk::config::ClearAllActionBindings(int port) { +void ClearAllActionBindings(int port) { for (auto& actionBinding : getActionBinds() | std::views::values) { actionBinding.configVars->at(port).setValue(PAD_NATIVE_BUTTON_INVALID); } - Save(); + save(); } -ConfigVarBase* dusk::config::GetConfigVar(std::string_view name) { +ConfigVarBase* GetConfigVar(std::string_view name) { const auto configVar = RegisteredConfigVars.find(name); if (configVar != RegisteredConfigVars.end()) { return configVar->second; @@ -509,8 +574,69 @@ ConfigVarBase* dusk::config::GetConfigVar(std::string_view name) { return nullptr; } -void dusk::config::EnumerateRegistered(std::function callback) { +void EnumerateRegistered(std::function callback) { for (auto& pair : RegisteredConfigVars) { callback(*pair.second); } } + +Subscription subscribe(std::string_view name, ChangeCallback callback) { + const auto token = s_nextChangeToken++; + s_changeSubscriptions[std::string{name}].push_back({token, std::move(callback)}); + s_changeTokenNames.emplace(token, std::string{name}); + return token; +} + +void unsubscribe(Subscription token) { + const auto nameIt = s_changeTokenNames.find(token); + if (nameIt == s_changeTokenNames.end()) { + DuskConfigLog.fatal("Tried to unsubscribe unknown change token {}!", token); + } + + const auto subsIt = s_changeSubscriptions.find(nameIt->second); + auto& subscriptions = subsIt->second; + std::erase_if( + subscriptions, [token](const ChangeSubscription& sub) { return sub.token == token; }); + if (subscriptions.empty()) { + s_changeSubscriptions.erase(subsIt); + } + s_changeTokenNames.erase(nameIt); +} + +bool ConfigVarBase::has_subscribers() const { + return s_changeSubscriptions.contains(name); +} + +void ConfigVarBase::notify_changed(const void* previousValue) { + const auto subsIt = s_changeSubscriptions.find(name); + if (subsIt == s_changeSubscriptions.end()) { + return; + } + if (std::ranges::find(s_activeChangeNotifications, name) != s_activeChangeNotifications.end()) { + DuskConfigLog.error("Recursive change notification for CVar '{}' suppressed", name); + return; + } + + s_activeChangeNotifications.push_back(name); + // Copied so callbacks can subscribe/unsubscribe safely. + const auto subscriptions = subsIt->second; + for (const auto& sub : subscriptions) { + sub.callback(*this, previousValue); + } + s_activeChangeNotifications.pop_back(); +} + +void shutdown() { + for (auto& pair : RegisteredConfigVars) { + pair.second->unmarkRegistered(); + } + + RegisteredConfigVars.clear(); + UnregisteredConfigVars.clear(); + UnregisteredConfigVarOverrides.clear(); + s_changeSubscriptions.clear(); + s_changeTokenNames.clear(); + s_activeChangeNotifications.clear(); +} + +} // namespace dusk::config \ No newline at end of file diff --git a/src/dusk/data.cpp b/src/dusk/data.cpp index 40e7c30a21..69ee5bebc8 100644 --- a/src/dusk/data.cpp +++ b/src/dusk/data.cpp @@ -121,14 +121,6 @@ std::filesystem::path active_pref_path() { return get_pref_path(); } -std::filesystem::path base_path_relative(const std::filesystem::path& path) { - const auto* basePath = SDL_GetBasePath(); - if (!basePath) { - return path; - } - return path_from_utf8(basePath) / path; -} - std::filesystem::path default_data_path(const std::filesystem::path& prefPath) { #ifdef __APPLE__ #if TARGET_OS_IOS && !TARGET_OS_TV @@ -888,6 +880,14 @@ void ensure_data_directory(const std::filesystem::path& dataPath) { } // namespace +std::filesystem::path base_path_relative(const std::filesystem::path& path) { + const auto* basePath = SDL_GetBasePath(); + if (!basePath) { + return path; + } + return path_from_utf8(basePath) / path; +} + bool open_data_path() { #if DUSK_CAN_OPEN_DATA_FOLDER std::error_code ec; diff --git a/src/dusk/data.hpp b/src/dusk/data.hpp index bf20035a2d..7e7861dbf0 100644 --- a/src/dusk/data.hpp +++ b/src/dusk/data.hpp @@ -29,6 +29,7 @@ struct Paths { }; Paths initialize_data(); +std::filesystem::path base_path_relative(const std::filesystem::path& path); std::filesystem::path configured_data_path(); std::filesystem::path cache_path(); bool open_data_path(); diff --git a/src/dusk/imgui/ImGuiConfig.hpp b/src/dusk/imgui/ImGuiConfig.hpp index e3e80b9920..4bceb36a38 100644 --- a/src/dusk/imgui/ImGuiConfig.hpp +++ b/src/dusk/imgui/ImGuiConfig.hpp @@ -9,7 +9,7 @@ namespace dusk::config { bool copy = var.getValue(); if (ImGui::Checkbox(title, ©)) { var.setValue(copy); - Save(); + save(); return true; } @@ -20,7 +20,7 @@ namespace dusk::config { float val = var; if (ImGui::SliderFloat(label, &val, v_min, v_max, format, flags)) { var.setValue(val); - Save(); + save(); return true; } @@ -31,7 +31,7 @@ namespace dusk::config { int val = var; if (ImGui::SliderInt(label, &val, v_min, v_max, format, flags)) { var.setValue(val); - Save(); + save(); return true; } @@ -42,7 +42,7 @@ namespace dusk::config { bool copy = p_selected.getValue(); if (ImGui::MenuItem(label, shortcut, ©, enabled)) { p_selected.setValue(copy); - Save(); + save(); return true; } diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index d5c2c65bd2..2408e7e9c1 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -254,7 +254,7 @@ namespace dusk { if (ImGui::IsKeyPressed(ImGuiKey_F11)) { getSettings().video.enableFullscreen.setValue(!getSettings().video.enableFullscreen); VISetWindowFullscreen(getSettings().video.enableFullscreen); - config::Save(); + config::save(); } if (getSettings().game.enableResetKeybind && ImGui::GetIO().KeyCtrl && @@ -337,7 +337,7 @@ namespace dusk { if constexpr (SupportsProcessRestart) { if (ImGui::Button("Retry (Auto backend)")) { getSettings().backend.graphicsBackend.setValue("auto"); - config::Save(); + config::save(); RestartRequested = true; IsRunning = false; } diff --git a/src/dusk/imgui/ImGuiMenuTools.cpp b/src/dusk/imgui/ImGuiMenuTools.cpp index 02326eceea..cf5b22a270 100644 --- a/src/dusk/imgui/ImGuiMenuTools.cpp +++ b/src/dusk/imgui/ImGuiMenuTools.cpp @@ -73,7 +73,7 @@ namespace dusk { bool disableWaterRefraction = getSettings().game.disableWaterRefraction; if (ImGui::Checkbox("Disable Water Refraction", &disableWaterRefraction)) { getSettings().game.disableWaterRefraction.setValue(disableWaterRefraction); - config::Save(); + config::save(); } ImGui::Checkbox("Enable LOD Bias", &aurora::gx::enableLodBias); ImGui::EndMenu(); diff --git a/src/dusk/mods/loader/bundle_disk.cpp b/src/dusk/mods/loader/bundle_disk.cpp new file mode 100644 index 0000000000..26e1bbd722 --- /dev/null +++ b/src/dusk/mods/loader/bundle_disk.cpp @@ -0,0 +1,60 @@ +#include + +#include "dusk/io.hpp" +#include "loader.hpp" + +namespace fs = std::filesystem; + +namespace dusk::mods { +ModBundleDisk::ModBundleDisk(fs::path root) : root_path(std::move(root)) {} + +std::vector ModBundleDisk::readFile(const std::string& fileName) { + return io::FileStream::ReadAllBytes(toRealPath(fileName)); +} + +std::vector ModBundleDisk::getFileNames() { + std::vector files; + + std::error_code ec; + for (fs::recursive_directory_iterator it(root_path, + fs::directory_options::skip_permission_denied | + fs::directory_options::follow_directory_symlink, + ec); + it != fs::recursive_directory_iterator(); it.increment(ec)) + { + if (ec) { + break; + } + + if (!it->is_regular_file()) { + continue; + } + + const auto& path = it->path(); + const auto relPath = fs::relative(path, root_path); + auto string = io::fs_path_to_string(relPath); + if constexpr (fs::path::preferred_separator != '/') { + // Convert \ to / on Windows + for (auto& chr : string) { + if (chr == fs::path::preferred_separator) { + chr = '/'; + } + } + } + + files.emplace_back(std::move(string)); + } + + return files; +} + +size_t ModBundleDisk::getFileSize(const std::string& fileName) { + return std::filesystem::file_size(toRealPath(fileName)); +} + +std::filesystem::path ModBundleDisk::toRealPath(const std::string& fileName) const { + const fs::path filePath = reinterpret_cast(fileName.c_str()); + return root_path / filePath; +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/bundle_zip.cpp b/src/dusk/mods/loader/bundle_zip.cpp new file mode 100644 index 0000000000..517c5943c2 --- /dev/null +++ b/src/dusk/mods/loader/bundle_zip.cpp @@ -0,0 +1,68 @@ +#include "fmt/format.h" +#include "loader.hpp" + +#include + +namespace dusk::mods { + +ModBundleZip::ModBundleZip(std::vector&& data) : zip_data(std::move(data)) { + if (!mz_zip_reader_init_mem(&res_zip, zip_data.data(), zip_data.size(), 0)) { + const auto error = mz_zip_get_last_error(&res_zip); + throw std::runtime_error( + fmt::format("Opening zip failed: {}", mz_zip_get_error_string(error))); + } +} + +ModBundleZip::~ModBundleZip() { + mz_zip_reader_end(&res_zip); +} + +std::vector ModBundleZip::readFile(const std::string& fileName) { + std::lock_guard lock{m_mutex}; + size_t size; + const auto ptr = mz_zip_reader_extract_file_to_heap(&res_zip, fileName.c_str(), &size, 0); + + if (!ptr) { + throw std::runtime_error(fmt::format("File does not exist: {}", fileName)); + } + + std::span data(static_cast(ptr), size); + std::vector vec(data.begin(), data.end()); + + mz_free(ptr); + + return vec; +} + +std::vector ModBundleZip::getFileNames() { + std::lock_guard lock{m_mutex}; + std::vector results; + + for (mz_uint i = 0, n = mz_zip_reader_get_num_files(&res_zip); i < n; ++i) { + mz_zip_archive_file_stat stat{}; + if (!mz_zip_reader_file_stat(&res_zip, i, &stat)) { + continue; + } + if (mz_zip_reader_is_file_a_directory(&res_zip, i)) { + continue; + } + + results.emplace_back(stat.m_filename); + } + + return results; +} + +size_t ModBundleZip::getFileSize(const std::string& fileName) { + std::lock_guard lock{m_mutex}; + const auto idx = mz_zip_reader_locate_file(&res_zip, fileName.c_str(), nullptr, 0); + if (idx < 0) { + throw std::runtime_error(fmt::format("Unable to locate file in zip: {}", fileName)); + } + + mz_zip_archive_file_stat stat{}; + mz_zip_reader_file_stat(&res_zip, idx, &stat); + return stat.m_uncomp_size; +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/context.cpp b/src/dusk/mods/loader/context.cpp new file mode 100644 index 0000000000..548f0bb06f --- /dev/null +++ b/src/dusk/mods/loader/context.cpp @@ -0,0 +1,61 @@ +#include "loader.hpp" + +#include "dusk/mods/log_buffer.hpp" +#include "dusk/mods/svc/registry.hpp" + +namespace dusk::mods { + +LoadedMod* mod_from_context(ModContext* context) { + return context != nullptr ? context->mod : nullptr; +} + +const LoadedMod* mod_from_context(const ModContext* context) { + return context != nullptr ? context->mod : nullptr; +} + +const char* mod_id_from_context(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->metadata.id.c_str() : "mod"; +} + +bool is_safe_resource_path(std::string_view path) { + if (path.empty() || path.starts_with('/') || path.starts_with('\\') || + path.find(':') != std::string_view::npos) + { + return false; + } + + while (!path.empty()) { + const auto slash = path.find_first_of("/\\"); + const auto segment = path.substr(0, slash); + if (segment.empty() || segment == "." || segment == "..") { + return false; + } + if (slash == std::string_view::npos) { + break; + } + path.remove_prefix(slash + 1); + } + + return true; +} + +void fail_mod(LoadedMod& mod, ModResult code, std::string_view message) { + const bool firstFailure = !mod.loadFailed; + mod.active = false; + mod.loadFailed = true; + if (firstFailure || mod.failureReason.empty()) { + mod.failureReason = message; + } + // Stop the failed mod's services from resolving; mods that required them fail in turn. + // Pointers already handed to other mods stay callable since the library remains loaded. + // Nothing else is torn down here: fail_mod can run mid-frame (e.g. from a failing mod + // callback), and full teardown happens via deactivate_mod at a safe point. + svc::remove_services_for_provider(mod); + mod.servicesRegistered = false; + ModLoader::instance().notify_mod_failure(mod, firstFailure); + log::write( + mod.metadata.id, LOG_LEVEL_ERROR, "failed: {} ({})", message, static_cast(code)); +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/depgraph.cpp b/src/dusk/mods/loader/depgraph.cpp new file mode 100644 index 0000000000..d1748b56c4 --- /dev/null +++ b/src/dusk/mods/loader/depgraph.cpp @@ -0,0 +1,204 @@ +#include "depgraph.hpp" + +#include +#include + +#include "dusk/logging.h" +#include "loader.hpp" +#include "native_module.hpp" // IWYU pragma: keep + +namespace dusk::mods::loader { +namespace { +aurora::Module Log{"dusk::mods::loader"}; + +struct Edge { + size_t provider; + size_t importer; + bool required; + bool alive = true; +}; + +std::vector collect_edges(const std::vector>& mods) { + for (auto& mod : mods) { + mod->dependencies.clear(); + mod->dependents.clear(); + } + + // Mirrors the registry's first-registration-wins rule for duplicate exports. + const auto findProvider = [&](const ModManifestInfo::Import& serviceImport) -> size_t { + for (size_t i = 0; i < mods.size(); ++i) { + const auto matches = [&](const ModManifestInfo::Export& serviceExport) { + return serviceExport.major == serviceImport.major && + serviceExport.id == serviceImport.id; + }; + if (std::ranges::any_of(mods[i]->manifestInfo.exports, matches)) { + return i; + } + } + return mods.size(); // Host-provided or unavailable: no ordering constraint. + }; + + std::vector edges; + for (size_t importer = 0; importer < mods.size(); ++importer) { + auto& mod = *mods[importer]; + for (const auto& serviceImport : mod.manifestInfo.imports) { + const size_t provider = findProvider(serviceImport); + if (provider >= mods.size() || provider == importer) { + continue; + } + auto& providerMod = *mods[provider]; + const bool required = serviceImport.required; + + const auto existing = std::ranges::find_if(edges, [&](const Edge& edge) { + return edge.provider == provider && edge.importer == importer; + }); + if (existing != edges.end()) { + if (required && !existing->required) { + existing->required = true; + for (auto& dep : mod.dependencies) { + if (dep.mod == &providerMod) { + dep.required = true; + } + } + for (auto& dep : providerMod.dependents) { + if (dep.mod == &mod) { + dep.required = true; + } + } + } + } else { + edges.push_back({provider, importer, required}); + mod.dependencies.push_back({&providerMod, required}); + providerMod.dependents.push_back({&mod, required}); + } + } + } + return edges; +} + +// True if `start` can reach itself following live required edges through unplaced mods. +bool in_required_cycle( + const size_t start, const std::vector& edges, const std::vector& placed) { + std::vector pending{start}; + std::vector visited(placed.size(), false); + while (!pending.empty()) { + const size_t current = pending.back(); + pending.pop_back(); + for (const auto& edge : edges) { + if (!edge.alive || !edge.required || edge.provider != current || placed[edge.importer]) + { + continue; + } + if (edge.importer == start) { + return true; + } + if (!visited[edge.importer]) { + visited[edge.importer] = true; + pending.push_back(edge.importer); + } + } + } + return false; +} + +} // namespace + +void sort_mods(std::vector>& mods) { + const size_t count = mods.size(); + auto edges = collect_edges(mods); + if (edges.empty()) { + return; + } + + std::vector indegree(count, 0); + for (const auto& edge : edges) { + ++indegree[edge.importer]; + } + + std::vector order; + order.reserve(count); + std::vector placed(count, false); + + const auto place = [&](const size_t index) { + placed[index] = true; + order.push_back(index); + for (auto& edge : edges) { + if (edge.alive && edge.provider == index) { + edge.alive = false; + --indegree[edge.importer]; + } + } + }; + + while (order.size() < count) { + // Always take the lowest unplaced scan index that is ready, keeping the + // final order as close to scan (filename) order as the graph allows. + const auto ready = [&]() -> size_t { + for (size_t i = 0; i < count; ++i) { + if (!placed[i] && indegree[i] == 0) { + return i; + } + } + return count; + }(); + if (ready < count) { + place(ready); + continue; + } + + // Stalled: every unplaced mod is on or downstream of a cycle. + std::vector cycleMods; + for (size_t i = 0; i < count; ++i) { + if (!placed[i] && in_required_cycle(i, edges, placed)) { + cycleMods.push_back(i); + } + } + if (!cycleMods.empty()) { + std::string names; + for (const size_t index : cycleMods) { + if (!names.empty()) { + names += ", "; + } + names += mods[index]->metadata.id; + } + for (const size_t index : cycleMods) { + fail_mod(*mods[index], MOD_CONFLICT, + "Required service import cycle between mods: " + names); + place(index); + } + continue; + } + + // Only optional imports left in the cycle: drop one edge and retry. The + // import still resolves, but without any initialization-order guarantee. + const auto optionalEdge = std::ranges::find_if(edges, [&](const Edge& edge) { + return edge.alive && !edge.required && !placed[edge.provider] && !placed[edge.importer]; + }); + if (optionalEdge == edges.end()) { + // Unreachable: a stall with no required cycle implies an optional edge. + Log.error("mod dependency sort stalled unexpectedly"); + break; + } + Log.warn("optional service import cycle: '{}' will initialize before its optional " + "provider '{}'", + mods[optionalEdge->importer]->metadata.id, mods[optionalEdge->provider]->metadata.id); + optionalEdge->alive = false; + --indegree[optionalEdge->importer]; + } + + // Defensive: append anything a stall break left behind, in scan order. + for (size_t i = 0; i < count; ++i) { + if (!placed[i]) { + place(i); + } + } + + std::vector> sorted; + sorted.reserve(count); + for (const size_t index : order) { + sorted.push_back(std::move(mods[index])); + } + mods = std::move(sorted); +} + +} // namespace dusk::mods::loader diff --git a/src/dusk/mods/loader/depgraph.hpp b/src/dusk/mods/loader/depgraph.hpp new file mode 100644 index 0000000000..a6f5b30d50 --- /dev/null +++ b/src/dusk/mods/loader/depgraph.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +#include "dusk/mod_loader.hpp" + +namespace dusk::mods::loader { + +// Reorders mods so service providers precede their importers, populating +// LoadedMod::dependencies/dependents from manifest imports along the way. +// Mods whose required imports form a cycle are failed; a cycle that can be +// broken by dropping an optional import is broken with a warning. Scan order +// is preserved between mods with no dependency relationship. +void sort_mods(std::vector>& mods); + +} // namespace dusk::mods::loader diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp new file mode 100644 index 0000000000..e1bb2797c8 --- /dev/null +++ b/src/dusk/mods/loader/loader.cpp @@ -0,0 +1,1140 @@ +#include "loader.hpp" +#include "dusk/logging.h" +#include "dusk/mod_loader.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "../manifest.hpp" +#include "depgraph.hpp" +#include "dusk/config.hpp" +#include "dusk/io.hpp" +#include "dusk/mods/log_buffer.hpp" +#include "dusk/mods/svc/config.hpp" +#include "dusk/mods/svc/registry.hpp" +#include "dusk/ui/mods_window.hpp" +#include "dusk/ui/ui.hpp" +#include "miniz.h" +#include "native_module.hpp" +#include "nlohmann/json.hpp" + +using namespace std::string_literals; +using namespace std::string_view_literals; + +#if defined(_WIN32) +#if defined(_M_ARM64) +static constexpr std::string_view k_nativeLibName = "windows-arm64.dll"sv; +#elif defined(_M_X64) +static constexpr std::string_view k_nativeLibName = "windows-amd64.dll"sv; +#elif defined(_M_IX86) +static constexpr std::string_view k_nativeLibName = "windows-x86.dll"sv; +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif +#elif defined(__ANDROID__) +#if defined(__aarch64__) +static constexpr std::string_view k_nativeLibName = "android-aarch64.so"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativeLibName = "android-x86_64.so"sv; +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif +#elif defined(__APPLE__) +#include +#if TARGET_OS_IOS +static constexpr std::string_view k_nativeLibName = "ios-arm64.dylib"sv; +#elif TARGET_OS_TV +static constexpr std::string_view k_nativeLibName = "tvos-arm64.dylib"sv; +#elif defined(__aarch64__) +static constexpr std::string_view k_nativeLibName = "darwin-arm64.dylib"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativeLibName = "darwin-x86_64.dylib"sv; +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif +#elif defined(__linux__) +#if defined(__aarch64__) +static constexpr std::string_view k_nativeLibName = "linux-aarch64.so"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativeLibName = "linux-x86_64.so"sv; +#elif defined(__i386__) +static constexpr std::string_view k_nativeLibName = "linux-x86.so"sv; +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif + +namespace dusk::mods { +namespace { +aurora::Module Log{"dusk::mods::loader"}; +ModLoader g_modLoader; + +std::unique_ptr load_bundle(const std::filesystem::path& modPath, bool fromDir) { + if (fromDir) { + return std::make_unique(modPath); + } else { + std::vector data = io::FileStream::ReadAllBytes(modPath); + return std::make_unique(std::move(data)); + } +} + +struct DllLocateResult { + std::string entry; + bool anyLibs = false; +}; + +DllLocateResult locate_dll_in_bundle(ModBundle& bundle) { + DllLocateResult result; + for (const auto& name : bundle.getFileNames()) { + if (name.find('/') != std::string::npos || + (!name.ends_with(".dll"sv) && !name.ends_with(".dylib"sv) && !name.ends_with(".so"sv))) + { + continue; + } + result.anyLibs = true; + if (name == k_nativeLibName) { + result.entry = name; + } + } + return result; +} +} // namespace + +ModLoader& ModLoader::instance() { + return g_modLoader; +} + +class InvalidModDataException : public std::runtime_error { +public: + explicit InvalidModDataException(const std::string& msg) : runtime_error(msg) {} + explicit InvalidModDataException(const char* msg) : runtime_error(msg) {} +}; + +static void validate_mod_id(std::string_view const str) { + if (str.empty()) { + throw InvalidModDataException("Missing ID value in mod metadata!"); + } + + bool lastWasPeriod = false; + for (auto const chr : str) { + if (chr == '.') { + if (lastWasPeriod) { + throw InvalidModDataException("Cannot have two consecutive periods in mod ID!"); + } + lastWasPeriod = true; + continue; + } + + lastWasPeriod = false; + + if (chr == '_') + continue; + + if (chr >= '0' && chr <= '9') + continue; + + if (chr >= 'a' && chr <= 'z') + continue; + + if (chr >= 'A' && chr <= 'Z') + continue; + + throw InvalidModDataException( + fmt::format("Invalid character '{}' in mod ID. Valid characters are period, " + "underscore, and alphanumerics.", + chr)); + } +} + +static bool bundle_has_file(ModBundle& bundle, const std::string& path) { + try { + bundle.getFileSize(path); + return true; + } catch (const std::runtime_error&) { + return false; + } +} + +static std::string resolve_image_path(ModBundle& bundle, const std::string& modId, + std::string_view key, const std::string& manifestPath, const std::string& defaultPath) { + if (!manifestPath.empty()) { + if (!is_safe_resource_path(manifestPath)) { + log::write( + modId, LOG_LEVEL_WARN, "invalid {} path '{}' in mod.json", key, manifestPath); + } else if (!bundle_has_file(bundle, manifestPath)) { + log::write( + modId, LOG_LEVEL_WARN, "{} path '{}' not found in bundle", key, manifestPath); + } else { + return manifestPath; + } + } + if (bundle_has_file(bundle, defaultPath)) { + return defaultPath; + } + return {}; +} + +static ModMetadata load_metadata(const std::filesystem::path& modPath, ModBundle& bundle) { + const auto metaJson = bundle.readFile("mod.json"); + auto j = nlohmann::json::parse(metaJson); + + std::string metaId = j.value("id", ""); + std::string metaName = j.value("name", ""); + std::string metaVersion = j.value("version", ""); + std::string metaAuthor = j.value("author", ""); + std::string metaDescription = j.value("description", ""); + std::string metaIcon = j.value("icon", ""); + std::string metaBanner = j.value("banner", ""); + + validate_mod_id(metaId); + + if (metaName.empty()) { + metaName = io::fs_path_to_string(modPath.stem()); + } + if (metaVersion.empty()) { + metaVersion = "?"s; + } + if (metaAuthor.empty()) { + metaAuthor = "unknown"s; + } + + std::string iconPath = resolve_image_path(bundle, metaId, "icon", metaIcon, "res/icon.png"s); + std::string bannerPath = + resolve_image_path(bundle, metaId, "banner", metaBanner, "res/banner.png"s); + + return ModMetadata{ + std::move(metaId), + std::move(metaName), + std::move(metaVersion), + std::move(metaAuthor), + std::move(metaDescription), + std::move(iconPath), + std::move(bannerPath), + }; +} + +static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) { + if (manifest == nullptr) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "returned a null mod manifest"); + mod.nativeStatus = NativeModStatus::MissingExport; + return false; + } + if (manifest->struct_size != sizeof(ModManifest)) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "manifest has invalid size {} (expected {})", + manifest->struct_size, sizeof(ModManifest)); + mod.nativeStatus = NativeModStatus::ApiVersionMismatch; + return false; + } + if (manifest->abi_version != MOD_ABI_VERSION) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expects ABI v{} but engine is v{}, skipping", + manifest->abi_version, MOD_ABI_VERSION); + mod.nativeStatus = NativeModStatus::ApiVersionMismatch; + return false; + } + if ((manifest->import_count > 0 && manifest->imports == nullptr) || + (manifest->export_count > 0 && manifest->exports == nullptr)) + { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "manifest has invalid import/export arrays"); + mod.nativeStatus = NativeModStatus::MissingExport; + return false; + } + return true; +} + +static bool validate_context_symbol(ModContext** contextSymbol, LoadedMod& mod) { + if (contextSymbol == nullptr) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "missing required mod_ctx export"); + mod.nativeStatus = NativeModStatus::MissingExport; + return false; + } + return true; +} + +static std::string lifecycle_error_message( + const char* fnName, const ModResult result, const ModError& error) { + if (error.message[0] != '\0') { + return error.message; + } + return fmt::format("{} failed with result {}", fnName, static_cast(result)); +} + +static std::string native_status_message(const NativeModStatus status) { + switch (status) { + case NativeModStatus::BuildDisabled: + return "Code mods are disabled on this Dusklight build"; + case NativeModStatus::ModMissingPlatform: + return fmt::format("Mod not supported on this platform ({})", k_nativeLibName); + case NativeModStatus::ApiVersionMismatch: + // TODO: differentiate whether mod or Dusklight is out of date + return "Mod ABI version mismatch"; + case NativeModStatus::MissingExport: + return "Missing required mod API exports"; + case NativeModStatus::Unknown: + return "Unknown mod load failure"; + case NativeModStatus::None: + case NativeModStatus::Loaded: + break; + } + return "native mod failed to load"; +} + +std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod) const { + namespace fs = std::filesystem; + if (k_nativeLibName.empty()) { + return {}; + } + const auto& libDir = m_searchDirs[mod.searchDirIndex].nativeLibDir; + if (libDir.empty()) { + return {}; + } + fs::path path = libDir / fs::path(mod.metadata.id + + io::fs_path_to_string(fs::path(k_nativeLibName).extension())); + std::error_code ec; + if (!fs::is_regular_file(path, ec)) { + return {}; + } + return path; +} + +void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { + if (!EnableCodeMods) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "Code mods are not available in this build"); + mod.nativeStatus = NativeModStatus::BuildDisabled; + return; + } + + namespace fs = std::filesystem; + + const fs::path cacheDir = m_cacheDir / mod.metadata.id; + std::error_code ec; + fs::create_directories(cacheDir, ec); + + fs::path libPath; + if (mod.inPlace) { + if (!dllEntry.empty()) { + libPath = fs::path(mod.modPath) / dllEntry; + } else if (auto external = external_native_lib_path(mod); !external.empty()) { + libPath = std::move(external); + } else { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "no native library named {} found; skipping", k_nativeLibName); + mod.nativeStatus = NativeModStatus::ModMissingPlatform; + return; + } + } else { + if (dllEntry.empty()) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "no native library named {} found; skipping", k_nativeLibName); + mod.nativeStatus = NativeModStatus::ModMissingPlatform; + return; + } + + // Generation-versioned filename: every dlopen gets a path it has never seen, so a reload + // always yields a fresh image with fresh statics even if the previous dlclose did not + // fully unmap the old one (TLS/ObjC pinning). The .cache dir is wiped on startup. + const fs::path dllCachePath = + cacheDir / fmt::format("{}.g{}{}", mod.metadata.id, ++mod.cacheGeneration, + io::fs_path_to_string(fs::path(dllEntry).extension())); + + std::vector dllData; + try { + dllData = mod.bundle->readFile(dllEntry); + } catch (const std::exception& e) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to extract {}", dllEntry); + return; + } + + { + std::ofstream out(dllCachePath, std::ios::binary | std::ios::out); + if (!out) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", + io::fs_path_to_string(dllCachePath)); + return; + } + + out.write(reinterpret_cast(dllData.data()), + static_cast(dllData.size())); + } + + libPath = dllCachePath; + } + + auto nativeMod = std::make_unique(); + try { + nativeMod->handle = std::make_unique(libPath); + } catch (const std::runtime_error& e) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to open {}: {}", + io::fs_path_to_string(libPath), e.what()); + return; + } + + const auto getManifest = nativeMod->handle->LookupSymbol("mod_get_manifest"); + nativeMod->contextSymbol = nativeMod->handle->LookupSymbol("mod_ctx"); + nativeMod->fn_initialize = nativeMod->handle->LookupSymbol("mod_initialize"); + nativeMod->fn_update = nativeMod->handle->LookupSymbol("mod_update"); + nativeMod->fn_shutdown = nativeMod->handle->LookupSymbol("mod_shutdown"); + + if (!getManifest || !nativeMod->contextSymbol || !nativeMod->fn_initialize || + !nativeMod->fn_update || !nativeMod->fn_shutdown) + { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "{} missing required mod API exports; skipping", + io::fs_path_to_string(libPath.filename())); + mod.nativeStatus = NativeModStatus::MissingExport; + return; + } + + nativeMod->manifest = getManifest(); + if (!validate_manifest(nativeMod->manifest, mod)) { + return; + } + + if (!validate_context_symbol(nativeMod->contextSymbol, mod)) { + return; + } + *nativeMod->contextSymbol = mod.context.get(); + + mod.dir = io::fs_path_to_string(fs::absolute(cacheDir)); + mod.nativePath = io::fs_path_to_string(fs::absolute(libPath)); + mod.native = std::move(nativeMod); + mod.nativeStatus = NativeModStatus::Loaded; +} + +void ModLoader::unload_native(LoadedMod& mod) { + if (!mod.native || mod.inPlace) { + return; + } + // Deferred dlclose: this mod's code may still be on the stack below the current tick + m_retiredNatives.push_back({std::move(mod.native), std::move(mod.nativePath)}); + mod.nativePath.clear(); +} + +void ModLoader::drain_retired_natives() { + for (auto& retired : m_retiredNatives) { + retired.native.reset(); + if (!retired.path.empty()) { + std::error_code ec; + std::filesystem::remove(retired.path, ec); + } + } + m_retiredNatives.clear(); +} + +static ModManifestInfo build_manifest_info(const ModManifest& manifest) { + ModManifestInfo info; + info.imports.reserve(manifest.import_count); + for (size_t i = 0; i < manifest.import_count; ++i) { + const auto& serviceImport = manifest.imports[i]; + if (serviceImport.struct_size != sizeof(ServiceImport) || + !svc::valid_service_id(serviceImport.service_id)) + { + continue; + } + info.imports.push_back({serviceImport.service_id, serviceImport.major_version, + (serviceImport.flags & SERVICE_IMPORT_OPTIONAL) == 0}); + } + info.exports.reserve(manifest.export_count); + for (size_t i = 0; i < manifest.export_count; ++i) { + const auto& serviceExport = manifest.exports[i]; + if (serviceExport.struct_size != sizeof(ServiceExport) || + !svc::valid_service_id(serviceExport.service_id)) + { + continue; + } + info.exports.push_back({serviceExport.service_id, serviceExport.major_version}); + } + return info; +} + +std::string escape_mod_id_for_config(std::string_view const id) { + std::string buf; + + // Simple escaping. All characters in mod IDs literal, except for '.' and '_'. + // '.' -> '_', '_' -> '__' + for (char const chr : id) { + if (chr == '.') { + buf.push_back('_'); + } else if (chr == '_') { + buf.push_back('_'); + buf.push_back('_'); + } else { + buf.push_back(chr); + } + } + + return buf; +} + +static std::string mod_enabled_cvar_name(std::string_view const id) { + return fmt::format("mod.{}.enabled", escape_mod_id_for_config(id)); +} + +static bool required_deps_active(const LoadedMod& mod) { + return std::ranges::all_of(mod.dependencies, + [](const ModDependencyEdge& edge) { return !edge.required || edge.mod->active; }); +} + +// A deferred export that was not published by the end of the provider's initialization can +// never resolve, which is almost certainly a bug in the provider. +static void warn_unpublished_deferred_exports(const LoadedMod& mod) { + if (!mod.active || !mod.native || mod.native->manifest == nullptr) { + return; + } + + const auto& manifest = *mod.native->manifest; + for (size_t i = 0; i < manifest.export_count; ++i) { + const auto& serviceExport = manifest.exports[i]; + if (serviceExport.struct_size != sizeof(ServiceExport) || + (serviceExport.flags & SERVICE_EXPORT_DEFERRED) == 0) + { + continue; + } + const auto* record = + svc::find_service_record(serviceExport.service_id, serviceExport.major_version); + if (record != nullptr && record->service == nullptr) { + log::write(mod.metadata.id, LOG_LEVEL_WARN, + "declared deferred service '{}@{}' but never published it during initialization", + serviceExport.service_id, serviceExport.major_version); + } + } +} + +void ModLoader::try_load_mod( + const std::filesystem::path& modPath, bool fromDir, uint32_t searchDirIndex) { + namespace fs = std::filesystem; + + std::unique_ptr bundle; + try { + bundle = load_bundle(modPath, fromDir); + } catch (const std::exception& e) { + Log.error( + "Failed to open {} bundle: {}", io::fs_path_to_string(modPath.filename()), e.what()); + return; + } + + ModMetadata metadata; + try { + metadata = load_metadata(modPath, *bundle); + } catch (const std::exception& e) { + Log.error("bad mod.json in {}: {}", io::fs_path_to_string(modPath.filename()), e.what()); + return; + } + + if (const auto* existing = find_mod(metadata.id)) { + if (existing->searchDirIndex < searchDirIndex) { + log::write(metadata.id, LOG_LEVEL_INFO, "{} shadowed by higher-priority copy {}", + io::fs_path_to_string(modPath.filename()), existing->modPath); + } else { + log::write(metadata.id, LOG_LEVEL_ERROR, "duplicate mod id, not loading {}", + io::fs_path_to_string(modPath.filename())); + } + return; + } + + const auto& inserted = m_mods.emplace_back(std::make_unique()); + + auto& mod = *inserted; + mod.active = true; + mod.modPath = io::fs_path_to_string(fs::absolute(modPath)); + mod.searchDirIndex = searchDirIndex; + mod.inPlace = m_searchDirs[searchDirIndex].inPlaceNative && fromDir; + mod.metadata = std::move(metadata); + mod.bundle = std::move(bundle); + mod.context = std::make_unique(); + mod.context->mod = &mod; + mod.cvarIsEnabled = + std::make_unique>(mod_enabled_cvar_name(mod.metadata.id), true); + + const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); + if (anyLibs || (mod.inPlace && !external_native_lib_path(mod).empty())) { + mod.nativeStatus = NativeModStatus::Unknown; + load_native(mod, dllEntry); + if (mod.nativeStatus != NativeModStatus::Loaded) { + Log.error("Native mod '{}' failed to load, disabling", mod.metadata.id); + fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); + } else { + mod.manifestInfo = build_manifest_info(*mod.native->manifest); + } + } + + log::write(mod.metadata.id, LOG_LEVEL_INFO, "found '{}' v{} by {} ({})", mod.metadata.name, + mod.metadata.version, mod.metadata.author, io::fs_path_to_string(modPath.filename())); +} + +bool ModLoader::activate_mod(LoadedMod& mod) { + log::write(mod.metadata.id, LOG_LEVEL_INFO, "activating mod"); + mod.active = true; + + // Asset-only mods have no lifecycle beyond their overlay files. + if (!mod.native) { + mod.enabledApplied = true; + return true; + } + + if (!mod.servicesRegistered) { + if (!register_static_service_exports(mod)) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); + deactivate_mod(mod); + return false; + } + mod.servicesRegistered = true; + } + + if (!resolve_service_imports(mod)) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to resolve service imports"); + deactivate_mod(mod); + return false; + } + + *mod.native->contextSymbol = mod.context.get(); + + log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling mod_initialize"); + try { + ModError error = MOD_ERROR_INIT; + const auto result = mod.native->fn_initialize(&error); + if (result == MOD_OK && !mod.loadFailed) { + mod.initialized = true; + log::write(mod.metadata.id, LOG_LEVEL_TRACE, "mod_initialize succeeded"); + } else if (result != MOD_OK && !mod.loadFailed) { + fail_mod(mod, result, lifecycle_error_message("mod_initialize", result, error)); + } + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("Exception in mod_initialize: {}", e.what())); + } catch (...) { + fail_mod(mod, MOD_ERROR, "Unknown exception in mod_initialize"); + } + + warn_unpublished_deferred_exports(mod); + + if (!mod.active) { + // Failed initialization may have left hooks or other service state behind + deactivate_mod(mod); + return false; + } + + mod.enabledApplied = true; + return true; +} + +void ModLoader::deactivate_mod(LoadedMod& mod) { + if (mod.initialized && mod.native && mod.native->fn_shutdown) { + log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling mod_shutdown"); + try { + ModError error = MOD_ERROR_INIT; + const auto result = mod.native->fn_shutdown(&error); + if (result == MOD_OK) { + log::write(mod.metadata.id, LOG_LEVEL_TRACE, "mod_shutdown succeeded"); + } else { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_shutdown failed: {}", + lifecycle_error_message("mod_shutdown", result, error)); + } + } catch (...) { + } + } + mod.initialized = false; + + if (mod.servicesRegistered) { + svc::remove_services_for_provider(mod); + mod.servicesRegistered = false; + } + svc::modules_mod_detached(mod); + unload_native(mod); + + mod.active = false; + mod.enabledApplied = false; +} + +void ModLoader::init() { + if (m_initialized) { + return; + } + m_initialized = true; + + manifest::initialize(); + + if (m_searchDirs.empty()) { + Log.warn("no mod search directories configured; mod loading skipped"); + return; + } + + if (m_cacheDir.empty()) { + m_cacheDir = m_searchDirs.front().path / ".cache"; + } + + namespace fs = std::filesystem; + std::error_code ec; + + // Stale libs from previous sessions (see load_native). + fs::remove_all(m_cacheDir, ec); + + for (size_t dirIndex = 0; dirIndex < m_searchDirs.size(); ++dirIndex) { + const auto& searchDir = m_searchDirs[dirIndex]; + + // --mods can point the user dir at the bundled dir; don't scan the same dir twice. + bool alreadyScanned = false; + for (size_t earlier = 0; earlier < dirIndex && !alreadyScanned; ++earlier) { + alreadyScanned = fs::equivalent(m_searchDirs[earlier].path, searchDir.path, ec); + } + if (alreadyScanned) { + continue; + } + + if (!fs::is_directory(searchDir.path)) { + if (dirIndex == 0) { + Log.info("mods directory '{}' not found", io::fs_path_to_string(searchDir.path)); + } else { + Log.debug("mods directory '{}' not found", io::fs_path_to_string(searchDir.path)); + } + continue; + } + + std::vector entries; + for (auto& e : fs::directory_iterator(searchDir.path, ec)) { + if (e.is_directory() && std::filesystem::exists(e.path() / "mod.json")) { + entries.push_back(e); + } else if (e.is_regular_file() && e.path().extension() == ".dusk") { + entries.push_back(e); + } + } + std::sort(entries.begin(), entries.end(), + [](const fs::directory_entry& a, const fs::directory_entry& b) { + return a.path().filename() < b.path().filename(); + }); + + for (auto& entry : entries) { + try_load_mod(entry.path(), entry.is_directory(), static_cast(dirIndex)); + } + } + + if (m_mods.empty()) { + Log.info("no mods found"); + return; + } + + std::stable_sort(m_mods.begin(), m_mods.end(), + [](const auto& a, const auto& b) { return a->searchDirIndex > b->searchDirIndex; }); + + Log.info("initializing {} mod(s)...", m_mods.size()); + for (auto& mod : mods()) { + mod.enabledSubscription = Register(*mod.cvarIsEnabled, + [this, &mod](const bool&, const bool&) { on_enabled_changed(mod); }); + } + + init_services(); + + // Providers must initialize (and publish deferred services) before their importers, so + // imports are resolved per mod, interleaved with initialization, in dependency order. + loader::sort_mods(m_mods); + + // Decide the startup lifecycle state before publishing exports. Config-disabled mods and + // mods blocked by required providers keep dependency edges but must not resolve or provide + // services until they can actually initialize. + for (auto& mod : mods()) { + if (!mod.cvarIsEnabled->getValue()) { + log::write(mod.metadata.id, LOG_LEVEL_INFO, "disabled by config"); + mod.active = false; + mod.suspendedByProvider = false; + continue; + } + if (!mod.loadFailed && !required_deps_active(mod)) { + log::write( + mod.metadata.id, LOG_LEVEL_INFO, "suspended: a required provider is disabled"); + mod.active = false; + mod.suspendedByProvider = true; + continue; + } + mod.suspendedByProvider = false; + } + + for (auto& mod : mods()) { + if (!mod.active || !mod.native) { + continue; + } + if (register_static_service_exports(mod)) { + mod.servicesRegistered = true; + } else { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); + deactivate_mod(mod); + } + } + + for (auto& mod : mods()) { + if (mod.active) { + activate_mod(mod); + } + } + + svc::modules_lifecycle_applied(); + + auto active = std::ranges::count_if(mods(), [](const LoadedMod& m) { return m.active; }); + Log.info("{}/{} mod(s) active", active, m_mods.size()); + + m_startupComplete = true; +} + +LoadedMod* ModLoader::find_mod(std::string_view id) const { + for (auto& mod : mods()) { + if (mod.metadata.id == id) { + return &mod; + } + } + return nullptr; +} + +void ModLoader::request_enable(std::string_view id) { + if (auto* mod = find_mod(id)) { + mod->cvarIsEnabled->setValue(true); + } +} + +void ModLoader::request_disable(std::string_view id) { + if (auto* mod = find_mod(id)) { + mod->cvarIsEnabled->setValue(false); + } +} + +void ModLoader::request_reload(std::string_view id) { + m_pendingRequests.push_back({std::string{id}, RequestKind::Reload}); +} + +void ModLoader::notify_mod_failure(LoadedMod& mod, bool firstFailure) { + if (firstFailure) { + m_pendingFailures.push_back(mod.metadata.name); + } + // Startup failures are handled inline by activate_mod + if (!m_startupComplete) { + return; + } + m_pendingRequests.push_back({mod.metadata.id, RequestKind::Disable}); +} + +void ModLoader::flush_toasts() { + if (m_pendingFailures.empty()) { + return; + } + + const auto names = std::exchange(m_pendingFailures, {}); + + // Skip displaying toasts if the mods window is currently open + if (const auto* window = dynamic_cast(ui::top_document())) { + if (window->visible()) { + return; + } + } + + ui::Toast toast{.type = "warning", .duration = std::chrono::seconds{5}}; + if (names.size() == 1) { + toast.title = "Mod failed"; + toast.content = + fmt::format("
{} failed and was disabled.
Check Mods for " + "more information.
", + ui::escape(names.front())); + } else { + toast.title = "Mods failed"; + toast.content = fmt::format("
{} mods failed and were disabled.
Check " + "Mods for more information.
", + names.size()); + } + ui::push_toast(std::move(toast)); +} + +std::vector ModLoader::collect_lifecycle_set(LoadedMod& target) { + std::vector included{&target}; + std::vector pending{&target}; + while (!pending.empty()) { + auto* current = pending.back(); + pending.pop_back(); + for (const auto& edge : current->dependents) { + auto* dependent = edge.mod; + if (!dependent->active && !dependent->suspendedByProvider) { + continue; + } + if (std::ranges::find(included, dependent) != included.end()) { + continue; + } + included.push_back(dependent); + pending.push_back(dependent); + } + } + + std::vector ordered; + ordered.reserve(included.size()); + for (auto& mod : mods()) { + if (std::ranges::find(included, &mod) != included.end()) { + ordered.push_back(&mod); + } + } + return ordered; +} + +bool ModLoader::ensure_native_loaded(LoadedMod& mod) { + if (mod.native || mod.nativeStatus == NativeModStatus::None) { + return true; + } + + const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); + if (!anyLibs && !(mod.inPlace && !external_native_lib_path(mod).empty())) { + mod.nativeStatus = NativeModStatus::None; + return true; + } + + mod.nativeStatus = NativeModStatus::Unknown; + load_native(mod, dllEntry); + if (mod.nativeStatus != NativeModStatus::Loaded) { + fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); + return false; + } + return true; +} + +bool ModLoader::reload_bundle(LoadedMod& mod) { + namespace fs = std::filesystem; + log::write(mod.metadata.id, LOG_LEVEL_INFO, "reloading from {}", mod.modPath); + + std::shared_ptr newBundle; + ModMetadata newMetadata; + try { + std::error_code ec; + newBundle = load_bundle(mod.modPath, fs::is_directory(mod.modPath, ec)); + newMetadata = load_metadata(mod.modPath, *newBundle); + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("Reload failed: {}", e.what())); + return false; + } + + if (newMetadata.id != mod.metadata.id) { + fail_mod(mod, MOD_CONFLICT, + fmt::format("Mod ID changed on reload ('{}'); restart required", newMetadata.id)); + return false; + } + + mod.metadata = std::move(newMetadata); + // In-flight readers of the old bundle keep it alive through their shared_ptr. + mod.bundle = std::move(newBundle); + mod.loadFailed = false; + mod.failureReason.clear(); + + ModManifestInfo newInfo; + if (const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); anyLibs) { + mod.nativeStatus = NativeModStatus::Unknown; + load_native(mod, dllEntry); + if (mod.nativeStatus != NativeModStatus::Loaded) { + fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); + return false; + } + newInfo = build_manifest_info(*mod.native->manifest); + } else { + mod.nativeStatus = NativeModStatus::None; + ++mod.cacheGeneration; + } + + if (newInfo != mod.manifestInfo) { + // The reload changes the mod's imports/exports; rebuild the dependency graph so edges, + // init/tick/shutdown order and cascade sets reflect the new manifest. + log::write(mod.metadata.id, LOG_LEVEL_INFO, + "changed its service imports/exports; rebuilding mod dependency graph"); + mod.manifestInfo = std::move(newInfo); + loader::sort_mods(m_mods); + } + + return true; +} + +void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { + auto affected = collect_lifecycle_set(target); + + // Dependents first (reverse init order), like shutdown. + for (auto* mod : affected | std::views::reverse) { + const bool needsTeardown = + mod->active || + (mod == &target && (mod->initialized || (reload && mod->native != nullptr))); + if (!needsTeardown) { + continue; + } + const bool wasActive = mod->active; + log::write(mod->metadata.id, LOG_LEVEL_INFO, "deactivating mod"); + deactivate_mod(*mod); + if (mod != &target && wasActive) { + // Provisional; cleared below if the mod comes straight back up. + mod->suspendedByProvider = true; + } + } + + if (reload) { + // On failure the target is failed and stays down; dependents get resume attempts below + // and suspend against the failed provider where required. + reload_bundle(target); + + // The reload may have rebuilt the dependency graph and reordered m_mods; refresh the + // set's iteration order so reactivation still runs providers first. + std::vector reordered; + reordered.reserve(affected.size()); + for (auto& mod : mods()) { + if (std::ranges::find(affected, &mod) != affected.end()) { + reordered.push_back(&mod); + } + } + affected = std::move(reordered); + } + + // Mirror startup: publish every candidate's static exports before any of them initialize, + // so importers within the set resolve providers regardless of initialization order + // (optional cycles rely on this). + for (auto* mod : affected) { + if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { + continue; + } + if (!ensure_native_loaded(*mod)) { + continue; + } + if (mod->native && !mod->servicesRegistered) { + if (register_static_service_exports(*mod)) { + mod->servicesRegistered = true; + } else { + log::write( + mod->metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); + deactivate_mod(*mod); + } + } + } + + // Providers first (init order). The target is naturally first among the affected mods. + for (auto* mod : affected) { + if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { + continue; + } + if (!required_deps_active(*mod)) { + mod->suspendedByProvider = true; + log::write( + mod->metadata.id, LOG_LEVEL_INFO, "suspended: a required provider is disabled"); + continue; + } + mod->suspendedByProvider = false; + activate_mod(*mod); + } + + // Mods that stayed down must not leave their exports resolvable. + for (auto* mod : affected) { + if (!mod->active && mod->servicesRegistered) { + svc::remove_services_for_provider(*mod); + mod->servicesRegistered = false; + } + } +} + +void ModLoader::on_enabled_changed(LoadedMod& mod) { + svc::config_mark_dirty(); + if (mod.loadFailed) { + if (!mod.cvarIsEnabled->getValue()) { + mod.loadFailed = false; + mod.failureReason.clear(); + } + return; + } + if (mod.suspendedByProvider) { + if (!mod.cvarIsEnabled->getValue()) { + // The user disabled a suspended mod; stop waiting for its providers. + mod.suspendedByProvider = false; + } + return; + } + m_pendingRequests.push_back({mod.metadata.id, + mod.cvarIsEnabled->getValue() ? RequestKind::Enable : RequestKind::Disable}); +} + +void ModLoader::apply_pending_requests() { + // Images retired by the previous tick have had a full frame to unwind off the stack. + drain_retired_natives(); + + if (m_pendingRequests.empty()) { + return; + } + + // Coalesce per mod, last request wins. Failures during apply re-enqueue for next tick. + const auto requests = std::exchange(m_pendingRequests, {}); + std::vector coalesced; + for (const auto& request : requests) { + const auto existing = std::ranges::find_if( + coalesced, [&](const Request& r) { return r.modId == request.modId; }); + if (existing != coalesced.end()) { + existing->kind = request.kind; + } else { + coalesced.push_back(request); + } + } + + for (const auto& request : coalesced) { + auto* mod = find_mod(request.modId); + if (mod == nullptr) { + Log.warn("lifecycle request for unknown mod '{}'", request.modId); + continue; + } + if (request.kind == RequestKind::Reload && mod->inPlace) { + log::write(mod->metadata.id, LOG_LEVEL_WARN, "is a built-in mod and can't be reloaded"); + continue; + } + if (request.kind == RequestKind::Enable && mod->enabledApplied) { + continue; + } + if (request.kind == RequestKind::Disable && !mod->enabledApplied && !mod->active) { + continue; + } + apply_lifecycle_change(*mod, request.kind == RequestKind::Reload); + } + + svc::modules_lifecycle_applied(); + + auto active = std::ranges::count_if(mods(), [](const LoadedMod& m) { return m.active; }); + Log.info("{}/{} mod(s) active", active, m_mods.size()); +} + +void ModLoader::tick() { + svc::modules_frame_begin(); + apply_pending_requests(); + + for (auto& mod : mods()) { + if (!mod.active || !mod.native) { + continue; + } + try { + ModError error = MOD_ERROR_INIT; + const auto result = mod.native->fn_update(&error); + if (result != MOD_OK) { + fail_mod(mod, result, lifecycle_error_message("mod_update", result, error)); + } + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("Exception in mod_update: {}", e.what())); + } catch (...) { + fail_mod(mod, MOD_ERROR, "Unknown exception in mod_update"); + } + } + + svc::modules_frame_end(); + flush_toasts(); +} + +void ModLoader::shutdown() { + // Reverse initialization order, so importers shut down before their service providers. + for (auto& mod : mods() | std::views::reverse) { + deactivate_mod(mod); + if (mod.enabledSubscription != 0) { + config::unsubscribe(mod.enabledSubscription); + mod.enabledSubscription = 0; + } + unregister(*mod.cvarIsEnabled); + } + + m_mods.clear(); + drain_retired_natives(); + svc::modules_shutdown(); + Log.info("all mods unloaded"); +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/loader.hpp b/src/dusk/mods/loader/loader.hpp new file mode 100644 index 0000000000..6b9fd39232 --- /dev/null +++ b/src/dusk/mods/loader/loader.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include "miniz.h" + +#include "dusk/mod_loader.hpp" + +namespace dusk::mods { + +#if DUSK_CODE_MODS +constexpr bool EnableCodeMods = true; +#else +constexpr bool EnableCodeMods = false; +#endif + +// Implementations must be safe for concurrent calls; bundle reads are not limited to the +// game thread. +class ModBundle { +public: + virtual ~ModBundle() = default; + + virtual std::vector readFile(const std::string& fileName) = 0; + virtual std::vector getFileNames() = 0; + virtual size_t getFileSize(const std::string& fileName) = 0; +}; + +class ModBundleZip final : public ModBundle { +public: + explicit ModBundleZip(std::vector&& data); + ~ModBundleZip() override; + std::vector readFile(const std::string& fileName) override; + std::vector getFileNames() override; + size_t getFileSize(const std::string& fileName) override; + +private: + std::vector zip_data; + mz_zip_archive res_zip{}; + bool res_zip_open = false; + std::mutex m_mutex; +}; + +class ModBundleDisk final : public ModBundle { +public: + explicit ModBundleDisk(std::filesystem::path root); + ~ModBundleDisk() override = default; + std::vector readFile(const std::string& fileName) override; + std::vector getFileNames() override; + size_t getFileSize(const std::string& fileName) override; + +private: + [[nodiscard]] std::filesystem::path toRealPath(const std::string& fileName) const; + std::filesystem::path root_path; +}; + +LoadedMod* mod_from_context(ModContext* context); +const LoadedMod* mod_from_context(const ModContext* context); +const char* mod_id_from_context(ModContext* context); +void fail_mod(LoadedMod& mod, ModResult code, std::string_view message); +bool is_safe_resource_path(std::string_view path); +std::string escape_mod_id_for_config(std::string_view id); + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/native_module.cpp b/src/dusk/mods/loader/native_module.cpp new file mode 100644 index 0000000000..edd3addb4f --- /dev/null +++ b/src/dusk/mods/loader/native_module.cpp @@ -0,0 +1,91 @@ +#include "native_module.hpp" + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#endif + +#if defined(__SANITIZE_ADDRESS__) +#define ADDRESS_SANITIZER 1 +#elif defined(__has_feature) +#if __has_feature(address_sanitizer) +#define ADDRESS_SANITIZER 1 +#endif +#endif + +namespace { +#if defined(_WIN32) +void* pl_dlopen(const std::filesystem::path& p) { + return LoadLibraryW(p.wstring().c_str()); +} +void* pl_dlsym(void* h, const char* name) { + return reinterpret_cast(GetProcAddress(static_cast(h), name)); +} +void pl_dlclose(void* h) { + FreeLibrary(static_cast(h)); +} +std::string pl_dlerror() { + char buf[256]{}; + FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, + GetLastError(), 0, buf, sizeof(buf), nullptr); + std::string s = buf; + while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) { + s.pop_back(); + } + return s; +} +#else +#include +void* pl_dlopen(const std::filesystem::path& p) { + int flags = RTLD_LAZY | RTLD_LOCAL; +#if defined(RTLD_DEEPBIND) && !defined(ADDRESS_SANITIZER) + flags |= RTLD_DEEPBIND; +#endif + return dlopen(p.c_str(), flags); +} +void* pl_dlsym(void* h, const char* name) { + return dlsym(h, name); +} +void pl_dlclose(void* h) { + dlclose(h); +} +std::string pl_dlerror() { + const char* e = dlerror(); + return e ? e : "(unknown error)"; +} +#endif +} + +namespace dusk::mods::loader { +NativeModule::NativeModule() noexcept : handle(nullptr) { +} + +NativeModule::NativeModule(NativeModule&& other) noexcept { + handle = other.handle; + other.handle = nullptr; +} + +NativeModule& NativeModule::operator=(NativeModule&& other) noexcept { + handle = other.handle; + other.handle = nullptr; + return *this; +} + +NativeModule::NativeModule(const std::filesystem::path& path) { + handle = pl_dlopen(path); + if (!handle) { + throw std::runtime_error(pl_dlerror()); + } +} + +NativeModule::~NativeModule() { + if (handle) { + pl_dlclose(handle); + } +} + +void* NativeModule::LookupSymbol(const char* name) const { + return pl_dlsym(handle, name); +} +} // namespace dusk::mods::loader diff --git a/src/dusk/mods/loader/native_module.hpp b/src/dusk/mods/loader/native_module.hpp new file mode 100644 index 0000000000..ff2acf9632 --- /dev/null +++ b/src/dusk/mods/loader/native_module.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace dusk::mods::loader { +class NativeModule final { +public: + NativeModule() noexcept; + NativeModule(const NativeModule& other) = delete; + NativeModule(NativeModule&& other) noexcept; + explicit NativeModule(const std::filesystem::path& path); + ~NativeModule(); + + void* LookupSymbol(const char* name) const; + + template + T LookupSymbol(const char* name) const { + return reinterpret_cast(LookupSymbol(name)); + } + + NativeModule& operator=(NativeModule&& other) noexcept; + +#if defined(_WIN32) + static constexpr auto LibraryExtension = ".dll"; +#elif defined(__APPLE__) + static constexpr auto LibraryExtension = ".dylib"; +#else + static constexpr auto LibraryExtension = ".so"; +#endif + +private: + void* handle; +}; + +} diff --git a/src/dusk/mods/log_buffer.cpp b/src/dusk/mods/log_buffer.cpp new file mode 100644 index 0000000000..bfee810767 --- /dev/null +++ b/src/dusk/mods/log_buffer.cpp @@ -0,0 +1,103 @@ +#include "log_buffer.hpp" + +#include +#include + +#include "dusk/logging.h" + +namespace dusk::mods::log { +namespace { + +struct BufferState { + std::mutex mutex; + std::vector ring; + size_t head = 0; + size_t count = 0; + uint64_t nextSeq = 0; + std::vector modIds; + + uint16_t intern(std::string_view modId) { + for (size_t i = 0; i < modIds.size(); ++i) { + if (modIds[i] == modId) { + return static_cast(i); + } + } + modIds.emplace_back(modId); + return static_cast(modIds.size() - 1); + } +}; +BufferState g_buffer; + +} // namespace + +void emit(Source source, const std::string& modId, LogLevel level, const std::string& message) { + const auto timeMs = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + + { + std::lock_guard lock{g_buffer.mutex}; + if (g_buffer.ring.empty()) { + g_buffer.ring.resize(k_capacity); + } + auto& slot = g_buffer.ring[(g_buffer.head + g_buffer.count) % k_capacity]; + if (g_buffer.count == k_capacity) { + g_buffer.head = (g_buffer.head + 1) % k_capacity; + } else { + ++g_buffer.count; + } + slot.seq = g_buffer.nextSeq++; + slot.timeMs = timeMs; + slot.level = level; + slot.modIndex = g_buffer.intern(modId); + slot.source = source; + // assign() reuses the slot's capacity once the ring has wrapped + slot.message.assign(message); + } + + AuroraLogLevel auroraLevel = LOG_INFO; + switch (level) { + case LOG_LEVEL_TRACE: + case LOG_LEVEL_DEBUG: + auroraLevel = LOG_DEBUG; + break; + case LOG_LEVEL_INFO: + auroraLevel = LOG_INFO; + break; + case LOG_LEVEL_WARN: + auroraLevel = LOG_WARNING; + break; + case LOG_LEVEL_ERROR: + auroraLevel = LOG_ERROR; + break; + } + if (aurora::g_config.logLevel <= auroraLevel) { + aurora::log_internal(auroraLevel, modId.c_str(), message.c_str(), + static_cast(message.length())); + } +} + +Range copy_since(uint64_t sinceSeq, std::vector& out) { + std::lock_guard lock{g_buffer.mutex}; + const Range range{ + .firstSeq = g_buffer.nextSeq - g_buffer.count, + .nextSeq = g_buffer.nextSeq, + }; + for (uint64_t seq = std::max(sinceSeq, range.firstSeq); seq < range.nextSeq; ++seq) { + out.push_back(g_buffer.ring[(g_buffer.head + (seq - range.firstSeq)) % k_capacity]); + } + return range; +} + +std::vector ids() { + std::lock_guard lock{g_buffer.mutex}; + return g_buffer.modIds; +} + +void clear() { + std::lock_guard lock{g_buffer.mutex}; + g_buffer.head = 0; + g_buffer.count = 0; +} + +} // namespace dusk::mods::log diff --git a/src/dusk/mods/log_buffer.hpp b/src/dusk/mods/log_buffer.hpp new file mode 100644 index 0000000000..33a143705d --- /dev/null +++ b/src/dusk/mods/log_buffer.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "mods/svc/log.h" + +namespace dusk::mods::log { + +constexpr size_t k_capacity = 2048; + +enum class Source : uint8_t { + Loader, + Mod, +}; + +struct Line { + uint64_t seq = 0; // monotonic, never reset (survives clear) + int64_t timeMs = 0; + LogLevel level = LOG_LEVEL_INFO; + uint16_t modIndex = 0; // index into ids() + Source source = Source::Loader; + std::string message; +}; + +struct Range { + uint64_t firstSeq = 0; // oldest retained entry + uint64_t nextSeq = 0; // one past the newest entry +}; + +// Appends to the mod log buffer and emits through aurora logging with the mod ID as the +// module tag. Console/file output honors the configured log level; the buffer captures +// every level. Thread-safe; mods may log from worker threads. +void emit(Source source, const std::string& modId, LogLevel level, const std::string& message); + +// Appends entries with seq >= sinceSeq to `out` and returns the retained range, +// so callers diffing by seq can detect both new entries and truncation. +Range copy_since(uint64_t sinceSeq, std::vector& out); + +// Append-only intern table of mod ids; indices are stable for the session. +std::vector ids(); + +void clear(); + +// Formatting helper for loader messages. +template +void write(const std::string& modId, LogLevel level, fmt::format_string format, T&&... args) { + emit(Source::Loader, modId, level, fmt::format(format, std::forward(args)...)); +} + +} // namespace dusk::mods::log diff --git a/src/dusk/mods/manifest.cpp b/src/dusk/mods/manifest.cpp new file mode 100644 index 0000000000..7e83dfb8f6 --- /dev/null +++ b/src/dusk/mods/manifest.cpp @@ -0,0 +1,386 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include "manifest.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "aurora/lib/logging.hpp" + +#include "dusk/io.hpp" + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#elif defined(__APPLE__) +#include +#include +#elif defined(__linux__) +#include +#include +#endif + +namespace dusk::mods::manifest { +namespace { + +aurora::Module Log("dusk::mods::manifest"); + +constexpr char kMagic[8] = {'S', 'Y', 'M', 'G', 'E', 'N', '\0', '\0'}; +constexpr uint32_t kVersion = 2; + +enum class Compression : uint32_t { + None = 0, + Zstd = 1, +}; + +// Mirrors the symgen manifest writer. +struct Header { + char magic[8]; + uint32_t version; + uint32_t compression; + uint64_t uncompressedLen; + uint64_t compressedLen; + uint32_t buildIdLen; + uint8_t buildId[32]; + uint32_t entryCount; +}; +static_assert(sizeof(Header) == 72); + +struct Entry { + uint64_t hash; + uint64_t rva; + uint32_t nameOff; + HookSymbolFlags flags; +}; +static_assert(sizeof(Entry) == 24); + +struct State { + std::vector data; + const Entry* entries = nullptr; + uint32_t entryCount = 0; + const char* strings = nullptr; + uint64_t stringsLen = 0; + uintptr_t imageBase = 0; + // (rva, nameOff) of entries flagged kFlagInlineSites, sorted by rva + std::vector > inlineSites; + bool loaded = false; + bool initialized = false; +}; +State s_state; + +uint64_t fnv1a64(const char* str) { + uint64_t hash = 0xcbf29ce484222325ull; + for (const char* p = str; *p != '\0'; ++p) { + hash ^= static_cast(*p); + hash *= 0x100000001b3ull; + } + return hash; +} + +// Build id of the running executable image, matching what symgen recorded: +// PDB GUID (RFC 4122 byte order) + age on Windows, LC_UUID on Mach-O, GNU +// build-id on ELF. Also reports the address RVAs are relative to. +bool running_image_identity(std::vector& outId, uintptr_t& outBase) { +#if defined(_WIN32) + auto* base = reinterpret_cast(GetModuleHandleW(nullptr)); + outBase = reinterpret_cast(base); + const auto* dos = reinterpret_cast(base); + const auto* nt = reinterpret_cast(base + dos->e_lfanew); + const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG]; + if (dir.VirtualAddress == 0) { + return false; + } + const auto* entries = reinterpret_cast(base + dir.VirtualAddress); + for (size_t i = 0; i < dir.Size / sizeof(IMAGE_DEBUG_DIRECTORY); ++i) { + if (entries[i].Type != IMAGE_DEBUG_TYPE_CODEVIEW) { + continue; + } + struct CvInfo { + uint32_t signature; // 'RSDS' + uint8_t guid[16]; + uint32_t age; + }; + if (entries[i].SizeOfData < sizeof(CvInfo)) { + continue; + } + const auto* cv = reinterpret_cast(base + entries[i].AddressOfRawData); + if (cv->signature != 0x53445352) { // "RSDS" + continue; + } + // The GUID struct stores Data1..Data3 little-endian in memory; the manifest + // stores RFC 4122 (big-endian) order, so swap them here. + outId.assign(cv->guid, cv->guid + 16); + std::swap(outId[0], outId[3]); + std::swap(outId[1], outId[2]); + std::swap(outId[4], outId[5]); + std::swap(outId[6], outId[7]); + for (int b = 0; b < 4; ++b) { + outId.push_back(static_cast(cv->age >> (8 * b))); + } + return true; + } + return false; +#elif defined(__APPLE__) + // Image 0 is the main executable. The manifest stores link-time vmaddrs + // (nm convention, __TEXT vmaddr included). + const auto* header = _dyld_get_image_header(0); + outBase = static_cast(_dyld_get_image_vmaddr_slide(0)); + const auto* header64 = reinterpret_cast(header); + const auto* cmd = reinterpret_cast(header64 + 1); + for (uint32_t i = 0; i < header64->ncmds; ++i) { + if (cmd->cmd == LC_UUID) { + const auto* uuidCmd = reinterpret_cast(cmd); + outId.assign(uuidCmd->uuid, uuidCmd->uuid + 16); + return true; + } + cmd = reinterpret_cast( + reinterpret_cast(cmd) + cmd->cmdsize); + } + return false; +#elif defined(__linux__) + struct Ctx { + std::vector* id; + uintptr_t base = 0; + bool found = false; + } ctx{&outId}; + dl_iterate_phdr( + [](dl_phdr_info* info, size_t, void* data) -> int { + auto* ctx = static_cast(data); + // The first callback is the main executable. + ctx->base = info->dlpi_addr; + for (int i = 0; i < info->dlpi_phnum; ++i) { + const auto& phdr = info->dlpi_phdr[i]; + if (phdr.p_type != PT_NOTE) { + continue; + } + const auto* p = reinterpret_cast(info->dlpi_addr + phdr.p_vaddr); + const auto* end = p + phdr.p_memsz; + while (p + sizeof(ElfW(Nhdr)) <= end) { + const auto* note = reinterpret_cast(p); + const auto* name = p + sizeof(ElfW(Nhdr)); + const auto* desc = name + ((note->n_namesz + 3) & ~3u); + if (note->n_type == NT_GNU_BUILD_ID && note->n_namesz == 4 && + std::memcmp(name, "GNU", 4) == 0) + { + ctx->id->assign(desc, desc + note->n_descsz); + ctx->found = true; + return 1; + } + p = desc + ((note->n_descsz + 3) & ~3u); + } + } + return 1; // only inspect the main executable + }, + &ctx); + outBase = ctx.base; + return ctx.found; +#else + (void)outId; + (void)outBase; + return false; +#endif +} + +std::filesystem::path manifest_path() { + const char* basePath = SDL_GetBasePath(); + std::filesystem::path dir = + basePath != nullptr ? std::filesystem::path{basePath} : std::filesystem::current_path(); + return dir / "dusklight.symdb"; +} + +std::string hex_string(const uint8_t* data, size_t len) { + std::string out; + out.reserve(len * 2); + for (size_t i = 0; i < len; ++i) { + constexpr char kHex[] = "0123456789abcdef"; + out.push_back(kHex[data[i] >> 4]); + out.push_back(kHex[data[i] & 0xF]); + } + return out; +} + +} // namespace + +void initialize() { + if (s_state.initialized) { + return; + } + s_state.initialized = true; + + const auto path = manifest_path(); + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + Log.info("no symbol manifest at {}; by-name resolution unavailable", + io::fs_path_to_string(path)); + return; + } + std::vector data; + try { + data = io::FileStream::ReadAllBytes(path); + } catch (const std::exception& e) { + Log.error("failed to read symbol manifest {}: {}", io::fs_path_to_string(path), e.what()); + return; + } + if (data.size() < sizeof(Header)) { + Log.error( + "symbol manifest {} is truncated ({} bytes)", io::fs_path_to_string(path), data.size()); + return; + } + + Header header{}; + std::memcpy(&header, data.data(), sizeof(header)); + if (std::memcmp(header.magic, kMagic, sizeof(kMagic)) != 0 || header.version != kVersion) { + Log.error("symbol manifest {} has wrong magic/version", io::fs_path_to_string(path)); + return; + } + const auto compression = static_cast(header.compression); + if ((compression != Compression::None && compression != Compression::Zstd) || + header.buildIdLen > sizeof(header.buildId) || + header.compressedLen > data.size() - sizeof(Header) || + header.uncompressedLen > std::numeric_limits::max() || + (compression == Compression::None && header.compressedLen != header.uncompressedLen)) + { + Log.error("symbol manifest {} is malformed", io::fs_path_to_string(path)); + return; + } + + std::vector imageId; + uintptr_t imageBase = 0; + if (!running_image_identity(imageId, imageBase)) { + Log.error("cannot determine the running image's build id; ignoring symbol manifest"); + return; + } + if (imageId.size() != header.buildIdLen || + std::memcmp(imageId.data(), header.buildId, imageId.size()) != 0) + { + Log.error("symbol manifest {} is stale: built for {}, running image is {}", + io::fs_path_to_string(path), hex_string(header.buildId, header.buildIdLen), + hex_string(imageId.data(), imageId.size())); + return; + } + + const auto compressedLen = static_cast(header.compressedLen); + const auto uncompressedLen = static_cast(header.uncompressedLen); + std::vector payload; + const auto* storedPayload = data.data() + sizeof(Header); + if (compression == Compression::None) { + payload.assign(storedPayload, storedPayload + compressedLen); + } else { + payload.resize(uncompressedLen); + const size_t decompressedLen = + ZSTD_decompress(payload.data(), payload.size(), storedPayload, compressedLen); + if (ZSTD_isError(decompressedLen)) { + Log.error("failed to decompress symbol manifest {}: {}", io::fs_path_to_string(path), + ZSTD_getErrorName(decompressedLen)); + return; + } + if (decompressedLen != payload.size()) { + Log.error("symbol manifest {} decompressed to {} bytes, expected {}", + io::fs_path_to_string(path), decompressedLen, payload.size()); + return; + } + } + data = std::move(payload); + + const uint64_t entriesEnd = uint64_t{header.entryCount} * sizeof(Entry); + if (entriesEnd > data.size()) { + Log.error("decompressed symbol manifest {} is malformed", io::fs_path_to_string(path)); + return; + } + + s_state.data = std::move(data); + s_state.entries = reinterpret_cast(s_state.data.data()); + s_state.entryCount = header.entryCount; + s_state.strings = reinterpret_cast(s_state.data.data() + entriesEnd); + s_state.stringsLen = s_state.data.size() - entriesEnd; + s_state.imageBase = imageBase; + for (uint32_t i = 0; i < s_state.entryCount; ++i) { + const Entry& entry = s_state.entries[i]; + if ((entry.flags & kFlagInlineSites) != 0 && entry.nameOff < s_state.stringsLen) { + s_state.inlineSites.emplace_back(entry.rva, entry.nameOff); + } + } + std::sort(s_state.inlineSites.begin(), s_state.inlineSites.end()); + s_state.inlineSites.erase(std::unique(s_state.inlineSites.begin(), s_state.inlineSites.end(), + [](const auto& a, const auto& b) { return a.first == b.first; }), + s_state.inlineSites.end()); + s_state.loaded = true; + Log.info("symbol manifest loaded: {} symbols, build id {}", s_state.entryCount, + hex_string(header.buildId, header.buildIdLen)); +} + +bool available() { + return s_state.loaded; +} + +const std::vector& image_build_id() { + static const std::vector s_id = [] { + std::vector id; + uintptr_t base = 0; + running_image_identity(id, base); + return id; + }(); + return s_id; +} + +ResolveStatus resolve(const char* name, void** outAddr, HookSymbolFlags* outFlags) { + if (!s_state.loaded) { + return ResolveStatus::Unavailable; + } + const uint64_t hash = fnv1a64(name); + const Entry* begin = s_state.entries; + const Entry* end = begin + s_state.entryCount; + size_t lo = 0; + size_t hi = s_state.entryCount; + while (lo < hi) { + const size_t mid = lo + (hi - lo) / 2; + if (begin[mid].hash < hash) { + lo = mid + 1; + } else { + hi = mid; + } + } + for (const Entry* entry = begin + lo; entry != end && entry->hash == hash; ++entry) { + if (entry->nameOff >= s_state.stringsLen || + std::strcmp(s_state.strings + entry->nameOff, name) != 0) + { + continue; + } + if ((entry->flags & kFlagDupName) != 0) { + return ResolveStatus::Ambiguous; + } + *outAddr = reinterpret_cast(s_state.imageBase + entry->rva); + if (outFlags != nullptr) { + *outFlags = entry->flags; + } + return ResolveStatus::Ok; + } + return ResolveStatus::NotFound; +} + +bool has_inline_sites(const void* addr, const char** outName) { + if (!s_state.loaded || s_state.inlineSites.empty()) { + return false; + } + const auto rva = static_cast(reinterpret_cast(addr) - s_state.imageBase); + const auto it = std::lower_bound(s_state.inlineSites.begin(), s_state.inlineSites.end(), + std::pair{rva, 0}); + if (it == s_state.inlineSites.end() || it->first != rva) { + return false; + } + if (outName != nullptr) { + *outName = s_state.strings + it->second; + } + return true; +} + +} // namespace dusk::mods::manifest diff --git a/src/dusk/mods/manifest.hpp b/src/dusk/mods/manifest.hpp new file mode 100644 index 0000000000..c6bc03f038 --- /dev/null +++ b/src/dusk/mods/manifest.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +#include "mods/svc/hook.h" + +namespace dusk::mods::manifest { + +// Symbol flags mirrored from symgen. +constexpr uint32_t kFlagCode = 1u << 0; +constexpr uint32_t kFlagData = 1u << 1; +constexpr uint32_t kFlagLocal = 1u << 2; +constexpr uint32_t kFlagMultiName = 1u << 3; +constexpr uint32_t kFlagDupName = 1u << 4; +constexpr uint32_t kFlagInlineSites = 1u << 5; +constexpr uint32_t kFlagDisplay = 1u << 6; + +enum class ResolveStatus { + Ok, + Unavailable, // no manifest loaded (missing, stale, or malformed) + NotFound, + Ambiguous, // name maps to multiple addresses (overloads / per-TU statics) +}; + +// Maps the symbol manifest next to the game binary and validates it against the +// running image's build id (PDB GUID+age / Mach-O UUID / GNU build-id). A missing or +// stale manifest logs and leaves by-name resolution unavailable; hooks by address are +// unaffected. Safe to call more than once. +void initialize(); + +bool available(); + +// Build id of the running executable image (PDB GUID+age / Mach-O UUID / GNU +// build-id), computed once on first use; empty if it couldn't be determined. +// Independent of whether a manifest file was loaded. +const std::vector& image_build_id(); + +// Resolve a symbol name to its address in the running image. Names can be either the platform's +// mangled name (i.e. the name passed to dlopen; no Mach-O leading underscore) or the function name +// without parameters (e.g. "daAlink_c::execute"). +ResolveStatus resolve(const char* name, void** outAddr, HookSymbolFlags* outFlags = nullptr); + +// True if the manifest records that the function at this code address was inlined into +// at least one caller in this build. An entry hook on it only intercepts the calls +// that were not inlined. outName receives the symbol name (valid for the process lifetime) +// when known. False when no manifest is loaded. +bool has_inline_sites(const void* addr, const char** outName = nullptr); + +} // namespace dusk::mods::manifest diff --git a/src/dusk/mods/svc/camera.cpp b/src/dusk/mods/svc/camera.cpp new file mode 100644 index 0000000000..37057e2397 --- /dev/null +++ b/src/dusk/mods/svc/camera.cpp @@ -0,0 +1,134 @@ +#include "registry.hpp" + +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/camera.h" + +#include "f_op/f_op_view.h" +#include "m_Do/m_Do_mtx.h" + +#include +#include + +namespace dusk::mods::svc { +namespace { + +void to_column_major(const Mtx44 in, float out[16]) { + for (int c = 0; c < 4; ++c) { + for (int r = 0; r < 4; ++r) { + out[c * 4 + r] = in[r][c]; + } + } +} + +void store_affine(const Mtx in, float out[16]) { + for (int c = 0; c < 4; ++c) { + for (int r = 0; r < 3; ++r) { + out[c * 4 + r] = in[r][c]; + } + out[c * 4 + 3] = 0.0f; + } + out[15] = 1.0f; +} + +/* affine * 4x4; operand-order mirror of cMtx_concatProjView */ +void concat_affine_proj(const Mtx a, const Mtx44 b, Mtx44 out) { + for (int r = 0; r < 3; ++r) { + for (int c = 0; c < 4; ++c) { + out[r][c] = + a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c] + a[r][3] * b[3][c]; + } + } + std::memcpy(out[3], b[3], sizeof(f32) * 4); +} + +ModResult snapshot_view(const view_class* view, CameraInfo* outInfo) { + if (view == nullptr || !(view->near_ > 0.0f) || !(view->far_ > view->near_) || + !(view->fovy > 0.0f) || view->fovy >= 180.0f || !(view->aspect > 0.0f)) + { + return MOD_UNAVAILABLE; + } + + // Build from the GXSetProjection values + const f32 p00 = view->projMtx[0][0]; + const f32 p02 = view->projMtx[0][2]; + const f32 p11 = view->projMtx[1][1]; + const f32 p12 = view->projMtx[1][2]; + const f32 p22 = view->projMtx[2][2]; + const f32 p23 = view->projMtx[2][3]; + if (view->projMtx[3][2] != -1.0f || p00 == 0.0f || p11 == 0.0f || p23 == 0.0f) { + return MOD_UNAVAILABLE; + } + + // WebGPU-convention projection + Aurora reversed Z + const bool reversedZ = aurora::gfx::uses_reversed_z(); + const f32 e = reversedZ ? -p22 : p22 - 1.0f; + const f32 f = reversedZ ? -p23 : p23; + Mtx44 proj{}; + proj[0][0] = p00; + proj[0][2] = p02; + proj[1][1] = p11; + proj[1][2] = p12; + proj[2][2] = e; + proj[2][3] = f; + proj[3][2] = -1.0f; + + // Analytic inverse of the sparse perspective form + Mtx44 invProj{}; + invProj[0][0] = 1.0f / p00; + invProj[0][3] = p02 / p00; + invProj[1][1] = 1.0f / p11; + invProj[1][3] = p12 / p11; + invProj[2][3] = -1.0f; + invProj[3][2] = 1.0f / f; + invProj[3][3] = e / f; + + Mtx44 projWorld; + cMtx_concatProjView(proj, view->viewMtx, projWorld); + Mtx44 worldProj; + concat_affine_proj(view->invViewMtx, invProj, worldProj); + + store_affine(view->viewMtx, outInfo->view_from_world); + store_affine(view->invViewMtx, outInfo->world_from_view); + to_column_major(proj, outInfo->proj_from_view); + to_column_major(invProj, outInfo->view_from_proj); + to_column_major(projWorld, outInfo->proj_from_world); + to_column_major(worldProj, outInfo->world_from_proj); + + outInfo->eye[0] = view->lookat.eye.x; + outInfo->eye[1] = view->lookat.eye.y; + outInfo->eye[2] = view->lookat.eye.z; + outInfo->fovy = view->fovy; + outInfo->aspect = view->aspect; + outInfo->near_plane = view->near_; + outInfo->far_plane = view->far_; + return MOD_OK; +} + +ModResult camera_get_camera_from_view( + ModContext* context, const void* gameView, CameraInfo* outInfo) { + if (outInfo == nullptr || outInfo->struct_size < sizeof(CameraInfo) || + mod_from_context(context) == nullptr || gameView == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + const uint32_t structSize = outInfo->struct_size; + std::memset(outInfo, 0, sizeof(CameraInfo)); + outInfo->struct_size = structSize; + return snapshot_view(static_cast(gameView), outInfo); +} + +constexpr CameraService s_cameraService{ + .header = SERVICE_HEADER(CameraService, CAMERA_SERVICE_MAJOR, CAMERA_SERVICE_MINOR), + .get_camera = camera_get_camera_from_view, +}; + +} // namespace + +constinit const ServiceModule g_cameraModule{ + .id = CAMERA_SERVICE_ID, + .majorVersion = CAMERA_SERVICE_MAJOR, + .minorVersion = CAMERA_SERVICE_MINOR, + .service = &s_cameraService, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/config.cpp b/src/dusk/mods/svc/config.cpp new file mode 100644 index 0000000000..bf943d61b8 --- /dev/null +++ b/src/dusk/mods/svc/config.cpp @@ -0,0 +1,434 @@ +#include "config.hpp" + +#include "registry.hpp" +#include "slot_map.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/config.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::config"); + +enum class ConfigSlotKind : uint8_t { + Var, + Subscription, +}; + +struct ConfigSlot { + ConfigSlotKind kind = ConfigSlotKind::Var; + // Var payload + ConfigVarType type = CONFIG_VAR_BOOL; + std::unique_ptr var; + // Subscription payload + uint64_t varHandle = 0; + config::Subscription coreSubscription = 0; +}; + +SlotMap s_slots; +bool s_dirty = false; +std::chrono::steady_clock::time_point s_lastSave{}; +constexpr std::chrono::seconds kSaveDebounce{2}; + +void config_flush_if_dirty(const bool force) { + if (!s_dirty) { + return; + } + const auto now = std::chrono::steady_clock::now(); + if (!force && now - s_lastSave < kSaveDebounce) { + return; + } + s_dirty = false; + s_lastSave = now; + config::save(); +} + +// Translate the type-erased previous-value pointer into the C ABI snapshot struct. The string +// pointer aliases the previous std::string, which outlives the notification. +ConfigVarValue translate_previous(const uint32_t type, const void* previous) { + ConfigVarValue value{}; + value.struct_size = sizeof(ConfigVarValue); + value.type = static_cast(type); + switch (type) { + case CONFIG_VAR_BOOL: + value.bool_value = *static_cast(previous); + break; + case CONFIG_VAR_INT: + value.int_value = *static_cast(previous); + break; + case CONFIG_VAR_FLOAT: + value.float_value = *static_cast(previous); + break; + case CONFIG_VAR_STRING: { + const auto* str = static_cast(previous); + value.string_value = str->c_str(); + value.string_length = str->size(); + break; + } + default: + break; + } + return value; +} + +// Snapshot the var's current (new) value for the notification. Strings are copied into +// stringStorage so the snapshot stays valid even if the callback writes the var again. +ConfigVarValue translate_current( + const uint32_t type, config::ConfigVarBase& varBase, std::string& stringStorage) { + ConfigVarValue value{}; + value.struct_size = sizeof(ConfigVarValue); + value.type = static_cast(type); + switch (type) { + case CONFIG_VAR_BOOL: + value.bool_value = static_cast&>(varBase).getValue(); + break; + case CONFIG_VAR_INT: + value.int_value = static_cast&>(varBase).getValue(); + break; + case CONFIG_VAR_FLOAT: + value.float_value = static_cast&>(varBase).getValue(); + break; + case CONFIG_VAR_STRING: + stringStorage = static_cast&>(varBase).getValue(); + value.string_value = stringStorage.c_str(); + value.string_length = stringStorage.size(); + break; + default: + break; + } + return value; +} + +bool valid_var_fragment(const char* name) { + if (name == nullptr) { + return false; + } + const std::string_view fragment{name}; + if (fragment.empty() || fragment.size() > 64) { + return false; + } + return std::ranges::all_of(fragment, [](char ch) { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || + ch == '_' || ch == '-'; + }); +} + +config::ConfigVarBase* find_var(LoadedMod& mod, const uint64_t handle, uint32_t expectedType) { + const auto* entry = s_slots.find_owned(handle, mod); + if (entry == nullptr || entry->value.kind != ConfigSlotKind::Var || + entry->value.type != expectedType) + { + return nullptr; + } + return entry->value.var.get(); +} + +template +ConfigVar* find_typed_var(ModContext* context, ConfigVarHandle handle, uint32_t type) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return nullptr; + } + // The type tag was checked, so the downcast is safe. + return static_cast*>(find_var(*mod, handle, type)); +} + +void config_remove_mod(LoadedMod& mod) { + const auto entries = s_slots.take_all(mod); + for (const auto& entry : entries) { + if (entry.value.kind == ConfigSlotKind::Subscription) { + config::unsubscribe(entry.value.coreSubscription); + } + } + for (const auto& entry : entries) { + if (entry.value.kind == ConfigSlotKind::Var) { + config::unregister(*entry.value.var); + } + } +} + +ModResult config_register_var( + ModContext* context, const ConfigVarDesc* desc, ConfigVarHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(ConfigVarDesc) || + !valid_var_fragment(desc->name)) + { + return MOD_INVALID_ARGUMENT; + } + + const auto fullName = + fmt::format("mod.{}.{}", escape_mod_id_for_config(mod->metadata.id), desc->name); + if (config::GetConfigVar(fullName) != nullptr) { + Log.error("[{}] config var '{}' conflicts with an existing config key", mod->metadata.id, + fullName); + return MOD_CONFLICT; + } + + std::unique_ptr var; + switch (desc->type) { + case CONFIG_VAR_BOOL: + var = std::make_unique>(fullName, desc->default_bool); + break; + case CONFIG_VAR_INT: + var = std::make_unique>(fullName, desc->default_int); + break; + case CONFIG_VAR_FLOAT: + var = std::make_unique>(fullName, desc->default_float); + break; + case CONFIG_VAR_STRING: + var = std::make_unique>( + fullName, desc->default_string != nullptr ? desc->default_string : ""); + break; + default: + return MOD_INVALID_ARGUMENT; + } + + // Back-fills a stashed/saved value (or a --cvar override) if one exists for this key. + // Loads apply silently: the registering mod cannot have subscribed to this var yet, and it + // reads the value right after registration anyway. + config::Register(*var); + + const auto handle = s_slots.emplace( + *mod, ConfigSlot{.kind = ConfigSlotKind::Var, .type = desc->type, .var = std::move(var)}); + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult config_unregister_var(ModContext* context, ConfigVarHandle var) { + auto* mod = mod_from_context(context); + if (mod == nullptr || var == 0) { + return MOD_INVALID_ARGUMENT; + } + const auto* entry = s_slots.find_owned(var, *mod); + if (entry == nullptr || entry->value.kind != ConfigSlotKind::Var) { + Log.error("[{}] config unregister failed: unknown handle {}", mod->metadata.id, var); + return MOD_INVALID_ARGUMENT; + } + + // Only the owning mod can (currently) subscribe to a var + std::vector bound; + s_slots.for_each([&](const uint64_t handle, const auto& e) { + if (e.value.kind == ConfigSlotKind::Subscription && e.value.varHandle == var) { + bound.push_back(handle); + } + }); + for (const auto handle : bound) { + config::unsubscribe(s_slots.take(handle)->value.coreSubscription); + } + + // The persisted value is stashed and restored by a future registration of the same name. + config::unregister(*s_slots.take(var)->value.var); + return MOD_OK; +} + +ModResult config_get_bool(ModContext* context, ConfigVarHandle var, bool* outValue) { + if (outValue != nullptr) { + *outValue = false; + } + auto* cvar = find_typed_var(context, var, CONFIG_VAR_BOOL); + if (cvar == nullptr || outValue == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outValue = cvar->getValue(); + return MOD_OK; +} + +ModResult config_set_bool(ModContext* context, ConfigVarHandle var, bool value) { + auto* cvar = find_typed_var(context, var, CONFIG_VAR_BOOL); + if (cvar == nullptr) { + return MOD_INVALID_ARGUMENT; + } + cvar->setValue(value); + config_mark_dirty(); + return MOD_OK; +} + +ModResult config_get_int(ModContext* context, ConfigVarHandle var, int64_t* outValue) { + if (outValue != nullptr) { + *outValue = 0; + } + auto* cvar = find_typed_var(context, var, CONFIG_VAR_INT); + if (cvar == nullptr || outValue == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outValue = cvar->getValue(); + return MOD_OK; +} + +ModResult config_set_int(ModContext* context, ConfigVarHandle var, int64_t value) { + auto* cvar = find_typed_var(context, var, CONFIG_VAR_INT); + if (cvar == nullptr) { + return MOD_INVALID_ARGUMENT; + } + cvar->setValue(value); + config_mark_dirty(); + return MOD_OK; +} + +ModResult config_get_float(ModContext* context, ConfigVarHandle var, double* outValue) { + if (outValue != nullptr) { + *outValue = 0.0; + } + auto* cvar = find_typed_var(context, var, CONFIG_VAR_FLOAT); + if (cvar == nullptr || outValue == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outValue = cvar->getValue(); + return MOD_OK; +} + +ModResult config_set_float(ModContext* context, ConfigVarHandle var, double value) { + auto* cvar = find_typed_var(context, var, CONFIG_VAR_FLOAT); + if (cvar == nullptr) { + return MOD_INVALID_ARGUMENT; + } + cvar->setValue(value); + config_mark_dirty(); + return MOD_OK; +} + +ModResult config_get_string( + ModContext* context, ConfigVarHandle var, char* buffer, size_t bufferSize, size_t* outLength) { + if (outLength != nullptr) { + *outLength = 0; + } + auto* cvar = find_typed_var(context, var, CONFIG_VAR_STRING); + if (cvar == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto& value = cvar->getValue(); + if (outLength != nullptr) { + *outLength = value.size(); + } + if (buffer == nullptr) { + // Length query; any other use of a null buffer is a caller bug. + return bufferSize == 0 ? MOD_OK : MOD_INVALID_ARGUMENT; + } + if (bufferSize < value.size() + 1) { + return MOD_INVALID_ARGUMENT; + } + std::memcpy(buffer, value.c_str(), value.size() + 1); + return MOD_OK; +} + +ModResult config_set_string(ModContext* context, ConfigVarHandle var, const char* value) { + auto* cvar = find_typed_var(context, var, CONFIG_VAR_STRING); + if (cvar == nullptr || value == nullptr) { + return MOD_INVALID_ARGUMENT; + } + cvar->setValue(std::string{value}); + config_mark_dirty(); + return MOD_OK; +} + +ModResult config_subscribe(ModContext* context, ConfigVarHandle var, ConfigChangedFn callback, + void* userData, ConfigSubscriptionHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || var == 0 || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto* entry = s_slots.find_owned(var, *mod); + if (entry == nullptr || entry->value.kind != ConfigSlotKind::Var) { + return MOD_INVALID_ARGUMENT; + } + + const auto coreSubscription = config::subscribe(entry->value.var->getName(), + [modPtr = mod, callback, userData, varHandle = var, type = entry->value.type]( + config::ConfigVarBase& varBase, const void* previous) { + const ConfigVarValue previousValue = translate_previous(type, previous); + std::string stringStorage; + const ConfigVarValue currentValue = translate_current(type, varBase, stringStorage); + try { + callback(modPtr->context.get(), varHandle, ¤tValue, &previousValue, userData); + } catch (const std::exception& e) { + fail_mod(*modPtr, MOD_ERROR, + fmt::format("Exception in config change callback: {}", e.what())); + } catch (...) { + fail_mod(*modPtr, MOD_ERROR, "Unknown exception in config change callback"); + } + }); + const auto handle = s_slots.emplace(*mod, ConfigSlot{ + .kind = ConfigSlotKind::Subscription, + .varHandle = var, + .coreSubscription = coreSubscription, + }); + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult config_unsubscribe(ModContext* context, ConfigSubscriptionHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + const auto* entry = s_slots.find_owned(handle, *mod); + if (entry == nullptr || entry->value.kind != ConfigSlotKind::Subscription) { + Log.error("[{}] config unsubscribe failed: unknown handle {}", mod->metadata.id, handle); + return MOD_INVALID_ARGUMENT; + } + config::unsubscribe(entry->value.coreSubscription); + s_slots.erase(handle); + return MOD_OK; +} + +constexpr ConfigService s_configService{ + .header = SERVICE_HEADER(ConfigService, CONFIG_SERVICE_MAJOR, CONFIG_SERVICE_MINOR), + .register_var = config_register_var, + .unregister_var = config_unregister_var, + .get_bool = config_get_bool, + .set_bool = config_set_bool, + .get_int = config_get_int, + .set_int = config_set_int, + .get_float = config_get_float, + .set_float = config_set_float, + .get_string = config_get_string, + .set_string = config_set_string, + .subscribe = config_subscribe, + .unsubscribe = config_unsubscribe, +}; + +} // namespace + +void config_mark_dirty() { + s_dirty = true; +} + +config::ConfigVarBase* config_find_var( + LoadedMod& mod, const ConfigVarHandle handle, const uint32_t expectedType) { + return find_var(mod, handle, expectedType); +} + +constinit const ServiceModule g_configModule{ + .id = CONFIG_SERVICE_ID, + .majorVersion = CONFIG_SERVICE_MAJOR, + .minorVersion = CONFIG_SERVICE_MINOR, + .service = &s_configService, + .modDetached = config_remove_mod, + .frameEnd = [] { config_flush_if_dirty(false); }, + .shutdown = [] { config_flush_if_dirty(true); }, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/config.hpp b/src/dusk/mods/svc/config.hpp new file mode 100644 index 0000000000..b095c05ec6 --- /dev/null +++ b/src/dusk/mods/svc/config.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "dusk/config.hpp" +#include "mods/svc/config.h" + +namespace dusk::mods { +struct LoadedMod; +} // namespace dusk::mods + +namespace dusk::mods::svc { + +// Returns a config var owned by `mod` when the handle exists and its type matches. +config::ConfigVarBase* config_find_var( + LoadedMod& mod, ConfigVarHandle handle, uint32_t expectedType); + +// Marks the mods' config keys dirty for the config service's debounced save. The loader +// calls this for the enabled cvars it owns. +void config_mark_dirty(); + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/game.cpp b/src/dusk/mods/svc/game.cpp new file mode 100644 index 0000000000..f0bb8639a3 --- /dev/null +++ b/src/dusk/mods/svc/game.cpp @@ -0,0 +1,21 @@ +#include "registry.hpp" + +#include "mods/svc/game.h" + +namespace dusk::mods::svc { +namespace { + +constexpr GameService s_gameService{ + .header = SERVICE_HEADER(GameService, GAME_SERVICE_MAJOR, GAME_SERVICE_MINOR), +}; + +} // namespace + +constinit const ServiceModule g_gameModule{ + .id = GAME_SERVICE_ID, + .majorVersion = GAME_SERVICE_MAJOR, + .minorVersion = GAME_SERVICE_MINOR, + .service = &s_gameService, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/gfx.cpp b/src/dusk/mods/svc/gfx.cpp new file mode 100644 index 0000000000..e14b587f5e --- /dev/null +++ b/src/dusk/mods/svc/gfx.cpp @@ -0,0 +1,798 @@ +#include "registry.hpp" +#include "slot_map.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/gfx.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/gfx.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace dusk::mods { +namespace { + +aurora::Module Log("dusk::mods::gfx"); + +enum class GfxSlotKind : uint8_t { + DrawType, + StageHook, + ComputeType, +}; + +enum class GfxStreamBuffer : uint8_t { + Verts, + Indices, + Uniform, + Storage, +}; + +struct GfxSlot { + GfxSlotKind kind = GfxSlotKind::DrawType; + ModContext* ownerContext = nullptr; + std::string ownerId; + void* userData = nullptr; + + GfxDrawFn drawFn = nullptr; + aurora::gfx::DrawTypeId auroraDrawId = aurora::gfx::InvalidDrawType; + + GfxStageFn stageFn = nullptr; + GfxStage stage = GFX_STAGE_SCENE_AFTER_TERRAIN; + + GfxComputeFn computeFn = nullptr; + aurora::gfx::EncoderTaskId auroraTaskId = aurora::gfx::InvalidEncoderTask; +}; + +struct WorkerFailure { + std::string modId; + std::string message; + std::vector drawIds; + std::vector taskIds; +}; + +std::mutex s_mutex; +using GfxSlotMap = svc::SlotMap; +GfxSlotMap s_slots; +std::vector s_workerFailures; +bool s_modOffscreenOpen = false; + +GfxSlotMap::Entry* resolve_entry_locked(uint64_t handle, GfxSlotKind kind) { + auto* entry = s_slots.find(handle); + if (entry == nullptr || entry->value.kind != kind) { + return nullptr; + } + return entry; +} + +GfxSlot* resolve_slot_locked(uint64_t handle, GfxSlotKind kind) { + auto* entry = resolve_entry_locked(handle, kind); + return entry != nullptr ? &entry->value : nullptr; +} + +GfxSlot* resolve_owned_slot_locked(LoadedMod& mod, uint64_t handle, GfxSlotKind kind) { + auto* entry = s_slots.find_owned(handle, mod); + if (entry == nullptr || entry->value.kind != kind) { + return nullptr; + } + return &entry->value; +} + +void collect_mod_slots_locked(LoadedMod& owner, std::vector& drawIds, + std::vector& taskIds) { + auto entries = s_slots.take_all(owner); + for (auto& entry : entries) { + 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 && + slot.auroraTaskId != aurora::gfx::InvalidEncoderTask) + { + taskIds.push_back(slot.auroraTaskId); + } + } +} + +void unregister_aurora_types(const std::vector& drawIds, + const std::vector& taskIds) { + for (const auto id : drawIds) { + aurora::gfx::unregister_draw_type(id); + } + for (const auto id : taskIds) { + aurora::gfx::unregister_encoder_task_type(id); + } +} + +void draw_trampoline(const aurora::gfx::DrawContext& ctx, const wgpu::RenderPassEncoder& pass, + const void* payload, size_t payloadSize, void* userdata) { + const auto handle = static_cast(reinterpret_cast(userdata)); + GfxDrawFn fn = nullptr; + void* userData = nullptr; + ModContext* modContext = nullptr; + LoadedMod* owner = nullptr; + std::string ownerId; + { + std::lock_guard lock{s_mutex}; + auto* entry = resolve_entry_locked(handle, GfxSlotKind::DrawType); + if (entry == nullptr) { + return; + } + const auto& slot = entry->value; + fn = slot.drawFn; + userData = slot.userData; + modContext = slot.ownerContext; + owner = entry->owner; + ownerId = slot.ownerId; + } + + GfxDrawContext drawContext{ + .struct_size = sizeof(GfxDrawContext), + .device = ctx.device.Get(), + .queue = ctx.queue.Get(), + .pass = pass.Get(), + .vertex_buffer = ctx.vertexBuffer.Get(), + .index_buffer = ctx.indexBuffer.Get(), + .uniform_buffer = ctx.uniformBuffer.Get(), + .storage_buffer = ctx.storageBuffer.Get(), + .color_format = static_cast(ctx.colorFormat), + .depth_format = static_cast(ctx.depthFormat), + .sample_count = ctx.sampleCount, + .target_width = ctx.targetWidth, + .target_height = ctx.targetHeight, + .uses_reversed_z = aurora::gfx::uses_reversed_z(), + }; + + std::string failure; + try { + fn(modContext, &drawContext, payload, payloadSize, userData); + return; + } catch (const std::exception& e) { + failure = fmt::format("exception in gfx draw callback: {}", e.what()); + } catch (...) { + failure = "unknown exception in gfx draw callback"; + } + + std::lock_guard lock{s_mutex}; + WorkerFailure record{ + .modId = std::move(ownerId), + .message = std::move(failure), + }; + collect_mod_slots_locked(*owner, record.drawIds, record.taskIds); + s_workerFailures.push_back(std::move(record)); +} + +void compute_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)); + GfxComputeFn fn = nullptr; + void* userData = nullptr; + ModContext* modContext = nullptr; + LoadedMod* owner = nullptr; + std::string ownerId; + { + std::lock_guard lock{s_mutex}; + auto* entry = resolve_entry_locked(handle, GfxSlotKind::ComputeType); + if (entry == nullptr) { + return; + } + const auto& slot = entry->value; + fn = slot.computeFn; + userData = slot.userData; + modContext = slot.ownerContext; + owner = entry->owner; + ownerId = slot.ownerId; + } + + GfxComputeContext computeContext{ + .struct_size = sizeof(GfxComputeContext), + .device = ctx.device.Get(), + .queue = ctx.queue.Get(), + .encoder = cmd.Get(), + .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, &computeContext, payload, payloadSize, userData); + return; + } catch (const std::exception& e) { + failure = fmt::format("exception in gfx compute callback: {}", e.what()); + } catch (...) { + failure = "unknown exception in gfx compute callback"; + } + + std::lock_guard lock{s_mutex}; + WorkerFailure record{ + .modId = std::move(ownerId), + .message = std::move(failure), + }; + collect_mod_slots_locked(*owner, record.drawIds, record.taskIds); + s_workerFailures.push_back(std::move(record)); +} + +} // namespace + +ModResult gfx_register_draw_type( + LoadedMod& mod, const char* label, GfxDrawFn draw, void* userData, uint64_t& outHandle) { + outHandle = 0; + + uint64_t handle = 0; + { + std::lock_guard lock{s_mutex}; + handle = s_slots.emplace(mod, GfxSlot{ + .kind = GfxSlotKind::DrawType, + .ownerContext = mod.context.get(), + .ownerId = mod.metadata.id, + .userData = userData, + .drawFn = draw, + }); + } + + const auto auroraId = aurora::gfx::register_draw_type(aurora::gfx::DrawTypeDescriptor{ + .label = label, + .draw = draw_trampoline, + .userdata = reinterpret_cast(static_cast(handle)), + }); + if (auroraId == aurora::gfx::InvalidDrawType) { + std::lock_guard lock{s_mutex}; + s_slots.erase_owned(handle, mod); + return MOD_ERROR; + } + + { + std::lock_guard lock{s_mutex}; + if (auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::DrawType)) { + slot->auroraDrawId = auroraId; + } + } + outHandle = handle; + return MOD_OK; +} + +ModResult gfx_unregister_draw_type(LoadedMod& mod, uint64_t handle) { + aurora::gfx::DrawTypeId auroraId = aurora::gfx::InvalidDrawType; + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::DrawType); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auroraId = slot->auroraDrawId; + s_slots.erase_owned(handle, mod); + } + aurora::gfx::unregister_draw_type(auroraId); + return MOD_OK; +} + +ModResult gfx_push_draw(LoadedMod& mod, uint64_t handle, const void* payload, size_t payloadSize) { + aurora::gfx::DrawTypeId auroraId = aurora::gfx::InvalidDrawType; + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::DrawType); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auroraId = slot->auroraDrawId; + } + if (!aurora::gfx::push_custom_draw(auroraId, payload, payloadSize)) { + return MOD_UNAVAILABLE; + } + return MOD_OK; +} + +ModResult gfx_push_stream( + GfxStreamBuffer buffer, const void* data, size_t size, size_t alignment, GfxRange& outRange) { + aurora::gfx::Range range; + const auto* bytes = static_cast(data); + switch (buffer) { + case GfxStreamBuffer::Verts: + range = aurora::gfx::push_verts(bytes, size, alignment); + break; + case GfxStreamBuffer::Indices: + range = aurora::gfx::push_indices(bytes, size, alignment); + break; + case GfxStreamBuffer::Uniform: + range = aurora::gfx::push_uniform(bytes, size); + break; + case GfxStreamBuffer::Storage: + range = aurora::gfx::push_storage(bytes, size); + break; + } + if (range.size == 0) { + return MOD_UNAVAILABLE; + } + outRange = GfxRange{.offset = range.offset, .size = range.size}; + return MOD_OK; +} + +ModResult gfx_register_stage_hook( + LoadedMod& mod, GfxStage stage, GfxStageFn callback, void* userData, uint64_t& outHandle) { + outHandle = 0; + std::lock_guard lock{s_mutex}; + outHandle = s_slots.emplace(mod, GfxSlot{ + .kind = GfxSlotKind::StageHook, + .ownerContext = mod.context.get(), + .ownerId = mod.metadata.id, + .userData = userData, + .stageFn = callback, + .stage = stage, + }); + return MOD_OK; +} + +ModResult gfx_unregister_stage_hook(LoadedMod& mod, uint64_t handle) { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::StageHook); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + s_slots.erase_owned(handle, mod); + return MOD_OK; +} + +ModResult gfx_resolve_pass(LoadedMod& mod, const GfxResolveDesc& desc, GfxResolvedTargets& out) { + out = GfxResolvedTargets{.struct_size = sizeof(GfxResolvedTargets)}; + if (aurora::gfx::is_offscreen() && !s_modOffscreenOpen) { + Log.error( + "[{}] resolve_pass: the active offscreen pass belongs to the game", mod.metadata.id); + return MOD_UNAVAILABLE; + } + const bool closesModOffscreen = s_modOffscreenOpen; + + aurora::gfx::ResolvedTargets resolved; + if (!aurora::gfx::resolve_pass( + aurora::gfx::ResolveDesc{.color = desc.color, .depth = desc.depth}, resolved)) + { + return MOD_UNAVAILABLE; + } + if (closesModOffscreen) { + s_modOffscreenOpen = false; + } + + out.color = resolved.color.Get(); + out.depth = resolved.depth.Get(); + out.color_format = static_cast(resolved.colorFormat); + out.width = resolved.width; + out.height = resolved.height; + return MOD_OK; +} + +ModResult gfx_create_pass(LoadedMod& mod, uint32_t width, uint32_t height) { + if (aurora::gfx::is_offscreen()) { + Log.error("[{}] create_pass: an offscreen pass is already active", mod.metadata.id); + return MOD_UNAVAILABLE; + } + if (!aurora::gfx::create_pass(width, height)) { + return MOD_UNAVAILABLE; + } + s_modOffscreenOpen = true; + return MOD_OK; +} + +ModResult gfx_register_compute_type( + LoadedMod& mod, const char* label, GfxComputeFn callback, void* userData, uint64_t& outHandle) { + outHandle = 0; + + uint64_t handle = 0; + { + std::lock_guard lock{s_mutex}; + handle = s_slots.emplace(mod, GfxSlot{ + .kind = GfxSlotKind::ComputeType, + .ownerContext = mod.context.get(), + .ownerId = mod.metadata.id, + .userData = userData, + .computeFn = callback, + }); + } + + const auto auroraId = + aurora::gfx::register_encoder_task_type(aurora::gfx::EncoderTaskDescriptor{ + .label = label, + .callback = compute_trampoline, + .userdata = reinterpret_cast(static_cast(handle)), + }); + 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}; + if (auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::ComputeType)) { + slot->auroraTaskId = auroraId; + } + } + outHandle = handle; + return MOD_OK; +} + +ModResult gfx_unregister_compute_type(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::ComputeType); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auroraId = slot->auroraTaskId; + s_slots.erase_owned(handle, mod); + } + aurora::gfx::unregister_encoder_task_type(auroraId); + return MOD_OK; +} + +ModResult gfx_push_compute( + LoadedMod& mod, uint64_t handle, const void* payload, size_t payloadSize) { + aurora::gfx::EncoderTaskId auroraId = aurora::gfx::InvalidEncoderTask; + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::ComputeType); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auroraId = slot->auroraTaskId; + } + if (!aurora::gfx::push_encoder_task(auroraId, payload, payloadSize)) { + return MOD_UNAVAILABLE; + } + return MOD_OK; +} + +void gfx_run_stage( + GfxStage stage, const view_class* gameView, const view_port_class* gameViewport) { + struct StageEntry { + uint64_t handle; + GfxStageFn fn; + void* userData; + ModContext* context; + LoadedMod* owner; + }; + + std::vector entries; + { + std::lock_guard lock{s_mutex}; + s_slots.for_each([&](uint64_t handle, const auto& slotEntry) { + const auto& slot = slotEntry.value; + if (slot.kind == GfxSlotKind::StageHook && slot.stage == stage) { + entries.push_back(StageEntry{ + .handle = handle, + .fn = slot.stageFn, + .userData = slot.userData, + .context = slot.ownerContext, + .owner = slotEntry.owner, + }); + } + }); + } + if (entries.empty()) { + return; + } + + const GfxStageContext stageContext{ + .struct_size = sizeof(GfxStageContext), + .stage = stage, + .game_view = gameView, + .game_viewport = gameViewport, + }; + + for (const auto& entry : entries) { + { + std::lock_guard lock{s_mutex}; + if (resolve_slot_locked(entry.handle, GfxSlotKind::StageHook) == nullptr) { + continue; + } + } + if (!entry.owner->active) { + continue; + } + + const bool wasOffscreen = aurora::gfx::is_offscreen(); + try { + entry.fn(entry.context, &stageContext, entry.userData); + } catch (const std::exception& e) { + fail_mod(*entry.owner, MOD_ERROR, + fmt::format("exception in gfx stage callback: {}", e.what())); + } catch (...) { + fail_mod(*entry.owner, MOD_ERROR, "unknown exception in gfx stage callback"); + } + + if (aurora::gfx::is_offscreen() != wasOffscreen) { + aurora::gfx::ResolvedTargets discarded; + aurora::gfx::resolve_pass( + aurora::gfx::ResolveDesc{.color = false, .depth = false}, discarded); + s_modOffscreenOpen = false; + fail_mod(*entry.owner, MOD_ERROR, + "gfx stage callback returned with its offscreen pass still open"); + } + } +} + +void gfx_drain_worker_failures() { + std::vector failures; + { + std::lock_guard lock{s_mutex}; + failures.swap(s_workerFailures); + } + if (failures.empty()) { + 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) { + fail_mod(mod, MOD_ERROR, failure.message); + break; + } + } + } +} + +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)) { + return MOD_INVALID_ARGUMENT; + } + const uint32_t structSize = outInfo->struct_size; + *outInfo = GfxDeviceInfo{.struct_size = structSize}; + + auto* mod = mod_from_context(context); + if (mod == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + outInfo->device = aurora::gfx::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(); + return MOD_OK; +} + +void* gfx_get_proc_address(ModContext* context, const char* name) { + if (mod_from_context(context) == nullptr || name == nullptr) { + return nullptr; + } + return reinterpret_cast(wgpuGetProcAddress(WGPUStringView{name, WGPU_STRLEN})); +} + +ModResult gfx_register_draw_type_impl( + ModContext* context, const GfxDrawTypeDesc* desc, GfxDrawTypeHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(GfxDrawTypeDesc) || + desc->draw == nullptr || outHandle == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = + gfx_register_draw_type(*mod, desc->label, desc->draw, desc->user_data, handle); + if (result != MOD_OK) { + return result; + } + *outHandle = handle; + return MOD_OK; +} + +ModResult gfx_unregister_draw_type_impl(ModContext* context, GfxDrawTypeHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + return gfx_unregister_draw_type(*mod, handle); +} + +ModResult gfx_push_draw_impl( + ModContext* context, GfxDrawTypeHandle 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_draw(*mod, handle, payload, payloadSize); +} + +ModResult gfx_push_stream_impl(ModContext* context, GfxStreamBuffer buffer, const void* data, + size_t size, size_t alignment, GfxRange* outRange) { + if (outRange != nullptr) { + *outRange = GfxRange{0, 0}; + } + if (mod_from_context(context) == nullptr || data == nullptr || size == 0 || outRange == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + return gfx_push_stream(buffer, data, size, alignment, *outRange); +} + +ModResult gfx_push_verts_impl( + ModContext* context, const void* data, size_t size, size_t alignment, GfxRange* outRange) { + return gfx_push_stream_impl(context, GfxStreamBuffer::Verts, data, size, alignment, outRange); +} + +ModResult gfx_push_indices_impl( + ModContext* context, const void* data, size_t size, size_t alignment, GfxRange* outRange) { + return gfx_push_stream_impl(context, GfxStreamBuffer::Indices, data, size, alignment, outRange); +} + +ModResult gfx_push_uniform_impl( + ModContext* context, const void* data, size_t size, GfxRange* outRange) { + return gfx_push_stream_impl(context, GfxStreamBuffer::Uniform, data, size, 0, outRange); +} + +ModResult gfx_push_storage_impl( + ModContext* context, const void* data, size_t size, GfxRange* outRange) { + return gfx_push_stream_impl(context, GfxStreamBuffer::Storage, data, size, 0, outRange); +} + +bool valid_stage(GfxStage stage) { + return stage == GFX_STAGE_SCENE_AFTER_TERRAIN || stage == GFX_STAGE_FRAME_BEFORE_HUD || + stage == GFX_STAGE_FRAME_AFTER_HUD || stage == GFX_STAGE_SCENE_BEGIN || + stage == GFX_STAGE_SCENE_AFTER_OPAQUE; +} + +ModResult gfx_register_stage_hook_impl(ModContext* context, GfxStage stage, + const GfxStageHookDesc* desc, GfxStageHookHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(GfxStageHookDesc) || + desc->callback == nullptr || outHandle == nullptr || !valid_stage(stage)) + { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = + gfx_register_stage_hook(*mod, stage, desc->callback, desc->user_data, handle); + if (result != MOD_OK) { + return result; + } + *outHandle = handle; + return MOD_OK; +} + +ModResult gfx_unregister_stage_hook_impl(ModContext* context, GfxStageHookHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + return gfx_unregister_stage_hook(*mod, handle); +} + +ModResult gfx_resolve_pass_impl( + ModContext* context, const GfxResolveDesc* desc, GfxResolvedTargets* outTargets) { + if (outTargets != nullptr && outTargets->struct_size >= sizeof(GfxResolvedTargets)) { + *outTargets = GfxResolvedTargets{.struct_size = sizeof(GfxResolvedTargets)}; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(GfxResolveDesc) || + outTargets == nullptr || outTargets->struct_size < sizeof(GfxResolvedTargets) || + (!desc->color && !desc->depth)) + { + return MOD_INVALID_ARGUMENT; + } + return gfx_resolve_pass(*mod, *desc, *outTargets); +} + +ModResult gfx_create_pass_impl(ModContext* context, uint32_t width, uint32_t height) { + auto* mod = mod_from_context(context); + if (mod == nullptr || width == 0 || height == 0) { + return MOD_INVALID_ARGUMENT; + } + return gfx_create_pass(*mod, width, height); +} + +ModResult gfx_register_compute_type_impl( + ModContext* context, const GfxComputeTypeDesc* desc, GfxComputeTypeHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(GfxComputeTypeDesc) || + desc->callback == nullptr || outHandle == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = + gfx_register_compute_type(*mod, desc->label, desc->callback, desc->user_data, handle); + if (result != MOD_OK) { + return result; + } + *outHandle = handle; + return MOD_OK; +} + +ModResult gfx_unregister_compute_type_impl(ModContext* context, GfxComputeTypeHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + return gfx_unregister_compute_type(*mod, handle); +} + +ModResult gfx_push_compute_impl( + ModContext* context, GfxComputeTypeHandle 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_compute(*mod, handle, payload, payloadSize); +} + +constexpr GfxService s_gfxService{ + .header = SERVICE_HEADER(GfxService, GFX_SERVICE_MAJOR, GFX_SERVICE_MINOR), + .get_device_info = gfx_get_device_info, + .get_proc_address = gfx_get_proc_address, + .register_draw_type = gfx_register_draw_type_impl, + .unregister_draw_type = gfx_unregister_draw_type_impl, + .push_draw = gfx_push_draw_impl, + .register_compute_type = gfx_register_compute_type_impl, + .unregister_compute_type = gfx_unregister_compute_type_impl, + .push_compute = gfx_push_compute_impl, + .push_verts = gfx_push_verts_impl, + .push_indices = gfx_push_indices_impl, + .push_uniform = gfx_push_uniform_impl, + .push_storage = gfx_push_storage_impl, + .register_stage_hook = gfx_register_stage_hook_impl, + .unregister_stage_hook = gfx_unregister_stage_hook_impl, + .resolve_pass = gfx_resolve_pass_impl, + .create_pass = gfx_create_pass_impl, +}; + +} // namespace + +constinit const ServiceModule g_gfxModule{ + .id = GFX_SERVICE_ID, + .majorVersion = GFX_SERVICE_MAJOR, + .minorVersion = GFX_SERVICE_MINOR, + .service = &s_gfxService, + .modDetached = gfx_remove_mod, + .frameBegin = gfx_drain_worker_failures, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/hook.cpp b/src/dusk/mods/svc/hook.cpp new file mode 100644 index 0000000000..41399bf97d --- /dev/null +++ b/src/dusk/mods/svc/hook.cpp @@ -0,0 +1,519 @@ +#include "registry.hpp" + +#include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/manifest.hpp" +#include "mods/svc/hook.h" + +#if DUSK_CODE_MODS +#include "dusk/logging.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace dusk::mods::svc { +namespace { + +#if DUSK_CODE_MODS + +struct PreHookFn { + ModContext* context = nullptr; + HookPreFn callback = nullptr; + HookOptions options = HOOK_OPTIONS_INIT; + uint64_t order = 0; +}; + +struct VoidHookFn { + ModContext* context = nullptr; + HookReplaceFn replaceCallback = nullptr; + HookPostFn postCallback = nullptr; + HookOptions options = HOOK_OPTIONS_INIT; + uint64_t order = 0; +}; + +struct HookSlot { + std::vector pre; + VoidHookFn replace{}; + std::vector post; +}; + +// One per mod that requested a hook on a target: its template-generated trampoline and the +// address of its Hook::g_orig, both living in the mod's dylib. Any candidate's trampoline +// is interchangeable (dispatch walks the shared HookSlot), so when the active installer's mod +// unloads, the funchook detour is handed off to a surviving candidate and every candidate's +// *orig_store is rewritten to the new original pointer. +struct HookCandidate { + ModContext* context = nullptr; + void* trampoline = nullptr; + void** origStore = nullptr; + uint64_t order = 0; +}; + +struct InstalledHook { + funchook_t* handle = nullptr; + void* original = nullptr; + ModContext* active = nullptr; + std::vector candidates; +}; + +std::unordered_map s_registry; +std::unordered_map s_installed; +uint64_t s_nextOrder = 0; + +HookOptions normalize_options(const HookOptions* options) { + if (options == nullptr || options->struct_size < sizeof(HookOptions)) { + return HOOK_OPTIONS_INIT; + } + return *options; +} + +void fail_hook_callback(ModContext* context, const char* kind, const std::exception& e) { + if (auto* mod = mod_from_context(context)) { + fail_mod(*mod, MOD_ERROR, fmt::format("Exception in {} hook callback: {}", kind, e.what())); + } +} + +void fail_unknown_hook_callback(ModContext* context, const char* kind) { + if (auto* mod = mod_from_context(context)) { + fail_mod(*mod, MOD_ERROR, fmt::format("Unknown exception in {} hook callback", kind)); + } +} + +template +void sort_hooks(std::vector& hooks) { + std::ranges::stable_sort(hooks, [](const T& a, const T& b) { + if (a.options.priority != b.options.priority) { + return a.options.priority > b.options.priority; + } + return a.order < b.order; + }); +} + +// Follow E9/FF25 chains to skip MSVC incremental-link and import stubs. +void* resolve_import_thunk(void* addr) { +#if defined(_WIN32) && (defined(_M_X64) || defined(__x86_64__)) + for (int i = 0; i < 8; ++i) { + const auto* p = static_cast(addr); + if (p[0] == 0x48 && p[1] == 0xFF && p[2] == 0x25) { // lld emits a REX.W prefix + ++p; + } + if (p[0] == 0xFF && p[1] == 0x25) { + int32_t offset; + std::memcpy(&offset, p + 2, 4); + addr = const_cast(*reinterpret_cast(p + 6 + offset)); + break; + } + if (p[0] == 0xE9) { + int32_t offset; + std::memcpy(&offset, p + 1, 4); + addr = const_cast(p) + 5 + offset; + } else { + break; + } + } +#elif defined(_WIN32) && (defined(_M_ARM64) || defined(__aarch64__)) + // Import thunks are `adrp x16; ldr x16, [x16, #off]; br x16` (deref the IAT slot); + // incremental-link stubs are a plain `b`, or `adrp x16; add x16, x16, #off; br x16` + // range-extension thunks when the target is out of B range. + for (int i = 0; i < 8; ++i) { + const auto* p = static_cast(addr); + uint32_t insn0, insn1, insn2; + std::memcpy(&insn0, p, 4); + if ((insn0 & 0xFC000000u) == 0x14000000u) { // b imm26 + auto imm26 = static_cast(insn0 << 6) >> 6; + addr = const_cast(p) + static_cast(imm26) * 4; + continue; + } + if ((insn0 & 0x9F00001Fu) != 0x90000010u) { // adrp x16, page + break; + } + std::memcpy(&insn1, p + 4, 4); + std::memcpy(&insn2, p + 8, 4); + if (insn2 != 0xD61F0200u) { // br x16 + break; + } + auto immhi = static_cast(static_cast(insn0 << 8) >> 13); // bits 23:5 + auto immlo = static_cast((insn0 >> 29) & 3); + auto page = (reinterpret_cast(p) & ~uintptr_t{0xFFF}) + + (static_cast((immhi << 2) | immlo) << 12); + if ((insn1 & 0xFFC003FFu) == 0xF9400210u) { // ldr x16, [x16, #imm12*8] + auto slot = page + ((insn1 >> 10) & 0xFFF) * 8; + addr = *reinterpret_cast(slot); + break; + } + if ((insn1 & 0xFF8003FFu) == 0x91000210u) { // add x16, x16, #imm12{, lsl #12} + auto imm = static_cast((insn1 >> 10) & 0xFFF); + addr = reinterpret_cast(page + (((insn1 >> 22) & 1) != 0 ? imm << 12 : imm)); + continue; + } + break; + } +#endif + return addr; +} + +funchook_t* install_trampoline(void* fnAddr, void* trampoline, void** outOriginal) { + funchook_t* fh = funchook_create(); + if (fh == nullptr) { + DuskLog.warn("HookSystem: funchook_create failed for {:p}", fnAddr); + return nullptr; + } + + void* fn = fnAddr; + const int prep = funchook_prepare(fh, &fn, trampoline); + const int inst = prep == 0 ? funchook_install(fh, 0) : -1; + if (prep != 0 || inst != 0) { + const char* message = funchook_error_message(fh); + DuskLog.warn("HookSystem: funchook failed for {:p} (prepare={} install={}): {}", fnAddr, + prep, inst, message != nullptr && message[0] != '\0' ? message : "no details"); + funchook_destroy(fh); + return nullptr; + } + + *outOriginal = fn; + return fh; +} + +ModResult hook_install(ModContext* context, void* fnAddr, void* trampolineFn, void** outOriginal) { + if (fnAddr == nullptr || trampolineFn == nullptr || outOriginal == nullptr) { + return MOD_INVALID_ARGUMENT; + } + // Try to detect an invalid function pointer (possibly a vtable slot offset or Itanium mfp + // value) and provide a helpful warning instead of faulting + const auto raw = reinterpret_cast(fnAddr); + if (raw < 0x10000 +#if defined(__aarch64__) || defined(_M_ARM64) + || (raw & 3) != 0 // code is 4-aligned +#endif + ) + { + DuskLog.warn("HookSystem: {:p} from {} is not a code address (virtual member function " + "pointer? hook via dusk::mods::Hook or resolve())", + fnAddr, mod_id_from_context(context)); + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_import_thunk(fnAddr); + const auto key = reinterpret_cast(fnAddr); + if (const auto it = s_installed.find(key); it != s_installed.end()) { + auto& entry = it->second; + // hook_add_pre + hook_add_post on the same target share one g_orig per mod. + const bool known = std::ranges::any_of(entry.candidates, [&](const HookCandidate& cand) { + return cand.context == context && cand.origStore == outOriginal; + }); + if (!known) { + entry.candidates.push_back({context, trampolineFn, outOriginal, s_nextOrder++}); + } + *outOriginal = entry.original; + return MOD_OK; + } + + // Inlining can't be intercepted by an entry patch: warn once per target when this + // build inlined the function into callers. + if (const char* name = nullptr; manifest::has_inline_sites(fnAddr, &name)) { + DuskLog.warn("HookSystem: '{}' ({:p}) for {} was inlined into callers in this build; " + "the hook only covers the calls that were not inlined", + name != nullptr ? name : "?", fnAddr, mod_id_from_context(context)); + } + + void* original = nullptr; + funchook_t* fh = install_trampoline(fnAddr, trampolineFn, &original); + if (fh == nullptr) { + return MOD_ERROR; + } + + auto& entry = s_installed[key]; + entry.handle = fh; + entry.original = original; + entry.active = context; + entry.candidates.push_back({context, trampolineFn, outOriginal, s_nextOrder++}); + *outOriginal = original; + return MOD_OK; +} + +ModResult hook_add_pre( + ModContext* context, void* fnAddr, HookPreFn callback, const HookOptions* options) { + if (fnAddr == nullptr || context == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + fnAddr = resolve_import_thunk(fnAddr); + auto& hooks = s_registry[reinterpret_cast(fnAddr)].pre; + hooks.push_back({context, callback, normalize_options(options), s_nextOrder++}); + sort_hooks(hooks); + return MOD_OK; +} + +ModResult hook_add_post( + ModContext* context, void* fnAddr, HookPostFn callback, const HookOptions* options) { + if (fnAddr == nullptr || context == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + fnAddr = resolve_import_thunk(fnAddr); + auto& hooks = s_registry[reinterpret_cast(fnAddr)].post; + hooks.push_back({context, nullptr, callback, normalize_options(options), s_nextOrder++}); + sort_hooks(hooks); + return MOD_OK; +} + +ModResult hook_replace( + ModContext* context, void* fnAddr, HookReplaceFn callback, const HookOptions* options) { + if (fnAddr == nullptr || context == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + const HookOptions normalized = normalize_options(options); + fnAddr = resolve_import_thunk(fnAddr); + auto& slot = s_registry[reinterpret_cast(fnAddr)]; + if (slot.replace.replaceCallback == nullptr) { + slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; + return MOD_OK; + } + + switch (normalized.replace_policy) { + case HOOK_REPLACE_CONFLICT: + DuskLog.error("HookSystem: '{}' conflicts with '{}', both replace the same function", + mod_id_from_context(context), mod_id_from_context(slot.replace.context)); + return MOD_CONFLICT; + case HOOK_REPLACE_PRIORITY: + if (normalized.priority <= slot.replace.options.priority) { + return MOD_CONFLICT; + } + slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; + return MOD_OK; + case HOOK_REPLACE_OVERRIDE: + slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; + return MOD_OK; + } + return MOD_INVALID_ARGUMENT; +} + +ModResult hook_dispatch_pre( + ModContext*, void* fnAddr, void* args, void* retval, int* outSkipOriginal) { + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 0; + } + if (fnAddr == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_import_thunk(fnAddr); + const auto it = s_registry.find(reinterpret_cast(fnAddr)); + if (it == s_registry.end()) { + return MOD_OK; + } + auto& slot = it->second; + for (auto& hook : slot.pre) { + if (hook.callback == nullptr) { + continue; + } + HookAction action = HOOK_CONTINUE; + try { + action = hook.callback(hook.context, args, retval, hook.options.userdata); + } catch (const std::exception& e) { + fail_hook_callback(hook.context, "pre", e); + continue; + } catch (...) { + fail_unknown_hook_callback(hook.context, "pre"); + continue; + } + if (action == HOOK_SKIP_ORIGINAL) { + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 1; + } + return MOD_OK; + } + } + if (slot.replace.replaceCallback != nullptr) { + try { + slot.replace.replaceCallback( + slot.replace.context, args, retval, slot.replace.options.userdata); + } catch (const std::exception& e) { + fail_hook_callback(slot.replace.context, "replace", e); + return MOD_ERROR; + } catch (...) { + fail_unknown_hook_callback(slot.replace.context, "replace"); + return MOD_ERROR; + } + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 1; + } + } + return MOD_OK; +} + +ModResult hook_dispatch_post(ModContext*, void* fnAddr, void* args, void* retval) { + if (fnAddr == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_import_thunk(fnAddr); + const auto it = s_registry.find(reinterpret_cast(fnAddr)); + if (it == s_registry.end()) { + return MOD_OK; + } + for (auto& hook : it->second.post) { + if (hook.postCallback != nullptr) { + try { + hook.postCallback(hook.context, args, retval, hook.options.userdata); + } catch (const std::exception& e) { + fail_hook_callback(hook.context, "post", e); + } catch (...) { + fail_unknown_hook_callback(hook.context, "post"); + } + } + } + return MOD_OK; +} + +void hook_remove_mod(LoadedMod& mod) { + ModContext* context = mod.context.get(); + + for (auto it = s_registry.begin(); it != s_registry.end();) { + auto& slot = it->second; + std::erase_if(slot.pre, [&](const PreHookFn& hook) { return hook.context == context; }); + std::erase_if(slot.post, [&](const VoidHookFn& hook) { return hook.context == context; }); + if (slot.replace.context == context) { + slot.replace = {}; + } + if (slot.pre.empty() && slot.post.empty() && slot.replace.replaceCallback == nullptr) { + it = s_registry.erase(it); + } else { + ++it; + } + } + + for (auto it = s_installed.begin(); it != s_installed.end();) { + auto& entry = it->second; + // The departing mod's g_orig slots are about to be unmapped; drop its candidates before + // any orig_store rewrites below. + std::erase_if(entry.candidates, + [context](const HookCandidate& cand) { return cand.context == context; }); + if (entry.active != context) { + ++it; + continue; + } + + auto* target = reinterpret_cast(it->first); + const int uninst = funchook_uninstall(entry.handle, 0); + const int destr = funchook_destroy(entry.handle); + if (uninst != 0 || destr != 0) { + DuskLog.warn("HookSystem: funchook uninstall/destroy for {:p} returned {}/{}", target, + uninst, destr); + } + entry.handle = nullptr; + entry.active = nullptr; + + if (entry.candidates.empty()) { + it = s_installed.erase(it); + continue; + } + + // Hand the detour off to a surviving candidate (lowest registration order first; the + // vector is append-ordered). A candidate whose install fails stays in the list: its + // g_orig must still track the current original pointer. + for (auto& cand : entry.candidates) { + void* original = nullptr; + funchook_t* fh = install_trampoline(target, cand.trampoline, &original); + if (fh == nullptr) { + continue; + } + entry.handle = fh; + entry.original = original; + entry.active = cand.context; + DuskLog.info("HookSystem: reinstalled trampoline for {:p}: {} -> {} (tramp={:p})", + target, mod_id_from_context(context), mod_id_from_context(cand.context), + cand.trampoline); + break; + } + + if (entry.active == nullptr) { + DuskLog.warn("HookSystem: no reinstallable trampoline for {:p}; hooks there are " + "disabled until a mod reinstalls one", + target); + for (auto& cand : entry.candidates) { + *cand.origStore = target; + } + it = s_installed.erase(it); + continue; + } + + for (auto& cand : entry.candidates) { + *cand.origStore = entry.original; + } + ++it; + } +} + +#else // DUSK_CODE_MODS + +ModResult hook_install(ModContext*, void*, void*, void**) { + return MOD_UNSUPPORTED; +} +ModResult hook_add_pre(ModContext*, void*, HookPreFn, const HookOptions*) { + return MOD_UNSUPPORTED; +} +ModResult hook_add_post(ModContext*, void*, HookPostFn, const HookOptions*) { + return MOD_UNSUPPORTED; +} +ModResult hook_replace(ModContext*, void*, HookReplaceFn, const HookOptions*) { + return MOD_UNSUPPORTED; +} +ModResult hook_dispatch_pre(ModContext*, void*, void*, void*, int* outSkipOriginal) { + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 0; + } + return MOD_UNSUPPORTED; +} +ModResult hook_dispatch_post(ModContext*, void*, void*, void*) { + return MOD_UNSUPPORTED; +} +void hook_remove_mod(LoadedMod&) {} + +#endif // DUSK_CODE_MODS + +// By-name resolution reads the symbol manifest, which is independent of the hook engine. +ModResult hook_resolve(ModContext*, const char* symbol, void** outAddr, HookSymbolFlags* outFlags) { + if (symbol == nullptr || outAddr == nullptr) { + return MOD_INVALID_ARGUMENT; + } + switch (manifest::resolve(symbol, outAddr, outFlags)) { + case manifest::ResolveStatus::Ok: + return MOD_OK; + case manifest::ResolveStatus::Unavailable: + return MOD_UNSUPPORTED; + case manifest::ResolveStatus::NotFound: + return MOD_UNAVAILABLE; + case manifest::ResolveStatus::Ambiguous: + return MOD_CONFLICT; + } + return MOD_ERROR; +} + +constexpr HookService s_hookService{ + .header = SERVICE_HEADER(HookService, HOOK_SERVICE_MAJOR, HOOK_SERVICE_MINOR), + .install = hook_install, + .add_pre = hook_add_pre, + .add_post = hook_add_post, + .replace = hook_replace, + .dispatch_pre = hook_dispatch_pre, + .dispatch_post = hook_dispatch_post, + .resolve = hook_resolve, +}; + +} // namespace + +constinit const ServiceModule g_hookModule{ + .id = HOOK_SERVICE_ID, + .majorVersion = HOOK_SERVICE_MAJOR, + .minorVersion = HOOK_SERVICE_MINOR, + .service = &s_hookService, + .modDetached = hook_remove_mod, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp new file mode 100644 index 0000000000..0199a7865a --- /dev/null +++ b/src/dusk/mods/svc/host.cpp @@ -0,0 +1,163 @@ +#include "registry.hpp" +#include "slot_map.hpp" + +#include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/manifest.hpp" +#include "fmt/format.h" + +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +ModResult host_get_service(ModContext*, const char* serviceId, const uint16_t majorVersion, + const uint16_t minMinorVersion, const void** outService) { + if (outService == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outService = nullptr; + const auto* service = find_service(serviceId, majorVersion, minMinorVersion); + if (service == nullptr) { + return MOD_UNAVAILABLE; + } + *outService = service->service; + return MOD_OK; +} + +ModResult host_publish_service( + ModContext* context, const char* serviceId, const uint16_t majorVersion, const void* service) { + auto* mod = mod_from_context(context); + if (mod == nullptr || !valid_service_id(serviceId) || service == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + return publish_deferred_service(*mod, serviceId, majorVersion, service); +} + +void host_fail(ModContext* context, const ModResult code, const char* message) { + auto* mod = mod_from_context(context); + if (mod != nullptr) { + fail_mod(*mod, code, message != nullptr ? message : "Mod reported an unknown failure"); + } +} + +const char* host_mod_id(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->metadata.id.c_str() : ""; +} + +const char* host_mod_name(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->metadata.name.c_str() : ""; +} + +const char* host_mod_version(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->metadata.version.c_str() : ""; +} + +const char* host_mod_dir(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->dir.c_str() : ""; +} + +struct LifecycleWatcher { + ModLifecycleFn fn = nullptr; + void* userData = nullptr; + uint64_t order = 0; +}; + +SlotMap s_watchers; +uint64_t s_nextWatchOrder = 0; + +ModResult host_watch_mod_lifecycle( + ModContext* context, ModLifecycleFn fn, void* userData, uint64_t* outHandle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || fn == nullptr || outHandle == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto handle = s_watchers.emplace( + *mod, LifecycleWatcher{.fn = fn, .userData = userData, .order = s_nextWatchOrder++}); + *outHandle = handle; + return MOD_OK; +} + +ModResult host_unwatch_mod_lifecycle(ModContext* context, const uint64_t handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return s_watchers.erase_owned(handle, *mod) ? MOD_OK : MOD_INVALID_ARGUMENT; +} + +void host_mod_detached(LoadedMod& mod) { + // The subject's own watches go first: a mod is never notified about its own teardown. + s_watchers.erase_all(mod); + + // Iterate a snapshot in registration order: callbacks may watch/unwatch, and a failing + // callback erases the failing mod's services. + struct PendingNotify { + uint64_t order; + uint64_t handle; + }; + std::vector snapshot; + s_watchers.for_each([&](const uint64_t handle, const auto& entry) { + snapshot.push_back({.order = entry.value.order, .handle = handle}); + }); + std::ranges::sort(snapshot, {}, &PendingNotify::order); + + for (const auto& pending : snapshot) { + const auto* entry = s_watchers.find(pending.handle); + if (entry == nullptr) { + continue; + } + // Do not retain pointers into SlotMap across a callback that may mutate it. + auto* owner = entry->owner; + const auto watcher = entry->value; + try { + watcher.fn(owner->context.get(), mod.context.get(), mod.metadata.id.c_str(), + MOD_LIFECYCLE_DETACHED, watcher.userData); + } catch (const std::exception& e) { + fail_mod(*owner, MOD_ERROR, + fmt::format("Exception in mod lifecycle callback: {}", e.what())); + } catch (...) { + fail_mod(*owner, MOD_ERROR, "Unknown exception in mod lifecycle callback"); + } + } +} + +constinit HostService s_hostService{ + .header = SERVICE_HEADER(HostService, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR), + .version = DUSK_VERSION_STRING, + .build_id = nullptr, + .build_id_len = 0, + .get_service = host_get_service, + .publish_service = host_publish_service, + .fail = host_fail, + .mod_id = host_mod_id, + .mod_name = host_mod_name, + .mod_version = host_mod_version, + .mod_dir = host_mod_dir, + .watch_mod_lifecycle = host_watch_mod_lifecycle, + .unwatch_mod_lifecycle = host_unwatch_mod_lifecycle, +}; + +} // namespace + +constinit const ServiceModule g_hostModule{ + .id = HOST_SERVICE_ID, + .majorVersion = HOST_SERVICE_MAJOR, + .minorVersion = HOST_SERVICE_MINOR, + .service = &s_hostService, + .initialize = + [] { + const auto& buildId = manifest::image_build_id(); + s_hostService.build_id = buildId.empty() ? nullptr : buildId.data(); + s_hostService.build_id_len = static_cast(buildId.size()); + }, + .modDetached = host_mod_detached, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/log.cpp b/src/dusk/mods/svc/log.cpp new file mode 100644 index 0000000000..498a20aeaf --- /dev/null +++ b/src/dusk/mods/svc/log.cpp @@ -0,0 +1,53 @@ +#include "registry.hpp" + +#include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/log_buffer.hpp" + +namespace dusk::mods::svc { +namespace { + +void log_write(ModContext* context, const LogLevel level, const char* message) { + const char* text = message != nullptr ? message : ""; + log::emit(log::Source::Mod, mod_id_from_context(context), level, text); +} + +void log_trace(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_TRACE, message); +} + +void log_debug(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_DEBUG, message); +} + +void log_info(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_INFO, message); +} + +void log_warn(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_WARN, message); +} + +void log_error(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_ERROR, message); +} + +constexpr LogService s_logService{ + .header = SERVICE_HEADER(LogService, LOG_SERVICE_MAJOR, LOG_SERVICE_MINOR), + .write = log_write, + .trace = log_trace, + .debug = log_debug, + .info = log_info, + .warn = log_warn, + .error = log_error, +}; + +} // namespace + +constinit const ServiceModule g_logModule{ + .id = LOG_SERVICE_ID, + .majorVersion = LOG_SERVICE_MAJOR, + .minorVersion = LOG_SERVICE_MINOR, + .service = &s_logService, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/overlay.cpp b/src/dusk/mods/svc/overlay.cpp new file mode 100644 index 0000000000..44eef6ab17 --- /dev/null +++ b/src/dusk/mods/svc/overlay.cpp @@ -0,0 +1,350 @@ +#include "registry.hpp" +#include "slot_map.hpp" + +#include "aurora/dvd.h" +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/overlay.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace std::string_literals; + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::overlay"); + +struct OverlayFileData { + std::string bundlePath; + std::shared_ptr bundle; + std::shared_ptr > buffer; +}; + +// Keyed by the id passed to Aurora as per-file userdata. Guarded by s_overlayMutex: Aurora may +// call cbOpen from a DVD thread while the game thread replaces the set in overlay_sync_files. +// The shared bundle/buffer pointer keeps a disabled/reloaded mod's data readable until the last +// open completes. +std::unordered_map s_overlayFiles; +uintptr_t s_nextOverlayId = 1; +std::mutex s_overlayMutex; + +struct RuntimeOverlaySlot { + std::string discPath; + std::string bundlePath; // bundle-backed if non-empty + std::shared_ptr> buffer; // buffer-backed otherwise + size_t size = 0; + uint64_t order = 0; +}; +SlotMap s_runtimeOverlays; +uint64_t s_nextRuntimeOrder = 0; +bool s_overlaysDirty = false; + +// Aurora matches overlay paths against the disc case-insensitively and later entries win, so +// claims are tracked by lowercased path and re-claims by a different mod warn. +void claim_overlay_path(std::unordered_map& claims, + const std::string& discPath, const LoadedMod& mod) { + std::string key = discPath; + for (auto& ch : key) { + if (ch >= 'A' && ch <= 'Z') { + ch += 'a' - 'A'; + } + } + const auto [it, inserted] = claims.try_emplace(std::move(key), &mod); + if (!inserted && it->second != &mod) { + Log.warn("Overlay conflict: '{}' is provided by both '{}' and '{}'; '{}' wins.", discPath, + it->second->metadata.id, mod.metadata.id, mod.metadata.id); + it->second = &mod; + } +} + +void find_overlay_files(std::vector& files, LoadedMod& mod, + std::unordered_map& claims) { + for (const auto& file : mod.bundle->getFileNames()) { + if (!file.starts_with("overlay/")) { + continue; + } + + auto overlayPath = file.substr("overlay/"s.size()); + assert(!overlayPath.starts_with('/')); + overlayPath.insert(0, "/"); + + const auto size = mod.bundle->getFileSize(file); + + const auto id = s_nextOverlayId++; + s_overlayFiles.emplace(id, OverlayFileData{file, mod.bundle, nullptr}); + claim_overlay_path(claims, overlayPath, mod); + files.emplace_back(strdup(overlayPath.c_str()), reinterpret_cast(id), size); + } +} + +void append_runtime_overlays(std::vector& files, LoadedMod& mod, + std::unordered_map& claims) { + // Aurora resolves duplicate paths later-entry-wins, so emit in registration order (SlotMap + // iteration is index order, and freed indices are reused). + std::vector slots; + s_runtimeOverlays.for_each([&](uint64_t, const auto& entry) { + if (entry.owner == &mod) { + slots.push_back(&entry.value); + } + }); + std::ranges::sort(slots, {}, &RuntimeOverlaySlot::order); + + for (const auto* slot : slots) { + const auto id = s_nextOverlayId++; + if (slot->buffer != nullptr) { + s_overlayFiles.emplace(id, OverlayFileData{{}, nullptr, slot->buffer}); + } else { + s_overlayFiles.emplace(id, OverlayFileData{slot->bundlePath, mod.bundle, nullptr}); + } + claim_overlay_path(claims, slot->discPath, mod); + files.emplace_back(strdup(slot->discPath.c_str()), reinterpret_cast(id), slot->size); + } +} + +struct OpenOverlayFile { + std::vector ownedData; + std::shared_ptr > shared; + size_t pos = 0; + + [[nodiscard]] const std::vector& data() const { + return shared != nullptr ? *shared : ownedData; + } +}; + +void* cbOpen(void* userdata) { + const auto id = reinterpret_cast(userdata); + OverlayFileData fileData; + { + std::lock_guard lock{s_overlayMutex}; + const auto it = s_overlayFiles.find(id); + if (it == s_overlayFiles.end()) { + // The overlay set was re-pushed between the FST lookup and this call. + return nullptr; + } + fileData = it->second; + } + + if (fileData.buffer != nullptr) { + return new OpenOverlayFile{.shared = std::move(fileData.buffer)}; + } + + try { + auto fileContents = fileData.bundle->readFile(fileData.bundlePath); + return new OpenOverlayFile{.ownedData = std::move(fileContents)}; + } catch (const std::runtime_error& e) { + Log.error("Failed to read overlay file {}: {}", fileData.bundlePath, e.what()); + return nullptr; + } +} + +void cbClose(void* handle) { + const auto openFile = static_cast(handle); + delete openFile; +} + +int64_t cbRead(void* handle, uint8_t* buf, const size_t len) { + auto& openFile = *static_cast(handle); + + const auto remainingSpace = openFile.data().size() - openFile.pos; + const auto toRead = std::min(remainingSpace, len); + std::memcpy(buf, openFile.data().data() + openFile.pos, toRead); + openFile.pos += toRead; + return static_cast(toRead); +} + +int64_t cbSeek(void* handle, int64_t offset, int32_t whence) { + if (whence != 0) { + Log.fatal("Invalid seek mode from aurora: {}", whence); + } + + auto& openFile = *static_cast(handle); + const auto posSigned = + std::clamp(offset, static_cast(0), static_cast(openFile.data().size())); + openFile.pos = static_cast(posSigned); + return posSigned; +} + +constexpr AuroraOverlayCallbacks s_overlayCallbacks = { + .open = cbOpen, + .close = cbClose, + .read = cbRead, + .seek = cbSeek, +}; + +void overlay_sync_files() { + static bool callbacksRegistered = false; + if (!callbacksRegistered) { + aurora_dvd_overlay_callbacks(&s_overlayCallbacks); + callbacksRegistered = true; + } + + s_overlaysDirty = false; + + std::vector files; + std::unordered_map claims; + { + std::lock_guard lock{s_overlayMutex}; + s_overlayFiles.clear(); + for (auto& mod : ModLoader::instance().active_mods()) { + find_overlay_files(files, mod, claims); + append_runtime_overlays(files, mod, claims); + } + } + + Log.debug("Registering {} overlay file(s).", files.size()); + aurora_dvd_overlay_files(files.data(), files.size(), nullptr); + + for (const auto& file : files) { + std::free(const_cast(file.fileName)); + } +} + +uint64_t overlay_add_file( + LoadedMod& mod, std::string discPath, std::string bundlePath, size_t size) { + const auto handle = s_runtimeOverlays.emplace(mod, RuntimeOverlaySlot{ + .discPath = std::move(discPath), + .bundlePath = std::move(bundlePath), + .size = size, + .order = s_nextRuntimeOrder++, + }); + s_overlaysDirty = true; + return handle; +} + +uint64_t overlay_add_buffer(LoadedMod& mod, std::string discPath, std::vector data) { + const auto size = data.size(); + const auto handle = s_runtimeOverlays.emplace(mod, + RuntimeOverlaySlot{ + .discPath = std::move(discPath), + .buffer = std::make_shared>(std::move(data)), + .size = size, + .order = s_nextRuntimeOrder++, + }); + s_overlaysDirty = true; + return handle; +} + +bool overlay_remove(LoadedMod& mod, uint64_t handle) { + if (!s_runtimeOverlays.erase_owned(handle, mod)) { + return false; + } + s_overlaysDirty = true; + return true; +} + +void overlay_remove_mod(LoadedMod& mod) { + if (s_runtimeOverlays.erase_all(mod) != 0) { + s_overlaysDirty = true; + } +} + +bool consume_overlays_dirty() { + return std::exchange(s_overlaysDirty, false); +} + +constexpr size_t kMaxOverlayFileSize = UINT32_MAX; + +bool is_valid_disc_path(const char* discPath) { + if (discPath == nullptr) { + return false; + } + const std::string_view path{discPath}; + return path.starts_with('/') && is_safe_resource_path(path.substr(1)); +} + +ModResult overlay_add_file( + ModContext* context, const char* discPath, const char* bundlePath, OverlayHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_disc_path(discPath) || bundlePath == nullptr || + !is_safe_resource_path(bundlePath)) + { + return MOD_INVALID_ARGUMENT; + } + + size_t size = 0; + try { + size = mod->bundle->getFileSize(bundlePath); + } catch (const std::exception& e) { + Log.error( + "[{}] overlay add_file '{}' failed: {}", mod->metadata.id, bundlePath, e.what()); + return MOD_UNAVAILABLE; + } + if (size > kMaxOverlayFileSize) { + Log.error("[{}] overlay add_file '{}' failed: file too large ({} bytes)", + mod->metadata.id, bundlePath, size); + return MOD_INVALID_ARGUMENT; + } + + const auto handle = overlay_add_file(*mod, discPath, bundlePath, size); + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult overlay_add_buffer(ModContext* context, const char* discPath, const void* data, + size_t size, OverlayHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_disc_path(discPath) || (data == nullptr && size != 0) || + size > kMaxOverlayFileSize) + { + return MOD_INVALID_ARGUMENT; + } + + const auto* bytes = static_cast(data); + const auto handle = overlay_add_buffer(*mod, discPath, std::vector{bytes, bytes + size}); + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult overlay_remove(ModContext* context, OverlayHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + if (!overlay_remove(*mod, handle)) { + Log.error("[{}] overlay remove failed: unknown handle {}", mod->metadata.id, handle); + return MOD_INVALID_ARGUMENT; + } + return MOD_OK; +} + +constexpr OverlayService s_overlayService{ + .header = SERVICE_HEADER(OverlayService, OVERLAY_SERVICE_MAJOR, OVERLAY_SERVICE_MINOR), + .add_file = overlay_add_file, + .add_buffer = overlay_add_buffer, + .remove = overlay_remove, +}; + +} // namespace + +constinit const ServiceModule g_overlayModule{ + .id = OVERLAY_SERVICE_ID, + .majorVersion = OVERLAY_SERVICE_MAJOR, + .minorVersion = OVERLAY_SERVICE_MINOR, + .service = &s_overlayService, + .modDetached = overlay_remove_mod, + .lifecycleApplied = overlay_sync_files, + .frameEnd = + [] { + if (consume_overlays_dirty()) { + overlay_sync_files(); + } + }, +}; +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp new file mode 100644 index 0000000000..338625230a --- /dev/null +++ b/src/dusk/mods/svc/registry.cpp @@ -0,0 +1,323 @@ +#include "registry.hpp" + +#include "dusk/app_info.hpp" +#include "dusk/logging.h" +#include "dusk/mods/loader/loader.hpp" + +#include +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +std::unordered_map s_services; +std::vector s_modules; + +std::string service_key(std::string_view id, const uint16_t majorVersion) { + std::string key{id}; + key.push_back('\x1f'); + key += std::to_string(majorVersion); + return key; +} + +const char* mod_id(const LoadedMod* mod) { + return mod != nullptr ? mod->metadata.id.c_str() : AppName; +} + +bool validate_service_header(const ServiceHeader* header, const char* serviceId, + const uint16_t majorVersion, const uint16_t minorVersion, LoadedMod* provider) { + if (header == nullptr) { + DuskLog.error("[{}] service '{}' has null header", mod_id(provider), serviceId); + return false; + } + if (header->struct_size < sizeof(ServiceHeader)) { + DuskLog.error("[{}] service '{}' has invalid header size {}", mod_id(provider), serviceId, + header->struct_size); + return false; + } + if (header->major_version != majorVersion || header->minor_version != minorVersion) { + DuskLog.error("[{}] service '{}' header version {}.{} does not match export {}.{}", + mod_id(provider), serviceId, header->major_version, header->minor_version, majorVersion, + minorVersion); + return false; + } + return true; +} + +void clear_services() { + s_services.clear(); + s_modules.clear(); +} + +} // namespace + +bool valid_service_id(const char* serviceId) { + return serviceId != nullptr && serviceId[0] != '\0'; +} + +ModResult register_service(const char* serviceId, const uint16_t majorVersion, + const uint16_t minorVersion, const void* service, LoadedMod* provider, const bool deferred) { + if (!valid_service_id(serviceId)) { + DuskLog.error("[{}] attempted to register a service with no id", mod_id(provider)); + return MOD_INVALID_ARGUMENT; + } + + if (!deferred && !validate_service_header(static_cast(service), serviceId, + majorVersion, minorVersion, provider)) + { + return MOD_INVALID_ARGUMENT; + } + + const auto key = service_key(serviceId, majorVersion); + if (s_services.contains(key)) { + DuskLog.error("[{}] duplicate service '{}@{}'", mod_id(provider), serviceId, majorVersion); + return MOD_CONFLICT; + } + + s_services.emplace(key, ServiceRecord{ + serviceId, + majorVersion, + minorVersion, + service, + provider, + deferred, + }); + return MOD_OK; +} + +ModResult publish_deferred_service( + LoadedMod& provider, const char* serviceId, const uint16_t majorVersion, const void* service) { + if (!valid_service_id(serviceId) || service == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + const auto it = s_services.find(service_key(serviceId, majorVersion)); + if (it == s_services.end() || !it->second.deferred || it->second.provider != &provider) { + DuskLog.error("[{}] tried to publish undeclared service '{}@{}'", provider.metadata.id, + serviceId, majorVersion); + return MOD_UNSUPPORTED; + } + auto& record = it->second; + if (record.service != nullptr) { + return MOD_CONFLICT; + } + + const auto* header = static_cast(service); + if (!validate_service_header(header, serviceId, majorVersion, record.minorVersion, &provider)) { + return MOD_INVALID_ARGUMENT; + } + + record.service = service; + record.minorVersion = header->minor_version; + return MOD_OK; +} + +void remove_services_for_provider(const LoadedMod& provider) { + std::erase_if( + s_services, [&](const auto& entry) { return entry.second.provider == &provider; }); +} + +const ServiceRecord* find_service( + const char* serviceId, const uint16_t majorVersion, const uint16_t minMinorVersion) { + const auto* record = find_service_record(serviceId, majorVersion); + if (record == nullptr || record->service == nullptr || record->minorVersion < minMinorVersion) { + return nullptr; + } + return record; +} + +const ServiceRecord* find_service_record(const char* serviceId, const uint16_t majorVersion) { + if (!valid_service_id(serviceId)) { + return nullptr; + } + + const auto it = s_services.find(service_key(serviceId, majorVersion)); + return it != s_services.end() ? &it->second : nullptr; +} + +ModResult register_module(const ServiceModule& module) { + const auto result = register_service( + module.id, module.majorVersion, module.minorVersion, module.service, nullptr, false); + if (result != MOD_OK) { + return result; + } + s_modules.push_back(&module); + if (module.initialize != nullptr) { + module.initialize(); + } + return MOD_OK; +} + +void modules_mod_detached(LoadedMod& mod) { + for (const auto* module : s_modules | std::views::reverse) { + if (module->modDetached != nullptr) { + module->modDetached(mod); + } + } +} + +void modules_lifecycle_applied() { + for (const auto* module : s_modules) { + if (module->lifecycleApplied != nullptr) { + module->lifecycleApplied(); + } + } +} + +void modules_frame_begin() { + for (const auto* module : s_modules) { + if (module->frameBegin != nullptr) { + module->frameBegin(); + } + } +} + +void modules_frame_end() { + for (const auto* module : s_modules) { + if (module->frameEnd != nullptr) { + module->frameEnd(); + } + } +} + +void modules_shutdown() { + for (const auto* module : s_modules | std::views::reverse) { + if (module->shutdown != nullptr) { + module->shutdown(); + } + } + clear_services(); +} + +} // namespace dusk::mods::svc + +namespace dusk::mods { + +void ModLoader::init_services() { + svc::clear_services(); + for (const auto* module : + { + &svc::g_hostModule, + &svc::g_logModule, + &svc::g_resourceModule, + &svc::g_hookModule, + &svc::g_overlayModule, + &svc::g_textureModule, + &svc::g_configModule, + &svc::g_uiModule, + &svc::g_gameModule, + &svc::g_cameraModule, + &svc::g_gfxModule, + }) + { + svc::register_module(*module); + } +} + +bool ModLoader::register_static_service_exports(LoadedMod& mod) { + if (!mod.native || mod.native->manifest == nullptr) { + return true; + } + + const auto& manifest = *mod.native->manifest; + for (size_t i = 0; i < manifest.export_count; ++i) { + const auto& serviceExport = manifest.exports[i]; + if (serviceExport.struct_size != sizeof(ServiceExport) || + !svc::valid_service_id(serviceExport.service_id)) + { + fail_mod(mod, MOD_INVALID_ARGUMENT, "Invalid service export descriptor"); + return false; + } + + const bool deferred = (serviceExport.flags & SERVICE_EXPORT_DEFERRED) != 0; + if (!deferred && serviceExport.service == nullptr) { + fail_mod(mod, MOD_INVALID_ARGUMENT, "Static service export has null service pointer"); + return false; + } + + const auto result = + svc::register_service(serviceExport.service_id, serviceExport.major_version, + serviceExport.minor_version, serviceExport.service, &mod, deferred); + if (result != MOD_OK) { + fail_mod(mod, result, "Service export registration failed"); + return false; + } + } + + return true; +} + +std::string ModLoader::describe_missing_import( + const char* serviceId, const uint16_t majorVersion, const uint16_t minMinorVersion) const { + if (const auto* record = svc::find_service_record(serviceId, majorVersion)) { + if (record->service == nullptr) { + return fmt::format("Required service {}@{} was never published by provider '{}'", + serviceId, majorVersion, svc::mod_id(record->provider)); + } + return fmt::format("Required service {}@{} only provides minor version {} (need >= {})", + serviceId, majorVersion, record->minorVersion, minMinorVersion); + } + + // No record can also mean the provider failed or is disabled and its services were removed. + for (const auto& other : mods()) { + if ((other.active && !other.loadFailed) || !other.native || + other.native->manifest == nullptr) + { + continue; + } + const auto& manifest = *other.native->manifest; + for (size_t i = 0; i < manifest.export_count; ++i) { + const auto& serviceExport = manifest.exports[i]; + if (serviceExport.struct_size == sizeof(ServiceExport) && + svc::valid_service_id(serviceExport.service_id) && + std::string_view{serviceExport.service_id} == serviceId && + serviceExport.major_version == majorVersion) + { + return fmt::format("Required service {}@{} unavailable: provider '{}' {}", + serviceId, majorVersion, other.metadata.id, + other.loadFailed ? "failed to load" : "is disabled"); + } + } + } + + return fmt::format("Required service unavailable: {}@{}", serviceId, majorVersion); +} + +bool ModLoader::resolve_service_imports(LoadedMod& mod) { + if (!mod.native || mod.native->manifest == nullptr) { + return true; + } + + const auto& manifest = *mod.native->manifest; + for (size_t i = 0; i < manifest.import_count; ++i) { + const auto& serviceImport = manifest.imports[i]; + if (serviceImport.struct_size != sizeof(ServiceImport) || + !svc::valid_service_id(serviceImport.service_id) || serviceImport.slot == nullptr) + { + fail_mod(mod, MOD_INVALID_ARGUMENT, "Invalid service import descriptor"); + return false; + } + + const auto* service = svc::find_service( + serviceImport.service_id, serviceImport.major_version, serviceImport.min_minor_version); + if (service == nullptr) { + *static_cast(serviceImport.slot) = nullptr; + if ((serviceImport.flags & SERVICE_IMPORT_OPTIONAL) != 0) { + continue; + } + + fail_mod(mod, MOD_UNAVAILABLE, + describe_missing_import(serviceImport.service_id, serviceImport.major_version, + serviceImport.min_minor_version)); + return false; + } + + *static_cast(serviceImport.slot) = service->service; + } + + return true; +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp new file mode 100644 index 0000000000..191626e0ef --- /dev/null +++ b/src/dusk/mods/svc/registry.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include "dusk/mod_loader.hpp" +#include "mods/svc/host.h" +#include "mods/svc/log.h" + +#include +#include + +namespace dusk::mods::svc { + +struct ServiceRecord { + std::string id; + uint16_t majorVersion = 0; + uint16_t minorVersion = 0; + const void* service = nullptr; + LoadedMod* provider = nullptr; + bool deferred = false; +}; + +// A host service and its lifecycle hooks. Every hook is optional. Frame and lifecycle hooks run in +// registration order, modDetached in reverse registration order. +struct ServiceModule { + const char* id = nullptr; + uint16_t majorVersion = 0; + uint16_t minorVersion = 0; + const void* service = nullptr; + + // One-time setup, at registration (ModLoader::init_services). + void (*initialize)() = nullptr; + // A mod is going away (deactivation or failed activation): drop all state held for it. + // Runs after the mod's mod_shutdown and before its library unloads, so pointers into + // the mod are still valid but must not be called. + void (*modDetached)(LoadedMod& mod) = nullptr; + // A batch of (de)activations finished applying: startup, and runtime enable/disable/ + // reload requests. The set of active mods is stable when this runs. + void (*lifecycleApplied)() = nullptr; + // Top of ModLoader::tick, before pending lifecycle requests apply. + void (*frameBegin)() = nullptr; + // End of ModLoader::tick, after every mod_update. + void (*frameEnd)() = nullptr; + // ModLoader::shutdown, after every mod has deactivated. + void (*shutdown)() = nullptr; +}; + +bool valid_service_id(const char* serviceId); +ModResult register_service(const char* serviceId, uint16_t majorVersion, uint16_t minorVersion, + const void* service, LoadedMod* provider, bool deferred); +ModResult publish_deferred_service( + LoadedMod& provider, const char* serviceId, uint16_t majorVersion, const void* service); +void remove_services_for_provider(const LoadedMod& provider); +const ServiceRecord* find_service( + const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion); +// Unlike find_service, also returns deferred records that have not been published yet. +const ServiceRecord* find_service_record(const char* serviceId, uint16_t majorVersion); + +ModResult register_module(const ServiceModule& module); +void modules_mod_detached(LoadedMod& mod); +void modules_lifecycle_applied(); +void modules_frame_begin(); +void modules_frame_end(); +void modules_shutdown(); + +extern const ServiceModule g_hostModule; +extern const ServiceModule g_logModule; +extern const ServiceModule g_resourceModule; +extern const ServiceModule g_hookModule; +extern const ServiceModule g_overlayModule; +extern const ServiceModule g_textureModule; +extern const ServiceModule g_configModule; +extern const ServiceModule g_uiModule; +extern const ServiceModule g_gameModule; +extern const ServiceModule g_cameraModule; +extern const ServiceModule g_gfxModule; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/resource.cpp b/src/dusk/mods/svc/resource.cpp new file mode 100644 index 0000000000..b03934560f --- /dev/null +++ b/src/dusk/mods/svc/resource.cpp @@ -0,0 +1,110 @@ +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/resource.h" + +#include + +#include +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::resource"); + +// Allocations by owning mod, so buffers still live when a mod detaches can be freed. +std::unordered_map s_buffers; + +void resource_remove_mod(LoadedMod& mod) { + size_t reclaimed = 0; + std::erase_if(s_buffers, [&](const auto& entry) { + if (entry.second != &mod) { + return false; + } + std::free(entry.first); + ++reclaimed; + return true; + }); + if (reclaimed != 0) { + Log.warn("[{}] reclaimed {} resource buffer(s) that were never freed", mod.metadata.id, + reclaimed); + } +} + +ModResult resource_load(ModContext* context, const char* relativePath, ResourceBuffer* outBuffer) { + if (outBuffer == nullptr || outBuffer->struct_size < sizeof(ResourceBuffer)) { + return MOD_INVALID_ARGUMENT; + } + outBuffer->data = nullptr; + outBuffer->size = 0; + auto* mod = mod_from_context(context); + if (mod == nullptr || relativePath == nullptr || !is_safe_resource_path(relativePath)) { + return MOD_INVALID_ARGUMENT; + } + + const auto entry = fmt::format("res/{}", relativePath); + std::vector data; + try { + data = mod->bundle->readFile(entry); + } catch (const std::runtime_error& e) { + Log.error("[{}] resource load '{}' failed: {}", mod->metadata.id, entry, e.what()); + return MOD_UNAVAILABLE; + } + + if (!data.empty()) { + void* copy = std::malloc(data.size()); + if (copy == nullptr) { + return MOD_ERROR; + } + std::memcpy(copy, data.data(), data.size()); + s_buffers.emplace(copy, mod); + outBuffer->data = copy; + outBuffer->size = data.size(); + } + return MOD_OK; +} + +void resource_free(ModContext* context, ResourceBuffer* buffer) { + if (buffer == nullptr || buffer->struct_size < sizeof(ResourceBuffer) || + buffer->data == nullptr) + { + return; + } + auto* mod = mod_from_context(context); + const auto it = s_buffers.find(buffer->data); + if (it == s_buffers.end()) { + Log.error("[{}] resource free: not a live loaded buffer", mod_id_from_context(context)); + return; + } + if (mod == nullptr || it->second != mod) { + Log.error("[{}] resource free: buffer is owned by '{}'", mod_id_from_context(context), + it->second != nullptr ? it->second->metadata.id : "unknown"); + return; + } + s_buffers.erase(it); + std::free(buffer->data); + buffer->data = nullptr; + buffer->size = 0; +} + +constexpr ResourceService s_resourceService{ + .header = SERVICE_HEADER(ResourceService, RESOURCE_SERVICE_MAJOR, RESOURCE_SERVICE_MINOR), + .load = resource_load, + .free = resource_free, +}; + +} // namespace + +constinit const ServiceModule g_resourceModule{ + .id = RESOURCE_SERVICE_ID, + .majorVersion = RESOURCE_SERVICE_MAJOR, + .minorVersion = RESOURCE_SERVICE_MINOR, + .service = &s_resourceService, + .modDetached = resource_remove_mod, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/slot_map.hpp b/src/dusk/mods/svc/slot_map.hpp new file mode 100644 index 0000000000..3e45635d79 --- /dev/null +++ b/src/dusk/mods/svc/slot_map.hpp @@ -0,0 +1,188 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods { + +struct LoadedMod; + +namespace svc { + +template +class SlotMap { +public: + static_assert(std::is_nothrow_move_constructible_v); + + using Handle = uint64_t; + static constexpr Handle InvalidHandle = 0; + + struct Entry { + LoadedMod* owner = nullptr; + T value; + }; + + template + Handle emplace(LoadedMod& owner, Args&&... args) { + T value{std::forward(args)...}; + const auto index = allocate_index(); + auto& slot = m_slots[index]; + slot.entry.emplace(Entry{.owner = &owner, .value = std::move(value)}); + return make_handle(index, slot.generation); + } + + // Returned pointers remain valid only until the next mutating operation. + Entry* find(Handle handle) { + auto* slot = find_slot(handle); + return slot != nullptr ? &*slot->entry : nullptr; + } + + const Entry* find(Handle handle) const { + const auto* slot = find_slot(handle); + return slot != nullptr ? &*slot->entry : nullptr; + } + + Entry* find_owned(Handle handle, const LoadedMod& owner) { + auto* entry = find(handle); + return entry != nullptr && entry->owner == &owner ? entry : nullptr; + } + + const Entry* find_owned(Handle handle, const LoadedMod& owner) const { + const auto* entry = find(handle); + return entry != nullptr && entry->owner == &owner ? entry : nullptr; + } + + std::optional take(Handle handle) { + const auto index = handle_index(handle); + auto* slot = find_slot(handle); + if (slot == nullptr) { + return std::nullopt; + } + std::optional entry{std::move(slot->entry)}; + release_slot(index); + return entry; + } + + std::optional take_owned(Handle handle, const LoadedMod& owner) { + if (find_owned(handle, owner) == nullptr) { + return std::nullopt; + } + return take(handle); + } + + std::vector take_all(const LoadedMod& owner) { + std::vector entries; + for (size_t slotIndex = 0; slotIndex < m_slots.size(); ++slotIndex) { + const auto index = static_cast(slotIndex); + auto& slot = m_slots[index]; + if (!slot.entry.has_value() || slot.entry->owner != &owner) { + continue; + } + entries.push_back(std::move(*slot.entry)); + release_slot(index); + } + return entries; + } + + bool erase(Handle handle) { + const auto index = handle_index(handle); + if (find_slot(handle) == nullptr) { + return false; + } + release_slot(index); + return true; + } + + bool erase_owned(Handle handle, const LoadedMod& owner) { + if (find_owned(handle, owner) == nullptr) { + return false; + } + return erase(handle); + } + + size_t erase_all(const LoadedMod& owner) { + return take_all(owner).size(); + } + + template + void for_each(Fn&& fn) const { + // The visitor may inspect entries but must not mutate this SlotMap. + for (size_t slotIndex = 0; slotIndex < m_slots.size(); ++slotIndex) { + const auto index = static_cast(slotIndex); + const auto& slot = m_slots[index]; + if (slot.entry.has_value()) { + fn(make_handle(index, slot.generation), *slot.entry); + } + } + } + +private: + struct Slot { + uint32_t generation = 1; + std::optional entry; + }; + + static constexpr Handle make_handle(uint32_t index, uint32_t generation) { + return static_cast(generation) << 32 | index; + } + + static constexpr uint32_t handle_index(Handle handle) { + return static_cast(handle & std::numeric_limits::max()); + } + + static constexpr uint32_t handle_generation(Handle handle) { + return static_cast(handle >> 32); + } + + Slot* find_slot(Handle handle) { + return const_cast(std::as_const(*this).find_slot(handle)); + } + + const Slot* find_slot(Handle handle) const { + const auto index = handle_index(handle); + if (handle == InvalidHandle || index >= m_slots.size()) { + return nullptr; + } + const auto& slot = m_slots[index]; + if (!slot.entry.has_value() || slot.generation != handle_generation(handle)) { + return nullptr; + } + return &slot; + } + + uint32_t allocate_index() { + if (!m_freeSlots.empty()) { + const auto index = m_freeSlots.back(); + m_freeSlots.pop_back(); + return index; + } + if (m_slots.size() > std::numeric_limits::max()) { + throw std::length_error{"SlotMap handle space exhausted"}; + } + const auto index = static_cast(m_slots.size()); + m_slots.emplace_back(); + return index; + } + + void release_slot(uint32_t index) { + auto& slot = m_slots[index]; + slot.entry.reset(); + if (slot.generation == std::numeric_limits::max()) { + return; + } + ++slot.generation; + m_freeSlots.push_back(index); + } + + std::vector m_slots; + std::vector m_freeSlots; +}; + +} // namespace svc +} // namespace dusk::mods diff --git a/src/dusk/mods/svc/texture.cpp b/src/dusk/mods/svc/texture.cpp new file mode 100644 index 0000000000..63c8ba822b --- /dev/null +++ b/src/dusk/mods/svc/texture.cpp @@ -0,0 +1,463 @@ +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/texture.h" + +#include +#include + +#include +#include +#include +#include +#include + +using namespace std::string_literals; + +static_assert(TEXTURE_HASH_WILDCARD == aurora::texture::kWildcardTextureHash); +static_assert(TEXTURE_TLUT_WILDCARD == aurora::texture::kWildcardTlutHash); + +namespace dusk::mods::svc { +namespace { + +struct TextureRawData { + std::vector data; + uint32_t width = 0; + uint32_t height = 0; + uint32_t mipCount = 1; + uint32_t gxFormat = 0; +}; + +aurora::Module Log("dusk::mods::textures"); + +// Referenced by Aurora's lazy virtual-file reads (from arbitrary threads, under Aurora's registry +// lock) and by raw-entry spans. Immutable after construction; freed only after the corresponding +// unregister_replacement returns, at which point Aurora guarantees no further reads. +struct TextureKeepalive { + std::shared_ptr bundle; + std::string bundlePath; + std::vector ownedData; +}; + +// Called with Aurora's registry lock held: must not take any Dusk lock or re-enter +// aurora::texture. ModBundle reads are documented thread-safe. +bool texture_read_cb(void* userData, const char* path, std::vector& outBytes) { + auto* keepalive = static_cast(userData); + try { + outBytes = keepalive->bundle->readFile(path); + return true; + } catch (...) { + return false; + } +} + +struct RuntimeTextureEntry { + uint64_t handle = 0; + aurora::texture::ReplacementRegistration registration; + std::shared_ptr keepalive; + // Original inputs, kept for re-registration when the mod's priority changes. + bool isVirtual = false; + aurora::texture::ReplacementKey key; // raw entries only + uint32_t width = 0; + uint32_t height = 0; + uint32_t mipCount = 1; + uint32_t gxFormat = 0; + std::string label; +}; + +struct ModTextureRecord { + int32_t appliedPriority = 0; + bool staticRegistered = false; + aurora::texture::ReplacementGroup staticGroup; + std::vector> staticKeepalives; + std::vector runtime; +}; + +// Game thread only: all mutations happen in service calls made from mod code (init/update/hooks +// run inside ModLoader::tick), in the loader's sync/deactivate paths, or at shutdown. +std::unordered_map s_modTextures; +uint64_t s_nextTextureHandle = 1; + +// Position in m_mods (dependency-sorted load order) + 1; later-loaded mods win. The user +// texture_replacements directory uses kUserTextureReplacementPriority, below any mod. +int32_t compute_mod_priority(const LoadedMod& mod) { + int32_t index = 0; + for (const auto& other : ModLoader::instance().mods()) { + ++index; + if (&other == &mod) { + return index; + } + } + return index + 1; +} + +bool is_sidecar_mip(std::string_view stem) { + constexpr std::string_view tag = "_mip"; + size_t i = stem.size(); + while (i > 0 && stem[i - 1] >= '0' && stem[i - 1] <= '9') { + --i; + } + if (i == stem.size() || i < tag.size()) { + return false; + } + return stem.substr(i - tag.size(), tag.size()) == tag; +} + +bool has_replacement_extension(std::string_view filename) { + const auto dot = filename.rfind('.'); + if (dot == std::string_view::npos) { + return false; + } + std::string ext{filename.substr(dot)}; + std::ranges::transform(ext, ext.begin(), + [](char ch) { return ch >= 'A' && ch <= 'Z' ? static_cast(ch + 'a' - 'A') : ch; }); + return ext == ".dds" || ext == ".png"; +} + +std::string_view final_path_component(std::string_view path) { + const auto slash = path.rfind('/'); + return slash == std::string_view::npos ? path : path.substr(slash + 1); +} + +const LoadedMod* find_static_conflict( + const aurora::texture::ReplacementKey& key, const LoadedMod* exclude) { + for (const auto& [mod, record] : s_modTextures) { + if (mod == exclude) { + continue; + } + for (const auto& registration : record.staticGroup.registrations) { + if (registration.key == key) { + return mod; + } + } + } + return nullptr; +} + +void register_static_textures(LoadedMod& mod, ModTextureRecord& record) { + std::vector candidates; + for (const auto& file : mod.bundle->getFileNames()) { + if (!file.starts_with("textures/") || !has_replacement_extension(file)) { + continue; + } + auto filename = final_path_component(file); + if (is_sidecar_mip(filename.substr(0, filename.rfind('.')))) { + continue; + } + candidates.push_back(file); + } + // Deterministic order; with the first parse of a key winning, this mirrors Aurora's + // load_replacement_directory dedupe semantics. + std::ranges::sort(candidates); + + std::vector seenKeys; + for (const auto& path : candidates) { + const auto parsed = aurora::texture::parse_replacement_filename(final_path_component(path)); + if (!parsed.has_value()) { + Log.warn( + "[{}] '{}' does not follow the texture replacement naming convention; skipped.", + mod.metadata.id, path); + continue; + } + const aurora::texture::ReplacementKey key{*parsed}; + if (std::ranges::find(seenKeys, key) != seenKeys.end()) { + continue; + } + seenKeys.push_back(key); + + if (const auto* other = find_static_conflict(key, &mod); other != nullptr) { + const auto& winner = + s_modTextures.find(other)->second.appliedPriority > record.appliedPriority ? + *other : + mod; + Log.warn( + "Texture replacement conflict: '{}' is replaced by both '{}' and '{}'; '{}' wins.", + path, other->metadata.id, mod.metadata.id, winner.metadata.id); + } + + auto keepalive = std::make_shared(mod.bundle, path); + const auto registration = aurora::texture::register_virtual_replacement(path, + {.read = texture_read_cb, .userData = keepalive.get()}, + {.priority = record.appliedPriority}); + if (registration.id == 0) { + continue; + } + record.staticGroup.registrations.push_back(registration); + record.staticKeepalives.push_back(std::move(keepalive)); + } + + record.staticRegistered = true; + if (!record.staticGroup.registrations.empty()) { + Log.info("[{}] registered {} texture replacement(s).", mod.metadata.id, + record.staticGroup.registrations.size()); + } +} + +void register_runtime_entry(RuntimeTextureEntry& entry, int32_t priority) { + if (entry.isVirtual) { + entry.registration = aurora::texture::register_virtual_replacement( + entry.keepalive->bundlePath, + {.read = texture_read_cb, .userData = entry.keepalive.get()}, {.priority = priority}); + } else { + entry.registration = aurora::texture::register_replacement(entry.key, + { + .bytes = {entry.keepalive->ownedData.data(), entry.keepalive->ownedData.size()}, + .width = entry.width, + .height = entry.height, + .mipCount = entry.mipCount, + .gxFormat = entry.gxFormat, + .label = entry.label, + }, + {.priority = priority}); + } +} + +void unregister_record(ModTextureRecord& record) { + aurora::texture::unregister_replacements(record.staticGroup); + record.staticGroup.registrations.clear(); + record.staticKeepalives.clear(); + record.staticRegistered = false; + for (auto& entry : record.runtime) { + aurora::texture::unregister_replacement(entry.registration); + entry.registration = {}; + } +} + +void textures_sync_replacements() { + // Module detach removes records eagerly, but a record whose mod is no + // longer active must not linger with stale priority. + std::erase_if(s_modTextures, [&](auto& item) { + if (item.first->active) { + return false; + } + unregister_record(item.second); + return true; + }); + + for (auto& mod : ModLoader::instance().active_mods()) { + const auto priority = compute_mod_priority(mod); + auto& record = s_modTextures[&mod]; + + if (record.staticRegistered && record.appliedPriority == priority) { + continue; + } + + if (record.staticRegistered) { + // A reload re-sorted m_mods and changed this mod's priority: re-register everything + // at the new priority. Cheap, since file-backed entries decode lazily. + aurora::texture::unregister_replacements(record.staticGroup); + record.staticGroup.registrations.clear(); + record.staticKeepalives.clear(); + record.appliedPriority = priority; + register_static_textures(mod, record); + for (auto& entry : record.runtime) { + aurora::texture::unregister_replacement(entry.registration); + register_runtime_entry(entry, priority); + } + } else { + record.appliedPriority = priority; + register_static_textures(mod, record); + } + } +} + +uint64_t texture_register_raw( + LoadedMod& mod, const aurora::texture::ReplacementKey& key, TextureRawData data) { + auto& record = s_modTextures[&mod]; + if (record.appliedPriority == 0) { + record.appliedPriority = compute_mod_priority(mod); + } + + auto& entry = record.runtime.emplace_back(); + entry.handle = s_nextTextureHandle++; + entry.keepalive = std::make_shared(); + entry.keepalive->ownedData = std::move(data.data); + entry.isVirtual = false; + entry.key = key; + entry.width = data.width; + entry.height = data.height; + entry.mipCount = data.mipCount; + entry.gxFormat = data.gxFormat; + entry.label = fmt::format("mod {} texture {}", mod.metadata.id, entry.handle); + register_runtime_entry(entry, record.appliedPriority); + if (entry.registration.id == 0) { + Log.error("[{}] texture register_data failed: replacement was rejected", mod.metadata.id); + record.runtime.pop_back(); + return 0; + } + return entry.handle; +} + +uint64_t texture_register_file(LoadedMod& mod, std::string bundlePath) { + auto& record = s_modTextures[&mod]; + if (record.appliedPriority == 0) { + record.appliedPriority = compute_mod_priority(mod); + } + + auto& entry = record.runtime.emplace_back(); + entry.handle = s_nextTextureHandle++; + entry.keepalive = std::make_shared(mod.bundle, std::move(bundlePath)); + entry.isVirtual = true; + register_runtime_entry(entry, record.appliedPriority); + if (entry.registration.id == 0) { + record.runtime.pop_back(); + return 0; + } + return entry.handle; +} + +bool texture_unregister(LoadedMod& mod, uint64_t handle) { + const auto it = s_modTextures.find(&mod); + if (it == s_modTextures.end()) { + return false; + } + auto& runtime = it->second.runtime; + const auto entry = + std::ranges::find_if(runtime, [&](const auto& e) { return e.handle == handle; }); + if (entry == runtime.end()) { + return false; + } + aurora::texture::unregister_replacement(entry->registration); + runtime.erase(entry); + return true; +} + +void textures_remove_mod(LoadedMod& mod) { + const auto it = s_modTextures.find(&mod); + if (it == s_modTextures.end()) { + return; + } + unregister_record(it->second); + s_modTextures.erase(it); +} + +std::optional translate_key(const TextureKey* key) { + if (key == nullptr || key->struct_size < sizeof(TextureKey)) { + return std::nullopt; + } + switch (key->kind) { + case TEXTURE_KEY_POINTER: + if (key->pointer == nullptr) { + return std::nullopt; + } + return aurora::texture::ReplacementKey{aurora::texture::TexturePointerKey{key->pointer}}; + case TEXTURE_KEY_SOURCE: + if (key->width == 0 || key->height == 0) { + return std::nullopt; + } + return aurora::texture::ReplacementKey{aurora::texture::TextureSourceKey{ + .textureHash = key->texture_hash, + .tlutHash = key->tlut_hash, + .width = key->width, + .height = key->height, + .format = key->gx_format, + .hasTlut = key->has_tlut, + }}; + default: + return std::nullopt; + } +} + +ModResult texture_register_data(ModContext* context, const TextureKey* key, const TextureData* data, + TextureReplacementHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + const auto translatedKey = translate_key(key); + if (mod == nullptr || !translatedKey.has_value() || data == nullptr || + data->struct_size < sizeof(TextureData) || data->data == nullptr || data->size == 0 || + data->width == 0 || data->height == 0 || data->mip_count == 0) + { + return MOD_INVALID_ARGUMENT; + } + + const auto* bytes = static_cast(data->data); + const auto handle = texture_register_raw(*mod, *translatedKey, + { + .data = std::vector{bytes, bytes + data->size}, + .width = data->width, + .height = data->height, + .mipCount = data->mip_count, + .gxFormat = data->gx_format, + }); + if (handle == 0) { + return MOD_INVALID_ARGUMENT; + } + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult texture_register_file( + ModContext* context, const char* bundlePath, TextureReplacementHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || bundlePath == nullptr || !is_safe_resource_path(bundlePath)) { + return MOD_INVALID_ARGUMENT; + } + + const std::string_view path{bundlePath}; + const auto slash = path.rfind('/'); + const auto filename = slash == std::string_view::npos ? path : path.substr(slash + 1); + if (!aurora::texture::parse_replacement_filename(filename).has_value()) { + Log.error("[{}] texture register_file '{}' failed: " + "filename does not follow the replacement naming convention", + mod->metadata.id, bundlePath); + return MOD_INVALID_ARGUMENT; + } + + try { + mod->bundle->getFileSize(bundlePath); + } catch (const std::exception& e) { + Log.error( + "[{}] texture register_file '{}' failed: {}", mod->metadata.id, bundlePath, e.what()); + return MOD_UNAVAILABLE; + } + + const auto handle = texture_register_file(*mod, bundlePath); + if (handle == 0) { + return MOD_INVALID_ARGUMENT; + } + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult texture_unregister(ModContext* context, TextureReplacementHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + if (!texture_unregister(*mod, handle)) { + Log.error( + "[{}] texture unregister failed: unknown handle {}", mod->metadata.id, handle); + return MOD_INVALID_ARGUMENT; + } + return MOD_OK; +} + +constexpr TextureService s_textureService{ + .header = SERVICE_HEADER(TextureService, TEXTURE_SERVICE_MAJOR, TEXTURE_SERVICE_MINOR), + .register_data = texture_register_data, + .register_file = texture_register_file, + .unregister = texture_unregister, +}; + +} // namespace + +constinit const ServiceModule g_textureModule{ + .id = TEXTURE_SERVICE_ID, + .majorVersion = TEXTURE_SERVICE_MAJOR, + .minorVersion = TEXTURE_SERVICE_MINOR, + .service = &s_textureService, + .modDetached = textures_remove_mod, + .lifecycleApplied = textures_sync_replacements, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/ui.cpp b/src/dusk/mods/svc/ui.cpp new file mode 100644 index 0000000000..cf4ea86808 --- /dev/null +++ b/src/dusk/mods/svc/ui.cpp @@ -0,0 +1,1399 @@ +#include "ui.hpp" + +#include "config.hpp" +#include "registry.hpp" +#include "slot_map.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mod_loader.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "dusk/ui/menu_bar.hpp" +#include "dusk/ui/mod_window.hpp" +#include "dusk/ui/modal.hpp" +#include "dusk/ui/ui.hpp" +#include "mods/svc/ui.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods::svc::ui_impl { +namespace { + +aurora::Module Log("dusk::mods::ui"); + +enum class UiSlotKind : u8 { + Window, + Dialog, + Pane, + Text, + Progress, + Control, + Style, + MenuTab, +}; + +const char* slot_kind_name(UiSlotKind kind) { + switch (kind) { + case UiSlotKind::Window: + return "window"; + case UiSlotKind::Dialog: + return "dialog"; + case UiSlotKind::Pane: + return "pane"; + case UiSlotKind::Text: + return "text"; + case UiSlotKind::Progress: + return "progress"; + case UiSlotKind::Control: + return "control"; + case UiSlotKind::Style: + return "style"; + case UiSlotKind::MenuTab: + return "menu tab"; + default: + return "unknown"; + } +} + +// Game thread only: all mutations happen in service calls made from mod code, in UI callbacks +// (ui::update), or in the loader's deactivate paths. +struct UiSlot { + UiSlotKind kind = UiSlotKind::Window; + // Pane/Text/Progress/Control: freed automatically when the element is destroyed + Rml::Element* element = nullptr; + // Pane payload + ui::Pane* pane = nullptr; + ui::Pane* helpPane = nullptr; + // Window/Dialog payload (non-owning; the document stack owns the document) + ui::Document* document = nullptr; + UiWindowClosedFn onClosed = nullptr; + void* onClosedUserData = nullptr; + // Style payload + ui::DocumentScope styleScope = ui::DocumentScope::None; + std::string styleId; + // Cached rendered values for element setters. These make the natural "set every update" + // style cheap when the displayed value has not changed. + std::string elementRml; + float elementFloat = 0.0f; + bool hasElementValue = false; +}; + +SlotMap s_slots; + +struct ModUiPanel { + UiPanelBuildFn build = nullptr; + UiPanelUpdateFn update = nullptr; + void* userData = nullptr; +}; +std::unordered_map s_modPanels; + +struct ModMenuTab { + uint64_t handle = 0; + std::string label; + UiPressedFn onSelected = nullptr; + void* userData = nullptr; +}; +std::unordered_map> s_modMenuTabs; +bool s_menuTabsDirty = false; + +UiSlot* slot_from_handle(uint64_t handle) { + auto* entry = s_slots.find(handle); + return entry != nullptr ? &entry->value : nullptr; +} + +// Note: s_slots may reallocate on any later allocation, so callers must not hold the returned +// slot reference across calls that can allocate (e.g. mod build callbacks); re-resolve instead. +UiSlot& alloc_slot(LoadedMod& mod, UiSlotKind kind, uint64_t& outHandle) { + outHandle = s_slots.emplace(mod, UiSlot{.kind = kind}); + return s_slots.find(outHandle)->value; +} + +UiSlot* resolve(LoadedMod& mod, uint64_t handle, UiSlotKind kind, const char* what) { + auto* entry = s_slots.find_owned(handle, mod); + if (entry == nullptr || entry->value.kind != kind) { + Log.error("[{}] {}: stale or invalid {} handle {:#x}", mod.metadata.id, what, + slot_kind_name(kind), handle); + return nullptr; + } + return &entry->value; +} + +// Whether the registration a callback was created under is still live. Callbacks captured by +// host-owned UI must check this before calling into the mod: `mod->active` alone is true again +// once a reload completes, but captured fn pointers still target the unloaded image. Teardown +// frees the slots (ui_remove_mod), which invalidates every callback built under them. +bool slot_live(uint64_t handle) { + return s_slots.find(handle) != nullptr; +} + +bool dialog_open(uint64_t handle) { + auto* slot = slot_from_handle(handle); + return slot != nullptr && slot->kind == UiSlotKind::Dialog && slot->document != nullptr && + slot->document->active(); +} + +// Frees the slot when the tracked element is destroyed (tab rebuilds, window teardown, ...). +// The generation check makes a late detach of an already-recycled slot a no-op. +class SlotDetachListener final : public Rml::EventListener { +public: + explicit SlotDetachListener(uint64_t handle) : m_handle{handle} {} + + void ProcessEvent(Rml::Event&) override {} + + void OnDetach(Rml::Element*) override { + s_slots.erase(m_handle); + delete this; + } + +private: + uint64_t m_handle; +}; + +void track_element(uint64_t handle, UiSlot& slot, Rml::Element& element) { + slot.element = &element; + element.AddEventListener(Rml::EventId::Click, new SlotDetachListener{handle}); +} + +template +T guarded_call(LoadedMod& mod, const char* what, T fallback, Fn&& fn) { + if (!mod.active) { + return fallback; + } + try { + return fn(); + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("exception in {}: {}", what, e.what())); + } catch (...) { + fail_mod(mod, MOD_ERROR, fmt::format("unknown exception in {}", what)); + } + return fallback; +} + +template +void guarded_call(LoadedMod& mod, const char* what, Fn&& fn) { + if (!mod.active) { + return; + } + try { + fn(); + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("exception in {}: {}", what, e.what())); + } catch (...) { + fail_mod(mod, MOD_ERROR, fmt::format("unknown exception in {}", what)); + } +} + +// Shared by panel/tab build and update callbacks: translates a non-OK result or an escaped +// exception into fail_mod, mirroring mod_update handling. +template +void invoke_mod_ui_callback(LoadedMod& mod, const char* what, Fn&& fn) { + ModError error = MOD_ERROR_INIT; + const ModResult result = guarded_call(mod, what, MOD_OK, [&] { return fn(&error); }); + if (result != MOD_OK && mod.active) { + fail_mod( + mod, result, error.message[0] != '\0' ? error.message : fmt::format("{} failed", what)); + } +} + +uint64_t wrap_pane(LoadedMod& mod, ui::Pane& pane, ui::Pane* helpPane) { + uint64_t handle = 0; + auto& slot = alloc_slot(mod, UiSlotKind::Pane, handle); + slot.pane = &pane; + slot.helpPane = helpPane; + track_element(handle, slot, *pane.root()); + return handle; +} + +int clamp_to_int(int64_t value) { + return static_cast(std::clamp(value, INT_MIN, INT_MAX)); +} + +std::function wrap_predicate( + LoadedMod& mod, UiPredicateFn fn, void* userData, uint64_t guardHandle) { + if (fn == nullptr) { + return {}; + } + return [modPtr = &mod, fn, userData, guardHandle] { + if (!slot_live(guardHandle)) { + return false; + } + return guarded_call(*modPtr, "control predicate", false, + [&] { return fn(modPtr->context.get(), userData); }); + }; +} + +void wire_callback_binding( + LoadedMod& mod, const UiControlDesc& desc, ui::ModControlSpec& spec, uint64_t guardHandle) { + auto* modPtr = &mod; + const auto get = desc.get; + const auto set = desc.set; + auto* userData = desc.user_data; + const auto getValue = [modPtr, get, userData, guardHandle] { + UiControlValue value = UI_CONTROL_VALUE_INIT; + if (!slot_live(guardHandle)) { + return value; + } + guarded_call(*modPtr, "control getter", [&] { + get(modPtr->context.get(), userData, &value); + }); + return value; + }; + const auto setValue = [modPtr, set, userData, guardHandle](const UiControlValue& value) { + if (!slot_live(guardHandle)) { + return; + } + guarded_call(*modPtr, "control setter", [&] { + set(modPtr->context.get(), userData, &value); + }); + }; + switch (desc.kind) { + case UI_CONTROL_TOGGLE: + spec.getBool = [getValue] { return getValue().bool_value; }; + spec.setBool = [setValue](bool value) { + UiControlValue raw = UI_CONTROL_VALUE_INIT; + raw.bool_value = value; + setValue(raw); + }; + break; + case UI_CONTROL_NUMBER: + case UI_CONTROL_SELECT: + spec.getInt = [getValue] { return clamp_to_int(getValue().int_value); }; + spec.setInt = [setValue](int value) { + UiControlValue raw = UI_CONTROL_VALUE_INIT; + raw.int_value = value; + setValue(raw); + }; + break; + case UI_CONTROL_STRING: + spec.getString = [getValue]() -> Rml::String { + const UiControlValue value = getValue(); + return value.string_value != nullptr ? value.string_value : ""; + }; + spec.setString = [setValue](Rml::String value) { + UiControlValue raw = UI_CONTROL_VALUE_INIT; + raw.string_value = value.c_str(); + setValue(raw); + }; + break; + default: + break; + } +} + +// The lambdas re-resolve the var on every call, so a control whose var was unregistered +// mid-flight degrades to a no-op instead of a dangling read. +bool wire_config_var_binding(LoadedMod& mod, const UiControlDesc& desc, ui::ModControlSpec& spec) { + auto* modPtr = &mod; + const uint64_t varHandle = desc.config_var; + switch (desc.kind) { + case UI_CONTROL_TOGGLE: { + const auto find = [modPtr, varHandle] { + return static_cast*>( + config_find_var(*modPtr, varHandle, CONFIG_VAR_BOOL)); + }; + if (find() == nullptr) { + return false; + } + spec.getBool = [find] { + const auto* var = find(); + return var != nullptr && var->getValue(); + }; + spec.setBool = [find](bool value) { + auto* var = find(); + if (var == nullptr || var->getValue() == value) { + return; + } + var->setValue(value); + config_mark_dirty(); + }; + if (!spec.isModified) { + spec.isModified = [find] { + const auto* var = find(); + return var != nullptr && var->getValue() != var->getDefaultValue(); + }; + } + return true; + } + case UI_CONTROL_NUMBER: + case UI_CONTROL_SELECT: { + const auto find = [modPtr, varHandle] { + return static_cast*>( + config_find_var(*modPtr, varHandle, CONFIG_VAR_INT)); + }; + if (find() == nullptr) { + return false; + } + spec.getInt = [find] { + const auto* var = find(); + return var != nullptr ? clamp_to_int(var->getValue()) : 0; + }; + spec.setInt = [find](int value) { + auto* var = find(); + if (var == nullptr || var->getValue() == value) { + return; + } + var->setValue(value); + config_mark_dirty(); + }; + if (!spec.isModified) { + spec.isModified = [find] { + const auto* var = find(); + return var != nullptr && var->getValue() != var->getDefaultValue(); + }; + } + return true; + } + case UI_CONTROL_STRING: { + const auto find = [modPtr, varHandle] { + return static_cast*>( + config_find_var(*modPtr, varHandle, CONFIG_VAR_STRING)); + }; + if (find() == nullptr) { + return false; + } + spec.getString = [find]() -> Rml::String { + const auto* var = find(); + return var != nullptr ? var->getValue() : ""; + }; + spec.setString = [find](Rml::String value) { + auto* var = find(); + if (var == nullptr || var->getValue() == value) { + return; + } + var->setValue(std::move(value)); + config_mark_dirty(); + }; + if (!spec.isModified) { + spec.isModified = [find] { + const auto* var = find(); + return var != nullptr && var->getValue() != var->getDefaultValue(); + }; + } + return true; + } + default: + return false; + } +} + +void on_mod_window_destroyed(uint64_t handle) { + const auto* entry = s_slots.find(handle); + if (entry == nullptr || entry->value.kind != UiSlotKind::Window) { + return; + } + auto released = s_slots.take(handle); + auto* mod = released->owner; + const UiWindowClosedFn onClosed = released->value.onClosed; + void* userData = released->value.onClosedUserData; + if (mod != nullptr && onClosed != nullptr) { + guarded_call(*mod, "window on_closed callback", [&] { + onClosed(mod->context.get(), handle, userData); + }); + } +} + +void on_mod_dialog_destroyed(uint64_t handle) { + auto* slot = slot_from_handle(handle); + if (slot != nullptr && slot->kind == UiSlotKind::Dialog) { + s_slots.erase(handle); + } +} + +class ModDialog final : public ui::Modal { +public: + ModDialog(Props props, std::function onDestroyed) + : Modal{std::move(props)}, m_onDestroyed{std::move(onDestroyed)} {} + + ~ModDialog() override { + if (m_onDestroyed) { + m_onDestroyed(); + } + } + + void close() { pop(); } + void force_close() { Document::hide(true); } + +private: + std::function m_onDestroyed; +}; + +void push_stacked_document(std::unique_ptr document) { + if (auto* previousTop = ui::top_document()) { + previousTop->push(std::move(document)); + } else { + ui::push_document(std::move(document)); + } +} + +// Shared by dialog_push and dialog_add_action; the guard handle keeps a +// pressed callback from calling into a torn-down mod. +ui::ModalAction make_dialog_action(LoadedMod& mod, uint64_t handle, const UiDialogAction& action) { + return { + .label = action.label, + .onPressed = + [modPtr = &mod, handle, fn = action.on_pressed, userData = action.user_data, + keepOpen = action.keep_open != 0](ui::Modal& modal) { + if (!dialog_open(handle)) { + return; // already being torn down + } + if (fn != nullptr) { + guarded_call(*modPtr, "dialog action callback", [&] { + fn(modPtr->context.get(), handle, userData); + }); + } + // The callback may have closed the dialog already + if (!keepOpen && dialog_open(handle)) { + static_cast(modal).close(); + } + }, + }; +} + +} // namespace + +ModResult ui_register_mods_panel(LoadedMod& mod, const UiModsPanelDesc& desc) { + s_modPanels[&mod] = {desc.build, desc.update, desc.user_data}; + return MOD_OK; +} + +void ui_build_mods_panels(LoadedMod& mod, ui::Pane& pane) { + const auto it = s_modPanels.find(&mod); + if (it == s_modPanels.end()) { + return; + } + const uint64_t paneHandle = wrap_pane(mod, pane, nullptr); + const auto& panel = it->second; + if (!mod.active || panel.build == nullptr) { + return; + } + invoke_mod_ui_callback(mod, "mod UI panel build", [&](ModError* error) { + return panel.build(mod.context.get(), paneHandle, panel.userData, error); + }); +} + +void ui_update_mods_panels(LoadedMod& mod) { + const auto it = s_modPanels.find(&mod); + if (it == s_modPanels.end()) { + return; + } + const auto& panel = it->second; + if (!mod.active || panel.update == nullptr) { + return; + } + invoke_mod_ui_callback(mod, "mod UI panel update", [&](ModError* error) { + return panel.update(mod.context.get(), panel.userData, error); + }); +} + +ModResult ui_pane_add_section(LoadedMod& mod, uint64_t pane, const char* title) { + auto* slot = resolve(mod, pane, UiSlotKind::Pane, "pane_add_section"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + slot->pane->add_section(title); + return MOD_OK; +} + +ModResult ui_pane_add_text(LoadedMod& mod, uint64_t pane, const char* text, uint64_t* outElem) { + auto* slot = resolve(mod, pane, UiSlotKind::Pane, "pane_add_text"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auto* elem = slot->pane->add_text(text); + if (outElem != nullptr) { + auto& elemSlot = alloc_slot(mod, UiSlotKind::Text, *outElem); + elemSlot.elementRml = ui::escape(text); + elemSlot.hasElementValue = true; + track_element(*outElem, elemSlot, *elem); + } + return MOD_OK; +} + +ModResult ui_pane_add_rml(LoadedMod& mod, uint64_t pane, const char* rml, uint64_t* outElem) { + auto* slot = resolve(mod, pane, UiSlotKind::Pane, "pane_add_rml"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auto* elem = slot->pane->add_rml(rml); + if (outElem != nullptr) { + auto& elemSlot = alloc_slot(mod, UiSlotKind::Text, *outElem); + elemSlot.elementRml = rml; + elemSlot.hasElementValue = true; + track_element(*outElem, elemSlot, *elem); + } + return MOD_OK; +} + +ModResult ui_pane_add_progress(LoadedMod& mod, uint64_t pane, float value, uint64_t* outElem) { + auto* slot = resolve(mod, pane, UiSlotKind::Pane, "pane_add_progress"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auto* elem = ui::append(slot->element, "progress"); + elem->SetAttribute("value", value); + if (outElem != nullptr) { + auto& elemSlot = alloc_slot(mod, UiSlotKind::Progress, *outElem); + elemSlot.elementFloat = value; + elemSlot.hasElementValue = true; + track_element(*outElem, elemSlot, *elem); + } + return MOD_OK; +} + +ModResult ui_pane_add_control( + LoadedMod& mod, uint64_t pane, const UiControlDesc& desc, uint64_t* outElem) { + auto* slot = resolve(mod, pane, UiSlotKind::Pane, "pane_add_control"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + ui::ModControlSpec spec; + spec.label = desc.label; + spec.helpRml = desc.help_rml != nullptr ? desc.help_rml : ""; + spec.isDisabled = wrap_predicate(mod, desc.is_disabled, desc.user_data, pane); + spec.isModified = wrap_predicate(mod, desc.is_modified, desc.user_data, pane); + switch (desc.kind) { + case UI_CONTROL_BUTTON: + spec.kind = ui::ModControlSpec::Kind::Button; + spec.onPressed = [modPtr = &mod, fn = desc.on_pressed, userData = desc.user_data, + guardHandle = pane] { + if (!slot_live(guardHandle)) { + return; + } + guarded_call(*modPtr, "control on_pressed callback", [&] { + fn(modPtr->context.get(), userData); + }); + }; + break; + case UI_CONTROL_TOGGLE: + spec.kind = ui::ModControlSpec::Kind::Toggle; + break; + case UI_CONTROL_NUMBER: + spec.kind = ui::ModControlSpec::Kind::Number; + if (desc.min != desc.max) { + spec.min = clamp_to_int(desc.min); + spec.max = clamp_to_int(desc.max); + if (spec.max < spec.min) { + std::swap(spec.min, spec.max); + } + } + spec.step = desc.step < 1 ? 1 : clamp_to_int(desc.step); + spec.prefix = desc.prefix != nullptr ? desc.prefix : ""; + spec.suffix = desc.suffix != nullptr ? desc.suffix : ""; + break; + case UI_CONTROL_STRING: + spec.kind = ui::ModControlSpec::Kind::String; + spec.maxLength = desc.max_length < 1 ? -1 : desc.max_length; + break; + case UI_CONTROL_SELECT: + spec.kind = ui::ModControlSpec::Kind::Select; + if (slot->helpPane == nullptr) { + Log.error("[{}] pane_add_control: SELECT controls need a help pane (mod window tabs)", + mod.metadata.id); + return MOD_UNSUPPORTED; + } + for (size_t i = 0; i < desc.option_count; ++i) { + spec.options.emplace_back(desc.options[i]); + } + break; + default: + return MOD_INVALID_ARGUMENT; + } + + if (desc.kind != UI_CONTROL_BUTTON) { + if (desc.binding == UI_BINDING_CONFIG_VAR) { + if (!wire_config_var_binding(mod, desc, spec)) { + Log.error("[{}] pane_add_control: config var handle {:#x} is unknown or its type " + "does not match the control kind", + mod.metadata.id, desc.config_var); + return MOD_INVALID_ARGUMENT; + } + } else { + wire_callback_binding(mod, desc, spec, pane); + } + } + + // Copy the pane pointers out: allocating the control's slot below may reallocate s_slots + auto* paneComponent = slot->pane; + auto* helpPane = slot->helpPane; + auto* control = ui::build_mod_control(*paneComponent, helpPane, std::move(spec)); + if (control == nullptr) { + return MOD_UNSUPPORTED; + } + if (outElem != nullptr) { + auto& elemSlot = alloc_slot(mod, UiSlotKind::Control, *outElem); + track_element(*outElem, elemSlot, *control->root()); + } + return MOD_OK; +} + +ModResult ui_elem_set_text(LoadedMod& mod, uint64_t elem, const char* text) { + auto* slot = resolve(mod, elem, UiSlotKind::Text, "elem_set_text"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const std::string rml = ui::escape(text); + if (slot->hasElementValue && slot->elementRml == rml) { + return MOD_OK; + } + slot->elementRml = rml; + slot->hasElementValue = true; + slot->element->SetInnerRML(slot->elementRml); + return MOD_OK; +} + +ModResult ui_elem_set_rml(LoadedMod& mod, uint64_t elem, const char* rml) { + auto* slot = resolve(mod, elem, UiSlotKind::Text, "elem_set_rml"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + if (slot->hasElementValue && slot->elementRml == rml) { + return MOD_OK; + } + slot->elementRml = rml; + slot->hasElementValue = true; + slot->element->SetInnerRML(rml); + return MOD_OK; +} + +ModResult ui_elem_set_progress(LoadedMod& mod, uint64_t elem, float value) { + auto* slot = resolve(mod, elem, UiSlotKind::Progress, "elem_set_progress"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + if (slot->hasElementValue && slot->elementFloat == value) { + return MOD_OK; + } + slot->elementFloat = value; + slot->hasElementValue = true; + slot->element->SetAttribute("value", value); + return MOD_OK; +} + +ModResult ui_elem_set_class(LoadedMod& mod, uint64_t elem, const char* name, bool active) { + auto* entry = s_slots.find_owned(elem, mod); + if (entry == nullptr || entry->value.element == nullptr) { + Log.error( + "[{}] elem_set_class: stale or invalid element handle {:#x}", mod.metadata.id, elem); + return MOD_INVALID_ARGUMENT; + } + entry->value.element->SetClass(name, active); + return MOD_OK; +} + +ModResult ui_window_push(LoadedMod& mod, const UiWindowDesc& desc, uint64_t& outHandle) { + outHandle = 0; + if (!aurora::rmlui::is_initialized()) { + return MOD_UNAVAILABLE; + } + if (desc.rcss != nullptr && desc.rcss[0] != '\0' && + Rml::Factory::InstanceStyleSheetString(desc.rcss) == nullptr) + { + Log.error("[{}] window_push: failed to parse window RCSS", mod.metadata.id); + return MOD_INVALID_ARGUMENT; + } + + uint64_t handle = 0; + { + auto& slot = alloc_slot(mod, UiSlotKind::Window, handle); + slot.onClosed = desc.on_closed; + slot.onClosedUserData = desc.user_data; + } + + ui::ModWindow::Desc windowDesc; + windowDesc.modId = mod.metadata.id; + windowDesc.rcss = desc.rcss != nullptr ? desc.rcss : ""; + windowDesc.onDestroyed = [handle] { on_mod_window_destroyed(handle); }; + for (size_t i = 0; i < desc.tab_count; ++i) { + const UiTabDesc& tab = desc.tabs[i]; + ui::ModWindow::Tab hostTab; + hostTab.title = tab.title; + hostTab.build = [modPtr = &mod, handle, build = tab.build, userData = tab.user_data]( + ui::ModWindow&, ui::Pane& left, ui::Pane& right) { + if (build == nullptr || !slot_live(handle) || !modPtr->active) { + return; + } + const uint64_t leftHandle = wrap_pane(*modPtr, left, &right); + const uint64_t rightHandle = wrap_pane(*modPtr, right, nullptr); + invoke_mod_ui_callback(*modPtr, "mod UI tab build", [&](ModError* error) { + return build( + modPtr->context.get(), handle, leftHandle, rightHandle, userData, error); + }); + }; + if (tab.update != nullptr) { + hostTab.update = [modPtr = &mod, handle, update = tab.update, + userData = tab.user_data] { + if (!slot_live(handle) || !modPtr->active) { + return; + } + invoke_mod_ui_callback(*modPtr, "mod UI tab update", [&](ModError* error) { + return update(modPtr->context.get(), userData, error); + }); + }; + } + windowDesc.tabs.push_back(std::move(hostTab)); + } + + // The first tab builds during construction, which can allocate slots; only + // re-resolve the window slot afterwards. + auto window = std::make_unique(std::move(windowDesc)); + if (auto* slot = slot_from_handle(handle)) { + slot->document = window.get(); + } + push_stacked_document(std::move(window)); + outHandle = handle; + return MOD_OK; +} + +ModResult ui_window_close(LoadedMod& mod, uint64_t handle) { + auto* slot = resolve(mod, handle, UiSlotKind::Window, "window_close"); + if (slot == nullptr || slot->document == nullptr) { + return MOD_INVALID_ARGUMENT; + } + slot->document->hide(true); + return MOD_OK; +} + +ModResult ui_dialog_push(LoadedMod& mod, const UiDialogDesc& desc, uint64_t& outHandle) { + outHandle = 0; + if (!aurora::rmlui::is_initialized()) { + return MOD_UNAVAILABLE; + } + uint64_t handle = 0; + alloc_slot(mod, UiSlotKind::Dialog, handle); + + const char* defaultIcon = ""; + ui::Modal::Props props; + switch (desc.variant) { + case UI_DIALOG_WARNING: + defaultIcon = "warning"; + break; + case UI_DIALOG_DANGER: + props.variant = "danger"; + defaultIcon = "error"; + break; + default: + break; + } + props.title = ui::escape(desc.title); + props.bodyRml = desc.body_rml; + props.icon = desc.icon != nullptr ? desc.icon : defaultIcon; + props.onDismiss = [modPtr = &mod, handle, fn = desc.on_dismiss, userData = desc.user_data]( + ui::Modal& modal) { + if (!dialog_open(handle)) { + return; // already being torn down + } + if (fn != nullptr) { + guarded_call(*modPtr, "dialog on_dismiss callback", [&] { + fn(modPtr->context.get(), handle, userData); + }); + } + if (dialog_open(handle)) { + static_cast(modal).close(); + } + }; + for (size_t i = 0; i < desc.action_count; ++i) { + props.actions.push_back(make_dialog_action(mod, handle, desc.actions[i])); + } + + auto dialog = + std::make_unique(std::move(props), [handle] { on_mod_dialog_destroyed(handle); }); + if (auto* slot = slot_from_handle(handle)) { + slot->document = dialog.get(); + } + push_stacked_document(std::move(dialog)); + outHandle = handle; + return MOD_OK; +} + +ModResult ui_dialog_close(LoadedMod& mod, uint64_t handle) { + auto* slot = resolve(mod, handle, UiSlotKind::Dialog, "dialog_close"); + if (slot == nullptr || slot->document == nullptr) { + return MOD_INVALID_ARGUMENT; + } + // Programmatic close: no dismiss notification, no sound + static_cast(slot->document)->close(); + return MOD_OK; +} + +ModResult ui_dialog_set_body(LoadedMod& mod, uint64_t handle, const char* rml) { + auto* slot = resolve(mod, handle, UiSlotKind::Dialog, "dialog_set_body"); + if (slot == nullptr || slot->document == nullptr) { + return MOD_INVALID_ARGUMENT; + } + static_cast(slot->document)->set_body(rml); + return MOD_OK; +} + +ModResult ui_dialog_set_icon(LoadedMod& mod, uint64_t handle, const char* icon) { + auto* slot = resolve(mod, handle, UiSlotKind::Dialog, "dialog_set_icon"); + if (slot == nullptr || slot->document == nullptr) { + return MOD_INVALID_ARGUMENT; + } + static_cast(slot->document)->set_icon(icon); + return MOD_OK; +} + +ModResult ui_dialog_add_action(LoadedMod& mod, uint64_t handle, const UiDialogAction& action) { + auto* slot = resolve(mod, handle, UiSlotKind::Dialog, "dialog_add_action"); + if (slot == nullptr || slot->document == nullptr) { + return MOD_INVALID_ARGUMENT; + } + static_cast(slot->document)->add_action(make_dialog_action(mod, handle, action)); + return MOD_OK; +} + +ModResult ui_register_menu_tab(LoadedMod& mod, const UiMenuTabDesc& desc, uint64_t& outHandle) { + outHandle = 0; + for (const auto& [owner, tabs] : s_modMenuTabs) { + for (const auto& tab : tabs) { + if (owner != &mod && tab.label == desc.label) { + Log.warn("[{}] register_menu_tab: label '{}' is already used by [{}]", + mod.metadata.id, desc.label, owner->metadata.id); + } + } + } + uint64_t handle = 0; + alloc_slot(mod, UiSlotKind::MenuTab, handle); + s_modMenuTabs[&mod].push_back({.handle = handle, + .label = desc.label, + .onSelected = desc.on_selected, + .userData = desc.user_data}); + s_menuTabsDirty = true; + outHandle = handle; + return MOD_OK; +} + +ModResult ui_unregister_menu_tab(LoadedMod& mod, uint64_t handle) { + auto* slot = resolve(mod, handle, UiSlotKind::MenuTab, "unregister_menu_tab"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto it = s_modMenuTabs.find(&mod); + if (it != s_modMenuTabs.end()) { + std::erase_if(it->second, [&](const auto& tab) { return tab.handle == handle; }); + if (it->second.empty()) { + s_modMenuTabs.erase(it); + } + } + s_slots.erase_owned(handle, mod); + s_menuTabsDirty = true; + return MOD_OK; +} + +std::vector ui_mod_menu_tabs() { + // The consumer (a MenuBar being constructed) now reflects the current tab + // set, so a pending rebuild for earlier mutations is moot. + s_menuTabsDirty = false; + std::vector entries; + for (auto& mod : ModLoader::instance().mods()) { + if (!mod.active) { + continue; + } + const auto it = s_modMenuTabs.find(&mod); + if (it == s_modMenuTabs.end()) { + continue; + } + for (const auto& tab : it->second) { + entries.push_back({.label = tab.label, + .onSelected = [modPtr = &mod, handle = tab.handle, fn = tab.onSelected, + userData = tab.userData] { + if (!slot_live(handle) || !modPtr->active) { + return; // registered by a since-unloaded mod image + } + guarded_call(*modPtr, "menu tab on_selected callback", [&] { + fn(modPtr->context.get(), userData); + }); + }}); + } + } + return entries; +} + +void ui_sync_menu_tabs() { + if (!s_menuTabsDirty) { + return; + } + s_menuTabsDirty = false; + if (aurora::rmlui::is_initialized()) { + ui::MenuBar::rebuild(); + } +} + +bool ui_any_document_visible() { + return ui::any_document_visible(); +} + +ModResult ui_register_styles( + LoadedMod& mod, uint32_t scope, const char* rcss, uint64_t& outHandle) { + outHandle = 0; + ui::DocumentScope docScope; + switch (scope) { + case UI_SCOPE_PRELAUNCH: + docScope = ui::DocumentScope::Prelaunch; + break; + case UI_SCOPE_WINDOW: + docScope = ui::DocumentScope::Window; + break; + case UI_SCOPE_MENU_BAR: + docScope = ui::DocumentScope::MenuBar; + break; + case UI_SCOPE_OVERLAY: + docScope = ui::DocumentScope::Overlay; + break; + case UI_SCOPE_TOUCH_CONTROLS: + docScope = ui::DocumentScope::TouchControls; + break; + case UI_SCOPE_GRAPHICS_TUNER: + docScope = ui::DocumentScope::GraphicsTuner; + break; + default: + return MOD_INVALID_ARGUMENT; + } + + uint64_t handle = 0; + auto& slot = alloc_slot(mod, UiSlotKind::Style, handle); + slot.styleScope = docScope; + slot.styleId = fmt::format("{}:{:x}", mod.metadata.id, handle); + if (!ui::register_scoped_styles(docScope, slot.styleId, rcss)) { + Log.error("[{}] register_styles: failed to parse RCSS", mod.metadata.id); + s_slots.erase(handle); + return MOD_INVALID_ARGUMENT; + } + outHandle = handle; + return MOD_OK; +} + +ModResult ui_register_styles_file( + LoadedMod& mod, uint32_t scope, const char* path, uint64_t& outHandle) { + outHandle = 0; + if (mod.bundle == nullptr) { + return MOD_UNAVAILABLE; + } + std::vector data; + const std::string entry = std::string{"res/"} + path; + try { + data = mod.bundle->readFile(entry); + } catch (const std::runtime_error& e) { + Log.error("[{}] register_styles_file '{}' failed: {}", mod.metadata.id, entry, e.what()); + return MOD_UNAVAILABLE; + } + const std::string rcss{data.begin(), data.end()}; + return ui_register_styles(mod, scope, rcss.c_str(), outHandle); +} + +ModResult ui_unregister_styles(LoadedMod& mod, uint64_t handle) { + auto* slot = resolve(mod, handle, UiSlotKind::Style, "unregister_styles"); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auto released = s_slots.take_owned(handle, mod); + ui::unregister_scoped_styles(released->value.styleScope, released->value.styleId); + return MOD_OK; +} + +void ui_remove_mod(LoadedMod& mod) { + s_modPanels.erase(&mod); + if (s_modMenuTabs.erase(&mod) != 0) { + s_menuTabsDirty = true; + } + auto entries = s_slots.take_all(mod); + for (auto& entry : entries) { + auto& slot = entry.value; + switch (slot.kind) { + case UiSlotKind::Window: { + auto* window = static_cast(slot.document); + if (window != nullptr) { + window->force_close(); + } + break; + } + case UiSlotKind::Dialog: { + auto* dialog = static_cast(slot.document); + if (dialog != nullptr) { + dialog->force_close(); + } + break; + } + case UiSlotKind::Style: + ui::unregister_scoped_styles(slot.styleScope, slot.styleId); + break; + default: + break; + } + } +} + +} // namespace dusk::mods::svc::ui_impl + +namespace dusk::mods::svc { +namespace { + +// Validation of the tagged control descriptor: required fields per kind/binding. Value +// translation and cvar wiring live in loader/ui.cpp. +bool valid_control_desc(const UiControlDesc& desc) { + if (desc.struct_size < sizeof(UiControlDesc) || desc.label == nullptr) { + return false; + } + switch (desc.kind) { + case UI_CONTROL_BUTTON: + return desc.on_pressed != nullptr; + case UI_CONTROL_TOGGLE: + case UI_CONTROL_NUMBER: + case UI_CONTROL_STRING: + case UI_CONTROL_SELECT: + break; + default: + return false; + } + if (desc.kind == UI_CONTROL_SELECT) { + if (desc.options == nullptr || desc.option_count == 0) { + return false; + } + for (size_t i = 0; i < desc.option_count; ++i) { + if (desc.options[i] == nullptr) { + return false; + } + } + } + switch (desc.binding) { + case UI_BINDING_CALLBACKS: + return desc.get != nullptr && desc.set != nullptr; + case UI_BINDING_CONFIG_VAR: + return desc.config_var != 0; + default: + return false; + } +} + +ModResult ui_register_mods_panel(ModContext* context, const UiModsPanelDesc* desc) { + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(UiModsPanelDesc) || + desc->build == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_register_mods_panel(*mod, *desc); +} + +ModResult ui_pane_add_section(ModContext* context, UiElementHandle pane, const char* title) { + auto* mod = mod_from_context(context); + if (mod == nullptr || pane == 0 || title == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_pane_add_section(*mod, pane, title); +} + +ModResult ui_pane_add_text( + ModContext* context, UiElementHandle pane, const char* text, UiElementHandle* outElem) { + if (outElem != nullptr) { + *outElem = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || pane == 0 || text == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_pane_add_text(*mod, pane, text, outElem); +} + +ModResult ui_pane_add_rml( + ModContext* context, UiElementHandle pane, const char* rml, UiElementHandle* outElem) { + if (outElem != nullptr) { + *outElem = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || pane == 0 || rml == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_pane_add_rml(*mod, pane, rml, outElem); +} + +ModResult ui_pane_add_progress( + ModContext* context, UiElementHandle pane, float value, UiElementHandle* outElem) { + if (outElem != nullptr) { + *outElem = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || pane == 0) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_pane_add_progress(*mod, pane, value, outElem); +} + +ModResult ui_pane_add_control(ModContext* context, UiElementHandle pane, const UiControlDesc* desc, + UiElementHandle* outElem) { + if (outElem != nullptr) { + *outElem = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || pane == 0 || desc == nullptr || !valid_control_desc(*desc)) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_pane_add_control(*mod, pane, *desc, outElem); +} + +ModResult ui_elem_set_text(ModContext* context, UiElementHandle elem, const char* text) { + auto* mod = mod_from_context(context); + if (mod == nullptr || elem == 0 || text == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_elem_set_text(*mod, elem, text); +} + +ModResult ui_elem_set_rml(ModContext* context, UiElementHandle elem, const char* rml) { + auto* mod = mod_from_context(context); + if (mod == nullptr || elem == 0 || rml == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_elem_set_rml(*mod, elem, rml); +} + +ModResult ui_elem_set_progress(ModContext* context, UiElementHandle elem, float value) { + auto* mod = mod_from_context(context); + if (mod == nullptr || elem == 0) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_elem_set_progress(*mod, elem, value); +} + +ModResult ui_elem_set_class( + ModContext* context, UiElementHandle elem, const char* name, bool active) { + auto* mod = mod_from_context(context); + if (mod == nullptr || elem == 0 || name == nullptr || name[0] == '\0') { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_elem_set_class(*mod, elem, name, active); +} + +ModResult ui_window_push(ModContext* context, const UiWindowDesc* desc, UiWindowHandle* outWindow) { + if (outWindow != nullptr) { + *outWindow = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(UiWindowDesc) || + desc->tabs == nullptr || desc->tab_count == 0) + { + return MOD_INVALID_ARGUMENT; + } + for (size_t i = 0; i < desc->tab_count; ++i) { + const UiTabDesc& tab = desc->tabs[i]; + if (tab.struct_size < sizeof(UiTabDesc) || tab.title == nullptr || tab.build == nullptr) { + return MOD_INVALID_ARGUMENT; + } + } + uint64_t handle = 0; + const auto result = ui_impl::ui_window_push(*mod, *desc, handle); + if (result == MOD_OK && outWindow != nullptr) { + *outWindow = handle; + } + return result; +} + +ModResult ui_window_close(ModContext* context, UiWindowHandle window) { + auto* mod = mod_from_context(context); + if (mod == nullptr || window == 0) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_window_close(*mod, window); +} + +ModResult ui_dialog_push(ModContext* context, const UiDialogDesc* desc, UiDialogHandle* outDialog) { + if (outDialog != nullptr) { + *outDialog = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(UiDialogDesc) || + desc->title == nullptr || desc->body_rml == nullptr || desc->actions == nullptr || + desc->action_count == 0 || desc->variant > UI_DIALOG_DANGER) + { + return MOD_INVALID_ARGUMENT; + } + for (size_t i = 0; i < desc->action_count; ++i) { + if (desc->actions[i].label == nullptr) { + return MOD_INVALID_ARGUMENT; + } + } + uint64_t handle = 0; + const auto result = ui_impl::ui_dialog_push(*mod, *desc, handle); + if (result == MOD_OK && outDialog != nullptr) { + *outDialog = handle; + } + return result; +} + +ModResult ui_dialog_close(ModContext* context, UiDialogHandle dialog) { + auto* mod = mod_from_context(context); + if (mod == nullptr || dialog == 0) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_dialog_close(*mod, dialog); +} + +ModResult ui_is_any_document_visible(ModContext* context, bool* outVisible) { + auto* mod = mod_from_context(context); + if (mod == nullptr || outVisible == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outVisible = ui_impl::ui_any_document_visible(); + return MOD_OK; +} + +ModResult ui_register_styles( + ModContext* context, UiStyleScope scope, const char* rcss, UiStyleHandle* outStyle) { + if (outStyle != nullptr) { + *outStyle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || rcss == nullptr || scope > UI_SCOPE_GRAPHICS_TUNER) { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = ui_impl::ui_register_styles(*mod, scope, rcss, handle); + if (result == MOD_OK && outStyle != nullptr) { + *outStyle = handle; + } + return result; +} + +ModResult ui_register_styles_file( + ModContext* context, UiStyleScope scope, const char* path, UiStyleHandle* outStyle) { + if (outStyle != nullptr) { + *outStyle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || path == nullptr || !is_safe_resource_path(path) || + scope > UI_SCOPE_GRAPHICS_TUNER) + { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = ui_impl::ui_register_styles_file(*mod, scope, path, handle); + if (result == MOD_OK && outStyle != nullptr) { + *outStyle = handle; + } + return result; +} + +ModResult ui_unregister_styles(ModContext* context, UiStyleHandle style) { + auto* mod = mod_from_context(context); + if (mod == nullptr || style == 0) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_unregister_styles(*mod, style); +} + +ModResult ui_register_menu_tab( + ModContext* context, const UiMenuTabDesc* desc, UiMenuTabHandle* outTab) { + if (outTab != nullptr) { + *outTab = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(UiMenuTabDesc) || + desc->label == nullptr || desc->label[0] == '\0' || desc->on_selected == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = ui_impl::ui_register_menu_tab(*mod, *desc, handle); + if (result == MOD_OK && outTab != nullptr) { + *outTab = handle; + } + return result; +} + +ModResult ui_unregister_menu_tab(ModContext* context, UiMenuTabHandle tab) { + auto* mod = mod_from_context(context); + if (mod == nullptr || tab == 0) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_unregister_menu_tab(*mod, tab); +} + +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) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_dialog_set_body(*mod, dialog, bodyRml); +} + +ModResult ui_dialog_set_icon(ModContext* context, UiDialogHandle dialog, const char* icon) { + auto* mod = mod_from_context(context); + if (mod == nullptr || dialog == 0 || icon == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_dialog_set_icon(*mod, dialog, icon); +} + +ModResult ui_dialog_add_action( + ModContext* context, UiDialogHandle dialog, const UiDialogAction* action) { + auto* mod = mod_from_context(context); + if (mod == nullptr || dialog == 0 || action == nullptr || action->label == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_dialog_add_action(*mod, dialog, *action); +} + +constexpr UiService s_uiService{ + .header = SERVICE_HEADER(UiService, UI_SERVICE_MAJOR, UI_SERVICE_MINOR), + .register_mods_panel = ui_register_mods_panel, + .pane_add_section = ui_pane_add_section, + .pane_add_text = ui_pane_add_text, + .pane_add_rml = ui_pane_add_rml, + .pane_add_progress = ui_pane_add_progress, + .pane_add_control = ui_pane_add_control, + .elem_set_text = ui_elem_set_text, + .elem_set_rml = ui_elem_set_rml, + .elem_set_progress = ui_elem_set_progress, + .elem_set_class = ui_elem_set_class, + .window_push = ui_window_push, + .window_close = ui_window_close, + .dialog_push = ui_dialog_push, + .dialog_close = ui_dialog_close, + .dialog_set_body = ui_dialog_set_body, + .dialog_set_icon = ui_dialog_set_icon, + .dialog_add_action = ui_dialog_add_action, + .is_any_document_visible = ui_is_any_document_visible, + .register_styles = ui_register_styles, + .register_styles_file = ui_register_styles_file, + .unregister_styles = ui_unregister_styles, + .register_menu_tab = ui_register_menu_tab, + .unregister_menu_tab = ui_unregister_menu_tab, +}; + +} // namespace + +void ui_build_mods_panels(LoadedMod& mod, ui::Pane& pane) { + ui_impl::ui_build_mods_panels(mod, pane); +} + +void ui_update_mods_panels(LoadedMod& mod) { + ui_impl::ui_update_mods_panels(mod); +} + +std::vector ui_mod_menu_tabs() { + return ui_impl::ui_mod_menu_tabs(); +} + +constinit const ServiceModule g_uiModule{ + .id = UI_SERVICE_ID, + .majorVersion = UI_SERVICE_MAJOR, + .minorVersion = UI_SERVICE_MINOR, + .service = &s_uiService, + .modDetached = ui_impl::ui_remove_mod, + .frameEnd = ui_impl::ui_sync_menu_tabs, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/ui.hpp b/src/dusk/mods/svc/ui.hpp new file mode 100644 index 0000000000..0f0efc7c9d --- /dev/null +++ b/src/dusk/mods/svc/ui.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "dusk/mod_loader.hpp" + +#include +#include +#include + +namespace dusk::ui { +class Pane; +} // namespace dusk::ui + +namespace dusk::mods::svc { + +void ui_build_mods_panels(LoadedMod& mod, ui::Pane& pane); +void ui_update_mods_panels(LoadedMod& mod); + +struct ModMenuTabEntry { + std::string label; + std::function onSelected; +}; + +std::vector ui_mod_menu_tabs(); + +} // namespace dusk::mods::svc diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp index 4a54139b51..980ca24629 100644 --- a/src/dusk/settings.cpp +++ b/src/dusk/settings.cpp @@ -1,5 +1,6 @@ #include "dusk/settings.h" #include "dusk/config.hpp" +#include namespace dusk { @@ -298,7 +299,8 @@ void registerSettings() { Register(g_userSettings.game.touchCameraYSensitivity); Register(g_userSettings.game.minimalHUD); Register(g_userSettings.game.hudScale); - Register(g_userSettings.game.pauseOnFocusLost); + Register(g_userSettings.game.pauseOnFocusLost, + [](const bool& value, const bool&) { aurora_set_pause_on_focus_lost(value); }); Register(g_userSettings.game.enableDiscordPresence); Register(g_userSettings.game.bloomMode); Register(g_userSettings.game.bloomMultiplier); diff --git a/src/dusk/speedrun.cpp b/src/dusk/speedrun.cpp index 38488600b1..275a8de900 100644 --- a/src/dusk/speedrun.cpp +++ b/src/dusk/speedrun.cpp @@ -1,5 +1,6 @@ #include "dusk/speedrun.h" #include "dusk/settings.h" +#include "dusk/config.hpp" #include "m_Do/m_Do_main.h" #include @@ -34,13 +35,24 @@ void resetForSpeedrunMode() { getSettings().game.fastRoll.setSpeedrunValue(false); getSettings().game.fastSpinner.setSpeedrunValue(false); getSettings().game.armorRupeeDrain.setSpeedrunValue(MagicArmorMode::NORMAL); + getSettings().game.invincibleEnemies.setSpeedrunValue(false); getSettings().game.pauseOnFocusLost.setSpeedrunValue(false); - aurora_set_pause_on_focus_lost(false); getSettings().backend.enableAdvancedSettings.setSpeedrunValue(false); getSettings().game.recordingMode.setSpeedrunValue(false); getSettings().game.debugFlyCam.setSpeedrunValue(false); } +static void clearSpeedrunOverrides() { + config::EnumerateRegistered([](config::ConfigVarBase& cvar) { + cvar.clearSpeedrunOverride(); + }); +} + +void restoreFromSpeedrunMode() { + clearSpeedrunOverrides(); + aurora_set_pause_on_focus_lost(getSettings().game.pauseOnFocusLost.getValue()); +} + } // namespace dusk diff --git a/src/dusk/stubs.cpp b/src/dusk/stubs.cpp index c6832d2cac..b283eb43bf 100644 --- a/src/dusk/stubs.cpp +++ b/src/dusk/stubs.cpp @@ -179,14 +179,6 @@ int OSJamMessage(OSMessageQueue* mq, void* msg, s32 flags) { return 1; } -// ========================================================================== -// Arena Functions -// ========================================================================== - -void* OSInitAlloc(void* arenaStart, void* arenaEnd, int maxHeaps) { - return arenaStart; -} - // ========================================================================== // Remaining OS Stubs // ========================================================================== diff --git a/src/dusk/texture_replacements.cpp b/src/dusk/texture_replacements.cpp index 5477a38dd9..2866af855f 100644 --- a/src/dusk/texture_replacements.cpp +++ b/src/dusk/texture_replacements.cpp @@ -20,7 +20,8 @@ void reload() { } const auto root = ConfigPath / "texture_replacements"; - s_directoryGroup = aurora::texture::load_replacement_directory(root); + s_directoryGroup = aurora::texture::load_replacement_directory( + root, {.priority = kUserTextureReplacementPriority}); DuskLog.info("Texture replacement directory loaded: {} registration(s)", s_directoryGroup.registrations.size()); } diff --git a/src/dusk/ui/controller_config.cpp b/src/dusk/ui/controller_config.cpp index a506a593a6..b57b16eba5 100644 --- a/src/dusk/ui/controller_config.cpp +++ b/src/dusk/ui/controller_config.cpp @@ -243,11 +243,7 @@ int rumble_raw_to_percent(u16 raw) { } // namespace -ControllerConfigWindow::ControllerConfigWindow(bool prelaunch) { - if (prelaunch) { - mSuppressNavFallback = true; - } - +ControllerConfigWindow::ControllerConfigWindow() { listen( Rml::EventId::Keydown, [this](Rml::Event& event) { @@ -278,7 +274,7 @@ ControllerConfigWindow::ControllerConfigWindow(bool prelaunch) { void ControllerConfigWindow::hide(bool close) { stop_rumble_test(); cancel_pending_binding(); - config::Save(); + config::save(); Window::hide(close); } @@ -403,7 +399,7 @@ void ControllerConfigWindow::render_page(Pane& pane, int port, Page page) { PADClearPort(port); PADSetKeyboardActive(static_cast(port), FALSE); PADSerializeMappings(); - ClearAllActionBindings(port); + config::ClearAllActionBindings(port); refresh_controller_page(); }); @@ -417,7 +413,7 @@ void ControllerConfigWindow::render_page(Pane& pane, int port, Page page) { PADClearPort(port); PADSetKeyboardActive(static_cast(port), TRUE); PADSerializeMappings(); - ClearAllActionBindings(port); + config::ClearAllActionBindings(port); }); const u32 controllerCount = PADCount(); @@ -439,7 +435,7 @@ void ControllerConfigWindow::render_page(Pane& pane, int port, Page page) { PADSetKeyboardActive(static_cast(port), FALSE); PADSetPortForIndex(i, port); PADSerializeMappings(); - ClearAllActionBindings(port); + config::ClearAllActionBindings(port); }); } break; @@ -1125,7 +1121,7 @@ void ControllerConfigWindow::poll_pending_binding() { return; } mPendingActionBinding->setValue(button); - config::Save(); + config::save(); finish_pending_binding(completedPort); } return; diff --git a/src/dusk/ui/controller_config.hpp b/src/dusk/ui/controller_config.hpp index b5a50ad8b1..e5e5f20cfc 100644 --- a/src/dusk/ui/controller_config.hpp +++ b/src/dusk/ui/controller_config.hpp @@ -9,7 +9,7 @@ namespace dusk::ui { class ControllerConfigWindow : public Window { public: - ControllerConfigWindow(bool prelaunch); + ControllerConfigWindow(); void update() override; void hide(bool close) override; diff --git a/src/dusk/ui/controls.hpp b/src/dusk/ui/controls.hpp index 2a83d6127f..5b03bf179a 100644 --- a/src/dusk/ui/controls.hpp +++ b/src/dusk/ui/controls.hpp @@ -51,6 +51,8 @@ struct ControlProps { float h = 0.0f; float scale = 1.0f; ControlAnchor anchor = ControlAnchor::None; + + bool operator==(const ControlProps&) const = default; }; struct ControlRect { @@ -76,6 +78,8 @@ struct ControlLayout { int version = Version; std::map > controls; + + bool operator==(const ControlLayout&) const = default; }; constexpr std::array kControlLayoutIds = { diff --git a/src/dusk/ui/document.cpp b/src/dusk/ui/document.cpp index 1ad03bc1ec..2b387f3fe9 100644 --- a/src/dusk/ui/document.cpp +++ b/src/dusk/ui/document.cpp @@ -5,6 +5,8 @@ #include "m_Do/m_Do_audio.h" +#include + namespace dusk::ui { namespace { @@ -18,8 +20,16 @@ Rml::ElementDocument* load_document(const Rml::String& source) { } // namespace -Document::Document(const Rml::String& source, bool passive) - : mDocument(load_document(source)), mPassive(passive) { +Document::Document(const Rml::String& source, bool passive, DocumentScope scope) + : mDocument(load_document(source)), mScope(scope), mPassive(passive) { + if (mDocument != nullptr) { + if (const auto* base = mDocument->GetStyleSheetContainer()) { + // Clone a pristine snapshot to rebuild from on every restyle + mBaseStyleSheets = base->CombineStyleSheetContainer(Rml::StyleSheetContainer{}); + } + apply_scoped_styles(*this); + } + // Block events while hidden (except for Menu command); play nav sounds when visible listen( Rml::EventId::Keydown, @@ -91,6 +101,51 @@ bool Document::focus() { return false; } +bool Document::set_document_styles(const Rml::String& rcss) { + if (rcss.empty()) { + mDocumentStyleSheets = nullptr; + } else { + auto sheet = Rml::Factory::InstanceStyleSheetString(rcss); + if (sheet == nullptr) { + return false; + } + mDocumentStyleSheets = std::move(sheet); + } + apply_scoped_styles(*this); + return true; +} + +void Document::restyle(std::span sheets) { + if (mDocument == nullptr) { + return; + } + const bool wantsExtra = + mDocumentStyleSheets != nullptr || + std::ranges::any_of(sheets, [](const auto* sheet) { return sheet != nullptr; }); + // Nothing to add + if (!wantsExtra && !mRestyled) { + return; + } + auto combined = mBaseStyleSheets; + const auto combine = [&combined](const Rml::StyleSheetContainer& sheet) { + if (combined != nullptr) { + combined = combined->CombineStyleSheetContainer(sheet); + } else { + combined = sheet.CombineStyleSheetContainer(Rml::StyleSheetContainer{}); + } + }; + for (const auto* sheet : sheets) { + if (sheet != nullptr) { + combine(*sheet); + } + } + if (mDocumentStyleSheets != nullptr) { + combine(*mDocumentStyleSheets); + } + mDocument->SetStyleSheetContainer(std::move(combined)); + mRestyled = wantsExtra; +} + void Document::listen(Rml::Element* element, Rml::EventId event, ScopedEventListener::Callback callback, bool capture) { if (element == nullptr) { @@ -139,6 +194,9 @@ bool Document::handle_nav_event(Rml::Event& event) { bool Document::handle_nav_command(Rml::Event& event, NavCommand cmd) { if (cmd == NavCommand::Menu) { + if (game_obscured_below(*this)) { + return true; + } mDoAud_seStartMenu(visible() ? kSoundMenuClose : kSoundMenuOpen); toggle(); return true; diff --git a/src/dusk/ui/document.hpp b/src/dusk/ui/document.hpp index c3428d9fea..e7bd35947d 100644 --- a/src/dusk/ui/document.hpp +++ b/src/dusk/ui/document.hpp @@ -3,11 +3,14 @@ #include "component.hpp" #include "ui.hpp" +#include + namespace dusk::ui { class Document { public: - explicit Document(const Rml::String& source, bool passive = false); + explicit Document( + const Rml::String& source, bool passive = false, DocumentScope scope = DocumentScope::None); virtual ~Document(); Document(const Document&) = delete; @@ -19,7 +22,22 @@ public: virtual bool focus(); virtual bool visible() const; virtual bool active() const; + virtual bool obscures_game() const { return false; } + virtual void cover() { + mWasVisible = visible(); + hide(false); + } + virtual void uncover() { + if (mWasVisible) { + show(); + } else { + focus(); + } + } + DocumentScope scope() const { return mScope; } + bool set_document_styles(const Rml::String& rcss); + void restyle(std::span sheets); void listen(Rml::Element* element, Rml::EventId event, ScopedEventListener::Callback callback, bool capture = false); void listen(Rml::Element* element, const Rml::String& event, @@ -40,11 +58,11 @@ public: } void push(std::unique_ptr document) { push_document(std::move(document)); - hide(false); + cover(); } - void pop(bool show = true) { + void pop() { hide(true); - focus_top_document(show); + uncover_top_document(); } bool closed() const { return mClosed; } @@ -55,10 +73,15 @@ protected: virtual bool handle_nav_command(Rml::Event& event, NavCommand cmd); Rml::ElementDocument* mDocument; - std::vector > mListeners; + std::vector> mListeners; + Rml::SharedPtr mBaseStyleSheets; + Rml::SharedPtr mDocumentStyleSheets; + DocumentScope mScope = DocumentScope::None; bool mPendingClose = false; bool mClosed = false; bool mPassive = false; + bool mRestyled = false; + bool mWasVisible = false; }; } // namespace dusk::ui diff --git a/src/dusk/ui/graphics_tuner.cpp b/src/dusk/ui/graphics_tuner.cpp index 57fa3ee329..cddcf06330 100644 --- a/src/dusk/ui/graphics_tuner.cpp +++ b/src/dusk/ui/graphics_tuner.cpp @@ -244,9 +244,9 @@ Rml::String format_graphics_setting_value(GraphicsOption option, int value) { return ""; } -GraphicsTuner::GraphicsTuner(GraphicsTunerProps props, bool prelaunch) - : Document(kDocumentSource), mOption(props.option), mValueMin(props.valueMin), - mValueMax(props.valueMax), mDefaultValue(props.defaultValue), mPrelaunch(prelaunch) { +GraphicsTuner::GraphicsTuner(GraphicsTunerProps props) + : Document(kDocumentSource, false, DocumentScope::GraphicsTuner), mOption(props.option), + mValueMin(props.valueMin), mValueMax(props.valueMax), mDefaultValue(props.defaultValue) { if (mDocument == nullptr) { return; } @@ -300,7 +300,7 @@ void GraphicsTuner::show() { } void GraphicsTuner::hide(bool close) { - config::Save(); + config::save(); mRoot->RemoveAttribute("open"); if (close) { mPendingClose = true; @@ -338,7 +338,7 @@ bool GraphicsTuner::handle_nav_command(Rml::Event& event, NavCommand cmd) { return true; } - return mPrelaunch ? false : Document::handle_nav_command(event, cmd); + return Document::handle_nav_command(event, cmd); } void GraphicsTuner::reset_default() { diff --git a/src/dusk/ui/graphics_tuner.hpp b/src/dusk/ui/graphics_tuner.hpp index 03b3d2f1c0..d778583b29 100644 --- a/src/dusk/ui/graphics_tuner.hpp +++ b/src/dusk/ui/graphics_tuner.hpp @@ -63,7 +63,7 @@ struct GraphicsTunerProps { class GraphicsTuner : public Document { public: - explicit GraphicsTuner(GraphicsTunerProps props, bool prelaunch); + explicit GraphicsTuner(GraphicsTunerProps props); void show() override; void hide(bool close) override; @@ -92,7 +92,6 @@ private: std::vector > mComponents; SteppedCarousel* mCarousel; Rml::Element* mRoot; - bool mPrelaunch; }; } // namespace dusk::ui diff --git a/src/dusk/ui/logs_window.cpp b/src/dusk/ui/logs_window.cpp new file mode 100644 index 0000000000..0a6fc2402d --- /dev/null +++ b/src/dusk/ui/logs_window.cpp @@ -0,0 +1,296 @@ +#include "logs_window.hpp" + +#include +#include + +#include +#include + +#include "pane.hpp" + +namespace dusk::ui { +namespace { + +const char* level_name(LogLevel level) { + switch (level) { + case LOG_LEVEL_TRACE: + return "Trace"; + case LOG_LEVEL_DEBUG: + return "Debug"; + case LOG_LEVEL_INFO: + return "Info"; + case LOG_LEVEL_WARN: + return "Warn"; + case LOG_LEVEL_ERROR: + return "Error"; + } + return "?"; +} + +const char* level_logger_name(LogLevel level) { + switch (level) { + case LOG_LEVEL_TRACE: + return "TRACE"; + case LOG_LEVEL_DEBUG: + return "DEBUG"; + case LOG_LEVEL_INFO: + return "INFO"; + case LOG_LEVEL_WARN: + return "WARNING"; + case LOG_LEVEL_ERROR: + return "ERROR"; + } + return "?"; +} + +const char* level_class(LogLevel level) { + switch (level) { + case LOG_LEVEL_TRACE: + return "lvl-trace"; + case LOG_LEVEL_DEBUG: + return "lvl-debug"; + case LOG_LEVEL_INFO: + return "lvl-info"; + case LOG_LEVEL_WARN: + return "lvl-warn"; + case LOG_LEVEL_ERROR: + return "lvl-error"; + } + return "lvl-info"; +} + +std::string format_time(int64_t timeMs) { + const auto seconds = static_cast(timeMs / 1000); + std::tm localTime{}; +#if _WIN32 + localtime_s(&localTime, &seconds); +#else + localtime_r(&seconds, &localTime); +#endif + std::array buffer{}; + std::strftime(buffer.data(), buffer.size(), "%H:%M:%S", &localTime); + return fmt::format("{}.{:03}", buffer.data(), timeMs % 1000); +} + +Rml::Element* append_span(Rml::Element* parent, const char* className, const Rml::String& text) { + auto* span = append(parent, "span"); + span->SetClass(className, true); + append_text(span, text); + return span; +} + +} // namespace + +LogsWindow::LogsWindow(std::string modFilter) + : Window{Props{.tabBar = false, .styleSheets = {"res/rml/logs.rcss"}}}, + mModFilter{std::move(modFilter)} { + mRoot->SetClass("logs", true); + set_content([this](Rml::Element* content) { build_content(content); }); +} + +void LogsWindow::build_content(Rml::Element* content) { + auto* toolbar = append(content, "div"); + toolbar->SetClass("log-toolbar", true); + + auto* title = append(toolbar, "div"); + title->SetClass("log-title", true); + title->SetInnerRML("Logs"); + + auto* modLabel = append(toolbar, "div"); + modLabel->SetClass("log-title-mod", true); + modLabel->SetInnerRML(mModFilter.empty() ? "All mods" : fmt::format("{}", escape(mModFilter))); + + append(toolbar, "div")->SetClass("log-toolbar-spacer", true); + + for (const LogLevel level : + {LOG_LEVEL_TRACE, LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_WARN, LOG_LEVEL_ERROR}) + { + add_child(toolbar, + ControlledButton::Props{ + .text = level_name(level), + .isSelected = [this, level] { return mMinLevel <= level; }, + }) + .on_pressed([this, level] { + mMinLevel = level; + rebuild_lines(); + }); + } + + append(toolbar, "div")->SetClass("log-toolbar-spacer", true); + + add_child