mirror of
https://github.com/JHDev2006/Super-Mario-Bros.-Remastered-Public.git
synced 2025-10-22 23:48:11 +00:00
added the game
This commit is contained in:
85
addons/mod_loader/internal/cache.gd
Normal file
85
addons/mod_loader/internal/cache.gd
Normal file
@@ -0,0 +1,85 @@
|
||||
class_name _ModLoaderCache
|
||||
extends RefCounted
|
||||
|
||||
|
||||
# This Class provides methods for caching data.
|
||||
|
||||
const CACHE_FILE_PATH = "user://mod_loader_cache.json"
|
||||
const LOG_NAME = "ModLoader:Cache"
|
||||
|
||||
|
||||
# ModLoaderStore is passed as parameter so the cache data can be loaded on ModLoaderStore._init()
|
||||
static func init_cache(_ModLoaderStore) -> void:
|
||||
if not _ModLoaderFile.file_exists(CACHE_FILE_PATH):
|
||||
_init_cache_file()
|
||||
return
|
||||
|
||||
_load_file(_ModLoaderStore)
|
||||
|
||||
|
||||
# Adds data to the cache
|
||||
static func add_data(key: String, data: Dictionary) -> Dictionary:
|
||||
if ModLoaderStore.cache.has(key):
|
||||
ModLoaderLog.error("key: \"%s\" already exists in \"ModLoaderStore.cache\"" % key, LOG_NAME)
|
||||
return {}
|
||||
|
||||
ModLoaderStore.cache[key] = data
|
||||
|
||||
return ModLoaderStore.cache[key]
|
||||
|
||||
|
||||
# Get data from a specific key
|
||||
static func get_data(key: String) -> Dictionary:
|
||||
if not ModLoaderStore.cache.has(key):
|
||||
ModLoaderLog.info("key: \"%s\" not found in \"ModLoaderStore.cache\"" % key, LOG_NAME)
|
||||
return {}
|
||||
|
||||
return ModLoaderStore.cache[key]
|
||||
|
||||
|
||||
# Get the entire cache dictionary
|
||||
static func get_cache() -> Dictionary:
|
||||
return ModLoaderStore.cache
|
||||
|
||||
|
||||
static func has_key(key: String) -> bool:
|
||||
return ModLoaderStore.cache.has(key)
|
||||
|
||||
|
||||
# Updates or adds data to the cache
|
||||
static func update_data(key: String, data: Dictionary) -> Dictionary:
|
||||
# If the key exists
|
||||
if has_key(key):
|
||||
# Update the data
|
||||
ModLoaderStore.cache[key].merge(data, true)
|
||||
else:
|
||||
ModLoaderLog.info("key: \"%s\" not found in \"ModLoaderStore.cache\" added as new data instead." % key, LOG_NAME, true)
|
||||
# Else add new data
|
||||
add_data(key, data)
|
||||
|
||||
return ModLoaderStore.cache[key]
|
||||
|
||||
|
||||
# Remove data from the cache
|
||||
static func remove_data(key: String) -> void:
|
||||
if not ModLoaderStore.cache.has(key):
|
||||
ModLoaderLog.error("key: \"%s\" not found in \"ModLoaderStore.cache\"" % key, LOG_NAME)
|
||||
return
|
||||
|
||||
ModLoaderStore.cache.erase(key)
|
||||
|
||||
|
||||
# Save the cache to the cache file
|
||||
static func save_to_file() -> void:
|
||||
_ModLoaderFile.save_dictionary_to_json_file(ModLoaderStore.cache, CACHE_FILE_PATH)
|
||||
|
||||
|
||||
# Load the cache file data and store it in ModLoaderStore
|
||||
# ModLoaderStore is passed as parameter so the cache data can be loaded on ModLoaderStore._init()
|
||||
static func _load_file(_ModLoaderStore = ModLoaderStore) -> void:
|
||||
_ModLoaderStore.cache = _ModLoaderFile.get_json_as_dict(CACHE_FILE_PATH)
|
||||
|
||||
|
||||
# Create an empty cache file
|
||||
static func _init_cache_file() -> void:
|
||||
_ModLoaderFile.save_dictionary_to_json_file({}, CACHE_FILE_PATH)
|
1
addons/mod_loader/internal/cache.gd.uid
Normal file
1
addons/mod_loader/internal/cache.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b73enisoxe0uq
|
85
addons/mod_loader/internal/cli.gd
Normal file
85
addons/mod_loader/internal/cli.gd
Normal file
@@ -0,0 +1,85 @@
|
||||
class_name _ModLoaderCLI
|
||||
extends RefCounted
|
||||
|
||||
|
||||
# This Class provides util functions for working with cli arguments.
|
||||
# Currently all of the included functions are internal and should only be used by the mod loader itself.
|
||||
|
||||
const LOG_NAME := "ModLoader:CLI"
|
||||
|
||||
|
||||
# Check if the provided command line argument was present when launching the game
|
||||
static func is_running_with_command_line_arg(argument: String) -> bool:
|
||||
for arg in OS.get_cmdline_args():
|
||||
if argument == arg.split("=")[0]:
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
# Get the command line argument value if present when launching the game
|
||||
static func get_cmd_line_arg_value(argument: String) -> String:
|
||||
var args := _get_fixed_cmdline_args()
|
||||
|
||||
for arg_index in args.size():
|
||||
var arg := args[arg_index] as String
|
||||
|
||||
var key := arg.split("=")[0]
|
||||
if key == argument:
|
||||
# format: `--arg=value` or `--arg="value"`
|
||||
if "=" in arg:
|
||||
var value := arg.trim_prefix(argument + "=")
|
||||
value = value.trim_prefix('"').trim_suffix('"')
|
||||
value = value.trim_prefix("'").trim_suffix("'")
|
||||
return value
|
||||
|
||||
# format: `--arg value` or `--arg "value"`
|
||||
elif arg_index +1 < args.size() and not args[arg_index +1].begins_with("--"):
|
||||
return args[arg_index + 1]
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
static func _get_fixed_cmdline_args() -> PackedStringArray:
|
||||
return fix_godot_cmdline_args_string_space_splitting(OS.get_cmdline_args())
|
||||
|
||||
|
||||
# Reverses a bug in Godot, which splits input strings at spaces even if they are quoted
|
||||
# e.g. `--arg="some value" --arg-two 'more value'` becomes `[ --arg="some, value", --arg-two, 'more, value' ]`
|
||||
static func fix_godot_cmdline_args_string_space_splitting(args: PackedStringArray) -> PackedStringArray:
|
||||
if not OS.has_feature("editor"): # only happens in editor builds
|
||||
return args
|
||||
if OS.has_feature("windows"): # windows is unaffected
|
||||
return args
|
||||
|
||||
var fixed_args := PackedStringArray([])
|
||||
var fixed_arg := ""
|
||||
# if we encounter an argument that contains `=` followed by a quote,
|
||||
# or an argument that starts with a quote, take all following args and
|
||||
# concatenate them into one, until we find the closing quote
|
||||
for arg in args:
|
||||
var arg_string := arg as String
|
||||
if '="' in arg_string or '="' in fixed_arg or \
|
||||
arg_string.begins_with('"') or fixed_arg.begins_with('"'):
|
||||
if not fixed_arg == "":
|
||||
fixed_arg += " "
|
||||
fixed_arg += arg_string
|
||||
if arg_string.ends_with('"'):
|
||||
fixed_args.append(fixed_arg.trim_prefix(" "))
|
||||
fixed_arg = ""
|
||||
continue
|
||||
# same thing for single quotes
|
||||
elif "='" in arg_string or "='" in fixed_arg \
|
||||
or arg_string.begins_with("'") or fixed_arg.begins_with("'"):
|
||||
if not fixed_arg == "":
|
||||
fixed_arg += " "
|
||||
fixed_arg += arg_string
|
||||
if arg_string.ends_with("'"):
|
||||
fixed_args.append(fixed_arg.trim_prefix(" "))
|
||||
fixed_arg = ""
|
||||
continue
|
||||
|
||||
else:
|
||||
fixed_args.append(arg_string)
|
||||
|
||||
return fixed_args
|
1
addons/mod_loader/internal/cli.gd.uid
Normal file
1
addons/mod_loader/internal/cli.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c3rvk5ry6rqyq
|
131
addons/mod_loader/internal/dependency.gd
Normal file
131
addons/mod_loader/internal/dependency.gd
Normal file
@@ -0,0 +1,131 @@
|
||||
class_name _ModLoaderDependency
|
||||
extends RefCounted
|
||||
|
||||
|
||||
# This Class provides methods for working with dependencies.
|
||||
# Currently all of the included methods are internal and should only be used by the mod loader itself.
|
||||
|
||||
const LOG_NAME := "ModLoader:Dependency"
|
||||
|
||||
|
||||
# Run dependency checks on a mod, checking any dependencies it lists in its
|
||||
# mod_manifest (ie. its manifest.json file). If a mod depends on another mod that
|
||||
# hasn't been loaded, the dependent mod won't be loaded, if it is a required dependency.
|
||||
#
|
||||
# Parameters:
|
||||
# - mod: A ModData object representing the mod being checked.
|
||||
# - dependency_chain: An array that stores the IDs of the mods that have already
|
||||
# been checked to avoid circular dependencies.
|
||||
# - is_required: A boolean indicating whether the mod is a required or optional
|
||||
# dependency. Optional dependencies will not prevent the dependent mod from
|
||||
# loading if they are missing.
|
||||
#
|
||||
# Returns: A boolean indicating whether a circular dependency was detected.
|
||||
static func check_dependencies(mod: ModData, is_required := true, dependency_chain := []) -> bool:
|
||||
var dependency_type := "required" if is_required else "optional"
|
||||
# Get the dependency array based on the is_required flag
|
||||
var dependencies := mod.manifest.dependencies if is_required else mod.manifest.optional_dependencies
|
||||
# Get the ID of the mod being checked
|
||||
var mod_id := mod.dir_name
|
||||
|
||||
ModLoaderLog.debug("Checking dependencies - mod_id: %s %s dependencies: %s" % [mod_id, dependency_type, dependencies], LOG_NAME)
|
||||
|
||||
# Check for circular dependency
|
||||
if mod_id in dependency_chain:
|
||||
ModLoaderLog.debug("%s dependency check - circular dependency detected for mod with ID %s." % [dependency_type.capitalize(), mod_id], LOG_NAME)
|
||||
return true
|
||||
|
||||
# Add mod_id to dependency_chain to avoid circular dependencies
|
||||
dependency_chain.append(mod_id)
|
||||
|
||||
# Loop through each dependency listed in the mod's manifest
|
||||
for dependency_id in dependencies:
|
||||
# Check if dependency is missing
|
||||
if not ModLoaderStore.mod_data.has(dependency_id) or not ModLoaderStore.mod_data[dependency_id].is_loadable or not ModLoaderStore.mod_data[dependency_id].is_active:
|
||||
# Skip to the next dependency if it's optional
|
||||
if not is_required:
|
||||
ModLoaderLog.info("Missing optional dependency - mod: -> %s dependency -> %s" % [mod_id, dependency_id], LOG_NAME)
|
||||
continue
|
||||
_handle_missing_dependency(mod_id, dependency_id)
|
||||
# Flag the mod so it's not loaded later
|
||||
mod.is_loadable = false
|
||||
else:
|
||||
var dependency: ModData = ModLoaderStore.mod_data[dependency_id]
|
||||
|
||||
# Increase the importance score of the dependency by 1
|
||||
dependency.importance += 1
|
||||
ModLoaderLog.debug("%s dependency -> %s importance -> %s" % [dependency_type.capitalize(), dependency_id, dependency.importance], LOG_NAME)
|
||||
|
||||
# Check if the dependency has any dependencies of its own
|
||||
if dependency.manifest.dependencies.size() > 0:
|
||||
if check_dependencies(dependency, is_required, dependency_chain):
|
||||
return true
|
||||
|
||||
# Return false if all dependencies have been resolved
|
||||
return false
|
||||
|
||||
|
||||
# Run load before check on a mod, checking any load_before entries it lists in its
|
||||
# mod_manifest (ie. its manifest.json file). Add the mod to the dependency of the
|
||||
# mods inside the load_before array.
|
||||
static func check_load_before(mod: ModData) -> void:
|
||||
# Skip if no entries in load_before
|
||||
if mod.manifest.load_before.size() == 0:
|
||||
return
|
||||
|
||||
ModLoaderLog.debug("Load before - In mod %s detected." % mod.dir_name, LOG_NAME)
|
||||
|
||||
# For each mod id in load_before
|
||||
for load_before_id in mod.manifest.load_before:
|
||||
# Check if the load_before mod exists
|
||||
if not ModLoaderStore.mod_data.has(load_before_id):
|
||||
ModLoaderLog.debug("Load before - Skipping %s because it's missing" % load_before_id, LOG_NAME)
|
||||
continue
|
||||
|
||||
var load_before_mod_dependencies := ModLoaderStore.mod_data[load_before_id].manifest.dependencies as PackedStringArray
|
||||
|
||||
# Check if it's already a dependency
|
||||
if mod.dir_name in load_before_mod_dependencies:
|
||||
ModLoaderLog.debug("Load before - Skipping because it's already a dependency for %s" % load_before_id, LOG_NAME)
|
||||
continue
|
||||
|
||||
# Add the mod to the dependency array
|
||||
load_before_mod_dependencies.append(mod.dir_name)
|
||||
ModLoaderStore.mod_data[load_before_id].manifest.dependencies = load_before_mod_dependencies
|
||||
|
||||
ModLoaderLog.debug("Load before - Added %s as dependency for %s" % [mod.dir_name, load_before_id], LOG_NAME)
|
||||
|
||||
|
||||
# Get the load order of mods, using a custom sorter
|
||||
static func get_load_order(mod_data_array: Array) -> Array:
|
||||
# Add loadable mods to the mod load order array
|
||||
for mod in mod_data_array:
|
||||
mod = mod as ModData
|
||||
if mod.is_loadable:
|
||||
ModLoaderStore.mod_load_order.append(mod)
|
||||
|
||||
# Sort mods by the importance value
|
||||
ModLoaderStore.mod_load_order.sort_custom(Callable(CompareImportance, "_compare_importance"))
|
||||
return ModLoaderStore.mod_load_order
|
||||
|
||||
|
||||
# Handles a missing dependency for a given mod ID. Logs an error message indicating the missing dependency and adds
|
||||
# the dependency ID to the mod_missing_dependencies dictionary for the specified mod.
|
||||
static func _handle_missing_dependency(mod_id: String, dependency_id: String) -> void:
|
||||
ModLoaderLog.error("Missing dependency - mod: -> %s dependency -> %s" % [mod_id, dependency_id], LOG_NAME)
|
||||
# if mod is not present in the missing dependencies array
|
||||
if not ModLoaderStore.mod_missing_dependencies.has(mod_id):
|
||||
# add it
|
||||
ModLoaderStore.mod_missing_dependencies[mod_id] = []
|
||||
|
||||
ModLoaderStore.mod_missing_dependencies[mod_id].append(dependency_id)
|
||||
|
||||
|
||||
# Inner class so the sort function can be called by get_load_order()
|
||||
class CompareImportance:
|
||||
# Custom sorter that orders mods by important
|
||||
static func _compare_importance(a: ModData, b: ModData) -> bool:
|
||||
if a.importance > b.importance:
|
||||
return true # a -> b
|
||||
else:
|
||||
return false # b -> a
|
1
addons/mod_loader/internal/dependency.gd.uid
Normal file
1
addons/mod_loader/internal/dependency.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bnmjbsvid8sxk
|
241
addons/mod_loader/internal/file.gd
Normal file
241
addons/mod_loader/internal/file.gd
Normal file
@@ -0,0 +1,241 @@
|
||||
class_name _ModLoaderFile
|
||||
extends RefCounted
|
||||
|
||||
|
||||
# This Class provides util functions for working with files.
|
||||
# Currently all of the included functions are internal and should only be used by the mod loader itself.
|
||||
|
||||
const LOG_NAME := "ModLoader:File"
|
||||
|
||||
# Get Data
|
||||
# =============================================================================
|
||||
|
||||
# Parses JSON from a given file path and returns a [Dictionary].
|
||||
# Returns an empty [Dictionary] if no file exists (check with size() < 1)
|
||||
static func get_json_as_dict(path: String) -> Dictionary:
|
||||
if not file_exists(path):
|
||||
return {}
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
var error = file.get_open_error()
|
||||
|
||||
if file == null:
|
||||
ModLoaderLog.error("Error opening file. Code: %s" % error, LOG_NAME)
|
||||
|
||||
var content := file.get_as_text()
|
||||
return _get_json_string_as_dict(content)
|
||||
|
||||
|
||||
# Parses JSON from a given [String] and returns a [Dictionary].
|
||||
# Returns an empty [Dictionary] on error (check with size() < 1)
|
||||
static func _get_json_string_as_dict(string: String) -> Dictionary:
|
||||
if string == "":
|
||||
return {}
|
||||
|
||||
var test_json_conv = JSON.new()
|
||||
var error = test_json_conv.parse(string)
|
||||
if not error == OK:
|
||||
ModLoaderLog.error("Error parsing JSON", LOG_NAME)
|
||||
return {}
|
||||
|
||||
if not test_json_conv.data is Dictionary:
|
||||
ModLoaderLog.error("JSON is not a dictionary", LOG_NAME)
|
||||
return {}
|
||||
return test_json_conv.data
|
||||
|
||||
|
||||
# Opens the path and reports all the errors that can happen
|
||||
static func open_dir(folder_path: String) -> DirAccess:
|
||||
var mod_dir := DirAccess.open(folder_path)
|
||||
if mod_dir == null:
|
||||
ModLoaderLog.error("Can't open mod folder %s" % [folder_path], LOG_NAME)
|
||||
return null
|
||||
|
||||
var mod_dir_open_error := mod_dir.get_open_error()
|
||||
if not mod_dir_open_error == OK:
|
||||
ModLoaderLog.info(
|
||||
"Can't open mod folder %s (Error: %s, %s)" %
|
||||
[folder_path, mod_dir_open_error, error_string(mod_dir_open_error)],
|
||||
LOG_NAME
|
||||
)
|
||||
return null
|
||||
var mod_dir_listdir_error := mod_dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
|
||||
if not mod_dir_listdir_error == OK:
|
||||
ModLoaderLog.error(
|
||||
"Can't read mod folder %s (Error: %s, %s)" %
|
||||
[folder_path, mod_dir_listdir_error, error_string(mod_dir_listdir_error)],
|
||||
LOG_NAME
|
||||
)
|
||||
return null
|
||||
|
||||
return mod_dir
|
||||
|
||||
|
||||
static func get_json_as_dict_from_zip(zip_path: String, file_path: String, is_full_path := false) -> Dictionary:
|
||||
if not file_exists(zip_path):
|
||||
ModLoaderLog.error("Zip was not found at %s" % [zip_path], LOG_NAME)
|
||||
return {}
|
||||
|
||||
var reader := ZIPReader.new()
|
||||
|
||||
var zip_open_error := reader.open(zip_path)
|
||||
if not zip_open_error == OK:
|
||||
ModLoaderLog.error(
|
||||
"Error opening zip. (Error: %s, %s)" %
|
||||
[zip_open_error, error_string(zip_open_error)],
|
||||
LOG_NAME
|
||||
)
|
||||
|
||||
var full_path := ""
|
||||
if is_full_path:
|
||||
full_path = file_path
|
||||
if not reader.file_exists(full_path):
|
||||
ModLoaderLog.error("File was not found in zip at path %s" % [file_path], LOG_NAME)
|
||||
return {}
|
||||
else:
|
||||
# Go through all files and find the file
|
||||
# Since we don't know which mod folder will be in the zip to get the exact full path
|
||||
# (zip naming is not required to be the name as folder name)
|
||||
for path in reader.get_files():
|
||||
if Array(path.rsplit("/", false, 1)).back() == file_path:
|
||||
full_path = path
|
||||
if not full_path:
|
||||
ModLoaderLog.error("File was not found in zip at path %s" % [file_path], LOG_NAME)
|
||||
return {}
|
||||
|
||||
var content := reader.read_file(full_path).get_string_from_utf8()
|
||||
return _get_json_string_as_dict(content)
|
||||
|
||||
|
||||
# Save Data
|
||||
# =============================================================================
|
||||
|
||||
# Saves a dictionary to a file, as a JSON string
|
||||
static func _save_string_to_file(save_string: String, filepath: String) -> bool:
|
||||
# Create directory if it doesn't exist yet
|
||||
var file_directory := filepath.get_base_dir()
|
||||
var dir := DirAccess.open(file_directory)
|
||||
|
||||
_code_note(str(
|
||||
"View error codes here:",
|
||||
"https://docs.godotengine.org/en/stable/classes/class_%40globalscope.html#enum-globalscope-error"
|
||||
))
|
||||
|
||||
if not dir:
|
||||
var makedir_error := DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(file_directory))
|
||||
if not makedir_error == OK:
|
||||
ModLoaderLog.fatal("Encountered an error (%s) when attempting to create a directory, with the path: %s" % [makedir_error, file_directory], LOG_NAME)
|
||||
return false
|
||||
|
||||
# Save data to the file
|
||||
var file := FileAccess.open(filepath, FileAccess.WRITE)
|
||||
|
||||
if not file:
|
||||
ModLoaderLog.fatal("Encountered an error (%s) when attempting to write to a file, with the path: %s" % [FileAccess.get_open_error(), filepath], LOG_NAME)
|
||||
return false
|
||||
|
||||
file.store_string(save_string)
|
||||
file.close()
|
||||
|
||||
return true
|
||||
|
||||
|
||||
# Saves a dictionary to a file, as a JSON string
|
||||
static func save_dictionary_to_json_file(data: Dictionary, filepath: String) -> bool:
|
||||
var json_string := JSON.stringify(data, "\t")
|
||||
return _save_string_to_file(json_string, filepath)
|
||||
|
||||
|
||||
# Remove Data
|
||||
# =============================================================================
|
||||
|
||||
# Removes a file from the given path
|
||||
static func remove_file(file_path: String) -> bool:
|
||||
var dir := DirAccess.open(file_path)
|
||||
|
||||
if not dir.file_exists(file_path):
|
||||
ModLoaderLog.error("No file found at \"%s\"" % file_path, LOG_NAME)
|
||||
return false
|
||||
|
||||
var error := dir.remove(file_path)
|
||||
|
||||
if error:
|
||||
ModLoaderLog.error(
|
||||
"Encountered an error (%s) when attempting to remove the file, with the path: %s"
|
||||
% [error, file_path],
|
||||
LOG_NAME
|
||||
)
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
# Checks
|
||||
# =============================================================================
|
||||
|
||||
static func file_exists(path: String, zip_path: String = "") -> bool:
|
||||
if not zip_path.is_empty():
|
||||
return file_exists_in_zip(zip_path, path)
|
||||
|
||||
var exists := FileAccess.file_exists(path)
|
||||
|
||||
# If the file is not found, check if it has been remapped because it is a Resource.
|
||||
if not exists:
|
||||
exists = ResourceLoader.exists(path)
|
||||
|
||||
return exists
|
||||
|
||||
|
||||
static func dir_exists(path: String) -> bool:
|
||||
return DirAccess.dir_exists_absolute(path)
|
||||
|
||||
|
||||
static func file_exists_in_zip(zip_path: String, path: String) -> bool:
|
||||
var reader := zip_reader_open(zip_path)
|
||||
if not reader:
|
||||
return false
|
||||
|
||||
if _ModLoaderGodot.is_version_below(_ModLoaderGodot.ENGINE_VERSION_HEX_4_2_0):
|
||||
return reader.get_files().has(path.trim_prefix("res://"))
|
||||
else:
|
||||
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)
|
||||
if err != OK:
|
||||
ModLoaderLog.error("Could not open zip with error: %s" % error_string(err), LOG_NAME)
|
||||
return
|
||||
return reader
|
||||
|
||||
|
||||
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
|
||||
# modders in understanding and troubleshooting issues.
|
||||
static func _code_note(_msg:String):
|
||||
pass
|
1
addons/mod_loader/internal/file.gd.uid
Normal file
1
addons/mod_loader/internal/file.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d34sgvhw73mtb
|
116
addons/mod_loader/internal/godot.gd
Normal file
116
addons/mod_loader/internal/godot.gd
Normal file
@@ -0,0 +1,116 @@
|
||||
@tool
|
||||
class_name _ModLoaderGodot
|
||||
extends Object
|
||||
|
||||
|
||||
# This Class provides methods for interacting with Godot.
|
||||
# Currently all of the included methods are internal and should only be used by the mod loader itself.
|
||||
|
||||
const LOG_NAME := "ModLoader:Godot"
|
||||
const AUTOLOAD_CONFIG_HELP_MSG := "To configure your autoloads, go to Project > Project Settings > Autoload."
|
||||
|
||||
const ENGINE_VERSION_HEX_4_2_2 := 0x040202
|
||||
const ENGINE_VERSION_HEX_4_2_0 := 0x040200
|
||||
|
||||
static var engine_version_hex: int = Engine.get_version_info().hex
|
||||
|
||||
|
||||
# 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 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)
|
||||
|
||||
# Check if the Store is before the ModLoader
|
||||
if not autoload_name_before_index < autoload_name_after_index:
|
||||
var error_msg := (
|
||||
"Expected %s ( position: %s ) to be loaded before %s ( position: %s ). "
|
||||
% [autoload_name_before, autoload_name_before_index, autoload_name_after, autoload_name_after_index]
|
||||
)
|
||||
var help_msg := AUTOLOAD_CONFIG_HELP_MSG if OS.has_feature("editor") else ""
|
||||
|
||||
if trigger_error:
|
||||
var final_message = error_msg + help_msg
|
||||
push_error(final_message)
|
||||
ModLoaderLog._write_to_log_file(final_message)
|
||||
ModLoaderLog._write_to_log_file(JSON.stringify(get_stack(), " "))
|
||||
assert(false, final_message)
|
||||
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
# Check the index position of the provided autoload (0 = 1st, 1 = 2nd, etc).
|
||||
# Returns a bool if the position does not match.
|
||||
# Optionally triggers a fatal error
|
||||
static func check_autoload_position(autoload_name: String, position_index: int, trigger_error := false) -> bool:
|
||||
var autoload_array := get_autoload_array()
|
||||
var autoload_index := autoload_array.find(autoload_name)
|
||||
var position_matches := autoload_index == position_index
|
||||
|
||||
if not position_matches and trigger_error:
|
||||
var error_msg := (
|
||||
"Expected %s to be the autoload in position %s, but this is currently %s. "
|
||||
% [autoload_name, str(position_index + 1), autoload_array[position_index]]
|
||||
)
|
||||
var help_msg := AUTOLOAD_CONFIG_HELP_MSG if OS.has_feature("editor") else ""
|
||||
var final_message = error_msg + help_msg
|
||||
|
||||
push_error(final_message)
|
||||
ModLoaderLog._write_to_log_file(final_message)
|
||||
ModLoaderLog._write_to_log_file(JSON.stringify(get_stack(), " "))
|
||||
assert(false, final_message)
|
||||
|
||||
return position_matches
|
||||
|
||||
|
||||
# Get an array of all autoloads -> ["autoload/AutoloadName", ...]
|
||||
static func get_autoload_array() -> Array:
|
||||
var autoloads := []
|
||||
|
||||
# Get all autoload settings
|
||||
for prop in ProjectSettings.get_property_list():
|
||||
var name: String = prop.name
|
||||
if name.begins_with("autoload/"):
|
||||
autoloads.append(name.trim_prefix("autoload/"))
|
||||
|
||||
return autoloads
|
||||
|
||||
|
||||
# Get the index of a specific autoload
|
||||
static func get_autoload_index(autoload_name: String) -> int:
|
||||
var autoloads := get_autoload_array()
|
||||
var autoload_index := autoloads.find(autoload_name)
|
||||
|
||||
return autoload_index
|
||||
|
||||
|
||||
static func is_version_below(version_hex: int) -> bool:
|
||||
return engine_version_hex < version_hex
|
||||
|
||||
|
||||
static func is_version_above(version_hex: int) -> bool:
|
||||
return engine_version_hex > version_hex
|
1
addons/mod_loader/internal/godot.gd.uid
Normal file
1
addons/mod_loader/internal/godot.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dc86ulvsd1da7
|
60
addons/mod_loader/internal/hooks.gd
Normal file
60
addons/mod_loader/internal/hooks.gd
Normal file
@@ -0,0 +1,60 @@
|
||||
@tool
|
||||
class_name _ModLoaderHooks
|
||||
extends Object
|
||||
|
||||
# This Class provides utility functions for working with Mod Hooks.
|
||||
# Currently all of the included functions are internal and should only be used by the mod loader itself.
|
||||
# Functions with external use are exposed through the ModLoaderMod class.
|
||||
|
||||
const LOG_NAME := "ModLoader:Hooks"
|
||||
|
||||
static var any_mod_hooked := false
|
||||
|
||||
|
||||
## Internal ModLoader method. [br]
|
||||
## To add hooks from a mod use [method ModLoaderMod.add_hook].
|
||||
static func add_hook(mod_callable: Callable, script_path: String, method_name: String) -> void:
|
||||
any_mod_hooked = true
|
||||
var hash := get_hook_hash(script_path, method_name)
|
||||
if not ModLoaderStore.modding_hooks.has(hash):
|
||||
ModLoaderStore.modding_hooks[hash] = []
|
||||
ModLoaderStore.modding_hooks[hash].push_back(mod_callable)
|
||||
ModLoaderLog.debug('Added hook "%s" to method: "%s" in script: "%s"'
|
||||
% [mod_callable.get_method(), method_name, script_path], LOG_NAME
|
||||
)
|
||||
|
||||
if not ModLoaderStore.hooked_script_paths.has(script_path):
|
||||
ModLoaderStore.hooked_script_paths[script_path] = [method_name]
|
||||
elif not ModLoaderStore.hooked_script_paths[script_path].has(method_name):
|
||||
ModLoaderStore.hooked_script_paths[script_path].append(method_name)
|
||||
|
||||
|
||||
static func call_hooks(vanilla_method: Callable, args: Array, hook_hash: int) -> Variant:
|
||||
var hooks: Array = ModLoaderStore.modding_hooks.get(hook_hash, [])
|
||||
if hooks.is_empty():
|
||||
return vanilla_method.callv(args)
|
||||
|
||||
var chain := ModLoaderHookChain.new(vanilla_method.get_object(), [vanilla_method] + hooks)
|
||||
return chain.execute_next(args)
|
||||
|
||||
|
||||
static func call_hooks_async(vanilla_method: Callable, args: Array, hook_hash: int) -> Variant:
|
||||
var hooks: Array = ModLoaderStore.modding_hooks.get(hook_hash, [])
|
||||
if hooks.is_empty():
|
||||
return await vanilla_method.callv(args)
|
||||
|
||||
var chain := ModLoaderHookChain.new(vanilla_method.get_object(), [vanilla_method] + hooks)
|
||||
return await chain.execute_next_async(args)
|
||||
|
||||
|
||||
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)
|
1
addons/mod_loader/internal/hooks.gd.uid
Normal file
1
addons/mod_loader/internal/hooks.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://yrog1crr7kxp
|
74
addons/mod_loader/internal/mod_hook_packer.gd
Normal file
74
addons/mod_loader/internal/mod_hook_packer.gd
Normal file
@@ -0,0 +1,74 @@
|
||||
class_name _ModLoaderModHookPacker
|
||||
extends RefCounted
|
||||
|
||||
|
||||
# This class is used to generate mod hooks on demand and pack them into a zip file.
|
||||
# Currently all of the included functions are internal and should only be used by the mod loader itself.
|
||||
|
||||
const LOG_NAME := "ModLoader:ModHookPacker"
|
||||
|
||||
|
||||
static func start() -> void:
|
||||
ModLoaderLog.info("Generating mod hooks .zip", LOG_NAME)
|
||||
var hook_pre_processor = _ModLoaderModHookPreProcessor.new()
|
||||
hook_pre_processor.process_begin()
|
||||
|
||||
var mod_hook_pack_path := _ModLoaderPath.get_path_to_hook_pack()
|
||||
|
||||
# Create mod hook pack path if necessary
|
||||
if not DirAccess.dir_exists_absolute(mod_hook_pack_path.get_base_dir()):
|
||||
var error := DirAccess.make_dir_recursive_absolute(mod_hook_pack_path.get_base_dir())
|
||||
if not error == OK:
|
||||
ModLoaderLog.error("Error creating the mod hook directory at %s" % mod_hook_pack_path, LOG_NAME)
|
||||
return
|
||||
ModLoaderLog.debug("Created dir at: %s" % mod_hook_pack_path, LOG_NAME)
|
||||
|
||||
# Create mod hook zip
|
||||
var zip_writer := ZIPPacker.new()
|
||||
var error: Error
|
||||
|
||||
if not FileAccess.file_exists(mod_hook_pack_path):
|
||||
# Clear cache if the hook pack does not exist
|
||||
_ModLoaderCache.remove_data("hooks")
|
||||
error = zip_writer.open(mod_hook_pack_path)
|
||||
else:
|
||||
# If there is a pack already, append to it
|
||||
error = zip_writer.open(mod_hook_pack_path, ZIPPacker.APPEND_ADDINZIP)
|
||||
if not error == OK:
|
||||
ModLoaderLog.error("Error (%s) writing to hooks zip, consider deleting this file: %s" % [error, mod_hook_pack_path], LOG_NAME)
|
||||
return
|
||||
|
||||
ModLoaderLog.debug("Scripts requiring hooks: %s" % [ModLoaderStore.hooked_script_paths], LOG_NAME)
|
||||
|
||||
var cache := _ModLoaderCache.get_data("hooks")
|
||||
var cached_script_paths: Dictionary = {} if cache.is_empty() or not cache.has("hooked_script_paths") else cache.hooked_script_paths
|
||||
if cached_script_paths == ModLoaderStore.hooked_script_paths:
|
||||
ModLoaderLog.info("Scripts are already processed according to cache, skipping process.", LOG_NAME)
|
||||
zip_writer.close()
|
||||
return
|
||||
|
||||
var new_hooks_created := false
|
||||
# Get all scripts that need processing
|
||||
for path in ModLoaderStore.hooked_script_paths.keys():
|
||||
var method_mask: Array[String] = []
|
||||
method_mask.assign(ModLoaderStore.hooked_script_paths[path])
|
||||
var processed_source_code := hook_pre_processor.process_script_verbose(path, false, method_mask)
|
||||
|
||||
# Skip writing to the zip if no new hooks were created for this script
|
||||
if not hook_pre_processor.script_paths_hooked.has(path):
|
||||
ModLoaderLog.debug("No new hooks were created in \"%s\", skipping writing to hook pack." % path, LOG_NAME)
|
||||
continue
|
||||
|
||||
zip_writer.start_file(path.trim_prefix("res://"))
|
||||
zip_writer.write_file(processed_source_code.to_utf8_buffer())
|
||||
zip_writer.close_file()
|
||||
|
||||
ModLoaderLog.debug("Hooks created for script: %s" % path, LOG_NAME)
|
||||
new_hooks_created = true
|
||||
|
||||
if new_hooks_created:
|
||||
_ModLoaderCache.update_data("hooks", {"hooked_script_paths": ModLoaderStore.hooked_script_paths})
|
||||
_ModLoaderCache.save_to_file()
|
||||
ModLoader.new_hooks_created.emit()
|
||||
|
||||
zip_writer.close()
|
1
addons/mod_loader/internal/mod_hook_packer.gd.uid
Normal file
1
addons/mod_loader/internal/mod_hook_packer.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dnwq8741pln26
|
649
addons/mod_loader/internal/mod_hook_preprocessor.gd
Normal file
649
addons/mod_loader/internal/mod_hook_preprocessor.gd
Normal file
@@ -0,0 +1,649 @@
|
||||
@tool
|
||||
class_name _ModLoaderModHookPreProcessor
|
||||
extends RefCounted
|
||||
|
||||
|
||||
# This class is used to process the source code from a script at a given path.
|
||||
# Currently all of the included functions are internal and should only be used by the mod loader itself.
|
||||
|
||||
const LOG_NAME := "ModLoader:ModHookPreProcessor"
|
||||
|
||||
const REQUIRE_EXPLICIT_ADDITION := false
|
||||
const METHOD_PREFIX := "vanilla_"
|
||||
const HASH_COLLISION_ERROR := \
|
||||
"MODDING HOOKS ERROR: Hash collision between %s and %s. The collision can be resolved by renaming one of the methods or changing their script's path."
|
||||
const MOD_LOADER_HOOKS_START_STRING := \
|
||||
"\n# ModLoader Hooks - The following code has been automatically added by the Godot Mod Loader."
|
||||
|
||||
## \\bfunc\\b\\s+ -> Match the word 'func' and one or more whitespace characters
|
||||
## \\b%s\\b -> the function name
|
||||
## (?:.*\\n*)*?\\s*\\( -> Match any character between zero and unlimited times, but be lazy
|
||||
## and only do this until a '(' is found.
|
||||
const REGEX_MATCH_FUNC_WITH_WHITESPACE := "\\bfunc\\b\\s+\\b%s\\b(?:.*\\n*)*?\\s*\\("
|
||||
|
||||
## finds function names used as setters and getters (excluding inline definitions)
|
||||
## group 2 and 4 contain the setter/getter names
|
||||
var regex_getter_setter := RegEx.create_from_string("(.*?[sg]et\\s*=\\s*)(\\w+)(\\g<1>)?(\\g<2>)?")
|
||||
|
||||
## finds every instance where super() is called
|
||||
## returns only the super word, excluding the (, as match to make substitution easier
|
||||
var regex_super_call := RegEx.create_from_string("\\bsuper(?=\\s*\\()")
|
||||
|
||||
## Matches the indented function body.
|
||||
## Needs to start from the : of a function declaration to work (.search() offset param)
|
||||
## The body of a function is every line that is empty or starts with an indent or comment
|
||||
var regex_func_body := RegEx.create_from_string("(?smn)\\N*(\\n^(([\\t #]+\\N*)|$))*")
|
||||
|
||||
## Just await between word boundaries
|
||||
var regex_keyword_await := RegEx.create_from_string("\\bawait\\b")
|
||||
|
||||
## Just void between word boundaries
|
||||
var regex_keyword_void := RegEx.create_from_string("\\bvoid\\b")
|
||||
|
||||
var hashmap := {}
|
||||
var script_paths_hooked := {}
|
||||
|
||||
|
||||
func process_begin() -> void:
|
||||
hashmap.clear()
|
||||
|
||||
## Calls [method process_script] with additional logging
|
||||
func process_script_verbose(path: String, enable_hook_check := false, method_mask: Array[String] = []) -> String:
|
||||
var start_time := Time.get_ticks_msec()
|
||||
ModLoaderLog.debug("Start processing script at path: %s" % path, LOG_NAME)
|
||||
var processed := process_script(path, enable_hook_check, method_mask)
|
||||
ModLoaderLog.debug("Finished processing script at path: %s in %s ms" % [path, Time.get_ticks_msec() - start_time], LOG_NAME)
|
||||
return processed
|
||||
|
||||
|
||||
## [param path]: File path to the script to be processed.[br]
|
||||
## [param enable_hook_check]: Adds a check that _ModLoaderHooks.any_mod_hooked is [code]true[/code] to the processed method, reducing hash checks.[br]
|
||||
## [param method_mask]: If provided, only methods in this [Array] will be processed.[br]
|
||||
func process_script(path: String, enable_hook_check := false, method_mask: Array[String] = []) -> String:
|
||||
var current_script := load(path) as GDScript
|
||||
var source_code := current_script.source_code
|
||||
var source_code_additions := ""
|
||||
|
||||
# We need to stop all vanilla methods from forming inheritance chains,
|
||||
# since the generated methods will fulfill inheritance requirements
|
||||
var class_prefix := str(hash(path))
|
||||
var method_store: Array[String] = []
|
||||
|
||||
var getters_setters := collect_getters_and_setters(source_code)
|
||||
var moddable_methods := current_script.get_script_method_list().filter(
|
||||
is_func_moddable.bind(source_code, getters_setters)
|
||||
)
|
||||
|
||||
var methods_hooked := {}
|
||||
for method in moddable_methods:
|
||||
if method.name in method_store:
|
||||
continue
|
||||
|
||||
var full_prefix := "%s%s_" % [METHOD_PREFIX, class_prefix]
|
||||
|
||||
# Check if the method name starts with the prefix added by `edit_vanilla_method()`.
|
||||
# This indicates that the method was previously processed, possibly by the export plugin.
|
||||
# If so, store the method name (excluding the prefix) in `methods_hooked`.
|
||||
if method.name.begins_with(full_prefix):
|
||||
var method_name_vanilla: String = method.name.trim_prefix(full_prefix)
|
||||
methods_hooked[method_name_vanilla] = true
|
||||
continue
|
||||
# This ensures we avoid creating a hook for the 'imposter' method, which
|
||||
# is generated by `build_mod_hook_string()` and has the vanilla method name.
|
||||
if methods_hooked.has(method.name):
|
||||
continue
|
||||
|
||||
# If a mask is provided, only methods with their name in the mask will be converted.
|
||||
# Can't be filtered before the loop since it removes prefixed methods required by the previous check.
|
||||
if not method_mask.is_empty():
|
||||
if not method.name in method_mask:
|
||||
continue
|
||||
|
||||
var type_string := get_return_type_string(method.return)
|
||||
var is_static := true if method.flags == METHOD_FLAG_STATIC + METHOD_FLAG_NORMAL else false
|
||||
|
||||
var func_def: RegExMatch = match_func_with_whitespace(method.name, source_code)
|
||||
if not func_def: # Could not regex match a function with that name
|
||||
continue # Means invalid Script, should never happen
|
||||
|
||||
# Processing does not cover methods in subclasses yet.
|
||||
# If a function with the same name was found in a subclass,
|
||||
# try again until we find the top level one
|
||||
var max_loop := 1000
|
||||
while not is_top_level_func(source_code, func_def.get_start(), is_static): # indent before "func"
|
||||
func_def = match_func_with_whitespace(method.name, source_code, func_def.get_end())
|
||||
if not func_def or max_loop <= 0: # Couldn't match any func like before
|
||||
break # Means invalid Script, unless it's a child script.
|
||||
# In such cases, the method name might be listed in the script_method_list
|
||||
# but absent in the actual source_code.
|
||||
max_loop -= 1
|
||||
|
||||
if not func_def: # If no valid function definition is found after processing.
|
||||
continue # Skip to the next iteration.
|
||||
|
||||
# Shift the func_def_end index back by one to start on the opening parentheses.
|
||||
# Because the match_func_with_whitespace().get_end() is the index after the opening parentheses.
|
||||
var closing_paren_index := get_closing_paren_index(func_def.get_end() - 1, source_code)
|
||||
|
||||
var func_body_start_index := get_func_body_start_index(closing_paren_index, source_code)
|
||||
if func_body_start_index == -1: # The function is malformed, opening ( was not closed by )
|
||||
continue # Means invalid Script, should never happen
|
||||
|
||||
var func_body := match_method_body(method.name, func_body_start_index, source_code)
|
||||
if not func_body: # No indented lines found
|
||||
continue # Means invalid Script, should never happen
|
||||
|
||||
var is_async := is_func_async(func_body.get_string())
|
||||
var can_return := can_return(source_code, method.name, closing_paren_index, func_body_start_index)
|
||||
var method_arg_string_with_defaults_and_types := get_function_parameters(method.name, source_code, is_static)
|
||||
var method_arg_string_names_only := get_function_arg_name_string(method.args)
|
||||
|
||||
var hook_id := _ModLoaderHooks.get_hook_hash(path, method.name)
|
||||
var hook_id_data := [path, method.name, true]
|
||||
if hashmap.has(hook_id):
|
||||
push_error(HASH_COLLISION_ERROR%[hashmap[hook_id], hook_id_data])
|
||||
hashmap[hook_id] = hook_id_data
|
||||
|
||||
var mod_loader_hook_string := build_mod_hook_string(
|
||||
method.name,
|
||||
method_arg_string_names_only,
|
||||
method_arg_string_with_defaults_and_types,
|
||||
type_string,
|
||||
can_return,
|
||||
is_static,
|
||||
is_async,
|
||||
hook_id,
|
||||
full_prefix,
|
||||
enable_hook_check
|
||||
)
|
||||
|
||||
# Store the method name
|
||||
# Not sure if there is a way to get only the local methods in a script,
|
||||
# get_script_method_list() returns a full list,
|
||||
# including the methods from the scripts it extends,
|
||||
# which leads to multiple entries in the list if they are overridden by the child script.
|
||||
method_store.push_back(method.name)
|
||||
source_code = edit_vanilla_method(
|
||||
method.name,
|
||||
source_code,
|
||||
func_def,
|
||||
func_body,
|
||||
full_prefix
|
||||
)
|
||||
source_code_additions += "\n%s" % mod_loader_hook_string
|
||||
|
||||
script_paths_hooked[path] = true
|
||||
|
||||
# If we have some additions to the code, append them at the end
|
||||
if source_code_additions != "":
|
||||
source_code = "%s\n%s\n%s" % [source_code, MOD_LOADER_HOOKS_START_STRING, source_code_additions]
|
||||
|
||||
return source_code
|
||||
|
||||
|
||||
static func is_func_moddable(method: Dictionary, source_code: String, getters_setters := {}) -> bool:
|
||||
if getters_setters.has(method.name):
|
||||
return false
|
||||
|
||||
var method_first_line_start := _ModLoaderModHookPreProcessor.get_index_at_method_start(method.name, source_code)
|
||||
if method_first_line_start == -1:
|
||||
return false
|
||||
|
||||
if not _ModLoaderModHookPreProcessor.is_func_marked_moddable(method_first_line_start, source_code):
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
func is_func_async(func_body_text: String) -> bool:
|
||||
if not func_body_text.contains("await"):
|
||||
return false
|
||||
|
||||
var lines := func_body_text.split("\n")
|
||||
var in_multiline_string := false
|
||||
var current_multiline_delimiter := ""
|
||||
|
||||
for _line in lines:
|
||||
var line: String = _line
|
||||
var char_index := 0
|
||||
while char_index < line.length():
|
||||
if in_multiline_string:
|
||||
# Check if we are exiting the multiline string
|
||||
if line.substr(char_index).begins_with(current_multiline_delimiter):
|
||||
in_multiline_string = false
|
||||
char_index += 3
|
||||
else:
|
||||
char_index += 1
|
||||
continue
|
||||
|
||||
# Comments: Skip the rest of the line
|
||||
if line.substr(char_index).begins_with("#"):
|
||||
break
|
||||
|
||||
# Check for multiline string start
|
||||
if line.substr(char_index).begins_with('"""') or line.substr(char_index).begins_with("'''"):
|
||||
in_multiline_string = true
|
||||
current_multiline_delimiter = line.substr(char_index, 3)
|
||||
char_index += 3
|
||||
continue
|
||||
|
||||
# Check for single-quoted strings
|
||||
if line[char_index] == '"' or line[char_index] == "'":
|
||||
var delimiter = line[char_index]
|
||||
char_index += 1
|
||||
while char_index < line.length() and line[char_index] != delimiter:
|
||||
# Skip escaped quotes
|
||||
if line[char_index] == "\\":
|
||||
char_index += 1
|
||||
char_index += 1
|
||||
char_index += 1 # Skip the closing quote
|
||||
continue
|
||||
|
||||
# Check for the "await" keyword
|
||||
if not line.substr(char_index).begins_with("await"):
|
||||
char_index += 1
|
||||
continue
|
||||
|
||||
# Ensure "await" is a standalone word
|
||||
var start := char_index -1 if char_index > 0 else 0
|
||||
if regex_keyword_await.search(line.substr(start)):
|
||||
return true # Just return here, we don't need every occurence
|
||||
# i += 5 # Normal parser: Skip the keyword
|
||||
else:
|
||||
char_index += 1
|
||||
|
||||
return false
|
||||
|
||||
|
||||
static func get_function_arg_name_string(args: Array) -> String:
|
||||
var arg_string := ""
|
||||
for x in args.size():
|
||||
if x == args.size() -1:
|
||||
arg_string += args[x].name
|
||||
else:
|
||||
arg_string += "%s, " % args[x].name
|
||||
|
||||
return arg_string
|
||||
|
||||
|
||||
static func get_function_parameters(method_name: String, text: String, is_static: bool, offset := 0) -> String:
|
||||
var result := match_func_with_whitespace(method_name, text, offset)
|
||||
if result == null:
|
||||
return ""
|
||||
|
||||
# Find the index of the opening parenthesis
|
||||
var opening_paren_index := result.get_end() - 1
|
||||
if opening_paren_index == -1:
|
||||
return ""
|
||||
|
||||
if not is_top_level_func(text, result.get_start(), is_static):
|
||||
return get_function_parameters(method_name, text, is_static, result.get_end())
|
||||
|
||||
# Shift the func_def_end index back by one to start on the opening parentheses.
|
||||
# Because the match_func_with_whitespace().get_end() is the index after the opening parentheses.
|
||||
var closing_paren_index := get_closing_paren_index(opening_paren_index - 1, text)
|
||||
if closing_paren_index == -1:
|
||||
return ""
|
||||
|
||||
# Extract the substring between the parentheses
|
||||
var param_string := text.substr(opening_paren_index + 1, closing_paren_index - opening_paren_index - 1)
|
||||
|
||||
# Clean whitespace characters (spaces, newlines, tabs)
|
||||
param_string = param_string.strip_edges()\
|
||||
.replace(" ", "")\
|
||||
.replace("\t", "")\
|
||||
.replace(",", ", ")\
|
||||
.replace(":", ": ")
|
||||
|
||||
return param_string
|
||||
|
||||
|
||||
static func get_closing_paren_index(opening_paren_index: int, text: String) -> int:
|
||||
# Use a stack counter to match parentheses
|
||||
var stack := 0
|
||||
var closing_paren_index := opening_paren_index
|
||||
while closing_paren_index < text.length():
|
||||
var char := text[closing_paren_index]
|
||||
if char == '(':
|
||||
stack += 1
|
||||
elif char == ')':
|
||||
stack -= 1
|
||||
if stack == 0:
|
||||
break
|
||||
closing_paren_index += 1
|
||||
|
||||
# If the stack is not empty, that means there's no matching closing parenthesis
|
||||
if stack != 0:
|
||||
return -1
|
||||
|
||||
return closing_paren_index
|
||||
|
||||
|
||||
func edit_vanilla_method(
|
||||
method_name: String,
|
||||
text: String,
|
||||
func_def: RegExMatch,
|
||||
func_body: RegExMatch,
|
||||
prefix := METHOD_PREFIX,
|
||||
) -> String:
|
||||
text = fix_method_super(method_name, func_body, text)
|
||||
text = text.erase(func_def.get_start(), func_def.get_end() - func_def.get_start())
|
||||
text = text.insert(func_def.get_start(), "func %s%s(" % [prefix, method_name])
|
||||
|
||||
return text
|
||||
|
||||
|
||||
func fix_method_super(method_name: String, func_body: RegExMatch, text: String) -> String:
|
||||
if _ModLoaderGodot.is_version_below(_ModLoaderGodot.ENGINE_VERSION_HEX_4_2_2):
|
||||
return fix_method_super_before_4_2_2(method_name, func_body, text)
|
||||
|
||||
return regex_super_call.sub(
|
||||
text, "super.%s" % method_name,
|
||||
true, func_body.get_start(), func_body.get_end()
|
||||
)
|
||||
|
||||
|
||||
# https://github.com/godotengine/godot/pull/86052
|
||||
# Quote:
|
||||
# When the end argument of RegEx.sub was used,
|
||||
# it would truncate the Subject String before even doing the substitution.
|
||||
func fix_method_super_before_4_2_2(method_name: String, func_body: RegExMatch, text: String) -> String:
|
||||
var text_after_func_body_end := text.substr(func_body.get_end())
|
||||
|
||||
text = regex_super_call.sub(
|
||||
text, "super.%s" % method_name,
|
||||
true, func_body.get_start(), func_body.get_end()
|
||||
)
|
||||
|
||||
text = text + text_after_func_body_end
|
||||
|
||||
return text
|
||||
|
||||
|
||||
static func get_func_body_start_index(closing_paren_index: int, source_code: String) -> int:
|
||||
if closing_paren_index == -1:
|
||||
return -1
|
||||
return source_code.find(":", closing_paren_index) + 1
|
||||
|
||||
|
||||
func match_method_body(method_name: String, func_body_start_index: int, text: String) -> RegExMatch:
|
||||
return regex_func_body.search(text, func_body_start_index)
|
||||
|
||||
|
||||
static func match_func_with_whitespace(method_name: String, text: String, offset := 0) -> RegExMatch:
|
||||
# Dynamically create the new regex for that specific name
|
||||
var func_with_whitespace := RegEx.create_from_string(REGEX_MATCH_FUNC_WITH_WHITESPACE % method_name)
|
||||
return func_with_whitespace.search(text, offset)
|
||||
|
||||
|
||||
static func build_mod_hook_string(
|
||||
method_name: String,
|
||||
method_arg_string_names_only: String,
|
||||
method_arg_string_with_defaults_and_types: String,
|
||||
method_type: String,
|
||||
can_return: bool,
|
||||
is_static: bool,
|
||||
is_async: bool,
|
||||
hook_id: int,
|
||||
method_prefix := METHOD_PREFIX,
|
||||
enable_hook_check := false,
|
||||
) -> String:
|
||||
var type_string := " -> %s" % method_type if not method_type.is_empty() else ""
|
||||
var return_string := "return " if can_return else ""
|
||||
var static_string := "static " if is_static else ""
|
||||
var await_string := "await " if is_async else ""
|
||||
var async_string := "_async" if is_async else ""
|
||||
var hook_check := "if _ModLoaderHooks.any_mod_hooked:\n\t\t" if enable_hook_check else ""
|
||||
var hook_check_else := get_hook_check_else_string(
|
||||
return_string, await_string, method_prefix, method_name, method_arg_string_names_only
|
||||
) if enable_hook_check else ""
|
||||
|
||||
return """
|
||||
{STATIC}func {METHOD_NAME}({METHOD_PARAMS}){RETURN_TYPE_STRING}:
|
||||
{HOOK_CHECK}{RETURN}{AWAIT}_ModLoaderHooks.call_hooks{ASYNC}({METHOD_PREFIX}{METHOD_NAME}, [{METHOD_ARGS}], {HOOK_ID}){HOOK_CHECK_ELSE}
|
||||
""".format({
|
||||
"METHOD_PREFIX": method_prefix,
|
||||
"METHOD_NAME": method_name,
|
||||
"METHOD_PARAMS": method_arg_string_with_defaults_and_types,
|
||||
"RETURN_TYPE_STRING": type_string,
|
||||
"METHOD_ARGS": method_arg_string_names_only,
|
||||
"STATIC": static_string,
|
||||
"RETURN": return_string,
|
||||
"AWAIT": await_string,
|
||||
"ASYNC": async_string,
|
||||
"HOOK_ID": hook_id,
|
||||
"HOOK_CHECK": hook_check,
|
||||
"HOOK_CHECK_ELSE": hook_check_else
|
||||
})
|
||||
|
||||
|
||||
static func get_previous_line_to(text: String, index: int) -> String:
|
||||
if index <= 0 or index >= text.length():
|
||||
return ""
|
||||
|
||||
var start_index := index - 1
|
||||
# Find the end of the previous line
|
||||
while start_index > 0 and text[start_index] != "\n":
|
||||
start_index -= 1
|
||||
|
||||
if start_index == 0:
|
||||
return ""
|
||||
|
||||
start_index -= 1
|
||||
|
||||
# Find the start of the previous line
|
||||
var end_index := start_index
|
||||
while start_index > 0 and text[start_index - 1] != "\n":
|
||||
start_index -= 1
|
||||
|
||||
return text.substr(start_index, end_index - start_index + 1)
|
||||
|
||||
|
||||
static func is_func_marked_moddable(method_start_idx, text) -> bool:
|
||||
var prevline := get_previous_line_to(text, method_start_idx)
|
||||
|
||||
if prevline.contains("@not-moddable"):
|
||||
return false
|
||||
if not REQUIRE_EXPLICIT_ADDITION:
|
||||
return true
|
||||
|
||||
return prevline.contains("@moddable")
|
||||
|
||||
|
||||
static func get_index_at_method_start(method_name: String, text: String) -> int:
|
||||
var result := match_func_with_whitespace(method_name, text)
|
||||
|
||||
if result:
|
||||
return text.find("\n", result.get_end())
|
||||
else:
|
||||
return -1
|
||||
|
||||
|
||||
static func is_top_level_func(text: String, result_start_index: int, is_static := false) -> bool:
|
||||
if is_static:
|
||||
result_start_index = text.rfind("static", result_start_index)
|
||||
|
||||
var line_start_index := text.rfind("\n", result_start_index) + 1
|
||||
var pre_func_length := result_start_index - line_start_index
|
||||
|
||||
if pre_func_length > 0:
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
# Make sure to only pass one line
|
||||
static func is_comment(text: String, start_index: int) -> bool:
|
||||
# Check for # before the start_index
|
||||
if text.rfind("#", start_index) == -1:
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
# Get the left side substring of a line from a given start index
|
||||
static func get_line_left(text: String, start: int) -> String:
|
||||
var line_start_index := text.rfind("\n", start) + 1
|
||||
return text.substr(line_start_index, start - line_start_index)
|
||||
|
||||
|
||||
# Check if a static void type is declared
|
||||
func is_void(source_code: String, func_def_closing_paren_index: int, func_body_start_index: int) -> bool:
|
||||
var func_def_end_index := func_body_start_index - 1 # func_body_start_index - 1 should be `:` position.
|
||||
var type_zone := source_code.substr(func_def_closing_paren_index, func_def_end_index - func_def_closing_paren_index)
|
||||
|
||||
for void_match in regex_keyword_void.search_all(type_zone):
|
||||
if is_comment(
|
||||
get_line_left(type_zone, void_match.get_start()),
|
||||
void_match.get_start()
|
||||
):
|
||||
continue
|
||||
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
func can_return(source_code: String, method_name: String, func_def_closing_paren_index: int, func_body_start_index: int) -> bool:
|
||||
if method_name == "_init":
|
||||
return false
|
||||
if is_void(source_code, func_def_closing_paren_index, func_body_start_index):
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
static func get_return_type_string(return_data: Dictionary) -> String:
|
||||
if return_data.type == 0:
|
||||
return ""
|
||||
var type_base: String
|
||||
if return_data.has("class_name") and not str(return_data.class_name).is_empty():
|
||||
type_base = str(return_data.class_name)
|
||||
else:
|
||||
type_base = get_type_name(return_data.type)
|
||||
|
||||
var type_hint: String = "" if return_data.hint_string.is_empty() else ("[%s]" % return_data.hint_string)
|
||||
|
||||
return "%s%s" % [type_base, type_hint]
|
||||
|
||||
|
||||
func collect_getters_and_setters(text: String) -> Dictionary:
|
||||
var result := {}
|
||||
# a valid match has 2 or 4 groups, split into the method names and the rest of the line
|
||||
# (var example: set = )(example_setter)(, get = )(example_getter)
|
||||
# if things between the names are empty or commented, exclude them
|
||||
for mat in regex_getter_setter.search_all(text):
|
||||
if mat.get_string(1).is_empty() or mat.get_string(1).contains("#"):
|
||||
continue
|
||||
result[mat.get_string(2)] = true
|
||||
|
||||
if mat.get_string(3).is_empty() or mat.get_string(3).contains("#"):
|
||||
continue
|
||||
result[mat.get_string(4)] = true
|
||||
|
||||
return result
|
||||
|
||||
|
||||
static func get_hook_check_else_string(
|
||||
return_string: String,
|
||||
await_string: String,
|
||||
method_prefix: String,
|
||||
method_name: String,
|
||||
method_arg_string_names_only: String
|
||||
) -> String:
|
||||
return "\n\telse:\n\t\t{RETURN}{AWAIT}{METHOD_PREFIX}{METHOD_NAME}({METHOD_ARGS})".format(
|
||||
{
|
||||
"RETURN": return_string,
|
||||
"AWAIT": await_string,
|
||||
"METHOD_PREFIX": method_prefix,
|
||||
"METHOD_NAME": method_name,
|
||||
"METHOD_ARGS": method_arg_string_names_only
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# This function was taken from
|
||||
# https://github.com/godotengine/godot/blob/7e67b496ff7e35f66b88adcbdd5b252d01739cbb/modules/gdscript/tests/scripts/utils.notest.gd#L69
|
||||
# It is used instead of type_string because type_string does not exist in Godot 4.1
|
||||
static func get_type_name(type: Variant.Type) -> String:
|
||||
match type:
|
||||
TYPE_NIL:
|
||||
return "Nil" # `Nil` in core, `null` in GDScript.
|
||||
TYPE_BOOL:
|
||||
return "bool"
|
||||
TYPE_INT:
|
||||
return "int"
|
||||
TYPE_FLOAT:
|
||||
return "float"
|
||||
TYPE_STRING:
|
||||
return "String"
|
||||
TYPE_VECTOR2:
|
||||
return "Vector2"
|
||||
TYPE_VECTOR2I:
|
||||
return "Vector2i"
|
||||
TYPE_RECT2:
|
||||
return "Rect2"
|
||||
TYPE_RECT2I:
|
||||
return "Rect2i"
|
||||
TYPE_VECTOR3:
|
||||
return "Vector3"
|
||||
TYPE_VECTOR3I:
|
||||
return "Vector3i"
|
||||
TYPE_TRANSFORM2D:
|
||||
return "Transform2D"
|
||||
TYPE_VECTOR4:
|
||||
return "Vector4"
|
||||
TYPE_VECTOR4I:
|
||||
return "Vector4i"
|
||||
TYPE_PLANE:
|
||||
return "Plane"
|
||||
TYPE_QUATERNION:
|
||||
return "Quaternion"
|
||||
TYPE_AABB:
|
||||
return "AABB"
|
||||
TYPE_BASIS:
|
||||
return "Basis"
|
||||
TYPE_TRANSFORM3D:
|
||||
return "Transform3D"
|
||||
TYPE_PROJECTION:
|
||||
return "Projection"
|
||||
TYPE_COLOR:
|
||||
return "Color"
|
||||
TYPE_STRING_NAME:
|
||||
return "StringName"
|
||||
TYPE_NODE_PATH:
|
||||
return "NodePath"
|
||||
TYPE_RID:
|
||||
return "RID"
|
||||
TYPE_OBJECT:
|
||||
return "Object"
|
||||
TYPE_CALLABLE:
|
||||
return "Callable"
|
||||
TYPE_SIGNAL:
|
||||
return "Signal"
|
||||
TYPE_DICTIONARY:
|
||||
return "Dictionary"
|
||||
TYPE_ARRAY:
|
||||
return "Array"
|
||||
TYPE_PACKED_BYTE_ARRAY:
|
||||
return "PackedByteArray"
|
||||
TYPE_PACKED_INT32_ARRAY:
|
||||
return "PackedInt32Array"
|
||||
TYPE_PACKED_INT64_ARRAY:
|
||||
return "PackedInt64Array"
|
||||
TYPE_PACKED_FLOAT32_ARRAY:
|
||||
return "PackedFloat32Array"
|
||||
TYPE_PACKED_FLOAT64_ARRAY:
|
||||
return "PackedFloat64Array"
|
||||
TYPE_PACKED_STRING_ARRAY:
|
||||
return "PackedStringArray"
|
||||
TYPE_PACKED_VECTOR2_ARRAY:
|
||||
return "PackedVector2Array"
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
return "PackedVector3Array"
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
return "PackedColorArray"
|
||||
38: # TYPE_PACKED_VECTOR4_ARRAY
|
||||
return "PackedVector4Array"
|
||||
push_error("Argument `type` is invalid. Use `TYPE_*` constants.")
|
||||
return "<unknown type %s>" % type
|
1
addons/mod_loader/internal/mod_hook_preprocessor.gd.uid
Normal file
1
addons/mod_loader/internal/mod_hook_preprocessor.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://gxlbhvqctoix
|
106
addons/mod_loader/internal/mod_loader_utils.gd
Normal file
106
addons/mod_loader/internal/mod_loader_utils.gd
Normal file
@@ -0,0 +1,106 @@
|
||||
class_name ModLoaderUtils
|
||||
extends Node
|
||||
|
||||
|
||||
const LOG_NAME := "ModLoader:ModLoaderUtils"
|
||||
|
||||
|
||||
## 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
|
||||
## modders in understanding and troubleshooting issues
|
||||
static func _code_note(_msg:String):
|
||||
pass
|
||||
|
||||
|
||||
## Returns an empty [String] if the key does not exist or is not type of [String]
|
||||
static func get_string_from_dict(dict: Dictionary, key: String) -> String:
|
||||
if not dict.has(key):
|
||||
return ""
|
||||
|
||||
if not dict[key] is String:
|
||||
return ""
|
||||
|
||||
return dict[key]
|
||||
|
||||
|
||||
## Returns an empty [Array] if the key does not exist or is not type of [Array]
|
||||
static func get_array_from_dict(dict: Dictionary, key: String) -> Array:
|
||||
if not dict.has(key):
|
||||
return []
|
||||
|
||||
if not dict[key] is Array:
|
||||
return []
|
||||
|
||||
return dict[key]
|
||||
|
||||
|
||||
## Returns an empty [Dictionary] if the key does not exist or is not type of [Dictionary]
|
||||
static func get_dict_from_dict(dict: Dictionary, key: String) -> Dictionary:
|
||||
if not dict.has(key):
|
||||
return {}
|
||||
|
||||
if not dict[key] is Dictionary:
|
||||
return {}
|
||||
|
||||
return dict[key]
|
||||
|
||||
|
||||
## 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[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)
|
||||
return false
|
||||
|
||||
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")
|
||||
var registered_class_icons: Dictionary = ProjectSettings.get_setting("_global_script_class_icons")
|
||||
|
||||
for new_class in new_global_classes:
|
||||
if not _is_valid_global_class_dict(new_class):
|
||||
continue
|
||||
for old_class in registered_classes:
|
||||
if old_class.get_class() == new_class.get_class():
|
||||
if OS.has_feature("editor"):
|
||||
ModLoaderLog.info('Class "%s" to be registered as global was already registered by the editor. Skipping.' % new_class.get_class(), LOG_NAME)
|
||||
else:
|
||||
ModLoaderLog.info('Class "%s" to be registered as global already exists. Skipping.' % new_class.get_class(), LOG_NAME)
|
||||
continue
|
||||
|
||||
registered_classes.append(new_class)
|
||||
registered_class_icons[new_class.get_class()] = "" # empty icon, does not matter
|
||||
|
||||
ProjectSettings.set_setting("_global_script_classes", registered_classes)
|
||||
ProjectSettings.set_setting("_global_script_class_icons", registered_class_icons)
|
||||
|
||||
|
||||
## Checks if all required fields are in the given [Dictionary]
|
||||
## Format: [code]{ "base": "ParentClass", "class": "ClassName", "language": "GDScript", "path": "res://path/class_name.gd" }[/code]
|
||||
static func _is_valid_global_class_dict(global_class_dict: Dictionary) -> bool:
|
||||
var required_fields := ["base", "class", "language", "path"]
|
||||
if not global_class_dict.has_all(required_fields):
|
||||
ModLoaderLog.fatal("Global class to be registered is missing one of %s" % required_fields, LOG_NAME)
|
||||
return false
|
||||
|
||||
if not _ModLoaderFile.file_exists(global_class_dict.path):
|
||||
ModLoaderLog.fatal('Class "%s" to be registered as global could not be found at given path "%s"' %
|
||||
[global_class_dict.get_class, global_class_dict.path], LOG_NAME)
|
||||
return false
|
||||
|
||||
return true
|
1
addons/mod_loader/internal/mod_loader_utils.gd.uid
Normal file
1
addons/mod_loader/internal/mod_loader_utils.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bk8dltcgr6n5d
|
293
addons/mod_loader/internal/path.gd
Normal file
293
addons/mod_loader/internal/path.gd
Normal file
@@ -0,0 +1,293 @@
|
||||
class_name _ModLoaderPath
|
||||
extends RefCounted
|
||||
|
||||
|
||||
# This Class provides util functions for working with paths.
|
||||
# Currently all of the included functions are internal and should only be used by the mod loader itself.
|
||||
|
||||
const LOG_NAME := "ModLoader:Path"
|
||||
const MOD_CONFIG_DIR_PATH := "user://mod_configs"
|
||||
const MOD_CONFIG_DIR_PATH_OLD := "user://configs"
|
||||
|
||||
|
||||
# Get the path to a local folder. Primarily used to get the (packed) mods
|
||||
# folder, ie "res://mods" or the OS's equivalent, as well as the configs path
|
||||
static func get_local_folder_dir(subfolder: String = "") -> String:
|
||||
return get_game_install_dir().path_join(subfolder)
|
||||
|
||||
|
||||
static func get_game_install_dir() -> String:
|
||||
var game_install_directory := OS.get_executable_path().get_base_dir()
|
||||
|
||||
if OS.get_name() == "macOS":
|
||||
game_install_directory = game_install_directory.get_base_dir().get_base_dir()
|
||||
if game_install_directory.ends_with(".app"):
|
||||
game_install_directory = game_install_directory.get_base_dir()
|
||||
|
||||
# Fix for running the game through the Godot editor (as the EXE path would be
|
||||
# the editor's own EXE, which won't have any mod ZIPs)
|
||||
# if OS.is_debug_build():
|
||||
if OS.has_feature("editor"):
|
||||
game_install_directory = "res://"
|
||||
|
||||
return game_install_directory
|
||||
|
||||
|
||||
# Get the path where override.cfg will be stored.
|
||||
# Not the same as the local folder dir (for mac)
|
||||
static func get_override_path() -> String:
|
||||
var base_path := ""
|
||||
if OS.has_feature("editor"):
|
||||
base_path = ProjectSettings.globalize_path("res://")
|
||||
else:
|
||||
# this is technically different to res:// in macos, but we want the
|
||||
# executable dir anyway, so it is exactly what we need
|
||||
base_path = OS.get_executable_path().get_base_dir()
|
||||
|
||||
return base_path.path_join("override.cfg")
|
||||
|
||||
|
||||
# Provide a path, get the file name at the end of the path
|
||||
static func get_file_name_from_path(path: String, make_lower_case := true, remove_extension := false) -> String:
|
||||
var file_name := path.get_file()
|
||||
|
||||
if make_lower_case:
|
||||
file_name = file_name.to_lower()
|
||||
|
||||
if remove_extension:
|
||||
file_name = file_name.trim_suffix("." + file_name.get_extension())
|
||||
|
||||
return file_name
|
||||
|
||||
|
||||
# Provide a zip_path to a workshop mod, returns the steam_workshop_id
|
||||
static func get_steam_workshop_id(zip_path: String) -> String:
|
||||
if not zip_path.contains("/Steam/steamapps/workshop/content"):
|
||||
return ""
|
||||
|
||||
return zip_path.get_base_dir().split("/")[-1]
|
||||
|
||||
|
||||
# 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 = []
|
||||
var regex: RegEx
|
||||
if p_match_is_regex:
|
||||
regex = RegEx.new()
|
||||
var _compile_error: int = regex.compile(p_match)
|
||||
if not regex.is_valid():
|
||||
return data
|
||||
|
||||
var dirs := [p_dir]
|
||||
var first := true
|
||||
while not dirs.is_empty():
|
||||
var dir_name: String = dirs.back()
|
||||
dirs.pop_back()
|
||||
|
||||
var dir := DirAccess.open(dir_name)
|
||||
|
||||
if not dir == null:
|
||||
var _dirlist_error: int = dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
|
||||
var file_name := dir.get_next()
|
||||
while file_name != "":
|
||||
if not dir_name == "res://":
|
||||
first = false
|
||||
# ignore hidden, temporary, or system content
|
||||
if not file_name.begins_with(".") and not file_name.get_extension() in ["tmp", "import"]:
|
||||
# If a directory, then add to list of directories to visit
|
||||
if dir.current_is_dir():
|
||||
dirs.push_back(dir.get_current_dir().path_join(file_name))
|
||||
# If a file, check if we already have a record for the same name
|
||||
else:
|
||||
var path := dir.get_current_dir() + ("/" if not first else "") + file_name
|
||||
# grab all
|
||||
if not p_match:
|
||||
data.append(path)
|
||||
# grab matching strings
|
||||
elif not p_match_is_regex and file_name.find(p_match, 0) != -1:
|
||||
data.append(path)
|
||||
# grab matching regex
|
||||
else:
|
||||
var regex_match := regex.search(path)
|
||||
if regex_match != null:
|
||||
data.append(path)
|
||||
# Move on to the next file in this directory
|
||||
file_name = dir.get_next()
|
||||
# We've exhausted all files in this directory. Close the iterator.
|
||||
dir.list_dir_end()
|
||||
return data
|
||||
|
||||
|
||||
# Returns an array of file paths inside the src dir
|
||||
static func get_file_paths_in_dir(src_dir_path: String) -> Array:
|
||||
var file_paths := []
|
||||
|
||||
var dir := DirAccess.open(src_dir_path)
|
||||
|
||||
if dir == null:
|
||||
ModLoaderLog.error("Encountered an error (%s) when attempting to open a directory, with the path: %s" % [error_string(DirAccess.get_open_error()), src_dir_path], LOG_NAME)
|
||||
return file_paths
|
||||
|
||||
dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
|
||||
var file_name := dir.get_next()
|
||||
while (file_name != ""):
|
||||
if not dir.current_is_dir():
|
||||
file_paths.push_back(src_dir_path.path_join(file_name))
|
||||
file_name = dir.get_next()
|
||||
|
||||
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 dir := DirAccess.open(src_dir_path)
|
||||
|
||||
if dir == null:
|
||||
ModLoaderLog.error("Encountered an error (%s) when attempting to open a directory, with the path: %s" % [error_string(DirAccess.get_open_error()), src_dir_path], LOG_NAME)
|
||||
return dir_paths
|
||||
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
while (file_name != ""):
|
||||
if file_name == "." or file_name == "..":
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
if dir.current_is_dir():
|
||||
dir_paths.push_back(src_dir_path.path_join(file_name))
|
||||
file_name = dir.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")
|
||||
|
||||
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())
|
||||
|
||||
if ModLoaderStore.has_feature.editor or ModLoaderStore.ml_options.load_from_unpacked:
|
||||
mod_paths.append_array(mod_dirs)
|
||||
else:
|
||||
ModLoaderLog.info("Loading mods from \"res://mods-unpacked\" is disabled.", LOG_NAME)
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Get the path to the configs folder, with any applicable overrides applied
|
||||
static func get_path_to_configs() -> String:
|
||||
if _ModLoaderFile.dir_exists(MOD_CONFIG_DIR_PATH_OLD):
|
||||
handle_mod_config_path_deprecation()
|
||||
var configs_path := MOD_CONFIG_DIR_PATH
|
||||
if ModLoaderStore:
|
||||
if ModLoaderStore.ml_options.override_path_to_configs:
|
||||
configs_path = ModLoaderStore.ml_options.override_path_to_configs
|
||||
return configs_path
|
||||
|
||||
|
||||
# Get the path to a mods config folder
|
||||
static func get_path_to_mod_configs_dir(mod_id: String) -> String:
|
||||
return get_path_to_configs().path_join(mod_id)
|
||||
|
||||
|
||||
# Get the path to a mods config file
|
||||
static func get_path_to_mod_config_file(mod_id: String, config_name: String) -> String:
|
||||
var mod_config_dir := get_path_to_mod_configs_dir(mod_id)
|
||||
return mod_config_dir.path_join(config_name + ".json")
|
||||
|
||||
|
||||
# Get the path to the zip file that contains the vanilla scripts with
|
||||
# added mod hooks, considering all overrides
|
||||
static func get_path_to_hook_pack() -> String:
|
||||
var path := get_game_install_dir()
|
||||
if not ModLoaderStore.ml_options.override_path_to_hook_pack.is_empty():
|
||||
path = ModLoaderStore.ml_options.override_path_to_hook_pack
|
||||
|
||||
var name := ModLoaderStore.MOD_HOOK_PACK_NAME
|
||||
if not ModLoaderStore.ml_options.override_hook_pack_name.is_empty():
|
||||
name = ModLoaderStore.ml_options.override_hook_pack_name
|
||||
|
||||
return path.path_join(name)
|
||||
|
||||
|
||||
# Returns the mod directory name ("some-mod") from a given path (e.g. "res://mods-unpacked/some-mod/extensions/extension.gd")
|
||||
static func get_mod_dir(path: String) -> String:
|
||||
var initial := ModLoaderStore.UNPACKED_DIR
|
||||
var ending := "/"
|
||||
var start_index: int = path.find(initial)
|
||||
if start_index == -1:
|
||||
ModLoaderLog.error("Initial string not found.", LOG_NAME)
|
||||
return ""
|
||||
|
||||
start_index += initial.length()
|
||||
|
||||
var end_index: int = path.find(ending, start_index)
|
||||
if end_index == -1:
|
||||
ModLoaderLog.error("Ending string not found.", LOG_NAME)
|
||||
return ""
|
||||
|
||||
var found_string: String = path.substr(start_index, end_index - start_index)
|
||||
|
||||
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")
|
||||
var error := DirAccess.rename_absolute(MOD_CONFIG_DIR_PATH_OLD, MOD_CONFIG_DIR_PATH)
|
||||
if not error == OK:
|
||||
ModLoaderLog.error("Failed to rename the config directory with error \"%s\"." % [error_string(error)], LOG_NAME)
|
||||
else:
|
||||
ModLoaderLog.success("Successfully renamed config directory to \"%s\"." % MOD_CONFIG_DIR_PATH, LOG_NAME)
|
1
addons/mod_loader/internal/path.gd.uid
Normal file
1
addons/mod_loader/internal/path.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dahg6tbvgiy3q
|
56
addons/mod_loader/internal/scene_extension.gd
Normal file
56
addons/mod_loader/internal/scene_extension.gd
Normal file
@@ -0,0 +1,56 @@
|
||||
class_name _ModLoaderSceneExtension
|
||||
extends RefCounted
|
||||
|
||||
# This Class provides methods for working with scene extensions.
|
||||
# Currently all of the included methods are internal and should only be used by the mod loader itself.
|
||||
|
||||
const LOG_NAME := "ModLoader:SceneExtension"
|
||||
|
||||
|
||||
# Iterates over the list of scenes to refresh them from storage.
|
||||
# Used to apply script extensions to preloaded scenes.
|
||||
static func refresh_scenes() -> void:
|
||||
for scene_path in ModLoaderStore.scenes_to_refresh:
|
||||
# Refresh cached scenes from storage
|
||||
var _scene_from_file: PackedScene = ResourceLoader.load(
|
||||
scene_path, "", ResourceLoader.CACHE_MODE_REPLACE
|
||||
)
|
||||
ModLoaderLog.debug("Refreshed scene at path: %s" % scene_path, LOG_NAME)
|
||||
|
||||
|
||||
# Iterates over the list of scenes to modify and applies the specified edits to each scene.
|
||||
static func handle_scene_extensions() -> void:
|
||||
for scene_path in ModLoaderStore.scenes_to_modify.keys():
|
||||
for scene_edit_callable in ModLoaderStore.scenes_to_modify[scene_path]:
|
||||
var cached_scene: PackedScene = load(scene_path)
|
||||
var cached_scene_instance: Node = cached_scene.instantiate()
|
||||
var edited_scene: Node = scene_edit_callable.call(cached_scene_instance)
|
||||
if not edited_scene:
|
||||
ModLoaderLog.fatal(
|
||||
(
|
||||
'Scene extension of "%s" failed since the edit callable "%s" does not return the modified scene_instance'
|
||||
% [scene_path, scene_edit_callable.get_method()]
|
||||
),
|
||||
LOG_NAME
|
||||
)
|
||||
return
|
||||
_save_scene(edited_scene, scene_path)
|
||||
|
||||
|
||||
# Saves a modified scene to resource cache.
|
||||
# Further attempts to load this scene by path will instead return this resource.
|
||||
#
|
||||
# Parameters:
|
||||
# - modified_scene (Node): The modified scene instance to be saved.
|
||||
# - scene_path (String): The path to the scene file that will be replaced.
|
||||
#
|
||||
# Returns: void
|
||||
static func _save_scene(modified_scene: Node, scene_path: String) -> void:
|
||||
var packed_scene := PackedScene.new()
|
||||
var _pack_error := packed_scene.pack(modified_scene)
|
||||
ModLoaderLog.debug("packing scene -> %s" % packed_scene, LOG_NAME)
|
||||
packed_scene.take_over_path(scene_path)
|
||||
ModLoaderLog.debug(
|
||||
"save_scene - taking over path - new path -> %s" % packed_scene.resource_path, LOG_NAME
|
||||
)
|
||||
ModLoaderStore.saved_objects.append(packed_scene)
|
1
addons/mod_loader/internal/scene_extension.gd.uid
Normal file
1
addons/mod_loader/internal/scene_extension.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://daijyovwv2vnr
|
156
addons/mod_loader/internal/script_extension.gd
Normal file
156
addons/mod_loader/internal/script_extension.gd
Normal file
@@ -0,0 +1,156 @@
|
||||
class_name _ModLoaderScriptExtension
|
||||
extends RefCounted
|
||||
|
||||
# This Class provides methods for working with script extensions.
|
||||
# Currently all of the included methods are internal and should only be used by the mod loader itself.
|
||||
|
||||
const LOG_NAME := "ModLoader:ScriptExtension"
|
||||
|
||||
|
||||
# Sort script extensions by inheritance and apply them in order
|
||||
static func handle_script_extensions() -> void:
|
||||
var extension_paths := []
|
||||
for extension_path in ModLoaderStore.script_extensions:
|
||||
if FileAccess.file_exists(extension_path):
|
||||
extension_paths.push_back(extension_path)
|
||||
else:
|
||||
ModLoaderLog.error(
|
||||
"The child script path '%s' does not exist" % [extension_path], LOG_NAME
|
||||
)
|
||||
|
||||
# Sort by inheritance
|
||||
InheritanceSorting.new(extension_paths)
|
||||
|
||||
# Load and install all extensions
|
||||
for extension in extension_paths:
|
||||
var script: Script = apply_extension(extension)
|
||||
_reload_vanilla_child_classes_for(script)
|
||||
|
||||
|
||||
# Sorts script paths by their ancestors. Scripts are organized by their common
|
||||
# ancestors then sorted such that scripts extending script A will be before
|
||||
# a script extending script B if A is an ancestor of B.
|
||||
class InheritanceSorting:
|
||||
var stack_cache := {}
|
||||
# This dictionary's keys are mod_ids and it stores the corresponding position in the load_order
|
||||
var load_order := {}
|
||||
|
||||
func _init(inheritance_array_to_sort: Array) -> void:
|
||||
_populate_load_order_table()
|
||||
inheritance_array_to_sort.sort_custom(check_inheritances)
|
||||
|
||||
# Comparator function. return true if a should go before b. This may
|
||||
# enforce conditions beyond the stated inheritance relationship.
|
||||
func check_inheritances(extension_a: String, extension_b: String) -> bool:
|
||||
var a_stack := cached_inheritances_stack(extension_a)
|
||||
var b_stack := cached_inheritances_stack(extension_b)
|
||||
|
||||
var last_index: int
|
||||
for index in a_stack.size():
|
||||
if index >= b_stack.size():
|
||||
return false
|
||||
if a_stack[index] != b_stack[index]:
|
||||
return a_stack[index] < b_stack[index]
|
||||
last_index = index
|
||||
|
||||
if last_index < b_stack.size() - 1:
|
||||
return true
|
||||
|
||||
return compare_mods_order(extension_a, extension_b)
|
||||
|
||||
# Returns a list of scripts representing all the ancestors of the extension
|
||||
# script with the most recent ancestor last.
|
||||
#
|
||||
# Results are stored in a cache keyed by extension path
|
||||
func cached_inheritances_stack(extension_path: String) -> Array:
|
||||
if stack_cache.has(extension_path):
|
||||
return stack_cache[extension_path]
|
||||
|
||||
var stack := []
|
||||
|
||||
var parent_script: Script = load(extension_path)
|
||||
while parent_script:
|
||||
stack.push_front(parent_script.resource_path)
|
||||
parent_script = parent_script.get_base_script()
|
||||
stack.pop_back()
|
||||
|
||||
stack_cache[extension_path] = stack
|
||||
return stack
|
||||
|
||||
# Secondary comparator function for resolving scripts extending the same vanilla script
|
||||
# Will return whether a comes before b in the load order
|
||||
func compare_mods_order(extension_a: String, extension_b: String) -> bool:
|
||||
var mod_a_id: String = _ModLoaderPath.get_mod_dir(extension_a)
|
||||
var mod_b_id: String = _ModLoaderPath.get_mod_dir(extension_b)
|
||||
|
||||
return load_order[mod_a_id] < load_order[mod_b_id]
|
||||
|
||||
# Populate a load order dictionary for faster access and comparison between mod ids
|
||||
func _populate_load_order_table() -> void:
|
||||
var mod_index := 0
|
||||
for mod in ModLoaderStore.mod_load_order:
|
||||
load_order[mod.dir_name] = mod_index
|
||||
mod_index += 1
|
||||
|
||||
|
||||
static func apply_extension(extension_path: String) -> Script:
|
||||
# Check path to file exists
|
||||
if not FileAccess.file_exists(extension_path):
|
||||
ModLoaderLog.error("The child script path '%s' does not exist" % [extension_path], LOG_NAME)
|
||||
return null
|
||||
|
||||
var child_script: Script = load(extension_path)
|
||||
# Adding metadata that contains the extension script path
|
||||
# We cannot get that path in any other way
|
||||
# Passing the child_script as is would return the base script path
|
||||
# Passing the .duplicate() would return a '' path
|
||||
child_script.set_meta("extension_script_path", extension_path)
|
||||
|
||||
# Force Godot to compile the script now.
|
||||
# We need to do this here to ensure that the inheritance chain is
|
||||
# properly set up, and multiple mods can chain-extend the same
|
||||
# class multiple times.
|
||||
# This is also needed to make Godot instantiate the extended class
|
||||
# when creating singletons.
|
||||
child_script.reload()
|
||||
|
||||
var parent_script: Script = child_script.get_base_script()
|
||||
var parent_script_path: String = parent_script.resource_path
|
||||
|
||||
# We want to save scripts for resetting later
|
||||
# All the scripts are saved in order already
|
||||
if not ModLoaderStore.saved_scripts.has(parent_script_path):
|
||||
ModLoaderStore.saved_scripts[parent_script_path] = []
|
||||
# The first entry in the saved script array that has the path
|
||||
# used as a key will be the duplicate of the not modified script
|
||||
ModLoaderStore.saved_scripts[parent_script_path].append(parent_script.duplicate())
|
||||
|
||||
ModLoaderStore.saved_scripts[parent_script_path].append(child_script)
|
||||
|
||||
ModLoaderLog.info(
|
||||
"Installing script extension: %s <- %s" % [parent_script_path, extension_path], LOG_NAME
|
||||
)
|
||||
child_script.take_over_path(parent_script_path)
|
||||
|
||||
return child_script
|
||||
|
||||
|
||||
# Reload all children classes of the vanilla class we just extended
|
||||
# Calling reload() the children of an extended class seems to allow them to be extended
|
||||
# e.g if B is a child class of A, reloading B after apply an extender of A allows extenders of B to properly extend B, taking A's extender(s) into account
|
||||
static func _reload_vanilla_child_classes_for(script: Script) -> void:
|
||||
if script == null:
|
||||
return
|
||||
var current_child_classes := []
|
||||
var actual_path: String = script.get_base_script().resource_path
|
||||
var classes: Array = ProjectSettings.get_global_class_list()
|
||||
|
||||
for _class in classes:
|
||||
if _class.path == actual_path:
|
||||
current_child_classes.push_back(_class)
|
||||
break
|
||||
|
||||
for _class in current_child_classes:
|
||||
for child_class in classes:
|
||||
if child_class.base == _class.get_class():
|
||||
load(child_class.path).reload()
|
1
addons/mod_loader/internal/script_extension.gd.uid
Normal file
1
addons/mod_loader/internal/script_extension.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dinxouhn4hk1d
|
108
addons/mod_loader/internal/third_party/steam.gd
vendored
Normal file
108
addons/mod_loader/internal/third_party/steam.gd
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
class_name _ModLoaderSteam
|
||||
extends Node
|
||||
|
||||
const LOG_NAME := "ModLoader:ThirdParty:Steam"
|
||||
|
||||
# Methods related to Steam and the Steam Workshop
|
||||
|
||||
|
||||
# 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]:
|
||||
# TODO: use new diraccess methods + filter
|
||||
var zip_paths: Array[String] = []
|
||||
var workshop_folder_path := _get_path_to_workshop()
|
||||
|
||||
ModLoaderLog.info("Checking workshop items, with path: \"%s\"" % workshop_folder_path, LOG_NAME)
|
||||
|
||||
var workshop_dir := DirAccess.open(workshop_folder_path)
|
||||
if workshop_dir == null:
|
||||
ModLoaderLog.error("Can't open workshop folder %s (Error: %s)" % [workshop_folder_path, error_string(DirAccess.get_open_error())], LOG_NAME)
|
||||
return []
|
||||
var workshop_dir_listdir_error := workshop_dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
|
||||
if not workshop_dir_listdir_error == OK:
|
||||
ModLoaderLog.error("Can't read workshop folder %s (Error: %s)" % [workshop_folder_path, error_string(workshop_dir_listdir_error)], LOG_NAME)
|
||||
return []
|
||||
|
||||
# Loop 1: Workshop folders
|
||||
while true:
|
||||
# Get the next workshop item folder
|
||||
var item_dir := workshop_dir.get_next()
|
||||
if item_dir == '':
|
||||
break
|
||||
|
||||
var item_path := workshop_dir.get_current_dir() + "/" + item_dir
|
||||
ModLoaderLog.info("Checking workshop item path: \"%s\"" % item_path, LOG_NAME)
|
||||
|
||||
# Only check directories
|
||||
if not workshop_dir.current_is_dir():
|
||||
continue
|
||||
|
||||
# Loop 2: ZIPs inside the workshop folders
|
||||
zip_paths.append_array(_ModLoaderPath.get_zip_paths_in(ProjectSettings.globalize_path(item_path)))
|
||||
|
||||
workshop_dir.list_dir_end()
|
||||
|
||||
return zip_paths
|
||||
|
||||
|
||||
# Get the path to the Steam workshop folder. Only works for Steam games, as it
|
||||
# traverses directories relative to where a Steam game and its workshop content
|
||||
# would be installed. Based on code by Blobfish (developer of Brotato).
|
||||
# For reference, these are the paths of a Steam game and its workshop folder:
|
||||
# GAME = Steam/steamapps/common/GameName
|
||||
# WORKSHOP = Steam/steamapps/workshop/content/AppID
|
||||
# Eg. Brotato:
|
||||
# GAME = Steam/steamapps/common/Brotato
|
||||
# WORKSHOP = Steam/steamapps/workshop/content/1942280
|
||||
static func _get_path_to_workshop() -> String:
|
||||
if ModLoaderStore.ml_options.override_path_to_workshop:
|
||||
return ModLoaderStore.ml_options.override_path_to_workshop
|
||||
|
||||
var game_install_directory := _ModLoaderPath.get_local_folder_dir()
|
||||
var path := ""
|
||||
|
||||
# Traverse up to the steamapps directory (ie. `cd ..\..\` on Windows)
|
||||
var path_array := game_install_directory.split("/")
|
||||
path_array.resize(path_array.size() - 3)
|
||||
|
||||
# Reconstruct the path, now that it has "common/GameName" removed
|
||||
path = "/".join(path_array)
|
||||
|
||||
# Append the game's workshop path
|
||||
path = path.path_join("workshop/content/" + _get_steam_app_id())
|
||||
|
||||
return path
|
||||
|
||||
|
||||
# Gets the steam app ID from ml_options or the steam_data.json, which should be in the root
|
||||
# directory (ie. res://steam_data.json). This file is used by Godot Workshop
|
||||
# Utility (GWU), which was developed by Brotato developer Blobfish:
|
||||
# https://github.com/thomasgvd/godot-workshop-utility
|
||||
static func _get_steam_app_id() -> String:
|
||||
# Check if the steam id is stored in the options
|
||||
if ModLoaderStore.ml_options.steam_id:
|
||||
return str(ModLoaderStore.ml_options.steam_id)
|
||||
ModLoaderLog.debug("No Steam ID specified in the Mod Loader options. Attempting to read the steam_data.json file next.", LOG_NAME)
|
||||
|
||||
# If the steam_id is not stored in the options try to get it from the steam_data.json file.
|
||||
var game_install_directory := _ModLoaderPath.get_local_folder_dir()
|
||||
var steam_app_id := ""
|
||||
var file := FileAccess.open(game_install_directory.path_join("steam_data.json"), FileAccess.READ)
|
||||
|
||||
if not file == null:
|
||||
var test_json_conv = JSON.new()
|
||||
test_json_conv.parse(file.get_as_text())
|
||||
var file_content: Dictionary = test_json_conv.get_data()
|
||||
file.close()
|
||||
|
||||
if not file_content.has("app_id"):
|
||||
ModLoaderLog.error("The steam_data file does not contain an app ID. Mod uploading will not work.", LOG_NAME)
|
||||
return ""
|
||||
|
||||
steam_app_id = str(file_content.app_id)
|
||||
else :
|
||||
ModLoaderLog.error("Can't open steam_data file, \"%s\". Please make sure the file exists and is valid." % game_install_directory.path_join("steam_data.json"), LOG_NAME)
|
||||
|
||||
return steam_app_id
|
1
addons/mod_loader/internal/third_party/steam.gd.uid
vendored
Normal file
1
addons/mod_loader/internal/third_party/steam.gd.uid
vendored
Normal file
@@ -0,0 +1 @@
|
||||
uid://br0xd56w758rg
|
Reference in New Issue
Block a user