gdharness 1.1.11 → 1.1.13

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.
@@ -18,15 +18,27 @@ const LISTED_CANDIDATES: int = 10
18
18
  ## Matched the way a find matches `says`, against a control's own words, and only among controls
19
19
  ## the player can see. A match inside a button presses the button, because the label is what says
20
20
  ## the words and the button is what takes the click: aimed at the label, the pointer lands on the
21
- ## button and the answer would call the click a miss. Several matches are refused rather than
22
- ## guessed, each named with the [param which] that picks it, counting from 0.
21
+ ## button and the answer would call the click a miss.
22
+ ##
23
+ ## The matches are ranked the way a person reads a screen for the thing to press: a button before
24
+ ## text that cannot be pressed, and a control whose words are exactly these before one saying them
25
+ ## as part of a sentence. A plain word is matched anywhere in a text, so on a screen full of prose
26
+ ## "Back" was said by ten labels as well as the one button reading "Back", and the refusal listing
27
+ ## eleven sent the caller to an index that moved every time the prose did. Several matches at the
28
+ ## best rank are refused rather than guessed, each named with the [param which] that picks it,
29
+ ## counting from 0 in rank order.
30
+ ##
31
+ ## A control with something drawn over its centre that a pointer there would reach instead is not
32
+ ## on screen for a player, and a click at it lands on the thing on top. The screen a game shows
33
+ ## over its hall covers the hall's drawers without hiding them, so their words matched as well.
23
34
  static func control_saying(root: Node, root_path: String, wanted: String, which: Variant) -> Dictionary:
24
35
  var reached: Dictionary = Values.node_at(root, root_path)
25
36
  if reached.has("message"):
26
37
  return reached
27
- var targets: Array[Control] = []
38
+ var matched: Array[Control] = []
28
39
  # The words of the control that matched, which a button pressed on a label's behalf has none of.
29
40
  var said: Array[String] = []
41
+ var exact: Array[bool] = []
30
42
  var hidden: int = 0
31
43
  var pending: Array[Node] = [reached["node"]]
32
44
  while not pending.is_empty():
@@ -35,32 +47,69 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
35
47
  if not Queries.shown(node):
36
48
  hidden += 1
37
49
  else:
38
- var matched: Control = node
39
- var target: Control = _pressed_through(matched)
40
- if not targets.has(target):
41
- targets.append(target)
42
- said.append(Words.said_by(node))
50
+ var control: Control = node
51
+ var target: Control = _pressed_through(control)
52
+ var words: String = Words.said_by(node)
53
+ var whole: bool = _says_exactly(words, wanted)
54
+ var at: int = matched.find(target)
55
+ if at == -1:
56
+ matched.append(target)
57
+ said.append(words)
58
+ exact.append(whole)
59
+ elif whole and not exact[at]:
60
+ said[at] = words
61
+ exact[at] = true
43
62
  var children: Array[Node] = node.get_children(true)
44
63
  for index: int in range(children.size() - 1, -1, -1):
45
64
  pending.push_front(children[index])
46
65
 
66
+ var drawn: Array[Node] = _pointer_takers(root)
67
+ var areas: Array[Rect2] = _areas_of(drawn)
68
+ var covered: int = 0
69
+ var cover: Node = null
70
+ var open: Array[int] = []
71
+ for index: int in matched.size():
72
+ var over: Node = _cover_of(matched[index], drawn, areas)
73
+ if over == null:
74
+ open.append(index)
75
+ else:
76
+ covered += 1
77
+ if cover == null:
78
+ cover = over
79
+ # Stable, so matches of one rank keep the order they are drawn in.
80
+ open.sort_custom(
81
+ func(a: int, b: int) -> bool:
82
+ var first: int = _rank(matched[a], exact[a])
83
+ var second: int = _rank(matched[b], exact[b])
84
+ return first > second or (first == second and a < b)
85
+ )
86
+ var targets: Array[Control] = []
87
+ var ranks: Array[int] = []
88
+ var lines: Array[String] = []
89
+ for index: int in open:
90
+ targets.append(matched[index])
91
+ ranks.append(_rank(matched[index], exact[index]))
92
+ lines.append(said[index])
93
+
47
94
  var under: String = "" if root_path == "/root" else " under %s" % root_path
