gdharness 1.1.5 → 1.1.7

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.
@@ -80,10 +80,13 @@ func _init_tools() -> void:
80
80
  "list_connections": [_scene_tools, "list_connections"],
81
81
  "rescan_filesystem": [_scene_tools, "rescan_filesystem"],
82
82
  "scan_status": [_scene_tools, "scan_status"],
83
+ "reimport_files": [_scene_tools, "reimport_files"],
83
84
  "global_classes": [_class_tools, "global_classes"],
84
85
  "reload_script": [_class_tools, "reload_script"],
85
86
  # Settings, for a caller that asked the editor rather than the file on disk
86
87
  "get_project_setting": [_settings_tools, "get_project_setting"],
88
+ "adopt_project_settings": [_settings_tools, "adopt_project_settings"],
89
+ "adopt_audio_bus_layout": [_settings_tools, "adopt_audio_bus_layout"],
87
90
  # Resource tools
88
91
  "create_resource": [_resource_tools, "create_resource"],
89
92
  "modify_resource": [_resource_tools, "modify_resource"],
@@ -24,6 +24,10 @@ var _scans_completed: int = 0
24
24
  ## whether that write can still be coming.
25
25
  var _scan_finished_at_msec: int = -1
26
26
 
27
+ ## How many reimports asked for through [method reimport_files] have finished. Counted here rather
28
+ ## than off `resources_reimported`, which a scan's own import emits as well.
29
+ var _reimports_completed: int = 0
30
+
27
31
 
28
32
  func set_editor_plugin(plugin: EditorPlugin) -> void:
29
33
  _editor_plugin = plugin
@@ -701,4 +705,44 @@ func scan_status(_args: Dictionary) -> Dictionary:
701
705
  "importing": filesystem.is_importing(),
702
706
  "sinceScanFinishedMs":
703
707
  -1 if _scan_finished_at_msec < 0 else Time.get_ticks_msec() - _scan_finished_at_msec,
708
+ "reimportsCompleted": _reimports_completed,
709
+ }
710
+
711
+
712
+ ## Reimport the files named under `paths`, whatever their state, the way the editor's Reimport
713
+ ## button does, and report whether the reimport was started.
714
+ ##
715
+ ## Started on the next frame rather than run here: the editor imports on its main thread, and a
716
+ ## reimport of a few hundred scenes outlasts the wait on any one command. The caller waits for
717
+ ## `reimportsCompleted` in [method scan_status] to move past `reimportsCompletedBefore`. Declined
718
+ ## while the editor scans or imports, since `reimport_files` refuses to run inside an import.
719
+ func reimport_files(args: Dictionary) -> Dictionary:
720
+ if not _editor_plugin:
721
+ return {"ok": false, "error": "Editor plugin unavailable"}
722
+ var paths: Array[String] = []
723
+ var given: Variant = args.get("paths", [])
724
+ if given is Array:
725
+ for path: Variant in given:
726
+ paths.append(str(path))
727
+ if paths.is_empty():
728
+ return {"ok": false, "error": "reimport_files needs paths"}
729
+ var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
730
+ var busy: bool = filesystem.is_scanning() or filesystem.is_importing()
731
+ if not busy:
732
+ _reimport.call_deferred(PackedStringArray(paths))
733
+ return {
734
+ "ok": true,
735
+ "started": not busy,
736
+ "scanning": filesystem.is_scanning(),
737
+ "importing": filesystem.is_importing(),
738
+ "reimportsCompletedBefore": _reimports_completed,
704
739
  }
740
+
741
+
742
+ func _reimport(paths: PackedStringArray) -> void:
743
+ var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
744
+ # A scan can begin between the answer and this frame, and its import would refuse this one.
745
+ while filesystem.is_scanning() or filesystem.is_importing():
746
+ await get_tree().process_frame
747
+ filesystem.reimport_files(paths)
748
+ _reimports_completed += 1
@@ -1,7 +1,8 @@
1
1
  @tool
