mirror of
https://github.com/JHDev2006/Super-Mario-Bros.-Remastered-Public.git
synced 2025-10-22 15:38:14 +00:00
added the game
This commit is contained in:
410
addons/mod_loader/api/config.gd
Normal file
410
addons/mod_loader/api/config.gd
Normal file
@@ -0,0 +1,410 @@
|
||||
class_name ModLoaderConfig
|
||||
extends Object
|
||||
##
|
||||
## Class for managing per-mod configurations.
|
||||
##
|
||||
## @tutorial(Creating a Mod Config Schema with JSON-Schemas): https://wiki.godotmodding.com/guides/modding/config_json/
|
||||
|
||||
const LOG_NAME := "ModLoader:Config"
|
||||
const DEFAULT_CONFIG_NAME := "default"
|
||||
|
||||
|
||||
## Creates a new configuration for a mod.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod.[br]
|
||||
## - [param config_name] ([String]): The name of the configuration.[br]
|
||||
## - [param config_data] ([Dictionary]): The configuration data to be stored.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [ModConfig]: The created [ModConfig] object if successful, or null otherwise.
|
||||
static func create_config(mod_id: String, config_name: String, config_data: Dictionary) -> ModConfig:
|
||||
var default_config: ModConfig = get_default_config(mod_id)
|
||||
if not default_config:
|
||||
ModLoaderLog.error(
|
||||
"Failed to create config \"%s\". No config schema found for \"%s\"."
|
||||
% [config_name, mod_id], LOG_NAME
|
||||
)
|
||||
return null
|
||||
|
||||
# Make sure the config name is not empty
|
||||
if config_name == "":
|
||||
ModLoaderLog.error(
|
||||
"Failed to create config \"%s\". The config name cannot be empty."
|
||||
% config_name, LOG_NAME
|
||||
)
|
||||
return null
|
||||
|
||||
# Make sure the config name is unique
|
||||
if ModLoaderStore.mod_data[mod_id].configs.has(config_name):
|
||||
ModLoaderLog.error(
|
||||
"Failed to create config \"%s\". A config with the name \"%s\" already exists."
|
||||
% [config_name, config_name], LOG_NAME
|
||||
)
|
||||
return null
|
||||
|
||||
# Create the config save path based on the config_name
|
||||
var config_file_path := _ModLoaderPath.get_path_to_mod_configs_dir(mod_id).path_join("%s.json" % config_name)
|
||||
# Initialize a new ModConfig object with the provided parameters
|
||||
var mod_config := ModConfig.new(
|
||||
mod_id,
|
||||
config_data,
|
||||
config_file_path
|
||||
)
|
||||
|
||||
# Check if the mod_config is valid
|
||||
if not mod_config.is_valid:
|
||||
return null
|
||||
|
||||
# Store the mod_config in the mod's ModData
|
||||
ModLoaderStore.mod_data[mod_id].configs[config_name] = mod_config
|
||||
# Save the mod_config to a new config JSON file in the mod's config directory
|
||||
var is_save_success := mod_config.save_to_file()
|
||||
|
||||
if not is_save_success:
|
||||
return null
|
||||
|
||||
ModLoaderLog.debug("Created new config \"%s\" for mod \"%s\"" % [config_name, mod_id], LOG_NAME)
|
||||
|
||||
return mod_config
|
||||
|
||||
|
||||
## Updates an existing [ModConfig] object with new data and saves the config file.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param config] ([ModConfig]): The [ModConfig] object to be updated.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [ModConfig]: The updated [ModConfig] object if successful, or null otherwise.
|
||||
static func update_config(config: ModConfig) -> ModConfig:
|
||||
# Validate the config and check for any validation errors
|
||||
var error_message := config.validate()
|
||||
|
||||
# Check if the config is the "default" config, which cannot be modified
|
||||
if config.name == DEFAULT_CONFIG_NAME:
|
||||
ModLoaderLog.error("The \"default\" config cannot be modified. Please create a new config instead.", LOG_NAME)
|
||||
return null
|
||||
|
||||
# Check if the config passed validation
|
||||
if not config.is_valid:
|
||||
ModLoaderLog.error("Update for config \"%s\" failed validation with error message \"%s\"" % [config.name, error_message], LOG_NAME)
|
||||
return null
|
||||
|
||||
# Save the updated config to the config file
|
||||
var is_save_success := config.save_to_file()
|
||||
|
||||
if not is_save_success:
|
||||
ModLoaderLog.error("Failed to save config \"%s\" to \"%s\"." % [config.name, config.save_path], LOG_NAME)
|
||||
return null
|
||||
|
||||
# Return the updated config
|
||||
return config
|
||||
|
||||
|
||||
## Deletes a [ModConfig] object and performs cleanup operations.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param config] ([ModConfig]): The [ModConfig] object to be deleted.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [bool]: True if the deletion was successful, False otherwise.
|
||||
static func delete_config(config: ModConfig) -> bool:
|
||||
# Check if the config is the "default" config, which cannot be deleted
|
||||
if config.name == DEFAULT_CONFIG_NAME:
|
||||
ModLoaderLog.error("Deletion of the default configuration is not allowed.", LOG_NAME)
|
||||
return false
|
||||
|
||||
# Change the current config to the "default" config
|
||||
set_current_config(get_default_config(config.mod_id))
|
||||
# Remove the config file from the Mod Config directory
|
||||
var is_remove_success := config.remove_file()
|
||||
|
||||
if not is_remove_success:
|
||||
return false
|
||||
|
||||
# Remove the config from ModData
|
||||
ModLoaderStore.mod_data[config.mod_id].configs.erase(config.name)
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Sets the current configuration of a mod to the specified configuration.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param config] ([ModConfig]): The [ModConfig] object to be set as current config.
|
||||
static func set_current_config(config: ModConfig) -> void:
|
||||
ModLoaderStore.mod_data[config.mod_id].current_config = config
|
||||
|
||||
|
||||
## Returns the schema for the specified mod id.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - A dictionary representing the schema for the mod's configuration file.
|
||||
static func get_config_schema(mod_id: String) -> Dictionary:
|
||||
# Get all config files for the specified mod
|
||||
var mod_configs := get_configs(mod_id)
|
||||
|
||||
# If no config files were found, return an empty dictionary
|
||||
if mod_configs.is_empty():
|
||||
return {}
|
||||
|
||||
# The schema is the same for all config files, so we just return the schema of the default config file
|
||||
return mod_configs.default.schema
|
||||
|
||||
|
||||
## Retrieves the schema for a specific property key.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param config] ([ModConfig]): The [ModConfig] object from which to retrieve the schema.[br]
|
||||
## - [param prop] ([String]): The property key for which to retrieve the schema.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Dictionary]: The schema dictionary for the specified property.
|
||||
static func get_schema_for_prop(config: ModConfig, prop: String) -> Dictionary:
|
||||
# Split the property string into an array of property keys
|
||||
var prop_array := prop.split(".")
|
||||
|
||||
# If the property array is empty, return the schema for the root property
|
||||
if prop_array.is_empty():
|
||||
return config.schema.properties[prop]
|
||||
|
||||
# Traverse the schema dictionary to find the schema for the specified property
|
||||
var schema_for_prop := _traverse_schema(config.schema.properties, prop_array)
|
||||
|
||||
# If the schema for the property is empty, log an error and return an empty dictionary
|
||||
if schema_for_prop.is_empty():
|
||||
ModLoaderLog.error("No Schema found for property \"%s\" in config \"%s\" for mod \"%s\"" % [prop, config.name, config.mod_id], LOG_NAME)
|
||||
return {}
|
||||
|
||||
return schema_for_prop
|
||||
|
||||
|
||||
# Recursively traverses the schema dictionary based on the provided [code]prop_key_array[/code]
|
||||
# and returns the corresponding schema for the target property.[br]
|
||||
# [br]
|
||||
# [b]Parameters:[/b][br]
|
||||
# - [param schema_prop]: The current schema dictionary to traverse.[br]
|
||||
# - [param prop_key_array]: An array containing the property keys representing the path to the target property.[br]
|
||||
# [br]
|
||||
# [b]Returns:[/b][br]
|
||||
# - [Dictionary]: The schema dictionary corresponding to the target property specified by the [code]prop_key_array[/code].
|
||||
# If the target property is not found, an empty dictionary is returned.
|
||||
static func _traverse_schema(schema_prop: Dictionary, prop_key_array: Array) -> Dictionary:
|
||||
# Return the current schema_prop if the prop_key_array is empty (reached the destination property)
|
||||
if prop_key_array.is_empty():
|
||||
return schema_prop
|
||||
|
||||
# Get and remove the first prop_key in the array
|
||||
var prop_key: String = prop_key_array.pop_front()
|
||||
|
||||
# Check if the searched property exists
|
||||
if not schema_prop.has(prop_key):
|
||||
return {}
|
||||
|
||||
schema_prop = schema_prop[prop_key]
|
||||
|
||||
# If the schema_prop has a 'type' key, is of type 'object', and there are more property keys remaining
|
||||
if schema_prop.has("type") and schema_prop.type == "object" and not prop_key_array.is_empty():
|
||||
# Set the properties of the object as the current 'schema_prop'
|
||||
schema_prop = schema_prop.properties
|
||||
|
||||
schema_prop = _traverse_schema(schema_prop, prop_key_array)
|
||||
|
||||
return schema_prop
|
||||
|
||||
|
||||
## Retrieves an Array of mods that have configuration files.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Array]: An Array containing the mod data of mods that have configuration files.
|
||||
static func get_mods_with_config() -> Array:
|
||||
# Create an empty array to store mods with configuration files
|
||||
var mods_with_config := []
|
||||
|
||||
# Iterate over each mod in ModLoaderStore.mod_data
|
||||
for mod_id in ModLoaderStore.mod_data:
|
||||
# Retrieve the mod data for the current mod ID
|
||||
# *The ModData type cannot be used because ModData is not fully loaded when this code is executed.*
|
||||
var mod_data = ModLoaderStore.mod_data[mod_id]
|
||||
|
||||
# Check if the mod has any configuration files
|
||||
if not mod_data.configs.is_empty():
|
||||
mods_with_config.push_back(mod_data)
|
||||
|
||||
# Return the array of mods with configuration files
|
||||
return mods_with_config
|
||||
|
||||
|
||||
## Retrieves the configurations dictionary for a given mod ID.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id]: The ID of the mod.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Dictionary]: A dictionary containing the configurations for the specified mod.
|
||||
## If the mod ID is invalid or no configurations are found, an empty dictionary is returned.
|
||||
static func get_configs(mod_id: String) -> Dictionary:
|
||||
# Check if the mod ID is invalid
|
||||
if not ModLoaderStore.mod_data.has(mod_id):
|
||||
ModLoaderLog.fatal("Mod ID \"%s\" not found" % [mod_id], LOG_NAME)
|
||||
return {}
|
||||
|
||||
var config_dictionary: Dictionary = ModLoaderStore.mod_data[mod_id].configs
|
||||
|
||||
# Check if there is no config file for the mod
|
||||
if config_dictionary.is_empty():
|
||||
ModLoaderLog.debug("No config for mod id \"%s\"" % mod_id, LOG_NAME, true)
|
||||
return {}
|
||||
|
||||
return config_dictionary
|
||||
|
||||
|
||||
## Retrieves the configuration for a specific mod and configuration name.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod.[br]
|
||||
## - [param config_name] ([String]): The name of the configuration.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [ModConfig]: The configuration as a [ModConfig] object or null if not found.
|
||||
static func get_config(mod_id: String, config_name: String) -> ModConfig:
|
||||
var configs := get_configs(mod_id)
|
||||
|
||||
if not configs.has(config_name):
|
||||
ModLoaderLog.error("No config with name \"%s\" found for mod_id \"%s\" " % [config_name, mod_id], LOG_NAME)
|
||||
return null
|
||||
|
||||
return configs[config_name]
|
||||
|
||||
|
||||
## Checks whether a mod has a current configuration set.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [bool]: True if the mod has a current configuration, false otherwise.
|
||||
static func has_current_config(mod_id: String) -> bool:
|
||||
var mod_data := ModLoaderMod.get_mod_data(mod_id)
|
||||
return not mod_data.current_config == null
|
||||
|
||||
|
||||
## Checks whether a mod has a configuration with the specified name.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod.[br]
|
||||
## - [param config_name] ([String]): The name of the configuration.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [bool]: True if the mod has a configuration with the specified name, False otherwise.
|
||||
static func has_config(mod_id: String, config_name: String) -> bool:
|
||||
var mod_data := ModLoaderMod.get_mod_data(mod_id)
|
||||
return mod_data.configs.has(config_name)
|
||||
|
||||
|
||||
## Retrieves the default configuration for a specified mod ID.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [ModConfig]: The [ModConfig] object representing the default configuration for the specified mod.
|
||||
## If the mod ID is invalid or no configuration is found, returns null.
|
||||
static func get_default_config(mod_id: String) -> ModConfig:
|
||||
return get_config(mod_id, DEFAULT_CONFIG_NAME)
|
||||
|
||||
|
||||
## Retrieves the currently active configuration for a specific mod.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [ModConfig]: The configuration as a [ModConfig] object or [code]null[/code] if not found.
|
||||
static func get_current_config(mod_id: String) -> ModConfig:
|
||||
var current_config_name := get_current_config_name(mod_id)
|
||||
var current_config: ModConfig
|
||||
|
||||
# Load the default configuration if there is no configuration set as current yet
|
||||
# Otherwise load the corresponding configuration
|
||||
if current_config_name.is_empty():
|
||||
current_config = get_default_config(mod_id)
|
||||
else:
|
||||
current_config = get_config(mod_id, current_config_name)
|
||||
|
||||
return current_config
|
||||
|
||||
|
||||
## Retrieves the name of the current configuration for a specific mod.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [String] The currently active configuration name for the given mod id or an empty string if not found.
|
||||
static func get_current_config_name(mod_id: String) -> String:
|
||||
# Check if user profile has been loaded
|
||||
if not ModLoaderStore.current_user_profile or not ModLoaderStore.user_profiles.has(ModLoaderStore.current_user_profile.name):
|
||||
# Warn and return an empty string if the user profile has not been loaded
|
||||
ModLoaderLog.warning("Can't get current mod config name for \"%s\", because no current user profile is present." % mod_id, LOG_NAME)
|
||||
return ""
|
||||
|
||||
# Retrieve the current user profile from ModLoaderStore
|
||||
# *Can't use ModLoaderUserProfile because it causes a cyclic dependency*
|
||||
var current_user_profile = ModLoaderStore.current_user_profile
|
||||
|
||||
# Check if the mod exists in the user profile's mod list and if it has a current config
|
||||
if not current_user_profile.mod_list.has(mod_id) or not current_user_profile.mod_list[mod_id].has("current_config"):
|
||||
# Log an error and return an empty string if the mod has no config file
|
||||
ModLoaderLog.error("Can't get current mod config name for \"%s\" because no config file exists." % mod_id, LOG_NAME)
|
||||
return ""
|
||||
|
||||
# Return the name of the current configuration for the mod
|
||||
return current_user_profile.mod_list[mod_id].current_config
|
||||
|
||||
|
||||
## Refreshes the data of the provided configuration by reloading it from the config file.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param config] ([ModConfig]): The [ModConfig] object whose data needs to be refreshed.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [ModConfig]: The [ModConfig] object with refreshed data if successful, or the original object otherwise.
|
||||
static func refresh_config_data(config: ModConfig) -> ModConfig:
|
||||
# Retrieve updated configuration data from the config file
|
||||
var new_config_data := _ModLoaderFile.get_json_as_dict(config.save_path)
|
||||
# Update the data property of the ModConfig object with the refreshed data
|
||||
config.data = new_config_data
|
||||
|
||||
return config
|
||||
|
||||
|
||||
## Iterates over all mods to refresh the data of their current configurations, if available.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
## [br]
|
||||
## Compares the previous configuration data with the refreshed data and emits the [signal ModLoader.current_config_changed]
|
||||
## signal if changes are detected.[br]
|
||||
## This function ensures that any changes made to the configuration files outside the application
|
||||
## are reflected within the application's runtime, allowing for dynamic updates without the need for a restart.
|
||||
static func refresh_current_configs() -> void:
|
||||
for mod_id in ModLoaderMod.get_mod_data_all().keys():
|
||||
# Skip if the mod has no config
|
||||
if not has_current_config(mod_id):
|
||||
return
|
||||
|
||||
# Retrieve the current configuration for the mod
|
||||
var config := get_current_config(mod_id)
|
||||
# Create a deep copy of the current configuration data for comparison
|
||||
var config_data_previous := config.data.duplicate(true)
|
||||
# Refresh the configuration data
|
||||
var config_new := refresh_config_data(config)
|
||||
|
||||
# Compare previous data with refreshed data
|
||||
if not config_data_previous == config_new.data:
|
||||
# Emit signal indicating that the current configuration has changed
|
||||
ModLoader.current_config_changed.emit(config)
|
1
addons/mod_loader/api/config.gd.uid
Normal file
1
addons/mod_loader/api/config.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://byhbvq7il70cy
|
76
addons/mod_loader/api/deprecated.gd
Normal file
76
addons/mod_loader/api/deprecated.gd
Normal file
@@ -0,0 +1,76 @@
|
||||
class_name ModLoaderDeprecated
|
||||
extends Object
|
||||
##
|
||||
## API methods for deprecating funcs. Can be used by mods with public APIs.
|
||||
|
||||
|
||||
const LOG_NAME := "ModLoader:Deprecated"
|
||||
|
||||
|
||||
## Marks a method that has changed its name or class.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param old_method] ([String]): The name of the deprecated method.[br]
|
||||
## - [param new_method] ([String]): The name of the new method to use.[br]
|
||||
## - [param since_version] ([String]): The version number from which the method has been deprecated.[br]
|
||||
## - [param show_removal_note] ([bool]): (optional) If true, includes a note about future removal of the old method. Default is true.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
static func deprecated_changed(old_method: String, new_method: String, since_version: String, show_removal_note: bool = true) -> void:
|
||||
_deprecated_log(str(
|
||||
"DEPRECATED: ",
|
||||
"The method \"%s\" has been deprecated since version %s. " % [old_method, since_version],
|
||||
"Please use \"%s\" instead. " % new_method,
|
||||
"The old method will be removed with the next major update, and will break your code if not changed. " if show_removal_note else ""
|
||||
))
|
||||
|
||||
|
||||
## Marks a method that has been entirely removed, with no replacement.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param old_method] ([String]): The name of the removed method.[br]
|
||||
## - [param since_version] ([String]): The version number from which the method has been deprecated.[br]
|
||||
## - [param show_removal_note] ([bool]): (optional) If true, includes a note about future removal of the old method. Default is true.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
## [br]
|
||||
## ===[br]
|
||||
## [b]Note:[/b][br]
|
||||
## This should rarely be needed but is included for completeness.[br]
|
||||
## ===[br]
|
||||
static func deprecated_removed(old_method: String, since_version: String, show_removal_note: bool = true) -> void:
|
||||
_deprecated_log(str(
|
||||
"DEPRECATED: ",
|
||||
"The method \"%s\" has been deprecated since version %s, and is no longer available. " % [old_method, since_version],
|
||||
"There is currently no replacement method. ",
|
||||
"The method will be removed with the next major update, and will break your code if not changed. " if show_removal_note else ""
|
||||
))
|
||||
|
||||
|
||||
## Marks a method with a freeform deprecation message.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param msg] ([String]): The deprecation message.[br]
|
||||
## - [param since_version] ([String]): (optional) The version number from which the deprecation applies.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
static func deprecated_message(msg: String, since_version: String = "") -> void:
|
||||
var since_text := " (since version %s)" % since_version if since_version else ""
|
||||
_deprecated_log(str("DEPRECATED: ", msg, since_text))
|
||||
|
||||
|
||||
# Internal function for logging deprecation messages with support to trigger warnings instead of fatal errors.[br]
|
||||
# [br]
|
||||
# [b]Parameters:[/b][br]
|
||||
# - [param msg] ([String]): The deprecation message.[br]
|
||||
# [br]
|
||||
# [b]Returns:[/b][br]
|
||||
# - No return value[br]
|
||||
static func _deprecated_log(msg: String) -> void:
|
||||
if ModLoaderStore and ModLoaderStore.ml_options.ignore_deprecated_errors or OS.has_feature("standalone"):
|
||||
ModLoaderLog.warning(msg, LOG_NAME, true)
|
||||
else:
|
||||
ModLoaderLog.fatal(msg, LOG_NAME, true)
|
1
addons/mod_loader/api/deprecated.gd.uid
Normal file
1
addons/mod_loader/api/deprecated.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://2sifoxblubxv
|
83
addons/mod_loader/api/hook_chain.gd
Normal file
83
addons/mod_loader/api/hook_chain.gd
Normal file
@@ -0,0 +1,83 @@
|
||||
class_name ModLoaderHookChain
|
||||
extends RefCounted
|
||||
## Small class to keep the state of hook execution chains and move between mod hook calls.[br]
|
||||
## For examples, see [method ModLoaderMod.add_hook].
|
||||
|
||||
|
||||
## The reference object is usually the [Node] that the vanilla script is attached to. [br]
|
||||
## If the hooked method is [code]static[/code], it will contain the [GDScript] itself.
|
||||
var reference_object: Object
|
||||
|
||||
var _callbacks: Array[Callable] = []
|
||||
var _callback_index := -1
|
||||
|
||||
|
||||
const LOG_NAME := "ModLoaderHookChain"
|
||||
|
||||
|
||||
# `callbacks` is kept as untyped Array for simplicity when creating a new chain.
|
||||
# This approach allows direct use of `[vanilla_method] + hooks` without the need to cast types with Array.assign().
|
||||
func _init(reference_object: Object, callbacks: Array) -> void:
|
||||
self.reference_object = reference_object
|
||||
_callbacks.assign(callbacks)
|
||||
_callback_index = callbacks.size()
|
||||
|
||||
|
||||
## Will execute the next mod hook callable or vanilla method and return the result.[br]
|
||||
## [br]
|
||||
## [br][b]Parameters:[/b][br]
|
||||
## - [param args] ([Array]): An array of all arguments passed into the vanilla function. [br]
|
||||
## [br]
|
||||
## [br][b]Returns:[/b][br]
|
||||
## - [Variant]: Return value of the next function in the chain.[br]
|
||||
## [br]
|
||||
## Make sure to call this method [i][color=orange]once[/color][/i] somewhere in the [param mod_callable] you pass to [method ModLoaderMod.add_hook]. [br]
|
||||
func execute_next(args := []) -> Variant:
|
||||
var callback := _get_next_callback()
|
||||
if not callback:
|
||||
return
|
||||
|
||||
# Vanilla needs to be called without the hook chain being passed
|
||||
if _is_callback_vanilla():
|
||||
return callback.callv(args)
|
||||
|
||||
return callback.callv([self] + args)
|
||||
|
||||
|
||||
## Same as [method execute_next], but asynchronous - it can be used if a method uses [code]await[/code]. [br]
|
||||
## [br]
|
||||
## [br][b]Parameters:[/b][br]
|
||||
## - [param args] ([Array]): An array of all arguments passed into the vanilla function. [br]
|
||||
## [br]
|
||||
## [br][b]Returns:[/b][br]
|
||||
## - [Variant]: Return value of the next function in the chain.[br]
|
||||
## [br]
|
||||
## This hook needs to be used if the vanilla method uses [code]await[/code] somewhere. [br]
|
||||
## Make sure to call this method [i][color=orange]once[/color][/i] somewhere in the [param mod_callable] you pass to [method ModLoaderMod.add_hook]. [br]
|
||||
func execute_next_async(args := []) -> Variant:
|
||||
var callback := _get_next_callback()
|
||||
if not callback:
|
||||
return
|
||||
|
||||
# Vanilla needs to be called without the hook chain being passed
|
||||
if _is_callback_vanilla():
|
||||
return await callback.callv(args)
|
||||
|
||||
return await callback.callv([self] + args)
|
||||
|
||||
|
||||
func _get_next_callback() -> Variant:
|
||||
_callback_index -= 1
|
||||
if not _callback_index >= 0:
|
||||
ModLoaderLog.fatal(
|
||||
"The hook chain index should never be negative. " +
|
||||
"A mod hook has called execute_next twice or ModLoaderHookChain was modified in an unsupported way.",
|
||||
LOG_NAME
|
||||
)
|
||||
return
|
||||
|
||||
return _callbacks[_callback_index]
|
||||
|
||||
|
||||
func _is_callback_vanilla() -> bool:
|
||||
return _callback_index == 0
|
1
addons/mod_loader/api/hook_chain.gd.uid
Normal file
1
addons/mod_loader/api/hook_chain.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://nyep44jvp7yc
|
599
addons/mod_loader/api/log.gd
Normal file
599
addons/mod_loader/api/log.gd
Normal file
@@ -0,0 +1,599 @@
|
||||
@tool
|
||||
class_name ModLoaderLog
|
||||
extends Object
|
||||
##
|
||||
## This Class provides methods for logging, retrieving logged data, and internal methods for working with log files.
|
||||
|
||||
|
||||
# Path to the latest log file.
|
||||
const MOD_LOG_PATH := "user://logs/modloader.log"
|
||||
|
||||
const _LOG_NAME := "ModLoader:Log"
|
||||
|
||||
## Denotes the severity of a log entry
|
||||
enum VERBOSITY_LEVEL {
|
||||
ERROR, ## For errors and fatal errors
|
||||
WARNING, ## For warnings
|
||||
INFO, ## For everything informational and successes
|
||||
DEBUG, ## For debugging, can get quite verbose
|
||||
}
|
||||
|
||||
## Keeps track of logged messages, to avoid flooding the log with duplicate notices
|
||||
## Can also be used by mods, eg. to create an in-game developer console that
|
||||
## shows messages
|
||||
static var logged_messages := {
|
||||
"all": {},
|
||||
"by_mod": {},
|
||||
"by_type": {
|
||||
"fatal-error": {},
|
||||
"error": {},
|
||||
"warning": {},
|
||||
"info": {},
|
||||
"success": {},
|
||||
"debug": {},
|
||||
"hint": {},
|
||||
}
|
||||
}
|
||||
|
||||
## Verbosity/Logging level.
|
||||
## Used to filter out messages below the set level
|
||||
## (if the [enum VERBOSITY_LEVEL] int of a new entry is larger than the [member verbosity] it is ignored)
|
||||
static var verbosity: VERBOSITY_LEVEL = VERBOSITY_LEVEL.DEBUG
|
||||
|
||||
## Array of mods that should be ignored when logging messages (contains mod IDs as strings)
|
||||
static var ignored_mods: Array[String] = []
|
||||
|
||||
## Highlighting color for hint type log messages
|
||||
static var hint_color := Color("#70bafa")
|
||||
|
||||
## This Sub-Class represents a log entry in ModLoader.
|
||||
class ModLoaderLogEntry:
|
||||
extends Resource
|
||||
|
||||
## Name of the mod or ModLoader class this entry refers to.
|
||||
var mod_name: String
|
||||
|
||||
## The message of the log entry.
|
||||
var message: String
|
||||
|
||||
## The log type, which indicates the verbosity level of this entry.
|
||||
var type: String
|
||||
|
||||
## The readable format of the time when this log entry was created.
|
||||
## Used for printing in the log file and output.
|
||||
var time: String
|
||||
|
||||
## The timestamp when this log entry was created.
|
||||
## Used for comparing and sorting log entries by time.
|
||||
var time_stamp: int
|
||||
|
||||
## An array of ModLoaderLogEntry objects.
|
||||
## If the message has been logged before, it is added to the stack.
|
||||
var stack := []
|
||||
|
||||
|
||||
## Initialize a ModLoaderLogEntry object with provided values.[br]
|
||||
##[br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param _mod_name] ([String]): Name of the mod or ModLoader class this entry refers to.[br]
|
||||
## [param _message] ([String]): The message of the log entry.[br]
|
||||
## [param _type] ([String]): The log type, which indicates the verbosity level of this entry.[br]
|
||||
## [param _time] ([String]): The readable format of the time when this log entry was created.[br]
|
||||
##[br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
func _init(_mod_name: String, _message: String, _type: String, _time: String) -> void:
|
||||
mod_name = _mod_name
|
||||
message = _message
|
||||
type = _type
|
||||
time = _time
|
||||
time_stamp = Time.get_ticks_msec()
|
||||
|
||||
|
||||
## Get the log entry as a formatted string.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b] [String]
|
||||
func get_entry() -> String:
|
||||
return str(time, get_prefix(), message)
|
||||
|
||||
|
||||
## Get the prefix string for the log entry, including the log type and mod name.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b] [String]
|
||||
func get_prefix() -> String:
|
||||
return "%s %s: " % [type.to_upper(), mod_name]
|
||||
|
||||
|
||||
## Generate an MD5 hash of the log entry (prefix + message).[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b] [String]
|
||||
func get_md5() -> String:
|
||||
return str(get_prefix(), message).md5_text()
|
||||
|
||||
|
||||
## Get all log entries, including the current entry and entries in the stack.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b] [Array]
|
||||
func get_all_entries() -> Array:
|
||||
var entries := [self]
|
||||
entries.append_array(stack)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
# API log functions - logging
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Logs the error in red and a stack trace. Prefixed FATAL-ERROR.[br]
|
||||
## Always logged.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param message] ([String]): The message to be logged as an error.[br]
|
||||
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
|
||||
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
## [br]
|
||||
## ===[br]
|
||||
## [b]Note:[color=bug "Breakpoint"][/color][/b][br]
|
||||
## Stops execution in the editor, use this when something really needs to be fixed.[br]
|
||||
## ===[br]
|
||||
static func fatal(message: String, mod_name: String, only_once := false) -> void:
|
||||
_log(message, mod_name, "fatal-error", only_once)
|
||||
|
||||
|
||||
## Logs the message and pushes an error. Prefixed ERROR.[br]
|
||||
## Always logged.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param message] ([String]): The message to be logged as an error.[br]
|
||||
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
|
||||
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
static func error(message: String, mod_name: String, only_once := false) -> void:
|
||||
_log(message, mod_name, "error", only_once)
|
||||
|
||||
|
||||
## Logs the message and pushes a warning. Prefixed WARNING.[br]
|
||||
## Logged with verbosity level at or above warning ([code]-v[/code] or [code]--log-warning[/code]).[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param message] ([String]): The message to be logged as a warning.[br]
|
||||
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
|
||||
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
static func warning(message: String, mod_name: String, only_once := false) -> void:
|
||||
_log(message, mod_name, "warning", only_once)
|
||||
|
||||
|
||||
## Logs the message. Prefixed INFO.[br]
|
||||
## Logged with verbosity level at or above info ([code]-vv[/code] or [code]--log-info[/code]).[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param message] ([String]): The message to be logged as an information.[br]
|
||||
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
|
||||
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
static func info(message: String, mod_name: String, only_once := false) -> void:
|
||||
_log(message, mod_name, "info", only_once)
|
||||
|
||||
|
||||
## Logs the message. Prefixed SUCCESS.[br]
|
||||
## Logged with verbosity level at or above info ([code]-vv[/code] or [code]--log-info[/code]).[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param message] ([String]): The message to be logged as a success.[br]
|
||||
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
|
||||
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
static func success(message: String, mod_name: String, only_once := false) -> void:
|
||||
_log(message, mod_name, "success", only_once)
|
||||
|
||||
|
||||
## Logs the message. Prefixed DEBUG.[br]
|
||||
## Logged with verbosity level at or above debug ([code]-vvv[/code] or [code]--log-debug[/code]).[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param message] ([String]): The message to be logged as a debug.[br]
|
||||
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
|
||||
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
static func debug(message: String, mod_name: String, only_once := false) -> void:
|
||||
_log(message, mod_name, "debug", only_once)
|
||||
|
||||
|
||||
## Logs the message. Prefixed HINT and highligted.[br]
|
||||
## Logged with verbosity level at or above debug ([code]-vvv[/code] or [code]--log-debug[/code]) and in the editor only. Not written to mod loader log.[br]
|
||||
## ===[br]
|
||||
## [b]Note:[/b][br]
|
||||
## Use this to help other developers debug issues by giving them error-specific hints.[br]
|
||||
## ===[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param message] ([String]): The message to be logged as a debug.[br]
|
||||
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
|
||||
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
static func hint(message: String, mod_name: String, only_once := false) -> void:
|
||||
_log(message, mod_name, "hint", only_once)
|
||||
|
||||
|
||||
## Logs the message formatted with [method JSON.print]. Prefixed DEBUG.[br]
|
||||
## Logged with verbosity level at or above debug ([code]-vvv[/code] or [code]--log-debug[/code]).[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param message] ([String]): The message to be logged as a debug.[br]
|
||||
## [param json_printable] (Variant): The variable to be formatted and printed using [method JSON.print].[br]
|
||||
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
|
||||
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
static func debug_json_print(message: String, json_printable, mod_name: String, only_once := false) -> void:
|
||||
message = "%s\n%s" % [message, JSON.stringify(json_printable, " ")]
|
||||
_log(message, mod_name, "debug", only_once)
|
||||
|
||||
|
||||
# API log functions - stored logs
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Returns an array of log entries as a resource.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Array]: An array of log entries represented as resource.
|
||||
static func get_all_as_resource() -> Array:
|
||||
return get_all()
|
||||
|
||||
|
||||
## Returns an array of log entries as a string.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Array]: An array of log entries represented as strings.
|
||||
static func get_all_as_string() -> Array:
|
||||
var log_entries := get_all()
|
||||
return get_all_entries_as_string(log_entries)
|
||||
|
||||
|
||||
## Returns an array of log entries as a resource for a specific mod_name.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with the log entries.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Array]: An array of log entries represented as resource for the specified [code]mod_name[/code].
|
||||
static func get_by_mod_as_resource(mod_name: String) -> Array:
|
||||
return get_by_mod(mod_name)
|
||||
|
||||
|
||||
## Returns an array of log entries as a string for a specific mod_name.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with the log entries.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Array]: An array of log entries represented as strings for the specified [code]mod_name[/code].
|
||||
static func get_by_mod_as_string(mod_name: String) -> Array:
|
||||
var log_entries := get_by_mod(mod_name)
|
||||
return get_all_entries_as_string(log_entries)
|
||||
|
||||
|
||||
## Returns an array of log entries as a resource for a specific type.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param type] ([String]): The log type associated with the log entries.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Array]: An array of log entries represented as resource for the specified [code]type[/code].
|
||||
static func get_by_type_as_resource(type: String) -> Array:
|
||||
return get_by_type(type)
|
||||
|
||||
|
||||
## Returns an array of log entries as a string for a specific type.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param type] ([String]): The log type associated with the log entries.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Array]: An array of log entries represented as strings for the specified [code]type[/code].
|
||||
static func get_by_type_as_string(type: String) -> Array:
|
||||
var log_entries := get_by_type(type)
|
||||
return get_all_entries_as_string(log_entries)
|
||||
|
||||
|
||||
## Returns an array of all log entries.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Array]: An array of all log entries.
|
||||
static func get_all() -> Array:
|
||||
var log_entries := []
|
||||
|
||||
# Get all log entries
|
||||
for entry_key in logged_messages.all.keys():
|
||||
var entry: ModLoaderLogEntry = logged_messages.all[entry_key]
|
||||
log_entries.append_array(entry.get_all_entries())
|
||||
|
||||
# Sort them by time
|
||||
log_entries.sort_custom(Callable(ModLoaderLogCompare, "time"))
|
||||
|
||||
return log_entries
|
||||
|
||||
|
||||
## Returns an array of log entries for a specific mod_name.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with the log entries.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Array]: An array of log entries for the specified [code]mod_name[/code].
|
||||
static func get_by_mod(mod_name: String) -> Array:
|
||||
var log_entries := []
|
||||
|
||||
if not logged_messages.by_mod.has(mod_name):
|
||||
error("\"%s\" not found in logged messages." % mod_name, _LOG_NAME)
|
||||
return []
|
||||
|
||||
for entry_key in logged_messages.by_mod[mod_name].keys():
|
||||
var entry: ModLoaderLogEntry = logged_messages.by_mod[mod_name][entry_key]
|
||||
log_entries.append_array(entry.get_all_entries())
|
||||
|
||||
return log_entries
|
||||
|
||||
|
||||
## Returns an array of log entries for a specific type.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param type] ([String]): The log type associated with the log entries.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Array]: An array of log entries for the specified [code]type[/code].
|
||||
static func get_by_type(type: String) -> Array:
|
||||
var log_entries := []
|
||||
|
||||
for entry_key in logged_messages.by_type[type].keys():
|
||||
var entry: ModLoaderLogEntry = logged_messages.by_type[type][entry_key]
|
||||
log_entries.append_array(entry.get_all_entries())
|
||||
|
||||
return log_entries
|
||||
|
||||
|
||||
## Returns an array of log entries represented as strings.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## [param log_entries] ([Array]): An array of ModLoaderLogEntry Objects.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Array]: An array of log entries represented as strings.
|
||||
static func get_all_entries_as_string(log_entries: Array) -> Array:
|
||||
var log_entry_strings := []
|
||||
|
||||
# Get all the strings
|
||||
for entry in log_entries:
|
||||
log_entry_strings.push_back(entry.get_entry())
|
||||
|
||||
return log_entry_strings
|
||||
|
||||
|
||||
# Internal log functions
|
||||
# =============================================================================
|
||||
|
||||
static func _log(message: String, mod_name: String, log_type: String = "info", only_once := false) -> void:
|
||||
if _is_mod_name_ignored(mod_name):
|
||||
return
|
||||
|
||||
var time := "%s " % _get_time_string()
|
||||
var log_entry := ModLoaderLogEntry.new(mod_name, message, log_type, time)
|
||||
|
||||
if only_once and _is_logged_before(log_entry):
|
||||
return
|
||||
|
||||
_store_log(log_entry)
|
||||
|
||||
# Check if the scene_tree is available
|
||||
if Engine.get_main_loop() and ModLoader:
|
||||
ModLoader.emit_signal("logged", log_entry)
|
||||
|
||||
_code_note(str(
|
||||
"If you are seeing this after trying to run the game, there is an error in your mod somewhere.",
|
||||
"Check the Debugger tab (below) to see the error.",
|
||||
"Click through the files listed in Stack Frames to trace where the error originated.",
|
||||
"View Godot's documentation for more info:",
|
||||
"https://docs.godotengine.org/en/stable/tutorials/scripting/debug/debugger_panel.html#doc-debugger-panel"
|
||||
))
|
||||
|
||||
match log_type.to_lower():
|
||||
"fatal-error":
|
||||
push_error(message)
|
||||
_write_to_log_file(log_entry.get_entry())
|
||||
_write_to_log_file(JSON.stringify(get_stack(), " "))
|
||||
assert(false, message)
|
||||
"error":
|
||||
printerr(log_entry.get_prefix() + message)
|
||||
push_error(message)
|
||||
_write_to_log_file(log_entry.get_entry())
|
||||
"warning":
|
||||
if verbosity >= VERBOSITY_LEVEL.WARNING:
|
||||
print(log_entry.get_prefix() + message)
|
||||
push_warning(message)
|
||||
_write_to_log_file(log_entry.get_entry())
|
||||
"info", "success":
|
||||
if verbosity >= VERBOSITY_LEVEL.INFO:
|
||||
print(log_entry.get_prefix() + message)
|
||||
_write_to_log_file(log_entry.get_entry())
|
||||
"debug":
|
||||
if verbosity >= VERBOSITY_LEVEL.DEBUG:
|
||||
print(log_entry.get_prefix() + message)
|
||||
_write_to_log_file(log_entry.get_entry())
|
||||
"hint":
|
||||
if OS.has_feature("editor") and verbosity >= VERBOSITY_LEVEL.DEBUG:
|
||||
print_rich("[color=%s]%s[/color]" % [hint_color.to_html(false), log_entry.get_prefix() + message])
|
||||
|
||||
|
||||
static func _is_mod_name_ignored(mod_name: String) -> bool:
|
||||
if ignored_mods.is_empty():
|
||||
return false
|
||||
|
||||
if mod_name in ignored_mods:
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
static func _store_log(log_entry: ModLoaderLogEntry) -> void:
|
||||
var existing_entry: ModLoaderLogEntry
|
||||
|
||||
# Store in all
|
||||
# If it's a new entry
|
||||
if not logged_messages.all.has(log_entry.get_md5()):
|
||||
logged_messages.all[log_entry.get_md5()] = log_entry
|
||||
# If it's a existing entry
|
||||
else:
|
||||
existing_entry = logged_messages.all[log_entry.get_md5()]
|
||||
existing_entry.time = log_entry.time
|
||||
existing_entry.stack.push_back(log_entry)
|
||||
|
||||
# Store in by_mod
|
||||
# If the mod is not yet in "by_mod" init the entry
|
||||
if not logged_messages.by_mod.has(log_entry.mod_name):
|
||||
logged_messages.by_mod[log_entry.mod_name] = {}
|
||||
|
||||
logged_messages.by_mod[log_entry.mod_name][log_entry.get_md5()] = log_entry if not existing_entry else existing_entry
|
||||
|
||||
# Store in by_type
|
||||
logged_messages.by_type[log_entry.type.to_lower()][log_entry.get_md5()] = log_entry if not existing_entry else existing_entry
|
||||
|
||||
|
||||
static func _is_logged_before(entry: ModLoaderLogEntry) -> bool:
|
||||
if not logged_messages.all.has(entry.get_md5()):
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
class ModLoaderLogCompare:
|
||||
# Custom sorter that orders logs by time
|
||||
static func time(a: ModLoaderLogEntry, b: ModLoaderLogEntry) -> bool:
|
||||
if a.time_stamp > b.time_stamp:
|
||||
return true # a -> b
|
||||
else:
|
||||
return false # b -> a
|
||||
|
||||
|
||||
# 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:
|
||||
if not FileAccess.file_exists(MOD_LOG_PATH):
|
||||
_rotate_log_file()
|
||||
|
||||
var log_file := FileAccess.open(MOD_LOG_PATH, FileAccess.READ_WRITE)
|
||||
|
||||
if log_file == null:
|
||||
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("debug/file_logging/max_log_files")
|
||||
|
||||
if FileAccess.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 := DirAccess.open(MOD_LOG_PATH.get_base_dir())
|
||||
if not dir == null:
|
||||
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 log_file := FileAccess.open(MOD_LOG_PATH, FileAccess.WRITE)
|
||||
if log_file == null:
|
||||
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("debug/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 := DirAccess.open(MOD_LOG_PATH.get_base_dir())
|
||||
if dir == null:
|
||||
return
|
||||
|
||||
dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
|
||||
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)
|
||||
|
||||
|
||||
# Internal util funcs
|
||||
# =============================================================================
|
||||
# This are duplicates of the functions in mod_loader_utils.gd to prevent
|
||||
# a cyclic reference error between ModLoaderLog and 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
|
1
addons/mod_loader/api/log.gd.uid
Normal file
1
addons/mod_loader/api/log.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dfoleo2pforxu
|
358
addons/mod_loader/api/mod.gd
Normal file
358
addons/mod_loader/api/mod.gd
Normal file
@@ -0,0 +1,358 @@
|
||||
class_name ModLoaderMod
|
||||
extends Object
|
||||
##
|
||||
## This Class provides helper functions to build mods.
|
||||
##
|
||||
## @tutorial(Script Extensions): https://wiki.godotmodding.com/#/guides/modding/script_extensions
|
||||
## @tutorial(Script Hooks): https://wiki.godotmodding.com/#/guides/modding/script_hooks
|
||||
## @tutorial(Mod Structure): https://wiki.godotmodding.com/#/guides/modding/mod_structure
|
||||
## @tutorial(Mod Files): https://wiki.godotmodding.com/#/guides/modding/mod_files
|
||||
|
||||
|
||||
const LOG_NAME := "ModLoader:Mod"
|
||||
|
||||
|
||||
## Installs a script extension that extends a vanilla script.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param child_script_path] ([String]): The path to the mod's extender script.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
## [br]
|
||||
## This is the preferred way of modifying a vanilla [Script][br]
|
||||
## Since Godot 4, extensions can cause issues with scripts that use [code]class_name[/code]
|
||||
## and should be avoided if present.[br]
|
||||
## See [method add_hook] for those cases.[br]
|
||||
## [br]
|
||||
## The [param child_script_path] should point to your mod's extender script.[br]
|
||||
## Example: [code]"MOD/extensions/singletons/utils.gd"[/code][br]
|
||||
## Inside the extender script, include [code]extends {target}[/code] where [code]{target}[/code] is the vanilla path.[br]
|
||||
## Example: [code]extends "res://singletons/utils.gd"[/code].[br]
|
||||
## ===[br]
|
||||
## [b]Note:[/b][br]
|
||||
## Your extender script doesn't have to follow the same directory path as the vanilla file,
|
||||
## but it's good practice to do so.[br]
|
||||
## ===[br]
|
||||
## [br]
|
||||
static func install_script_extension(child_script_path: String) -> void:
|
||||
var mod_id: String = _ModLoaderPath.get_mod_dir(child_script_path)
|
||||
var mod_data: ModData = get_mod_data(mod_id)
|
||||
if not ModLoaderStore.saved_extension_paths.has(mod_data.manifest.get_mod_id()):
|
||||
ModLoaderStore.saved_extension_paths[mod_data.manifest.get_mod_id()] = []
|
||||
ModLoaderStore.saved_extension_paths[mod_data.manifest.get_mod_id()].append(child_script_path)
|
||||
|
||||
# If this is called during initialization, add it with the other
|
||||
# extensions to be installed taking inheritance chain into account
|
||||
if ModLoaderStore.is_initializing:
|
||||
ModLoaderStore.script_extensions.push_back(child_script_path)
|
||||
|
||||
# If not, apply the extension directly
|
||||
else:
|
||||
_ModLoaderScriptExtension.apply_extension(child_script_path)
|
||||
|
||||
|
||||
## Adds all methods from a file as hooks. [br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param vanilla_script_path] ([String]): The path to the script which will be hooked.[br]
|
||||
## - [param hook_script_path] ([String]): The path to the script containing hooks.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
## [br]
|
||||
## The file needs to extend [Object].[br]
|
||||
## The methods in the file need to have the exact same name as the vanilla method
|
||||
## they intend to hook, all mismatches will be ignored. [br]
|
||||
## See: [method add_hook]
|
||||
## [br]
|
||||
## [b]Examples:[/b][br]
|
||||
## [codeblock]
|
||||
## ModLoaderMod.install_script_hooks(
|
||||
## "res://tools/utilities.gd",
|
||||
## extensions_dir_path.path_join("tools/utilities-hook.gd")
|
||||
## )
|
||||
## [/codeblock]
|
||||
static func install_script_hooks(vanilla_script_path: String, hook_script_path: String) -> void:
|
||||
var hook_script := load(hook_script_path) as GDScript
|
||||
var hook_script_instance := hook_script.new()
|
||||
|
||||
# Every script that inherits RefCounted will be cleaned up by the engine as
|
||||
# soon as there are no more references to it. If the reference is gone
|
||||
# the method can't be called and everything returns null.
|
||||
# Only Object won't be removed, so we can use it here.
|
||||
if hook_script_instance is RefCounted:
|
||||
ModLoaderLog.fatal(
|
||||
"Scripts holding mod hooks should always extend Object (%s)"
|
||||
% hook_script_path, LOG_NAME
|
||||
)
|
||||
|
||||
var vanilla_script := load(vanilla_script_path) as GDScript
|
||||
var vanilla_methods := vanilla_script.get_script_method_list().map(
|
||||
func(method: Dictionary) -> String:
|
||||
return method.name
|
||||
)
|
||||
|
||||
var methods := hook_script.get_script_method_list()
|
||||
for hook in methods:
|
||||
if hook.name in vanilla_methods:
|
||||
ModLoaderMod.add_hook(Callable(hook_script_instance, hook.name), vanilla_script_path, hook.name)
|
||||
continue
|
||||
|
||||
ModLoaderLog.debug(
|
||||
'Skipped adding hook "%s" (not found in vanilla script %s)'
|
||||
% [hook.name, vanilla_script_path], LOG_NAME
|
||||
)
|
||||
|
||||
if not OS.has_feature("editor"):
|
||||
continue
|
||||
|
||||
vanilla_methods.sort_custom((
|
||||
func(a_name: String, b_name: String, target_name: String) -> bool:
|
||||
return a_name.similarity(target_name) > b_name.similarity(target_name)
|
||||
).bind(hook.name))
|
||||
|
||||
var closest_vanilla: String = vanilla_methods.front()
|
||||
if closest_vanilla.similarity(hook.name) > 0.8:
|
||||
ModLoaderLog.hint(
|
||||
'Did you mean "%s" instead of "%s"?'
|
||||
% [closest_vanilla, hook.name], LOG_NAME
|
||||
)
|
||||
|
||||
|
||||
## Adds a hook, a custom mod function, to a vanilla method.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_callable] ([Callable]): The function that will executed when
|
||||
## the vanilla method is executed. When writing a mod callable, make sure
|
||||
## that it [i]always[/i] receives a [ModLoaderHookChain] object as first argument,
|
||||
## which is used to continue down the hook chain (see: [method ModLoaderHookChain.execute_next])
|
||||
## and allows manipulating parameters before and return values after the
|
||||
## vanilla method is called. [br]
|
||||
## - [param script_path] ([String]): Path to the vanilla script that holds the method.[br]
|
||||
## - [param method_name] ([String]): The method the hook will be applied to.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br][br]
|
||||
## - No return value[br]
|
||||
## [br]
|
||||
## Opposed to script extensions, hooks can be applied to scripts that use
|
||||
## [code]class_name[/code] without issues.[br]
|
||||
## If possible, prefer [method install_script_extension].[br]
|
||||
## [br]
|
||||
## [b]Examples:[/b][br]
|
||||
## [br]
|
||||
## Given the following vanilla script [code]main.gd[/code]
|
||||
## [codeblock]
|
||||
## class_name MainGame
|
||||
## extends Node2D
|
||||
##
|
||||
## var version := "vanilla 1.0.0"
|
||||
##
|
||||
##
|
||||
## func _ready():
|
||||
## $CanvasLayer/Control/Label.text = "Version: %s" % version
|
||||
## print(Utilities.format_date(15, 11, 2024))
|
||||
## [/codeblock]
|
||||
##
|
||||
## It can be hooked in [code]mod_main.gd[/code] like this
|
||||
## [codeblock]
|
||||
## func _init() -> void:
|
||||
## ModLoaderMod.add_hook(change_version, "res://main.gd", "_ready")
|
||||
## ModLoaderMod.add_hook(time_travel, "res://tools/utilities.gd", "format_date")
|
||||
## # Multiple hooks can be added to a single method.
|
||||
## ModLoaderMod.add_hook(add_season, "res://tools/utilities.gd", "format_date")
|
||||
##
|
||||
##
|
||||
## # The script we are hooking is attached to a node, which we can get from reference_object
|
||||
## # then we can change any variables it has
|
||||
## func change_version(chain: ModLoaderHookChain) -> void:
|
||||
## # Using a typecast here (with "as") can help with autocomplete and avoiding errors
|
||||
## var main_node := chain.reference_object as MainGame
|
||||
## main_node.version = "Modloader Hooked!"
|
||||
## # _ready, which we are hooking, does not have any arguments
|
||||
## chain.execute_next()
|
||||
##
|
||||
##
|
||||
## # Parameters can be manipulated easily by changing what is passed into .execute_next()
|
||||
## # The vanilla method (Utilities.format_date) takes 3 arguments, our hook method takes
|
||||
## # the ModLoaderHookChain followed by the same 3
|
||||
## func time_travel(chain: ModLoaderHookChain, day: int, month: int, year: int) -> String:
|
||||
## print("time travel!")
|
||||
## year -= 100
|
||||
## # Just the vanilla arguments are passed along in the same order, wrapped into an Array
|
||||
## var val = chain.execute_next([day, month, year])
|
||||
## return val
|
||||
##
|
||||
##
|
||||
## # The return value can be manipulated by calling the next hook (or vanilla) first
|
||||
## # then changing it and returning the new value.
|
||||
## func add_season(chain: ModLoaderHookChain, day: int, month: int, year: int) -> String:
|
||||
## var output = chain.execute_next([day, month, year])
|
||||
## match month:
|
||||
## 12, 1, 2:
|
||||
## output += ", Winter"
|
||||
## 3, 4, 5:
|
||||
## output += ", Spring"
|
||||
## 6, 7, 8:
|
||||
## output += ", Summer"
|
||||
## 9, 10, 11:
|
||||
## output += ", Autumn"
|
||||
## return output
|
||||
## [/codeblock]
|
||||
##
|
||||
static func add_hook(mod_callable: Callable, script_path: String, method_name: String) -> void:
|
||||
_ModLoaderHooks.add_hook(mod_callable, script_path, method_name)
|
||||
|
||||
|
||||
## Registers an array of classes to the global scope since Godot only does that in the editor.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param new_global_classes] ([Array]): An array of class definitions to be registered.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
## [br]
|
||||
## Format: [code]{ "base": "ParentClass", "class": "ClassName", "language": "GDScript", "path": "res://path/class_name.gd" }[/code][br]
|
||||
## [br]
|
||||
## ===[br]
|
||||
## [b]Tip:[/b][color=tip][/color][br]
|
||||
## You can find these easily in the project.godot file under `_global_script_classes`[br]
|
||||
## (but you should only include classes belonging to your mod)[br]
|
||||
## ===[br]
|
||||
static func register_global_classes_from_array(new_global_classes: Array) -> void:
|
||||
ModLoaderUtils.register_global_classes_from_array(new_global_classes)
|
||||
var _savecustom_error: int = ProjectSettings.save_custom(_ModLoaderPath.get_override_path())
|
||||
|
||||
|
||||
## Adds a translation file.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param resource_path] ([String]): The path to the translation resource file.[br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
## [br]
|
||||
## ===[br]
|
||||
## [b]Note:[/b][br]
|
||||
## The [code].translation[/code] file should have been created by the Godot editor already, usually when importing a CSV file.
|
||||
## The translation file should named [code]name.langcode.translation[/code] -> [code]mytranslation.en.translation[/code].[br]
|
||||
## ===[br]
|
||||
static func add_translation(resource_path: String) -> void:
|
||||
if not _ModLoaderFile.file_exists(resource_path):
|
||||
ModLoaderLog.fatal("Tried to load a position resource from a file that doesn't exist. The invalid path was: %s" % [resource_path], LOG_NAME)
|
||||
return
|
||||
|
||||
var translation_object: Translation = load(resource_path)
|
||||
if translation_object:
|
||||
TranslationServer.add_translation(translation_object)
|
||||
ModLoaderLog.info("Added Translation from Resource -> %s" % resource_path, LOG_NAME)
|
||||
else:
|
||||
ModLoaderLog.fatal("Failed to load translation at path: %s" % [resource_path], LOG_NAME)
|
||||
|
||||
|
||||
|
||||
## Marks the given scene for to be refreshed. It will be refreshed at the correct point in time later.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param scene_path] ([String]): The path to the scene file to be refreshed.
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
## [br]
|
||||
## ===[br]
|
||||
## [b]Note:[/b][color=abstract "Version"][/color][br]
|
||||
## This function requires Godot 4.3 or higher.[br]
|
||||
## ===[br]
|
||||
## [br]
|
||||
## This function is useful if a script extension is not automatically applied.
|
||||
## This situation can occur when a script is attached to a preloaded scene.
|
||||
## If you encounter issues where your script extension is not working as expected,
|
||||
## try to identify the scene to which it is attached and use this method to refresh it.
|
||||
## This will reload already loaded scenes and apply the script extension.
|
||||
## [br]
|
||||
static func refresh_scene(scene_path: String) -> void:
|
||||
if scene_path in ModLoaderStore.scenes_to_refresh:
|
||||
return
|
||||
|
||||
ModLoaderStore.scenes_to_refresh.push_back(scene_path)
|
||||
ModLoaderLog.debug("Added \"%s\" to be refreshed." % scene_path, LOG_NAME)
|
||||
|
||||
|
||||
## Extends a specific scene by providing a callable function to modify it.
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param scene_vanilla_path] ([String]): The path to the vanilla scene file.[br]
|
||||
## - [param edit_callable] ([Callable]): The callable function to modify the scene.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - No return value[br]
|
||||
## [br]
|
||||
## The callable receives an instance of the "vanilla_scene" as the first parameter.[br]
|
||||
static func extend_scene(scene_vanilla_path: String, edit_callable: Callable) -> void:
|
||||
if not ModLoaderStore.scenes_to_modify.has(scene_vanilla_path):
|
||||
ModLoaderStore.scenes_to_modify[scene_vanilla_path] = []
|
||||
|
||||
ModLoaderStore.scenes_to_modify[scene_vanilla_path].push_back(edit_callable)
|
||||
|
||||
|
||||
## Gets the [ModData] from the provided namespace.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [ModData]: The [ModData] associated with the provided [code]mod_id[/code], or null if the [code]mod_id[/code] is invalid.[br]
|
||||
static func get_mod_data(mod_id: String) -> ModData:
|
||||
if not ModLoaderStore.mod_data.has(mod_id):
|
||||
ModLoaderLog.error("%s is an invalid mod_id" % mod_id, LOG_NAME)
|
||||
return null
|
||||
|
||||
return ModLoaderStore.mod_data[mod_id]
|
||||
|
||||
|
||||
## Gets the [ModData] of all loaded Mods as [Dictionary].[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Dictionary]: A dictionary containing the [ModData] of all loaded mods.[br]
|
||||
static func get_mod_data_all() -> Dictionary:
|
||||
return ModLoaderStore.mod_data
|
||||
|
||||
|
||||
## Returns the path to the directory where unpacked mods are stored.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [String]: The path to the unpacked mods directory.[br]
|
||||
static func get_unpacked_dir() -> String:
|
||||
return _ModLoaderPath.get_unpacked_mods_dir_path()
|
||||
|
||||
|
||||
## Returns true if the mod with the given [code]mod_id[/code] was successfully loaded.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [bool]: true if the mod is loaded, false otherwise.[br]
|
||||
static func is_mod_loaded(mod_id: String) -> bool:
|
||||
if ModLoaderStore.is_initializing:
|
||||
ModLoaderLog.warning(
|
||||
"The ModLoader is not fully initialized. " +
|
||||
"Calling \"is_mod_loaded()\" in \"_init()\" may result in an unexpected return value as mods are still loading.",
|
||||
LOG_NAME
|
||||
)
|
||||
|
||||
# If the mod is not present in the mod_data dictionary or the mod is flagged as not loadable.
|
||||
if not ModLoaderStore.mod_data.has(mod_id) or not ModLoaderStore.mod_data[mod_id].is_loadable:
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Returns true if the mod with the given mod_id was successfully loaded and is currently active.
|
||||
## [br]
|
||||
## Parameters:
|
||||
## - [param mod_id] ([String]): The ID of the mod.
|
||||
## [br]
|
||||
## Returns:
|
||||
## - [bool]: true if the mod is loaded and active, false otherwise.
|
||||
static func is_mod_active(mod_id: String) -> bool:
|
||||
return is_mod_loaded(mod_id) and ModLoaderStore.mod_data[mod_id].is_active
|
1
addons/mod_loader/api/mod.gd.uid
Normal file
1
addons/mod_loader/api/mod.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d2hugw88f3q4e
|
475
addons/mod_loader/api/profile.gd
Normal file
475
addons/mod_loader/api/profile.gd
Normal file
@@ -0,0 +1,475 @@
|
||||
class_name ModLoaderUserProfile
|
||||
extends Object
|
||||
##
|
||||
## This Class provides methods for working with user profiles.
|
||||
|
||||
|
||||
const LOG_NAME := "ModLoader:UserProfile"
|
||||
|
||||
# The path where the Mod User Profiles data is stored.
|
||||
const FILE_PATH_USER_PROFILES := "user://mod_user_profiles.json"
|
||||
|
||||
|
||||
# API profile functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Enables a mod - it will be loaded on the next game start[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod to enable.[br]
|
||||
## - [param user_profile] ([ModUserProfile]): (Optional) The user profile to enable the mod for. Default is the current user profile.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [bool]: True on success.
|
||||
static func enable_mod(mod_id: String, user_profile:= ModLoaderStore.current_user_profile) -> bool:
|
||||
return _set_mod_state(mod_id, user_profile.name, true)
|
||||
|
||||
|
||||
## Forces a mod to enable, ensuring it loads at the next game start, regardless of load warnings.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod to enable.[br]
|
||||
## - [param user_profile] ([ModUserProfile]): (Optional) The user profile for which the mod will be enabled. Defaults to the current user profile.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [bool]: True on success.
|
||||
static func force_enable_mod(mod_id: String, user_profile:= ModLoaderStore.current_user_profile) -> bool:
|
||||
return _set_mod_state(mod_id, user_profile.name, true, true)
|
||||
|
||||
|
||||
## Disables a mod - it will not be loaded on the next game start[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod to disable.[br]
|
||||
## - [param user_profile] ([ModUserProfile]): (Optional) The user profile to disable the mod for. Default is the current user profile.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [bool]: True on success.
|
||||
static func disable_mod(mod_id: String, user_profile := ModLoaderStore.current_user_profile) -> bool:
|
||||
return _set_mod_state(mod_id, user_profile.name, false)
|
||||
|
||||
|
||||
## Sets the current config for a mod in a user profile's mod_list.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param mod_id] ([String]): The ID of the mod.[br]
|
||||
## - [param mod_config] ([ModConfig]): The mod config to set as the current config.[br]
|
||||
## - [param user_profile] ([ModUserProfile]): (Optional) The user profile to update. Default is the current user profile.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [bool]: True on success.
|
||||
static func set_mod_current_config(mod_id: String, mod_config: ModConfig, user_profile := ModLoaderStore.current_user_profile) -> bool:
|
||||
# Verify whether the mod_id is present in the profile's mod_list.
|
||||
if not _is_mod_id_in_mod_list(mod_id, user_profile.name):
|
||||
return false
|
||||
|
||||
# Update the current config in the mod_list of the user profile
|
||||
user_profile.mod_list[mod_id].current_config = mod_config.name
|
||||
|
||||
# Store the new profile in the json file
|
||||
var is_save_success := _save()
|
||||
|
||||
if is_save_success:
|
||||
ModLoaderLog.debug("Set the \"current_config\" of \"%s\" to \"%s\" in user profile \"%s\" " % [mod_id, mod_config.name, user_profile.name], LOG_NAME)
|
||||
|
||||
return is_save_success
|
||||
|
||||
|
||||
## Creates a new user profile with the given name, using the currently loaded mods as the mod list.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param profile_name] ([String]): The name of the new user profile (must be unique).[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [bool]: True on success.
|
||||
static func create_profile(profile_name: String) -> bool:
|
||||
# Verify that the profile name is not already in use
|
||||
if ModLoaderStore.user_profiles.has(profile_name):
|
||||
ModLoaderLog.error("User profile with the name of \"%s\" already exists." % profile_name, LOG_NAME)
|
||||
return false
|
||||
|
||||
var mod_list := _generate_mod_list()
|
||||
|
||||
var new_profile := _create_new_profile(profile_name, mod_list)
|
||||
|
||||
# If there was an error creating the new user profile return
|
||||
if not new_profile:
|
||||
return false
|
||||
|
||||
# Store the new profile in the ModLoaderStore
|
||||
ModLoaderStore.user_profiles[profile_name] = new_profile
|
||||
|
||||
# Set it as the current profile
|
||||
ModLoaderStore.current_user_profile = new_profile
|
||||
|
||||
# Store the new profile in the json file
|
||||
var is_save_success := _save()
|
||||
|
||||
if is_save_success:
|
||||
ModLoaderLog.debug("Created new user profile \"%s\"" % profile_name, LOG_NAME)
|
||||
|
||||
return is_save_success
|
||||
|
||||
|
||||
## Sets the current user profile to the given user profile.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param user_profile] ([ModUserProfile]): The user profile to set as the current profile.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [bool]: True on success.
|
||||
static func set_profile(user_profile: ModUserProfile) -> bool:
|
||||
# Check if the profile name is unique
|
||||
if not ModLoaderStore.user_profiles.has(user_profile.name):
|
||||
ModLoaderLog.error("User profile with name \"%s\" not found." % user_profile.name, LOG_NAME)
|
||||
return false
|
||||
|
||||
# Update the current_user_profile in the ModLoaderStore
|
||||
ModLoaderStore.current_user_profile = ModLoaderStore.user_profiles[user_profile.name]
|
||||
|
||||
# Save changes in the json file
|
||||
var is_save_success := _save()
|
||||
|
||||
if is_save_success:
|
||||
ModLoaderLog.debug("Current user profile set to \"%s\"" % user_profile.name, LOG_NAME)
|
||||
|
||||
return is_save_success
|
||||
|
||||
|
||||
## Deletes the given user profile.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param user_profile] ([ModUserProfile]): The user profile to delete.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [bool]: True on success.
|
||||
static func delete_profile(user_profile: ModUserProfile) -> bool:
|
||||
# If the current_profile is about to get deleted log an error
|
||||
if ModLoaderStore.current_user_profile.name == user_profile.name:
|
||||
ModLoaderLog.error(str(
|
||||
"You cannot delete the currently selected user profile \"%s\" " +
|
||||
"because it is currently in use. Please switch to a different profile before deleting this one.") % user_profile.name,
|
||||
LOG_NAME)
|
||||
return false
|
||||
|
||||
# Deleting the default profile is not allowed
|
||||
if user_profile.name == "default":
|
||||
ModLoaderLog.error("You can't delete the default profile", LOG_NAME)
|
||||
return false
|
||||
|
||||
# Delete the user profile
|
||||
if not ModLoaderStore.user_profiles.erase(user_profile.name):
|
||||
# Erase returns false if the the key is not present in user_profiles
|
||||
ModLoaderLog.error("User profile with name \"%s\" not found." % user_profile.name, LOG_NAME)
|
||||
return false
|
||||
|
||||
# Save profiles to the user profiles JSON file
|
||||
var is_save_success := _save()
|
||||
|
||||
if is_save_success:
|
||||
ModLoaderLog.debug("Deleted user profile \"%s\"" % user_profile.name, LOG_NAME)
|
||||
|
||||
return is_save_success
|
||||
|
||||
|
||||
## Returns the current user profile.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [ModUserProfile]: The current profile or [code]null[/code] if not set.
|
||||
static func get_current() -> ModUserProfile:
|
||||
return ModLoaderStore.current_user_profile
|
||||
|
||||
|
||||
## Returns the user profile with the given name.[br]
|
||||
## [br]
|
||||
## [b]Parameters:[/b][br]
|
||||
## - [param profile_name] ([String]): The name of the user profile to retrieve.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [ModUserProfile]: The profile or [code]null[/code] if not found
|
||||
static func get_profile(profile_name: String) -> ModUserProfile:
|
||||
if not ModLoaderStore.user_profiles.has(profile_name):
|
||||
ModLoaderLog.error("User profile with name \"%s\" not found." % profile_name, LOG_NAME)
|
||||
return null
|
||||
|
||||
return ModLoaderStore.user_profiles[profile_name]
|
||||
|
||||
|
||||
## Returns an array containing all user profiles stored in ModLoaderStore.[br]
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [Array]: A list of [ModUserProfile] Objects
|
||||
static func get_all_as_array() -> Array:
|
||||
var user_profiles := []
|
||||
|
||||
for user_profile_name in ModLoaderStore.user_profiles.keys():
|
||||
user_profiles.push_back(ModLoaderStore.user_profiles[user_profile_name])
|
||||
|
||||
return user_profiles
|
||||
|
||||
|
||||
## Returns true if the Mod User Profiles are initialized.
|
||||
## [br]
|
||||
## [b]Returns:[/b][br]
|
||||
## - [bool]: True if profiles are ready.
|
||||
## [br]
|
||||
## On the first execution of the game, user profiles might not yet be created.
|
||||
## Use this method to check if everything is ready to interact with the ModLoaderUserProfile API.
|
||||
static func is_initialized() -> bool:
|
||||
return _ModLoaderFile.file_exists(FILE_PATH_USER_PROFILES)
|
||||
|
||||
|
||||
# Internal profile functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# Update the global list of disabled mods based on the current user profile
|
||||
# The user profile will override the disabled_mods property that can be set via the options resource in the editor.
|
||||
# Example: If "Mod-TestMod" is set in disabled_mods via the editor, the mod will appear disabled in the user profile.
|
||||
# If the user then enables the mod in the profile the entry in disabled_mods will be removed.
|
||||
static func _update_disabled_mods() -> void:
|
||||
var current_user_profile: ModUserProfile = get_current()
|
||||
|
||||
# Check if a current user profile is set
|
||||
if not current_user_profile:
|
||||
ModLoaderLog.info("There is no current user profile. The \"default\" profile will be created.", LOG_NAME)
|
||||
return
|
||||
|
||||
# Iterate through the mod list in the current user profile to find disabled mods
|
||||
for mod_id in current_user_profile.mod_list:
|
||||
var mod_list_entry: Dictionary = current_user_profile.mod_list[mod_id]
|
||||
if ModLoaderStore.mod_data.has(mod_id):
|
||||
ModLoaderStore.mod_data[mod_id].set_mod_state(mod_list_entry.is_active, true)
|
||||
|
||||
ModLoaderLog.debug(
|
||||
"Updated the active state of all mods, based on the current user profile \"%s\""
|
||||
% current_user_profile.name,
|
||||
LOG_NAME)
|
||||
|
||||
|
||||
# This function updates the mod lists of all user profiles with newly loaded mods that are not already present.
|
||||
# It does so by comparing the current set of loaded mods with the mod list of each user profile, and adding any missing mods.
|
||||
# Additionally, it checks for and deletes any mods from each profile's mod list that are no longer installed on the system.
|
||||
static func _update_mod_lists() -> bool:
|
||||
# Generate a list of currently present mods by combining the mods
|
||||
# in mod_data and ml_options.disabled_mods from ModLoaderStore.
|
||||
var current_mod_list := _generate_mod_list()
|
||||
|
||||
# Iterate over all user profiles
|
||||
for profile_name in ModLoaderStore.user_profiles.keys():
|
||||
var profile: ModUserProfile = ModLoaderStore.user_profiles[profile_name]
|
||||
|
||||
# Merge the profiles mod_list with the previously created current_mod_list
|
||||
profile.mod_list.merge(current_mod_list)
|
||||
|
||||
var update_mod_list := _update_mod_list(profile.mod_list)
|
||||
|
||||
profile.mod_list = update_mod_list
|
||||
|
||||
# Save the updated user profiles to the JSON file
|
||||
var is_save_success := _save()
|
||||
|
||||
if is_save_success:
|
||||
ModLoaderLog.debug("Updated the mod lists of all user profiles", LOG_NAME)
|
||||
|
||||
return is_save_success
|
||||
|
||||
|
||||
# This function takes a mod_list dictionary and optional mod_data dictionary as input and returns
|
||||
# an updated mod_list dictionary. It iterates over each mod ID in the mod list, checks if the mod
|
||||
# is still installed and if the current_config is present. If the mod is not installed or the current
|
||||
# config is missing, the mod is removed or its current_config is reset to the default configuration.
|
||||
static func _update_mod_list(mod_list: Dictionary, mod_data := ModLoaderStore.mod_data) -> Dictionary:
|
||||
var updated_mod_list := mod_list.duplicate(true)
|
||||
|
||||
# Iterate over each mod ID in the mod list
|
||||
for mod_id in updated_mod_list.keys():
|
||||
var mod_list_entry: Dictionary = updated_mod_list[mod_id]
|
||||
|
||||
# Check if the current config doesn't exist
|
||||
# This can happen if the config file was manually deleted
|
||||
if mod_list_entry.has("current_config") and _ModLoaderPath.get_path_to_mod_config_file(mod_id, mod_list_entry.current_config).is_empty():
|
||||
# If the current config doesn't exist, reset it to the default configuration
|
||||
mod_list_entry.current_config = ModLoaderConfig.DEFAULT_CONFIG_NAME
|
||||
|
||||
if (
|
||||
# If the mod is not loaded
|
||||
not mod_data.has(mod_id) and
|
||||
# Check if the entry has a zip_path key
|
||||
mod_list_entry.has("zip_path") and
|
||||
# Check if the entry has a zip_path
|
||||
not mod_list_entry.zip_path.is_empty() and
|
||||
# Check if the zip file for the mod doesn't exist
|
||||
not _ModLoaderFile.file_exists(mod_list_entry.zip_path)
|
||||
):
|
||||
# If the mod directory doesn't exist,
|
||||
# the mod is no longer installed and can be removed from the mod list
|
||||
ModLoaderLog.debug(
|
||||
"Mod \"%s\" has been deleted from all user profiles as the corresponding zip file no longer exists at path \"%s\"."
|
||||
% [mod_id, mod_list_entry.zip_path],
|
||||
LOG_NAME,
|
||||
true
|
||||
)
|
||||
|
||||
updated_mod_list.erase(mod_id)
|
||||
continue
|
||||
|
||||
updated_mod_list[mod_id] = mod_list_entry
|
||||
|
||||
return updated_mod_list
|
||||
|
||||
|
||||
# Generates a dictionary with data to be stored for each mod.
|
||||
static func _generate_mod_list() -> Dictionary:
|
||||
var mod_list := {}
|
||||
|
||||
# Create a mod_list with the currently loaded mods
|
||||
for mod_id in ModLoaderStore.mod_data.keys():
|
||||
mod_list[mod_id] = _generate_mod_list_entry(mod_id, true)
|
||||
|
||||
# Add the deactivated mods to the list
|
||||
for mod_id in ModLoaderStore.ml_options.disabled_mods:
|
||||
mod_list[mod_id] = _generate_mod_list_entry(mod_id, false)
|
||||
|
||||
return mod_list
|
||||
|
||||
|
||||
# Generates a mod list entry dictionary with the given mod ID and active status.
|
||||
# If the mod has a config schema, sets the 'current_config' key to the current_config stored in the Mods ModData.
|
||||
static func _generate_mod_list_entry(mod_id: String, is_active: bool) -> Dictionary:
|
||||
var mod_list_entry := {}
|
||||
|
||||
# Set the mods active state
|
||||
mod_list_entry.is_active = is_active
|
||||
|
||||
# Set the mods zip path if available
|
||||
if ModLoaderStore.mod_data.has(mod_id):
|
||||
mod_list_entry.zip_path = ModLoaderStore.mod_data[mod_id].zip_path
|
||||
|
||||
# Set the current_config if the mod has a config schema and is active
|
||||
if is_active and not ModLoaderConfig.get_config_schema(mod_id).is_empty():
|
||||
var current_config: ModConfig = ModLoaderStore.mod_data[mod_id].current_config
|
||||
if current_config and current_config.is_valid:
|
||||
# Set to the current_config name if valid
|
||||
mod_list_entry.current_config = current_config.name
|
||||
else:
|
||||
# If not valid revert to the default config
|
||||
mod_list_entry.current_config = ModLoaderConfig.DEFAULT_CONFIG_NAME
|
||||
|
||||
return mod_list_entry
|
||||
|
||||
|
||||
# Handles the activation or deactivation of a mod in a user profile.
|
||||
static func _set_mod_state(mod_id: String, profile_name: String, should_activate: bool, force := false) -> bool:
|
||||
# Verify whether the mod_id is present in the profile's mod_list.
|
||||
if not _is_mod_id_in_mod_list(mod_id, profile_name):
|
||||
return false
|
||||
|
||||
# Handle mod state
|
||||
# Set state in the ModData
|
||||
var was_toggled: bool = ModLoaderStore.mod_data[mod_id].set_mod_state(should_activate, force)
|
||||
if not was_toggled:
|
||||
return false
|
||||
|
||||
# Set state for user profile
|
||||
ModLoaderStore.user_profiles[profile_name].mod_list[mod_id].is_active = should_activate
|
||||
|
||||
# Save profiles to the user profiles JSON file
|
||||
var is_save_success := _save()
|
||||
|
||||
if is_save_success:
|
||||
ModLoaderLog.debug("Mod activation state changed: mod_id=%s should_activate=%s profile_name=%s" % [mod_id, should_activate, profile_name], LOG_NAME)
|
||||
|
||||
return is_save_success
|
||||
|
||||
|
||||
# Checks whether a given mod_id is present in the mod_list of the specified user profile.
|
||||
# Returns True if the mod_id is present, False otherwise.
|
||||
static func _is_mod_id_in_mod_list(mod_id: String, profile_name: String) -> bool:
|
||||
# Get the user profile
|
||||
var user_profile := get_profile(profile_name)
|
||||
if not user_profile:
|
||||
# Return false if there is an error getting the user profile
|
||||
return false
|
||||
|
||||
# Return false if the mod_id is not in the profile's mod_list
|
||||
if not user_profile.mod_list.has(mod_id):
|
||||
ModLoaderLog.error("Mod id \"%s\" not found in the \"mod_list\" of user profile \"%s\"." % [mod_id, profile_name], LOG_NAME)
|
||||
return false
|
||||
|
||||
# Return true if the mod_id is in the profile's mod_list
|
||||
return true
|
||||
|
||||
|
||||
# Creates a new Profile with the given name and mod list.
|
||||
# Returns the newly created Profile object.
|
||||
static func _create_new_profile(profile_name: String, mod_list: Dictionary) -> ModUserProfile:
|
||||
var new_profile := ModUserProfile.new()
|
||||
|
||||
# If no name is provided, log an error and return null
|
||||
if profile_name == "":
|
||||
ModLoaderLog.error("Please provide a name for the new profile", LOG_NAME)
|
||||
return null
|
||||
|
||||
# Set the profile name
|
||||
new_profile.name = profile_name
|
||||
|
||||
# If no mods are specified in the mod_list, log a warning and return the new profile
|
||||
if mod_list.keys().size() == 0:
|
||||
ModLoaderLog.info("No mod_ids inside \"mod_list\" for user profile \"%s\" " % profile_name, LOG_NAME)
|
||||
return new_profile
|
||||
|
||||
# Set the mod_list
|
||||
new_profile.mod_list = _update_mod_list(mod_list)
|
||||
|
||||
return new_profile
|
||||
|
||||
|
||||
# Loads user profiles from the JSON file and adds them to ModLoaderStore.
|
||||
static func _load() -> bool:
|
||||
# Load JSON data from the user profiles file
|
||||
var data := _ModLoaderFile.get_json_as_dict(FILE_PATH_USER_PROFILES)
|
||||
|
||||
# If there is no data, log an error and return
|
||||
if data.is_empty():
|
||||
ModLoaderLog.error("No profile file found at \"%s\"" % FILE_PATH_USER_PROFILES, LOG_NAME)
|
||||
return false
|
||||
|
||||
# Loop through each profile in the data and add them to ModLoaderStore
|
||||
for profile_name in data.profiles.keys():
|
||||
# Get the profile data from the JSON object
|
||||
var profile_data: Dictionary = data.profiles[profile_name]
|
||||
|
||||
# Create a new profile object and add it to ModLoaderStore.user_profiles
|
||||
var new_profile := _create_new_profile(profile_name, profile_data.mod_list)
|
||||
ModLoaderStore.user_profiles[profile_name] = new_profile
|
||||
|
||||
# Set the current user profile to the one specified in the data
|
||||
ModLoaderStore.current_user_profile = ModLoaderStore.user_profiles[data.current_profile]
|
||||
|
||||
return true
|
||||
|
||||
|
||||
# Saves the user profiles in the ModLoaderStore to the user profiles JSON file.
|
||||
static func _save() -> bool:
|
||||
# Initialize a dictionary to hold the serialized user profiles data
|
||||
var save_dict := {
|
||||
"current_profile": "",
|
||||
"profiles": {}
|
||||
}
|
||||
|
||||
# Set the current profile name in the save_dict
|
||||
save_dict.current_profile = ModLoaderStore.current_user_profile.name
|
||||
|
||||
# Serialize the mod_list data for each user profile and add it to the save_dict
|
||||
for profile_name in ModLoaderStore.user_profiles.keys():
|
||||
var profile: ModUserProfile = ModLoaderStore.user_profiles[profile_name]
|
||||
|
||||
# Init the profile dict
|
||||
save_dict.profiles[profile.name] = {}
|
||||
# Init the mod_list dict
|
||||
save_dict.profiles[profile.name].mod_list = profile.mod_list
|
||||
|
||||
# Save the serialized user profiles data to the user profiles JSON file
|
||||
return _ModLoaderFile.save_dictionary_to_json_file(save_dict, FILE_PATH_USER_PROFILES)
|
1
addons/mod_loader/api/profile.gd.uid
Normal file
1
addons/mod_loader/api/profile.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c0u28df0ffhan
|
Reference in New Issue
Block a user