Skip to content

Commit

Permalink
refactor: ♻️ reworked mod uninstall detection (#298)
Browse files Browse the repository at this point in the history
* feat: ✨ added ` get_dir_paths_in_dir()`

* feat: ✨ added `zip_name` and `zip_path` to `ModData`

* refactor: ♻️ added `zip_path` to user profiles

This allows to verify if the mod is still installed by confirming the existence of the zip file. However, this check is only performed when the mod is not loaded and a path to the zip file exists. This ensures that mods are not deleted from the profile when running in the editor. It's important to note that this new check may cause mods to appear in user profiles even if they are currently not loaded. To determine if a mod is actually loaded, you should also check `ModLoaderStore.mod_data` or use `ModLoaderMod.is_mod_loaded()`.

closes #288
  • Loading branch information
KANAjetzt authored Jun 27, 2023
1 parent 21dea2a commit ee7312a
Show file tree
Hide file tree
Showing 7 changed files with 124 additions and 40 deletions.
28 changes: 28 additions & 0 deletions addons/mod_loader/api/profile.gd
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,28 @@ static func _update_mod_list(mod_list: Dictionary, mod_data := ModLoaderStore.mo
# If the current config doesn't exist, reset it to the default configuration
mod_list_entry.current_config = ModLoaderConfig.DEFAULT_CONFIG_NAME

# If the mod is not loaded
if not mod_data.has(mod_id):
if (
# Check if the entry has a zip_path key
mod_list_entry.has("zip_path") and
# Check if the entry has a zip_path
not mod_list_entry.zip_path.empty() and
# Check if the zip file for the mod exists
not _ModLoaderFile.file_exists(mod_list_entry.zip_path)
):
# If the mod directory doesn't exist,
# the mod is no longer installed and can be removed from the mod list
ModLoaderLog.debug(
"Mod \"%s\" has been deleted from all user profiles as the corresponding zip file no longer exists at path \"%s\"."
% [mod_id, mod_list_entry.zip_path],
LOG_NAME,
true
)

updated_mod_list.erase(mod_id)
continue

updated_mod_list[mod_id] = mod_list_entry

return updated_mod_list
Expand All @@ -295,7 +317,13 @@ static func _generate_mod_list() -> Dictionary:
static func _generate_mod_list_entry(mod_id: String, is_active: bool) -> Dictionary:
var mod_list_entry := {}

# Set the mods active state
mod_list_entry.is_active = is_active

# Set the mods zip path if available
if ModLoaderStore.mod_data.has(mod_id):
mod_list_entry.zip_path = ModLoaderStore.mod_data[mod_id].zip_path

# Set the current_config if the mod has a config schema and is active
if is_active and not ModLoaderConfig.get_config_schema(mod_id).empty():
var current_config: ModConfig = ModLoaderStore.mod_data[mod_id].current_config
Expand Down
37 changes: 27 additions & 10 deletions addons/mod_loader/internal/file.gd
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,20 @@ static func _get_json_string_as_dict(string: String) -> Dictionary:


# Load the mod ZIP from the provided directory
static func load_zips_in_folder(folder_path: String) -> int:
var temp_zipped_mods_count := 0
static func load_zips_in_folder(folder_path: String) -> Dictionary:
var zip_data := {}

var mod_dir := Directory.new()
var mod_dir_open_error := mod_dir.open(folder_path)
if not mod_dir_open_error == OK:
ModLoaderLog.error("Can't open mod folder %s (Error: %s)" % [folder_path, mod_dir_open_error], LOG_NAME)
return -1
return {}
var mod_dir_listdir_error := mod_dir.list_dir_begin()
if not mod_dir_listdir_error == OK:
ModLoaderLog.error("Can't read mod folder %s (Error: %s)" % [folder_path, mod_dir_listdir_error], LOG_NAME)
return -1
return {}



# Get all zip folders inside the game mod folder
while true:
Expand All @@ -76,9 +78,25 @@ static func load_zips_in_folder(folder_path: String) -> int:
# Go to the next file
continue

var mod_folder_path := folder_path.plus_file(mod_zip_file_name)
var mod_folder_global_path := ProjectSettings.globalize_path(mod_folder_path)
var is_mod_loaded_successfully := ProjectSettings.load_resource_pack(mod_folder_global_path, false)
var mod_zip_path := folder_path.plus_file(mod_zip_file_name)
var mod_zip_global_path := ProjectSettings.globalize_path(mod_zip_path)
var is_mod_loaded_successfully := ProjectSettings.load_resource_pack(mod_zip_global_path, false)

# Get the current directories inside UNPACKED_DIR
# This array is used to determine which directory is new
var current_mod_dirs := _ModLoaderPath.get_dir_paths_in_dir(_ModLoaderPath.get_unpacked_mods_dir_path())
# Create a backup to reference when the next mod is loaded
var current_mod_dirs_backup := current_mod_dirs.duplicate()

# Remove all directory paths that existed before, leaving only the one added last
for previous_mod_dir in ModLoaderStore.previous_mod_dirs:
current_mod_dirs.erase(previous_mod_dir)

# The key is the mod_id of the latest loaded mod, and the value is the path to the zip file
zip_data[current_mod_dirs[0].get_slice("/", 3)] = mod_zip_global_path

# Update previous_mod_dirs in ModLoaderStore to use for the next mod
ModLoaderStore.previous_mod_dirs = current_mod_dirs_backup

# Notifies developer of an issue with Godot, where using `load_resource_pack`
# in the editor WIPES the entire virtual res:// directory the first time you
Expand All @@ -94,7 +112,7 @@ static func load_zips_in_folder(folder_path: String) -> int:
"Please unpack your mod ZIPs instead, and add them to ", _ModLoaderPath.get_unpacked_mods_dir_path()), LOG_NAME)
ModLoaderStore.has_shown_editor_zips_warning = true

