Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion addons/mod_loader/api/mod.gd
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,17 @@ static func add_hook(mod_callable: Callable, script_path: String, method_name: S
if not ModLoaderStore.modding_hooks.has(hash):
ModLoaderStore.modding_hooks[hash] = []
ModLoaderStore.modding_hooks[hash].push_back(mod_callable)
ModLoaderLog.debug("Added hook script: \"%s\" method: \"%s\" is_before: \"%s\"" % [script_path, method_name, is_before], LOG_NAME)
if not ModLoaderStore.hooked_script_paths.has(script_path):
ModLoaderStore.hooked_script_paths[script_path] = null


static func call_hooks(self_object: Object, args: Array, hook_hash:int) -> void:
var hooks = ModLoaderStore.modding_hooks.get(hook_hash, null)
if hooks:
for mod_func in hooks:
mod_func.call(self_object)
mod_func.call(self_object, args)


static func get_hook_hash(path:String, method:String, is_before:bool) -> int:
return hash(path + method + ("before" if is_before else "after"))
Expand Down
2 changes: 1 addition & 1 deletion addons/mod_loader/internal/cache.gd
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ static func add_data(key: String, data: Dictionary) -> Dictionary:
# Get data from a specific key
static func get_data(key: String) -> Dictionary:
if not ModLoaderStore.cache.has(key):
ModLoaderLog.error("key: \"%s\" not found in \"ModLoaderStore.cache\"" % key, LOG_NAME)
ModLoaderLog.info("key: \"%s\" not found in \"ModLoaderStore.cache\"" % key, LOG_NAME)
return {}

return ModLoaderStore.cache[key]
Expand Down
1 change: 0 additions & 1 deletion addons/mod_loader/internal/file.gd
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,3 @@ static func dir_exists(path: String) -> bool:
# modders in understanding and troubleshooting issues.
static func _code_note(_msg:String):
pass

95 changes: 48 additions & 47 deletions addons/mod_loader/internal/mod_hook_packer.gd
Original file line number Diff line number Diff line change
@@ -1,64 +1,65 @@
extends Node
class_name _ModLoaderModHookPacker
extends RefCounted


const ModHookPreprocessorScript = preload("res://addons/mod_loader/internal/mod_hook_preprocessor.gd")
static var ModHookPreprocessor
# 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"

func _ready() -> void:
run_script()
await get_tree().process_frame
get_tree().quit()

static func start() -> void:
var hook_pre_processor = _ModLoaderModHookPreProcessor.new()
hook_pre_processor.process_begin()

func run_script() -> void:
ModHookPreprocessor = ModHookPreprocessorScript.new()
ModHookPreprocessor.process_begin()
var mod_hook_pack_path := _ModLoaderPath.get_path_to_hook_pack()

# TODO: consider mac location
var res := OS.get_executable_path().get_base_dir()
if OS.has_feature("editor"):
res = ProjectSettings.globalize_path("res://").rsplit("/", true, 2)[0]

var save_base_path := res.path_join("godot_mod_loader/")
prints("Saved to:", save_base_path)
DirAccess.make_dir_recursive_absolute(save_base_path)
# 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 err := zip_writer.open(save_base_path.path_join("temp_test_mod.zip"))
if err != OK:
printerr(err)

transform_scripts_recursive(ModHookPreprocessor.process_script, zip_writer)

zip_writer.close()


func transform_scripts_recursive(callback: Callable, zip_writer: ZIPPacker, path := "res://") -> void:
var dir := DirAccess.open(path)
if not dir:
printt("An error occurred when trying to access the path:", path)
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 zip file at path: %s" % [error, mod_hook_pack_path], LOG_NAME)
return

dir.list_dir_begin()
var file_name = dir.get_next()
while file_name != "":
if path.begins_with("res://addons") or path.begins_with("res://mods-unpacked"):
file_name = dir.get_next()
continue
var cache := _ModLoaderCache.get_data("hooks")
var script_paths_with_hook: Array = [] if cache.is_empty() else cache.script_paths
var new_hooks_created := false

if dir.current_is_dir():
transform_scripts_recursive(callback, zip_writer, dir.get_current_dir() + file_name + "/")
file_name = dir.get_next()
# Get all scripts that need processing
ModLoaderLog.debug("Scripts requiring hooks: %s" % [ModLoaderStore.hooked_script_paths.keys()], LOG_NAME)
for path in ModLoaderStore.hooked_script_paths.keys():
if path in script_paths_with_hook:
continue

if file_name.get_extension() != "gd":
file_name = dir.get_next()
continue
var processed_source_code := hook_pre_processor.process_script(path)

var processed: String = callback.call(dir.get_current_dir() + file_name)
zip_writer.start_file(path.trim_prefix("res://").path_join(file_name))
zip_writer.write_file(processed.to_utf8_buffer())
zip_writer.start_file(path.trim_prefix("res://"))
zip_writer.write_file(processed_source_code.to_utf8_buffer())
zip_writer.close_file()

file_name = dir.get_next()
ModLoaderLog.debug("Hooks created for script: %s" % path, LOG_NAME)
new_hooks_created = true
script_paths_with_hook.push_back(path)

if new_hooks_created:
_ModLoaderCache.update_data("hooks", {"script_paths": script_paths_with_hook})
_ModLoaderCache.save_to_file()
ModLoader.new_hooks_created.emit()

zip_writer.close()
20 changes: 16 additions & 4 deletions addons/mod_loader/internal/mod_hook_preprocessor.gd
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
extends Object
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_"
Expand Down Expand Up @@ -30,8 +37,12 @@ func process_begin() -> void:


func process_script(path: String) -> String:
var start_time := Time.get_ticks_msec()
ModLoaderLog.debug("Start processing script at path: %s" % path, LOG_NAME)
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,
Expand Down Expand Up @@ -109,8 +120,9 @@ func process_script(path: String) -> String:
if source_code_additions != "":
source_code = "%s\n%s\n%s" % [source_code, MOD_LOADER_HOOKS_START_STRING, source_code_additions]

return source_code
ModLoaderLog.debug("Finished processing script at path: %s in %s ms" % [path, Time.get_ticks_msec() - start_time], LOG_NAME)

return source_code


static func get_function_arg_name_string(args: Array) -> String:
Expand Down Expand Up @@ -253,10 +265,10 @@ static func get_mod_loader_hook(

return """
{%STATIC%}func {%METHOD_NAME%}({%METHOD_PARAMS%}){%RETURN_TYPE_STRING%}:
if ModLoaderStore.any_mod_hooked:
if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
ModLoaderMod.call_hooks({%SELF%}, [{%METHOD_ARGS%}], {%HOOK_ID_BEFORE%})
{%METHOD_RETURN_VAR%}{%METHOD_PREFIX%}_{%METHOD_NAME%}({%METHOD_ARGS%})
if ModLoaderStore.any_mod_hooked:
if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
ModLoaderMod.call_hooks({%SELF%}, [{%METHOD_ARGS%}], {%HOOK_ID_AFTER%})
{%METHOD_RETURN%}""".format({
"%METHOD_PREFIX%": method_prefix,
Expand Down
14 changes: 14 additions & 0 deletions addons/mod_loader/internal/path.gd
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,20 @@ static func get_path_to_mod_config_file(mod_id: String, config_name: String) ->
return mod_config_dir.path_join(config_name + ".json")


# Get the path to the mod hook pack
static func get_path_to_hook_pack() -> String:
if ModLoaderStore.ml_options.override_path_to_hook_pack.is_empty():
if ModLoaderStore.ml_options.override_hook_pack_name.is_empty():
return OS.get_executable_path().get_base_dir().path_join(ModLoaderStore.MOD_HOOK_PACK_NAME)
else:
return OS.get_executable_path().get_base_dir().path_join(ModLoaderStore.ml_options.override_hook_pack_name)
else:
if ModLoaderStore.ml_options.override_hook_pack_name.is_empty():
return ModLoaderStore.ml_options.override_path_to_hook_pack.path_join(ModLoaderStore.MOD_HOOK_PACK_NAME)
else:
return ModLoaderStore.ml_options.override_path_to_hook_pack.path_join(ModLoaderStore.ml_options.override_hook_pack_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
Expand Down
35 changes: 34 additions & 1 deletion addons/mod_loader/mod_loader.gd
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ signal logged(entry: ModLoaderLog.ModLoaderLogEntry)
## Emitted if the [member ModData.current_config] of any mod changed.
## Use the [member ModConfig.mod_id] of the [ModConfig] to check if the config of your mod has changed.
signal current_config_changed(config: ModConfig)

## Emitted when new mod hooks are created. A game restart is required to load them.
signal new_hooks_created

const LOG_NAME := "ModLoader"

Expand All @@ -34,6 +35,17 @@ const LOG_NAME := "ModLoader"
# =============================================================================

func _init() -> void:
# Only load the hook pack if not in the editor
# We can't use it in the editor - see https://github.com/godotengine/godot/issues/19815
# Mod devs can use the Dev Tool to generate hooks in the editor.
if not OS.has_feature("editor") and FileAccess.file_exists(_ModLoaderPath.get_path_to_hook_pack()):
# Load mod hooks
var load_hooks_pack_success := ProjectSettings.load_resource_pack(_ModLoaderPath.get_path_to_hook_pack())
if not load_hooks_pack_success:
ModLoaderLog.error("Failed loading hooks pack from: %s" % _ModLoaderPath.get_path_to_hook_pack(), LOG_NAME)
else:
ModLoaderLog.debug("Successfully loaded hooks pack from: %s" % _ModLoaderPath.get_path_to_hook_pack(), LOG_NAME)

# Ensure the ModLoaderStore and ModLoader autoloads are in the correct position.
_check_autoload_positions()

Expand Down Expand Up @@ -61,6 +73,8 @@ func _init() -> void:

ModLoaderStore.is_initializing = false

new_hooks_created.connect(_on_new_hooks_created)


func _ready():
# Create the default user profile if it doesn't exist already
Expand All @@ -71,12 +85,22 @@ func _ready():
# Update the mod_list for each user profile
var _success_update_mod_lists := ModLoaderUserProfile._update_mod_lists()

# Hooks must be generated after all autoloads are available.
# Variables initialized with an autoload property otherwise causes errors.
if not OS.has_feature("editor") and ModLoaderStore.any_mod_hooked:
# Generate mod hooks
_ModLoaderModHookPacker.start()


func _exit_tree() -> void:
# Save the cache stored in ModLoaderStore to the cache file.
_ModLoaderCache.save_to_file()


func are_mods_disabled() -> bool:
return false


func _load_mods() -> void:
ModLoaderStore.previous_mod_dirs = _ModLoaderPath.get_dir_paths_in_dir(_ModLoaderPath.get_unpacked_mods_dir_path())
# Loop over "res://mods" and add any mod zips to the unpacked virtual
Expand Down Expand Up @@ -370,3 +394,12 @@ func _disable_mod(mod: ModData) -> void:
_ModLoaderScriptExtension.remove_all_extensions_of_mod(mod)

remove_child(mod_main_instance)


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()
add_child(restart_notification_scene)
35 changes: 26 additions & 9 deletions addons/mod_loader/mod_loader_store.gd
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const DEBUG_ENABLE_STORING_FILEPATHS := false
# This is where mod ZIPs are unpacked to
const UNPACKED_DIR := "res://mods-unpacked/"

# Default name for the mod hook pack
const MOD_HOOK_PACK_NAME := "mod-hooks.zip"

# Set to true to require using "--enable-mods" to enable them
const REQUIRE_CMD_LINE := false

Expand All @@ -30,18 +33,22 @@ const LOG_NAME = "ModLoader:Store"
# Vars
# =============================================================================


var any_mod_hooked := false

# Example:
# var callable_stack := {
# "res://game/Game.gd": {
# "_ready": {
# "before": [],
# "after": []
# }
# }
# var modding_hooks := {
# 1917482423: [Callable, Callable],
# 3108290668: [Callable],
# }
var any_mod_hooked := false
var modding_hooks := {}

# Example:
# var hooked_script_paths := {
# "res://game/game.gd": null,
# }
var hooked_script_paths := {}

# Order for mods to be loaded in, set by `get_load_order`
var mod_load_order := []

Expand Down Expand Up @@ -142,6 +149,11 @@ var ml_options := {
# Can be used in the editor to load mods from your Steam workshop directory
override_path_to_workshop = "",

# Override for the path where the modding hook resource pack is located.
# Requires an absolute path.
override_path_to_hook_pack = "", # Default if unspecified: "OS.get_executable_path().get_base_dir()" -- get with _ModLoaderPath.get_path_to_hook_pack()
override_hook_pack_name = "", # Default if unspecified: "mod-hooks.zip"

# If true, using deprecated funcs will trigger a warning, instead of a fatal
# error. This can be helpful when developing mods that depend on a mod that
# hasn't been updated to fix the deprecated issues yet
Expand All @@ -155,6 +167,11 @@ var ml_options := {
load_from_steam_workshop = false,
# Indicates whether to load mods from the "mods" folder located at the game's install directory, or the overridden mods path.
load_from_local = true,

# Can be used to overwrite the default scene that is displayed if a game restart is required.
restart_notification_scene_path = "res://addons/mod_loader/restart_notification.tscn",
# Can be used to disable the mod loader's restart logic.
disable_restart = false,
}


Expand All @@ -175,7 +192,7 @@ func _update_ml_options_from_options_resource() -> void:
var ml_options_path := "res://addons/mod_loader/options/options.tres"

# Get user options for ModLoader
if not _ModLoaderFile.file_exists(ml_options_path):
if not _ModLoaderFile.file_exists(ml_options_path) and not ResourceLoader.exists(ml_options_path):
ModLoaderLog.fatal(str("A critical file is missing: ", ml_options_path), LOG_NAME)

var options_resource: ModLoaderCurrentOptions = load(ml_options_path)
Expand Down
17 changes: 14 additions & 3 deletions addons/mod_loader/resources/options_profile.gd
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,24 @@ extends Resource
@export var disabled_mods: Array[String] = []
@export var allow_modloader_autoloads_anywhere: bool = false
@export var steam_id: int = 0
@export_dir var override_path_to_mods = ""
@export_dir var override_path_to_configs = ""
@export_dir var override_path_to_workshop = ""
@export_global_dir var override_path_to_mods = ""
@export_global_dir var override_path_to_configs = ""
@export_global_dir var override_path_to_workshop = ""
@export var ignore_deprecated_errors: bool = false
@export var ignored_mod_names_in_log: Array[String] = []
@export_group("Mod Source")
## Indicates whether to load mods from the Steam Workshop directory, or the overridden workshop path.
@export var load_from_steam_workshop: bool = false
## Indicates whether to load mods from the "mods" folder located at the game's install directory, or the overridden mods path.
@export var load_from_local: bool = true
@export_group("Mod Hooks")
## Can be used to override the default hook pack path, the hook pack is located inside the game's install directory by default.
## To override the path specify a new absolute path.
@export_global_dir var override_path_to_hook_pack := ""
## Can be used to override the default hook pack name, by default it is [constant ModLoaderStore.MOD_HOOK_PACK_NAME]
@export var override_hook_pack_name := ""
## Can be used to specify your own scene that is displayed if a game restart is required.
## For example if new mod hooks where generated.
@export_dir var restart_notification_scene_path := "res://addons/mod_loader/restart_notification.tscn"
## Can be used to disable the mod loader's restart logic. Use the [signal ModLoader.new_hooks_created] to implement your own restart logic.
@export var disable_restart := false
Loading