feat: Implement mod loader

Co-authored-by: Aussiemon <mattrohrlach+github@gmail.com>
This commit is contained in:
Lucas Schwiderski 2023-02-22 09:55:27 +01:00
parent c2c2710a68
commit 41564b6578
Signed by: lucas
GPG key ID: AA12679AAA6DF4D8
2 changed files with 324 additions and 598 deletions

View file

@ -13,4 +13,20 @@ dofile("scripts/mods/dml/require")
dofile("scripts/mods/dml/class") dofile("scripts/mods/dml/class")
dofile("scripts/mods/dml/hook") dofile("scripts/mods/dml/hook")
function loader:init(boot_gui, mod_data)
local ModLoader = dofile("scripts/mods/dml/mod_loader")
local mod_loader = ModLoader:init(boot_gui, mod_data)
self._mod_loader = mod_loader
Mods.hook.set(StateGame, "update", function(func, dt, ...)
mod_loader:update(dt)
return func(dt, ...)
end)
end
function loader:update(dt)
self._mod_loader:update(dt)
end
return loader return loader

View file

@ -1,59 +1,48 @@
-- Copyright on this file is owned by Fatshark. -- Copyright on this file is owned by Fatshark.
-- It is extracted, used and modified with permission only for -- It is extracted, used and modified with permission only for
-- the purpose of loading mods within Warhammer 40,000: Darktide. -- the purpose of loading mods within Warhammer 40,000: Darktide.
require("scripts/managers/mod/mod_shim") local ModLoader = class("ModLoader")
ModManager = class(ModManager) local LOG_LEVELS = {
spew = 4,
info = 3,
warning = 2,
error = 1
}
local DEFAULT_SETTINGS = {
log_level = LOG_LEVELS.error,
developer_mode = false
}
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")
ModLoader.init = function(self, boot_gui, mod_data)
self._mod_data = mod_data
self._gui = boot_gui
self._settings = Application.user_setting("mod_settings") or DEFAULT_SETTINGS
ModManager.init = function (self, boot_gui)
self._mods = {} self._mods = {}
self._num_mods = nil self._num_mods = nil
self._state = "not_loaded"
self._settings = Application.user_setting("mod_settings") or {
toposort = false,
log_level = 1,
developer_mode = false
}
self._chat_print_buffer = {} self._chat_print_buffer = {}
self._reload_data = {} self._reload_data = {}
self._gui = boot_gui
self._ui_time = 0 self._ui_time = 0
self._network_callbacks = {}
local in_modded_realm = script_data["eac-untrusted"]
Crashify.print_property("realm", (in_modded_realm and "modded") or "official") if Crashify then
Crashify.print_property("modded", true)
if rawget(_G, "Presence") then
Presence.set_presence("status", (in_modded_realm and "Modded Realm") or "Official Realm")
end end
ModShim.start() self._state = "scanning"
local has_enabled_mods = self:_has_enabled_mods()
local is_bundled = Application.bundled()
printf("[ModManager] Mods enabled: %s // Bundled: %s", has_enabled_mods, is_bundled)
if has_enabled_mods and is_bundled then
print("[ModManager] Fetching mod metadata ...")
if in_modded_realm then
self._mod_metadata = {}
self._state = "fetching_metadata"
else
self:_fetch_mod_metadata()
end
else
self._state = "done"
self._num_mods = 0
end
end end
ModManager.developer_mode_enabled = function (self) ModLoader.developer_mode_enabled = function(self)
return self._settings.developer_mode return self._settings.developer_mode
end end
ModManager._draw_state_to_gui = function (self, gui, dt) ModLoader._draw_state_to_gui = function(self, gui, dt)
local state = self._state local state = self._state
local t = self._ui_time + dt local t = self._ui_time + dt
self._ui_time = t self._ui_time = t
@ -64,51 +53,29 @@ ModManager._draw_state_to_gui = function (self, gui, dt)
elseif state == "loading" then elseif state == "loading" then
local mod = self._mods[self._mod_load_index] local mod = self._mods[self._mod_load_index]
status_str = string.format("Loading mod %q", mod.name) status_str = string.format("Loading mod %q", mod.name)
elseif state == "fetching_metadata" then
status_str = "Fetching mod metadata"
end end
Gui.text(gui, status_str .. string.rep(".", (2 * t) % 4), "materials/fonts/arial", 16, nil, Vector3(5, 10, 1)) Gui.text(gui, status_str .. string.rep(".", (2 * t) % 4), "materials/fonts/arial", 16, nil, Vector3(5, 10, 1))
end end
ModManager.remove_gui = function (self) ModLoader.remove_gui = function(self)
assert(self._gui, "Trying to remove gui without setting gui first.")
self._gui = nil self._gui = nil
end end
ModManager._has_enabled_mods = function (self, in_modded_realm) ModLoader._check_reload = function()
local mod_settings = Application.user_setting("mods") return Keyboard.pressed(BUTTON_INDEX_R) and
Keyboard.button(BUTTON_INDEX_LEFT_SHIFT) +
if not mod_settings then Keyboard.button(BUTTON_INDEX_LEFT_CTRL) == 2
return false
end
for i = 1, #mod_settings, 1 do
if mod_settings[i].enabled then
return true
end
end
return false
end end
local Keyboard = Keyboard ModLoader.update = function(self, dt)
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")
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 chat_print_buffer = self._chat_print_buffer
local num_delayed_prints = #chat_print_buffer local num_delayed_prints = #chat_print_buffer
if num_delayed_prints > 0 and Managers.chat then if num_delayed_prints > 0 and Managers.chat then
for i = 1, num_delayed_prints, 1 do for i = 1, num_delayed_prints, 1 do
Managers.chat:add_local_system_message(1, chat_print_buffer[i], true) -- TODO: Use new chat system
-- Managers.chat:add_local_system_message(1, chat_print_buffer[i], true)
chat_print_buffer[i] = nil chat_print_buffer[i] = nil
end end
@ -128,18 +95,12 @@ ModManager.update = function (self, dt)
for i = 1, self._num_mods, 1 do for i = 1, self._num_mods, 1 do
local mod = self._mods[i] local mod = self._mods[i]
if mod and mod.enabled and not mod.callbacks_disabled then if mod and not mod.callbacks_disabled then
self:_run_callback(mod, "update", dt) self:_run_callback(mod, "update", dt)
end end
end end
elseif self._state == "fetching_metadata" then elseif self._state == "scanning" then
if self._mod_metadata then self:_build_mod_table()
self:_start_scan()
end
elseif self._state == "scanning" and not Mod.is_scanning() then
local mod_handles = Mod.mods()
self:_build_mod_table(mod_handles)
self._state = self:_load_mod(1) self._state = self:_load_mod(1)
self._ui_time = 0 self._ui_time = 0
@ -157,14 +118,11 @@ ModManager.update = function (self, dt)
mod.state = "running" mod.state = "running"
local ok, object = pcall(mod_data.run) local ok, object = pcall(mod_data.run)
if not ok then if not ok then self:print("error", "%s", object) end
self:print("error", "%s", object)
end
local name = mod.name local name = mod.name
mod.object = object or {} mod.object = object or {}
self:_run_callback(mod, "init", self._reload_data[mod.id])
self:print("info", "%s loaded.", name) self:print("info", "%s loaded.", name)
self._state = self:_load_mod(self._mod_load_index + 1) self._state = self:_load_mod(self._mod_load_index + 1)
@ -185,15 +143,23 @@ ModManager.update = function (self, dt)
end end
end end
ModManager.all_mods_loaded = function (self) ModLoader.all_mods_loaded = function(self)
return self._state == "done" return self._state == "done"
end end
ModManager.destroy = function (self) ModLoader.destroy = function(self)
for i = 1, self._num_mods, 1 do
local mod = self._mods[i]
if mod and not mod.callbacks_disabled then
self:_run_callback(mod, "on_destroy")
end
end
self:unload_all_mods() self:unload_all_mods()
end end
ModManager._run_callback = function (self, mod, callback_name, ...) ModLoader._run_callback = function (self, mod, callback_name, ...)
local object = mod.object local object = mod.object
local cb = object[callback_name] local cb = object[callback_name]
@ -213,179 +179,48 @@ ModManager._run_callback = function (self, mod, callback_name, ...)
end end
end end
ModManager._fetch_mod_metadata = function (self) ModLoader._start_scan = function(self)
local url = "http://cdn.fatsharkgames.se/mod_metadata.txt"
local headers = {
["User-Agent"] = "Warhammer: Vermintide 2"
}
Managers.curl:get(url, headers, callback(self, "_cb_mod_metadata"))
self._state = "fetching_metadata"
end
ModManager._cb_mod_metadata = function (self, success, return_code, headers, data, userdata)
printf("[ModManager] Metadata request completed. success=%s code=%s", success, return_code)
local mod_metadata = {}
if success and return_code >= 200 and return_code < 300 then
local line_number = 0
for line in string.gmatch(data, "[^\n\r]+") do
line_number = line_number + 1
line = string.gsub(line, "#(.*)$", "")
if line ~= "" then
local key, value = string.match(line, "(%d+)%s*=%s*(%w+)")
if key then
printf("[ModManager] Metadata set: [%s] = %s", key, value)
mod_metadata[key] = value
else
printf("[ModManager] Malformed metadata entry near line %d", line_number)
end
end
end
end
self._mod_metadata = mod_metadata
end
ModManager._start_scan = function (self)
self:print("info", "Starting mod scan") self:print("info", "Starting mod scan")
self._state = "scanning" self._state = "scanning"
Mod.start_scan(not script_data["eac-untrusted"])
end end
ModManager._build_mod_table = function (self, mod_handles) ModLoader._build_mod_table = function(self)
fassert(table.is_empty(self._mods), "Trying to add mods to non-empty mod table") fassert(table.is_empty(self._mods), "Trying to add mods to non-empty mod table")
local user_settings_mod_list = Application.user_setting("mods") or {} for i, mod_data in ipairs(self._mod_data) do
printf("[ModLoader] mods[%d] = id=%q | name=%q", i, mod_data.id, mod_data.name)
if self._settings.toposort then
user_settings_mod_list = self:_topologically_sorted(user_settings_mod_list)
end
table.dump(mod_handles, "mod_handles", 3)
local mod_metadata = self._mod_metadata
print("user_setting.mods =>")
for i, mod_data in ipairs(user_settings_mod_list) do
local id = mod_data.id or -9999
local handle = mod_handles[id]
local enabled = mod_data.enabled
if not handle then
self:print("warning", "Mod %q with id %d was not found in the workshop folder.", mod_data.name, id)
self:print("warning", "Did you try loading an unsanctioned mod in Official?")
enabled = false
end
local metadata = mod_metadata[id]
if enabled and metadata then
local last_updated_string = mod_data.last_updated
local month, day, year, hour, minute, second, am_pm = string.match(last_updated_string, "(%d+)/(%d+)/(%d+) (%d+):(%d+):(%d+) ([AP]M)")
if month then
if am_pm == "PM" then
hour = tonumber(hour) + 12
end
local last_updated = string.format("%04d%02d%02dT%02d%02d%02dZ", year, month, day, hour, minute, second)
printf("[ModManager] id=%s last_updated=%s metadata=%s", id, last_updated, metadata)
if last_updated < metadata then
enabled = false
end
else
printf("[ModManager] Could not parse date for %s", id)
enabled = false
end
end
self._mods[i] = { self._mods[i] = {
id = mod_data.id,
state = "not_loaded", state = "not_loaded",
callbacks_disabled = false, callbacks_disabled = false,
id = id,
name = mod_data.name, name = mod_data.name,
enabled = enabled, loaded_packages = {},
handle = handle, packages = mod_data.packages,
loaded_packages = {}
} }
end end
for i, mod_data in ipairs(user_settings_mod_list) do
printf("[ModManager] mods[%d] = (id=%d, name=%q, enabled=%q, last_updated=%q)", i, mod_data.id, mod_data.name, mod_data.enabled, mod_data.last_updated)
end
self._num_mods = #self._mods self._num_mods = #self._mods
self:print("info", "Found %i mods", #self._mods) self:print("info", "Found %i mods", #self._mods)
end end
ModManager._load_mod = function (self, index) ModLoader._load_mod = function(self, index)
self._ui_time = 0 self._ui_time = 0
local mods = self._mods local mods = self._mods
local mod = mods[index] local mod = mods[index]
while mod and not mod.enabled do
index = index + 1
mod = mods[index]
end
if not mod then if not mod then
table.clear(self._reload_data) table.clear(self._reload_data)
return "done" return "done"
end end
local id = mod.id self:print("info", "loading mod %i", mod.id)
local handle = mod.handle
self:print("info", "loading mod %s", id)
local info = Mod.info(handle)
self:print("spew", "<mod info>\n%s\n</mod info>", info)
Crashify.print_property("modded", true)
local chunk, err_msg = loadstring(info)
if not chunk then
self:print("error", "Syntax error in .mod file. Mod %q with id %d skipped.", mod.name, mod.id)
self:print("info", err_msg)
mod.enabled = false
return self:_load_mod(index + 1)
end
local ok, data_or_error = pcall(chunk)
if not ok then
self:print("error", "Error in .mod file return table. Mod %q with id %d skipped.", mod.name, mod.id)
self:print("info", data_or_error)
mod.enabled = false
return self:_load_mod(index + 1)
end
mod.data = data_or_error
mod.name = mod.name or data_or_error.NAME or "Mod " .. id
mod.state = "loading" mod.state = "loading"
Crashify.print_property(string.format("Mod:%s:%s", id, mod.name), true) Crashify.print_property(string.format("Mod:%i:%s", mod.id, mod.name), true)
self._mod_load_index = index self._mod_load_index = index
@ -394,9 +229,9 @@ ModManager._load_mod = function (self, index)
return "loading" return "loading"
end end
ModManager._load_package = function (self, mod, index) ModLoader._load_package = function(self, mod, index)
mod.package_index = index mod.package_index = index
local package_name = mod.data.packages[index] local package_name = mod.packages[index]
if not package_name then if not package_name then
return return
@ -404,7 +239,7 @@ ModManager._load_package = function (self, mod, index)
self:print("info", "loading package %q", package_name) self:print("info", "loading package %q", package_name)
local resource_handle = Mod.resource_package(mod.handle, package_name) local resource_handle = Application.resource_package(package_name)
self._loading_resource_handle = resource_handle self._loading_resource_handle = resource_handle
ResourcePackage.load(resource_handle) ResourcePackage.load(resource_handle)
@ -412,7 +247,7 @@ ModManager._load_package = function (self, mod, index)
mod.loaded_packages[#mod.loaded_packages + 1] = resource_handle mod.loaded_packages[#mod.loaded_packages + 1] = resource_handle
end end
ModManager.unload_all_mods = function (self) ModLoader.unload_all_mods = function(self)
if self._state ~= "done" then if self._state ~= "done" then
self:print("error", "Mods can't be unloaded, mod state is not \"done\". current: %q", self._state) self:print("error", "Mods can't be unloaded, mod state is not \"done\". current: %q", self._state)
@ -424,7 +259,7 @@ ModManager.unload_all_mods = function (self)
for i = self._num_mods, 1, -1 do for i = self._num_mods, 1, -1 do
local mod = self._mods[i] local mod = self._mods[i]
if mod and mod.enabled then if mod then
self:unload_mod(i) self:unload_mod(i)
end end
@ -435,15 +270,15 @@ ModManager.unload_all_mods = function (self)
self._state = "unloaded" self._state = "unloaded"
end end
ModManager.unload_mod = function (self, index) ModLoader.unload_mod = function(self, index)
local mod = self._mods[index] local mod = self._mods[index]
if mod then if mod then
self:print("info", "Unloading %q.", mod.name) self:print("info", "Unloading %q.", mod.name)
self:_run_callback(mod, "on_unload")
for _, handle in ipairs(mod.loaded_packages) do for _, handle in ipairs(mod.loaded_packages) do
Mod.release_resource_package(handle) ResourcePackage.unload(handle)
Application.release_resource_package(handle)
end end
mod.state = "not_loaded" mod.state = "not_loaded"
@ -452,7 +287,7 @@ ModManager.unload_mod = function (self, index)
end end
end end
ModManager._reload_mods = function (self) ModLoader._reload_mods = function(self)
self:print("info", "reloading mods") self:print("info", "reloading mods")
for i = 1, self._num_mods, 1 do for i = 1, self._num_mods, 1 do
@ -473,12 +308,12 @@ ModManager._reload_mods = function (self)
self._reload_requested = false self._reload_requested = false
end end
ModManager.on_game_state_changed = function (self, status, state_name, state_object) ModLoader.on_game_state_changed = function(self, status, state_name, state_object)
if self._state == "done" then if self._state == "done" then
for i = 1, self._num_mods, 1 do for i = 1, self._num_mods, 1 do
local mod = self._mods[i] local mod = self._mods[i]
if mod and mod.enabled and not mod.callbacks_disabled then if mod and not mod.callbacks_disabled then
self:_run_callback(mod, "on_game_state_changed", status, state_name, state_object) self:_run_callback(mod, "on_game_state_changed", status, state_name, state_object)
end end
end end
@ -487,67 +322,8 @@ ModManager.on_game_state_changed = function (self, status, state_name, state_obj
end end
end end
ModManager._topologically_sorted = function (self, mod_list) ModLoader.print = function(self, level, str, ...)
local visited = {} local message = string.format("[ModLoader][" .. level .. "] " .. str, ...)
local sorted = {}
for _, mod_data in ipairs(mod_list) do
if not visited[mod_data] then
self:_visit(mod_list, visited, sorted, mod_data)
end
end
return sorted
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
slot6 = 1
slot7 = mod_data.num_children or 0
for i = slot6, slot7, 1 do
local child_id = mod_data.children[j]
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", 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
local LOG_LEVELS = {
spew = 4,
info = 3,
warning = 2,
error = 1
}
ModManager.print = function (self, level, str, ...)
local message = string.format("[ModManager][" .. level .. "] " .. str, ...)
local log_level = LOG_LEVELS[level] or 99 local log_level = LOG_LEVELS[level] or 99
if log_level <= 2 then if log_level <= 2 then
@ -559,70 +335,4 @@ ModManager.print = function (self, level, str, ...)
end end
end end
ModManager.network_bind = function (self, port, callback) return ModLoader
local ncbs = self._network_callbacks
fassert(not ncbs[port], "Port %d already in use", port)
ncbs[port] = callback
end
ModManager.network_unbind = function (self, port)
local ncbs = self._network_callbacks
fassert(ncbs[port], "Port %d not in use", port)
ncbs[port] = nil
end
ModManager.network_is_occupied = function (self, port)
return self._network_callbacks[port] ~= nil
end
ModManager.network_send = function (self, destination_peer_id, port, payload)
if destination_peer_id == self._my_peer_id then
Managers.state.network.network_transmit:queue_local_rpc("rpc_mod_user_data", port, payload)
end
local channel_id = PEER_ID_TO_CHANNEL[(self._is_server and destination_peer_id) or self._host_peer_id]
if channel_id then
RPC.rpc_mod_user_data(channel_id, self._my_peer_id, destination_peer_id, port, payload)
end
end
ModManager.rpc_mod_user_data = function (self, relay_channel_id, source_peer_id, destination_peer_id, port, payload)
if destination_peer_id == self._my_peer_id then
local cb = self._network_callbacks[port]
if cb then
cb(source_peer_id, payload)
end
elseif self._is_server then
local channel_id = PEER_ID_TO_CHANNEL[destination_peer_id]
if channel_id then
RPC.rpc_mod_user_data(channel_id, source_peer_id, destination_peer_id, port, payload)
end
end
end
ModManager.register_network_event_delegate = function (self, network_event_delegate)
network_event_delegate:register(self, "rpc_mod_user_data")
self._network_event_delegate = network_event_delegate
end
ModManager.unregister_network_event_delegate = function (self)
self._network_event_delegate:unregister(self)
self._network_event_delegate = nil
end
ModManager.network_context_created = function (self, host_peer_id, my_peer_id, is_server)
self._host_peer_id = host_peer_id
self._my_peer_id = my_peer_id
self._is_server = is_server
end
return