-
Notifications
You must be signed in to change notification settings - Fork 27
/
mod_loader.gd
424 lines (320 loc) · 16.3 KB
/
mod_loader.gd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# ModLoader - A mod loader for GDScript
#
# Written in 2021 by harrygiel <harrygiel@gmail.com>,
# in 2021 by Mariusz Chwalba <mariusz@chwalba.net>,
# in 2022 by Vladimir Panteleev <git@cy.md>,
# in 2023 by KANA <kai@kana.jetzt>,
# in 2023 by Darkly77,
# in 2023 by otDan <otdanofficial@gmail.com>,
# in 2023 by Qubus0/Ste
#
# To the extent possible under law, the author(s) have
# dedicated all copyright and related and neighboring
# rights to this software to the public domain worldwide.
# This software is distributed without any warranty.
#
# You should have received a copy of the CC0 Public
# Domain Dedication along with this software. If not, see
# <http://creativecommons.org/publicdomain/zero/1.0/>.
extends Node
signal logged(entry)
signal current_config_changed(config)
# Prefix for this file when using mod_log or dev_log
const LOG_NAME := "ModLoader"
# --- DEPRECATED ---
# UNPACKED_DIR was moved to ModLoaderStore.
# However, many mods use this const directly, which is why the deprecation warning was added.
var UNPACKED_DIR := "res://mods-unpacked/" setget ,deprecated_direct_access_UNPACKED_DIR
# mod_data was moved to ModLoaderStore.
# However, many mods use this const directly, which is why the deprecation warning was added.
var mod_data := {} setget , deprecated_direct_access_mod_data
# Main
# =============================================================================
func _init() -> void:
# Ensure the ModLoaderStore and ModLoader autoloads are in the correct position.
_check_autoload_positions()
# if mods are not enabled - don't load mods
if ModLoaderStore.REQUIRE_CMD_LINE and not _ModLoaderCLI.is_running_with_command_line_arg("--enable-mods"):
return
# Rotate the log files once on startup. Can't be checked in utils, since it's static
ModLoaderLog._rotate_log_file()
# Log the autoloads order. Helpful when providing support to players
ModLoaderLog.debug_json_print("Autoload order", _ModLoaderGodot.get_autoload_array(), LOG_NAME)
# Log game install dir
ModLoaderLog.info("game_install_directory: %s" % _ModLoaderPath.get_local_folder_dir(), LOG_NAME)
if not ModLoaderStore.ml_options.enable_mods:
ModLoaderLog.info("Mods are currently disabled", LOG_NAME)
return
# Load user profiles into ModLoaderStore
var _success_user_profile_load := ModLoaderUserProfile._load()
_load_mods()
ModLoaderStore.is_initializing = false
func _ready():
# Create the default user profile if it doesn't exist already
# This should always be present unless the JSON file was manually edited
if not ModLoaderStore.user_profiles.has("default"):
var _success_user_profile_create := ModLoaderUserProfile.create_profile("default")
# Update the mod_list for each user profile
var _success_update_mod_lists := ModLoaderUserProfile._update_mod_lists()
func _exit_tree() -> void:
# Save the cache stored in ModLoaderStore to the cache file.
_ModLoaderCache.save_to_file()
func _load_mods() -> void:
# Loop over "res://mods" and add any mod zips to the unpacked virtual
# directory (UNPACKED_DIR)
var zip_data := _load_mod_zips()
if zip_data.empty():
ModLoaderLog.info("No zipped mods found", LOG_NAME)
else:
ModLoaderLog.success("DONE: Loaded %s mod files into the virtual filesystem" % zip_data.size(), LOG_NAME)
# Initializes the mod_data dictionary if zipped mods are loaded.
# If mods are unpacked in the "mods-unpacked" directory,
# mod_data is initialized in the _setup_mods() function.
for mod_id in zip_data.keys():
var zip_path: String = zip_data[mod_id]
_init_mod_data(mod_id, zip_path)
# Loop over UNPACKED_DIR. This triggers _init_mod_data for each mod
# directory, which adds their data to mod_data.
var setup_mods := _setup_mods()
if setup_mods > 0:
ModLoaderLog.success("DONE: Setup %s mods" % setup_mods, LOG_NAME)
else:
ModLoaderLog.info("No mods were setup", LOG_NAME)
# Update active state of mods based on the current user profile
ModLoaderUserProfile._update_disabled_mods()
# Loop over all loaded mods via their entry in mod_data. Verify that they
# have all the required files (REQUIRED_MOD_FILES), load their meta data
# (from their manifest.json file), and verify that the meta JSON has all
# required properties (REQUIRED_META_TAGS)
for dir_name in ModLoaderStore.mod_data:
var mod: ModData = ModLoaderStore.mod_data[dir_name]
mod.load_manifest()
if mod.manifest.get("config_schema") and not mod.manifest.config_schema.empty():
mod.load_configs()
ModLoaderLog.success("DONE: Loaded all meta data", LOG_NAME)
# Check for mods with load_before. If a mod is listed in load_before,
# add the current mod to the dependencies of the the mod specified
# in load_before.
for dir_name in ModLoaderStore.mod_data:
var mod: ModData = ModLoaderStore.mod_data[dir_name]
if not mod.is_loadable:
continue
_ModLoaderDependency.check_load_before(mod)
# Run optional dependency checks after loading mod_manifest.
# If a mod depends on another mod that hasn't been loaded,
# that dependent mod will be loaded regardless.
for dir_name in ModLoaderStore.mod_data:
var mod: ModData = ModLoaderStore.mod_data[dir_name]
if not mod.is_loadable:
continue
var _is_circular := _ModLoaderDependency.check_dependencies(mod, false)
# Run dependency checks after loading mod_manifest. If a mod depends on another
# mod that hasn't been loaded, that dependent mod won't be loaded.
for dir_name in ModLoaderStore.mod_data:
var mod: ModData = ModLoaderStore.mod_data[dir_name]
if not mod.is_loadable:
continue
var _is_circular := _ModLoaderDependency.check_dependencies(mod)
# Sort mod_load_order by the importance score of the mod
ModLoaderStore.mod_load_order = _ModLoaderDependency.get_load_order(ModLoaderStore.mod_data.values())
# Log mod order
var mod_i := 1
for mod in ModLoaderStore.mod_load_order: # mod === mod_data
mod = mod as ModData
ModLoaderLog.info("mod_load_order -> %s) %s" % [mod_i, mod.dir_name], LOG_NAME)
mod_i += 1
# Instance every mod and add it as a node to the Mod Loader
for mod in ModLoaderStore.mod_load_order:
mod = mod as ModData
# Continue if mod is disabled
if not mod.is_active:
continue
ModLoaderLog.info("Initializing -> %s" % mod.manifest.get_mod_id(), LOG_NAME)
_init_mod(mod)
ModLoaderLog.debug_json_print("mod data", ModLoaderStore.mod_data, LOG_NAME)
ModLoaderLog.success("DONE: Completely finished loading mods", LOG_NAME)
_ModLoaderScriptExtension.handle_script_extensions()
ModLoaderLog.success("DONE: Installed all script extensions", LOG_NAME)
ModLoaderStore.is_initializing = false
# Internal call to reload mods
func _reload_mods() -> void:
_reset_mods()
_load_mods()
# Internal call that handles the resetting of all mod related data
func _reset_mods() -> void:
_disable_mods()
ModLoaderStore.mod_data.clear()
ModLoaderStore.mod_load_order.clear()
ModLoaderStore.mod_missing_dependencies.clear()
ModLoaderStore.script_extensions.clear()
# Internal call that handles the disabling of all mods
func _disable_mods() -> void:
for mod in ModLoaderStore.mod_data:
_disable_mod(ModLoaderStore.mod_data[mod])
# Check autoload positions:
# Ensure 1st autoload is `ModLoaderStore`, and 2nd is `ModLoader`.
func _check_autoload_positions() -> void:
var ml_options: Object = preload("res://addons/mod_loader/options/options.tres").current_options
var override_cfg_path := _ModLoaderPath.get_override_path()
var is_override_cfg_setup := _ModLoaderFile.file_exists(override_cfg_path)
# If the override file exists we assume the ModLoader was setup with the --setup-create-override-cfg cli arg
# In that case the ModLoader will be the last entry in the autoload array
if is_override_cfg_setup:
ModLoaderLog.info("override.cfg setup detected, ModLoader will be the last autoload loaded.", LOG_NAME)
return
# If there are Autoloads that need to be before the ModLoader
# "allow_modloader_autoloads_anywhere" in the ModLoader Options can be enabled.
# With that only the correct order of, ModLoaderStore first and ModLoader second, is checked.
if ml_options.allow_modloader_autoloads_anywhere:
_ModLoaderGodot.check_autoload_order("ModLoaderStore", "ModLoader", true)
else:
var _pos_ml_store := _ModLoaderGodot.check_autoload_position("ModLoaderStore", 0, true)
var _pos_ml_core := _ModLoaderGodot.check_autoload_position("ModLoader", 1, true)
# Loop over "res://mods" and add any mod zips to the unpacked virtual directory
# (UNPACKED_DIR)
func _load_mod_zips() -> Dictionary:
var zip_data := {}
if not ModLoaderStore.ml_options.steam_workshop_enabled:
var mods_folder_path := _ModLoaderPath.get_path_to_mods()
# If we're not using Steam workshop, just loop over the mod ZIPs.
var loaded_zip_data := _ModLoaderFile.load_zips_in_folder(mods_folder_path)
zip_data.merge(loaded_zip_data)
else:
# If we're using Steam workshop, loop over the workshop item directories
var loaded_workshop_zip_data := _ModLoaderSteam.load_steam_workshop_zips()
zip_data.merge(loaded_workshop_zip_data)
return zip_data
# Loop over UNPACKED_DIR and triggers `_init_mod_data` for each mod directory,
# which adds their data to mod_data.
func _setup_mods() -> int:
# Path to the unpacked mods folder
var unpacked_mods_path := _ModLoaderPath.get_unpacked_mods_dir_path()
var dir := Directory.new()
if not dir.open(unpacked_mods_path) == OK:
ModLoaderLog.error("Can't open unpacked mods folder %s." % unpacked_mods_path, LOG_NAME)
return -1
if not dir.list_dir_begin() == OK:
ModLoaderLog.error("Can't read unpacked mods folder %s." % unpacked_mods_path, LOG_NAME)
return -1
var unpacked_mods_count := 0
# Get all unpacked mod dirs
while true:
# Get the next file in the directory
var mod_dir_name := dir.get_next()
# If there is no more file
if mod_dir_name == "":
# Stop loading mod zip files
break
if (
# Only check directories
not dir.current_is_dir()
# Ignore self, parent and hidden directories
or mod_dir_name.begins_with(".")
):
continue
if ModLoaderStore.ml_options.disabled_mods.has(mod_dir_name):
ModLoaderLog.info("Skipped setting up mod: \"%s\"" % mod_dir_name, LOG_NAME)
continue
# Initialize the mod data for each mod if there is no existing mod data for that mod.
if not ModLoaderStore.mod_data.has(mod_dir_name):
_init_mod_data(mod_dir_name)
unpacked_mods_count += 1
dir.list_dir_end()
return unpacked_mods_count
# Add a mod's data to mod_data.
# The mod_folder_path is just the folder name that was added to UNPACKED_DIR,
# which depends on the name used in a given mod ZIP (eg "mods-unpacked/Folder-Name")
func _init_mod_data(mod_id: String, zip_path := "") -> void:
# Path to the mod in UNPACKED_DIR (eg "res://mods-unpacked/My-Mod")
var local_mod_path := _ModLoaderPath.get_unpacked_mods_dir_path().plus_file(mod_id)
var mod := ModData.new()
if not zip_path.empty():
mod.zip_name = _ModLoaderPath.get_file_name_from_path(zip_path)
mod.zip_path = zip_path
mod.dir_path = local_mod_path
mod.dir_name = mod_id
var mod_overwrites_path := mod.get_optional_mod_file_path(ModData.optional_mod_files.OVERWRITES)
mod.is_overwrite = _ModLoaderFile.file_exists(mod_overwrites_path)
mod.is_locked = true if mod_id in ModLoaderStore.ml_options.locked_mods else false
ModLoaderStore.mod_data[mod_id] = mod
# Get the mod file paths
# Note: This was needed in the original version of this script, but it's
# not needed anymore. It can be useful when debugging, but it's also an expensive
# operation if a mod has a large number of files (eg. Brotato's Invasion mod,
# which has ~1,000 files). That's why it's disabled by default
if ModLoaderStore.DEBUG_ENABLE_STORING_FILEPATHS:
mod.file_paths = _ModLoaderPath.get_flat_view_dict(local_mod_path)
# Instance every mod and add it as a node to the Mod Loader.
# Runs mods in the order stored in mod_load_order.
func _init_mod(mod: ModData) -> void:
var mod_main_path := mod.get_required_mod_file_path(ModData.required_mod_files.MOD_MAIN)
var mod_overwrites_path := mod.get_optional_mod_file_path(ModData.optional_mod_files.OVERWRITES)
# If the mod contains overwrites initialize the overwrites script
if mod.is_overwrite:
ModLoaderLog.debug("Overwrite script detected -> %s" % mod_overwrites_path, LOG_NAME)
var mod_overwrites_script := load(mod_overwrites_path)
mod_overwrites_script.new()
ModLoaderLog.debug("Initialized overwrite script -> %s" % mod_overwrites_path, LOG_NAME)
ModLoaderLog.debug("Loading script from -> %s" % mod_main_path, LOG_NAME)
var mod_main_script: GDScript = ResourceLoader.load(mod_main_path)
ModLoaderLog.debug("Loaded script -> %s" % mod_main_script, LOG_NAME)
var argument_found: bool = false
for method in mod_main_script.get_script_method_list():
if method.name == "_init":
if method.args.size() > 0:
argument_found = true
var mod_main_instance: Node
if argument_found:
mod_main_instance = mod_main_script.new(self)
ModLoaderDeprecated.deprecated_message("The mod_main.gd _init argument (modLoader = ModLoader) is deprecated. Remove it from your _init to avoid crashes in the next major version.", "6.1.0")
else:
mod_main_instance = mod_main_script.new()
mod_main_instance.name = mod.manifest.get_mod_id()
ModLoaderStore.saved_mod_mains[mod_main_path] = mod_main_instance
ModLoaderLog.debug("Adding child -> %s" % mod_main_instance, LOG_NAME)
add_child(mod_main_instance, true)
# Call the disable method in every mod if present.
# This way developers can implement their own disable handling logic,
# that is needed if there are actions that are not done through the Mod Loader.
func _disable_mod(mod: ModData) -> void:
if mod == null:
ModLoaderLog.error("The provided ModData does not exist", LOG_NAME)
return
var mod_main_path := mod.get_required_mod_file_path(ModData.required_mod_files.MOD_MAIN)
if not ModLoaderStore.saved_mod_mains.has(mod_main_path):
ModLoaderLog.error("The provided Mod %s has no saved mod main" % mod.manifest.get_mod_id(), LOG_NAME)
return
var mod_main_instance: Node = ModLoaderStore.saved_mod_mains[mod_main_path]
if mod_main_instance.has_method("_disable"):
mod_main_instance._disable()
else:
ModLoaderLog.warning("The provided Mod %s does not have a \"_disable\" method" % mod.manifest.get_mod_id(), LOG_NAME)
ModLoaderStore.saved_mod_mains.erase(mod_main_path)
_ModLoaderScriptExtension.remove_all_extensions_of_mod(mod)
remove_child(mod_main_instance)
# Deprecated
# =============================================================================
func install_script_extension(child_script_path:String) -> void:
ModLoaderDeprecated.deprecated_changed("ModLoader.install_script_extension", "ModLoaderMod.install_script_extension", "6.0.0")
ModLoaderMod.install_script_extension(child_script_path)
func register_global_classes_from_array(new_global_classes: Array) -> void:
ModLoaderDeprecated.deprecated_changed("ModLoader.register_global_classes_from_array", "ModLoaderMod.register_global_classes_from_array", "6.0.0")
ModLoaderMod.register_global_classes_from_array(new_global_classes)
func add_translation_from_resource(resource_path: String) -> void:
ModLoaderDeprecated.deprecated_changed("ModLoader.add_translation_from_resource", "ModLoaderMod.add_translation", "6.0.0")
ModLoaderMod.add_translation(resource_path)
func append_node_in_scene(modified_scene: Node, node_name: String = "", node_parent = null, instance_path: String = "", is_visible: bool = true) -> void:
ModLoaderDeprecated.deprecated_changed("ModLoader.append_node_in_scene", "ModLoaderMod.append_node_in_scene", "6.0.0")
ModLoaderMod.append_node_in_scene(modified_scene, node_name, node_parent, instance_path, is_visible)
func save_scene(modified_scene: Node, scene_path: String) -> void:
ModLoaderDeprecated.deprecated_changed("ModLoader.save_scene", "ModLoaderMod.save_scene", "6.0.0")
ModLoaderMod.save_scene(modified_scene, scene_path)
func get_mod_config(mod_dir_name: String = "", key: String = "") -> ModConfig:
ModLoaderDeprecated.deprecated_changed("ModLoader.get_mod_config", "ModLoaderConfig.get_config", "6.0.0")
return ModLoaderConfig.get_config(mod_dir_name, ModLoaderConfig.DEFAULT_CONFIG_NAME)
func deprecated_direct_access_UNPACKED_DIR() -> String:
ModLoaderDeprecated.deprecated_message("The const \"UNPACKED_DIR\" was removed, use \"ModLoaderMod.get_unpacked_dir()\" instead", "6.0.0")
return _ModLoaderPath.get_unpacked_mods_dir_path()
func deprecated_direct_access_mod_data() -> Dictionary:
ModLoaderDeprecated.deprecated_message("The var \"mod_data\" was removed, use \"ModLoaderMod.get_mod_data_all()\" instead", "6.0.0")
return ModLoaderStore.mod_data