2
2
  extends Node
3
3
 
4
- ## Project settings read from the open editor, for a caller that asked for the editor's answer.
4
+ ## Project settings read from the open editor, for a caller that asked for the editor's answer, and
5
+ ## taken up by it after a write to the file.
5
6
  ##
6
7
  ## The same question answered headless starts an engine, loads project.godot from disk and exits.
7
8
  ## This answers from the ProjectSettings the editor already has, which costs nothing and is not the
@@ -56,3 +57,51 @@ func _settings_under(prefix: String) -> Dictionary:
56
57
 
57
58
  found.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["setting"] < b["setting"])
58
59
  return {"ok": true, "prefix": prefix, "count": found.size(), "settings": found}
60
+
61
+
62
+ ## Takes the named settings from project.godot as it is on disk, after something else wrote it.
63
+ ##
64
+ ## The editor loads the file once, at startup. Written from outside, it goes on holding the old
65
+ ## values, answers them when asked, and writes them back over the new ones the next time it saves
66
+ ## the project settings. A setting gone from the file goes back to its default, or goes altogether
67
+ ## when the engine declares none, as an autoload does once it is removed.
68
+ func adopt_project_settings(args: Dictionary) -> Dictionary:
69
+ var file: ConfigFile = ConfigFile.new()
70
+ var loaded: Error = file.load("res://project.godot")
71
+ if loaded != OK:
72
+ return {"ok": false, "error": "Could not read project.godot: " + error_string(loaded)}
73
+ var adopted: Array[String] = []
74
+ var named: Variant = args.get("settings", [])
75
+ if not named is Array:
76
+ return {"ok": false, "error": "settings must be a list of setting names"}
77
+ var settings: Array = named
78
+ for entry: Variant in settings:
79
+ var setting: String = str(entry)
80
+ var at: int = setting.find("/")
81
+ if at <= 0:
82
+ continue
83
+ var section: String = setting.substr(0, at)
84
+ var key: String = setting.substr(at + 1)
85
+ if file.has_section_key(section, key):
86
+ ProjectSettings.set_setting(setting, file.get_value(section, key))
87
+ elif ProjectSettings.property_can_revert(setting):
88
+ ProjectSettings.set_setting(setting, ProjectSettings.property_get_revert(setting))
89
+ else:
90
+ ProjectSettings.set_setting(setting, null)
91
+ adopted.append(setting)
92
+ return {"ok": true, "adopted": adopted}
93
+
94
+
95
+ ## Loads the bus layout the project names into the editor's audio server, after something else
96
+ ## wrote it: the editor holds the layout it loaded and saves that one when its audio panel changes.
97
+ func adopt_audio_bus_layout(_args: Dictionary) -> Dictionary:
98
+ var path: String = str(
99
+ ProjectSettings.get_setting("audio/buses/default_bus_layout", "res://default_bus_layout.tres")
100
+ )
101
+ var layout: AudioBusLayout = ResourceLoader.load(
102
+ path, "AudioBusLayout", ResourceLoader.CACHE_MODE_REPLACE
103
+ )
104
+ if layout == null:
105
+ return {"ok": false, "error": "Could not load the bus layout at " + path}
106
+ AudioServer.set_bus_layout(layout)
107
+ return {"ok": true, "layout": path}
@@ -13,13 +13,13 @@ func _init(host: Node) -> void:
13
13
 
14
14
 
15
15
  func capture_screenshot(params: Dictionary) -> Dictionary:
16
- return _capture(_host.get_tree().root, params)
16
+ return await _capture(_host.get_tree().root, params)
17
17
 
18
18
 
19
19
  func capture_viewport(params: Dictionary) -> Dictionary:
20
20
  var viewport_path: String = str(params.get("viewportPath", ""))
21
21
  if viewport_path.is_empty():
22
- return capture_screenshot(params)
22
+ return await capture_screenshot(params)
23
23
 
