diff --git a/addons/CORE/src/classes/basemodule.gd b/addons/CORE/src/classes/basemodule.gd
index 4bf52fe..4059050 100644
--- a/addons/CORE/src/classes/basemodule.gd
+++ b/addons/CORE/src/classes/basemodule.gd
@@ -24,10 +24,8 @@ class_name CoreBaseModule
## Contains a reference to the CORE Object.
var core: Core
-## Set to CORE's logger implementation.
-@onready var logger: CoreBaseModule = core.logger
-## Set to a [class CoreLoggerInstance] with the path you supplied to [method Core.register_custom_module]. You should use this over [code]logger[/code].
-var loggeri: CoreLoggerInstance
+## Set to a [class CoreLoggerInstance] with the path you supplied to [method Core.register_custom_module].
+var logger: CoreLoggerInstance
## Marks a module as fully initialized and ready. **Don't forget to set this!**
var initialized: bool = false
diff --git a/addons/CORE/src/classes/config.gd b/addons/CORE/src/classes/config.gd
index 1edfe7f..6dfd198 100644
--- a/addons/CORE/src/classes/config.gd
+++ b/addons/CORE/src/classes/config.gd
@@ -19,6 +19,8 @@ class_name CoreConfiguration
@export var custom_modules: bool
## If [method Core.quit_safely] (and by extension [method Core.cleanup]) should be called when pressing the X.
@export var automatic_shutdown: bool
+## Hides the window during engine shutdown. Useful when your game and the framework will take longer to cleanup.
+@export var hide_window_on_shutdown: bool
@export_category("Logger")
## The minimum log level you want to be displayed.
@@ -26,8 +28,7 @@ class_name CoreConfiguration
## Determines if the logger's output should be colored.
@export var logger_colored: bool
## Determines if the logger should check if running in verbose mode (see [method OS.is_stdout_verbose]).[br]
-## Comes with a huge performance penalty on startup, delaying startup by about one to two seconds.[br]
-## Update [code]verbose_mode[/code] accordingly yourself if you've disabled this, or diagnostic log messages might appear messed up.[br]
+## Update [code]verbose_mode[/code] yourself accordingly if you've set this to [code]false[/code], or face messed up diagnostic log messages.[br]
## [b]Warning: [i]Updating this during runtime does nothing.[/i][/b]
@export var logger_detect_verbose_mode: bool
## The template for all log messages.[br]
@@ -45,6 +46,20 @@ class_name CoreConfiguration
@export var logui_background_color: Color
## What font size the graphical log should have.
@export var logui_font_size: int
+## How many lines can be visible on the screen at any given time. [b]Never[/b] set this higher than 100 or it will cause tremendous amounts of lag for no real benefit. Instead [i]lowering[/i] that amount helps performance.
+@export var logui_max_lines: int
+
+@export_category("Miscellaneous")
+## Shows or hides the type when calling [code]stringify_variables[/code].
+@export var misc_stringify_show_type: bool
+## Determines how [code]stringify_variables[/code] should display [class Color] variables.[br]
+## Will display colors from [code]0[/code] to [code]255[/code] if [code]true[/code] or from [code]-1.0[/code] to [code]1.0[/code] if [code]false[/code].
+@export var misc_stringify_color_range8: bool
+## Determines if [class Array]s should be processed by [code]stringify_variables[/code].
+@export var misc_stringify_array: bool
+## Determines if [class Dictionary]s should be processed by [code]stringify_variables[/code].
+@export var misc_stringify_dictionary: bool
+
@export_category("Easy Request Maker")
## Determines how unsecure requests should be handled.
@export var erm_unsecure_requests: CoreTypes.BlockadeLevel
@@ -54,18 +69,29 @@ func _init() -> void:
# Global
headless = false
development = false
- custom_modules = false
+ custom_modules = true
automatic_shutdown = true
+ hide_window_on_shutdown = true
# Logger
logger_level = CoreTypes.LoggerLevel.INFO
logger_colored = true
+ logger_detect_verbose_mode = true
logger_format = "%color%[%time%] [%level% %origin%] %message%"
logger_newlines_override = true
logger_newlines_sizelimit = 40
+
# Log UI
logui_enabled = true
logui_background_color = Color.BLACK # To disable the background, use Color.TRANSPARENT
logui_font_size = 14
+ logui_max_lines = 100
+
+ # Misc
+ misc_stringify_show_type = false
+ misc_stringify_color_range8 = true
+ misc_stringify_array = true
+ misc_stringify_dictionary = true
+
# Easy Request Maker
erm_unsecure_requests = CoreTypes.BlockadeLevel.BLOCK
diff --git a/addons/CORE/src/classes/types.gd b/addons/CORE/src/classes/types.gd
index 4d083da..f269ad4 100644
--- a/addons/CORE/src/classes/types.gd
+++ b/addons/CORE/src/classes/types.gd
@@ -23,9 +23,11 @@ class_name CoreTypes
## Available version types, following the StarOpenSource Versioning Specification (SOSVS) version 1.
enum VersionType { RELEASE, RELEASECANDIDATE, BETA, ALPHA }
-## Available log levels, followingthe StarOpenSource Logging Specification (SOSLS) version 1.
-enum LoggerLevel { NONE, ERROR, WARN, INFO, VERB, DIAG }
+## Available log levels, following the StarOpenSource Logging Specification (SOSLS) version 1.
+enum LoggerLevel { NONE, SPECIAL, ERROR, WARN, INFO, VERB, DIAG }
## Available scene types.
enum SceneType { NONE, DEBUG, CUTSCENE, MENU, MAIN, BACKGROUND }
## To what degree [i]something[/i] should be blocked.
enum BlockadeLevel { IGNORE, WARN, BLOCK }
+## All validation rules some data can be checked against.
+enum ValidationType { MATCHES_TYPE, MATCHES_CLASS, IN_RANGE, HAS_MINIMUM, HAS_MAXIMUM, HAS_VALUES, CONTAINS, MATCHES_REGEX, IS_NOT_EMPTY, IS_NOT_NULL, IS_NORMALIZED, IS_ORTHONORMALIZED }
diff --git a/addons/CORE/src/classes/validationschema.gd b/addons/CORE/src/classes/validationschema.gd
new file mode 100644
index 0000000..a4f4cff
--- /dev/null
+++ b/addons/CORE/src/classes/validationschema.gd
@@ -0,0 +1,91 @@
+# CORE FRAMEWORK SOURCE FILE
+# Copyright (c) 2024 The StarOpenSource Project & Contributors
+# Licensed under the GNU Affero General Public License v3
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+## Validates a Dictionary against multiple [CoreValidationSingle]s.
+extends Node
+class_name CoreValidationSchema
+
+## Internal, don't modify.
+var core: Core
+## Internal, don't modify.
+var logger: CoreLoggerInstance
+## Internal, don't modify.
+var parent: Node
+## Contains the schema to validate.[br]
+## [b]Note: [i]Don't modify.[/i][/b]
+var schema: Dictionary = {}
+## Contains the data to validate.[br]
+## [b]Note: [i]Don't modify.[/i][/b]
+var data: Dictionary = {}
+
+func _init(core_new: Core, schema_new: Dictionary, data_new: Dictionary, parent_new: Node) -> void:
+ core = core_new
+ logger = core.logger.get_instance(core.basepath.replace("res://", "") + "src/classes/validationschema.gd", self)
+ parent = parent_new
+ schema = schema_new
+ data = data_new
+
+ # Check Dictionary
+ _check_dictionary_recursive(schema)
+
+func _check_dictionary_recursive(schema_parent: Dictionary, path: String = "") -> bool:
+ var success: bool = false
+ for key in schema_parent:
+ if typeof(key) != TYPE_STRING:
+ logger.error(core.stringify_variables("Could not parse schema: Schema key %key% is not of type String", { "key": path + "/" + key }))
+ success = false
+ continue
+ match(typeof(schema_parent[key])):
+ TYPE_OBJECT:
+ if schema_parent[key].get_class() != "Node":
+ logger.error(core.stringify_variables("Could not parse schema: Schema value of %key% is not of type Node", { "key": path + "/" + key }))
+ success = false
+ continue
+ TYPE_DICTIONARY:
+ _check_dictionary_recursive(schema_parent[key], path + "/" + key)
+ _:
+ logger.error(core.stringify_variables("Could not parse schema: Schema value of %key% is not of type CoreValidationSingle or Dictionary", { "key": path + "/" + key }))
+ success = false
+ continue
+
+ return success
+
+func evaluate() -> Array[String]:
+ return _evaluate_recursive(str(randf()).repeat(50), schema, data)
+
+func _evaluate_recursive(random: String, schema_parent: Dictionary, data_parent: Dictionary, path: String = "") -> Array[String]:
+ var failed: Array[String] = []
+ for key in schema_parent:
+ # Check if key exists in data
+ if str(data_parent.get(key, random)) == random:
+ # Does not exist, append error
+ failed.append(core.stringify_variables("Key %key% is present in schema but missing in data", { "key": path + "/" + key }))
+ else:
+ # Exists in data
+ if typeof(schema_parent[key]) == TYPE_DICTIONARY:
+ # Key is of type Dictionary, allow for recursion to happen
+ failed.append_array(_evaluate_recursive(random, schema_parent[key], data_parent[key], path + "/" + key))
+ else:
+ # Key is not of type Dictionary, evaluate against data
+ schema_parent[key].data = data_parent[key]
+ if !schema_parent[key].evaluate():
+ logger.error(core.stringify_variables("Validation for key %key% failed", { "key": path + "/" + key }))
+ for failure in schema_parent[key].failures:
+ # Append failures from single
+ failed.append(path + "/" + key + ": " + failure)
+
+ return failed
diff --git a/addons/CORE/src/classes/validationsingle.gd b/addons/CORE/src/classes/validationsingle.gd
new file mode 100644
index 0000000..e77111d
--- /dev/null
+++ b/addons/CORE/src/classes/validationsingle.gd
@@ -0,0 +1,317 @@
+# CORE FRAMEWORK SOURCE FILE
+# Copyright (c) 2024 The StarOpenSource Project & Contributors
+# Licensed under the GNU Affero General Public License v3
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+## Validates some [param data] against a set of predefined rules.
+extends Node
+class_name CoreValidationSingle
+
+## Internal, don't modify.
+var core: Core
+## Internal, don't modify.
+var logger: CoreLoggerInstance
+## Is set to the parent owning the validation single.[br]
+## [b]Note: [i]Don't modify.[/i][/b]
+var parent: Node
+## Contains the data to validate.[br]
+## [b]Note: [i]Don't modify.[/i][/b]
+var data # This is the only instance where we don't want to define what type this variable should have
+
+## All rules to evaluate in [method evaluate].[br]
+## [b]Note: [i]Don't modify.[/i][/b]
+var rules: Array[Dictionary]
+## Contains error messages for failed rules.[br]
+## [b]Note: [i]Don't modify.[/i][/b]
+var failures: Array[String] = []
+
+# +++ constructor +++
+func _init(core_new: Core, data_new, parent_new: Node) -> void:
+ core = core_new
+ logger = core.logger.get_instance(core.basepath.replace("res://", "") + "src/classes/validationsingle.gd", self)
+ parent = parent_new
+ data = data_new
+
+# +++ evaluation +++
+## Evaluates all set rules and returns [code]true[/code] if all rules passed, or [code]false[/code] if at least one failed.
+func evaluate() -> bool:
+ # Reset failures
+ failures = []
+
+ # Iterate through all rules
+ for rule in rules:
+ match(rule["type"]):
+ CoreTypes.ValidationType.MATCHES_TYPE:
+ var success: bool = false
+ for type in rule["types"]:
+ if typeof(data) == type:
+ success = true
+
+ if !success:
+ var types_string: String = ""
+ for type in rule["types"]:
+ if types_string == "": types_string = type_string(type)
+ else: types_string = types_string + "|" + type_string(type)
+ failures.append(core.stringify_variables("Data is not of expected type(s) %expected% (is %got%)", { "expected": types_string, "got": type_string(typeof(data)) }))
+ CoreTypes.ValidationType.MATCHES_CLASS:
+ # If not an object, skip
+ if typeof(data) != Variant.Type.TYPE_OBJECT:
+ logger.warn("Can't match class as data is not of type Object")
+ continue
+
+ if rule["exact"]:
+ # Only check if class name matches specified class
+ if data.get_class() != rule["class"]:
+ failures.append(core.stringify_variables("Expected exact class %expected% but data is of class %got%", { "expected": rule["class"], "got": data.get_class() }))
+ else:
+ # Check if class is or inherits specified class
+ if !data.is_class(rule["class"]):
+ failures.append(core.stringify_variables("Expected class %expected% but data is of class %got%", { "expected": rule["class"], "got": data.get_class() }))
+ CoreTypes.ValidationType.IN_RANGE:
+ # If not an int or float, skip
+ match(rule["matched_against"]):
+ "integer":
+ if typeof(data) != Variant.Type.TYPE_INT:
+ logger.warn("Can't match class as data is not of type int")
+ continue
+ "float":
+ if typeof(data) != Variant.Type.TYPE_FLOAT:
+ logger.warn("Can't match class as data is not of type float")
+ continue
+ _: logger.crash(core.stringify_variables("Invalid 'matched_against' value %value% for IN_RANGE", { "value": rule["matched_against"] }))
+
+ # Check if from is bigger than to
+ if rule["from"] > rule["to"]:
+ logger.error(core.stringify_variables("Can't match invalid %type% range %from% to %to%", { "type": rule["matched_against"], "from": rule["from"], "to": rule["to"] }))
+ continue
+
+ # Perform checks
+ if data < rule["from"]: failures.append(core.stringify_variables("Data is smaller than %type% range start %expected%", { "type": rule["matched_against"], "expected": rule["from"] }))
+ if data > rule["to"]: failures.append(core.stringify_variables("Data is larger than %type% range end %expected%", { "type": rule["matched_against"], "expected": rule["to"] }))
+ CoreTypes.ValidationType.HAS_MINIMUM:
+ # If not an int or float, skip
+ match(rule["matched_against"]):
+ "integer":
+ if typeof(data) != Variant.Type.TYPE_INT:
+ logger.warn("Can't determine if data has minimum value as data is not of type int")
+ continue
+ "float":
+ if typeof(data) != Variant.Type.TYPE_FLOAT:
+ logger.warn("Can't determine if data has minimum value as data is not of type float")
+ continue
+ _: logger.crash(core.stringify_variables("Invalid 'matched_against' value %value% for HAS_MINIMUM", { "value": rule["matched_against"] }))
+
+ # Perform check
+ if data < rule["minimum"]: failures.append(core.stringify_variables("Data is smaller than minimum %type% value %expected%", { "type": rule["matched_against"], "expected": rule["minimum"] }))
+ CoreTypes.ValidationType.HAS_MAXIMUM:
+ # If not an int or float, skip
+ match(rule["matched_against"]):
+ "integer":
+ if typeof(data) != Variant.Type.TYPE_INT:
+ logger.warn("Can't determine if data has maximum value as data is not of type int")
+ continue
+ "float":
+ if typeof(data) != Variant.Type.TYPE_FLOAT:
+ logger.warn("Can't determine if data has maximum value as data is not of type float")
+ continue
+ _: logger.crash(core.stringify_variables("Invalid 'matched_against' value %value% for HAS_MINIMUM", { "value": rule["matched_against"] }))
+
+ # Perform check
+ if data > rule["maximum"]: failures.append(core.stringify_variables("Data is smaller than minimum %type% value %expected%", { "type": rule["matched_against"], "expected": rule["maximum"] }))
+ CoreTypes.ValidationType.HAS_VALUES:
+ var success: bool = false
+
+ for value in rule["values"]:
+ if data == value:
+ success = true
+
+ if !success: failures.append("Data did not match a single provided value")
+ CoreTypes.ValidationType.CONTAINS:
+ # If not a String or StringName, skip
+ if typeof(data) != Variant.Type.TYPE_STRING and typeof(data) != Variant.Type.TYPE_STRING_NAME:
+ logger.warn("Can't determine if data contains values as data is not of type String or StringName")
+ continue
+
+ # Set 'minimum_matches' to the amount of values if set to '-1'
+ if rule["minimum_matches"] == -1: rule["minimum_matches"] = rule["values"].size()
+
+ var successes: int = 0
+ for value in rule["values"]:
+ if data.contains(value):
+ successes += 1
+
+ # Success if equals or bigger than 'minimum_matches'
+ if successes < rule["minimum_matches"]: failures.append(core.stringify_variables("Data did matched %got% out of %expected% expected strings", { "got": successes, "expected": rule["minimum_matches"] }))
+ CoreTypes.ValidationType.MATCHES_REGEX:
+ # If not a String or StringName, skip
+ if typeof(data) != Variant.Type.TYPE_STRING and typeof(data) != Variant.Type.TYPE_STRING_NAME:
+ logger.warn("Can't determine if data matches regex as data is not of type String or StringName")
+ continue
+
+ var regex: RegEx = RegEx.new()
+ # Compile regex
+ regex.compile(rule["regex_string"])
+ # Get result
+ var result: RegExMatch = regex.search(data)
+ # If result yielded no result, fail
+ if !result: failures.append(core.stringify_variables("Data doesn't match regex %regex%", { "regex": rule["regex_string"] }))
+ CoreTypes.ValidationType.IS_NOT_EMPTY:
+ match(typeof(data)):
+ Variant.Type.TYPE_STRING: if data == "": failures.append("Data (String) is empty")
+ Variant.Type.TYPE_STRING_NAME: if data == "": failures.append("Data (StringName) is empty")
+ Variant.Type.TYPE_INT: if data == 0: failures.append("Data (int) is zero")
+ Variant.Type.TYPE_FLOAT: if data == 0.0: failures.append("Data (float) is zero")
+ Variant.Type.TYPE_ARRAY: if data == []: failures.append("Data (Array) is empty")
+ Variant.Type.TYPE_DICTIONARY: if data == {}: failures.append("Data (Dictionary) is empty")
+ # If not supported, skip
+ _: logger.warn("Can't determine if data is null as data is not of type String, StringName, int, float, Array or Dictionary")
+ CoreTypes.ValidationType.IS_NOT_NULL:
+ # ⡴⠑⡄⠀⠀⠀⠀⠀⠀⠀ ⣀⣀⣤⣤⣤⣀⡀
+ # ⠸⡇⠀⠿⡀⠀⠀⠀⣀⡴⢿⣿⣿⣿⣿⣿⣿⣿⣷⣦⡀
+ # ⠀⠀⠀⠀⠑⢄⣠⠾⠁⣀⣄⡈⠙⣿⣿⣿⣿⣿⣿⣿⣿⣆
+ # ⠀⠀⠀⠀⢀⡀⠁⠀⠀⠈⠙⠛⠂⠈⣿⣿⣿⣿⣿⠿⡿⢿⣆
+ # ⠀⠀⠀⢀⡾⣁⣀⠀⠴⠂⠙⣗⡀⠀⢻⣿⣿⠭⢤⣴⣦⣤⣹⠀⠀⠀⢀⢴⣶⣆
+ # ⠀⠀⢀⣾⣿⣿⣿⣷⣮⣽⣾⣿⣥⣴⣿⣿⡿⢂⠔⢚⡿⢿⣿⣦⣴⣾⠸⣼⡿
+ # ⠀⢀⡞⠁⠙⠻⠿⠟⠉⠀⠛⢹⣿⣿⣿⣿⣿⣌⢤⣼⣿⣾⣿⡟⠉
+ # ⠀⣾⣷⣶⠇⠀⠀⣤⣄⣀⡀⠈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
+ # ⠀⠉⠈⠉⠀⠀⢦⡈⢻⣿⣿⣿⣶⣶⣶⣶⣤⣽⡹⣿⣿⣿⣿⡇
+ # ⠀⠀⠀⠀⠀⠀⠀⠉⠲⣽⡻⢿⣿⣿⣿⣿⣿⣿⣷⣜⣿⣿⣿⡇
+ # ⠀⠀ ⠀⠀⠀⠀⠀⢸⣿⣿⣷⣶⣮⣭⣽⣿⣿⣿⣿⣿⣿⣿⠇
+ # ⠀⠀⠀⠀⠀⠀⣀⣀⣈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇
+ # ⠀⠀⠀⠀⠀⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃
+ # https://steamcommunity.com/sharedfiles/filedetails/?id=2902061979
+ if data == null: failures.append("Data is null")
+ CoreTypes.ValidationType.IS_NORMALIZED:
+ # If not a Vector2, Vector3, Vector4, Plane or Quaternion, skip
+ match(typeof(data)):
+ Variant.Type.TYPE_VECTOR2: pass
+ Variant.Type.TYPE_VECTOR3: pass
+ Variant.Type.TYPE_VECTOR4: pass
+ Variant.Type.TYPE_PLANE: pass
+ Variant.Type.TYPE_QUATERNION: pass
+ _:
+ logger.warn("Can't determine if data is normalized as data is not of type Vector2, Vector3, Vector4, Plane or Quaternion")
+ continue
+
+ # Perform check
+ if !data.is_normalized(): failures.append("Data is not normalized")
+ CoreTypes.ValidationType.IS_ORTHONORMALIZED:
+ # If not a Transform2D, Transform3D, or Basis, skip
+ match(typeof(data)):
+ Variant.Type.TYPE_TRANSFORM2D: pass
+ Variant.Type.TYPE_TRANSFORM3D: pass
+ Variant.Type.TYPE_BASIS: pass
+ _:
+ logger.warn("Can't determine if data is orthonormalized as data is not of type Transform2D, Transform3D or Basis")
+ continue
+
+ # Perform check
+ if !data.is_orthonormalized(): failures.append("Data is not orthonormalized")
+ _: await logger.crash(core.stringify_variables("Invalid validation rule type %type%", { "type": rule["type"] }))
+
+ return failures.size() == 0
+
+# +++ types and classes +++
+## Validates if [param data] matches some [enum Variant.Type].[br]
+## Applies to all data types (obviously).
+func matches_type(types: Array) -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.MATCHES_TYPE, "types": types })
+ return self
+
+## Validates if [param data] matches some class.[br]
+## Applies to [Object].
+func matches_class(clazz: StringName, exact: bool) -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.MATCHES_CLASS, "class": clazz, "exact": exact })
+ return self
+
+# +++ ranges +++
+## Validates if [param data] contains the specified integer range.[br]
+## Applies to [int].
+func in_range_int(from: int, to: int) -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.IN_RANGE, "matched_against": "integer", "from": from, "to": to })
+ return self
+
+## Validates if [param data] contains the specified float range.[br]
+## Applies to [float].
+func in_range_float(from: float, to: float) -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.IN_RANGE, "matched_against": "float", "from": from, "to": to })
+ return self
+
+## Ensures that [param data] is equal to or exceeds the specified integer.[br]
+## Applies to [int].
+func has_minimum_int(minimum: int) -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.HAS_MINIMUM, "matched_against": "integer", "minimum": minimum })
+ return self
+
+## Ensures that [param data] is under the specified integer.[br]
+## Applies to [int].
+func has_maximum_int(maximum: int) -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.HAS_MAXIMUM, "matched_against": "integer", "maximum": maximum })
+ return self
+
+## Ensures that [param data] is equal to or exceeds the specified float.[br]
+## Applies to [float].
+func has_minimum_float(minimum: float) -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.HAS_MINIMUM, "matched_against": "float", "minimum": minimum })
+ return self
+
+## Ensures that [param data] is under the specified float.[br]
+## Applies to [float].
+func has_maximum_float(maximum: float) -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.HAS_MAXIMUM, "matched_against": "float", "maximum": maximum })
+ return self
+
+# +++ values +++
+## Checks whenether at least one value matches [param data].[br]
+## Applies to all data types.
+func has_values(values: Array) -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.HAS_VALUES, "values": values })
+ return self
+
+## Ensures that [param data] contains at least <[code]minimum_matches[/code]> (or all if set to [code]-1[/code]) values.[br]
+## Applies to [String] & [StringName].
+func contains(values: Array, minimum_matches: int = 1) -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.CONTAINS, "values": values, "minimum_matches": minimum_matches })
+ return self
+
+## Matches a regular expression against [param data].[br]
+## Applies to [String] & [StringName].
+func matches_regex(regex_string: String) -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.MATCHES_REGEX, "regex_string": regex_string })
+ return self
+
+# +++ empty/null and booleans +++
+## Ensures that [param data] is not empty.[br]
+## Applies to [String] & [StringName] ([code]!= ""[/code]), [int] ([code]!= 0[/code]), [float] ([code]!= 0.0[/code]), [Array] ([code]!= [][/code]) and [Dictionary] ([code]!= {}[/code]).
+func is_not_empty() -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.IS_NOT_EMPTY })
+ return self
+
+## Ensures that [param data] is not [code]null[/code].
+func is_not_null() -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.IS_NOT_NULL })
+ return self
+
+## Ensures that [param data] is normalized.[br]
+## Applies to [Vector2], [Vector3], [Vector4], [Plane] and [Quaternion].
+func is_normalized() -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.IS_NORMALIZED })
+ return self
+
+## Ensures that [param data] is orthonormalized.[br]
+## Applies to [Transform2D], [Transform3D] and [Basis].
+func is_orthonormalized() -> CoreValidationSingle:
+ rules.append({ "type": CoreTypes.ValidationType.IS_ORTHONORMALIZED })
+ return self
diff --git a/addons/CORE/src/core.gd b/addons/CORE/src/core.gd
index 4b8e3fc..121f3af 100644
--- a/addons/CORE/src/core.gd
+++ b/addons/CORE/src/core.gd
@@ -24,15 +24,17 @@ class_name Core
# Versioning
## The version number
-const version_version: int = 1
+const version_version: int = 2
## The version type. See [enum CoreTypes.VersionType] for more information.
const version_type: CoreTypes.VersionType = CoreTypes.VersionType.RELEASE
## The version type number. Resets on every new version and version type.
-const version_typerelease: int = 1
+const version_typerelease: int = 0
+## The fork indicator. Update this if you intend on soft or hard forking this framework.
+const version_fork: String = ""
# Modules
## Used internally for loading, managing and unloading modules.
-const modules: Array[String] = [ "logger", "misc", "sms", "logui", "erm", "storage" ]
+const modules: Array[String] = [ "logger", "misc", "sms", "logui", "erm", "storage", "validation" ]
## CORE's configuration object.[br]
## [b]NEVER access this yourself! To change the configuration use [method reload_configuration] instead.[/b]
var config: CoreConfiguration
@@ -52,60 +54,88 @@ var logui: CoreBaseModule
var erm: CoreBaseModule
## The 'Storage' module
var storage: CoreBaseModule
+## The 'Data Validation' module
+var validation: CoreBaseModule
# /etc/
## Stores the path to CORE's installation directory.[br]
## [b]Danger: [i]Don't modify this.[/i][/b]
var basepath: String
-## Contains a list of all loaded custom modules.[br]
-## [b]Danger: [i]Don't modify this.[/i][/b]
+# Contains a list of all registered cleanup hooks.
+var cleanup_hooks: Dictionary = {}
+## Internal, don't modify.
+# Contains a list of all loaded custom modules.
var custom_modules: Dictionary = {}
-## Contains the node holding all custom modules as children.[br]
-## [b]Danger: [i]Don't modify this.[/i][/b]
+## Internal, don't modify.
+# Contains the node holding all custom modules as children.
var custom_modules_node: Node
-## The CORE Object's logger instance.
-## [b]Danger: [i]Don't modify this.[/i][/b]
+## Internal, don't modify.
+# The CORE Object's logger instance.
var loggeri: CoreLoggerInstance
+## Internal, don't modify.
+# Makes CORE inaccessible if true.
+var disabled: bool = false
+## Internal, don't modify.
+# Displays the ✨ special ✨ welcome message if true
+var welcomed: bool = false
+
+## Contains the amount of time it took to preinitialize the framework, measured in milliseconds.[br]
+## Captured in [method _init].[br]
+## [b]Danger: [i]Don't modify this.[/i][/b]
+var initduration_preinitialization: int = 0
+## Contains the amount of time it took to initialize the framework, measured in milliseconds.[br]
+## Captured in [method _ready].[br]
+## [b]Danger: [i]Don't modify this.[/i][/b]
+var initduration_initialization: int = 0
+## Contains the amount of time it took to completely initialize the framework, measured in milliseconds.[br]
+## Captured in [method complete_init].[br]
+## [b]Danger: [i]Don't modify this.[/i][/b]
+var initduration_complete_initialization: int = 0
# +++ initialization +++
-## Handles the preinitialization part. Does stuff like checking the engine version, loading the config and loading all modules into memory.
+# Preinitialization
func _init(new_config: CoreConfiguration = CoreConfiguration.new()) -> void:
+ var inittime: int = Time.get_ticks_msec()
name = "CORE"
if !check_godot_version(): return
- if !determine_basepath(): queue_free()
+ if !determine_basepath():
+ get_tree().quit(70)
+ while true: await get_tree().create_timer(9999).timeout
custom_modules_node = Node.new()
reload_configuration(new_config)
initialize_modules()
apply_configuration()
initialize_scheduler()
+ initduration_preinitialization = Time.get_ticks_msec() - inittime
-## Handles the initialization part. Injects the builtin modules into the SceneTree and makes sure custom modules can be loaded properly.[br]
-## [b]Danger: [i]Don't call this.[/i][/b]
+# Initialization
func _ready() -> void:
+ var inittime: int = Time.get_ticks_msec()
inject_modules()
custom_modules_node.name = "Custom Modules"
add_child(custom_modules_node)
loggeri = logger.get_instance(basepath.replace("res://", "") + "src/core.gd", self)
add_child(scheduler)
get_tree().auto_accept_quit = false
+ initduration_initialization = Time.get_ticks_msec() - inittime
-## Initializes all built-in modules during the preinitialization phase.[br]
-## [b]Danger: [i]Don't call this.[/i][/b]
+# Initializes all built-in modules during the preinitialization phase.
+## Internal, don't call.
func initialize_modules() -> void:
for module in modules:
set(module, CoreBaseModule.new())
get(module).name = module
get(module).set_script(load(basepath + "src/" + module + ".gd"))
get(module).core = self
- get(module).loggeri = logger.get_instance(basepath.replace("res://", "") + "src/" + module + ".gd", get(module))
+ get(module).logger = logger.get_instance(basepath.replace("res://", "") + "src/" + module + ".gd", get(module))
get(module)._initialize()
-## Injects CORE's builtin modules into the SceneTree.[br]
-## [b]Danger: [i]Don't call this.[/i][/b]
+# Injects CORE's builtin modules into the SceneTree.
+## Internal, don't call.
func inject_modules() -> void: for module in modules: add_child(get(module))
-## Initializes the framework scheduler.
-## [b]Danger: [i]Don't call this.[/i][/b]
+# Initializes the framework scheduler.
+## Internal, don't call.
func initialize_scheduler() -> void:
scheduler = Timer.new()
scheduler.name = "Scheduler"
@@ -116,7 +146,9 @@ func initialize_scheduler() -> void:
scheduler.process_mode = Node.PROCESS_MODE_ALWAYS
scheduler.connect("timeout", func() -> void:
loggeri.verb("Running scheduler tasks")
- for module in modules: await get(module)._schedule()
+ var modules_reverse: Array[String] = modules.duplicate()
+ modules_reverse.reverse()
+ for module in modules_reverse: await get(module)._schedule()
for module in custom_modules_node.get_children(): await module._schedule()
)
@@ -124,9 +156,10 @@ func initialize_scheduler() -> void:
## [br]
## This ensures that all modules are fully initialized and ready for usage.[br]
## [i][b]Not calling this function during startup may lead to runtime issues.[/b][/i]
-func complete_init(no_success_message: bool = false) -> void:
- var modsinit_builtin: Array[String] = ["workaround"]
- var modsinit_custom: Array[String] = ["workaround"]
+func complete_init() -> void:
+ var inittime: int = Time.get_ticks_msec()
+ var modsinit_builtin: Array[String] = [ "workaround" ]
+ var modsinit_custom: Array[String] = [ "workaround" ]
while modsinit_builtin.size() != 0 and modsinit_custom.size() != 0:
# Clear arrays
@@ -149,22 +182,45 @@ func complete_init(no_success_message: bool = false) -> void:
# Initialization complete
await get_tree().process_frame
- if !no_success_message: loggeri.info("Initialized CORE successfully")
+ if !welcomed:
+ welcomed = true
+ var version_welcome: String = await get_formatted_string("v%version%-%version_type_technical%%version_typerelease%%version_fork%")
+ if version_welcome.length() > 15:
+ await logger.crash("Invalid version size of 15")
+ elif version_welcome.length() < 15:
+ version_welcome = " ".repeat(15-version_welcome.length()) + version_welcome
+ logger._log(CoreTypes.LoggerLevel.SPECIAL, basepath.replace("res://", "") + "src/core.gd", """_________________________________ __________ %version% ______
+__ ____/_ __ \\__ __ \\__ ____/ ___ ____/____________ _______ ___________ _________________ /__
+_ / _ / / /_ /_/ /_ __/ __ /_ __ ___/ __ `/_ __ `__ \\ _ \\_ | /| / / __ \\_ ___/_ //_/
+/ /___ / /_/ /_ _, _/_ /___ _ __/ _ / / /_/ /_ / / / / / __/_ |/ |/ // /_/ / / _ ,<
+\\____/ \\____/ /_/ |_| /_____/ /_/ /_/ \\__,_/ /_/ /_/ /_/\\___/____/|__/ \\____//_/ /_/|_|
+Copyright (c) 2023-2024 The StarOpenSource Project & Contributors.
+Licensed under the GNU Affero General Public License v3 WITHOUT ANY WARRANTY.
+You should have recieved a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Consider contributing to the CORE Framework to make it even better... and remember: #TransRightsAreHumanRights
+Thank you for using the CORE Framework to develop your application or game!""".replace("%version%", version_welcome))
+ if is_devmode(): loggeri.warn("The CORE Framework is running in development mode.\nThis may cause bugs and issues inside the framework. Here be dragons!")
+ if logger.verbose_mode: loggeri.warn("Godot is running in verbose stdout mode.\nDue to a bug in the engine that prevents displaying truecolor ANSI escape\nsequences CORE changed the color of all diagnostic log messages.\nTo prevent this, set 'logger_detect_verbose_mode' in the configuration to 'false'.")
+
+ initduration_complete_initialization = Time.get_ticks_msec() - inittime
+ loggeri.info("Framework initialization took " + str(initduration_preinitialization + initduration_initialization + initduration_complete_initialization) + "ms (pre " + str(initduration_preinitialization) + "ms, init " + str(initduration_initialization) + "ms, complete " + str(initduration_complete_initialization) + "ms)")
# +++ configuration +++
## Loads a (new) configuration object and applies it to all modules.
func reload_configuration(new_config: CoreConfiguration = CoreConfiguration.new()) -> void:
var initialized = config != null
if initialized: loggeri.verb("Reloading CORE's configuration")
- if config != null: config.queue_free()
+ if is_instance_valid(config): config.free()
config = new_config.duplicate()
if is_devmode(): # Override configuration in development mode
config.logger_level = CoreTypes.LoggerLevel.DIAG
if initialized: loggeri.verb("Overrode configuration (development mode)")
if initialized: apply_configuration()
-## Applies the a configuration.[br]
-## [b]Danger: [i]Don't call this.[/i][/b]
+# Applies a new configuration.
+## Internal, don't call.
func apply_configuration() -> void:
if loggeri != null: loggeri.verb("Applying configuration")
if is_devmode() and loggeri != null: loggeri.warn("The CORE Framework is in development mode. Here be dragons!")
@@ -177,7 +233,7 @@ func apply_configuration() -> void:
custom_modules[module]._pull_config()
# Workaround
- logger.verbose_mode = OS.is_stdout_verbose()
+ if config.logger_detect_verbose_mode: logger.verbose_mode = OS.is_stdout_verbose()
# +++ custom module support +++
## Registers a new custom module.
@@ -187,13 +243,13 @@ func register_custom_module(module_name: String, module_origin: String, module_c
loggeri.error("Registering module failed: Custom module support is disabled.")
return false
if custom_modules.has(module_name):
- loggeri.error("Registering module failed: A custom module with the name \"" + module_name + "\" already exists.")
+ loggeri.error(stringify_variables("Registering module failed: A custom module with the name %name% already exists.", { "name": module_name }))
return false
loggeri.diag("Updating variables")
module_class.name = module_name
module_class.core = self
- module_class.loggeri = logger.get_instance(module_origin, module_class)
- module_class.loggeri.framework = true
+ module_class.logger = logger.get_instance(module_origin, module_class)
+ module_class.logger.framework = true
loggeri.diag("Adding module to SceneTree")
custom_modules_node.add_child(module_class)
loggeri.diag("Merging module with custom_modules")
@@ -208,11 +264,11 @@ func register_custom_module(module_name: String, module_origin: String, module_c
func unregister_custom_module(module_name: String) -> void:
loggeri.verb("Unregistering custom module \"" + module_name + "\"")
if !custom_modules.has(module_name):
- loggeri.error("Unregistering module failed: A custom module with the name \"" + module_name + "\" does not exist.")
+ loggeri.error(stringify_variables("Unregistering module failed: A custom module with the name %name% does not exist.", { "name": module_name }))
return
- var module: Node = get_custom_module(module_name)
+ var module: Node = custom_modules[module_name]
await module._cleanup()
- module.loggeri.queue_free()
+ module.logger.queue_free()
custom_modules_node.remove_child(module)
custom_modules.erase(module_name)
module.queue_free()
@@ -222,27 +278,88 @@ func unregister_custom_module(module_name: String) -> void:
func get_custom_module(module_name: String) -> CoreBaseModule:
loggeri.diag("Getting custom module \"" + module_name + "\"")
if !custom_modules.has(module_name):
- loggeri.error("Getting module failed: A custom module with the name \"" + module_name + "\" does not exist.")
+ loggeri.error(stringify_variables("Getting module failed: A custom module with the name %name% does not exist.", { "name": module_name }))
return null
return custom_modules[module_name]
-# +++ etc ++
+# +++ cleanup ++
## Makes sure that CORE does not leak memory on shutdown/unload.[br]
-## Unloads all custom modules, built-in modules, frees any of CORE's classes and lastly itself.
+## Unloads all custom modules, built-in modules, frees any of CORE's classes and lastly itself.[br]
+## Only call this function if you're sure that your application or game no longer uses the CORE Framework.
func cleanup() -> void:
loggeri.info("Cleaning up")
- for module in custom_modules_node.get_children(): unregister_custom_module(module.name)
+ loggeri.verb("Calling cleanup hooks")
+ for hook in cleanup_hooks:
+ if !cleanup_hooks[hook].is_valid():
+ loggeri.error(stringify_variables("Cleanup hook %id% is invalid", { "id": hook }))
+ else:
+ loggeri.diag("Calling cleanup hook #" + str(hook))
+ await cleanup_hooks[hook].call()
+ await get_tree().process_frame
+ loggeri.verb("Unregistering custom modules")
+ for module in custom_modules_node.get_children(): await unregister_custom_module(module.name)
+ await get_tree().process_frame
+ loggeri.verb("Removing custom module support")
remove_child(custom_modules_node)
custom_modules_node.queue_free()
+ await get_tree().process_frame
+ loggeri.verb("Unloading built-in modules")
var modules_reverse: Array[String] = modules.duplicate()
modules_reverse.reverse()
for module in modules_reverse:
await get(module)._cleanup()
- get(module).loggeri.queue_free()
get(module).queue_free()
- config.queue_free()
+ await get_tree().process_frame
+ print("Freeing configuration")
+ config.free()
+ print("Freeing")
queue_free()
+# Generates a new cleanup hook id
+## Internal, don't call.
+func _generate_hook_id() -> int:
+ var id = randi()
+ if cleanup_hooks.has(id):
+ loggeri.warn(stringify_variables("New cleanup hook id %id% is already taken", { "id": id }))
+ return _generate_hook_id()
+ elif id == -1:
+ loggeri.warn(stringify_variables("Invalid cleanup hook id %id%", { "id": id }))
+ return _generate_hook_id()
+ return id
+
+## Registers a new cleanup hook.[br]
+## Returns the hook id.
+func register_cleanup_hook(callable: Callable) -> int:
+ if !callable.is_valid():
+ loggeri.error("Could not add cleanup hook: Callable is not valid")
+ return -1
+
+ var id: int = _generate_hook_id()
+ loggeri.verb("Adding new cleanup hook #" + str(id))
+ cleanup_hooks.merge({ id: callable })
+ return id
+
+## Unregisters a cleanup hook by it's id.
+func unregister_cleanup_hook_by_id(id: int) -> bool:
+ if !cleanup_hooks.has(id):
+ loggeri.error(stringify_variables("Could not remove cleanup hook (id): Hook %id% does not exist", { "id": id }))
+ return false
+ loggeri.verb(stringify_variables("Removed cleanup hook %id%", { "id": id }))
+ cleanup_hooks.erase(id)
+ return true
+
+## Unregisters a cleanup hook by it's reference.
+func unregister_cleanup_hook_by_ref(callable: Callable) -> bool:
+ var id: Variant = cleanup_hooks.find_key(callable)
+ if id == null:
+ loggeri.error("Could not remove cleanup hook (ref): Could not find a matching hook")
+ return false
+ if typeof(id) != TYPE_INT: await loggeri.crash(stringify_variables("Could not remove cleanup hook (ref): find_key did not return an integer (returned %id%)", { "id": id }))
+ cleanup_hooks.erase(id)
+ loggeri.verb(stringify_variables("Removed cleanup hook %id% (ref)", { "id": id }))
+ return true
+
+# +++ etc +++
## Returns if the framework is in development mode.
func is_devmode() -> bool:
return config.development
@@ -261,6 +378,7 @@ func get_formatted_string(string: String) -> String:
# Version strings
string = string.replace("%version%", str(version_version))
string = string.replace("%version_typerelease%", str(version_typerelease))
+ string = string.replace("%version_fork%", "" if version_fork == "" else "-" + version_fork)
var semantic_version: Array[int] = get_version_semantic()
string = string.replace("%version_semantic%", str(semantic_version[0]) + "." + str(semantic_version[1]) + "." + str(semantic_version[2]))
match(version_type):
@@ -299,8 +417,7 @@ func get_version_semantic() -> Array[int]:
CoreTypes.VersionType.ALPHA: version_type_int = 0
return [version_version, version_type_int, version_typerelease]
-## Determines CORE's installation/base path.[br]
-## [b]Danger: [i]Don't call this.[/i][/b]
+# Determines CORE's installation/base path.
func determine_basepath() -> bool:
if FileAccess.file_exists("res://.corebasepath"):
basepath = "res://"
@@ -309,18 +426,17 @@ func determine_basepath() -> bool:
elif FileAccess.file_exists("res://addons/CORE/.corebasepath"):
basepath = "res://addons/CORE/"
else:
- assert(false, "CORE is not located at 'res://CORE/', aborting initialization")
+ printerr("CORE is not located at 'res://CORE/' or 'res://addons/CORE', aborting initialization.")
return false
return true
-# Checks Godot's version
## Checks compatibility with the running version.
func check_godot_version() -> bool:
var version: Dictionary = Engine.get_version_info()
match(version["major"]):
4: pass
_:
- printerr("The CORE Framework does not support Godot versions older or newer than 4.x.x")
+ printerr("The CORE Framework does not support Godot versions older or newer than 4.x.x.")
return false
match(version["minor"]):
0: printerr("The CORE Framework does not support Godot versions older than 4.2.x. Please update to Godot 4.2.x to ensure full compatibility.")
@@ -330,7 +446,7 @@ func check_godot_version() -> bool:
printerr("The CORE Framework does not support Godot versions newer than 4.2.x. Please downgrade to Godot 4.2.x.")
return false
if version["status"] != "stable":
- printerr("The CORE Framework does not support unstable Godot versions. Please switch to Godot stable 4.2.x.")
+ printerr("The CORE Framework does not support unstable Godot versions. Please switch to Godot 4.2.x.stable to ensure full compatibility.")
return false
return true
@@ -339,12 +455,35 @@ func check_godot_version() -> bool:
## [b]Note: [i]Using the [code]await[/code] keyword is required for this function.[/i][/b]
func quit_safely(exitcode: int = 0) -> void:
loggeri.info("Shutting down (code " + str(exitcode) + ")")
+ if config.hide_window_on_shutdown:
+ loggeri.verb("Hiding window")
+ Engine.max_fps = -1 # a higher framerate seems to make the shutdown process muuuuch faster
+ DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ENABLED) # we don't want to cook the cpu tho
+ DisplayServer.window_set_exclusive(0, false)
+ DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
+ DisplayServer.window_set_min_size(Vector2.ZERO)
+ DisplayServer.window_set_size(Vector2i.ZERO)
+ DisplayServer.window_set_max_size(Vector2.ZERO)
+ DisplayServer.window_set_position(Vector2i(9999999, 9999999))
+ DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
+ DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_ALWAYS_ON_TOP, false)
+ DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_MOUSE_PASSTHROUGH, false)
+ DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_NO_FOCUS, true)
+ DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_RESIZE_DISABLED, true)
await get_tree().create_timer(0.25).timeout
await cleanup()
get_tree().quit(exitcode)
+## Internal, don't call
+# Makes misc.stringify_variables() calls shorter
+func stringify_variables(template: String, arguments: Dictionary, no_quotes: bool = false) -> String:
+ return misc.stringify_variables(template, arguments, no_quotes, true)
+
+## Internal, don't call.
+# Just ignore this.
func _notification(what) -> void:
match(what):
NOTIFICATION_WM_CLOSE_REQUEST:
if config.automatic_shutdown:
+ loggeri.diag("Got close request, shutting down")
await quit_safely(0)
diff --git a/addons/CORE/src/erm.gd b/addons/CORE/src/erm.gd
index a242139..e24466a 100644
--- a/addons/CORE/src/erm.gd
+++ b/addons/CORE/src/erm.gd
@@ -18,14 +18,11 @@
## Allows for awaited, batched and oneline requests.
extends CoreBaseModule
-## Contains a list of all queued downloads.[br]
-## [b]Danger: [i]Don't modify this[/i][/b].
+# Contains a list of all queued downloads.
var list_queue: Dictionary = {}
-## Contains a list of all active downloads.[br]
-## [b]Danger: [i]Don't modify this[/i][/b].
+# Contains a list of all active downloads.
var list_active: Dictionary = {}
-## Contains a liust of all completed downloads.[br]
-## [b]Danger: [i]Don't modify this[/i][/b].
+# Contains a liust of all completed downloads.
var list_complete: Dictionary = {}
## Determines how unsecure requests should be handled.
@@ -57,11 +54,12 @@ func _cleanup() -> void:
## [/codeblock]
## [b]Note: [i]Using the [code]await[/code] keyword is required for this function.[/i][/b]
func awaited_request(url: String, parse_utf8: bool, method: HTTPClient.Method = HTTPClient.Method.METHOD_GET, headers: PackedStringArray = PackedStringArray([]), data: String = "") -> Dictionary:
- loggeri.verb("Creating awaited request")
+ logger.verb("Creating awaited request")
if !await is_url_allowed(url): return {}
var id: int = create_request(url, method, headers, data)
start_request(id, parse_utf8)
- loggeri.diag("Waiting for request " + str(id) + " to finish")
+ logger.diag("Waiting for request " + str(id) + " to finish")
+ logger.diag(core.stringify_variables("Waiting for request %id% to finish", { "id": id }))
while !is_request_completed(id): await get_tree().create_timer(0.1, true).timeout
var dldata: Dictionary = list_complete[id]
list_complete.erase(id)
@@ -96,7 +94,7 @@ func oneline_awaited_request(url: String, return_utf8: bool = true, ignore_http_
## [/codeblock]
## [b]Note: [i]Using the [code]await[/code] keyword is required for this function.[/i][/b]
func batch_awaited_request(urls: PackedStringArray, parse_utf8: bool, method: HTTPClient.Method = HTTPClient.Method.METHOD_GET, headers: PackedStringArray = PackedStringArray([]), data: String = "") -> Array[Dictionary]:
- loggeri.verb("Creating " + str(urls.size()) + " awaited request(s)")
+ logger.verb("Creating " + str(urls.size()) + " awaited request(s)")
var dldata: Array[Dictionary] = []
for url in urls:
if !await is_url_allowed(url): continue
@@ -107,29 +105,28 @@ func batch_awaited_request(urls: PackedStringArray, parse_utf8: bool, method: HT
list_complete.erase(id)
return dldata
-## Internal function, do not call
+# Does the work, but in a thread.
func _batch_awaited_request(url: String, parse_utf8: bool, method: HTTPClient.Method = HTTPClient.Method.METHOD_GET, headers: PackedStringArray = PackedStringArray([]), data: String = "") -> int:
var id: int = create_request(url, method, headers, data)
start_request(id, parse_utf8)
- loggeri.diag("Waiting for request " + str(id) + " to finish")
+ logger.diag(core.stringify_variables("Waiting for request %id% to finish", { "id": id }))
while !is_request_completed(id): await get_tree().create_timer(0.1, true).timeout
return id
# +++ internal +++
-## Returns a new download id.[br]
-## [b]Danger: [i]Don't call this.[/i][/b]
+# Returns a new download id.
func generate_id() -> int:
var id = randi()
if list_queue.has(id) or list_active.has(id):
- loggeri.warn("New download id '" + str(id) + "' is already taken")
+ logger.warn(core.stringify_variables("New download id %id% already taken", { "id": id }))
return generate_id()
- loggeri.diag("Generated new download id " + str(id))
+ logger.diag(core.stringify_variables("Generated new download id %id%", { "id": id }))
return id
## Creates a new request and stores it in the queue. Returns the download id.[br]
## [b]Warning: [i]You'll probably not need this. Only use this function when implementing your own downloading method.[/i][/b]
func create_request(url: String, method: HTTPClient.Method = HTTPClient.Method.METHOD_GET, headers: PackedStringArray = PackedStringArray([]), body: String = "") -> int:
- loggeri.verb("Creating new request\n-> URL: " + url + "\n-> Method: " + str(method) + "\nHeaders: " + str(headers.size()) + "\nBody size: " + str(body.length()) + " Characters")
+ logger.verb("Creating new request\n-> URL: " + url + "\n-> Method: " + str(method) + "\nHeaders: " + str(headers.size()) + "\nBody size: " + str(body.length()) + " Characters")
var id = generate_id()
list_queue.merge({ id: { "url": url, "method": method, "headers": headers, "body": body } })
return id
@@ -138,16 +135,17 @@ func create_request(url: String, method: HTTPClient.Method = HTTPClient.Method.M
## [b]Note: [i]Using the [code]await[/code] keyword is required for this function.[/i][/b][br]
## [b]Warning: [i]You'll probably not need this. Only use this function when implementing your own downloading method.[/i][/b]
func start_request(id: int, parse_utf8: bool) -> void:
- loggeri.verb("Starting request " + str(id))
+ logger.verb("Starting request " + str(id))
+ logger.verb(core.stringify_variables("Starting request %id%", { "id": id }))
list_active.merge({ id: list_queue[id] })
list_queue.erase(id)
- loggeri.diag("Creating new HTTPRequest \"Request #" + str(id) + "\"")
+ logger.diag("Creating new HTTPRequest \"Request #" + str(id) + "\"")
var download: HTTPRequest = HTTPRequest.new()
download.name = "Request #" + str(id)
download.accept_gzip = true
download.use_threads = true
var lambda: Callable = func(result: int, http_code: int, headers: PackedStringArray, body: PackedByteArray, httprequest: HTTPRequest) -> void:
- loggeri.verb("Request " + str(id) + " completed\nResult: " + str(result) + "\nHTTP response code: " + str(http_code) + "\nHeaders: " + str(headers.size()) + "\nBody size: " + str(body.size()) + " Bytes // " + str(core.misc.byte2mib(body.size(), true)) + " MiB\nParse body as UTF-8: " + str(parse_utf8))
+ logger.verb(core.stringify_variables("Request %id% completed\nResult: %result%\nHTTP response code: %http_code%\nHeaders: %headers%\nBody size: %body_size_bytes% Bytes // %body_size_mib% MiB\nParsed body as UTF-8: %parse_utf8%", { "result": result, "http_code": http_code, "headers": headers.size(), "body_size_bytes": body.size(), "body_size_mib": core.misc.byte2mib(body.size(), true), "parse_utf8": parse_utf8 }, true))
list_complete.merge({ id: { "result": result, "http_code": http_code, "headers": headers, "body": body, "body_utf8": body.get_string_from_utf8() if parse_utf8 else "" } })
list_active.erase(id)
remove_child(httprequest)
@@ -161,14 +159,14 @@ func is_url_allowed(url: String) -> bool:
if url.begins_with("http://"):
match(config_unsecure_requests):
CoreTypes.BlockadeLevel.BLOCK:
- loggeri.error("Blocked unsecure url '" + url + "'")
+ logger.error(core.stringify_variables("Blocked unsecure url %url%", { "url": url }))
return false
- CoreTypes.BlockadeLevel.WARN: loggeri.warn("Requesting unsecure url '" + url + "'")
+ CoreTypes.BlockadeLevel.WARN: logger.warn(core.stringify_variables("Requesting unsecure url %url%", { "url": url }))
CoreTypes.BlockadeLevel.IGNORE: pass
- _: await loggeri.crash("Invalid BlockadeLevel '" + str(config_unsecure_requests) + "'")
+ _: await logger.crash(core.stringify_variables("Invalid BlockadeLevel %level%", { "level": config_unsecure_requests }))
elif url.begins_with("https://"): pass
else:
- loggeri.error("Invalid url '" + url + "'")
+ logger.error(core.stringify_variables("Invalid url %url%", { "url": url }))
return false
return true
@@ -177,10 +175,10 @@ func is_request_completed(id: int) -> bool: return list_complete.has(id)
## Cleans the request queue.
func clean_queue() -> void:
- loggeri.verb("Cleaning request queue")
+ logger.verb("Cleaning request queue")
list_queue.clear()
## Cleans the completed requests list.
func clean_completed() -> void:
- loggeri.verb("Cleaning completed requests")
+ logger.verb("Cleaning completed requests")
list_complete.clear()
diff --git a/addons/CORE/src/logger.gd b/addons/CORE/src/logger.gd
index e0ad18e..c2deee7 100644
--- a/addons/CORE/src/logger.gd
+++ b/addons/CORE/src/logger.gd
@@ -23,9 +23,7 @@ extends CoreBaseModule
signal log_event
-## Keeps track of all logger instances.
-## Unused instances will be cleaned periodically.
-## [b]Danger: [i]Don't modify this.[/i][/b]
+# Keeps track of all logger instances. Unused instances will be cleaned periodically by CORE's scheduler.
var instances: Array[CoreLoggerInstance] = []
## Used to determine if running in verbose/command line mode.[br]
@@ -46,16 +44,23 @@ var config_newlines_sizelimit: int
# +++ module +++
func _cleanup() -> void:
- _schedule()
- await get_tree().process_frame
+ for instance in instances:
+ if is_instance_valid(instance):
+ logger.diag("Removing instance '" + instance.name + "'")
+ instance.queue_free()
func _schedule() -> void:
+ var instances_remove_enty: Array[CoreLoggerInstance] = []
for instance in instances:
if !is_instance_valid(instance): continue
if !is_instance_valid(instance.parent):
- loggeri.diag("Removing instance '" + instance.name + "'")
+ logger.diag("Removing instance '" + instance.name + "'")
instance.queue_free()
- instances.remove_at(instances.find(instance))
+ instances_remove_enty.append(instance)
+ for instance in instances_remove_enty:
+ var index: int = instances.find(instance)
+ if index == -1: logger.error("Invalid index -1")
+ else: instances.remove_at(index)
func _pull_config() -> void:
config_level = core.config.logger_level
@@ -65,8 +70,7 @@ func _pull_config() -> void:
config_newlines_sizelimit = core.config.logger_newlines_sizelimit
# +++ logging +++
-## The main logging function that does the heavy lifting.[br]
-## [b]Danger: [i]Don't call this.[/i][/b]
+# The main logging function that does the heavy lifting.
func _log(level: CoreTypes.LoggerLevel, origin: String, message: String) -> void:
if !is_level_allowed(level):
emit_signal("log_event", false, level, origin, message, "")
@@ -99,6 +103,10 @@ func _log(level: CoreTypes.LoggerLevel, origin: String, message: String) -> void
format = format.replace("%level%", "ERR!")
format_newline = format_newline.replace("%level%", "ERR!")
format = format.replace("%color%", "[color=red]")
+ CoreTypes.LoggerLevel.SPECIAL:
+ format = format.replace("%level%", "UWU!")
+ format_newline = format_newline.replace("%level%", "HI:3")
+ format = format.replace("%color%", "[color=purple]")
CoreTypes.LoggerLevel.NONE:
format = format.replace("%level%", "CRSH")
format_newline = format_newline.replace("%level%", "CRSH")
diff --git a/addons/CORE/src/logui.gd b/addons/CORE/src/logui.gd
index 16ad723..f375794 100644
--- a/addons/CORE/src/logui.gd
+++ b/addons/CORE/src/logui.gd
@@ -28,12 +28,21 @@ var logrtl: RichTextLabel
var font_normal: Font
var font_bold: Font
+# Configuration
+var config_max_lines: int
+
# +++ module +++
func _pull_config() -> void:
- background.visible = !core.config.headless and core.config.logui_enabled
+ if !core.config.logui_enabled:
+ background.visible = false
+ logrtl.text = ""
+ else:
+ background.visible = !core.config.headless
background.color = core.config.logui_background_color
logrtl.add_theme_font_size_override("normal_font_size", core.config.logui_font_size)
logrtl.add_theme_font_size_override("bold_font_size", core.config.logui_font_size)
+
+ config_max_lines = core.config.logui_max_lines
func _cleanup() -> void:
background.remove_child(logrtl)
@@ -90,7 +99,9 @@ func _ready() -> void:
vsbar.add_theme_stylebox_override("grabber_pressed", StyleBoxEmpty.new())
# Connect log_event
- logger.connect("log_event", func(allowed: bool, _level: CoreTypes.LoggerLevel, _origin: String, _message: String, format: String) -> void: if allowed: logrtl.text = logrtl.text + format + "\n")
+ core.logger.connect("log_event", func(allowed: bool, _level: CoreTypes.LoggerLevel, _origin: String, _message: String, format: String) -> void:
+ if allowed and core.config.logui_enabled: logrtl.text = logrtl.text + format + "\n"
+ )
# +++ process +++
func _process(_delta: float) -> void:
@@ -98,3 +109,17 @@ func _process(_delta: float) -> void:
var window_size: Vector2i = DisplayServer.window_get_size()
background.size = window_size
logrtl.size = window_size
+
+ var stripped_text: String = ""
+ if logrtl.text.count("\n") > config_max_lines:
+ var lines: PackedStringArray = logrtl.text.split("\n")
+
+ var index: int = 0
+ for line in lines:
+ if index >= lines.size()-config_max_lines:
+ if stripped_text == "": stripped_text = line
+ else: stripped_text += "\n" + line
+
+ index += 1
+
+ logrtl.text = stripped_text
diff --git a/addons/CORE/src/misc.gd b/addons/CORE/src/misc.gd
index dc9e120..57c107f 100644
--- a/addons/CORE/src/misc.gd
+++ b/addons/CORE/src/misc.gd
@@ -21,35 +21,48 @@
## and generally make your life as a developer easier.
extends CoreBaseModule
+# Configuration
+var config_stringify_show_type: bool
+var config_stringify_color_range8: bool
+var config_stringify_array: bool
+var config_stringify_dictionary: bool
+
+# +++ module +++
+func _pull_config() -> void:
+ config_stringify_show_type = core.config.misc_stringify_show_type
+ config_stringify_color_range8 = core.config.misc_stringify_color_range8
+ config_stringify_array = core.config.misc_stringify_array
+ config_stringify_dictionary = core.config.misc_stringify_dictionary
+
# +++ data type conversion +++
## Converts a number of bytes into mebibytes.[br]
## [br]
## If [code]flatten[/code] is set to [code]true[/code], the decimal part will be discarded.
@warning_ignore("integer_division")
func byte2mib(bytes: int, flatten: bool = true) -> float:
- if flatten: return bytes/1048576
- return bytes/float(1048576)
+ if flatten: return float(int(float(bytes)/1048576.0))
+ return float(bytes)/1048576.0
## Converts a number of mebibytes into bytes.[br]
## [br]
## If [code]flatten[/code] is set to [code]true[/code], the decimal part will be discarded.
func mib2byte(mib: float, flatten: bool = true) -> float:
- if flatten: return int(mib*1048576)
- return mib*1048576
+ if flatten: return float(int(mib*1048576.0))
+ return mib*1048576.0
## Converts a number of mebibytes into gibibytes.[br]
## [br]
## If [code]flatten[/code] is set to [code]true[/code], the decimal part will be discarded.
func mib2gib(mib: float, flatten: bool = true) -> float:
- if flatten: return int(mib/1024)
- return mib/1024
+ if flatten: return float(int(mib/1024.0))
+ return mib/1024.0
## Converts a number of gebibytes into mebibytes.[br]
## [br]
## If [code]flatten[/code] is set to [code]true[/code], the decimal part will be discarded.
func gib2mib(gib: float, flatten: bool = true) -> float:
- if flatten: return int(gib*1024)
- return gib*1024
+ if flatten: return float(int(gib*1024.0))
+ return gib*1024.0
# +++ type formatting +++
## Converts a string array into a normal, nicely formatted string.[br]
@@ -72,11 +85,11 @@ func format_stringarray(array: Array[String], item_before: String = "", item_aft
var output: String = ""
if array.size() == 0:
- loggeri.warn("Unable to format a string with a size of 0")
+ logger.warn("Unable to format a string with a size of 0")
return ""
elif array.size() == 1:
- loggeri.warn("Unable to format a string with a size of 1")
- return array[0]
+ logger.warn("Unable to format a string with a size of 1")
+ return item_before + array[0] + item_after
for item in array:
if output == "": output = item_before + item + item_after
@@ -94,7 +107,7 @@ func array_to_stringarray(array: Array) -> Array[String]:
for item in array:
if typeof(item) != TYPE_STRING:
- logger.error("Cannot convert Array to Array[String]: Item '" + str(item) + "' is not of type String")
+ logger.error(core.stringify_string("Cannot convert Array to Array[String]: Item %item% is not of type String", { "item": item }))
return []
output.append(item)
@@ -125,6 +138,166 @@ func stringarray_to_array(array: Array[String]) -> Array:
func get_center(parent_size: Vector2, child_size: Vector2) -> Vector2:
return Vector2(parent_size.x/2-child_size.x/2, parent_size.y/2-child_size.y/2)
+## Makes variables as look correct inside strings.[br]
+## Short examples:[br]
+## [code]true[/code] -> [code]'true'[/code][br]
+## [code]Vector2(69.064, PI)[/code] -> [code]'x=69.064 y=3.14159265358979'[/code][br]
+## [code]"This is a test string"[/code] -> [code]'"This is a test string"'[/code][br]
+## Full example:[br]
+## [codeblock]
+## Code:
+## logger.diag(stringify_variables("Triggered %trigger% (pos=%position%, successful=%success%)", { "trigger": "shoot", "position": Vector2(5156.149, 581.69), "success": true }))
+##
+## Output:
+## [16:44:35] [DIAG Test.gd] Triggered '"shoot"' (pos='x=5156.149 y=581.69', successful='true')
+## [/codeblock]
+func stringify_variables(template: String, variables: Dictionary, no_quotes: bool = false, force_no_type: bool = false) -> String:
+ # To decrease allocations
+ var value
+ var type: String = ""
+ var replacement: String = ""
+ for placeholder in variables:
+ # Check key type
+ if typeof(placeholder) != TYPE_STRING:
+ logger.error("Invalid placeholder type '\"" + type_string(typeof(placeholder)) + "\", skipping")
+ continue
+
+ # Check for correct type
+ value = variables[placeholder]
+
+ match(typeof(value)):
+ # Primitives
+ Variant.Type.TYPE_NIL:
+ replacement = "null"
+ type = ""
+ Variant.Type.TYPE_BOOL:
+ replacement = str(value)
+ type = "bool"
+ Variant.Type.TYPE_INT:
+ replacement = str(value)
+ type = "int"
+ Variant.Type.TYPE_FLOAT:
+ replacement = str(value)
+ type = "float"
+ Variant.Type.TYPE_STRING:
+ replacement = "\"" + value + "\""
+ type = "String"
+ Variant.Type.TYPE_STRING_NAME:
+ replacement = "\"" + value + "\""
+ type = "StringName"
+ # Non-primitives
+ Variant.Type.TYPE_OBJECT:
+ replacement = str(value)
+ type = "Object"
+ Variant.Type.TYPE_COLOR:
+ if config_stringify_color_range8: replacement = "r=" + _sa(value.r8) + " g=" + _sa(value.g8) + " b=" + _sa(value.b8) + " a=" + _sa(value.a8)
+ else: replacement = "r=" + _sa(value.r) + " g=" + _sa(value.g) + " b=" + _sa(value.b) + " a=" + _sa(value.a)
+ type = "Color"
+ Variant.Type.TYPE_RID:
+ replacement = "id=" + _sa(value.get_id()) + " valid=" + _sa(value.is_valid())
+ type = "RID"
+ Variant.Type.TYPE_ARRAY:
+ if config_stringify_array:
+ if value.size() == 0:
+ replacement = "[]"
+ else:
+ replacement = "[ "
+ for item in value:
+ if replacement == "[ ": replacement += _sa(item)
+ else: replacement += ", " + _sa(item)
+ replacement += " ]"
+ else: replacement = str(value)
+ if value.get_typed_builtin() != TYPE_NIL:
+ type = "Array[" + type_string(typeof(value.get_typed_builtin())) + "]"
+ else:
+ type = "Array"
+ Variant.Type.TYPE_DICTIONARY:
+ if config_stringify_dictionary:
+ if value.size() == 0: replacement = "{}"
+ else:
+ replacement = "{ "
+ for key in value:
+ if replacement == "{ ": replacement += _sa(key) + ": " + _sa(value[key])
+ else: replacement += ", " + _sa(key)
+ replacement += " }"
+ else: replacement = str(value)
+ type = "Dictionary"
+ # TODO: Packed Arrays
+ # Nodes & scripting
+ Variant.Type.TYPE_NODE_PATH:
+ replacement = str(value)
+ type = "NodePath"
+ Variant.Type.TYPE_CALLABLE:
+ replacement = "valid=" + _sa(value.is_valid()) + " standard=" + _sa(value.is_standard()) + " object=" + _sa(value.get_object() ) + " method=" + value.get_method() + " args=" + _sa(value.get_bound_arguments())
+ type = "Callable"
+ Variant.Type.TYPE_SIGNAL:
+ replacement = "name=" + _sa(value.get_name()) + " object=" + _sa(value.get_object())
+ type = "Signal"
+ # 2D
+ Variant.Type.TYPE_VECTOR2:
+ replacement = "x=" + _sa(value.x) + " y=" + _sa(value.y)
+ type = "Vector2"
+ Variant.Type.TYPE_VECTOR2I:
+ replacement = "x=" + _sa(value.x) + " y=" + _sa(value.y)
+ type = "Vector2i"
+ Variant.Type.TYPE_RECT2:
+ replacement = "size=" + _sa(value.size) + " pos=" + _sa(value.position) + " end=" + _sa(value.end)
+ type = "Rect2"
+ Variant.Type.TYPE_RECT2I:
+ replacement = "size=" + _sa(value.size) + " pos=" + _sa(value.position) + " end=" + _sa(value.end)
+ type = "Rect2i"
+ Variant.Type.TYPE_TRANSFORM2D:
+ replacement = "x=" + _sa(value.x) + " y=" + _sa(value.y) + " origin=" + _sa(value.origin)
+ type = "Transform2D"
+ # 3D
+ Variant.Type.TYPE_VECTOR3:
+ replacement = "x=" + _sa(value.x) + " y=" + _sa(value.y) + " z=" + _sa(value.z)
+ type = "Vector3"
+ Variant.Type.TYPE_VECTOR3I:
+ replacement = "x=" + _sa(value.x) + " y=" + _sa(value.y) + " z=" + _sa(value.z)
+ type = "Vector3i"
+ Variant.Type.TYPE_PLANE:
+ replacement = "x=" + _sa(value.x) + " y=" + _sa(value.y) + " z=" + _sa(value.z) + " d=" + _sa(value.d) + " normal=" + _sa(value.normal)
+ type = "Plane"
+ Variant.Type.TYPE_QUATERNION:
+ replacement = "x=" + _sa(value.x) + " y=" + _sa(value.y) + " z=" + _sa(value.z) + " w=" + _sa(value.w)
+ type = "Quaternion"
+ Variant.Type.TYPE_AABB:
+ replacement = "size=" + _sa(value.size) + " pos=" + _sa(value.position) + " end=" + _sa(value.end)
+ type = "AABB"
+ Variant.Type.TYPE_TRANSFORM3D:
+ replacement = "basis=" + _sa(value.basis) + " origin=" + _sa(value.origin)
+ type = "Transform3D"
+ Variant.Type.TYPE_BASIS:
+ replacement = "x=" + _sa(value.x) + " y=" + _sa(value.y) + " z=" + _sa(value.z)
+ type = "Basis"
+ Variant.Type.TYPE_PROJECTION:
+ replacement = "x=" + _sa(value.x) + " y=" + _sa(value.y) + " z=" + _sa(value.z) + " w=" + _sa(value.w)
+ type = "Projection"
+ # 4D
+ Variant.Type.TYPE_VECTOR4:
+ replacement = "x=" + _sa(value.x) + " y=" + _sa(value.y) + " z=" + _sa(value.z) + " w=" + _sa(value.w)
+ type = "Vector4"
+ Variant.Type.TYPE_VECTOR4I:
+ replacement = "x=" + _sa(value.x) + " y=" + _sa(value.y) + " z=" + _sa(value.z) + " w=" + _sa(value.w)
+ type = "Vector4i"
+ _:
+ replacement = str(value)
+ type = "unknown"
+
+ # Replace
+ if config_stringify_show_type and !force_no_type:
+ if type != "": type = "(" + type.to_lower() + ") "
+ else:
+ type = ""
+ var quote: String = "'" if !no_quotes else ""
+ template = template.replace("%" + placeholder + "%", quote + type + replacement + quote)
+ return template
+
+# Makes internal calls shorter
+func _sa(value) -> String:
+ return stringify_variables("%var%", { "var": value }, true)
+
## Moved to [method Core.quit_safely].
## @deprecated
func quit_safely(exitcode: int = 0) -> void: await core.quit_safely(exitcode)
diff --git a/addons/CORE/src/sms.gd b/addons/CORE/src/sms.gd
index 26138b5..9ec644f 100644
--- a/addons/CORE/src/sms.gd
+++ b/addons/CORE/src/sms.gd
@@ -22,24 +22,14 @@ extends CoreBaseModule
## Used internally for adding, managing and removing scene collections.
const scene_nodes: Array[String] = [ "debug", "cutscene", "menu", "main", "background" ]
-## The 'debug' scene collection.[br]
-## [b]Danger: [i]Don't modify this.[/i][/b]
+# Scene collections
var scenes_debug: Node = Node.new()
-## The 'cutscene' scene collection.[br]
-## [b]Danger: [i]Don't modify this.[/i][/b]
var scenes_cutscene: Node = Node.new()
-## The 'menu' scene collection.[br]
-## [b]Danger: [i]Don't modify this.[/i][/b]
var scenes_menu: Node = Node.new()
-## The 'main' scene collection.[br]
-## [b]Danger: [i]Don't modify this.[/i][/b]
var scenes_main: Node = Node.new()
-## The 'background' scene collection.[br]
-## [b]Danger: [i]Don't modify this.[/i][/b]
var scenes_background: Node = Node.new()
-## A list of all loaded scenes[br]
-## [b]Danger: [i]Don't modify this.[/i][/b]
+# A list of all loaded scenes.
var scenes: Dictionary = {}
# +++ module +++
@@ -59,114 +49,114 @@ func _cleanup() -> void:
func _pull_config() -> void:
if core.config.headless:
# Remove all scenes
- if is_inside_tree(): loggeri.verb("Removing all scenes (triggered by headless mode)")
+ if is_inside_tree(): logger.verb("Removing all scenes (triggered by headless mode)")
for scene in scenes: remove_scene(scene, true)
# +++ scene management +++
## Adds a scene to some scene collection.
-func add_scene(sname: String, sclass: Node, type: CoreTypes.SceneType) -> bool:
+func add_scene(scene_name: String, scene_class: Node, scene_type: CoreTypes.SceneType) -> bool:
if core.config.headless: return false
- loggeri.verb("Adding scene \"" + sname + "\" of type " + str(type))
- if exists(sname) != CoreTypes.SceneType.NONE:
- loggeri.error("Scene with name \"" + sname + "\" already exists")
+ logger.verb(core.stringify_variables("Adding scene %name% of type %type%", { "name": scene_name, "type": scene_type }))
+ if exists(scene_name) != CoreTypes.SceneType.NONE:
+ logger.error(core.stringify_variables("A scene named %name% already exists", { "name": scene_name }))
return false
- if typeof(sclass) != TYPE_OBJECT or !sclass.is_class("Node"):
- loggeri.error("Scene class \"" + sname + "\" is not of type Node")
+ if typeof(scene_class) != TYPE_OBJECT or !scene_class.is_class("Node"):
+ logger.error(core.stringify_variables("Scene class %name% is not of type Node", { "name": scene_name }))
return false
- sclass.name = sname
- match(type):
- CoreTypes.SceneType.DEBUG: scenes_debug.add_child(sclass)
- CoreTypes.SceneType.CUTSCENE: scenes_cutscene.add_child(sclass)
- CoreTypes.SceneType.MENU: scenes_menu.add_child(sclass)
- CoreTypes.SceneType.MAIN: scenes_main.add_child(sclass)
- CoreTypes.SceneType.BACKGROUND: scenes_background.add_child(sclass)
+ scene_class.name = scene_name
+ match(scene_type):
+ CoreTypes.SceneType.DEBUG: scenes_debug.add_child(scene_class)
+ CoreTypes.SceneType.CUTSCENE: scenes_cutscene.add_child(scene_class)
+ CoreTypes.SceneType.MENU: scenes_menu.add_child(scene_class)
+ CoreTypes.SceneType.MAIN: scenes_main.add_child(scene_class)
+ CoreTypes.SceneType.BACKGROUND: scenes_background.add_child(scene_class)
CoreTypes.SceneType.NONE:
- loggeri.error("CoreTypes.SceneType.NONE is not a valid scene type")
+ logger.error("CoreTypes.SceneType.NONE is not a valid scene type")
return false
- _: await loggeri.crash("Invalid SceneType " + str(type))
- scenes.merge({ sname: { "type": type, "class": sclass } })
+ _: await logger.crash(core.stringify_variables("Invalid SceneType %type%", { "type": scene_type }))
+ scenes.merge({ scene_name: { "type": scene_type, "class": scene_class } })
return true
## Removes a scene from some scene collection.[br]
## [b]Danger: [i]Don't set [code]force_remove[/code] to [code]true[/code], thanks![/i][/b]
-func remove_scene(sname: String, force_remove: bool = false) -> bool:
+func remove_scene(scene_name: String, force_remove: bool = false) -> bool:
if core.config.headless and !force_remove: return false
- if force_remove: await loggeri.crash("force_remove = true is not allowed")
- loggeri.verb("Removing scene \"" + sname + "\"")
- match(exists(sname)):
+ if force_remove: await logger.crash("force_remove is not allowed to be true")
+ logger.verb(core.stringify_variables("Removing scene %name%", { "name": scene_name }))
+ match(exists(scene_name)):
CoreTypes.SceneType.DEBUG:
- scenes_debug.remove_child(scenes[sname]["class"])
- scenes[sname]["class"].queue_free()
+ scenes_debug.remove_child(scenes[scene_name]["class"])
+ scenes[scene_name]["class"].queue_free()
CoreTypes.SceneType.CUTSCENE:
- scenes_cutscene.remove_child(scenes[sname]["class"])
- scenes[sname]["class"].queue_free()
+ scenes_cutscene.remove_child(scenes[scene_name]["class"])
+ scenes[scene_name]["class"].queue_free()
CoreTypes.SceneType.MENU:
- scenes_menu.remove_child(scenes[sname]["class"])
- scenes[sname]["class"].queue_free()
+ scenes_menu.remove_child(scenes[scene_name]["class"])
+ scenes[scene_name]["class"].queue_free()
CoreTypes.SceneType.MAIN:
- scenes_main.remove_child(scenes[sname]["class"])
- scenes[sname]["class"].queue_free()
+ scenes_main.remove_child(scenes[scene_name]["class"])
+ scenes[scene_name]["class"].queue_free()
CoreTypes.SceneType.BACKGROUND:
- scenes_background.remove_child(scenes[sname]["class"])
- scenes[sname]["class"].queue_free()
+ scenes_background.remove_child(scenes[scene_name]["class"])
+ scenes[scene_name]["class"].queue_free()
CoreTypes.SceneType.NONE:
- loggeri.error("Scene \"" + sname + "\" does not exist")
+ logger.error(core.stringify_variables("Scene %name% does not exist", { "name": scene_name }))
return false
- _: await loggeri.crash("Invalid SceneType " + str(exists(sname)))
- scenes.erase(sname)
+ _: await logger.crash(core.stringify_variables("Invalid SceneType %type%", { "type": exists(scene_name) }))
+ scenes.erase(scene_name)
return true
# +++ getters +++
## Returns a scene from some scene collection.[br]
## [br]
## Returns [code]null[/code] if no scene with that name was found.
-func get_scene(sname: String) -> Node:
+func get_scene(scene_name: String) -> Node:
if core.config.headless: return null
- match(exists(sname)):
- CoreTypes.SceneType.DEBUG: return scenes[sname]["class"]
- CoreTypes.SceneType.CUTSCENE: return scenes[sname]["class"]
- CoreTypes.SceneType.MENU: return scenes[sname]["class"]
- CoreTypes.SceneType.MAIN: return scenes[sname]["class"]
- CoreTypes.SceneType.BACKGROUND: return scenes[sname]["class"]
- CoreTypes.SceneType.NONE: loggeri.error("Scene \"" + sname + "\" does not exist")
- _: await loggeri.crash("Invalid SceneType " + str(exists(sname)))
+ match(exists(scene_name)):
+ CoreTypes.SceneType.DEBUG: return scenes[scene_name]["class"]
+ CoreTypes.SceneType.CUTSCENE: return scenes[scene_name]["class"]
+ CoreTypes.SceneType.MENU: return scenes[scene_name]["class"]
+ CoreTypes.SceneType.MAIN: return scenes[scene_name]["class"]
+ CoreTypes.SceneType.BACKGROUND: return scenes[scene_name]["class"]
+ CoreTypes.SceneType.NONE: logger.error(core.stringify_variables("Scene %name% does not exist", { "name": scene_name }))
+ _: await logger.crash(core.stringify_variables("Invalid SceneType %type%", { "type": exists(scene_name) }))
return null
## Returns a scene collection node.[br]
## Useful if you want to change a child's index.[br]
## [b]Danger: [i]Don't change any properties of the scene collection or free it, otherwise you may cause breakage.[/i][/b]
-func get_scene_collection(type: CoreTypes.SceneType) -> Node:
+func get_scene_collection(scene_type: CoreTypes.SceneType) -> Node:
if core.config.headless: return null
- match(type):
+ match(scene_type):
CoreTypes.SceneType.DEBUG: return scenes_debug
CoreTypes.SceneType.CUTSCENE: return scenes_cutscene
CoreTypes.SceneType.MENU: return scenes_menu
CoreTypes.SceneType.MAIN: return scenes_main
CoreTypes.SceneType.BACKGROUND: return scenes_background
- CoreTypes.SceneType.NONE: loggeri.error("No scene collection was found for CoreTypes.SceneType.NONE")
- _: await loggeri.crash("Invalid SceneType " + str(type))
+ CoreTypes.SceneType.NONE: logger.error("No scene collection was found for CoreTypes.SceneType.NONE")
+ _: await logger.crash(core.stringify_variables("Invalid SceneType %type%", { "type": scene_type }))
return null
## Returns a list of all loaded scenes in some scene collection.
-func get_scene_collection_list(type: CoreTypes.SceneType) -> Array[Node]:
+func get_scene_collection_list(scene_type: CoreTypes.SceneType) -> Array[Node]:
var list: Array[Node] = []
for scene in scenes:
- if scenes[scene]["type"] == type:
+ if scenes[scene]["type"] == scene_type:
list.append(scenes[scene]["class"])
return list
## Returns the number of loaded scenes in some scene collection.
-func get_scene_collection_count(type: CoreTypes.SceneType) -> int:
+func get_scene_collection_count(scene_type: CoreTypes.SceneType) -> int:
var amount: int = 0
for scene in scenes:
- if scenes[scene]["type"] == type:
+ if scenes[scene]["type"] == scene_type:
amount += 1
return amount
## Returns the scene collection a scene is loaded in.[br]
## [br]
## [enum CoreTypes.SceneType][code].NONE[/code] if no scene with that name was found.
-func exists(sname: String) -> CoreTypes.SceneType:
+func exists(scene_name: String) -> CoreTypes.SceneType:
for scene in scenes:
- if scene == sname: return scenes[scene]["type"]
+ if scene == scene_name: return scenes[scene]["type"]
return CoreTypes.SceneType.NONE
diff --git a/addons/CORE/src/storage.gd b/addons/CORE/src/storage.gd
index 391de50..6c9dd8b 100644
--- a/addons/CORE/src/storage.gd
+++ b/addons/CORE/src/storage.gd
@@ -23,51 +23,49 @@ extends CoreBaseModule
## Indicates if a storage file is currently open.[br]
## [b]Danger: [i]Don't modify this.[/i][/b]
var is_open: bool = false
-## The parsed data inside the storage file.[br]
-## [b]Danger: [i]Don't modify this.[/i][/b]
+# The parsed data inside the storage file.
var storage: Dictionary = {}
-## The location of the storage file.[br]
-## [b]Danger: [i]Don't modify this.[/i][/b]
+# The location of the storage file.
var storage_location: String = ""
# +++ file management +++
-## Opens a storage file into memory.
+## Opens a storage file and loads it into memory.
func open_storage(location: String, create_new: bool = true, sanity_check: bool = true, fail_on_sanity_check: bool = false) -> bool:
if is_open:
- loggeri.error("Failed to open storage: A storage file is already open")
+ logger.error("Failed to open storage: A storage file is already open")
return false
- loggeri.verb("Opening storage file at \"" + location + "\"")
+ logger.verb(core.stringify_variables("Opening storage file at %location%", { "location": location }))
var file: FileAccess
if !FileAccess.file_exists(location):
if create_new:
file = FileAccess.open(location, FileAccess.WRITE)
if file == null:
- await loggeri.crash("Could not open storage file at \"" + location + "\": Failed with code " + str(FileAccess.get_open_error()))
+ await logger.crash(core.stringify_variables("Could not open storage file at %location%: Failed with error %error%", { "location": location, "error": error_string(FileAccess.get_open_error()) }))
return false
file.store_string("{}")
file.close()
else:
- loggeri.error("Failed to open storage: create_new is set to false")
+ logger.error("Failed to open storage: create_new is set to false")
return false
file = FileAccess.open(location, FileAccess.READ)
var storage_temp: Variant = file.get_as_text()
file.close()
storage_temp = JSON.parse_string(storage_temp)
if typeof(storage_temp) != TYPE_DICTIONARY:
- loggeri.error("Failed to open storage: Parsed storage file is of type " + str(typeof(storage_temp)))
+ logger.error(core.stringify_variables("Failed to open storage: Parsed storage file is of type %type%", { "type": type_string(typeof(storage_temp)) }))
return false
if sanity_check:
var check_result: Array[String] = perform_sanity_check(storage_temp)
if check_result.size() != 0:
if fail_on_sanity_check:
- loggeri.error("Sanity check failed (stopping):")
+ logger.error("Sanity check failed (stopping):")
for error in check_result:
- loggeri.error("-> " + error)
+ logger.error("-> " + error)
return false
else:
- loggeri.warn("Sanity check failed (continuing anyway):")
+ logger.warn("Sanity check failed (continuing anyway):")
for error in check_result:
- loggeri.warn("-> " + error)
+ logger.warn("-> " + error)
storage = storage_temp
storage_location = location
is_open = true
@@ -76,9 +74,9 @@ func open_storage(location: String, create_new: bool = true, sanity_check: bool
## Closes the active storage file.
func close_storage() -> bool:
if !is_open:
- loggeri.error("Failed to close storage: No storage file is open")
+ logger.error("Failed to close storage: No storage file is open")
return false
- loggeri.verb("Closing storage file")
+ logger.verb("Closing storage file")
storage = {}
is_open = false
return true
@@ -86,13 +84,13 @@ func close_storage() -> bool:
## Saves the active storage file to disk.
func save_storage() -> bool:
if !is_open:
- loggeri.error("Failed to save storage: No storage file is open")
+ logger.error("Failed to save storage: No storage file is open")
return false
var file: FileAccess = FileAccess.open(storage_location, FileAccess.WRITE)
if file == null:
- await loggeri.crash("Could not open storage file at \"" + storage_location + "\": Failed with code " + str(FileAccess.get_open_error()))
+ await logger.crash(core.stringify_variables("Could not open storage file at %location%: Failed with error %error%", { "location": storage_location, "error": error_string(FileAccess.get_open_error()) }))
return false
- loggeri.diag("Writing storage file to disk")
+ logger.diag("Writing storage file to disk")
file.store_string(JSON.stringify(storage))
file.close()
return true
@@ -101,9 +99,9 @@ func save_storage() -> bool:
## Removes all keys from the active storage file. The nuclear option basically.
func nuke_storage(autosave: bool = true) -> bool:
if !is_open:
- loggeri.error("Failed to nuke storage: No storage file is open")
+ logger.error("Failed to nuke storage: No storage file is open")
return false
- loggeri.warn("Nuking storage")
+ logger.warn("Nuking storage")
storage = {}
if autosave: save_storage()
return true
@@ -111,17 +109,17 @@ func nuke_storage(autosave: bool = true) -> bool:
## Returns a storage key. Can also return a default value if unset.
func get_key(key: String, default: Variant = null) -> Variant:
if !is_open:
- loggeri.error("Failed to get key: No storage file is open")
+ logger.error("Failed to get key: No storage file is open")
return NAN
- loggeri.diag("Returning storage key \"" + key + "\" (default='" + str(default) + "')")
+ logger.diag(core.stringify_variables("Returning storage key %key% (default=%default%)", { "key": key, "default": default }))
return storage.get(key, default)
## Updates a storage key with the specified value.
func set_key(key: String, value: Variant, overwrite: bool = true, autosave: bool = true) -> bool:
if !is_open:
- loggeri.error("Failed to set key: No storage file is open")
+ logger.error("Failed to set key: No storage file is open")
return false
- loggeri.diag("Updating storage key \"" + key + "\" with value '" + str(value) + "' (overwrite='" + str(overwrite) + "' autosave='" + str(autosave) + "'")
+ logger.diag(core.stringify_variables("Updating storage key %key% with value %value% (overwrite=%overwrite% autosave=%autosave%)", { "key": key, "value": value, "overwrite": overwrite, "autosave": autosave }))
storage.merge({key: value}, overwrite)
if autosave: save_storage()
return true
@@ -131,7 +129,7 @@ func del_key(key: String, autosave: bool = true) -> bool:
if !is_open:
logger.errof("storage", "Failed to delete key: No storage file is open")
return false
- loggeri.diag("Deleting storage key \"" + key + "\" (autosave='" + str(autosave) + "')")
+ logger.diag(core.stringify_variables("Deleting storage key %key% (autosave=%autosave%)", { "key": key, "autosave": autosave }))
storage.erase(key)
if autosave: save_storage()
return true
@@ -141,30 +139,30 @@ func del_key(key: String, autosave: bool = true) -> bool:
## pass your modified [class Dictionary to [method safe_dict].
func get_dict() -> Dictionary:
if !is_open:
- loggeri.error("Failed to get dictionary: No storage file is open")
+ logger.error("Failed to get dictionary: No storage file is open")
return {}
- loggeri.verb("Returning storage dictionary")
+ logger.verb("Returning storage dictionary")
return storage
# +++ raw manipulation +++
## Saves a arbitrary dictionary as a [param storage] [class Dictionary] with sanity checking ([code]sanity_check[/code] and [code]fail_on_sanity_check[/code]).
func save_dict(dict: Dictionary, sanity_check: bool = true, fail_on_sanity_check: bool = false, autosave: bool = true) -> bool:
if !is_open:
- loggeri.error("Failed to save dictionary: No storage file is open")
+ logger.error("Failed to save dictionary: No storage file is open")
return false
- loggeri.verb("Saving custom dictionary as storage")
+ logger.verb("Saving custom dictionary as storage")
if sanity_check:
var check_result: Array[String] = perform_sanity_check(dict)
if check_result.size() != 0:
if fail_on_sanity_check:
- loggeri.error("Sanity check failed (stopping):")
+ logger.error("Sanity check failed (stopping):")
for error in check_result:
- loggeri.error("-> " + error)
+ logger.error("-> " + error)
return false
else:
- loggeri.warn("Sanity check failed (continuing anyway):")
+ logger.warn("Sanity check failed (continuing anyway):")
for error in check_result:
- loggeri.warn("-> " + error)
+ logger.warn("-> " + error)
storage = dict
if autosave: save_storage()
return true
@@ -172,14 +170,14 @@ func save_dict(dict: Dictionary, sanity_check: bool = true, fail_on_sanity_check
# +++ etc +++
## Performs sanity checks on a [class Dictionary] to determine if it can be saved and loaded using this module.
func perform_sanity_check(storage_check: Dictionary) -> Array[String]:
- loggeri.verb("Performing a sanity check on some storage dictionary")
+ logger.verb("Performing a sanity check on some storage dictionary")
var errors: Array[String] = []
for key in storage_check:
if typeof(key) != TYPE_STRING:
- errors.append("Key \"" + str(key) + "\" is not of type String (type '" + type_string(typeof(key)) + "')")
+ errors.append(core.stringify_variables("Key %key% is not of type String (is of type %type%)", { "key": key, "type": type_string(typeof(key)) }))
continue
if typeof(storage_check[key]) != TYPE_NIL and typeof(storage_check[key]) != TYPE_STRING and typeof(storage_check[key]) != TYPE_INT and typeof(storage_check[key]) != TYPE_FLOAT and typeof(storage_check[key]) != TYPE_BOOL and typeof(storage_check[key]) != TYPE_ARRAY and typeof(storage_check[key]) != TYPE_DICTIONARY:
- errors.append("The value of \"" + key + "\" is not null, a string, an integer, a float, boolean, array or dictionary (type '" + type_string(typeof(key)) + "')")
-
- loggeri.verb("Completed sanity check with " + str(errors.size()) + " errors")
+ errors.append(core.stringify_variables("The value of %key% is not null, a String, an int, a float, boolean, an Array or a Dictionary (is of type %type%)", { "key": key, "type": type_string(typeof(key)) }))
+
+ logger.verb(core.stringify_variables("Completed sanity check with %errors% errors", { "errors": errors.size() }))
return errors
diff --git a/addons/CORE/src/validation.gd b/addons/CORE/src/validation.gd
new file mode 100644
index 0000000..68d0761
--- /dev/null
+++ b/addons/CORE/src/validation.gd
@@ -0,0 +1,81 @@
+# CORE FRAMEWORK SOURCE FILE
+# Copyright (c) 2024 The StarOpenSource Project & Contributors
+# Licensed under the GNU Affero General Public License v3
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+## Allows for data validation.
+extends CoreBaseModule
+
+var schemas: Array[CoreValidationSchema]
+var singles: Array[CoreValidationSingle]
+
+# +++ module +++
+func _cleanup() -> void:
+ # Schemas
+ var schemas_remove_enty: Array[CoreValidationSchema] = []
+ for schema in schemas:
+ schemas_remove_enty.append(schema)
+ if !is_instance_valid(schema): continue
+ if !is_instance_valid(schema.parent):
+ logger.diag("Removing schema '" + schema.name + "'")
+ schema.queue_free()
+ for schema in schemas_remove_enty:
+ schemas.remove_at(schemas.find(schema))
+
+ # Singles
+ var singles_remove_enty: Array[CoreValidationSingle] = []
+ for single in singles:
+ singles_remove_enty.append(single)
+ if !is_instance_valid(single): continue
+ if !is_instance_valid(single.parent):
+ logger.diag("Removing single '" + single.name + "'")
+ single.queue_free()
+ for single in singles_remove_enty:
+ singles.remove_at(singles.find(single))
+
+func _schedule() -> void:
+ # Singles
+ for single in singles:
+ if is_instance_valid(single):
+ logger.diag("Removing single '" + single.name + "'")
+ single.queue_free()
+
+ # Schemas
+ for schema in schemas:
+ if is_instance_valid(schema):
+ logger.diag("Removing schema '" + schema.name + "'")
+ schema.queue_free()
+
+# +++ data validation +++
+## Returns a new [CoreValidationSingle].
+func get_single(data, parent: Node) -> CoreValidationSingle:
+ var single: CoreValidationSingle = CoreValidationSingle.new(core, data, parent)
+ single.name = core.stringify_variables("CoreValidationSingle -> %parent% (type=%type% bytes=%bytes%)", { "parent": parent, "type": type_string(typeof(data)), "bytes": var_to_bytes_with_objects(data).size() })
+ singles.append(single)
+ return single
+
+## Returns a new [CoreValidationSingle] for use in [CoreValidationSchema]s.
+func get_single_schema(parent: Node) -> CoreValidationSingle:
+ var single: CoreValidationSingle = CoreValidationSingle.new(core, null, parent)
+ single.name = core.stringify_variables("CoreValidationSingle -> %parent% (no data, for schema)", { "parent": parent })
+ singles.append(single)
+ return single
+
+## Returns a new [CoreValidationSchema].
+func get_schema(schema_new: Dictionary, data_new: Dictionary, parent: Node) -> CoreValidationSchema:
+ var schema: CoreValidationSchema = CoreValidationSchema.new(core, schema_new, data_new, parent)
+ schema.name = core.stringify_variables("CoreValidationSchema -> %parent% (keys_schema=%keys_schema% keys_data=%keys_data% bytes_schema=%bytes_schema% bytes_data=%bytes_data%)", { "parent": parent, "keys_schema": schema_new.size(), "keys_data": data_new.size(), "bytes_schema": var_to_bytes_with_objects(schema_new).size(), "bytes_data": var_to_bytes_with_objects(data_new).size() })
+ schemas.append(schema)
+ return schema