gdharness 1.3.0 → 1.3.2

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 (72) hide show
  1. package/build/cli.js +322 -96
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +7 -19
  3. package/build/godot/addons/gdharness_editor/bridge_client.gd +8 -8
  4. package/build/godot/addons/gdharness_editor/declared_values.gd +116 -0
  5. package/build/godot/addons/gdharness_editor/declared_values.gd.uid +1 -0
  6. package/build/godot/addons/gdharness_editor/file_reads.gd +65 -25
  7. package/build/godot/addons/gdharness_editor/headless_check.gd +7 -7
  8. package/build/godot/addons/gdharness_editor/plugin.gd +6 -6
  9. package/build/godot/addons/gdharness_editor/property_values.gd +222 -59
  10. package/build/godot/addons/gdharness_editor/reading.gd +65 -0
  11. package/build/godot/addons/gdharness_editor/scene_file.gd +28 -23
  12. package/build/godot/addons/gdharness_editor/serialisation.gd +49 -94
  13. package/build/godot/addons/gdharness_editor/tool_executor.gd +24 -24
  14. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +52 -41
  15. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +4 -4
  16. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +2 -2
  17. package/build/godot/addons/gdharness_editor/tools/profile_tools.gd +14 -14
  18. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +175 -74
  19. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +17 -17
  20. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +14 -12
  21. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +7 -7
  22. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +88 -84
  23. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +4 -4
  24. package/build/godot/addons/gdharness_editor/value_shapes.gd +118 -0
  25. package/build/godot/addons/gdharness_editor/value_shapes.gd.uid +1 -0
  26. package/build/godot/addons/gdharness_runtime/reading.gd +65 -0
  27. package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +18 -18
  28. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +29 -29
  29. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +21 -18
  30. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +86 -80
  31. package/build/godot/addons/gdharness_runtime/runtime_input.gd +65 -64
  32. package/build/godot/addons/gdharness_runtime/runtime_items.gd +6 -6
  33. package/build/godot/addons/gdharness_runtime/runtime_menus.gd +8 -8
  34. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +6 -6
  35. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +10 -10
  36. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +7 -7
  37. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +41 -41
  38. package/build/godot/addons/gdharness_runtime/runtime_says.gd +3 -3
  39. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +33 -33
  40. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +20 -20
  41. package/build/godot/addons/gdharness_runtime/runtime_values.gd +52 -25
  42. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +56 -50
  43. package/build/godot/addons/gdharness_runtime/serialisation.gd +49 -94
  44. package/build/godot/addons/gdharness_runtime/value_shapes.gd +118 -0
  45. package/build/godot/addons/gdharness_runtime/value_shapes.gd.uid +1 -0
  46. package/build/godot/operations/animation_reads.gd +7 -19
  47. package/build/godot/operations/audio_buses.gd +10 -10
  48. package/build/godot/operations/class_cache.gd +16 -16
  49. package/build/godot/operations/classdb_queries.gd +25 -15
  50. package/build/godot/operations/declared_values.gd +116 -0
  51. package/build/godot/operations/dependencies.gd +141 -33
  52. package/build/godot/operations/editor_tools.gd +12 -12
  53. package/build/godot/operations/export_presets.gd +14 -14
  54. package/build/godot/operations/file_reads.gd +65 -25
  55. package/build/godot/operations/file_walk.gd +34 -0
  56. package/build/godot/operations/gdscript_analysis.gd +27 -22
  57. package/build/godot/operations/gdscript_authoring.gd +122 -17
  58. package/build/godot/operations/godot_operations.gd +66 -63
  59. package/build/godot/operations/import_pipeline.gd +59 -20
  60. package/build/godot/operations/input_actions.gd +90 -18
  61. package/build/godot/operations/logger.gd +1 -1
  62. package/build/godot/operations/plugins.gd +8 -8
  63. package/build/godot/operations/project_config.gd +83 -20
  64. package/build/godot/operations/project_diagnostics.gd +36 -11
  65. package/build/godot/operations/property_values.gd +222 -59
  66. package/build/godot/operations/reading.gd +65 -0
  67. package/build/godot/operations/rename_checks.gd +3 -3
  68. package/build/godot/operations/resource_files.gd +9 -6
  69. package/build/godot/operations/script_check.gd +23 -7
  70. package/build/godot/operations/serialisation.gd +49 -94
  71. package/build/godot/operations/value_shapes.gd +118 -0
  72. package/package.json +1 -1
@@ -14,8 +14,10 @@ extends RefCounted
14
14
  ## write accepts. What is here on top of it is the part only a property knows: the type wanted, so a
15
15
  ## dictionary shaped like a vector or a bare pair of numbers can be read as one.
16
16
 
