gdharness 1.2.26 → 1.2.28

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.
@@ -41,12 +41,45 @@ try {
41
41
  if ($element.TryGetCurrentPattern($pattern, [ref]$found)) { return $found }
42
42
  return $null
43
43
  }
44
+ # FullDescription is where the description is put, and the managed client refuses every property
45
+ # newer than itself, so it is asked through the core call the client makes for the rest.
46
+ $script:unread = $null
47
+ try {
48
+ $client = [System.Windows.Automation.AutomationElement]
49
+ $core = $client.Assembly.GetType('MS.Internal.Automation.UiaCoreApi')
50
+ $ask = $core.GetMethod('UiaGetPropertyValue', [System.Reflection.BindingFlags]'NonPublic,Public,Static')
51
+ $nodeField = $client.GetField('_hnode', [System.Reflection.BindingFlags]'NonPublic,Instance')
52
+ if ($null -eq $ask -or $null -eq $nodeField) {
53
+ $script:unread = 'this Windows has no UiaGetPropertyValue to ask it through'
54
+ }
55
+ } catch {
56
+ $script:unread = "$_"
57
+ }
58
+ function FullDescription($element) {
59
+ if ($null -ne $script:unread) { return '' }
60
+ try {
61
+ $asked = @($nodeField.GetValue($element), [int]30159, $null)
62
+ $null = $ask.Invoke($null, $asked)
63
+ if ($asked[2] -is [string]) { return $asked[2] }
64
+ } catch {
65
+ $script:unread = "$_"
66
+ }
67
+ return ''
68
+ }
44
69
  function Describe($element, $ox, $oy, $depth) {
45
70
  $c = $element.Current
46
71
  $role = $c.ControlType.ProgrammaticName -replace '^ControlType[.]', ''
47
72
  $role = ($role -creplace '(?<=[a-z])(?=[A-Z])', '_').ToLowerInvariant()
48
73
  $node = [ordered]@{ role = $role; name = $c.Name }
49
- if ($c.HelpText) { $node.description = $c.HelpText }
74
+ $description = FullDescription $element
75
+ if ($description) { $node.description = $description }
76
+ if ($c.HelpText) { $node.placeholder = $c.HelpText }
77
+ # The element's own id, the last four parts of its runtime id, which the game reads back as the
78
+ # node that made it.
79
+ $runtime = $element.GetRuntimeId()
80
+ if ($null -ne $runtime -and $runtime.Length -ge 4) {
81
+ $node.element_id = @($runtime[-4], $runtime[-3], $runtime[-2], $runtime[-1])
82
+ }
50
83
  $r = $c.BoundingRectangle
51
84
  if (-not $r.IsEmpty) {
52
85
  $node.bounds = [ordered]@{ x = [int]($r.X - $ox); y = [int]($r.Y - $oy) }
@@ -100,15 +133,19 @@ try {
100
133
  $windows = @()
101
134
  foreach ($handle in $Handles) {
102
135
  $root = [System.Windows.Automation.AutomationElement]::FromHandle([IntPtr][long]$handle)
103
- $r = $root.Current.BoundingRectangle
104
136
  $deadline = [DateTime]::UtcNow.AddMilliseconds($SettleMs)
105
137
  $empty = [DateTime]::UtcNow.AddMilliseconds($EmptyMs)
138
+ # The window's corner is read on every walk: before the engine builds its tree the window is
139
+ # its frame, and after it the client area inside it, so a corner read once placed everything
140
+ # by the frame's.
141
+ $r = $root.Current.BoundingRectangle
106
142
  $tree = Describe $root $r.X $r.Y 0
107
143
  $last = Count $tree
108
144
  # Walked again until two walks in a row agree, since the first question is what makes the
109
145
  # engine build the tree, which arrives over the frames after it.
110
146
  while ([DateTime]::UtcNow -lt $deadline) {
111
147
  Start-Sleep -Milliseconds 150
148
+ $r = $root.Current.BoundingRectangle
112
149
  $tree = Describe $root $r.X $r.Y 0
113
150
  $count = Count $tree
114
151
  if ($count -gt 1 -and $count -eq $last) { break }
@@ -117,7 +154,9 @@ try {
117
154
  }
118
155
  $windows += , $tree
119
156
  }
120
- Answer @{ windows = $windows }
157
+ $answer = @{ windows = $windows }
158
+ if ($null -ne $script:unread) { $answer.description_unread = $script:unread }
159
+ Answer $answer
121
160
  } catch {
122
161
  Answer @{ error = "$_" }
123
162
  }
@@ -207,14 +246,41 @@ func get_accessibility(_params: Dictionary) -> Dictionary:
207
246
  return _refused("UI Automation could not read the game's windows: %s" % str(answer["error"]))
208
247
 
209
248
  var windows: Array = answer.get("windows", [])
249
+ var made_by: Dictionary = {}
250
+ _note_elements(_host.get_tree().root, made_by)
251
+ var placing: Placing = Placing.new()
210
252
  var elements: int = 0
211
253
  var unnamed: int = 0
212
254
  for at: int in windows.size():
213
255
  var window: Dictionary = windows[at]
214
- window["window_id"] = ids[at] if at < ids.size() else -1
256
+ var window_id: int = ids[at] if at < ids.size() else -1
257
+ window["window_id"] = window_id
258
+ var holder: Window = _window_with(_host.get_tree().root, window_id)
259
+ var holder_path: String = ""
260
+ if holder != null:
261
+ holder_path = str(holder.get_path())
262
+ window["node"] = holder_path
263
+ _place(window, holder_path, made_by, placing, true)
215
264
  elements += _count(window)
216
265
  unnamed += _unnamed_text(window)
217
266
  var result: Dictionary = {"windows": windows, "elements": elements}
267
+ if placing.tooltips > 0:
268
+ result["tooltip_note"] = (
269
+ "tooltip is the node's tooltip_text, which the engine puts in the tree, and UI Automation, as"
270
+ + " AccessKit fills it in, has no property that carries it (measured on 4.7.2), so a screen"
271
+ + " reader on Windows is not told it. Say what a reader needs in accessibility_description."
272
+ )
273
+ if placing.asked > 0 and placing.placed == 0:
274
+ # Nothing at all matched: the element ids are not the engine's element RIDs any more, which
275
+ # an AccessKit that numbers its elements another way would do, and every node would read null.
276
+ result["node_note"] = (
277
+ "No element could be matched to the node that made it: UI Automation's runtime ids did not"
278
+ + " carry the engine's element ids, so node and part_of are the windows' alone."
279
+ )
280
+ if answer.has("description_unread"):
281
+ result["description_note"] = (
282
+ "Descriptions could not be read, so none is shown: %s" % str(answer["description_unread"])
283
+ )
218
284
  if elements <= windows.size():
219
285
  var setting: String = _setting()
220
286
  # The command line's --accessibility is consumed by the engine and not handed on, so only
@@ -272,8 +338,88 @@ static func _count(element: Dictionary) -> int:
272
338
  return total
273
339
 
274
340
 
275
- ## Text elements with bounds and no name. A window also holds a nameless text element with no bounds
276
- ## that no node of the game made, in every scene tried, which is left out.
341
+ ## Every node's element by its id, for the walk's elements to be read back as the node that made
342
+ ## them. Internal children too: a dialog's buttons are elements like any other.
343
+ static func _note_elements(node: Node, made_by: Dictionary) -> void:
344
+ var element: RID = node.get_accessibility_element()
345
+ if element.is_valid():
346
+ made_by[element.get_id()] = node
347
+ for child: Node in node.get_children(true):
348
+ _note_elements(child, made_by)
349
+
350
+
351
+ static func _window_with(node: Node, window_id: int) -> Window:
352
+ if node is Window:
353
+ var window: Window = node
354
+ if window.get_window_id() == window_id:
355
+ return window
356
+ for child: Node in node.get_children(true):
357
+ var found: Window = _window_with(child, window_id)
358
+ if found != null:
359
+ return found
360
+ return null
361
+
362
+
363
+ ## Gives each child of [param element] the node that made it, or, for an element a node made as a
364
+ ## part of itself (a line of a text box, a window's announcements), the node it is a part of.
365
+ ##
366
+ ## UI Automation's runtime id for an element ends in the four halves of AccessKit's id for it, and
367
+ ## Godot gives AccessKit the element's RID: measured on 4.7.2, /root/Main's element answered
368
+ ## 114,1,0,0 with RID 114 << 32 | 1. Which pair holds it is not assumed, so both are tried.
369
+ static func _place(
370
+ element: Dictionary, holder: String, made_by: Dictionary, placing: Placing, is_window: bool
371
+ ) -> void:
372
+ var children: Array = element.get("children", [])
373
+ for child: Variant in children:
374
+ if not child is Dictionary:
375
+ continue
376
+ var inner: Dictionary = child
377
+ var parts: Array = inner.get("element_id", [])
378
+ var _dropped: bool = inner.erase("element_id")
379
+ var node: Node = null
380
+ if parts.size() == 4:
381
+ placing.asked += 1
382
+ node = _made(parts, made_by)
383
+ var path: String = holder
384
+ if node != null:
385
+ placing.placed += 1
386
+ path = str(node.get_path())
387
+ inner["node"] = path
388
+ if node is Control:
389
+ var control: Control = node
390
+ if not control.tooltip_text.is_empty():
391
+ inner["tooltip"] = control.tooltip_text
392
+ placing.tooltips += 1
393
+ else:
394
+ inner["part_of"] = holder
395
+ # The one static text a window makes of itself: where it speaks what is announced, named
396
+ # only while something is.
397
+ if is_window and inner.get("role") == "text":
398
+ inner["part"] = "announcement"
399
+ _place(inner, path, made_by, placing, false)
400
+
401
+
402
+ static func _made(parts: Array, made_by: Dictionary) -> Node:
403
+ var halves: Array[int] = []
404
+ for part: Variant in parts:
405
+ halves.append(Read.as_int(part, 0) & 0xFFFFFFFF)
406
+ for at: int in [0, 2]:
407
+ var id: int = (halves[at] << 32) | halves[at + 1]
408
+ if made_by.has(id):
409
+ return made_by[id]
410
+ return null
411
+
412
+
413
+ ## Counts kept while the elements are placed.
414
+ class Placing:
415
+ extends RefCounted
416
+ var asked: int = 0
417
+ var placed: int = 0
418
+ var tooltips: int = 0
419
+
420
+
421
+ ## Text elements with bounds and no name. The window's announcement element has no bounds and is
422
+ ## left out with them: it is empty until something is announced.
277
423
  static func _unnamed_text(element: Dictionary) -> int:
278
424
  var total: int = 0
279
425
  if element.get("role") == "text" and str(element.get("name", "")).is_empty() and element.has("bounds"):
@@ -35,7 +35,10 @@ var _host: Node
35
35
  var _values: Values
36
36
  ## How long each of the last frames took, oldest first, in microseconds, as the game timed them.
37
37
  var _frame_usec: PackedInt64Array = PackedInt64Array()
38
+ ## How many physics steps ran in each of those frames, in the same order.
39
+ var _frame_physics: PackedInt64Array = PackedInt64Array()
38
40
  var _last_frame_usec: int = 0
41
+ var _last_physics_frames: int = 0
39
42
 
40
43
 
41
44
  ## The host is the autoload, which is how the tree is reached: it is not in one when the
@@ -662,12 +665,21 @@ func get_property(params: Dictionary) -> Dictionary:
662
665
  ## read 45ms for a second at a time, and a change to the game showed only on the next second's
663
666
  ## worst frame. The time between two of these calls is the frame as it passed, physics and the wait
664
667
  ## for the display included.
668
+ ##
669
+ ## The physics steps are counted beside it because the engine steps physics from the wall clock and
670
+ ## nothing else reports how many it took: a game drawing sixty frames a second whose brew advanced
671
+ ## by physics steps moved a tenth as far as the frames promised, and every reading available said
672
+ ## the game was running well.
665
673
  func note_frame(now_usec: int) -> void:
674
+ var physics: int = Engine.get_physics_frames()
666
675
  if _last_frame_usec > 0:
667
676
  var _grew: bool = _frame_usec.push_back(now_usec - _last_frame_usec)
677
+ var _stepped: bool = _frame_physics.push_back(physics - _last_physics_frames)
668
678
  if _frame_usec.size() > FRAMES_TIMED:
669
679
  _frame_usec.remove_at(0)
680
+ _frame_physics.remove_at(0)
670
681
  _last_frame_usec = now_usec
682
+ _last_physics_frames = physics
671
683
 
672
684
 
673
685
  ## Whether the frame waits for the display, which sets its floor: a game with vsync on cannot
@@ -695,16 +707,26 @@ func get_metrics(params: Dictionary) -> Dictionary:
695
707
  total_usec += one
696
708
  slowest_usec = maxi(slowest_usec, one)
697
709
  # Null rather than zero before a frame has been timed: no frame took no time.
710
+ var steps: int = 0
711
+ for one: int in _frame_physics:
712
+ steps += one
698
713
  var frame_time: Variant = null
699
714
  var frame_time_max: Variant = null
715
+ var physics_steps_per_second: Variant = null
700
716
  if timed > 0:
701
717
  frame_time = float(total_usec) / timed / 1000000.0
702
718
  frame_time_max = slowest_usec / 1000000.0
719
+ if total_usec > 0:
720
+ physics_steps_per_second = steps * 1000000.0 / total_usec
703
721
  var all: Dictionary = {
704
722
  "fps": Engine.get_frames_per_second(),
705
723
  "frame_time": frame_time,
706
724
  "frame_time_max": frame_time_max,
707
725
  "frames_timed": timed,
726
+ "physics_steps_per_second": physics_steps_per_second,
727
+ "physics_ticks_per_second": Engine.physics_ticks_per_second,
728
+ "max_physics_steps_per_frame": Engine.max_physics_steps_per_frame,
729
+ "time_scale": Engine.time_scale,
708
730
  "process_time_max": Performance.get_monitor(Performance.TIME_PROCESS),
709
731
  "physics_time": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS),
710
732
  "vsync": _vsync(),