added the game

This commit is contained in:
JHDev2006
2025-09-13 16:30:32 +01:00
parent 5ef689109b
commit 3773bdaf64
3616 changed files with 263702 additions and 0 deletions

View 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)

View File

@@ -0,0 +1 @@
uid://b73enisoxe0uq

View 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

View File

@@ -0,0 +1 @@
uid://c3rvk5ry6rqyq

View 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

View File

@@ -0,0 +1 @@
uid://bnmjbsvid8sxk

View 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

View File

@@ -0,0 +1 @@
uid://d34sgvhw73mtb

View 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

View File

@@ -0,0 +1 @@
uid://dc86ulvsd1da7

View 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)

View File

@@ -0,0 +1 @@
uid://yrog1crr7kxp

View 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()

View File

@@ -0,0 +1 @@
uid://dnwq8741pln26

View 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

View File

@@ -0,0 +1 @@
uid://gxlbhvqctoix

View 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

View File

@@ -0,0 +1 @@
uid://bk8dltcgr6n5d

View 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)

View File

@@ -0,0 +1 @@
uid://dahg6tbvgiy3q

View 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)

View File

@@ -0,0 +1 @@
uid://daijyovwv2vnr

View 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()

View File

@@ -0,0 +1 @@
uid://dinxouhn4hk1d

View 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

View File

@@ -0,0 +1 @@
uid://br0xd56w758rg