48
95
  if targets.is_empty():
49
96
  return {
50
97
  "type": "error",
51
98
  "message":
52
99
  (
53
- 'no control on screen%s says "%s", so nothing was clicked%s'
54
- % [under, wanted, _hidden_note(hidden)]
100
+ 'no control on screen%s says "%s", so nothing was clicked%s%s'
101
+ % [under, wanted, _hidden_note(hidden), _covered_note(covered, cover)]
55
102
  )
56
103
  }
57
- if which == null and targets.size() > 1:
104
+ var best: int = ranks.count(ranks[0])
105
+ if which == null and best > 1:
106
+ var order: String = "" if best == targets.size() else ", best matches first"
58
107
  return {
59
108
  "type": "error",
60
109
  "message":
61
110
  (
62
- '%d controls on screen%s say "%s", so which to click is not clear; pass index, counting from 0: %s'
63
- % [targets.size(), under, wanted, _candidates(targets, said)]
111
+ "%s, so which to click is not clear; pass index, counting from 0%s: %s"
112
+ % [_described(best, ranks[0], wanted, under), order, _candidates(targets, lines)]
64
113
  )
65
114
  }
66
115
  var index: int = 0 if which == null else Read.as_int(which, -1)
@@ -71,13 +120,58 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
71
120
  "message":
72
121
  (
73
122
  'index %s is not one of the %d control%s on screen%s saying "%s": %s'
74
- % [str(which), targets.size(), plural, under, wanted, _candidates(targets, said)]
123
+ % [str(which), targets.size(), plural, under, wanted, _candidates(targets, lines)]
75
124
  )
76
125
  }
77
- return {
78
- "path": str(targets[index].get_path()),
79
- "found": {"says": wanted, "index": index, "of": targets.size()},
80
- }
126
+ var found: Dictionary = {"says": wanted, "index": index, "of": targets.size()}
127
+ if which == null and targets.size() > 1:
128
+ var others: String = (
129
+ "the other one on screen %s" % _passed_over(ranks[0], true)
130
+ if targets.size() == 2
131
+ else "the other %d on screen %s" % [targets.size() - 1, _passed_over(ranks[0], false)]
132
+ )
133
+ found["picked"] = "%s; %s" % [_described(1, ranks[0], wanted, under), others]
134
+ if covered > 0:
135
+ found["covered"] = covered
136
+ return {"path": str(targets[index].get_path()), "found": found}
137
+
138
+
139
+ ## How good a match [param target] is for a click: a button over what cannot be pressed, and then
140
+ ## the whole of its words over a part of them.
141
+ static func _rank(target: Control, whole: bool) -> int:
142
+ return (2 if target is BaseButton else 0) + (1 if whole else 0)
143
+
144
+
145
+ ## Whether [param said] is the whole of what [param wanted] asks for. A glob is matched against the
146
+ ## whole text already, so every match of one is whole.
147
+ static func _says_exactly(said: String, wanted: String) -> bool:
148
+ var words: String = Queries.as_said(wanted)
149
+ if words.contains("*") or words.contains("?"):
150
+ return true
151
+ return said.strip_edges().nocasecmp_to(words.strip_edges()) == 0
152
+
153
+
154
+ ## [param count] matches of [param rank], as the start of a sentence.
155
+ static func _described(count: int, rank: int, wanted: String, under: String) -> String:
156
+ var what: String = "button" if rank >= 2 else "control"
157
+ var how: String = '"%s"' % wanted
158
+ if not Queries.as_said(wanted).contains("*") and not Queries.as_said(wanted).contains("?"):
159
+ how = ('exactly "%s"' if rank % 2 == 1 else '"%s" as part of more') % wanted
160
+ if count == 1:
161
+ return "the one %s on screen%s saying %s" % [what, under, how]
162
+ return "%d %ss on screen%s say %s" % [count, what, under, how]
163
+
164
+
165
+ ## Why the matches below [param rank] were passed over, said of one of them when [param one].
166
+ static func _passed_over(rank: int, one: bool) -> String:
167
+ var says: String = "says" if one else "say"
168
+ match rank:
169
+ 3:
170
+ return "cannot be pressed or %s it as part of more" % says
171
+ 2:
172
+ return "cannot be pressed"
173
+ _:
174
+ return "%s it as part of more" % says
81
175
 
82
176
 
83
177
  ## What to add to a refusal that found nothing on screen, when words matched controls out of sight.
@@ -87,6 +181,15 @@ static func _hidden_note(hidden: int) -> String:
87
181
  return "; %d hidden control%s" % [hidden, " says it" if hidden == 1 else "s say it"]
88
182
 
89
183
 
184
+ ## What to add to a refusal that found nothing on screen, when the controls saying it are covered.
185
+ static func _covered_note(covered: int, cover: Node) -> String:
186
+ if covered == 0:
187
+ return ""
188
+ if covered == 1:
189
+ return "; 1 control says it under %s, which is drawn over it" % cover.get_path()
190
+ return "; %d controls say it under what is drawn over them, such as %s" % [covered, cover.get_path()]
191
+
192
+
90
193
  ## [param control], or the button holding it when it is not one itself, which is what a click on
91
194
  ## its words means.
92
195
  static func _pressed_through(control: Control) -> Control:
@@ -99,6 +202,117 @@ static func _pressed_through(control: Control) -> Control:
99
202
  return control
100
203
 
101
204
 
205
+ ## Everything in the tree [param root] heads that a pointer can land on: each control on screen
206
+ ## that does not let the pointer through, and each window embedded in another.
207
+ static func _pointer_takers(root: Node) -> Array[Node]:
208
+ var found: Array[Node] = []
209
+ var pending: Array[Node] = [root]
210
+ while not pending.is_empty():
211
+ var node: Node = pending.pop_back()
212
+ var control: Control = node as Control
213
+ var window: Window = node as Window
214
+ if control != null:
215
+ if not control.is_visible_in_tree():
216
+ continue
217
+ if control.get_mouse_filter_with_override() != Control.MOUSE_FILTER_IGNORE:
218
+ found.append(control)
219
+ elif window != null and window != root:
220
+ if not window.visible:
221
+ continue
222
+ if window.is_embedded():
223
+ found.append(window)
224
+ pending.append_array(node.get_children(true))
225
+ return found
226
+
227
+
228
+ ## The box each of [param drawn] takes up in its viewport: a window's rectangle, and the box around
229
+ ## a control's however it is turned or scaled.
230
+ ##
231
+ ## Worked out once for a click rather than once for each match. A plain word on a screen of prose
232
+ ## matches a hundred labels, and asking all four thousand controls of a hall for their transform
233
+ ## for each of them took a click by words 80 ms, where the matching alone takes half that.
234
+ static func _areas_of(drawn: Array[Node]) -> Array[Rect2]:
235
+ var areas: Array[Rect2] = []
236
+ for each: Node in drawn:
237
+ var window: Window = each as Window
238
+ if window != null:
239
+ areas.append(Rect2(window.position, window.size))
240
+ else:
241
+ var control: Control = each
242
+ areas.append(control.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, control.size))
243
+ return areas
244
+
245
+
246
+ ## What a pointer at [param target]'s centre reaches instead of it, or null when nothing does.
247
+ ##
248
+ ## The engine's own order for which control takes the pointer: an embedded window over everything
249
+ ## in the viewport that holds it, then the higher canvas layer, then the later in the tree. A
250
+ ## control holding the target or held by it is part of the same click. A target whose centre its
251
+ ## own container clips away is judged not covered, since the click scrolls it into view first and
252
+ ## what is drawn there now is not what it will land on.
253
+ ##
254
+ ## [param areas] holds the box around each of [param drawn], so everything whose box misses the
255
+ ## point is passed over before anything costlier is asked of it.
256
+ static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2]) -> Node:
257
+ var viewport: Viewport = target.get_viewport()
258
+ var point: Vector2 = target.get_global_transform_with_canvas() * (target.size * 0.5)
259
+ if _clipped_away(target, point):
260
+ return null
261
+ var layer: int = _layer_of(target)
262
+ for at: int in drawn.size():
263
+ if not areas[at].has_point(point):
264
+ continue
265
+ var other: Node = drawn[at]
266
+ var window: Window = other as Window
267
+ if window != null:
268
+ if (
269
+ window.get_parent() != null
270
+ and window.get_parent().get_viewport() == viewport
271
+ and not window.is_ancestor_of(target)
272
+ ):
273
+ return window
274
+ continue
275
+ var control: Control = other
276
+ if control == target or control.is_ancestor_of(target) or target.is_ancestor_of(control):
277
+ continue
278
+ if control.get_viewport() != viewport:
279
+ continue
280
+ var its_layer: int = _layer_of(control)
281
+ if its_layer < layer or (its_layer == layer and not control.is_greater_than(target)):
282
+ continue
283
+ if _holds(control, point) and not _clipped_away(control, point):
284
+ return control
285
+ return null
286
+
287
+
288
+ ## Whether [param control] holds [param point], given in its viewport's coordinates.
289
+ static func _holds(control: Control, point: Vector2) -> bool:
290
+ var placed: Transform2D = control.get_global_transform_with_canvas()
291
+ # A control scaled to nothing holds no point, and inverting its transform is an engine error.
292
+ if is_zero_approx(placed.determinant()):
293
+ return false
294
+ # The rectangle, which is the engine's own answer unless a script overrides `_has_point`; the
295
+ # engine does not offer the overridden answer to scripts.
296
+ return Rect2(Vector2.ZERO, control.size).has_point(placed.affine_inverse() * point)
297
+
298
+
299
+ ## Whether a container clipping what it holds cuts [param point] off from [param control].
300
+ static func _clipped_away(control: Control, point: Vector2) -> bool:
301
+ var walk: Node = control.get_parent()
302
+ while walk != null:
303
+ var holder: Control = walk as Control
304
+ if holder != null and holder.clip_contents and not _holds(holder, point):
305
+ return true
306
+ walk = walk.get_parent()
307
+ return false
308
+
309
+
310
+ ## The canvas layer [param control] is drawn on, 0 for the viewport's own canvas.
311
+ static func _layer_of(control: Control) -> int:
312
+ var layer: CanvasLayer = control.get_canvas_layer_node()
313
+ return 0 if layer == null else layer.layer
314
+
315
+
102
316
  ## The controls a click by words could mean, each with its index, its path and the words that
