└── addons ├── JSON_Schema_Validator ├── JSON_Schema_validator_LICENSE └── json_schema_validator.gd └── mod_loader ├── LICENSE ├── api ├── config.gd ├── deprecated.gd ├── log.gd ├── mod.gd ├── mod_manager.gd └── profile.gd ├── internal ├── cache.gd ├── cli.gd ├── dependency.gd ├── file.gd ├── godot.gd ├── mod_loader_utils.gd ├── path.gd ├── script_extension.gd └── third_party │ └── steam.gd ├── mod_loader.gd ├── mod_loader_store.gd ├── options ├── options.tres └── profiles │ ├── current.tres │ ├── default.tres │ ├── disable_mods.tres │ ├── editor.tres │ ├── production_no_workshop.tres │ └── production_workshop.tres ├── resources ├── mod_config.gd ├── mod_data.gd ├── mod_manifest.gd ├── mod_user_profile.gd ├── options_current.gd └── options_profile.gd └── setup ├── setup_log.gd └── setup_utils.gd /addons/JSON_Schema_Validator/JSON_Schema_validator_LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sahedo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /addons/JSON_Schema_Validator/json_schema_validator.gd: -------------------------------------------------------------------------------- 1 | class_name JSONSchema 2 | extends Reference 3 | 4 | 5 | # JSON Schema main script 6 | # Inherits from Reference for easy use 7 | 8 | const SMALL_FLOAT_THRESHOLD = 0.001 9 | const MAX_DECIMAL_PLACES = 3 10 | 11 | const DEF_KEY_NAME = "schema root" 12 | const DEF_ERROR_STRING = "##error##" 13 | 14 | const JST_ARRAY = "array" 15 | const JST_BOOLEAN = "boolean" 16 | const JST_INTEGER = "integer" 17 | const JST_NULL = "null" 18 | const JST_NUMBER = "number" 19 | const JST_OBJECT = "object" 20 | const JST_STRING = "string" 21 | 22 | const JSKW_TYPE = "type" 23 | const JSKW_PROP = "properties" 24 | const JSKW_REQ = "required" 25 | const JSKW_TITLE = "title" 26 | const JSKW_DESCR = "description" 27 | const JSKW_DEFAULT = "default" 28 | const JSKW_EXAMPLES = "examples" 29 | const JSKW_COMMENT = "$comment" 30 | const JSKW_ENUM = "enum" 31 | const JSKW_CONST = "const" 32 | const JSKW_PREFIX_ITEMS = "prefixItems" 33 | const JSKW_ITEMS = "items" 34 | const JSKW_MIN_ITEMS = "minItems" 35 | const JSKW_MAX_ITEMS = "maxItems" 36 | const JSKW_CONTAINS = "contains" 37 | const JSKW_ADD_ITEMS = "additionalItems" 38 | const JSKW_UNIQUE_ITEMS = "uniqueItems" 39 | const JSKW_MULT_OF = "multipleOf" 40 | const JSKW_MINIMUM = "minimum" 41 | const JSKW_MIN_EX = "exclusiveMinimum" 42 | const JSKW_MAXIMUM = "maximum" 43 | const JSKW_MAX_EX = "exclusiveMaximum" 44 | const JSKW_PROP_ADD = "additionalProperties" 45 | const JSKW_PROP_PATTERN = "patternProperties" 46 | const JSKW_PROP_NAMES = "propertyNames" 47 | const JSKW_PROP_MIN = "minProperties" 48 | const JSKW_PROP_MAX = "maxProperties" 49 | const JSKW_DEPEND = "dependencies" 50 | const JSKW_LENGTH_MIN = "minLength" 51 | const JSKW_LENGTH_MAX = "maxLength" 52 | const JSKW_PATTERN = "pattern" 53 | const JSKW_FORMAT = "format" 54 | const JSKW_COLOR = "color" 55 | 56 | const JSM_GREATER = "greater" 57 | const JSM_GREATER_EQ = "greater or equal" 58 | const JSM_LESS = "less" 59 | const JSM_LESS_EQ = "less or equal" 60 | const JSM_OBJ_DICT = "object (dictionary)" 61 | 62 | const JSL_AND = "%s and %s" 63 | const JSL_OR = "%s or %s" 64 | 65 | const ERR_SCHEMA_FALSE = "Schema declared as deny all" 66 | const ERR_WRONG_SCHEMA_GEN = "Schema error: " 67 | const ERR_WRONG_SCHEMA_TYPE = "Schema error: schema must be empty object or object with 'type' keyword or boolean value" 68 | const ERR_WRONG_SHEMA_NOTA = "Schema error: expected that all elements of '%s.%s' must be '%s'" 69 | const ERR_WRONG_PROP_TYPE = "Schema error: any schema item must be object with 'type' keyword" 70 | const ERR_REQ_PROP_GEN = "Schema error: expected array of required properties for '%s'" 71 | const ERR_REQ_PROP_MISSING = "Missing required property: '%s' for '%s'" 72 | const ERR_NO_PROP_ADD = "Additional properties are not required: found '%s'" 73 | const ERR_FEW_PROP = "%d propertie(s) are not enough properties, at least %d are required" 74 | const ERR_MORE_PROP = "%d propertie(s) are too many properties, at most %d are allowed" 75 | const ERR_FEW_ITEMS = "%s item(s) are not enough items, at least %s are required" 76 | const ERR_MORE_ITEMS = "%s item(s) are too many items, at most %s are allowed" 77 | const ERR_INVALID_JSON_GEN = "Validation fails with message: %s" 78 | const ERR_INVALID_JSON_EXT = "Invalid JSON data passed with message: %s" 79 | const ERR_TYPE_MISMATCH_GEN = "Type mismatch: expected %s for '%s'" 80 | const ERR_INVALID_NUMBER = "The %s key that equals %s should have a maximum of %s decimal places" 81 | const ERR_INVALID_MULT = "Multiplier in key %s that equals %s must be greater or equal to %s" 82 | const ERR_MULT_D = "Key %s that equal %d must be multiple of %d" 83 | const ERR_MULT_F = "Key %s that equal %f must be multiple of %f" 84 | const ERR_RANGE_D = "Key %s that equal %d must be %s than %d" 85 | const ERR_RANGE_F = "Key %s that equal %f must be %s than %f" 86 | const ERR_RANGE_S = "Length of '%s' (%d) %s than declared (%d)" 87 | const ERR_WRONG_PATTERN = "String '%s' does not match its corresponding pattern" 88 | const ERR_FORMAT = "String '%s' does not match its corresponding format '%s'" 89 | 90 | # This is one and only function that need you to call outside 91 | # If all validation checks passes, this return empty String 92 | func validate(json_data : String, schema: String) -> String: 93 | var error : String = "" 94 | 95 | # General validation input data as JSON file 96 | error = validate_json(json_data) 97 | if error: return ERR_INVALID_JSON_EXT % error 98 | 99 | # General validation input schema as JSONSchema file 100 | error = validate_json(schema) 101 | if error: return ERR_WRONG_SCHEMA_GEN + error 102 | var parsed_schema = parse_json(schema) 103 | match typeof(parsed_schema): 104 | TYPE_BOOL: 105 | if !parsed_schema: 106 | return ERR_INVALID_JSON_GEN % ERR_SCHEMA_FALSE 107 | else: 108 | return "" 109 | TYPE_DICTIONARY: 110 | if parsed_schema.empty(): 111 | return "" 112 | elif parsed_schema.keys().size() > 0 && !parsed_schema.has(JSKW_TYPE): 113 | return ERR_WRONG_SCHEMA_TYPE 114 | _: return ERR_WRONG_SCHEMA_TYPE 115 | 116 | # All inputs seems valid. Begin type validation 117 | error = _type_selection(json_data, parsed_schema) 118 | 119 | # Normal return empty string, meaning OK 120 | return error 121 | 122 | func _to_string(): 123 | return "[JSONSchema:%d]" % get_instance_id() 124 | 125 | # TODO: title, description, default, examples, $comment, enum, const 126 | func _type_selection(json_data: String, schema: Dictionary, key: String = DEF_KEY_NAME) -> String: 127 | # If the schema is an empty object it always passes validation 128 | if schema.empty(): 129 | return "" 130 | 131 | if typeof(schema) == TYPE_BOOL: 132 | # If the schema is true it always passes validation 133 | if schema: 134 | return "" 135 | # If the schema is false it always vales validation 136 | else: 137 | return ERR_INVALID_JSON_GEN + "false is always invalid" 138 | 139 | var typearr: Array = _var_to_array(schema.type) 140 | var parsed_data = parse_json(json_data) 141 | var error: String = ERR_TYPE_MISMATCH_GEN % [typearr, key] 142 | for type in typearr: 143 | match type: 144 | JST_ARRAY: 145 | if typeof(parsed_data) == TYPE_ARRAY: 146 | error = _validate_array(parsed_data, schema, key) 147 | else: 148 | error = ERR_TYPE_MISMATCH_GEN % [[JST_ARRAY], key] 149 | JST_BOOLEAN: 150 | if typeof(parsed_data) != TYPE_BOOL: 151 | return ERR_TYPE_MISMATCH_GEN % [[JST_BOOLEAN], key] 152 | else: 153 | error = "" 154 | JST_INTEGER: 155 | if typeof(parsed_data) == TYPE_INT: 156 | error = _validate_integer(parsed_data, schema, key) 157 | if typeof(parsed_data) == TYPE_REAL && parsed_data == int(parsed_data): 158 | error = _validate_integer(int(parsed_data), schema, key) 159 | JST_NULL: 160 | if typeof(parsed_data) != TYPE_NIL: 161 | return ERR_TYPE_MISMATCH_GEN % [[JST_NULL], key] 162 | else: 163 | error = "" 164 | JST_NUMBER: 165 | if typeof(parsed_data) == TYPE_REAL: 166 | error = _validate_number(parsed_data, schema, key) 167 | else: 168 | error = ERR_TYPE_MISMATCH_GEN % [[JST_NUMBER], key] 169 | JST_OBJECT: 170 | if typeof(parsed_data) == TYPE_DICTIONARY: 171 | error = _validate_object(parsed_data, schema, key) 172 | else: 173 | error = ERR_TYPE_MISMATCH_GEN % [[JST_OBJECT], key] 174 | JST_STRING: 175 | if typeof(parsed_data) == TYPE_STRING: 176 | error = _validate_string(parsed_data, schema, key) 177 | else: 178 | error = ERR_TYPE_MISMATCH_GEN % [[JST_STRING], key] 179 | return error 180 | 181 | 182 | func _var_to_array(variant) -> Array: 183 | var result : Array = [] 184 | if typeof(variant) == TYPE_ARRAY: 185 | result = variant 186 | else: 187 | result.append(variant) 188 | return result 189 | 190 | func _validate_array(input_data: Array, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: 191 | # TODO: contains minContains maxContains uniqueItems 192 | 193 | # Initialize variables 194 | var error : String = "" # Variable to store any error messages 195 | var items_array : Array # Array of items in the schema 196 | var suberror : Array = [] # Array of suberrors in each item 197 | var additional_items_schema: Dictionary # Schema for additional items in the input data 198 | var is_additional_item_allowed: bool # Flag to check if additional items are allowed 199 | 200 | # Check if minItems key exists in the schema 201 | if input_schema.has(JSKW_MIN_ITEMS): 202 | # Check if non negative number 203 | if input_schema.minItems < 0: 204 | return ERR_WRONG_SCHEMA_GEN + "minItems must be a non-negative number." 205 | 206 | if input_data.size() < input_schema.minItems: 207 | return ERR_FEW_ITEMS % [input_data.size(), input_schema.minItems] 208 | 209 | # Check if maxItems key exists in the schema 210 | if input_schema.has(JSKW_MAX_ITEMS): 211 | # Check if non negative number 212 | if input_schema.maxItems < 0: 213 | return ERR_WRONG_SCHEMA_GEN + "minItems must be a non-negative number." 214 | 215 | if input_data.size() > input_schema.maxItems: 216 | return ERR_MORE_ITEMS % [input_data.size(), input_schema.maxItems] 217 | 218 | # Check if prefixItems key exists in the schema 219 | if input_schema.has(JSKW_PREFIX_ITEMS): 220 | # Check if items key exists in the schema 221 | if not input_schema.has(JSKW_ITEMS): 222 | return ERR_REQ_PROP_MISSING % [JSKW_ITEMS, JSKW_PREFIX_ITEMS] 223 | 224 | # Return error if items key is not a bool or a dictionary 225 | if not typeof(input_schema.items) == TYPE_DICTIONARY and not typeof(input_schema.items) == TYPE_BOOL: 226 | return ERR_WRONG_SCHEMA_TYPE 227 | 228 | if typeof(input_schema.items) == TYPE_BOOL: 229 | # Check if additional items in the input data are allowed 230 | if input_schema.items == false: 231 | # Check if there are more items in the input data than specified in prefixItems 232 | if input_data.size() > input_schema.prefixItems.size(): 233 | # Create an error message if there are more items than allowed 234 | var substr := "Array '%s' is of size %s but no addition items allowed." % [input_data, input_data.size()] 235 | return ERR_INVALID_JSON_GEN % substr 236 | # If the 'items' key is set to true all types are allowed for addition items. 237 | else: 238 | additional_items_schema = {} 239 | 240 | # Check if items key is a dictionary 241 | if typeof(input_schema.items) == TYPE_DICTIONARY: 242 | # Any items after the specified ones in prefixItems have to be validated with this schema 243 | # Set the schema for additional array items 244 | additional_items_schema = input_schema.items 245 | 246 | # Check if all entries in prefixItems are a dictionary 247 | for schema in input_schema.prefixItems: 248 | if typeof(schema) != TYPE_DICTIONARY: 249 | return ERR_WRONG_SHEMA_NOTA % [property_name, JSKW_ITEMS, JST_OBJECT] 250 | 251 | # Check every item in the input data 252 | for index in input_data.size(): 253 | var item = input_data[index] 254 | var current_schema: Dictionary 255 | var key_substr: String 256 | 257 | if index <= input_schema.prefixItems.size() - 1: 258 | # As long as there are prefixItems in the array work with those 259 | current_schema = input_schema.prefixItems[index] 260 | key_substr = ".prefixItems" 261 | else: 262 | # After that use the items schema 263 | current_schema = additional_items_schema 264 | key_substr = ".items" 265 | 266 | var sub_error_message := _type_selection(JSON.print(item), current_schema, property_name + key_substr + "["+String(index)+"]") 267 | if not sub_error_message == "": 268 | suberror.append(sub_error_message) 269 | 270 | if suberror.size() > 0: 271 | return ERR_INVALID_JSON_GEN % String(suberror) 272 | 273 | # Return inside this if block, because we don't want to validate the items key twice. 274 | return error 275 | 276 | # Check if items key exists in the schema 277 | if input_schema.has(JSKW_ITEMS): 278 | #'items' must be an object 279 | if not typeof(input_schema.items) == TYPE_DICTIONARY: 280 | return ERR_WRONG_SHEMA_NOTA % [property_name, JSKW_ITEMS, JST_OBJECT] 281 | 282 | # Check every item of input Array on 283 | for index in input_data.size(): 284 | index = index - 1 285 | 286 | # Validate the array item with the schema defined by the 'items' key 287 | var sub_error_message := _type_selection(JSON.print(input_data[index]), input_schema.items, property_name+"["+String(index)+"]") 288 | if not sub_error_message == "": 289 | suberror.append(sub_error_message) 290 | 291 | if suberror.size() > 0: 292 | return ERR_INVALID_JSON_GEN % String(suberror) 293 | 294 | return error 295 | 296 | func _validate_boolean(input_data: bool, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: 297 | # nothing to check 298 | return "" 299 | 300 | func _validate_integer(input_data: int, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: 301 | # all processing is performed in 302 | return _validate_number(input_data, input_schema, property_name) 303 | 304 | func _validate_null(input_data, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: 305 | # nothing to check 306 | return "" 307 | 308 | func _validate_number(input_data: float, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: 309 | var types: Array = _var_to_array(input_schema.type) 310 | # integer mode turns on only if types has integer and has not number 311 | var integer_mode: bool = types.has(JST_INTEGER) && !types.has(JST_NUMBER) 312 | 313 | # Processing multiple check 314 | if input_schema.has(JSKW_MULT_OF): 315 | var mult: float 316 | var mod: float 317 | var is_zero: bool 318 | 319 | # Get the multipleOf value from the schema and convert to float 320 | mult = float(input_schema[JSKW_MULT_OF]) 321 | # Convert to integer if integer_mode is enabled 322 | mult = int(mult) if integer_mode else mult 323 | 324 | # Check if the number has more decimal places then allowed 325 | var decimal_places := str(input_data).get_slice('.', 1) 326 | if not decimal_places.empty() and decimal_places.length() > MAX_DECIMAL_PLACES: 327 | return ERR_INVALID_NUMBER % [property_name, input_data, str(MAX_DECIMAL_PLACES)] 328 | 329 | # Check if multipleOf is smaller than SMALL_FLOAT_THRESHOLD 330 | if not mult >= SMALL_FLOAT_THRESHOLD: 331 | return ERR_INVALID_MULT % [property_name, mult, str(SMALL_FLOAT_THRESHOLD)] 332 | 333 | # Multiply by a big number if input is smaller than 1 to prevent float issues 334 | if input_data < 1.0 or mult < 1.0: 335 | mod = fmod(input_data * 1000, mult * 1000) 336 | else: 337 | mod = fmod(input_data, mult) 338 | 339 | # Check if the remainder is close to zero 340 | is_zero = is_zero_approx(mod) 341 | 342 | # Return error message if remainder is not close to zero 343 | if not is_zero: 344 | if integer_mode: 345 | return ERR_MULT_D % [property_name, input_data, mult] 346 | else: 347 | return ERR_MULT_F % [property_name, input_data, mult] 348 | 349 | # processing minimum check 350 | if input_schema.has(JSKW_MINIMUM): 351 | var minimum = float(input_schema[JSKW_MINIMUM]) 352 | minimum = int(minimum) if integer_mode else minimum 353 | if input_data < minimum: 354 | if integer_mode: 355 | return ERR_RANGE_D % [property_name, input_data, JSM_GREATER_EQ, minimum] 356 | else: 357 | return ERR_RANGE_F % [property_name, input_data, JSM_GREATER_EQ, minimum] 358 | 359 | # processing exclusive minimum check 360 | if input_schema.has(JSKW_MIN_EX): 361 | var minimum = float(input_schema[JSKW_MIN_EX]) 362 | minimum = int(minimum) if integer_mode else minimum 363 | if input_data <= minimum: 364 | if integer_mode: 365 | return ERR_RANGE_D % [property_name, input_data, JSM_GREATER, minimum] 366 | else: 367 | return ERR_RANGE_F % [property_name, input_data, JSM_GREATER, minimum] 368 | 369 | # processing maximum check 370 | if input_schema.has(JSKW_MAXIMUM): 371 | var maximum = float(input_schema[JSKW_MAXIMUM]) 372 | maximum = int(maximum) if integer_mode else maximum 373 | if input_data > maximum: 374 | if integer_mode: 375 | return ERR_RANGE_D % [property_name, input_data, JSM_LESS_EQ, maximum] 376 | else: 377 | return ERR_RANGE_F % [property_name, input_data, JSM_LESS_EQ, maximum] 378 | 379 | # processing exclusive minimum check 380 | if input_schema.has(JSKW_MAX_EX): 381 | var maximum = float(input_schema[JSKW_MAX_EX]) 382 | maximum = int(maximum) if integer_mode else maximum 383 | if input_data >= maximum: 384 | if integer_mode: 385 | return ERR_RANGE_D % [property_name, input_data, JSM_LESS, maximum] 386 | else: 387 | return ERR_RANGE_F % [property_name, input_data, JSM_LESS, maximum] 388 | 389 | return "" 390 | 391 | func _validate_object(input_data: Dictionary, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: 392 | # TODO: patternProperties 393 | var error : String = "" 394 | 395 | # Process dependencies 396 | if input_schema.has(JSKW_DEPEND): 397 | for dependency in input_schema.dependencies.keys(): 398 | if input_data.has(dependency): 399 | match typeof(input_schema.dependencies[dependency]): 400 | TYPE_ARRAY: 401 | if input_schema.has(JSKW_REQ): 402 | for property in input_schema.dependencies[dependency]: 403 | input_schema.required.append(property) 404 | else: 405 | input_schema.required = input_schema.dependencies[dependency] 406 | TYPE_DICTIONARY: 407 | for key in input_schema.dependencies[dependency].keys(): 408 | if input_schema.has(key): 409 | match typeof(input_schema[key]): 410 | TYPE_ARRAY: 411 | for element in input_schema.dependencies[dependency][key]: 412 | input_schema[key].append(element) 413 | TYPE_DICTIONARY: 414 | for element in input_schema.dependencies[dependency][key].keys(): 415 | input_schema[key][element] = input_schema.dependencies[dependency][key][element] 416 | _: 417 | input_schema[key] = input_schema.dependencies[dependency][key] 418 | else: 419 | input_schema[key] = input_schema.dependencies[dependency][key] 420 | _: 421 | return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JSL_OR % [JST_ARRAY, JSM_OBJ_DICT], property_name] 422 | 423 | # Process properties 424 | if input_schema.has(JSKW_PROP): 425 | 426 | # Process required 427 | if input_schema.has(JSKW_REQ): 428 | if typeof(input_schema.required) != TYPE_ARRAY: return ERR_REQ_PROP_GEN % property_name 429 | for i in input_schema.required: 430 | if !input_data.has(i): return ERR_REQ_PROP_MISSING % [i, property_name] 431 | 432 | # Continue validating schema subelements 433 | if typeof(input_schema.properties) != TYPE_DICTIONARY: 434 | return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JSM_OBJ_DICT, property_name] 435 | 436 | # Process property items 437 | for key in input_schema.properties: 438 | if !input_schema.properties[key].has(JSKW_TYPE): 439 | return ERR_WRONG_PROP_TYPE 440 | if input_data.has(key): 441 | error = _type_selection(JSON.print(input_data[key]), input_schema.properties[key], key) 442 | else: 443 | pass 444 | if error: return error 445 | 446 | # Process additional properties 447 | if input_schema.has(JSKW_PROP_ADD): 448 | match typeof(input_schema.additionalProperties): 449 | TYPE_BOOL: 450 | if not input_schema.additionalProperties: 451 | for key in input_data: 452 | if not input_schema.properties.has(key): 453 | return ERR_NO_PROP_ADD % key 454 | TYPE_DICTIONARY: 455 | for key in input_data: 456 | if not input_schema.properties.has(key): 457 | return _type_selection(JSON.print(input_data[key]), input_schema.additionalProperties, key) 458 | _: 459 | return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JSL_OR % [JST_BOOLEAN, JSM_OBJ_DICT], property_name] 460 | 461 | # Process properties names 462 | if input_schema.has(JSKW_PROP_NAMES): 463 | if typeof(input_schema.propertyNames) != TYPE_DICTIONARY: 464 | return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JSM_OBJ_DICT, property_name] 465 | for key in input_data: 466 | error = _validate_string(key, input_schema.propertyNames, key) 467 | if error: return error 468 | 469 | # Process minProperties maxProperties 470 | if input_schema.has(JSKW_PROP_MIN): 471 | if typeof(input_schema[JSKW_PROP_MIN]) != TYPE_REAL: 472 | return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JST_INTEGER, property_name] 473 | if input_data.keys().size() < input_schema[JSKW_PROP_MIN]: 474 | return ERR_FEW_PROP % [input_data.keys().size(), input_schema[JSKW_PROP_MIN]] 475 | 476 | if input_schema.has(JSKW_PROP_MAX): 477 | if typeof(input_schema[JSKW_PROP_MAX]) != TYPE_REAL: 478 | return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JST_INTEGER, property_name] 479 | if input_data.keys().size() > input_schema[JSKW_PROP_MAX]: 480 | return ERR_MORE_PROP % [input_data.keys().size(), input_schema[JSKW_PROP_MAX]] 481 | 482 | return error 483 | 484 | func _validate_string(input_data: String, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: 485 | # TODO: format 486 | var error : String = "" 487 | if input_schema.has(JSKW_LENGTH_MIN): 488 | if not (typeof(input_schema[JSKW_LENGTH_MIN]) == TYPE_INT || typeof(input_schema[JSKW_LENGTH_MIN]) == TYPE_REAL): 489 | return ERR_TYPE_MISMATCH_GEN % [JST_INTEGER, property_name+"."+JSKW_LENGTH_MIN] 490 | if input_data.length() < input_schema[JSKW_LENGTH_MIN]: 491 | return ERR_INVALID_JSON_GEN % ERR_RANGE_S % [property_name, input_data.length(), JSM_LESS ,input_schema[JSKW_LENGTH_MIN]] 492 | 493 | if input_schema.has(JSKW_LENGTH_MAX): 494 | if not (typeof(input_schema[JSKW_LENGTH_MAX]) == TYPE_INT || typeof(input_schema[JSKW_LENGTH_MAX]) == TYPE_REAL): 495 | return ERR_TYPE_MISMATCH_GEN % [JST_INTEGER, property_name+"."+JSKW_LENGTH_MAX] 496 | if input_data.length() > input_schema[JSKW_LENGTH_MAX]: 497 | return ERR_INVALID_JSON_GEN % ERR_RANGE_S % [property_name, input_data.length(), JSM_GREATER, input_schema[JSKW_LENGTH_MAX]] 498 | 499 | if input_schema.has(JSKW_PATTERN): 500 | if not (typeof(input_schema[JSKW_PATTERN]) == TYPE_STRING): 501 | return ERR_TYPE_MISMATCH_GEN % [JST_STRING, property_name+"."+JSKW_PATTERN] 502 | var regex = RegEx.new() 503 | regex.compile(input_schema[JSKW_PATTERN]) 504 | if regex.search(input_data) == null: 505 | return ERR_INVALID_JSON_GEN % ERR_WRONG_PATTERN % property_name 506 | 507 | if input_schema.has(JSKW_FORMAT): 508 | # validate "color" format 509 | if input_schema.format.to_lower() == JSKW_COLOR: 510 | if not input_data.is_valid_html_color(): 511 | return ERR_INVALID_JSON_GEN % ERR_FORMAT % [property_name, JSKW_COLOR] 512 | 513 | return error 514 | -------------------------------------------------------------------------------- /addons/mod_loader/LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /addons/mod_loader/api/config.gd: -------------------------------------------------------------------------------- 1 | # This Class provides functionality for working with per-mod Configurations. 2 | class_name ModLoaderConfig 3 | extends Object 4 | 5 | 6 | const LOG_NAME := "ModLoader:Config" 7 | const DEFAULT_CONFIG_NAME := "default" 8 | 9 | 10 | # Creates a new configuration for a mod. 11 | # 12 | # Parameters: 13 | # - mod_id (String): The ID of the mod for which the configuration is being created. 14 | # - config_name (String): The name of the configuration. 15 | # - config_data (Dictionary): The configuration data to be stored in the new configuration. 16 | # 17 | # Returns: 18 | # - ModConfig: The created ModConfig object if successful, or null otherwise. 19 | static func create_config(mod_id: String, config_name: String, config_data: Dictionary) -> ModConfig: 20 | # Check if the config schema exists by retrieving the default config 21 | var default_config: ModConfig = get_default_config(mod_id) 22 | if not default_config: 23 | ModLoaderLog.error( 24 | "Failed to create config \"%s\". No config schema found for \"%s\"." 25 | % [config_name, mod_id], LOG_NAME 26 | ) 27 | return null 28 | 29 | # Make sure the config name is not empty 30 | if config_name == "": 31 | ModLoaderLog.error( 32 | "Failed to create config \"%s\". The config name cannot be empty." 33 | % config_name, LOG_NAME 34 | ) 35 | return null 36 | 37 | # Make sure the config name is unique 38 | if ModLoaderStore.mod_data[mod_id].configs.has(config_name): 39 | ModLoaderLog.error( 40 | "Failed to create config \"%s\". A config with the name \"%s\" already exists." 41 | % [config_name, config_name], LOG_NAME 42 | ) 43 | return null 44 | 45 | # Create the config save path based on the config_name 46 | var config_file_path := _ModLoaderPath.get_path_to_mod_configs_dir(mod_id).plus_file("%s.json" % config_name) 47 | 48 | # Initialize a new ModConfig object with the provided parameters 49 | var mod_config := ModConfig.new( 50 | mod_id, 51 | config_data, 52 | config_file_path 53 | ) 54 | 55 | # Check if the mod_config is valid 56 | if not mod_config.is_valid: 57 | return null 58 | 59 | # Store the mod_config in the mod's ModData 60 | ModLoaderStore.mod_data[mod_id].configs[config_name] = mod_config 61 | # Save the mod_config to a new config JSON file in the mod's config directory 62 | var is_save_success := mod_config.save_to_file() 63 | 64 | if not is_save_success: 65 | return null 66 | 67 | ModLoaderLog.debug("Created new config \"%s\" for mod \"%s\"" % [config_name, mod_id], LOG_NAME) 68 | 69 | return mod_config 70 | 71 | 72 | # Updates an existing ModConfig object with new data and save the config file. 73 | # 74 | # Parameters: 75 | # - config (ModConfig): The ModConfig object to be updated. 76 | # 77 | # Returns: 78 | # - ModConfig: The updated ModConfig object if successful, or null otherwise. 79 | static func update_config(config: ModConfig) -> ModConfig: 80 | # Validate the config and check for any validation errors 81 | var error_message := config.validate() 82 | 83 | # Check if the config is the "default" config, which cannot be modified 84 | if config.name == DEFAULT_CONFIG_NAME: 85 | ModLoaderLog.error("The \"default\" config cannot be modified. Please create a new config instead.", LOG_NAME) 86 | return null 87 | 88 | # Check if the config passed validation 89 | if not config.is_valid: 90 | ModLoaderLog.error("Update for config \"%s\" failed validation with error message \"%s\"" % [config.name, error_message], LOG_NAME) 91 | return null 92 | 93 | # Save the updated config to the config file 94 | var is_save_success := config.save_to_file() 95 | 96 | if not is_save_success: 97 | ModLoaderLog.error("Failed to save config \"%s\" to \"%s\"." % [config.name, config.save_path], LOG_NAME) 98 | return null 99 | 100 | # Return the updated config 101 | return config 102 | 103 | 104 | # Deletes a ModConfig object and performs cleanup operations. 105 | # 106 | # Parameters: 107 | # - config (ModConfig): The ModConfig object to be deleted. 108 | # 109 | # Returns: 110 | # - bool: True if the deletion was successful, False otherwise. 111 | static func delete_config(config: ModConfig) -> bool: 112 | # Check if the config is the "default" config, which cannot be deleted 113 | if config.name == DEFAULT_CONFIG_NAME: 114 | ModLoaderLog.error("Deletion of the default configuration is not allowed.", LOG_NAME) 115 | return false 116 | 117 | # Change the current config to the "default" config 118 | set_current_config(get_default_config(config.mod_id)) 119 | 120 | # Remove the config file from the Mod Config directory 121 | var is_remove_success := config.remove_file() 122 | 123 | if not is_remove_success: 124 | return false 125 | 126 | # Remove the config from ModData 127 | ModLoaderStore.mod_data[config.mod_id].configs.erase(config.name) 128 | 129 | return true 130 | 131 | 132 | # Sets the current configuration of a mod to the specified configuration. 133 | # 134 | # Parameters: 135 | # - config (ModConfig): The ModConfig object to be set as current config. 136 | static func set_current_config(config: ModConfig) -> void: 137 | ModLoaderStore.mod_data[config.mod_id].current_config = config 138 | 139 | 140 | # Returns the schema for the specified mod id. 141 | # If no configuration file exists for the mod, an empty dictionary is returned. 142 | # 143 | # Parameters: 144 | # - mod_id (String): the ID of the mod to get the configuration schema for 145 | # 146 | # Returns: 147 | # - A dictionary representing the schema for the mod's configuration file 148 | static func get_config_schema(mod_id: String) -> Dictionary: 149 | # Get all config files for the specified mod 150 | var mod_configs := get_configs(mod_id) 151 | 152 | # If no config files were found, return an empty dictionary 153 | if mod_configs.empty(): 154 | return {} 155 | 156 | # The schema is the same for all config files, so we just return the schema of the default config file 157 | return mod_configs.default.schema 158 | 159 | 160 | # Retrieves the schema for a specific property key. 161 | # 162 | # Parameters: 163 | # - config (ModConfig): The ModConfig object from which to retrieve the schema. 164 | # - prop (String): The property key for which to retrieve the schema. 165 | # e.g. `parentProp.childProp.nthChildProp` || `propKey` 166 | # 167 | # Returns: 168 | # - Dictionary: The schema dictionary for the specified property. 169 | static func get_schema_for_prop(config: ModConfig, prop: String) -> Dictionary: 170 | # Split the property string into an array of property keys 171 | var prop_array := prop.split(".") 172 | 173 | # If the property array is empty, return the schema for the root property 174 | if prop_array.empty(): 175 | return config.schema.properties[prop] 176 | 177 | # Traverse the schema dictionary to find the schema for the specified property 178 | var schema_for_prop := _traverse_schema(config.schema.properties, prop_array) 179 | 180 | # If the schema for the property is empty, log an error and return an empty dictionary 181 | if schema_for_prop.empty(): 182 | ModLoaderLog.error("No Schema found for property \"%s\" in config \"%s\" for mod \"%s\"" % [prop, config.name, config.mod_id], LOG_NAME) 183 | return {} 184 | 185 | return schema_for_prop 186 | 187 | 188 | # Recursively traverses the schema dictionary based on the provided prop_key_array 189 | # and returns the corresponding schema for the target property. 190 | # 191 | # Parameters: 192 | # - schema_prop: The current schema dictionary to traverse. 193 | # - prop_key_array: An array containing the property keys representing the path to the target property. 194 | # 195 | # Returns: 196 | # The schema dictionary corresponding to the target property specified by the prop_key_array. 197 | # If the target property is not found, an empty dictionary is returned. 198 | static func _traverse_schema(schema_prop: Dictionary, prop_key_array: Array) -> Dictionary: 199 | # Return the current schema_prop if the prop_key_array is empty (reached the destination property) 200 | if prop_key_array.empty(): 201 | return schema_prop 202 | 203 | # Get and remove the first prop_key in the array 204 | var prop_key: String = prop_key_array.pop_front() 205 | 206 | # Check if the searched property exists 207 | if not schema_prop.has(prop_key): 208 | return {} 209 | 210 | schema_prop = schema_prop[prop_key] 211 | 212 | # If the schema_prop has a 'type' key, is of type 'object', and there are more property keys remaining 213 | if schema_prop.has("type") and schema_prop.type == "object" and not prop_key_array.empty(): 214 | # Set the properties of the object as the current 'schema_prop' 215 | schema_prop = schema_prop.properties 216 | 217 | schema_prop = _traverse_schema(schema_prop, prop_key_array) 218 | 219 | return schema_prop 220 | 221 | 222 | # Retrieves an Array of mods that have configuration files. 223 | # 224 | # Returns: 225 | # An Array containing the mod data of mods that have configuration files. 226 | static func get_mods_with_config() -> Array: 227 | # Create an empty array to store mods with configuration files 228 | var mods_with_config := [] 229 | 230 | # Iterate over each mod in ModLoaderStore.mod_data 231 | for mod_id in ModLoaderStore.mod_data: 232 | # Retrieve the mod data for the current mod ID 233 | # *The ModData type cannot be used because ModData is not fully loaded when this code is executed.* 234 | var mod_data = ModLoaderStore.mod_data[mod_id] 235 | 236 | # Check if the mod has any configuration files 237 | if not mod_data.configs.empty(): 238 | mods_with_config.push_back(mod_data) 239 | 240 | # Return the array of mods with configuration files 241 | return mods_with_config 242 | 243 | 244 | # Retrieves the configurations dictionary for a given mod ID. 245 | # 246 | # Parameters: 247 | # - mod_id: The ID of the mod for which to retrieve the configurations. 248 | # 249 | # Returns: 250 | # A dictionary containing the configurations for the specified mod. 251 | # If the mod ID is invalid or no configurations are found, an empty dictionary is returned. 252 | static func get_configs(mod_id: String) -> Dictionary: 253 | # Check if the mod ID is invalid 254 | if not ModLoaderStore.mod_data.has(mod_id): 255 | ModLoaderLog.fatal("Mod ID \"%s\" not found" % [mod_id], LOG_NAME) 256 | return {} 257 | 258 | var config_dictionary: Dictionary = ModLoaderStore.mod_data[mod_id].configs 259 | 260 | # Check if there is no config file for the mod 261 | if config_dictionary.empty(): 262 | ModLoaderLog.debug("No config for mod id \"%s\"" % mod_id, LOG_NAME, true) 263 | return {} 264 | 265 | return config_dictionary 266 | 267 | 268 | # Retrieves the configuration for a specific mod and configuration name. 269 | # Returns the configuration as a ModConfig object or null if not found. 270 | # 271 | # Parameters: 272 | # - mod_id (String): The ID of the mod to retrieve the configuration for. 273 | # - config_name (String): The name of the configuration to retrieve. 274 | # 275 | # Returns: 276 | # The configuration as a ModConfig object or null if not found. 277 | static func get_config(mod_id: String, config_name: String) -> ModConfig: 278 | var configs := get_configs(mod_id) 279 | 280 | if not configs.has(config_name): 281 | ModLoaderLog.error("No config with name \"%s\" found for mod_id \"%s\" " % [config_name, mod_id], LOG_NAME) 282 | return null 283 | 284 | return configs[config_name] 285 | 286 | 287 | # Retrieves the default configuration for a specified mod ID. 288 | # 289 | # Parameters: 290 | # - mod_id: The ID of the mod for which to retrieve the default configuration. 291 | # 292 | # Returns: 293 | # The ModConfig object representing the default configuration for the specified mod. 294 | # If the mod ID is invalid or no configuration is found, returns null. 295 | # 296 | static func get_default_config(mod_id: String) -> ModConfig: 297 | return get_config(mod_id, DEFAULT_CONFIG_NAME) 298 | 299 | 300 | # Retrieves the currently active configuration for a specific mod 301 | # 302 | # Parameters: 303 | # mod_id (String): The ID of the mod to retrieve the configuration for. 304 | # 305 | # Returns: 306 | # The configuration as a ModConfig object or null if not found. 307 | static func get_current_config(mod_id: String) -> ModConfig: 308 | var current_config_name := get_current_config_name(mod_id) 309 | var current_config := get_config(mod_id, current_config_name) 310 | 311 | return current_config 312 | 313 | 314 | # Retrieves the name of the current configuration for a specific mod 315 | # Returns an empty string if no configuration exists for the mod or the user profile has not been loaded 316 | # 317 | # Parameters: 318 | # mod_id (String): The ID of the mod to retrieve the current configuration name for. 319 | # 320 | # Returns: 321 | # The currently active configuration name for the given mod id or an empty string if not found. 322 | static func get_current_config_name(mod_id: String) -> String: 323 | # Check if user profile has been loaded 324 | if not ModLoaderStore.current_user_profile or not ModLoaderStore.user_profiles.has(ModLoaderStore.current_user_profile.name): 325 | # Warn and return an empty string if the user profile has not been loaded 326 | ModLoaderLog.warning("Can't get current mod config for \"%s\", because no current user profile is present." % mod_id, LOG_NAME) 327 | return "" 328 | 329 | # Retrieve the current user profile from ModLoaderStore 330 | # *Can't use ModLoaderUserProfile because it causes a cyclic dependency* 331 | var current_user_profile = ModLoaderStore.current_user_profile 332 | 333 | # Check if the mod exists in the user profile's mod list and if it has a current config 334 | if not current_user_profile.mod_list.has(mod_id) or not current_user_profile.mod_list[mod_id].has("current_config"): 335 | # Log an error and return an empty string if the mod has no config file 336 | ModLoaderLog.error("Mod \"%s\" has no config file." % mod_id, LOG_NAME) 337 | return "" 338 | 339 | # Return the name of the current configuration for the mod 340 | return current_user_profile.mod_list[mod_id].current_config 341 | -------------------------------------------------------------------------------- /addons/mod_loader/api/deprecated.gd: -------------------------------------------------------------------------------- 1 | # API methods for deprecating funcs. Can be used by mods with public APIs. 2 | class_name ModLoaderDeprecated 3 | extends Node 4 | 5 | 6 | const LOG_NAME := "ModLoader:Deprecated" 7 | 8 | 9 | # Marks a method that has changed its name or class. 10 | # 11 | # Parameters: 12 | # - old_method (String): The name of the deprecated method. 13 | # - new_method (String): The name of the new method to use. 14 | # - since_version (String): The version number from which the method has been deprecated. 15 | # - show_removal_note (bool): (optional) If true, includes a note about future removal of the old method. Default is true. 16 | # 17 | # Returns: void 18 | static func deprecated_changed(old_method: String, new_method: String, since_version: String, show_removal_note: bool = true) -> void: 19 | _deprecated_log(str( 20 | "DEPRECATED: ", 21 | "The method \"%s\" has been deprecated since version %s. " % [old_method, since_version], 22 | "Please use \"%s\" instead. " % new_method, 23 | "The old method will be removed with the next major update, and will break your code if not changed. " if show_removal_note else "" 24 | )) 25 | 26 | 27 | # Marks a method that has been entirely removed, with no replacement. 28 | # Note: This should rarely be needed but is included for completeness. 29 | # 30 | # Parameters: 31 | # - old_method (String): The name of the removed method. 32 | # - since_version (String): The version number from which the method has been deprecated. 33 | # - show_removal_note (bool): (optional) If true, includes a note about future removal of the old method. Default is true. 34 | # 35 | # Returns: void 36 | static func deprecated_removed(old_method: String, since_version: String, show_removal_note: bool = true) -> void: 37 | _deprecated_log(str( 38 | "DEPRECATED: ", 39 | "The method \"%s\" has been deprecated since version %s, and is no longer available. " % [old_method, since_version], 40 | "There is currently no replacement method. ", 41 | "The method will be removed with the next major update, and will break your code if not changed. " if show_removal_note else "" 42 | )) 43 | 44 | 45 | # Marks a method with a freeform deprecation message. 46 | # 47 | # Parameters: 48 | # - msg (String): The deprecation message. 49 | # - since_version (String): (optional) The version number from which the deprecation applies. 50 | # 51 | # Returns: void 52 | static func deprecated_message(msg: String, since_version: String = "") -> void: 53 | var since_text := " (since version %s)" % since_version if since_version else "" 54 | _deprecated_log(str("DEPRECATED: ", msg, since_text)) 55 | 56 | 57 | # Internal function for logging deprecation messages with support to trigger warnings instead of fatal errors. 58 | # 59 | # Parameters: 60 | # - msg (String): The deprecation message. 61 | # 62 | # Returns: void 63 | static func _deprecated_log(msg: String) -> void: 64 | if ModLoaderStore.ml_options.ignore_deprecated_errors or OS.has_feature("standalone"): 65 | ModLoaderLog.warning(msg, LOG_NAME) 66 | else: 67 | ModLoaderLog.fatal(msg, LOG_NAME) 68 | -------------------------------------------------------------------------------- /addons/mod_loader/api/log.gd: -------------------------------------------------------------------------------- 1 | # This Class provides methods for logging, retrieving logged data, and internal methods for working with log files. 2 | class_name ModLoaderLog 3 | extends Node 4 | 5 | # Path to the latest log file. 6 | const MOD_LOG_PATH := "user://logs/modloader.log" 7 | 8 | const LOG_NAME := "ModLoader:Log" 9 | 10 | enum VERBOSITY_LEVEL { 11 | ERROR, 12 | WARNING, 13 | INFO, 14 | DEBUG, 15 | } 16 | 17 | # This Sub-Class represents a log entry in ModLoader. 18 | class ModLoaderLogEntry: 19 | extends Resource 20 | 21 | # Name of the mod or ModLoader class this entry refers to. 22 | var mod_name: String 23 | 24 | # The message of the log entry. 25 | var message: String 26 | 27 | # The log type, which indicates the verbosity level of this entry. 28 | var type: String 29 | 30 | # The readable format of the time when this log entry was created. 31 | # Used for printing in the log file and output. 32 | var time: String 33 | 34 | # The timestamp when this log entry was created. 35 | # Used for comparing and sorting log entries by time. 36 | var time_stamp: int 37 | 38 | # An array of ModLoaderLogEntry objects. 39 | # If the message has been logged before, it is added to the stack. 40 | var stack := [] 41 | 42 | 43 | # Initialize a ModLoaderLogEntry object with provided values. 44 | # 45 | # Parameters: 46 | # - _mod_name (String): Name of the mod or ModLoader class this entry refers to. 47 | # - _message (String): The message of the log entry. 48 | # - _type (String): The log type, which indicates the verbosity level of this entry. 49 | # - _time (String): The readable format of the time when this log entry was created. 50 | # 51 | # Returns: void 52 | func _init(_mod_name: String, _message: String, _type: String, _time: String) -> void: 53 | mod_name = _mod_name 54 | message = _message 55 | type = _type 56 | time = _time 57 | time_stamp = Time.get_ticks_msec() 58 | 59 | 60 | # Get the log entry as a formatted string. 61 | # 62 | # Returns: String 63 | func get_entry() -> String: 64 | return str(time, get_prefix(), message) 65 | 66 | 67 | # Get the prefix string for the log entry, including the log type and mod name. 68 | # 69 | # Returns: String 70 | func get_prefix() -> String: 71 | return "%s %s: " % [type.to_upper(), mod_name] 72 | 73 | 74 | # Generate an MD5 hash of the log entry (prefix + message). 75 | # 76 | # Returns: String 77 | func get_md5() -> String: 78 | return str(get_prefix(), message).md5_text() 79 | 80 | 81 | # Get all log entries, including the current entry and entries in the stack. 82 | # 83 | # Returns: Array 84 | func get_all_entries() -> Array: 85 | var entries := [self] 86 | entries.append_array(stack) 87 | 88 | return entries 89 | 90 | 91 | # API log functions - logging 92 | # ============================================================================= 93 | 94 | 95 | # Logs the error in red and a stack trace. Prefixed FATAL-ERROR. 96 | # 97 | # *Note: Stops the execution in editor* 98 | # 99 | # Parameters: 100 | # - message (String): The message to be logged as an error. 101 | # - mod_name (String): The name of the mod or ModLoader class associated with this log entry. 102 | # - only_once (bool): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false. 103 | # 104 | # Returns: void 105 | static func fatal(message: String, mod_name: String, only_once := false) -> void: 106 | _log(message, mod_name, "fatal-error", only_once) 107 | 108 | 109 | # Logs the message and pushes an error. Prefixed ERROR. 110 | # 111 | # *Note: Always logged* 112 | # 113 | # Parameters: 114 | # - message (String): The message to be logged as an error. 115 | # - mod_name (String): The name of the mod or ModLoader class associated with this log entry. 116 | # - only_once (bool): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false. 117 | # 118 | # Returns: void 119 | static func error(message: String, mod_name: String, only_once := false) -> void: 120 | _log(message, mod_name, "error", only_once) 121 | 122 | 123 | # Logs the message and pushes a warning. Prefixed WARNING. 124 | # 125 | # *Note: Logged with verbosity level at or above warning (-v).* 126 | # 127 | # Parameters: 128 | # - message (String): The message to be logged as a warning. 129 | # - mod_name (String): The name of the mod or ModLoader class associated with this log entry. 130 | # - only_once (bool): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false. 131 | # 132 | # Returns: void 133 | static func warning(message: String, mod_name: String, only_once := false) -> void: 134 | _log(message, mod_name, "warning", only_once) 135 | 136 | 137 | # Logs the message. Prefixed INFO. 138 | # 139 | # *Note: Logged with verbosity level at or above info (-vv).* 140 | # 141 | # Parameters: 142 | # - message (String): The message to be logged as an information. 143 | # - mod_name (String): The name of the mod or ModLoader class associated with this log entry. 144 | # - only_once (bool): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false. 145 | # 146 | # Returns: void 147 | static func info(message: String, mod_name: String, only_once := false) -> void: 148 | _log(message, mod_name, "info", only_once) 149 | 150 | 151 | # Logs the message. Prefixed SUCCESS. 152 | # 153 | # *Note: Logged with verbosity level at or above info (-vv).* 154 | # 155 | # Parameters: 156 | # - message (String): The message to be logged as a success. 157 | # - mod_name (String): The name of the mod or ModLoader class associated with this log entry. 158 | # - only_once (bool): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false. 159 | # 160 | # Returns: void 161 | static func success(message: String, mod_name: String, only_once := false) -> void: 162 | _log(message, mod_name, "success", only_once) 163 | 164 | 165 | # Logs the message. Prefixed DEBUG. 166 | # 167 | # *Note: Logged with verbosity level at or above debug (-vvv).* 168 | # 169 | # Parameters: 170 | # - message (String): The message to be logged as a debug. 171 | # - mod_name (String): The name of the mod or ModLoader class associated with this log entry. 172 | # - only_once (bool): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false. 173 | # 174 | # Returns: void 175 | static func debug(message: String, mod_name: String, only_once := false) -> void: 176 | _log(message, mod_name, "debug", only_once) 177 | 178 | 179 | # Logs the message formatted with [method JSON.print]. Prefixed DEBUG. 180 | # 181 | # *Note: Logged with verbosity level at or above debug (-vvv).* 182 | # 183 | # Parameters: 184 | # - message (String): The message to be logged as a debug. 185 | # - json_printable (Variant): The variable to be formatted and printed using [method JSON.print]. 186 | # - mod_name (String): The name of the mod or ModLoader class associated with this log entry. 187 | # - only_once (bool): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false. 188 | # 189 | # Returns: void 190 | static func debug_json_print(message: String, json_printable, mod_name: String, only_once := false) -> void: 191 | message = "%s\n%s" % [message, JSON.print(json_printable, " ")] 192 | _log(message, mod_name, "debug", only_once) 193 | 194 | 195 | # API log functions - stored logs 196 | # ============================================================================= 197 | 198 | 199 | # Returns an array of log entries as a resource. 200 | # 201 | # Returns: 202 | # - Array: An array of log entries represented as resource. 203 | static func get_all_as_resource() -> Array: 204 | return get_all() 205 | 206 | 207 | # Returns an array of log entries as a string. 208 | # 209 | # Returns: 210 | # - Array: An array of log entries represented as strings. 211 | static func get_all_as_string() -> Array: 212 | var log_entries := get_all() 213 | return get_all_entries_as_string(log_entries) 214 | 215 | 216 | # Returns an array of log entries as a resource for a specific mod_name. 217 | # 218 | # Parameters: 219 | # - mod_name (String): The name of the mod or ModLoader class associated with the log entries. 220 | # 221 | # Returns: 222 | # - Array: An array of log entries represented as resource for the specified mod_name. 223 | static func get_by_mod_as_resource(mod_name: String) -> Array: 224 | return get_by_mod(mod_name) 225 | 226 | 227 | # Returns an array of log entries as a string for a specific mod_name. 228 | # 229 | # Parameters: 230 | # - mod_name (String): The name of the mod or ModLoader class associated with the log entries. 231 | # 232 | # Returns: 233 | # - Array: An array of log entries represented as strings for the specified mod_name. 234 | static func get_by_mod_as_string(mod_name: String) -> Array: 235 | var log_entries := get_by_mod(mod_name) 236 | return get_all_entries_as_string(log_entries) 237 | 238 | 239 | # Returns an array of log entries as a resource for a specific type. 240 | # 241 | # Parameters: 242 | # - type (String): The log type associated with the log entries. 243 | # 244 | # Returns: 245 | # - Array: An array of log entries represented as resource for the specified type. 246 | static func get_by_type_as_resource(type: String) -> Array: 247 | return get_by_type(type) 248 | 249 | 250 | # Returns an array of log entries as a string for a specific type. 251 | # 252 | # Parameters: 253 | # - type (String): The log type associated with the log entries. 254 | # 255 | # Returns: 256 | # - Array: An array of log entries represented as strings for the specified type. 257 | static func get_by_type_as_string(type: String) -> Array: 258 | var log_entries := get_by_type(type) 259 | return get_all_entries_as_string(log_entries) 260 | 261 | 262 | # Returns an array of all log entries. 263 | # 264 | # Returns: 265 | # - Array: An array of all log entries. 266 | static func get_all() -> Array: 267 | var log_entries := [] 268 | 269 | # Get all log entries 270 | for entry_key in ModLoaderStore.logged_messages.all.keys(): 271 | var entry: ModLoaderLogEntry = ModLoaderStore.logged_messages.all[entry_key] 272 | log_entries.append_array(entry.get_all_entries()) 273 | 274 | # Sort them by time 275 | log_entries.sort_custom(ModLoaderLogCompare, "time") 276 | 277 | return log_entries 278 | 279 | 280 | # Returns an array of log entries for a specific mod_name. 281 | # 282 | # Parameters: 283 | # - mod_name (String): The name of the mod or ModLoader class associated with the log entries. 284 | # 285 | # Returns: 286 | # - Array: An array of log entries for the specified mod_name. 287 | static func get_by_mod(mod_name: String) -> Array: 288 | var log_entries := [] 289 | 290 | if not ModLoaderStore.logged_messages.by_mod.has(mod_name): 291 | error("\"%s\" not found in logged messages." % mod_name, LOG_NAME) 292 | return [] 293 | 294 | for entry_key in ModLoaderStore.logged_messages.by_mod[mod_name].keys(): 295 | var entry: ModLoaderLogEntry = ModLoaderStore.logged_messages.by_mod[mod_name][entry_key] 296 | log_entries.append_array(entry.get_all_entries()) 297 | 298 | return log_entries 299 | 300 | 301 | # Returns an array of log entries for a specific type. 302 | # 303 | # Parameters: 304 | # - type (String): The log type associated with the log entries. 305 | # 306 | # Returns: 307 | # - Array: An array of log entries for the specified type. 308 | static func get_by_type(type: String) -> Array: 309 | var log_entries := [] 310 | 311 | for entry_key in ModLoaderStore.logged_messages.by_type[type].keys(): 312 | var entry: ModLoaderLogEntry = ModLoaderStore.logged_messages.by_type[type][entry_key] 313 | log_entries.append_array(entry.get_all_entries()) 314 | 315 | return log_entries 316 | 317 | 318 | # Returns an array of log entries represented as strings. 319 | # 320 | # Parameters: 321 | # - log_entries (Array): An array of ModLoaderLogEntry Objects. 322 | # 323 | # Returns: 324 | # - Array: An array of log entries represented as strings. 325 | static func get_all_entries_as_string(log_entries: Array) -> Array: 326 | var log_entry_strings := [] 327 | 328 | # Get all the strings 329 | for entry in log_entries: 330 | log_entry_strings.push_back(entry.get_entry()) 331 | 332 | return log_entry_strings 333 | 334 | 335 | # Internal log functions 336 | # ============================================================================= 337 | 338 | static func _log(message: String, mod_name: String, log_type: String = "info", only_once := false) -> void: 339 | if _is_mod_name_ignored(mod_name): 340 | return 341 | 342 | var time := "%s " % _get_time_string() 343 | var log_entry := ModLoaderLogEntry.new(mod_name, message, log_type, time) 344 | 345 | if only_once and _is_logged_before(log_entry): 346 | return 347 | 348 | if ModLoaderStore: 349 | _store_log(log_entry) 350 | 351 | # Check if the scene_tree is available 352 | if Engine.get_main_loop(): 353 | ModLoader.emit_signal("logged", log_entry) 354 | 355 | _code_note(str( 356 | "If you are seeing this after trying to run the game, there is an error in your mod somewhere.", 357 | "Check the Debugger tab (below) to see the error.", 358 | "Click through the files listed in Stack Frames to trace where the error originated.", 359 | "View Godot's documentation for more info:", 360 | "https://docs.godotengine.org/en/stable/tutorials/scripting/debug/debugger_panel.html#doc-debugger-panel" 361 | )) 362 | 363 | match log_type.to_lower(): 364 | "fatal-error": 365 | push_error(message) 366 | _write_to_log_file(log_entry.get_entry()) 367 | _write_to_log_file(JSON.print(get_stack(), " ")) 368 | assert(false, message) 369 | "error": 370 | printerr(message) 371 | push_error(message) 372 | _write_to_log_file(log_entry.get_entry()) 373 | "warning": 374 | if _get_verbosity() >= VERBOSITY_LEVEL.WARNING: 375 | print(log_entry.get_prefix() + message) 376 | push_warning(message) 377 | _write_to_log_file(log_entry.get_entry()) 378 | "info", "success": 379 | if _get_verbosity() >= VERBOSITY_LEVEL.INFO: 380 | print(log_entry.get_prefix() + message) 381 | _write_to_log_file(log_entry.get_entry()) 382 | "debug": 383 | if _get_verbosity() >= VERBOSITY_LEVEL.DEBUG: 384 | print(log_entry.get_prefix() + message) 385 | _write_to_log_file(log_entry.get_entry()) 386 | 387 | 388 | static func _is_mod_name_ignored(mod_log_name: String) -> bool: 389 | if not ModLoaderStore: 390 | return false 391 | 392 | var ignored_mod_log_names := ModLoaderStore.ml_options.ignored_mod_names_in_log as Array 393 | 394 | # No ignored mod names 395 | if ignored_mod_log_names.size() == 0: 396 | return false 397 | 398 | # Directly match a full mod log name. ex: "ModLoader:Deprecated" 399 | if mod_log_name in ignored_mod_log_names: 400 | return true 401 | 402 | # Match a mod log name with a wildcard. ex: "ModLoader:*" 403 | for ignored_mod_name in ignored_mod_log_names: 404 | if ignored_mod_name.ends_with("*"): 405 | if mod_log_name.begins_with(ignored_mod_name.trim_suffix("*")): 406 | return true 407 | 408 | # No match 409 | return false 410 | 411 | 412 | static func _get_verbosity() -> int: 413 | if not ModLoaderStore: 414 | return VERBOSITY_LEVEL.DEBUG 415 | return ModLoaderStore.ml_options.log_level 416 | 417 | 418 | static func _store_log(log_entry: ModLoaderLogEntry) -> void: 419 | var existing_entry: ModLoaderLogEntry 420 | 421 | # Store in all 422 | # If it's a new entry 423 | if not ModLoaderStore.logged_messages.all.has(log_entry.get_md5()): 424 | ModLoaderStore.logged_messages.all[log_entry.get_md5()] = log_entry 425 | # If it's a existing entry 426 | else: 427 | existing_entry = ModLoaderStore.logged_messages.all[log_entry.get_md5()] 428 | existing_entry.time = log_entry.time 429 | existing_entry.stack.push_back(log_entry) 430 | 431 | # Store in by_mod 432 | # If the mod is not yet in "by_mod" init the entry 433 | if not ModLoaderStore.logged_messages.by_mod.has(log_entry.mod_name): 434 | ModLoaderStore.logged_messages.by_mod[log_entry.mod_name] = {} 435 | 436 | ModLoaderStore.logged_messages.by_mod[log_entry.mod_name][log_entry.get_md5()] = log_entry if not existing_entry else existing_entry 437 | 438 | # Store in by_type 439 | ModLoaderStore.logged_messages.by_type[log_entry.type.to_lower()][log_entry.get_md5()] = log_entry if not existing_entry else existing_entry 440 | 441 | 442 | static func _is_logged_before(entry: ModLoaderLogEntry) -> bool: 443 | if not ModLoaderStore.logged_messages.all.has(entry.get_md5()): 444 | return false 445 | 446 | return true 447 | 448 | 449 | class ModLoaderLogCompare: 450 | # Custom sorter that orders logs by time 451 | static func time(a: ModLoaderLogEntry, b: ModLoaderLogEntry) -> bool: 452 | if a.time_stamp > b.time_stamp: 453 | return true # a -> b 454 | else: 455 | return false # b -> a 456 | 457 | 458 | # Internal Date Time 459 | # ============================================================================= 460 | 461 | # Returns the current time as a string in the format hh:mm:ss 462 | static func _get_time_string() -> String: 463 | var date_time := Time.get_datetime_dict_from_system() 464 | return "%02d:%02d:%02d" % [ date_time.hour, date_time.minute, date_time.second ] 465 | 466 | 467 | # Returns the current date as a string in the format yyyy-mm-dd 468 | static func _get_date_string() -> String: 469 | var date_time := Time.get_datetime_dict_from_system() 470 | return "%s-%02d-%02d" % [ date_time.year, date_time.month, date_time.day ] 471 | 472 | 473 | # Returns the current date and time as a string in the format yyyy-mm-dd_hh:mm:ss 474 | static func _get_date_time_string() -> String: 475 | return "%s_%s" % [ _get_date_string(), _get_time_string() ] 476 | 477 | 478 | 479 | # Internal File 480 | # ============================================================================= 481 | 482 | static func _write_to_log_file(string_to_write: String) -> void: 483 | var log_file := File.new() 484 | 485 | if not log_file.file_exists(MOD_LOG_PATH): 486 | _rotate_log_file() 487 | 488 | var error := log_file.open(MOD_LOG_PATH, File.READ_WRITE) 489 | if not error == OK: 490 | assert(false, "Could not open log file, error code: %s" % error) 491 | return 492 | 493 | log_file.seek_end() 494 | log_file.store_string("\n" + string_to_write) 495 | log_file.close() 496 | 497 | 498 | # Keeps log backups for every run, just like the Godot gdscript implementation of 499 | # https://github.com/godotengine/godot/blob/1d14c054a12dacdc193b589e4afb0ef319ee2aae/core/io/logger.cpp#L151 500 | static func _rotate_log_file() -> void: 501 | var MAX_LOGS := int(ProjectSettings.get_setting("logging/file_logging/max_log_files")) 502 | var log_file := File.new() 503 | 504 | if log_file.file_exists(MOD_LOG_PATH): 505 | if MAX_LOGS > 1: 506 | var datetime := _get_date_time_string().replace(":", ".") 507 | var backup_name: String = MOD_LOG_PATH.get_basename() + "_" + datetime 508 | if MOD_LOG_PATH.get_extension().length() > 0: 509 | backup_name += "." + MOD_LOG_PATH.get_extension() 510 | 511 | var dir := Directory.new() 512 | if dir.dir_exists(MOD_LOG_PATH.get_base_dir()): 513 | dir.copy(MOD_LOG_PATH, backup_name) 514 | _clear_old_log_backups() 515 | 516 | # only File.WRITE creates a new file, File.READ_WRITE throws an error 517 | var error := log_file.open(MOD_LOG_PATH, File.WRITE) 518 | if not error == OK: 519 | assert(false, "Could not open log file, error code: %s" % error) 520 | log_file.store_string('%s Created log' % _get_date_string()) 521 | log_file.close() 522 | 523 | 524 | static func _clear_old_log_backups() -> void: 525 | var MAX_LOGS := int(ProjectSettings.get_setting("logging/file_logging/max_log_files")) 526 | var MAX_BACKUPS := MAX_LOGS - 1 # -1 for the current new log (not a backup) 527 | var basename := MOD_LOG_PATH.get_file().get_basename() as String 528 | var extension := MOD_LOG_PATH.get_extension() as String 529 | 530 | var dir := Directory.new() 531 | if not dir.dir_exists(MOD_LOG_PATH.get_base_dir()): 532 | return 533 | if not dir.open(MOD_LOG_PATH.get_base_dir()) == OK: 534 | return 535 | 536 | dir.list_dir_begin() 537 | var file := dir.get_next() 538 | var backups := [] 539 | while file.length() > 0: 540 | if (not dir.current_is_dir() and 541 | file.begins_with(basename) and 542 | file.get_extension() == extension and 543 | not file == MOD_LOG_PATH.get_file()): 544 | backups.append(file) 545 | file = dir.get_next() 546 | dir.list_dir_end() 547 | 548 | if backups.size() > MAX_BACKUPS: 549 | backups.sort() 550 | backups.resize(backups.size() - MAX_BACKUPS) 551 | for file_to_delete in backups: 552 | dir.remove(file_to_delete) 553 | 554 | 555 | # Internal util funcs 556 | # ============================================================================= 557 | # This are duplicates of the functions in mod_loader_utils.gd to prevent 558 | # a cyclic reference error between ModLoaderLog and ModLoaderUtils. 559 | 560 | 561 | # This is a dummy func. It is exclusively used to show notes in the code that 562 | # stay visible after decompiling a PCK, as is primarily intended to assist new 563 | # modders in understanding and troubleshooting issues. 564 | static func _code_note(_msg:String): 565 | pass 566 | -------------------------------------------------------------------------------- /addons/mod_loader/api/mod.gd: -------------------------------------------------------------------------------- 1 | # This Class provides helper functions to build mods. 2 | class_name ModLoaderMod 3 | extends Object 4 | 5 | 6 | const LOG_NAME := "ModLoader:Mod" 7 | 8 | 9 | # Install a script extension that extends a vanilla script. 10 | # The child_script_path should point to your mod's extender script. 11 | # 12 | # Example: `"MOD/extensions/singletons/utils.gd"` 13 | # 14 | # Inside the extender script, include `extends {target}` where `{target}` is the vanilla path. 15 | # 16 | # Example: `extends "res://singletons/utils.gd"`. 17 | # 18 | # *Note: Your extender script doesn't have to follow the same directory path as the vanilla file, 19 | # but it's good practice to do so.* 20 | # 21 | # Parameters: 22 | # - child_script_path (String): The path to the mod's extender script. 23 | # 24 | # Returns: void 25 | static func install_script_extension(child_script_path: String) -> void: 26 | 27 | var mod_id: String = _ModLoaderPath.get_mod_dir(child_script_path) 28 | var mod_data: ModData = get_mod_data(mod_id) 29 | if not ModLoaderStore.saved_extension_paths.has(mod_data.manifest.get_mod_id()): 30 | ModLoaderStore.saved_extension_paths[mod_data.manifest.get_mod_id()] = [] 31 | ModLoaderStore.saved_extension_paths[mod_data.manifest.get_mod_id()].append(child_script_path) 32 | 33 | # If this is called during initialization, add it with the other 34 | # extensions to be installed taking inheritance chain into account 35 | if ModLoaderStore.is_initializing: 36 | ModLoaderStore.script_extensions.push_back(child_script_path) 37 | 38 | # If not, apply the extension directly 39 | else: 40 | _ModLoaderScriptExtension.apply_extension(child_script_path) 41 | 42 | 43 | # Register an array of classes to the global scope since Godot only does that in the editor. 44 | # 45 | # Format: `{ "base": "ParentClass", "class": "ClassName", "language": "GDScript", "path": "res://path/class_name.gd" }` 46 | # 47 | # *Note: You can find these easily in the project.godot file under `_global_script_classes` 48 | # (but you should only include classes belonging to your mod)* 49 | # 50 | # Parameters: 51 | # - new_global_classes (Array): An array of class definitions to be registered. 52 | # 53 | # Returns: void 54 | static func register_global_classes_from_array(new_global_classes: Array) -> void: 55 | ModLoaderUtils.register_global_classes_from_array(new_global_classes) 56 | var _savecustom_error: int = ProjectSettings.save_custom(_ModLoaderPath.get_override_path()) 57 | 58 | 59 | # Add a translation file. 60 | # 61 | # *Note: The translation file should have been created in Godot already, 62 | # such as when importing a CSV file. The translation file should be in the format `mytranslation.en.translation`.* 63 | # 64 | # Parameters: 65 | # - resource_path (String): The path to the translation resource file. 66 | # 67 | # Returns: void 68 | static func add_translation(resource_path: String) -> void: 69 | if not _ModLoaderFile.file_exists(resource_path): 70 | ModLoaderLog.fatal("Tried to load a translation resource from a file that doesn't exist. The invalid path was: %s" % [resource_path], LOG_NAME) 71 | return 72 | 73 | var translation_object: Translation = load(resource_path) 74 | if translation_object: 75 | TranslationServer.add_translation(translation_object) 76 | ModLoaderLog.info("Added Translation from Resource -> %s" % resource_path, LOG_NAME) 77 | else: 78 | ModLoaderLog.fatal("Failed to load translation at path: %s" % [resource_path], LOG_NAME) 79 | 80 | 81 | 82 | # Appends a new node to a modified scene. 83 | # 84 | # Parameters: 85 | # - modified_scene (Node): The modified scene where the node will be appended. 86 | # - node_name (String): (Optional) The name of the new node. Default is an empty string. 87 | # - node_parent (Node): (Optional) The parent node where the new node will be added. Default is null (direct child of modified_scene). 88 | # - instance_path (String): (Optional) The path to a scene resource that will be instantiated as the new node. 89 | # Default is an empty string resulting in a `Node` instance. 90 | # - is_visible (bool): (Optional) If true, the new node will be visible. Default is true. 91 | # 92 | # Returns: void 93 | static func append_node_in_scene(modified_scene: Node, node_name: String = "", node_parent = null, instance_path: String = "", is_visible: bool = true) -> void: 94 | var new_node: Node 95 | if not instance_path == "": 96 | new_node = load(instance_path).instance() 97 | else: 98 | new_node = Node.instance() 99 | if not node_name == "": 100 | new_node.name = node_name 101 | if is_visible == false: 102 | new_node.visible = false 103 | if not node_parent == null: 104 | var tmp_node: Node = modified_scene.get_node(node_parent) 105 | tmp_node.add_child(new_node) 106 | new_node.set_owner(modified_scene) 107 | else: 108 | modified_scene.add_child(new_node) 109 | new_node.set_owner(modified_scene) 110 | 111 | 112 | # Saves a modified scene to a file. 113 | # 114 | # Parameters: 115 | # - modified_scene (Node): The modified scene instance to be saved. 116 | # - scene_path (String): The path to the scene file that will be replaced. 117 | # 118 | # Returns: void 119 | static func save_scene(modified_scene: Node, scene_path: String) -> void: 120 | var packed_scene := PackedScene.new() 121 | var _pack_error := packed_scene.pack(modified_scene) 122 | ModLoaderLog.debug("packing scene -> %s" % packed_scene, LOG_NAME) 123 | packed_scene.take_over_path(scene_path) 124 | ModLoaderLog.debug("save_scene - taking over path - new path -> %s" % packed_scene.resource_path, LOG_NAME) 125 | ModLoaderStore.saved_objects.append(packed_scene) 126 | 127 | 128 | # Gets the ModData from the provided namespace 129 | # 130 | # Parameters: 131 | # - mod_id (String): The ID of the mod. 132 | # 133 | # Returns: 134 | # - ModData: The ModData associated with the provided mod_id, or null if the mod_id is invalid. 135 | static func get_mod_data(mod_id: String) -> ModData: 136 | if not ModLoaderStore.mod_data.has(mod_id): 137 | ModLoaderLog.error("%s is an invalid mod_id" % mod_id, LOG_NAME) 138 | return null 139 | 140 | return ModLoaderStore.mod_data[mod_id] 141 | 142 | 143 | # Gets the ModData of all loaded Mods as Dictionary. 144 | # 145 | # Returns: 146 | # - Dictionary: A dictionary containing the ModData of all loaded mods. 147 | static func get_mod_data_all() -> Dictionary: 148 | return ModLoaderStore.mod_data 149 | 150 | 151 | # Returns the path to the directory where unpacked mods are stored. 152 | # 153 | # Returns: 154 | # - String: The path to the unpacked mods directory. 155 | static func get_unpacked_dir() -> String: 156 | return _ModLoaderPath.get_unpacked_mods_dir_path() 157 | 158 | 159 | # Returns true if the mod with the given mod_id was successfully loaded. 160 | # 161 | # Parameters: 162 | # - mod_id (String): The ID of the mod. 163 | # 164 | # Returns: 165 | # - bool: true if the mod is loaded, false otherwise. 166 | static func is_mod_loaded(mod_id: String) -> bool: 167 | if ModLoaderStore.is_initializing: 168 | ModLoaderLog.warning( 169 | "The ModLoader is not fully initialized. " + 170 | "Calling \"is_mod_loaded()\" in \"_init()\" may result in an unexpected return value as mods are still loading.", 171 | LOG_NAME 172 | ) 173 | 174 | # If the mod is not present in the mod_data dictionary or the mod is flagged as not loadable. 175 | if not ModLoaderStore.mod_data.has(mod_id) or not ModLoaderStore.mod_data[mod_id].is_loadable: 176 | return false 177 | 178 | return true 179 | -------------------------------------------------------------------------------- /addons/mod_loader/api/mod_manager.gd: -------------------------------------------------------------------------------- 1 | # This Class provides methods to manage mod state. 2 | # *Note: Intended to be used by game developers.* 3 | class_name ModLoaderModManager 4 | extends Reference 5 | 6 | 7 | const LOG_NAME := "ModLoader:Manager" 8 | 9 | 10 | # Uninstall a script extension. 11 | # 12 | # Parameters: 13 | # - extension_script_path (String): The path to the extension script to be uninstalled. 14 | # 15 | # Returns: void 16 | static func uninstall_script_extension(extension_script_path: String) -> void: 17 | # Currently this is the only thing we do, but it is better to expose 18 | # this function like this for further changes 19 | _ModLoaderScriptExtension.remove_specific_extension_from_script(extension_script_path) 20 | 21 | 22 | # Reload all mods. 23 | # 24 | # *Note: This function should be called only when actually necessary 25 | # as it can break the game and require a restart for mods 26 | # that do not fully use the systems put in place by the mod loader, 27 | # so anything that just uses add_node, move_node ecc... 28 | # To not have your mod break on reload please use provided functions 29 | # like ModLoader::save_scene, ModLoader::append_node_in_scene and 30 | # all the functions that will be added in the next versions 31 | # Used to reload already present mods and load new ones* 32 | # 33 | # Returns: void 34 | static func reload_mods() -> void: 35 | 36 | # Currently this is the only thing we do, but it is better to expose 37 | # this function like this for further changes 38 | ModLoader._reload_mods() 39 | 40 | 41 | # Disable all mods. 42 | # 43 | # *Note: This function should be called only when actually necessary 44 | # as it can break the game and require a restart for mods 45 | # that do not fully use the systems put in place by the mod loader, 46 | # so anything that just uses add_node, move_node ecc... 47 | # To not have your mod break on disable please use provided functions 48 | # and implement a _disable function in your mod_main.gd that will 49 | # handle removing all the changes that were not done through the Mod Loader* 50 | # 51 | # Returns: void 52 | static func disable_mods() -> void: 53 | 54 | # Currently this is the only thing we do, but it is better to expose 55 | # this function like this for further changes 56 | ModLoader._disable_mods() 57 | 58 | 59 | # Disable a mod. 60 | # 61 | # *Note: This function should be called only when actually necessary 62 | # as it can break the game and require a restart for mods 63 | # that do not fully use the systems put in place by the mod loader, 64 | # so anything that just uses add_node, move_node ecc... 65 | # To not have your mod break on disable please use provided functions 66 | # and implement a _disable function in your mod_main.gd that will 67 | # handle removing all the changes that were not done through the Mod Loader* 68 | # 69 | # Parameters: 70 | # - mod_data (ModData): The ModData object representing the mod to be disabled. 71 | # 72 | # Returns: void 73 | static func disable_mod(mod_data: ModData) -> void: 74 | 75 | # Currently this is the only thing we do, but it is better to expose 76 | # this function like this for further changes 77 | ModLoader._disable_mod(mod_data) 78 | -------------------------------------------------------------------------------- /addons/mod_loader/api/profile.gd: -------------------------------------------------------------------------------- 1 | # This Class provides methods for working with user profiles. 2 | class_name ModLoaderUserProfile 3 | extends Object 4 | 5 | 6 | const LOG_NAME := "ModLoader:UserProfile" 7 | 8 | # The path where the Mod User Profiles data is stored. 9 | const FILE_PATH_USER_PROFILES := "user://mod_user_profiles.json" 10 | 11 | 12 | # API profile functions 13 | # ============================================================================= 14 | 15 | 16 | # Enables a mod - it will be loaded on the next game start 17 | # 18 | # Parameters: 19 | # - mod_id (String): The ID of the mod to enable. 20 | # - user_profile (ModUserProfile): (Optional) The user profile to enable the mod for. Default is the current user profile. 21 | # 22 | # Returns: bool 23 | static func enable_mod(mod_id: String, user_profile := ModLoaderStore.current_user_profile) -> bool: 24 | return _set_mod_state(mod_id, user_profile.name, true) 25 | 26 | 27 | # Disables a mod - it will not be loaded on the next game start 28 | # 29 | # Parameters: 30 | # - mod_id (String): The ID of the mod to disable. 31 | # - user_profile (ModUserProfile): (Optional) The user profile to disable the mod for. Default is the current user profile. 32 | # 33 | # Returns: bool 34 | static func disable_mod(mod_id: String, user_profile := ModLoaderStore.current_user_profile) -> bool: 35 | return _set_mod_state(mod_id, user_profile.name, false) 36 | 37 | 38 | # Sets the current config for a mod in a user profile's mod_list. 39 | # 40 | # Parameters: 41 | # - mod_id (String): The ID of the mod. 42 | # - mod_config (ModConfig): The mod config to set as the current config. 43 | # - user_profile (ModUserProfile): (Optional) The user profile to update. Default is the current user profile. 44 | # 45 | # Returns: bool 46 | static func set_mod_current_config(mod_id: String, mod_config: ModConfig, user_profile := ModLoaderStore.current_user_profile) -> bool: 47 | # Verify whether the mod_id is present in the profile's mod_list. 48 | if not _is_mod_id_in_mod_list(mod_id, user_profile.name): 49 | return false 50 | 51 | # Update the current config in the mod_list of the user profile 52 | user_profile.mod_list[mod_id].current_config = mod_config.name 53 | 54 | # Store the new profile in the json file 55 | var is_save_success := _save() 56 | 57 | if is_save_success: 58 | ModLoaderLog.debug("Set the \"current_config\" of \"%s\" to \"%s\" in user profile \"%s\" " % [mod_id, mod_config.name, user_profile.name], LOG_NAME) 59 | 60 | return is_save_success 61 | 62 | 63 | # Creates a new user profile with the given name, using the currently loaded mods as the mod list. 64 | # 65 | # Parameters: 66 | # - profile_name (String): The name of the new user profile (must be unique). 67 | # 68 | # Returns: bool 69 | static func create_profile(profile_name: String) -> bool: 70 | # Verify that the profile name is not already in use 71 | if ModLoaderStore.user_profiles.has(profile_name): 72 | ModLoaderLog.error("User profile with the name of \"%s\" already exists." % profile_name, LOG_NAME) 73 | return false 74 | 75 | var mod_list := _generate_mod_list() 76 | 77 | var new_profile := _create_new_profile(profile_name, mod_list) 78 | 79 | # If there was an error creating the new user profile return 80 | if not new_profile: 81 | return false 82 | 83 | # Store the new profile in the ModLoaderStore 84 | ModLoaderStore.user_profiles[profile_name] = new_profile 85 | 86 | # Set it as the current profile 87 | ModLoaderStore.current_user_profile = ModLoaderStore.user_profiles[profile_name] 88 | 89 | # Store the new profile in the json file 90 | var is_save_success := _save() 91 | 92 | if is_save_success: 93 | ModLoaderLog.debug("Created new user profile \"%s\"" % profile_name, LOG_NAME) 94 | 95 | return is_save_success 96 | 97 | 98 | # Renames an existing user profile. 99 | # 100 | # Parameters: 101 | # - old_profile_name (String): The current name for the user profile (must be unique). 102 | # - new_profile_name (String): The new name for the user profile (must be unique). 103 | # 104 | # Returns: bool 105 | static func rename_profile(old_profile_name: String, new_profile_name: String) -> bool: 106 | # Verify that the old profile name is already in use 107 | if not ModLoaderStore.user_profiles.has(old_profile_name): 108 | ModLoaderLog.error("User profile with the name of \"%s\" does not exist." % old_profile_name, LOG_NAME) 109 | return false 110 | 111 | # Verify that the new profile_name is not already in use 112 | if ModLoaderStore.user_profiles.has(new_profile_name): 113 | ModLoaderLog.error("User profile with the name of \"%s\" already exists." % new_profile_name, LOG_NAME) 114 | return false 115 | 116 | # Rename user profile 117 | var profile_renamed := ModLoaderStore.user_profiles[old_profile_name].duplicate() as ModUserProfile 118 | profile_renamed.name = new_profile_name 119 | 120 | # Remove old profile entry, replace it with new name entry in the ModLoaderStore 121 | ModLoaderStore.user_profiles.erase(old_profile_name) 122 | ModLoaderStore.user_profiles[new_profile_name] = profile_renamed 123 | 124 | # Set it as the current profile if it was the current profile 125 | if ModLoaderStore.current_user_profile.name == old_profile_name: 126 | set_profile(profile_renamed) 127 | 128 | # Store the new profile in the json file 129 | var is_save_success := _save() 130 | 131 | if is_save_success: 132 | ModLoaderLog.debug("Renamed user profile from \"%s\" to \"%s\"" % [old_profile_name, new_profile_name], LOG_NAME) 133 | 134 | return is_save_success 135 | 136 | 137 | # Sets the current user profile to the given user profile. 138 | # 139 | # Parameters: 140 | # - user_profile (ModUserProfile): The user profile to set as the current profile. 141 | # 142 | # Returns: bool 143 | static func set_profile(user_profile: ModUserProfile) -> bool: 144 | # Check if the profile name is unique 145 | if not ModLoaderStore.user_profiles.has(user_profile.name): 146 | ModLoaderLog.error("User profile with name \"%s\" not found." % user_profile.name, LOG_NAME) 147 | return false 148 | 149 | # Update the current_user_profile in the ModLoaderStore 150 | ModLoaderStore.current_user_profile = ModLoaderStore.user_profiles[user_profile.name] 151 | 152 | # Save changes in the json file 153 | var is_save_success := _save() 154 | 155 | if is_save_success: 156 | ModLoaderLog.debug("Current user profile set to \"%s\"" % user_profile.name, LOG_NAME) 157 | 158 | return is_save_success 159 | 160 | 161 | # Deletes the given user profile. 162 | # 163 | # Parameters: 164 | # - user_profile (ModUserProfile): The user profile to delete. 165 | # 166 | # Returns: bool 167 | static func delete_profile(user_profile: ModUserProfile) -> bool: 168 | # If the current_profile is about to get deleted log an error 169 | if ModLoaderStore.current_user_profile.name == user_profile.name: 170 | ModLoaderLog.error(str( 171 | "You cannot delete the currently selected user profile \"%s\" " + 172 | "because it is currently in use. Please switch to a different profile before deleting this one.") % user_profile.name, 173 | LOG_NAME) 174 | return false 175 | 176 | # Deleting the default profile is not allowed 177 | if user_profile.name == "default": 178 | ModLoaderLog.error("You can't delete the default profile", LOG_NAME) 179 | return false 180 | 181 | # Delete the user profile 182 | if not ModLoaderStore.user_profiles.erase(user_profile.name): 183 | # Erase returns false if the the key is not present in user_profiles 184 | ModLoaderLog.error("User profile with name \"%s\" not found." % user_profile.name, LOG_NAME) 185 | return false 186 | 187 | # Save profiles to the user profiles JSON file 188 | var is_save_success := _save() 189 | 190 | if is_save_success: 191 | ModLoaderLog.debug("Deleted user profile \"%s\"" % user_profile.name, LOG_NAME) 192 | 193 | return is_save_success 194 | 195 | 196 | # Returns the current user profile. 197 | # 198 | # Returns: ModUserProfile 199 | static func get_current() -> ModUserProfile: 200 | return ModLoaderStore.current_user_profile 201 | 202 | 203 | # Returns the user profile with the given name. 204 | # 205 | # Parameters: 206 | # - profile_name (String): The name of the user profile to retrieve. 207 | # 208 | # Returns: ModUserProfile or null if not found 209 | static func get_profile(profile_name: String) -> ModUserProfile: 210 | if not ModLoaderStore.user_profiles.has(profile_name): 211 | ModLoaderLog.error("User profile with name \"%s\" not found." % profile_name, LOG_NAME) 212 | return null 213 | 214 | return ModLoaderStore.user_profiles[profile_name] 215 | 216 | 217 | # Returns an array containing all user profiles stored in ModLoaderStore. 218 | # 219 | # Returns: Array of ModUserProfile Objects 220 | static func get_all_as_array() -> Array: 221 | var user_profiles := [] 222 | 223 | for user_profile_name in ModLoaderStore.user_profiles.keys(): 224 | user_profiles.push_back(ModLoaderStore.user_profiles[user_profile_name]) 225 | 226 | return user_profiles 227 | 228 | 229 | # Returns true if the Mod User Profiles are initialized. 230 | # On the first execution of the game, user profiles might not yet be created. 231 | # Use this method to check if everything is ready to interact with the ModLoaderUserProfile API. 232 | static func is_initialized() -> bool: 233 | return _ModLoaderFile.file_exists(FILE_PATH_USER_PROFILES) 234 | 235 | 236 | # Internal profile functions 237 | # ============================================================================= 238 | 239 | 240 | # Update the global list of disabled mods based on the current user profile 241 | # The user profile will override the disabled_mods property that can be set via the options resource in the editor. 242 | # Example: If "Mod-TestMod" is set in disabled_mods via the editor, the mod will appear disabled in the user profile. 243 | # If the user then enables the mod in the profile the entry in disabled_mods will be removed. 244 | static func _update_disabled_mods() -> void: 245 | var current_user_profile: ModUserProfile 246 | 247 | current_user_profile = get_current() 248 | 249 | # Check if a current user profile is set 250 | if not current_user_profile: 251 | ModLoaderLog.info("There is no current user profile. The \"default\" profile will be created.", LOG_NAME) 252 | return 253 | 254 | # Iterate through the mod list in the current user profile to find disabled mods 255 | for mod_id in current_user_profile.mod_list: 256 | var mod_list_entry: Dictionary = current_user_profile.mod_list[mod_id] 257 | if ModLoaderStore.mod_data.has(mod_id): 258 | ModLoaderStore.mod_data[mod_id].is_active = mod_list_entry.is_active 259 | 260 | ModLoaderLog.debug( 261 | "Updated the active state of all mods, based on the current user profile \"%s\"" 262 | % current_user_profile.name, 263 | LOG_NAME) 264 | 265 | 266 | # This function updates the mod lists of all user profiles with newly loaded mods that are not already present. 267 | # It does so by comparing the current set of loaded mods with the mod list of each user profile, and adding any missing mods. 268 | # Additionally, it checks for and deletes any mods from each profile's mod list that are no longer installed on the system. 269 | static func _update_mod_lists() -> bool: 270 | # Generate a list of currently present mods by combining the mods 271 | # in mod_data and ml_options.disabled_mods from ModLoaderStore. 272 | var current_mod_list := _generate_mod_list() 273 | 274 | # Iterate over all user profiles 275 | for profile_name in ModLoaderStore.user_profiles.keys(): 276 | var profile: ModUserProfile = ModLoaderStore.user_profiles[profile_name] 277 | 278 | # Merge the profiles mod_list with the previously created current_mod_list 279 | profile.mod_list.merge(current_mod_list) 280 | 281 | var update_mod_list := _update_mod_list(profile.mod_list) 282 | 283 | profile.mod_list = update_mod_list 284 | 285 | # Save the updated user profiles to the JSON file 286 | var is_save_success := _save() 287 | 288 | if is_save_success: 289 | ModLoaderLog.debug("Updated the mod lists of all user profiles", LOG_NAME) 290 | 291 | return is_save_success 292 | 293 | 294 | # This function takes a mod_list dictionary and optional mod_data dictionary as input and returns 295 | # an updated mod_list dictionary. It iterates over each mod ID in the mod list, checks if the mod 296 | # is still installed and if the current_config is present. If the mod is not installed or the current 297 | # config is missing, the mod is removed or its current_config is reset to the default configuration. 298 | static func _update_mod_list(mod_list: Dictionary, mod_data := ModLoaderStore.mod_data) -> Dictionary: 299 | var updated_mod_list := mod_list.duplicate(true) 300 | 301 | # Iterate over each mod ID in the mod list 302 | for mod_id in updated_mod_list.keys(): 303 | var mod_list_entry: Dictionary = updated_mod_list[mod_id] 304 | 305 | # Check if the current config doesn't exist 306 | # This can happen if the config file was manually deleted 307 | if mod_list_entry.has("current_config") and _ModLoaderPath.get_path_to_mod_config_file(mod_id, mod_list_entry.current_config).empty(): 308 | # If the current config doesn't exist, reset it to the default configuration 309 | mod_list_entry.current_config = ModLoaderConfig.DEFAULT_CONFIG_NAME 310 | 311 | if ( 312 | # If the mod is not loaded 313 | not mod_data.has(mod_id) and 314 | # Check if the entry has a zip_path key 315 | mod_list_entry.has("zip_path") and 316 | # Check if the entry has a zip_path 317 | not mod_list_entry.zip_path.empty() and 318 | # Check if the zip file for the mod doesn't exist 319 | not _ModLoaderFile.file_exists(mod_list_entry.zip_path) 320 | ): 321 | # If the mod directory doesn't exist, 322 | # the mod is no longer installed and can be removed from the mod list 323 | ModLoaderLog.debug( 324 | "Mod \"%s\" has been deleted from all user profiles as the corresponding zip file no longer exists at path \"%s\"." 325 | % [mod_id, mod_list_entry.zip_path], 326 | LOG_NAME, 327 | true 328 | ) 329 | 330 | updated_mod_list.erase(mod_id) 331 | continue 332 | 333 | updated_mod_list[mod_id] = mod_list_entry 334 | 335 | return updated_mod_list 336 | 337 | 338 | # Generates a dictionary with data to be stored for each mod. 339 | static func _generate_mod_list() -> Dictionary: 340 | var mod_list := {} 341 | 342 | # Create a mod_list with the currently loaded mods 343 | for mod_id in ModLoaderStore.mod_data.keys(): 344 | mod_list[mod_id] = _generate_mod_list_entry(mod_id, true) 345 | 346 | # Add the deactivated mods to the list 347 | for mod_id in ModLoaderStore.ml_options.disabled_mods: 348 | mod_list[mod_id] = _generate_mod_list_entry(mod_id, false) 349 | 350 | return mod_list 351 | 352 | 353 | # Generates a mod list entry dictionary with the given mod ID and active status. 354 | # If the mod has a config schema, sets the 'current_config' key to the current_config stored in the Mods ModData. 355 | static func _generate_mod_list_entry(mod_id: String, is_active: bool) -> Dictionary: 356 | var mod_list_entry := {} 357 | 358 | # Set the mods active state 359 | mod_list_entry.is_active = is_active 360 | 361 | # Set the mods zip path if available 362 | if ModLoaderStore.mod_data.has(mod_id): 363 | mod_list_entry.zip_path = ModLoaderStore.mod_data[mod_id].zip_path 364 | 365 | # Set the current_config if the mod has a config schema and is active 366 | if is_active and not ModLoaderConfig.get_config_schema(mod_id).empty(): 367 | var current_config: ModConfig = ModLoaderStore.mod_data[mod_id].current_config 368 | if current_config and current_config.is_valid: 369 | # Set to the current_config name if valid 370 | mod_list_entry.current_config = current_config.name 371 | else: 372 | # If not valid revert to the default config 373 | mod_list_entry.current_config = ModLoaderConfig.DEFAULT_CONFIG_NAME 374 | 375 | return mod_list_entry 376 | 377 | 378 | # Handles the activation or deactivation of a mod in a user profile. 379 | static func _set_mod_state(mod_id: String, profile_name: String, activate: bool) -> bool: 380 | # Verify whether the mod_id is present in the profile's mod_list. 381 | if not _is_mod_id_in_mod_list(mod_id, profile_name): 382 | return false 383 | 384 | # Check if it is a locked mod 385 | if ModLoaderStore.mod_data.has(mod_id) and ModLoaderStore.mod_data[mod_id].is_locked: 386 | ModLoaderLog.error( 387 | "Unable to disable mod \"%s\" as it is marked as locked. Locked mods: %s" 388 | % [mod_id, ModLoaderStore.ml_options.locked_mods], 389 | LOG_NAME) 390 | return false 391 | 392 | # Handle mod state 393 | # Set state for user profile 394 | ModLoaderStore.user_profiles[profile_name].mod_list[mod_id].is_active = activate 395 | # Set state in the ModData 396 | ModLoaderStore.mod_data[mod_id].is_active = activate 397 | 398 | # Save profiles to the user profiles JSON file 399 | var is_save_success := _save() 400 | 401 | if is_save_success: 402 | ModLoaderLog.debug("Mod activation state changed: mod_id=%s activate=%s profile_name=%s" % [mod_id, activate, profile_name], LOG_NAME) 403 | 404 | return is_save_success 405 | 406 | 407 | # Checks whether a given mod_id is present in the mod_list of the specified user profile. 408 | # Returns True if the mod_id is present, False otherwise. 409 | static func _is_mod_id_in_mod_list(mod_id: String, profile_name: String) -> bool: 410 | # Get the user profile 411 | var user_profile := get_profile(profile_name) 412 | if not user_profile: 413 | # Return false if there is an error getting the user profile 414 | return false 415 | 416 | # Return false if the mod_id is not in the profile's mod_list 417 | if not user_profile.mod_list.has(mod_id): 418 | ModLoaderLog.error("Mod id \"%s\" not found in the \"mod_list\" of user profile \"%s\"." % [mod_id, profile_name], LOG_NAME) 419 | return false 420 | 421 | # Return true if the mod_id is in the profile's mod_list 422 | return true 423 | 424 | 425 | # Creates a new Profile with the given name and mod list. 426 | # Returns the newly created Profile object. 427 | static func _create_new_profile(profile_name: String, mod_list: Dictionary) -> ModUserProfile: 428 | var new_profile := ModUserProfile.new() 429 | 430 | # If no name is provided, log an error and return null 431 | if profile_name == "": 432 | ModLoaderLog.error("Please provide a name for the new profile", LOG_NAME) 433 | return null 434 | 435 | # Set the profile name 436 | new_profile.name = profile_name 437 | 438 | # If no mods are specified in the mod_list, log a warning and return the new profile 439 | if mod_list.keys().size() == 0: 440 | ModLoaderLog.info("No mod_ids inside \"mod_list\" for user profile \"%s\" " % profile_name, LOG_NAME) 441 | return new_profile 442 | 443 | # Set the mod_list 444 | new_profile.mod_list = _update_mod_list(mod_list) 445 | 446 | return new_profile 447 | 448 | 449 | # Loads user profiles from the JSON file and adds them to ModLoaderStore. 450 | static func _load() -> bool: 451 | # Load JSON data from the user profiles file 452 | var data := _ModLoaderFile.get_json_as_dict(FILE_PATH_USER_PROFILES) 453 | 454 | # If there is no data, log an error and return 455 | if data.empty(): 456 | ModLoaderLog.error("No profile file found at \"%s\"" % FILE_PATH_USER_PROFILES, LOG_NAME) 457 | return false 458 | 459 | # Loop through each profile in the data and add them to ModLoaderStore 460 | for profile_name in data.profiles.keys(): 461 | # Get the profile data from the JSON object 462 | var profile_data: Dictionary = data.profiles[profile_name] 463 | 464 | # Create a new profile object and add it to ModLoaderStore.user_profiles 465 | var new_profile := _create_new_profile(profile_name, profile_data.mod_list) 466 | ModLoaderStore.user_profiles[profile_name] = new_profile 467 | 468 | # Set the current user profile to the one specified in the data 469 | ModLoaderStore.current_user_profile = ModLoaderStore.user_profiles[data.current_profile] 470 | 471 | return true 472 | 473 | 474 | # Saves the user profiles in the ModLoaderStore to the user profiles JSON file. 475 | static func _save() -> bool: 476 | # Initialize a dictionary to hold the serialized user profiles data 477 | var save_dict := { 478 | "current_profile": "", 479 | "profiles": {} 480 | } 481 | 482 | # Set the current profile name in the save_dict 483 | save_dict.current_profile = ModLoaderStore.current_user_profile.name 484 | 485 | # Serialize the mod_list data for each user profile and add it to the save_dict 486 | for profile_name in ModLoaderStore.user_profiles.keys(): 487 | var profile: ModUserProfile = ModLoaderStore.user_profiles[profile_name] 488 | 489 | # Init the profile dict 490 | save_dict.profiles[profile.name] = {} 491 | # Init the mod_list dict 492 | save_dict.profiles[profile.name].mod_list = profile.mod_list 493 | 494 | # Save the serialized user profiles data to the user profiles JSON file 495 | return _ModLoaderFile.save_dictionary_to_json_file(save_dict, FILE_PATH_USER_PROFILES) 496 | -------------------------------------------------------------------------------- /addons/mod_loader/internal/cache.gd: -------------------------------------------------------------------------------- 1 | class_name _ModLoaderCache 2 | extends Reference 3 | 4 | 5 | # This Class provides methods for caching data. 6 | 7 | const CACHE_FILE_PATH = "user://mod_loader_cache.json" 8 | const LOG_NAME = "ModLoader:Cache" 9 | 10 | 11 | # ModLoaderStore is passed as parameter so the cache data can be loaded on ModLoaderStore._init() 12 | static func init_cache(_ModLoaderStore) -> void: 13 | if not _ModLoaderFile.file_exists(CACHE_FILE_PATH): 14 | _init_cache_file() 15 | return 16 | 17 | _load_file(_ModLoaderStore) 18 | 19 | 20 | # Adds data to the cache 21 | static func add_data(key: String, data: Dictionary) -> Dictionary: 22 | if ModLoaderStore.cache.has(key): 23 | ModLoaderLog.error("key: \"%s\" already exists in \"ModLoaderStore.cache\"" % key, LOG_NAME) 24 | return {} 25 | 26 | ModLoaderStore.cache[key] = data 27 | 28 | return ModLoaderStore.cache[key] 29 | 30 | 31 | # Get data from a specific key 32 | static func get_data(key: String) -> Dictionary: 33 | if not ModLoaderStore.cache.has(key): 34 | ModLoaderLog.info("key: \"%s\" not found in \"ModLoaderStore.cache\"" % key, LOG_NAME) 35 | return {} 36 | 37 | return ModLoaderStore.cache[key] 38 | 39 | 40 | # Get the entire cache dictionary 41 | static func get_cache() -> Dictionary: 42 | return ModLoaderStore.cache 43 | 44 | 45 | static func has_key(key: String) -> bool: 46 | return ModLoaderStore.cache.has(key) 47 | 48 | 49 | # Updates or adds data to the cache 50 | static func update_data(key: String, data: Dictionary) -> Dictionary: 51 | # If the key exists 52 | if has_key(key): 53 | # Update the data 54 | ModLoaderStore.cache[key].merge(data, true) 55 | else: 56 | ModLoaderLog.info("key: \"%s\" not found in \"ModLoaderStore.cache\" added as new data instead." % key, LOG_NAME, true) 57 | # Else add new data 58 | add_data(key, data) 59 | 60 | return ModLoaderStore.cache[key] 61 | 62 | 63 | # Remove data from the cache 64 | static func remove_data(key: String) -> void: 65 | if not ModLoaderStore.cache.has(key): 66 | ModLoaderLog.error("key: \"%s\" not found in \"ModLoaderStore.cache\"" % key, LOG_NAME) 67 | return 68 | 69 | ModLoaderStore.cache.erase(key) 70 | 71 | 72 | # Save the cache to the cache file 73 | static func save_to_file() -> void: 74 | _ModLoaderFile.save_dictionary_to_json_file(ModLoaderStore.cache, CACHE_FILE_PATH) 75 | 76 | 77 | # Load the cache file data and store it in ModLoaderStore 78 | # ModLoaderStore is passed as parameter so the cache data can be loaded on ModLoaderStore._init() 79 | static func _load_file(_ModLoaderStore = ModLoaderStore) -> void: 80 | _ModLoaderStore.cache = _ModLoaderFile.get_json_as_dict(CACHE_FILE_PATH) 81 | 82 | 83 | # Create an empty cache file 84 | static func _init_cache_file() -> void: 85 | _ModLoaderFile.save_dictionary_to_json_file({}, CACHE_FILE_PATH) 86 | -------------------------------------------------------------------------------- /addons/mod_loader/internal/cli.gd: -------------------------------------------------------------------------------- 1 | class_name _ModLoaderCLI 2 | extends Reference 3 | 4 | 5 | # This Class provides util functions for working with cli arguments. 6 | # Currently all of the included functions are internal and should only be used by the mod loader itself. 7 | 8 | const LOG_NAME := "ModLoader:CLI" 9 | 10 | 11 | # Check if the provided command line argument was present when launching the game 12 | static func is_running_with_command_line_arg(argument: String) -> bool: 13 | for arg in OS.get_cmdline_args(): 14 | if argument == arg.split("=")[0]: 15 | return true 16 | 17 | return false 18 | 19 | 20 | # Get the command line argument value if present when launching the game 21 | static func get_cmd_line_arg_value(argument: String) -> String: 22 | var args := _get_fixed_cmdline_args() 23 | 24 | for arg_index in args.size(): 25 | var arg := args[arg_index] as String 26 | 27 | var key := arg.split("=")[0] 28 | if key == argument: 29 | # format: `--arg=value` or `--arg="value"` 30 | if "=" in arg: 31 | var value := arg.trim_prefix(argument + "=") 32 | value = value.trim_prefix('"').trim_suffix('"') 33 | value = value.trim_prefix("'").trim_suffix("'") 34 | return value 35 | 36 | # format: `--arg value` or `--arg "value"` 37 | elif arg_index +1 < args.size() and not args[arg_index +1].begins_with("--"): 38 | return args[arg_index + 1] 39 | 40 | return "" 41 | 42 | 43 | static func _get_fixed_cmdline_args() -> PoolStringArray: 44 | return fix_godot_cmdline_args_string_space_splitting(OS.get_cmdline_args()) 45 | 46 | 47 | # Reverses a bug in Godot, which splits input strings at spaces even if they are quoted 48 | # e.g. `--arg="some value" --arg-two 'more value'` becomes `[ --arg="some, value", --arg-two, 'more, value' ]` 49 | static func fix_godot_cmdline_args_string_space_splitting(args: PoolStringArray) -> PoolStringArray: 50 | if not OS.has_feature("editor"): # only happens in editor builds 51 | return args 52 | if OS.has_feature("Windows"): # windows is unaffected 53 | return args 54 | 55 | var fixed_args := PoolStringArray([]) 56 | var fixed_arg := "" 57 | # if we encounter an argument that contains `=` followed by a quote, 58 | # or an argument that starts with a quote, take all following args and 59 | # concatenate them into one, until we find the closing quote 60 | for arg in args: 61 | var arg_string := arg as String 62 | if '="' in arg_string or '="' in fixed_arg or \ 63 | arg_string.begins_with('"') or fixed_arg.begins_with('"'): 64 | if not fixed_arg == "": 65 | fixed_arg += " " 66 | fixed_arg += arg_string 67 | if arg_string.ends_with('"'): 68 | fixed_args.append(fixed_arg.trim_prefix(" ")) 69 | fixed_arg = "" 70 | continue 71 | # same thing for single quotes 72 | elif "='" in arg_string or "='" in fixed_arg \ 73 | or arg_string.begins_with("'") or fixed_arg.begins_with("'"): 74 | if not fixed_arg == "": 75 | fixed_arg += " " 76 | fixed_arg += arg_string 77 | if arg_string.ends_with("'"): 78 | fixed_args.append(fixed_arg.trim_prefix(" ")) 79 | fixed_arg = "" 80 | continue 81 | 82 | else: 83 | fixed_args.append(arg_string) 84 | 85 | return fixed_args 86 | -------------------------------------------------------------------------------- /addons/mod_loader/internal/dependency.gd: -------------------------------------------------------------------------------- 1 | class_name _ModLoaderDependency 2 | extends Reference 3 | 4 | 5 | # This Class provides methods for working with dependencies. 6 | # Currently all of the included methods are internal and should only be used by the mod loader itself. 7 | 8 | const LOG_NAME := "ModLoader:Dependency" 9 | 10 | 11 | # Run dependency checks on a mod, checking any dependencies it lists in its 12 | # mod_manifest (ie. its manifest.json file). If a mod depends on another mod that 13 | # hasn't been loaded, the dependent mod won't be loaded, if it is a required dependency. 14 | # 15 | # Parameters: 16 | # - mod: A ModData object representing the mod being checked. 17 | # - dependency_chain: An array that stores the IDs of the mods that have already 18 | # been checked to avoid circular dependencies. 19 | # - is_required: A boolean indicating whether the mod is a required or optional 20 | # dependency. Optional dependencies will not prevent the dependent mod from 21 | # loading if they are missing. 22 | # 23 | # Returns: A boolean indicating whether a circular dependency was detected. 24 | static func check_dependencies(mod: ModData, is_required := true, dependency_chain := []) -> bool: 25 | var dependency_type := "required" if is_required else "optional" 26 | # Get the dependency array based on the is_required flag 27 | var dependencies := mod.manifest.dependencies if is_required else mod.manifest.optional_dependencies 28 | # Get the ID of the mod being checked 29 | var mod_id := mod.dir_name 30 | 31 | ModLoaderLog.debug("Checking dependencies - mod_id: %s %s dependencies: %s" % [mod_id, dependency_type, dependencies], LOG_NAME) 32 | 33 | # Check for circular dependency 34 | if mod_id in dependency_chain: 35 | ModLoaderLog.debug("%s dependency check - circular dependency detected for mod with ID %s." % [dependency_type.capitalize(), mod_id], LOG_NAME) 36 | return true 37 | 38 | # Add mod_id to dependency_chain to avoid circular dependencies 39 | dependency_chain.append(mod_id) 40 | 41 | # Loop through each dependency listed in the mod's manifest 42 | for dependency_id in dependencies: 43 | # Check if dependency is missing 44 | if not ModLoaderStore.mod_data.has(dependency_id) or not ModLoaderStore.mod_data[dependency_id].is_loadable: 45 | # Skip to the next dependency if it's optional 46 | if not is_required: 47 | ModLoaderLog.info("Missing optional dependency - mod: -> %s dependency -> %s" % [mod_id, dependency_id], LOG_NAME) 48 | continue 49 | _handle_missing_dependency(mod_id, dependency_id) 50 | # Flag the mod so it's not loaded later 51 | mod.is_loadable = false 52 | else: 53 | var dependency: ModData = ModLoaderStore.mod_data[dependency_id] 54 | 55 | # Increase the importance score of the dependency by 1 56 | dependency.importance += 1 57 | ModLoaderLog.debug("%s dependency -> %s importance -> %s" % [dependency_type.capitalize(), dependency_id, dependency.importance], LOG_NAME) 58 | 59 | # Check if the dependency has any dependencies of its own 60 | if dependency.manifest.dependencies.size() > 0: 61 | if check_dependencies(dependency, is_required, dependency_chain): 62 | return true 63 | 64 | # Return false if all dependencies have been resolved 65 | return false 66 | 67 | 68 | # Run load before check on a mod, checking any load_before entries it lists in its 69 | # mod_manifest (ie. its manifest.json file). Add the mod to the dependency of the 70 | # mods inside the load_before array. 71 | static func check_load_before(mod: ModData) -> void: 72 | # Skip if no entries in load_before 73 | if mod.manifest.load_before.size() == 0: 74 | return 75 | 76 | ModLoaderLog.debug("Load before - In mod %s detected." % mod.dir_name, LOG_NAME) 77 | 78 | # For each mod id in load_before 79 | for load_before_id in mod.manifest.load_before: 80 | # Check if the load_before mod exists 81 | if not ModLoaderStore.mod_data.has(load_before_id): 82 | ModLoaderLog.debug("Load before - Skipping %s because it's missing" % load_before_id, LOG_NAME) 83 | continue 84 | 85 | var load_before_mod_dependencies := ModLoaderStore.mod_data[load_before_id].manifest.dependencies as PoolStringArray 86 | 87 | # Check if it's already a dependency 88 | if mod.dir_name in load_before_mod_dependencies: 89 | ModLoaderLog.debug("Load before - Skipping because it's already a dependency for %s" % load_before_id, LOG_NAME) 90 | continue 91 | 92 | # Add the mod to the dependency array 93 | load_before_mod_dependencies.append(mod.dir_name) 94 | ModLoaderStore.mod_data[load_before_id].manifest.dependencies = load_before_mod_dependencies 95 | 96 | ModLoaderLog.debug("Load before - Added %s as dependency for %s" % [mod.dir_name, load_before_id], LOG_NAME) 97 | 98 | 99 | # Get the load order of mods, using a custom sorter 100 | static func get_load_order(mod_data_array: Array) -> Array: 101 | # Add loadable mods to the mod load order array 102 | for mod in mod_data_array: 103 | mod = mod as ModData 104 | if mod.is_loadable: 105 | ModLoaderStore.mod_load_order.append(mod) 106 | 107 | # Sort mods by the importance value 108 | ModLoaderStore.mod_load_order.sort_custom(CompareImportance, "_compare_importance") 109 | return ModLoaderStore.mod_load_order 110 | 111 | 112 | # Handles a missing dependency for a given mod ID. Logs an error message indicating the missing dependency and adds 113 | # the dependency ID to the mod_missing_dependencies dictionary for the specified mod. 114 | static func _handle_missing_dependency(mod_id: String, dependency_id: String) -> void: 115 | ModLoaderLog.error("Missing dependency - mod: -> %s dependency -> %s" % [mod_id, dependency_id], LOG_NAME) 116 | # if mod is not present in the missing dependencies array 117 | if not ModLoaderStore.mod_missing_dependencies.has(mod_id): 118 | # add it 119 | ModLoaderStore.mod_missing_dependencies[mod_id] = [] 120 | 121 | ModLoaderStore.mod_missing_dependencies[mod_id].append(dependency_id) 122 | 123 | 124 | # Inner class so the sort function can be called by get_load_order() 125 | class CompareImportance: 126 | # Custom sorter that orders mods by important 127 | static func _compare_importance(a: ModData, b: ModData) -> bool: 128 | if a.importance > b.importance: 129 | return true # a -> b 130 | else: 131 | return false # b -> a 132 | -------------------------------------------------------------------------------- /addons/mod_loader/internal/file.gd: -------------------------------------------------------------------------------- 1 | class_name _ModLoaderFile 2 | extends Reference 3 | 4 | 5 | # This Class provides util functions for working with files. 6 | # Currently all of the included functions are internal and should only be used by the mod loader itself. 7 | 8 | const LOG_NAME := "ModLoader:File" 9 | 10 | 11 | # Get Data 12 | # ============================================================================= 13 | 14 | # Parses JSON from a given file path and returns a [Dictionary]. 15 | # Returns an empty [Dictionary] if no file exists (check with size() < 1) 16 | static func get_json_as_dict(path: String) -> Dictionary: 17 | var file := File.new() 18 | 19 | if !file.file_exists(path): 20 | file.close() 21 | return {} 22 | 23 | var error := file.open(path, File.READ) 24 | if not error == OK: 25 | ModLoaderLog.error("Error opening file. Code: %s" % error, LOG_NAME) 26 | 27 | var content := file.get_as_text() 28 | return _get_json_string_as_dict(content) 29 | 30 | 31 | # Parses JSON from a given [String] and returns a [Dictionary]. 32 | # Returns an empty [Dictionary] on error (check with size() < 1) 33 | static func _get_json_string_as_dict(string: String) -> Dictionary: 34 | if string == "": 35 | return {} 36 | var parsed := JSON.parse(string) 37 | if parsed.error: 38 | ModLoaderLog.error("Error parsing JSON", LOG_NAME) 39 | return {} 40 | if not parsed.result is Dictionary: 41 | ModLoaderLog.error("JSON is not a dictionary", LOG_NAME) 42 | return {} 43 | return parsed.result 44 | 45 | 46 | # Load the mod ZIP from the provided directory 47 | static func load_zips_in_folder(folder_path: String) -> Dictionary: 48 | var URL_MOD_STRUCTURE_DOCS := "https://wiki.godotmodding.com/#/guides/modding/mod_structure" 49 | var zip_data := {} 50 | 51 | var mod_dir := Directory.new() 52 | var mod_dir_open_error := mod_dir.open(folder_path) 53 | if not mod_dir_open_error == OK: 54 | ModLoaderLog.info("Can't open mod folder %s (Error: %s)" % [folder_path, mod_dir_open_error], LOG_NAME) 55 | return {} 56 | var mod_dir_listdir_error := mod_dir.list_dir_begin() 57 | if not mod_dir_listdir_error == OK: 58 | ModLoaderLog.error("Can't read mod folder %s (Error: %s)" % [folder_path, mod_dir_listdir_error], LOG_NAME) 59 | return {} 60 | 61 | # Get all zip folders inside the game mod folder 62 | while true: 63 | # Get the next file in the directory 64 | var mod_zip_file_name := mod_dir.get_next() 65 | 66 | # If there is no more file 67 | if mod_zip_file_name == "": 68 | # Stop loading mod zip files 69 | break 70 | 71 | # Ignore files that aren't ZIP or PCK 72 | if not mod_zip_file_name.get_extension() == "zip" and not mod_zip_file_name.get_extension() == "pck": 73 | continue 74 | 75 | # If the current file is a directory 76 | if mod_dir.current_is_dir(): 77 | # Go to the next file 78 | continue 79 | 80 | var mod_zip_path := folder_path.plus_file(mod_zip_file_name) 81 | var mod_zip_global_path := ProjectSettings.globalize_path(mod_zip_path) 82 | var is_mod_loaded_successfully := ProjectSettings.load_resource_pack(mod_zip_global_path, false) 83 | 84 | # Get the current directories inside UNPACKED_DIR 85 | # This array is used to determine which directory is new 86 | var current_mod_dirs := _ModLoaderPath.get_dir_paths_in_dir(_ModLoaderPath.get_unpacked_mods_dir_path()) 87 | 88 | # Create a backup to reference when the next mod is loaded 89 | var current_mod_dirs_backup := current_mod_dirs.duplicate() 90 | 91 | # Remove all directory paths that existed before, leaving only the one added last 92 | for previous_mod_dir in ModLoaderStore.previous_mod_dirs: 93 | current_mod_dirs.erase(previous_mod_dir) 94 | 95 | # If the mod zip is not structured correctly, it may not be in the UNPACKED_DIR. 96 | if current_mod_dirs.empty(): 97 | ModLoaderLog.fatal( 98 | "The mod zip at path \"%s\" does not have the correct file structure. For more information, please visit \"%s\"." 99 | % [mod_zip_global_path, URL_MOD_STRUCTURE_DOCS], 100 | LOG_NAME 101 | ) 102 | continue 103 | 104 | # The key is the mod_id of the latest loaded mod, and the value is the path to the zip file 105 | zip_data[current_mod_dirs[0].get_slice("/", 3)] = mod_zip_global_path 106 | 107 | # Update previous_mod_dirs in ModLoaderStore to use for the next mod 108 | ModLoaderStore.previous_mod_dirs = current_mod_dirs_backup 109 | 110 | # Notifies developer of an issue with Godot, where using `load_resource_pack` 111 | # in the editor WIPES the entire virtual res:// directory the first time you 112 | # use it. This means that unpacked mods are no longer accessible, because they 113 | # no longer exist in the file system. So this warning basically says 114 | # "don't use ZIPs with unpacked mods!" 115 | # https://github.com/godotengine/godot/issues/19815 116 | # https://github.com/godotengine/godot/issues/16798 117 | if OS.has_feature("editor") and not ModLoaderStore.has_shown_editor_zips_warning: 118 | ModLoaderLog.warning(str( 119 | "Loading any resource packs (.zip/.pck) with `load_resource_pack` will WIPE the entire virtual res:// directory. ", 120 | "If you have any unpacked mods in ", _ModLoaderPath.get_unpacked_mods_dir_path(), ", they will not be loaded. ", 121 | "Please unpack your mod ZIPs instead, and add them to ", _ModLoaderPath.get_unpacked_mods_dir_path()), LOG_NAME) 122 | ModLoaderStore.has_shown_editor_zips_warning = true 123 | 124 | ModLoaderLog.debug("Found mod ZIP: %s" % mod_zip_global_path, LOG_NAME) 125 | 126 | # If there was an error loading the mod zip file 127 | if not is_mod_loaded_successfully: 128 | # Log the error and continue with the next file 129 | ModLoaderLog.error("%s failed to load." % mod_zip_file_name, LOG_NAME) 130 | continue 131 | 132 | # Mod successfully loaded! 133 | ModLoaderLog.success("%s loaded." % mod_zip_file_name, LOG_NAME) 134 | 135 | mod_dir.list_dir_end() 136 | 137 | return zip_data 138 | 139 | 140 | # Save Data 141 | # ============================================================================= 142 | 143 | # Saves a dictionary to a file, as a JSON string 144 | static func _save_string_to_file(save_string: String, filepath: String) -> bool: 145 | # Create directory if it doesn't exist yet 146 | var file_directory := filepath.get_base_dir() 147 | var dir := Directory.new() 148 | 149 | _code_note(str( 150 | "View error codes here:", 151 | "https://docs.godotengine.org/en/stable/classes/class_%40globalscope.html#enum-globalscope-error" 152 | )) 153 | 154 | if not dir.dir_exists(file_directory): 155 | var makedir_error := dir.make_dir_recursive(file_directory) 156 | if not makedir_error == OK: 157 | ModLoaderLog.fatal("Encountered an error (%s) when attempting to create a directory, with the path: %s" % [makedir_error, file_directory], LOG_NAME) 158 | return false 159 | 160 | var file := File.new() 161 | 162 | # Save data to the file 163 | var fileopen_error := file.open(filepath, File.WRITE) 164 | 165 | if not fileopen_error == OK: 166 | ModLoaderLog.fatal("Encountered an error (%s) when attempting to write to a file, with the path: %s" % [fileopen_error, filepath], LOG_NAME) 167 | return false 168 | 169 | file.store_string(save_string) 170 | file.close() 171 | 172 | return true 173 | 174 | 175 | # Saves a dictionary to a file, as a JSON string 176 | static func save_dictionary_to_json_file(data: Dictionary, filepath: String) -> bool: 177 | var json_string := JSON.print(data, "\t") 178 | return _save_string_to_file(json_string, filepath) 179 | 180 | 181 | # Remove Data 182 | # ============================================================================= 183 | 184 | # Removes a file from the given path 185 | static func remove_file(file_path: String) -> bool: 186 | var dir := Directory.new() 187 | 188 | if not dir.file_exists(file_path): 189 | ModLoaderLog.error("No file found at \"%s\"" % file_path, LOG_NAME) 190 | return false 191 | 192 | var error := dir.remove(file_path) 193 | 194 | if error: 195 | ModLoaderLog.error( 196 | "Encountered an error (%s) when attempting to remove the file, with the path: %s" 197 | % [error, file_path], 198 | LOG_NAME 199 | ) 200 | return false 201 | 202 | return true 203 | 204 | 205 | # Checks 206 | # ============================================================================= 207 | 208 | static func file_exists(path: String) -> bool: 209 | var file := File.new() 210 | var exists := file.file_exists(path) 211 | 212 | # If the file is not found, check if it has been remapped because it is a Resource. 213 | if not exists: 214 | exists = ResourceLoader.exists(path) 215 | 216 | return exists 217 | 218 | 219 | static func dir_exists(path: String) -> bool: 220 | var dir := Directory.new() 221 | return dir.dir_exists(path) 222 | 223 | 224 | # Internal util functions 225 | # ============================================================================= 226 | # This are duplicates of the functions in mod_loader_utils.gd to prevent 227 | # a cyclic reference error. 228 | 229 | # This is a dummy func. It is exclusively used to show notes in the code that 230 | # stay visible after decompiling a PCK, as is primarily intended to assist new 231 | # modders in understanding and troubleshooting issues. 232 | static func _code_note(_msg:String): 233 | pass 234 | -------------------------------------------------------------------------------- /addons/mod_loader/internal/godot.gd: -------------------------------------------------------------------------------- 1 | class_name _ModLoaderGodot 2 | extends Object 3 | 4 | 5 | # This Class provides methods for interacting with Godot. 6 | # Currently all of the included methods are internal and should only be used by the mod loader itself. 7 | 8 | const LOG_NAME := "ModLoader:Godot" 9 | const AUTOLOAD_CONFIG_HELP_MSG := "To configure your autoloads, go to Project > Project Settings > Autoload." 10 | 11 | 12 | # Check if autoload_name_before is before autoload_name_after 13 | # Returns a bool if the position does not match. 14 | # Optionally triggers a fatal error 15 | static func check_autoload_order(autoload_name_before: String, autoload_name_after: String, trigger_error := false) -> bool: 16 | var autoload_name_before_index := get_autoload_index(autoload_name_before) 17 | var autoload_name_after_index := get_autoload_index(autoload_name_after) 18 | 19 | # Check if the Store is before the ModLoader 20 | if not autoload_name_before_index < autoload_name_after_index: 21 | var error_msg := ( 22 | "Expected %s ( position: %s ) to be loaded before %s ( position: %s ). " 23 | % [autoload_name_before, autoload_name_before_index, autoload_name_after, autoload_name_after_index] 24 | ) 25 | var help_msg := AUTOLOAD_CONFIG_HELP_MSG if OS.has_feature("editor") else "" 26 | 27 | if trigger_error: 28 | var final_message = error_msg + help_msg 29 | push_error(final_message) 30 | ModLoaderLog._write_to_log_file(final_message) 31 | ModLoaderLog._write_to_log_file(JSON.print(get_stack(), " ")) 32 | assert(false, final_message) 33 | 34 | return false 35 | 36 | return true 37 | 38 | 39 | # Check the index position of the provided autoload (0 = 1st, 1 = 2nd, etc). 40 | # Returns a bool if the position does not match. 41 | # Optionally triggers a fatal error 42 | static func check_autoload_position(autoload_name: String, position_index: int, trigger_error := false) -> bool: 43 | var autoload_array := get_autoload_array() 44 | var autoload_index := autoload_array.find(autoload_name) 45 | var position_matches := autoload_index == position_index 46 | 47 | if not position_matches and trigger_error: 48 | var error_msg := ( 49 | "Expected %s to be the autoload in position %s, but this is currently %s. " 50 | % [autoload_name, str(position_index + 1), autoload_array[position_index]] 51 | ) 52 | var help_msg := AUTOLOAD_CONFIG_HELP_MSG if OS.has_feature("editor") else "" 53 | var final_message = error_msg + help_msg 54 | 55 | push_error(final_message) 56 | ModLoaderLog._write_to_log_file(final_message) 57 | ModLoaderLog._write_to_log_file(JSON.print(get_stack(), " ")) 58 | assert(false, final_message) 59 | 60 | return position_matches 61 | 62 | 63 | # Get an array of all autoloads -> ["autoload/AutoloadName", ...] 64 | static func get_autoload_array() -> Array: 65 | var autoloads := [] 66 | 67 | # Get all autoload settings 68 | for prop in ProjectSettings.get_property_list(): 69 | var name: String = prop.name 70 | if name.begins_with("autoload/"): 71 | autoloads.append(name.trim_prefix("autoload/")) 72 | 73 | return autoloads 74 | 75 | 76 | # Get the index of a specific autoload 77 | static func get_autoload_index(autoload_name: String) -> int: 78 | var autoloads := get_autoload_array() 79 | var autoload_index := autoloads.find(autoload_name) 80 | 81 | return autoload_index 82 | -------------------------------------------------------------------------------- /addons/mod_loader/internal/mod_loader_utils.gd: -------------------------------------------------------------------------------- 1 | class_name ModLoaderUtils 2 | extends Node 3 | 4 | 5 | const LOG_NAME := "ModLoader:ModLoaderUtils" 6 | 7 | 8 | # This is a dummy func. It is exclusively used to show notes in the code that 9 | # stay visible after decompiling a PCK, as is primarily intended to assist new 10 | # modders in understanding and troubleshooting issues 11 | static func _code_note(_msg:String): 12 | pass 13 | 14 | 15 | # Returns an empty String if the key does not exist or is not type of String 16 | static func get_string_from_dict(dict: Dictionary, key: String) -> String: 17 | if not dict.has(key): 18 | return "" 19 | 20 | if not dict[key] is String: 21 | return "" 22 | 23 | return dict[key] 24 | 25 | 26 | # Returns an empty Array if the key does not exist or is not type of Array 27 | static func get_array_from_dict(dict: Dictionary, key: String) -> Array: 28 | if not dict.has(key): 29 | return [] 30 | 31 | if not dict[key] is Array: 32 | return [] 33 | 34 | return dict[key] 35 | 36 | 37 | # Returns an empty Dictionary if the key does not exist or is not type of Dictionary 38 | static func get_dict_from_dict(dict: Dictionary, key: String) -> Dictionary: 39 | if not dict.has(key): 40 | return {} 41 | 42 | if not dict[key] is Dictionary: 43 | return {} 44 | 45 | return dict[key] 46 | 47 | 48 | # Works like [method Dictionary.has_all], 49 | # but allows for more specific errors if a field is missing 50 | static func dict_has_fields(dict: Dictionary, required_fields: Array) -> bool: 51 | var missing_fields := required_fields 52 | 53 | for key in dict.keys(): 54 | if(required_fields.has(key)): 55 | missing_fields.erase(key) 56 | 57 | if missing_fields.size() > 0: 58 | ModLoaderLog.fatal("Dictionary is missing required fields: %s" % missing_fields, LOG_NAME) 59 | return false 60 | 61 | return true 62 | 63 | 64 | # Register an array of classes to the global scope, since Godot only does that in the editor. 65 | static func register_global_classes_from_array(new_global_classes: Array) -> void: 66 | var registered_classes: Array = ProjectSettings.get_setting("_global_script_classes") 67 | var registered_class_icons: Dictionary = ProjectSettings.get_setting("_global_script_class_icons") 68 | 69 | for new_class in new_global_classes: 70 | if not _is_valid_global_class_dict(new_class): 71 | continue 72 | for old_class in registered_classes: 73 | if old_class.class == new_class.class: 74 | if OS.has_feature("editor"): 75 | ModLoaderLog.info('Class "%s" to be registered as global was already registered by the editor. Skipping.' % new_class.class, LOG_NAME) 76 | else: 77 | ModLoaderLog.info('Class "%s" to be registered as global already exists. Skipping.' % new_class.class, LOG_NAME) 78 | continue 79 | 80 | registered_classes.append(new_class) 81 | registered_class_icons[new_class.class] = "" # empty icon, does not matter 82 | 83 | ProjectSettings.set_setting("_global_script_classes", registered_classes) 84 | ProjectSettings.set_setting("_global_script_class_icons", registered_class_icons) 85 | 86 | 87 | # Checks if all required fields are in the given [Dictionary] 88 | # Format: { "base": "ParentClass", "class": "ClassName", "language": "GDScript", "path": "res://path/class_name.gd" } 89 | static func _is_valid_global_class_dict(global_class_dict: Dictionary) -> bool: 90 | var required_fields := ["base", "class", "language", "path"] 91 | if not global_class_dict.has_all(required_fields): 92 | ModLoaderLog.fatal("Global class to be registered is missing one of %s" % required_fields, LOG_NAME) 93 | return false 94 | 95 | if not _ModLoaderFile.file_exists(global_class_dict.path): 96 | ModLoaderLog.fatal('Class "%s" to be registered as global could not be found at given path "%s"' % 97 | [global_class_dict.class, global_class_dict.path], LOG_NAME) 98 | return false 99 | 100 | return true 101 | 102 | 103 | # Deprecated 104 | # ============================================================================= 105 | 106 | # Logs the error in red and a stack trace. Prefixed FATAL-ERROR 107 | # Stops the execution in editor 108 | # Always logged 109 | static func log_fatal(message: String, mod_name: String) -> void: 110 | ModLoaderDeprecated.deprecated_changed("ModLoaderUtils.log_fatal", "ModLoaderLog.fatal", "6.0.0") 111 | ModLoaderLog.fatal(message, mod_name) 112 | 113 | 114 | # Logs the message and pushed an error. Prefixed ERROR 115 | # Always logged 116 | static func log_error(message: String, mod_name: String) -> void: 117 | ModLoaderDeprecated.deprecated_changed("ModLoaderUtils.log_error", "ModLoaderLog.error", "6.0.0") 118 | ModLoaderLog.error(message, mod_name) 119 | 120 | 121 | # Logs the message and pushes a warning. Prefixed WARNING 122 | # Logged with verbosity level at or above warning (-v) 123 | static func log_warning(message: String, mod_name: String) -> void: 124 | ModLoaderDeprecated.deprecated_changed("ModLoaderUtils.log_warning", "ModLoaderLog.warning", "6.0.0") 125 | ModLoaderLog.warning(message, mod_name) 126 | 127 | 128 | # Logs the message. Prefixed INFO 129 | # Logged with verbosity level at or above info (-vv) 130 | static func log_info(message: String, mod_name: String) -> void: 131 | ModLoaderDeprecated.deprecated_changed("ModLoaderUtils.log_info", "ModLoaderLog.info", "6.0.0") 132 | ModLoaderLog.info(message, mod_name) 133 | 134 | 135 | # Logs the message. Prefixed SUCCESS 136 | # Logged with verbosity level at or above info (-vv) 137 | static func log_success(message: String, mod_name: String) -> void: 138 | ModLoaderDeprecated.deprecated_changed("ModLoaderUtils.log_success", "ModLoaderLog.success", "6.0.0") 139 | ModLoaderLog.success(message, mod_name) 140 | 141 | 142 | # Logs the message. Prefixed DEBUG 143 | # Logged with verbosity level at or above debug (-vvv) 144 | static func log_debug(message: String, mod_name: String) -> void: 145 | ModLoaderDeprecated.deprecated_changed("ModLoaderUtils.log_debug", "ModLoaderLog.debug", "6.0.0") 146 | ModLoaderLog.debug(message, mod_name) 147 | 148 | 149 | # Logs the message formatted with [method JSON.print]. Prefixed DEBUG 150 | # Logged with verbosity level at or above debug (-vvv) 151 | static func log_debug_json_print(message: String, json_printable, mod_name: String) -> void: 152 | ModLoaderDeprecated.deprecated_changed("ModLoaderUtils.log_debug_json_print", "ModLoaderLog.debug_json_print", "6.0.0") 153 | ModLoaderLog.debug_json_print(message, json_printable, mod_name) 154 | -------------------------------------------------------------------------------- /addons/mod_loader/internal/path.gd: -------------------------------------------------------------------------------- 1 | class_name _ModLoaderPath 2 | extends Reference 3 | 4 | 5 | # This Class provides util functions for working with paths. 6 | # Currently all of the included functions are internal and should only be used by the mod loader itself. 7 | 8 | const LOG_NAME := "ModLoader:Path" 9 | const MOD_CONFIG_DIR_PATH := "user://configs" 10 | 11 | 12 | # Get the path to a local folder. Primarily used to get the (packed) mods 13 | # folder, ie "res://mods" or the OS's equivalent, as well as the configs path 14 | static func get_local_folder_dir(subfolder: String = "") -> String: 15 | var game_install_directory := OS.get_executable_path().get_base_dir() 16 | 17 | if OS.get_name() == "OSX": 18 | game_install_directory = game_install_directory.get_base_dir().get_base_dir() 19 | 20 | # Fix for running the game through the Godot editor (as the EXE path would be 21 | # the editor's own EXE, which won't have any mod ZIPs) 22 | # if OS.is_debug_build(): 23 | if OS.has_feature("editor"): 24 | game_install_directory = "res://" 25 | 26 | return game_install_directory.plus_file(subfolder) 27 | 28 | 29 | # Get the path where override.cfg will be stored. 30 | # Not the same as the local folder dir (for mac) 31 | static func get_override_path() -> String: 32 | var base_path := "" 33 | if OS.has_feature("editor"): 34 | base_path = ProjectSettings.globalize_path("res://") 35 | else: 36 | # this is technically different to res:// in macos, but we want the 37 | # executable dir anyway, so it is exactly what we need 38 | base_path = OS.get_executable_path().get_base_dir() 39 | 40 | return base_path.plus_file("override.cfg") 41 | 42 | 43 | # Provide a path, get the file name at the end of the path 44 | static func get_file_name_from_path(path: String, make_lower_case := true, remove_extension := false) -> String: 45 | var file_name := path.get_file() 46 | 47 | if make_lower_case: 48 | file_name = file_name.to_lower() 49 | 50 | if remove_extension: 51 | file_name = file_name.trim_suffix("." + file_name.get_extension()) 52 | 53 | return file_name 54 | 55 | 56 | # Get a flat array of all files in the target directory. This was needed in the 57 | # original version of this script, before becoming deprecated. It may still be 58 | # used if DEBUG_ENABLE_STORING_FILEPATHS is true. 59 | # Source: https://gist.github.com/willnationsdev/00d97aa8339138fd7ef0d6bd42748f6e 60 | static func get_flat_view_dict(p_dir := "res://", p_match := "", p_match_is_regex := false) -> PoolStringArray: 61 | var data: PoolStringArray = [] 62 | var regex: RegEx 63 | if p_match_is_regex: 64 | regex = RegEx.new() 65 | var _compile_error: int = regex.compile(p_match) 66 | if not regex.is_valid(): 67 | return data 68 | 69 | var dirs := [p_dir] 70 | var first := true 71 | while not dirs.empty(): 72 | var dir := Directory.new() 73 | var dir_name: String = dirs.back() 74 | dirs.pop_back() 75 | 76 | if dir.open(dir_name) == OK: 77 | var _dirlist_error: int = dir.list_dir_begin() 78 | var file_name := dir.get_next() 79 | while file_name != "": 80 | if not dir_name == "res://": 81 | first = false 82 | # ignore hidden, temporary, or system content 83 | if not file_name.begins_with(".") and not file_name.get_extension() in ["tmp", "import"]: 84 | # If a directory, then add to list of directories to visit 85 | if dir.current_is_dir(): 86 | dirs.push_back(dir.get_current_dir().plus_file(file_name)) 87 | # If a file, check if we already have a record for the same name 88 | else: 89 | var path := dir.get_current_dir() + ("/" if not first else "") + file_name 90 | # grab all 91 | if not p_match: 92 | data.append(path) 93 | # grab matching strings 94 | elif not p_match_is_regex and file_name.find(p_match, 0) != -1: 95 | data.append(path) 96 | # grab matching regex 97 | else: 98 | var regex_match := regex.search(path) 99 | if regex_match != null: 100 | data.append(path) 101 | # Move on to the next file in this directory 102 | file_name = dir.get_next() 103 | # We've exhausted all files in this directory. Close the iterator. 104 | dir.list_dir_end() 105 | return data 106 | 107 | 108 | # Returns an array of file paths inside the src dir 109 | static func get_file_paths_in_dir(src_dir_path: String) -> Array: 110 | var file_paths := [] 111 | 112 | var directory := Directory.new() 113 | var error := directory.open(src_dir_path) 114 | 115 | if not error == OK: 116 | ModLoaderLog.error("Encountered an error (%s) when attempting to open a directory, with the path: %s" % [error, src_dir_path], LOG_NAME) 117 | return file_paths 118 | 119 | directory.list_dir_begin() 120 | var file_name := directory.get_next() 121 | while (file_name != ""): 122 | if not directory.current_is_dir(): 123 | file_paths.push_back(src_dir_path.plus_file(file_name)) 124 | file_name = directory.get_next() 125 | 126 | return file_paths 127 | 128 | 129 | # Returns an array of directory paths inside the src dir 130 | static func get_dir_paths_in_dir(src_dir_path: String) -> Array: 131 | var dir_paths := [] 132 | 133 | var directory := Directory.new() 134 | var error := directory.open(src_dir_path) 135 | 136 | if not error == OK: 137 | ModLoaderLog.error("Encountered an error (%s) when attempting to open a directory, with the path: %s" % [error, src_dir_path], LOG_NAME) 138 | return dir_paths 139 | 140 | directory.list_dir_begin() 141 | var file_name := directory.get_next() 142 | while (file_name != ""): 143 | if file_name == "." or file_name == "..": 144 | file_name = directory.get_next() 145 | continue 146 | if directory.current_is_dir(): 147 | dir_paths.push_back(src_dir_path.plus_file(file_name)) 148 | file_name = directory.get_next() 149 | 150 | return dir_paths 151 | 152 | 153 | # Get the path to the mods folder, with any applicable overrides applied 154 | static func get_path_to_mods() -> String: 155 | var mods_folder_path := get_local_folder_dir("mods") 156 | if ModLoaderStore: 157 | if ModLoaderStore.ml_options.override_path_to_mods: 158 | mods_folder_path = ModLoaderStore.ml_options.override_path_to_mods 159 | return mods_folder_path 160 | 161 | 162 | static func get_unpacked_mods_dir_path() -> String: 163 | return ModLoaderStore.UNPACKED_DIR 164 | 165 | 166 | # Get the path to the configs folder, with any applicable overrides applied 167 | static func get_path_to_configs() -> String: 168 | var configs_path := MOD_CONFIG_DIR_PATH 169 | if ModLoaderStore: 170 | if ModLoaderStore.ml_options.override_path_to_configs: 171 | configs_path = ModLoaderStore.ml_options.override_path_to_configs 172 | return configs_path 173 | 174 | 175 | # Get the path to a mods config folder 176 | static func get_path_to_mod_configs_dir(mod_id: String) -> String: 177 | return get_path_to_configs().plus_file(mod_id) 178 | 179 | 180 | # Get the path to a mods config file 181 | static func get_path_to_mod_config_file(mod_id: String, config_name: String) -> String: 182 | var mod_config_dir := get_path_to_mod_configs_dir(mod_id) 183 | 184 | return mod_config_dir.plus_file( config_name + ".json") 185 | 186 | 187 | # Returns the mod directory name ("some-mod") from a given path (e.g. "res://mods-unpacked/some-mod/extensions/extension.gd") 188 | static func get_mod_dir(path: String) -> String: 189 | var initial = ModLoaderStore.UNPACKED_DIR 190 | var ending = "/" 191 | var start_index: int = path.find(initial) 192 | if start_index == -1: 193 | ModLoaderLog.error("Initial string not found.", LOG_NAME) 194 | return "" 195 | 196 | start_index += initial.length() 197 | 198 | var end_index: int = path.find(ending, start_index) 199 | if end_index == -1: 200 | ModLoaderLog.error("Ending string not found.", LOG_NAME) 201 | return "" 202 | 203 | var found_string: String = path.substr(start_index, end_index - start_index) 204 | 205 | return found_string 206 | -------------------------------------------------------------------------------- /addons/mod_loader/internal/script_extension.gd: -------------------------------------------------------------------------------- 1 | class_name _ModLoaderScriptExtension 2 | extends Reference 3 | 4 | 5 | # This Class provides methods for working with script extensions. 6 | # Currently all of the included methods are internal and should only be used by the mod loader itself. 7 | 8 | const LOG_NAME := "ModLoader:ScriptExtension" 9 | 10 | 11 | # Sort script extensions by inheritance and apply them in order 12 | static func handle_script_extensions() -> void: 13 | var extension_paths := [] 14 | for extension_path in ModLoaderStore.script_extensions: 15 | if File.new().file_exists(extension_path): 16 | extension_paths.push_back(extension_path) 17 | else: 18 | ModLoaderLog.error("The child script path '%s' does not exist" % [extension_path], LOG_NAME) 19 | 20 | # Sort by inheritance 21 | extension_paths.sort_custom(InheritanceSorting.new(), "_check_inheritances") 22 | 23 | # Load and install all extensions 24 | for extension in extension_paths: 25 | var script: Script = apply_extension(extension) 26 | _reload_vanilla_child_classes_for(script) 27 | 28 | 29 | # Sorts script paths by their ancestors. Scripts are organized by their common 30 | # acnestors then sorted such that scripts extending script A will be before 31 | # a script extending script B if A is an ancestor of B. 32 | class InheritanceSorting: 33 | var stack_cache := {} 34 | # This dictionary's keys are mod_ids and it stores the corresponding position in the load_order 35 | var load_order := {} 36 | 37 | func _init() -> void: 38 | _populate_load_order_table() 39 | 40 | # Comparator function. return true if a should go before b. This may 41 | # enforce conditions beyond the stated inheritance relationship. 42 | func _check_inheritances(extension_a: String, extension_b: String) -> bool: 43 | var a_stack := cached_inheritances_stack(extension_a) 44 | var b_stack := cached_inheritances_stack(extension_b) 45 | 46 | var last_index: int 47 | for index in a_stack.size(): 48 | if index >= b_stack.size(): 49 | return false 50 | if a_stack[index] != b_stack[index]: 51 | return a_stack[index] < b_stack[index] 52 | last_index = index 53 | 54 | if last_index < b_stack.size() - 1: 55 | return true 56 | 57 | return compare_mods_order(extension_a, extension_b) 58 | 59 | # Returns a list of scripts representing all the ancestors of the extension 60 | # script with the most recent ancestor last. 61 | # 62 | # Results are stored in a cache keyed by extension path 63 | func cached_inheritances_stack(extension_path: String) -> Array: 64 | if stack_cache.has(extension_path): 65 | return stack_cache[extension_path] 66 | 67 | var stack := [] 68 | 69 | var parent_script: Script = load(extension_path) 70 | while parent_script: 71 | stack.push_front(parent_script.resource_path) 72 | parent_script = parent_script.get_base_script() 73 | stack.pop_back() 74 | 75 | stack_cache[extension_path] = stack 76 | return stack 77 | 78 | # Secondary comparator function for resolving scripts extending the same vanilla script 79 | # Will return whether a comes before b in the load order 80 | func compare_mods_order(extension_a: String, extension_b: String) -> bool: 81 | var mod_a_id: String = _ModLoaderPath.get_mod_dir(extension_a) 82 | var mod_b_id: String = _ModLoaderPath.get_mod_dir(extension_b) 83 | 84 | return load_order[mod_a_id] < load_order[mod_b_id] 85 | 86 | # Populate a load order dictionary for faster access and comparison between mod ids 87 | func _populate_load_order_table() -> void: 88 | var mod_index := 0 89 | for mod in ModLoaderStore.mod_load_order: 90 | load_order[mod.dir_name] = mod_index 91 | mod_index += 1 92 | 93 | 94 | static func apply_extension(extension_path: String) -> Script: 95 | # Check path to file exists 96 | if not File.new().file_exists(extension_path): 97 | ModLoaderLog.error("The child script path '%s' does not exist" % [extension_path], LOG_NAME) 98 | return null 99 | 100 | var child_script: Script = load(extension_path) 101 | # Adding metadata that contains the extension script path 102 | # We cannot get that path in any other way 103 | # Passing the child_script as is would return the base script path 104 | # Passing the .duplicate() would return a '' path 105 | child_script.set_meta("extension_script_path", extension_path) 106 | 107 | # Force Godot to compile the script now. 108 | # We need to do this here to ensure that the inheritance chain is 109 | # properly set up, and multiple mods can chain-extend the same 110 | # class multiple times. 111 | # This is also needed to make Godot instantiate the extended class 112 | # when creating singletons. 113 | child_script.reload() 114 | 115 | var parent_script: Script = child_script.get_base_script() 116 | var parent_script_path: String = parent_script.resource_path 117 | 118 | # We want to save scripts for resetting later 119 | # All the scripts are saved in order already 120 | if not ModLoaderStore.saved_scripts.has(parent_script_path): 121 | ModLoaderStore.saved_scripts[parent_script_path] = [] 122 | # The first entry in the saved script array that has the path 123 | # used as a key will be the duplicate of the not modified script 124 | ModLoaderStore.saved_scripts[parent_script_path].append(parent_script.duplicate()) 125 | 126 | ModLoaderStore.saved_scripts[parent_script_path].append(child_script) 127 | 128 | ModLoaderLog.info("Installing script extension: %s <- %s" % [parent_script_path, extension_path], LOG_NAME) 129 | child_script.take_over_path(parent_script_path) 130 | 131 | return child_script 132 | 133 | 134 | # Reload all children classes of the vanilla class we just extended 135 | # Calling reload() the children of an extended class seems to allow them to be extended 136 | # e.g if B is a child class of A, reloading B after apply an extender of A allows extenders of B to properly extend B, taking A's extender(s) into account 137 | static func _reload_vanilla_child_classes_for(script: Script) -> void: 138 | if script == null: 139 | return 140 | var current_child_classes := [] 141 | var actual_path: String = script.get_base_script().resource_path 142 | var classes: Array = ProjectSettings.get_setting("_global_script_classes") 143 | 144 | for _class in classes: 145 | if _class.path == actual_path: 146 | current_child_classes.push_back(_class) 147 | break 148 | 149 | for _class in current_child_classes: 150 | for child_class in classes: 151 | 152 | if child_class.base == _class.class: 153 | load(child_class.path).reload() 154 | 155 | 156 | # Used to remove a specific extension 157 | static func remove_specific_extension_from_script(extension_path: String) -> void: 158 | # Check path to file exists 159 | if not _ModLoaderFile.file_exists(extension_path): 160 | ModLoaderLog.error("The extension script path \"%s\" does not exist" % [extension_path], LOG_NAME) 161 | return 162 | 163 | var extension_script: Script = ResourceLoader.load(extension_path) 164 | var parent_script: Script = extension_script.get_base_script() 165 | var parent_script_path: String = parent_script.resource_path 166 | 167 | # Check if the script to reset has been extended 168 | if not ModLoaderStore.saved_scripts.has(parent_script_path): 169 | ModLoaderLog.error("The extension parent script path \"%s\" has not been extended" % [parent_script_path], LOG_NAME) 170 | return 171 | 172 | # Check if the script to reset has anything actually saved 173 | # If we ever encounter this it means something went very wrong in extending 174 | if not ModLoaderStore.saved_scripts[parent_script_path].size() > 0: 175 | ModLoaderLog.error("The extension script path \"%s\" does not have the base script saved, this should never happen, if you encounter this please create an issue in the github repository" % [parent_script_path], LOG_NAME) 176 | return 177 | 178 | var parent_script_extensions: Array = ModLoaderStore.saved_scripts[parent_script_path].duplicate() 179 | parent_script_extensions.remove(0) 180 | 181 | # Searching for the extension that we want to remove 182 | var found_script_extension: Script = null 183 | for script_extension in parent_script_extensions: 184 | if script_extension.get_meta("extension_script_path") == extension_path: 185 | found_script_extension = script_extension 186 | break 187 | 188 | if found_script_extension == null: 189 | ModLoaderLog.error("The extension script path \"%s\" has not been found in the saved extension of the base script" % [parent_script_path], LOG_NAME) 190 | return 191 | parent_script_extensions.erase(found_script_extension) 192 | 193 | # Preparing the script to have all other extensions reapllied 194 | _remove_all_extensions_from_script(parent_script_path) 195 | 196 | # Reapplying all the extensions without the removed one 197 | for script_extension in parent_script_extensions: 198 | apply_extension(script_extension.get_meta("extension_script_path")) 199 | 200 | 201 | # Used to fully reset the provided script to a state prior of any extension 202 | static func _remove_all_extensions_from_script(parent_script_path: String) -> void: 203 | # Check path to file exists 204 | if not _ModLoaderFile.file_exists(parent_script_path): 205 | ModLoaderLog.error("The parent script path \"%s\" does not exist" % [parent_script_path], LOG_NAME) 206 | return 207 | 208 | # Check if the script to reset has been extended 209 | if not ModLoaderStore.saved_scripts.has(parent_script_path): 210 | ModLoaderLog.error("The parent script path \"%s\" has not been extended" % [parent_script_path], LOG_NAME) 211 | return 212 | 213 | # Check if the script to reset has anything actually saved 214 | # If we ever encounter this it means something went very wrong in extending 215 | if not ModLoaderStore.saved_scripts[parent_script_path].size() > 0: 216 | ModLoaderLog.error("The parent script path \"%s\" does not have the base script saved, \nthis should never happen, if you encounter this please create an issue in the github repository" % [parent_script_path], LOG_NAME) 217 | return 218 | 219 | var parent_script: Script = ModLoaderStore.saved_scripts[parent_script_path][0] 220 | parent_script.take_over_path(parent_script_path) 221 | 222 | # Remove the script after it has been reset so we do not do it again 223 | ModLoaderStore.saved_scripts.erase(parent_script_path) 224 | 225 | 226 | # Used to remove all extensions that are of a specific mod 227 | static func remove_all_extensions_of_mod(mod: ModData) -> void: 228 | var _to_remove_extension_paths: Array = ModLoaderStore.saved_extension_paths[mod.manifest.get_mod_id()] 229 | for extension_path in _to_remove_extension_paths: 230 | remove_specific_extension_from_script(extension_path) 231 | ModLoaderStore.saved_extension_paths.erase(mod.manifest.get_mod_id()) 232 | -------------------------------------------------------------------------------- /addons/mod_loader/internal/third_party/steam.gd: -------------------------------------------------------------------------------- 1 | class_name _ModLoaderSteam 2 | extends Node 3 | 4 | const LOG_NAME := "ModLoader:ThirdParty:Steam" 5 | 6 | # Methods related to Steam and the Steam Workshop 7 | 8 | 9 | # Load mod ZIPs from Steam workshop folders. Uses 2 loops: One for each 10 | # workshop item's folder, with another inside that which loops over the ZIPs 11 | # inside each workshop item's folder 12 | static func load_steam_workshop_zips() -> Dictionary: 13 | var zip_data := {} 14 | var workshop_folder_path := _get_path_to_workshop() 15 | 16 | ModLoaderLog.info("Checking workshop items, with path: \"%s\"" % workshop_folder_path, LOG_NAME) 17 | 18 | var workshop_dir := Directory.new() 19 | var workshop_dir_open_error := workshop_dir.open(workshop_folder_path) 20 | if not workshop_dir_open_error == OK: 21 | ModLoaderLog.error("Can't open workshop folder %s (Error: %s)" % [workshop_folder_path, workshop_dir_open_error], LOG_NAME) 22 | return {} 23 | var workshop_dir_listdir_error := workshop_dir.list_dir_begin() 24 | if not workshop_dir_listdir_error == OK: 25 | ModLoaderLog.error("Can't read workshop folder %s (Error: %s)" % [workshop_folder_path, workshop_dir_listdir_error], LOG_NAME) 26 | return {} 27 | 28 | # Loop 1: Workshop folders 29 | while true: 30 | # Get the next workshop item folder 31 | var item_dir := workshop_dir.get_next() 32 | var item_path := workshop_dir.get_current_dir() + "/" + item_dir 33 | 34 | ModLoaderLog.info("Checking workshop item path: \"%s\"" % item_path, LOG_NAME) 35 | 36 | # Stop loading mods when there's no more folders 37 | if item_dir == '': 38 | break 39 | 40 | # Only check directories 41 | if not workshop_dir.current_is_dir(): 42 | continue 43 | 44 | # Loop 2: ZIPs inside the workshop folders 45 | zip_data.merge(_ModLoaderFile.load_zips_in_folder(ProjectSettings.globalize_path(item_path))) 46 | 47 | workshop_dir.list_dir_end() 48 | 49 | return zip_data 50 | 51 | 52 | # Get the path to the Steam workshop folder. Only works for Steam games, as it 53 | # traverses directories relative to where a Steam game and its workshop content 54 | # would be installed. Based on code by Blobfish (developer of Brotato). 55 | # For reference, these are the paths of a Steam game and its workshop folder: 56 | # GAME = Steam/steamapps/common/GameName 57 | # WORKSHOP = Steam/steamapps/workshop/content/AppID 58 | # Eg. Brotato: 59 | # GAME = Steam/steamapps/common/Brotato 60 | # WORKSHOP = Steam/steamapps/workshop/content/1942280 61 | static func _get_path_to_workshop() -> String: 62 | if ModLoaderStore.ml_options.override_path_to_workshop: 63 | return ModLoaderStore.ml_options.override_path_to_workshop 64 | 65 | var game_install_directory := _ModLoaderPath.get_local_folder_dir() 66 | var path := "" 67 | 68 | # Traverse up to the steamapps directory (ie. `cd ..\..\` on Windows) 69 | var path_array := game_install_directory.split("/") 70 | path_array.resize(path_array.size() - 3) 71 | 72 | # Reconstruct the path, now that it has "common/GameName" removed 73 | path = "/".join(path_array) 74 | 75 | # Append the workgame's workshop path 76 | path = path.plus_file("workshop/content/" + _get_steam_app_id()) 77 | 78 | return path 79 | 80 | 81 | # Gets the steam app ID from steam_data.json, which should be in the root 82 | # directory (ie. res://steam_data.json). This file is used by Godot Workshop 83 | # Utility (GWU), which was developed by Brotato developer Blobfish: 84 | # https://github.com/thomasgvd/godot-workshop-utility 85 | static func _get_steam_app_id() -> String: 86 | var game_install_directory := _ModLoaderPath.get_local_folder_dir() 87 | var steam_app_id := "" 88 | var file := File.new() 89 | 90 | if file.open(game_install_directory.plus_file("steam_data.json"), File.READ) == OK: 91 | var file_content: Dictionary = parse_json(file.get_as_text()) 92 | file.close() 93 | 94 | if not file_content.has("app_id"): 95 | ModLoaderLog.error("The steam_data file does not contain an app ID. Mod uploading will not work.", LOG_NAME) 96 | return "" 97 | 98 | steam_app_id = file_content.app_id 99 | else : 100 | ModLoaderLog.error("Can't open steam_data file, \"%s\". Please make sure the file exists and is valid." % game_install_directory.plus_file("steam_data.json"), LOG_NAME) 101 | 102 | return steam_app_id 103 | -------------------------------------------------------------------------------- /addons/mod_loader/mod_loader.gd: -------------------------------------------------------------------------------- 1 | # ModLoader - A mod loader for GDScript 2 | # 3 | # Written in 2021 by harrygiel , 4 | # in 2021 by Mariusz Chwalba , 5 | # in 2022 by Vladimir Panteleev , 6 | # in 2023 by KANA , 7 | # in 2023 by Darkly77, 8 | # in 2023 by otDan , 9 | # in 2023 by Qubus0/Ste 10 | # 11 | # To the extent possible under law, the author(s) have 12 | # dedicated all copyright and related and neighboring 13 | # rights to this software to the public domain worldwide. 14 | # This software is distributed without any warranty. 15 | # 16 | # You should have received a copy of the CC0 Public 17 | # Domain Dedication along with this software. If not, see 18 | # . 19 | 20 | extends Node 21 | 22 | 23 | signal logged(entry) 24 | signal current_config_changed(config) 25 | 26 | # Prefix for this file when using mod_log or dev_log 27 | const LOG_NAME := "ModLoader:Loader" 28 | 29 | # --- DEPRECATED --- 30 | # UNPACKED_DIR was moved to ModLoaderStore. 31 | # However, many mods use this const directly, which is why the deprecation warning was added. 32 | var UNPACKED_DIR := "res://mods-unpacked/" setget ,deprecated_direct_access_UNPACKED_DIR 33 | 34 | # mod_data was moved to ModLoaderStore. 35 | # However, many mods use this const directly, which is why the deprecation warning was added. 36 | var mod_data := {} setget , deprecated_direct_access_mod_data 37 | 38 | # Main 39 | # ============================================================================= 40 | 41 | func _init() -> void: 42 | # Ensure the ModLoaderStore and ModLoader autoloads are in the correct position. 43 | _check_autoload_positions() 44 | 45 | # if mods are not enabled - don't load mods 46 | if ModLoaderStore.REQUIRE_CMD_LINE and not _ModLoaderCLI.is_running_with_command_line_arg("--enable-mods"): 47 | return 48 | 49 | # Rotate the log files once on startup. Can't be checked in utils, since it's static 50 | ModLoaderLog._rotate_log_file() 51 | 52 | # Log the autoloads order. Helpful when providing support to players 53 | ModLoaderLog.debug_json_print("Autoload order", _ModLoaderGodot.get_autoload_array(), LOG_NAME) 54 | 55 | # Log game install dir 56 | ModLoaderLog.info("game_install_directory: %s" % _ModLoaderPath.get_local_folder_dir(), LOG_NAME) 57 | 58 | if not ModLoaderStore.ml_options.enable_mods: 59 | ModLoaderLog.info("Mods are currently disabled", LOG_NAME) 60 | return 61 | 62 | # Load user profiles into ModLoaderStore 63 | if ModLoaderUserProfile.is_initialized(): 64 | var _success_user_profile_load := ModLoaderUserProfile._load() 65 | 66 | _load_mods() 67 | 68 | ModLoaderStore.is_initializing = false 69 | 70 | 71 | func _ready(): 72 | # Create the default user profile if it doesn't exist already 73 | # This should always be present unless the JSON file was manually edited 74 | if not ModLoaderStore.user_profiles.has("default"): 75 | var _success_user_profile_create := ModLoaderUserProfile.create_profile("default") 76 | 77 | # Update the mod_list for each user profile 78 | var _success_update_mod_lists := ModLoaderUserProfile._update_mod_lists() 79 | 80 | 81 | func _exit_tree() -> void: 82 | # Save the cache stored in ModLoaderStore to the cache file. 83 | _ModLoaderCache.save_to_file() 84 | 85 | 86 | func _load_mods() -> void: 87 | # Loop over "res://mods" and add any mod zips to the unpacked virtual 88 | # directory (UNPACKED_DIR) 89 | var zip_data := _load_mod_zips() 90 | 91 | if zip_data.empty(): 92 | ModLoaderLog.info("No zipped mods found", LOG_NAME) 93 | else: 94 | ModLoaderLog.success("DONE: Loaded %s mod files into the virtual filesystem" % zip_data.size(), LOG_NAME) 95 | 96 | # Initializes the mod_data dictionary if zipped mods are loaded. 97 | # If mods are unpacked in the "mods-unpacked" directory, 98 | # mod_data is initialized in the _setup_mods() function. 99 | for mod_id in zip_data.keys(): 100 | var zip_path: String = zip_data[mod_id] 101 | _init_mod_data(mod_id, zip_path) 102 | 103 | 104 | # Loop over UNPACKED_DIR. This triggers _init_mod_data for each mod 105 | # directory, which adds their data to mod_data. 106 | var setup_mods := _setup_mods() 107 | if setup_mods > 0: 108 | ModLoaderLog.success("DONE: Setup %s mods" % setup_mods, LOG_NAME) 109 | else: 110 | ModLoaderLog.info("No mods were setup", LOG_NAME) 111 | 112 | # Update active state of mods based on the current user profile 113 | ModLoaderUserProfile._update_disabled_mods() 114 | 115 | # Loop over all loaded mods via their entry in mod_data. Verify that they 116 | # have all the required files (REQUIRED_MOD_FILES), load their meta data 117 | # (from their manifest.json file), and verify that the meta JSON has all 118 | # required properties (REQUIRED_META_TAGS) 119 | for dir_name in ModLoaderStore.mod_data: 120 | var mod: ModData = ModLoaderStore.mod_data[dir_name] 121 | mod.load_manifest() 122 | if mod.manifest.get("config_schema") and not mod.manifest.config_schema.empty(): 123 | mod.load_configs() 124 | 125 | ModLoaderLog.success("DONE: Loaded all meta data", LOG_NAME) 126 | 127 | # Check for mods with load_before. If a mod is listed in load_before, 128 | # add the current mod to the dependencies of the the mod specified 129 | # in load_before. 130 | for dir_name in ModLoaderStore.mod_data: 131 | var mod: ModData = ModLoaderStore.mod_data[dir_name] 132 | if not mod.is_loadable: 133 | continue 134 | _ModLoaderDependency.check_load_before(mod) 135 | 136 | 137 | # Run optional dependency checks after loading mod_manifest. 138 | # If a mod depends on another mod that hasn't been loaded, 139 | # that dependent mod will be loaded regardless. 140 | for dir_name in ModLoaderStore.mod_data: 141 | var mod: ModData = ModLoaderStore.mod_data[dir_name] 142 | if not mod.is_loadable: 143 | continue 144 | var _is_circular := _ModLoaderDependency.check_dependencies(mod, false) 145 | 146 | 147 | # Run dependency checks after loading mod_manifest. If a mod depends on another 148 | # mod that hasn't been loaded, that dependent mod won't be loaded. 149 | for dir_name in ModLoaderStore.mod_data: 150 | var mod: ModData = ModLoaderStore.mod_data[dir_name] 151 | if not mod.is_loadable: 152 | continue 153 | var _is_circular := _ModLoaderDependency.check_dependencies(mod) 154 | 155 | # Sort mod_load_order by the importance score of the mod 156 | ModLoaderStore.mod_load_order = _ModLoaderDependency.get_load_order(ModLoaderStore.mod_data.values()) 157 | 158 | # Log mod order 159 | var mod_i := 1 160 | for mod in ModLoaderStore.mod_load_order: # mod === mod_data 161 | mod = mod as ModData 162 | ModLoaderLog.info("mod_load_order -> %s) %s" % [mod_i, mod.dir_name], LOG_NAME) 163 | mod_i += 1 164 | 165 | # Instance every mod and add it as a node to the Mod Loader 166 | for mod in ModLoaderStore.mod_load_order: 167 | mod = mod as ModData 168 | 169 | # Continue if mod is disabled 170 | if not mod.is_active: 171 | continue 172 | 173 | ModLoaderLog.info("Initializing -> %s" % mod.manifest.get_mod_id(), LOG_NAME) 174 | _init_mod(mod) 175 | 176 | ModLoaderLog.debug_json_print("mod data", ModLoaderStore.mod_data, LOG_NAME) 177 | 178 | ModLoaderLog.success("DONE: Completely finished loading mods", LOG_NAME) 179 | 180 | _ModLoaderScriptExtension.handle_script_extensions() 181 | 182 | ModLoaderLog.success("DONE: Installed all script extensions", LOG_NAME) 183 | 184 | ModLoaderStore.is_initializing = false 185 | 186 | 187 | # Internal call to reload mods 188 | func _reload_mods() -> void: 189 | _reset_mods() 190 | _load_mods() 191 | 192 | 193 | # Internal call that handles the resetting of all mod related data 194 | func _reset_mods() -> void: 195 | _disable_mods() 196 | ModLoaderStore.mod_data.clear() 197 | ModLoaderStore.mod_load_order.clear() 198 | ModLoaderStore.mod_missing_dependencies.clear() 199 | ModLoaderStore.script_extensions.clear() 200 | 201 | 202 | # Internal call that handles the disabling of all mods 203 | func _disable_mods() -> void: 204 | for mod in ModLoaderStore.mod_data: 205 | _disable_mod(ModLoaderStore.mod_data[mod]) 206 | 207 | 208 | # Check autoload positions: 209 | # Ensure 1st autoload is `ModLoaderStore`, and 2nd is `ModLoader`. 210 | func _check_autoload_positions() -> void: 211 | var ml_options: Object = preload("res://addons/mod_loader/options/options.tres").current_options 212 | var override_cfg_path := _ModLoaderPath.get_override_path() 213 | var is_override_cfg_setup := _ModLoaderFile.file_exists(override_cfg_path) 214 | # If the override file exists we assume the ModLoader was setup with the --setup-create-override-cfg cli arg 215 | # In that case the ModLoader will be the last entry in the autoload array 216 | if is_override_cfg_setup: 217 | ModLoaderLog.info("override.cfg setup detected, ModLoader will be the last autoload loaded.", LOG_NAME) 218 | return 219 | 220 | # If there are Autoloads that need to be before the ModLoader 221 | # "allow_modloader_autoloads_anywhere" in the ModLoader Options can be enabled. 222 | # With that only the correct order of, ModLoaderStore first and ModLoader second, is checked. 223 | if ml_options.allow_modloader_autoloads_anywhere: 224 | _ModLoaderGodot.check_autoload_order("ModLoaderStore", "ModLoader", true) 225 | else: 226 | var _pos_ml_store := _ModLoaderGodot.check_autoload_position("ModLoaderStore", 0, true) 227 | var _pos_ml_core := _ModLoaderGodot.check_autoload_position("ModLoader", 1, true) 228 | 229 | 230 | # Loop over "res://mods" and add any mod zips to the unpacked virtual directory 231 | # (UNPACKED_DIR) 232 | func _load_mod_zips() -> Dictionary: 233 | var zip_data := {} 234 | 235 | if not ModLoaderStore.ml_options.steam_workshop_enabled: 236 | var mods_folder_path := _ModLoaderPath.get_path_to_mods() 237 | 238 | # If we're not using Steam workshop, just loop over the mod ZIPs. 239 | var loaded_zip_data := _ModLoaderFile.load_zips_in_folder(mods_folder_path) 240 | zip_data.merge(loaded_zip_data) 241 | else: 242 | # If we're using Steam workshop, loop over the workshop item directories 243 | var loaded_workshop_zip_data := _ModLoaderSteam.load_steam_workshop_zips() 244 | zip_data.merge(loaded_workshop_zip_data) 245 | 246 | return zip_data 247 | 248 | 249 | # Loop over UNPACKED_DIR and triggers `_init_mod_data` for each mod directory, 250 | # which adds their data to mod_data. 251 | func _setup_mods() -> int: 252 | # Path to the unpacked mods folder 253 | var unpacked_mods_path := _ModLoaderPath.get_unpacked_mods_dir_path() 254 | 255 | var dir := Directory.new() 256 | if not dir.open(unpacked_mods_path) == OK: 257 | ModLoaderLog.error("Can't open unpacked mods folder %s." % unpacked_mods_path, LOG_NAME) 258 | return -1 259 | if not dir.list_dir_begin() == OK: 260 | ModLoaderLog.error("Can't read unpacked mods folder %s." % unpacked_mods_path, LOG_NAME) 261 | return -1 262 | 263 | var unpacked_mods_count := 0 264 | # Get all unpacked mod dirs 265 | while true: 266 | # Get the next file in the directory 267 | var mod_dir_name := dir.get_next() 268 | 269 | # If there is no more file 270 | if mod_dir_name == "": 271 | # Stop loading mod zip files 272 | break 273 | 274 | if ( 275 | # Only check directories 276 | not dir.current_is_dir() 277 | # Ignore self, parent and hidden directories 278 | or mod_dir_name.begins_with(".") 279 | ): 280 | continue 281 | 282 | if ModLoaderStore.ml_options.disabled_mods.has(mod_dir_name): 283 | ModLoaderLog.info("Skipped setting up mod: \"%s\"" % mod_dir_name, LOG_NAME) 284 | continue 285 | 286 | # Initialize the mod data for each mod if there is no existing mod data for that mod. 287 | if not ModLoaderStore.mod_data.has(mod_dir_name): 288 | _init_mod_data(mod_dir_name) 289 | 290 | unpacked_mods_count += 1 291 | 292 | dir.list_dir_end() 293 | return unpacked_mods_count 294 | 295 | 296 | # Add a mod's data to mod_data. 297 | # The mod_folder_path is just the folder name that was added to UNPACKED_DIR, 298 | # which depends on the name used in a given mod ZIP (eg "mods-unpacked/Folder-Name") 299 | func _init_mod_data(mod_id: String, zip_path := "") -> void: 300 | # Path to the mod in UNPACKED_DIR (eg "res://mods-unpacked/My-Mod") 301 | var local_mod_path := _ModLoaderPath.get_unpacked_mods_dir_path().plus_file(mod_id) 302 | 303 | var mod := ModData.new() 304 | if not zip_path.empty(): 305 | mod.zip_name = _ModLoaderPath.get_file_name_from_path(zip_path) 306 | mod.zip_path = zip_path 307 | mod.dir_path = local_mod_path 308 | mod.dir_name = mod_id 309 | var mod_overwrites_path := mod.get_optional_mod_file_path(ModData.optional_mod_files.OVERWRITES) 310 | mod.is_overwrite = _ModLoaderFile.file_exists(mod_overwrites_path) 311 | mod.is_locked = true if mod_id in ModLoaderStore.ml_options.locked_mods else false 312 | ModLoaderStore.mod_data[mod_id] = mod 313 | 314 | # Get the mod file paths 315 | # Note: This was needed in the original version of this script, but it's 316 | # not needed anymore. It can be useful when debugging, but it's also an expensive 317 | # operation if a mod has a large number of files (eg. Brotato's Invasion mod, 318 | # which has ~1,000 files). That's why it's disabled by default 319 | if ModLoaderStore.DEBUG_ENABLE_STORING_FILEPATHS: 320 | mod.file_paths = _ModLoaderPath.get_flat_view_dict(local_mod_path) 321 | 322 | 323 | # Instance every mod and add it as a node to the Mod Loader. 324 | # Runs mods in the order stored in mod_load_order. 325 | func _init_mod(mod: ModData) -> void: 326 | var mod_main_path := mod.get_required_mod_file_path(ModData.required_mod_files.MOD_MAIN) 327 | var mod_overwrites_path := mod.get_optional_mod_file_path(ModData.optional_mod_files.OVERWRITES) 328 | 329 | # If the mod contains overwrites initialize the overwrites script 330 | if mod.is_overwrite: 331 | ModLoaderLog.debug("Overwrite script detected -> %s" % mod_overwrites_path, LOG_NAME) 332 | var mod_overwrites_script := load(mod_overwrites_path) 333 | mod_overwrites_script.new() 334 | ModLoaderLog.debug("Initialized overwrite script -> %s" % mod_overwrites_path, LOG_NAME) 335 | 336 | ModLoaderLog.debug("Loading script from -> %s" % mod_main_path, LOG_NAME) 337 | var mod_main_script: GDScript = ResourceLoader.load(mod_main_path) 338 | ModLoaderLog.debug("Loaded script -> %s" % mod_main_script, LOG_NAME) 339 | 340 | var argument_found: bool = false 341 | for method in mod_main_script.get_script_method_list(): 342 | if method.name == "_init": 343 | if method.args.size() > 0: 344 | argument_found = true 345 | 346 | var mod_main_instance: Node 347 | if argument_found: 348 | mod_main_instance = mod_main_script.new(self) 349 | ModLoaderDeprecated.deprecated_message("The mod_main.gd _init argument (modLoader = ModLoader) is deprecated. Remove it from your _init to avoid crashes in the next major version.", "6.1.0") 350 | else: 351 | mod_main_instance = mod_main_script.new() 352 | mod_main_instance.name = mod.manifest.get_mod_id() 353 | 354 | ModLoaderStore.saved_mod_mains[mod_main_path] = mod_main_instance 355 | 356 | ModLoaderLog.debug("Adding child -> %s" % mod_main_instance, LOG_NAME) 357 | add_child(mod_main_instance, true) 358 | 359 | 360 | # Call the disable method in every mod if present. 361 | # This way developers can implement their own disable handling logic, 362 | # that is needed if there are actions that are not done through the Mod Loader. 363 | func _disable_mod(mod: ModData) -> void: 364 | if mod == null: 365 | ModLoaderLog.error("The provided ModData does not exist", LOG_NAME) 366 | return 367 | var mod_main_path := mod.get_required_mod_file_path(ModData.required_mod_files.MOD_MAIN) 368 | 369 | if not ModLoaderStore.saved_mod_mains.has(mod_main_path): 370 | ModLoaderLog.error("The provided Mod %s has no saved mod main" % mod.manifest.get_mod_id(), LOG_NAME) 371 | return 372 | 373 | var mod_main_instance: Node = ModLoaderStore.saved_mod_mains[mod_main_path] 374 | if mod_main_instance.has_method("_disable"): 375 | mod_main_instance._disable() 376 | else: 377 | ModLoaderLog.warning("The provided Mod %s does not have a \"_disable\" method" % mod.manifest.get_mod_id(), LOG_NAME) 378 | 379 | ModLoaderStore.saved_mod_mains.erase(mod_main_path) 380 | _ModLoaderScriptExtension.remove_all_extensions_of_mod(mod) 381 | 382 | remove_child(mod_main_instance) 383 | 384 | 385 | # Deprecated 386 | # ============================================================================= 387 | 388 | func install_script_extension(child_script_path:String) -> void: 389 | ModLoaderDeprecated.deprecated_changed("ModLoader.install_script_extension", "ModLoaderMod.install_script_extension", "6.0.0") 390 | ModLoaderMod.install_script_extension(child_script_path) 391 | 392 | 393 | func register_global_classes_from_array(new_global_classes: Array) -> void: 394 | ModLoaderDeprecated.deprecated_changed("ModLoader.register_global_classes_from_array", "ModLoaderMod.register_global_classes_from_array", "6.0.0") 395 | ModLoaderMod.register_global_classes_from_array(new_global_classes) 396 | 397 | 398 | func add_translation_from_resource(resource_path: String) -> void: 399 | ModLoaderDeprecated.deprecated_changed("ModLoader.add_translation_from_resource", "ModLoaderMod.add_translation", "6.0.0") 400 | ModLoaderMod.add_translation(resource_path) 401 | 402 | 403 | func append_node_in_scene(modified_scene: Node, node_name: String = "", node_parent = null, instance_path: String = "", is_visible: bool = true) -> void: 404 | ModLoaderDeprecated.deprecated_changed("ModLoader.append_node_in_scene", "ModLoaderMod.append_node_in_scene", "6.0.0") 405 | ModLoaderMod.append_node_in_scene(modified_scene, node_name, node_parent, instance_path, is_visible) 406 | 407 | 408 | func save_scene(modified_scene: Node, scene_path: String) -> void: 409 | ModLoaderDeprecated.deprecated_changed("ModLoader.save_scene", "ModLoaderMod.save_scene", "6.0.0") 410 | ModLoaderMod.save_scene(modified_scene, scene_path) 411 | 412 | 413 | func get_mod_config(mod_dir_name: String = "", key: String = "") -> ModConfig: 414 | ModLoaderDeprecated.deprecated_changed("ModLoader.get_mod_config", "ModLoaderConfig.get_config", "6.0.0") 415 | return ModLoaderConfig.get_config(mod_dir_name, ModLoaderConfig.DEFAULT_CONFIG_NAME) 416 | 417 | 418 | func deprecated_direct_access_UNPACKED_DIR() -> String: 419 | ModLoaderDeprecated.deprecated_message("The const \"UNPACKED_DIR\" was removed, use \"ModLoaderMod.get_unpacked_dir()\" instead", "6.0.0") 420 | return _ModLoaderPath.get_unpacked_mods_dir_path() 421 | 422 | 423 | func deprecated_direct_access_mod_data() -> Dictionary: 424 | ModLoaderDeprecated.deprecated_message("The var \"mod_data\" was removed, use \"ModLoaderMod.get_mod_data_all()\" instead", "6.0.0") 425 | return ModLoaderStore.mod_data 426 | -------------------------------------------------------------------------------- /addons/mod_loader/mod_loader_store.gd: -------------------------------------------------------------------------------- 1 | extends Node 2 | 3 | 4 | # ModLoaderStore 5 | # Singleton (autoload) for storing data. Should be added before ModLoader, 6 | # as an autoload called `ModLoaderStore` 7 | 8 | 9 | # Constants 10 | # ============================================================================= 11 | 12 | # Most of these settings should never need to change, aside from the DEBUG_* 13 | # options (which should be `false` when distributing compiled PCKs) 14 | 15 | const MODLOADER_VERSION = "6.3.0" 16 | 17 | # If true, a complete array of filepaths is stored for each mod. This is 18 | # disabled by default because the operation can be very expensive, but may 19 | # be useful for debugging 20 | const DEBUG_ENABLE_STORING_FILEPATHS := false 21 | 22 | # This is where mod ZIPs are unpacked to 23 | const UNPACKED_DIR := "res://mods-unpacked/" 24 | 25 | # Set to true to require using "--enable-mods" to enable them 26 | const REQUIRE_CMD_LINE := false 27 | 28 | const LOG_NAME = "ModLoader:Store" 29 | 30 | # Vars 31 | # ============================================================================= 32 | 33 | # Order for mods to be loaded in, set by `get_load_order` 34 | var mod_load_order := [] 35 | 36 | # Stores data for every found/loaded mod 37 | var mod_data := {} 38 | 39 | # Any mods that are missing their dependancies are added to this 40 | # Example property: "mod_id": ["dep_mod_id_0", "dep_mod_id_2"] 41 | var mod_missing_dependencies := {} 42 | 43 | # Set to false after ModLoader._init() 44 | # Helps to decide whether a script extension should go through the _ModLoaderScriptExtension.handle_script_extensions() process 45 | var is_initializing := true 46 | 47 | # Used when loading mod zips to determine which mod zip corresponds to which mod directory in the UNPACKED_DIR. 48 | var previous_mod_dirs := [] 49 | 50 | # Store all extenders paths 51 | var script_extensions := [] 52 | 53 | # True if ModLoader has displayed the warning about using zipped mods 54 | var has_shown_editor_zips_warning := false 55 | 56 | # Things to keep to ensure they are not garbage collected (used by `save_scene`) 57 | var saved_objects := [] 58 | 59 | # Stores all the taken over scripts for restoration 60 | var saved_scripts := {} 61 | 62 | # Stores main scripts for mod disabling 63 | var saved_mod_mains := {} 64 | 65 | # Stores script extension paths with the key being the namespace of a mod 66 | var saved_extension_paths := {} 67 | 68 | # Keeps track of logged messages, to avoid flooding the log with duplicate notices 69 | # Can also be used by mods, eg. to create an in-game developer console that 70 | # shows messages 71 | var logged_messages := { 72 | "all": {}, 73 | "by_mod": {}, 74 | "by_type": { 75 | "fatal-error": {}, 76 | "error": {}, 77 | "warning": {}, 78 | "info": {}, 79 | "success": {}, 80 | "debug": {}, 81 | } 82 | } 83 | 84 | # Active user profile 85 | var current_user_profile: ModUserProfile 86 | 87 | # List of user profiles loaded from user://mod_user_profiles.json 88 | var user_profiles := {} 89 | 90 | # ModLoader cache is stored in user://mod_loader_cache.json 91 | var cache := {} 92 | 93 | # These variables handle various options, which can be changed either via 94 | # Godot's GUI (with the options.tres resource file), or via CLI args. 95 | # Usage: `ModLoaderStore.ml_options.KEY` 96 | # See: res://addons/mod_loader/options/options.tres 97 | # See: res://addons/mod_loader/resources/options_profile.gd 98 | var ml_options := { 99 | enable_mods = true, 100 | log_level = ModLoaderLog.VERBOSITY_LEVEL.DEBUG, 101 | 102 | # Mods that can't be disabled or enabled in a user profile (contains mod IDs as strings) 103 | locked_mods = [], 104 | 105 | # Array of disabled mods (contains mod IDs as strings) 106 | disabled_mods = [], 107 | 108 | # If this flag is set to true, the ModLoaderStore and ModLoader Autoloads don't have to be the first Autoloads. 109 | # The ModLoaderStore Autoload still needs to be placed before the ModLoader Autoload. 110 | allow_modloader_autoloads_anywhere = false, 111 | 112 | # If true, ModLoader will load mod ZIPs from the Steam workshop directory, 113 | # instead of the default location (res://mods) 114 | steam_workshop_enabled = false, 115 | 116 | # Overrides for the path mods/configs/workshop folders are loaded from. 117 | # Only applied if custom settings are provided, either via the options.tres 118 | # resource, or via CLI args. Note that CLI args can be tested in the editor 119 | # via: Project Settings > Display> Editor > Main Run Args 120 | override_path_to_mods = "", # Default if unspecified: "res://mods" -- get with _ModLoaderPath.get_path_to_mods() 121 | override_path_to_configs = "", # Default if unspecified: "res://configs" -- get with _ModLoaderPath.get_path_to_configs() 122 | 123 | # Can be used in the editor to load mods from your Steam workshop directory 124 | override_path_to_workshop = "", 125 | 126 | # If true, using deprecated funcs will trigger a warning, instead of a fatal 127 | # error. This can be helpful when developing mods that depend on a mod that 128 | # hasn't been updated to fix the deprecated issues yet 129 | ignore_deprecated_errors = false, 130 | 131 | # Array of mods that should be ignored when logging messages (contains mod IDs as strings) 132 | ignored_mod_names_in_log = [], 133 | } 134 | 135 | 136 | # Methods 137 | # ============================================================================= 138 | 139 | func _init(): 140 | _update_ml_options_from_options_resource() 141 | _update_ml_options_from_cli_args() 142 | # ModLoaderStore is passed as argument so the cache data can be loaded on _init() 143 | _ModLoaderCache.init_cache(self) 144 | 145 | 146 | # Update ModLoader's options, via the custom options resource 147 | func _update_ml_options_from_options_resource() -> void: 148 | # Path to the options resource 149 | # See: res://addons/mod_loader/resources/options_current.gd 150 | var ml_options_path := "res://addons/mod_loader/options/options.tres" 151 | 152 | # Get user options for ModLoader 153 | if not _ModLoaderFile.file_exists(ml_options_path): 154 | ModLoaderLog.fatal(str("A critical file is missing: ", ml_options_path), LOG_NAME) 155 | 156 | var options_resource: ModLoaderCurrentOptions = load(ml_options_path) 157 | if options_resource.current_options == null: 158 | ModLoaderLog.warning(str( 159 | "No current options are set. Falling back to defaults. ", 160 | "Edit your options at %s. " % ml_options_path 161 | ), LOG_NAME) 162 | else: 163 | var current_options = options_resource.current_options 164 | if not current_options is ModLoaderOptionsProfile: 165 | ModLoaderLog.error(str( 166 | "Current options is not a valid Resource of type ModLoaderOptionsProfile. ", 167 | "Please edit your options at %s. " % ml_options_path 168 | ), LOG_NAME) 169 | # Update from the options in the resource 170 | for key in ml_options: 171 | ml_options[key] = current_options[key] 172 | 173 | # Get options overrides by feature tags 174 | # An override is saved as Dictionary[String: ModLoaderOptionsProfile] 175 | for feature_tag in options_resource.feature_override_options.keys(): 176 | if not feature_tag is String: 177 | ModLoaderLog.error(str( 178 | "Options override keys are required to be of type String. Failing key: \"%s.\" " % feature_tag, 179 | "Please edit your options at %s. " % ml_options_path, 180 | "Consult the documentation for all available feature tags: ", 181 | "https://docs.godotengine.org/en/3.5/tutorials/export/feature_tags.html" 182 | ), LOG_NAME) 183 | continue 184 | 185 | if not OS.has_feature(feature_tag): 186 | ModLoaderLog.info("Options override feature tag \"%s\". does not apply, skipping." % feature_tag, LOG_NAME) 187 | continue 188 | 189 | ModLoaderLog.info("Applying options override with feature tag \"%s\"." % feature_tag, LOG_NAME) 190 | var override_options = options_resource.feature_override_options[feature_tag] 191 | if not override_options is ModLoaderOptionsProfile: 192 | ModLoaderLog.error(str( 193 | "Options override is not a valid Resource of type ModLoaderOptionsProfile. ", 194 | "Options override key with invalid resource: \"%s\". " % feature_tag, 195 | "Please edit your options at %s. " % ml_options_path 196 | ), LOG_NAME) 197 | continue 198 | 199 | # Update from the options in the resource 200 | for key in ml_options: 201 | ml_options[key] = override_options[key] 202 | 203 | 204 | # Update ModLoader's options, via CLI args 205 | func _update_ml_options_from_cli_args() -> void: 206 | # Disable mods 207 | if _ModLoaderCLI.is_running_with_command_line_arg("--disable-mods"): 208 | ml_options.enable_mods = false 209 | 210 | # Override paths to mods 211 | # Set via: --mods-path 212 | # Example: --mods-path="C://path/mods" 213 | var cmd_line_mod_path := _ModLoaderCLI.get_cmd_line_arg_value("--mods-path") 214 | if cmd_line_mod_path: 215 | ml_options.override_path_to_mods = cmd_line_mod_path 216 | ModLoaderLog.info("The path mods are loaded from has been changed via the CLI arg `--mods-path`, to: " + cmd_line_mod_path, LOG_NAME) 217 | 218 | # Override paths to configs 219 | # Set via: --configs-path 220 | # Example: --configs-path="C://path/configs" 221 | var cmd_line_configs_path := _ModLoaderCLI.get_cmd_line_arg_value("--configs-path") 222 | if cmd_line_configs_path: 223 | ml_options.override_path_to_configs = cmd_line_configs_path 224 | ModLoaderLog.info("The path configs are loaded from has been changed via the CLI arg `--configs-path`, to: " + cmd_line_configs_path, LOG_NAME) 225 | 226 | # Log level verbosity 227 | if _ModLoaderCLI.is_running_with_command_line_arg("-vvv") or _ModLoaderCLI.is_running_with_command_line_arg("--log-debug"): 228 | ml_options.log_level = ModLoaderLog.VERBOSITY_LEVEL.DEBUG 229 | elif _ModLoaderCLI.is_running_with_command_line_arg("-vv") or _ModLoaderCLI.is_running_with_command_line_arg("--log-info"): 230 | ml_options.log_level = ModLoaderLog.VERBOSITY_LEVEL.INFO 231 | elif _ModLoaderCLI.is_running_with_command_line_arg("-v") or _ModLoaderCLI.is_running_with_command_line_arg("--log-warning"): 232 | ml_options.log_level = ModLoaderLog.VERBOSITY_LEVEL.WARNING 233 | 234 | # Ignored mod_names in log 235 | var ignore_mod_names := _ModLoaderCLI.get_cmd_line_arg_value("--log-ignore") 236 | if not ignore_mod_names == "": 237 | ml_options.ignored_mod_names_in_log = ignore_mod_names.split(",") 238 | -------------------------------------------------------------------------------- /addons/mod_loader/options/options.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Resource" load_steps=4 format=2] 2 | 3 | [ext_resource path="res://addons/mod_loader/options/profiles/current.tres" type="Resource" id=1] 4 | [ext_resource path="res://addons/mod_loader/resources/options_current.gd" type="Script" id=2] 5 | [ext_resource path="res://addons/mod_loader/options/profiles/editor.tres" type="Resource" id=3] 6 | 7 | [resource] 8 | script = ExtResource( 2 ) 9 | current_options = ExtResource( 1 ) 10 | feature_override_options = { 11 | "editor": ExtResource( 3 ) 12 | } 13 | -------------------------------------------------------------------------------- /addons/mod_loader/options/profiles/current.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Resource" load_steps=2 format=2] 2 | 3 | [ext_resource path="res://addons/mod_loader/resources/options_profile.gd" type="Script" id=1] 4 | 5 | [resource] 6 | script = ExtResource( 1 ) 7 | enable_mods = true 8 | log_level = 3 9 | disabled_mods = [ ] 10 | steam_workshop_enabled = false 11 | override_path_to_mods = "" 12 | override_path_to_configs = "" 13 | override_path_to_workshop = "" 14 | -------------------------------------------------------------------------------- /addons/mod_loader/options/profiles/default.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Resource" load_steps=2 format=2] 2 | 3 | [ext_resource path="res://addons/mod_loader/resources/options_profile.gd" type="Script" id=1] 4 | 5 | 6 | [resource] 7 | script = ExtResource( 1 ) 8 | enable_mods = true 9 | log_level = 3 10 | disabled_mods = [ ] 11 | steam_workshop_enabled = false 12 | override_path_to_mods = "" 13 | override_path_to_configs = "" 14 | override_path_to_workshop = "" 15 | -------------------------------------------------------------------------------- /addons/mod_loader/options/profiles/disable_mods.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Resource" load_steps=2 format=2] 2 | 3 | [ext_resource path="res://addons/mod_loader/resources/options_profile.gd" type="Script" id=1] 4 | 5 | 6 | [resource] 7 | script = ExtResource( 1 ) 8 | enable_mods = false 9 | log_level = 3 10 | disabled_mods = [ ] 11 | steam_workshop_enabled = false 12 | override_path_to_mods = "" 13 | override_path_to_configs = "" 14 | override_path_to_workshop = "" 15 | -------------------------------------------------------------------------------- /addons/mod_loader/options/profiles/editor.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Resource" load_steps=2 format=2] 2 | 3 | [ext_resource path="res://addons/mod_loader/resources/options_profile.gd" type="Script" id=1] 4 | 5 | [resource] 6 | script = ExtResource( 1 ) 7 | enable_mods = true 8 | locked_mods = [ ] 9 | log_level = 3 10 | disabled_mods = [ ] 11 | allow_modloader_autoloads_anywhere = false 12 | steam_workshop_enabled = false 13 | override_path_to_mods = "" 14 | override_path_to_configs = "" 15 | override_path_to_workshop = "" 16 | ignore_deprecated_errors = true 17 | ignored_mod_names_in_log = [ ] 18 | -------------------------------------------------------------------------------- /addons/mod_loader/options/profiles/production_no_workshop.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Resource" load_steps=2 format=2] 2 | 3 | [ext_resource path="res://addons/mod_loader/resources/options_profile.gd" type="Script" id=1] 4 | 5 | 6 | [resource] 7 | script = ExtResource( 1 ) 8 | enable_mods = true 9 | log_level = 2 10 | disabled_mods = [ ] 11 | steam_workshop_enabled = false 12 | override_path_to_mods = "" 13 | override_path_to_configs = "" 14 | override_path_to_workshop = "" 15 | -------------------------------------------------------------------------------- /addons/mod_loader/options/profiles/production_workshop.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="Resource" load_steps=2 format=2] 2 | 3 | [ext_resource path="res://addons/mod_loader/resources/options_profile.gd" type="Script" id=1] 4 | 5 | 6 | [resource] 7 | script = ExtResource( 1 ) 8 | enable_mods = true 9 | log_level = 2 10 | disabled_mods = [ ] 11 | steam_workshop_enabled = true 12 | override_path_to_mods = "" 13 | override_path_to_configs = "" 14 | override_path_to_workshop = "" 15 | -------------------------------------------------------------------------------- /addons/mod_loader/resources/mod_config.gd: -------------------------------------------------------------------------------- 1 | class_name ModConfig 2 | extends Resource 3 | 4 | # This Class is used to represent a configuration for a mod. 5 | # The Class provides functionality to initialize, validate, save, and remove a mod's configuration. 6 | 7 | const LOG_NAME := "ModLoader:ModConfig" 8 | 9 | # Name of the config - must be unique 10 | var name: String 11 | # The mod_id this config belongs to 12 | var mod_id: String 13 | # The JSON-Schema this config uses for validation 14 | var schema: Dictionary 15 | # The data this config holds 16 | var data: Dictionary 17 | # The path where the JSON file for this config is stored 18 | var save_path: String 19 | # False if any data is invalid 20 | var is_valid := false 21 | 22 | 23 | func _init(_mod_id: String, _data: Dictionary, _save_path: String, _schema: Dictionary = {}) -> void: 24 | name = _ModLoaderPath.get_file_name_from_path(_save_path, true, true) 25 | mod_id = _mod_id 26 | schema = ModLoaderStore.mod_data[_mod_id].manifest.config_schema if _schema.empty() else _schema 27 | data = _data 28 | save_path = _save_path 29 | 30 | var error_message := validate() 31 | 32 | if not error_message == "": 33 | ModLoaderLog.error("Mod Config for mod \"%s\" failed JSON Schema Validation with error message: \"%s\"" % [mod_id, error_message], LOG_NAME) 34 | return 35 | 36 | is_valid = true 37 | 38 | 39 | func get_data_as_string() -> String: 40 | return JSON.print(data) 41 | 42 | 43 | func get_schema_as_string() -> String: 44 | return JSON.print(schema) 45 | 46 | 47 | # Empty string if validation was successful 48 | func validate() -> String: 49 | var json_schema := JSONSchema.new() 50 | var error := json_schema.validate(get_data_as_string(), get_schema_as_string()) 51 | 52 | if error.empty(): 53 | is_valid = true 54 | else: 55 | is_valid = false 56 | 57 | return error 58 | 59 | 60 | # Runs the JSON-Schema validation and returns true if valid 61 | func is_valid() -> bool: 62 | if validate() == "": 63 | is_valid = true 64 | return true 65 | 66 | is_valid = false 67 | return false 68 | 69 | 70 | # Saves the config data to the config file 71 | func save_to_file() -> bool: 72 | var is_success := _ModLoaderFile.save_dictionary_to_json_file(data, save_path) 73 | return is_success 74 | 75 | 76 | # Removes the config file 77 | func remove_file() -> bool: 78 | var is_success := _ModLoaderFile.remove_file(save_path) 79 | return is_success 80 | -------------------------------------------------------------------------------- /addons/mod_loader/resources/mod_data.gd: -------------------------------------------------------------------------------- 1 | class_name ModData 2 | extends Resource 3 | 4 | # Stores and validates all Data required to load a mod successfully 5 | # If some of the data is invalid, [member is_loadable] will be false 6 | 7 | const LOG_NAME := "ModLoader:ModData" 8 | 9 | # Controls how manifest.json data is logged for each mod 10 | # true = Full JSON contents (floods the log) 11 | # false = Single line (default) 12 | const USE_EXTENDED_DEBUGLOG := false 13 | 14 | # These 2 files are always required by mods. 15 | # [i]mod_main.gd[/i] = The main init file for the mod 16 | # [i]manifest.json[/i] = Meta data for the mod, including its dependencies 17 | enum required_mod_files { 18 | MOD_MAIN, 19 | MANIFEST, 20 | } 21 | 22 | enum optional_mod_files { 23 | OVERWRITES 24 | } 25 | 26 | # Name of the Mod's zip file 27 | var zip_name := "" 28 | # Path to the Mod's zip file 29 | var zip_path := "" 30 | # Directory of the mod. Has to be identical to [method ModManifest.get_mod_id] 31 | var dir_name := "" 32 | # Path to the Mod's Directory 33 | var dir_path := "" 34 | # False if any data is invalid 35 | var is_loadable := true 36 | # True if overwrites.gd exists 37 | var is_overwrite := false 38 | # True if mod can't be disabled or enabled in a user profile 39 | var is_locked := false 40 | # Flag indicating whether the mod should be loaded 41 | var is_active := true 42 | # Is increased for every mod depending on this mod. Highest importance is loaded first 43 | var importance := 0 44 | # Contents of the manifest 45 | var manifest: ModManifest 46 | # Updated in load_configs 47 | var configs := {} 48 | var current_config: ModConfig setget _set_current_config 49 | 50 | # only set if DEBUG_ENABLE_STORING_FILEPATHS is enabled 51 | var file_paths: PoolStringArray = [] 52 | 53 | 54 | # Load meta data from a mod's manifest.json file 55 | func load_manifest() -> void: 56 | if not _has_required_files(): 57 | return 58 | 59 | ModLoaderLog.info("Loading mod_manifest (manifest.json) for -> %s" % dir_name, LOG_NAME) 60 | 61 | # Load meta data file 62 | var manifest_path := get_required_mod_file_path(required_mod_files.MANIFEST) 63 | var manifest_dict := _ModLoaderFile.get_json_as_dict(manifest_path) 64 | 65 | if USE_EXTENDED_DEBUGLOG: 66 | ModLoaderLog.debug_json_print("%s loaded manifest data -> " % dir_name, manifest_dict, LOG_NAME) 67 | else: 68 | ModLoaderLog.debug(str("%s loaded manifest data -> " % dir_name, manifest_dict), LOG_NAME) 69 | 70 | var mod_manifest := ModManifest.new(manifest_dict) 71 | 72 | is_loadable = _has_manifest(mod_manifest) 73 | if not is_loadable: return 74 | is_loadable = _is_mod_dir_name_same_as_id(mod_manifest) 75 | if not is_loadable: return 76 | manifest = mod_manifest 77 | 78 | 79 | # Load each mod config json from the mods config directory. 80 | func load_configs() -> void: 81 | # If the default values in the config schema are invalid don't load configs 82 | if not manifest.load_mod_config_defaults(): 83 | return 84 | 85 | var config_dir_path := _ModLoaderPath.get_path_to_mod_configs_dir(dir_name) 86 | var config_file_paths := _ModLoaderPath.get_file_paths_in_dir(config_dir_path) 87 | for config_file_path in config_file_paths: 88 | _load_config(config_file_path) 89 | 90 | # Set the current_config based on the user profile 91 | if ModLoaderUserProfile.is_initialized(): 92 | current_config = ModLoaderConfig.get_current_config(dir_name) 93 | else: 94 | current_config = ModLoaderConfig.get_config(dir_name, ModLoaderConfig.DEFAULT_CONFIG_NAME) 95 | 96 | 97 | # Create a new ModConfig instance for each Config JSON and add it to the configs dictionary. 98 | func _load_config(config_file_path: String) -> void: 99 | var config_data := _ModLoaderFile.get_json_as_dict(config_file_path) 100 | var mod_config = ModConfig.new( 101 | dir_name, 102 | config_data, 103 | config_file_path, 104 | manifest.config_schema 105 | ) 106 | 107 | # Add the config to the configs dictionary 108 | configs[mod_config.name] = mod_config 109 | 110 | 111 | # Update the mod_list of the current user profile 112 | func _set_current_config(new_current_config: ModConfig) -> void: 113 | ModLoaderUserProfile.set_mod_current_config(dir_name, new_current_config) 114 | current_config = new_current_config 115 | ModLoader.emit_signal("current_config_changed", new_current_config) 116 | 117 | 118 | # Validates if [member dir_name] matches [method ModManifest.get_mod_id] 119 | func _is_mod_dir_name_same_as_id(mod_manifest: ModManifest) -> bool: 120 | var manifest_id := mod_manifest.get_mod_id() 121 | if not dir_name == manifest_id: 122 | ModLoaderLog.fatal('Mod directory name "%s" does not match the data in manifest.json. Expected "%s" (Format: {namespace}-{name})' % [ dir_name, manifest_id ], LOG_NAME) 123 | return false 124 | return true 125 | 126 | 127 | # Confirms that all files from [member required_mod_files] exist 128 | func _has_required_files() -> bool: 129 | for required_file in required_mod_files: 130 | var file_path := get_required_mod_file_path(required_mod_files[required_file]) 131 | 132 | if !_ModLoaderFile.file_exists(file_path): 133 | ModLoaderLog.fatal("ERROR - %s is missing a required file: %s" % [dir_name, file_path], LOG_NAME) 134 | is_loadable = false 135 | return is_loadable 136 | 137 | 138 | # Validates if manifest is set 139 | func _has_manifest(mod_manifest: ModManifest) -> bool: 140 | if mod_manifest == null: 141 | ModLoaderLog.fatal("Mod manifest could not be created correctly due to errors.", LOG_NAME) 142 | return false 143 | return true 144 | 145 | 146 | # Converts enum indices [member required_mod_files] into their respective file paths 147 | func get_required_mod_file_path(required_file: int) -> String: 148 | match required_file: 149 | required_mod_files.MOD_MAIN: 150 | return dir_path.plus_file("mod_main.gd") 151 | required_mod_files.MANIFEST: 152 | return dir_path.plus_file("manifest.json") 153 | return "" 154 | 155 | func get_optional_mod_file_path(optional_file: int) -> String: 156 | match optional_file: 157 | optional_mod_files.OVERWRITES: 158 | return dir_path.plus_file("overwrites.gd") 159 | return "" 160 | -------------------------------------------------------------------------------- /addons/mod_loader/resources/mod_manifest.gd: -------------------------------------------------------------------------------- 1 | class_name ModManifest 2 | extends Resource 3 | 4 | # Stores and validates contents of the manifest set by the user 5 | 6 | const LOG_NAME := "ModLoader:ModManifest" 7 | 8 | # Mod name. 9 | # Validated by [method is_name_or_namespace_valid] 10 | var name := "" 11 | # Mod namespace, most commonly the main author. 12 | # Validated by [method is_name_or_namespace_valid] 13 | var namespace := "" 14 | # Semantic version. Not a number, but required to be named like this by Thunderstore 15 | # Validated by [method is_semver_valid] 16 | var version_number := "0.0.0" 17 | var description := "" 18 | var website_url := "" 19 | # Used to determine mod load order 20 | var dependencies: PoolStringArray = [] 21 | # Used to determine mod load order 22 | var optional_dependencies: PoolStringArray = [] 23 | 24 | var authors: PoolStringArray = [] 25 | # only used for information 26 | var compatible_game_version: PoolStringArray = [] 27 | # only used for information 28 | # Validated by [method _handle_compatible_mod_loader_version] 29 | var compatible_mod_loader_version: PoolStringArray = [] 30 | # only used for information 31 | var incompatibilities: PoolStringArray = [] 32 | var load_before: PoolStringArray = [] 33 | var tags : PoolStringArray = [] 34 | var config_schema := {} 35 | var description_rich := "" 36 | var image: StreamTexture 37 | 38 | 39 | # Required keys in a mod's manifest.json file 40 | const REQUIRED_MANIFEST_KEYS_ROOT = [ 41 | "name", 42 | "namespace", 43 | "version_number", 44 | "website_url", 45 | "description", 46 | "dependencies", 47 | "extra", 48 | ] 49 | 50 | # Required keys in manifest's `json.extra.godot` 51 | const REQUIRED_MANIFEST_KEYS_EXTRA = [ 52 | "authors", 53 | "compatible_mod_loader_version", 54 | "compatible_game_version", 55 | ] 56 | 57 | 58 | # Takes the manifest as [Dictionary] and validates everything. 59 | # Will return null if something is invalid. 60 | func _init(manifest: Dictionary) -> void: 61 | if ( 62 | not ModLoaderUtils.dict_has_fields(manifest, REQUIRED_MANIFEST_KEYS_ROOT) or 63 | not ModLoaderUtils.dict_has_fields(manifest.extra, ["godot"]) or 64 | not ModLoaderUtils.dict_has_fields(manifest.extra.godot, REQUIRED_MANIFEST_KEYS_EXTRA) 65 | ): 66 | return 67 | 68 | name = manifest.name 69 | namespace = manifest.namespace 70 | version_number = manifest.version_number 71 | 72 | if ( 73 | not is_name_or_namespace_valid(name) or 74 | not is_name_or_namespace_valid(namespace) 75 | ): 76 | return 77 | 78 | var mod_id = get_mod_id() 79 | 80 | if not is_semver_valid(mod_id, version_number, "version_number"): 81 | return 82 | 83 | description = manifest.description 84 | website_url = manifest.website_url 85 | dependencies = manifest.dependencies 86 | 87 | var godot_details: Dictionary = manifest.extra.godot 88 | authors = ModLoaderUtils.get_array_from_dict(godot_details, "authors") 89 | optional_dependencies = ModLoaderUtils.get_array_from_dict(godot_details, "optional_dependencies") 90 | incompatibilities = ModLoaderUtils.get_array_from_dict(godot_details, "incompatibilities") 91 | load_before = ModLoaderUtils.get_array_from_dict(godot_details, "load_before") 92 | compatible_game_version = ModLoaderUtils.get_array_from_dict(godot_details, "compatible_game_version") 93 | compatible_mod_loader_version = _handle_compatible_mod_loader_version(mod_id, godot_details) 94 | description_rich = ModLoaderUtils.get_string_from_dict(godot_details, "description_rich") 95 | tags = ModLoaderUtils.get_array_from_dict(godot_details, "tags") 96 | config_schema = ModLoaderUtils.get_dict_from_dict(godot_details, "config_schema") 97 | 98 | if ( 99 | not is_mod_id_array_valid(mod_id, dependencies, "dependency") or 100 | not is_mod_id_array_valid(mod_id, incompatibilities, "incompatibility") or 101 | not is_mod_id_array_valid(mod_id, optional_dependencies, "optional_dependency") or 102 | not is_mod_id_array_valid(mod_id, load_before, "load_before") 103 | ): 104 | return 105 | 106 | if ( 107 | not validate_distinct_mod_ids_in_arrays( 108 | mod_id, 109 | dependencies, 110 | incompatibilities, 111 | ["dependencies", "incompatibilities"] 112 | ) or 113 | not validate_distinct_mod_ids_in_arrays( 114 | mod_id, 115 | optional_dependencies, 116 | dependencies, 117 | ["optional_dependencies", "dependencies"] 118 | ) or 119 | not validate_distinct_mod_ids_in_arrays( 120 | mod_id, 121 | optional_dependencies, 122 | incompatibilities, 123 | ["optional_dependencies", "incompatibilities"] 124 | ) or 125 | not validate_distinct_mod_ids_in_arrays( 126 | mod_id, 127 | load_before, 128 | dependencies, 129 | ["load_before", "dependencies"], 130 | "\"load_before\" should be handled as optional dependency adding it to \"dependencies\" will cancel out the desired effect." 131 | ) or 132 | not validate_distinct_mod_ids_in_arrays( 133 | mod_id, 134 | load_before, 135 | optional_dependencies, 136 | ["load_before", "optional_dependencies"], 137 | "\"load_before\" can be viewed as optional dependency, please remove the duplicate mod-id." 138 | ) or 139 | not validate_distinct_mod_ids_in_arrays( 140 | mod_id, 141 | load_before, 142 | incompatibilities, 143 | ["load_before", "incompatibilities"]) 144 | ): 145 | return 146 | 147 | 148 | # Mod ID used in the mod loader 149 | # Format: {namespace}-{name} 150 | func get_mod_id() -> String: 151 | return "%s-%s" % [namespace, name] 152 | 153 | 154 | # Package ID used by Thunderstore 155 | # Format: {namespace}-{name}-{version_number} 156 | func get_package_id() -> String: 157 | return "%s-%s-%s" % [namespace, name, version_number] 158 | 159 | 160 | # Returns the Manifest values as a dictionary 161 | func get_as_dict() -> Dictionary: 162 | return { 163 | "name": name, 164 | "namespace": namespace, 165 | "version_number": version_number, 166 | "description": description, 167 | "website_url": website_url, 168 | "dependencies": dependencies, 169 | "optional_dependencies": optional_dependencies, 170 | "authors": authors, 171 | "compatible_game_version": compatible_game_version, 172 | "compatible_mod_loader_version": compatible_mod_loader_version, 173 | "incompatibilities": incompatibilities, 174 | "load_before": load_before, 175 | "tags": tags, 176 | "config_schema": config_schema, 177 | "description_rich": description_rich, 178 | "image": image, 179 | } 180 | 181 | 182 | # Returns the Manifest values as JSON, in the manifest.json format 183 | func to_json() -> String: 184 | return JSON.print({ 185 | "name": name, 186 | "namespace": namespace, 187 | "version_number": version_number, 188 | "description": description, 189 | "website_url": website_url, 190 | "dependencies": dependencies, 191 | "extra": { 192 | "godot":{ 193 | "authors": authors, 194 | "optional_dependencies": optional_dependencies, 195 | "compatible_game_version": compatible_game_version, 196 | "compatible_mod_loader_version": compatible_mod_loader_version, 197 | "incompatibilities": incompatibilities, 198 | "load_before": load_before, 199 | "tags": tags, 200 | "config_schema": config_schema, 201 | "description_rich": description_rich, 202 | "image": image, 203 | } 204 | } 205 | }, "\t") 206 | 207 | 208 | # Loads the default configuration for a mod. 209 | func load_mod_config_defaults() -> ModConfig: 210 | var default_config_save_path := _ModLoaderPath.get_path_to_mod_config_file(get_mod_id(), ModLoaderConfig.DEFAULT_CONFIG_NAME) 211 | var config := ModConfig.new( 212 | get_mod_id(), 213 | {}, 214 | default_config_save_path, 215 | config_schema 216 | ) 217 | 218 | # Check if there is no default.json file in the mods config directory 219 | if not _ModLoaderFile.file_exists(config.save_path): 220 | # Generate config_default based on the default values in config_schema 221 | config.data = _generate_default_config_from_schema(config.schema.properties) 222 | 223 | # If the default.json file exists 224 | else: 225 | var current_schema_md5 := config.get_schema_as_string().md5_text() 226 | var cache_schema_md5s := _ModLoaderCache.get_data("config_schemas") 227 | var cache_schema_md5: String = cache_schema_md5s[config.mod_id] if cache_schema_md5s.has(config.mod_id) else '' 228 | 229 | # Generate a new default config if the config schema has changed or there is nothing cached 230 | if not current_schema_md5 == cache_schema_md5 or not cache_schema_md5.empty(): 231 | config.data = _generate_default_config_from_schema(config.schema.properties) 232 | 233 | # If the config schema has not changed just load the json file 234 | else: 235 | config.data = _ModLoaderFile.get_json_as_dict(config.save_path) 236 | 237 | # Validate the config defaults 238 | if config.is_valid(): 239 | # Create the default config file 240 | config.save_to_file() 241 | 242 | # Store the md5 of the config schema in the cache 243 | _ModLoaderCache.update_data("config_schemas", {config.mod_id: config.get_schema_as_string().md5_text()} ) 244 | 245 | # Return the default ModConfig 246 | return config 247 | 248 | ModLoaderLog.fatal("The default config values for %s-%s are invalid. Configs will not be loaded." % [namespace, name], LOG_NAME) 249 | return null 250 | 251 | 252 | # Recursively searches for default values 253 | func _generate_default_config_from_schema(property: Dictionary, current_prop := {}) -> Dictionary: 254 | # Exit function if property is empty 255 | if property.empty(): 256 | return current_prop 257 | 258 | for property_key in property.keys(): 259 | var prop = property[property_key] 260 | 261 | # If this property contains nested properties, we recursively call this function 262 | if "properties" in prop: 263 | current_prop[property_key] = {} 264 | _generate_default_config_from_schema(prop.properties, current_prop[property_key]) 265 | # Return early here because a object will not have a "default" key 266 | return current_prop 267 | 268 | # If this property contains a default value, add it to the global config_defaults dictionary 269 | if JSONSchema.JSKW_DEFAULT in prop: 270 | # Initialize the current_key if it is missing in config_defaults 271 | if not current_prop.has(property_key): 272 | current_prop[property_key] = {} 273 | 274 | # Add the default value to the config_defaults 275 | current_prop[property_key] = prop.default 276 | 277 | return current_prop 278 | 279 | 280 | # Handles deprecation of the single string value in the compatible_mod_loader_version. 281 | func _handle_compatible_mod_loader_version(mod_id: String, godot_details: Dictionary) -> Array: 282 | var link_manifest_docs := "https://wiki.godotmodding.com/#/guides/modding/mod_files?id=manifestjson" 283 | var array_value := ModLoaderUtils.get_array_from_dict(godot_details, "compatible_mod_loader_version") 284 | 285 | # If there are array values 286 | if array_value.size() > 0: 287 | # Check for valid versions 288 | if not is_semver_version_array_valid(mod_id, array_value, "compatible_mod_loader_version"): 289 | return [] 290 | 291 | return array_value 292 | 293 | # If the array is empty check if a string was passed 294 | var string_value := ModLoaderUtils.get_string_from_dict(godot_details, "compatible_mod_loader_version") 295 | # If an empty string was passed 296 | if string_value == "": 297 | # Using str() here because format strings caused an error 298 | ModLoaderLog.fatal( 299 | str ( 300 | "%s - \"compatible_mod_loader_version\" is a required field." + 301 | " For more details visit %s" 302 | ) % [mod_id, link_manifest_docs], 303 | LOG_NAME) 304 | return [] 305 | 306 | # If a string was passed 307 | ModLoaderDeprecated.deprecated_message( 308 | str( 309 | "%s - The single String value for \"compatible_mod_loader_version\" is deprecated. " + 310 | "Please provide an Array. For more details visit %s" 311 | ) % [mod_id, link_manifest_docs], 312 | "6.0.0") 313 | return [string_value] 314 | 315 | 316 | # A valid namespace may only use letters (any case), numbers and underscores 317 | # and has to be longer than 3 characters 318 | # a-z A-Z 0-9 _ (longer than 3 characters) 319 | static func is_name_or_namespace_valid(check_name: String, is_silent := false) -> bool: 320 | var re := RegEx.new() 321 | var _compile_error_1 = re.compile("^[a-zA-Z0-9_]*$") # alphanumeric and _ 322 | 323 | if re.search(check_name) == null: 324 | if not is_silent: 325 | ModLoaderLog.fatal("Invalid name or namespace: \"%s\". You may only use letters, numbers and underscores." % check_name, LOG_NAME) 326 | return false 327 | 328 | var _compile_error_2 = re.compile("^[a-zA-Z0-9_]{3,}$") # at least 3 long 329 | if re.search(check_name) == null: 330 | if not is_silent: 331 | ModLoaderLog.fatal("Invalid name or namespace: \"%s\". Must be longer than 3 characters." % check_name, LOG_NAME) 332 | return false 333 | 334 | return true 335 | 336 | 337 | static func is_semver_version_array_valid(mod_id: String, version_array: PoolStringArray, version_array_descripton: String, is_silent := false) -> bool: 338 | var is_valid := true 339 | 340 | for version in version_array: 341 | if not is_semver_valid(mod_id, version, version_array_descripton, is_silent): 342 | is_valid = false 343 | 344 | return is_valid 345 | 346 | 347 | # A valid semantic version should follow this format: {mayor}.{minor}.{patch} 348 | # reference https://semver.org/ for details 349 | # {0-9}.{0-9}.{0-9} (no leading 0, shorter than 16 characters total) 350 | static func is_semver_valid(mod_id: String, check_version_number: String, field_name: String, is_silent := false) -> bool: 351 | var re := RegEx.new() 352 | var _compile_error = re.compile("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$") 353 | 354 | if re.search(check_version_number) == null: 355 | if not is_silent: 356 | # Using str() here because format strings caused an error 357 | ModLoaderLog.fatal( 358 | str( 359 | "Invalid semantic version: \"%s\" in field \"%s\" of mod \"%s\". " + 360 | "You may only use numbers without leading zero and periods" + 361 | "following this format {mayor}.{minor}.{patch}" 362 | ) % [check_version_number, field_name, mod_id], 363 | LOG_NAME 364 | ) 365 | return false 366 | 367 | if check_version_number.length() > 16: 368 | if not is_silent: 369 | ModLoaderLog.fatal( 370 | str( 371 | "Invalid semantic version: \"%s\" in field \"%s\" of mod \"%s\". " + 372 | "Version number must be shorter than 16 characters." 373 | ) % [check_version_number, field_name, mod_id], 374 | LOG_NAME 375 | ) 376 | return false 377 | 378 | return true 379 | 380 | 381 | static func validate_distinct_mod_ids_in_arrays( 382 | mod_id: String, 383 | array_one: PoolStringArray, 384 | array_two: PoolStringArray, 385 | array_description: PoolStringArray, 386 | additional_info := "", 387 | is_silent := false 388 | ) -> bool: 389 | # Initialize an empty array to hold any overlaps. 390 | var overlaps: PoolStringArray = [] 391 | 392 | # Loop through each incompatibility and check if it is also listed as a dependency. 393 | for mod_id in array_one: 394 | if array_two.has(mod_id): 395 | overlaps.push_back(mod_id) 396 | 397 | # If no overlaps were found 398 | if overlaps.size() == 0: 399 | return true 400 | 401 | # If any overlaps were found 402 | if not is_silent: 403 | ModLoaderLog.fatal( 404 | ( 405 | "The mod -> %s lists the same mod(s) -> %s - in \"%s\" and \"%s\". %s" 406 | % [mod_id, overlaps, array_description[0], array_description[1], additional_info] 407 | ), 408 | LOG_NAME 409 | ) 410 | return false 411 | 412 | # If silent just return false 413 | return false 414 | 415 | 416 | static func is_mod_id_array_valid(own_mod_id: String, mod_id_array: PoolStringArray, mod_id_array_description: String, is_silent := false) -> bool: 417 | var is_valid := true 418 | 419 | # If there are mod ids 420 | if mod_id_array.size() > 0: 421 | for mod_id in mod_id_array: 422 | # Check if mod id is the same as the mods mod id. 423 | if mod_id == own_mod_id: 424 | is_valid = false 425 | if not is_silent: 426 | ModLoaderLog.fatal("The mod \"%s\" lists itself as \"%s\" in its own manifest.json file" % [mod_id, mod_id_array_description], LOG_NAME) 427 | 428 | # Check if the mod id is a valid mod id. 429 | if not is_mod_id_valid(own_mod_id, mod_id, mod_id_array_description, is_silent): 430 | is_valid = false 431 | 432 | return is_valid 433 | 434 | 435 | static func is_mod_id_valid(original_mod_id: String, check_mod_id: String, type := "", is_silent := false) -> bool: 436 | var intro_text = "A %s for the mod \"%s\" is invalid: " % [type, original_mod_id] if not type == "" else "" 437 | 438 | # contains hyphen? 439 | if not check_mod_id.count("-") == 1: 440 | if not is_silent: 441 | ModLoaderLog.fatal(str(intro_text, "Expected a single hyphen in the mod ID, but the %s was: \"%s\"" % [type, check_mod_id]), LOG_NAME) 442 | return false 443 | 444 | # at least 7 long (1 for hyphen, 3 each for namespace/name) 445 | var mod_id_length = check_mod_id.length() 446 | if mod_id_length < 7: 447 | if not is_silent: 448 | ModLoaderLog.fatal(str(intro_text, "Mod ID for \"%s\" is too short. It must be at least 7 characters, but its length is: %s" % [check_mod_id, mod_id_length]), LOG_NAME) 449 | return false 450 | 451 | var split = check_mod_id.split("-") 452 | var check_namespace = split[0] 453 | var check_name = split[1] 454 | var re := RegEx.new() 455 | re.compile("^[a-zA-Z0-9_]{3,}$") # alphanumeric and _ and at least 3 characters 456 | 457 | if re.search(check_namespace) == null: 458 | if not is_silent: 459 | ModLoaderLog.fatal(str(intro_text, "Mod ID has an invalid namespace (author) for \"%s\". Namespace can only use letters, numbers and underscores, but was: \"%s\"" % [check_mod_id, check_namespace]), LOG_NAME) 460 | return false 461 | 462 | if re.search(check_name) == null: 463 | if not is_silent: 464 | ModLoaderLog.fatal(str(intro_text, "Mod ID has an invalid name for \"%s\". Name can only use letters, numbers and underscores, but was: \"%s\"" % [check_mod_id, check_name]), LOG_NAME) 465 | return false 466 | 467 | return true 468 | -------------------------------------------------------------------------------- /addons/mod_loader/resources/mod_user_profile.gd: -------------------------------------------------------------------------------- 1 | extends Resource 2 | class_name ModUserProfile 3 | 4 | 5 | # This Class is used to represent a User Profile for the ModLoader. 6 | 7 | export var name := "" 8 | export var mod_list := {} 9 | 10 | 11 | func _init(_name := "", _mod_list := {}) -> void: 12 | name = _name 13 | mod_list = _mod_list 14 | -------------------------------------------------------------------------------- /addons/mod_loader/resources/options_current.gd: -------------------------------------------------------------------------------- 1 | class_name ModLoaderCurrentOptions 2 | extends Resource 3 | 4 | # The default options set for the mod loader 5 | export (Resource) var current_options: Resource = preload("res://addons/mod_loader/options/profiles/default.tres") 6 | 7 | # Overrides for all available feature tags through OS.has_feature() 8 | # Format: Dictionary[String: ModLoaderOptionsProfile] where the string is a tag 9 | # Warning: Some tags can occur at the same time (Windows + editor for example) - 10 | # In a case where multiple apply, the last one in the dict will override all others 11 | export (Dictionary) var feature_override_options: Dictionary = { 12 | "editor": preload("res://addons/mod_loader/options/profiles/editor.tres") 13 | } 14 | 15 | -------------------------------------------------------------------------------- /addons/mod_loader/resources/options_profile.gd: -------------------------------------------------------------------------------- 1 | class_name ModLoaderOptionsProfile 2 | extends Resource 3 | 4 | # export (String) var my_string := "" 5 | # export (Resource) var upgrade_to_process_icon = null 6 | # export (Array, Resource) var elites: = [] 7 | 8 | export (bool) var enable_mods = true 9 | export (Array, String) var locked_mods = [] 10 | export (ModLoaderLog.VERBOSITY_LEVEL) var log_level := ModLoaderLog.VERBOSITY_LEVEL.DEBUG 11 | export (Array, String) var disabled_mods = [] 12 | export (bool) var allow_modloader_autoloads_anywhere = false 13 | export (bool) var steam_workshop_enabled = false 14 | export (String, DIR) var override_path_to_mods = "" 15 | export (String, DIR) var override_path_to_configs = "" 16 | export (String, DIR) var override_path_to_workshop = "" 17 | export (bool) var ignore_deprecated_errors = false 18 | export (Array, String) var ignored_mod_names_in_log = [] 19 | -------------------------------------------------------------------------------- /addons/mod_loader/setup/setup_log.gd: -------------------------------------------------------------------------------- 1 | class_name ModLoaderSetupLog 2 | 3 | 4 | # Slimed down version of ModLoaderLog for the ModLoader Self Setup 5 | 6 | const MOD_LOG_PATH := "user://logs/modloader.log" 7 | 8 | enum VERBOSITY_LEVEL { 9 | ERROR, 10 | WARNING, 11 | INFO, 12 | DEBUG, 13 | } 14 | 15 | 16 | class ModLoaderLogEntry: 17 | extends Resource 18 | 19 | var mod_name: String 20 | var message: String 21 | var type: String 22 | var time: String 23 | 24 | 25 | func _init(_mod_name: String, _message: String, _type: String, _time: String) -> void: 26 | mod_name = _mod_name 27 | message = _message 28 | type = _type 29 | time = _time 30 | 31 | 32 | func get_entry() -> String: 33 | return time + get_prefix() + message 34 | 35 | 36 | func get_prefix() -> String: 37 | return "%s %s: " % [type.to_upper(), mod_name] 38 | 39 | 40 | func get_md5() -> String: 41 | return str(get_prefix(), message).md5_text() 42 | 43 | 44 | # API log functions 45 | # ============================================================================= 46 | 47 | # Logs the error in red and a stack trace. Prefixed FATAL-ERROR 48 | # Stops the execution in editor 49 | # Always logged 50 | static func fatal(message: String, mod_name: String) -> void: 51 | _log(message, mod_name, "fatal-error") 52 | 53 | 54 | # Logs the message and pushed an error. Prefixed ERROR 55 | # Always logged 56 | static func error(message: String, mod_name: String) -> void: 57 | _log(message, mod_name, "error") 58 | 59 | 60 | # Logs the message and pushes a warning. Prefixed WARNING 61 | # Logged with verbosity level at or above warning (-v) 62 | static func warning(message: String, mod_name: String) -> void: 63 | _log(message, mod_name, "warning") 64 | 65 | 66 | # Logs the message. Prefixed INFO 67 | # Logged with verbosity level at or above info (-vv) 68 | static func info(message: String, mod_name: String) -> void: 69 | _log(message, mod_name, "info") 70 | 71 | 72 | # Logs the message. Prefixed SUCCESS 73 | # Logged with verbosity level at or above info (-vv) 74 | static func success(message: String, mod_name: String) -> void: 75 | _log(message, mod_name, "success") 76 | 77 | 78 | # Logs the message. Prefixed DEBUG 79 | # Logged with verbosity level at or above debug (-vvv) 80 | static func debug(message: String, mod_name: String) -> void: 81 | _log(message, mod_name, "debug") 82 | 83 | 84 | # Logs the message formatted with [method JSON.print]. Prefixed DEBUG 85 | # Logged with verbosity level at or above debug (-vvv) 86 | static func debug_json_print(message: String, json_printable, mod_name: String) -> void: 87 | message = "%s\n%s" % [message, JSON.print(json_printable, " ")] 88 | _log(message, mod_name, "debug") 89 | 90 | 91 | # Internal log functions 92 | # ============================================================================= 93 | 94 | static func _log(message: String, mod_name: String, log_type: String = "info") -> void: 95 | var time := "%s " % _get_time_string() 96 | var log_entry := ModLoaderLogEntry.new(mod_name, message, log_type, time) 97 | 98 | match log_type.to_lower(): 99 | "fatal-error": 100 | push_error(message) 101 | _write_to_log_file(log_entry.get_entry()) 102 | _write_to_log_file(JSON.print(get_stack(), " ")) 103 | assert(false, message) 104 | "error": 105 | printerr(message) 106 | push_error(message) 107 | _write_to_log_file(log_entry.get_entry()) 108 | "warning": 109 | print(log_entry.get_prefix() + message) 110 | push_warning(message) 111 | _write_to_log_file(log_entry.get_entry()) 112 | "info", "success": 113 | print(log_entry.get_prefix() + message) 114 | _write_to_log_file(log_entry.get_entry()) 115 | "debug": 116 | print(log_entry.get_prefix() + message) 117 | _write_to_log_file(log_entry.get_entry()) 118 | 119 | 120 | # Internal Date Time 121 | # ============================================================================= 122 | 123 | # Returns the current time as a string in the format hh:mm:ss 124 | static func _get_time_string() -> String: 125 | var date_time := Time.get_datetime_dict_from_system() 126 | return "%02d:%02d:%02d" % [ date_time.hour, date_time.minute, date_time.second ] 127 | 128 | 129 | # Returns the current date as a string in the format yyyy-mm-dd 130 | static func _get_date_string() -> String: 131 | var date_time := Time.get_datetime_dict_from_system() 132 | return "%s-%02d-%02d" % [ date_time.year, date_time.month, date_time.day ] 133 | 134 | 135 | # Returns the current date and time as a string in the format yyyy-mm-dd_hh:mm:ss 136 | static func _get_date_time_string() -> String: 137 | return "%s_%s" % [ _get_date_string(), _get_time_string() ] 138 | 139 | 140 | # Internal File 141 | # ============================================================================= 142 | 143 | static func _write_to_log_file(string_to_write: String) -> void: 144 | var log_file := File.new() 145 | 146 | if not log_file.file_exists(MOD_LOG_PATH): 147 | _rotate_log_file() 148 | 149 | var error := log_file.open(MOD_LOG_PATH, File.READ_WRITE) 150 | if not error == OK: 151 | assert(false, "Could not open log file, error code: %s" % error) 152 | return 153 | 154 | log_file.seek_end() 155 | log_file.store_string("\n" + string_to_write) 156 | log_file.close() 157 | 158 | 159 | # Keeps log backups for every run, just like the Godot; gdscript implementation of 160 | # https://github.com/godotengine/godot/blob/1d14c054a12dacdc193b589e4afb0ef319ee2aae/core/io/logger.cpp#L151 161 | static func _rotate_log_file() -> void: 162 | var MAX_LOGS := int(ProjectSettings.get_setting("logging/file_logging/max_log_files")) 163 | var log_file := File.new() 164 | 165 | if log_file.file_exists(MOD_LOG_PATH): 166 | if MAX_LOGS > 1: 167 | var datetime := _get_date_time_string().replace(":", ".") 168 | var backup_name: String = MOD_LOG_PATH.get_basename() + "_" + datetime 169 | if MOD_LOG_PATH.get_extension().length() > 0: 170 | backup_name += "." + MOD_LOG_PATH.get_extension() 171 | 172 | var dir := Directory.new() 173 | if dir.dir_exists(MOD_LOG_PATH.get_base_dir()): 174 | dir.copy(MOD_LOG_PATH, backup_name) 175 | _clear_old_log_backups() 176 | 177 | # only File.WRITE creates a new file, File.READ_WRITE throws an error 178 | var error := log_file.open(MOD_LOG_PATH, File.WRITE) 179 | if not error == OK: 180 | assert(false, "Could not open log file, error code: %s" % error) 181 | log_file.store_string('%s Created log' % _get_date_string()) 182 | log_file.close() 183 | 184 | 185 | static func _clear_old_log_backups() -> void: 186 | var MAX_LOGS := int(ProjectSettings.get_setting("logging/file_logging/max_log_files")) 187 | var MAX_BACKUPS := MAX_LOGS - 1 # -1 for the current new log (not a backup) 188 | var basename := MOD_LOG_PATH.get_file().get_basename() as String 189 | var extension := MOD_LOG_PATH.get_extension() as String 190 | 191 | var dir := Directory.new() 192 | if not dir.dir_exists(MOD_LOG_PATH.get_base_dir()): 193 | return 194 | if not dir.open(MOD_LOG_PATH.get_base_dir()) == OK: 195 | return 196 | 197 | dir.list_dir_begin() 198 | var file := dir.get_next() 199 | var backups := [] 200 | while file.length() > 0: 201 | if (not dir.current_is_dir() and 202 | file.begins_with(basename) and 203 | file.get_extension() == extension and 204 | not file == MOD_LOG_PATH.get_file()): 205 | backups.append(file) 206 | file = dir.get_next() 207 | dir.list_dir_end() 208 | 209 | if backups.size() > MAX_BACKUPS: 210 | backups.sort() 211 | backups.resize(backups.size() - MAX_BACKUPS) 212 | for file_to_delete in backups: 213 | dir.remove(file_to_delete) 214 | -------------------------------------------------------------------------------- /addons/mod_loader/setup/setup_utils.gd: -------------------------------------------------------------------------------- 1 | class_name ModLoaderSetupUtils 2 | 3 | 4 | # Slimed down version of ModLoaderUtils for the ModLoader Self Setup 5 | 6 | const LOG_NAME := "ModLoader:SetupUtils" 7 | 8 | 9 | # Get the path to a local folder. Primarily used to get the (packed) mods 10 | # folder, ie "res://mods" or the OS's equivalent, as well as the configs path 11 | static func get_local_folder_dir(subfolder: String = "") -> String: 12 | var game_install_directory := OS.get_executable_path().get_base_dir() 13 | 14 | if OS.get_name() == "OSX": 15 | game_install_directory = game_install_directory.get_base_dir().get_base_dir() 16 | 17 | # Fix for running the game through the Godot editor (as the EXE path would be 18 | # the editor's own EXE, which won't have any mod ZIPs) 19 | # if OS.is_debug_build(): 20 | if OS.has_feature("editor"): 21 | game_install_directory = "res://" 22 | 23 | return game_install_directory.plus_file(subfolder) 24 | 25 | 26 | # Provide a path, get the file name at the end of the path 27 | static func get_file_name_from_path(path: String, make_lower_case := true, remove_extension := false) -> String: 28 | var file_name := path.get_file() 29 | 30 | if make_lower_case: 31 | file_name = file_name.to_lower() 32 | 33 | if remove_extension: 34 | file_name = file_name.trim_suffix("." + file_name.get_extension()) 35 | 36 | return file_name 37 | 38 | 39 | # Get an array of all autoloads -> ["autoload/AutoloadName", ...] 40 | static func get_autoload_array() -> Array: 41 | var autoloads := [] 42 | 43 | # Get all autoload settings 44 | for prop in ProjectSettings.get_property_list(): 45 | var name: String = prop.name 46 | if name.begins_with("autoload/"): 47 | autoloads.append(name.trim_prefix("autoload/")) 48 | 49 | return autoloads 50 | 51 | 52 | # Get the index of a specific autoload 53 | static func get_autoload_index(autoload_name: String) -> int: 54 | var autoloads := get_autoload_array() 55 | var autoload_index := autoloads.find(autoload_name) 56 | 57 | return autoload_index 58 | 59 | 60 | # Get the path where override.cfg will be stored. 61 | # Not the same as the local folder dir (for mac) 62 | static func get_override_path() -> String: 63 | var base_path := "" 64 | if OS.has_feature("editor"): 65 | base_path = ProjectSettings.globalize_path("res://") 66 | else: 67 | # this is technically different to res:// in macos, but we want the 68 | # executable dir anyway, so it is exactly what we need 69 | base_path = OS.get_executable_path().get_base_dir() 70 | 71 | return base_path.plus_file("override.cfg") 72 | 73 | 74 | # Register an array of classes to the global scope, since Godot only does that in the editor. 75 | static func register_global_classes_from_array(new_global_classes: Array) -> void: 76 | var ModLoaderSetupLog: Object = load("res://addons/mod_loader/setup/setup_log.gd") 77 | var registered_classes: Array = ProjectSettings.get_setting("_global_script_classes") 78 | var registered_class_icons: Dictionary = ProjectSettings.get_setting("_global_script_class_icons") 79 | 80 | for new_class in new_global_classes: 81 | if not _is_valid_global_class_dict(new_class): 82 | continue 83 | for old_class in registered_classes: 84 | if old_class.class == new_class.class: 85 | if OS.has_feature("editor"): 86 | ModLoaderSetupLog.info('Class "%s" to be registered as global was already registered by the editor. Skipping.' % new_class.class, LOG_NAME) 87 | else: 88 | ModLoaderSetupLog.info('Class "%s" to be registered as global already exists. Skipping.' % new_class.class, LOG_NAME) 89 | continue 90 | 91 | registered_classes.append(new_class) 92 | registered_class_icons[new_class.class] = "" # empty icon, does not matter 93 | 94 | ProjectSettings.set_setting("_global_script_classes", registered_classes) 95 | ProjectSettings.set_setting("_global_script_class_icons", registered_class_icons) 96 | 97 | 98 | # Checks if all required fields are in the given [Dictionary] 99 | # Format: { "base": "ParentClass", "class": "ClassName", "language": "GDScript", "path": "res://path/class_name.gd" } 100 | static func _is_valid_global_class_dict(global_class_dict: Dictionary) -> bool: 101 | var ModLoaderSetupLog: Object = load("res://addons/mod_loader/setup/setup_log.gd") 102 | var required_fields := ["base", "class", "language", "path"] 103 | if not global_class_dict.has_all(required_fields): 104 | ModLoaderSetupLog.fatal("Global class to be registered is missing one of %s" % required_fields, LOG_NAME) 105 | return false 106 | 107 | var file = File.new() 108 | if not file.file_exists(global_class_dict.path): 109 | ModLoaderSetupLog.fatal('Class "%s" to be registered as global could not be found at given path "%s"' % 110 | [global_class_dict.class, global_class_dict.path], LOG_NAME) 111 | return false 112 | 113 | return true 114 | 115 | 116 | # Check if the provided command line argument was present when launching the game 117 | static func is_running_with_command_line_arg(argument: String) -> bool: 118 | for arg in OS.get_cmdline_args(): 119 | if argument == arg.split("=")[0]: 120 | return true 121 | 122 | return false 123 | 124 | 125 | # Get the command line argument value if present when launching the game 126 | static func get_cmd_line_arg_value(argument: String) -> String: 127 | var args := _get_fixed_cmdline_args() 128 | 129 | for arg_index in args.size(): 130 | var arg := args[arg_index] as String 131 | 132 | var key := arg.split("=")[0] 133 | if key == argument: 134 | # format: `--arg=value` or `--arg="value"` 135 | if "=" in arg: 136 | var value := arg.trim_prefix(argument + "=") 137 | value = value.trim_prefix('"').trim_suffix('"') 138 | value = value.trim_prefix("'").trim_suffix("'") 139 | return value 140 | 141 | # format: `--arg value` or `--arg "value"` 142 | elif arg_index +1 < args.size() and not args[arg_index +1].begins_with("--"): 143 | return args[arg_index + 1] 144 | 145 | return "" 146 | 147 | 148 | static func _get_fixed_cmdline_args() -> PoolStringArray: 149 | return fix_godot_cmdline_args_string_space_splitting(OS.get_cmdline_args()) 150 | 151 | 152 | # Reverses a bug in Godot, which splits input strings at spaces even if they are quoted 153 | # e.g. `--arg="some value" --arg-two 'more value'` becomes `[ --arg="some, value", --arg-two, 'more, value' ]` 154 | static func fix_godot_cmdline_args_string_space_splitting(args: PoolStringArray) -> PoolStringArray: 155 | if not OS.has_feature("editor"): # only happens in editor builds 156 | return args 157 | if OS.has_feature("Windows"): # windows is unaffected 158 | return args 159 | 160 | var fixed_args := PoolStringArray([]) 161 | var fixed_arg := "" 162 | # if we encounter an argument that contains `=` followed by a quote, 163 | # or an argument that starts with a quote, take all following args and 164 | # concatenate them into one, until we find the closing quote 165 | for arg in args: 166 | var arg_string := arg as String 167 | if '="' in arg_string or '="' in fixed_arg or \ 168 | arg_string.begins_with('"') or fixed_arg.begins_with('"'): 169 | if not fixed_arg == "": 170 | fixed_arg += " " 171 | fixed_arg += arg_string 172 | if arg_string.ends_with('"'): 173 | fixed_args.append(fixed_arg.trim_prefix(" ")) 174 | fixed_arg = "" 175 | continue 176 | # same thing for single quotes 177 | elif "='" in arg_string or "='" in fixed_arg \ 178 | or arg_string.begins_with("'") or fixed_arg.begins_with("'"): 179 | if not fixed_arg == "": 180 | fixed_arg += " " 181 | fixed_arg += arg_string 182 | if arg_string.ends_with("'"): 183 | fixed_args.append(fixed_arg.trim_prefix(" ")) 184 | fixed_arg = "" 185 | continue 186 | 187 | else: 188 | fixed_args.append(arg_string) 189 | 190 | return fixed_args 191 | --------------------------------------------------------------------------------