Skip to content

Commit

Permalink
refactor: ♻️ mod loading refactor (#485)
Browse files Browse the repository at this point in the history
* refactor: ♻️ should probably start committing

* refactor: ♻️ load mod zips

* refactor: ♻️ move DOCS const to Store

* refactor: ♻️ draw the rest of the owl

* refactor: 🔥 remove old mod_loader.gd

* fix: 🐛 only log pck warning if zip is loaded

* refactor: ♻️ leaner mod order logging

* refactor: ♻️ always have `mod_data` available

* refactor: ♻️ prevent error if mods dir doesn't exists

* style: 🎨 formating

* style: ✏️ looong

* test: 🧪 fix for non static manifest

* fix: 🐛 Add back cache saving on `_exit_tree()`

* fix: 🐛 Add back restart scene on new hooks created

* fix: 🐛 bring back `_ready()`

* refactor: ♻️ skip config loading if mod is not loadable

* fix: ✏️ don't log success if mod is not loaded

* refactor: ♻️ move `get_zip_paths_in()` to `_ModLoaderPath`

and use it for `get_mod_paths_from_all_sources()`, also removed the logging from `get_zip_paths_in()` and moved it to `ModLoader`

* docs: ✏️ better error message

* feat: ✨ added `_ModLoaderFile.get_mod_dir_name_in_zip()`

and used it in `ModData`

* refactor: ♻️ remove unused `else`

* refactor: ♻️ removed class from func call

* refactor: ♻️ use `is_zip()`

* style: ✏️ changed `mod_i` to `mod_index`

* refactor: 📝 mention the dev tool in `any_mod_hooked` info log

* style: ✏️ fix typo
  • Loading branch information
KANAjetzt authored Jan 7, 2025
1 parent 7b36e32 commit 9c9a5fd
Show file tree
Hide file tree
Showing 12 changed files with 417 additions and 525 deletions.
4 changes: 2 additions & 2 deletions addons/mod_loader/api/config.gd
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ static func get_current_config_name(mod_id: String) -> String:
# Check if user profile has been loaded
if not ModLoaderStore.current_user_profile or not ModLoaderStore.user_profiles.has(ModLoaderStore.current_user_profile.name):
# Warn and return an empty string if the user profile has not been loaded
ModLoaderLog.warning("Can't get current mod config for \"%s\", because no current user profile is present." % mod_id, LOG_NAME)
ModLoaderLog.warning("Can't get current mod config name for \"%s\", because no current user profile is present." % mod_id, LOG_NAME)
return ""

# Retrieve the current user profile from ModLoaderStore
Expand All @@ -359,7 +359,7 @@ static func get_current_config_name(mod_id: String) -> String:
# Check if the mod exists in the user profile's mod list and if it has a current config
if not current_user_profile.mod_list.has(mod_id) or not current_user_profile.mod_list[mod_id].has("current_config"):
# Log an error and return an empty string if the mod has no config file
ModLoaderLog.error("Mod \"%s\" has no config file." % mod_id, LOG_NAME)
ModLoaderLog.error("Can't get current mod config name for \"%s\" because no config file exists." % mod_id, LOG_NAME)
return ""

# Return the name of the current configuration for the mod
Expand Down
50 changes: 25 additions & 25 deletions addons/mod_loader/internal/file.gd
Original file line number Diff line number Diff line change
Expand Up @@ -107,25 +107,6 @@ static func get_json_as_dict_from_zip(zip_path: String, file_path: String, is_fu
return _get_json_string_as_dict(content)


# Finds the global paths to all zips in provided directory
static func get_zip_paths_in(folder_path: String) -> Array[String]:
var zip_paths: Array[String] = []

var files := Array(DirAccess.get_files_at(folder_path))\
.filter(
func(file_name: String):
return file_name.get_extension() == "zip"
).map(
func(file_name: String):
return ProjectSettings.globalize_path(folder_path.path_join(file_name))
)
ModLoaderLog.debug("Found %s mod ZIPs: %s" % [files.size(), str(files)], LOG_NAME)

# only .assign()ing to a typed array lets us return Array[String] instead of just Array
zip_paths.assign(files)
return zip_paths


# Save Data
# =============================================================================

Expand Down Expand Up @@ -194,7 +175,7 @@ static func remove_file(file_path: String) -> bool:

static func file_exists(path: String, zip_path: String = "") -> bool:
if not zip_path.is_empty():
return file_exists_in_zip(path, zip_path)
return file_exists_in_zip(zip_path, path)

var exists := FileAccess.file_exists(path)

Expand All @@ -209,13 +190,28 @@ static func dir_exists(path: String) -> bool:
return DirAccess.dir_exists_absolute(path)


static func file_exists_in_zip(path: String, zip_path: String = "") -> bool:
static func file_exists_in_zip(zip_path: String, path: String) -> bool:
var reader := zip_reader_open(zip_path)
if not reader:
return false
return reader.file_exists(path.trim_prefix("res://"))


static func get_mod_dir_name_in_zip(zip_path: String) -> String:
var reader := _ModLoaderFile.zip_reader_open(zip_path)
if not reader:
return ""

var file_paths := reader.get_files()

for file_path in file_paths:
# We asume tat the mod_main.gd is at the root of the mod dir
if file_path.ends_with("mod_main.gd") and file_path.split("/").size() == 3:
return file_path.split("/")[-2]

return ""


static func zip_reader_open(zip_path) -> ZIPReader:
var reader := ZIPReader.new()
var err := reader.open(zip_path)
Expand All @@ -225,10 +221,14 @@ static func zip_reader_open(zip_path) -> ZIPReader:
return reader


# Internal util functions
# =============================================================================
# These are duplicates of the functions in mod_loader_utils.gd to prevent
# a cyclic reference error.
static func load_manifest_file(path: String) -> Dictionary:
ModLoaderLog.debug("Loading mod_manifest from -> %s" % path, LOG_NAME)

if _ModLoaderPath.is_zip(path):
return get_json_as_dict_from_zip(path, ModData.MANIFEST)

return get_json_as_dict(path.path_join(ModData.MANIFEST))


# This is a dummy func. It is exclusively used to show notes in the code that
# stay visible after decompiling a PCK, as is primarily intended to assist new
Expand Down
23 changes: 22 additions & 1 deletion addons/mod_loader/internal/godot.gd
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,31 @@ const LOG_NAME := "ModLoader:Godot"
const AUTOLOAD_CONFIG_HELP_MSG := "To configure your autoloads, go to Project > Project Settings > Autoload."


# Check autoload positions:
# Ensure 1st autoload is `ModLoaderStore`, and 2nd is `ModLoader`.
static func check_autoload_positions() -> void:
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 ModLoaderStore.ml_options.allow_modloader_autoloads_anywhere:
is_autoload_before("ModLoaderStore", "ModLoader", true)
else:
var _pos_ml_store := check_autoload_position("ModLoaderStore", 0, true)
var _pos_ml_core := check_autoload_position("ModLoader", 1, true)


# Check if autoload_name_before is before autoload_name_after
# Returns a bool if the position does not match.
# Optionally triggers a fatal error
static func check_autoload_order(autoload_name_before: String, autoload_name_after: String, trigger_error := false) -> bool:
static func is_autoload_before(autoload_name_before: String, autoload_name_after: String, trigger_error := false) -> bool:
var autoload_name_before_index := get_autoload_index(autoload_name_before)
var autoload_name_after_index := get_autoload_index(autoload_name_after)

Expand Down
8 changes: 7 additions & 1 deletion addons/mod_loader/internal/hooks.gd
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,10 @@ static func get_hook_hash(path: String, method: String) -> int:
return hash(path + method)



static func on_new_hooks_created() -> void:
if ModLoaderStore.ml_options.disable_restart:
ModLoaderLog.debug("Mod Loader handled restart is disabled.", LOG_NAME)
return
ModLoaderLog.debug("Instancing restart notification scene from path: %s" % [ModLoaderStore.ml_options.restart_notification_scene_path], LOG_NAME)
var restart_notification_scene = load(ModLoaderStore.ml_options.restart_notification_scene_path).instantiate()
ModLoader.add_child(restart_notification_scene)
18 changes: 12 additions & 6 deletions addons/mod_loader/internal/mod_loader_utils.gd
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,8 @@ static func get_dict_from_dict(dict: Dictionary, key: String) -> Dictionary:

## Works like [method Dictionary.has_all],
## but allows for more specific errors if a field is missing
static func dict_has_fields(dict: Dictionary, required_fields: Array) -> bool:
var missing_fields := required_fields.duplicate()

for key in dict.keys():
if(required_fields.has(key)):
missing_fields.erase(key)
static func dict_has_fields(dict: Dictionary, required_fields: Array[String]) -> bool:
var missing_fields := get_missing_dict_fields(dict, required_fields)

if missing_fields.size() > 0:
ModLoaderLog.fatal("Dictionary is missing required fields: %s" % str(missing_fields), LOG_NAME)
Expand All @@ -61,6 +57,16 @@ static func dict_has_fields(dict: Dictionary, required_fields: Array) -> bool:
return true


static func get_missing_dict_fields(dict: Dictionary, required_fields: Array[String]) -> Array[String]:
var missing_fields := required_fields.duplicate()

for key in dict.keys():
if(required_fields.has(key)):
missing_fields.erase(key)

return missing_fields


## Register an array of classes to the global scope, since Godot only does that in the editor.
static func register_global_classes_from_array(new_global_classes: Array) -> void:
var registered_classes: Array = ProjectSettings.get_setting("_global_script_classes")
Expand Down
51 changes: 48 additions & 3 deletions addons/mod_loader/internal/path.gd
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,7 @@ static func get_steam_workshop_id(zip_path: String) -> String:
return zip_path.get_base_dir().split("/")[-1]


# Get a flat array of all files in the target directory. This was needed in the
# original version of this script, before becoming deprecated. It may still be
# used if DEBUG_ENABLE_STORING_FILEPATHS is true.
# Get a flat array of all files in the target directory.
# Source: https://gist.github.com/willnationsdev/00d97aa8339138fd7ef0d6bd42748f6e
static func get_flat_view_dict(p_dir := "res://", p_match := "", p_match_is_regex := false) -> PackedStringArray:
var data: PackedStringArray = []
Expand Down Expand Up @@ -167,12 +165,54 @@ static func get_dir_paths_in_dir(src_dir_path: String) -> Array:
# Get the path to the mods folder, with any applicable overrides applied
static func get_path_to_mods() -> String:
var mods_folder_path := get_local_folder_dir("mods")

if ModLoaderStore:
if ModLoaderStore.ml_options.override_path_to_mods:
mods_folder_path = ModLoaderStore.ml_options.override_path_to_mods
return mods_folder_path


# Finds the global paths to all zips in provided directory
static func get_zip_paths_in(folder_path: String) -> Array[String]:
var zip_paths: Array[String] = []

var files := Array(DirAccess.get_files_at(folder_path))\
.filter(
func(file_name: String):
return is_zip(file_name)
).map(
func(file_name: String):
return ProjectSettings.globalize_path(folder_path.path_join(file_name))
)

# only .assign()ing to a typed array lets us return Array[String] instead of just Array
zip_paths.assign(files)
return zip_paths


static func get_mod_paths_from_all_sources() -> Array[String]:
var mod_paths: Array[String] = []

var mod_dirs := get_dir_paths_in_dir(get_unpacked_mods_dir_path())
mod_paths.append_array(mod_dirs)

if ModLoaderStore.ml_options.load_from_local:
var mods_dir := get_path_to_mods()
if not DirAccess.dir_exists_absolute(mods_dir):
ModLoaderLog.info("The directory for mods at path \"%s\" does not exist." % mods_dir, LOG_NAME)
else:
mod_paths.append_array(get_zip_paths_in(mods_dir))

if ModLoaderStore.ml_options.load_from_steam_workshop:
mod_paths.append_array(_ModLoaderSteam.find_steam_workshop_zips())

return mod_paths


static func get_path_to_mod_manifest(mod_id: String) -> String:
return get_path_to_mods().path_join(mod_id).path_join("manifest.json")


static func get_unpacked_mods_dir_path() -> String:
return ModLoaderStore.UNPACKED_DIR

Expand Down Expand Up @@ -234,6 +274,11 @@ static func get_mod_dir(path: String) -> String:
return found_string


# Checks if the path ends with .zip
static func is_zip(path: String) -> bool:
return path.get_extension() == "zip"


static func handle_mod_config_path_deprecation() -> void:
ModLoaderDeprecated.deprecated_message("The mod config path has been moved to \"%s\".
The Mod Loader will attempt to rename the config directory." % MOD_CONFIG_DIR_PATH, "7.0.0")
Expand Down
4 changes: 2 additions & 2 deletions addons/mod_loader/internal/third_party/steam.gd
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const LOG_NAME := "ModLoader:ThirdParty:Steam"
# Methods related to Steam and the Steam Workshop


# Load mod ZIPs from Steam workshop folders.
# Get mod zip paths from steam workshop folders.
# folder structure of a workshop item
# <workshop folder>/<steam app id>/<workshop item id>/<mod>.zip
static func find_steam_workshop_zips() -> Array[String]:
Expand Down Expand Up @@ -40,7 +40,7 @@ static func find_steam_workshop_zips() -> Array[String]:
continue

# Loop 2: ZIPs inside the workshop folders
zip_paths.append_array(_ModLoaderFile.get_zip_paths_in(ProjectSettings.globalize_path(item_path)))
zip_paths.append_array(_ModLoaderPath.get_zip_paths_in(ProjectSettings.globalize_path(item_path)))

workshop_dir.list_dir_end()

Expand Down
Loading

0 comments on commit 9c9a5fd

Please sign in to comment.