gdharness 1.1.21 → 1.1.23

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.
Files changed (51) hide show
  1. package/build/cli.js +1470 -726
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +12 -2
  3. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  4. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  6. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  8. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  9. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  10. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  12. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  14. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  15. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  17. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +15 -5
  20. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +51 -5
  21. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  22. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  24. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +89 -16
  25. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +107 -343
  26. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  27. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  28. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  29. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  30. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  31. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +61 -28
  32. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  33. package/build/godot/operations/audio_buses.gd +47 -11
  34. package/build/godot/operations/class_cache.gd +18 -2
  35. package/build/godot/operations/classdb_queries.gd +28 -17
  36. package/build/godot/operations/dependencies.gd +128 -17
  37. package/build/godot/operations/file_walk.gd +20 -0
  38. package/build/godot/operations/gdscript_analysis.gd +59 -20
  39. package/build/godot/operations/gdscript_authoring.gd +127 -60
  40. package/build/godot/operations/godot_operations.gd +24 -5
  41. package/build/godot/operations/import_pipeline.gd +179 -19
  42. package/build/godot/operations/logger.gd +2 -2
  43. package/build/godot/operations/plugins.gd +88 -70
  44. package/build/godot/operations/project_config.gd +41 -6
  45. package/build/godot/operations/project_diagnostics.gd +27 -11
  46. package/build/godot/operations/reading.gd +17 -0
  47. package/build/godot/operations/resource_files.gd +60 -17
  48. package/build/godot/operations/serialisation.gd +116 -7
  49. package/build/index.js +1385 -553
  50. package/build/keeper.js +4 -0
  51. package/package.json +1 -1
@@ -23,6 +23,24 @@ func create_gdscript(params: Dictionary) -> Dictionary:
23
23
 
24
24
  _log.info("Creating GDScript: " + script_path)
25
25
 
26
+ # Content is the whole file, so a header written above it would be a second one: a content that
27
+ # starts with its own `extends` did not parse, and one without it was not the file that was sent.
28
+ if not content.is_empty():
29
+ var alongside: Array[String] = []
30
+ for name: String in ["class_name", "extends", "template"]:
31
+ if not str(params.get(name, "")).is_empty():
32
+ alongside.append(name)
33
+ if not alongside.is_empty():
34
+ return _log.failure(
35
+ (
36
+ "content is the whole file, so "
37
+ + " and ".join(alongside)
38
+ + " cannot be given with it: write "
39
+ + ("them" if alongside.size() > 1 else "it")
40
+ + " into content instead"
41
+ )
42
+ )
43
+
26
44
  var full_script_path: String = script_path
27
45
  if not full_script_path.begins_with("res://"):
28
46
  full_script_path = "res://" + full_script_path
@@ -41,20 +59,16 @@ func create_gdscript(params: Dictionary) -> Dictionary:
41
59
  if error != OK:
42
60
  return _log.failure("Failed to create directory: " + script_dir + ", error: " + str(error))
43
61
 
44
- var script_content: String = ""
45
-
46
- if not cls_name_param.is_empty():
47
- script_content += "class_name " + cls_name_param + "\n"
48
-
49
- script_content += "extends " + extends_class + "\n\n"
50
-
51
- if not template.is_empty():
52
- script_content += _script_template(template)
53
- elif not content.is_empty():
54
- script_content += content
55
- else:
56
- script_content += "func _ready() -> void:\n"
57
- script_content += "\tpass\n"
62
+ var script_content: String = content
63
+ if content.is_empty():
64
+ if not cls_name_param.is_empty():
65
+ script_content += "class_name " + cls_name_param + "\n"
66
+ script_content += "extends " + extends_class + "\n\n"
67
+ if not template.is_empty():
68
+ script_content += _script_template(template)
69
+ else:
70
+ script_content += "func _ready() -> void:\n"
71
+ script_content += "\tpass\n"
58
72
 
59
73
  var file: FileAccess = FileAccess.open(full_script_path, FileAccess.WRITE)
60
74
  if not file:
@@ -65,21 +79,20 @@ func create_gdscript(params: Dictionary) -> Dictionary:
65
79
  if not stored:
66
80
  return _log.failure("Failed to write to script file: " + full_script_path)
67
81
 
68
- # Whether the engine accepts what was written, parsed under this project's own warning
69
- # settings: a script that does not load is a script the caller wants to hear about now,
70
- # and the reason is on stderr, which comes back with the answer.
71
82
  var written: Script = ResourceLoader.load(full_script_path, "Script", ResourceLoader.CACHE_MODE_IGNORE)
72
-
73
- return {
83
+ var answer: Dictionary = {
74
84
  "success": true,
75
85
  "script_path": script_path,
76
86
  "full_path": full_script_path,
77
87
  "absolute_path": ProjectSettings.globalize_path(full_script_path),
78
- "registered": not cls_name_param.is_empty(),
79
- "extends": extends_class,
80
88
  "template_used": template if not template.is_empty() else "none",
81
- "parses": written != null and written.can_instantiate(),
89
+ "parses": _parses(written),
82
90
  }
91
+ # Read off the file as the engine parsed it rather than off the arguments, which content leaves out.
92
+ if written != null:
93
+ answer["extends"] = _base_named(written)
94
+ answer["class_name"] = str(written.get_global_name())
95
+ return answer
83
96
 
84
97
 
85
98
  # Modify an existing GDScript file by adding functions, variables, or signals
@@ -103,34 +116,40 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
103
116
  var original_content: String = file.get_as_text()
104
117
  file.close()
105
118
 
119
+ # All of them checked before any is made, so a call is made whole or not at all: one skipped for a
120
+ # missing name used to leave the rest written and the answer saying success over an empty list.
121
+ var problems: Array[String] = _modification_problems(modifications)
122
+ if not problems.is_empty():
123
+ return _log.failure("Nothing was changed: " + "; ".join(problems))
124
+
106
125
  var lines: Array[String] = []
107
126
  lines.assign(original_content.split("\n"))
108
- var modifications_applied: Array[Dictionary] = []
127
+ # Each placed line as a zero-based index that moves down whenever a later insertion lands above
128
+ # it, so the numbers answered are where the lines are in the file written, not where each went
129
+ # at the moment it was placed.
130
+ var placed: Array[Dictionary] = []
109
131
 
110
132
  for mod: Variant in modifications:
111
- if not mod is Dictionary:
112
- _log.error("Modification must be an object")
113
- continue
114
133
  var fields: Dictionary = mod
115
134
  var mod_type: String = str(fields.get("type", ""))
116
- var mod_name: String = str(fields.get("name", ""))
117
-
118
- if mod_name.is_empty():
119
- _log.error("Modification missing 'name' field")
120
- continue
121
-
135
+ var at: int = -1
122
136
  match mod_type:
123
137
  "add_variable":
124
- var line: int = _add_variable(lines, fields)
125
- modifications_applied.append({"type": "add_variable", "name": mod_name, "line": line})
138
+ at = _add_variable(lines, fields, placed)
126
139
  "add_signal":
127
- var line: int = _add_signal(lines, fields)
128
- modifications_applied.append({"type": "add_signal", "name": mod_name, "line": line})
140
+ at = _add_signal(lines, fields, placed)
129
141
  "add_function":
130
- var line: int = _add_function(lines, fields)
131
- modifications_applied.append({"type": "add_function", "name": mod_name, "line": line})
132
- _:
133
- _log.error("Unknown modification type: " + mod_type)
142
+ at = _add_function(lines, fields, placed)
143
+ if at == -1:
144
+ return _log.failure(
145
+ "Nothing was changed: " + mod_type + " " + str(fields.get("name")) + " could not be placed"
146
+ )
147
+ placed.append({"type": mod_type, "name": str(fields.get("name")), "index": at})
148
+
149
+ var modifications_applied: Array[Dictionary] = []
150
+ for entry: Dictionary in placed:
151
+ var index: int = entry["index"]
152
+ modifications_applied.append({"type": entry["type"], "name": entry["name"], "line": index + 1})
134
153
 
135
154
  file = FileAccess.open(full_script_path, FileAccess.WRITE)
136
155
  if not file:
@@ -141,17 +160,75 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
141
160
  if not rewrote:
142
161
  return _log.failure("Failed to write to script file: " + full_script_path)
143
162
 
163
+ var made: Script = ResourceLoader.load(full_script_path, "Script", ResourceLoader.CACHE_MODE_IGNORE)
144
164
  return {
145
165
  "success": true,
146
166
  "script_path": script_path,
147
167
  "modifications_applied": modifications_applied,
148
- "total_modifications": modifications_applied.size()
168
+ "total_modifications": modifications_applied.size(),
169
+ "parses": _parses(made),
149
170
  }
150
171
 
151
172
 
152
- # Inserts the declaration and answers with its one-based line number, or 0 for one that could not
153
- # be placed, which is a number no line has.
154
- func _add_variable(lines: Array[String], mod: Dictionary) -> int:
173
+ # Every reason [param modifications] cannot be made as asked, each naming the entry by position.
174
+ static func _modification_problems(modifications: Array) -> Array[String]:
175
+ var problems: Array[String] = []
176
+ if modifications.is_empty():
177
+ problems.append("modifications is empty")
178
+ for index: int in range(modifications.size()):
179
+ var mod: Variant = modifications[index]
180
+ var which: String = "modification " + str(index + 1)
181
+ if not mod is Dictionary:
182
+ problems.append(which + " is not an object")
183
+ continue
184
+ var fields: Dictionary = mod
185
+ var mod_type: String = str(fields.get("type", ""))
186
+ var mod_name: String = str(fields.get("name", ""))
187
+ if mod_type not in ["add_variable", "add_signal", "add_function"]:
188
+ problems.append(
189
+ which + " has type '" + mod_type + "', not add_variable, add_signal or add_function"
190
+ )
191
+ if mod_name.is_empty():
192
+ problems.append(which + " has no name")
193
+ elif not mod_name.is_valid_unicode_identifier():
194
+ problems.append(which + " is named '" + mod_name + "', which GDScript does not accept as a name")
195
+ return problems
196
+
197
+
198
+ # Inserts [param new_lines] at [param at] and moves every placed line at or below it down by as many.
199
+ static func _insert(
200
+ lines: Array[String], at: int, new_lines: Array[String], placed: Array[Dictionary]
201
+ ) -> bool:
202
+ for i: int in range(new_lines.size() - 1, -1, -1):
203
+ if lines.insert(at, new_lines[i]) != OK:
204
+ push_error("gdharness: could not place a line at " + str(at))
205
+ return false
206
+ for entry: Dictionary in placed:
207
+ var index: int = entry["index"]
208
+ if index >= at:
209
+ entry["index"] = index + new_lines.size()
210
+ return true
211
+
212
+
213
+ # Whether the engine accepts [param script], parsed under this project's own warning settings: a
214
+ # script that does not load is one the caller wants to hear about now, and the reason is on stderr,
215
+ # which comes back with the answer. A script that fails to parse still loads, as one that cannot be
216
+ # instantiated; an abstract one that parses can be, on 4.7.
217
+ static func _parses(script: Script) -> bool:
218
+ return script != null and script.can_instantiate()
219
+
220
+
221
+ # What [param script] extends: the base script by class name or path, or the native class.
222
+ static func _base_named(script: Script) -> String:
223
+ var base: Script = script.get_base_script()
224
+ if base == null:
225
+ return str(script.get_instance_base_type())
226
+ var named: String = str(base.get_global_name())
227
+ return named if not named.is_empty() else base.resource_path
228
+
229
+
230
+ # Inserts the declaration and answers with its zero-based index, or -1 for one that could not be placed.
231
+ func _add_variable(lines: Array[String], mod: Dictionary, placed: Array[Dictionary]) -> int:
155
232
  var var_name: String = str(mod.get("name", ""))
156
233
  var var_type: String = str(mod.get("varType", ""))
157
234
  var default_value: String = str(mod.get("defaultValue", ""))
@@ -186,10 +263,7 @@ func _add_variable(lines: Array[String], mod: Dictionary) -> int:
186
263
  var_line += ": Variant"
187
264
 
188
265
  var insert_line: int = _variable_insertion_point(lines)
189
- if lines.insert(insert_line, var_line) != OK:
190
- push_error("gdharness: could not place a declaration at line " + str(insert_line))
191
- return 0
192
- return insert_line + 1
266
+ return insert_line if _insert(lines, insert_line, [var_line], placed) else -1
193
267
 
194
268
 
195
269
  # The type name a constant expression evaluates to, or Variant when it is not one: a call
@@ -208,7 +282,7 @@ func _type_of_literal(expression: String) -> String:
208
282
  return type_string(typeof(value))
209
283
 
210
284
 
211
- func _add_signal(lines: Array[String], mod: Dictionary) -> int:
285
+ func _add_signal(lines: Array[String], mod: Dictionary, placed: Array[Dictionary]) -> int:
212
286
  var signal_name: String = str(mod.get("name", ""))
213
287
  var signal_params: String = str(mod.get("params", ""))
214
288
 
@@ -217,13 +291,11 @@ func _add_signal(lines: Array[String], mod: Dictionary) -> int:
217
291
  signal_line += "(" + signal_params + ")"
218
292
 
219
293
  var insert_line: int = _signal_insertion_point(lines)
220
- if lines.insert(insert_line, signal_line) != OK:
221
- push_error("gdharness: could not place a signal at line " + str(insert_line))
222
- return 0
223
- return insert_line + 1
294
+ return insert_line if _insert(lines, insert_line, [signal_line], placed) else -1
224
295
 
225
296
 
226
- func _add_function(lines: Array[String], mod: Dictionary) -> int:
297
+ # Answers with the zero-based index of the `func` line, below the blank line that separates it.
298
+ func _add_function(lines: Array[String], mod: Dictionary, placed: Array[Dictionary]) -> int:
227
299
  var func_name: String = str(mod.get("name", ""))
228
300
  var func_params: String = str(mod.get("params", ""))
229
301
  # A function that names no return type returns nothing, and says so, for the same reason
@@ -248,12 +320,7 @@ func _add_function(lines: Array[String], mod: Dictionary) -> int:
248
320
  if insert_line < lines.size():
249
321
  func_lines.append("")
250
322
 
251
- for i: int in range(func_lines.size() - 1, -1, -1):
252
- if lines.insert(insert_line, func_lines[i]) != OK:
253
- push_error("gdharness: could not place a function at line " + str(insert_line))
254
- return 0
255
-
256
- return insert_line + 1
323
+ return insert_line + 1 if _insert(lines, insert_line, func_lines, placed) else -1
257
324
 
258
325
 
259
326
  func _variable_insertion_point(lines: Array[String]) -> int:
@@ -2,8 +2,13 @@
2
2
  extends SceneTree
3
3
 
4
4
  # The command line the server runs is `godot --headless --path <project> --script <this>
5
- # <operation> @file:<params.json>`, and one JSON object on stdout is the answer. Everything an
6
- # operation does lives in a sibling module; this file is the command table and the wire format.
5
+ # <operation> @file:<params.json> <answer.json>`, and the one JSON object written to the answer
6
+ # file is the answer. Everything an operation does lives in a sibling module; this file is the
7
+ # command table and the wire format.
8
+ #
9
+ # A file rather than a line on stdout, because the project's autoloads run after `_init` and print
10
+ # into the same stream: an autoload printing a dictionary in `_process` was the last JSON line out
11
+ # and was taken as the answer. Nothing the project prints can reach the file.
7
12
  #
8
13
  # Each module is preloaded by a path relative to this script rather than a res:// one, because
9
14
  # the operations directory ships inside the server package and is handed to the engine as an
@@ -39,8 +44,9 @@ func _init() -> void:
39
44
  return
40
45
 
41
46
  var params_index: int = script_index + 3
42
- if args.size() <= params_index:
43
- _log.error("Usage: godot --headless --script godot_operations.gd <operation> <json_params>")
47
+ var answer_index: int = script_index + 4
48
+ if args.size() <= answer_index:
49
+ _log.error("Usage: godot_operations.gd <operation> @file:<params.json> <answer.json>")
44
50
  _log.error("Not enough command-line arguments provided.")
45
51
  quit(1)
46
52
  return
@@ -65,7 +71,20 @@ func _init() -> void:
65
71
  quit(1)
66
72
  return
67
73
 
68
- print(JSON.stringify(payload))
74
+ var answer_path: String = args[answer_index]
75
+ var answer: FileAccess = FileAccess.open(answer_path, FileAccess.WRITE)
76
+ if answer == null:
77
+ _log.error(
78
+ "Could not write the answer to " + answer_path + ": " + error_string(FileAccess.get_open_error())
79
+ )
80
+ quit(1)
81
+ return
82
+ var written: bool = answer.store_string(JSON.stringify(payload))
83
+ answer.close()
84
+ if not written:
85
+ _log.error("Could not write the answer to " + answer_path)
86
+ quit(1)
87
+ return
69
88
  quit()
70
89
 
71
90
 
@@ -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.error("Import file does not exist: " + import_file_path)
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.error("Import file does not exist: " + import_file_path)
146
- return _log.failure("This resource may not have been imported yet")
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
- var updated_keys: Array[String] = []
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 value: Variant = _as_option(options[key], config.get_value("params", name, null))
158
- config.set_value("params", name, value)
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(value))
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 FileAccess.file_exists(main_scene):
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 file does not exist: " + 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 exactly when the editor's next scan would import it.
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
- # The server reads one JSON object off stdout and everything else as diagnostics, so the
4
- # prefixes are what let a human tell the two apart in a log without parsing it.
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.