gdharness 0.6.2 → 0.6.4

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
@@ -157,8 +157,8 @@ function runArguments(options) {
157
157
  }
158
158
  return args;
159
159
  }
160
- function editorArguments(projectPath) {
161
- return ["-e", "--path", projectPath];
160
+ function editorArguments(projectPath, ports) {
161
+ return ["-e", "--path", projectPath, "--lsp-port", String(ports.lsp), "--dap-port", String(ports.dap)];
162
162
  }
163
163
  function userDataIn(home, variables = process.env) {
164
164
  const moved = ["APPDATA", "XDG_DATA_HOME"];
@@ -15834,12 +15834,12 @@ var init_tool_definitions = __esm(() => {
15834
15834
  },
15835
15835
  {
15836
15836
  name: "runtime_input",
15837
- description: "Input to the running game: a whole click on a Control or a 3D node named by path, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
15837
+ description: "Input to the running game: a whole click on a Control or a 3D node named by path, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
15838
15838
  parameters: {
15839
15839
  projectPath: RUNNING_PROJECT_PATH,
15840
15840
  nodePath: {
15841
15841
  type: "string",
15842
- description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn."
15842
+ description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn. choose: the PopupMenu, or the OptionButton or MenuButton in front of one."
15843
15843
  },
15844
15844
  action: { type: "string", description: "action: the InputMap action name." },
15845
15845
  pressed: { type: "boolean", description: "Press or release. Default true." },
@@ -15847,7 +15847,11 @@ var init_tool_definitions = __esm(() => {
15847
15847
  keycode: { type: "string", description: 'key: the key name, such as "Space" or "A".' },
15848
15848
  text: {
15849
15849
  type: "string",
15850
- description: "text: what to type. A newline is Enter and a tab is Tab."
15850
+ description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
15851
+ },
15852
+ index: {
15853
+ type: "number",
15854
+ description: "choose: the item to take, by where it is in the list, when text will not do."
15851
15855
  },
15852
15856
  shift: { type: "boolean" },
15853
15857
  ctrl: { type: "boolean" },
@@ -15869,6 +15873,10 @@ var init_tool_definitions = __esm(() => {
15869
15873
  summary: "press and release on a Control, a frame apart, and answer with what was under the pointer and what became of the control: in_tree, removed or freed. A control out of sight inside a ScrollContainer is scrolled to first, and scrolled_into_view says whether the view moved. A 3D node is clicked where it is drawn, and landed then says the interface did not swallow the press",
15870
15874
  requires: ["nodePath"]
15871
15875
  },
15876
+ choose: {
15877
+ summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen and what the button in front of it shows now",
15878
+ requires: ["nodePath"]
15879
+ },
15872
15880
  action: { summary: "press or release an action", requires: ["action"] },
15873
15881
  key: { summary: "press or release a key", requires: ["keycode"] },
15874
15882
  text: {
@@ -30783,10 +30791,11 @@ var init_framing = __esm(() => {
30783
30791
  });
30784
30792
 
30785
30793
  // src/ports.ts
30786
- function portFromEnv(variable, fallback) {
30794
+ import { createServer } from "node:net";
30795
+ function portFromEnvOrNull(variable) {
30787
30796
  const raw = process.env[variable]?.trim();
30788
30797
  if (!raw) {
30789
- return fallback;
30798
+ return null;
30790
30799
  }
30791
30800
  const parsed = Number.parseInt(raw, 10);
30792
30801
  if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535 || String(parsed) !== raw) {
@@ -30794,6 +30803,36 @@ function portFromEnv(variable, fallback) {
30794
30803
  }
30795
30804
  return parsed;
30796
30805
  }
30806
+ function portFromEnv(variable, fallback) {
30807
+ return portFromEnvOrNull(variable) ?? fallback;
30808
+ }
30809
+ async function freePort(preferred, host = "127.0.0.1") {
30810
+ const asked = await bindable(preferred, host);
30811
+ if (asked !== null) {
30812
+ return asked;
30813
+ }
30814
+ const spare = await bindable(0, host);
30815
+ if (spare === null) {
30816
+ throw new Refusal2(`No port could be bound on ${host}.`);
30817
+ }
30818
+ return spare;
30819
+ }
30820
+ function bindable(port, host) {
30821
+ return new Promise((resolve) => {
30822
+ const probe = createServer();
30823
+ probe.once("error", () => {
30824
+ resolve(null);
30825
+ });
30826
+ probe.once("listening", () => {
30827
+ const bound = probe.address();
30828
+ const taken = typeof bound === "object" && bound !== null ? bound.port : port;
30829
+ probe.close(() => {
30830
+ resolve(taken);
30831
+ });
30832
+ });
30833
+ probe.listen(port, host);
30834
+ });
30835
+ }
30797
30836
  var init_ports = __esm(() => {
30798
30837
  init_errors();
30799
30838
  });
@@ -34181,6 +34220,9 @@ function resolveDefaultBridgeHost() {
34181
34220
  const host = process.env["GDHARNESS_BRIDGE_HOST"]?.trim();
34182
34221
  return host === undefined || host === "" ? DEFAULT_HOST : host;
34183
34222
  }
34223
+ function servedPort(said) {
34224
+ return typeof said === "number" && Number.isInteger(said) && said > 0 && said <= 65535 ? said : undefined;
34225
+ }
34184
34226
  function getDefaultBridge(mayMove = false) {
34185
34227
  defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
34186
34228
  return defaultBridge;
@@ -34321,6 +34363,9 @@ var init_godot_bridge = __esm(() => {
34321
34363
  lastPongAt: this.connectionInfo?.lastPongAt,
34322
34364
  addonVersion: this.connectionInfo?.addonVersion,
34323
34365
  editorPid: this.connectionInfo?.editorPid,
34366
+ lspPort: this.connectionInfo?.lspPort,
34367
+ dapPort: this.connectionInfo?.dapPort,
34368
+ debugPort: this.connectionInfo?.debugPort,
34324
34369
  pendingRequests: this.pendingRequests.size,
34325
34370
  queuedResources: this.resourceQueues.size
34326
34371
  };
@@ -34454,6 +34499,9 @@ var init_godot_bridge = __esm(() => {
34454
34499
  this.connectionInfo.projectPath = message.project_path;
34455
34500
  this.connectionInfo.addonVersion = message.addon_version ?? "";
34456
34501
  this.connectionInfo.editorPid = message.editor_pid;
34502
+ this.connectionInfo.lspPort = servedPort(message.lsp_port);
34503
+ this.connectionInfo.dapPort = servedPort(message.dap_port);
34504
+ this.connectionInfo.debugPort = servedPort(message.debug_port);
34457
34505
  this.log("info", `Godot ready: ${message.project_path}`);
34458
34506
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
34459
34507
  }
@@ -35360,10 +35408,10 @@ function asToolResponse(payload) {
35360
35408
  ]
35361
35409
  };
35362
35410
  }
35363
- function normalizeLSPError(error) {
35411
+ function normalizeLSPError(error, port) {
35364
35412
  if (error instanceof Error) {
35365
35413
  if (error.message.includes("ECONNREFUSED") || error.message.includes("Failed to connect to Godot LSP") || error.message.includes("socket closed")) {
35366
- return `Godot LSP is unavailable on port ${portFromEnv("GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}. Start the Godot editor and enable Language Server in Editor Settings, or set GDHARNESS_LSP_PORT to the port it serves.`;
35414
+ return `Godot LSP is unavailable on port ${port}. Start the Godot editor and enable Language Server in Editor Settings, or set GDHARNESS_LSP_PORT to the port it serves.`;
35367
35415
  }
35368
35416
  return error.message;
35369
35417
  }
@@ -35441,7 +35489,7 @@ async function handleLSPTool(client, toolName, args) {
35441
35489
  }
35442
35490
  } catch (error) {
35443
35491
  return asToolResponse({
35444
- error: normalizeLSPError(error)
35492
+ error: normalizeLSPError(error, client.port)
35445
35493
  });
35446
35494
  }
35447
35495
  }
@@ -36307,12 +36355,23 @@ class GodotServer {
36307
36355
  case "runtime_capture":
36308
36356
  return await this.handleRuntimeCommand(op === "screenshot" ? "capture_screenshot" : "capture_viewport", args);
36309
36357
  case "runtime_input":
36310
- return op === "click" ? await this.handleRuntimeCommand("click", {
36311
- projectPath: args["projectPath"],
36312
- path: readNonEmptyString(args, "nodePath") ?? "",
36313
- button: readString(args, "button") ?? "left",
36314
- double: readBoolean(args, "doubleClick") ?? false
36315
- }) : await this.handleRuntimeCommand(`inject_${op}`, args);
36358
+ if (op === "click") {
36359
+ return await this.handleRuntimeCommand("click", {
36360
+ projectPath: args["projectPath"],
36361
+ path: readNonEmptyString(args, "nodePath") ?? "",
36362
+ button: readString(args, "button") ?? "left",
36363
+ double: readBoolean(args, "doubleClick") ?? false
36364
+ });
36365
+ }
36366
+ if (op === "choose") {
36367
+ return await this.handleRuntimeCommand("choose", {
36368
+ projectPath: args["projectPath"],
36369
+ path: readNonEmptyString(args, "nodePath") ?? "",
36370
+ text: readString(args, "text") ?? "",
36371
+ ...args["index"] === undefined ? {} : { index: args["index"] }
36372
+ });
36373
+ }
36374
+ return await this.handleRuntimeCommand(`inject_${op}`, args);
36316
36375
  case "runtime_wait":
36317
36376
  return await this.handleRuntimeWait(op, args);
36318
36377
  case "debug_breakpoint": {
@@ -36393,7 +36452,7 @@ class GodotServer {
36393
36452
  return {
36394
36453
  ok: false,
36395
36454
  response: this.createErrorResponse(`The editor is playing the game but its debug adapter did not answer: ${errorMessage(error)}`, [
36396
- "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
36455
+ `This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
36397
36456
  "GDHARNESS_DAP_PORT points this server at another one"
36398
36457
  ])
36399
36458
  };
@@ -36760,7 +36819,8 @@ class GodotServer {
36760
36819
  if (payload["error"] !== undefined) {
36761
36820
  const reason = payload["error"];
36762
36821
  return this.createErrorResponse(`Diagnostics unavailable: ${typeof reason === "string" ? reason : JSON.stringify(reason)}`, [
36763
- "Ensure the Godot editor is running with its language server enabled, on port 6005 or on the port GDHARNESS_LSP_PORT names"
36822
+ `Ensure the Godot editor is running with its language server enabled, on port ${this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}`,
36823
+ "GDHARNESS_LSP_PORT points this server at another one"
36764
36824
  ]);
36765
36825
  }
36766
36826
  const diagnostics = readArray(payload, "diagnostics") ?? [];
@@ -36777,16 +36837,33 @@ class GodotServer {
36777
36837
  });
36778
36838
  }
36779
36839
  async handleLSP(toolName, args) {
36780
- this.lspClient ??= new GodotLSPClient;
36840
+ const port = this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT);
36841
+ if (this.lspClient !== null && this.lspClient.port !== port) {
36842
+ await this.lspClient.disconnect();
36843
+ this.lspClient = null;
36844
+ }
36845
+ this.lspClient ??= new GodotLSPClient(port);
36781
36846
  return handleLSPTool(this.lspClient, toolName, args);
36782
36847
  }
36783
36848
  async handleDAP(toolName, args) {
36784
36849
  return handleDAPTool(this.dap(), toolName, args);
36785
36850
  }
36786
36851
  dap() {
36787
- this.dapClient ??= new GodotDAPClient;
36852
+ const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
36853
+ if (this.dapClient !== null && this.dapClient.port !== port) {
36854
+ this.dapClient = null;
36855
+ }
36856
+ this.dapClient ??= new GodotDAPClient(port);
36788
36857
  return this.dapClient;
36789
36858
  }
36859
+ editorServes(field, variable, fallback) {
36860
+ const named = portFromEnvOrNull(variable);
36861
+ if (named !== null) {
36862
+ return named;
36863
+ }
36864
+ const status = this.godotBridge.getStatus();
36865
+ return (status.connected ? status[field] : undefined) ?? fallback;
36866
+ }
36790
36867
  getEditorStatusPayload() {
36791
36868
  const status = this.godotBridge.getStatus();
36792
36869
  const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
@@ -36814,7 +36891,8 @@ class GodotServer {
36814
36891
  const answer = await this.godotBridge.invokeTool("playing_status", {});
36815
36892
  return {
36816
36893
  playing: readBoolean(asParams(answer), "playing") ?? false,
36817
- scene: readString(asParams(answer), "scenePath") ?? ""
36894
+ scene: readString(asParams(answer), "scenePath") ?? "",
36895
+ debugPort: readNumber(asParams(answer), "debugPort")
36818
36896
  };
36819
36897
  } catch {
36820
36898
  return null;
@@ -36832,15 +36910,19 @@ class GodotServer {
36832
36910
  problem: reply.ok ? null : reply.message
36833
36911
  };
36834
36912
  }));
36913
+ const playing = await this.editorPlayingState();
36835
36914
  return this.jsonTextResponse({
36836
- editor: this.getEditorStatusPayload(),
36915
+ editor: {
36916
+ ...this.getEditorStatusPayload(),
36917
+ debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
36918
+ },
36837
36919
  godot: {
36838
36920
  path: godotPath,
36839
36921
  version: godotPath === null ? null : await this.godotVersion(godotPath)
36840
36922
  },
36841
36923
  game: {
36842
36924
  processActive: this.activeProcess !== null,
36843
- playingInEditor: await this.editorPlayingState(),
36925
+ playingInEditor: playing,
36844
36926
  runtimeConnected: games.some((game) => game.reachable),
36845
36927
  runtimes: games
36846
36928
  }
@@ -36899,10 +36981,10 @@ class GodotServer {
36899
36981
  return project.response;
36900
36982
  }
36901
36983
  if (this.godotBridge.isConnected()) {
36902
- return this.createErrorResponse("An editor is already connected to this server, and a second one would take the language server and debug adapter ports from it.", [
36984
+ return this.createErrorResponse("An editor is already connected to this server.", [
36903
36985
  "editor_status says which editor is answering, and for which project",
36904
36986
  "editor_launch restart replaces the connected editor rather than joining it",
36905
- "Close the open editor first if the new project is the one you want"
36987
+ "Another project wants its own gdharness server, which is its own harness session"
36906
36988
  ]);
36907
36989
  }
36908
36990
  const engine = await this.engine();
@@ -36910,10 +36992,16 @@ class GodotServer {
36910
36992
  return engine.response;
36911
36993
  }
36912
36994
  this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
36913
- const editor = spawn(engine.value, editorArguments(project.value.path), {
36995
+ const ports = await this.portsForAnEditor();
36996
+ const editor = spawn(engine.value, editorArguments(project.value.path, ports), {
36914
36997
  stdio: "ignore",
36915
36998
  detached: true,
36916
- env: { ...process.env, GDHARNESS_RUNTIME_DIR: runtimeDirectory() }
36999
+ env: {
37000
+ ...process.env,
37001
+ GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
37002
+ GDHARNESS_LSP_PORT: String(ports.lsp),
37003
+ GDHARNESS_DAP_PORT: String(ports.dap)
37004
+ }
36917
37005
  });
36918
37006
  const started = await new Promise((resolve) => {
36919
37007
  editor.once("spawn", () => {
@@ -36930,9 +37018,16 @@ class GodotServer {
36930
37018
  return this.jsonTextResponse({
36931
37019
  launched: true,
36932
37020
  pid: editor.pid ?? null,
36933
- projectPath: project.value.path
37021
+ projectPath: project.value.path,
37022
+ lspPort: ports.lsp,
37023
+ dapPort: ports.dap
36934
37024
  });
36935
37025
  }
37026
+ async portsForAnEditor() {
37027
+ const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
37028
+ const dap = portFromEnvOrNull("GDHARNESS_DAP_PORT") ?? await freePort(DEFAULT_DAP_PORT);
37029
+ return { lsp, dap };
37030
+ }
36936
37031
  async handleRunProject(args, op) {
36937
37032
  const project = this.project(args);
36938
37033
  if (!project.ok) {
@@ -37000,7 +37095,7 @@ class GodotServer {
37000
37095
  await this.dap().connect();
37001
37096
  } catch (error) {
37002
37097
  return this.createErrorResponse(`The editor is connected but its debug adapter is not: ${errorMessage(error)}`, [
37003
- "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
37098
+ `This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
37004
37099
  "GDHARNESS_DAP_PORT points this server at another one"
37005
37100
  ]);
37006
37101
  }
@@ -37022,6 +37117,7 @@ class GodotServer {
37022
37117
  through: "editor",
37023
37118
  scene: scene === null ? "the main scene" : `res://${scene}`,
37024
37119
  refreshedClasses,
37120
+ debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
37025
37121
  message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it."
37026
37122
  });
37027
37123
  }
@@ -37204,7 +37300,7 @@ class GodotServer {
37204
37300
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
37205
37301
  const { op: _op, projectPath, ...params } = asParams(args);
37206
37302
  const announced = runtimesAnnounced();
37207
- const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : undefined, announced.unspoken);
37303
+ const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken);
37208
37304
  if ("problem" in choice) {
37209
37305
  return this.createErrorResponse(choice.problem);
37210
37306
  }
@@ -37312,6 +37408,7 @@ var init_server3 = __esm(() => {
37312
37408
  init_junit();
37313
37409
  init_lsp_client();
37314
37410
  init_paths();
37411
+ init_ports();
37315
37412
  init_project_scan();
37316
37413
  init_resources();
37317
37414
  init_runtime_client();
@@ -38635,6 +38732,9 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
38635
38732
  - Read \`editor_output\` after every run. It returns the engine's errors and warnings as entries
38636
38733
  with their backtraces, and a \`clean\` verdict, so a run that printed an error is one call away
38637
38734
  from being known.
38735
+ - One server, one editor. A second project is a second harness session with its own server, and
38736
+ that works: each editor is opened on its own language server and debug adapter ports, and says
38737
+ where it serves. Two editors on one project is the thing to refuse.
38638
38738
 
38639
38739
  ## Measuring a running game
38640
38740
 
@@ -38645,6 +38745,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
38645
38745
  | Press a button | \`runtime_input click\`, which says what was under the pointer |
38646
38746
  | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` |
38647
38747
  | Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
38748
+ | Pick something out of a dropdown | \`runtime_input choose\` on it, by what the item says |
38648
38749
  | Where a 3D node is on screen | \`runtime_inspect\` \`rect\`, rather than unprojecting by hand |
38649
38750
  | Wait for something | \`runtime_wait\`, never a sleep |
38650
38751
  | A picture, for a person who asked to see one | \`runtime_capture\` |
@@ -18,6 +18,17 @@ const ANNOUNCE_PROTOCOL: int = 1
18
18
  const RECONNECT_DELAY: float = 3.0
19
19
  const MAX_RECONNECT_DELAY: float = 30.0
20
20
 
21
+ ## Where Godot keeps the three ports an editor serves. None of them is per editor: the settings
22
+ ## file is one for every editor on the machine, so two open at once want the same three and the
23
+ ## second one binds nothing.
24
+ const LSP_SETTING: String = "network/language_server/remote_port"
25
+ const DAP_SETTING: String = "network/debug_adapter/remote_port"
26
+ const DEBUGGER_SETTING: String = "network/debug/remote_port"
27
+ ## What a server that opened this editor put in the environment, matching the ports it named on
28
+ ## the command line. Kept in step with `editorArguments` in src/launch.ts.
29
+ const LSP_ASKED: String = "GDHARNESS_LSP_PORT"
30
+ const DAP_ASKED: String = "GDHARNESS_DAP_PORT"
31
+
21
32
  ## How long one attempt is given before the address is called a bad one.
22
33
  ##
23
34
  ## A socket pointed at a port nothing holds gives up by itself, in thirty seconds on Windows. One
@@ -62,6 +73,7 @@ var _connecting_for: float = 0.0
62
73
  func _ready() -> void:
63
74
  _project_path = ProjectSettings.globalize_path("res://")
64
75
  version_at_load = _loaded_version()
76
+ _keep_the_ports_this_editor_was_given()
65
77
 
66
78
  _reconnect_timer = Timer.new()
67
79
  _reconnect_timer.one_shot = true
@@ -243,18 +255,73 @@ func _handle_connect() -> void:
243
255
  # somebody restarts it, and nothing else can tell the two apart.
244
256
  # The process id with it, because a restarted editor is a different process from the one
245
257
  # whoever started it is holding, and nothing else says which one is now on the other end.
258
+ # The three ports with that, because a server that assumes the defaults talks to whichever
259
+ # editor took them, which on a machine running two is not this one.
246
260
  _send_message(
247
261
  {
248
262
  "type": "godot_ready",
249
263
  "project_path": _project_path,
250
264
  "addon_version": version_at_load,
251
- "editor_pid": OS.get_process_id()
265
+ "editor_pid": OS.get_process_id(),
266
+ "lsp_port": _serves(LSP_ASKED, LSP_SETTING),
267
+ "dap_port": _serves(DAP_ASKED, DAP_SETTING),
268
+ "debug_port": _serving(DEBUGGER_SETTING)
252
269
  }
253
270
  )
254
271
 
255
272
  connected.emit()
256
273
 
257
274
 
275
+ ## Writes the ports this editor was started on into the settings it reads them from.
276
+ ##
277
+ ## The command line moved the language server and the debug adapter for this run and the engine
278
+ ## keeps that override to itself: the setting still reads whatever it read before, so an editor
279
+ ## that restarts itself comes back on the old number and lands on top of whichever editor holds
280
+ ## it. Writing it here is what makes the move survive a restart, and what leaves one place either
281
+ ## side has to read.
282
+ func _keep_the_ports_this_editor_was_given() -> void:
283
+ if not Engine.is_editor_hint():
284
+ return
285
+ _keep_port(LSP_ASKED, LSP_SETTING)
286
+ _keep_port(DAP_ASKED, DAP_SETTING)
287
+
288
+
289
+ func _keep_port(variable: String, setting: String) -> void:
290
+ var port: int = _asked_for(variable)
291
+ if port < 1 or port == _serving(setting):
292
+ return
293
+ var settings: EditorSettings = EditorInterface.get_editor_settings()
294
+ if settings != null:
295
+ settings.set_setting(setting, port)
296
+
297
+
298
+ ## What this editor serves: what it was told to when a server opened it, and what its settings
299
+ ## say otherwise. The first is the one that counts, because the settings are the thing the command
300
+ ## line was overriding.
301
+ func _serves(variable: String, setting: String) -> int:
302
+ var asked: int = _asked_for(variable)
303
+ return asked if asked > 0 else _serving(setting)
304
+
305
+
306
+ ## The port this variable names, or 0 for anything that is not one.
307
+ static func _asked_for(variable: String) -> int:
308
+ var said: String = OS.get_environment(variable)
309
+ if not said.is_valid_int():
310
+ return 0
311
+ var port: int = int(said)
312
+ return port if port >= 1 and port <= 65535 else 0
313
+
314
+
315
+ ## What the settings say this editor serves on, or 0 when there is nothing to ask.
316
+ func _serving(setting: String) -> int:
317
+ if not Engine.is_editor_hint():
318
+ return 0
319
+ var settings: EditorSettings = EditorInterface.get_editor_settings()
320
+ if settings == null or not settings.has_setting(setting):
321
+ return 0
322
+ return int(settings.get_setting(setting))
323
+
324
+
258
325
  ## The version marker beside this addon, or "" when the copy was not installed by gdharness.
259
326
  ##
260
327
  ## Read once, when this copy loads, and never again: an upgrade replaces the files under a
@@ -10,6 +10,10 @@ extends Node
10
10
 
11
11
  ## What the display server calls itself when the engine was started with no display at all.
12
12
  const HEADLESS_DISPLAY: String = "headless"
13
+ ## Where Godot keeps the port its own debugger listens on for a game it is playing. One setting
14
+ ## for every editor on the machine, and bound only while a game runs.
15
+ const DEBUGGER_SETTING: String = "network/debug/remote_port"
16
+ const LOOPBACK: String = "127.0.0.1"
13
17
 
14
18
  var _editor_plugin: EditorPlugin = null
15
19
 
@@ -24,6 +28,8 @@ func play_scene(args: Dictionary) -> Dictionary:
24
28
  if EditorInterface.is_playing_scene():
25
29
  EditorInterface.stop_playing_scene()
26
30
 
31
+ var debugger: int = _a_port_for_the_debugger()
32
+
27
33
  if scene_path.is_empty():
28
34
  EditorInterface.play_main_scene()
29
35
  else:
@@ -35,10 +41,43 @@ func play_scene(args: Dictionary) -> Dictionary:
35
41
  return {
36
42
  "ok": true,
37
43
  "playing": EditorInterface.is_playing_scene(),
38
- "scenePath": EditorInterface.get_playing_scene()
44
+ "scenePath": EditorInterface.get_playing_scene(),
45
+ "debugPort": debugger
39
46
  }
40
47
 
41
48
 
49
+ ## Gives this editor's debugger a port of its own, and answers the one the game will be sent to.
50
+ ##
51
+ ## The editor binds this while a game runs, out of a setting shared by every editor on the
52
+ ## machine, and Godot takes --lsp-port and --dap-port on its command line but nothing at all for
53
+ ## this one. So two editors playing at once want the same number, and both ways that can go are
54
+ ## wrong: a bind that fails leaves a game with no debugger behind it, which is every debug tool
55
+ ## and the whole console gone, and a bind that succeeds anyway leaves two editors on one port with
56
+ ## the games going to whichever one the operating system picks.
57
+ ##
58
+ ## Asked of the operating system each time rather than tested first, which is how the runtime
59
+ ## addon takes its own port: a port that was free a moment ago is not a port that is still free,
60
+ ## and nothing this can ask distinguishes the editor's own debugger from another editor's.
61
+ func _a_port_for_the_debugger() -> int:
62
+ var settings: EditorSettings = EditorInterface.get_editor_settings()
63
+ if settings == null or not settings.has_setting(DEBUGGER_SETTING):
64
+ return 0
65
+ var spare: int = _any_free_port()
66
+ if spare > 0:
67
+ settings.set_setting(DEBUGGER_SETTING, spare)
68
+ return spare
69
+
70
+
71
+ ## A port the operating system handed out, or 0 when it would not give one.
72
+ static func _any_free_port() -> int:
73
+ var probe: TCPServer = TCPServer.new()
74
+ if probe.listen(0, LOOPBACK) != OK:
75
+ return 0
76
+ var port: int = probe.get_local_port()
77
+ probe.stop()
78
+ return port
79
+
80
+
42
81
  func stop_playing(_args: Dictionary) -> Dictionary:
43
82
  var was_playing: bool = EditorInterface.is_playing_scene()
44
83
  if was_playing:
@@ -88,6 +127,17 @@ func restart_editor(_args: Dictionary) -> Dictionary:
88
127
 
89
128
  func playing_status(_args: Dictionary) -> Dictionary:
90
129
  var playing: bool = EditorInterface.is_playing_scene()
130
+ # The debugger's port with it, read now rather than remembered: it is taken again before every
131
+ # play, so the one from when this editor greeted the server is a number it has moved off.
132
+ var settings: EditorSettings = EditorInterface.get_editor_settings()
133
+ var debugger: int = (
134
+ int(settings.get_setting(DEBUGGER_SETTING))
135
+ if settings != null and settings.has_setting(DEBUGGER_SETTING)
136
+ else 0
137
+ )
91
138
  return {
92
- "ok": true, "playing": playing, "scenePath": EditorInterface.get_playing_scene() if playing else ""
139
+ "ok": true,
140
+ "playing": playing,
141
+ "scenePath": EditorInterface.get_playing_scene() if playing else "",
142
+ "debugPort": debugger
93
143
  }
@@ -70,6 +70,7 @@ func _init() -> void:
70
70
  "inject_mouse_click": _input.inject_mouse_click,
71
71
  "inject_mouse_motion": _input.inject_mouse_motion,
72
72
  "click": _input.click,
73
+ "choose": _input.choose,
73
74
  "wait_frames": _waits.wait_frames,
74
75
  "wait_signal": _waits.wait_signal,
75
76
  "wait_until": _waits.wait_until,
@@ -400,6 +400,158 @@ func _no_window_note(viewport: Viewport) -> String:
400
400
  return ". This game has no window: run it with a window to reach this control"
401
401
 
402
402
 
403
+ ## Chooses an item out of a menu, by what it says or by where it is in the list.
404
+ ##
405
+ ## A menu's items are drawn rather than built, so there is no node under the pointer to aim at and
406
+ ## no rectangle to ask for: [PopupMenu] exposes their text, their ids and which one has the focus,
407
+ ## and nothing about where any of them is. So a click cannot reach one, and a whole click on the
408
+ ## [OptionButton] in front of it opens the menu on the press and closes it again on the release.
409
+ ## Every language picker, every filter and every dropdown in a game was unreachable, and the way
410
+ ## past it was to call `select` and emit `item_selected`, which sets a number and runs none of the
411
+ ## engine's own path.
412
+ ##
413
+ ## Chosen the way a keyboard chooses: the item takes the focus and then Enter presses it, which is
414
+ ## the same route through [PopupMenu] a pointer takes and which needs no geometry, so it works in a
415
+ ## game with no window as well.
416
+ ##
417
+ ## [param path] may be the menu or the button in front of it. Naming the button is what a caller
418
+ ## has, since the menu is an internal child with a generated name that changes between runs.
419
+ func choose(params: Dictionary) -> Dictionary:
420
+ var node_path: String = str(params.get("path", ""))
421
+ if node_path.is_empty():
422
+ return {"type": "error", "message": "Node path required"}
423
+ var node: Node = _host.get_tree().root.get_node_or_null(node_path)
424
+ if node == null:
425
+ return {"type": "error", "message": "Node not found: " + node_path}
426
+
427
+ var menu: PopupMenu = _menu_of(node)
428
+ if menu == null:
429
+ return {
430
+ "type": "error",
431
+ "message":
432
+ (
433
+ "%s is a %s, which is neither a PopupMenu nor something holding one"
434
+ % [node_path, node.get_class()]
435
+ )
436
+ }
437
+
438
+ if not params.has("index") and str(params.get("text", "")).is_empty():
439
+ return {
440
+ "type": "error",
441
+ "message":
442
+ (
443
+ "%s needs the item named, by text or index. It holds: %s"
444
+ % [node_path, ", ".join(_items_of(menu))]
445
+ )
446
+ }
447
+
448
+ var index: int = _wanted_item(menu, params)
449
+ if index < 0:
450
+ return {
451
+ "type": "error",
452
+ "message": "%s has no such item. It holds: %s" % [node_path, ", ".join(_items_of(menu))]
453
+ }
454
+ if menu.is_item_separator(index):
455
+ return {"type": "error", "message": "%s item %d is a separator, not a choice" % [node_path, index]}
456
+ if menu.is_item_disabled(index):
457
+ return {
458
+ "type": "error",
459
+ "message": "%s item %d, %s, is disabled" % [node_path, index, menu.get_item_text(index)]
460
+ }
461
+
462
+ # Shown first, because a menu nobody has opened has no focus to move and Enter would go to
463
+ # whatever is behind it. An OptionButton opens its own; a bare PopupMenu is popped where it
464
+ # already sits, which leaves a menu that was already open where it is.
465
+ var opened: bool = _open_the_menu(node, menu)
466
+ await _host.get_tree().process_frame
467
+
468
+ menu.scroll_to_item(index)
469
+ menu.set_focused_item(index)
470
+ # Through Input rather than pushed at the menu, which is how a keyboard reaches an open one: a
471
+ # popup is a Window, it takes the focus when it opens, and Input delivers to whichever window
472
+ # has it. Pushed straight at the menu the event arrived and nothing happened.
473
+ Input.parse_input_event(_accept(true))
474
+ await _host.get_tree().process_frame
475
+ Input.parse_input_event(_accept(false))
476
+ await _host.get_tree().process_frame
477
+
478
+ var answer: Dictionary = {
479
+ "type": "chosen",
480
+ "path": node_path,
481
+ "index": index,
482
+ "text": menu.get_item_text(index),
483
+ "id": menu.get_item_id(index),
484
+ "opened": opened,
485
+ "menu": str(menu.get_path()),
486
+ }
487
+ # What the button in front of the menu reads now, which is the answer to "did it take": a menu
488
+ # item that fired changes the thing holding it, and nothing else about the press says so.
489
+ var chooser: OptionButton = node as OptionButton
490
+ if chooser != null:
491
+ answer["selected"] = chooser.get_selected()
492
+ answer["shows"] = chooser.text
493
+ return answer
494
+
495
+
496
+ ## The menu [param node] is, or the one it holds. An [OptionButton] and a [MenuButton] both keep
497
+ ## theirs as an internal child, which is a node a caller cannot name and should not have to.
498
+ static func _menu_of(node: Node) -> PopupMenu:
499
+ var menu: PopupMenu = node as PopupMenu
500
+ if menu != null:
501
+ return menu
502
+ if node.has_method("get_popup"):
503
+ var held: Variant = node.call("get_popup")
504
+ if held is PopupMenu:
505
+ return held
506
+ return null
507
+
508
+
509
+ ## Which item was asked for: `text`, matched exactly and then case-insensitively, or `index`.
510
+ ## Minus one when neither names one that is there.
511
+ static func _wanted_item(menu: PopupMenu, params: Dictionary) -> int:
512
+ if params.has("index"):
513
+ var asked: int = int(params.get("index", -1))
514
+ return asked if asked >= 0 and asked < menu.get_item_count() else -1
515
+ var wanted: String = str(params.get("text", ""))
516
+ if wanted.is_empty():
517
+ return -1
518
+ for index: int in menu.get_item_count():
519
+ if menu.get_item_text(index) == wanted:
520
+ return index
521
+ for index: int in menu.get_item_count():
522
+ if menu.get_item_text(index).nocasecmp_to(wanted) == 0:
523
+ return index
524
+ return -1
525
+
526
+
527
+ ## What the menu says, for a refusal that names the choices rather than the miss.
528
+ static func _items_of(menu: PopupMenu) -> PackedStringArray:
529
+ var said: PackedStringArray = PackedStringArray()
530
+ for index: int in menu.get_item_count():
531
+ said.append("%d: %s" % [index, menu.get_item_text(index)])
532
+ return said
533
+
534
+
535
+ ## Opens the menu if it is not already, and answers whether anything opened.
536
+ static func _open_the_menu(node: Node, menu: PopupMenu) -> bool:
537
+ if menu.visible:
538
+ return false
539
+ if node.has_method("show_popup"):
540
+ node.call("show_popup")
541
+ return true
542
+ menu.popup()
543
+ return true
544
+
545
+
546
+ func _accept(pressed: bool) -> InputEventKey:
547
+ var event: InputEventKey = InputEventKey.new()
548
+ event.keycode = KEY_ENTER
549
+ event.physical_keycode = KEY_ENTER
550
+ event.key_label = KEY_ENTER
551
+ event.pressed = pressed
552
+ return event
553
+
554
+
403
555
  ## A whole click aimed at where a 3D node is drawn, for a game that picks with a ray out of the
404
556
  ## cursor rather than with a Control.
405
557
  ##
package/build/index.js CHANGED
@@ -24861,10 +24861,11 @@ class FrameReader {
24861
24861
  }
24862
24862
 
24863
24863
  // src/ports.ts
24864
- function portFromEnv(variable, fallback) {
24864
+ import { createServer } from "node:net";
24865
+ function portFromEnvOrNull(variable) {
24865
24866
  const raw = process.env[variable]?.trim();
24866
24867
  if (!raw) {
24867
- return fallback;
24868
+ return null;
24868
24869
  }
24869
24870
  const parsed = Number.parseInt(raw, 10);
24870
24871
  if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535 || String(parsed) !== raw) {
@@ -24872,6 +24873,36 @@ function portFromEnv(variable, fallback) {
24872
24873
  }
24873
24874
  return parsed;
24874
24875
  }
24876
+ function portFromEnv(variable, fallback) {
24877
+ return portFromEnvOrNull(variable) ?? fallback;
24878
+ }
24879
+ async function freePort(preferred, host = "127.0.0.1") {
24880
+ const asked = await bindable(preferred, host);
24881
+ if (asked !== null) {
24882
+ return asked;
24883
+ }
24884
+ const spare = await bindable(0, host);
24885
+ if (spare === null) {
24886
+ throw new Refusal(`No port could be bound on ${host}.`);
24887
+ }
24888
+ return spare;
24889
+ }
24890
+ function bindable(port, host) {
24891
+ return new Promise((resolve) => {
24892
+ const probe = createServer();
24893
+ probe.once("error", () => {
24894
+ resolve(null);
24895
+ });
24896
+ probe.once("listening", () => {
24897
+ const bound = probe.address();
24898
+ const taken = typeof bound === "object" && bound !== null ? bound.port : port;
24899
+ probe.close(() => {
24900
+ resolve(taken);
24901
+ });
24902
+ });
24903
+ probe.listen(port, host);
24904
+ });
24905
+ }
24875
24906
 
24876
24907
  // src/dap_client.ts
24877
24908
  var DEFAULT_DAP_PORT = 6006;
@@ -25476,6 +25507,9 @@ function resolveDefaultBridgeHost() {
25476
25507
  const host = process.env["GDHARNESS_BRIDGE_HOST"]?.trim();
25477
25508
  return host === undefined || host === "" ? DEFAULT_HOST : host;
25478
25509
  }
25510
+ function servedPort(said) {
25511
+ return typeof said === "number" && Number.isInteger(said) && said > 0 && said <= 65535 ? said : undefined;
25512
+ }
25479
25513
 
25480
25514
  class GodotBridge extends EventEmitter {
25481
25515
  httpServer = null;
@@ -25608,6 +25642,9 @@ class GodotBridge extends EventEmitter {
25608
25642
  lastPongAt: this.connectionInfo?.lastPongAt,
25609
25643
  addonVersion: this.connectionInfo?.addonVersion,
25610
25644
  editorPid: this.connectionInfo?.editorPid,
25645
+ lspPort: this.connectionInfo?.lspPort,
25646
+ dapPort: this.connectionInfo?.dapPort,
25647
+ debugPort: this.connectionInfo?.debugPort,
25611
25648
  pendingRequests: this.pendingRequests.size,
25612
25649
  queuedResources: this.resourceQueues.size
25613
25650
  };
@@ -25741,6 +25778,9 @@ class GodotBridge extends EventEmitter {
25741
25778
  this.connectionInfo.projectPath = message.project_path;
25742
25779
  this.connectionInfo.addonVersion = message.addon_version ?? "";
25743
25780
  this.connectionInfo.editorPid = message.editor_pid;
25781
+ this.connectionInfo.lspPort = servedPort(message.lsp_port);
25782
+ this.connectionInfo.dapPort = servedPort(message.dap_port);
25783
+ this.connectionInfo.debugPort = servedPort(message.debug_port);
25744
25784
  this.log("info", `Godot ready: ${message.project_path}`);
25745
25785
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
25746
25786
  }
@@ -26013,8 +26053,8 @@ function runArguments(options) {
26013
26053
  }
26014
26054
  return args;
26015
26055
  }
26016
- function editorArguments(projectPath) {
26017
- return ["-e", "--path", projectPath];
26056
+ function editorArguments(projectPath, ports) {
26057
+ return ["-e", "--path", projectPath, "--lsp-port", String(ports.lsp), "--dap-port", String(ports.dap)];
26018
26058
  }
26019
26059
  function userDataIn(home, variables = process.env) {
26020
26060
  const moved = ["APPDATA", "XDG_DATA_HOME"];
@@ -26911,10 +26951,10 @@ function asToolResponse(payload) {
26911
26951
  ]
26912
26952
  };
26913
26953
  }
26914
- function normalizeLSPError(error) {
26954
+ function normalizeLSPError(error, port) {
26915
26955
  if (error instanceof Error) {
26916
26956
  if (error.message.includes("ECONNREFUSED") || error.message.includes("Failed to connect to Godot LSP") || error.message.includes("socket closed")) {
26917
- return `Godot LSP is unavailable on port ${portFromEnv("GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}. Start the Godot editor and enable Language Server in Editor Settings, or set GDHARNESS_LSP_PORT to the port it serves.`;
26957
+ return `Godot LSP is unavailable on port ${port}. Start the Godot editor and enable Language Server in Editor Settings, or set GDHARNESS_LSP_PORT to the port it serves.`;
26918
26958
  }
26919
26959
  return error.message;
26920
26960
  }
@@ -26992,7 +27032,7 @@ async function handleLSPTool(client, toolName, args) {
26992
27032
  }
26993
27033
  } catch (error) {
26994
27034
  return asToolResponse({
26995
- error: normalizeLSPError(error)
27035
+ error: normalizeLSPError(error, client.port)
26996
27036
  });
26997
27037
  }
26998
27038
  }
@@ -28348,12 +28388,12 @@ var TOOL_SPECS = [
28348
28388
  },
28349
28389
  {
28350
28390
  name: "runtime_input",
28351
- description: "Input to the running game: a whole click on a Control or a 3D node named by path, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
28391
+ description: "Input to the running game: a whole click on a Control or a 3D node named by path, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
28352
28392
  parameters: {
28353
28393
  projectPath: RUNNING_PROJECT_PATH,
28354
28394
  nodePath: {
28355
28395
  type: "string",
28356
- description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn."
28396
+ description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn. choose: the PopupMenu, or the OptionButton or MenuButton in front of one."
28357
28397
  },
28358
28398
  action: { type: "string", description: "action: the InputMap action name." },
28359
28399
  pressed: { type: "boolean", description: "Press or release. Default true." },
@@ -28361,7 +28401,11 @@ var TOOL_SPECS = [
28361
28401
  keycode: { type: "string", description: 'key: the key name, such as "Space" or "A".' },
28362
28402
  text: {
28363
28403
  type: "string",
28364
- description: "text: what to type. A newline is Enter and a tab is Tab."
28404
+ description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
28405
+ },
28406
+ index: {
28407
+ type: "number",
28408
+ description: "choose: the item to take, by where it is in the list, when text will not do."
28365
28409
  },
28366
28410
  shift: { type: "boolean" },
28367
28411
  ctrl: { type: "boolean" },
@@ -28383,6 +28427,10 @@ var TOOL_SPECS = [
28383
28427
  summary: "press and release on a Control, a frame apart, and answer with what was under the pointer and what became of the control: in_tree, removed or freed. A control out of sight inside a ScrollContainer is scrolled to first, and scrolled_into_view says whether the view moved. A 3D node is clicked where it is drawn, and landed then says the interface did not swallow the press",
28384
28428
  requires: ["nodePath"]
28385
28429
  },
28430
+ choose: {
28431
+ summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen and what the button in front of it shows now",
28432
+ requires: ["nodePath"]
28433
+ },
28386
28434
  action: { summary: "press or release an action", requires: ["action"] },
28387
28435
  key: { summary: "press or release a key", requires: ["keycode"] },
28388
28436
  text: {
@@ -29080,12 +29128,23 @@ class GodotServer {
29080
29128
  case "runtime_capture":
29081
29129
  return await this.handleRuntimeCommand(op === "screenshot" ? "capture_screenshot" : "capture_viewport", args);
29082
29130
  case "runtime_input":
29083
- return op === "click" ? await this.handleRuntimeCommand("click", {
29084
- projectPath: args["projectPath"],
29085
- path: readNonEmptyString(args, "nodePath") ?? "",
29086
- button: readString(args, "button") ?? "left",
29087
- double: readBoolean(args, "doubleClick") ?? false
29088
- }) : await this.handleRuntimeCommand(`inject_${op}`, args);
29131
+ if (op === "click") {
29132
+ return await this.handleRuntimeCommand("click", {
29133
+ projectPath: args["projectPath"],
29134
+ path: readNonEmptyString(args, "nodePath") ?? "",
29135
+ button: readString(args, "button") ?? "left",
29136
+ double: readBoolean(args, "doubleClick") ?? false
29137
+ });
29138
+ }
29139
+ if (op === "choose") {
29140
+ return await this.handleRuntimeCommand("choose", {
29141
+ projectPath: args["projectPath"],
29142
+ path: readNonEmptyString(args, "nodePath") ?? "",
29143
+ text: readString(args, "text") ?? "",
29144
+ ...args["index"] === undefined ? {} : { index: args["index"] }
29145
+ });
29146
+ }
29147
+ return await this.handleRuntimeCommand(`inject_${op}`, args);
29089
29148
  case "runtime_wait":
29090
29149
  return await this.handleRuntimeWait(op, args);
29091
29150
  case "debug_breakpoint": {
@@ -29166,7 +29225,7 @@ class GodotServer {
29166
29225
  return {
29167
29226
  ok: false,
29168
29227
  response: this.createErrorResponse(`The editor is playing the game but its debug adapter did not answer: ${errorMessage(error)}`, [
29169
- "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
29228
+ `This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
29170
29229
  "GDHARNESS_DAP_PORT points this server at another one"
29171
29230
  ])
29172
29231
  };
@@ -29533,7 +29592,8 @@ class GodotServer {
29533
29592
  if (payload["error"] !== undefined) {
29534
29593
  const reason = payload["error"];
29535
29594
  return this.createErrorResponse(`Diagnostics unavailable: ${typeof reason === "string" ? reason : JSON.stringify(reason)}`, [
29536
- "Ensure the Godot editor is running with its language server enabled, on port 6005 or on the port GDHARNESS_LSP_PORT names"
29595
+ `Ensure the Godot editor is running with its language server enabled, on port ${this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}`,
29596
+ "GDHARNESS_LSP_PORT points this server at another one"
29537
29597
  ]);
29538
29598
  }
29539
29599
  const diagnostics = readArray(payload, "diagnostics") ?? [];
@@ -29550,16 +29610,33 @@ class GodotServer {
29550
29610
  });
29551
29611
  }
29552
29612
  async handleLSP(toolName, args) {
29553
- this.lspClient ??= new GodotLSPClient;
29613
+ const port = this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT);
29614
+ if (this.lspClient !== null && this.lspClient.port !== port) {
29615
+ await this.lspClient.disconnect();
29616
+ this.lspClient = null;
29617
+ }
29618
+ this.lspClient ??= new GodotLSPClient(port);
29554
29619
  return handleLSPTool(this.lspClient, toolName, args);
29555
29620
  }
29556
29621
  async handleDAP(toolName, args) {
29557
29622
  return handleDAPTool(this.dap(), toolName, args);
29558
29623
  }
29559
29624
  dap() {
29560
- this.dapClient ??= new GodotDAPClient;
29625
+ const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
29626
+ if (this.dapClient !== null && this.dapClient.port !== port) {
29627
+ this.dapClient = null;
29628
+ }
29629
+ this.dapClient ??= new GodotDAPClient(port);
29561
29630
  return this.dapClient;
29562
29631
  }
29632
+ editorServes(field, variable, fallback) {
29633
+ const named = portFromEnvOrNull(variable);
29634
+ if (named !== null) {
29635
+ return named;
29636
+ }
29637
+ const status = this.godotBridge.getStatus();
29638
+ return (status.connected ? status[field] : undefined) ?? fallback;
29639
+ }
29563
29640
  getEditorStatusPayload() {
29564
29641
  const status = this.godotBridge.getStatus();
29565
29642
  const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
@@ -29587,7 +29664,8 @@ class GodotServer {
29587
29664
  const answer = await this.godotBridge.invokeTool("playing_status", {});
29588
29665
  return {
29589
29666
  playing: readBoolean(asParams(answer), "playing") ?? false,
29590
- scene: readString(asParams(answer), "scenePath") ?? ""
29667
+ scene: readString(asParams(answer), "scenePath") ?? "",
29668
+ debugPort: readNumber(asParams(answer), "debugPort")
29591
29669
  };
29592
29670
  } catch {
29593
29671
  return null;
@@ -29605,15 +29683,19 @@ class GodotServer {
29605
29683
  problem: reply.ok ? null : reply.message
29606
29684
  };
29607
29685
  }));
29686
+ const playing = await this.editorPlayingState();
29608
29687
  return this.jsonTextResponse({
29609
- editor: this.getEditorStatusPayload(),
29688
+ editor: {
29689
+ ...this.getEditorStatusPayload(),
29690
+ debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
29691
+ },
29610
29692
  godot: {
29611
29693
  path: godotPath,
29612
29694
  version: godotPath === null ? null : await this.godotVersion(godotPath)
29613
29695
  },
29614
29696
  game: {
29615
29697
  processActive: this.activeProcess !== null,
29616
- playingInEditor: await this.editorPlayingState(),
29698
+ playingInEditor: playing,
29617
29699
  runtimeConnected: games.some((game) => game.reachable),
29618
29700
  runtimes: games
29619
29701
  }
@@ -29672,10 +29754,10 @@ class GodotServer {
29672
29754
  return project.response;
29673
29755
  }
29674
29756
  if (this.godotBridge.isConnected()) {
29675
- return this.createErrorResponse("An editor is already connected to this server, and a second one would take the language server and debug adapter ports from it.", [
29757
+ return this.createErrorResponse("An editor is already connected to this server.", [
29676
29758
  "editor_status says which editor is answering, and for which project",
29677
29759
  "editor_launch restart replaces the connected editor rather than joining it",
29678
- "Close the open editor first if the new project is the one you want"
29760
+ "Another project wants its own gdharness server, which is its own harness session"
29679
29761
  ]);
29680
29762
  }
29681
29763
  const engine = await this.engine();
@@ -29683,10 +29765,16 @@ class GodotServer {
29683
29765
  return engine.response;
29684
29766
  }
29685
29767
  this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
29686
- const editor = spawn(engine.value, editorArguments(project.value.path), {
29768
+ const ports = await this.portsForAnEditor();
29769
+ const editor = spawn(engine.value, editorArguments(project.value.path, ports), {
29687
29770
  stdio: "ignore",
29688
29771
  detached: true,
29689
- env: { ...process.env, GDHARNESS_RUNTIME_DIR: runtimeDirectory() }
29772
+ env: {
29773
+ ...process.env,
29774
+ GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
29775
+ GDHARNESS_LSP_PORT: String(ports.lsp),
29776
+ GDHARNESS_DAP_PORT: String(ports.dap)
29777
+ }
29690
29778
  });
29691
29779
  const started = await new Promise((resolve) => {
29692
29780
  editor.once("spawn", () => {
@@ -29703,9 +29791,16 @@ class GodotServer {
29703
29791
  return this.jsonTextResponse({
29704
29792
  launched: true,
29705
29793
  pid: editor.pid ?? null,
29706
- projectPath: project.value.path
29794
+ projectPath: project.value.path,
29795
+ lspPort: ports.lsp,
29796
+ dapPort: ports.dap
29707
29797
  });
29708
29798
  }
29799
+ async portsForAnEditor() {
29800
+ const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
29801
+ const dap = portFromEnvOrNull("GDHARNESS_DAP_PORT") ?? await freePort(DEFAULT_DAP_PORT);
29802
+ return { lsp, dap };
29803
+ }
29709
29804
  async handleRunProject(args, op) {
29710
29805
  const project = this.project(args);
29711
29806
  if (!project.ok) {
@@ -29773,7 +29868,7 @@ class GodotServer {
29773
29868
  await this.dap().connect();
29774
29869
  } catch (error) {
29775
29870
  return this.createErrorResponse(`The editor is connected but its debug adapter is not: ${errorMessage(error)}`, [
29776
- "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
29871
+ `This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
29777
29872
  "GDHARNESS_DAP_PORT points this server at another one"
29778
29873
  ]);
29779
29874
  }
@@ -29795,6 +29890,7 @@ class GodotServer {
29795
29890
  through: "editor",
29796
29891
  scene: scene === null ? "the main scene" : `res://${scene}`,
29797
29892
  refreshedClasses,
29893
+ debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
29798
29894
  message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it."
29799
29895
  });
29800
29896
  }
@@ -29977,7 +30073,7 @@ class GodotServer {
29977
30073
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
29978
30074
  const { op: _op, projectPath, ...params } = asParams(args);
29979
30075
  const announced = runtimesAnnounced();
29980
- const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : undefined, announced.unspoken);
30076
+ const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken);
29981
30077
  if ("problem" in choice) {
29982
30078
  return this.createErrorResponse(choice.problem);
29983
30079
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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",