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
@@ -22,12 +22,39 @@ const Serialisation = preload("serialisation.gd")
22
22
  # list of two numbers: without being told, every element was read as itself and the engine turned
23
23
  # the lot into zeroes on the way into the property.
24
24
  const PACKED_ELEMENTS: Dictionary = {
25
+ TYPE_PACKED_BYTE_ARRAY: TYPE_INT,
26
+ TYPE_PACKED_INT32_ARRAY: TYPE_INT,
27
+ TYPE_PACKED_INT64_ARRAY: TYPE_INT,
28
+ TYPE_PACKED_FLOAT32_ARRAY: TYPE_FLOAT,
29
+ TYPE_PACKED_FLOAT64_ARRAY: TYPE_FLOAT,
30
+ TYPE_PACKED_STRING_ARRAY: TYPE_STRING,
25
31
  TYPE_PACKED_VECTOR2_ARRAY: TYPE_VECTOR2,
26
32
  TYPE_PACKED_VECTOR3_ARRAY: TYPE_VECTOR3,
27
33
  TYPE_PACKED_VECTOR4_ARRAY: TYPE_VECTOR4,
28
34
  TYPE_PACKED_COLOR_ARRAY: TYPE_COLOR,
29
35
  }
30
36
 
37
+ # The types made of floats, which a read back compares within float precision: a Vector2 property
38
+ # stores 32-bit components, so 0.1 comes back as 0.100000001.
39
+ const FLOAT_SHAPES: Array[int] = [
40
+ TYPE_VECTOR2,
41
+ TYPE_VECTOR3,
42
+ TYPE_VECTOR4,
43
+ TYPE_QUATERNION,
44
+ TYPE_COLOR,
45
+ TYPE_RECT2,
46
+ TYPE_TRANSFORM2D,
47
+ TYPE_BASIS,
48
+ TYPE_TRANSFORM3D,
49
+ TYPE_PLANE,
50
+ TYPE_AABB,
51
+ TYPE_PROJECTION,
52
+ ]
53
+
54
+ # Entries in a property list that only head a section of the inspector. Object.set ignores their
55
+ # names, so a write to "Visibility" or "Transform" changed nothing and was answered as made.
56
+ const HEADINGS: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
57
+
31
58
  var _values: Serialisation = Serialisation.new()
32
59
 
33
60
  # Why the value being parsed cannot be written, when the parse is what found out. A resource is
@@ -41,16 +68,23 @@ var _refused: String = ""
41
68
  ## ways: a dictionary that names its own type, a dictionary shaped like the type the property
42
69
  ## declares, or a bare array positional for a vector. Each is its own function; asking all three
43
70
  ## in one was twenty-four exits deep and impossible to follow.
44
- func parse(value: Variant, expected_type: int = TYPE_NIL) -> Variant:
71
+ ##
72
+ ## [param elements] is what a typed Array or Dictionary holds, as [method elements_of] reads it.
73
+ func parse(value: Variant, expected_type: int = TYPE_NIL, elements: Array = []) -> Variant:
45
74
  if value is Dictionary:
46
75
  var fields: Dictionary = value
76
+ # A dictionary with keys that are not text arrives as its entries, and is read against the
77
+ # declared element types like any other.
78
+ if str(fields.get("_type", "")) == "Dictionary" and Serialisation.pairs(fields.get("entries")):
79
+ var built: Dictionary = _values.deserialize_value(fields)
80
+ return _parse_entries(built, elements)
47
81
  var tagged: Array = _parse_tagged_dictionary(fields)
48
82
  if tagged[0]:
49
83
  return tagged[1]
50
- return _parse_shaped_dictionary(fields, expected_type)
84
+ return _parse_shaped_dictionary(fields, expected_type, elements)
51
85
  if value is Array:
52
86
  var items: Array = value
53
- return _parse_array(items, expected_type)
87
+ return _parse_array(items, expected_type, elements)
54
88
  # The same fitting the running game does, so "2.5" written to a float property here and there
55
89
  # mean the same thing. It leaves a value it cannot fit alone rather than casting it, which is
56
90
  # what lets the write be refused instead of landing as whatever the cast produced.
@@ -64,64 +98,262 @@ func parse(value: Variant, expected_type: int = TYPE_NIL) -> Variant:
64
98
  ## saved holding a value nobody asked for and the tool reports the change as made, which is worse
65
99
  ## than refusing and worse than failing, because there is nothing anywhere to read afterwards that
66
100
  ## says the value is not the one that was sent.
67
- func cannot_hold(holder: String, property: String, value: Variant, expected_type: int) -> String:
101
+ func cannot_hold(
102
+ holder: String, property: String, value: Variant, expected_type: int, elements: Array = []
103
+ ) -> String:
68
104
  var held: String = type_string(expected_type)
69
105
  var cost: String = " Setting it would save a different value than the one asked for."
70
106
  if not Serialisation.acceptable(value, expected_type):
71
107
  var shape: String = "%s.%s is %s and the value given is %s, which cannot become one."
72
108
  return shape % [holder, property, held, type_string(typeof(value))] + cost
73
109
 
74
- # And the same question of each element, because a list is accepted for a packed array whatever
75
- # is in it: a polygon written as [{"x": 1}] passes the check above and lands as one zero vector.
110
+ # And the same question of each element, because a list is accepted for a packed or typed array
111
+ # whatever is in it: a polygon written as [{"x": 1}] passes the check above and lands as one zero
112
+ # vector, and [1.5, "x"] for a PackedInt32Array as [1, 0].
76
113
  var element: int = Read.as_int(PACKED_ELEMENTS.get(expected_type, TYPE_NIL), TYPE_NIL)
77
- if element == TYPE_NIL or not value is Array:
78
- return ""
79
- var items: Array = value
80
- for index: int in items.size():
81
- if not Serialisation.acceptable(items[index], element):
82
- var shape: String = "%s.%s is %s and item %d of the list given is %s, not %s."
83
- var named: Array = [
84
- holder, property, held, index, type_string(typeof(items[index])), type_string(element)
85
- ]
86
- return shape % named + cost
114
+ if expected_type == TYPE_ARRAY and not elements.is_empty():
115
+ element = Read.as_int(elements[0], TYPE_NIL)
116
+ if element != TYPE_NIL and value is Array:
117
+ var items: Array = value
118
+ for index: int in items.size():
119
+ if not Serialisation.acceptable(items[index], element):
120
+ var shape: String = "%s.%s is %s and item %d of the list given is %s, not %s."
121
+ var named: Array = [
122
+ holder, property, held, index, type_string(typeof(items[index])), type_string(element)
123
+ ]
124
+ return shape % named + cost
125
+ if expected_type == TYPE_DICTIONARY and elements.size() == 2 and value is Dictionary:
126
+ var entries: Dictionary = value
127
+ var key_type: int = Read.as_int(elements[0], TYPE_NIL)
128
+ var value_type: int = Read.as_int(elements[1], TYPE_NIL)
129
+ for key: Variant in entries:
130
+ if not Serialisation.acceptable(key, key_type):
131
+ var shape: String = "%s.%s has %s keys, and %s is not one."
132
+ return shape % [holder, property, type_string(key_type), shown(key)] + cost
133
+ var entry: Variant = entries[key]
134
+ if not Serialisation.acceptable(entry, value_type):
135
+ var shape: String = "%s.%s holds %s values, and the one given for %s is %s."
136
+ var named: Array = [holder, property, type_string(value_type), shown(key), shown(entry)]
137
+ return shape % named + cost
87
138
  return ""
88
139
 
89
140
 
90
141
  ## Writes each of [param properties] onto [param target], stopping at the first it cannot write and
91
- ## answering with why, or "" when every one of them went on.
142
+ ## answering with why, or "" when every one of them went on and holds what was given.
92
143
  ##
93
144
  ## In the order they were given, because a property list is not fixed: a ShaderMaterial has no
94
145
  ## `shader_parameter/tint` until its `shader` is set, so a pass that checked them all before writing
95
146
  ## any would refuse the pair that works.
96
147
  ##
148
+ ## Every value is read back, because a setter can refuse or change what it is given and say so only
149
+ ## in the editor's log. Measured on 4.7.2: a Timer given a wait_time of 0 holds 1, a Sprite2D given
150
+ ## 0 hframes holds 1, a Sprite2D given a Material for its texture holds nothing. A value a setter
151
+ ## clamped against a property given later in the same call (a ProgressBar's value against its
152
+ ## max_value) is set once more after the rest, so the order a caller wrote them in does not decide
153
+ ## what is saved.
154
+ ##
97
155
  ## Nothing here saves, so a refusal partway leaves the change in memory and the file as it was. The
98
156
  ## caller returns the refusal instead of saving.
99
157
  func write_all(target: Object, properties: Dictionary) -> String:
158
+ var given: Dictionary = {}
100
159
  for key: Variant in properties:
101
160
  var property: String = str(key)
102
- if not has_property(target, property):
161
+ var declared: Dictionary = declaration(target, property)
162
+ if declared.is_empty():
103
163
  return "%s has no property %s" % [target.get_class(), property]
104
- var expected_type: int = property_type(target, property)
105
- var raw: Variant = properties[key]
106
-
107
- # A resource-valued property takes the path of one, which is how a caller names a TileSet,
108
- # a material or a theme: there is no other way to hand a tool a Resource.
109
- if expected_type == TYPE_OBJECT and typeof(raw) == TYPE_STRING:
110
- var found: Array = _resource_at(str(raw), property)
111
- if not found[0]:
112
- return str(found[1])
113
- target.set(property, found[1])
114
- continue
164
+ var value: Array = value_for(target, declared, properties[key])
165
+ if not value[0]:
166
+ return str(value[1])
167
+ target.set(property, value[1])
168
+ given[property] = value[1]
169
+
170
+ for property: String in given:
171
+ if not same(target.get(property), given[property]):
172
+ target.set(property, given[property])
173
+ for property: String in given:
174
+ var held: Variant = target.get(property)
175
+ if not same(held, given[property]):
176
+ return (
177
+ (
178
+ "%s.%s was given %s and holds %s after it was set: the engine changed or refused the "
179
+ + "value, so nothing was written."
180
+ )
181
+ % [target.get_class(), property, shown(given[property]), shown(held)]
182
+ )
183
+ return ""
115
184
 
185
+
186
+ ## [param raw] as the value [param declared] takes, as [ok, value] or [false, why not].
187
+ func value_for(target: Object, declared: Dictionary, raw: Variant) -> Array:
188
+ var property: String = str(declared.get("name", ""))
189
+ var expected_type: int = Read.as_int(declared.get("type", TYPE_NIL), TYPE_NIL)
190
+ var hint: int = Read.as_int(declared.get("hint", PROPERTY_HINT_NONE))
191
+ if expected_type == TYPE_OBJECT and hint == PROPERTY_HINT_NODE_TYPE:
192
+ return _node_reference(target, property, raw)
193
+
194
+ # A resource-valued property takes the path of one, which is how a caller names a TileSet,
195
+ # a material or a theme: there is no other way to hand a tool a Resource.
196
+ var value: Variant = null
197
+ if expected_type == TYPE_OBJECT and typeof(raw) == TYPE_STRING:
198
+ var found: Array = _resource_at(str(raw), property)
199
+ if not found[0]:
200
+ return found
201
+ value = found[1]
202
+ else:
203
+ var elements: Array = elements_of(declared)
204
+ if _fraction(raw, expected_type, elements):
205
+ return [false, _fraction_refusal(target, property, raw)]
116
206
  _refused = ""
117
- var value: Variant = parse(raw, expected_type)
207
+ value = parse(raw, expected_type, elements)
118
208
  if not _refused.is_empty():
119
- return _refused
120
- var refusal: String = cannot_hold(target.get_class(), property, value, expected_type)
209
+ return [false, _refused]
210
+ var refusal: String = cannot_hold(target.get_class(), property, value, expected_type, elements)
121
211
  if not refusal.is_empty():
122
- return refusal
123
- target.set(property, value)
124
- return ""
212
+ return [false, refusal]
213
+ if value is Object:
214
+ var held: Object = value
215
+ var wrong: String = _wrong_class(held, declared)
216
+ if not wrong.is_empty():
217
+ return [false, wrong]
218
+ return [true, value]
219
+
220
+
221
+ ## A property holding a Node is given the node the path leads to from [param target], as the
222
+ ## editor's inspector gives it; the scene is saved with the path. A res:// path could never name a
223
+ ## node, so the remedy a caller is given is a node path.
224
+ func _node_reference(target: Object, property: String, raw: Variant) -> Array:
225
+ if raw == null:
226
+ return [true, null]
227
+ var node: Node = target as Node
228
+ if node == null:
229
+ return [false, "%s holds a node, and only a node can hold one." % property]
230
+ var rebuilt: Variant = _values.deserialize_value(raw)
231
+ var path: NodePath = NodePath()
232
+ if rebuilt is NodePath:
233
+ path = rebuilt
234
+ elif typeof(raw) == TYPE_STRING and not str(raw).contains("://"):
235
+ path = NodePath(str(raw))
236
+ else:
237
+ return [
238
+ false,
239
+ (
240
+ (
241
+ "%s holds a node, and takes the path to it from the node being set, such as "
242
+ + '"../Player", not %s.'
243
+ )
244
+ % [property, shown(raw)]
245
+ )
246
+ ]
247
+ var found: Node = node.get_node_or_null(path)
248
+ if found == null:
249
+ return [
250
+ false, "%s names %s, and there is no node at that path from %s." % [property, path, node.name]
251
+ ]
252
+ return [true, found]
253
+
254
+
255
+ ## Why [param value] is not the class [param declared] holds, or "". Object.set does not refuse
256
+ ## an object of the wrong class: measured on 4.7.2, a Sprite2D given a Material for its texture
257
+ ## held nothing afterwards, and the scene was saved without the texture it had.
258
+ func _wrong_class(value: Object, declared: Dictionary) -> String:
259
+ var hint: int = Read.as_int(declared.get("hint", PROPERTY_HINT_NONE))
260
+ var wanted: String = str(declared.get("hint_string", ""))
261
+ if hint != PROPERTY_HINT_RESOURCE_TYPE or wanted.is_empty():
262
+ return ""
263
+ var names: PackedStringArray = wanted.split(",", false)
264
+ for name: String in names:
265
+ if value.is_class(name.strip_edges()):
266
+ return ""
267
+ var script: Script = value.get_script()
268
+ while script != null:
269
+ if str(script.get_global_name()) == name.strip_edges():
270
+ return ""
271
+ script = script.get_base_script()
272
+ return (
273
+ "%s holds %s, and the value given is a %s."
274
+ % [str(declared.get("name", "")), " or ".join(names), value.get_class()]
275
+ )
276
+
277
+
278
+ ## The element types a typed Array or Dictionary property declares: [element] for an array,
279
+ ## [key, value] for a dictionary, [] when nothing is declared.
280
+ ##
281
+ ## Read from the declaration because the value cannot say: a script's exported `Array[int]` sits
282
+ ## behind a placeholder in the editor, and measured on 4.7.2 that placeholder stores [1.5, 2.0]
283
+ ## as given, which the game then fails to load into the typed array.
284
+ static func elements_of(declared: Dictionary) -> Array:
285
+ var type: int = Read.as_int(declared.get("type", TYPE_NIL), TYPE_NIL)
286
+ var hint: int = Read.as_int(declared.get("hint", PROPERTY_HINT_NONE))
287
+ var wanted: String = str(declared.get("hint_string", ""))
288
+ if wanted.is_empty():
289
+ return []
290
+ if hint == PROPERTY_HINT_TYPE_STRING:
291
+ var parts: PackedStringArray = wanted.split(";")
292
+ if type == TYPE_ARRAY and parts.size() == 1:
293
+ return [_type_in_hint(parts[0])]
294
+ if type == TYPE_DICTIONARY and parts.size() == 2:
295
+ return [_type_in_hint(parts[0]), _type_in_hint(parts[1])]
296
+ return []
297
+ if hint == PROPERTY_HINT_ARRAY_TYPE and type == TYPE_ARRAY:
298
+ return [_type_named(wanted)]
299
+ if hint == PROPERTY_HINT_DICTIONARY_TYPE and type == TYPE_DICTIONARY:
300
+ var parts: PackedStringArray = wanted.split(";")
301
+ if parts.size() == 2:
302
+ return [_type_named(parts[0]), _type_named(parts[1])]
303
+ return []
304
+
305
+
306
+ ## The type at the front of one entry of a type-string hint, "2:" or "24/17:Texture2D".
307
+ static func _type_in_hint(entry: String) -> int:
308
+ var number: String = entry.get_slice(":", 0).get_slice("/", 0)
309
+ return number.to_int() if number.is_valid_int() else TYPE_NIL
310
+
311
+
312
+ ## The Variant type [param name] names, or TYPE_OBJECT for a class.
313
+ static func _type_named(name: String) -> int:
314
+ var named: String = name.get_slice(":", 0).strip_edges()
315
+ for type: int in TYPE_MAX:
316
+ if type_string(type) == named:
317
+ return type
318
+ return TYPE_OBJECT if not named.is_empty() else TYPE_NIL
319
+
320
+
321
+ ## Whether [param value] holds a number with a fractional part where an integer is wanted, in a
322
+ ## list or a dictionary as much as bare. The conversion would cut 2.7 to 2 without a word, and a
323
+ ## dictionary key arrives as the text "2.7", which reads as the same number.
324
+ func _fraction(value: Variant, type: int, elements: Array = []) -> bool:
325
+ if type == TYPE_INT:
326
+ var number: Variant = value
327
+ if typeof(value) == TYPE_STRING:
328
+ number = Read.json_or_null(str(value))
329
+ if typeof(number) != TYPE_FLOAT:
330
+ return false
331
+ var whole: float = number
332
+ return is_finite(whole) and whole != floorf(whole)
333
+ var element: int = Read.as_int(PACKED_ELEMENTS.get(type, TYPE_NIL), TYPE_NIL)
334
+ if type == TYPE_ARRAY and not elements.is_empty():
335
+ element = Read.as_int(elements[0], TYPE_NIL)
336
+ if value is Array and element != TYPE_NIL:
337
+ var items: Array = value
338
+ return items.any(func(item: Variant) -> bool: return _fraction(item, element))
339
+ if type == TYPE_DICTIONARY and elements.size() == 2 and value is Dictionary:
340
+ var entries: Dictionary = value
341
+ var key_type: int = Read.as_int(elements[0], TYPE_NIL)
342
+ var value_type: int = Read.as_int(elements[1], TYPE_NIL)
343
+ for key: Variant in entries:
344
+ if _fraction(key, key_type) or _fraction(entries[key], value_type):
345
+ return true
346
+ return false
347
+
348
+
349
+ func _fraction_refusal(target: Object, property: String, raw: Variant) -> String:
350
+ return (
351
+ (
352
+ "%s.%s holds whole numbers, and %s would be cut to fit. Setting it would save a different "
353
+ + "value than the one asked for."
354
+ )
355
+ % [target.get_class(), property, shown(raw)]
356
+ )
125
357
 
126
358
 
127
359
  ## The resource [param path] names, as [found, resource or why not].
@@ -135,39 +367,152 @@ func _resource_at(path: String, property: String) -> Array:
135
367
  return [false, "%s takes a res:// or uid:// path, not %s" % [property, path]]
136
368
  if path.split("/").has(".."):
137
369
  return [false, "%s leaves the project: %s" % [property, path]]
370
+ if path.contains("::"):
371
+ return [
372
+ false,
373
+ (
374
+ (
375
+ "%s is a resource built into %s, and only that file can refer to it. Build a new one "
376
+ + 'for %s with "_type" set to its class and its properties beside it.'
377
+ )
378
+ % [path, path.get_slice("::", 0), property]
379
+ )
380
+ ]
138
381
  if not ResourceLoader.exists(path):
139
382
  return [false, "No resource at %s for %s" % [path, property]]
140
383
  return [true, load(path)]
141
384
 
142
385
 
143
- ## Whether [param target] declares [param property] at all. Object.set ignores a name it does not
144
- ## know, so without this a typo saved a file that had not changed and reported it as one that had.
145
- func has_property(target: Object, property: String) -> bool:
386
+ ## The entry [param target] declares for [param property], or {} when it declares none. Headings
387
+ ## are left out: a category or group shares the list with the properties, and Object.set ignores
388
+ ## its name, so without this a typo or a heading saved a file that had not changed and reported it
389
+ ## as one that had.
390
+ func declaration(target: Object, property: String) -> Dictionary:
146
391
  for declared: Dictionary in target.get_property_list():
147
- if str(declared.get("name", "")) == property:
148
- return true
149
- return false
392
+ if str(declared.get("name", "")) != property:
393
+ continue
394
+ if Read.as_int(declared.get("usage", 0)) & HEADINGS:
395
+ continue
396
+ return declared
397
+ return {}
150
398
 
151
399
 
152
- ## The type [param property] is declared as, or TYPE_NIL where nothing declares one.
153
- func property_type(target: Object, property: String) -> int:
154
- for declared: Dictionary in target.get_property_list():
155
- if str(declared.get("name", "")) == property:
156
- return Read.as_int(declared.get("type", TYPE_NIL), TYPE_NIL)
157
- return TYPE_NIL
400
+ ## Whether [param held] is [param given], as a property read back after a set answers it.
401
+ ##
402
+ ## Close enough rather than equal for anything made of floats: a Vector2 property stores 32-bit
403
+ ## components, so 0.1 comes back as 0.100000001. A packed array read back against the list it was
404
+ ## given compares element by element, since `==` between the two is an error rather than false.
405
+ static func same(held: Variant, given: Variant) -> bool:
406
+ var held_type: int = typeof(held)
407
+ var given_type: int = typeof(given)
408
+ # An empty object property reads back as an Object that is null, not as nil, and the two are
409
+ # the same nothing.
410
+ var held_none: bool = held_type == TYPE_NIL or (held_type == TYPE_OBJECT and held == null)
411
+ var given_none: bool = given_type == TYPE_NIL or (given_type == TYPE_OBJECT and given == null)
412
+ if held_none or given_none:
413
+ return held_none == given_none
414
+ if held_type == TYPE_OBJECT or given_type == TYPE_OBJECT:
415
+ return _same_object(held, given)
416
+ if Serialisation.NUMBERS.has(held_type) and Serialisation.NUMBERS.has(given_type):
417
+ if held_type == TYPE_BOOL or given_type == TYPE_BOOL:
418
+ return held_type == given_type and held == given
419
+ var held_number: float = Read.as_float(held)
420
+ var given_number: float = Read.as_float(given)
421
+ return (is_nan(held_number) and is_nan(given_number)) or is_equal_approx(held_number, given_number)
422
+ if Serialisation.TEXTS.has(held_type) and Serialisation.TEXTS.has(given_type):
423
+ return str(held) == str(given)
424
+ if _listed(held_type) and _listed(given_type):
425
+ var held_items: Array = type_convert(held, TYPE_ARRAY)
426
+ var given_items: Array = type_convert(given, TYPE_ARRAY)
427
+ if held_items.size() != given_items.size():
428
+ return false
429
+ for index: int in held_items.size():
430
+ if not same(held_items[index], given_items[index]):
431
+ return false
432
+ return true
433
+ if held_type == TYPE_DICTIONARY and given_type == TYPE_DICTIONARY:
434
+ var held_entries: Dictionary = held
435
+ var given_entries: Dictionary = given
436
+ return _same_entries(held_entries, given_entries)
437
+ if held_type != given_type:
438
+ return false
439
+ return _same_shape(held, given)
440
+
441
+
442
+ static func _same_object(held: Variant, given: Variant) -> bool:
443
+ if typeof(held) != TYPE_OBJECT or typeof(given) != TYPE_OBJECT:
444
+ return false
445
+ if is_same(held, given):
446
+ return true
447
+ if not held is Resource or not given is Resource:
448
+ return false
449
+ var held_resource: Resource = held
450
+ var given_resource: Resource = given
451
+ if not held_resource.resource_path.is_empty():
452
+ return held_resource.resource_path == given_resource.resource_path
453
+ # A resource local to the scene is duplicated for every instance, so the copy read back is
454
+ # another object of the same class.
455
+ return held_resource.resource_local_to_scene and held_resource.get_class() == given_resource.get_class()
456
+
457
+
458
+ static func _same_entries(held: Dictionary, given: Dictionary) -> bool:
459
+ if held.size() != given.size():
460
+ return false
461
+ for key: Variant in given:
462
+ var found: bool = false
463
+ for held_key: Variant in held:
464
+ if same(held_key, key):
465
+ found = same(held[held_key], given[key])
466
+ break
467
+ if not found:
468
+ return false
469
+ return true
470
+
471
+
472
+ ## Two values of one type made of floats, compared component by component through the type's own
473
+ ## text form, which lists every component: each type has its own is_equal_approx, and reaching it
474
+ ## through a Variant is a call the typed gate refuses.
475
+ static func _same_shape(held: Variant, given: Variant) -> bool:
476
+ if typeof(held) not in FLOAT_SHAPES:
477
+ return held == given
478
+ var held_parts: Array[float] = _components(held)
479
+ var given_parts: Array[float] = _components(given)
480
+ if held_parts.size() != given_parts.size():
481
+ return false
482
+ for index: int in held_parts.size():
483
+ if not same(held_parts[index], given_parts[index]):
484
+ return false
485
+ return true
486
+
487
+
488
+ static func _components(value: Variant) -> Array[float]:
489
+ var parts: Array[float] = []
490
+ var text: String = var_to_str(value)
491
+ var inner: String = text.substr(text.find("(") + 1).trim_suffix(")")
492
+ for part: String in inner.split(",", false):
493
+ parts.append(part.strip_edges().to_float())
494
+ return parts
495
+
496
+
497
+ static func _listed(type: int) -> bool:
498
+ return type == TYPE_ARRAY or PACKED_ELEMENTS.has(type)
499
+
500
+
501
+ ## [param value] as a caller would write it, for a refusal to quote.
502
+ func shown(value: Variant) -> String:
503
+ return JSON.stringify(_values.serialize_value(value))
158
504
 
159
505
 
160
506
  ## A dictionary carrying its own type name, as the serialiser writes it.
161
507
  ##
162
508
  ## Answers [handled, value] rather than just the value, because a handled tag may legitimately
163
- ## produce null: a Resource with no path is "this is nothing", not "this is not mine". A tag
164
- ## that names a type but lacks the keys to build it is left unhandled on purpose, so the caller
165
- ## can still read it against the type the property declares.
509
+ ## produce null: a Resource tag with neither a path nor a class is "this is nothing", not "this is
510
+ ## not mine". A tag that names a type but lacks the keys to build it is left unhandled on purpose,
511
+ ## so the caller can still read it against the type the property declares.
166
512
  func _parse_tagged_dictionary(value: Dictionary) -> Array:
167
513
  var type_tag: String = str(value.get("_type", value.get("type", "")))
168
514
  if type_tag == "Resource":
169
- var resource_path: String = str(value.get("path", ""))
170
- return [true, null if resource_path.is_empty() else load(resource_path)]
515
+ return _parse_resource_tag(value)
171
516
 
172
517
  # The shared builder is the same one the answer was written by, so every shape it writes comes
173
518
  # back as itself. `type` as the tag is read as `_type`: it is what these tools answered with
@@ -183,6 +528,32 @@ func _parse_tagged_dictionary(value: Dictionary) -> Array:
183
528
  return _parse_new_resource(type_tag, value)
184
529
 
185
530
 
531
+ ## A resource named by its path, which goes through the same checks as a path given bare: the
532
+ ## project boundary, and a file being there. A tag skipping them loaded an absolute path into the
533
+ ## scene, and cleared the property when nothing was at the path.
534
+ func _parse_resource_tag(value: Dictionary) -> Array:
535
+ var resource_path: String = str(value.get("path", ""))
536
+ if not resource_path.is_empty():
537
+ var found: Array = _resource_at(resource_path, "the value")
538
+ if not found[0]:
539
+ _refused = str(found[1])
540
+ return [true, null]
541
+ return [true, found[1]]
542
+ # What a read answers for a resource built into the scene it was read from: a class and no
543
+ # path. Taken as nothing, it cleared the property it was written back to.
544
+ if value.has("class"):
545
+ _refused = (
546
+ (
547
+ "The %s given has no path: it was read from a resource built into a scene, which cannot "
548
+ + 'be referred to from elsewhere. Build a new one with "_type" set to its class and its '
549
+ + "properties beside it."
550
+ )
551
+ % str(value["class"])
552
+ )
553
+ return [true, null]
554
+ return [true, null]
555
+
556
+
186
557
  ## A tag naming a Resource class builds a fresh one, its other keys set as properties, so a
187
558
  ## NavigationRegion2D can arrive with its NavigationPolygon and an AnimationTree with its root
188
559
  ## state machine in the same add as any other property.
@@ -208,9 +579,11 @@ func _parse_new_resource(type_tag: String, value: Dictionary) -> Array:
208
579
  return [true, built]
209
580
 
210
581
 
211
- ## A dictionary with no tag, read against the type the property declares. Falls back to the
212
- ## dictionary itself, since a property may genuinely want one.
213
- func _parse_shaped_dictionary(value: Dictionary, expected_type: int) -> Variant:
582
+ ## A dictionary with no tag, read against the type the property declares. Anything else is a
583
+ ## dictionary the property genuinely wants, and its values are read as values in their own right:
584
+ ## kept as the tags they arrived as, a Vector2 read off one node and written to another was saved as
585
+ ## a dictionary holding the word "Vector2".
586
+ func _parse_shaped_dictionary(value: Dictionary, expected_type: int, elements: Array) -> Variant:
214
587
  match expected_type:
215
588
  TYPE_VECTOR2:
216
589
  if value.has("x") and value.has("y"):
@@ -253,30 +626,66 @@ func _parse_shaped_dictionary(value: Dictionary, expected_type: int) -> Variant:
253
626
  TYPE_NODE_PATH:
254
627
  if value.has("path"):
255
628
  return NodePath(str(value["path"]))
256
- return value
629
+ if expected_type != TYPE_NIL and expected_type != TYPE_DICTIONARY:
630
+ return value
631
+
632
+ return _parse_entries(value, elements)
633
+
634
+
635
+ ## The entries of a dictionary a property holds, each value read as a value in its own right, and
636
+ ## typed as the property declares when every entry fits.
637
+ func _parse_entries(value: Dictionary, elements: Array) -> Dictionary:
638
+ var key_type: int = TYPE_NIL
639
+ var value_type: int = TYPE_NIL
640
+ if elements.size() == 2:
641
+ key_type = Read.as_int(elements[0], TYPE_NIL)
642
+ value_type = Read.as_int(elements[1], TYPE_NIL)
643
+ var entries: Dictionary = {}
644
+ for key: Variant in value:
645
+ entries[_values.fitted(key, key_type)] = parse(value[key], value_type)
646
+ return _typed_dictionary(entries, key_type, value_type)
647
+
648
+
649
+ ## [param entries] as the typed dictionary a property declares, so the file holds the type the
650
+ ## game's script will load it into. Left untyped when an entry does not fit, for the check after
651
+ ## the parse to refuse by name.
652
+ func _typed_dictionary(entries: Dictionary, key_type: int, value_type: int) -> Dictionary:
653
+ if key_type == TYPE_NIL and value_type == TYPE_NIL:
654
+ return entries
655
+ if key_type == TYPE_OBJECT or value_type == TYPE_OBJECT:
656
+ return entries
657
+ for key: Variant in entries:
658
+ if key_type != TYPE_NIL and typeof(key) != key_type:
659
+ return entries
660
+ if not Serialisation.acceptable(entries[key], value_type):
661
+ return entries
662
+ var fitted: Dictionary = {}
663
+ for key: Variant in entries:
664
+ fitted[key] = type_convert(entries[key], value_type) if value_type != TYPE_NIL else entries[key]
665
+ return Dictionary(fitted, key_type, &"", null, value_type, &"", null)
257
666
 
258
667
 
259
668
  ## An array, either positional for a vector the property declares, or a list to parse per item.
260
- func _parse_array(value: Array, expected_type: int) -> Variant:
669
+ func _parse_array(value: Array, expected_type: int, elements: Array) -> Variant:
261
670
  if PACKED_ELEMENTS.has(expected_type):
262
671
  var element: int = Read.as_int(PACKED_ELEMENTS[expected_type], TYPE_NIL)
263
672
  return value.map(func(item: Variant) -> Variant: return parse(item, element))
264
673
 
265
674
  match expected_type:
266
675
  TYPE_VECTOR2:
267
- if value.size() >= 2:
676
+ if value.size() == 2:
268
677
  return Vector2(Read.as_float(value[0]), Read.as_float(value[1]))
269
678
  TYPE_VECTOR2I:
270
- if value.size() >= 2:
679
+ if value.size() == 2:
271
680
  return Vector2i(Read.as_int(value[0]), Read.as_int(value[1]))
272
681
  TYPE_VECTOR3:
273
- if value.size() >= 3:
682
+ if value.size() == 3:
274
683
  return Vector3(Read.as_float(value[0]), Read.as_float(value[1]), Read.as_float(value[2]))
275
684
  TYPE_VECTOR3I:
276
- if value.size() >= 3:
685
+ if value.size() == 3:
277
686
  return Vector3i(Read.as_int(value[0]), Read.as_int(value[1]), Read.as_int(value[2]))
278
687
  TYPE_VECTOR4:
279
- if value.size() >= 4:
688
+ if value.size() == 4:
280
689
  return Vector4(
281
690
  Read.as_float(value[0]),
282
691
  Read.as_float(value[1]),
@@ -284,11 +693,21 @@ func _parse_array(value: Array, expected_type: int) -> Variant:
284
693
  Read.as_float(value[3])
285
694
  )
286
695
  TYPE_COLOR:
287
- if value.size() >= 3:
696
+ if value.size() == 3 or value.size() == 4:
288
697
  return Color(
289
698
  Read.as_float(value[0]),
290
699
  Read.as_float(value[1]),
291
700
  Read.as_float(value[2]),
292
- Read.as_float(value[3]) if value.size() >= 4 else 1.0
701
+ Read.as_float(value[3]) if value.size() == 4 else 1.0
293
702
  )
294
- return value.map(func(item: Variant) -> Variant: return parse(item))
703
+
704
+ var element_type: int = TYPE_NIL
705
+ if expected_type == TYPE_ARRAY and not elements.is_empty():
706
+ element_type = Read.as_int(elements[0], TYPE_NIL)
707
+ var items: Array = value.map(func(item: Variant) -> Variant: return parse(item, element_type))
708
+ if element_type == TYPE_NIL or element_type == TYPE_OBJECT:
709
+ return items
710
+ if not items.all(func(item: Variant) -> bool: return Serialisation.acceptable(item, element_type)):
711
+ return items
712
+ var fitted: Array = items.map(func(item: Variant) -> Variant: return type_convert(item, element_type))
713
+ return Array(fitted, element_type, &"", null)