17
- const Read = preload("reading.gd")
18
- const Serialisation = preload("serialisation.gd")
17
+ const GdhDeclared = preload("declared_values.gd")
18
+ const GdhRead = preload("reading.gd")
19
+ const GdhSerialisation = preload("serialisation.gd")
20
+ const GdhShapes = preload("value_shapes.gd")
19
21
 
20
22
  # The data types a Theme names its items by, as in "Button/colors/font_color", and what each holds.
21
23
  const THEME_ITEMS: Dictionary[String, Array] = {
@@ -65,12 +67,23 @@ const FLOAT_SHAPES: Array[int] = [
65
67
  # names, so a write to "Visibility" or "Transform" changed nothing and was answered as made.
66
68
  const HEADINGS: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
67
69
 
68
- var _values: Serialisation = Serialisation.new()
70
+ var _values: GdhSerialisation = GdhSerialisation.new()
69
71
 
70
72
  # Why the value being parsed cannot be written, when the parse is what found out. A resource is
71
73
  # built inside the parse and there is nowhere in a parsed value to put the reason it is wrong.
72
74
  var _refused: String = ""
73
75
 
76
+ # The stored properties the last write_all changed without being given them, by name, as [what it
77
+ # held before, what it holds now]. A setter keeps other properties in step with the one it is given:
78
+ # a Gradient given a third colour gives that point an offset of 0, a Control given a size moves its
79
+ # offsets. An answer naming only what was given leaves the caller to guess the rest.
80
+ var _also_changed: Dictionary = {}
81
+
82
+ # The values the last write_all wrote outside what their property declares, by name, as a sentence
83
+ # saying so: a number past an export range, or one no enum value has. The engine stores either, and
84
+ # the inspector would not have let it be entered, so a write answered as made said nothing of it.
85
+ var _outside_declared: Dictionary = {}
86
+
74
87
 
75
88
  ## Turns what arrived over the wire into the Godot value a property wants.
76
89
  ##
@@ -85,7 +98,7 @@ func parse(value: Variant, expected_type: int = TYPE_NIL, elements: Array = [])
85
98
  var fields: Dictionary = value
86
99
  # A dictionary with keys that are not text arrives as its entries, and is read against the
87
100
  # declared element types like any other.
88
- if str(fields.get("_type", "")) == "Dictionary" and Serialisation.pairs(fields.get("entries")):
101
+ if str(fields.get("_type", "")) == "Dictionary" and GdhSerialisation.pairs(fields.get("entries")):
89
102
  var built: Dictionary = _values.deserialize_value(fields)
90
103
  return _parse_entries(built, elements)
91
104
  var tagged: Array = _parse_tagged_dictionary(fields)
@@ -113,38 +126,39 @@ func cannot_hold(
113
126
  ) -> String:
114
127
  var held: String = type_string(expected_type)
115
128
  var cost: String = " Setting it would save a different value than the one asked for."
116
- if not Serialisation.acceptable(value, expected_type):
129
+ if not GdhSerialisation.acceptable(value, expected_type):
117
130
  var shape: String = "%s.%s is %s and the value given is %s, which cannot become one."
118
- return shape % [holder, property, held, type_string(typeof(value))] + cost
131
+ var remedy: String = GdhShapes.remedy(value, expected_type)
132
+ return shape % [holder, property, held, type_string(typeof(value))] + remedy + cost
119
133
 
120
134
  # And the same question of each element, because a list is accepted for a packed or typed array
121
135
  # whatever is in it: a polygon written as [{"x": 1}] passes the check above and lands as one zero
122
136
  # vector, and [1.5, "x"] for a PackedInt32Array as [1, 0].
123
- var element: int = Read.as_int(PACKED_ELEMENTS.get(expected_type, TYPE_NIL), TYPE_NIL)
137
+ var element: int = GdhRead.as_int(PACKED_ELEMENTS.get(expected_type, TYPE_NIL), TYPE_NIL)
124
138
  if expected_type == TYPE_ARRAY and not elements.is_empty():
125
- element = Read.as_int(elements[0], TYPE_NIL)
139
+ element = GdhRead.as_int(elements[0], TYPE_NIL)
126
140
  if element != TYPE_NIL and value is Array:
127
141
  var items: Array = value
128
142
  for index: int in items.size():
129
- if not Serialisation.acceptable(items[index], element):
143
+ if not GdhSerialisation.acceptable(items[index], element):
130
144
  var shape: String = "%s.%s is %s and item %d of the list given is %s, not %s."
131
145
  var named: Array = [
132
146
  holder, property, held, index, type_string(typeof(items[index])), type_string(element)
133
147
  ]
134
- return shape % named + cost
148
+ return shape % named + GdhShapes.remedy(items[index], element) + cost
135
149
  if expected_type == TYPE_DICTIONARY and elements.size() == 2 and value is Dictionary:
136
150
  var entries: Dictionary = value
137
- var key_type: int = Read.as_int(elements[0], TYPE_NIL)
138
- var value_type: int = Read.as_int(elements[1], TYPE_NIL)
151
+ var key_type: int = GdhRead.as_int(elements[0], TYPE_NIL)
152
+ var value_type: int = GdhRead.as_int(elements[1], TYPE_NIL)
139
153
  for key: Variant in entries:
140
- if not Serialisation.acceptable(key, key_type):
154
+ if not GdhSerialisation.acceptable(key, key_type):
141
155
  var shape: String = "%s.%s has %s keys, and %s is not one."
142
156
  return shape % [holder, property, type_string(key_type), shown(key)] + cost
143
157
  var entry: Variant = entries[key]
144
- if not Serialisation.acceptable(entry, value_type):
158
+ if not GdhSerialisation.acceptable(entry, value_type):
145
159
  var shape: String = "%s.%s holds %s values, and the one given for %s is %s."
146
160
  var named: Array = [holder, property, type_string(value_type), shown(key), shown(entry)]
147
- return shape % named + cost
161
+ return shape % named + GdhShapes.remedy(entry, value_type) + cost
148
162
  return ""
149
163
 
150
164
 
@@ -168,19 +182,35 @@ func cannot_hold(
168
182
  ## Nothing here saves, so a refusal partway leaves the change in memory and the file as it was. The
169
183
  ## caller returns the refusal instead of saving.
170
184
  func write_all(target: Object, properties: Dictionary) -> String:
185
+ _also_changed = {}
186
+ _outside_declared = {}
187
+ var named: Dictionary = {}
188
+ for key: Variant in properties:
189
+ named[str(key).get_slice(":", 0)] = true
190
+ var before: Dictionary = _stored(target, named)
171
191
  var given: Dictionary = {}
192
+ # A refusal of one of several leaves the others unwritten too, which a refusal naming only the
193
+ # one it refused left a caller to guess.
194
+ var unwritten: String = (
195
+ "" if properties.size() < 2 else " None of the %d properties given was written." % properties.size()
196
+ )
172
197
  for key: Variant in properties:
173
198
  var property: String = str(key)
174
199
  var declared: Dictionary = declaration(target, property)
175
200
  if declared.is_empty() and ":" in property:
176
201
  declared = _component_slot(target, property)
177
202
  if declared.has("refused"):
178
- return str(declared["refused"])
203
+ return str(declared["refused"]) + unwritten
204
+ if declared.is_empty() and target is ShaderMaterial and property.begins_with("shader_parameter/"):
205
+ return _no_uniform(target as ShaderMaterial, property) + unwritten
179
206
  if declared.is_empty():
180
- return "%s has no property %s" % [target.get_class(), property]
207
+ return "%s has no property %s." % [class_called(target), property] + unwritten
181
208
  var value: Array = value_for(target, declared, properties[key])
182
209
  if not value[0]:
183
- return str(value[1])
210
+ return str(value[1]) + unwritten
211
+ var outside: String = GdhDeclared.outside(target, declared, value[1], shown(value[1]))
212
+ if not outside.is_empty():
213
+ _outside_declared[property] = outside
184
214
  _write(target, property, value[1])
185
215
  given[property] = value[1]
186
216
 
@@ -197,9 +227,83 @@ func write_all(target: Object, properties: Dictionary) -> String:
197
227
  )
198
228
  % [target.get_class(), property, shown(given[property]), shown(held)]
199
229
  )
230
+ for property: String in before:
231
+ var now: Variant = target.get(property)
232
+ if not same(now, before[property]):
233
+ _also_changed[property] = [before[property], now]
200
234
  return ""
201
235
 
202
236
 
237
+ ## Why [param material] has no [param property]: a ShaderMaterial's parameters are its shader's
238
+ ## uniforms, so the shader is what lacks one, and "ShaderMaterial has no property" named the
239
+ ## material.
240
+ static func _no_uniform(material: ShaderMaterial, property: String) -> String:
241
+ var uniform: String = property.trim_prefix("shader_parameter/")
242
+ if material.shader == null:
243
+ return "The ShaderMaterial has no shader, so it has no parameter %s yet; set shader first." % uniform
244
+ var shader: String = material.shader.resource_path
245
+ var named: String = shader if not shader.is_empty() else "Its shader"
246
+ var uniforms: Array = []
247
+ for entry: Dictionary in material.shader.get_shader_uniform_list():
248
+ uniforms.append(str(entry["name"]))
249
+ var said: String = "%s declares no uniform %s, which %s would set" % [named, uniform, property]
250
+ if uniforms.is_empty():
251
+ return said + "; it declares no uniforms."
252
+ return GdhRead.offered(said, uniform, uniforms) + "."
253
+
254
+
255
+ ## Puts under alsoChanged in [param answer] each property the last [method write_all] changed
256
+ ## without being given it, as {was, now}, and under outsideDeclared each value it wrote outside what
257
+ ## its property declares, when there are any. A node is named by its path from [param relative_to],
258
+ ## as a scene answer names one. Called before the scene is written, since the write frees the nodes
259
+ ## these values can hold.
260
+ func note_write(answer: Dictionary, relative_to: Node = null) -> void:
261
+ if not _outside_declared.is_empty():
262
+ answer["outsideDeclared"] = _outside_declared.duplicate()
263
+ if _also_changed.is_empty():
264
+ return
265
+ var noted: Dictionary = {}
266
+ for property: Variant in _also_changed:
267
+ var pair: Array = _also_changed[property]
268
+ noted[property] = {"was": _readable(pair[0], relative_to), "now": _readable(pair[1], relative_to)}
269
+ answer["alsoChanged"] = noted
270
+
271
+
272
+ func _readable(value: Variant, relative_to: Node) -> Variant:
273
+ if value is Node and relative_to != null:
274
+ var target: Node = value
275
+ return _values.serialize_value(relative_to.get_path_to(target))
276
+ if value is Array:
277
+ var items: Array = value
278
+ return items.map(func(item: Variant) -> Variant: return _readable(item, relative_to))
279
+ return _values.serialize_value(value)
280
+
281
+
282
+ ## What [param target] stores, by name, but its script and the properties in [param leave]. A list
283
+ ## or a map is copied, since a setter can change the one it holds in place.
284
+ func _stored(target: Object, leave: Dictionary) -> Dictionary:
285
+ var held: Dictionary = {}
286
+ for entry: Dictionary in target.get_property_list():
287
+ var name: String = str(entry["name"])
288
+ var usage: int = GdhRead.as_int(entry["usage"])
289
+ if (
290
+ usage & PROPERTY_USAGE_STORAGE == 0
291
+ or usage & HEADINGS != 0
292
+ or name == "script"
293
+ or leave.has(name)
294
+ ):
295
+ continue
296
+ var value: Variant = target.get(name)
297
+ if value is Array:
298
+ var listed: Array = value
299
+ value = listed.duplicate(true)
300
+ elif value is Dictionary:
301
+ var mapped: Dictionary = value
302
+ value = mapped.duplicate(true)
303
+ held[name] = value
304
+ return held
305
+
306
+
203
307
  ## What [param target] holds under [param key], a property or a colon path into one's components.
204
308
  func read_at(target: Object, key: String) -> Variant:
205
309
  if ":" in key and declaration(target, key).is_empty():
@@ -228,9 +332,8 @@ func _component_slot(target: Object, path: String) -> Dictionary:
228
332
  var walked: String = steps[0]
229
333
  for index: int in range(1, steps.size()):
230
334
  var step: String = steps[index]
231
- var has: PackedStringArray = Serialisation.components_of(value)
232
- var kind: String = type_string(typeof(value))
233
- var sort: String = ("an " if kind.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + kind
335
+ var has: PackedStringArray = GdhSerialisation.components_of(value)
336
+ var sort: String = GdhRead.with_article(type_string(typeof(value)))
234
337
  if has.is_empty():
235
338
  return {
236
339
  "refused":
@@ -255,10 +358,22 @@ func _component_slot(target: Object, path: String) -> Dictionary:
255
358
  ## [param raw] as the value [param declared] takes, as [ok, value] or [false, why not].
256
359
  func value_for(target: Object, declared: Dictionary, raw: Variant) -> Array:
257
360
  var property: String = str(declared.get("name", ""))
258
- var expected_type: int = Read.as_int(declared.get("type", TYPE_NIL), TYPE_NIL)
259
- var hint: int = Read.as_int(declared.get("hint", PROPERTY_HINT_NONE))
361
+ var expected_type: int = GdhRead.as_int(declared.get("type", TYPE_NIL), TYPE_NIL)
362
+ var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
260
363
  if expected_type == TYPE_OBJECT and hint == PROPERTY_HINT_NODE_TYPE:
261
364
  return _node_reference(target, property, raw)
365
+ # An enum is written by the name of a value as well as its number, since the name is what the
366
+ # script and the inspector show and the number is only where the value happens to sit.
367
+ if expected_type == TYPE_INT and hint == PROPERTY_HINT_ENUM and typeof(raw) == TYPE_STRING:
368
+ var choices: Dictionary = GdhDeclared.enum_of(target, declared)
369
+ var chosen: Variant = GdhDeclared.enum_named(choices, str(raw))
370
+ if chosen == null:
371
+ var hinted: Dictionary = GdhDeclared.enum_values(str(declared.get("hint_string", "")))
372
+ chosen = GdhDeclared.enum_named(hinted, str(raw))
373
+ if chosen != null:
374
+ return [true, chosen]
375
+ if not str(raw).strip_edges().is_valid_int():
376
+ return [false, GdhDeclared.enum_refusal(class_called(target), property, str(raw), choices)]
262
377
 
263
378
  # A resource-valued property takes the path of one, which is how a caller names a TileSet,
264
379
  # a material or a theme: there is no other way to hand a tool a Resource.
@@ -272,14 +387,14 @@ func value_for(target: Object, declared: Dictionary, raw: Variant) -> Array:
272
387
  var elements: Array = elements_of(declared)
273
388
  if _fraction(raw, expected_type, elements):
274
389
  return [false, _fraction_refusal(target, property, raw)]
275
- var rounded: String = Serialisation.inexact(raw, expected_type)
390
+ var rounded: String = GdhSerialisation.inexact(raw, expected_type)
276
391
  if not rounded.is_empty():
277
- return [false, "%s.%s holds int: %s." % [target.get_class(), property, rounded]]
392
+ return [false, "%s.%s holds int: %s." % [class_called(target), property, rounded]]
278
393
  _refused = ""
279
394
  value = parse(raw, expected_type, elements)
280
395
  if not _refused.is_empty():
281
396
  return [false, _refused]
282
- var refusal: String = cannot_hold(target.get_class(), property, value, expected_type, elements)
397
+ var refusal: String = cannot_hold(class_called(target), property, value, expected_type, elements)
283
398
  if not refusal.is_empty():
284
399
  return [false, refusal]
285
400
  if value is Object:
@@ -328,7 +443,7 @@ func _node_reference(target: Object, property: String, raw: Variant) -> Array:
328
443
  ## an object of the wrong class: measured on 4.7.2, a Sprite2D given a Material for its texture
329
444
  ## held nothing afterwards, and the scene was saved without the texture it had.
330
445
  func _wrong_class(value: Object, declared: Dictionary) -> String:
331
- var hint: int = Read.as_int(declared.get("hint", PROPERTY_HINT_NONE))
446
+ var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
332
447
  var wanted: String = str(declared.get("hint_string", ""))
333
448
  if hint != PROPERTY_HINT_RESOURCE_TYPE or wanted.is_empty():
334
449
  return ""
@@ -354,8 +469,8 @@ func _wrong_class(value: Object, declared: Dictionary) -> String:
354
469
  ## behind a placeholder in the editor, and measured on 4.7.2 that placeholder stores [1.5, 2.0]
355
470
  ## as given, which the game then fails to load into the typed array.
356
471
  static func elements_of(declared: Dictionary) -> Array:
357
- var type: int = Read.as_int(declared.get("type", TYPE_NIL), TYPE_NIL)
358
- var hint: int = Read.as_int(declared.get("hint", PROPERTY_HINT_NONE))
472
+ var type: int = GdhRead.as_int(declared.get("type", TYPE_NIL), TYPE_NIL)
473
+ var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
359
474
  var wanted: String = str(declared.get("hint_string", ""))
360
475
  if wanted.is_empty():
361
476
  return []
@@ -397,21 +512,21 @@ func _fraction(value: Variant, type: int, elements: Array = []) -> bool:
397
512
  if type == TYPE_INT:
398
513
  var number: Variant = value
399
514
  if typeof(value) == TYPE_STRING:
400
- number = Read.json_or_null(str(value))
515
+ number = GdhRead.json_or_null(str(value))
401
516
  if typeof(number) != TYPE_FLOAT:
402
517
  return false
403
518
  var whole: float = number
404
519
  return is_finite(whole) and whole != floorf(whole)
405
- var element: int = Read.as_int(PACKED_ELEMENTS.get(type, TYPE_NIL), TYPE_NIL)
520
+ var element: int = GdhRead.as_int(PACKED_ELEMENTS.get(type, TYPE_NIL), TYPE_NIL)
406
521
  if type == TYPE_ARRAY and not elements.is_empty():
407
- element = Read.as_int(elements[0], TYPE_NIL)
522
+ element = GdhRead.as_int(elements[0], TYPE_NIL)
408
523
  if value is Array and element != TYPE_NIL:
409
524
  var items: Array = value
410
525
  return items.any(func(item: Variant) -> bool: return _fraction(item, element))
411
526
  if type == TYPE_DICTIONARY and elements.size() == 2 and value is Dictionary:
412
527
  var entries: Dictionary = value
413
- var key_type: int = Read.as_int(elements[0], TYPE_NIL)
414
- var value_type: int = Read.as_int(elements[1], TYPE_NIL)
528
+ var key_type: int = GdhRead.as_int(elements[0], TYPE_NIL)
529
+ var value_type: int = GdhRead.as_int(elements[1], TYPE_NIL)
415
530
  for key: Variant in entries:
416
531
  if _fraction(key, key_type) or _fraction(entries[key], value_type):
417
532
  return true
@@ -424,10 +539,43 @@ func _fraction_refusal(target: Object, property: String, raw: Variant) -> String
424
539
  "%s.%s holds whole numbers, and %s would be cut to fit. Setting it would save a different "
425
540
  + "value than the one asked for."
426
541
  )
427
- % [target.get_class(), property, shown(raw)]
542
+ % [class_called(target), property, shown(raw)]
428
543
  )
429
544
 
430
545
 
546
+ ## [param raw] as a colour, read as a property write reads one, as [true, Color] or [false, why]:
547
+ ## fields from 0 to 1 with a optional, or an html code. [param named] is what the refusal calls it.
548
+ func colour_from(raw: Variant, named: String) -> Array:
549
+ _refused = ""
550
+ var value: Variant = parse(raw, TYPE_COLOR)
551
+ if not _refused.is_empty():
552
+ return [false, _refused]
553
+ if value is Color:
554
+ return [true, value]
555
+ return [
556
+ false,
557
+ (
558
+ (
559
+ '%s is {"r": .., "g": .., "b": .., "a": ..} from 0 to 1, a optional, or an html code such as '
560
+ + '"#ff8000" or "#ff800080"; %s is neither.'
561
+ )
562
+ % [named, JSON.stringify(raw)]
563
+ )
564
+ ]
565
+
566
+
567
+ ## What [param target] is called in a refusal: the class_name its script declares, or else its
568
+ ## engine class. A resource made as a project class is a Resource to the engine, and "Resource.kind"
569
+ ## named a property no Resource has.
570
+ static func class_called(target: Object) -> String:
571
+ var attached: Variant = target.get_script()
572
+ if attached is Script:
573
+ var script: Script = attached
574
+ if not str(script.get_global_name()).is_empty():
575
+ return str(script.get_global_name())
576
+ return target.get_class()
577
+
578
+
431
579
  ## The resource [param path] names, as [found, resource or why not].
432
580
  ##
433
581
  ## The project boundary is enforced here as well as on the server, because only the engine knows
@@ -451,6 +599,19 @@ func _resource_at(path: String, property: String) -> Array:
451
599
  )
