commit d816cefae8c779be428c31fa97314695a5121806 Author: Aussiemon Date: Sun Feb 26 23:14:26 2023 -0700 Initialize repo from files diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f4fb26 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +Contains a small set of basic functionality required for loading other mods. It also handles initial setup and contains a mod_load_order.txt file for mod management. + +Installation: + 1. Copy the Darktide Mod Loader files to your game directory and overwrite existing. + 2. Run the "toggle_darktide_mods.bat" script in your game folder. + 3. Copy the Darktide Mod Framework files to your "mods" directory (/mods) and overwrite existing. + 3. Install other mods by downloading them from the Nexus site (https://www.nexusmods.com/warhammer40kdarktide) then adding them to "/mods/mod_load_order.txt" with a text editor. + +Disable mods: + * Disable individual mods by removing their name from your mods/mod_load_order.txt file. + * Run the "toggle_darktide_mods.bat" script at your game folder and choose to unpatch the bundle database to disable all mod loading. + +Uninstallation: + 1. Run the "toggle_darktide_mods.bat" script at your game folder and choose to unpatch the bundle database. + 2. Delete the mods and tools folders from your game directory. + 3. Delete the "mod_loader" file from /binaries. + 4. Delete the "9ba626afa44a3aa3.patch_999" file from /bundle. + +Mods will automatically disable when the game updates, so re-run the "toggle_darktide_mods.bat" script to re-enable mods after an update. + +This mod does not need to be added to your mod_load_order.txt file. \ No newline at end of file diff --git a/binaries/mod_loader b/binaries/mod_loader new file mode 100644 index 0000000..d9c7d65 --- /dev/null +++ b/binaries/mod_loader @@ -0,0 +1,470 @@ +local mod_directory = "./../mods" + +-- Mod initialization code -- +local debug = debug +local io = io +local ffi = ffi + +local assert = assert +local ipairs = ipairs +local loadstring = loadstring +local pairs = pairs +local pcall = pcall +local print = print +local rawget = rawget +local rawset = rawset +local select = select +local setmetatable = setmetatable +local string = string +local table = table +local tonumber = tonumber +local tostring = tostring + +Mods = { + file = {}, + message = {}, + lua = { + debug = debug, + io = io, + ffi = ffi, + loadstring = loadstring, + os = os + } +} + +local chat_sound = "wwise/events/ui/play_ui_click" + +local notify = function(message) + local event_manager = Managers and Managers.event + + if event_manager then + event_manager:trigger("event_add_notification_message", "default", message, nil, chat_sound) + end + + print(message) +end +Mods.message.notify = notify + +local echo = function(message, sender) + local chat_manager = Managers and Managers.chat + local event_manager = Managers and Managers.event + + if chat_manager and event_manager then + local message_obj = { + message_body = message, + is_current_user = false, + } + + local participant = { + displayname = sender or "SYSTEM", + } + + local message_sent = false + + local channel_handle, channel = next(chat_manager:connected_chat_channels()) + if channel then + event_manager:trigger("chat_manager_message_recieved", channel_handle, participant, message_obj) + message_sent = true + end + + if not message_sent then + notify(message) + return + end + end + + print(message) +end +Mods.message.echo = echo + +local get_file_path = function(local_path, file_name, file_extension) + local file_path = mod_directory + + if local_path and local_path ~= "" then + file_path = file_path .. "/" .. local_path + end + + if file_name and file_name ~= "" then + file_path = file_path .. "/" .. file_name + end + + if file_extension and file_extension ~= "" then + file_path = file_path .. "." .. file_extension + else + file_path = file_path .. ".lua" + end + + if string.find(file_path, "\\") then + file_path = string.gsub(file_path, "\\", "/") + end + + return file_path +end + +local function read_or_execute(file_path, args, return_type) + local f = io.open(file_path, "r") + + local result + if return_type == "lines" then + result = {} + for line in f:lines() do + if line then + -- Trim whitespace + line = line:gsub("^%s*(.-)%s*$", "%1") + + -- Handle empty lines and single-line comments + if line ~= "" and line:sub(1, 2) ~= "--" then + table.insert(result, line) + end + end + end + else + result = f:read("*all") + + -- Either execute the data or leave it unmodified + if return_type == "exec_result" or return_type == "exec_boolean" then + local func = loadstring(result, file_path) + result = func(args) + end + end + + f:close() + if return_type == "exec_boolean" then + return true + else + return result + end +end + +local function handle_io(local_path, file_name, file_extension, args, safe_call, return_type) + + local file_path = get_file_path(local_path, file_name, file_extension) + print("[Mod] Loading " .. file_path) + + -- Check for the existence of the path + local ff, err_io = io.open(file_path, "r") + if ff ~= nil then + ff:close() + + -- Initialize variables + local status, result + + -- If this is a safe call, wrap it in a pcall + if safe_call then + status, result = pcall(function () + return read_or_execute(file_path, args, return_type) + end) + + -- If status is failed, notify the user and return false + if not status then + notify("[Mod] Error processing '" .. file_path .. "': " .. tostring(result)) + return false + end + + -- If this isn't a safe call, load without a pcall + else + result = read_or_execute(file_path, args, return_type) + end + + return result + + -- If the initial open failed, report failure + else + notify("[Mod] Error opening '" .. file_path .. "': " .. tostring(err_io)) + return false + end +end + +local function exec(local_path, file_name, file_extension, args) + return handle_io(local_path, file_name, file_extension, args, true, "exec_boolean") +end +Mods.file.exec = exec + +local function exec_unsafe(local_path, file_name, file_extension, args) + return handle_io(local_path, file_name, file_extension, args, false, "exec_boolean") +end +Mods.file.exec_unsafe = exec_unsafe + +local function exec_with_return(local_path, file_name, file_extension, args) + return handle_io(local_path, file_name, file_extension, args, true, "exec_result") +end +Mods.file.exec_with_return = exec_with_return + +local function exec_unsafe_with_return(local_path, file_name, file_extension, args) + return handle_io(local_path, file_name, file_extension, args, false, "exec_result") +end +Mods.file.exec_unsafe_with_return = exec_unsafe_with_return + +local function mod_dofile(file_path, args) + return handle_io(file_path, nil, nil, args, true, "exec_result") +end +Mods.file.dofile = mod_dofile + +local function read_content(file_path, file_extension) + return handle_io(file_path, nil, file_extension, nil, true, "data") +end +Mods.file.read_content = read_content + +local function read_content_to_table(file_path, file_extension) + return handle_io(file_path, nil, file_extension, nil, true, "lines") +end +Mods.file.read_content_to_table = read_content_to_table + +local file_exists = function(name) + print(name) + local f = io.open(name,"r") + + if f ~= nil then + print("[Mod]: File exists") + io.close(f) + return true + else + print("[Mod]: File does not exist") + return false + end +end +Mods.file.exists = file_exists + +-- Load remaining base modules +exec("base/function", "require") + +local init_mod_framework = function() + + print("[DMF]: Initializing basic mod hook system...") + exec("base/function", "hook") + + -- The mod manager isn't in the bundles, so load our version from the mods folder + local ModManager = exec_with_return("base", "mod_manager") + + -- Initialize mods after loading managers and state_game files + Mods.hook.set("Base", "StateRequireScripts._require_scripts", function (req_func, ...) + local req_result = req_func(...) + + Managers.mod = Managers.mod or ModManager:new() + + -- Update mod manager + Mods.hook.set("Base", "StateGame.update", function (func, self, dt, ...) + Managers.mod:update(dt) + + return func(self, dt, ...) + end) + + -- Skip splash view + Mods.hook.set("Base", "StateSplash.on_enter", function (func, self, ...) + local result = func(self, ...) + + self._should_skip = true + self._continue = true + + return result + end) + + -- Trigger state change events + Mods.hook.set("Base", "GameStateMachine._change_state", function (func, self, ...) + local old_state = self._state + local old_state_name = old_state and self:current_state_name() + + if old_state_name then + Managers.mod:on_game_state_changed("exit", old_state_name, old_state) + end + + local result = func(self, ...) + + local new_state = self._state + local new_state_name = new_state and self:current_state_name() + + if new_state_name then + Managers.mod:on_game_state_changed("enter", new_state_name, new_state) + end + + return result + end) + + -- Trigger ending state change event + Mods.hook.set("Base", "GameStateMachine.destroy", function (func, self, ...) + local old_state = self._state + local old_state_name = old_state and self:current_state_name() + + if old_state_name then + Managers.mod:on_game_state_changed("exit", old_state_name) + end + + return func(self, ...) + end) + + return req_result + end) + + -- Set crashify modded property + Mods.hook.set("Base", "StateRequireScripts._get_is_modded", function () + return true + end) +end + +-- Original main script +Main = Main or {} + +require("scripts/boot_init") +require("scripts/foundation/utilities/class") + +-- Expose classes at the global table +exec("base/function", "class") + +require("scripts/foundation/utilities/patches") +require("scripts/foundation/utilities/settings") +require("scripts/foundation/utilities/table") + +local GameStateMachine = require("scripts/foundation/utilities/game_state_machine") +local LocalizationManager = require("scripts/managers/localization/localization_manager") +local PackageManager = require("scripts/foundation/managers/package/package_manager") +local PackageManagerEditor = require("scripts/foundation/managers/package/package_manager_editor") +local ParameterResolver = require("scripts/foundation/utilities/parameters/parameter_resolver") +local StateBoot = require("scripts/game_states/state_boot") +local StateLoadAudioSettings = require("scripts/game_states/boot/state_load_audio_settings") +local StateLoadBootAssets = require("scripts/game_states/boot/state_load_boot_assets") + +-- Unused right now, but might be useful in case we want to re-add the state at some point +local StateLoadMods = require("scripts/game_states/boot/state_load_mods") + +local StateLoadRenderSettings = require("scripts/game_states/boot/state_load_render_settings") +local StateRequireScripts = require("scripts/game_states/boot/state_require_scripts") + +Main.init = function (self) + Script.configure_garbage_collection(Script.ACCEPTABLE_GARBAGE, 0.1, Script.MAXIMUM_GARBAGE, 0.5, Script.FORCE_FULL_COLLECT_GARBAGE_LEVEL, 1, Script.MINIMUM_COLLECT_TIME_MS, 0.1, Script.MAXIMUM_COLLECT_TIME_MS, 0.5) + ParameterResolver.resolve_command_line() + ParameterResolver.resolve_game_parameters() + ParameterResolver.resolve_dev_parameters() + Application.set_time_step_policy("throttle", DEDICATED_SERVER and 1 / GameParameters.fixed_time_step or 30) + + if type(GameParameters.window_title) == "string" and GameParameters.window_title ~= "" then + Window.set_title(GameParameters.window_title) + end + + local package_manager = LEVEL_EDITOR_TEST and PackageManagerEditor:new() or PackageManager:new() + local localization_manager = LocalizationManager:new() + local params = { + next_state = "StateGame", + index_offset = 1, + states = { + { + StateLoadBootAssets, + { + package_manager = package_manager, + localization_manager = localization_manager + } + }, + { + StateRequireScripts, + { + package_manager = package_manager + } + }, + { + StateLoadAudioSettings, + {} + } + }, + package_manager = package_manager, + localization_manager = localization_manager + } + + if PLATFORM == "win32" and not LEVEL_EDITOR_TEST then + table.insert(params.states, 1, { + StateLoadRenderSettings, + {} + }) + end + + if LEVEL_EDITOR_TEST then + Wwise.load_bank("wwise/world_sound_fx") + end + + self._package_manager = package_manager + self._sm = GameStateMachine:new(nil, StateBoot, params) + + -- ####################### + -- ## Mod intialization ## + init_mod_framework() + -- ####################### +end + +Main.update = function (self, dt) + self._sm:update(dt) +end + +Main.render = function (self) + self._sm:render() +end + +Main.on_reload = function (self, refreshed_resources) + self._sm:on_reload(refreshed_resources) +end + +Main.on_close = function (self) + local should_close = self._sm:on_close() + + return should_close +end + +Main.shutdown = function (self) + Application.force_silent_exit_policy() + if rawget(_G, "Crashify") then + Crashify.print_property("shutdown", true) + end + + local owns_package_manager = true + + if rawget(_G, "Managers") and Managers.package then + Managers.package:shutdown_has_started() + + owns_package_manager = false + end + + self._sm:destroy() + + if owns_package_manager then + self._package_manager:delete() + end +end + +function init() + Main:init() +end + +function update(dt) + Main:update(dt) +end + +function render() + Main:render() +end + +function on_reload(refreshed_resources) + Main:on_reload(refreshed_resources) +end + +function on_activate(active) + print("LUA window => " .. (active and "ACTIVATED" or "DEACTIVATED")) + + if active and rawget(_G, "Managers") and Managers.dlc then + Managers.dlc:evaluate_consumables() + end +end + +function on_close() + local should_close = Main:on_close() + + if should_close then + Application.force_silent_exit_policy() + + if rawget(_G, "Crashify") then + Crashify.print_property("shutdown", true) + end + end + + return should_close +end + +function shutdown() + Main:shutdown() +end diff --git a/bundle/9ba626afa44a3aa3.patch_999 b/bundle/9ba626afa44a3aa3.patch_999 new file mode 100644 index 0000000..a0e417c Binary files /dev/null and b/bundle/9ba626afa44a3aa3.patch_999 differ diff --git a/mods/base/function/class.lua b/mods/base/function/class.lua new file mode 100644 index 0000000..19b0c91 --- /dev/null +++ b/mods/base/function/class.lua @@ -0,0 +1,22 @@ +Mods.original_class = Mods.original_class or class + +local _G = _G +local rawget = rawget +local rawset = rawset + +_G.CLASS = _G.CLASS or setmetatable({}, { + __index = function(_, key) + return key + end +}) + +class = function(class_name, super_name, ...) + local result = Mods.original_class(class_name, super_name, ...) + if not rawget(_G, class_name) then + rawset(_G, class_name, result) + end + if not rawget(_G.CLASS, class_name) then + rawset(_G.CLASS, class_name, result) + end + return result +end \ No newline at end of file diff --git a/mods/base/function/hook.lua b/mods/base/function/hook.lua new file mode 100644 index 0000000..b4b6b61 --- /dev/null +++ b/mods/base/function/hook.lua @@ -0,0 +1,276 @@ +--[[ + Mods Hook v2: + New version with better control +--]] + +-- Hook structure +MODS_HOOKS = MODS_HOOKS or {} +MODS_HOOKS_BY_FILE = MODS_HOOKS_BY_FILE or {} + +local _loadstring = Mods.lua.loadstring + +local item_template = { + name = "", + func = EMPTY_FUNC, + hooks = {}, +} + +local item_hook_template = { + name = "", + func = EMPTY_FUNC, + enable = false, + exec = EMPTY_FUNC, +} +local Log = Log + +local print_log_info = function(mod_name, message) + Log = Log or rawget(_G, "Log") + if Log then + Log._info(mod_name, message) + else + print("[" .. mod_name .. "]: " .. message) + end +end + +local print_log_warning = function(mod_name, message) + Log = Log or rawget(_G, "Log") + if Log then + Log._warning(mod_name, message) + else + print("[" .. mod_name .. "]: " .. message) + end +end + +Mods.hook = { + -- + -- Set hook + -- + set = function(mod_name, func_name, hook_func) + local item = Mods.hook._get_item(func_name) + local item_hook = Mods.hook._get_item_hook(item, mod_name) + + print_log_info(mod_name, "Hooking " .. func_name) + + item_hook.enable = true + item_hook.func = hook_func + + Mods.hook._patch() + end, + + -- + -- Set hook on every instance of the given file + -- + set_on_file = function(mod_name, filepath, func_name, hook_func) + -- Add hook create function to list for the file + MODS_HOOKS_BY_FILE[filepath] = MODS_HOOKS_BY_FILE[filepath] or {} + local hook_create_func = function(this_filepath, this_index) + local dynamic_func_name = "Mods.require_store[\"" .. this_filepath .. "\"][" .. tostring(this_index) .. "]." .. func_name + Mods.hook.set(mod_name, dynamic_func_name, hook_func, false) + end + table.insert(MODS_HOOKS_BY_FILE[filepath], hook_create_func) + + -- Add the new hook to every instance of the file + local all_file_instances = Mods.require_store[filepath] + if all_file_instances then + for i, item in ipairs(all_file_instances) do + if item then + hook_create_func(filepath, i) + end + end + end + end, + + -- + -- Enable/Disable hook + -- + enable = function(value, mod_name, func_name) + for _, item in ipairs(MODS_HOOKS) do + if item.name == func_name or func_name == nil then + for _, hook in ipairs(item.hooks) do + if hook.name == mod_name then + hook.enable = value + Mods.hook._patch() + end + end + end + end + + return + end, + + -- + -- Enable all hooks on a stored file + -- + enable_by_file = function(filepath, store_index) + local all_file_instances = Mods.require_store[filepath] + local file_instance = all_file_instances and all_file_instances[store_index] + + local all_file_hooks = MODS_HOOKS_BY_FILE[filepath] + + if all_file_hooks and file_instance then + for i, hook_create_func in ipairs(all_file_hooks) do + hook_create_func(filepath, store_index) + end + end + end, + + -- + -- Remove hook from chain + -- + ["remove"] = function(func_name, mod_name) + for i, item in ipairs(MODS_HOOKS) do + if item.name == func_name then + if mod_name ~= nil then + for j, hook in ipairs(item.hooks) do + if hook.name == mod_name then + table.remove(item.hooks, j) + + Mods.hook._patch() + end + end + else + local item_name = "MODS_HOOKS[" .. tostring(i) .. "]" + + -- Restore orginal function + assert(_loadstring(item.name .. " = " .. item_name .. ".func"))() + + -- Remove hook function + table.remove(MODS_HOOKS, i) + + return + end + end + end + + return + end, + + -- + -- Move hook to front of the hook chain + -- + front = function(mod_name, func_name) + for _, item in ipairs(MODS_HOOKS) do + if item.name == func_name or func_name == nil then + for i, hook in ipairs(item.hooks) do + if hook.name == mod_name then + local saved_hook = table.clone(hook) + table.remove(item.hooks, i) + table.insert(item.hooks, saved_hook) + + Mods.hook._patch() + end + end + end + end + + return + end, + + -- + -- Get function by function name + -- + _get_func = function(func_name) + return assert(_loadstring("return " .. func_name))() + end, + + -- + -- Get item by function name + -- + _get_item = function(func_name) + -- Find existing item + for _, item in ipairs(MODS_HOOKS) do + if item.name == func_name then + return item + end + end + + -- Create new item + local item = table.clone(item_template) + item.name = func_name + item.func = Mods.hook._get_func(func_name) + + -- Save + table.insert(MODS_HOOKS, item) + + return item + end, + + -- + -- Get item hook by mod name + -- + _get_item_hook = function(item, mod_name) + -- Find existing item + for _, hook in ipairs(item.hooks) do + if hook.name == mod_name then + return hook + end + end + + -- Create new item + local item_hook = table.clone(item_hook_template) + item_hook.name = mod_name + + -- Save + table.insert(item.hooks, 1, item_hook) + + return item_hook + end, + + -- + -- If settings are changed the hook itself needs to be updated + -- + _patch = function(mods_hook_item) + for i, item in ipairs(MODS_HOOKS) do + local item_name = "MODS_HOOKS[" .. i .. "]" + + local last_j = 1 + for j, hook in ipairs(item.hooks) do + local hook_name = item_name .. ".hooks[" .. j .. "]" + local before_hook_name = item_name .. ".hooks[" .. (j - 1) .. "]" + + if j == 1 then + if hook.enable then + assert( + _loadstring( + hook_name .. ".exec = function(...)" .. + " return " .. hook_name .. ".func(" .. item_name .. ".func, ...)" .. + "end" + ) + )() + else + assert( + _loadstring( + hook_name .. ".exec = function(...)" .. + " return " .. item_name .. ".func(...)" .. + "end" + ) + )() + end + else + if hook.enable then + assert( + _loadstring( + hook_name .. ".exec = function(...)" .. + " return " .. hook_name .. ".func(" .. before_hook_name .. ".exec, ...)" .. + "end" + ) + )() + else + assert( + _loadstring( + hook_name .. ".exec = function(...)" .. + " return " .. before_hook_name .. ".exec(...)" .. + "end" + ) + )() + end + end + + last_j = j + end + + -- Patch orginal function call + assert(_loadstring(item.name .. " = " .. item_name .. ".hooks[" .. last_j .. "].exec"))() + end + end, +} diff --git a/mods/base/function/require.lua b/mods/base/function/require.lua new file mode 100644 index 0000000..6e876f7 --- /dev/null +++ b/mods/base/function/require.lua @@ -0,0 +1,35 @@ +Mods.require_store = Mods.require_store or {} +Mods.original_require = Mods.original_require or require + +local can_insert = function(filepath, new_result) + local store = Mods.require_store[filepath] + if not store or #store == 0 then + return true + end + + if store[#store] ~= new_result then + return true + end +end + +require = function(filepath, ...) + local Mods = Mods + + local result = Mods.original_require(filepath, ...) + if result and type(result) == "table" then + + if can_insert(filepath, result) then + Mods.require_store[filepath] = Mods.require_store[filepath] or {} + local store = Mods.require_store[filepath] + + table.insert(store, result) + + --print("[Require] #" .. tostring(#store) .. " of " .. filepath) + if Mods.hook then + Mods.hook.enable_by_file(filepath, #store) + end + end + end + + return result +end \ No newline at end of file diff --git a/mods/base/mod_manager.lua b/mods/base/mod_manager.lua new file mode 100644 index 0000000..0814072 --- /dev/null +++ b/mods/base/mod_manager.lua @@ -0,0 +1,417 @@ +local ModManager = class("ModManager") + +local Keyboard = Keyboard +local BUTTON_INDEX_R = Keyboard.button_index("r") +local BUTTON_INDEX_LEFT_SHIFT = Keyboard.button_index("left shift") +local BUTTON_INDEX_LEFT_CTRL = Keyboard.button_index("left ctrl") + +local LOG_LEVELS = { + spew = 4, + info = 3, + warning = 2, + error = 1 +} + +local string_format = string.format +local function printf(f, ...) + print(string.format(f, ...)) +end + +local function deepcopy(orig) + local orig_type = type(orig) + local copy + if orig_type == 'table' then + copy = {} + for orig_key, orig_value in next, orig, nil do + copy[deepcopy(orig_key)] = deepcopy(orig_value) + end + setmetatable(copy, deepcopy(getmetatable(orig))) + else -- number, string, boolean, etc + copy = orig + end + return copy +end + +-- Clone the global mod file library to a local table +local _io = deepcopy(Mods.file) + +ModManager.init = function (self, boot_gui) + self._mods = {} + self._num_mods = nil + self._state = "not_loaded" + self._settings = Application.user_setting("mod_manager_settings") or { + log_level = 1, + developer_mode = false + } + self._chat_print_buffer = {} + self._reload_data = {} + self._gui = boot_gui + self._ui_time = 0 + self._network_callbacks = {} + + Crashify.print_property("realm", "modded") + + print("[ModManager] Starting mod manager...") + + self._mod_metadata = {} + self._state = "scanning" +end + +ModManager.developer_mode_enabled = function (self) + return self._settings.developer_mode +end + +ModManager._draw_state_to_gui = function (self, gui, dt) + local state = self._state + local t = self._ui_time + dt + self._ui_time = t + local status_str = "Loading mods" + + if state == "scanning" then + status_str = "Scanning for mods" + elseif state == "loading" then + local mod = self._mods[self._mod_load_index] + status_str = string.format("Loading mod %q", mod.name) + end + + --Gui.text(gui, status_str .. string.rep(".", (2 * t) % 4), "materials/fonts/arial", 16, nil, Vector3(5, 10, 1)) +end + +ModManager.remove_gui = function (self) + self._gui = nil +end + +ModManager._has_enabled_mods = function (self) + return true +end + +ModManager._check_reload = function (self) + return Keyboard.pressed(BUTTON_INDEX_R) and + Keyboard.button(BUTTON_INDEX_LEFT_SHIFT) + Keyboard.button(BUTTON_INDEX_LEFT_CTRL) == 2 +end + +ModManager.update = function (self, dt) + local chat_print_buffer = self._chat_print_buffer + local num_delayed_prints = #chat_print_buffer + + if num_delayed_prints > 0 and Managers.chat then + for i = 1, num_delayed_prints, 1 do + Mods.message.echo(chat_print_buffer[i]) + + chat_print_buffer[i] = nil + end + end + + local old_state = self._state + + if self._settings.developer_mode and self:_check_reload() then + self._reload_requested = true + end + + if self._reload_requested and self._state == "done" then + self:_reload_mods() + end + + if self._state == "done" then + if self._num_mods then + for i = 1, self._num_mods, 1 do + local mod = self._mods[i] + + if mod and mod.enabled and not mod.callbacks_disabled then + self:_run_callback(mod, "update", dt) + end + end + end + + elseif self._state == "scanning" then + self:_build_mod_table() + self._state = self:_load_mod(1) + self._ui_time = 0 + + elseif self._state == "loading" then + local mod = self._mods[self._mod_load_index] + local mod_data = mod.data + + mod.state = "running" + local ok, object = pcall(mod_data.run) + + if not ok then + self:print("error", "%s", object) + end + + local name = mod.name + mod.object = object or {} + + self:_run_callback(mod, "init", self._reload_data[mod.id]) + self:print("info", "%s loaded.", name) + + self._state = self:_load_mod(self._mod_load_index + 1) + end + + local gui = self._gui + + if gui then + self:_draw_state_to_gui(gui, dt) + end + + if old_state ~= self._state then + self:print("info", "%s -> %s", old_state, self._state) + end +end + +ModManager.all_mods_loaded = function (self) + return self._state == "done" +end + +ModManager.destroy = function (self) + self:unload_all_mods() +end + +ModManager._run_callback = function (self, mod, callback_name, ...) + local object = mod.object + local cb = object[callback_name] + + if not cb then + return + end + + local success, val = pcall(cb, object, ...) + + if success then + return val + else + self:print("error", "%s", val or "[unknown error]") + self:print("error", "Failed to run callback %q for mod %q with id %d. Disabling callbacks until reload.", + callback_name, mod.name, mod.id) + + mod.callbacks_disabled = true + end +end + +ModManager._build_mod_table = function (self) + fassert(table.is_empty(self._mods), "Trying to add mods to non-empty mod table") + + -- Get the mods' load order from mod_load_order file + local mod_load_order = _io.read_content_to_table("mod_load_order", "txt") + if not mod_load_order then + print("ERROR executing mod_load_order: " .. tostring(mod_load_order)) + mod_load_order = {} + end + + -- Add DMF to the mod load order + table.insert(mod_load_order, 1, "dmf") + + -- Read the .mod files of given mods and, if everything's fine, add mods' entries to the mods list. + for i = 1, #mod_load_order do + local mod_name = mod_load_order[i] + self._mods[i] = { + state = "not_loaded", + callbacks_disabled = false, + id = i, + name = mod_name, + enabled = true, + handle = mod_name, + loaded_packages = {} + } + end + + self._num_mods = #self._mods +end + +ModManager._load_mod = function (self, index) + self._ui_time = 0 + local mods = self._mods + local mod = mods[index] + + while mod and not mod.enabled do + index = index + 1 + mod = mods[index] + end + + if not mod then + table.clear(self._reload_data) + return "done" + end + + local id = mod.id + local mod_name = mod.name + + self:print("info", "loading mod %s", id) + Crashify.print_property("modded", true) + + local mod_data = _io.exec_with_return(mod_name, mod_name, "mod") + + if not mod_data then + self:print("error", "Mod file is invalid or missing. Mod %q with id %d skipped.", mod.name, mod.id) + + mod.enabled = false + + return self:_load_mod(index + 1) + end + self:print("spew", "\n%s\n", mod_data) + + mod.data = mod_data + mod.name = mod.name or mod_data.NAME or "Mod " .. mod.id + mod.state = "loading" + + Crashify.print_property(string.format("Mod:%s:%s", id, mod.name), true) + + self._mod_load_index = index + + return "loading" +end + +ModManager.unload_all_mods = function (self) + if self._state ~= "done" then + self:print("error", "Mods can't be unloaded, mod state is not \"done\". current: %q", self._state) + + return + end + + self:print("info", "Unload all mod packages") + + for i = self._num_mods, 1, -1 do + local mod = self._mods[i] + + if mod and mod.enabled then + self:unload_mod(i) + end + + self._mods[i] = nil + end + + self._num_mods = nil + self._state = "unloaded" +end + +ModManager.unload_mod = function (self, index) + local mod = self._mods[index] + + if mod then + self:print("info", "Unloading %q.", mod.name) + self:_run_callback(mod, "on_unload") + + mod.state = "not_loaded" + else + self:print("error", "Mod index %i can't be unloaded, has not been loaded", index) + end +end + +ModManager._reload_mods = function (self) + for i = 1, self._num_mods, 1 do + local mod = self._mods[i] + + if mod and mod.state == "running" then + self:print("info", "reloading %s", mod.name) + + self._reload_data[mod.id] = self:_run_callback(mod, "on_reload") + else + self:print("info", "not reloading mod, state: %s", mod.state) + end + end + + self:unload_all_mods() + self._state = "scanning" + self._reload_requested = false + + Mods.message.notify("Mods reloaded.") +end + +ModManager.on_game_state_changed = function (self, status, state_name, state_object) + if self._state == "done" then + for i = 1, self._num_mods, 1 do + local mod = self._mods[i] + + if mod and mod.enabled and not mod.callbacks_disabled then + self:_run_callback(mod, "on_game_state_changed", status, state_name, state_object) + end + end + else + self:print("warning", "Ignored on_game_state_changed call due to being in state %q", self._state) + end +end + +ModManager._visit = function (self, mod_list, visited, sorted, mod_data) + self:print("debug", "Visiting mod %q with id %d", mod_data.name, mod_data.id) + + if visited[mod_data] then + return mod_data.enabled + end + + if visited[mod_data] ~= nil then + self:print("error", "Dependency cycle detected at mod %q with id %d", mod_data.name, mod_data.id) + + return false + end + + visited[mod_data] = false + local enabled = mod_data.enabled or false + + for i = 1, mod_data.num_children or 0 do + local child_id = mod_data.children[i] + local child_index = table.find_by_key(mod_list, "id", child_id) + local child_mod_data = mod_list[child_index] + + if not child_mod_data then + self:print("warning", "Mod with id %d not found", child_id) + elseif not self:_visit(mod_list, visited, sorted, child_mod_data) and enabled then + self:print("warning", "Disabled mod %q with id %d due to missing dependency %d.", + mod_data.name, mod_data.id, child_id) + + enabled = false + end + end + + mod_data.enabled = enabled + visited[mod_data] = true + sorted[#sorted + 1] = mod_data + + return enabled +end + +ModManager.print = function (self, level, str, ...) + local message = string.format("[ModManager][" .. level .. "] " .. tostring(str), ...) + local log_level = LOG_LEVELS[level] or 99 + + if log_level <= 2 then + print(message) + end + + if log_level <= self._settings.log_level then + self._chat_print_buffer[#self._chat_print_buffer + 1] = message + end +end + +ModManager.network_bind = function (self) + return +end + +ModManager.network_unbind = function (self) + return +end + +ModManager.network_is_occupied = function (self) + return false +end + +ModManager.network_send = function (self) + return +end + +ModManager.rpc_mod_user_data = function (self) + return +end + +ModManager.register_network_event_delegate = function (self) + return +end + +ModManager.unregister_network_event_delegate = function (self) + return +end + +ModManager.network_context_created = function (self) + return +end + +return ModManager diff --git a/mods/mod_load_order.txt b/mods/mod_load_order.txt new file mode 100644 index 0000000..086a2e7 --- /dev/null +++ b/mods/mod_load_order.txt @@ -0,0 +1,6 @@ +-- ################################################################ +-- Enter user mod names below, separated by line. +-- Order in the list determines the order in which mods are loaded. +-- Do not rename a mod's folders. +-- You do not need to include 'base' or 'dmf' mod folders. +-- ################################################################ diff --git a/toggle_darktide_mods.bat b/toggle_darktide_mods.bat new file mode 100644 index 0000000..abfed86 --- /dev/null +++ b/toggle_darktide_mods.bat @@ -0,0 +1,10 @@ +@echo off +echo Starting Darktide patcher... +.\tools\dtkit-patch --toggle .\bundle +if errorlevel 1 goto failure +pause +exit +:failure +echo Error patching the Darktide bundle database. See logs. +pause +exit \ No newline at end of file diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..bb184d4 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,5 @@ +# dtkit-patch + +Simple tool based on Aussiemon's nodejs script for patching `bundle_database.data` to load Darktide mods. + +https://github.com/ManShanko/dtkit-patch \ No newline at end of file diff --git a/tools/dtkit-patch.exe b/tools/dtkit-patch.exe new file mode 100644 index 0000000..2306a5c Binary files /dev/null and b/tools/dtkit-patch.exe differ