gdharness 1.1.21 → 1.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/cli.js +1036 -525
- package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
- package/build/godot/addons/gdharness_editor/reading.gd +17 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
- package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
- package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
- package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
- package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
- package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
- package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
- package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +8 -4
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
- package/build/godot/addons/gdharness_runtime/runtime_paths.gd +28 -8
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +7 -299
- package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
- package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
- package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +20 -9
- package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
- package/build/godot/operations/audio_buses.gd +47 -11
- package/build/godot/operations/class_cache.gd +18 -2
- package/build/godot/operations/dependencies.gd +128 -17
- package/build/godot/operations/file_walk.gd +20 -0
- package/build/godot/operations/gdscript_analysis.gd +59 -20
- package/build/godot/operations/gdscript_authoring.gd +127 -60
- package/build/godot/operations/godot_operations.gd +24 -5
- package/build/godot/operations/import_pipeline.gd +179 -19
- package/build/godot/operations/logger.gd +2 -2
- package/build/godot/operations/plugins.gd +88 -70
- package/build/godot/operations/project_config.gd +41 -6
- package/build/godot/operations/project_diagnostics.gd +25 -10
- package/build/godot/operations/reading.gd +17 -0
- package/build/godot/operations/resource_files.gd +60 -17
- package/build/godot/operations/serialisation.gd +116 -7
- package/build/index.js +949 -435
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
extends RefCounted
|
|
2
2
|
|
|
3
3
|
const Read = preload("reading.gd")
|
|
4
|
+
const Serialisation = preload("serialisation.gd")
|
|
4
5
|
const FileWalk = preload("file_walk.gd")
|
|
5
6
|
const Log = preload("logger.gd")
|
|
6
7
|
|
|
@@ -37,11 +38,20 @@ const IMPORTABLE_EXTENSIONS: Array[String] = [
|
|
|
37
38
|
"obj",
|
|
38
39
|
"dae",
|
|
39
40
|
]
|
|
41
|
+
## The format version each importer writes as `importer_version` where it is above 0, which the
|
|
42
|
+
## editor reimports anything older than. Read off sidecars 4.7.2 writes: scene and animation_library
|
|
43
|
+
## write 1, and the texture importers write none. A case imports one fresh and compares, so an engine
|
|
44
|
+
## that raises one fails it rather than leaving this behind.
|
|
45
|
+
const IMPORTER_VERSIONS: Dictionary[String, int] = {"scene": 1, "animation_library": 1}
|
|
46
|
+
## The VRAM compression formats a project can ask for, each behind its
|
|
47
|
+
## `rendering/textures/vram_compression/import_<format>` setting.
|
|
48
|
+
const VRAM_FORMATS: Array[String] = ["s3tc_bptc", "etc2_astc"]
|
|
40
49
|
const EDITOR_DIRECTORY: String = "res://.godot/editor"
|
|
41
50
|
const EDITOR_CACHE_PREFIX: String = "filesystem_cache"
|
|
42
51
|
|
|
43
52
|
var _log: Log
|
|
44
53
|
var _files: FileWalk = FileWalk.new()
|
|
54
|
+
var _values: Serialisation = Serialisation.new()
|
|
45
55
|
var _sidecar_md5s: Variant = null
|
|
46
56
|
|
|
47
57
|
|
|
@@ -63,7 +73,7 @@ func get_import_status(params: Dictionary) -> Dictionary:
|
|
|
63
73
|
|
|
64
74
|
var resources: Array[Dictionary] = []
|
|
65
75
|
var summary: Dictionary = {
|
|
66
|
-
"total": 0, "needs_reimport": 0, "failed": 0, "up_to_date": 0, "missing_source": 0
|
|
76
|
+
"total": 0, "needs_reimport": 0, "failed": 0, "up_to_date": 0, "missing_source": 0, "not_imported": 0
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
if not resource_path.is_empty():
|
|
@@ -107,8 +117,7 @@ func get_import_options(params: Dictionary) -> Dictionary:
|
|
|
107
117
|
var import_file_path: String = resource_path + ".import"
|
|
108
118
|
|
|
109
119
|
if not FileAccess.file_exists(import_file_path):
|
|
110
|
-
_log.
|
|
111
|
-
return _log.failure("This resource may not have been imported yet")
|
|
120
|
+
return _log.failure(_no_sidecar(resource_path))
|
|
112
121
|
|
|
113
122
|
var config: ConfigFile = ConfigFile.new()
|
|
114
123
|
var err: Error = config.load(import_file_path)
|
|
@@ -120,11 +129,14 @@ func get_import_options(params: Dictionary) -> Dictionary:
|
|
|
120
129
|
"resource_path": resource_path, "import_file": import_file_path, "remap": {}, "deps": {}, "params": {}
|
|
121
130
|
}
|
|
122
131
|
|
|
132
|
+
# Through the serialiser, as every other read answers: an engine type written straight into
|
|
133
|
+
# JSON is its own text, so a Vector3 option came back as "(1, 1, 1)", and writing that back put
|
|
134
|
+
# a string in the sidecar where the importer reads a vector.
|
|
123
135
|
for section: String in ["remap", "deps", "params"]:
|
|
124
136
|
if config.has_section(section):
|
|
125
137
|
var values: Dictionary = result[section]
|
|
126
138
|
for key: String in config.get_section_keys(section):
|
|
127
|
-
values[key] = config.get_value(section, key)
|
|
139
|
+
values[key] = _values.serialize_value(config.get_value(section, key))
|
|
128
140
|
|
|
129
141
|
return result
|
|
130
142
|
|
|
@@ -142,8 +154,12 @@ func set_import_options(params: Dictionary) -> Dictionary:
|
|
|
142
154
|
var import_file_path: String = resource_path + ".import"
|
|
143
155
|
|
|
144
156
|
if not FileAccess.file_exists(import_file_path):
|
|
145
|
-
_log.
|
|
146
|
-
|
|
157
|
+
return _log.failure(_no_sidecar(resource_path))
|
|
158
|
+
# A sidecar left behind by a source that has gone takes options that nothing will ever apply.
|
|
159
|
+
if not FileAccess.file_exists(resource_path):
|
|
160
|
+
return _log.failure(
|
|
161
|
+
resource_path + " is not on disk, so there is nothing its import options apply to"
|
|
162
|
+
)
|
|
147
163
|
|
|
148
164
|
var config: ConfigFile = ConfigFile.new()
|
|
149
165
|
var err: Error = config.load(import_file_path)
|
|
@@ -151,13 +167,37 @@ func set_import_options(params: Dictionary) -> Dictionary:
|
|
|
151
167
|
if err != OK:
|
|
152
168
|
return _log.failure("Failed to parse import file: " + str(err))
|
|
153
169
|
|
|
154
|
-
|
|
170
|
+
# Every option checked before any is written, so a refused one leaves the sidecar as it was.
|
|
171
|
+
var fitted_values: Dictionary = {}
|
|
172
|
+
var refused: Array[String] = []
|
|
155
173
|
for key: Variant in options:
|
|
156
174
|
var name: String = str(key)
|
|
157
|
-
var
|
|
158
|
-
|
|
175
|
+
var held: Variant = config.get_value("params", name, null)
|
|
176
|
+
var given: Variant = _values.deserialize_value(options[key])
|
|
177
|
+
var value: Variant = _as_option(_values.fitted(options[key], typeof(held)), held)
|
|
178
|
+
# A number the option cannot hold whole is refused rather than truncated: 2.5 fitted to an
|
|
179
|
+
# enum option became 2, which is another setting.
|
|
180
|
+
var lossy: bool = (
|
|
181
|
+
(given is int or given is float)
|
|
182
|
+
and (value is int or value is float)
|
|
183
|
+
and type_convert(value, typeof(given)) != given
|
|
184
|
+
)
|
|
185
|
+
if held != null and (lossy or not Serialisation.acceptable(value, typeof(held))):
|
|
186
|
+
var held_type: String = type_string(typeof(held))
|
|
187
|
+
var article: String = "an" if held_type.to_lower()[0] in "aeiou" else "a"
|
|
188
|
+
refused.append(
|
|
189
|
+
"%s holds %s %s, and %s is not one" % [name, article, held_type, JSON.stringify(options[key])]
|
|
190
|
+
)
|
|
191
|
+
continue
|
|
192
|
+
fitted_values[name] = value
|
|
193
|
+
if not refused.is_empty():
|
|
194
|
+
return _log.failure("Nothing was written: " + "; ".join(refused))
|
|
195
|
+
|
|
196
|
+
var updated_keys: Array[String] = []
|
|
197
|
+
for name: String in fitted_values:
|
|
198
|
+
config.set_value("params", name, fitted_values[name])
|
|
159
199
|
updated_keys.append(name)
|
|
160
|
-
_log.debug("Set " + name + " = " + str(
|
|
200
|
+
_log.debug("Set " + name + " = " + str(fitted_values[name]))
|
|
161
201
|
|
|
162
202
|
err = config.save(import_file_path)
|
|
163
203
|
if err != OK:
|
|
@@ -178,6 +218,19 @@ static func _as_option(given: Variant, held: Variant) -> Variant:
|
|
|
178
218
|
return given
|
|
179
219
|
|
|
180
220
|
|
|
221
|
+
# The names of the project's export presets, in the order export_presets.cfg holds them.
|
|
222
|
+
static func _preset_names() -> Array[String]:
|
|
223
|
+
var names: Array[String] = []
|
|
224
|
+
var config: ConfigFile = ConfigFile.new()
|
|
225
|
+
if config.load("res://export_presets.cfg") != OK:
|
|
226
|
+
return names
|
|
227
|
+
var index: int = 0
|
|
228
|
+
while config.has_section("preset." + str(index)):
|
|
229
|
+
names.append(str(config.get_value("preset." + str(index), "name", "")))
|
|
230
|
+
index += 1
|
|
231
|
+
return names
|
|
232
|
+
|
|
233
|
+
|
|
181
234
|
# List export presets
|
|
182
235
|
func list_export_presets(_params: Dictionary) -> Dictionary:
|
|
183
236
|
_log.info("Listing export presets")
|
|
@@ -257,12 +310,12 @@ func validate_project(params: Dictionary) -> Dictionary:
|
|
|
257
310
|
include_suggestions
|
|
258
311
|
)
|
|
259
312
|
)
|
|
260
|
-
elif not
|
|
313
|
+
elif not FileWalk.missing_reason(main_scene).is_empty():
|
|
261
314
|
issues.append(
|
|
262
315
|
_finding(
|
|
263
316
|
"error",
|
|
264
317
|
"main_scene",
|
|
265
|
-
"Main scene
|
|
318
|
+
"Main scene " + FileWalk.missing_reason(main_scene),
|
|
266
319
|
"Update the main scene setting or create the missing scene file",
|
|
267
320
|
include_suggestions
|
|
268
321
|
)
|
|
@@ -280,6 +333,30 @@ func validate_project(params: Dictionary) -> Dictionary:
|
|
|
280
333
|
)
|
|
281
334
|
)
|
|
282
335
|
|
|
336
|
+
# The preset asked about is looked for rather than only logged: validating "for" a preset that
|
|
337
|
+
# does not exist answered valid, as though it had been checked.
|
|
338
|
+
if not preset_name.is_empty():
|
|
339
|
+
checks_performed.append("export_preset")
|
|
340
|
+
var named: Array[String] = _preset_names()
|
|
341
|
+
if preset_name not in named:
|
|
342
|
+
issues.append(
|
|
343
|
+
_finding(
|
|
344
|
+
"error",
|
|
345
|
+
"export_preset",
|
|
346
|
+
(
|
|
347
|
+
"No export preset is named "
|
|
348
|
+
+ preset_name
|
|
349
|
+
+ (
|
|
350
|
+
": the project has none"
|
|
351
|
+
if named.is_empty()
|
|
352
|
+
else ": the presets are " + ", ".join(named)
|
|
353
|
+
)
|
|
354
|
+
),
|
|
355
|
+
"Pass one of the preset names, or add the preset in the editor: Project > Export",
|
|
356
|
+
include_suggestions
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
|
|
283
360
|
checks_performed.append("icon")
|
|
284
361
|
var icon_path: String = str(ProjectSettings.get_setting("application/config/icon", ""))
|
|
285
362
|
if icon_path.is_empty():
|
|
@@ -323,11 +400,8 @@ func validate_project(params: Dictionary) -> Dictionary:
|
|
|
323
400
|
|
|
324
401
|
# A hundred scripts is enough to say whether the project is tidy without a large one
|
|
325
402
|
# turning validation into a full read of its source tree.
|
|
326
|
-
for script_path: String in script_files:
|
|
403
|
+
for script_path: String in script_files.slice(0, 100):
|
|
327
404
|
scripts_checked += 1
|
|
328
|
-
if scripts_checked > 100:
|
|
329
|
-
break
|
|
330
|
-
|
|
331
405
|
var file: FileAccess = FileAccess.open(script_path, FileAccess.READ)
|
|
332
406
|
if file:
|
|
333
407
|
var content: String = file.get_as_text()
|
|
@@ -355,6 +429,8 @@ func validate_project(params: Dictionary) -> Dictionary:
|
|
|
355
429
|
"warnings": warnings,
|
|
356
430
|
"checks_performed": checks_performed,
|
|
357
431
|
"scripts_checked": scripts_checked,
|
|
432
|
+
# Said beside the count, so a project of four hundred scripts is not read as checked whole.
|
|
433
|
+
"scripts_total": script_files.size(),
|
|
358
434
|
"issue_count": issues.size(),
|
|
359
435
|
"warning_count": warnings.size()
|
|
360
436
|
}
|
|
@@ -389,10 +465,14 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
|
|
|
389
465
|
|
|
390
466
|
var import_file_exists: bool = FileAccess.file_exists(import_file_path)
|
|
391
467
|
if not import_file_exists:
|
|
468
|
+
# A file with no sidecar has not been imported only if it is something the engine imports.
|
|
469
|
+
# A scene answered as needing an import, and a reimport of it ran a pass and then reported
|
|
470
|
+
# the scene as not reimported, which reads as the import failing.
|
|
471
|
+
var never: String = _never_imported(resource_path)
|
|
392
472
|
return {
|
|
393
473
|
"path": resource_path,
|
|
394
|
-
"status": "needs_reimport",
|
|
395
|
-
"reason": "it has not been imported",
|
|
474
|
+
"status": "needs_reimport" if never.is_empty() else "not_imported",
|
|
475
|
+
"reason": "it has not been imported" if never.is_empty() else never,
|
|
396
476
|
"import_file_exists": false,
|
|
397
477
|
"source_exists": true
|
|
398
478
|
}
|
|
@@ -402,7 +482,9 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
|
|
|
402
482
|
}
|
|
403
483
|
|
|
404
484
|
# The checks are the ones EditorFileSystem::_test_for_reimport makes, in its order, so a resource
|
|
405
|
-
# reads as needing an import
|
|
485
|
+
# reads as needing an import when the editor's next scan would import it. The importer's own two
|
|
486
|
+
# cannot be asked here, where no importer is loaded, so they are made from what was measured of
|
|
487
|
+
# the built-in ones; an importer a plugin adds is judged by its files alone.
|
|
406
488
|
var sidecar: ConfigFile = ConfigFile.new()
|
|
407
489
|
var stale: Dictionary = (
|
|
408
490
|
_stale("its import file cannot be read")
|
|
@@ -416,6 +498,41 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
|
|
|
416
498
|
return status
|
|
417
499
|
|
|
418
500
|
|
|
501
|
+
# Why [param resource_path] has no import file, with what to do about it where anything can be done.
|
|
502
|
+
func _no_sidecar(resource_path: String) -> String:
|
|
503
|
+
if not FileAccess.file_exists(resource_path):
|
|
504
|
+
return resource_path + " is not on disk"
|
|
505
|
+
var never: String = _never_imported(resource_path)
|
|
506
|
+
if not never.is_empty():
|
|
507
|
+
return resource_path + " has no import options: " + never
|
|
508
|
+
return (
|
|
509
|
+
resource_path
|
|
510
|
+
+ " has not been imported yet, so it has no import file: project_import reimport imports it"
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# Why the engine will never import [param resource_path], or empty when it would.
|
|
515
|
+
func _never_imported(resource_path: String) -> String:
|
|
516
|
+
var folder: String = resource_path.get_base_dir()
|
|
517
|
+
while folder.begins_with("res://"):
|
|
518
|
+
if _files.is_stepped_over(folder if folder.ends_with("/") else folder + "/"):
|
|
519
|
+
return "a .gdignore in " + folder + " keeps the engine from importing anything under it"
|
|
520
|
+
if folder == "res://":
|
|
521
|
+
break
|
|
522
|
+
folder = folder.get_base_dir()
|
|
523
|
+
var extension: String = resource_path.get_extension().to_lower()
|
|
524
|
+
if extension in IMPORTABLE_EXTENSIONS:
|
|
525
|
+
return ""
|
|
526
|
+
# Loadable without a sidecar is what a scene, a script or a saved resource is: read as it is.
|
|
527
|
+
if ResourceLoader.exists(resource_path):
|
|
528
|
+
return "the engine loads ." + extension + " files as they are, without importing them"
|
|
529
|
+
return (
|
|
530
|
+
"no importer built into the engine takes ."
|
|
531
|
+
+ extension
|
|
532
|
+
+ " files; one added by a plugin or an extension is known only to the editor, whose scan imports them"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
419
536
|
static func _stale(reason: String) -> Dictionary:
|
|
420
537
|
return {"status": "needs_reimport", "reason": reason}
|
|
421
538
|
|
|
@@ -443,6 +560,27 @@ func _sidecar_staleness(resource_path: String, import_file_path: String, sidecar
|
|
|
443
560
|
## Whether the import itself is still the one its sidecar and source describe: an answer for a
|
|
444
561
|
## stale resource, empty for a current one.
|
|
445
562
|
static func _import_staleness(resource_path: String, sidecar: ConfigFile) -> Dictionary:
|
|
563
|
+
# The importer is asked first, as the editor asks it: whether it still writes this format, and
|
|
564
|
+
# whether the settings the import was made under still hold. Both are true of an import whose
|
|
565
|
+
# files are all current, so they were answered as up to date while the editor's next scan
|
|
566
|
+
# imported them again: every scene after an engine upgrade raised the importer's version, and
|
|
567
|
+
# every VRAM texture once the project asked for another compression format.
|
|
568
|
+
var importer: String = str(sidecar.get_value("remap", "importer", ""))
|
|
569
|
+
var version: int = Read.as_int(sidecar.get_value("remap", "importer_version", 0))
|
|
570
|
+
var current: int = Read.as_int(IMPORTER_VERSIONS.get(importer, 0))
|
|
571
|
+
if current > version:
|
|
572
|
+
return _stale(
|
|
573
|
+
(
|
|
574
|
+
"it was imported at version %d of the %s importer, and this engine imports at version %d"
|
|
575
|
+
% [version, importer, current]
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
var lacking: Array[String] = _vram_formats_lacking(sidecar.get_value("remap", "metadata", {}))
|
|
579
|
+
if not lacking.is_empty():
|
|
580
|
+
return _stale(
|
|
581
|
+
"the project now asks for " + ", ".join(lacking) + " textures, and this import did not write them"
|
|
582
|
+
)
|
|
583
|
+
|
|
446
584
|
if not sidecar.has_section_key("remap", "uid"):
|
|
447
585
|
return _stale("its import file has no uid, which the import writes")
|
|
448
586
|
|
|
@@ -505,6 +643,28 @@ static func _import_staleness(resource_path: String, sidecar: ConfigFile) -> Dic
|
|
|
505
643
|
return built
|
|
506
644
|
|
|
507
645
|
|
|
646
|
+
## The VRAM formats the project asks textures to be imported in that an import recorded in
|
|
647
|
+
## [param metadata] did not write, as the texture importers judge whether their settings still hold.
|
|
648
|
+
## Measured on 4.7.2 for the texture and texture array importers: turning a format on reimports every
|
|
649
|
+
## VRAM texture without it, and turning one off reimports nothing.
|
|
650
|
+
static func _vram_formats_lacking(metadata: Variant) -> Array[String]:
|
|
651
|
+
var lacking: Array[String] = []
|
|
652
|
+
if not metadata is Dictionary:
|
|
653
|
+
return lacking
|
|
654
|
+
var meta: Dictionary = metadata
|
|
655
|
+
if not Read.as_bool(meta.get("vram_texture", false)):
|
|
656
|
+
return lacking
|
|
657
|
+
var written: Variant = meta.get("imported_formats", [])
|
|
658
|
+
var formats: Array = written if written is Array else []
|
|
659
|
+
for format: String in VRAM_FORMATS:
|
|
660
|
+
var asked: bool = Read.as_bool(
|
|
661
|
+
ProjectSettings.get_setting("rendering/textures/vram_compression/import_" + format, false)
|
|
662
|
+
)
|
|
663
|
+
if asked and format not in formats:
|
|
664
|
+
lacking.append(format)
|
|
665
|
+
return lacking
|
|
666
|
+
|
|
667
|
+
|
|
508
668
|
## The hashes [param resource_path]'s last import recorded, [code]source_md5[/code] of its source
|
|
509
669
|
## and [code]dest_md5[/code] of its outputs, empty when there is no record: the editor keeps them
|
|
510
670
|
## beside the outputs, under the same base name with [code].md5[/code].
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
extends RefCounted
|
|
2
2
|
|
|
3
|
-
#
|
|
4
|
-
#
|
|
3
|
+
# Everything printed is diagnostics; the answer goes to a file. The server reads `[ERROR]` lines as
|
|
4
|
+
# the operation's own errors, and the prefixes let a human read the rest without parsing it.
|
|
5
5
|
#
|
|
6
6
|
# Every module preloads this as `Log`. `Logger` is a native class from Godot 4.5 on, and a
|
|
7
7
|
# constant of that name is a parse error rather than a shadowing warning.
|
|
@@ -24,48 +24,63 @@ func list_plugins(_params: Dictionary) -> Dictionary:
|
|
|
24
24
|
"message": "No addons directory found in the project"
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
var
|
|
28
|
-
_log.debug("Enabled plugins: " + str(
|
|
27
|
+
var enabled_paths: Array[String] = _enabled_paths()
|
|
28
|
+
_log.debug("Enabled plugins: " + str(enabled_paths))
|
|
29
29
|
|
|
30
30
|
var plugins: Array[Dictionary] = []
|
|
31
31
|
var enabled_count: int = 0
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
var
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
plugin_info["author"] = plugin_config.get_value("plugin", "author", "")
|
|
52
|
-
plugin_info["version"] = plugin_config.get_value("plugin", "version", "")
|
|
53
|
-
plugin_info["script"] = plugin_config.get_value("plugin", "script", "")
|
|
54
|
-
|
|
55
|
-
plugins.append(plugin_info)
|
|
56
|
-
if enabled:
|
|
57
|
-
enabled_count += 1
|
|
58
|
-
|
|
59
|
-
folder_name = dir.get_next()
|
|
60
|
-
|
|
61
|
-
dir.list_dir_end()
|
|
62
|
-
|
|
63
|
-
return {
|
|
32
|
+
for folder: String in _plugin_folders(addons_path):
|
|
33
|
+
var plugin_cfg_path: String = folder.path_join("plugin.cfg")
|
|
34
|
+
var name: String = folder.trim_prefix(addons_path)
|
|
35
|
+
var enabled: bool = plugin_cfg_path in enabled_paths
|
|
36
|
+
var plugin_info: Dictionary = {"name": name, "path": folder, "enabled": enabled}
|
|
37
|
+
|
|
38
|
+
var plugin_config: ConfigFile = ConfigFile.new()
|
|
39
|
+
if plugin_config.load(plugin_cfg_path) == OK:
|
|
40
|
+
plugin_info["display_name"] = plugin_config.get_value("plugin", "name", name)
|
|
41
|
+
plugin_info["description"] = plugin_config.get_value("plugin", "description", "")
|
|
42
|
+
plugin_info["author"] = plugin_config.get_value("plugin", "author", "")
|
|
43
|
+
plugin_info["version"] = plugin_config.get_value("plugin", "version", "")
|
|
44
|
+
plugin_info["script"] = plugin_config.get_value("plugin", "script", "")
|
|
45
|
+
|
|
46
|
+
plugins.append(plugin_info)
|
|
47
|
+
if enabled:
|
|
48
|
+
enabled_count += 1
|
|
49
|
+
|
|
50
|
+
var answer: Dictionary = {
|
|
64
51
|
"plugins": plugins,
|
|
65
52
|
"addons_directory_exists": true,
|
|
66
53
|
"enabled_count": enabled_count,
|
|
67
54
|
"disabled_count": plugins.size() - enabled_count
|
|
68
55
|
}
|
|
56
|
+
# Enabled in the project with no plugin.cfg where the entry points: the editor reports each one
|
|
57
|
+
# as an error at every start and loads nothing for it.
|
|
58
|
+
var missing: Array[String] = []
|
|
59
|
+
for path: String in enabled_paths:
|
|
60
|
+
if not FileAccess.file_exists(path):
|
|
61
|
+
missing.append(path)
|
|
62
|
+
if not missing.is_empty():
|
|
63
|
+
answer["enabled_but_missing"] = missing
|
|
64
|
+
return answer
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Every folder under [param root] holding a plugin.cfg, at any depth, the way the editor's plugin
|
|
68
|
+
# list finds them: a folder with one is a plugin and is not looked inside, and one without is
|
|
69
|
+
# searched. Only the top level was read, so a plugin one folder down was never listed.
|
|
70
|
+
func _plugin_folders(root: String) -> Array[String]:
|
|
71
|
+
var found: Array[String] = []
|
|
72
|
+
var dir: DirAccess = DirAccess.open(root)
|
|
73
|
+
if dir == null:
|
|
74
|
+
return found
|
|
75
|
+
for folder_name: String in dir.get_directories():
|
|
76
|
+
if folder_name.begins_with("."):
|
|
77
|
+
continue
|
|
78
|
+
var folder: String = root.path_join(folder_name)
|
|
79
|
+
if FileAccess.file_exists(folder.path_join("plugin.cfg")):
|
|
80
|
+
found.append(folder)
|
|
81
|
+
else:
|
|
82
|
+
found.append_array(_plugin_folders(folder))
|
|
83
|
+
return found
|
|
69
84
|
|
|
70
85
|
|
|
71
86
|
# Enable a plugin
|
|
@@ -79,19 +94,19 @@ func enable_plugin(params: Dictionary) -> Dictionary:
|
|
|
79
94
|
_log.error("Plugin not found: " + plugin_name)
|
|
80
95
|
return _log.failure("Expected plugin.cfg at: " + plugin_cfg_path)
|
|
81
96
|
|
|
82
|
-
var
|
|
97
|
+
var enabled_paths: Array[String] = _enabled_paths()
|
|
83
98
|
|
|
84
|
-
if
|
|
99
|
+
if plugin_cfg_path in enabled_paths:
|
|
85
100
|
return {
|
|
86
101
|
"plugin_name": plugin_name, "action": "already_enabled", "message": "Plugin is already enabled"
|
|
87
102
|
}
|
|
88
103
|
|
|
89
|
-
|
|
90
|
-
var err: Error =
|
|
104
|
+
enabled_paths.append(plugin_cfg_path)
|
|
105
|
+
var err: Error = _save_enabled_paths(enabled_paths)
|
|
91
106
|
if err != OK:
|
|
92
107
|
return _log.failure("Failed to save project.godot: " + error_string(err))
|
|
93
108
|
|
|
94
|
-
return {"plugin_name": plugin_name, "action": "enabled", "enabled_plugins":
|
|
109
|
+
return {"plugin_name": plugin_name, "action": "enabled", "enabled_plugins": _names_of(enabled_paths)}
|
|
95
110
|
|
|
96
111
|
|
|
97
112
|
# Disable a plugin
|
|
@@ -100,42 +115,50 @@ func disable_plugin(params: Dictionary) -> Dictionary:
|
|
|
100
115
|
|
|
101
116
|
_log.info("Disabling plugin: " + plugin_name)
|
|
102
117
|
|
|
103
|
-
var
|
|
118
|
+
var enabled_paths: Array[String] = _enabled_paths()
|
|
119
|
+
var plugin_cfg_path: String = "res://addons/" + plugin_name + "/plugin.cfg"
|
|
104
120
|
|
|
105
|
-
if not
|
|
121
|
+
if not plugin_cfg_path in enabled_paths:
|
|
106
122
|
return {
|
|
107
123
|
"plugin_name": plugin_name,
|
|
108
124
|
"action": "already_disabled",
|
|
109
125
|
"message": "Plugin is not currently enabled"
|
|
110
126
|
}
|
|
111
127
|
|
|
112
|
-
|
|
113
|
-
var err: Error =
|
|
128
|
+
enabled_paths.erase(plugin_cfg_path)
|
|
129
|
+
var err: Error = _save_enabled_paths(enabled_paths)
|
|
114
130
|
if err != OK:
|
|
115
131
|
return _log.failure("Failed to save project.godot: " + error_string(err))
|
|
116
132
|
|
|
117
|
-
return {"plugin_name": plugin_name, "action": "disabled", "enabled_plugins":
|
|
133
|
+
return {"plugin_name": plugin_name, "action": "disabled", "enabled_plugins": _names_of(enabled_paths)}
|
|
118
134
|
|
|
119
135
|
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
# Every entry in [editor_plugins], as the plugin.cfg path the editor stores. Paths rather than
|
|
137
|
+
# folder names, because the list is written back whole: reading names off a pattern for
|
|
138
|
+
# `res://addons/<name>/plugin.cfg` dropped a plugin one folder down, and the next enable or disable
|
|
139
|
+
# wrote the list without it, disabling a plugin nobody named. The editor stores the engine
|
|
140
|
+
# expression PackedStringArray(...), which loads as that type; the String form is what an older
|
|
141
|
+
# hand-edited file can carry.
|
|
142
|
+
func _enabled_paths() -> Array[String]:
|
|
143
|
+
var paths: Array[String] = []
|
|
127
144
|
var enabled_value: Variant = ProjectSettings.get_setting("editor_plugins/enabled", PackedStringArray())
|
|
128
145
|
if enabled_value is PackedStringArray:
|
|
129
|
-
var
|
|
130
|
-
|
|
131
|
-
var m: RegExMatch = regex.search(path)
|
|
132
|
-
if m:
|
|
133
|
-
names.append(m.get_string(1))
|
|
146
|
+
var packed: PackedStringArray = enabled_value
|
|
147
|
+
paths.assign(packed)
|
|
134
148
|
elif enabled_value is String:
|
|
135
149
|
var listed: String = enabled_value
|
|
136
|
-
for m: RegExMatch in
|
|
137
|
-
|
|
150
|
+
for m: RegExMatch in Patterns.compiled("res://[^\"',)]+?plugin\\.cfg").search_all(listed):
|
|
151
|
+
paths.append(m.get_string())
|
|
152
|
+
return paths
|
|
138
153
|
|
|
154
|
+
|
|
155
|
+
# The names callers use for [param paths]: the folder under addons/, or the path itself for an
|
|
156
|
+
# entry somewhere else.
|
|
157
|
+
static func _names_of(paths: Array[String]) -> Array[String]:
|
|
158
|
+
var names: Array[String] = []
|
|
159
|
+
for path: String in paths:
|
|
160
|
+
var inside: bool = path.begins_with("res://addons/") and path.ends_with("/plugin.cfg")
|
|
161
|
+
names.append(path.trim_prefix("res://addons/").trim_suffix("/plugin.cfg") if inside else path)
|
|
139
162
|
return names
|
|
140
163
|
|
|
141
164
|
|
|
@@ -145,19 +168,14 @@ func _enabled_plugin_names() -> Array[String]:
|
|
|
145
168
|
# the unquoted expression the editor reads, whereas the same text handed over as a String is
|
|
146
169
|
# written quoted and loads as a String the editor does not recognise as a plugin list. An
|
|
147
170
|
# empty list is the setting removed, which is what the editor writes for no plugins.
|
|
148
|
-
func
|
|
149
|
-
if
|
|
171
|
+
func _save_enabled_paths(paths: Array[String]) -> Error:
|
|
172
|
+
if paths.is_empty():
|
|
150
173
|
ProjectSettings.set_setting("editor_plugins/enabled", null)
|
|
151
174
|
return ProjectSettings.save()
|
|
152
175
|
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
var enabled_paths: Array[String] = []
|
|
159
|
-
for name: String in names:
|
|
160
|
-
enabled_paths.append("res://addons/" + name + "/plugin.cfg")
|
|
161
|
-
|
|
162
|
-
ProjectSettings.set_setting("editor_plugins/enabled", PackedStringArray(enabled_paths))
|
|
176
|
+
# Kept in an Array[String] and converted here, because appending to a packed array answers with
|
|
177
|
+
# whether it worked and a project holding return_value_discarded at error level will not compile
|
|
178
|
+
# a script that drops that answer. The conversion is what matters: an Array[String] handed to
|
|
179
|
+
# set_setting is written as Array[String]([...]), which the editor does not read as a plugin list.
|
|
180
|
+
ProjectSettings.set_setting("editor_plugins/enabled", PackedStringArray(paths))
|
|
163
181
|
return ProjectSettings.save()
|
|
@@ -34,6 +34,19 @@ func get_project_setting(params: Dictionary) -> Dictionary:
|
|
|
34
34
|
result["value"] = null
|
|
35
35
|
result["message"] = "Setting does not exist"
|
|
36
36
|
|
|
37
|
+
# The value under this name is not the one every build reads: `name.windows` replaces it on
|
|
38
|
+
# Windows, `name.editor` in the editor. Read alone, a caller saw the plain value and not the one
|
|
39
|
+
# their game showed.
|
|
40
|
+
var overrides: Dictionary = {}
|
|
41
|
+
for info: Dictionary in ProjectSettings.get_property_list():
|
|
42
|
+
var name: String = str(info.get("name", ""))
|
|
43
|
+
if name.begins_with(setting_path + "."):
|
|
44
|
+
overrides[name.substr(setting_path.length() + 1)] = _values.serialize_value(
|
|
45
|
+
ProjectSettings.get_setting(name)
|
|
46
|
+
)
|
|
47
|
+
if not overrides.is_empty():
|
|
48
|
+
result["overrides"] = overrides
|
|
49
|
+
|
|
37
50
|
return result
|
|
38
51
|
|
|
39
52
|
|
|
@@ -139,7 +152,19 @@ func _converts_faithfully(value: Variant, to: int) -> bool:
|
|
|
139
152
|
func add_autoload(params: Dictionary) -> Dictionary:
|
|
140
153
|
var name: String = str(params.get("name", ""))
|
|
141
154
|
var path: String = str(params.get("path", ""))
|
|
142
|
-
var
|
|
155
|
+
var global: bool = Read.as_bool(params.get("global", true), true)
|
|
156
|
+
|
|
157
|
+
# There is no disabled autoload in Godot 4: every entry in the list is loaded under the root at
|
|
158
|
+
# every start. `enabled: false` used to write the entry without its asterisk and answer that it
|
|
159
|
+
# was disabled, and the autoload ran all the same.
|
|
160
|
+
if not Read.as_bool(params.get("enabled", true), true):
|
|
161
|
+
return _log.failure(
|
|
162
|
+
(
|
|
163
|
+
"An autoload cannot be added disabled: Godot loads every autoload in the list at every"
|
|
164
|
+
+ " start. global: false keeps its name out of the global scope and still loads it;"
|
|
165
|
+
+ " remove_autoload is what stops it loading"
|
|
166
|
+
)
|
|
167
|
+
)
|
|
143
168
|
|
|
144
169
|
if not path.begins_with("res://"):
|
|
145
170
|
path = "res://" + path
|
|
@@ -154,14 +179,21 @@ func add_autoload(params: Dictionary) -> Dictionary:
|
|
|
154
179
|
var setting: String = "autoload/" + name
|
|
155
180
|
var was_updated: bool = ProjectSettings.has_setting(setting)
|
|
156
181
|
|
|
157
|
-
# An asterisk in front of the path makes the autoload a global
|
|
158
|
-
# node still comes up under the root; only the name
|
|
159
|
-
|
|
182
|
+
# An asterisk in front of the path makes the autoload's name a global variable, the editor's
|
|
183
|
+
# "Global Variable" column. Without it the node still comes up under the root; only the name
|
|
184
|
+
# stops resolving.
|
|
185
|
+
ProjectSettings.set_setting(setting, ("*" if global else "") + path)
|
|
160
186
|
var err: Error = ProjectSettings.save()
|
|
161
187
|
if err != OK:
|
|
162
188
|
return _log.failure("Failed to save project.godot: " + error_string(err))
|
|
163
189
|
|
|
164
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
"name": name,
|
|
192
|
+
"path": path,
|
|
193
|
+
"enabled": true,
|
|
194
|
+
"global": global,
|
|
195
|
+
"action": "updated" if was_updated else "added",
|
|
196
|
+
}
|
|
165
197
|
|
|
166
198
|
|
|
167
199
|
# Remove an autoload singleton
|
|
@@ -196,10 +228,13 @@ func list_autoloads(_params: Dictionary) -> Dictionary:
|
|
|
196
228
|
var value: String = str(ProjectSettings.get_setting(setting))
|
|
197
229
|
var path: String = value.trim_prefix("*")
|
|
198
230
|
|
|
231
|
+
# Enabled whatever the asterisk says, because every autoload listed is loaded; the asterisk
|
|
232
|
+
# is whether its name is a global variable.
|
|
199
233
|
var autoload_info: Dictionary = {
|
|
200
234
|
"name": setting.trim_prefix("autoload/"),
|
|
201
235
|
"path": path,
|
|
202
|
-
"enabled":
|
|
236
|
+
"enabled": true,
|
|
237
|
+
"global": value.begins_with("*"),
|
|
203
238
|
"file_exists": FileAccess.file_exists(path),
|
|
204
239
|
}
|
|
205
240
|
autoloads.append(autoload_info)
|