diff --git a/addons/mod_loader/mod_loader_setup.gd b/addons/mod_loader/mod_loader_setup.gd index 3ca75fbd..5e4cfe65 100644 --- a/addons/mod_loader/mod_loader_setup.gd +++ b/addons/mod_loader/mod_loader_setup.gd @@ -65,24 +65,30 @@ const new_global_classes := [ "class": "ModLoaderSteam", "language": "GDScript", "path": "res://addons/mod_loader/api/third_party/steam.gd" + }, { + "base": "Node", + "class": "ModLoaderLog", + "language": "GDScript", + "path": "res://addons/mod_loader/api/log.gd" } ] -# IMPORTANT: use the ModLoaderUtils via this variable within this script! +# IMPORTANT: use the ModLoaderLog via this variable within this script! # Otherwise, script compilation will break on first load since the class is not defined. -var modloaderutils: Node = load("res://addons/mod_loader/mod_loader_utils.gd").new() +var ModLoaderSetupLog: Object = load("res://addons/mod_loader/setup/setup_log.gd") +var ModLoaderSetupUtils: Object = load("res://addons/mod_loader/setup/setup_utils.gd") var path := {} var file_name := {} -var is_only_setup: bool = modloaderutils.is_running_with_command_line_arg("--only-setup") -var is_setup_create_override_cfg : bool = modloaderutils.is_running_with_command_line_arg("--setup-create-override-cfg") +var is_only_setup: bool = ModLoaderSetupUtils.is_running_with_command_line_arg("--only-setup") +var is_setup_create_override_cfg : bool = ModLoaderSetupUtils.is_running_with_command_line_arg("--setup-create-override-cfg") func _init() -> void: - modloaderutils.log_debug("ModLoader setup initialized", LOG_NAME) + ModLoaderSetupLog.debug("ModLoader setup initialized", LOG_NAME) - var mod_loader_index: int = modloaderutils.get_autoload_index("ModLoader") - var mod_loader_store_index: int = modloaderutils.get_autoload_index("ModLoaderStore") + var mod_loader_index: int = ModLoaderSetupUtils.get_autoload_index("ModLoader") + var mod_loader_store_index: int = ModLoaderSetupUtils.get_autoload_index("ModLoaderStore") # Avoid doubling the setup work # Checks if the ModLoaderStore is the first autoload and ModLoader the second @@ -101,7 +107,7 @@ func _init() -> void: # ModLoader already setup - switch to the main scene func modded_start() -> void: - modloaderutils.log_info("ModLoader is available, mods can be loaded!", LOG_NAME) + ModLoaderSetupLog.info("ModLoader is available, mods can be loaded!", LOG_NAME) OS.set_window_title("%s (Modded)" % ProjectSettings.get_setting("application/config/name")) @@ -110,13 +116,13 @@ func modded_start() -> void: # Set up the ModLoader as an autoload and register the other global classes. func setup_modloader() -> void: - modloaderutils.log_info("Setting up ModLoader", LOG_NAME) + ModLoaderSetupLog.info("Setting up ModLoader", LOG_NAME) # Setup path and file_name dict with all required paths and file names. setup_file_data() # Register all new helper classes as global - modloaderutils.register_global_classes_from_array(new_global_classes) + ModLoaderSetupUtils.register_global_classes_from_array(new_global_classes) # Add ModLoader autoload (the * marks the path as autoload) reorder_autoloads() @@ -132,7 +138,7 @@ func setup_modloader() -> void: handle_project_binary() # ModLoader is set up. A game restart is required to apply the ProjectSettings. - modloaderutils.log_info("ModLoader is set up, a game restart is required.", LOG_NAME) + ModLoaderSetupLog.info("ModLoader is set up, a game restart is required.", LOG_NAME) match true: # If the --only-setup cli argument is passed, quit with exit code 0 @@ -170,13 +176,13 @@ func reorder_autoloads() -> void: # Saves the ProjectSettings to a override.cfg file in the base game directory. func handle_override_cfg() -> void: - modloaderutils.log_debug("using the override.cfg file", LOG_NAME) - var _save_custom_error: int = ProjectSettings.save_custom(modloaderutils.get_override_path()) + ModLoaderSetupLog.debug("using the override.cfg file", LOG_NAME) + var _save_custom_error: int = ProjectSettings.save_custom(ModLoaderSetupUtils.get_override_path()) # Creates the project.binary file, adds it to the pck and removes the no longer needed project.binary file. func handle_project_binary() -> void: - modloaderutils.log_debug("injecting the project.binary file", LOG_NAME) + ModLoaderSetupLog.debug("injecting the project.binary file", LOG_NAME) create_project_binary() inject_project_binary() clean_up_project_binary_file() @@ -191,7 +197,7 @@ func create_project_binary() -> void: func inject_project_binary() -> void: var output_add_project_binary := [] var _exit_code_add_project_binary := OS.execute(path.pck_tool, ["--pack", path.pck, "--action", "add", "--file", path.project_binary, "--remove-prefix", path.mod_loader_dir], true, output_add_project_binary) - modloaderutils.log_debug_json_print("Adding custom project.binary to res://", output_add_project_binary, LOG_NAME) + ModLoaderSetupLog.debug_json_print("Adding custom project.binary to res://", output_add_project_binary, LOG_NAME) # Removes the project.binary file @@ -205,27 +211,28 @@ func setup_file_data() -> void: # C:/path/to/game/game.exe path.exe = OS.get_executable_path() # C:/path/to/game/ - path.game_base_dir = modloaderutils.get_local_folder_dir() + path.game_base_dir = ModLoaderSetupUtils.get_local_folder_dir() # C:/path/to/game/addons/mod_loader path.mod_loader_dir = path.game_base_dir + "addons/mod_loader/" # C:/path/to/game/addons/mod_loader/vendor/godotpcktool/godotpcktool.exe path.pck_tool = path.mod_loader_dir + "vendor/godotpcktool/godotpcktool.exe" # can be supplied to override the exe_name - file_name.cli_arg_exe = modloaderutils.get_cmd_line_arg_value("--exe-name") + file_name.cli_arg_exe = ModLoaderSetupUtils.get_cmd_line_arg_value("--exe-name") # can be supplied to override the pck_name - file_name.cli_arg_pck = modloaderutils.get_cmd_line_arg_value("--pck-name") + file_name.cli_arg_pck = ModLoaderSetupUtils.get_cmd_line_arg_value("--pck-name") # game - or use the value of cli_arg_exe_name if there is one - file_name.exe = modloaderutils.get_file_name_from_path(path.exe, true, true) if file_name.cli_arg_exe == '' else file_name.cli_arg_exe + file_name.exe = ModLoaderSetupUtils.get_file_name_from_path(path.exe, true, true) if file_name.cli_arg_exe == '' else file_name.cli_arg_exe # game - or use the value of cli_arg_pck_name if there is one # using exe_path.get_file() instead of exe_name # so you don't override the pck_name with the --exe-name cli arg # the main pack name is the same as the .exe name # if --main-pack cli arg is not set - file_name.pck = modloaderutils.get_file_name_from_path(path.exe, true, true) if file_name.cli_arg_pck == '' else file_name.cli_arg_pck + file_name.pck = ModLoaderSetupUtils.get_file_name_from_path(path.exe, true, true) if file_name.cli_arg_pck == '' else file_name.cli_arg_pck # C:/path/to/game/game.pck path.pck = path.game_base_dir.plus_file(file_name.pck + '.pck') # C:/path/to/game/addons/mod_loader/project.binary path.project_binary = path.mod_loader_dir + "project.binary" - modloaderutils.log_debug_json_print("path: ", path, LOG_NAME) - modloaderutils.log_debug_json_print("file_name: ", file_name, LOG_NAME) \ No newline at end of file + ModLoaderSetupLog.debug_json_print("path: ", path, LOG_NAME) + ModLoaderSetupLog.debug_json_print("file_name: ", file_name, LOG_NAME) + \ No newline at end of file diff --git a/addons/mod_loader/setup/setup_log.gd b/addons/mod_loader/setup/setup_log.gd new file mode 100644 index 00000000..f1218571 --- /dev/null +++ b/addons/mod_loader/setup/setup_log.gd @@ -0,0 +1,213 @@ +class_name ModLoaderSetupLog + + +# Slimed down version of ModLoaderLog for the ModLoader Self Setup + +const MOD_LOG_PATH := "user://logs/modloader.log" + +enum VERBOSITY_LEVEL { + ERROR, + WARNING, + INFO, + DEBUG, +} + + +class ModLoaderLogEntry: + extends Resource + + var mod_name: String + var message: String + var type: String + var time: String + + + func _init(_mod_name: String, _message: String, _type: String, _time: String) -> void: + mod_name = _mod_name + message = _message + type = _type + time = _time + + + func get_entry() -> String: + return time + get_prefix() + message + + + func get_prefix() -> String: + return "%s %s: " % [type.to_upper(), mod_name] + + + func get_md5() -> String: + return str(get_prefix(), message).md5_text() + + +# API log functions +# ============================================================================= + +# Logs the error in red and a stack trace. Prefixed FATAL-ERROR +# Stops the execution in editor +# Always logged +static func fatal(message: String, mod_name: String) -> void: + _log(message, mod_name, "fatal-error") + + +# Logs the message and pushed an error. Prefixed ERROR +# Always logged +static func error(message: String, mod_name: String) -> void: + _log(message, mod_name, "error") + + +# Logs the message and pushes a warning. Prefixed WARNING +# Logged with verbosity level at or above warning (-v) +static func warning(message: String, mod_name: String) -> void: + _log(message, mod_name, "warning") + + +# Logs the message. Prefixed INFO +# Logged with verbosity level at or above info (-vv) +static func info(message: String, mod_name: String) -> void: + _log(message, mod_name, "info") + + +# Logs the message. Prefixed SUCCESS +# Logged with verbosity level at or above info (-vv) +static func success(message: String, mod_name: String) -> void: + _log(message, mod_name, "success") + + +# Logs the message. Prefixed DEBUG +# Logged with verbosity level at or above debug (-vvv) +static func debug(message: String, mod_name: String) -> void: + _log(message, mod_name, "debug") + + +# Logs the message formatted with [method JSON.print]. Prefixed DEBUG +# Logged with verbosity level at or above debug (-vvv) +static func debug_json_print(message: String, json_printable, mod_name: String) -> void: + message = "%s\n%s" % [message, JSON.print(json_printable, " ")] + _log(message, mod_name, "debug") + + +# Internal log functions +# ============================================================================= + +static func _log(message: String, mod_name: String, log_type: String = "info") -> void: + var time := "%s " % _get_time_string() + var log_entry := ModLoaderLogEntry.new(mod_name, message, log_type, time) + + match log_type.to_lower(): + "fatal-error": + push_error(message) + _write_to_log_file(log_entry.get_entry()) + _write_to_log_file(JSON.print(get_stack(), " ")) + assert(false, message) + "error": + printerr(message) + push_error(message) + _write_to_log_file(log_entry.get_entry()) + "warning": + print(log_entry.get_prefix() + message) + push_warning(message) + _write_to_log_file(log_entry.get_entry()) + "info", "success": + print(log_entry.get_prefix() + message) + _write_to_log_file(log_entry.get_entry()) + "debug": + print(log_entry.get_prefix() + message) + _write_to_log_file(log_entry.get_entry()) + + +# Internal Date Time +# ============================================================================= + +# Returns the current time as a string in the format hh:mm:ss +static func _get_time_string() -> String: + var date_time := Time.get_datetime_dict_from_system() + return "%02d:%02d:%02d" % [ date_time.hour, date_time.minute, date_time.second ] + + +# Returns the current date as a string in the format yyyy-mm-dd +static func _get_date_string() -> String: + var date_time := Time.get_datetime_dict_from_system() + return "%s-%02d-%02d" % [ date_time.year, date_time.month, date_time.day ] + + +# Returns the current date and time as a string in the format yyyy-mm-dd_hh:mm:ss +static func _get_date_time_string() -> String: + return "%s_%s" % [ _get_date_string(), _get_time_string() ] + + +# Internal File +# ============================================================================= + +static func _write_to_log_file(string_to_write: String) -> void: + var log_file := File.new() + + if not log_file.file_exists(MOD_LOG_PATH): + _rotate_log_file() + + var error := log_file.open(MOD_LOG_PATH, File.READ_WRITE) + if not error == OK: + assert(false, "Could not open log file, error code: %s" % error) + return + + log_file.seek_end() + log_file.store_string("\n" + string_to_write) + log_file.close() + + +# Keeps log backups for every run, just like the Godot; gdscript implementation of +# https://github.com/godotengine/godot/blob/1d14c054a12dacdc193b589e4afb0ef319ee2aae/core/io/logger.cpp#L151 +static func _rotate_log_file() -> void: + var MAX_LOGS := int(ProjectSettings.get_setting("logging/file_logging/max_log_files")) + var log_file := File.new() + + if log_file.file_exists(MOD_LOG_PATH): + if MAX_LOGS > 1: + var datetime := _get_date_time_string().replace(":", ".") + var backup_name: String = MOD_LOG_PATH.get_basename() + "_" + datetime + if MOD_LOG_PATH.get_extension().length() > 0: + backup_name += "." + MOD_LOG_PATH.get_extension() + + var dir := Directory.new() + if dir.dir_exists(MOD_LOG_PATH.get_base_dir()): + dir.copy(MOD_LOG_PATH, backup_name) + _clear_old_log_backups() + + # only File.WRITE creates a new file, File.READ_WRITE throws an error + var error := log_file.open(MOD_LOG_PATH, File.WRITE) + if not error == OK: + assert(false, "Could not open log file, error code: %s" % error) + log_file.store_string('%s Created log' % _get_date_string()) + log_file.close() + + +static func _clear_old_log_backups() -> void: + var MAX_LOGS := int(ProjectSettings.get_setting("logging/file_logging/max_log_files")) + var MAX_BACKUPS := MAX_LOGS - 1 # -1 for the current new log (not a backup) + var basename := MOD_LOG_PATH.get_file().get_basename() as String + var extension := MOD_LOG_PATH.get_extension() as String + + var dir := Directory.new() + if not dir.dir_exists(MOD_LOG_PATH.get_base_dir()): + return + if not dir.open(MOD_LOG_PATH.get_base_dir()) == OK: + return + + dir.list_dir_begin() + var file := dir.get_next() + var backups := [] + while file.length() > 0: + if (not dir.current_is_dir() and + file.begins_with(basename) and + file.get_extension() == extension and + not file == MOD_LOG_PATH.get_file()): + backups.append(file) + file = dir.get_next() + dir.list_dir_end() + + if backups.size() > MAX_BACKUPS: + backups.sort() + backups.resize(backups.size() - MAX_BACKUPS) + for file_to_delete in backups: + dir.remove(file_to_delete) diff --git a/addons/mod_loader/setup/setup_utils.gd b/addons/mod_loader/setup/setup_utils.gd new file mode 100644 index 00000000..6623549a --- /dev/null +++ b/addons/mod_loader/setup/setup_utils.gd @@ -0,0 +1,190 @@ +class_name ModLoaderSetupUtils + + +# Slimed down version of ModLoaderUtils for the ModLoader Self Setup + +const LOG_NAME := "ModLoader:SetupUtils" + + +# 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: + var game_install_directory := OS.get_executable_path().get_base_dir() + + if OS.get_name() == "OSX": + game_install_directory = game_install_directory.get_base_dir().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.plus_file(subfolder) + + +# 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 + + +# 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 + + +# 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.plus_file("override.cfg") + + +# 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 ModLoaderSetupLog: Object = load("res://addons/mod_loader/setup/setup_log.gd") + 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.class == new_class.class: + if OS.has_feature("editor"): + ModLoaderSetupLog.info('Class "%s" to be registered as global was already registered by the editor. Skipping.' % new_class.class, LOG_NAME) + else: + ModLoaderSetupLog.info('Class "%s" to be registered as global already exists. Skipping.' % new_class.class, LOG_NAME) + continue + + registered_classes.append(new_class) + registered_class_icons[new_class.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: { "base": "ParentClass", "class": "ClassName", "language": "GDScript", "path": "res://path/class_name.gd" } +static func is_valid_global_class_dict(global_class_dict: Dictionary) -> bool: + var ModLoaderSetupLog: Object = load("res://addons/mod_loader/setup/setup_log.gd") + var required_fields := ["base", "class", "language", "path"] + if not global_class_dict.has_all(required_fields): + ModLoaderSetupLog.fatal("Global class to be registered is missing one of %s" % required_fields, LOG_NAME) + return false + + var file = File.new() + if not file.file_exists(global_class_dict.path): + ModLoaderSetupLog.fatal('Class "%s" to be registered as global could not be found at given path "%s"' % + [global_class_dict.class, global_class_dict.path], LOG_NAME) + return false + + return true + + +# 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() -> PoolStringArray: + 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: PoolStringArray) -> PoolStringArray: + 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 := PoolStringArray([]) + 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