gdharness 1.3.47 → 1.3.49

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 (32) hide show
  1. package/build/cli.js +589 -229
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +20 -0
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +37 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +6 -0
  5. package/build/godot/addons/gdharness_editor/property_values.gd +8 -11
  6. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +19 -0
  7. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +42 -0
  8. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +1 -1
  9. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +8 -1
  10. package/build/godot/addons/gdharness_editor/value_shapes.gd +9 -0
  11. package/build/godot/addons/gdharness_runtime/declared_values.gd +37 -0
  12. package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +32 -9
  13. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +3 -2
  14. package/build/godot/addons/gdharness_runtime/runtime_hover.gd +9 -7
  15. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +72 -11
  16. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +21 -1
  17. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +21 -8
  18. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +3 -2
  19. package/build/godot/addons/gdharness_runtime/runtime_words.gd +1 -1
  20. package/build/godot/addons/gdharness_runtime/value_shapes.gd +9 -0
  21. package/build/godot/operations/animation_reads.gd +20 -0
  22. package/build/godot/operations/declared_values.gd +37 -0
  23. package/build/godot/operations/dependencies.gd +65 -9
  24. package/build/godot/operations/file_reads.gd +6 -0
  25. package/build/godot/operations/import_pipeline.gd +10 -2
  26. package/build/godot/operations/project_config.gd +35 -0
  27. package/build/godot/operations/property_values.gd +8 -11
  28. package/build/godot/operations/resource_files.gd +1 -2
  29. package/build/godot/operations/value_shapes.gd +9 -0
  30. package/build/godot/tests/listed.gd +25 -0
  31. package/build/keeper.js +1312 -1026
  32. package/package.json +1 -1
@@ -16,6 +16,22 @@ const UPDATE_MODE_NAMES: Dictionary[int, String] = {
16
16
  Animation.UPDATE_DISCRETE: "discrete",
17
17
  Animation.UPDATE_CAPTURE: "capture",
18
18
  }
