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.
- package/build/cli.js +447 -115
- package/build/godot/addons/gdharness_editor/tool_executor.gd +3 -0
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +44 -0
- package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +50 -1
- package/build/godot/addons/gdharness_runtime/runtime_capture.gd +10 -3
- package/build/godot/operations/godot_operations.gd +0 -2
- package/build/godot/operations/import_pipeline.gd +226 -97
- package/build/index.js +454 -124
- package/build/keeper.js +272 -23
- package/package.json +1 -1
|
@@ -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",
|
|
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
|
-
|
|
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 = {
|
|
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.
|
|
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(
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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":
|
|
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(
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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:
|
|
410
|
-
var
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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(
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
return
|
|
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
|
|
445
|
-
##
|
|
446
|
-
|
|
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
|
-
|
|
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
|
|
463
|
-
##
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
|
483
|
-
static func _shaped_by_a_script(
|
|
484
|
-
|
|
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(
|
|
492
|
-
var config: ConfigFile = ConfigFile.new()
|
|
623
|
+
static func _outputs_of(sidecar: ConfigFile) -> Array[String]:
|
|
493
624
|
var outputs: Array[String] = []
|
|
494
|
-
|
|
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))
|