ModLoaderLog.debug("Found mod ZIP: %s" % mod_folder_global_path, LOG_NAME)
ModLoaderLog.debug("Found mod ZIP: %s" % mod_zip_global_path, LOG_NAME)

# If there was an error loading the mod zip file
if not is_mod_loaded_successfully:
Expand All @@ -104,11 +122,10 @@ static func load_zips_in_folder(folder_path: String) -> int:

# Mod successfully loaded!
ModLoaderLog.success("%s loaded." % mod_zip_file_name, LOG_NAME)
temp_zipped_mods_count += 1

mod_dir.list_dir_end()

return temp_zipped_mods_count
return zip_data


# Save Data
Expand Down
24 changes: 24 additions & 0 deletions addons/mod_loader/internal/path.gd
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,30 @@ static func get_file_paths_in_dir(src_dir_path: String) -> Array:
return file_paths


# Returns an array of directory paths inside the src dir
static func get_dir_paths_in_dir(src_dir_path: String) -> Array:
var dir_paths := []

var directory := Directory.new()
var error := directory.open(src_dir_path)

if not error == OK:
return dir_paths
ModLoaderLog.error("Error opening directory", LOG_NAME)

directory.list_dir_begin()
var file_name := directory.get_next()
while (file_name != ""):
if file_name == "." or file_name == "..":
file_name = directory.get_next()
continue
if directory.current_is_dir():
dir_paths.push_back(src_dir_path.plus_file(file_name))
file_name = directory.get_next()

return dir_paths


# 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")
Expand Down
12 changes: 6 additions & 6 deletions addons/mod_loader/internal/third_party/steam.gd
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const LOG_NAME := "ModLoader:ThirdParty:Steam"
# Load mod ZIPs from Steam workshop folders. Uses 2 loops: One for each
# workshop item's folder, with another inside that which loops over the ZIPs
# inside each workshop item's folder
static func load_steam_workshop_zips() -> int:
var temp_zipped_mods_count := 0
static func load_steam_workshop_zips() -> Dictionary:
var zip_data := {}
var workshop_folder_path := _get_path_to_workshop()