452
600
  ]
453
601
  if not ResourceLoader.exists(path):
602
+ # An image or a sound nobody has imported is a file the engine has no loader for, and "No
603
+ # resource at" sent a caller looking for a file that was there.
604
+ if path.begins_with("res://") and FileAccess.file_exists(path):
605
+ return [
606
+ false,
607
+ (
608
+ (
609
+ "%s is there, but it has never been imported, so the engine has no resource to load"
610
+ + " from it for %s. project_import reimport with resourcePath %s imports it."
611
+ )
612
+ % [path, property, path]
613
+ )
614
+ ]
454
615
  return [false, "No resource at %s for %s" % [path, property]]
455
616
  return [true, load(path)]
456
617
 
@@ -463,7 +624,7 @@ func declaration(target: Object, property: String) -> Dictionary:
463
624
  for declared: Dictionary in target.get_property_list():
464
625
  if str(declared.get("name", "")) != property:
465
626
  continue
466
- if Read.as_int(declared.get("usage", 0)) & HEADINGS:
627
+ if GdhRead.as_int(declared.get("usage", 0)) & HEADINGS:
467
628
  continue
468
629
  return declared
469
630
  if target is Theme:
@@ -503,13 +664,13 @@ static func same(held: Variant, given: Variant) -> bool:
503
664
  return held_none == given_none
