Merge pull request #2185 from encounter/mods-core

Dusklight mod API core
This commit is contained in:
Luke Street
2026-07-08 22:39:18 -06:00
committed by GitHub
86 changed files with 9714 additions and 199 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
---
Language: Cpp
Standard: C++03
Standard: c++20
AccessModifierOffset: -4
AlignAfterOpenBracket: DontAlign
AlignConsecutiveAssignments: false
+142 -136
View File
@@ -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
View File
@@ -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
+121
View File
@@ -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()
+27
View File
@@ -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})
+279
View File
@@ -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()
+13
View File
@@ -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}")
+60
View File
@@ -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}")
+125
View File
@@ -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()
+79
View File
@@ -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
View File
@@ -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.
+1 -1
+30
View File
@@ -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
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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, ...) \
+245
View File
@@ -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
+12
View File
@@ -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
+5
View File
@@ -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();
+13
View File
@@ -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))
+152
View File
@@ -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
+464
View File
@@ -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
+138
View File
@@ -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, \
}); \
}
+107
View File
@@ -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
+30
View File
@@ -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
+125
View File
@@ -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
+104
View File
@@ -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
+47
View File
@@ -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
+57
View File
@@ -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
+52
View File
@@ -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
+88
View File
@@ -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
+8
View File
@@ -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>
+97
View File
@@ -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;
}
+218
View File
@@ -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("&#xe87b;" 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;
}
+8
View File
@@ -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;
+4
View File
@@ -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
View File
@@ -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%);
}
+43
View File
@@ -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")
+139
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -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();
+60
View File
@@ -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
+68
View File
@@ -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
+61
View File
@@ -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
+204
View File
@@ -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
+17
View File
@@ -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
+64
View File
@@ -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
+83
View File
@@ -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
+35
View File
@@ -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;
};
}
+103
View File
@@ -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
+55
View File
@@ -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
+386
View File
@@ -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
+50
View File
@@ -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
+451
View File
@@ -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, &currentValue, &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
+9
View File
@@ -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
+21
View File
@@ -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
+519
View File
@@ -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
+154
View File
@@ -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
+53
View File
@@ -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
+351
View File
@@ -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
+320
View File
@@ -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
+73
View File
@@ -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
+110
View File
@@ -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
+463
View File
@@ -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
-8
View File
@@ -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
// ==========================================================================
+2 -1
View File
@@ -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());
}
+301
View File
@@ -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
+44
View File
@@ -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
+16 -2
View File
@@ -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
+2
View File
@@ -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;
+210
View File
@@ -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
+19
View File
@@ -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
+325
View File
@@ -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"(&nbsp;<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
+38
View File
@@ -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
+3
View File
@@ -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);
}
}
{
+11 -3
View File
@@ -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");
+2 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+3
View File
@@ -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
View File
@@ -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();
+19
View File
@@ -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
)
+7
View File
@@ -0,0 +1,7 @@
{
"id": "com.example.mod",
"name": "Template Mod",
"version": "1.0.0",
"author": "You",
"description": "An example Dusklight mod"
}
View File
+22
View File
@@ -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;
}
}