From 700bbf0a5a9c515bf45cbe1d393524bba120142c Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 13 Jul 2026 13:10:39 -0600 Subject: [PATCH] Mod API: Move dylibs to libs/{platform}; services import latest minor versions --- cmake/ModSDK.cmake | 75 +++++++-- docs/modding.md | 61 ++++++-- include/dusk/mod_loader.hpp | 7 +- include/mods/api.h | 2 +- include/mods/service.hpp | 10 +- include/mods/svc/camera.h | 1 + include/mods/svc/config.h | 1 + include/mods/svc/game.h | 1 + include/mods/svc/gfx.h | 1 + include/mods/svc/hook.h | 1 + include/mods/svc/host.h | 13 +- include/mods/svc/log.h | 1 + include/mods/svc/overlay.h | 1 + include/mods/svc/resource.h | 1 + include/mods/svc/texture.h | 1 + include/mods/svc/ui.h | 1 + src/dusk/mods/loader/loader.cpp | 205 ++++++++++++++++++------- src/dusk/mods/loader/native_module.cpp | 8 +- src/dusk/mods/svc/host.cpp | 6 + 19 files changed, 304 insertions(+), 93 deletions(-) diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index 833e433360..81d3c81509 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -1,8 +1,9 @@ -# add_mod( SOURCES ... MOD_JSON [RES_DIR ] [OVERLAY_DIR ] +# add_mod( SOURCES ... MOD_JSON +# [RUNTIME_LIBRARIES ...] [RES_DIR ] [OVERLAY_DIR ] # [TEXTURES_DIR ] [OUTPUT_DIR ] [BUNDLE]) set(DUSK_MODS_OUTPUT_DIR "${CMAKE_BINARY_DIR}/mods" CACHE PATH "Directory to write mod packages into") -function(_mod_lib_name out_var) +function(_mod_lib_info out_platform_var out_name_var) set(_arch "${CMAKE_SYSTEM_PROCESSOR}") if (APPLE AND CMAKE_OSX_ARCHITECTURES) list(LENGTH CMAKE_OSX_ARCHITECTURES _count) @@ -23,7 +24,8 @@ function(_mod_lib_name out_var) else () set(_ext ".so") endif () - set(${out_var} "${_platform}-${_arch}${_ext}" PARENT_SCOPE) + set(${out_platform_var} "${_platform}-${_arch}" PARENT_SCOPE) + set(${out_name_var} "mod${_ext}" PARENT_SCOPE) endfunction() function(_mod_resolve_source_path out_var path) @@ -45,7 +47,8 @@ function(_mod_collect_assets out_var dir) endfunction() function(add_mod target_name) - cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OVERLAY_DIR;TEXTURES_DIR;OUTPUT_DIR" "SOURCES" ${ARGN}) + cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OVERLAY_DIR;TEXTURES_DIR;OUTPUT_DIR" + "SOURCES;RUNTIME_LIBRARIES" ${ARGN}) if (NOT ARG_MOD_JSON) message(FATAL_ERROR "add_mod: MOD_JSON is required") endif () @@ -55,11 +58,12 @@ function(add_mod target_name) endif () set(_has_lib FALSE) + set(_lib_platform "") set(_lib_name "") if (ARG_SOURCES) set(_has_lib TRUE) add_library(${target_name} SHARED ${ARG_SOURCES}) - _mod_lib_name(_lib_name) + _mod_lib_info(_lib_platform _lib_name) set_target_properties(${target_name} PROPERTIES PREFIX "" C_VISIBILITY_PRESET hidden @@ -92,6 +96,9 @@ function(add_mod target_name) if (APPLE) # Game symbols resolve against the host executable at dlopen time. target_link_options(${target_name} PRIVATE -undefined dynamic_lookup) + set_target_properties(${target_name} PROPERTIES + BUILD_RPATH "@loader_path" + INSTALL_RPATH "@loader_path") elseif (ANDROID) if (TARGET dusklight) target_link_libraries(${target_name} PRIVATE dusklight) @@ -100,8 +107,14 @@ function(add_mod target_name) else () message(FATAL_ERROR "add_mod: DUSK_GAME_SOLIB is not set (libmain.so)") endif () + set_target_properties(${target_name} PROPERTIES + BUILD_RPATH "$ORIGIN" + INSTALL_RPATH "$ORIGIN") elseif (UNIX) target_link_options(${target_name} PRIVATE -Wl,--allow-shlib-undefined) + set_target_properties(${target_name} PROPERTIES + BUILD_RPATH "$ORIGIN" + INSTALL_RPATH "$ORIGIN") elseif (WIN32) # Link against the generated import library (game ABI surface). Function calls # resolve through import thunks. Data is toolchain dependent: @@ -134,6 +147,9 @@ function(add_mod target_name) endif () endif () endif () + if (ARG_RUNTIME_LIBRARIES AND NOT _has_lib) + message(FATAL_ERROR "add_mod: RUNTIME_LIBRARIES requires SOURCES") + endif () set(_output_dir "${DUSK_MODS_OUTPUT_DIR}") if (ARG_OUTPUT_DIR) @@ -142,18 +158,39 @@ function(add_mod target_name) set(_stage "${CMAKE_CURRENT_BINARY_DIR}/${target_name}_stage") set(_out "${_output_dir}/${target_name}.dusk") - set(_zip_args "${_lib_name}" mod.json) + set(_zip_args mod.json) set(_package_deps "${_mod_json}") set(_package_inputs "${_mod_json}") set(_extra_cmds "") set(_lib_copy_cmd "") set(_target_depend "") if (_has_lib) - list(APPEND _zip_args "${_lib_name}") - set(_lib_copy_cmd COMMAND ${CMAKE_COMMAND} -E copy_if_different - "$" "${_stage}/${_lib_name}") + list(APPEND _zip_args lib) + set(_lib_copy_cmd + COMMAND ${CMAKE_COMMAND} -E make_directory "${_stage}/lib/${_lib_platform}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" "${_stage}/lib/${_lib_platform}/${_lib_name}") set(_target_depend ${target_name}) endif () + string(TOLOWER "${_lib_name}" _lib_name_key) + set(_runtime_lib_name_keys "${_lib_name_key}") + foreach (_runtime_lib IN LISTS ARG_RUNTIME_LIBRARIES) + _mod_resolve_source_path(_runtime_lib_path "${_runtime_lib}") + if (NOT EXISTS "${_runtime_lib_path}" OR IS_DIRECTORY "${_runtime_lib_path}") + message(FATAL_ERROR "add_mod: runtime library does not exist or is not a file: ${_runtime_lib_path}") + endif () + get_filename_component(_runtime_lib_name "${_runtime_lib_path}" NAME) + string(TOLOWER "${_runtime_lib_name}" _runtime_lib_name_key) + list(FIND _runtime_lib_name_keys "${_runtime_lib_name_key}" _runtime_lib_name_index) + if (NOT _runtime_lib_name_index EQUAL -1) + message(FATAL_ERROR "add_mod: duplicate runtime library filename: ${_runtime_lib_name}") + endif () + list(APPEND _runtime_lib_name_keys "${_runtime_lib_name_key}") + list(APPEND _package_deps "${_runtime_lib_path}") + list(APPEND _package_inputs "${_runtime_lib_path}") + list(APPEND _lib_copy_cmd COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_runtime_lib_path}" "${_stage}/lib/${_lib_platform}/${_runtime_lib_name}") + endforeach () if (ARG_RES_DIR) _mod_resolve_source_path(_res_dir "${ARG_RES_DIR}") _mod_collect_assets(_res_deps "${_res_dir}") @@ -190,6 +227,7 @@ function(add_mod 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_PLATFORMS "${_lib_platform}") 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" @@ -230,7 +268,8 @@ endfunction() # read-only installs. # - macOS: pre-extracted stage dirs into the installed app's Contents/Resources/mods, dylibs # ad-hoc signed in place, then the whole bundle re-signed. -# - iOS/tvOS: assets into /mods/ and the dylib into Frameworks/.dylib. +# - iOS/tvOS: assets into /mods/, the mod dylib into Frameworks/.dylib, +# and runtime libraries alongside it. # - 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) @@ -239,6 +278,7 @@ function(install_bundled_mods) endif () get_property(_ids GLOBAL PROPERTY DUSK_BUNDLED_MOD_IDS) get_property(_stages GLOBAL PROPERTY DUSK_BUNDLED_MOD_STAGES) + get_property(_lib_platforms GLOBAL PROPERTY DUSK_BUNDLED_MOD_LIB_PLATFORMS) get_property(_lib_names GLOBAL PROPERTY DUSK_BUNDLED_MOD_LIB_NAMES) list(LENGTH _targets _count) math(EXPR _last "${_count} - 1") @@ -254,19 +294,30 @@ function(install_bundled_mods) list(GET _targets ${_i} _target) list(GET _ids ${_i} _id) list(GET _stages ${_i} _stage) + list(GET _lib_platforms ${_i} _lib_platform) list(GET _lib_names ${_i} _lib_name) install(DIRECTORY "${_stage}/" DESTINATION "${_bundle_dir}/mods/${_id}" - PATTERN "${_lib_name}" EXCLUDE) + PATTERN "lib" EXCLUDE) install(PROGRAMS "$" DESTINATION "${_bundle_dir}/Frameworks" RENAME "${_id}.dylib") + install(DIRECTORY "${_stage}/lib/${_lib_platform}/" + DESTINATION "${_bundle_dir}/Frameworks" + PATTERN "${_lib_name}" EXCLUDE) endforeach () else () foreach (_i RANGE ${_last}) list(GET _ids ${_i} _id) list(GET _stages ${_i} _stage) + list(GET _lib_platforms ${_i} _lib_platform) 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)") + install(CODE " + file(GLOB _mod_libs \"${_bundle_dir}/Contents/Resources/mods/${_id}/lib/${_lib_platform}/*\") + foreach (_mod_lib IN LISTS _mod_libs) + if (NOT IS_DIRECTORY \"\${_mod_lib}\") + execute_process(COMMAND /usr/bin/codesign --force --sign - \"\${_mod_lib}\" COMMAND_ERROR_IS_FATAL ANY) + endif () + endforeach ()") endforeach () if (TARGET crashpad_handler) install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - \"${_bundle_dir}/Contents/MacOS/$\" COMMAND_ERROR_IS_FATAL ANY)") diff --git a/docs/modding.md b/docs/modding.md index 3823bd05a4..4988f6a6e4 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -22,7 +22,7 @@ function, read and write data fields, and hook the vast majority of game functio 7. [Asset Overlays](#asset-overlays) 8. [Runtime Lifecycle](#runtime-lifecycle) 9. [Error Handling](#error-handling) -10. [Advanced: Exporting Services](#advanced-exporting-services) +10. [Advanced](#advanced) --- @@ -131,26 +131,30 @@ A service is a struct of C function pointers with a version header. You declare 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_SERVICE(LogService, svc_log); // required, latest minor version +IMPORT_SERVICE_VERSION(LogService, svc_log, 0); // required, minimum minor version 0 (for backwards compatibility) 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. +functions to the end of the struct without breaking existing callers and simply bumps the minor version. + +`IMPORT_SERVICE` and `IMPORT_OPTIONAL_SERVICE` require the latest minor version compiled against, making every field in +the service safe to call. A mod can use `IMPORT_SERVICE_VERSION` (or its optional counterpart) with an older minor +version to remain compatible with older Dusklight versions, then use `SERVICE_HAS` to check at runtime for fields added +after that explicitly requested version. 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.** +- **Anything at or below the minor version you imported can be called unconditionally.** The default macros import + the service type's current minor version; the versioned macros explicitly override that minimum. - 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. +- Fields newer than your imported minor version must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a + null check. --- @@ -200,13 +204,13 @@ 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). +`get_service`/`publish_service` provide dynamic service lookup; see [Exporting Services](#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); +IMPORT_SERVICE(HostService, svc_host); void on_mod_lifecycle(ModContext* ctx, ModContext* subject, const char* subject_id, ModLifecycleEvent event, void* user_data) { @@ -666,7 +670,9 @@ explicit results. --- -## Advanced: Exporting Services +## 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: @@ -690,6 +696,7 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = MY_MOD_SERVICE_ID; static constexpr uint16_t major_version = MY_MOD_SERVICE_MAJOR; + static constexpr uint16_t minor_version = MY_MOD_SERVICE_MINOR; }; #endif ``` @@ -718,7 +725,7 @@ 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 +#### 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 @@ -748,3 +755,31 @@ For services whose construction can't happen at static-init time, declare the ex 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. + +### Native Runtime Libraries + +`RUNTIME_LIBRARIES` passed to `add_mod` are packaged beside the mod's native module in `lib//`. Dusklight +extracts the whole directory before loading the mod, so libraries linked by the mod resolve normally. The SDK links the +mod itself with `$ORIGIN` on Linux and `@loader_path` on Apple platforms; runtime libraries with their own non-system +dependencies must also be built with origin-relative lookup paths. On Windows, Dusklight uses an isolated DLL search +rooted at this directory. + +```cmake +add_mod(my_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RUNTIME_LIBRARIES "${VENDOR_RUNTIME_LIBRARY}") +``` + +SDKs that load plugins by directory can pass them the absolute runtime path from the current HostService: + +```cpp +IMPORT_SERVICE(HostService, svc_host); + +const char* nativeDir = svc_host->native_dir(mod_ctx); // read-only +``` + +Libraries loaded explicitly by the mod remain its responsibility: stop their threads and unload them during +`mod_shutdown`. Do not write into `native_dir`; use `mod_dir` for writable state. Native library namespaces are +process-wide on some platforms, so two mods cannot safely assume that incompatible libraries with the same filename +will remain isolated. diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index a326c8d807..74e01a0fdd 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -176,6 +176,8 @@ struct LoadedMod { uint32_t cacheGeneration = 0; // Currently extracted native library, empty if none. std::string nativePath; + // Read-only directory containing the current platform's main module and runtime libraries. + std::string nativeDir; NativeModStatus nativeStatus = NativeModStatus::None; std::unique_ptr native; @@ -225,7 +227,7 @@ private: // the next tick, by which point every per-frame entry into the mod should have returned. struct RetiredNative { std::unique_ptr native; - std::string path; + std::string directory; }; std::vector> m_mods; @@ -238,7 +240,8 @@ private: 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); + void load_native(LoadedMod& mod, const std::string& dllEntry, + const std::vector& runtimeEntries); // Resolved / if it exists on disk, empty otherwise. [[nodiscard]] std::filesystem::path external_native_lib_path(const LoadedMod& mod) const; void unload_native(LoadedMod& mod); diff --git a/include/mods/api.h b/include/mods/api.h index 5a21127203..c63d08cfe2 100644 --- a/include/mods/api.h +++ b/include/mods/api.h @@ -23,7 +23,7 @@ extern "C" { #define MOD_EXTERN_C #endif -#define MOD_ABI_VERSION 6u +#define MOD_ABI_VERSION 1u #define MOD_ERROR_MESSAGE_SIZE 512u typedef struct ModContext ModContext; diff --git a/include/mods/service.hpp b/include/mods/service.hpp index f54b3f2149..f5ba322c45 100644 --- a/include/mods/service.hpp +++ b/include/mods/service.hpp @@ -42,7 +42,8 @@ inline ModResult set_error(ModError* outError, ModResult code, const char* messa // Declares `static const service_type* variable`, filled in by the host before mod_initialize. // Required imports are guaranteed non-null (the mod fails to load otherwise); optional imports -// must be checked against nullptr before use. +// must be checked against nullptr before use. The unversioned macros use the latest minor version; +// set an explicit version to target an older minor version for backwards compatibility. #define IMPORT_SERVICE_EX( \ service_type, variable, service_id_value, major_value, min_minor_value, flags_value) \ static const service_type* variable = nullptr; \ @@ -59,7 +60,9 @@ inline ModResult set_error(ModError* outError, ModResult code, const char* messa ::dusk::mods::ServiceTraits::major_version, min_minor_value, \ SERVICE_IMPORT_REQUIRED) -#define IMPORT_SERVICE(service_type, variable) IMPORT_SERVICE_VERSION(service_type, variable, 0) +#define IMPORT_SERVICE(service_type, variable) \ + IMPORT_SERVICE_VERSION( \ + service_type, variable, ::dusk::mods::ServiceTraits::minor_version) #define IMPORT_OPTIONAL_SERVICE_VERSION(service_type, variable, min_minor_value) \ IMPORT_SERVICE_EX(service_type, variable, ::dusk::mods::ServiceTraits::id, \ @@ -67,7 +70,8 @@ inline ModResult set_error(ModError* outError, ModResult code, const char* messa SERVICE_IMPORT_OPTIONAL) #define IMPORT_OPTIONAL_SERVICE(service_type, variable) \ - IMPORT_OPTIONAL_SERVICE_VERSION(service_type, variable, 0) + IMPORT_OPTIONAL_SERVICE_VERSION( \ + service_type, variable, ::dusk::mods::ServiceTraits::minor_version) #define EXPORT_SERVICE_AS(instance, service_id_value) \ MOD_META_RECORD static constinit ModMetaExport mod_meta_export_##instance = { \ diff --git a/include/mods/svc/camera.h b/include/mods/svc/camera.h index f974cec152..884aadef1e 100644 --- a/include/mods/svc/camera.h +++ b/include/mods/svc/camera.h @@ -60,5 +60,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = CAMERA_SERVICE_ID; static constexpr uint16_t major_version = CAMERA_SERVICE_MAJOR; + static constexpr uint16_t minor_version = CAMERA_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/config.h b/include/mods/svc/config.h index 2a26210295..4275312201 100644 --- a/include/mods/svc/config.h +++ b/include/mods/svc/config.h @@ -103,5 +103,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = CONFIG_SERVICE_ID; static constexpr uint16_t major_version = CONFIG_SERVICE_MAJOR; + static constexpr uint16_t minor_version = CONFIG_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/game.h b/include/mods/svc/game.h index fbd914d4bc..116d6fc316 100644 --- a/include/mods/svc/game.h +++ b/include/mods/svc/game.h @@ -26,5 +26,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = GAME_SERVICE_ID; static constexpr uint16_t major_version = GAME_SERVICE_MAJOR; + static constexpr uint16_t minor_version = GAME_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/gfx.h b/include/mods/svc/gfx.h index a9812fd044..d87255e978 100644 --- a/include/mods/svc/gfx.h +++ b/include/mods/svc/gfx.h @@ -205,5 +205,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = GFX_SERVICE_ID; static constexpr uint16_t major_version = GFX_SERVICE_MAJOR; + static constexpr uint16_t minor_version = GFX_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/hook.h b/include/mods/svc/hook.h index d1a75fa6cc..00760d2945 100644 --- a/include/mods/svc/hook.h +++ b/include/mods/svc/hook.h @@ -121,5 +121,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = HOOK_SERVICE_ID; static constexpr uint16_t major_version = HOOK_SERVICE_MAJOR; + static constexpr uint16_t minor_version = HOOK_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/host.h b/include/mods/svc/host.h index 43c19cadfb..b0b278612c 100644 --- a/include/mods/svc/host.h +++ b/include/mods/svc/host.h @@ -9,7 +9,7 @@ #define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" #define HOST_SERVICE_MAJOR 2u -#define HOST_SERVICE_MINOR 0u +#define HOST_SERVICE_MINOR 1u /* * Ignore unknown values: later service minors may add events. @@ -91,6 +91,16 @@ typedef struct HostService { ModResult (*watch_mod_lifecycle)( ModContext* ctx, ModLifecycleFn fn, void* user_data, uint64_t* out_handle); ModResult (*unwatch_mod_lifecycle)(ModContext* ctx, uint64_t handle); + + /* + * Read-only directory containing this platform's packaged native runtime: the mod module + * and any RUNTIME_LIBRARIES. The path is absolute and remains valid until mod_shutdown + * returns. Libraries loaded dynamically from here are owned by the mod and must be unloaded + * during mod_shutdown. + * + * Added in minor version 1. + */ + const char* (*native_dir)(ModContext* ctx); } HostService; #ifdef __cplusplus @@ -100,5 +110,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = HOST_SERVICE_ID; static constexpr uint16_t major_version = HOST_SERVICE_MAJOR; + static constexpr uint16_t minor_version = HOST_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/log.h b/include/mods/svc/log.h index bad94f9503..b25780ed4a 100644 --- a/include/mods/svc/log.h +++ b/include/mods/svc/log.h @@ -43,5 +43,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = LOG_SERVICE_ID; static constexpr uint16_t major_version = LOG_SERVICE_MAJOR; + static constexpr uint16_t minor_version = LOG_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/overlay.h b/include/mods/svc/overlay.h index 9fc86fc850..bf6fd7b975 100644 --- a/include/mods/svc/overlay.h +++ b/include/mods/svc/overlay.h @@ -53,5 +53,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = OVERLAY_SERVICE_ID; static constexpr uint16_t major_version = OVERLAY_SERVICE_MAJOR; + static constexpr uint16_t minor_version = OVERLAY_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/resource.h b/include/mods/svc/resource.h index 1173b2ab1d..602ab1a25f 100644 --- a/include/mods/svc/resource.h +++ b/include/mods/svc/resource.h @@ -48,5 +48,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = RESOURCE_SERVICE_ID; static constexpr uint16_t major_version = RESOURCE_SERVICE_MAJOR; + static constexpr uint16_t minor_version = RESOURCE_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/texture.h b/include/mods/svc/texture.h index d664c5f99d..1c7a944cba 100644 --- a/include/mods/svc/texture.h +++ b/include/mods/svc/texture.h @@ -84,5 +84,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = TEXTURE_SERVICE_ID; static constexpr uint16_t major_version = TEXTURE_SERVICE_MAJOR; + static constexpr uint16_t minor_version = TEXTURE_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/ui.h b/include/mods/svc/ui.h index d9f2ac669e..676706a739 100644 --- a/include/mods/svc/ui.h +++ b/include/mods/svc/ui.h @@ -280,5 +280,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = UI_SERVICE_ID; static constexpr uint16_t major_version = UI_SERVICE_MAJOR; + static constexpr uint16_t minor_version = UI_SERVICE_MINOR; }; #endif diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 9431160bd2..008ace157d 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -30,46 +30,51 @@ using namespace std::string_view_literals; #if defined(_WIN32) #if defined(_M_ARM64) -static constexpr std::string_view k_nativeLibName = "windows-arm64.dll"sv; +static constexpr std::string_view k_nativePlatform = "windows-arm64"sv; #elif defined(_M_X64) -static constexpr std::string_view k_nativeLibName = "windows-amd64.dll"sv; +static constexpr std::string_view k_nativePlatform = "windows-amd64"sv; #elif defined(_M_IX86) -static constexpr std::string_view k_nativeLibName = "windows-x86.dll"sv; +static constexpr std::string_view k_nativePlatform = "windows-x86"sv; #else -static constexpr std::string_view k_nativeLibName = ""sv; +static constexpr std::string_view k_nativePlatform = ""sv; #endif +static constexpr std::string_view k_nativeLibName = "mod.dll"sv; #elif defined(__ANDROID__) #if defined(__aarch64__) -static constexpr std::string_view k_nativeLibName = "android-aarch64.so"sv; +static constexpr std::string_view k_nativePlatform = "android-aarch64"sv; #elif defined(__x86_64__) -static constexpr std::string_view k_nativeLibName = "android-x86_64.so"sv; +static constexpr std::string_view k_nativePlatform = "android-x86_64"sv; #else -static constexpr std::string_view k_nativeLibName = ""sv; +static constexpr std::string_view k_nativePlatform = ""sv; #endif +static constexpr std::string_view k_nativeLibName = "mod.so"sv; #elif defined(__APPLE__) #include #if TARGET_OS_IOS -static constexpr std::string_view k_nativeLibName = "ios-arm64.dylib"sv; +static constexpr std::string_view k_nativePlatform = "ios-arm64"sv; #elif TARGET_OS_TV -static constexpr std::string_view k_nativeLibName = "tvos-arm64.dylib"sv; +static constexpr std::string_view k_nativePlatform = "tvos-arm64"sv; #elif defined(__aarch64__) -static constexpr std::string_view k_nativeLibName = "darwin-arm64.dylib"sv; +static constexpr std::string_view k_nativePlatform = "darwin-arm64"sv; #elif defined(__x86_64__) -static constexpr std::string_view k_nativeLibName = "darwin-x86_64.dylib"sv; +static constexpr std::string_view k_nativePlatform = "darwin-x86_64"sv; #else -static constexpr std::string_view k_nativeLibName = ""sv; +static constexpr std::string_view k_nativePlatform = ""sv; #endif +static constexpr std::string_view k_nativeLibName = "mod.dylib"sv; #elif defined(__linux__) #if defined(__aarch64__) -static constexpr std::string_view k_nativeLibName = "linux-aarch64.so"sv; +static constexpr std::string_view k_nativePlatform = "linux-aarch64"sv; #elif defined(__x86_64__) -static constexpr std::string_view k_nativeLibName = "linux-x86_64.so"sv; +static constexpr std::string_view k_nativePlatform = "linux-x86_64"sv; #elif defined(__i386__) -static constexpr std::string_view k_nativeLibName = "linux-x86.so"sv; +static constexpr std::string_view k_nativePlatform = "linux-x86"sv; #else -static constexpr std::string_view k_nativeLibName = ""sv; +static constexpr std::string_view k_nativePlatform = ""sv; #endif +static constexpr std::string_view k_nativeLibName = "mod.so"sv; #else +static constexpr std::string_view k_nativePlatform = ""sv; static constexpr std::string_view k_nativeLibName = ""sv; #endif @@ -77,6 +82,23 @@ namespace dusk::mods { namespace { aurora::Module Log{"dusk::mods::loader"}; ModLoader g_modLoader; +constexpr std::string_view k_nativeLibDir = "lib/"sv; + +class DirectoryRollback { +public: + ~DirectoryRollback() { + if (!mPath.empty()) { + std::error_code ec; + std::filesystem::remove_all(mPath, ec); + } + } + + void set_path(std::filesystem::path path) { mPath = std::move(path); } + void release() { mPath.clear(); } + +private: + std::filesystem::path mPath; +}; std::unique_ptr load_bundle(const std::filesystem::path& modPath, bool fromDir) { if (fromDir) { @@ -87,24 +109,49 @@ std::unique_ptr load_bundle(const std::filesystem::path& modPath, boo } } -struct DllLocateResult { +struct NativeLocateResult { std::string entry; + std::vector runtimeEntries; bool anyLibs = false; }; -DllLocateResult locate_dll_in_bundle(ModBundle& bundle) { - DllLocateResult result; +NativeLocateResult locate_native_runtime(ModBundle& bundle) { + NativeLocateResult result; + const std::string platformPrefix = fmt::format("{}{}/", k_nativeLibDir, k_nativePlatform); + const std::string nativeEntry = platformPrefix + std::string{k_nativeLibName}; for (const auto& name : bundle.getFileNames()) { - if (name.find('/') != std::string::npos || - (!name.ends_with(".dll"sv) && !name.ends_with(".dylib"sv) && !name.ends_with(".so"sv))) - { + if (!name.starts_with(k_nativeLibDir)) { continue; } - result.anyLibs = true; - if (name == k_nativeLibName) { + + const std::string_view libPath{ + name.data() + k_nativeLibDir.size(), name.size() - k_nativeLibDir.size()}; + const auto platformEnd = libPath.find('/'); + if (platformEnd != std::string_view::npos) { + const auto entryName = libPath.substr(platformEnd + 1); + if (entryName.find('/') == std::string_view::npos && + (entryName == "mod.dll"sv || entryName == "mod.dylib"sv || entryName == "mod.so"sv)) + { + result.anyLibs = true; + } + } + + if (!k_nativePlatform.empty() && name.starts_with(platformPrefix)) { + const std::string_view relativeName{ + name.data() + platformPrefix.size(), name.size() - platformPrefix.size()}; + if (!is_safe_resource_path(relativeName)) { + continue; + } + result.runtimeEntries.push_back(name); + } + if (name == nativeEntry) { result.entry = name; } } + std::ranges::sort(result.runtimeEntries); + result.runtimeEntries.erase( + std::unique(result.runtimeEntries.begin(), result.runtimeEntries.end()), + result.runtimeEntries.end()); return result; } } // namespace @@ -390,7 +437,7 @@ static std::string native_status_message(const NativeModStatus status) { case NativeModStatus::BuildDisabled: return "Code mods are disabled on this Dusklight build"; case NativeModStatus::ModMissingPlatform: - return fmt::format("Mod not supported on this platform ({})", k_nativeLibName); + return fmt::format("Mod not supported on this platform ({})", k_nativePlatform); case NativeModStatus::ApiVersionMismatch: // TODO: differentiate whether mod or Dusklight is out of date return "Mod ABI version mismatch"; @@ -425,7 +472,8 @@ std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod) return path; } -void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { +void ModLoader::load_native( + LoadedMod& mod, const std::string& dllEntry, const std::vector& runtimeEntries) { if (!EnableCodeMods) { log::write(mod.metadata.id, LOG_LEVEL_ERROR, "Code mods are not available in this build"); mod.nativeStatus = NativeModStatus::BuildDisabled; @@ -435,10 +483,19 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { namespace fs = std::filesystem; const fs::path cacheDir = m_cacheDir / mod.metadata.id; + const fs::path scratchDir = cacheDir / "data"; std::error_code ec; - fs::create_directories(cacheDir, ec); + fs::create_directories(scratchDir, ec); + if (ec) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to create mod directory {}: {}", + io::fs_path_to_string(scratchDir), ec.message()); + return; + } + mod.dir = io::fs_path_to_string(fs::absolute(scratchDir)); fs::path libPath; + fs::path runtimeDir; + DirectoryRollback runtimeDirRollback; if (mod.inPlace) { if (!dllEntry.empty()) { libPath = fs::path(mod.modPath) / dllEntry; @@ -450,6 +507,7 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { mod.nativeStatus = NativeModStatus::ModMissingPlatform; return; } + runtimeDir = libPath.parent_path(); } else { if (dllEntry.empty()) { log::write(mod.metadata.id, LOG_LEVEL_ERROR, @@ -458,34 +516,63 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { return; } - // Generation-versioned filename: every dlopen gets a path it has never seen, so a reload - // always yields a fresh image with fresh statics even if the previous dlclose did not - // fully unmap the old one (TLS/ObjC pinning). The .cache dir is wiped on startup. - const fs::path dllCachePath = - cacheDir / fmt::format("{}.g{}{}", mod.metadata.id, ++mod.cacheGeneration, - io::fs_path_to_string(fs::path(dllEntry).extension())); - - std::vector dllData; - try { - dllData = mod.bundle->readFile(dllEntry); - } catch (const std::exception& e) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to extract {}", dllEntry); + // Every generation gets a new directory. The main module and all of its runtime + // libraries therefore have fresh paths and can coexist with a previous generation + // that is still unwinding after a reload. + runtimeDir = cacheDir / fmt::format("g{}", ++mod.cacheGeneration); + runtimeDirRollback.set_path(runtimeDir); + fs::create_directories(runtimeDir, ec); + if (ec) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "failed to create native runtime directory {}: {}", + io::fs_path_to_string(runtimeDir), ec.message()); return; } - { - std::ofstream out(dllCachePath, std::ios::binary | std::ios::out); - if (!out) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", - io::fs_path_to_string(dllCachePath)); + const std::string platformPrefix = fmt::format("{}{}/", k_nativeLibDir, k_nativePlatform); + for (const auto& entry : runtimeEntries) { + if (!entry.starts_with(platformPrefix)) { + continue; + } + const std::string_view relativeName{ + entry.data() + platformPrefix.size(), entry.size() - platformPrefix.size()}; + if (!is_safe_resource_path(relativeName)) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "unsafe native runtime path '{}'; skipping", entry); return; } - out.write(reinterpret_cast(dllData.data()), - static_cast(dllData.size())); + const fs::path outputPath = runtimeDir / fs::path{relativeName}; + fs::create_directories(outputPath.parent_path(), ec); + if (ec) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "failed to create directory for {}: {}", entry, ec.message()); + return; + } + + std::vector data; + try { + data = mod.bundle->readFile(entry); + } catch (const std::exception& e) { + log::write( + mod.metadata.id, LOG_LEVEL_ERROR, "failed to extract {}: {}", entry, e.what()); + return; + } + + std::ofstream out(outputPath, std::ios::binary | std::ios::out); + if (!out) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", entry); + return; + } + out.write(reinterpret_cast(data.data()), + static_cast(data.size())); + if (!out) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", entry); + return; + } } - libPath = dllCachePath; + libPath = runtimeDir / fs::path{dllEntry}.filename(); } auto nativeMod = std::make_unique(); @@ -522,10 +609,11 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { } *nativeMod->contextSymbol = mod.context.get(); - mod.dir = io::fs_path_to_string(fs::absolute(cacheDir)); mod.nativePath = io::fs_path_to_string(fs::absolute(libPath)); + mod.nativeDir = io::fs_path_to_string(fs::absolute(runtimeDir)); mod.native = std::move(nativeMod); mod.nativeStatus = NativeModStatus::Loaded; + runtimeDirRollback.release(); } void ModLoader::unload_native(LoadedMod& mod) { @@ -533,16 +621,17 @@ void ModLoader::unload_native(LoadedMod& mod) { return; } // Deferred dlclose: this mod's code may still be on the stack below the current tick - m_retiredNatives.push_back({std::move(mod.native), std::move(mod.nativePath)}); + m_retiredNatives.push_back({std::move(mod.native), std::move(mod.nativeDir)}); mod.nativePath.clear(); + mod.nativeDir.clear(); } void ModLoader::drain_retired_natives() { for (auto& retired : m_retiredNatives) { retired.native.reset(); - if (!retired.path.empty()) { + if (!retired.directory.empty()) { std::error_code ec; - std::filesystem::remove(retired.path, ec); + std::filesystem::remove_all(retired.directory, ec); } } m_retiredNatives.clear(); @@ -663,10 +752,10 @@ void ModLoader::try_load_mod( mod.cvarIsEnabled = std::make_unique>(mod_enabled_cvar_name(mod.metadata.id), true); - const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); + const auto [dllEntry, runtimeEntries, anyLibs] = locate_native_runtime(*mod.bundle); if (anyLibs || (mod.inPlace && !external_native_lib_path(mod).empty())) { mod.nativeStatus = NativeModStatus::Unknown; - load_native(mod, dllEntry); + load_native(mod, dllEntry, runtimeEntries); if (mod.nativeStatus != NativeModStatus::Loaded) { Log.error("Native mod '{}' failed to load, disabling", mod.metadata.id); fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); @@ -992,14 +1081,14 @@ bool ModLoader::ensure_native_loaded(LoadedMod& mod) { return true; } - const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); + const auto [dllEntry, runtimeEntries, anyLibs] = locate_native_runtime(*mod.bundle); if (!anyLibs && !(mod.inPlace && !external_native_lib_path(mod).empty())) { mod.nativeStatus = NativeModStatus::None; return true; } mod.nativeStatus = NativeModStatus::Unknown; - load_native(mod, dllEntry); + load_native(mod, dllEntry, runtimeEntries); if (mod.nativeStatus != NativeModStatus::Loaded) { fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); return false; @@ -1035,9 +1124,11 @@ bool ModLoader::reload_bundle(LoadedMod& mod) { mod.failureReason.clear(); ModManifestInfo newInfo; - if (const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); anyLibs) { + if (const auto [dllEntry, runtimeEntries, anyLibs] = locate_native_runtime(*mod.bundle); + anyLibs) + { mod.nativeStatus = NativeModStatus::Unknown; - load_native(mod, dllEntry); + load_native(mod, dllEntry, runtimeEntries); if (mod.nativeStatus != NativeModStatus::Loaded) { fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); return false; diff --git a/src/dusk/mods/loader/native_module.cpp b/src/dusk/mods/loader/native_module.cpp index edd3addb4f..f6adf20c60 100644 --- a/src/dusk/mods/loader/native_module.cpp +++ b/src/dusk/mods/loader/native_module.cpp @@ -17,7 +17,8 @@ namespace { #if defined(_WIN32) void* pl_dlopen(const std::filesystem::path& p) { - return LoadLibraryW(p.wstring().c_str()); + return LoadLibraryExW(p.wstring().c_str(), nullptr, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS); } void* pl_dlsym(void* h, const char* name) { return reinterpret_cast(GetProcAddress(static_cast(h), name)); @@ -55,11 +56,10 @@ std::string pl_dlerror() { return e ? e : "(unknown error)"; } #endif -} +} // namespace namespace dusk::mods::loader { -NativeModule::NativeModule() noexcept : handle(nullptr) { -} +NativeModule::NativeModule() noexcept : handle(nullptr) {} NativeModule::NativeModule(NativeModule&& other) noexcept { handle = other.handle; diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp index 0199a7865a..f5d409f797 100644 --- a/src/dusk/mods/svc/host.cpp +++ b/src/dusk/mods/svc/host.cpp @@ -63,6 +63,11 @@ const char* host_mod_dir(ModContext* context) { return mod != nullptr ? mod->dir.c_str() : ""; } +const char* host_native_dir(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->nativeDir.c_str() : ""; +} + struct LifecycleWatcher { ModLifecycleFn fn = nullptr; void* userData = nullptr; @@ -142,6 +147,7 @@ constinit HostService s_hostService{ .mod_dir = host_mod_dir, .watch_mod_lifecycle = host_watch_mod_lifecycle, .unwatch_mod_lifecycle = host_unwatch_mod_lifecycle, + .native_dir = host_native_dir, }; } // namespace