19
+ const INTERPOLATION_NAMES: Dictionary[int, String] = {
20
+ Animation.INTERPOLATION_NEAREST: "nearest",
21
+ Animation.INTERPOLATION_LINEAR: "linear",
22
+ Animation.INTERPOLATION_CUBIC: "cubic",
23
+ Animation.INTERPOLATION_LINEAR_ANGLE: "linear_angle",
24
+ Animation.INTERPOLATION_CUBIC_ANGLE: "cubic_angle",
25
+ }
26
+ ## The track types whose keys are blended between, so how they are and whether a looping animation
27
+ ## blends its last key into its first say how the track moves.
28
+ const INTERPOLATED_TYPES: Array[int] = [
29
+ Animation.TYPE_VALUE,
30
+ Animation.TYPE_POSITION_3D,
31
+ Animation.TYPE_ROTATION_3D,
32
+ Animation.TYPE_SCALE_3D,
33
+ Animation.TYPE_BLEND_SHAPE,
34
+ ]
19
35
  const TRACK_TYPE_NAMES: Dictionary[int, String] = {
20
36
  Animation.TYPE_VALUE: "property",
21
37
  Animation.TYPE_POSITION_3D: "position_3d",
@@ -174,6 +190,10 @@ static func _track(anim: Animation, track: int, values: GdhSerialisation) -> Dic
174
190
  if kind == Animation.TYPE_VALUE:
175
191
  var mode: int = anim.value_track_get_update_mode(track)
176
192
  shaped["update"] = UPDATE_MODE_NAMES.get(mode, str(mode))
193
+ if kind in INTERPOLATED_TYPES:
194
+ var interpolation: int = anim.track_get_interpolation_type(track)
195
+ shaped["interpolation"] = INTERPOLATION_NAMES.get(interpolation, str(interpolation))
196
+ shaped["loopWrap"] = anim.track_get_interpolation_loop_wrap(track)
177
197
  var keyframes: Array[Dictionary] = []
178
198
  var methods: Dictionary = {}
179
199
  for key: int in anim.track_get_key_count(track):
@@ -113,6 +113,41 @@ static func engine_constants(target: Object, property: String) -> Dictionary:
113
113
  return values
114
114
 
115
115
 
116
+ ## The number [param word] names as a constant of the global enum the property [param declared] of
117
+ ## [param target] holds, such as VERTICAL_ALIGNMENT_BOTTOM, or null when it names none. ClassDB
118
+ ## lists no global enum and Expression reads no global constant, so a word spelt from the enum's
119
+ ## name and a value the hint gives is compiled, and taken only when the engine reads it as that
120
+ ## value: ORIENTATION_VERTICAL is spelt so and is not a constant, which is HORIZONTAL or VERTICAL.
121
+ static func _global_constant(target: Object, declared: Dictionary, word: String) -> Variant:
122
+ var engine_class: String = target.get_class()
123
+ var setter: String = str(
124
+ ClassDB.class_get_property_setter(engine_class, str(declared.get("name", "")))
125
+ )
126
+ var named: String = ""
127
+ for method: Dictionary in ClassDB.class_get_method_list(engine_class):
128
+ var arguments: Array = method.get("args", [])
129
+ if str(method.get("name", "")) == setter and not arguments.is_empty():
130
+ var value_argument: Dictionary = arguments[-1]
131
+ named = str(value_argument.get("class_name", ""))
132
+ break
133
+ if named.is_empty() or "." in named:
134
+ return null
135
+ var wanted: String = word.strip_edges().to_upper()
136
+ var hinted: Dictionary = enum_values(str(declared.get("hint_string", "")))
137
+ for value_word: String in hinted:
138
+ var constant: String = (
139
+ named.to_snake_case().to_upper() + "_" + value_word.to_upper().replace(" ", "_")
140
+ )
141
+ if constant != wanted:
142
+ continue
143
+ var reader: GDScript = GDScript.new()
144
+ reader.source_code = "extends RefCounted\nconst READ = %s\n" % constant
145
+ if (reader.reload() == OK
146
+ and reader.get_script_constant_map().get("READ") == hinted[value_word]):
147
+ return hinted[value_word]
148
+ return null
149
+
150
+
116
151
  ## [param word] as the number of the value the enum property [param declared] of [param target]
117
152
  ## names by it, as [true, number], or [false, why] naming the values, with [param holder] naming the
118
153
  ## target in the refusal. [false, ""] for a word that is a number, which converts as one.
@@ -132,6 +167,8 @@ static func enum_by_name(
132
167
  if chosen == null:
133
168
  constants = engine_constants(target, property)
134
169
  chosen = enum_named(constants, word)
170
+ if chosen == null and constants.is_empty():
171
+ chosen = _global_constant(target, declared, word)
135
172
  if chosen != null:
136
173
  return [true, chosen]
137
174
  if word.strip_edges().is_valid_int():
@@ -356,6 +356,12 @@ static func _entry(
356
356
  var data: Dictionary = {
357
357
  "name": node["name"], "type": node["type"], "path": path, "children": children,
358
358
  }
359
+ var given_values: Dictionary = node["properties"]
360
+ var attached: Variant = given_values.get("script")
361
+ if attached is Script:
362
+ var script: Script = attached
363
+ if not str(script.get_global_name()).is_empty():
364
+ data["scriptClass"] = str(script.get_global_name())
359
365
  # The root's file is the scene being read, so only another node's names a second scene.
360
366
  if path != "." and node.has("instanceOf"):
361
367
  data["instanceOf"] = node["instanceOf"]
@@ -118,7 +118,7 @@ func cannot_hold(
118
118
  if not GdhSerialisation.acceptable(value, expected_type):
119
119
  var shape: String = "%s.%s is %s and the value given is %s, which cannot become one."
120
120
  var remedy: String = GdhShapes.remedy(value, expected_type)
121
- return shape % [holder, property, held, _kind(value)] + remedy + cost
121
+ return shape % [holder, property, held, GdhShapes.kind(value)] + remedy + cost
122
122
 
123
123
  # And the same question of each element, because a list is accepted for a packed or typed array
124
124
  # whatever is in it: a polygon written as [{"x": 1}] passes the check above and lands as one
@@ -132,7 +132,12 @@ func cannot_hold(
132
132
  if not GdhSerialisation.acceptable(items[index], element):
133
133
  var shape: String = "%s.%s is %s and item %d of the list given is %s, not %s."
134
134
  var named: Array = [
135
- holder, property, held, index, _kind(items[index]), type_string(element),
135
+ holder,
136
+ property,
137
+ held,
138
+ index,
139
+ GdhShapes.kind(items[index]),
140
+ type_string(element),
136
141
  ]
137
142
  return shape % named + GdhShapes.remedy(items[index], element) + cost
138
143
  # A list of polygons is a list of lists of points, and each point is read as one too.
@@ -151,7 +156,7 @@ func cannot_hold(
151
156
  type_string(inner),
152
157
  at,
153
158
  index,
154
- _kind(points[at]),
159
+ GdhShapes.kind(points[at]),
155
160
  type_string(inner),
156
161
  ]
157
162
  return nested % said + GdhShapes.remedy(points[at], inner) + cost
@@ -173,14 +178,6 @@ func cannot_hold(
173
178
  return ""
174
179
 
175
180
 
176
- ## What [param value] is, as a refusal names it. A number arrives from JSON as a float whether or
177
- ## not it has a fraction, so [1, 2] given for a list of words was called a list of floats.
178
- static func _kind(value: Variant) -> String:
179
- if value is int or value is float:
180
- return "a number"
181
- return type_string(typeof(value))
182
-
183
-
184
181
  ## Writes each of [param properties] onto [param target], stopping at the first it cannot write and
185
182
  ## answering with why, or "" when every one of them went on and holds what was given.
186
183
  ##
@@ -109,6 +109,9 @@ func add_animation_track(args: Dictionary) -> Dictionary:
109
109
  if anim.track_get_type(track_idx) == Animation.TYPE_VALUE:
110
110
  var mode: int = anim.value_track_get_update_mode(track_idx)
111
111
  answer["update"] = GdhAnimationReads.UPDATE_MODE_NAMES.get(mode, str(mode))
112
+ var blended: int = anim.track_get_interpolation_type(track_idx)
113
+ answer["interpolation"] = GdhAnimationReads.INTERPOLATION_NAMES.get(blended, str(blended))
114
+ answer["loopWrap"] = anim.track_get_interpolation_loop_wrap(track_idx)
112
115
  if built[3]:
113
116
  answer["existingTrack"] = true
114
117
  var replaced: Array = built[5]
@@ -165,6 +168,7 @@ func _add_track(
165
168
  var kind: Animation.TrackType = Animation.TYPE_VALUE
166
169
  var path: NodePath = NodePath(target_path)
167
170
  var update: int = -1
171
+ var interpolation: int = -1
168
172
  if track_type == "property":
169
173
  var prop_name: String = str(track.get("property", ""))
170
174
  if prop_name.is_empty():
@@ -175,6 +179,11 @@ func _add_track(
175
179
  )
176
180
  if named != null:
177
181
  update = GdhRead.as_int(named)
182
+ var blended: Variant = GdhAnimationReads.INTERPOLATION_NAMES.find_key(
183
+ str(track.get("interpolation", ""))
184
+ )
185
+ if blended != null:
186
+ interpolation = GdhRead.as_int(blended)
178
187
  var values: Array = _key_values(target, prop_name, keyframes)
179
188
  if not values[0]:
180
189
  return _not_added(str(values[1]))
@@ -196,6 +205,12 @@ func _add_track(
196
205
  misfit = _key_misfit(target, method_name, arguments, index)
197
206
  method_found = target.has_method(method_name)
198
207
  kind = Animation.TYPE_METHOD
208
+ # A method key is called at its time and never blended, so neither has anything to set.
209
+ for blending: String in ["interpolation", "loopWrap"]:
210
+ if track.has(blending):
211
+ return _not_added(
212
+ "track.%s is for property tracks; a method key is not blended." % blending
213
+ )
199
214
  else:
200
215
  return _not_added(
201
216
  (
@@ -250,6 +265,10 @@ func _add_track(
250
265
  _as_the_editor_makes(anim, track_idx, player, target)
251
266
  if update >= 0:
252
267
  anim.value_track_set_update_mode(track_idx, update as Animation.UpdateMode)
268
+ if interpolation >= 0:
269
+ anim.track_set_interpolation_type(track_idx, interpolation as Animation.InterpolationType)
270
+ if track.has("loopWrap"):
271
+ anim.track_set_interpolation_loop_wrap(track_idx, GdhRead.as_bool(track["loopWrap"]))
253
272
  for index: int in keyframes.size():
254
273
  var keyframe: Dictionary = keyframes[index]
255
274
  if anim.track_insert_key(track_idx, GdhRead.as_float(keyframe["time"]), keys[index]) < 0:
@@ -14,6 +14,12 @@ const HEADLESS_DISPLAY: String = "headless"
14
14
  ## for every editor on the machine, and bound only while a game runs.
15
15
  const DEBUGGER_SETTING: String = "network/debug/remote_port"
16
16
  const LOOPBACK: String = "127.0.0.1"
17
+ ## Set in the environment of an editor the server started on its hidden desktop, and so in every
18
+ ## game it plays. Kept in step with `DESKTOP_VARIABLE` in the server's desktop module.
19
+ const DESKTOP_VARIABLE: String = "GDHARNESS_DESKTOP"
20
+ const RUN_ARGS_SETTING: String = "editor/run/main_run_args"
21
+ ## Kept in step with `HIDDEN_WINDOW_FPS` in the server's launch module.
22
+ const HIDDEN_WINDOW_FPS: int = 60
17
23
 
18
24
  var _editor_plugin: EditorPlugin = null
19
25
  ## Which play this is, counted up for every one the editor starts, whoever starts it. What a server
@@ -80,10 +86,26 @@ func play_scene(args: Dictionary) -> Dictionary:
80
86
 
81
87
  var debugger: int = _a_port_for_the_debugger()
82
88
 
89
+ var held: String = str(ProjectSettings.get_setting(RUN_ARGS_SETTING, ""))
90
+ var given: String = with_hidden_window_flags(
91
+ held,
92
+ OS.get_name() == "Windows" and not OS.get_environment(DESKTOP_VARIABLE).is_empty(),
93
+ GdhRead.as_int(ProjectSettings.get_setting("application/run/max_fps", 0)) > 0,
94
+ )
95
+ # The editor builds the game's command line from the text its run instances dialog holds, which
96
+ # it fetches from the setting on settings_changed. The project emits that a frame later, so it
97
+ # is emitted here on both sides of the play, and no frame passes in which anything could save
98
+ # the project with the flags in it.
99
+ if given != held:
100
+ ProjectSettings.set_setting(RUN_ARGS_SETTING, given)
101
+ ProjectSettings.settings_changed.emit()
83
102
  if full_path.is_empty():
84
103
  EditorInterface.play_main_scene()
85
104
  else:
86
105
  EditorInterface.play_custom_scene(full_path)
106
+ if given != held:
107
+ ProjectSettings.set_setting(RUN_ARGS_SETTING, held)
108
+ ProjectSettings.settings_changed.emit()
87
109
 
88
110
  _play += 1
89
111
  _was_playing = EditorInterface.is_playing_scene()
@@ -99,6 +121,26 @@ func play_scene(args: Dictionary) -> Dictionary:
99
121
  }
100
122
 
101
123
 
124
+ ## [param held], the project's run arguments, with the flags a window on the hidden desktop starts
125
+ ## with put in front when [param hidden] says the editor is there: the server's `hiddenWindowFlags`
126
+ ## gives the reason. Nothing is added for a game the arguments already play headless, and no cap
127
+ ## where [param capped] says the project sets one or the arguments do. In front because a `--` in
128
+ ## them makes everything after it the game's own; after `%command%` when they hold one, since what
129
+ ## goes before it is the command that runs the engine.
130
+ static func with_hidden_window_flags(held: String, hidden: bool, capped: bool) -> String:
131
+ var words: PackedStringArray = held.split(" ", false)
132
+ if not hidden or words.has("--headless"):
133
+ return held
134
+ var flags: String = "--disable-vsync"
135
+ if not capped and not words.has("--max-fps"):
136
+ flags += " --max-fps %d" % HIDDEN_WINDOW_FPS
137
+ var placeholder: int = held.find("%command%")
138
+ if placeholder == -1:
139
+ return (flags + " " + held).strip_edges()
140
+ var after: int = placeholder + "%command%".length()
141
+ return held.substr(0, after) + " " + flags + held.substr(after)
142
+
143
+
102
144
  ## Gives this editor's debugger a port of its own, and answers the one the game will be sent to.
103
145
  ##
104
146
  ## The editor binds this while a game runs, out of a setting shared by every editor on the
@@ -212,7 +212,7 @@ func create_resource(args: Dictionary) -> Dictionary:
212
212
  "ok": false,
213
213
  "error":
214
214
  (
215
- "%s is neither an engine class nor a class_name a script in this project declares."
215
+ "%s is neither an engine class nor a class_name in the project's class list."
216
216
  % resource_type
217
217
  ),
218
218
  }
@@ -50,9 +50,16 @@ static func what_to_add(node_type: String, instance_path: String, scene_path: St
50
50
 
51
51
 
52
52
  ## The refusal for [param given], sent as [param argument], which names no node class, with the
53
- ## node classes nearest it, so "Node2DD" is offered Node2D.
53
+ ## node classes nearest it, so "Node2DD" is offered Node2D. A name that is no engine class at all
54
+ ## may
55
+ ## have been meant as a class_name, which is taken too.
54
56
  static func not_a_node_class(argument: String, given: String) -> String:
55
57
  var said: String = "%s must be a node class, and %s is not one" % [argument, given]
58
+ if not ClassDB.class_exists(given):
59
+ said = (
60
+ "%s must be a node class or a class_name in the project's class list, and %s is neither"
61
+ % [argument, given]
62
+ )
56
63
  var classes: PackedStringArray = ClassDB.get_inheriters_from_class("Node")
57
64
  var _added: bool = classes.append("Node")
58
65
  var close: PackedStringArray = GdhRead.nearest(given, classes)
@@ -234,6 +234,15 @@ static func packed_from(items: Array, type: int, fit: Callable) -> Variant:
234
234
  return type_convert(built, type)
235
235
 
236
236
 
237
+ ## What [param value] is, as a refusal names it. A number arrives from JSON as a float whether or
238
+ ## not it has a fraction, so [1, 2] given for a list of words was called a list of floats, and 5
239
+ ## given for a Vector2 was called a float.
240
+ static func kind(value: Variant) -> String:
241
+ if value is int or value is float:
242
+ return "a number"
243
+ return type_string(typeof(value))
244
+
245
+
237
246
  ## The sentences a refusal of [param value] where a [param type] goes ends with: which of its keys
238
247
  ## kept it from being read as one, and how one is written. Empty for a type not built from fields.
239
248
  ##
@@ -113,6 +113,41 @@ static func engine_constants(target: Object, property: String) -> Dictionary:
113
113
  return values
114
114
 
115
115
 
116
+ ## The number [param word] names as a constant of the global enum the property [param declared] of
117
+ ## [param target] holds, such as VERTICAL_ALIGNMENT_BOTTOM, or null when it names none. ClassDB
118
+ ## lists no global enum and Expression reads no global constant, so a word spelt from the enum's
119
+ ## name and a value the hint gives is compiled, and taken only when the engine reads it as that
120
+ ## value: ORIENTATION_VERTICAL is spelt so and is not a constant, which is HORIZONTAL or VERTICAL.
121
+ static func _global_constant(target: Object, declared: Dictionary, word: String) -> Variant:
122
+ var engine_class: String = target.get_class()
123
+ var setter: String = str(
124
+ ClassDB.class_get_property_setter(engine_class, str(declared.get("name", "")))
125
+ )
126
+ var named: String = ""
127
+ for method: Dictionary in ClassDB.class_get_method_list(engine_class):
128
+ var arguments: Array = method.get("args", [])
129
+ if str(method.get("name", "")) == setter and not arguments.is_empty():
130
+ var value_argument: Dictionary = arguments[-1]
131
+ named = str(value_argument.get("class_name", ""))
132
+ break
133
+ if named.is_empty() or "." in named:
134
+ return null
135
+ var wanted: String = word.strip_edges().to_upper()
136
+ var hinted: Dictionary = enum_values(str(declared.get("hint_string", "")))
137
+ for value_word: String in hinted:
138
+ var constant: String = (
139
+ named.to_snake_case().to_upper() + "_" + value_word.to_upper().replace(" ", "_")
140
+ )
141
+ if constant != wanted:
142
+ continue
143
+ var reader: GDScript = GDScript.new()
144
+ reader.source_code = "extends RefCounted\nconst READ = %s\n" % constant
145
+ if (reader.reload() == OK
146
+ and reader.get_script_constant_map().get("READ") == hinted[value_word]):
147
+ return hinted[value_word]
148
+ return null
149
+
150
+
116
151
  ## [param word] as the number of the value the enum property [param declared] of [param target]
117
152
  ## names by it, as [true, number], or [false, why] naming the values, with [param holder] naming the
118
153
  ## target in the refusal. [false, ""] for a word that is a number, which converts as one.
@@ -132,6 +167,8 @@ static func enum_by_name(
132
167
  if chosen == null:
133
168
  constants = engine_constants(target, property)
134
169
  chosen = enum_named(constants, word)
170
+ if chosen == null and constants.is_empty():
171
+ chosen = _global_constant(target, declared, word)
135
172
  if chosen != null:
136
173
  return [true, chosen]
137
174
  if word.strip_edges().is_valid_int():
@@ -40,6 +40,7 @@ const WALK: String = r"""
40
40
  $ErrorActionPreference = 'Stop'
41
41
  function Answer($value) {
42
42
  [System.IO.File]::WriteAllText($Out, (ConvertTo-Json -InputObject $value -Depth 100 -Compress))
43
+ Mark 'answered'
43
44
  }
44
45
  function Mark($stage) {
45
46
  [System.IO.File]::AppendAllText($Progress, "$stage $([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())`n")
@@ -280,14 +281,24 @@ func get_accessibility(params: Dictionary) -> Dictionary:
280
281
  var looked_at: int = began_at
281
282
  while OS.is_process_running(pid):
282
283
  var now: int = Time.get_ticks_msec()
283
- if not walking and now - looked_at >= LOOK_MS:
284
+ if now - looked_at >= LOOK_MS:
284
285
  looked_at = now
285
- if FileAccess.get_file_as_string(progress).contains("loaded "):
286
+ var marked: String = FileAccess.get_file_as_string(progress)
287
+ if not walking and marked.contains("loaded "):
286
288
  walking = true
287
289
  deadline = _deadline(began_at, now, handles.size())
290
+ # The answer is whole once it is marked, so the read is done whatever the client does
291
+ # after it, and a client slow to exit costs the read nothing.
292
+ if marked.contains("answered "):
293
+ var _ended: Error = OS.kill(pid)
294
+ break
288
295
  if now > deadline:
289
296
  var _killed: Error = OS.kill(pid)
290
- var reached: String = _reached(FileAccess.get_file_as_string(progress), started_ms)
297
+ var reached: String = _reached(
298
+ FileAccess.get_file_as_string(progress),
299
+ started_ms,
300
+ int(Time.get_unix_time_from_system() * 1000.0)
301
+ )
291
302
  var _gone: Error = DirAccess.remove_absolute(progress)
292
303
  return _refused(
293
304
  (
@@ -743,19 +754,22 @@ static func _marks(progress: String) -> Array:
743
754
  return marks
744
755
 
745
756
 
746
- ## How far a client started at [param started_ms] got by the marks in [param progress], said for a
747
- ## caller whose read it stopped: the time went to starting PowerShell, loading UI Automation, or the
748
- ## walks, and which tells a loaded machine from a window that never answers.
749
- static func _reached(progress: String, started_ms: int) -> String:
757
+ ## How far a client started at [param started_ms] and stopped at [param stopped_ms] got by the marks
758
+ ## in [param progress], said for a caller whose read it stopped: the time went to starting
759
+ ## PowerShell, loading UI Automation, the walks, or after the last walk, and which tells a loaded
760
+ ## machine from a window that never answers.
761
+ static func _reached(progress: String, started_ms: int, stopped_ms: int) -> String:
750
762
  var marks: Array = _marks(progress)
751
763
  var at: Dictionary = {}
752
764
  var walks: int = 0
753
765
  var last: String = ""
766
+ var last_at: int = 0
754
767
  for mark: Array in marks:
755
768
  var stage: String = mark[0]
756
769
  if stage.begins_with("walked "):
757
770
  walks += 1
758
771
  last = stage.trim_prefix("walked ")
772
+ last_at = mark[1]
759
773
  elif not at.has(stage):
760
774
  at[stage] = mark[1]
761
775
  if not at.has("began"):
@@ -775,9 +789,18 @@ static func _reached(progress: String, started_ms: int) -> String:
775
789
  return (
776
790
  (
777
791
  "PowerShell began the client after %d ms and loaded UI Automation in %d ms, and the "
778
- + "windows were walked %d time%s, the last finding %s element%s"
792
+ + "windows were walked %d time%s, the last finding %s element%s and ending %d ms "
793
+ + "before the client was stopped"
779
794
  )
780
- % [began, loaded, walks, "" if walks == 1 else "s", last, "" if last == "1" else "s"]
795
+ % [
796
+ began,
797
+ loaded,
798
+ walks,
799
+ "" if walks == 1 else "s",
800
+ last,
801
+ "" if last == "1" else "s",
802
+ stopped_ms - last_at,
803
+ ]
781
804
  )
782
805
 
783
806
 
@@ -7,6 +7,7 @@ const GdhCallErrors = preload("call_errors.gd")
7
7
  const GdhDeclared = preload("declared_values.gd")
8
8
  const GdhPaths = preload("runtime_paths.gd")
9
9
  const GdhRead = preload("reading.gd")
10
+ const GdhShapes = preload("value_shapes.gd")
10
11
  const GdhValues = preload("runtime_values.gd")
11
12
  ## The most errors a call's answer carries, which is enough to see what the method ran into and
12
13
  ## short of a method reporting one per element of a list of thousands.
@@ -254,7 +255,7 @@ func _argument_for(
254
255
  named,
255
256
  type_string(wants),
256
257
  index + 1,
257
- type_string(typeof(given)),
258
+ GdhShapes.kind(sent),
258
259
  GdhValues.remedy(sent, wants),
259
260
  ]
260
261
  ),
@@ -379,7 +380,7 @@ func _value_for(
379
380
  called,
380
381
  named,
381
382
  type_string(wanted),
382
- type_string(typeof(given)),
383
+ GdhShapes.kind(value),
383
384
  GdhValues.remedy(value, wanted),
384
385
  ]
385
386
  ),
@@ -160,7 +160,7 @@ func _hover(viewport: Viewport, point: Vector2) -> void:
160
160
  var inside: Vector2 = container.get_global_transform_with_canvas().affine_inverse() * point
161
161
  if container.stretch:
162
162
  inside /= container.stretch_shrink
163
- for child: Node in container.get_children():
163
+ for child: Node in container.get_children(true):
164
164
  var shown: SubViewport = child as SubViewport
165
165
  if shown != null and not shown.gui_disable_input:
166
166
  _hover(shown, shown.get_final_transform().affine_inverse() * inside)
@@ -179,7 +179,7 @@ func _drop(viewport: Viewport, until: int) -> void:
179
179
  var over: Control = instance_from_id(top) as Control
180
180
  var container: SubViewportContainer = over as SubViewportContainer
181
181
  if container != null:
182
- for child: Node in container.get_children():
182
+ for child: Node in container.get_children(true):
183
183
  var shown: SubViewport = child as SubViewport
184
184
  if shown != null:
185
185
  _drop(shown, 0)
@@ -264,7 +264,7 @@ func _pressed_at(viewport: Viewport, point: Vector2) -> Node:
264
264
  var inside: Vector2 = container.get_global_transform_with_canvas().affine_inverse() * point
265
265
  if container.stretch:
266
266
  inside /= container.stretch_shrink
267
- for child: Node in container.get_children():
267
+ for child: Node in container.get_children(true):
268
268
  var shown: SubViewport = child as SubViewport
269
269
  if shown != null and not shown.gui_disable_input:
270
270
  var within: Node = _pressed_at(
@@ -277,9 +277,11 @@ func _pressed_at(viewport: Viewport, point: Vector2) -> Node:
277
277
 
278
278
  ## The controls [param node] holds that the engine keeps as roots of [param node]'s viewport: drawn
279
279
  ## on their own, or with no control above them. Not into another viewport, a window or a
280
- ## SubViewport, whose controls are that one's.
280
+ ## SubViewport, whose controls are that one's. Internal children count, as they do for the engine,
281
+ ## though get_children leaves them out unless asked: a layer added with INTERNAL_MODE_BACK was
282
+ ## passed over, and the press predicted for the control under it.
281
283
  static func _gather_roots(node: Node, roots: Array[Control]) -> void:
282
- for child: Node in node.get_children():
284
+ for child: Node in node.get_children(true):
283
285
  if child is Viewport:
284
286
  continue
285
287
  var control: Control = child as Control
@@ -331,8 +333,8 @@ func _at(item: CanvasItem, point: Vector2, holder: Transform2D) -> Control:
331
333
  var control: Control = item as Control
332
334
  var local: Vector2 = matrix.affine_inverse() * point
333
335
  if control == null or not control.is_clipping_contents() or _has_point(control, local):
334
- for index: int in range(item.get_child_count() - 1, -1, -1):
335
- var child: CanvasItem = item.get_child(index) as CanvasItem
336
+ for index: int in range(item.get_child_count(true) - 1, -1, -1):
337
+ var child: CanvasItem = item.get_child(index, true) as CanvasItem
336
338
  if child == null or child.top_level:
337
339
  continue
338
340
  var found: Control = _at(child, point, matrix)
@@ -338,19 +338,23 @@ static func walk_to(node: Object, node_path: String, reaching: String) -> Dictio
338
338
  ##
339
339
  ## A colon path read from [param node], the node the call was made on, the way the method's own
340
340
  ## path is: "_ladder:shop:_wares:-15". One beginning with a slash starts at the node it names
341
- ## instead, "/root/Main/Hud" or "/root/Main:_game:run". The record runtime_inspect answers an object
342
- ## with is not accepted, because its id is a 64-bit number and does not survive being sent as JSON:
343
- ## a rounded id names another object or none, and the path names the one meant.
341
+ ## instead, "/root/Main/Hud" or "/root/Main:_game:run". A res:// or uid:// path names a resource
342
+ ## file, which is loaded the way the game loads one, so a resource the game already holds is handed
343
+ ## over as that instance. The records a read answers a node and a filed resource with carry such a
344
+ ## path and are taken as it. The record runtime_inspect answers any other object with is not
345
+ ## accepted, because its id is a 64-bit number and does not survive being sent as JSON: a rounded id
346
+ ## names another object or none, and the path names the one meant.
344
347
  static func object_at(root: Node, node: Node, node_path: String, given: Variant) -> Dictionary:
348
+ var named: Variant = given
345
349
  if given is Dictionary:
346
- return {
347
- "message":
348
- (
349
- "an object is named by its path, not by the record runtime_inspect answered with:"
350
- + " its id does not survive the trip as a number"
351
- ),
352
- }
353
- var path: String = given
350
+ var record: Dictionary = given
351
+ var tag: String = str(record.get("_type", ""))
352
+ if not (tag in ["Node", "Resource"] and record.get("path") is String):
353
+ return { "message": _no_path_in(record) }
354
+ named = record["path"]
355
+ var path: String = named
356
+ if path.begins_with("res://") or path.begins_with("uid://"):
357
+ return _resource_at(path)
354
358
  var start: Node = node
355
359
  var start_path: String = node_path
356
360
  var reaching: String = path
@@ -382,6 +386,63 @@ static func object_at(root: Node, node: Node, node_path: String, given: Variant)
382
386
  return { "object": value }
383
387
 
384
388
 
389
+ ## Why [param record], a dictionary given where an object goes, names none. A resource built into a
390
+ ## scene is read as its class beside its properties and has no path; the runtime builds no resource
391
+ ## from those, so the remedy is the file, not the path the other records are told to use.
392
+ static func _no_path_in(record: Dictionary) -> String:
393
+ if record.has("id"):
394
+ return (
395
+ "an object is named by its path, not by the record runtime_inspect answered with:"
396
+ + " its id does not survive the trip as a number"
397
+ )
398
+ var sort: String = str(record.get("_type", record.get("class", "")))
399
+ if sort.is_empty():
400
+ return (
401
+ "an object is named by its path, or a resource by the res:// path of its file, and a"
402
+ + " dictionary names neither"
403
+ )
404
+ return (
405
+ (
406
+ "a resource is named by the res:// path of its file, and the %s given has none: a"
407
+ + " resource built into a scene has no path, and the game builds none from its"
408
+ + " properties"
409
+ )
410
+ % sort
411
+ )
412
+
413
+
414
+ ## The resource at [param path], a res:// or uid:// path, as `{"object": ...}`, or
415
+ ## `{"message": ...}` saying why there is none to hand over.
416
+ static func _resource_at(path: String) -> Dictionary:
417
+ if path.contains("::"):
418
+ return {
419
+ "message":
420
+ (
421
+ "%s is a resource built into %s, and only that file can refer to it"
422
+ % [path, path.get_slice("::", 0)]
423
+ ),
424
+ }
425
+ if not ResourceLoader.exists(path):
426
+ # An image or a sound nobody has imported is a file the engine has no loader for, and "No
427
+ # resource at" sent a caller looking for a file that was there.
428
+ if path.begins_with("res://") and FileAccess.file_exists(path):
429
+ return {
430
+ "message":
431
+ (
432
+ (
433
+ "%s is there, but it has never been imported, so the game has no resource "
434
+ + "to load from it. project_import reimport with resourcePath %s imports it"
435
+ )
436
+ % [path, path]
437
+ ),
438
+ }
439
+ return { "message": "No resource at %s" % path }
440
+ var loaded: Resource = load(path)
441
+ if loaded == null:
442
+ return { "message": "%s did not load; the game's output says why" % path }
443
+ return { "object": loaded }
444
+
445
+
385
446
  ## Whether a step can be taken into [param value] at all: an object, a list, a map or a value with
386
447
  ## components can be walked into, and a number or a string is where a path ends whether the caller
387
448
  ## meant it to or not.