# add_mod( [FEATURES ...] 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") set(DUSKLIGHT_SDK_STUB_URL "https://github.com/encounter/dusklight/releases/download/sdk" CACHE STRING "Base URL for game link stubs downloaded by out-of-tree mod builds") 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) if (_count GREATER 1) message(FATAL_ERROR "add_mod: universal binaries are not supported") endif () set(_arch "${CMAKE_OSX_ARCHITECTURES}") elseif (WIN32) set(_arch_id "${CMAKE_CXX_COMPILER_ARCHITECTURE_ID}") if (NOT _arch_id) set(_arch_id "${CMAKE_C_COMPILER_ARCHITECTURE_ID}") endif () if (_arch_id MATCHES "^ARM64(EC)?$") set(_arch "arm64") elseif (_arch_id STREQUAL "x64") set(_arch "amd64") elseif (_arch_id STREQUAL "X86") set(_arch "x86") endif () endif () string(TOLOWER "${CMAKE_SYSTEM_NAME}" _platform) if (_platform STREQUAL "darwin") set(_platform "macos") endif () string(TOLOWER "${_arch}" _arch) if (_arch MATCHES "^(i[3-6]86|x86)$") set(_arch "x86") endif () if (WIN32) set(_ext ".dll") else () set(_ext ".so") endif () set(${out_platform_var} "${_platform}-${_arch}" PARENT_SCOPE) set(${out_name_var} "mod${_ext}" PARENT_SCOPE) endfunction() # For out-of-tree builds without a game binary: download the version-independent link stub # (generated by symgen) for the target platform. function(_mod_download_link_stub out_var) _mod_lib_info(_platform _lib_name) if (WIN32) set(_asset "${_platform}.lib") elseif (ANDROID) set(_asset "stub-${_platform}.so") else () set(_asset "stub-${_platform}") endif () set(_stub "${CMAKE_BINARY_DIR}/dusklight-sdk-stubs/${_asset}") if (NOT EXISTS "${_stub}") message(STATUS "Mod SDK: downloading link stub ${_asset}") file(DOWNLOAD "${DUSKLIGHT_SDK_STUB_URL}/${_asset}" "${_stub}.tmp" STATUS _status) list(GET _status 0 _code) if (NOT _code EQUAL 0) list(GET _status 1 _error) file(REMOVE "${_stub}.tmp") message(FATAL_ERROR "Mod SDK: failed to download ${DUSKLIGHT_SDK_STUB_URL}/${_asset}: ${_error}\n" "Set DUSK_GAME_EXE to a game binary or link stub to skip the download.") endif () file(RENAME "${_stub}.tmp" "${_stub}") endif () set(${out_var} "${_stub}" 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}/*") # Dotfiles (.gitkeep, .DS_Store, ...) are not packaged; see CopyModAssets.cmake. set(_assets "") foreach (_file IN LISTS _files) file(RELATIVE_PATH _rel "${dir}" "${_file}") if (NOT _rel MATCHES "(^|/)\\.") list(APPEND _assets "${_file}") endif () endforeach () set(${out_var} ${_assets} PARENT_SCOPE) endfunction() function(_mod_add_webgpu_headers target_name) if (NOT TARGET dawn::dawncpp_headers AND NOT TARGET dawn::webgpu_dawn) include("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../extern/aurora/cmake/AuroraDependencyVersions.cmake") set(AURORA_DAWN_PROVIDER "package" CACHE STRING "How to provide Dawn for the mod SDK") include("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../extern/aurora/cmake/AuroraDawnProvider.cmake") endif () if (TARGET dawn::dawncpp_headers) target_link_libraries(${target_name} PRIVATE dawn::dawncpp_headers) elseif (TARGET dawn::webgpu_dawn) target_link_libraries(${target_name} PRIVATE dawn::webgpu_dawn) else () message(FATAL_ERROR "add_mod: FEATURES webgpu could not provide WebGPU headers") endif () endfunction() function(add_mod target_name) cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OVERLAY_DIR;TEXTURES_DIR;OUTPUT_DIR" "SOURCES;RUNTIME_LIBRARIES;FEATURES" ${ARGN}) if (ARG_UNPARSED_ARGUMENTS) message(FATAL_ERROR "add_mod: unknown arguments: ${ARG_UNPARSED_ARGUMENTS}") endif () if (NOT ARG_MOD_JSON) message(FATAL_ERROR "add_mod: MOD_JSON is required") endif () _mod_resolve_source_path(_mod_json "${ARG_MOD_JSON}") if (NOT EXISTS "${_mod_json}") message(FATAL_ERROR "add_mod: MOD_JSON does not exist: ${_mod_json}") endif () set(_supported_features game webgpu) set(_features "") foreach (_feature IN LISTS ARG_FEATURES) list(FIND _supported_features "${_feature}" _feature_index) if (_feature_index EQUAL -1) list(JOIN _supported_features ", " _supported_features_text) message(FATAL_ERROR "add_mod: unknown feature '${_feature}' (supported: ${_supported_features_text})") endif () list(FIND _features "${_feature}" _duplicate_index) if (NOT _duplicate_index EQUAL -1) message(FATAL_ERROR "add_mod: duplicate feature '${_feature}'") endif () list(APPEND _features "${_feature}") endforeach () if (_features AND NOT ARG_SOURCES) message(FATAL_ERROR "add_mod: FEATURES requires SOURCES") endif () set(_has_lib FALSE) set(_needs_host_link FALSE) set(_lib_platform "") set(_lib_name "") if (ARG_SOURCES) set(_has_lib TRUE) add_library(${target_name} MODULE ${ARG_SOURCES}) _mod_lib_info(_lib_platform _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_mod_api) foreach (_feature IN LISTS _features) target_link_libraries(${target_name} PRIVATE dusklight_mod_feature_${_feature}) if (_feature STREQUAL "webgpu") _mod_add_webgpu_headers(${target_name}) endif () if (_feature STREQUAL "game" OR _feature STREQUAL "webgpu") set(_needs_host_link TRUE) endif () endforeach () if (NOT TARGET dusklight) # Apply global compile options for out-of-tree mod builds if (CMAKE_SYSTEM_NAME STREQUAL Linux) target_compile_options(${target_name} PRIVATE -Wno-multichar -Wno-trigraphs -Wno-deprecated-declarations) elseif (APPLE) target_compile_options(${target_name} PRIVATE -Wno-declaration-after-statement -Wno-non-pod-varargs) elseif (MSVC) target_compile_options(${target_name} PRIVATE "$<$:/bigobj>" "$<$:/utf-8>") endif () # Use signed char on ARM to match the original game (and x86) string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _mod_arch) if (_mod_arch MATCHES "^(arm|aarch64)" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "GNU") target_compile_options(${target_name} PRIVATE -fsigned-char) endif () endif () if (APPLE) if (_needs_host_link) if (TARGET dusklight) set(_game_exe "$") add_dependencies(${target_name} dusklight) elseif (DUSK_GAME_EXE) _mod_resolve_source_path(_game_exe "${DUSK_GAME_EXE}") else () _mod_download_link_stub(_game_exe) endif () target_link_options(${target_name} PRIVATE -Xlinker -bundle_loader -Xlinker "${_game_exe}") set_property(TARGET ${target_name} APPEND PROPERTY LINK_DEPENDS "${_game_exe}") endif () set_target_properties(${target_name} PROPERTIES BUILD_RPATH "@loader_path" INSTALL_RPATH "@loader_path") elseif (ANDROID) if (_needs_host_link) if (TARGET dusklight) target_link_libraries(${target_name} PRIVATE dusklight) else () if (DUSK_GAME_EXE) _mod_resolve_source_path(_game_lib "${DUSK_GAME_EXE}") else () _mod_download_link_stub(_game_lib) endif () target_link_libraries(${target_name} PRIVATE "${_game_lib}") endif () endif () set_target_properties(${target_name} PROPERTIES BUILD_RPATH "$ORIGIN" INSTALL_RPATH "$ORIGIN") elseif (UNIX) if (_needs_host_link) target_link_options(${target_name} PRIVATE -Wl,--allow-shlib-undefined) endif () set_target_properties(${target_name} PROPERTIES BUILD_RPATH "$ORIGIN" INSTALL_RPATH "$ORIGIN") elseif (WIN32) # Mods link against the game's import library (sdk/windows-.lib, generated by # setup_windows_exports); cl and clang-cl both consume it in MSVC mode. Function # calls resolve through import thunks; game data is reachable only through # __declspec(dllimport), i.e. DUSK_GAME_DATA-annotated declarations. if (_needs_host_link) if (TARGET dusklight) if (NOT DUSK_GAME_IMPLIB) message(FATAL_ERROR "add_mod: DUSK_GAME_IMPLIB is not set (see setup_windows_exports)") endif () set(_game_lib "${DUSK_GAME_IMPLIB}") elseif (DUSK_GAME_EXE) _mod_resolve_source_path(_game_lib "${DUSK_GAME_EXE}") if (NOT _game_lib MATCHES "\\.lib$") message(FATAL_ERROR "add_mod: DUSK_GAME_EXE must be an import library on Windows " "(sdk/windows-.lib)") endif () else () _mod_download_link_stub(_game_lib) endif () target_link_libraries(${target_name} PRIVATE "${_game_lib}") endif () set_target_properties(${target_name} PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreadedDLL") target_compile_definitions(${target_name} PRIVATE _ITERATOR_DEBUG_LEVEL=0) 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) 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 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) 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}") 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} "-DSRC=${_res_dir}" "-DDST=${_stage}/res" -P "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/CopyModAssets.cmake") 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} "-DSRC=${_overlay_dir}" "-DDST=${_stage}/overlay" -P "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/CopyModAssets.cmake") 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} "-DSRC=${_textures_dir}" "-DDST=${_stage}/textures" -P "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/CopyModAssets.cmake") 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_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" COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_out}" "${CMAKE_BINARY_DIR}/bundled_mods/${target_name}.dusk") endif () set(_package_target "${target_name}_package") set(_package_inputs_file "${CMAKE_CURRENT_BINARY_DIR}/${target_name}_package_inputs.txt") list(SORT _package_inputs) set(_package_inputs_text "") foreach (_package_input IN LISTS _package_inputs) string(APPEND _package_inputs_text "${_package_input}\n") endforeach () file(GENERATE OUTPUT "${_package_inputs_file}" CONTENT "${_package_inputs_text}") add_custom_command(OUTPUT "${_out}" COMMAND ${CMAKE_COMMAND} -E rm -rf "${_stage}" COMMAND ${CMAKE_COMMAND} -E make_directory "${_stage}" "${_output_dir}" ${_lib_copy_cmd} COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_mod_json}" "${_stage}/mod.json" ${_extra_cmds} COMMAND ${CMAKE_COMMAND} -E chdir "${_stage}" ${CMAKE_COMMAND} -E tar cvf "${_out}" --format=zip ${_zip_args} ${_bundle_cmds} DEPENDS ${_target_depend} ${_package_deps} "${_package_inputs_file}" COMMENT "Packaging ${target_name} -> ${_out}" COMMAND_EXPAND_LISTS VERBATIM ) add_custom_target(${_package_target} ALL DEPENDS "${_out}") if (TARGET dusklight_mods) add_dependencies(dusklight_mods ${_package_target}) endif () endfunction() # Install rules for BUNDLE mods. # - Windows: the .dusk archives into /mods (the loader extracts native libs to the # user cache). # - Linux: pre-extracted stage dirs into /mods so native libs dlopen in place from # read-only installs. # - macOS: pre-extracted stage dirs into the installed app's Contents/Resources/mods, native # libs ad-hoc signed in place, then the whole bundle re-signed. # - iOS/tvOS: assets into /mods/, the mod library into Frameworks/.so, # 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) 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_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") 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_platforms ${_i} _lib_platform) list(GET _lib_names ${_i} _lib_name) install(DIRECTORY "${_stage}/" DESTINATION "${_bundle_dir}/mods/${_id}" PATTERN "lib" EXCLUDE) install(PROGRAMS "$" DESTINATION "${_bundle_dir}/Frameworks" RENAME "${_id}.so") install(DIRECTORY "${_stage}/lib/${_lib_platform}/" DESTINATION "${_bundle_dir}/Frameworks" PATTERN "${_lib_name}" EXCLUDE) endforeach () if (DUSK_HAS_PREPATCH) set(_prepatch_executable "${_bundle_dir}/Dusklight") set(_prepatch_mod_args "") foreach (_id IN LISTS _ids) string(APPEND _prepatch_mod_args " \"${_bundle_dir}/Frameworks/${_id}.so\"") endforeach () install(CODE " execute_process( COMMAND \"${SYMGEN_EXE}\" prepatch --binary \"${_prepatch_executable}\" --report \"${CMAKE_BINARY_DIR}/prepatch-report-$.json\" ${_prepatch_mod_args} COMMAND_ERROR_IS_FATAL ANY)") endif () 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 " 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 (DUSK_HAS_PREPATCH) set(_prepatch_executable "${_bundle_dir}/Contents/MacOS/Dusklight") set(_prepatch_mod_args "") foreach (_i RANGE ${_last}) list(GET _ids ${_i} _id) list(GET _lib_platforms ${_i} _lib_platform) list(GET _lib_names ${_i} _lib_name) string(APPEND _prepatch_mod_args " \"${_bundle_dir}/Contents/Resources/mods/${_id}/lib/${_lib_platform}/${_lib_name}\"") endforeach () install(CODE " execute_process( COMMAND \"${SYMGEN_EXE}\" prepatch --binary \"${_prepatch_executable}\" --report \"${CMAKE_BINARY_DIR}/prepatch-report-$.json\" ${_prepatch_mod_args} COMMAND_ERROR_IS_FATAL ANY)") endif () if (TARGET crashpad_handler) install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - \"${_bundle_dir}/Contents/MacOS/$\" COMMAND_ERROR_IS_FATAL ANY)") endif () install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - --entitlements \"${DUSK_ENTITLEMENTS}\" \"${_bundle_dir}\" COMMAND_ERROR_IS_FATAL ANY)") endif () return () endif () if (DUSK_PACKAGE_INSTALL) set(_mods_dest "${CMAKE_INSTALL_DATAROOTDIR}/dusklight/mods") else () set(_mods_dest "${CMAKE_INSTALL_PREFIX}/mods") endif () if (WIN32) foreach (_target IN LISTS _targets) install(FILES "${CMAKE_BINARY_DIR}/bundled_mods/${_target}.dusk" DESTINATION "${_mods_dest}") endforeach () else () foreach (_i RANGE ${_last}) list(GET _ids ${_i} _id) list(GET _stages ${_i} _stage) install(DIRECTORY "${_stage}/" DESTINATION "${_mods_dest}/${_id}") endforeach () endif () endfunction()