gdharness 1.1.29 → 1.1.31

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
@@ -384,9 +384,18 @@ function readCommandLine(command) {
384
384
  return {
385
385
  executable,
386
386
  projectPath,
387
- editor: words.some((word) => word === "-e" || word === "--editor")
387
+ editor: words.some((word) => word === "-e" || word === "--editor"),
388
+ oneShot: words.find((word) => ONE_SHOT_OPTIONS.includes(word)) ?? null
388
389
  };
389
390
  }
391
+ async function oneShotOptionOf(pid) {
392
+ const tree = await processTree();
393
+ const listed = tree?.get(pid);
394
+ if (listed === undefined || listed.command === "") {
395
+ return;
396
+ }
397
+ return readCommandLine(listed.command).oneShot;
398
+ }
390
399
  function commandWords(command) {
391
400
  const words = [];
392
401
  let word = "";
@@ -422,10 +431,31 @@ async function askPosix() {
422
431
  });
423
432
  return stdout;
424
433
  }
425
- var run, ASK_TIMEOUT_MS = 15000, lastListingFailure;
434
+ var run, ASK_TIMEOUT_MS = 15000, lastListingFailure, ONE_SHOT_OPTIONS;
426
435
  var init_process_children = __esm(() => {
427
436
  init_windows_ask();
428
437
  run = promisify(execFile);
438
+ ONE_SHOT_OPTIONS = [
439
+ "--import",
440
+ "--quit",
441
+ "--quit-after",
442
+ "--export-release",
443
+ "--export-debug",
444
+ "--export-pack",
445
+ "--export-patch",
446
+ "--doctool",
447
+ "--gdscript-docs",
448
+ "--script",
449
+ "-s",
450
+ "--check-only",
451
+ "--build-solutions",
452
+ "--dump-gdextension-interface",
453
+ "--dump-extension-api",
454
+ "--dump-extension-api-with-docs",
455
+ "--validate-extension-api",
456
+ "--convert-3to4",
457
+ "--validate-conversion-3to4"
458
+ ];
429
459
  });
430
460
 
431
461
  // src/bridge-announce.ts
