mirror of
https://github.com/TwilitRealm/dusklight
synced 2026-07-09 04:30:49 -04:00
Merge pull request #2185 from encounter/mods-core
Dusklight mod API core
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
Language: Cpp
|
||||
Standard: C++03
|
||||
Standard: c++20
|
||||
AccessModifierOffset: -4
|
||||
AlignAfterOpenBracket: DontAlign
|
||||
AlignConsecutiveAssignments: false
|
||||
|
||||
+142
-136
@@ -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=<SOURCE_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,23 +301,15 @@ 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})
|
||||
|
||||
# PARTIAL_DEBUG makes debug and release share one struct/vtable ABI so a mod binary loads into either
|
||||
set(GAME_COMPILE_DEFS TARGET_PC WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 MTX_USE_PS=1 PARTIAL_DEBUG=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_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)
|
||||
@@ -447,8 +377,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)
|
||||
@@ -475,7 +405,7 @@ set(GAME_DEBUG_FILES
|
||||
set_source_files_properties(
|
||||
${GAME_DEBUG_FILES}
|
||||
PROPERTIES
|
||||
COMPILE_DEFINITIONS "$<$<CONFIG:Debug>:DEBUG=1>;PARTIAL_DEBUG=1"
|
||||
COMPILE_DEFINITIONS "$<$<CONFIG:Debug>:DEBUG=1>"
|
||||
)
|
||||
|
||||
# game_base is for all other game code files
|
||||
@@ -489,16 +419,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}
|
||||
$<$<CONFIG:Debug>:DEBUG=1>
|
||||
PARTIAL_DEBUG=1
|
||||
)
|
||||
target_include_directories(${jsystem_lib} PRIVATE ${GAME_INCLUDE_DIRS})
|
||||
target_compile_definitions(${jsystem_lib} PRIVATE ${GAME_COMPILE_DEFS} $<$<CONFIG:Debug>:DEBUG=1>)
|
||||
target_link_libraries(${jsystem_lib} PRIVATE ${GAME_LIBS})
|
||||
set_target_properties(${jsystem_lib} PROPERTIES FOLDER "JSystem")
|
||||
endforeach()
|
||||
@@ -510,7 +435,7 @@ if (CMAKE_CXX_LINK_GROUP_USING_RESCAN_SUPPORTED OR CMAKE_LINK_GROUP_USING_RESCAN
|
||||
set(JSYSTEM_LINK_LIBRARIES "$<LINK_GROUP:RESCAN,${JSYSTEM_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)
|
||||
@@ -522,9 +447,55 @@ if (ENABLE_ASAN)
|
||||
endif ()
|
||||
|
||||
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 "$<$<COMPILE_LANGUAGE:CXX>:${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 $<$<COMPILE_LANGUAGE:C,CXX>:/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 $<$<COMPILE_LANGUAGE:C,CXX>:-fpatchable-function-entry=2,1>)
|
||||
else ()
|
||||
set(DUSK_PATCHABLE_ENTRY_FLAG $<$<COMPILE_LANGUAGE:C,CXX>:-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
|
||||
@@ -543,6 +514,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)
|
||||
@@ -583,6 +555,13 @@ 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(tools/mod_template)
|
||||
endif ()
|
||||
|
||||
if (APPLE)
|
||||
if (IOS)
|
||||
set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios)
|
||||
@@ -590,6 +569,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
|
||||
@@ -609,8 +589,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}
|
||||
@@ -621,6 +600,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 -
|
||||
"$<TARGET_FILE_DIR:dusklight>/crashpad_handler")
|
||||
endif ()
|
||||
add_custom_command(TARGET dusklight POST_BUILD
|
||||
${_sign_nested_commands}
|
||||
COMMAND /usr/bin/codesign --force --sign - --entitlements
|
||||
"${DUSK_ENTITLEMENTS}" "$<TARGET_BUNDLE_DIR:dusklight>"
|
||||
COMMENT "Signing Dusklight.app with entitlements"
|
||||
VERBATIM
|
||||
)
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
if (APPLE AND NOT IOS AND NOT TVOS)
|
||||
@@ -744,3 +748,5 @@ foreach (target IN LISTS BINARY_TARGETS)
|
||||
endif ()
|
||||
endforeach ()
|
||||
endforeach ()
|
||||
|
||||
install_bundled_mods()
|
||||
|
||||
+18
-2
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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})
|
||||
@@ -0,0 +1,279 @@
|
||||
# add_mod(<target> SOURCES <file>... MOD_JSON <mod.json> [RES_DIR <res>] [OVERLAY_DIR <overlay>]
|
||||
# [TEXTURES_DIR <textures>] [OUTPUT_DIR <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 ()
|
||||
|
||||
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
|
||||
"$<$<COMPILE_LANGUAGE:C,CXX>:/bigobj>"
|
||||
"$<$<COMPILE_LANGUAGE:C,CXX>:/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 "$<$<COMPILE_LANGUAGE:C,CXX>:/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 ()
|
||||
|
||||
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 "")
|
||||
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}"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different "$<TARGET_FILE:${target_name}>" "${_stage}/${_lib_name}"
|
||||
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_name} ${_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 <install>/mods (the loader extracts native libs to the
|
||||
# user cache).
|
||||
# - Linux: pre-extracted stage dirs into <install>/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 <app>/mods/<id> and the dylib into Frameworks/<id>.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 "$<TARGET_FILE:${_target}>"
|
||||
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 ()
|
||||
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()
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,125 @@
|
||||
include_guard(GLOBAL)
|
||||
|
||||
get_filename_component(_SYMBOL_MANIFEST_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY)
|
||||
|
||||
set(_SYMGEN_VERSION "1.1.0")
|
||||
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 "$<TARGET_PDB_FILE:${target}>")
|
||||
set(_out "$<TARGET_FILE_DIR:${target}>/dusklight.symdb")
|
||||
else ()
|
||||
set(_input --binary "$<TARGET_FILE:${target}>")
|
||||
if (APPLE)
|
||||
set(_out "$<TARGET_BUNDLE_CONTENT_DIR:${target}>/Resources/dusklight.symdb")
|
||||
else ()
|
||||
set(_out "$<TARGET_FILE_DIR:${target}>/dusklight.symdb")
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
add_custom_command(TARGET ${target} POST_BUILD
|
||||
COMMAND "${SYMGEN_EXE}" manifest ${_input} --out "${_out}"
|
||||
COMMENT "Generating symbol manifest"
|
||||
VERBATIM)
|
||||
endfunction()
|
||||
@@ -0,0 +1,79 @@
|
||||
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 "$<CONFIG>/")
|
||||
endif ()
|
||||
|
||||
set(_rsp_lines "$<TARGET_OBJECTS:${target}>")
|
||||
foreach (_lib IN LISTS JSYSTEM_LIBRARIES)
|
||||
list(APPEND _rsp_lines "$<TARGET_FILE:${_lib}>")
|
||||
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 "$<TARGET_FILE:${_lib}>")
|
||||
endif ()
|
||||
endforeach ()
|
||||
|
||||
# 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}
|
||||
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()
|
||||
+623
@@ -0,0 +1,623 @@
|
||||
# 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](../tools/mod_template/), 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/<preset>/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/<preset>/` 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 <dir>` 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.<escaped mod id>.<name>` (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.
|
||||
|
||||
---
|
||||
|
||||
## 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<daAlink_c*>(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<daAlink_c*>(args, 0));
|
||||
if (retval != nullptr) {
|
||||
*static_cast<int*>(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<HookshotHit>(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<T>(args, n); // copy
|
||||
T& ref = dusk::mods::arg_ref<T>(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<int>(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<cXyz>` 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.<escaped id>.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<MyModService> {
|
||||
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.
|
||||
Vendored
+1
-1
Submodule extern/aurora updated: 9087a409da...0a5a5d90ef
+30
@@ -1496,6 +1496,10 @@ 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/modal.cpp
|
||||
src/dusk/ui/modal.hpp
|
||||
src/dusk/ui/nav_types.hpp
|
||||
@@ -1507,6 +1511,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
|
||||
@@ -1541,6 +1547,30 @@ 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/config.cpp
|
||||
src/dusk/mods/svc/config.hpp
|
||||
src/dusk/mods/svc/game.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/registry.cpp
|
||||
src/dusk/mods/svc/registry.hpp
|
||||
src/dusk/discord.cpp
|
||||
src/dusk/discord.hpp
|
||||
src/dusk/discord_presence.cpp
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
#include "m_Do/m_Do_graphic.h"
|
||||
#include <cstring>
|
||||
|
||||
#include "tracy/Tracy.hpp"
|
||||
#include "dusk/profiling.hpp"
|
||||
|
||||
enum dComIfG_ButtonStatus {
|
||||
/* 0x00 */ BUTTON_STATUS_NONE,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
#include <dolphin/gx/GXAurora.h>
|
||||
#include <dolphin/gx/GXExtra.h>
|
||||
#include "tracy/Tracy.hpp"
|
||||
|
||||
#include "profiling.hpp"
|
||||
|
||||
#if DUSK_GFX_DEBUG_GROUPS
|
||||
#define GX_DEBUG_GROUP(name, ...) \
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <ranges>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#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<Import> imports;
|
||||
std::vector<Export> 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<loader::NativeModule> 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<ConfigVar<bool> > 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<NativeMod> native;
|
||||
std::unique_ptr<ModContext> context;
|
||||
|
||||
// Shared with overlay file registrations so in-flight DVD reads survive disable/reload.
|
||||
std::shared_ptr<ModBundle> bundle;
|
||||
|
||||
ModManifestInfo manifestInfo;
|
||||
|
||||
// Mods this mod imports services from, and mods importing services from this mod.
|
||||
std::vector<ModDependencyEdge> dependencies;
|
||||
std::vector<ModDependencyEdge> dependents;
|
||||
};
|
||||
|
||||
class ModLoader {
|
||||
public:
|
||||
static ModLoader& instance();
|
||||
|
||||
void set_search_dirs(std::vector<ModSearchDir> 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<NativeMod> native;
|
||||
std::string path;
|
||||
};
|
||||
|
||||
std::vector<std::unique_ptr<LoadedMod> > m_mods;
|
||||
std::vector<ModSearchDir> m_searchDirs;
|
||||
std::filesystem::path m_cacheDir;
|
||||
std::vector<Request> m_pendingRequests;
|
||||
std::vector<std::string> m_pendingFailures;
|
||||
std::vector<RetiredNative> 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 <nativeLibDir>/<mod id><ext> 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<LoadedMod*> collect_lifecycle_set(LoadedMod& target);
|
||||
bool reload_bundle(LoadedMod& mod);
|
||||
bool ensure_native_loaded(LoadedMod& mod);
|
||||
};
|
||||
|
||||
using ModIndex = std::ranges::range_difference_t<decltype(std::declval<ModLoader>().mods())>;
|
||||
|
||||
} // namespace dusk::mods
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#if defined(__has_include)
|
||||
#if __has_include(<tracy/Tracy.hpp>)
|
||||
#include <tracy/Tracy.hpp>
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifndef ZoneScoped
|
||||
#define ZoneScoped
|
||||
#define ZoneScopedN(name)
|
||||
#endif
|
||||
@@ -1,8 +1,13 @@
|
||||
#ifndef DUSK_TEXTURE_REPLACEMENTS_HPP
|
||||
#define DUSK_TEXTURE_REPLACEMENTS_HPP
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace dusk::texture_replacements {
|
||||
|
||||
// Mod replacements are prioritized *over* user replacements (<data folder>/texture_replacements/)
|
||||
inline constexpr int32_t kUserTextureReplacementPriority = -1'000'000;
|
||||
|
||||
void reload();
|
||||
void set_enabled(bool enabled);
|
||||
void shutdown();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef __cplusplus
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
extern "C" {
|
||||
#else
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#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
|
||||
@@ -0,0 +1,464 @@
|
||||
#pragma once
|
||||
|
||||
#include "mods/svc/hook.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
|
||||
namespace dusk::mods {
|
||||
|
||||
template <class T>
|
||||
T arg(void* argsRaw, int n) noexcept {
|
||||
void** args = static_cast<void**>(argsRaw);
|
||||
return *static_cast<std::add_pointer_t<std::remove_reference_t<T> > >(args[n]);
|
||||
}
|
||||
|
||||
template <class T>
|
||||
std::remove_reference_t<T>& arg_ref(void* argsRaw, int n) noexcept {
|
||||
void** args = static_cast<void**>(argsRaw);
|
||||
return *static_cast<std::add_pointer_t<std::remove_reference_t<T> > >(args[n]);
|
||||
}
|
||||
|
||||
template <class F>
|
||||
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 <size_t N>
|
||||
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 <class T>
|
||||
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<class daAlink_c>(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 <class C>
|
||||
constexpr auto vtable_symbol() {
|
||||
constexpr std::string_view name = class_name<C>();
|
||||
constexpr bool simple = name.find_first_of(":<> ") == std::string_view::npos;
|
||||
// "_ZTV" + decimal length + name / "??_7" + name + "@@6B@", NUL-terminated
|
||||
std::array<char, name.size() + 12> 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<char>('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<size_t>(-1);
|
||||
#if defined(_M_X64) || defined(__x86_64__)
|
||||
const auto* p = static_cast<const uint8_t*>(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<int8_t>(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<int8_t>(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<const uint8_t*>(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<int32_t>(insn[0] << 6) >> 6;
|
||||
p += static_cast<intptr_t>(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 <class C, class F>
|
||||
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<const void*>(words[0]);
|
||||
const size_t slot = vcall_slot_offset(fn);
|
||||
if (slot == static_cast<size_t>(-1)) { // not a vcall thunk: direct address
|
||||
*out = const_cast<void*>(fn);
|
||||
return MOD_OK;
|
||||
}
|
||||
void* vtable = nullptr;
|
||||
const ModResult resolved = hooks->resolve(mod_ctx, vtable_symbol<C>().data(), &vtable, nullptr);
|
||||
if (resolved != MOD_OK) {
|
||||
return resolved;
|
||||
}
|
||||
// ??_7 points at the first slot.
|
||||
*out = *reinterpret_cast<void**>(static_cast<char*>(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<void*>(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<C>().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<void**>(static_cast<char*>(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 <class Tag, class R, class... A>
|
||||
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<R>) {
|
||||
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<void*>(std::addressof(result)));
|
||||
if (!skipOriginal) {
|
||||
result = g_orig(args...);
|
||||
}
|
||||
dispatch_post(nullptr, static_cast<void*>(std::addressof(result)));
|
||||
return result;
|
||||
}
|
||||
} else {
|
||||
void* ptrs[] = {static_cast<void*>(std::addressof(args))...};
|
||||
if constexpr (std::is_void_v<R>) {
|
||||
const bool skipOriginal = dispatch_pre(static_cast<void*>(ptrs), nullptr);
|
||||
if (!skipOriginal) {
|
||||
g_orig(args...);
|
||||
}
|
||||
dispatch_post(static_cast<void*>(ptrs), nullptr);
|
||||
} else {
|
||||
R result{};
|
||||
const bool skipOriginal = dispatch_pre(
|
||||
static_cast<void*>(ptrs), static_cast<void*>(std::addressof(result)));
|
||||
if (!skipOriginal) {
|
||||
result = g_orig(args...);
|
||||
}
|
||||
dispatch_post(static_cast<void*>(ptrs), static_cast<void*>(std::addressof(result)));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
template <auto Target>
|
||||
using TargetTag = std::integral_constant<decltype(Target), Target>;
|
||||
template <FixedString Name>
|
||||
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 <auto Target>
|
||||
struct Hook;
|
||||
|
||||
template <class C, class R, class... A, R (C::*Target)(A...)>
|
||||
struct Hook<Target> : HookImpl<detail::TargetTag<Target>, R, C*, A...> {
|
||||
static ModResult resolve_target(const HookService* hooks, void** out) {
|
||||
return detail::member_target<C>(hooks, Target, out);
|
||||
}
|
||||
};
|
||||
|
||||
template <class C, class R, class... A, R (C::*Target)(A...) const>
|
||||
struct Hook<Target> : HookImpl<detail::TargetTag<Target>, R, const C*, A...> {
|
||||
static ModResult resolve_target(const HookService* hooks, void** out) {
|
||||
return detail::member_target<C>(hooks, Target, out);
|
||||
}
|
||||
};
|
||||
|
||||
template <class R, class... A, R (*Target)(A...)>
|
||||
struct Hook<Target> : HookImpl<detail::TargetTag<Target>, 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<HookshotHit>(svc_hook, on_hookshot_hit);
|
||||
*/
|
||||
template <FixedString Name, class Sig>
|
||||
struct NamedHook;
|
||||
|
||||
template <FixedString Name, class R, class... A>
|
||||
struct NamedHook<Name, R(A...)> : HookImpl<detail::NameTag<Name>, 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 <class Entry>
|
||||
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<void*>(Entry::trampoline),
|
||||
reinterpret_cast<void**>(&Entry::g_orig));
|
||||
}
|
||||
|
||||
template <auto Target>
|
||||
ModResult hook_install(const HookService* hooks) {
|
||||
return hook_install<Hook<Target> >(hooks);
|
||||
}
|
||||
|
||||
template <class Entry>
|
||||
ModResult hook_add_pre(
|
||||
const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) {
|
||||
const ModResult installed = hook_install<Entry>(hooks);
|
||||
if (installed != MOD_OK) {
|
||||
return installed;
|
||||
}
|
||||
|
||||
return hooks->add_pre(mod_ctx, Entry::target, callback, options);
|
||||
}
|
||||
|
||||
template <auto Target>
|
||||
ModResult hook_add_pre(
|
||||
const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) {
|
||||
return hook_add_pre<Hook<Target> >(hooks, callback, options);
|
||||
}
|
||||
|
||||
template <class Entry>
|
||||
ModResult hook_add_post(
|
||||
const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) {
|
||||
const ModResult installed = hook_install<Entry>(hooks);
|
||||
if (installed != MOD_OK) {
|
||||
return installed;
|
||||
}
|
||||
|
||||
return hooks->add_post(mod_ctx, Entry::target, callback, options);
|
||||
}
|
||||
|
||||
template <auto Target>
|
||||
ModResult hook_add_post(
|
||||
const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) {
|
||||
return hook_add_post<Hook<Target> >(hooks, callback, options);
|
||||
}
|
||||
|
||||
template <class Entry>
|
||||
ModResult hook_replace(
|
||||
const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) {
|
||||
const ModResult installed = hook_install<Entry>(hooks);
|
||||
if (installed != MOD_OK) {
|
||||
return installed;
|
||||
}
|
||||
|
||||
return hooks->replace(mod_ctx, Entry::target, callback, options);
|
||||
}
|
||||
|
||||
template <auto Target>
|
||||
ModResult hook_replace(
|
||||
const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) {
|
||||
return hook_replace<Hook<Target> >(hooks, callback, options);
|
||||
}
|
||||
|
||||
} // namespace dusk::mods
|
||||
@@ -0,0 +1,138 @@
|
||||
#pragma once
|
||||
|
||||
#include "mods/api.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <type_traits>
|
||||
#include <vector>
|
||||
|
||||
namespace dusk::mods {
|
||||
|
||||
template <class Service>
|
||||
struct ServiceTraits;
|
||||
|
||||
namespace detail {
|
||||
|
||||
inline std::vector<ServiceImport>& imports() {
|
||||
static std::vector<ServiceImport> entries;
|
||||
return entries;
|
||||
}
|
||||
|
||||
inline std::vector<ServiceExport>& exports() {
|
||||
static std::vector<ServiceExport> 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<uint16_t>(major_value), \
|
||||
static_cast<uint16_t>(min_minor_value), \
|
||||
static_cast<uint32_t>(flags_value), \
|
||||
&(variable), \
|
||||
})
|
||||
|
||||
#define IMPORT_SERVICE_VERSION(service_type, variable, min_minor_value) \
|
||||
IMPORT_SERVICE_EX(service_type, variable, ::dusk::mods::ServiceTraits<service_type>::id, \
|
||||
::dusk::mods::ServiceTraits<service_type>::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<service_type>::id, \
|
||||
::dusk::mods::ServiceTraits<service_type>::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<std::remove_cv_t<decltype(instance)> >::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<uint16_t>(major_value), \
|
||||
static_cast<uint16_t>(minor_value), \
|
||||
SERVICE_EXPORT_DEFERRED, \
|
||||
nullptr, \
|
||||
}); \
|
||||
}
|
||||
@@ -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.<escaped mod id>.<name>", 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<ConfigService> {
|
||||
static constexpr const char* id = CONFIG_SERVICE_ID;
|
||||
static constexpr uint16_t major_version = CONFIG_SERVICE_MAJOR;
|
||||
};
|
||||
#endif
|
||||
@@ -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<GameService> {
|
||||
static constexpr const char* id = GAME_SERVICE_ID;
|
||||
static constexpr uint16_t major_version = GAME_SERVICE_MAJOR;
|
||||
};
|
||||
#endif
|
||||
@@ -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<T> / arg_ref<T> 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<HookService> {
|
||||
static constexpr const char* id = HOOK_SERVICE_ID;
|
||||
static constexpr uint16_t major_version = HOOK_SERVICE_MAJOR;
|
||||
};
|
||||
#endif
|
||||
@@ -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<HostService> {
|
||||
static constexpr const char* id = HOST_SERVICE_ID;
|
||||
static constexpr uint16_t major_version = HOST_SERVICE_MAJOR;
|
||||
};
|
||||
#endif
|
||||
@@ -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<LogService> {
|
||||
static constexpr const char* id = LOG_SERVICE_ID;
|
||||
static constexpr uint16_t major_version = LOG_SERVICE_MAJOR;
|
||||
};
|
||||
#endif
|
||||
@@ -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<OverlayService> {
|
||||
static constexpr const char* id = OVERLAY_SERVICE_ID;
|
||||
static constexpr uint16_t major_version = OVERLAY_SERVICE_MAJOR;
|
||||
};
|
||||
#endif
|
||||
@@ -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<ResourceService> {
|
||||
static constexpr const char* id = RESOURCE_SERVICE_ID;
|
||||
static constexpr uint16_t major_version = RESOURCE_SERVICE_MAJOR;
|
||||
};
|
||||
#endif
|
||||
@@ -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<TextureService> {
|
||||
static constexpr const char* id = TEXTURE_SERVICE_ID;
|
||||
static constexpr uint16_t major_version = TEXTURE_SERVICE_MAJOR;
|
||||
};
|
||||
#endif
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+12
-3
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Dusklight Mod SDK entry point
|
||||
#
|
||||
# Provides game/service headers, compile definitions and version.h without
|
||||
# configuring the full game tree.
|
||||
#
|
||||
# Usage (from a mod project):
|
||||
# add_subdirectory(<dusk>/sdk dusk-sdk EXCLUDE_FROM_ALL)
|
||||
# add_mod(my_mod SOURCES ... MOD_JSON mod.json)
|
||||
#
|
||||
# On Windows, pass -DDUSK_GAME_IMPLIB=<path to sdk/dusklight.lib> 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()
|
||||
|
||||
# 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")
|
||||
@@ -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 <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
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<intptr_t*>(impSlot);
|
||||
char* target = base + item.target;
|
||||
const int bits = static_cast<int>(item.flags & 0xff);
|
||||
|
||||
intptr_t reldata;
|
||||
switch (bits) {
|
||||
case 8:
|
||||
reldata = *reinterpret_cast<int8_t*>(target);
|
||||
break;
|
||||
case 16:
|
||||
reldata = *reinterpret_cast<int16_t*>(target);
|
||||
break;
|
||||
case 32:
|
||||
reldata = *reinterpret_cast<int32_t*>(target);
|
||||
break;
|
||||
case 64:
|
||||
reldata = *reinterpret_cast<int64_t*>(target);
|
||||
break;
|
||||
default:
|
||||
report("unsupported %d-bit pseudo-relocation at RVA 0x%x", bits, item.target);
|
||||
return false;
|
||||
}
|
||||
reldata -= reinterpret_cast<intptr_t>(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<long long>(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<char*>(&__ImageBase);
|
||||
char* start = &__RUNTIME_PSEUDO_RELOC_LIST__;
|
||||
char* end = &__RUNTIME_PSEUDO_RELOC_LIST_END__;
|
||||
if (end - start < static_cast<ptrdiff_t>(sizeof(HdrV2))) {
|
||||
return; // no data auto-imports in this mod
|
||||
}
|
||||
const HdrV2* hdr = reinterpret_cast<const HdrV2*>(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<const ItemV2*>(hdr + 1);
|
||||
const ItemV2* itemsEnd = reinterpret_cast<const ItemV2*>(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<size_t>(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;
|
||||
}
|
||||
+8
-8
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#include <utility>
|
||||
|
||||
#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<u8> ModBundleDisk::readFile(const std::string& fileName) {
|
||||
return io::FileStream::ReadAllBytes(toRealPath(fileName));
|
||||
}
|
||||
|
||||
std::vector<std::string> ModBundleDisk::getFileNames() {
|
||||
std::vector<std::string> 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<const char8_t*>(fileName.c_str());
|
||||
return root_path / filePath;
|
||||
}
|
||||
|
||||
} // namespace dusk::mods
|
||||
@@ -0,0 +1,68 @@
|
||||
#include "fmt/format.h"
|
||||
#include "loader.hpp"
|
||||
|
||||
#include <span>
|
||||
|
||||
namespace dusk::mods {
|
||||
|
||||
ModBundleZip::ModBundleZip(std::vector<u8>&& 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<u8> 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<u8*>(ptr), size);
|
||||
std::vector vec(data.begin(), data.end());
|
||||
|
||||
mz_free(ptr);
|
||||
|
||||
return vec;
|
||||
}
|
||||
|
||||
std::vector<std::string> ModBundleZip::getFileNames() {
|
||||
std::lock_guard lock{m_mutex};
|
||||
std::vector<std::string> 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
|
||||
@@ -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<int>(code));
|
||||
}
|
||||
|
||||
} // namespace dusk::mods
|
||||
@@ -0,0 +1,204 @@
|
||||
#include "depgraph.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
#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<Edge> collect_edges(const std::vector<std::unique_ptr<LoadedMod>>& 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<Edge> 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<Edge>& edges, const std::vector<bool>& 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<std::unique_ptr<LoadedMod>>& mods) {
|
||||
const size_t count = mods.size();
|
||||
auto edges = collect_edges(mods);
|
||||
if (edges.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<size_t> indegree(count, 0);
|
||||
for (const auto& edge : edges) {
|
||||
++indegree[edge.importer];
|
||||
}
|
||||
|
||||
std::vector<size_t> 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<size_t> 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<std::unique_ptr<LoadedMod>> 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
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#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<std::unique_ptr<LoadedMod>>& mods);
|
||||
|
||||
} // namespace dusk::mods::loader
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string_view>
|
||||
#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<u8> readFile(const std::string& fileName) = 0;
|
||||
virtual std::vector<std::string> getFileNames() = 0;
|
||||
virtual size_t getFileSize(const std::string& fileName) = 0;
|
||||
};
|
||||
|
||||
class ModBundleZip final : public ModBundle {
|
||||
public:
|
||||
explicit ModBundleZip(std::vector<u8>&& data);
|
||||
~ModBundleZip() override;
|
||||
std::vector<u8> readFile(const std::string& fileName) override;
|
||||
std::vector<std::string> getFileNames() override;
|
||||
size_t getFileSize(const std::string& fileName) override;
|
||||
|
||||
private:
|
||||
std::vector<uint8_t> 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<u8> readFile(const std::string& fileName) override;
|
||||
std::vector<std::string> 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
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "native_module.hpp"
|
||||
|
||||
#if defined(_WIN32)
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#define NOMINMAX
|
||||
#include <Windows.h>
|
||||
#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<void*>(GetProcAddress(static_cast<HMODULE>(h), name));
|
||||
}
|
||||
void pl_dlclose(void* h) {
|
||||
FreeLibrary(static_cast<HMODULE>(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 <dlfcn.h>
|
||||
void* pl_dlopen(const std::filesystem::path& p) {
|
||||
int flags = RTLD_LAZY | RTLD_LOCAL;
|
||||
#if defined(RTLD_DEEPBIND)
|
||||
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
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
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<typename T>
|
||||
T LookupSymbol(const char* name) const {
|
||||
return reinterpret_cast<T>(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;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
#include "log_buffer.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
|
||||
#include "dusk/logging.h"
|
||||
|
||||
namespace dusk::mods::log {
|
||||
namespace {
|
||||
|
||||
struct BufferState {
|
||||
std::mutex mutex;
|
||||
std::vector<Line> ring;
|
||||
size_t head = 0;
|
||||
size_t count = 0;
|
||||
uint64_t nextSeq = 0;
|
||||
std::vector<std::string> modIds;
|
||||
|
||||
uint16_t intern(std::string_view modId) {
|
||||
for (size_t i = 0; i < modIds.size(); ++i) {
|
||||
if (modIds[i] == modId) {
|
||||
return static_cast<uint16_t>(i);
|
||||
}
|
||||
}
|
||||
modIds.emplace_back(modId);
|
||||
return static_cast<uint16_t>(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::milliseconds>(
|
||||
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<unsigned int>(message.length()));
|
||||
}
|
||||
}
|
||||
|
||||
Range copy_since(uint64_t sinceSeq, std::vector<Line>& 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<std::string> 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
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include <fmt/format.h>
|
||||
|
||||
#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<Line>& out);
|
||||
|
||||
// Append-only intern table of mod ids; indices are stable for the session.
|
||||
std::vector<std::string> ids();
|
||||
|
||||
void clear();
|
||||
|
||||
// Formatting helper for loader messages.
|
||||
template <typename... T>
|
||||
void write(const std::string& modId, LogLevel level, fmt::format_string<T...> format, T&&... args) {
|
||||
emit(Source::Loader, modId, level, fmt::format(format, std::forward<T>(args)...));
|
||||
}
|
||||
|
||||
} // namespace dusk::mods::log
|
||||
@@ -0,0 +1,386 @@
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
|
||||
#include "manifest.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <SDL3/SDL_filesystem.h>
|
||||
#include <zstd.h>
|
||||
|
||||
#include "aurora/lib/logging.hpp"
|
||||
|
||||
#include "dusk/io.hpp"
|
||||
|
||||
#if defined(_WIN32)
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#elif defined(__APPLE__)
|
||||
#include <mach-o/dyld.h>
|
||||
#include <mach-o/loader.h>
|
||||
#elif defined(__linux__)
|
||||
#include <elf.h>
|
||||
#include <link.h>
|
||||
#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<uint8_t> 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<std::pair<uint64_t, uint32_t> > 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<uint8_t>(*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<uint8_t>& outId, uintptr_t& outBase) {
|
||||
#if defined(_WIN32)
|
||||
auto* base = reinterpret_cast<uint8_t*>(GetModuleHandleW(nullptr));
|
||||
outBase = reinterpret_cast<uintptr_t>(base);
|
||||
const auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(base);
|
||||
const auto* nt = reinterpret_cast<const IMAGE_NT_HEADERS*>(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<const IMAGE_DEBUG_DIRECTORY*>(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<const CvInfo*>(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<uint8_t>(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<uintptr_t>(_dyld_get_image_vmaddr_slide(0));
|
||||
const auto* header64 = reinterpret_cast<const mach_header_64*>(header);
|
||||
const auto* cmd = reinterpret_cast<const load_command*>(header64 + 1);
|
||||
for (uint32_t i = 0; i < header64->ncmds; ++i) {
|
||||
if (cmd->cmd == LC_UUID) {
|
||||
const auto* uuidCmd = reinterpret_cast<const uuid_command*>(cmd);
|
||||
outId.assign(uuidCmd->uuid, uuidCmd->uuid + 16);
|
||||
return true;
|
||||
}
|
||||
cmd = reinterpret_cast<const load_command*>(
|
||||
reinterpret_cast<const uint8_t*>(cmd) + cmd->cmdsize);
|
||||
}
|
||||
return false;
|
||||
#elif defined(__linux__)
|
||||
struct Ctx {
|
||||
std::vector<uint8_t>* 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<Ctx*>(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<const uint8_t*>(info->dlpi_addr + phdr.p_vaddr);
|
||||
const auto* end = p + phdr.p_memsz;
|
||||
while (p + sizeof(ElfW(Nhdr)) <= end) {
|
||||
const auto* note = reinterpret_cast<const ElfW(Nhdr)*>(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<uint8_t> 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<Compression>(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<size_t>::max() ||
|
||||
(compression == Compression::None && header.compressedLen != header.uncompressedLen))
|
||||
{
|
||||
Log.error("symbol manifest {} is malformed", io::fs_path_to_string(path));
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> 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<size_t>(header.compressedLen);
|
||||
const auto uncompressedLen = static_cast<size_t>(header.uncompressedLen);
|
||||
std::vector<uint8_t> 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<const Entry*>(s_state.data.data());
|
||||
s_state.entryCount = header.entryCount;
|
||||
s_state.strings = reinterpret_cast<const char*>(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<uint8_t>& image_build_id() {
|
||||
static const std::vector<uint8_t> s_id = [] {
|
||||
std::vector<uint8_t> 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<void*>(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<uint64_t>(reinterpret_cast<uintptr_t>(addr) - s_state.imageBase);
|
||||
const auto it = std::lower_bound(s_state.inlineSites.begin(), s_state.inlineSites.end(),
|
||||
std::pair<uint64_t, uint32_t>{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
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#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<uint8_t>& 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
|
||||
@@ -0,0 +1,451 @@
|
||||
#include "config.hpp"
|
||||
|
||||
#include "registry.hpp"
|
||||
|
||||
#include "aurora/lib/logging.hpp"
|
||||
#include "dusk/config.hpp"
|
||||
#include "dusk/mods/loader/loader.hpp"
|
||||
#include "mods/svc/config.h"
|
||||
|
||||
#include <fmt/format.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace dusk::mods::svc {
|
||||
namespace {
|
||||
|
||||
aurora::Module Log("dusk::mods::config");
|
||||
|
||||
struct ModConfigVarEntry {
|
||||
uint64_t handle = 0;
|
||||
ConfigVarType type = CONFIG_VAR_BOOL;
|
||||
std::unique_ptr<config::ConfigVarBase> var;
|
||||
};
|
||||
|
||||
struct ModConfigSubscription {
|
||||
uint64_t handle = 0;
|
||||
uint64_t varHandle = 0;
|
||||
config::Subscription coreSubscription = 0;
|
||||
};
|
||||
|
||||
struct ModConfigRecord {
|
||||
std::vector<ModConfigVarEntry> vars;
|
||||
std::vector<ModConfigSubscription> subscriptions;
|
||||
};
|
||||
|
||||
std::unordered_map<const LoadedMod*, ModConfigRecord> s_modConfig;
|
||||
uint64_t s_nextHandle = 1;
|
||||
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<ConfigVarType>(type);
|
||||
switch (type) {
|
||||
case CONFIG_VAR_BOOL:
|
||||
value.bool_value = *static_cast<const bool*>(previous);
|
||||
break;
|
||||
case CONFIG_VAR_INT:
|
||||
value.int_value = *static_cast<const s64*>(previous);
|
||||
break;
|
||||
case CONFIG_VAR_FLOAT:
|
||||
value.float_value = *static_cast<const f64*>(previous);
|
||||
break;
|
||||
case CONFIG_VAR_STRING: {
|
||||
const auto* str = static_cast<const std::string*>(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<ConfigVarType>(type);
|
||||
switch (type) {
|
||||
case CONFIG_VAR_BOOL:
|
||||
value.bool_value = static_cast<ConfigVar<bool>&>(varBase).getValue();
|
||||
break;
|
||||
case CONFIG_VAR_INT:
|
||||
value.int_value = static_cast<ConfigVar<s64>&>(varBase).getValue();
|
||||
break;
|
||||
case CONFIG_VAR_FLOAT:
|
||||
value.float_value = static_cast<ConfigVar<f64>&>(varBase).getValue();
|
||||
break;
|
||||
case CONFIG_VAR_STRING:
|
||||
stringStorage = static_cast<ConfigVar<std::string>&>(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 recordIt = s_modConfig.find(&mod);
|
||||
if (recordIt == s_modConfig.end()) {
|
||||
return nullptr;
|
||||
}
|
||||
const auto& vars = recordIt->second.vars;
|
||||
const auto entry =
|
||||
std::ranges::find_if(vars, [&](const auto& e) { return e.handle == handle; });
|
||||
if (entry == vars.end() || entry->type != expectedType) {
|
||||
return nullptr;
|
||||
}
|
||||
return entry->var.get();
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
ConfigVar<T>* 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<ConfigVar<T>*>(find_var(*mod, handle, type));
|
||||
}
|
||||
|
||||
void config_remove_mod(LoadedMod& mod) {
|
||||
const auto it = s_modConfig.find(&mod);
|
||||
if (it == s_modConfig.end()) {
|
||||
return;
|
||||
}
|
||||
for (const auto& sub : it->second.subscriptions) {
|
||||
config::unsubscribe(sub.coreSubscription);
|
||||
}
|
||||
for (const auto& entry : it->second.vars) {
|
||||
config::unregister(*entry.var);
|
||||
}
|
||||
s_modConfig.erase(it);
|
||||
}
|
||||
|
||||
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<config::ConfigVarBase> var;
|
||||
switch (desc->type) {
|
||||
case CONFIG_VAR_BOOL:
|
||||
var = std::make_unique<ConfigVar<bool> >(fullName, desc->default_bool);
|
||||
break;
|
||||
case CONFIG_VAR_INT:
|
||||
var = std::make_unique<ConfigVar<s64> >(fullName, desc->default_int);
|
||||
break;
|
||||
case CONFIG_VAR_FLOAT:
|
||||
var = std::make_unique<ConfigVar<f64> >(fullName, desc->default_float);
|
||||
break;
|
||||
case CONFIG_VAR_STRING:
|
||||
var = std::make_unique<ConfigVar<std::string> >(
|
||||
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);
|
||||
|
||||
auto& record = s_modConfig[mod];
|
||||
auto& entry = record.vars.emplace_back();
|
||||
entry.handle = s_nextHandle++;
|
||||
entry.type = desc->type;
|
||||
entry.var = std::move(var);
|
||||
if (outHandle != nullptr) {
|
||||
*outHandle = entry.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 recordIt = s_modConfig.find(mod);
|
||||
if (recordIt != s_modConfig.end()) {
|
||||
auto& record = recordIt->second;
|
||||
const auto entry =
|
||||
std::ranges::find_if(record.vars, [&](const auto& e) { return e.handle == var; });
|
||||
if (entry != record.vars.end()) {
|
||||
std::erase_if(record.subscriptions, [&](const ModConfigSubscription& sub) {
|
||||
if (sub.varHandle != var) {
|
||||
return false;
|
||||
}
|
||||
config::unsubscribe(sub.coreSubscription);
|
||||
return true;
|
||||
});
|
||||
|
||||
// The persisted value is stashed and restored by a future registration of the
|
||||
// same name.
|
||||
config::unregister(*entry->var);
|
||||
record.vars.erase(entry);
|
||||
return MOD_OK;
|
||||
}
|
||||
}
|
||||
Log.error("[{}] config unregister failed: unknown handle {}", mod->metadata.id, var);
|
||||
return MOD_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
ModResult config_get_bool(ModContext* context, ConfigVarHandle var, bool* outValue) {
|
||||
if (outValue != nullptr) {
|
||||
*outValue = false;
|
||||
}
|
||||
auto* cvar = find_typed_var<bool>(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<bool>(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<s64>(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<s64>(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<f64>(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<f64>(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<std::string>(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<std::string>(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 recordIt = s_modConfig.find(mod);
|
||||
if (recordIt == s_modConfig.end()) {
|
||||
return MOD_INVALID_ARGUMENT;
|
||||
}
|
||||
auto& record = recordIt->second;
|
||||
const auto entry =
|
||||
std::ranges::find_if(record.vars, [&](const auto& e) { return e.handle == var; });
|
||||
if (entry == record.vars.end()) {
|
||||
return MOD_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
auto& sub = record.subscriptions.emplace_back();
|
||||
sub.handle = s_nextHandle++;
|
||||
sub.varHandle = var;
|
||||
sub.coreSubscription = config::subscribe(entry->var->getName(),
|
||||
[modPtr = mod, callback, userData, varHandle = var, type = entry->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");
|
||||
}
|
||||
});
|
||||
if (outHandle != nullptr) {
|
||||
*outHandle = sub.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 recordIt = s_modConfig.find(mod);
|
||||
if (recordIt != s_modConfig.end()) {
|
||||
auto& subscriptions = recordIt->second.subscriptions;
|
||||
const auto sub =
|
||||
std::ranges::find_if(subscriptions, [&](const auto& s) { return s.handle == handle; });
|
||||
if (sub != subscriptions.end()) {
|
||||
config::unsubscribe(sub->coreSubscription);
|
||||
subscriptions.erase(sub);
|
||||
return MOD_OK;
|
||||
}
|
||||
}
|
||||
Log.error("[{}] config unsubscribe failed: unknown handle {}", mod->metadata.id, handle);
|
||||
return MOD_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
namespace dusk::mods::svc {
|
||||
|
||||
// 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
|
||||
@@ -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
|
||||
@@ -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 <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <exception>
|
||||
#include <fmt/format.h>
|
||||
#include <funchook.h>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
#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<PreHookFn> pre;
|
||||
VoidHookFn replace{};
|
||||
std::vector<VoidHookFn> 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<HookCandidate> candidates;
|
||||
};
|
||||
|
||||
std::unordered_map<uintptr_t, HookSlot> s_registry;
|
||||
std::unordered_map<uintptr_t, InstalledHook> 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 <class T>
|
||||
void sort_hooks(std::vector<T>& 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<const uint8_t*>(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<void*>(*reinterpret_cast<const void* const*>(p + 6 + offset));
|
||||
break;
|
||||
}
|
||||
if (p[0] == 0xE9) {
|
||||
int32_t offset;
|
||||
std::memcpy(&offset, p + 1, 4);
|
||||
addr = const_cast<uint8_t*>(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<const uint8_t*>(addr);
|
||||
uint32_t insn0, insn1, insn2;
|
||||
std::memcpy(&insn0, p, 4);
|
||||
if ((insn0 & 0xFC000000u) == 0x14000000u) { // b imm26
|
||||
auto imm26 = static_cast<int32_t>(insn0 << 6) >> 6;
|
||||
addr = const_cast<uint8_t*>(p) + static_cast<intptr_t>(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<int64_t>(static_cast<int32_t>(insn0 << 8) >> 13); // bits 23:5
|
||||
auto immlo = static_cast<int64_t>((insn0 >> 29) & 3);
|
||||
auto page = (reinterpret_cast<uintptr_t>(p) & ~uintptr_t{0xFFF}) +
|
||||
(static_cast<intptr_t>((immhi << 2) | immlo) << 12);
|
||||
if ((insn1 & 0xFFC003FFu) == 0xF9400210u) { // ldr x16, [x16, #imm12*8]
|
||||
auto slot = page + ((insn1 >> 10) & 0xFFF) * 8;
|
||||
addr = *reinterpret_cast<void**>(slot);
|
||||
break;
|
||||
}
|
||||
if ((insn1 & 0xFF8003FFu) == 0x91000210u) { // add x16, x16, #imm12{, lsl #12}
|
||||
auto imm = static_cast<uintptr_t>((insn1 >> 10) & 0xFFF);
|
||||
addr = reinterpret_cast<void*>(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<uintptr_t>(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<uintptr_t>(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<uintptr_t>(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<uintptr_t>(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<uintptr_t>(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<uintptr_t>(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<uintptr_t>(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<void*>(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
|
||||
@@ -0,0 +1,154 @@
|
||||
#include "registry.hpp"
|
||||
|
||||
#include "dusk/mods/loader/loader.hpp"
|
||||
#include "dusk/mods/manifest.hpp"
|
||||
#include "fmt/format.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
#include <version.h>
|
||||
|
||||
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 {
|
||||
uint64_t handle = 0;
|
||||
LoadedMod* owner = nullptr;
|
||||
ModLifecycleFn fn = nullptr;
|
||||
void* userData = nullptr;
|
||||
};
|
||||
|
||||
std::vector<LifecycleWatcher> s_watchers;
|
||||
uint64_t s_nextWatchHandle = 1;
|
||||
|
||||
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_nextWatchHandle++;
|
||||
s_watchers.push_back({handle, mod, fn, userData});
|
||||
*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;
|
||||
}
|
||||
const auto erased = std::erase_if(s_watchers,
|
||||
[&](const LifecycleWatcher& w) { return w.handle == handle && w.owner == mod; });
|
||||
return erased != 0 ? 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.
|
||||
std::erase_if(s_watchers, [&](const LifecycleWatcher& w) { return w.owner == &mod; });
|
||||
|
||||
// Iterate a snapshot: callbacks may watch/unwatch, and a failing callback erases the
|
||||
// failing mod's services.
|
||||
const auto snapshot = s_watchers;
|
||||
for (const auto& watcher : snapshot) {
|
||||
const bool alive = std::ranges::any_of(
|
||||
s_watchers, [&](const LifecycleWatcher& w) { return w.handle == watcher.handle; });
|
||||
if (!alive) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
watcher.fn(watcher.owner->context.get(), mod.context.get(), mod.metadata.id.c_str(),
|
||||
MOD_LIFECYCLE_DETACHED, watcher.userData);
|
||||
} catch (const std::exception& e) {
|
||||
fail_mod(*watcher.owner, MOD_ERROR,
|
||||
fmt::format("Exception in mod lifecycle callback: {}", e.what()));
|
||||
} catch (...) {
|
||||
fail_mod(*watcher.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<uint32_t>(buildId.size());
|
||||
},
|
||||
.modDetached = host_mod_detached,
|
||||
};
|
||||
|
||||
} // namespace dusk::mods::svc
|
||||
@@ -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
|
||||
@@ -0,0 +1,351 @@
|
||||
#include "registry.hpp"
|
||||
|
||||
#include "aurora/dvd.h"
|
||||
#include "aurora/lib/logging.hpp"
|
||||
#include "dusk/mods/loader/loader.hpp"
|
||||
#include "mods/svc/overlay.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
using namespace std::string_literals;
|
||||
|
||||
namespace dusk::mods::svc {
|
||||
namespace {
|
||||
|
||||
aurora::Module Log("dusk::mods::overlay");
|
||||
|
||||
struct OverlayFileData {
|
||||
std::string bundlePath;
|
||||
std::shared_ptr<ModBundle> bundle;
|
||||
std::shared_ptr<const std::vector<u8> > 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<uintptr_t, OverlayFileData> s_overlayFiles;
|
||||
uintptr_t s_nextOverlayId = 1;
|
||||
std::mutex s_overlayMutex;
|
||||
|
||||
struct RuntimeOverlayEntry {
|
||||
uint64_t handle = 0;
|
||||
std::string discPath;
|
||||
std::string bundlePath; // bundle-backed if non-empty
|
||||
std::shared_ptr<const std::vector<u8>> buffer; // buffer-backed otherwise
|
||||
size_t size = 0;
|
||||
};
|
||||
std::unordered_map<const LoadedMod*, std::vector<RuntimeOverlayEntry>> s_runtimeOverlays;
|
||||
uint64_t s_nextRuntimeHandle = 1;
|
||||
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<std::string, const LoadedMod*>& 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<AuroraOverlayFile>& files, LoadedMod& mod,
|
||||
std::unordered_map<std::string, const LoadedMod*>& 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<void*>(id), size);
|
||||
}
|
||||
}
|
||||
|
||||
void append_runtime_overlays(std::vector<AuroraOverlayFile>& files, LoadedMod& mod,
|
||||
std::unordered_map<std::string, const LoadedMod*>& claims) {
|
||||
const auto it = s_runtimeOverlays.find(&mod);
|
||||
if (it == s_runtimeOverlays.end()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const auto& entry : it->second) {
|
||||
const auto id = s_nextOverlayId++;
|
||||
if (entry.buffer != nullptr) {
|
||||
s_overlayFiles.emplace(id, OverlayFileData{{}, nullptr, entry.buffer});
|
||||
} else {
|
||||
s_overlayFiles.emplace(id, OverlayFileData{entry.bundlePath, mod.bundle, nullptr});
|
||||
}
|
||||
claim_overlay_path(claims, entry.discPath, mod);
|
||||
files.emplace_back(strdup(entry.discPath.c_str()), reinterpret_cast<void*>(id), entry.size);
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenOverlayFile {
|
||||
std::vector<u8> ownedData;
|
||||
std::shared_ptr<const std::vector<u8> > shared;
|
||||
size_t pos = 0;
|
||||
|
||||
[[nodiscard]] const std::vector<u8>& data() const {
|
||||
return shared != nullptr ? *shared : ownedData;
|
||||
}
|
||||
};
|
||||
|
||||
void* cbOpen(void* userdata) {
|
||||
const auto id = reinterpret_cast<uintptr_t>(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<OpenOverlayFile*>(handle);
|
||||
delete openFile;
|
||||
}
|
||||
|
||||
int64_t cbRead(void* handle, uint8_t* buf, const size_t len) {
|
||||
auto& openFile = *static_cast<OpenOverlayFile*>(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<int64_t>(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<OpenOverlayFile*>(handle);
|
||||
const auto posSigned =
|
||||
std::clamp(offset, static_cast<int64_t>(0), static_cast<int64_t>(openFile.data().size()));
|
||||
openFile.pos = static_cast<size_t>(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<AuroraOverlayFile> files;
|
||||
std::unordered_map<std::string, const LoadedMod*> 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<char*>(file.fileName));
|
||||
}
|
||||
}
|
||||
|
||||
uint64_t overlay_add_file(
|
||||
LoadedMod& mod, std::string discPath, std::string bundlePath, size_t size) {
|
||||
const auto handle = s_nextRuntimeHandle++;
|
||||
s_runtimeOverlays[&mod].push_back({
|
||||
.handle = handle,
|
||||
.discPath = std::move(discPath),
|
||||
.bundlePath = std::move(bundlePath),
|
||||
.size = size,
|
||||
});
|
||||
s_overlaysDirty = true;
|
||||
return handle;
|
||||
}
|
||||
|
||||
uint64_t overlay_add_buffer(LoadedMod& mod, std::string discPath, std::vector<u8> data) {
|
||||
const auto handle = s_nextRuntimeHandle++;
|
||||
const auto size = data.size();
|
||||
s_runtimeOverlays[&mod].push_back({
|
||||
.handle = handle,
|
||||
.discPath = std::move(discPath),
|
||||
.buffer = std::make_shared<const std::vector<u8>>(std::move(data)),
|
||||
.size = size,
|
||||
});
|
||||
s_overlaysDirty = true;
|
||||
return handle;
|
||||
}
|
||||
|
||||
bool overlay_remove(LoadedMod& mod, uint64_t handle) {
|
||||
const auto it = s_runtimeOverlays.find(&mod);
|
||||
if (it == s_runtimeOverlays.end()) {
|
||||
return false;
|
||||
}
|
||||
if (std::erase_if(it->second, [&](const auto& entry) { return entry.handle == handle; }) == 0) {
|
||||
return false;
|
||||
}
|
||||
if (it->second.empty()) {
|
||||
s_runtimeOverlays.erase(it);
|
||||
}
|
||||
s_overlaysDirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void overlay_remove_mod(LoadedMod& mod) {
|
||||
if (s_runtimeOverlays.erase(&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<const u8*>(data);
|
||||
const auto handle = overlay_add_buffer(*mod, discPath, std::vector<u8>{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
|
||||
@@ -0,0 +1,320 @@
|
||||
#include "registry.hpp"
|
||||
|
||||
#include "dusk/app_info.hpp"
|
||||
#include "dusk/logging.h"
|
||||
#include "dusk/mods/loader/loader.hpp"
|
||||
|
||||
#include <ranges>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace dusk::mods::svc {
|
||||
namespace {
|
||||
|
||||
std::unordered_map<std::string, ServiceRecord> s_services;
|
||||
std::vector<const ServiceModule*> 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<const ServiceHeader*>(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<const ServiceHeader*>(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_gameModule,
|
||||
})
|
||||
{
|
||||
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<const void**>(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<const void**>(serviceImport.slot) = service->service;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace dusk::mods
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include "dusk/mod_loader.hpp"
|
||||
#include "mods/svc/host.h"
|
||||
#include "mods/svc/log.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
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_gameModule;
|
||||
|
||||
} // namespace dusk::mods::svc
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "registry.hpp"
|
||||
|
||||
#include "aurora/lib/logging.hpp"
|
||||
#include "dusk/mods/loader/loader.hpp"
|
||||
#include "mods/svc/resource.h"
|
||||
|
||||
#include <fmt/format.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
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<void*, const LoadedMod*> 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<u8> 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
|
||||
@@ -0,0 +1,463 @@
|
||||
#include "registry.hpp"
|
||||
|
||||
#include "aurora/lib/logging.hpp"
|
||||
#include "dusk/mods/loader/loader.hpp"
|
||||
#include "mods/svc/texture.h"
|
||||
|
||||
#include <aurora/texture.hpp>
|
||||
#include <fmt/format.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
|
||||
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<u8> 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<ModBundle> bundle;
|
||||
std::string bundlePath;
|
||||
std::vector<u8> 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<uint8_t>& outBytes) {
|
||||
auto* keepalive = static_cast<TextureKeepalive*>(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<TextureKeepalive> 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<std::shared_ptr<TextureKeepalive>> staticKeepalives;
|
||||
std::vector<RuntimeTextureEntry> 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<const LoadedMod*, ModTextureRecord> 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<char>(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<std::string> 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<aurora::texture::ReplacementKey> 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<TextureKeepalive>(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<TextureKeepalive>();
|
||||
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<TextureKeepalive>(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<aurora::texture::ReplacementKey> 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<const u8*>(data->data);
|
||||
const auto handle = texture_register_raw(*mod, *translatedKey,
|
||||
{
|
||||
.data = std::vector<u8>{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
|
||||
@@ -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
|
||||
// ==========================================================================
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
#include "logs_window.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <ctime>
|
||||
|
||||
#include <SDL3/SDL_timer.h>
|
||||
#include <fmt/format.h>
|
||||
|
||||
#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<std::time_t>(timeMs / 1000);
|
||||
std::tm localTime{};
|
||||
#if _WIN32
|
||||
localtime_s(&localTime, &seconds);
|
||||
#else
|
||||
localtime_r(&seconds, &localTime);
|
||||
#endif
|
||||
std::array<char, 16> buffer{};
|
||||
std::strftime(buffer.data(), buffer.size(), "%H:%M:%S", &localTime);
|
||||
return fmt::format("{}.{:03}", buffer.data(), timeMs % 1000);
|
||||
}
|
||||
|
||||
void append_text(Rml::ElementDocument* doc, Rml::Element* parent, const Rml::String& text) {
|
||||
parent->AppendChild(doc->CreateTextNode(text));
|
||||
}
|
||||
|
||||
Rml::Element* append_span(Rml::ElementDocument* doc, Rml::Element* parent, const char* className,
|
||||
const Rml::String& text) {
|
||||
auto span = doc->CreateElement("span");
|
||||
span->SetClass(className, true);
|
||||
append_text(doc, span.get(), text);
|
||||
return parent->AppendChild(std::move(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<ControlledButton>(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<Button>(toolbar, "Copy").on_pressed([this] { copy_to_clipboard(); });
|
||||
add_child<Button>(toolbar, "Clear").on_pressed([this] {
|
||||
mods::log::clear();
|
||||
rebuild_lines();
|
||||
});
|
||||
|
||||
auto& pane = add_child<Pane>(content, Pane::Type::Uncontrolled);
|
||||
pane.root()->SetClass("log-view", true);
|
||||
mScrollElem = pane.root();
|
||||
mLinesElem = append(pane.root(), "div");
|
||||
mLinesElem->SetClass("log-lines", true);
|
||||
pane.finalize();
|
||||
|
||||
listen(mScrollElem, Rml::EventId::Scroll, [this](Rml::Event&) {
|
||||
const float bottom = mScrollElem->GetScrollHeight() - mScrollElem->GetClientHeight();
|
||||
mStickToBottom = mScrollElem->GetScrollTop() >= bottom - 4.0f;
|
||||
});
|
||||
|
||||
rebuild_lines();
|
||||
}
|
||||
|
||||
void LogsWindow::update() {
|
||||
Window::update();
|
||||
if (mLinesElem == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const Uint64 perfFreq = SDL_GetPerformanceFrequency();
|
||||
const Uint64 now = SDL_GetPerformanceCounter();
|
||||
// Limit refreshes to ~8 per second
|
||||
const bool refresh =
|
||||
perfFreq == 0 || mLastRefresh == 0 ||
|
||||
static_cast<double>(now - mLastRefresh) >= 0.125 * static_cast<double>(perfFreq);
|
||||
if (refresh) {
|
||||
mLastRefresh = now;
|
||||
refresh_lines();
|
||||
}
|
||||
|
||||
// Applied every frame: layout of freshly appended lines is deferred, so a
|
||||
// single post-append scroll would land short of the real bottom.
|
||||
if (mStickToBottom && mScrollElem != nullptr) {
|
||||
mScrollElem->SetScrollTop(mScrollElem->GetScrollHeight() - mScrollElem->GetClientHeight());
|
||||
}
|
||||
|
||||
update_visible_window();
|
||||
}
|
||||
|
||||
// Mark items fully outside the scroll view as `visibility: hidden;`.
|
||||
// They retain their layout, but stops RmlUi from trying to render them.
|
||||
void LogsWindow::update_visible_window() {
|
||||
const float viewTop = mScrollElem->GetAbsoluteOffset(Rml::BoxArea::Border).y;
|
||||
const float viewHeight = mScrollElem->GetClientHeight();
|
||||
const int count = mLinesElem->GetNumChildren();
|
||||
for (int i = 0; i < count && i < static_cast<int>(mLines.size()); ++i) {
|
||||
auto* elem = mLinesElem->GetChild(i);
|
||||
const float top = elem->GetAbsoluteOffset(Rml::BoxArea::Border).y - viewTop;
|
||||
const bool shown = top + elem->GetOffsetHeight() >= -viewHeight && top <= viewHeight * 2.0f;
|
||||
if (shown != mLines[i].shown) {
|
||||
mLines[i].shown = shown;
|
||||
if (shown) {
|
||||
elem->RemoveProperty("visibility");
|
||||
} else {
|
||||
elem->SetProperty("visibility", "hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void LogsWindow::refresh_lines() {
|
||||
mScratch.clear();
|
||||
const auto [firstSeq, nextSeq] = mods::log::copy_since(mNextSeq, mScratch);
|
||||
mNextSeq = nextSeq;
|
||||
|
||||
// Drop displayed lines that fell out of the buffer (ring wrap or clear)
|
||||
while (!mLines.empty() && mLines.front().seq < firstSeq) {
|
||||
if (auto* first = mLinesElem->GetFirstChild()) {
|
||||
mLinesElem->RemoveChild(first);
|
||||
}
|
||||
mLines.pop_front();
|
||||
}
|
||||
|
||||
if (mScratch.empty()) {
|
||||
return;
|
||||
}
|
||||
for (const auto& line : mScratch) {
|
||||
if (line.modIndex >= mModIds.size()) {
|
||||
mModIds = mods::log::ids();
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const auto& line : mScratch) {
|
||||
if (!line_visible(line)) {
|
||||
continue;
|
||||
}
|
||||
append_log_line(line);
|
||||
mLines.push_back({.seq = line.seq});
|
||||
}
|
||||
}
|
||||
|
||||
void LogsWindow::rebuild_lines() {
|
||||
if (mLinesElem == nullptr) {
|
||||
return;
|
||||
}
|
||||
mModIds = mods::log::ids();
|
||||
mScratch.clear();
|
||||
const auto [_, nextSeq] = mods::log::copy_since(0, mScratch);
|
||||
mNextSeq = nextSeq;
|
||||
mLines.clear();
|
||||
while (auto* child = mLinesElem->GetFirstChild()) {
|
||||
mLinesElem->RemoveChild(child);
|
||||
}
|
||||
|
||||
for (const auto& line : mScratch) {
|
||||
if (!line_visible(line)) {
|
||||
continue;
|
||||
}
|
||||
append_log_line(line);
|
||||
mLines.push_back({.seq = line.seq});
|
||||
}
|
||||
mStickToBottom = true;
|
||||
}
|
||||
|
||||
bool LogsWindow::line_visible(const mods::log::Line& line) const {
|
||||
if (line.level < mMinLevel) {
|
||||
return false;
|
||||
}
|
||||
if (mModFilter.empty()) {
|
||||
return true;
|
||||
}
|
||||
return line.modIndex < mModIds.size() && mModIds[line.modIndex] == mModFilter;
|
||||
}
|
||||
|
||||
Rml::Element* LogsWindow::append_log_line(const mods::log::Line& line) {
|
||||
std::string_view modId;
|
||||
if (line.source == mods::log::Source::Loader) {
|
||||
modId = "loader";
|
||||
} else if (line.modIndex < mModIds.size()) {
|
||||
modId = std::string_view{mModIds[line.modIndex]};
|
||||
} else {
|
||||
modId = "?";
|
||||
}
|
||||
|
||||
auto elem = mDocument->CreateElement("div");
|
||||
elem->SetClass("log-line", true);
|
||||
elem->SetClass(level_class(line.level), true);
|
||||
|
||||
constexpr const char* kNbsp = "\xc2\xa0";
|
||||
append_span(mDocument, elem.get(), "log-time", format_time(line.timeMs));
|
||||
append_text(mDocument, elem.get(), kNbsp);
|
||||
append_span(mDocument, elem.get(), "log-mod", fmt::format("[{}]", modId));
|
||||
append_text(mDocument, elem.get(), kNbsp);
|
||||
append_span(mDocument, elem.get(), "log-msg", line.message);
|
||||
|
||||
return mLinesElem->AppendChild(std::move(elem));
|
||||
}
|
||||
|
||||
void LogsWindow::copy_to_clipboard() {
|
||||
mModIds = mods::log::ids();
|
||||
std::vector<mods::log::Line> lines;
|
||||
mods::log::copy_since(0, lines);
|
||||
|
||||
std::string text;
|
||||
for (const auto& line : lines) {
|
||||
if (!line_visible(line)) {
|
||||
continue;
|
||||
}
|
||||
const std::string_view modId =
|
||||
line.modIndex < mModIds.size() ? std::string_view{mModIds[line.modIndex]} : "?";
|
||||
text += fmt::format("{} [{}] [{}] {}\n", format_time(line.timeMs),
|
||||
level_logger_name(line.level), modId, line.message);
|
||||
}
|
||||
Rml::GetSystemInterface()->SetClipboardText(text);
|
||||
push_toast({.content = "Copied to clipboard", .duration = std::chrono::seconds(2)});
|
||||
}
|
||||
|
||||
} // namespace dusk::ui
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include "window.hpp"
|
||||
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "dusk/mods/log_buffer.hpp"
|
||||
|
||||
namespace dusk::ui {
|
||||
|
||||
class LogsWindow : public Window {
|
||||
public:
|
||||
explicit LogsWindow(std::string modFilter = {});
|
||||
void update() override;
|
||||
|
||||
private:
|
||||
struct DisplayLine {
|
||||
uint64_t seq = 0;
|
||||
bool shown = true;
|
||||
};
|
||||
|
||||
void build_content(Rml::Element* content);
|
||||
void rebuild_lines();
|
||||
void refresh_lines();
|
||||
void update_visible_window();
|
||||
bool line_visible(const mods::log::Line& line) const;
|
||||
Rml::Element* append_log_line(const mods::log::Line& line);
|
||||
void copy_to_clipboard();
|
||||
|
||||
std::string mModFilter;
|
||||
LogLevel mMinLevel = LOG_LEVEL_DEBUG;
|
||||
std::vector<std::string> mModIds;
|
||||
uint64_t mNextSeq = 0;
|
||||
std::vector<mods::log::Line> mScratch;
|
||||
std::deque<DisplayLine> mLines;
|
||||
Rml::Element* mLinesElem = nullptr;
|
||||
Rml::Element* mScrollElem = nullptr;
|
||||
bool mStickToBottom = true;
|
||||
Uint64 mLastRefresh = 0;
|
||||
};
|
||||
|
||||
} // namespace dusk::ui
|
||||
@@ -7,15 +7,16 @@
|
||||
|
||||
#include "achievements.hpp"
|
||||
#include "aurora/rmlui.hpp"
|
||||
#include "dusk/speedrun.h"
|
||||
#include "dusk/livesplit.h"
|
||||
#include "dusk/main.h"
|
||||
#include "dusk/settings.h"
|
||||
#include "dusk/speedrun.h"
|
||||
#include "editor.hpp"
|
||||
#include "f_pc/f_pc_manager.h"
|
||||
#include "f_pc/f_pc_name.h"
|
||||
#include "imgui.h"
|
||||
#include "modal.hpp"
|
||||
#include "mods_window.hpp"
|
||||
#include "settings.hpp"
|
||||
#include "ui.hpp"
|
||||
#include "warp.hpp"
|
||||
@@ -58,7 +59,7 @@ MenuBar::MenuBar() : Document(kDocumentSource), mRoot(mDocument->GetElementById(
|
||||
}
|
||||
|
||||
mTabBar->add_tab("Achievements", [this] { push(std::make_unique<AchievementsWindow>()); });
|
||||
|
||||
mTabBar->add_tab("Mods", [this] { push(std::make_unique<ModsWindow>()); });
|
||||
|
||||
mTabBar->add_tab("Reset", [this] {
|
||||
mTabBar->set_active_tab(-1);
|
||||
@@ -229,4 +230,17 @@ bool MenuBar::focus() {
|
||||
return mTabBar->focus();
|
||||
}
|
||||
|
||||
void MenuBar::rebuild() {
|
||||
for (auto& doc : get_document_stack()) {
|
||||
if (auto* menuBar = dynamic_cast<MenuBar*>(doc.get())) {
|
||||
const bool wasVisible = menuBar->visible();
|
||||
doc = std::make_unique<MenuBar>();
|
||||
if (wasVisible) {
|
||||
doc->show();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace dusk::ui
|
||||
|
||||
@@ -21,6 +21,8 @@ public:
|
||||
bool focus() override;
|
||||
bool visible() const override;
|
||||
|
||||
static void rebuild();
|
||||
|
||||
protected:
|
||||
bool handle_nav_command(Rml::Event& event, NavCommand cmd) override;
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
#include "mod_texture_provider.hpp"
|
||||
|
||||
#include "dusk/mod_loader.hpp"
|
||||
|
||||
#include <fmt/format.h>
|
||||
|
||||
namespace dusk::ui {
|
||||
|
||||
std::string mod_image_source(const mods::LoadedMod& mod, std::string_view bundlePath) {
|
||||
return fmt::format("mod://{}/{}?rev={}", mod.metadata.id, bundlePath, mod.cacheGeneration);
|
||||
}
|
||||
|
||||
} // namespace dusk::ui
|
||||
|
||||
#ifdef AURORA_ENABLE_RMLUI
|
||||
|
||||
#include <SDL3/SDL_iostream.h>
|
||||
#include <SDL3/SDL_surface.h>
|
||||
#include <aurora/lib/logging.hpp>
|
||||
#include <aurora/rmlui.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "dusk/mods/loader/loader.hpp"
|
||||
|
||||
namespace dusk::ui {
|
||||
namespace {
|
||||
|
||||
aurora::Module Log{"dusk::ui::modTexture"};
|
||||
|
||||
constexpr std::string_view kScheme = "mod";
|
||||
constexpr std::string_view kSourcePrefix = "mod://";
|
||||
constexpr size_t kMaxCachedImages = 64;
|
||||
constexpr size_t kMaxImageFileSize = 16 * 1024 * 1024;
|
||||
constexpr uint32_t kMaxImageDimension = 4096;
|
||||
|
||||
struct CachedImage {
|
||||
std::vector<uint8_t> pixels;
|
||||
uint32_t width = 0;
|
||||
uint32_t height = 0;
|
||||
};
|
||||
|
||||
std::unordered_map<std::string, CachedImage>& image_cache() {
|
||||
static auto* cache = new std::unordered_map<std::string, CachedImage>();
|
||||
return *cache;
|
||||
}
|
||||
|
||||
std::string_view strip_query(std::string_view path) noexcept {
|
||||
const auto queryPos = path.find_first_of("?#");
|
||||
if (queryPos != std::string_view::npos) {
|
||||
path = path.substr(0, queryPos);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
std::optional<CachedImage> decode_png(std::span<const uint8_t> data, std::string_view source) {
|
||||
SDL_IOStream* stream = SDL_IOFromConstMem(data.data(), data.size());
|
||||
if (stream == nullptr) {
|
||||
Log.warn("Failed to open image stream for '{}': {}", source, SDL_GetError());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
SDL_Surface* loadedSurface = SDL_LoadPNG_IO(stream, true);
|
||||
if (loadedSurface == nullptr) {
|
||||
Log.warn("Failed to decode image '{}': {}", source, SDL_GetError());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
SDL_Surface* rgbaSurface = SDL_ConvertSurface(loadedSurface, SDL_PIXELFORMAT_RGBA32);
|
||||
SDL_DestroySurface(loadedSurface);
|
||||
if (rgbaSurface == nullptr) {
|
||||
Log.warn("Failed to convert image '{}': {}", source, SDL_GetError());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const auto width = static_cast<uint32_t>(rgbaSurface->w);
|
||||
const auto height = static_cast<uint32_t>(rgbaSurface->h);
|
||||
if (width == 0 || height == 0 || width > kMaxImageDimension || height > kMaxImageDimension) {
|
||||
Log.warn("Image '{}' has unsupported dimensions {}x{}", source, width, height);
|
||||
SDL_DestroySurface(rgbaSurface);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const size_t rowSize = static_cast<size_t>(width) * 4;
|
||||
CachedImage image{
|
||||
.pixels = std::vector<uint8_t>(rowSize * height),
|
||||
.width = width,
|
||||
.height = height,
|
||||
};
|
||||
for (uint32_t row = 0; row < height; ++row) {
|
||||
const auto* src = static_cast<const uint8_t*>(rgbaSurface->pixels) +
|
||||
static_cast<size_t>(row) * static_cast<size_t>(rgbaSurface->pitch);
|
||||
auto* dst = image.pixels.data() + static_cast<size_t>(row) * rowSize;
|
||||
std::memcpy(dst, src, rowSize);
|
||||
|
||||
// Convert to premultiplied alpha for correct compositing.
|
||||
for (size_t col = 0; col < rowSize; col += 4) {
|
||||
const uint8_t alpha = dst[col + 3];
|
||||
for (size_t channel = 0; channel < 3; ++channel) {
|
||||
dst[col + channel] = static_cast<uint8_t>(
|
||||
(static_cast<uint32_t>(dst[col + channel]) * static_cast<uint32_t>(alpha)) /
|
||||
255);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SDL_DestroySurface(rgbaSurface);
|
||||
return image;
|
||||
}
|
||||
|
||||
std::optional<CachedImage> load_mod_image(std::string_view idAndPath, std::string_view source) {
|
||||
const auto slash = idAndPath.find('/');
|
||||
if (slash == std::string_view::npos || slash == 0 || slash + 1 >= idAndPath.size()) {
|
||||
Log.warn("Malformed mod image source '{}'", source);
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::string modId{idAndPath.substr(0, slash)};
|
||||
const std::string path{idAndPath.substr(slash + 1)};
|
||||
if (!mods::is_safe_resource_path(path)) {
|
||||
Log.warn("Unsafe path in mod image source '{}'", source);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::shared_ptr<mods::ModBundle> bundle;
|
||||
for (const auto& mod : mods::ModLoader::instance().mods()) {
|
||||
if (mod.metadata.id == modId) {
|
||||
bundle = mod.bundle;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bundle == nullptr) {
|
||||
Log.warn("Unknown mod in image source '{}'", source);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<u8> data;
|
||||
try {
|
||||
if (bundle->getFileSize(path) > kMaxImageFileSize) {
|
||||
Log.warn("Image '{}' exceeds the {} MiB limit", source, kMaxImageFileSize >> 20);
|
||||
return std::nullopt;
|
||||
}
|
||||
data = bundle->readFile(path);
|
||||
} catch (const std::runtime_error& e) {
|
||||
Log.warn("Failed to read image '{}': {}", source, e.what());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return decode_png(std::span{data.data(), data.size()}, source);
|
||||
}
|
||||
|
||||
std::optional<aurora::rmlui::RuntimeTexture> mod_texture_provider(std::string_view source) {
|
||||
if (!source.starts_with(kSourcePrefix)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
auto& cache = image_cache();
|
||||
const std::string key{source};
|
||||
auto it = cache.find(key);
|
||||
if (it == cache.end()) {
|
||||
auto image = load_mod_image(strip_query(source.substr(kSourcePrefix.size())), source);
|
||||
if (!image) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (cache.size() >= kMaxCachedImages) {
|
||||
cache.erase(cache.begin());
|
||||
}
|
||||
it = cache.emplace(key, std::move(*image)).first;
|
||||
}
|
||||
|
||||
const auto& image = it->second;
|
||||
return aurora::rmlui::RuntimeTexture{
|
||||
.width = image.width,
|
||||
.height = image.height,
|
||||
.rgba8 =
|
||||
std::span{reinterpret_cast<const std::byte*>(image.pixels.data()), image.pixels.size()},
|
||||
.premultipliedAlpha = true,
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void register_mod_texture_provider() noexcept {
|
||||
aurora::rmlui::register_texture_provider(std::string{kScheme}, mod_texture_provider);
|
||||
}
|
||||
|
||||
void unregister_mod_texture_provider() noexcept {
|
||||
aurora::rmlui::unregister_texture_provider(kScheme);
|
||||
image_cache().clear();
|
||||
}
|
||||
|
||||
} // namespace dusk::ui
|
||||
|
||||
#else
|
||||
|
||||
namespace dusk::ui {
|
||||
|
||||
void register_mod_texture_provider() noexcept {}
|
||||
void unregister_mod_texture_provider() noexcept {}
|
||||
|
||||
} // namespace dusk::ui
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace dusk::mods {
|
||||
struct LoadedMod;
|
||||
} // namespace dusk::mods
|
||||
|
||||
namespace dusk::ui {
|
||||
|
||||
// Serves PNG images out of mod bundles to RmlUi via the mod:// texture provider scheme.
|
||||
// Sources embed the mod's cacheGeneration so reloads bust RmlUi's texture cache.
|
||||
std::string mod_image_source(const mods::LoadedMod& mod, std::string_view bundlePath);
|
||||
|
||||
void register_mod_texture_provider() noexcept;
|
||||
void unregister_mod_texture_provider() noexcept;
|
||||
|
||||
} // namespace dusk::ui
|
||||
@@ -0,0 +1,325 @@
|
||||
#include "mods_window.hpp"
|
||||
|
||||
#include "dusk/mod_loader.hpp"
|
||||
#include "fmt/format.h"
|
||||
#include "logs_window.hpp"
|
||||
#include "mod_texture_provider.hpp"
|
||||
#include "pane.hpp"
|
||||
|
||||
#include "Z2AudioLib/Z2SeMgr.h"
|
||||
#include "m_Do/m_Do_audio.h"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace dusk::ui {
|
||||
namespace {
|
||||
|
||||
struct ModStatus {
|
||||
const char* badgeClass = "";
|
||||
const char* text = "";
|
||||
};
|
||||
|
||||
bool mod_enabled(const mods::LoadedMod& mod) {
|
||||
return mod.cvarIsEnabled != nullptr && mod.cvarIsEnabled->getValue();
|
||||
}
|
||||
|
||||
ModStatus mod_status(const mods::LoadedMod& mod) {
|
||||
if (mod.loadFailed) {
|
||||
return {"failed", "Failed"};
|
||||
}
|
||||
if (mod.active) {
|
||||
return {"active", "Active"};
|
||||
}
|
||||
if (mod.suspendedByProvider) {
|
||||
return {"suspended", "Suspended"};
|
||||
}
|
||||
return {"", "Disabled"};
|
||||
}
|
||||
|
||||
// Truncates to at most maxBytes without splitting a UTF-8 sequence.
|
||||
std::string snippet(std::string_view text, size_t maxBytes) {
|
||||
if (text.size() <= maxBytes) {
|
||||
return std::string{text};
|
||||
}
|
||||
size_t end = maxBytes;
|
||||
while (end > 0 && (static_cast<unsigned char>(text[end]) & 0xC0) == 0x80) {
|
||||
--end;
|
||||
}
|
||||
return std::string{text.substr(0, end)} + "...";
|
||||
}
|
||||
|
||||
class ModListEntry : public FluentComponent<ModListEntry> {
|
||||
public:
|
||||
ModListEntry(Rml::Element* parent, const mods::LoadedMod& mod)
|
||||
: FluentComponent{append(parent, "mod-entry")} {
|
||||
Rml::String iconRml;
|
||||
if (!mod.metadata.iconPath.empty()) {
|
||||
iconRml = fmt::format(R"(<img class="mod-icon" src="{}"/>)",
|
||||
mod_image_source(mod, mod.metadata.iconPath));
|
||||
} else {
|
||||
iconRml = R"(<icon class="mod-icon placeholder"/>)";
|
||||
}
|
||||
const auto status = mod_status(mod);
|
||||
mRoot->SetInnerRML(fmt::format(
|
||||
R"({})"
|
||||
R"(<div class="mod-entry-info">)"
|
||||
R"(<div class="mod-entry-name"><span class="mod-entry-name-text">{}</span>)"
|
||||
R"(<span class="mod-entry-version">v{}</span></div>)"
|
||||
R"(<div class="mod-entry-sub">{} - <span class="mod-entry-status {}">{}</span></div>)"
|
||||
R"(<div class="mod-entry-desc">{}</div>)"
|
||||
R"(</div>)",
|
||||
iconRml, escape(mod.metadata.name), escape(mod.metadata.version),
|
||||
escape(mod.metadata.author), status.badgeClass, status.text,
|
||||
escape(snippet(mod.metadata.description, 90))));
|
||||
mRoot->SetClass("inactive", !mod.active);
|
||||
mRoot->SetClass("failed", mod.loadFailed);
|
||||
|
||||
on_nav_command([this](Rml::Event&, NavCommand cmd) {
|
||||
if (cmd == NavCommand::Confirm) {
|
||||
mRoot->DispatchEvent(Rml::EventId::Submit, {});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
class ModDetailHeader : public FluentComponent<ModDetailHeader> {
|
||||
public:
|
||||
ModDetailHeader(
|
||||
Rml::Element* parent, const mods::LoadedMod& mod, std::function<void()> onShowLogs)
|
||||
: FluentComponent{append(parent, "mod-header")} {
|
||||
const bool hasBanner = !mod.metadata.bannerPath.empty();
|
||||
mRoot->SetClass(hasBanner ? "has-banner" : "no-banner", true);
|
||||
if (hasBanner) {
|
||||
mRoot->SetProperty("decorator", fmt::format(R"(image("{}" cover center top))",
|
||||
mod_image_source(mod, mod.metadata.bannerPath)));
|
||||
}
|
||||
|
||||
auto* actions = append(mRoot, "div");
|
||||
actions->SetClass("mod-actions", true);
|
||||
const std::string modId = mod.metadata.id;
|
||||
if (mod_enabled(mod)) {
|
||||
if (!mod.inPlace) {
|
||||
make_button(actions, "Reload").on_pressed([modId] {
|
||||
mods::ModLoader::instance().request_reload(modId);
|
||||
});
|
||||
}
|
||||
make_button(actions, "Disable").on_pressed([modId] {
|
||||
mods::ModLoader::instance().request_disable(modId);
|
||||
});
|
||||
} else {
|
||||
make_button(actions, "Enable").on_pressed([modId] {
|
||||
mods::ModLoader::instance().request_enable(modId);
|
||||
});
|
||||
}
|
||||
make_button(actions, "Logs").on_pressed(std::move(onShowLogs));
|
||||
|
||||
listen(Rml::EventId::Keydown, [this](Rml::Event& event) {
|
||||
const auto cmd = map_nav_event(event);
|
||||
if (cmd != NavCommand::Left && cmd != NavCommand::Right) {
|
||||
return;
|
||||
}
|
||||
int index = -1;
|
||||
for (int i = 0; i < static_cast<int>(mButtons.size()); ++i) {
|
||||
if (mButtons[i]->contains(event.GetTargetElement())) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
const int next = index + (cmd == NavCommand::Right ? 1 : -1);
|
||||
if (next >= 0 && next < static_cast<int>(mButtons.size()) && mButtons[next]->focus()) {
|
||||
mDoAud_seStartMenu(kSoundItemFocus);
|
||||
event.StopPropagation();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool focus() override {
|
||||
for (auto* button : mButtons) {
|
||||
if (button->focus()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
Button& make_button(Rml::Element* parent, Rml::String text) {
|
||||
auto button = std::make_unique<Button>(parent, std::move(text));
|
||||
Button& ref = *button;
|
||||
mChildren.emplace_back(std::move(button));
|
||||
mButtons.push_back(&ref);
|
||||
return ref;
|
||||
}
|
||||
|
||||
std::vector<Button*> mButtons;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
ModsWindow::ModsWindow() : Window{Props{.tabBar = false, .styleSheets = {"res/rml/mods.rcss"}}} {
|
||||
mRoot->SetClass("mods", true);
|
||||
|
||||
for (auto& trackedMod : mods::ModLoader::instance().mods()) {
|
||||
mSnapshot.push_back({
|
||||
.mod = &trackedMod,
|
||||
.active = trackedMod.active,
|
||||
.loadFailed = trackedMod.loadFailed,
|
||||
.enabled = mod_enabled(trackedMod),
|
||||
.suspended = trackedMod.suspendedByProvider,
|
||||
.cacheGeneration = trackedMod.cacheGeneration,
|
||||
});
|
||||
}
|
||||
|
||||
set_content([this](Rml::Element* content) { build_content(content); });
|
||||
}
|
||||
|
||||
void ModsWindow::build_content(Rml::Element* content) {
|
||||
mEntries.clear();
|
||||
mEntryMods.clear();
|
||||
|
||||
auto& listPane = add_child<Pane>(content, Pane::Type::Controlled);
|
||||
listPane.root()->SetClass("mod-list", true);
|
||||
auto& detailPane = add_child<Pane>(content, Pane::Type::Uncontrolled);
|
||||
detailPane.root()->SetClass("mod-detail", true);
|
||||
|
||||
if (mods::ModLoader::instance().mods().empty()) {
|
||||
listPane.add_text("No mods installed.");
|
||||
listPane.finalize();
|
||||
detailPane.finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto& trackedMod : mods::ModLoader::instance().mods()) {
|
||||
auto& entry = listPane.add_child<ModListEntry>(trackedMod);
|
||||
mEntries.push_back(&entry);
|
||||
mEntryMods.push_back(&trackedMod);
|
||||
listPane.register_control(entry, detailPane, [this, tracked = &trackedMod](Pane& pane) {
|
||||
mSelectedMod = tracked;
|
||||
pane.clear();
|
||||
build_detail(pane, *tracked);
|
||||
mark_current_entry();
|
||||
});
|
||||
}
|
||||
|
||||
if (mSelectedMod == nullptr) {
|
||||
mSelectedMod = mEntryMods.front();
|
||||
}
|
||||
build_detail(detailPane, *mSelectedMod);
|
||||
mark_current_entry();
|
||||
|
||||
listPane.finalize();
|
||||
}
|
||||
|
||||
void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) {
|
||||
pane.add_child<ModDetailHeader>(
|
||||
mod, [this, id = mod.metadata.id] { push(std::make_unique<LogsWindow>(id)); });
|
||||
|
||||
Rml::String statusBadge;
|
||||
if (mod.loadFailed || mod.suspendedByProvider) {
|
||||
const auto status = mod_status(mod);
|
||||
statusBadge = fmt::format(
|
||||
R"( <span class="status-badge {}">{}</span>)", status.badgeClass, status.text);
|
||||
}
|
||||
pane.add_rml(fmt::format(R"(<div class="mod-title">{} )"
|
||||
R"(<span class="mod-title-version">v{}</span>{}</div>)"
|
||||
R"(<div class="mod-author">by {}</div>)",
|
||||
escape(mod.metadata.name), escape(mod.metadata.version), statusBadge,
|
||||
escape(mod.metadata.author)));
|
||||
|
||||
if (mod.loadFailed && !mod.failureReason.empty()) {
|
||||
pane.add_rml(fmt::format(R"(<div class="mod-info-row">)"
|
||||
R"(<span class="mod-info-label failed">Reason</span>)"
|
||||
R"(<span class="mod-info-value">{}</span>)"
|
||||
R"(</div>)",
|
||||
escape(mod.failureReason)));
|
||||
} else if (mod.suspendedByProvider) {
|
||||
std::string providers;
|
||||
for (const auto& edge : mod.dependencies) {
|
||||
if (edge.required && edge.mod != nullptr && !edge.mod->active) {
|
||||
if (!providers.empty()) {
|
||||
providers += ", ";
|
||||
}
|
||||
providers += edge.mod->metadata.name;
|
||||
}
|
||||
}
|
||||
pane.add_rml(fmt::format(R"(<div class="mod-info-row">)"
|
||||
R"(<span class="mod-info-label">Waiting on</span>)"
|
||||
R"(<span class="mod-info-value">{}</span>)"
|
||||
R"(</div>)",
|
||||
escape(providers)));
|
||||
}
|
||||
|
||||
std::string activeDependents;
|
||||
for (const auto& edge : mod.dependents) {
|
||||
if (edge.mod != nullptr && edge.mod->active) {
|
||||
if (!activeDependents.empty()) {
|
||||
activeDependents += ", ";
|
||||
}
|
||||
activeDependents += edge.mod->metadata.name;
|
||||
}
|
||||
}
|
||||
if (mod.active && !activeDependents.empty()) {
|
||||
pane.add_rml(fmt::format(R"(<div class="mod-restart-note">{}</div>)",
|
||||
escape(fmt::format("Disabling or reloading also restarts: {}", activeDependents))));
|
||||
}
|
||||
|
||||
if (!mod.metadata.description.empty()) {
|
||||
pane.add_text(mod.metadata.description)->SetClass("mod-description", true);
|
||||
}
|
||||
|
||||
pane.finalize();
|
||||
}
|
||||
|
||||
void ModsWindow::mark_current_entry() {
|
||||
for (size_t i = 0; i < mEntries.size(); ++i) {
|
||||
mEntries[i]->root()->SetClass("current", mEntryMods[i] == mSelectedMod);
|
||||
}
|
||||
}
|
||||
|
||||
void ModsWindow::update() {
|
||||
bool dirty = false;
|
||||
for (auto& snapshot : mSnapshot) {
|
||||
const auto& mod = *snapshot.mod;
|
||||
if (mod.active != snapshot.active || mod.loadFailed != snapshot.loadFailed ||
|
||||
mod_enabled(mod) != snapshot.enabled || mod.suspendedByProvider != snapshot.suspended ||
|
||||
mod.cacheGeneration != snapshot.cacheGeneration)
|
||||
{
|
||||
snapshot.active = mod.active;
|
||||
snapshot.loadFailed = mod.loadFailed;
|
||||
snapshot.enabled = mod_enabled(mod);
|
||||
snapshot.suspended = mod.suspendedByProvider;
|
||||
snapshot.cacheGeneration = mod.cacheGeneration;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
if (dirty) {
|
||||
auto* focused = mDocument != nullptr ? mDocument->GetFocusLeafNode() : nullptr;
|
||||
bool hadContentFocus = false;
|
||||
for (auto* node = focused; node != nullptr; node = node->GetParentNode()) {
|
||||
if (node == mContentRoot) {
|
||||
hadContentFocus = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
rebuild_content();
|
||||
if (hadContentFocus) {
|
||||
for (size_t i = 0; i < mEntryMods.size(); ++i) {
|
||||
if (mEntryMods[i] == mSelectedMod) {
|
||||
mEntries[i]->focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Window::update();
|
||||
}
|
||||
|
||||
} // namespace dusk::ui
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include "window.hpp"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "dusk/mod_loader.hpp"
|
||||
|
||||
namespace dusk::ui {
|
||||
|
||||
class Pane;
|
||||
|
||||
class ModsWindow : public Window {
|
||||
public:
|
||||
ModsWindow();
|
||||
void update() override;
|
||||
|
||||
private:
|
||||
struct ModSnapshot {
|
||||
mods::LoadedMod* mod = nullptr;
|
||||
bool active = false;
|
||||
bool loadFailed = false;
|
||||
bool enabled = false;
|
||||
bool suspended = false;
|
||||
u32 cacheGeneration = 0;
|
||||
};
|
||||
|
||||
void build_content(Rml::Element* content);
|
||||
void build_detail(Pane& pane, mods::LoadedMod& mod);
|
||||
void mark_current_entry();
|
||||
|
||||
std::vector<ModSnapshot> mSnapshot;
|
||||
std::vector<Component*> mEntries;
|
||||
std::vector<mods::LoadedMod*> mEntryMods;
|
||||
mods::LoadedMod* mSelectedMod = nullptr;
|
||||
};
|
||||
|
||||
} // namespace dusk::ui
|
||||
@@ -89,6 +89,9 @@ Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) {
|
||||
} else if (toast.type == "controller") {
|
||||
auto* icon = append(heading, "icon");
|
||||
icon->SetClass("controller", true);
|
||||
} else if (toast.type == "warning") {
|
||||
auto* icon = append(heading, "icon");
|
||||
icon->SetClass("warning", true);
|
||||
}
|
||||
}
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "dusk/settings.h"
|
||||
#include "dusk/update_check.hpp"
|
||||
#include "modal.hpp"
|
||||
#include "mods_window.hpp"
|
||||
#include "preset.hpp"
|
||||
#include "settings.hpp"
|
||||
#include "version.h"
|
||||
@@ -49,14 +50,14 @@ const Rml::String kDocumentSource = R"RML(
|
||||
</hero>
|
||||
<div id="menu-list" />
|
||||
</menu>
|
||||
<disc-info class="intro-item delay-4">
|
||||
<disc-info class="intro-item delay-5">
|
||||
<div id="disc-status">
|
||||
<icon />
|
||||
<span id="disc-status-label" />
|
||||
</div>
|
||||
<span id="disc-version" class="detail" />
|
||||
</disc-info>
|
||||
<version-info class="intro-item delay-5">
|
||||
<version-info class="intro-item delay-6">
|
||||
<div class="version">Version <span id="version-text"></span></div>
|
||||
<div id="update-status" class="update">
|
||||
<span id="update-message"></span>
|
||||
@@ -726,9 +727,16 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB
|
||||
});
|
||||
apply_intro_animation(mMenuButtons.back()->root(), "delay-2");
|
||||
|
||||
mMenuButtons.push_back(std::make_unique<Button>(menuList, "Mods"));
|
||||
mMenuButtons.back()->on_pressed([this] {
|
||||
mRestartSuppressed = false;
|
||||
push(std::make_unique<ModsWindow>());
|
||||
});
|
||||
apply_intro_animation(mMenuButtons.back()->root(), "delay-3");
|
||||
|
||||
mMenuButtons.push_back(std::make_unique<Button>(menuList, "Quit"));
|
||||
mMenuButtons.back()->on_pressed([] { IsRunning = false; });
|
||||
apply_intro_animation(mMenuButtons.back()->root(), "delay-3");
|
||||
apply_intro_animation(mMenuButtons.back()->root(), "delay-4");
|
||||
}
|
||||
|
||||
mDiscStatus = mDocument->GetElementById("disc-status");
|
||||
|
||||
@@ -1329,12 +1329,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
|
||||
speedrun::disconnectLiveSplit();
|
||||
}
|
||||
}
|
||||
for (auto& doc : get_document_stack()) {
|
||||
if (dynamic_cast<MenuBar*>(doc.get())) {
|
||||
doc = std::make_unique<MenuBar>();
|
||||
break;
|
||||
}
|
||||
}
|
||||
MenuBar::rebuild();
|
||||
},
|
||||
});
|
||||
config_bool_select(leftPane, rightPane, getSettings().game.liveSplitEnabled,
|
||||
@@ -1568,15 +1563,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
|
||||
.helpText = "Show advanced settings and debugging tools with "
|
||||
"Shift+F1.<br/><br/><icon class=\"warning\"/> WARNING: Debugging tools "
|
||||
"can easily break your game. Do not use on a regular save!",
|
||||
.onChange =
|
||||
[](bool) {
|
||||
for (auto& doc : get_document_stack()) {
|
||||
if (dynamic_cast<MenuBar*>(doc.get())) {
|
||||
doc = std::make_unique<MenuBar>();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
.onChange = [](bool) { MenuBar::rebuild(); },
|
||||
.isDisabled = [] { return getSettings().game.speedrunMode.getValue(); },
|
||||
});
|
||||
config_bool_select(leftPane, rightPane, getSettings().game.showInputViewer,
|
||||
|
||||
+4
-1
@@ -17,8 +17,9 @@
|
||||
#include "aurora/lib/window.hpp"
|
||||
#include "dusk/config.hpp"
|
||||
#include "dusk/io.hpp"
|
||||
#include "input.hpp"
|
||||
#include "icon_provider.hpp"
|
||||
#include "input.hpp"
|
||||
#include "mod_texture_provider.hpp"
|
||||
#include "prelaunch.hpp"
|
||||
#include "window.hpp"
|
||||
|
||||
@@ -62,11 +63,13 @@ bool initialize() noexcept {
|
||||
load_font("NotoMono-Regular.ttf");
|
||||
|
||||
register_icon_texture_provider();
|
||||
register_mod_texture_provider();
|
||||
sInitialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void shutdown() noexcept {
|
||||
unregister_mod_texture_provider();
|
||||
unregister_icon_texture_provider();
|
||||
sDocumentStack.clear();
|
||||
sPassiveDocuments.clear();
|
||||
|
||||
+68
-14
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "aurora/lib/window.hpp"
|
||||
#include "aurora/rmlui.hpp"
|
||||
#include "fmt/format.h"
|
||||
#include "magic_enum.hpp"
|
||||
#include "pane.hpp"
|
||||
#include "ui.hpp"
|
||||
@@ -24,17 +25,24 @@ float base_body_padding(Rml::Context* context) noexcept {
|
||||
return 64.0f * dpRatio;
|
||||
}
|
||||
|
||||
const Rml::String kDocumentSource = R"RML(
|
||||
Rml::String window_document_source(const std::vector<Rml::String>& styleSheets) {
|
||||
Rml::String links;
|
||||
for (const auto& sheet : styleSheets) {
|
||||
links += fmt::format(" <link type=\"text/rcss\" href=\"{}\" />\n", sheet);
|
||||
}
|
||||
return fmt::format(R"RML(
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="res/rml/tabbing.rcss" />
|
||||
<link type="text/rcss" href="res/rml/window.rcss" />
|
||||
</head>
|
||||
{}</head>
|
||||
<body>
|
||||
<window id="window"></window>
|
||||
</body>
|
||||
</rml>
|
||||
)RML";
|
||||
)RML",
|
||||
links);
|
||||
}
|
||||
|
||||
const Rml::String kDocumentSourceSmall = R"RML(
|
||||
<rml>
|
||||
@@ -51,12 +59,25 @@ const Rml::String kDocumentSourceSmall = R"RML(
|
||||
|
||||
} // namespace
|
||||
|
||||
Window::Window() : Document(kDocumentSource), mRoot(mDocument->GetElementById("window")) {
|
||||
mTabBar = std::make_unique<TabBar>(mRoot, TabBar::Props{
|
||||
.onClose = [this] { request_close(); },
|
||||
.selectedTabIndex = 0,
|
||||
.autoSelect = true,
|
||||
});
|
||||
Window::Window(Props props)
|
||||
: Document(window_document_source(props.styleSheets)),
|
||||
mRoot(mDocument->GetElementById("window")) {
|
||||
if (props.tabBar) {
|
||||
mTabBar = std::make_unique<TabBar>(mRoot, TabBar::Props{
|
||||
.onClose = [this] { request_close(); },
|
||||
.selectedTabIndex = 0,
|
||||
.autoSelect = true,
|
||||
});
|
||||
} else {
|
||||
mCloseButton = std::make_unique<Button>(mRoot, Button::Props{}, "close");
|
||||
mCloseButton->on_nav_command([this](Rml::Event&, NavCommand cmd) {
|
||||
if (cmd == NavCommand::Confirm) {
|
||||
request_close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
auto elem = mDocument->CreateElement("content");
|
||||
elem->SetAttribute("id", "content");
|
||||
@@ -152,7 +173,7 @@ void Window::update_safe_area() noexcept {
|
||||
}
|
||||
|
||||
bool Window::set_active_tab(int index) {
|
||||
return mTabBar->set_active_tab(index);
|
||||
return mTabBar && mTabBar->set_active_tab(index);
|
||||
}
|
||||
|
||||
void Window::request_close() {
|
||||
@@ -167,10 +188,17 @@ bool Window::consume_close_request() {
|
||||
}
|
||||
|
||||
void Window::refresh_active_tab() {
|
||||
mTabBar->refresh_active_tab();
|
||||
if (mTabBar) {
|
||||
mTabBar->refresh_active_tab();
|
||||
} else {
|
||||
rebuild_content();
|
||||
}
|
||||
}
|
||||
|
||||
void Window::add_tab(const Rml::String& title, TabBuilder builder) {
|
||||
if (!mTabBar) {
|
||||
return;
|
||||
}
|
||||
mTabBar->add_tab(title, [this, builder = std::move(builder)] {
|
||||
clear_content();
|
||||
if (builder) {
|
||||
@@ -179,6 +207,18 @@ void Window::add_tab(const Rml::String& title, TabBuilder builder) {
|
||||
});
|
||||
}
|
||||
|
||||
void Window::set_content(TabBuilder builder) {
|
||||
mContentBuilder = std::move(builder);
|
||||
rebuild_content();
|
||||
}
|
||||
|
||||
void Window::rebuild_content() {
|
||||
clear_content();
|
||||
if (mContentBuilder) {
|
||||
mContentBuilder(mContentRoot);
|
||||
}
|
||||
}
|
||||
|
||||
void Window::clear_content() noexcept {
|
||||
mContentComponents.clear();
|
||||
while (mContentRoot->GetNumChildren() != 0) {
|
||||
@@ -187,7 +227,15 @@ void Window::clear_content() noexcept {
|
||||
}
|
||||
|
||||
bool Window::focus() {
|
||||
return mTabBar->focus();
|
||||
if (mTabBar) {
|
||||
return mTabBar->focus();
|
||||
}
|
||||
for (const auto& component : mContentComponents) {
|
||||
if (component->focus()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return mCloseButton && mCloseButton->focus();
|
||||
}
|
||||
|
||||
bool Window::visible() const {
|
||||
@@ -211,7 +259,7 @@ bool Window::handle_nav_command(Rml::Event& event, NavCommand cmd) {
|
||||
request_close();
|
||||
return true;
|
||||
}
|
||||
if (mTabBar->handle_nav_command(event, cmd)) {
|
||||
if (mTabBar && mTabBar->handle_nav_command(event, cmd)) {
|
||||
return true;
|
||||
}
|
||||
return mSuppressNavFallback ? false : Document::handle_nav_command(event, cmd);
|
||||
@@ -219,6 +267,9 @@ bool Window::handle_nav_command(Rml::Event& event, NavCommand cmd) {
|
||||
|
||||
bool Window::handle_content_nav(Rml::Event& event, NavCommand cmd) noexcept {
|
||||
if (cmd == NavCommand::Up) {
|
||||
if (!mTabBar) {
|
||||
return true;
|
||||
}
|
||||
if (focus()) {
|
||||
mDoAud_seStartMenu(kSoundItemFocus);
|
||||
return true;
|
||||
@@ -246,6 +297,9 @@ bool Window::handle_content_nav(Rml::Event& event, NavCommand cmd) noexcept {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!mTabBar) {
|
||||
return false;
|
||||
}
|
||||
return focus();
|
||||
} else if (cmd == NavCommand::Left || cmd == NavCommand::Right) {
|
||||
int currentComponent = -1;
|
||||
@@ -305,4 +359,4 @@ bool WindowSmall::visible() const {
|
||||
return mRoot->HasAttribute("open");
|
||||
}
|
||||
|
||||
} // namespace dusk::ui
|
||||
} // namespace dusk::ui
|
||||
|
||||
+12
-1
@@ -17,8 +17,13 @@ public:
|
||||
std::unique_ptr<Button> button;
|
||||
TabBuilder builder;
|
||||
};
|
||||
struct Props {
|
||||
bool tabBar = true;
|
||||
std::vector<Rml::String> styleSheets;
|
||||
};
|
||||
|
||||
Window();
|
||||
Window() : Window(Props{}) {}
|
||||
explicit Window(Props props);
|
||||
|
||||
Window(const Window&) = delete;
|
||||
Window& operator=(const Window&) = delete;
|
||||
@@ -34,6 +39,9 @@ protected:
|
||||
void request_close();
|
||||
virtual bool consume_close_request();
|
||||
void add_tab(const Rml::String& title, TabBuilder builder);
|
||||
// Tab-bar-less counterpart of add_tab: stores the builder and runs it immediately.
|
||||
void set_content(TabBuilder builder);
|
||||
void rebuild_content();
|
||||
void refresh_active_tab();
|
||||
void update_safe_area() noexcept;
|
||||
void clear_content() noexcept;
|
||||
@@ -52,6 +60,9 @@ protected:
|
||||
Rml::Element* mRoot;
|
||||
Rml::Element* mContentRoot;
|
||||
std::unique_ptr<TabBar> mTabBar;
|
||||
// Only set for tab-bar-less windows.
|
||||
std::unique_ptr<Button> mCloseButton;
|
||||
TabBuilder mContentBuilder;
|
||||
std::vector<std::unique_ptr<Component> > mContentComponents;
|
||||
Insets mBodyPadding;
|
||||
bool mInitialOpen = true;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "dusk/frame_interpolation.h"
|
||||
#include "dusk/livesplit.h"
|
||||
#include "dusk/logging.h"
|
||||
#include "dusk/mod_loader.hpp"
|
||||
#include "f_op/f_op_camera_mng.h"
|
||||
#include "f_op/f_op_draw_tag.h"
|
||||
#include "f_op/f_op_overlap_mng.h"
|
||||
@@ -815,6 +816,8 @@ static void duskExecute() {
|
||||
if (dusk::getSettings().game.infiniteOxygen) {
|
||||
dComIfGp_setOxygen(dComIfGp_getMaxOxygen());
|
||||
}
|
||||
|
||||
dusk::mods::ModLoader::instance().tick();
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
+57
-1
@@ -60,6 +60,7 @@
|
||||
#include "dusk/imgui/ImGuiConsole.hpp"
|
||||
#include "dusk/imgui/ImGuiEngine.hpp"
|
||||
#include "dusk/iso_validate.hpp"
|
||||
#include "dusk/mod_loader.hpp"
|
||||
#include "dusk/logging.h"
|
||||
#include "dusk/main.h"
|
||||
#include "dusk/ui/menu_bar.hpp"
|
||||
@@ -363,6 +364,7 @@ void main01(void) {
|
||||
} while (dusk::IsRunning);
|
||||
|
||||
exit:;
|
||||
dusk::mods::ModLoader::instance().shutdown();
|
||||
dusk::ui::shutdown();
|
||||
}
|
||||
|
||||
@@ -512,6 +514,7 @@ int game_main(int argc, char* argv[]) {
|
||||
("h,help", "Print usage")
|
||||
("console", "Show the Windows console window for logs", cxxopts::value<bool>()->default_value("false")->implicit_value("true"))
|
||||
("dvd", "Path to DVD image file", cxxopts::value<std::string>())
|
||||
("mods", "Path to mods directory", cxxopts::value<std::string>())
|
||||
("backend", "Graphics API backend to use (auto, d3d12, d3d11, metal, vulkan, null)", cxxopts::value<std::string>())
|
||||
("cvar", "Override configuration variables without modifying config", cxxopts::value<std::vector<std::string>>());
|
||||
|
||||
@@ -785,8 +788,61 @@ int game_main(int argc, char* argv[]) {
|
||||
// mDoMain::developmentMode = 1; // Force Dev Mode for Debugging
|
||||
mDoDvdThd::SyncWidthSound = false;
|
||||
|
||||
OSReport("Starting main01 (Game Loop)...\n");
|
||||
// Mod search directories, highest priority first: user dir (--mods replaces it), then
|
||||
// mods/ next to the app, then install-bundled mods inside the app bundle.
|
||||
{
|
||||
std::vector<dusk::mods::ModSearchDir> modDirs;
|
||||
if (parsed_arg_options.contains("mods") &&
|
||||
!parsed_arg_options["mods"].as<std::string>().empty())
|
||||
{
|
||||
modDirs.push_back({.path = parsed_arg_options["mods"].as<std::string>()});
|
||||
} else {
|
||||
modDirs.push_back({.path = dusk::ConfigPath / "mods"});
|
||||
}
|
||||
#if TARGET_ANDROID
|
||||
// APK-bundled mods are extracted to internal storage
|
||||
// by DuskActivity before SDL_main runs.
|
||||
modDirs.push_back({
|
||||
.path = dusk::CachePath / "bundled_mods",
|
||||
});
|
||||
#elif defined(__APPLE__) && (TARGET_OS_IOS || TARGET_OS_TV)
|
||||
modDirs.push_back({
|
||||
.path = dusk::data::base_path_relative("mods"),
|
||||
.inPlaceNative = true,
|
||||
.nativeLibDir = dusk::data::base_path_relative("Frameworks"),
|
||||
});
|
||||
#else
|
||||
#if defined(__APPLE__)
|
||||
// Base path is Contents/Resources; search up for dev mods
|
||||
// TODO: scope to non-CI builds
|
||||
modDirs.push_back({
|
||||
.path = dusk::data::base_path_relative("../../../mods").lexically_normal(),
|
||||
.inPlaceNative = true,
|
||||
});
|
||||
// Contents/Resources/mods
|
||||
modDirs.push_back({
|
||||
.path = dusk::data::base_path_relative("mods"),
|
||||
.inPlaceNative = true,
|
||||
});
|
||||
#else
|
||||
modDirs.push_back({
|
||||
.path = dusk::data::base_path_relative("mods"),
|
||||
.inPlaceNative = true,
|
||||
});
|
||||
#endif
|
||||
#endif
|
||||
dusk::mods::ModLoader::instance().set_search_dirs(std::move(modDirs));
|
||||
}
|
||||
#if TARGET_ANDROID
|
||||
// A user-relocated data dir can live on external storage, which is mounted noexec.
|
||||
// Native mod libraries must be extracted to internal storage.
|
||||
dusk::mods::ModLoader::instance().set_cache_dir(dusk::CachePath / "mod_cache");
|
||||
#endif
|
||||
|
||||
DuskLog.info("Initializing mods...");
|
||||
dusk::mods::ModLoader::instance().init();
|
||||
|
||||
OSReport("Starting main01 (Game Loop)...\n");
|
||||
|
||||
main01();
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "com.example.mod",
|
||||
"name": "Template Mod",
|
||||
"version": "1.0.0",
|
||||
"author": "You",
|
||||
"description": "An example Dusklight mod"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user