504
665
  if held_type == TYPE_OBJECT or given_type == TYPE_OBJECT:
505
666
  return _same_object(held, given)
506
- if Serialisation.NUMBERS.has(held_type) and Serialisation.NUMBERS.has(given_type):
667
+ if GdhSerialisation.NUMBERS.has(held_type) and GdhSerialisation.NUMBERS.has(given_type):
507
668
  if held_type == TYPE_BOOL or given_type == TYPE_BOOL:
508
669
  return held_type == given_type and held == given
509
- var held_number: float = Read.as_float(held)
510
- var given_number: float = Read.as_float(given)
670
+ var held_number: float = GdhRead.as_float(held)
671
+ var given_number: float = GdhRead.as_float(given)
511
672
  return (is_nan(held_number) and is_nan(given_number)) or is_equal_approx(held_number, given_number)
512
- if Serialisation.TEXTS.has(held_type) and Serialisation.TEXTS.has(given_type):
673
+ if GdhSerialisation.TEXTS.has(held_type) and GdhSerialisation.TEXTS.has(given_type):
513
674
  return str(held) == str(given)
514
675
  if _listed(held_type) and _listed(given_type):
515
676
  var held_items: Array = type_convert(held, TYPE_ARRAY)
@@ -675,7 +836,7 @@ func _parse_shaped_dictionary(value: Dictionary, expected_type: int, elements: A
675
836
  if expected_type == TYPE_NIL or expected_type == TYPE_DICTIONARY:
676
837
  return _parse_entries(value, elements)
677
838
  var tag: String = type_string(expected_type)
678
- if Serialisation.REQUIRED.has(tag):
839
+ if GdhShapes.REQUIRED.has(tag):
679
840
  var tagged: Dictionary = value.duplicate()
680
841
  tagged["_type"] = tag
681
842
  var built: Variant = _values.deserialize_value(tagged)
@@ -690,8 +851,8 @@ func _parse_entries(value: Dictionary, elements: Array) -> Dictionary:
690
851
  var key_type: int = TYPE_NIL
691
852
  var value_type: int = TYPE_NIL
692
853
  if elements.size() == 2:
693
- key_type = Read.as_int(elements[0], TYPE_NIL)
694
- value_type = Read.as_int(elements[1], TYPE_NIL)
854
+ key_type = GdhRead.as_int(elements[0], TYPE_NIL)
855
+ value_type = GdhRead.as_int(elements[1], TYPE_NIL)
695
856
  var entries: Dictionary = {}
696
857
  for key: Variant in value:
697
858
  entries[_values.fitted(key, key_type)] = parse(value[key], value_type)
@@ -709,7 +870,7 @@ func _typed_dictionary(entries: Dictionary, key_type: int, value_type: int) -> D
709
870
  for key: Variant in entries:
710
871
  if key_type != TYPE_NIL and typeof(key) != key_type:
711
872
  return entries
712
- if not Serialisation.acceptable(entries[key], value_type):
873
+ if not GdhSerialisation.acceptable(entries[key], value_type):
713
874
  return entries
714
875
  var fitted: Dictionary = {}
715
876
  for key: Variant in entries:
@@ -720,46 +881,48 @@ func _typed_dictionary(entries: Dictionary, key_type: int, value_type: int) -> D
720
881
  ## An array, either positional for a vector the property declares, or a list to parse per item.
721
882
  func _parse_array(value: Array, expected_type: int, elements: Array) -> Variant:
722
883
  if PACKED_ELEMENTS.has(expected_type):
723
- var element: int = Read.as_int(PACKED_ELEMENTS[expected_type], TYPE_NIL)
884
+ var element: int = GdhRead.as_int(PACKED_ELEMENTS[expected_type], TYPE_NIL)
724
885
  return value.map(func(item: Variant) -> Variant: return parse(item, element))
725
886
 
726
887
  match expected_type:
727
888
  TYPE_VECTOR2:
728
889
  if value.size() == 2:
729
- return Vector2(Read.as_float(value[0]), Read.as_float(value[1]))
890
+ return Vector2(GdhRead.as_float(value[0]), GdhRead.as_float(value[1]))
730
891
  TYPE_VECTOR2I:
731
892
  if value.size() == 2:
732
- return Vector2i(Read.as_int(value[0]), Read.as_int(value[1]))
893
+ return Vector2i(GdhRead.as_int(value[0]), GdhRead.as_int(value[1]))
733
894
  TYPE_VECTOR3:
734
895
  if value.size() == 3:
735
- return Vector3(Read.as_float(value[0]), Read.as_float(value[1]), Read.as_float(value[2]))
896
+ return Vector3(
897
+ GdhRead.as_float(value[0]), GdhRead.as_float(value[1]), GdhRead.as_float(value[2])
898
+ )
736
899
  TYPE_VECTOR3I:
737
900
  if value.size() == 3:
738
- return Vector3i(Read.as_int(value[0]), Read.as_int(value[1]), Read.as_int(value[2]))
901
+ return Vector3i(GdhRead.as_int(value[0]), GdhRead.as_int(value[1]), GdhRead.as_int(value[2]))
739
902
  TYPE_VECTOR4:
740
903
  if value.size() == 4:
741
904
  return Vector4(
742
- Read.as_float(value[0]),
743
- Read.as_float(value[1]),
744
- Read.as_float(value[2]),
745
- Read.as_float(value[3])
905
+ GdhRead.as_float(value[0]),
906
+ GdhRead.as_float(value[1]),
907
+ GdhRead.as_float(value[2]),
908
+ GdhRead.as_float(value[3])
746
909
  )
747
910
  TYPE_COLOR:
748
911
  if value.size() == 3 or value.size() == 4:
749
912
  return Color(
750
- Read.as_float(value[0]),
751
- Read.as_float(value[1]),
752
- Read.as_float(value[2]),
753
- Read.as_float(value[3]) if value.size() == 4 else 1.0
913
+ GdhRead.as_float(value[0]),
914
+ GdhRead.as_float(value[1]),
915
+ GdhRead.as_float(value[2]),
916
+ GdhRead.as_float(value[3]) if value.size() == 4 else 1.0
754
917
  )
755
918
 
756
919
  var element_type: int = TYPE_NIL
757
920
  if expected_type == TYPE_ARRAY and not elements.is_empty():
758
- element_type = Read.as_int(elements[0], TYPE_NIL)
921
+ element_type = GdhRead.as_int(elements[0], TYPE_NIL)
759
922
  var items: Array = value.map(func(item: Variant) -> Variant: return parse(item, element_type))
760
923
  if element_type == TYPE_NIL or element_type == TYPE_OBJECT:
761
924
  return items
762
- if not items.all(func(item: Variant) -> bool: return Serialisation.acceptable(item, element_type)):
925
+ if not items.all(func(item: Variant) -> bool: return GdhSerialisation.acceptable(item, element_type)):
763
926
  return items
764
927
  var fitted: Array = items.map(func(item: Variant) -> Variant: return type_convert(item, element_type))
765
928
  return Array(fitted, element_type, &"", null)
@@ -139,3 +139,68 @@ static func missing_below(
139
139
  "its children are " + shown if not children.is_empty() else "it has no children",
140
140
  ]
141
141
  )
