gdharness 1.3.48 → 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.
@@ -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():
@@ -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
  ##
@@ -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
  ),
@@ -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.
@@ -607,7 +607,7 @@ func get_rect(params: Dictionary) -> Dictionary:
607
607
  if node is Control:
608
608
  var control: Control = node
609
609
  var canvas_rect: Rect2 = control.get_global_rect()
610
- return {
610
+ var answer: Dictionary = {
611
611
  "type": "rect",
612
612
  "path": node_path,
613
613
  "visible": control.is_visible_in_tree(),
@@ -621,6 +621,19 @@ func get_rect(params: Dictionary) -> Dictionary:
621
621
  )
622
622
  ),
623
623
  }
624
+ # Visible and in view read as clickable for a button under a dialog covering the screen,
625
+ # which a click by its words refused. Loaded here, since runtime_targets preloads this
626
+ # script.
627
+ if answer["visible"] and answer["in_view"]:
628
+ var own: Script = get_script()
629
+ var targets: GDScript = load(
630
+ own.resource_path.get_base_dir().path_join("runtime_targets.gd")
631
+ )
632
+ var over: Variant = targets.call("drawn_over", control)
633
+ if over is Node:
634
+ var cover: Node = over
635
+ answer["drawn_over"] = str(cover.get_path())
636
+ return answer
624
637
  if node is Node2D:
625
638
  var item: Node2D = node
626
639
  var canvas_position: Vector2 = item.get_global_transform_with_canvas().origin
@@ -250,7 +250,7 @@ static func control_saying(
250
250
  (
251
251
  (
252
252
  'no %s on screen%s says exactly "%s", so nothing was clicked: %s %s says it as '
253
- + 'part of "%s", while %s; pass index %d to press that one anyway'
253
+ + 'part of "%s", while %s; pass index %d to press %s anyway'
254
254
  )
255
255
  % [
256
256
  "button" if exact_elsewhere.size() > exact_out_of_reach.size() else "control",
@@ -261,6 +261,7 @@ static func control_saying(
261
261
  _shortened(lines[index]),
262
262
  _out_of_reach_exactly(exact_elsewhere, elsewhere_as),
263
263
  index,
264
+ targets[index].get_path(),
264
265
  ]
265
266
  ),
266
267
  }
@@ -272,7 +273,7 @@ static func control_saying(
272
273
  (
273
274
  'no control on screen%s says "%s" as a word of its own, so nothing was '
274
275
  + 'clicked: %s %s has it only inside a longer word, "%s"; pass index %d to '
275
- + "press that one anyway"
276
+ + "press %s anyway"
276
277
  )
277
278
  % [
278
279
  under,
@@ -281,6 +282,7 @@ static func control_saying(
281
282
  targets[index].get_path(),
282
283
  _shortened(lines[index]),
283
284
  index,
285
+ targets[index].get_path(),
284
286
  ]
285
287
  ),
286
288
  }
@@ -871,8 +873,8 @@ static func _where_it_lands(
871
873
  var half: Vector2 = (drawn * Rect2(Vector2.ZERO, target.size)).size * 0.5
872
874
  var moves: Dictionary[ScrollContainer, Vector2] = {}
873
875
  var landed: Vector2 = point
874
- var walk: Node = target.get_parent()
875
- while walk != null and not (walk is Viewport):
876
+ var walk: CanvasItem = _drawn_inside(target)
877
+ while walk != null:
876
878
  var holder: ScrollContainer = walk as ScrollContainer
877
879
  if holder != null:
878
880
  var shown: Rect2 = (
@@ -887,7 +889,7 @@ static func _where_it_lands(
887
889
  if next != landed:
888
890
  moves[holder] = next - landed
889
891
  landed = next
890
- walk = walk.get_parent()
892
+ walk = _drawn_inside(walk)
891
893
  return moves
892
894
 
893
895
 
@@ -919,8 +921,8 @@ static func _off_screen(target: Control, aim: Vector2) -> bool:
919
921
  static func _clipped_away(
920
922
  control: Control, point: Vector2, moves: Dictionary[ScrollContainer, Vector2]
921
923
  ) -> bool:
922
- var walk: Node = control.get_parent()
923
- while walk != null and not (walk is Viewport):
924
+ var walk: CanvasItem = _drawn_inside(control)
925
+ while walk != null:
924
926
  var holder: Control = walk as Control
925
927
  if (
926
928
  holder != null
@@ -928,10 +930,21 @@ static func _clipped_away(
928
930
  and not _holds(holder, point - _moved_by(holder, moves))
929
931
  ):
930
932
  return true
931
- walk = walk.get_parent()
933
+ walk = _drawn_inside(walk)
932
934
  return false
933
935
 
934
936
 
937
+ ## The canvas item [param item] is drawn inside, which is what can clip it and what moves it, or
938
+ ## null where its drawing starts afresh: under a CanvasLayer, a viewport or a node that is not a
939
+ ## canvas item, or when it is top_level. A clipping Control above a CanvasLayer clips nothing on the
940
+ ## layer, and judged against one, every button of a reader drawn on a layer under a clipping desk
941
+ ## was refused as off the screen where it sat above the desk's top edge.
942
+ static func _drawn_inside(item: CanvasItem) -> CanvasItem:
943
+ if item.top_level:
944
+ return null
945
+ return item.get_parent() as CanvasItem
946
+
947
+
935
948
  ## The canvas layer [param control] is drawn on, 0 for the viewport's own canvas.
936
949
  static func _layer_of(control: Control) -> int:
937
950
  var layer: CanvasLayer = control.get_canvas_layer_node()
@@ -8,6 +8,7 @@ const GdhPaths = preload("runtime_paths.gd")
8
8
  const GdhQueries = preload("runtime_queries.gd")
9
9
  const GdhRead = preload("reading.gd")
10
10
  const GdhSays = preload("runtime_says.gd")
11
+ const GdhShapes = preload("value_shapes.gd")
11
12
  const GdhValues = preload("runtime_values.gd")
12
13
  ## The longest one wait may last, whatever the request says: past this the server has long
13
14
  ## since given up on the reply.
@@ -490,7 +491,7 @@ func wait_until(params: Dictionary) -> Dictionary:
490
491
  "%s.%s is declared to hold an object and the value to wait for is %s, which it can"
491
492
  + " never hold: wait for null, or for the object's own value on a property of it."
492
493
  )
493
- % [node_path, property, type_string(typeof(wanted))]
494
+ % [node_path, property, GdhShapes.kind(wanted)]
494
495
  )
495
496
  if not refused.is_empty():
496
497
  return { "type": "error", "message": refused }
@@ -679,7 +680,7 @@ static func _not_comparable(
679
680
  current: Variant, wanted: Variant, node_path: String, property: String
680
681
  ) -> String:
681
682
  var types: Array[String] = [
682
- node_path, property, type_string(typeof(current)), type_string(typeof(wanted)),
683
+ node_path, property, type_string(typeof(current)), GdhShapes.kind(wanted),
683
684
  ]
684
685
  # A value that cannot become what the property holds is said as that, which names the remedy,
685
686
  # before the comparison it would also fail; an object on either side is said as the comparison.
@@ -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
  ##