@@ -17617,7 +17647,7 @@ var init_tool_definitions = __esm(() => {
17617
17647
  },
17618
17648
  {
17619
17649
  name: "editor_status",
17620
- description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it; null when there is none. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs.",
17650
+ description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it; null when there is none. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs. An engine run that loads the editor addon to do one job and quit, such as a gate's godot --headless --import, an export or a script, is not taken as the editor: a process that greets without a server's mark has its command line read first, and while that is read identifying is true and connected false; one started with such an option is turned away, and turnedAway names the last one, its pid and the option.",
17621
17651
  parameters: {},
17622
17652
  requires: []
17623
17653
  },
@@ -17841,7 +17871,7 @@ var init_tool_definitions = __esm(() => {
17841
17871
  },
17842
17872
  {
17843
17873
  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.",
17874
+ 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
17875
  parameters: {
17846
17876
  projectPath: RUNNING_PROJECT_PATH,
17847
17877
  pid: RUNNING_PID,
@@ -17860,6 +17890,22 @@ var init_tool_definitions = __esm(() => {
17860
17890
  minimum: 1,
17861
17891
  description: "Scale the image to this height. Given alone, the width keeps the picture's proportions."
17862
17892
  },
17893
+ region: {
17894
+ type: "object",
17895
+ ops: ["screenshot"],
17896
+ 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."
17897
+ },
17898
+ nodePath: {
17899
+ type: "string",
17900
+ ops: ["screenshot"],
17901
+ 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."
17902
+ },
17903
+ zoom: {
17904
+ type: "integer",
17905
+ minimum: 1,
17906
+ maximum: 16,
17907
+ 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."
17908
+ },
17863
17909
  outputPath: {
17864
17910
  type: "string",
17865
17911
  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."
@@ -37461,7 +37507,7 @@ function getDefaultBridge(ownProject = null) {
37461
37507
  defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, ownProject);
37462
37508
  return defaultBridge;
37463
37509
  }
37464
- var DEFAULT_PORT = 6505, DEFAULT_HOST = "127.0.0.1", DEFAULT_TIMEOUT_MS = 30000, KEEPALIVE_INTERVAL_MS = 1e4, SECOND_CONNECTION_CLOSE_CODE = 4000, OTHER_PROJECT_CLOSE_CODE = 4001, CONNECT_WINDOW_MS = 35000, GodotBridge, defaultBridge = null;
37510
+ var DEFAULT_PORT = 6505, DEFAULT_HOST = "127.0.0.1", DEFAULT_TIMEOUT_MS = 30000, KEEPALIVE_INTERVAL_MS = 1e4, SECOND_CONNECTION_CLOSE_CODE = 4000, OTHER_PROJECT_CLOSE_CODE = 4001, ONE_SHOT_CLOSE_CODE = 4002, CONNECT_WINDOW_MS = 35000, GodotBridge, defaultBridge = null;
37465
37511
  var init_godot_bridge = __esm(() => {
37466
37512
  init_wrapper();
37467
37513
  init_errors();
@@ -37474,6 +37520,9 @@ var init_godot_bridge = __esm(() => {
37474
37520
  socket = null;
37475
37521
  pingInterval = null;
37476
37522
  connectionInfo = null;
37523
+ oneShotReader = null;
37524
+ oneShots = new Map;
37525
+ turnedAway = null;
37477
37526
  pendingRequests = new Map;
37478
37527
  resourceQueues = new Map;
37479
37528
  wantedPort;
@@ -37488,6 +37537,9 @@ var init_godot_bridge = __esm(() => {
37488
37537
  this.timeoutMs = timeoutMs;
37489
37538
  this.ownProject = ownProject;
37490
37539
  }
37540
+ identifyEditorsWith(reader) {
37541
+ this.oneShotReader = reader;
37542
+ }
37491
37543
  get mayMove() {
37492
37544
  return this.ownProject !== null;
37493
37545
  }
@@ -37594,25 +37646,29 @@ var init_godot_bridge = __esm(() => {
37594
37646
  this.log("info", "WebSocket bridge stopped");
37595
37647
  }
37596
37648
  isConnected() {
37597
- return this.socket?.readyState === import_websocket.default.OPEN;
37649
+ return this.socket?.readyState === import_websocket.default.OPEN && this.connectionInfo?.identifying !== true;
37598
37650
  }
37599
37651
  getStatus() {
37652
+ const identifying = this.connectionInfo?.identifying === true;
37653
+ const editor = identifying ? null : this.connectionInfo;
37600
37654
  return {
37601
37655
  host: this.host,
37602
37656
  port: this.port,
37603
37657
  connected: this.isConnected(),
37604
- projectPath: this.connectionInfo?.projectPath,
37605
- connectedAt: this.connectionInfo?.connectedAt,
37606
- lastPongAt: this.connectionInfo?.lastPongAt,
37607
- addonVersion: this.connectionInfo?.addonVersion,
37608
- addonDigest: this.connectionInfo?.addonDigest,
37609
- editorPid: this.connectionInfo?.editorPid,
37610
- lspPort: this.connectionInfo?.lspPort,
37611
- dapPort: this.connectionInfo?.dapPort,
37612
- debugPort: this.connectionInfo?.debugPort,
37613
- openedByAServer: this.connectionInfo?.openedByAServer,
37614
- startedByAnEditor: this.connectionInfo?.startedByAnEditor,
37615
- syncsBreakpoints: this.connectionInfo?.syncsBreakpoints,
37658
+ projectPath: editor?.projectPath,
37659
+ connectedAt: editor?.connectedAt,
37660
+ lastPongAt: editor?.lastPongAt,
37661
+ addonVersion: editor?.addonVersion,
37662
+ addonDigest: editor?.addonDigest,
37663
+ editorPid: editor?.editorPid,
37664
+ lspPort: editor?.lspPort,
37665
+ dapPort: editor?.dapPort,
37666
+ debugPort: editor?.debugPort,
37667
+ openedByAServer: editor?.openedByAServer,
37668
+ startedByAnEditor: editor?.startedByAnEditor,
37669
+ syncsBreakpoints: editor?.syncsBreakpoints,
37670
+ turnedAway: this.turnedAway ?? undefined,
37671
+ identifying: identifying ? true : undefined,
37616
37672
  pendingRequests: this.pendingRequests.size,
37617
37673
  queuedResources: this.resourceQueues.size,
37618
37674
  listeningSince: this.listeningSince ?? undefined,
@@ -37760,6 +37816,11 @@ var init_godot_bridge = __esm(() => {
37760
37816
  this.connectionInfo.startedByAnEditor = typeof message.started_by_an_editor === "boolean" ? message.started_by_an_editor : undefined;
37761
37817
  this.connectionInfo.syncsBreakpoints = typeof message.syncs_breakpoints === "boolean" ? message.syncs_breakpoints : undefined;
37762
37818
  this.log("info", `Godot ready: ${message.project_path}`);
37819
+ const pid = this.connectionInfo.editorPid;
37820
+ if (!this.connectionInfo.openedByAServer && pid !== undefined && this.oneShotReader !== null) {
37821
+ this.identify(pid);
37822
+ return;
37823
+ }
37763
37824
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
37764
37825
  }
37765
37826
  return;
@@ -37850,6 +37911,45 @@ var init_godot_bridge = __esm(() => {
37850
37911
  this.stopKeepalive();
37851
37912
  stranger?.close(OTHER_PROJECT_CLOSE_CODE, said);
37852
37913
  }
37914
+ identify(pid) {
37915
+ const known = this.oneShots.get(pid);
37916
+ if (known !== undefined) {
37917
+ this.turnAway(pid, known);
37918
+ return;
37919
+ }
37920
+ const info = this.connectionInfo;
37921
+ const socket = this.socket;
37922
+ const reader = this.oneShotReader;
37923
+ if (info === null || reader === null) {
37924
+ return;
37925
+ }
37926
+ info.identifying = true;
37927
+ reader(pid).catch(() => {
37928
+ return;
37929
+ }).then((option) => {
37930
+ if (this.socket !== socket || this.connectionInfo !== info) {
37931
+ return;
37932
+ }
37933
+ if (typeof option === "string") {
37934
+ this.oneShots.set(pid, option);
37935
+ this.turnAway(pid, option);
37936
+ return;
37937
+ }
37938
+ info.identifying = false;
37939
+ this.log("info", `Godot editor identified: pid ${pid}`);
37940
+ this.emitBridgeEvent("godot_connected", { projectPath: info.projectPath });
37941
+ });
37942
+ }
37943
+ turnAway(pid, option) {
37944
+ const said = `pid ${pid} was started with ${option}, which does one job and quits, so it is not an editor`;
37945
+ this.log("info", `Turning away an engine run: ${said}`);
37946
+ this.turnedAway = { pid, option, at: new Date };
37947
+ const run = this.socket;
37948
+ this.socket = null;
37949
+ this.connectionInfo = null;
37950
+ this.stopKeepalive();
37951
+ run?.close(ONE_SHOT_CLOSE_CODE, said);
37952
+ }
37853
37953
  handleDisconnect(disconnectedSocket, reason) {
37854
37954
  if (disconnectedSocket && this.socket && disconnectedSocket !== this.socket) {
37855
37955
  this.log("debug", "Ignoring stale Godot socket disconnect event");
@@ -39645,6 +39745,25 @@ function captureDestinationRefusal(outputPath, projects) {
39645
39745
  }
39646
39746
  return null;
39647
39747
  }
39748
+ function captureDetail(payload) {
39749
+ const parts = [];
39750
+ const region = payload["region"];
39751
+ if (region !== null && typeof region === "object") {
39752
+ const named = region;
39753
+ const at = (key) => String(Math.round(Number(named[key]) * 100) / 100);
39754
+ 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" : ""}`);
39755
+ }
39756
+ const drawnAt = payload["drawnAt"];
39757
+ if (drawnAt !== null && typeof drawnAt === "object") {
39758
+ const drawn = drawnAt;
39759
+ parts.push(`at the game's own pixels: it draws at ${String(drawn["width"])}x${String(drawn["height"])} and stretches that to its window`);
39760
+ }
39761
+ if (typeof payload["zoom"] === "number") {
39762
+ parts.push(`enlarged ${payload["zoom"]} times`);
39763
+ }
39764
+ const note = typeof payload["note"] === "string" ? `. ${payload["note"]}` : "";
39765
+ return parts.length === 0 ? note : `, ${parts.join(", ")}${note}`;
39766
+ }
39648
39767
  function patienceForFrames(frames, atLeast) {
39649
39768
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
39650
39769
  }
@@ -40224,6 +40343,7 @@ class GodotServer {
40224
40343
  constructor() {
40225
40344
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
40226
40345
  this.godotBridge = getDefaultBridge(this.ownProject);
40346
+ this.godotBridge.identifyEditorsWith(oneShotOptionOf);
40227
40347
  this.godotBridge.on("godot_connected", () => {
40228
40348
  this.settleTheLaunchReading();
40229
40349
  });
@@ -42483,6 +42603,10 @@ class GodotServer {
42483
42603
  const dap = portFromEnvOrNull("GDHARNESS_DAP_PORT") ?? await freePort(DEFAULT_DAP_PORT);
42484
42604
  return { lsp, dap };
42485
42605
  }
42606
+ editorOnTheBridge() {
42607
+ const pid = this.godotBridge.getStatus().editorPid;
42608
+ return pid === undefined ? "the editor on this bridge" : `the editor on this bridge (pid ${pid})`;
42609
+ }
42486
42610
  async handleRunProject(args, op) {
42487
42611
  const project = this.project(args);
42488
42612
  if (!project.ok) {
@@ -42490,7 +42614,7 @@ class GodotServer {
42490
42614
  }
42491
42615
  const open = this.godotBridge.isConnected() ? this.godotBridge.getStatus().projectPath : undefined;
42492
42616
  if (op !== "check" && open !== undefined && open !== "" && !isSameDirectory(open, project.value.path)) {
42493
- return this.createErrorResponse(`This start names ${project.value.path}, and the editor on this bridge has ${open} open.`, [
42617
+ return this.createErrorResponse(`This start names ${project.value.path}, and ${this.editorOnTheBridge()} has ${open} open.`, [
42494
42618
  "editor_status names the project this server's editor is showing",
42495
42619
  "One server serves one editor: start a second server for the other project"
42496
42620
  ]);
@@ -44207,7 +44331,7 @@ class GodotServer {
44207
44331
  const open = this.godotBridge.getStatus().projectPath;
44208
44332
  const meant = readNonEmptyString(args, "projectPath");
44209
44333
  if (open !== undefined && open !== "" && meant !== undefined && !isSameDirectory(open, meant)) {
44210
- return this.createErrorResponse(`This call names ${meant}, and the editor on this bridge has ${open} open.`, [
44334
+ return this.createErrorResponse(`This call names ${meant}, and ${this.editorOnTheBridge()} has ${open} open.`, [
44211
44335
  "editor_status names the project this server's editor is showing",
44212
44336
  "One server serves one editor: start a second server for the other project"
44213
44337
  ]);
@@ -44398,7 +44522,7 @@ ${staleNote}`);
44398
44522
  content: [
44399
44523
  {
44400
44524
  type: "text",
44401
- text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}${staleNote === undefined ? "" : `
44525
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${captureDetail(payload)}${saveTo === null ? "" : `. Saved to ${saveTo}`}${staleNote === undefined ? "" : `
44402
44526
 
44403
44527
  ${staleNote}`}`
44404
44528
  },
@@ -25,6 +25,10 @@ const MAX_RECONNECT_DELAY: float = 30.0
25
25
  ## What a server closes with when the editor that said hello belongs to another project. Kept in
26
26
  ## step with `OTHER_PROJECT_CLOSE_CODE` in src/godot-bridge.ts.
27
27
  const ELSEWHERE_CLOSE_CODE: int = 4001
28
+ ## What a server closes with when this process is one job that quits, such as a gate's `--import`,
29
+ ## rather than an editor: dialling in again changes nothing for the rest of the run. Kept in step
30
+ ## with `ONE_SHOT_CLOSE_CODE` in src/godot-bridge.ts.
31
+ const ONE_SHOT_CLOSE_CODE: int = 4002
28
32
 
29
33
  ## Where Godot keeps the three ports an editor serves. None of them is per editor: the settings
30
34
  ## file is one for every editor of this engine version on the machine, so two open at once want the
@@ -447,6 +451,8 @@ func _read_marker(path: String) -> String:
447
451
  func _handle_disconnect() -> void:
448
452
  _is_connected = false
449
453
  _said_elsewhere()
454
+ if socket.get_close_code() == ONE_SHOT_CLOSE_CODE:
455
+ _should_reconnect = false
450
456
  disconnected.emit()
451
457
 
452
458
  if _should_reconnect:
@@ -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
@@ -25284,6 +25284,27 @@ function ancestorsIn(tree, pid, root) {
25284
25284
  }
25285
25285
  return chain;
25286
25286
  }
25287
+ var ONE_SHOT_OPTIONS = [
25288
+ "--import",
25289
+ "--quit",
25290
+ "--quit-after",
25291
+ "--export-release",
25292
+ "--export-debug",
25293
+ "--export-pack",
25294
+ "--export-patch",
25295
+ "--doctool",
25296
+ "--gdscript-docs",
25297
+ "--script",
25298
+ "-s",
25299
+ "--check-only",
25300
+ "--build-solutions",
25301
+ "--dump-gdextension-interface",
25302
+ "--dump-extension-api",
25303
+ "--dump-extension-api-with-docs",
25304
+ "--validate-extension-api",
25305
+ "--convert-3to4",
25306
+ "--validate-conversion-3to4"
25307
+ ];
25287
25308
  function readCommandLine(command) {
25288
25309
  const all = commandWords(command);
25289
25310
  const separator = all.findIndex((word) => word === "--" || word === "++");
@@ -25304,9 +25325,18 @@ function readCommandLine(command) {
25304
25325
  return {
25305
25326
  executable,
25306
25327
  projectPath,
25307
- editor: words.some((word) => word === "-e" || word === "--editor")
25328
+ editor: words.some((word) => word === "-e" || word === "--editor"),
25329
+ oneShot: words.find((word) => ONE_SHOT_OPTIONS.includes(word)) ?? null
25308
25330
  };
25309
25331
  }
25332
+ async function oneShotOptionOf(pid) {
25333
+ const tree = await processTree();
25334
+ const listed = tree?.get(pid);
25335
+ if (listed === undefined || listed.command === "") {
25336
+ return;
25337
+ }
25338
+ return readCommandLine(listed.command).oneShot;
25339
+ }
25310
25340
  function commandWords(command) {
25311
25341
  const words = [];
25312
25342
  let word = "";
@@ -27664,6 +27694,7 @@ var DEFAULT_TIMEOUT_MS = 30000;
27664
27694
  var KEEPALIVE_INTERVAL_MS = 1e4;
27665
27695
  var SECOND_CONNECTION_CLOSE_CODE = 4000;
27666
27696
  var OTHER_PROJECT_CLOSE_CODE = 4001;
27697
+ var ONE_SHOT_CLOSE_CODE = 4002;
27667
27698
  function resolveDefaultBridgePort() {
27668
27699
  return portFromEnv("GDHARNESS_BRIDGE_PORT", DEFAULT_PORT);
27669
27700
  }
@@ -27695,6 +27726,9 @@ class GodotBridge extends EventEmitter {
27695
27726
  socket = null;
27696
27727
  pingInterval = null;
27697
27728
  connectionInfo = null;
27729
+ oneShotReader = null;
27730
+ oneShots = new Map;
27731
+ turnedAway = null;
27698
27732
  pendingRequests = new Map;
27699
27733
  resourceQueues = new Map;
27700
27734
  wantedPort;
@@ -27709,6 +27743,9 @@ class GodotBridge extends EventEmitter {
27709
27743
  this.timeoutMs = timeoutMs;
27710
27744
  this.ownProject = ownProject;
27711
27745
  }
27746
+ identifyEditorsWith(reader) {
27747
+ this.oneShotReader = reader;
27748
+ }
27712
27749
  get mayMove() {
27713
27750
  return this.ownProject !== null;
27714
27751
  }
@@ -27815,25 +27852,29 @@ class GodotBridge extends EventEmitter {
27815
27852
  this.log("info", "WebSocket bridge stopped");
27816
27853
  }
27817
27854
  isConnected() {
27818
- return this.socket?.readyState === import_websocket.default.OPEN;
27855
+ return this.socket?.readyState === import_websocket.default.OPEN && this.connectionInfo?.identifying !== true;
27819
27856
  }
27820
27857
  getStatus() {
27858
+ const identifying = this.connectionInfo?.identifying === true;
27859
+ const editor = identifying ? null : this.connectionInfo;
27821
27860
  return {
27822
27861
  host: this.host,
27823
27862
  port: this.port,
27824
27863
  connected: this.isConnected(),
27825
- projectPath: this.connectionInfo?.projectPath,
27826
- connectedAt: this.connectionInfo?.connectedAt,
27827
- lastPongAt: this.connectionInfo?.lastPongAt,
27828
- addonVersion: this.connectionInfo?.addonVersion,
27829
- addonDigest: this.connectionInfo?.addonDigest,
27830
- editorPid: this.connectionInfo?.editorPid,
27831
- lspPort: this.connectionInfo?.lspPort,
27832
- dapPort: this.connectionInfo?.dapPort,
27833
- debugPort: this.connectionInfo?.debugPort,
27834
- openedByAServer: this.connectionInfo?.openedByAServer,
27835
- startedByAnEditor: this.connectionInfo?.startedByAnEditor,
27836
- syncsBreakpoints: this.connectionInfo?.syncsBreakpoints,
27864
+ projectPath: editor?.projectPath,
27865
+ connectedAt: editor?.connectedAt,
27866
+ lastPongAt: editor?.lastPongAt,
27867
+ addonVersion: editor?.addonVersion,
27868
+ addonDigest: editor?.addonDigest,
27869
+ editorPid: editor?.editorPid,
27870
+ lspPort: editor?.lspPort,
27871
+ dapPort: editor?.dapPort,
27872
+ debugPort: editor?.debugPort,
27873
+ openedByAServer: editor?.openedByAServer,
27874
+ startedByAnEditor: editor?.startedByAnEditor,
27875
+ syncsBreakpoints: editor?.syncsBreakpoints,
27876
+ turnedAway: this.turnedAway ?? undefined,
27877
+ identifying: identifying ? true : undefined,
27837
27878
  pendingRequests: this.pendingRequests.size,
27838
27879
  queuedResources: this.resourceQueues.size,
27839
27880
  listeningSince: this.listeningSince ?? undefined,
@@ -27981,6 +28022,11 @@ class GodotBridge extends EventEmitter {
27981
28022
  this.connectionInfo.startedByAnEditor = typeof message.started_by_an_editor === "boolean" ? message.started_by_an_editor : undefined;
27982
28023
  this.connectionInfo.syncsBreakpoints = typeof message.syncs_breakpoints === "boolean" ? message.syncs_breakpoints : undefined;
27983
28024
  this.log("info", `Godot ready: ${message.project_path}`);
28025
+ const pid = this.connectionInfo.editorPid;
28026
+ if (!this.connectionInfo.openedByAServer && pid !== undefined && this.oneShotReader !== null) {
28027
+ this.identify(pid);
28028
+ return;
28029
+ }
27984
28030
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
27985
28031
  }
27986
28032
  return;
@@ -28071,6 +28117,45 @@ class GodotBridge extends EventEmitter {
28071
28117
  this.stopKeepalive();
28072
28118
  stranger?.close(OTHER_PROJECT_CLOSE_CODE, said);
28073
28119
  }
28120
+ identify(pid) {
28121
+ const known = this.oneShots.get(pid);
28122
+ if (known !== undefined) {
28123
+ this.turnAway(pid, known);
28124
+ return;
28125
+ }
28126
+ const info = this.connectionInfo;
28127
+ const socket = this.socket;
28128
+ const reader = this.oneShotReader;
28129
+ if (info === null || reader === null) {
28130
+ return;
28131
+ }
28132
+ info.identifying = true;
28133
+ reader(pid).catch(() => {
28134
+ return;
28135
+ }).then((option) => {
28136
+ if (this.socket !== socket || this.connectionInfo !== info) {
28137
+ return;
28138
+ }
28139
+ if (typeof option === "string") {
28140
+ this.oneShots.set(pid, option);
28141
+ this.turnAway(pid, option);
28142
+ return;
28143
+ }
28144
+ info.identifying = false;
28145
+ this.log("info", `Godot editor identified: pid ${pid}`);
28146
+ this.emitBridgeEvent("godot_connected", { projectPath: info.projectPath });
28147
+ });
28148
+ }
28149
+ turnAway(pid, option) {
28150
+ const said = `pid ${pid} was started with ${option}, which does one job and quits, so it is not an editor`;
28151
+ this.log("info", `Turning away an engine run: ${said}`);
28152
+ this.turnedAway = { pid, option, at: new Date };
28153
+ const run = this.socket;
28154
+ this.socket = null;
28155
+ this.connectionInfo = null;
28156
+ this.stopKeepalive();
28157
+ run?.close(ONE_SHOT_CLOSE_CODE, said);
28158
+ }
28074
28159
  handleDisconnect(disconnectedSocket, reason) {
28075
28160
  if (disconnectedSocket && this.socket && disconnectedSocket !== this.socket) {
28076
28161
  this.log("debug", "Ignoring stale Godot socket disconnect event");
@@ -31601,7 +31686,7 @@ var TOOL_SPECS = [
31601
31686
  },
31602
31687
  {
31603
31688
  name: "editor_status",
31604
- description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it; null when there is none. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs.",
31689
+ description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it; null when there is none. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs. An engine run that loads the editor addon to do one job and quit, such as a gate's godot --headless --import, an export or a script, is not taken as the editor: a process that greets without a server's mark has its command line read first, and while that is read identifying is true and connected false; one started with such an option is turned away, and turnedAway names the last one, its pid and the option.",
31605
31690
  parameters: {},
31606
31691
  requires: []
31607
31692
  },
@@ -31825,7 +31910,7 @@ var TOOL_SPECS = [
31825
31910
  },
31826
31911
  {
31827
31912
  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.",
31913
+ 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
31914
  parameters: {
31830
31915
  projectPath: RUNNING_PROJECT_PATH,
31831
31916
  pid: RUNNING_PID,
@@ -31844,6 +31929,22 @@ var TOOL_SPECS = [
31844
31929
  minimum: 1,
31845
31930
  description: "Scale the image to this height. Given alone, the width keeps the picture's proportions."
31846
31931
  },
31932
+ region: {
31933
+ type: "object",
31934
+ ops: ["screenshot"],
31935
+ 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."
31936
+ },
31937
+ nodePath: {
31938
+ type: "string",
31939
+ ops: ["screenshot"],
31940
+ 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."
31941
+ },
31942
+ zoom: {
31943
+ type: "integer",
31944
+ minimum: 1,
31945
+ maximum: 16,
31946
+ 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."
31947
+ },
31847
31948
  outputPath: {
31848
31949
  type: "string",
31849
31950
  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 +32262,25 @@ function captureDestinationRefusal(outputPath, projects) {
32161
32262
  }
32162
32263
  return null;
32163
32264
  }
32265
+ function captureDetail(payload) {
32266
+ const parts = [];
32267
+ const region = payload["region"];
32268
+ if (region !== null && typeof region === "object") {
32269
+ const named = region;
32270
+ const at = (key) => String(Math.round(Number(named[key]) * 100) / 100);
32271
+ 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" : ""}`);
32272
+ }
32273
+ const drawnAt = payload["drawnAt"];
32274
+ if (drawnAt !== null && typeof drawnAt === "object") {
32275
+ const drawn = drawnAt;
32276
+ parts.push(`at the game's own pixels: it draws at ${String(drawn["width"])}x${String(drawn["height"])} and stretches that to its window`);
32277
+ }
32278
+ if (typeof payload["zoom"] === "number") {
32279
+ parts.push(`enlarged ${payload["zoom"]} times`);
32280
+ }
32281
+ const note = typeof payload["note"] === "string" ? `. ${payload["note"]}` : "";
32282
+ return parts.length === 0 ? note : `, ${parts.join(", ")}${note}`;
32283
+ }
32164
32284
  function patienceForFrames(frames, atLeast) {
32165
32285
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
32166
32286
  }
@@ -32804,6 +32924,7 @@ class GodotServer {
32804
32924
  constructor() {
32805
32925
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
32806
32926
  this.godotBridge = getDefaultBridge(this.ownProject);
32927
+ this.godotBridge.identifyEditorsWith(oneShotOptionOf);
32807
32928
  this.godotBridge.on("godot_connected", () => {
32808
32929
  this.settleTheLaunchReading();
32809
32930
  });
@@ -35063,6 +35184,10 @@ class GodotServer {
35063
35184
  const dap = portFromEnvOrNull("GDHARNESS_DAP_PORT") ?? await freePort(DEFAULT_DAP_PORT);
35064
35185
  return { lsp, dap };
35065
35186
  }
35187
+ editorOnTheBridge() {
35188
+ const pid = this.godotBridge.getStatus().editorPid;
35189
+ return pid === undefined ? "the editor on this bridge" : `the editor on this bridge (pid ${pid})`;
35190
+ }
35066
35191
  async handleRunProject(args, op) {
35067
35192
  const project = this.project(args);
35068
35193
  if (!project.ok) {
@@ -35070,7 +35195,7 @@ class GodotServer {
35070
35195
  }
35071
35196
  const open = this.godotBridge.isConnected() ? this.godotBridge.getStatus().projectPath : undefined;
35072
35197
  if (op !== "check" && open !== undefined && open !== "" && !isSameDirectory(open, project.value.path)) {
35073
- return this.createErrorResponse(`This start names ${project.value.path}, and the editor on this bridge has ${open} open.`, [
35198
+ return this.createErrorResponse(`This start names ${project.value.path}, and ${this.editorOnTheBridge()} has ${open} open.`, [
35074
35199
  "editor_status names the project this server's editor is showing",
35075
35200
  "One server serves one editor: start a second server for the other project"
35076
35201
  ]);
@@ -36787,7 +36912,7 @@ class GodotServer {
36787
36912
  const open = this.godotBridge.getStatus().projectPath;
36788
36913
  const meant = readNonEmptyString(args, "projectPath");
36789
36914
  if (open !== undefined && open !== "" && meant !== undefined && !isSameDirectory(open, meant)) {
36790
- return this.createErrorResponse(`This call names ${meant}, and the editor on this bridge has ${open} open.`, [
36915
+ return this.createErrorResponse(`This call names ${meant}, and ${this.editorOnTheBridge()} has ${open} open.`, [
36791
36916
  "editor_status names the project this server's editor is showing",
36792
36917
  "One server serves one editor: start a second server for the other project"
36793
36918
  ]);
@@ -36978,7 +37103,7 @@ ${staleNote}`);
36978
37103
  content: [
36979
37104
  {
36980
37105
  type: "text",
36981
- text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}${staleNote === undefined ? "" : `
37106
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${captureDetail(payload)}${saveTo === null ? "" : `. Saved to ${saveTo}`}${staleNote === undefined ? "" : `
36982
37107
 
36983
37108
  ${staleNote}`}`
36984
37109
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.29",
3
+ "version": "1.1.31",
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",