142
+
143
+
144
+ ## [param said], the refusal for a [param kind] the scene does not have at [param node_path], with
145
+ ## what is there when the scene has a node there, [param found] its class, and [param holders], the
146
+ ## paths of the nodes in the scene that are one, so the caller has the path to pass. Names up to
147
+ ## [constant NAMED_CHILDREN] of them.
148
+ static func not_of_kind(
149
+ said: String, node_path: String, found: String, kind: String, holders: PackedStringArray
150
+ ) -> String:
151
+ var told: String = said + ("" if said.ends_with(".") else ".")
152
+ if not found.is_empty():
153
+ told += ' The node at "%s" is %s, not %s.' % [node_path, with_article(found), with_article(kind)]
154
+ if holders.is_empty():
155
+ return told + " The scene holds no %s." % kind
156
+ var quoted: PackedStringArray = []
157
+ for holder: String in holders.slice(0, NAMED_CHILDREN):
158
+ var _quoted: bool = quoted.append('"%s"' % holder)
159
+ var shown: String = ", ".join(quoted)
160
+ if holders.size() > NAMED_CHILDREN:
161
+ shown += " and %d more" % (holders.size() - NAMED_CHILDREN)
162
+ if holders.size() == 1:
163
+ return told + " The scene's %s is at %s." % [kind, shown]
164
+ return told + " The scene's %ss are at %s." % [kind, shown]
165
+
166
+
167
+ ## Up to three of [param names] closest to [param word], closest first, ignoring case: the names a
168
+ ## refusal offers for one that is not there, as a typo usually is one of them.
169
+ static func nearest(word: String, names: PackedStringArray) -> PackedStringArray:
170
+ var wanted: String = word.to_lower()
171
+ var close: Array[String] = []
172
+ for name: String in names:
173
+ if name.to_lower().similarity(wanted) >= 0.5:
174
+ close.append(name)
175
+ close.sort_custom(
176
+ func(a: String, b: String) -> bool:
177
+ return a.to_lower().similarity(wanted) > b.to_lower().similarity(wanted)
178
+ )
179
+ return PackedStringArray(close.slice(0, 3))
180
+
181
+
182
+ ## [param said], the refusal for [param word], which is not among [param names], with those nearest
183
+ ## it when any are, or else every one of them, so the caller has a name to send instead.
184
+ static func offered(said: String, word: String, names: Array) -> String:
185
+ var every: PackedStringArray = PackedStringArray(names)
186
+ var close: PackedStringArray = nearest(word, every)
187
+ if close.is_empty():
188
+ return "%s; it has %s" % [said, ", ".join(every)]
189
+ return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
190
+
191
+
192
+ ## The signals [param object] has that a caller can connect, its own class's first: the engine
193
+ ## lists them from Object down, which puts a Button's pressed after thirty a Control has, and names
194
+ ## starting with an underscore are the engine's own.
195
+ static func signals_of(object: Object) -> Array:
196
+ var names: Array = []
197
+ for entry: Dictionary in object.get_signal_list():
198
+ var name: String = str(entry["name"])
199
+ if not name.begins_with("_"):
200
+ names.push_front(name)
201
+ return names
202
+
203
+
204
+ ## [param word] after the article it takes, "an AnimationTree" or "a Node2D".
205
+ static func with_article(word: String) -> String:
206
+ return ("an " if word.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + word