gdharness 1.3.9 → 1.3.11

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 (73) hide show
  1. package/build/cli.js +4740 -3212
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +115 -4
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +272 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +102 -18
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +49 -2
  6. package/build/godot/addons/gdharness_editor/property_paths.gd +89 -16
  7. package/build/godot/addons/gdharness_editor/property_values.gd +180 -121
  8. package/build/godot/addons/gdharness_editor/reading.gd +51 -1
  9. package/build/godot/addons/gdharness_editor/scene_file.gd +77 -140
  10. package/build/godot/addons/gdharness_editor/scene_uids.gd +107 -0
  11. package/build/godot/addons/gdharness_editor/scene_uids.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/serialisation.gd +11 -22
  13. package/build/godot/addons/gdharness_editor/setting_reads.gd +121 -0
  14. package/build/godot/addons/gdharness_editor/setting_reads.gd.uid +1 -0
  15. package/build/godot/addons/gdharness_editor/tool_executor.gd +28 -5
  16. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +39 -20
  17. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +49 -362
  18. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +464 -0
  19. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd.uid +1 -0
  20. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +37 -0
  21. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +221 -0
  22. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +147 -492
  24. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +16 -0
  25. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +4 -3
  26. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +2 -1
  27. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +79 -5
  28. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +7 -30
  29. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +183 -37
  30. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd +485 -0
  31. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd.uid +1 -0
  32. package/build/godot/addons/gdharness_editor/value_shapes.gd +128 -1
  33. package/build/godot/addons/gdharness_runtime/declared_values.gd +272 -0
  34. package/build/godot/addons/gdharness_runtime/reading.gd +51 -1
  35. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +35 -3
  36. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +185 -30
  37. package/build/godot/addons/gdharness_runtime/runtime_input.gd +86 -79
  38. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +17 -0
  39. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +13 -4
  40. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +309 -22
  41. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +163 -20
  42. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +49 -3
  43. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +5 -2
  44. package/build/godot/addons/gdharness_runtime/runtime_values.gd +146 -5
  45. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +279 -11
  46. package/build/godot/addons/gdharness_runtime/runtime_words.gd +27 -0
  47. package/build/godot/addons/gdharness_runtime/serialisation.gd +11 -22
  48. package/build/godot/addons/gdharness_runtime/value_shapes.gd +128 -1
  49. package/build/godot/operations/animation_reads.gd +115 -4
  50. package/build/godot/operations/audio_buses.gd +93 -7
  51. package/build/godot/operations/classdb_queries.gd +119 -7
  52. package/build/godot/operations/declared_values.gd +272 -0
  53. package/build/godot/operations/dependencies.gd +154 -41
  54. package/build/godot/operations/editor_tools.gd +22 -14
  55. package/build/godot/operations/export_presets.gd +602 -4
  56. package/build/godot/operations/file_reads.gd +102 -18
  57. package/build/godot/operations/gdscript_authoring.gd +11 -1
  58. package/build/godot/operations/godot_operations.gd +16 -2
  59. package/build/godot/operations/import_pipeline.gd +20 -221
  60. package/build/godot/operations/input_actions.gd +108 -47
  61. package/build/godot/operations/project_config.gd +83 -69
  62. package/build/godot/operations/project_diagnostics.gd +4 -0
  63. package/build/godot/operations/project_validation.gd +266 -0
  64. package/build/godot/operations/property_paths.gd +89 -16
  65. package/build/godot/operations/property_values.gd +180 -121
  66. package/build/godot/operations/reading.gd +51 -1
  67. package/build/godot/operations/scene_connections.gd +222 -0
  68. package/build/godot/operations/serialisation.gd +11 -22
  69. package/build/godot/operations/setting_reads.gd +121 -0
  70. package/build/godot/operations/value_shapes.gd +128 -1
  71. package/build/godot/tests/listed.gd +73 -13
  72. package/build/keeper.js +1371 -706
  73. package/package.json +1 -1
@@ -5,6 +5,7 @@ extends RefCounted
5
5
  ## stores, which runs no script of the scene's, so a read answers the same either way.
6
6
 
7
7
  const GdhFileReads = preload("file_reads.gd")
8
+ const GdhRead = preload("reading.gd")
8
9
  const GdhSerialisation = preload("serialisation.gd")
9
10
 
10
11
  const LOOP_MODE_NAMES: Dictionary[int, String] = {
@@ -28,6 +29,11 @@ const SWITCH_MODE_NAMES: Dictionary[int, String] = {
28
29
  AnimationNodeStateMachineTransition.SWITCH_MODE_SYNC: "sync",
29
30
  AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END: "at_end",
30
31
  }
32
+ const ADVANCE_MODE_NAMES: Dictionary[int, String] = {
33
+ AnimationNodeStateMachineTransition.ADVANCE_MODE_DISABLED: "disabled",
34
+ AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED: "enabled",
35
+ AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO: "auto",
36
+ }
31
37
 
32
38
 
33
39
  ## [method animations] of the AnimationPlayer at [param player_path] in the file [param scene_path].
@@ -47,8 +53,21 @@ static func scene_states(scene_path: String, tree_path: String, state_machine_pa
47
53
  if made[0] == null:
48
54
  return made[1]
49
55
  var tree: AnimationTree = made[0]
50
- var answer: Dictionary = states(tree, state_machine_path)
56
+ # The player the tree plays from, read from the file the same way, since a bare tree has no
57
+ # siblings to find it among.
58
+ # A path above the root names no node the file has, and finds none, as the editor finds none.
59
+ var player: AnimationPlayer = null
60
+ var player_path: String = ""
61
+ if not str(tree.anim_player).is_empty():
62
+ player_path = (tree_path + "/" + str(tree.anim_player)).simplify_path()
63
+ if player_path.is_empty():
64
+ player_path = "."
65
+ var found: Array = _bare(scene_path, player_path, "AnimationPlayer")
66
+ player = found[0]
67
+ var answer: Dictionary = states(tree, state_machine_path, player, player_path)
51
68
  tree.free()
69
+ if player != null:
70
+ player.free()
52
71
  return answer
53
72
 
54
73
 
@@ -200,8 +219,28 @@ static func no_machine(tree: AnimationTree, state_machine_path: String) -> Strin
200
219
  return "No state machine at " + state_machine_path
201
220
 
202
221
 
222
+ ## Why a state playing [param animation_name] from [param player], at [param player_path] from the
223
+ ## scene root as playerNodePath takes it, plays nothing yet. anim_player is relative to the tree
224
+ ## ("../Anim"), which no argument takes.
225
+ static func missing_animation(player: AnimationPlayer, player_path: String, animation_name: String) -> String:
226
+ var said: String = (
227
+ "The AnimationPlayer at %s has no animation %s yet, so the state plays nothing until it has one."
228
+ % [player_path, animation_name]
229
+ )
230
+ var close: PackedStringArray = GdhRead.nearest(animation_name, player.get_animation_list())
231
+ if close.is_empty():
232
+ return said
233
+ return (
234
+ said + " The nearest %s %s." % ["it has is" if close.size() == 1 else "it has are", ", ".join(close)]
235
+ )
236
+
237
+
203
238
  ## The states and transitions of the state machine at [param state_machine_path] in [param tree].
204
- static func states(tree: AnimationTree, state_machine_path: String) -> Dictionary:
239
+ ## A state playing an animation says whether [param player], at [param player_path], has it, as
240
+ ## add_state answers: a state naming one it lacks plays nothing, and says so only in a log.
241
+ static func states(
242
+ tree: AnimationTree, state_machine_path: String, player: AnimationPlayer, player_path: String
243
+ ) -> Dictionary:
205
244
  var sm: AnimationNodeStateMachine = state_machine(tree, state_machine_path)
206
245
  if sm == null:
207
246
  return {"ok": false, "error": no_machine(tree, state_machine_path)}
@@ -212,6 +251,10 @@ static func states(tree: AnimationTree, state_machine_path: String) -> Dictionar
212
251
  if node is AnimationNodeAnimation:
213
252
  var plays: AnimationNodeAnimation = node
214
253
  entry["animationName"] = str(plays.animation)
254
+ var found: bool = player != null and player.has_animation(plays.animation)
255
+ entry["animationFound"] = found
256
+ if player != null and not found:
257
+ entry["animationMissing"] = missing_animation(player, player_path, str(plays.animation))
215
258
  listed.append(entry)
216
259
  var transitions: Array[Dictionary] = []
217
260
  for index: int in sm.get_transition_count():
@@ -220,8 +263,76 @@ static func states(tree: AnimationTree, state_machine_path: String) -> Dictionar
220
263
  "from": str(sm.get_transition_from(index)),
221
264
  "to": str(sm.get_transition_to(index)),
222
265
  "transitionType": SWITCH_MODE_NAMES.get(transition.switch_mode, str(transition.switch_mode)),
266
+ "advanceMode": ADVANCE_MODE_NAMES.get(transition.advance_mode, str(transition.advance_mode)),
223
267
  }
224
- if not str(transition.advance_condition).is_empty():
225
- shaped["advanceCondition"] = str(transition.advance_condition)
268
+ var condition: String = str(transition.advance_condition)
269
+ if not condition.is_empty():
270
+ shaped["advanceCondition"] = condition
271
+ # A condition is checked only on an auto transition, and the default is enabled: a machine
272
+ # with its conditions set true stayed where it was, and nothing said why.
273
+ if not condition.is_empty() and transition.advance_mode != transition.ADVANCE_MODE_AUTO:
274
+ shaped["conditionNote"] = (
275
+ (
276
+ "advanceMode is %s, so %s never advances it: the engine checks a condition only on an"
277
+ + " auto transition%s. disconnect_states and connect_states again with the condition"
278
+ + " write it as auto"
279
+ )
280
+ % [
281
+ shaped["advanceMode"],
282
+ condition,
283
+ (
284
+ ", and travel() still takes this one"
285
+ if transition.advance_mode == transition.ADVANCE_MODE_ENABLED
286
+ else ""
287
+ )
288
+ ]
289
+ )
226
290
  transitions.append(shaped)
227
291
  return {"ok": true, "states": listed, "transitions": transitions}
292
+
293
+
294
+ ## The states of the AnimationTrees under [param root] that play [param animation_name] from
295
+ ## [param player], as [[node, {animTreePath, state}]], a state named by the path of the nodes leading
296
+ ## to it.
297
+ static func states_playing(root: Node, player: AnimationPlayer, animation_name: String) -> Array:
298
+ var found: Array = []
299
+ var trees: Array[Node] = root.find_children("*", "AnimationTree", true, false)
300
+ if root is AnimationTree:
301
+ trees.append(root)
302
+ for node: Node in trees:
303
+ var tree: AnimationTree = node
304
+ if tree.tree_root == null or tree.get_node_or_null(tree.anim_player) != player:
305
+ continue
306
+ var tree_path: String = str(root.get_path_to(tree))
307
+ _collect_playing(tree.tree_root, "", tree_path, animation_name, found)
308
+ return found
309
+
310
+
311
+ static func _collect_playing(
312
+ node: AnimationNode, at: String, tree_path: String, animation_name: String, found: Array
313
+ ) -> void:
314
+ if node is AnimationNodeAnimation:
315
+ var plays: AnimationNodeAnimation = node
316
+ if plays.animation == StringName(animation_name):
317
+ found.append([plays, {"animTreePath": tree_path, "state": "." if at.is_empty() else at}])
318
+ return
319
+ var children: Dictionary = {}
320
+ if node is AnimationNodeStateMachine:
321
+ var machine: AnimationNodeStateMachine = node
322
+ for state: StringName in machine.get_node_list():
323
+ children[str(state)] = machine.get_node(state)
324
+ elif node is AnimationNodeBlendTree:
325
+ var blend: AnimationNodeBlendTree = node
326
+ for input: StringName in blend.get_node_list():
327
+ children[str(input)] = blend.get_node(input)
328
+ elif node is AnimationNodeBlendSpace1D:
329
+ var line: AnimationNodeBlendSpace1D = node
330
+ for point: int in line.get_blend_point_count():
331
+ children[str(point)] = line.get_blend_point_node(point)
332
+ elif node is AnimationNodeBlendSpace2D:
333
+ var plane: AnimationNodeBlendSpace2D = node
334
+ for point: int in plane.get_blend_point_count():
335
+ children[str(point)] = plane.get_blend_point_node(point)
336
+ for step: String in children:
337
+ var child: AnimationNode = children[step]
338
+ _collect_playing(child, step if at.is_empty() else at + "/" + step, tree_path, animation_name, found)
@@ -6,6 +6,36 @@ extends RefCounted
6
6
 
7
7
  const GdhRead = preload("reading.gd")
8
8
 
9
+ ## The section of layer_names in project.godot naming the layers of each layers hint.
10
+ const LAYER_NAMES: Dictionary[int, String] = {
11
+ PROPERTY_HINT_LAYERS_2D_PHYSICS: "2d_physics",
12
+ PROPERTY_HINT_LAYERS_2D_RENDER: "2d_render",
13
+ PROPERTY_HINT_LAYERS_2D_NAVIGATION: "2d_navigation",
14
+ PROPERTY_HINT_LAYERS_3D_PHYSICS: "3d_physics",
15
+ PROPERTY_HINT_LAYERS_3D_RENDER: "3d_render",
16
+ PROPERTY_HINT_LAYERS_3D_NAVIGATION: "3d_navigation",
17
+ PROPERTY_HINT_LAYERS_AVOIDANCE: "avoidance",
18
+ }
19
+ ## How many layers a mask holds, one bit each, whatever the editor names.
20
+ const MASK_LAYERS: int = 32
21
+
22
+
23
+ ## The layers [param mask] holds under a layers [param hint], each with the name project.godot
24
+ ## gives it where it gives one.
25
+ static func layers_in(hint: int, mask: int) -> Array[Dictionary]:
26
+ var layers: Array[Dictionary] = []
27
+ for layer: int in range(1, MASK_LAYERS + 1):
28
+ if mask & (1 << (layer - 1)) == 0:
29
+ continue
30
+ var entry: Dictionary = {"layer": layer}
31
+ var name: String = str(
32
+ ProjectSettings.get_setting("layer_names/%s/layer_%d" % [LAYER_NAMES[hint], layer], "")
33
+ )
34
+ if not name.is_empty():
35
+ entry["name"] = name
36
+ layers.append(entry)
37
+ return layers
38
+
9
39
 
10
40
  ## The values an enum hint names, by name, from its hint string: "SWORD:0,SHIELD:1" as a script
11
41
  ## declares one, or "Add,Subtract" as an engine property does, each name then taking the number
@@ -127,6 +157,77 @@ static func enum_refusal(
127
157
  return said + ". The nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
128
158
 
129
159
 
160
+ ## Why [param word], given as argument [param index] of [param called] for the enum [param named],
161
+ ## names none of its [param choices].
162
+ static func enum_argument_refusal(
163
+ called: String, index: int, named: String, word: String, choices: Dictionary
164
+ ) -> String:
165
+ var said: String = (
166
+ "%s takes the enum %s as argument %d, and %s is none of its values: send %s by name or by number"
167
+ % [called, named, index + 1, word, _listed(choices)]
168
+ )
169
+ var close: PackedStringArray = GdhRead.nearest(word, PackedStringArray(choices.keys()))
170
+ if close.is_empty():
171
+ return said + "."
172
+ return said + ". The nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
173
+
174
+
175
+ ## Why [param value], given as argument [param index] for the enum [param named], is none of its
176
+ ## [param choices], or "" when it is one. The engine passes any number on.
177
+ static func outside_enum_argument(index: int, named: String, value: Variant, choices: Dictionary) -> String:
178
+ if typeof(value) != TYPE_INT or choices.values().has(value):
179
+ return ""
180
+ return (
181
+ "argument %d, %d, is none of the values %s declares: %s."
182
+ % [index + 1, value, named, _listed(choices)]
183
+ )
184
+
185
+
186
+ ## The values of the enum the engine names [param qualified], by name: an engine class's, as
187
+ ## Node.ProcessMode, or a script's, as Named.Size for a class_name, res://main.gd.Mood for a script
188
+ ## without one, and either followed by inner classes. {} when it names nothing this can read.
189
+ static func enum_declared_as(qualified: String) -> Dictionary:
190
+ var cut: int = qualified.rfind(".")
191
+ if cut <= 0:
192
+ return {}
193
+ var owner: String = qualified.substr(0, cut)
194
+ var named: String = qualified.substr(cut + 1)
195
+ if ClassDB.class_exists(owner):
196
+ var constants: Dictionary = {}
197
+ for constant: String in ClassDB.class_get_enum_constants(owner, named):
198
+ constants[constant] = ClassDB.class_get_integer_constant(owner, constant)
199
+ return constants
200
+ var script: Script = _script_named_as(owner)
201
+ if script == null:
202
+ return {}
203
+ var found: Variant = script.get_script_constant_map().get(named)
204
+ if not found is Dictionary:
205
+ return {}
206
+ var values: Dictionary = found
207
+ return values
208
+
209
+
210
+ ## The script [param owner] names: a class_name, a res:// path, or an inner class of either after a
211
+ ## dot, or null.
212
+ static func _script_named_as(owner: String) -> Script:
213
+ var by_name: Script = script_class(owner)
214
+ if by_name != null:
215
+ return by_name
216
+ if owner.begins_with("res://") and ResourceLoader.exists(owner):
217
+ return load(owner) as Script
218
+ var cut: int = owner.rfind(".")
219
+ if cut <= 0:
220
+ return null
221
+ var outer: Script = _script_named_as(owner.substr(0, cut))
222
+ if outer == null:
223
+ return null
224
+ var inner: Variant = outer.get_script_constant_map().get(owner.substr(cut + 1))
225
+ if not inner is Script:
226
+ return null
227
+ var inner_script: Script = inner
228
+ return inner_script
229
+
230
+
130
231
  ## Why [param value], shown as [param shown], lies outside what [param declared] allows, an export
131
232
  ## range or an enum's values, or "" when it does not. A range marked or_greater or or_less is open
132
233
  ## on that side.
@@ -153,6 +254,19 @@ static func outside(target: Object, declared: Dictionary, value: Variant, shown:
153
254
  if choices.is_empty() or choices.values().has(value):
154
255
  return ""
155
256
  return "%d is none of the values %s declares: %s." % [value, property, _listed(choices)]
257
+ if hint == PROPERTY_HINT_FLAGS and typeof(value) == TYPE_INT:
258
+ var flags: Dictionary = flags_of(hint_string)
259
+ var known: int = 0
260
+ for bit: int in flags.values():
261
+ known |= bit
262
+ var whole: int = value
263
+ var stray: int = whole & ~known
264
+ if flags.is_empty() or stray == 0:
265
+ return ""
266
+ return (
267
+ "%d holds bits no flag of %s has (%d): its flags are %s; the engine stores it as given."
268
+ % [whole, property, stray, _listed(flags)]
269
+ )
156
270
  if hint != PROPERTY_HINT_RANGE:
157
271
  return ""
158
272
  var parts: PackedStringArray = []
@@ -173,6 +287,20 @@ static func outside(target: Object, declared: Dictionary, value: Variant, shown:
173
287
  )
174
288
 
175
289
 
290
+ ## The flags a flags hint names, each with its bit: "Fill:1,Expand:2" gives its values, and a name
291
+ ## with none takes the bit of its place, as @export_flags("A", "B") does.
292
+ static func flags_of(hint_string: String) -> Dictionary:
293
+ var flags: Dictionary = {}
294
+ var parts: PackedStringArray = hint_string.split(",", false)
295
+ for index: int in parts.size():
296
+ var part: String = parts[index].strip_edges()
297
+ if ":" in part:
298
+ flags[part.get_slice(":", 0)] = part.get_slice(":", 1).to_int()
299
+ else:
300
+ flags[part] = 1 << index
301
+ return flags
302
+
303
+
176
304
  static func _listed(choices: Dictionary) -> String:
177
305
  var listed: PackedStringArray = []
178
306
  for name: String in choices:
@@ -291,3 +419,147 @@ static func is_a(value: Object, name: String) -> bool:
291
419
  return true
292
420
  script = script.get_base_script()
293
421
  return false
422
+
423
+
424
+ ## Whether writing [param name] on [param target] runs code of its script: the script itself, a
425
+ ## property it declares, or any property once it takes sets through [code]_set[/code].
426
+ static func runs_script_code(target: Object, name: String) -> bool:
427
+ var script: Script = target.get_script()
428
+ if name == "script":
429
+ return true
430
+ if script == null:
431
+ return false
432
+ for method: Dictionary in script.get_script_method_list():
433
+ if str(method.get("name", "")) == "_set":
434
+ return true
435
+ for declared: Dictionary in script.get_script_property_list():
436
+ if str(declared.get("name", "")) == name:
437
+ return true
438
+ return false
439
+
440
+
441
+ ## Why [param property] of a [param holder], an [param engine_class], declared as [param declared],
442
+ ## holds [param held], as shown, after it was given [param given], the value and how it is shown. A
443
+ ## text property with a list of names holds only one of them, and its getter answers its default
444
+ ## for any other. A player's bus is one, though the engine fills its list only in the inspector, so
445
+ ## the buses are read from the AudioServer, which holds the project's layout; "the engine changed or
446
+ ## refused the value" left a caller to guess the bus did not exist.
447
+ static func not_held(
448
+ holder: String, engine_class: String, property: String, declared: Dictionary, given: Array, held: String
449
+ ) -> String:
450
+ var value: Variant = given[0]
451
+ var said: String = "%s.%s was given %s and holds %s after it was set" % [holder, property, given[1], held]
452
+ var type: int = GdhRead.as_int(declared.get("type", TYPE_NIL))
453
+ var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
454
+ if hint in LAYER_NAMES and typeof(value) == TYPE_INT:
455
+ return (
456
+ (
457
+ "%s: it is a mask of the %s layers, one bit each from layer 1 (1) to layer %d (%d), so it "
458
+ + "takes 0 to %d and the engine keeps only those bits. Nothing was written."
459
+ )
460
+ % [
461
+ said,
462
+ LAYER_NAMES[hint],
463
+ MASK_LAYERS,
464
+ 1 << (MASK_LAYERS - 1),
465
+ (1 << MASK_LAYERS) - 1,
466
+ ]
467
+ )
468
+ var names: PackedStringArray = []
469
+ if hint == PROPERTY_HINT_ENUM and (type == TYPE_STRING or type == TYPE_STRING_NAME):
470
+ names = PackedStringArray(enum_values(str(declared.get("hint_string", ""))).keys())
471
+ var bus: bool = property == "bus" and engine_class.begins_with("AudioStreamPlayer")
472
+ if bus:
473
+ names = []
474
+ for index: int in AudioServer.bus_count:
475
+ var _added: bool = names.append(AudioServer.get_bus_name(index))
476
+ if names.is_empty() or names.has(str(value)):
477
+ return said + ": the engine changed or refused the value, so nothing was written."
478
+ var close: PackedStringArray = GdhRead.nearest(str(value), names)
479
+ var nearest: String = (
480
+ "%s %s" % ["is" if close.size() == 1 else "are", ", ".join(close)] if not close.is_empty() else ""
481
+ )
482
+ if bus:
483
+ # Not "holds Master after it was set": the getter answers the first bus for any name the
484
+ # layout lacks, and nothing was kept.
485
+ return (
486
+ (
487
+ "%s.bus takes the name of one of the project's audio buses, and %s is none of them, so it "
488
+ + "would play on %s; %s. project_info include audio_buses lists them, and "
489
+ + "project_settings add_audio_bus adds one. Nothing was written."
490
+ )
491
+ % [
492
+ holder,
493
+ given[1],
494
+ AudioServer.get_bus_name(0),
495
+ "the buses are " + ", ".join(names) if nearest.is_empty() else "the nearest " + nearest
496
+ ]
497
+ )
498
+ return (
499
+ "%s: it takes one of the names it lists, and %s is none of them; %s. Nothing was written."
500
+ % [
501
+ said,
502
+ given[1],
503
+ "it lists " + ", ".join(names) if nearest.is_empty() else "the nearest it lists " + nearest
504
+ ]
505
+ )
506
+
507
+
508
+ ## The sentence naming the children of [param node] that have [param property], or "" when none
509
+ ## does. A property of a helper node set on the body it serves, as navigation_layers on the
510
+ ## CharacterBody2D rather than its NavigationAgent2D, was refused with the nearest physics layer.
511
+ static func held_by_children(node: Node, property: String) -> String:
512
+ var root: Node = node.owner if node.owner != null else node
513
+ var holders: PackedStringArray = []
514
+ for child: Node in node.get_children():
515
+ if property in child:
516
+ var _added: bool = holders.append("%s (a %s)" % [root.get_path_to(child), child.get_class()])
517
+ if holders.is_empty():
518
+ return ""
519
+ if holders.size() == 1:
520
+ return " The node at %s has it." % holders[0]
521
+ var last: String = holders[holders.size() - 1]
522
+ return " The nodes at %s and %s have it." % [", ".join(holders.slice(0, -1)), last]
523
+
524
+
525
+ ## A sentence for each node path in [param given], by property, that names no node from
526
+ ## [param node]. A path is written whatever it names, as the inspector writes one, since the node may
527
+ ## be added next; a joint whose node_a named nothing then joined nothing, and no answer said so.
528
+ static func naming_nothing(node: Node, given: Dictionary) -> Dictionary:
529
+ var said: Dictionary = {}
530
+ for property: String in given:
531
+ if "/" in property or ":" in property:
532
+ continue
533
+ # What the node holds, since a path given as text is held as text until the engine stores it.
534
+ var path: Variant = node.get(property)
535
+ if not path is NodePath:
536
+ continue
537
+ var named: NodePath = path
538
+ if named.is_empty() or node.get_node_or_null(named) != null:
539
+ continue
540
+ said[property] = (
541
+ (
542
+ "%s names no node from %s. It is written, as the editor writes one, since that node may "
543
+ + "be added next; until it is, %s finds nothing."
544
+ )
545
+ % [str(named), node.name, property]
546
+ )
547
+ return said
548
+
549
+
550
+ ## Why [param material] has no [param property]: a ShaderMaterial's parameters are its shader's
551
+ ## uniforms, so the shader is what lacks one, and "ShaderMaterial has no property" named the
552
+ ## material.
553
+ static func no_uniform(material: ShaderMaterial, property: String) -> String:
554
+ var uniform: String = property.trim_prefix("shader_parameter/")
555
+ if material.shader == null:
556
+ return "The ShaderMaterial has no shader, so it has no parameter %s yet; set shader first." % uniform
557
+ var shader: String = material.shader.resource_path
558
+ var named: String = shader if not shader.is_empty() else "Its shader"
559
+ var uniforms: Array = []
560
+ for entry: Dictionary in material.shader.get_shader_uniform_list():
561
+ uniforms.append(str(entry["name"]))
562
+ var said: String = "%s declares no uniform %s, which %s would set" % [named, uniform, property]
563
+ if uniforms.is_empty():
564
+ return said + "; it declares no uniforms."
565
+ return GdhRead.offered(said, uniform, uniforms) + "."
@@ -16,15 +16,19 @@ const GdhSerialisation = preload("serialisation.gd")
16
16
 
17
17
 
18
18
  ## The nodes of [param scene_path] as {name, type, path, children}, with instanceOf and
19
- ## editableChildren on a node that instances another scene and every stored property when
20
- ## [param include_properties] is set, the file's value or the default where it sets none.
19
+ ## editableChildren on a node that instances another scene and its stored properties when
20
+ ## [param include_properties] is set, the file's value or the default where it sets none, and on a
21
+ ## node another file makes setHere, the properties this file sets on it. Defaults the file leaves
22
+ ## alone are counted as defaultsLeftOut rather than listed unless [param include_defaults] is set.
21
23
  ## [param depth] -1 reads every level.
22
- static func scene_tree(scene_path: String, depth: int, include_properties: bool) -> Dictionary:
24
+ static func scene_tree(
25
+ scene_path: String, depth: int, include_properties: bool, include_defaults: bool
26
+ ) -> Dictionary:
23
27
  var laid: Dictionary = _laid(scene_path)
24
28
  if not laid.get("ok", false):
25
29
  return laid
26
30
  var nodes: Dictionary = laid["nodes"]
27
- return {"ok": true, "tree": _entry(nodes, ".", include_properties, depth, 0)}
31
+ return {"ok": true, "tree": _entry(nodes, ".", include_properties, include_defaults, depth, 0)}
28
32
 
29
33
 
30
34
  ## The node at [param node_path] in [param scene_path] as {ok, name, type, properties}, laid as
@@ -176,6 +180,7 @@ static func _lay(
176
180
  continue
177
181
  else:
178
182
  nodes[path] = _made(str(state.get_node_type(index)))
183
+ nodes[path]["madeHere"] = loaded != null
179
184
  if ids[index] >= 0:
180
185
  nodes[path]["uniqueId"] = ids[index]
181
186
  # Laid at the root with no scene handed in is a scene the one being read inherits, and the
@@ -202,10 +207,15 @@ static func _lay(
202
207
  if not grouped.has(str(group)):
203
208
  grouped.append(str(group))
204
209
  var held: Dictionary = nodes[path]["properties"]
210
+ # What the file being read sets, kept apart for a node another file makes, whose values read
211
+ # the same whether this scene changed them or that one gave them.
212
+ var laid: Dictionary = nodes[path]
213
+ var set_here: Array = laid.get_or_add("setHere", [])
205
214
  for property: int in state.get_node_property_count(index):
206
- held[str(state.get_node_property_name(index, property))] = state.get_node_property_value(
207
- index, property
208
- )
215
+ var named: String = str(state.get_node_property_name(index, property))
216
+ held[named] = state.get_node_property_value(index, property)
217
+ if loaded != null and not set_here.has(named):
218
+ set_here.append(named)
209
219
  # Only the scene being read and the scenes it inherits share its root, the owner whose editable
210
220
  # instances a write here sets; a file instanced below makes its own owner's editable.
211
221
  if prefix == ".":
@@ -321,7 +331,12 @@ static func _joined(prefix: String, given: String) -> String:
321
331
 
322
332
 
323
333
  static func _entry(
324
- nodes: Dictionary, path: String, include_properties: bool, depth: int, current_depth: int
334
+ nodes: Dictionary,
335
+ path: String,
336
+ include_properties: bool,
337
+ include_defaults: bool,
338
+ depth: int,
339
+ current_depth: int
325
340
  ) -> Dictionary:
326
341
  var node: Dictionary = nodes[path]
327
342
  var children: Array[Dictionary] = []
@@ -344,7 +359,21 @@ static func _entry(
344
359
  data["groups"] = sorted
345
360
  if include_properties:
346
361
  var given: Dictionary = node["properties"]
347
- data["properties"] = _stored(str(node["type"]), given)
362
+ var stored: Dictionary = _stored(str(node["type"]), given, include_defaults)
363
+ data["properties"] = stored["properties"]
364
+ if stored["left_out"] > 0:
365
+ data["defaultsLeftOut"] = stored["left_out"]
366
+ var cells: int = _cell_count(str(node["type"]), given)
367
+ if cells >= 0:
368
+ data["cellCount"] = cells
369
+ data["cellsListedBy"] = (
370
+ "scene_node get with nodePath %s, which lists them as set_tilemap_cells takes them" % path
371
+ )
372
+ if not node.get("madeHere", false):
373
+ var kept: Array = node.get("setHere", [])
374
+ var set_here: Array = kept.duplicate()
375
+ set_here.sort()
376
+ data["setHere"] = set_here
348
377
  var below: Array[String] = node["children"]
349
378
  # Counted rather than left as an empty list, which reads as a node with no children.
350
379
  if depth >= 0 and current_depth >= depth:
@@ -352,19 +381,56 @@ static func _entry(
352
381
  data["childrenNotListed"] = below.size()
353
382
  return data
354
383
  for child: String in below:
355
- children.append(_entry(nodes, child, include_properties, depth, current_depth + 1))
384
+ children.append(_entry(nodes, child, include_properties, include_defaults, depth, current_depth + 1))
356
385
  return data
357
386
 
358
387
 
388
+ ## How many cells a TileMapLayer or TileMap of [param type] holds under the values [param given], or
389
+ ## -1 when it is another node or the file sets none of its cells. The file keeps them as packed bytes,
390
+ ## which a read of the tree answered with alone, so a caller decoded the base64 to learn what was
391
+ ## painted. Only cells the file sets are counted: an instance's come from its own scene.
392
+ static func _cell_count(type: String, given: Dictionary) -> int:
393
+ if not ClassDB.class_exists(type) or not ClassDB.can_instantiate(type):
394
+ return -1
395
+ var single: bool = ClassDB.is_parent_class(type, "TileMapLayer")
396
+ if not single and not ClassDB.is_parent_class(type, "TileMap"):
397
+ return -1
398
+ var painted: bool = false
399
+ for property: Variant in given:
400
+ var named: String = str(property)
401
+ if named == "tile_map_data" or (named.begins_with("layer_") and named.ends_with("/tile_data")):
402
+ painted = true
403
+ if not painted:
404
+ return -1
405
+ var bare: Node = ClassDB.instantiate(type)
406
+ for property: Variant in given:
407
+ if property != "script":
408
+ bare.set(str(property), given[property])
409
+ var count: int = 0
410
+ if single:
411
+ count = (bare as TileMapLayer).get_used_cells().size()
412
+ else:
413
+ var layered: TileMap = bare as TileMap
414
+ for layer: int in layered.get_layers_count():
415
+ count += layered.get_used_cells(layer).size()
416
+ bare.free()
417
+ return count
418
+
419
+
359
420
  ## Every property a node of [param type] stores, with the value [param given] sets or the default,
360
- ## read as a node holding those values would answer them.
421
+ ## read as a node holding those values would answer them, as {properties, left_out}.
361
422
  ##
362
423
  ## The engine's half is read off a bare node of the class with the file's values set on it, which
363
424
  ## runs no script: what a node lists depends on its values (a thread group's settings appear only
364
425
  ## once it has one) and some setters set others (a Control's anchors preset sets how it grows), so
365
426
  ## the class's defaults and the file's entries alone answered neither. The script's half is its
366
427
  ## declared defaults under the file's values.
367
- static func _stored(type: String, given: Dictionary) -> Dictionary:
428
+ ##
429
+ ## Without [param include_defaults], properties keeps only what the file sets and what differs from
430
+ ## a node of the class holding none of its values, and left_out counts the rest. Every Control
431
+ ## carries a hundred properties, and ten of them came to 57 KB of defaults around the few values a
432
+ ## menu scene set.
433
+ static func _stored(type: String, given: Dictionary, include_defaults: bool) -> Dictionary:
368
434
  var attached: Variant = given.get("script")
369
435
  var script: Script = attached if attached is Script else null
370
436
  var scripted: Dictionary = {}
@@ -374,8 +440,10 @@ static func _stored(type: String, given: Dictionary) -> Dictionary:
374
440
  if not property.is_empty() and GdhRead.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
375
441
  scripted[property] = script.get_property_default_value(property)
376
442
  var values: Dictionary = {}
443
+ var defaults: Dictionary = {}
377
444
  if ClassDB.class_exists(type) and ClassDB.can_instantiate(type):
378
445
  var bare: Object = ClassDB.instantiate(type)
446
+ var plain: Object = ClassDB.instantiate(type)
379
447
  for property: Variant in given:
380
448
  if property != "script" and not scripted.has(property):
381
449
  bare.set(str(property), given[property])
@@ -383,18 +451,30 @@ static func _stored(type: String, given: Dictionary) -> Dictionary:
383
451
  var property: String = str(declared.get("name", ""))
384
452
  if not property.is_empty() and GdhRead.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
385
453
  values[property] = bare.get(property)
386
- if bare is Node:
387
- bare.free()
454
+ defaults[property] = plain.get(property)
455
+ for made: Object in [bare, plain]:
456
+ if made is Node:
457
+ made.free()
388
458
  for property: String in scripted:
389
459
  values[property] = scripted[property]
460
+ defaults[property] = scripted[property]
390
461
  # Given last, which keeps what a bare node cannot hold: the script itself, and a path to a node
391
462
  # for a property that holds the node once it is in a tree.
392
463
  values.merge(given, true)
393
464
  var serialiser: GdhSerialisation = GdhSerialisation.new()
394
465
  var answered: Dictionary = {}
466
+ var left_out: int = 0
395
467
  for property: Variant in values:
468
+ var as_default: bool = (
469
+ not given.has(property)
470
+ and defaults.has(property)
471
+ and GdhSame.same(values[property], defaults[property])
472
+ )
473
+ if as_default and not include_defaults:
474
+ left_out += 1
475
+ continue
396
476
  answered[property] = serialiser.serialize_value(values[property])
397
- return answered
477
+ return {"properties": answered, "left_out": left_out}
398
478
 
399
479
 
400
480
  ## What a resource file holds, in the form modify takes, so a value read here can be written back.
@@ -407,12 +487,16 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
407
487
  var file: String = res_path.get_slice("::", 0)
408
488
  if not FileAccess.file_exists(file):
409
489
  return {"ok": false, "error": "No resource at " + file}
410
- var loaded: Resource = ResourceLoader.load(file, "", ResourceLoader.CACHE_MODE_IGNORE)
411
- if loaded == null:
490
+ var whole: Resource = ResourceLoader.load(file, "", ResourceLoader.CACHE_MODE_IGNORE)
491
+ if whole == null:
412
492
  return {"ok": false, "error": "%s did not load as a resource." % file}
493
+ # The file's resource is held until the read is done, since what it keeps can depend on it: a
494
+ # TileSet that goes unlinks its sources, and an atlas source with no TileSet lists none of its
495
+ # tiles' physics, navigation or custom data.
496
+ var loaded: Resource = whole
413
497
  if file != res_path:
414
498
  var id: String = res_path.trim_prefix(file + "::")
415
- var inner: Resource = _kept_in(loaded, file, id, {})
499
+ var inner: Resource = _kept_in(whole, file, id, {})
416
500
  if inner == null:
417
501
  return {"ok": false, "error": "%s keeps no resource with the id %s." % [file, id]}
418
502
  loaded = inner