gdharness 1.1.28 → 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
|
@@ -17144,7 +17144,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
17144
17144
|
requires: ["resourcePath", "options"]
|
|
17145
17145
|
},
|
|
17146
17146
|
reimport: {
|
|
17147
|
-
summary: "reimport what status says is outdated or failed, one resource with resourcePath, or what is current as well with force. Through the open editor when one serves the project, so it reloads what it holds, waiting for it to finish; otherwise through the engine's own import pass. Answered from a second status read: reimported lists what is current now, notReimported what is not, with its status and reason, and via says which of the two did the work. stillImporting is an editor that had not finished within timeoutMs",
|
|
17147
|
+
summary: "reimport what status says is outdated or failed, one resource with resourcePath, or what is current as well with force. Through the open editor when one serves the project, so it reloads what it holds, waiting for it to finish; otherwise through the engine's own import pass. Answered from a second status read: reimported lists what is current now, notReimported what is not, with its status and reason, and via says which of the two did the work. A whole-project reimport also answers missingSource, the .import files whose source is not beside them, empty when there are none, whether or not anything needed reimporting; one resourcePath is not a walk of the project and leaves it out. stillImporting is an editor that had not finished within timeoutMs",
|
|
17148
17148
|
requires: []
|
|
17149
17149
|
},
|
|
17150
17150
|
uid: {
|
|
@@ -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
|
}
|
|
@@ -41377,6 +41412,7 @@ class GodotServer {
|
|
|
41377
41412
|
}
|
|
41378
41413
|
const listed = importStatuses(before.payload);
|
|
41379
41414
|
const missingSource = listed.filter((one) => one.status === "missing_source").map((one) => one.path);
|
|
41415
|
+
const sourceless = resourcePath === undefined ? { missingSource } : {};
|
|
41380
41416
|
if (resourcePath !== undefined && missingSource.length > 0) {
|
|
41381
41417
|
return {
|
|
41382
41418
|
ok: false,
|
|
@@ -41397,6 +41433,7 @@ class GodotServer {
|
|
|
41397
41433
|
payload: {
|
|
41398
41434
|
reimported: [],
|
|
41399
41435
|
notReimported: [],
|
|
41436
|
+
...sourceless,
|
|
41400
41437
|
note: resourcePath === undefined ? "Nothing needed reimporting. Pass force to reimport what is current." : `${listed[0]?.path ?? resourcePath} is current. Pass force to reimport it anyway.`
|
|
41401
41438
|
}
|
|
41402
41439
|
};
|
|
@@ -41415,6 +41452,7 @@ class GodotServer {
|
|
|
41415
41452
|
via: "editor",
|
|
41416
41453
|
stillImporting: true,
|
|
41417
41454
|
asked: targets,
|
|
41455
|
+
...sourceless,
|
|
41418
41456
|
note: `The editor had not finished after ${timeoutMs} ms. project_import status says what it has done so far; pass a longer timeoutMs to wait it out.`
|
|
41419
41457
|
}
|
|
41420
41458
|
};
|
|
@@ -41451,7 +41489,7 @@ class GodotServer {
|
|
|
41451
41489
|
via: viaEditor ? "editor" : "engine",
|
|
41452
41490
|
reimported,
|
|
41453
41491
|
notReimported,
|
|
41454
|
-
...
|
|
41492
|
+
...sourceless,
|
|
41455
41493
|
...extra
|
|
41456
41494
|
}
|
|
41457
41495
|
};
|
|
@@ -44395,7 +44433,7 @@ ${staleNote}`);
|
|
|
44395
44433
|
content: [
|
|
44396
44434
|
{
|
|
44397
44435
|
type: "text",
|
|
44398
|
-
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 ? "" : `
|
|
44399
44437
|
|
|
44400
44438
|
${staleNote}`}`
|
|
44401
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
|
|
131
|
-
var
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
@@ -31128,7 +31128,7 @@ var TOOL_SPECS = [
|
|
|
31128
31128
|
requires: ["resourcePath", "options"]
|
|
31129
31129
|
},
|
|
31130
31130
|
reimport: {
|
|
31131
|
-
summary: "reimport what status says is outdated or failed, one resource with resourcePath, or what is current as well with force. Through the open editor when one serves the project, so it reloads what it holds, waiting for it to finish; otherwise through the engine's own import pass. Answered from a second status read: reimported lists what is current now, notReimported what is not, with its status and reason, and via says which of the two did the work. stillImporting is an editor that had not finished within timeoutMs",
|
|
31131
|
+
summary: "reimport what status says is outdated or failed, one resource with resourcePath, or what is current as well with force. Through the open editor when one serves the project, so it reloads what it holds, waiting for it to finish; otherwise through the engine's own import pass. Answered from a second status read: reimported lists what is current now, notReimported what is not, with its status and reason, and via says which of the two did the work. A whole-project reimport also answers missingSource, the .import files whose source is not beside them, empty when there are none, whether or not anything needed reimporting; one resourcePath is not a walk of the project and leaves it out. stillImporting is an editor that had not finished within timeoutMs",
|
|
31132
31132
|
requires: []
|
|
31133
31133
|
},
|
|
31134
31134
|
uid: {
|
|
@@ -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
|
}
|
|
@@ -33957,6 +33992,7 @@ class GodotServer {
|
|
|
33957
33992
|
}
|
|
33958
33993
|
const listed = importStatuses(before.payload);
|
|
33959
33994
|
const missingSource = listed.filter((one) => one.status === "missing_source").map((one) => one.path);
|
|
33995
|
+
const sourceless = resourcePath === undefined ? { missingSource } : {};
|
|
33960
33996
|
if (resourcePath !== undefined && missingSource.length > 0) {
|
|
33961
33997
|
return {
|
|
33962
33998
|
ok: false,
|
|
@@ -33977,6 +34013,7 @@ class GodotServer {
|
|
|
33977
34013
|
payload: {
|
|
33978
34014
|
reimported: [],
|
|
33979
34015
|
notReimported: [],
|
|
34016
|
+
...sourceless,
|
|
33980
34017
|
note: resourcePath === undefined ? "Nothing needed reimporting. Pass force to reimport what is current." : `${listed[0]?.path ?? resourcePath} is current. Pass force to reimport it anyway.`
|
|
33981
34018
|
}
|
|
33982
34019
|
};
|
|
@@ -33995,6 +34032,7 @@ class GodotServer {
|
|
|
33995
34032
|
via: "editor",
|
|
33996
34033
|
stillImporting: true,
|
|
33997
34034
|
asked: targets,
|
|
34035
|
+
...sourceless,
|
|
33998
34036
|
note: `The editor had not finished after ${timeoutMs} ms. project_import status says what it has done so far; pass a longer timeoutMs to wait it out.`
|
|
33999
34037
|
}
|
|
34000
34038
|
};
|
|
@@ -34031,7 +34069,7 @@ class GodotServer {
|
|
|
34031
34069
|
via: viaEditor ? "editor" : "engine",
|
|
34032
34070
|
reimported,
|
|
34033
34071
|
notReimported,
|
|
34034
|
-
...
|
|
34072
|
+
...sourceless,
|
|
34035
34073
|
...extra
|
|
34036
34074
|
}
|
|
34037
34075
|
};
|
|
@@ -36975,7 +37013,7 @@ ${staleNote}`);
|
|
|
36975
37013
|
content: [
|
|
36976
37014
|
{
|
|
36977
37015
|
type: "text",
|
|
36978
|
-
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 ? "" : `
|
|
36979
37017
|
|
|
36980
37018
|
${staleNote}`}`
|
|
36981
37019
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.1.
|
|
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",
|