103
317
  ## matched, [param said] holding those in the same order.
104
318
  static func _candidates(targets: Array[Control], said: Array[String]) -> String:
@@ -7,7 +7,6 @@ const Paths = preload("runtime_paths.gd")
7
7
  const Queries = preload("runtime_queries.gd")
8
8
  const Read = preload("reading.gd")
9
9
  const Values = preload("runtime_values.gd")
10
- const Words = preload("runtime_words.gd")
11
10
 
12
11
  ## The longest one wait may last, whatever the request says: past this the server has long
13
12
  ## since given up on the reply.
@@ -284,7 +283,9 @@ func _wait_until_said(node_path: String, said: String, timeout_ms: int, include_
284
283
 
285
284
 
286
285
  ## Whether anything under [param node_path] says [param said], the node itself included, and shown
287
- ## unless hidden ones are wanted too.
286
+ ## unless hidden ones are wanted too. Matched as a find and a click match `says`, a glob over the
287
+ ## whole text when it has `*` or `?`: read as literal words, a glob a click had just pressed by was
288
+ ## waited on for the whole timeout with its label on screen, and answered as words never arriving.
288
289
  func _anything_says(node_path: String, said: String, include_hidden: bool) -> bool:
289
290
  var root: Node = _host.get_tree().root.get_node_or_null(node_path)
290
291
  if root == null:
@@ -292,7 +293,7 @@ func _anything_says(node_path: String, said: String, include_hidden: bool) -> bo
292
293
  var pending: Array[Node] = [root]
293
294
  while not pending.is_empty():
294
295
  var node: Node = pending.pop_back()
295
- if Words.said_by(node).containsn(said) and (include_hidden or Queries.shown(node)):
296
+ if Queries.says(node, said) and (include_hidden or Queries.shown(node)):
296
297
  return true
297
298
  pending.append_array(node.get_children(true))
298
299
  return false
package/build/index.js CHANGED
@@ -25210,6 +25210,29 @@ function errorReportOf(pid, directories = runtimeDirectories()) {
25210
25210
  }
25211
25211
  return null;
25212
25212
  }
25213
+ var CONSOLE_BOUNDARY = "[gdharness] runtime listening on ";
25214
+ function consoleBoundaryAt(bytes) {
25215
+ const wanted = Buffer.from(CONSOLE_BOUNDARY);
25216
+ for (let at = bytes.indexOf(wanted);at !== -1; at = bytes.indexOf(wanted, at + 1)) {
25217
+ if (at === 0 || bytes[at - 1] === 10) {
25218
+ return at;
25219
+ }
25220
+ }
25221
+ return -1;
25222
+ }
25223
+ function reportAlreadyInTranscript(transcript, report) {
25224
+ const boundary = consoleBoundaryAt(report);
25225
+ if (boundary === -1) {
25226
+ return 0;
25227
+ }
25228
+ const inTranscript = consoleBoundaryAt(transcript);
25229
+ if (inTranscript === -1) {
25230
+ return boundary;
25231
+ }
25232
+ const copied = transcript.subarray(inTranscript);
25233
+ const end = boundary + copied.length;
25234
+ return end <= report.length && report.subarray(boundary, end).equals(copied) ? end : report.length;
25235
+ }
25213
25236
  var ERROR_REPORT_KEEP_MS = 60 * 60 * 1000;
25214
25237
  function processAlive(pid) {
25215
25238
  try {
@@ -30731,7 +30754,7 @@ var TOOL_SPECS = [
30731
30754
  requires: []
30732
30755
  },
30733
30756
  rect: {
30734
- summary: "one node's rectangle or position, in canvas and in window pixels. A 3D node answers with the point to aim at, which is the middle of what it draws rather than the origin it stands on, the rectangle it covers under covers, the camera that drew it, and behind_camera when it is not in front of one",
30757
+ summary: "one node's rectangle or position, in canvas and in window pixels. Canvas is the node's own viewport; window is where it is drawn in the game's window, through any embedded dialog or SubViewport it sits in, which is what runtime_input mouse_click and mouse_motion take. A 3D node answers with the point to aim at, which is the middle of what it draws rather than the origin it stands on, the rectangle it covers under covers, the camera that drew it, and behind_camera when it is not in front of one",
30735
30758
  requires: ["nodePath"]
30736
30759
  },
30737
30760
  property: {
@@ -30849,7 +30872,7 @@ var TOOL_SPECS = [
30849
30872
  says: {
30850
30873
  type: "string",
30851
30874
  ops: ["click"],
30852
- description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. Several matches are refused with each one listed, and index picks one."
30875
+ description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it, is not on screen. The matches are ranked the way a person picks what to press: a button before text that cannot be pressed, then a control whose whole text is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one."
30853
30876
  },
30854
30877
  action: {
30855
30878
  type: "string",
@@ -30887,7 +30910,7 @@ var TOOL_SPECS = [
30887
30910
  index: {
30888
30911
  type: "number",
30889
30912
  ops: ["click", "choose"],
30890
- description: "click: which of several controls saying the words, counting from 0 in the order a refusal lists them. choose: the item to take, by where it is in the list, when text will not do."
30913
+ description: "click: which of several controls saying the words, counting from 0 in the order a refusal lists them, best matches first. choose: the item to take, by where it is in the list, when text will not do."
30891
30914
  },
30892
30915
  shift: { type: "boolean", ops: ["key"], description: "key: hold Shift with it. Default false." },
30893
30916
  ctrl: { type: "boolean", ops: ["key"], description: "key: hold Ctrl with it. Default false." },
@@ -30930,7 +30953,7 @@ var TOOL_SPECS = [
30930
30953
  requires: []
30931
30954
  },
30932
30955
  choose: {
30933
- summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen and what the button in front of it shows now",
30956
+ summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen, read before the press, what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick), and what the button in front of it shows now when it is still there",
30934
30957
  requires: ["nodePath"]
30935
30958
  },
30936
30959
  action: { summary: "press an action, or hold it", requires: ["action"] },
@@ -30980,7 +31003,7 @@ var TOOL_SPECS = [
30980
31003
  says: {
30981
31004
  type: "string",
30982
31005
  ops: ["until"],
30983
- description: "until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property. It looks every frame while a look is cheap and less often on a screen large enough for a look to cost a real share of a frame, so the game keeps its speed; the answer counts the frames the wait spanned and the looks it took."
31006
+ description: "until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Matched the way runtime_inspect find matches says: case-insensitive, a plain word anywhere in the text, a pattern with * or ? a glob over the whole of it; on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property. It looks every frame while a look is cheap and less often on a screen large enough for a look to cost a real share of a frame, so the game keeps its speed; the answer counts the frames the wait spanned and the looks it took."
30984
31007
  },
30985
31008
  includeHidden: {
30986
31009
  type: "boolean",
@@ -31469,6 +31492,8 @@ function outsideTheirRanges(spec, args) {
31469
31492
  }
31470
31493
  var BRIDGE_RETRY_MS = 2000;
31471
31494
  var SUCCESSOR_CHECK_MS = 1e4;
31495
+ var CONSOLE_WATCH_MS = 250;
31496
+ var BOUNDARY_WAIT_MS = 2000;
31472
31497
  var HANDOVER_MS = SUCCESSOR_CHECK_MS + 5000;
31473
31498
  var HANDOVER_POLL_MS = 250;
31474
31499
  var CACHE_WRITE_MS = 2000;
@@ -31687,6 +31712,7 @@ class GodotServer {
31687
31712
  bridgeStartupError = null;
31688
31713
  bridgeRetry = null;
31689
31714
  successorWatch = null;
31715
+ consoleWatch = null;
31690
31716
  lastProjectPath = null;
31691
31717
  shutdownInitiated = false;
31692
31718
  launchedEditor = null;
@@ -31859,10 +31885,30 @@ class GodotServer {
31859
31885
  this.successorWatch = null;
31860
31886
  }
31861
31887
  }
31888
+ watchThePlayedConsole() {
31889
+ if (this.consoleWatch !== null) {
31890
+ return;
31891
+ }
31892
+ const watch = setInterval(() => {
31893
+ const run = this.activeProcess;
31894
+ if (run?.throughEditor !== true || run.transcript === null) {
31895
+ clearInterval(watch);
31896
+ this.consoleWatch = null;
31897
+ return;
31898
+ }
31899
+ this.drainErrorReport(run);
31900
+ }, CONSOLE_WATCH_MS);
31901
+ watch.unref();
31902
+ this.consoleWatch = watch;
31903
+ }
31862
31904
  async cleanup() {
31863
31905
  this.logDebug("Cleaning up resources");
31864
31906
  this.stopTryingTheBridge();
31865
31907
  this.stopWatchingForASuccessor();
31908
+ if (this.consoleWatch !== null) {
31909
+ clearInterval(this.consoleWatch);
31910
+ this.consoleWatch = null;
31911
+ }
31866
31912
  withdrawBridge(this.announcedAt);
31867
31913
  this.announcedAt = null;
31868
31914
  this.activeProcess = null;
@@ -33430,7 +33476,16 @@ class GodotServer {
33430
33476
  }
33431
33477
  writeToTranscript(line) {
33432
33478
  const run = this.activeProcess;
33433
- if (run?.throughEditor !== true || run.transcript === null) {
33479
+ if (run?.throughEditor !== true) {
33480
+ return;
33481
+ }
33482
+ if (run.consoleFromReport === true || line.startsWith(CONSOLE_BOUNDARY)) {
33483
+ this.drainErrorReport(run, line);
33484
+ if (run.consoleFromReport === true) {
33485
+ return;
33486
+ }
33487
+ }
33488
+ if (run.transcript === null) {
33434
33489
  return;
33435
33490
  }
33436
33491
  try {
@@ -33983,6 +34038,7 @@ class GodotServer {
33983
34038
  seenPlaying: readBoolean(playAnswer, "playing") === true
33984
34039
  };
33985
34040
  this.activeProcess = played;
34041
+ this.watchThePlayedConsole();
33986
34042
  writeEditorRunNote({ projectPath, transcript: transcript.path, startedAt });
33987
34043
  sweepTranscripts();
33988
34044
  return this.jsonTextResponse({
@@ -34000,7 +34056,7 @@ class GodotServer {
34000
34056
  reason: one.reason
34001
34057
  })),
34002
34058
  runtime: await this.runtimeUp(projectPath, alreadyPlaying, runtimeWaitMs, false),
34003
- message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it." + endedToStartThis(ended)
34059
+ message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter and the runtime's report of it, and editor_run stop ends it." + endedToStartThis(ended)
34004
34060
  });
34005
34061
  }
34006
34062
  drainEditorOutput(game) {
@@ -34012,26 +34068,35 @@ class GodotServer {
34012
34068
  return;
34013
34069
  }
34014
34070
  if (game.transcript === null) {
34015
- for (const line of this.dapClient.getOutput(true)) {
34016
- game.log.append("stdout", line.endsWith(`
34071
+ const heard = this.dapClient.getOutput(true);
34072
+ if (game.consoleFromReport !== true) {
34073
+ for (const line of heard) {
34074
+ game.log.append("stdout", line.endsWith(`
34017
34075
  `) ? line : `${line}
34018
34076
  `);
34077
+ }
34019
34078
  }
34020
34079
  }
34021
- if (!this.dapClient.isConnected()) {
34080
+ if (!this.dapClient.isConnected() && game.consoleFromReport !== true) {
34022
34081
  game.consoleLost = true;
34023
34082
  }
34024
34083
  this.recordWhatItBrokeOn(game);
34025
34084
  }
34026
- drainErrorReport(game) {
34085
+ drainErrorReport(game, heard) {
34086
+ if (game.errorReport === null) {
34087
+ return;
34088
+ }
34027
34089
  if (game.errorReport === undefined) {
34028
- const its = this.announcedPidOf(game);
34090
+ const its = this.announcedPidOf(game) ?? (heard?.startsWith(CONSOLE_BOUNDARY) === true ? this.announcedPidNamedBy(game, heard) : undefined);
34029
34091
  const found = its === undefined ? null : errorReportOf(its);
34030
34092
  if (found === null) {
34093
+ if (heard?.startsWith(CONSOLE_BOUNDARY) === true) {
34094
+ game.errorReport = null;
34095
+ }
34031
34096
  return;
34032
34097
  }
34033
34098
  game.errorReport = found;
34034
- game.errorReportOffset = 0;
34099
+ game.errorReportOffset = this.reportTakenBefore(game, found);
34035
34100
  }
34036
34101
  let handle;
34037
34102
  try {
@@ -34047,8 +34112,22 @@ class GodotServer {
34047
34112
  }
34048
34113
  const buffer = Buffer.alloc(size - from);
34049
34114
  const read = readSync2(handle, buffer, 0, buffer.length, from);
34050
- game.errorReportOffset = from + read;
34051
- const reported = buffer.subarray(0, read);
34115
+ let reported = buffer.subarray(0, read);
34116
+ if (game.consoleFromReport !== true) {
34117
+ const boundary = consoleBoundaryAt(reported);
34118
+ if (boundary !== -1) {
34119
+ game.boundaryInReportSince ??= Date.now();
34120
+ if (heard?.startsWith(CONSOLE_BOUNDARY) === true || game.pickedUpPlaying === true || this.dapClient?.isConnected() !== true || Date.now() - game.boundaryInReportSince >= BOUNDARY_WAIT_MS) {
34121
+ game.consoleFromReport = true;
34122
+ } else {
34123
+ reported = reported.subarray(0, boundary);
34124
+ }
34125
+ }
34126
+ }
34127
+ game.errorReportOffset = from + reported.length;
34128
+ if (reported.length === 0) {
34129
+ return;
34130
+ }
34052
34131
  if (game.transcript !== null) {
34053
34132
  appendFileSync(game.transcript, reported);
34054
34133
  } else {
@@ -34058,9 +34137,32 @@ class GodotServer {
34058
34137
  closeSync2(handle);
34059
34138
  }
34060
34139
  }
34140
+ announcedPidNamedBy(run, line) {
34141
+ const named = /runtime-(\d+)\.json$/.exec(line.trim())?.[1];
34142
+ if (named === undefined) {
34143
+ return;
34144
+ }
34145
+ const before = run.announcedBefore ?? new Set;
34146
+ const theOne = this.announcedOfTheRunsProject(run, runtimesAnnounced().running).find((one) => one.pid === Number(named) && !before.has(one.pid) && this.isTheGameOf(run, one, run.projectPath));
34147
+ if (theOne !== undefined) {
34148
+ this.tie(run, theOne);
34149
+ }
34150
+ return run.announcedPid;
34151
+ }
34152
+ reportTakenBefore(game, report) {
34153
+ if (game.pickedUpPlaying !== true || game.transcript === null) {
34154
+ return 0;
34155
+ }
34156
+ try {
34157
+ return reportAlreadyInTranscript(readFileSync17(game.transcript), readFileSync17(report));
34158
+ } catch (error) {
34159
+ this.logDebug(`Could not compare a picked-up run's report with its transcript: ${errorMessage(error)}`);
34160
+ return 0;
34161
+ }
34162
+ }
34061
34163
  recordWhatItBrokeOn(game) {
34062
34164
  const halt = this.dapClient?.whereItStopped() ?? null;
34063
- if (halt?.reason !== "exception" || halt.text === "" || game.brokeOn === halt.text || game.errorReport !== undefined) {
34165
+ if (halt?.reason !== "exception" || halt.text === "" || game.brokeOn === halt.text || typeof game.errorReport === "string") {
34064
34166
  return;
34065
34167
  }
34066
34168
  game.brokeOn = halt.text;
@@ -34272,6 +34374,7 @@ class GodotServer {
34272
34374
  pickedUpPlaying: true
34273
34375
  };
34274
34376
  this.activeProcess = picked;
34377
+ this.watchThePlayedConsole();
34275
34378
  }
34276
34379
  announcedPidOf(run, announced) {
34277
34380
  if (run.announcedPid !== undefined) {
@@ -34690,7 +34793,7 @@ class GodotServer {
34690
34793
  }));
34691
34794
  }
34692
34795
  if (run.pickedUpPlaying === true) {
34693
- notes.push(run.transcript === null ? "The editor was already playing this when this server reached it, and the server that started it left no transcript, so this is not the run from its start: what it printed before that is only in the editor. It is the run the editor is holding now, which is the one the debug_* tools answer for." : "The editor was already playing this when this server reached it, and what it printed before that has been read back from its transcript. It is the run the editor is holding now, which is the one the debug_* tools answer for.");
34796
+ notes.push(run.transcript !== null ? "The editor was already playing this when this server reached it, and what it printed before that has been read back from its transcript. It is the run the editor is holding now, which is the one the debug_* tools answer for." : run.consoleFromReport === true ? "The editor was already playing this when this server reached it, and the server that started it left no transcript, so what is below was read back from the file the game's runtime writes its console to, which begins where the runtime started: the engine's own start-up before that is only in the editor. It is the run the editor is holding now, which is the one the debug_* tools answer for." : "The editor was already playing this when this server reached it, and the server that started it left no transcript, so this is not the run from its start: what it printed before that is only in the editor. It is the run the editor is holding now, which is the one the debug_* tools answer for.");
34694
34797
  }
34695
34798
  if (run.consoleLost === true) {
34696
34799
  const back = this.dapClient?.isConnected() === true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.11",
3
+ "version": "1.1.13",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",