24
24
  var standing: Dictionary = Values.node_at(_host.get_tree().root, viewport_path)
25
25
  if standing.has("message"):
@@ -28,7 +28,7 @@ func capture_viewport(params: Dictionary) -> Dictionary:
28
28
  if not node is Viewport:
29
29
  return {"type": "error", "message": "Node is not a Viewport: " + viewport_path}
30
30
  var viewport: Viewport = node
31
- return _capture(viewport, params)
31
+ return await _capture(viewport, params)
32
32
 
33
33
 
34
34
  ## The server names the file, so a game cannot point it at a path of its own choosing; a call
@@ -66,6 +66,13 @@ func _capture(viewport: Viewport, params: Dictionary) -> Dictionary:
66
66
  ),
67
67
  }
68
68
 
69
+ # The game announces itself before its first frame, and until a frame has been drawn the texture
70
+ # holds nothing the game drew: blank on one machine, solid white on another, which a caller took
71
+ # for the game flashing white on boot. Measured on 4.7.2 with both renderers, only the first
72
+ # processed frame reads it so; the next one holds the scene.
73
+ while Engine.get_frames_drawn() == 0:
74
+ await _host.get_tree().process_frame
75
+
69
76
  var viewport_texture: ViewportTexture = viewport.get_texture()
70
77
  if viewport_texture == null:
71
78
  return {"type": "error", "message": "No viewport texture available"}
@@ -118,8 +118,6 @@ func _run(operation: String, params: Dictionary) -> Dictionary:
118
118
  payload = ImportPipeline.new(_log).get_import_options(params)
119
119
  "set_import_options":
120
120
  payload = ImportPipeline.new(_log).set_import_options(params)
121
- "reimport_resource":
122
- payload = ImportPipeline.new(_log).reimport_resource(params)
123
121
  "list_export_presets":
124
122
  payload = ImportPipeline.new(_log).list_export_presets(params)
125
123
  "validate_project":
@@ -4,17 +4,45 @@ const Read = preload("reading.gd")
4
4
  const FileWalk = preload("file_walk.gd")
5
5
  const Log = preload("logger.gd")
6
6
 
7
+ ## What the engine imports with no tool outside it, for finding what has not been imported yet;
8
+ ## everything imported already is found by its sidecar whatever its extension.
7
9
  const IMPORTABLE_EXTENSIONS: Array[String] = [
8
- "png", "jpg", "jpeg", "webp", "svg", "wav", "mp3", "ogg", "ttf", "otf", "glb", "gltf", "fbx", "obj"
10
+ "png",
11
+ "jpg",
12
+ "jpeg",
13
+ "webp",
14
+ "svg",
15
+ "bmp",
16
+ "tga",
17
+ "exr",
18
+ "hdr",
19
+ "dds",
20
+ "ktx",
21
+ "wav",
22
+ "mp3",
23
+ "ogg",
24
+ "ttf",
25
+ "otf",
26
+ "ttc",
27
+ "otc",
28
+ "woff",
29
+ "woff2",
30
+ "pfb",
31
+ "pfm",
32
+ "fnt",
33
+ "font",
34
+ "glb",
35
+ "gltf",
36
+ "fbx",
37
+ "obj",
38
+ "dae",
9
39
  ]
10
-
11
- ## How many resources a scene's dependencies are followed through before the walk stops: a scene's
12
- ## materials and what they use are a handful, and a project that references its way around a cycle
13
- ## is not walked for ever.
14
- const DEPENDENCY_WALK_LIMIT: int = 200
40
+ const EDITOR_DIRECTORY: String = "res://.godot/editor"
41
+ const EDITOR_CACHE_PREFIX: String = "filesystem_cache"
15
42
 
16
43
  var _log: Log
17
44
  var _files: FileWalk = FileWalk.new()
45
+ var _sidecar_md5s: Variant = null
18
46
 
19
47
 
20
48
  func _init(p_log: Log) -> void:
@@ -34,7 +62,9 @@ func get_import_status(params: Dictionary) -> Dictionary:
34
62
  )
35
63
 
36
64
  var resources: Array[Dictionary] = []
37
- var summary: Dictionary = {"total": 0, "needs_reimport": 0, "up_to_date": 0, "missing_source": 0}
65
+ var summary: Dictionary = {
66
+ "total": 0, "needs_reimport": 0, "failed": 0, "up_to_date": 0, "missing_source": 0
67
+ }
38
68
 
39
69
  if not resource_path.is_empty():
40
70
  var full_path: String = resource_path
@@ -45,7 +75,17 @@ func get_import_status(params: Dictionary) -> Dictionary:
45
75
  resources.append(status)
46
76
  _tally(summary, status)
47
77
  else:
78
+ # Sources by extension, for what has never been imported, and sidecars, for what was imported
79
+ # from a type the list does not name and for a sidecar whose source is gone.
80
+ var found: Dictionary[String, bool] = {}
48
81
  for res_path: String in _files.find_files_with_extensions("res://", IMPORTABLE_EXTENSIONS):
82
+ found[res_path] = true
83
+ for sidecar: String in _files.find_files_with_extensions("res://", ["import"]):
84
+ found[sidecar.trim_suffix(".import")] = true
85
+ var paths: Array[String] = []
86
+ paths.assign(found.keys())
87
+ paths.sort()
88
+ for res_path: String in paths:
49
89
  var status: Dictionary = _import_status_of(res_path, res_path + ".import")
50
90
 
51
91
  if include_up_to_date or status["status"] != "up_to_date":
@@ -96,7 +136,6 @@ func set_import_options(params: Dictionary) -> Dictionary:
96
136
  resource_path = "res://" + resource_path
97
137
 
98
138
  var options: Dictionary = params.get("options", {})
99
- var do_reimport: bool = Read.as_bool(params.get("reimport", true), true)
100
139
 
101
140
  _log.info("Setting import options for: " + resource_path)
102
141
 
@@ -115,56 +154,28 @@ func set_import_options(params: Dictionary) -> Dictionary:
115
154
  var updated_keys: Array[String] = []
116
155
  for key: Variant in options:
117
156
  var name: String = str(key)
118
- config.set_value("params", name, options[key])
157
+ var value: Variant = _as_option(options[key], config.get_value("params", name, null))
158
+ config.set_value("params", name, value)
119
159
  updated_keys.append(name)
120
- _log.debug("Set " + name + " = " + str(options[key]))
160
+ _log.debug("Set " + name + " = " + str(value))
121
161
 
122
162
  err = config.save(import_file_path)
123
163
  if err != OK:
124
164
  return _log.failure("Failed to save import file: " + str(err))
125
165
 
126
- var result: Dictionary = {
127
- "resource_path": resource_path, "updated_options": updated_keys, "reimport_triggered": do_reimport
128
- }
129
-
130
- # A headless engine has no importer to run, so the .import file is as far as this goes.
131
- if do_reimport:
132
- result["note"] = "Import file updated. Run the editor or use 'reimport_resource' to apply changes."
133
-
134
- return result
135
-
136
-
137
- # Reimport a resource or all resources
138
- func reimport_resource(params: Dictionary) -> Dictionary:
139
- var resource_path: String = str(params.get("resource_path", ""))
140
- var force: bool = Read.as_bool(params.get("force", false))
141
-
142
- _log.info(
143
- (
144
- "Reimporting"
145
- + (" resource: " + resource_path if not resource_path.is_empty() else " all modified resources")
146
- )
147
- )
148
-
149
- # A full reimport is editor work; headless can only report what the state is.
150
- var result: Dictionary = {
151
- "status": "requested",
152
- "resource_path": resource_path if not resource_path.is_empty() else "all",
153
- "force": force,
154
- "note": "Reimport in headless mode is limited. For full reimport, open the project in the editor."
155
- }
156
-
157
- if not resource_path.is_empty():
158
- var full_path: String = resource_path
159
- if not full_path.begins_with("res://"):
160
- full_path = "res://" + full_path
161
-
162
- if not FileAccess.file_exists(full_path):
163
- return _log.failure("Resource file does not exist: " + full_path)
166
+ # The reimport that applies them is the server's, through the editor or the engine's import.
167
+ return {"resource_path": resource_path, "updated_options": updated_keys}
164
168
 
165
- result["current_status"] = _import_status_of(full_path, full_path + ".import")["status"]
166
169
 
167
- return result
170
+ ## [param given] as the option holds it. Every JSON number arrives as a float, and an enum option
171
+ ## such as compress/mode written as 1.0 is kept that way by the import, so a whole number becomes
172
+ ## an int unless the option already holds a float.
173
+ static func _as_option(given: Variant, held: Variant) -> Variant:
174
+ if given is float and not held is float:
175
+ var number: float = given
176
+ if number == floorf(number):
177
+ return int(number)
178
+ return given
168
179
 
169
180
 
170
181
  # List export presets
@@ -372,7 +383,7 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
372
383
  return {
373
384
  "path": resource_path,
374
385
  "status": "missing_source",
375
- "import_file_exists": false,
386
+ "import_file_exists": FileAccess.file_exists(import_file_path),
376
387
  "source_exists": false
377
388
  }
378
389
 
@@ -381,6 +392,7 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
381
392
  return {
382
393
  "path": resource_path,
383
394
  "status": "needs_reimport",
395
+ "reason": "it has not been imported",
384
396
  "import_file_exists": false,
385
397
  "source_exists": true
386
398
  }
@@ -389,33 +401,85 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
389
401
  "path": resource_path, "status": "up_to_date", "import_file_exists": true, "source_exists": true
390
402
  }
391
403
 
404
+ # The checks are the ones EditorFileSystem::_test_for_reimport makes, in its order, so a resource
405
+ # reads as needing an import exactly when the editor's next scan would import it.
406
+ var sidecar: ConfigFile = ConfigFile.new()
407
+ var stale: Dictionary = (
408
+ _stale("its import file cannot be read")
409
+ if sidecar.load(import_file_path) != OK
410
+ else _sidecar_staleness(resource_path, import_file_path, sidecar)
411
+ )
412
+ var kept: bool = str(sidecar.get_value("remap", "importer", "")) in ["keep", "skip"]
413
+ if stale.is_empty() and not kept:
414
+ stale = _import_staleness(resource_path, sidecar)
415
+ status.merge(stale, true)
416
+ return status
417
+
418
+
419
+ static func _stale(reason: String) -> Dictionary:
420
+ return {"status": "needs_reimport", "reason": reason}
421
+
422
+
423
+ ## What the editor reads off the sidecar before looking at the import: an answer for a stale or
424
+ ## failed resource, empty otherwise.
425
+ func _sidecar_staleness(resource_path: String, import_file_path: String, sidecar: ConfigFile) -> Dictionary:
426
+ # The editor compares the sidecar with the one it last imported from before reading anything in
427
+ # it, so an option set without a reimport is imported on its next scan, and not before.
428
+ var imported_from: String = _imported_sidecar_md5s().get(resource_path, "")
429
+ if not imported_from.is_empty() and FileAccess.get_md5(import_file_path) != imported_from:
430
+ return _stale("its import file changed after it was imported, as setting an option does")
431
+
432
+ # A failed import is never tried again on its own: the editor skips it to avoid a loop of
433
+ # reimports, so it stays failed after its source is fixed until something asks for a reimport.
434
+ var valid: Variant = sidecar.get_value("remap", "valid", true)
435
+ if valid is bool and not valid:
436
+ return {
437
+ "status": "failed",
438
+ "reason": "its last import failed, and the editor does not try a failed import again on its own",
439
+ }
440
+ return {}
441
+
442
+
443
+ ## Whether the import itself is still the one its sidecar and source describe: an answer for a
444
+ ## stale resource, empty for a current one.
445
+ static func _import_staleness(resource_path: String, sidecar: ConfigFile) -> Dictionary:
446
+ if not sidecar.has_section_key("remap", "uid"):
447
+ return _stale("its import file has no uid, which the import writes")
448
+
392
449
  # The sidecar alone does not say the import is there: deleting the outputs under
393
450
  # .godot/imported is the ordinary way to force one, and a resource whose output is gone was
394
451
  # answered as current, which is what a reimport is then skipped on.
395
- var outputs: Array[String] = _outputs_of(import_file_path)
452
+ var outputs: Array[String] = _outputs_of(sidecar)
396
453
  var missing: Array[String] = []
397
454
  for output: String in outputs:
398
455
  if not FileAccess.file_exists(output):
399
456
  missing.append(output)
400
457
  if not missing.is_empty():
401
- status["status"] = "needs_reimport"
402
- status["reason"] = "its imported output is not on disk"
403
- status["missing_outputs"] = missing
404
- return status
458
+ var gone: Dictionary = _stale("its imported output is not on disk")
459
+ gone["missing_outputs"] = missing
460
+ return gone
461
+
462
+ var source_file: String = str(sidecar.get_value("deps", "source_file", ""))
463
+ if not source_file.is_empty() and source_file != resource_path:
464
+ return _stale("its import file was written for " + source_file)
405
465
 
406
466
  # By content, as the editor judges it: the hash of the source the import recorded, against the
407
467
  # file. By time, an installer that rewrote 31 images byte for byte made every one read as changed,
408
468
  # and the editor, which compares the hash, rightly imported none of them.
409
- var recorded: String = _recorded_source_md5(resource_path)
410
- var changed: bool = (
411
- FileAccess.get_md5(resource_path) != recorded
412
- if not recorded.is_empty()
413
- else FileAccess.get_modified_time(resource_path) > FileAccess.get_modified_time(import_file_path)
414
- )
415
- if changed:
416
- status["status"] = "needs_reimport"
417
- status["reason"] = "the source changed after it was imported"
418
- return status
469
+ var recorded: Dictionary = _recorded_md5s(resource_path)
470
+ var recorded_source: String = recorded.get("source_md5", "")
471
+ if recorded_source.is_empty():
472
+ return _stale("its import recorded no hash of its source")
473
+ if FileAccess.get_md5(resource_path) != recorded_source:
474
+ return _stale("the source changed after it was imported")
475
+
476
+ var recorded_outputs: String = recorded.get("dest_md5", "")
477
+ if (
478
+ not outputs.is_empty()
479
+ and not recorded_outputs.is_empty()
480
+ and _md5_of_all(outputs) != recorded_outputs
481
+ ):
482
+ return _stale("its imported output changed after it was imported")
419
483
 
420
484
  # A scene built without an image it names: it loads each one as it imports, so one imported
421
485
  # before the image was is built without it. Read off what the imported scene depends on, not off
@@ -429,28 +493,102 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
429
493
  # with one using other images entirely, which is the scene it was meant to build.
430
494
  var without: Array[String] = []
431
495
  var images: Array[String] = _images_of(resource_path)
432
- if not images.is_empty() and not _shaped_by_a_script(import_file_path):
496
+ if not images.is_empty() and not _shaped_by_a_script(sidecar):
433
497
  var depended: Dictionary[String, bool] = _depended_on(resource_path)
434
498
  for image: String in images:
435
499
  if FileAccess.file_exists(image + ".import") and not depended.has(image):
436
500
  without.append(image)
437
- if not without.is_empty():
438
- status["status"] = "needs_reimport"
439
- status["reason"] = "it was imported without images it uses, which were not imported yet"
440
- status["imported_without"] = without
441
- return status
501
+ if without.is_empty():
502
+ return {}
503
+ var built: Dictionary = _stale("it was imported without images it uses, which were not imported yet")
504
+ built["imported_without"] = without
505
+ return built
442
506
 
443
507
 
444
- ## The hash of its source that [param resource_path]'s last import recorded, or "" when there is no
445
- ## record: the editor keeps it beside the outputs, under the same base name with [code].md5[/code].
446
- static func _recorded_source_md5(resource_path: String) -> String:
508
+ ## The hashes [param resource_path]'s last import recorded, [code]source_md5[/code] of its source
509
+ ## and [code]dest_md5[/code] of its outputs, empty when there is no record: the editor keeps them
510
+ ## beside the outputs, under the same base name with [code].md5[/code].
511
+ static func _recorded_md5s(resource_path: String) -> Dictionary:
447
512
  var record: String = (
448
513
  "res://.godot/imported/%s-%s.md5" % [resource_path.get_file(), resource_path.md5_text()]
449
514
  )
450
515
  var config: ConfigFile = ConfigFile.new()
451
516
  if config.load(record) != OK:
517
+ return {}
518
+ return {
519
+ "source_md5": str(config.get_value("", "source_md5", "")),
520
+ "dest_md5": str(config.get_value("", "dest_md5", "")),
521
+ }
522
+
523
+
524
+ ## One md5 over the contents of [param paths] in turn, as [code]FileAccess::get_multiple_md5[/code]
525
+ ## computes the [code]dest_md5[/code] an import records.
526
+ static func _md5_of_all(paths: Array[String]) -> String:
527
+ var hashing: HashingContext = HashingContext.new()
528
+ if hashing.start(HashingContext.HASH_MD5) != OK:
529
+ return ""
530
+ for path: String in paths:
531
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
532
+ if file == null:
533
+ continue
534
+ while not file.eof_reached():
535
+ var chunk: PackedByteArray = file.get_buffer(32768)
536
+ if chunk.is_empty():
537
+ break
538
+ if hashing.update(chunk) != OK:
539
+ return ""
540
+ return hashing.finish().hex_encode()
541
+
542
+
543
+ ## The md5 of each resource's sidecar as the editor last imported it, keyed by path, from the cache
544
+ ## the editor writes under .godot/editor; empty when there is no cache or it cannot be read.
545
+ func _imported_sidecar_md5s() -> Dictionary:
546
+ if _sidecar_md5s != null:
547
+ return _sidecar_md5s
548
+ var found: Dictionary = {}
549
+ _sidecar_md5s = found
550
+ var cache: String = _editor_cache_path()
551
+ var file: FileAccess = null if cache.is_empty() else FileAccess.open(cache, FileAccess.READ)
552
+ if file == null:
553
+ return found
554
+ # The first line is the import settings version; then a "::<dir>::<time>" line opens each
555
+ # directory, and each file line is nine fields split by "::", the last holding the file's
556
+ # dependencies, which may contain the splitter. The eighth field is "<>"-separated, with the
557
+ # sidecar's md5 sixth.
558
+ var directory: String = "res://"
559
+ var first: bool = true
560
+ while not file.eof_reached():
561
+ var line: String = file.get_line().strip_edges()
562
+ if first:
563
+ first = false
564
+ continue
565
+ if line.is_empty():
566
+ continue
567
+ if line.begins_with("::"):
568
+ var opened: PackedStringArray = line.split("::")
569
+ if opened.size() == 3:
570
+ directory = opened[1]
571
+ continue
572
+ var fields: PackedStringArray = line.split("::", true, 8)
573
+ if fields.size() < 9:
574
+ continue
575
+ var slices: PackedStringArray = fields[7].split("<>")
576
+ if slices.size() >= 7 and not slices[5].is_empty():
577
+ found[directory.path_join(fields[0])] = slices[5]
578
+ return found
579
+
580
+
581
+ ## The editor's filesystem cache, the newest version of it when an engine upgrade has left an older
582
+ ## one beside it, or "" when the project has none. Its name carries a format version that the
583
+ ## engine raises when the layout changes: 4.7.2 writes filesystem_cache10.
584
+ static func _editor_cache_path() -> String:
585
+ var newest: int = -1
586
+ if not DirAccess.dir_exists_absolute(EDITOR_DIRECTORY):
452
587
  return ""