ModLoaderLog.info("Checking workshop items, with path: \"%s\"" % workshop_folder_path, LOG_NAME)
Expand All @@ -19,11 +19,11 @@ static func load_steam_workshop_zips() -> int:
var workshop_dir_open_error := workshop_dir.open(workshop_folder_path)
if not workshop_dir_open_error == OK:
ModLoaderLog.error("Can't open workshop folder %s (Error: %s)" % [workshop_folder_path, workshop_dir_open_error], LOG_NAME)
return -1
return {}
var workshop_dir_listdir_error := workshop_dir.list_dir_begin()
if not workshop_dir_listdir_error == OK:
ModLoaderLog.error("Can't read workshop folder %s (Error: %s)" % [workshop_folder_path, workshop_dir_listdir_error], LOG_NAME)
return -1
return {}

# Loop 1: Workshop folders
while true:
Expand All @@ -42,11 +42,11 @@ static func load_steam_workshop_zips() -> int:
continue

# Loop 2: ZIPs inside the workshop folders
temp_zipped_mods_count += _ModLoaderFile.load_zips_in_folder(ProjectSettings.globalize_path(item_path))
zip_data.merge(_ModLoaderFile.load_zips_in_folder(ProjectSettings.globalize_path(item_path)))

workshop_dir.list_dir_end()

return temp_zipped_mods_count
return zip_data


# Get the path to the Steam workshop folder. Only works for Steam games, as it
Expand Down
52 changes: 32 additions & 20 deletions addons/mod_loader/mod_loader.gd
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,20 @@ func _exit_tree() -> void:
func _load_mods() -> void:
# Loop over "res://mods" and add any mod zips to the unpacked virtual
# directory (UNPACKED_DIR)
var unzipped_mods := _load_mod_zips()
if unzipped_mods > 0:
ModLoaderLog.success("DONE: Loaded %s mod files into the virtual filesystem" % unzipped_mods, LOG_NAME)
else:
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.
Expand Down Expand Up @@ -219,19 +228,21 @@ func _check_autoload_positions() -> void:

# Loop over "res://mods" and add any mod zips to the unpacked virtual directory
# (UNPACKED_DIR)
func _load_mod_zips() -> int:
var zipped_mods_count := 0
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.
zipped_mods_count += _ModLoaderFile.load_zips_in_folder(mods_folder_path)
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
zipped_mods_count += _ModLoaderSteam.load_steam_workshop_zips()
var loaded_workshop_zip_data := _ModLoaderSteam.load_steam_workshop_zips()
zip_data.merge(loaded_workshop_zip_data)

return zipped_mods_count
return zip_data


# Loop over UNPACKED_DIR and triggers `_init_mod_data` for each mod directory,
Expand Down Expand Up @@ -282,19 +293,20 @@ func _setup_mods() -> int:
# 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_folder_path: String) -> void:
# The file name should be a valid mod id
var dir_name := _ModLoaderPath.get_file_name_from_path(mod_folder_path, false, true)

# 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(dir_name)

var mod := ModData.new(local_mod_path)
mod.dir_name = dir_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 dir_name in ModLoaderStore.ml_options.locked_mods else false
ModLoaderStore.mod_data[dir_name] = mod
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
Expand Down
3 changes: 3 additions & 0 deletions addons/mod_loader/mod_loader_store.gd
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ var mod_missing_dependencies := {}
# Helps to decide whether a script extension should go through the _ModLoaderScriptExtension.handle_script_extensions() process
var is_initializing := true

# Used when loading mod zips to determine which mod zip corresponds to which mod directory in the UNPACKED_DIR.
var previous_mod_dirs := []

# Store all extenders paths
var script_extensions := []

Expand Down
8 changes: 4 additions & 4 deletions addons/mod_loader/resources/mod_data.gd
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ enum optional_mod_files {
OVERWRITES
}

# Name of the Mod's zip file
var zip_name := ""
# Path to the Mod's zip file
var zip_path := ""
# Directory of the mod. Has to be identical to [method ModManifest.get_mod_id]
var dir_name := ""
# Path to the Mod's Directory
Expand All @@ -47,10 +51,6 @@ var current_config: ModConfig setget _set_current_config
var file_paths: PoolStringArray = []


func _init(_dir_path: String) -> void:
dir_path = _dir_path


# Load meta data from a mod's manifest.json file
func load_manifest() -> void:
if not _has_required_files():
Expand Down

0 comments on commit ee7312a

Please sign in to comment.