gdharness 1.1.29 → 1.1.30

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.
package/build/cli.js CHANGED
@@ -17841,7 +17841,7 @@ var init_tool_definitions = __esm(() => {
17841
17841
  },
17842
17842
  {
17843
17843
  name: "runtime_capture",
17844
- description: "A picture of the running game: the whole screen or one viewport, as an image. The whole screen includes the game's own dialogs and popups where they sit, also when a project opens them as windows of their own (subwindows not embedded). Needs the game running with a window. A capture asked for before the game's first frame is drawn waits for that frame, since the game announces itself before it and the viewport holds nothing it drew until then.",
17844
+ description: "A picture of the running game: the whole screen, one region or node of it at full resolution, or one viewport, as an image. The whole screen includes the game's own dialogs and popups where they sit, also when a project opens them as windows of their own (subwindows not embedded). Needs the game running with a window. A capture asked for before the game's first frame is drawn waits for that frame, since the game announces itself before it and the viewport holds nothing it drew until then.",
17845
17845
  parameters: {
17846
17846
  projectPath: RUNNING_PROJECT_PATH,
17847
17847
  pid: RUNNING_PID,
@@ -17860,6 +17860,22 @@ var init_tool_definitions = __esm(() => {
17860
17860
  minimum: 1,
17861
17861
  description: "Scale the image to this height. Given alone, the width keeps the picture's proportions."
17862
17862
  },
17863
+ region: {
17864
+ type: "object",
17865
+ ops: ["screenshot"],
17866
+ description: "screenshot: capture only this rectangle of the window, at the game's own pixels: x, y, width and height in window pixels, or the window rectangle runtime_inspect rect answers, passed as it came. A region partly outside the window is cut to it and the answer says clipped; one wholly outside is refused. The answer names the region captured in window pixels, and drawnAt when the project draws at its own size and stretches that to the window, since the picture then has the game's pixels rather than the window's."
17867
+ },
17868
+ nodePath: {
17869
+ type: "string",
17870
+ ops: ["screenshot"],
17871
+ description: "screenshot: capture only where this node is drawn, as runtime_inspect rect answers it: a Control's rectangle, what a 3D node's geometry covers, or what a Sprite2D draws. A node with a position and no extent is refused; give region around it instead. Not with region."
17872
+ },
17873
+ zoom: {
17874
+ type: "integer",
17875
+ minimum: 1,
17876
+ maximum: 16,
17877
+ description: "Enlarge the picture this many times, each pixel made a square of pixels rather than blended with its neighbours, so a thin line or a corner can be read. Not with width or height. Default 1."
17878
+ },
17863
17879
  outputPath: {
17864
17880
  type: "string",
17865
17881
  description: "Also save the picture as a PNG at this absolute path, for somebody to open later; the answer says where. Refused unless it ends in .png, its directory exists, it is outside the project, and no file is there already."
@@ -39645,6 +39661,25 @@ function captureDestinationRefusal(outputPath, projects) {
39645
39661
  }
39646
39662
  return null;
39647
39663
  }
39664
+ function captureDetail(payload) {
39665
+ const parts = [];
39666
+ const region = payload["region"];
39667
+ if (region !== null && typeof region === "object") {
39668
+ const named = region;
39669
+ const at = (key) => String(Math.round(Number(named[key]) * 100) / 100);
39670
+ parts.push(`of the window region at ${at("x")},${at("y")}, ${at("width")}x${at("height")} window pixels${payload["clipped"] === true ? ", clipped to the window" : ""}`);
39671
+ }
39672
+ const drawnAt = payload["drawnAt"];
39673
+ if (drawnAt !== null && typeof drawnAt === "object") {
39674
+ const drawn = drawnAt;
39675
+ parts.push(`at the game's own pixels: it draws at ${String(drawn["width"])}x${String(drawn["height"])} and stretches that to its window`);
39676
+ }
39677
+ if (typeof payload["zoom"] === "number") {
39678
+ parts.push(`enlarged ${payload["zoom"]} times`);
39679
+ }
39680
+ const note = typeof payload["note"] === "string" ? `. ${payload["note"]}` : "";
39681
+ return parts.length === 0 ? note : `, ${parts.join(", ")}${note}`;
39682
+ }
39648
39683
  function patienceForFrames(frames, atLeast) {
39649
39684
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
39650
39685
  }
@@ -44398,7 +44433,7 @@ ${staleNote}`);
44398
44433
  content: [
44399
44434
  {
44400
44435
  type: "text",
44401
- text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}${staleNote === undefined ? "" : `
44436
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${captureDetail(payload)}${saveTo === null ? "" : `. Saved to ${saveTo}`}${staleNote === undefined ? "" : `
44402
44437
 
44403
44438
  ${staleNote}`}`
44404
44439
  },
@@ -58,7 +58,7 @@ var _queries: Queries = Queries.new(self, values)
58
58
  var _changes: Changes = Changes.new(self, values)
59
59
  var _input: InputCommands = InputCommands.new(self, values)
60
60
  var _typing: Typing = Typing.new(self)
61
- var _capture: Capture = Capture.new(self)
61
+ var _capture: Capture = Capture.new(self, values)
62
62
  var _waits: Waits = Waits.new(self, values)
63
63
 
64
64
  var _server: TCPServer
@@ -4,12 +4,20 @@ extends RefCounted
4
4
 
5
5
  const Read = preload("reading.gd")
6
6
  const Values = preload("runtime_values.gd")
7
+ const Queries = preload("runtime_queries.gd")
8
+ const Screen = preload("runtime_screen.gd")
9
+
10
+ ## How far zoom enlarges at most. Sixteen makes a 3-pixel ring 48 pixels across, past any need to
11
+ ## see it, and a 200-pixel region 3200, past the size anybody reads a picture at.
12
+ const ZOOM_MOST: int = 16
7
13
 
8
14
  var _host: Node
15
+ var _values: Values
9
16
 
10
17
 
11
- func _init(host: Node) -> void:
18
+ func _init(host: Node, values: Values) -> void:
12
19
  _host = host
20
+ _values = values
13
21
 
14
22
 
15
23
  func capture_screenshot(params: Dictionary) -> Dictionary:
@@ -88,12 +96,155 @@ static func scaled_to(drawn: Vector2i, width: int, height: int) -> Vector2i:
88
96
  return drawn
89
97
 
90
98
 
99
+ ## What part of the picture a capture is cut to and how far it is enlarged, from what was asked, or
100
+ ## a refusal saying what is wrong with the asking. Read before a frame is waited for, so a call that
101
+ ## cannot be answered costs no wait.
102
+ ##
103
+ ## A region and a node are asked for because width and height scale the whole picture, and a
104
+ ## question about a 3-pixel ring in a 1600-pixel window was answered by capturing the window, saving
105
+ ## it and cropping it with a script of the caller's own, three calls for every look.
106
+ func _cut_asked(params: Dictionary, in_window: bool) -> Dictionary:
107
+ var region_given: Variant = params.get("region")
108
+ var node_path: String = str(params.get("nodePath", ""))
109
+ var zoom: int = Read.as_int(params.get("zoom", 1), 1)
110
+ var sized: bool = Read.as_int(params.get("width", 0)) > 0 or Read.as_int(params.get("height", 0)) > 0
111
+ if region_given != null and not node_path.is_empty():
112
+ return _refused("region and nodePath each name the part to capture; give one of them")
113
+ if (region_given != null or not node_path.is_empty()) and not in_window:
114
+ return _refused(
115
+ (
116
+ "region and nodePath are in the window's pixels, and this viewport's texture is in its "
117
+ + "own: capture the screen with them, or this viewport whole"
118
+ )
119
+ )
120
+ if zoom < 1 or zoom > ZOOM_MOST:
121
+ return _refused("zoom is a whole factor from 1 to %d, and %d was asked for" % [ZOOM_MOST, zoom])
122
+ if zoom > 1 and sized:
123
+ return _refused(
124
+ (
125
+ "zoom enlarges by a whole factor and keeps every pixel square, and width and height "
126
+ + "scale to a size; give one or the other"
127
+ )
128
+ )
129
+ var asked: Dictionary = {"zoom": zoom}
130
+ if region_given != null:
131
+ var region: Variant = region_of(_values, region_given)
132
+ if region == null:
133
+ var shape: String = (
134
+ "region is a rectangle in window pixels with an area: x, y, width and height, or the "
135
+ + "window rectangle runtime_inspect rect answers, passed back as it came. %s is neither"
136
+ )
137
+ return _refused(shape % JSON.stringify(region_given))
138
+ asked["region"] = region
139
+ if not node_path.is_empty():
140
+ asked["node_path"] = node_path
141
+ return asked
142
+
143
+
144
+ static func _refused(message: String) -> Dictionary:
145
+ return {"type": "error", "message": message}
146
+
147
+
148
+ ## The rectangle [param given] names: the four numbers x, y, width and height, or a rectangle as the
149
+ ## runtime answers one, its two corners tagged with their type, passed back as it came. Null when it
150
+ ## is neither or has no area.
151
+ static func region_of(values: Values, given: Variant) -> Variant:
152
+ if not given is Dictionary:
153
+ return null
154
+ var fields: Dictionary = given
155
+ fields = fields.duplicate()
156
+ if not fields.has("_type"):
157
+ fields["_type"] = "Rect2"
158
+ var rebuilt: Variant = values.deserialize(fields)
159
+ var region: Rect2
160
+ if rebuilt is Rect2:
161
+ region = rebuilt
162
+ elif rebuilt is Rect2i:
163
+ var whole: Rect2i = rebuilt
164
+ region = Rect2(whole)
165
+ else:
166
+ return null
167
+ if not region.has_area():
168
+ return null
169
+ return region
170
+
171
+
172
+ ## Where [param node] is drawn, in window pixels, as runtime_inspect rect answers it: a Control's
173
+ ## rectangle, what a 3D node's geometry covers, or what a 2D node that knows its own rectangle draws.
174
+ ## A refusal's message when it has no extent on screen.
175
+ static func drawn_rect_of(node: Node) -> Variant:
176
+ if node is Control:
177
+ var control: Control = node
178
+ return Screen.rect_in_window(
179
+ control.get_viewport(),
180
+ control.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, control.size)
181
+ )
182
+ if node is Node3D:
183
+ var spatial: Node3D = node
184
+ var found: Dictionary = Queries.in_frame(spatial)
185
+ if not found.has("rect"):
186
+ return "%s is not drawn by any current camera, so it covers nothing on screen" % node.get_path()
187
+ var covered: Rect2 = found["rect"]
188
+ return Screen.rect_in_window(spatial.get_viewport(), covered)
189
+ if node is Node2D and node.has_method("get_rect"):
190
+ var item: Node2D = node
191
+ var own: Rect2 = item.call("get_rect")
192
+ return Screen.rect_in_window(item.get_viewport(), item.get_global_transform_with_canvas() * own)
193
+ var no_extent: String = (
194
+ "%s is a %s, which has a position on screen and no extent; give region around the point "
195
+ + "runtime_inspect rect answers for it"
196
+ )
197
+ return no_extent % [node.get_path(), node.get_class()]
198
+
199
+
200
+ ## [param region], in window pixels, as pixels of a picture drawn at [param drawn] in a window
201
+ ## [param window] pixels across: the same rectangle when the two match, scaled when the project draws
202
+ ## at its own size and stretches that to the window. Widened to whole pixels, so an edge falling
203
+ ## inside a pixel keeps that pixel, and cut to the picture. Without area when none of it is in it.
204
+ static func in_picture(region: Rect2, window: Vector2i, drawn: Vector2i) -> Rect2i:
205
+ return widened(region, window, drawn).intersection(Rect2i(Vector2i.ZERO, drawn))
206
+
207
+
208
+ ## [param region] in the picture's pixels as [method in_picture] takes it, before it is cut to the
209
+ ## picture, which is what tells a region that was clipped from one that fitted.
210
+ static func widened(region: Rect2, window: Vector2i, drawn: Vector2i) -> Rect2i:
211
+ var scale: Vector2 = Vector2.ONE
212
+ if window.x > 0 and window.y > 0:
213
+ scale = Vector2(drawn) / Vector2(window)
214
+ var start: Vector2 = (region.position * scale).floor()
215
+ var end: Vector2 = (region.end * scale).ceil()
216
+ return Rect2i(Vector2i(start), Vector2i(end - start))
217
+
218
+
219
+ ## [param picture], pixels of a picture drawn at [param drawn], as the window pixels it covers in a
220
+ ## window [param window] pixels across: what an answer names, since the region was asked for in them.
221
+ static func in_window_pixels(picture: Rect2i, window: Vector2i, drawn: Vector2i) -> Dictionary:
222
+ var scale: Vector2 = Vector2.ONE
223
+ if drawn.x > 0 and drawn.y > 0 and window.x > 0 and window.y > 0:
224
+ scale = Vector2(window) / Vector2(drawn)
225
+ var start: Vector2 = Vector2(picture.position) * scale
226
+ var size: Vector2 = Vector2(picture.size) * scale
227
+ return {"x": start.x, "y": start.y, "width": size.x, "height": size.y}
228
+
229
+
230
+ ## [param image] cut to [param picture] and enlarged [param zoom] times, each pixel made a square of
231
+ ## them rather than blended with its neighbours, so a pixel in the answer is a pixel the game drew.
232
+ static func cut(image: Image, picture: Rect2i, zoom: int) -> Image:
233
+ var part: Image = image.get_region(picture)
234
+ if zoom > 1:
235
+ part.resize(part.get_width() * zoom, part.get_height() * zoom, Image.INTERPOLATE_NEAREST)
236
+ return part
237
+
238
+
91
239
  ## The server names the file, so a game cannot point it at a path of its own choosing; a call
92
240
  ## with no path is a call the server did not make.
93
241
  func _capture(viewport: Viewport, params: Dictionary, with_windows: bool = false) -> Dictionary:
94
242
  var requested_path: String = str(params.get("output_path", ""))
95
243
  if requested_path.is_empty():
96
244
  return {"type": "error", "message": "output_path required"}
245
+ var asked: Dictionary = _cut_asked(params, with_windows)
246
+ if asked.has("message"):
247
+ return asked
97
248
 
98
249
  # Godot draws nothing to a minimised window and nothing at all without one, and the
99
250
  # texture keeps whatever was drawn last. A capture then comes back byte for byte the same
@@ -127,12 +278,57 @@ func _capture(viewport: Viewport, params: Dictionary, with_windows: bool = false
127
278
  if with_windows:
128
279
  _lay_native_windows(image)
129
280
 
130
- var drawn: Vector2i = Vector2i(image.get_width(), image.get_height())
131
- var target: Vector2i = scaled_to(
132
- drawn, Read.as_int(params.get("width", 0)), Read.as_int(params.get("height", 0))
133
- )
134
- if target != drawn:
135
- image.resize(target.x, target.y)
281
+ var described: Dictionary = {}
282
+ var region: Variant = asked.get("region")
283
+ if asked.has("node_path"):
284
+ var node_path: String = asked["node_path"]
285
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
286
+ if standing.has("message"):
287
+ return standing
288
+ var node: Node = standing["node"]
289
+ var placed: Variant = drawn_rect_of(node)
290
+ if placed is String:
291
+ var why: String = placed
292
+ return _refused(why)
293
+ region = placed
294
+ # A hidden node's rectangle is still a place, and what is drawn there is whatever is behind it.
295
+ if not Queries.shown(node):
296
+ described["note"] = (
297
+ "%s is hidden, so this is what is drawn where it would be rather than the node" % node_path
298
+ )
299
+ if region != null:
300
+ var asked_region: Rect2 = region
301
+ var window: Vector2i = _host.get_tree().root.size
302
+ var picture_size: Vector2i = image.get_size()
303
+ var whole: Rect2i = widened(asked_region, window, picture_size)
304
+ var picture: Rect2i = whole.intersection(Rect2i(Vector2i.ZERO, picture_size))
305
+ if not picture.has_area():
306
+ return _refused(
307
+ (
308
+ "The region at %s, %s pixels across, is outside the window, which is %dx%d pixels"
309
+ % [asked_region.position, asked_region.size, window.x, window.y]
310
+ )
311
+ )
312
+ image = cut(image, picture, 1)
313
+ described["region"] = in_window_pixels(picture, window, picture_size)
314
+ if picture != whole:
315
+ described["clipped"] = true
316
+ # The project draws at its own size and stretches that to the window, so the picture's pixels
317
+ # are not the window's: said, because a caller sizing a region by window pixels gets fewer.
318
+ if picture_size != window:
319
+ described["drawnAt"] = {"width": picture_size.x, "height": picture_size.y}
320
+
321
+ var zoom: int = asked["zoom"]
322
+ if zoom > 1:
323
+ image = cut(image, Rect2i(Vector2i.ZERO, image.get_size()), zoom)
324
+ described["zoom"] = zoom
325
+ else:
326
+ var drawn: Vector2i = Vector2i(image.get_width(), image.get_height())
327
+ var target: Vector2i = scaled_to(
328
+ drawn, Read.as_int(params.get("width", 0)), Read.as_int(params.get("height", 0))
329
+ )
330
+ if target != drawn:
331
+ image.resize(target.x, target.y)
136
332
 
137
333
  var screenshot_path: String = requested_path
138
334
  if screenshot_path.begins_with("user://") or screenshot_path.begins_with("res://"):
@@ -141,10 +337,12 @@ func _capture(viewport: Viewport, params: Dictionary, with_windows: bool = false
141
337
  if save_error != OK:
142
338
  return {"type": "error", "message": "Failed to save screenshot as PNG: " + str(save_error)}
143
339
 
144
- return {
340
+ var answer: Dictionary = {
145
341
  "type": "screenshot_file",
146
342
  "format": "png",
147
343
  "width": image.get_width(),
148
344
  "height": image.get_height(),
149
345
  "path": screenshot_path
150
346
  }
347
+ answer.merge(described)
348
+ return answer
package/build/index.js CHANGED
@@ -31825,7 +31825,7 @@ var TOOL_SPECS = [
31825
31825
  },
31826
31826
  {
31827
31827
  name: "runtime_capture",
31828
- description: "A picture of the running game: the whole screen or one viewport, as an image. The whole screen includes the game's own dialogs and popups where they sit, also when a project opens them as windows of their own (subwindows not embedded). Needs the game running with a window. A capture asked for before the game's first frame is drawn waits for that frame, since the game announces itself before it and the viewport holds nothing it drew until then.",
31828
+ description: "A picture of the running game: the whole screen, one region or node of it at full resolution, or one viewport, as an image. The whole screen includes the game's own dialogs and popups where they sit, also when a project opens them as windows of their own (subwindows not embedded). Needs the game running with a window. A capture asked for before the game's first frame is drawn waits for that frame, since the game announces itself before it and the viewport holds nothing it drew until then.",
31829
31829
  parameters: {
31830
31830
  projectPath: RUNNING_PROJECT_PATH,
31831
31831
  pid: RUNNING_PID,
@@ -31844,6 +31844,22 @@ var TOOL_SPECS = [
31844
31844
  minimum: 1,
31845
31845
  description: "Scale the image to this height. Given alone, the width keeps the picture's proportions."
31846
31846
  },
31847
+ region: {
31848
+ type: "object",
31849
+ ops: ["screenshot"],
31850
+ description: "screenshot: capture only this rectangle of the window, at the game's own pixels: x, y, width and height in window pixels, or the window rectangle runtime_inspect rect answers, passed as it came. A region partly outside the window is cut to it and the answer says clipped; one wholly outside is refused. The answer names the region captured in window pixels, and drawnAt when the project draws at its own size and stretches that to the window, since the picture then has the game's pixels rather than the window's."
31851
+ },
31852
+ nodePath: {
31853
+ type: "string",
31854
+ ops: ["screenshot"],
31855
+ description: "screenshot: capture only where this node is drawn, as runtime_inspect rect answers it: a Control's rectangle, what a 3D node's geometry covers, or what a Sprite2D draws. A node with a position and no extent is refused; give region around it instead. Not with region."
31856
+ },
31857
+ zoom: {
31858
+ type: "integer",
31859
+ minimum: 1,
31860
+ maximum: 16,
31861
+ description: "Enlarge the picture this many times, each pixel made a square of pixels rather than blended with its neighbours, so a thin line or a corner can be read. Not with width or height. Default 1."
31862
+ },
31847
31863
  outputPath: {
31848
31864
  type: "string",
31849
31865
  description: "Also save the picture as a PNG at this absolute path, for somebody to open later; the answer says where. Refused unless it ends in .png, its directory exists, it is outside the project, and no file is there already."
@@ -32161,6 +32177,25 @@ function captureDestinationRefusal(outputPath, projects) {
32161
32177
  }
32162
32178
  return null;
32163
32179
  }
32180
+ function captureDetail(payload) {
32181
+ const parts = [];
32182
+ const region = payload["region"];
32183
+ if (region !== null && typeof region === "object") {
32184
+ const named = region;
32185
+ const at = (key) => String(Math.round(Number(named[key]) * 100) / 100);
32186
+ parts.push(`of the window region at ${at("x")},${at("y")}, ${at("width")}x${at("height")} window pixels${payload["clipped"] === true ? ", clipped to the window" : ""}`);
32187
+ }
32188
+ const drawnAt = payload["drawnAt"];
32189
+ if (drawnAt !== null && typeof drawnAt === "object") {
32190
+ const drawn = drawnAt;
32191
+ parts.push(`at the game's own pixels: it draws at ${String(drawn["width"])}x${String(drawn["height"])} and stretches that to its window`);
32192
+ }
32193
+ if (typeof payload["zoom"] === "number") {
32194
+ parts.push(`enlarged ${payload["zoom"]} times`);
32195
+ }
32196
+ const note = typeof payload["note"] === "string" ? `. ${payload["note"]}` : "";
32197
+ return parts.length === 0 ? note : `, ${parts.join(", ")}${note}`;
32198
+ }
32164
32199
  function patienceForFrames(frames, atLeast) {
32165
32200
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
32166
32201
  }
@@ -36978,7 +37013,7 @@ ${staleNote}`);
36978
37013
  content: [
36979
37014
  {
36980
37015
  type: "text",
36981
- text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}${staleNote === undefined ? "" : `
37016
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${captureDetail(payload)}${saveTo === null ? "" : `. Saved to ${saveTo}`}${staleNote === undefined ? "" : `
36982
37017
 
36983
37018
  ${staleNote}`}`
36984
37019
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.29",
3
+ "version": "1.1.30",
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",