453
- return str(config.get_value("", "source_md5", ""))
588
+ for name: String in DirAccess.get_files_at(EDITOR_DIRECTORY):
589
+ if name.begins_with(EDITOR_CACHE_PREFIX) and name.trim_prefix(EDITOR_CACHE_PREFIX).is_valid_int():
590
+ newest = maxi(newest, name.trim_prefix(EDITOR_CACHE_PREFIX).to_int())
591
+ return "" if newest < 0 else EDITOR_DIRECTORY.path_join(EDITOR_CACHE_PREFIX + str(newest))
454
592
 
455
593
 
456
594
  ## The path of one entry [method ResourceLoader.get_dependencies] lists, past any uid and type.
@@ -459,41 +597,32 @@ static func _path_of(entry: String) -> String:
459
597
  return entry if at < 0 else entry.substr(at + 2)
460
598
 
461
599
 
462
- ## Every path [param resource_path] depends on, through the resources it depends on in turn, as far
463
- ## as [constant DEPENDENCY_WALK_LIMIT] resources; a set, keyed by path.
600
+ ## Every path [param resource_path] depends on, through the resources it depends on in turn; a set,
601
+ ## keyed by path.
464
602
  static func _depended_on(resource_path: String) -> Dictionary[String, bool]:
465
603
  var found: Dictionary[String, bool] = {}
466
604
  var pending: Array[String] = [resource_path]
467
- var walked: int = 0
468
- while not pending.is_empty() and walked < DEPENDENCY_WALK_LIMIT:
605
+ # Every path is followed once, which ends the walk and survives a cycle; a cap or a list of
606
+ # extensions would leave out a material behind a long chain or in a saved .mesh.
607
+ while not pending.is_empty():
469
608
  var next: String = pending.pop_back()
470
- walked += 1
471
609
  for entry: String in ResourceLoader.get_dependencies(next):
472
610
  var path: String = _path_of(entry)
473
- if found.has(path):
474
- continue
475
- found[path] = true
476
- # Only what can hold further references: an image or a sound depends on nothing.
477
- if path.get_extension().to_lower() in ["tres", "res", "tscn", "scn", "material"]:
611
+ if not found.has(path):
612
+ found[path] = true
478
613
  pending.append(path)
479
614
  return found
480
615
 
481
616
 
482
- ## Whether the import of the file [param import_file_path] describes runs a post-import script.
483
- static func _shaped_by_a_script(import_file_path: String) -> bool:
484
- var config: ConfigFile = ConfigFile.new()
485
- if config.load(import_file_path) != OK:
486
- return false
487
- return not str(config.get_value("params", "import_script/path", "")).is_empty()
617
+ ## Whether the import [param sidecar] describes runs a post-import script.
618
+ static func _shaped_by_a_script(sidecar: ConfigFile) -> bool:
619
+ return not str(sidecar.get_value("params", "import_script/path", "")).is_empty()
488
620
 
489
621
 
490
622
  ## The files an import wrote, from its sidecar's [code]dest_files[/code]; empty when it lists none.
491
- static func _outputs_of(import_file_path: String) -> Array[String]:
492
- var config: ConfigFile = ConfigFile.new()
623
+ static func _outputs_of(sidecar: ConfigFile) -> Array[String]:
493
624
  var outputs: Array[String] = []
494
- if config.load(import_file_path) != OK:
495
- return outputs
496
- var listed: Variant = config.get_value("deps", "dest_files", [])
625
+ var listed: Variant = sidecar.get_value("deps", "dest_files", [])
497
626
  if listed is Array:
498
627
  for one: Variant in listed:
499
628
  outputs.append(str(one))