Skip to content

Commit ef7e0b7

Browse files
authored
feat: 🚧 hook creation and loading (#3)
* feat: 🚧 hook creation and loading * feat: ✨ hook creation and loading
1 parent a9244e0 commit ef7e0b7

File tree

11 files changed

+201
-67
lines changed

11 files changed

+201
-67
lines changed

addons/mod_loader/api/mod.gd

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,17 @@ static func add_hook(mod_callable: Callable, script_path: String, method_name: S
2121
if not ModLoaderStore.modding_hooks.has(hash):
2222
ModLoaderStore.modding_hooks[hash] = []
2323
ModLoaderStore.modding_hooks[hash].push_back(mod_callable)
24+
ModLoaderLog.debug("Added hook script: \"%s\" method: \"%s\" is_before: \"%s\"" % [script_path, method_name, is_before], LOG_NAME)
25+
if not ModLoaderStore.hooked_script_paths.has(script_path):
26+
ModLoaderStore.hooked_script_paths[script_path] = null
27+
2428

2529
static func call_hooks(self_object: Object, args: Array, hook_hash:int) -> void:
2630
var hooks = ModLoaderStore.modding_hooks.get(hook_hash, null)
2731
if hooks:
2832
for mod_func in hooks:
29-
mod_func.call(self_object)
33+
mod_func.call(self_object, args)
34+
3035

3136
static func get_hook_hash(path:String, method:String, is_before:bool) -> int:
3237
return hash(path + method + ("before" if is_before else "after"))

addons/mod_loader/internal/cache.gd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ static func add_data(key: String, data: Dictionary) -> Dictionary:
3131
# Get data from a specific key
3232
static func get_data(key: String) -> Dictionary:
3333
if not ModLoaderStore.cache.has(key):
34-
ModLoaderLog.error("key: \"%s\" not found in \"ModLoaderStore.cache\"" % key, LOG_NAME)
34+
ModLoaderLog.info("key: \"%s\" not found in \"ModLoaderStore.cache\"" % key, LOG_NAME)
3535
return {}
3636

3737
return ModLoaderStore.cache[key]

addons/mod_loader/internal/file.gd

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,4 +227,3 @@ static func dir_exists(path: String) -> bool:
227227
# modders in understanding and troubleshooting issues.
228228
static func _code_note(_msg:String):
229229
pass
230-
Lines changed: 48 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,65 @@
1-
extends Node
1+
class_name _ModLoaderModHookPacker
2+
extends RefCounted
23

34

4-
const ModHookPreprocessorScript = preload("res://addons/mod_loader/internal/mod_hook_preprocessor.gd")
5-
static var ModHookPreprocessor
5+
# This class is used to generate mod hooks on demand and pack them into a zip file.
6+
# Currently all of the included functions are internal and should only be used by the mod loader itself.
67

8+
const LOG_NAME := "ModLoader:ModHookPacker"
79

8-
func _ready() -> void:
9-
run_script()
10-
await get_tree().process_frame
11-
get_tree().quit()
1210

11+
static func start() -> void:
12+
var hook_pre_processor = _ModLoaderModHookPreProcessor.new()
13+
hook_pre_processor.process_begin()
1314

14-
func run_script() -> void:
15-
ModHookPreprocessor = ModHookPreprocessorScript.new()
16-
ModHookPreprocessor.process_begin()
15+
var mod_hook_pack_path := _ModLoaderPath.get_path_to_hook_pack()
1716

18-
# TODO: consider mac location
19-
var res := OS.get_executable_path().get_base_dir()
20-
if OS.has_feature("editor"):
21-
res = ProjectSettings.globalize_path("res://").rsplit("/", true, 2)[0]
22-
23-
var save_base_path := res.path_join("godot_mod_loader/")
24-
prints("Saved to:", save_base_path)
25-
DirAccess.make_dir_recursive_absolute(save_base_path)
17+
# Create mod hook pack path if necessary
18+
if not DirAccess.dir_exists_absolute(mod_hook_pack_path.get_base_dir()):
19+
var error := DirAccess.make_dir_recursive_absolute(mod_hook_pack_path.get_base_dir())
20+
if not error == OK:
21+
ModLoaderLog.error("Error creating the mod hook directory at %s" % mod_hook_pack_path, LOG_NAME)
22+
return
23+
ModLoaderLog.debug("Created dir at: %s" % mod_hook_pack_path, LOG_NAME)
2624

25+
# Create mod hook zip
2726
var zip_writer := ZIPPacker.new()
28-
var err := zip_writer.open(save_base_path.path_join("temp_test_mod.zip"))
29-
if err != OK:
30-
printerr(err)
31-
32-
transform_scripts_recursive(ModHookPreprocessor.process_script, zip_writer)
33-
34-
zip_writer.close()
35-
36-
37-
func transform_scripts_recursive(callback: Callable, zip_writer: ZIPPacker, path := "res://") -> void:
38-
var dir := DirAccess.open(path)
39-
if not dir:
40-
printt("An error occurred when trying to access the path:", path)
27+
var error: Error
28+
29+
if not FileAccess.file_exists(mod_hook_pack_path):
30+
# Clear cache if the hook pack does not exist
31+
_ModLoaderCache.remove_data("hooks")
32+
error = zip_writer.open(mod_hook_pack_path)
33+
else:
34+
# If there is a pack already append to it
35+
error = zip_writer.open(mod_hook_pack_path, ZIPPacker.APPEND_ADDINZIP)
36+
if not error == OK:
37+
ModLoaderLog.error("Error(%s) writing to zip file at path: %s" % [error, mod_hook_pack_path], LOG_NAME)
4138
return
4239

43-
dir.list_dir_begin()
44-
var file_name = dir.get_next()
45-
while file_name != "":
46-
if path.begins_with("res://addons") or path.begins_with("res://mods-unpacked"):
47-
file_name = dir.get_next()
48-
continue
40+
var cache := _ModLoaderCache.get_data("hooks")
41+
var script_paths_with_hook: Array = [] if cache.is_empty() else cache.script_paths
42+
var new_hooks_created := false
4943

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

55-
if file_name.get_extension() != "gd":
56-
file_name = dir.get_next()
57-
continue
50+
var processed_source_code := hook_pre_processor.process_script(path)
5851

59-
var processed: String = callback.call(dir.get_current_dir() + file_name)
60-
zip_writer.start_file(path.trim_prefix("res://").path_join(file_name))
61-
zip_writer.write_file(processed.to_utf8_buffer())
52+
zip_writer.start_file(path.trim_prefix("res://"))
53+
zip_writer.write_file(processed_source_code.to_utf8_buffer())
6254
zip_writer.close_file()
6355

64-
file_name = dir.get_next()
56+
ModLoaderLog.debug("Hooks created for script: %s" % path, LOG_NAME)
57+
new_hooks_created = true
58+
script_paths_with_hook.push_back(path)
59+
60+
if new_hooks_created:
61+
_ModLoaderCache.update_data("hooks", {"script_paths": script_paths_with_hook})
62+
_ModLoaderCache.save_to_file()
63+
ModLoader.new_hooks_created.emit()
64+
65+
zip_writer.close()

addons/mod_loader/internal/mod_hook_preprocessor.gd

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
extends Object
1+
class_name _ModLoaderModHookPreProcessor
2+
extends RefCounted
3+
4+
5+
# This class is used to process the source code from a script at a given path.
6+
# Currently all of the included functions are internal and should only be used by the mod loader itself.
7+
8+
const LOG_NAME := "ModLoader:ModHookPreProcessor"
29

310
const REQUIRE_EXPLICIT_ADDITION := false
411
const METHOD_PREFIX := "vanilla_"
@@ -30,8 +37,12 @@ func process_begin() -> void:
3037

3138

3239
func process_script(path: String) -> String:
40+
var start_time := Time.get_ticks_msec()
41+
ModLoaderLog.debug("Start processing script at path: %s" % path, LOG_NAME)
3342
var current_script := load(path) as GDScript
43+
3444
var source_code := current_script.source_code
45+
3546
var source_code_additions := ""
3647

3748
# We need to stop all vanilla methods from forming inheritance chains,
@@ -109,8 +120,9 @@ func process_script(path: String) -> String:
109120
if source_code_additions != "":
110121
source_code = "%s\n%s\n%s" % [source_code, MOD_LOADER_HOOKS_START_STRING, source_code_additions]
111122

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

125+
return source_code
114126

115127

116128
static func get_function_arg_name_string(args: Array) -> String:
@@ -253,10 +265,10 @@ static func get_mod_loader_hook(
253265

254266
return """
255267
{%STATIC%}func {%METHOD_NAME%}({%METHOD_PARAMS%}){%RETURN_TYPE_STRING%}:
256-
if ModLoaderStore.any_mod_hooked:
268+
if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
257269
ModLoaderMod.call_hooks({%SELF%}, [{%METHOD_ARGS%}], {%HOOK_ID_BEFORE%})
258270
{%METHOD_RETURN_VAR%}{%METHOD_PREFIX%}_{%METHOD_NAME%}({%METHOD_ARGS%})
259-
if ModLoaderStore.any_mod_hooked:
271+
if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
260272
ModLoaderMod.call_hooks({%SELF%}, [{%METHOD_ARGS%}], {%HOOK_ID_AFTER%})
261273
{%METHOD_RETURN%}""".format({
262274
"%METHOD_PREFIX%": method_prefix,

addons/mod_loader/internal/path.gd

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,20 @@ static func get_path_to_mod_config_file(mod_id: String, config_name: String) ->
192192
return mod_config_dir.path_join(config_name + ".json")
193193

194194

195+
# Get the path to the mod hook pack
196+
static func get_path_to_hook_pack() -> String:
197+
if ModLoaderStore.ml_options.override_path_to_hook_pack.is_empty():
198+
if ModLoaderStore.ml_options.override_hook_pack_name.is_empty():
199+
return OS.get_executable_path().get_base_dir().path_join(ModLoaderStore.MOD_HOOK_PACK_NAME)
200+
else:
201+
return OS.get_executable_path().get_base_dir().path_join(ModLoaderStore.ml_options.override_hook_pack_name)
202+
else:
203+
if ModLoaderStore.ml_options.override_hook_pack_name.is_empty():
204+
return ModLoaderStore.ml_options.override_path_to_hook_pack.path_join(ModLoaderStore.MOD_HOOK_PACK_NAME)
205+
else:
206+
return ModLoaderStore.ml_options.override_path_to_hook_pack.path_join(ModLoaderStore.ml_options.override_hook_pack_name)
207+
208+
195209
# Returns the mod directory name ("some-mod") from a given path (e.g. "res://mods-unpacked/some-mod/extensions/extension.gd")
196210
static func get_mod_dir(path: String) -> String:
197211
var initial := ModLoaderStore.UNPACKED_DIR

addons/mod_loader/mod_loader.gd

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ signal logged(entry: ModLoaderLog.ModLoaderLogEntry)
2525
## Emitted if the [member ModData.current_config] of any mod changed.
2626
## Use the [member ModConfig.mod_id] of the [ModConfig] to check if the config of your mod has changed.
2727
signal current_config_changed(config: ModConfig)
28-
28+
## Emitted when new mod hooks are created. A game restart is required to load them.
29+
signal new_hooks_created
2930

3031
const LOG_NAME := "ModLoader"
3132

@@ -34,6 +35,17 @@ const LOG_NAME := "ModLoader"
3435
# =============================================================================
3536

3637
func _init() -> void:
38+
# Only load the hook pack if not in the editor
39+
# We can't use it in the editor - see https://github.com/godotengine/godot/issues/19815
40+
# Mod devs can use the Dev Tool to generate hooks in the editor.
41+
if not OS.has_feature("editor") and FileAccess.file_exists(_ModLoaderPath.get_path_to_hook_pack()):
42+
# Load mod hooks
43+
var load_hooks_pack_success := ProjectSettings.load_resource_pack(_ModLoaderPath.get_path_to_hook_pack())
44+
if not load_hooks_pack_success:
45+
ModLoaderLog.error("Failed loading hooks pack from: %s" % _ModLoaderPath.get_path_to_hook_pack(), LOG_NAME)
46+
else:
47+
ModLoaderLog.debug("Successfully loaded hooks pack from: %s" % _ModLoaderPath.get_path_to_hook_pack(), LOG_NAME)
48+
3749
# Ensure the ModLoaderStore and ModLoader autoloads are in the correct position.
3850
_check_autoload_positions()
3951

@@ -61,6 +73,8 @@ func _init() -> void:
6173

6274
ModLoaderStore.is_initializing = false
6375

76+
new_hooks_created.connect(_on_new_hooks_created)
77+
6478

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

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

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

7999

100+
func are_mods_disabled() -> bool:
101+
return false
102+
103+
80104
func _load_mods() -> void:
81105
ModLoaderStore.previous_mod_dirs = _ModLoaderPath.get_dir_paths_in_dir(_ModLoaderPath.get_unpacked_mods_dir_path())
82106
# Loop over "res://mods" and add any mod zips to the unpacked virtual
@@ -370,3 +394,12 @@ func _disable_mod(mod: ModData) -> void:
370394
_ModLoaderScriptExtension.remove_all_extensions_of_mod(mod)
371395

372396
remove_child(mod_main_instance)
397+
398+
399+
func _on_new_hooks_created() -> void:
400+
if ModLoaderStore.ml_options.disable_restart:
401+
ModLoaderLog.debug("Mod Loader handled restart is disabled.", LOG_NAME)
402+
return
403+
ModLoaderLog.debug("Instancing restart notification scene from path: %s" % [ModLoaderStore.ml_options.restart_notification_scene_path], LOG_NAME)
404+
var restart_notification_scene = load(ModLoaderStore.ml_options.restart_notification_scene_path).instantiate()
405+
add_child(restart_notification_scene)

addons/mod_loader/mod_loader_store.gd

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const DEBUG_ENABLE_STORING_FILEPATHS := false
2222
# This is where mod ZIPs are unpacked to
2323
const UNPACKED_DIR := "res://mods-unpacked/"
2424

25+
# Default name for the mod hook pack
26+
const MOD_HOOK_PACK_NAME := "mod-hooks.zip"
27+
2528
# Set to true to require using "--enable-mods" to enable them
2629
const REQUIRE_CMD_LINE := false
2730

@@ -30,18 +33,22 @@ const LOG_NAME = "ModLoader:Store"
3033
# Vars
3134
# =============================================================================
3235

36+
37+
var any_mod_hooked := false
38+
3339
# Example:
34-
# var callable_stack := {
35-
# "res://game/Game.gd": {
36-
# "_ready": {
37-
# "before": [],
38-
# "after": []
39-
# }
40-
# }
40+
# var modding_hooks := {
41+
# 1917482423: [Callable, Callable],
42+
# 3108290668: [Callable],
4143
# }
42-
var any_mod_hooked := false
4344
var modding_hooks := {}
4445

46+
# Example:
47+
# var hooked_script_paths := {
48+
# "res://game/game.gd": null,
49+
# }
50+
var hooked_script_paths := {}
51+
4552
# Order for mods to be loaded in, set by `get_load_order`
4653
var mod_load_order := []
4754

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

152+
# Override for the path where the modding hook resource pack is located.
153+
# Requires an absolute path.
154+
override_path_to_hook_pack = "", # Default if unspecified: "OS.get_executable_path().get_base_dir()" -- get with _ModLoaderPath.get_path_to_hook_pack()
155+
override_hook_pack_name = "", # Default if unspecified: "mod-hooks.zip"
156+
145157
# If true, using deprecated funcs will trigger a warning, instead of a fatal
146158
# error. This can be helpful when developing mods that depend on a mod that
147159
# hasn't been updated to fix the deprecated issues yet
@@ -155,6 +167,11 @@ var ml_options := {
155167
load_from_steam_workshop = false,
156168
# Indicates whether to load mods from the "mods" folder located at the game's install directory, or the overridden mods path.
157169
load_from_local = true,
170+
171+
# Can be used to overwrite the default scene that is displayed if a game restart is required.
172+
restart_notification_scene_path = "res://addons/mod_loader/restart_notification.tscn",
173+
# Can be used to disable the mod loader's restart logic.
174+
disable_restart = false,
158175
}
159176

160177

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

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

181198
var options_resource: ModLoaderCurrentOptions = load(ml_options_path)

addons/mod_loader/resources/options_profile.gd

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,24 @@ extends Resource
88
@export var disabled_mods: Array[String] = []
99
@export var allow_modloader_autoloads_anywhere: bool = false
1010
@export var steam_id: int = 0
11-
@export_dir var override_path_to_mods = ""
12-
@export_dir var override_path_to_configs = ""
13-
@export_dir var override_path_to_workshop = ""
11+
@export_global_dir var override_path_to_mods = ""
12+
@export_global_dir var override_path_to_configs = ""
13+
@export_global_dir var override_path_to_workshop = ""
1414
@export var ignore_deprecated_errors: bool = false
1515
@export var ignored_mod_names_in_log: Array[String] = []
1616
@export_group("Mod Source")
1717
## Indicates whether to load mods from the Steam Workshop directory, or the overridden workshop path.
1818
@export var load_from_steam_workshop: bool = false
1919
## Indicates whether to load mods from the "mods" folder located at the game's install directory, or the overridden mods path.
2020
@export var load_from_local: bool = true
21+
@export_group("Mod Hooks")
22+
## Can be used to override the default hook pack path, the hook pack is located inside the game's install directory by default.
23+
## To override the path specify a new absolute path.
24+
@export_global_dir var override_path_to_hook_pack := ""
25+
## Can be used to override the default hook pack name, by default it is [constant ModLoaderStore.MOD_HOOK_PACK_NAME]
26+
@export var override_hook_pack_name := ""
27+
## Can be used to specify your own scene that is displayed if a game restart is required.
28+
## For example if new mod hooks where generated.
29+
@export_dir var restart_notification_scene_path := "res://addons/mod_loader/restart_notification.tscn"
30+
## Can be used to disable the mod loader's restart logic. Use the [signal ModLoader.new_hooks_created] to implement your own restart logic.
31+
@export var disable_restart := false

0 commit comments

Comments
 (0)