gdharness 1.1.21 → 1.1.23

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.
Files changed (51) hide show
  1. package/build/cli.js +1470 -726
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +12 -2
  3. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  4. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  6. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  8. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  9. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  10. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  12. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  14. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  15. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  17. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +15 -5
  20. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +51 -5
  21. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  22. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  24. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +89 -16
  25. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +107 -343
  26. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  27. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  28. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  29. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  30. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  31. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +61 -28
  32. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  33. package/build/godot/operations/audio_buses.gd +47 -11
  34. package/build/godot/operations/class_cache.gd +18 -2
  35. package/build/godot/operations/classdb_queries.gd +28 -17
  36. package/build/godot/operations/dependencies.gd +128 -17
  37. package/build/godot/operations/file_walk.gd +20 -0
  38. package/build/godot/operations/gdscript_analysis.gd +59 -20
  39. package/build/godot/operations/gdscript_authoring.gd +127 -60
  40. package/build/godot/operations/godot_operations.gd +24 -5
  41. package/build/godot/operations/import_pipeline.gd +179 -19
  42. package/build/godot/operations/logger.gd +2 -2
  43. package/build/godot/operations/plugins.gd +88 -70
  44. package/build/godot/operations/project_config.gd +41 -6
  45. package/build/godot/operations/project_diagnostics.gd +27 -11
  46. package/build/godot/operations/reading.gd +17 -0
  47. package/build/godot/operations/resource_files.gd +60 -17
  48. package/build/godot/operations/serialisation.gd +116 -7
  49. package/build/index.js +1385 -553
  50. package/build/keeper.js +4 -0
  51. package/package.json +1 -1
package/build/index.js CHANGED
@@ -10058,23 +10058,24 @@ function sameCodeNote(addonVersion, serverVersion, addonDigest, shippedDigest) {
10058
10058
  }
10059
10059
  return `The editor loaded the ${addonVersion} addons, which are the same code this ${serverVersion} server ships, so nothing needs restarting; addonVersion changes at the next editor start.`;
10060
10060
  }
10061
- function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest) {
10061
+ function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest, onDisk) {
10062
10062
  if (!editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest)) {
10063
10063
  return;
10064
10064
  }
10065
10065
  const reported = addonVersion ?? "";
10066
+ const restart = onDisk !== undefined && onDisk !== shippedDigest ? `The project's own addons are not this server's either, so a restart would load the same code again: gdharness upgrade in the project installs the ${serverVersion} addons, and editor_launch restart then picks them up.` : "Restart it with editor_launch restart to pick this one up.";
10066
10067
  if (reported === serverVersion) {
10067
- return `The editor is running a different build of the ${reported} addon from the one this server ships. Restart it with editor_launch restart to pick this one up.`;
10068
+ return `The editor is running a different build of the ${reported} addon from the one this server ships. ${restart}`;
10068
10069
  }
10069
10070
  const editor = reported === "" ? UNVERSIONED : `the ${reported} addon`;
10070
10071
  const both = `The editor is running ${editor} while this server ships ${serverVersion}.`;
10071
10072
  if (reported !== "" && isNewer(reported, serverVersion)) {
10072
10073
  return `${both} This server is the older half: reconnect it in your harness so it spawns ${reported}.`;
10073
10074
  }
10074
- return `${both} Restart it with editor_launch restart to pick the new one up.`;
10075
+ return `${both} ${restart}`;
10075
10076
  }
10076
- function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDigest) {
10077
- const staleNote = addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest);
10077
+ function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDigest, onDisk) {
10078
+ const staleNote = addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest, onDisk);
10078
10079
  if (staleNote === undefined || typeof answer !== "object" || answer === null || Array.isArray(answer)) {
10079
10080
  return answer;
10080
10081
  }
@@ -10201,27 +10202,27 @@ function defectReport(where, error, context = {}) {
10201
10202
  }
10202
10203
 
10203
10204
  // src/server.ts
10204
- import { execFile as execFile6, spawn as spawn3 } from "node:child_process";
10205
+ import { execFile as execFile5, spawn as spawn4 } from "node:child_process";
10205
10206
  import { randomUUID as randomUUID2 } from "node:crypto";
10206
10207
  import {
10207
10208
  appendFileSync,
10208
10209
  closeSync as closeSync2,
10209
10210
  constants,
10210
10211
  copyFileSync,
10211
- existsSync as existsSync12,
10212
+ existsSync as existsSync14,
10212
10213
  fstatSync,
10213
10214
  mkdirSync as mkdirSync10,
10214
10215
  openSync as openSync3,
10215
10216
  readdirSync as readdirSync8,
10216
- readFileSync as readFileSync17,
10217
+ readFileSync as readFileSync18,
10217
10218
  readSync as readSync2,
10218
10219
  rmdirSync as rmdirSync2,
10219
10220
  statSync as statSync8
10220
10221
  } from "node:fs";
10221
- import { basename as basename4, dirname as dirname11, extname as extname2, isAbsolute as isAbsolute3, join as join18, normalize as normalize2, relative as relative3 } from "node:path";
10222
+ import { basename as basename4, dirname as dirname12, extname as extname2, isAbsolute as isAbsolute3, join as join19, normalize as normalize2, relative as relative3 } from "node:path";
10222
10223
  import { setTimeout as delay3 } from "node:timers/promises";
10223
10224
  import { fileURLToPath as fileURLToPath4 } from "node:url";
10224
- import { promisify as promisify6 } from "node:util";
10225
+ import { promisify as promisify5 } from "node:util";
10225
10226
 
10226
10227
  // node_modules/zod/v3/helpers/util.js
10227
10228
  var util;
@@ -24863,6 +24864,7 @@ function editorArguments(projectPath, ports, logFile, hidden) {
24863
24864
  ];
24864
24865
  }
24865
24866
  var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER";
24867
+ var EDITOR_PID_VARIABLE = "GDHARNESS_EDITOR_PID";
24866
24868
  function userDataIn(home, variables = process.env) {
24867
24869
  const moved = ["APPDATA", "XDG_DATA_HOME"];
24868
24870
  const carried = {};
@@ -24914,6 +24916,12 @@ function resolveWithinProject(projectPath, candidatePath) {
24914
24916
  return { ok: false, reason: "Path contains a null byte." };
24915
24917
  }
24916
24918
  const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
24919
+ if (withoutScheme.startsWith("uid://")) {
24920
+ return {
24921
+ ok: false,
24922
+ reason: `Path '${trimmed}' is a UID, and a file is judged here by where it is on disk, which a UID does not say. Give the file's res:// path.`
24923
+ };
24924
+ }
24917
24925
  const scheme = OTHER_SCHEME.exec(withoutScheme);
24918
24926
  if (scheme) {
24919
24927
  return {
@@ -25457,6 +25465,12 @@ function parseAnnouncement(file, pid) {
25457
25465
  }
25458
25466
  };
25459
25467
  }
25468
+ function dialAddress(address) {
25469
+ if (address === "*" || address === "0.0.0.0") {
25470
+ return "127.0.0.1";
25471
+ }
25472
+ return address === "::" ? "::1" : address;
25473
+ }
25460
25474
  function runtimesAnnounced(directories = runtimeDirectories()) {
25461
25475
  const running = new Map;
25462
25476
  const unspoken = new Map;
@@ -25620,13 +25634,13 @@ async function announcedSince(projectPath, before, waiting = {}) {
25620
25634
  const directories = waiting.directories ?? runtimeDirectories();
25621
25635
  const until = Date.now() + Math.max(waiting.budgetMs ?? ANNOUNCE_BUDGET_MS, 0);
25622
25636
  for (;; ) {
25623
- const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && isSameDirectory(endpoint.project.path, projectPath) && (waiting.accept?.(endpoint) ?? true));
25624
- if (fresh !== undefined) {
25637
+ const fresh = discoverRuntimes(directories).filter((endpoint) => !before.has(endpoint.pid) && isSameDirectory(endpoint.project.path, projectPath) && (waiting.accept?.(endpoint) ?? true));
25638
+ if (fresh.length > 0) {
25625
25639
  return fresh;
25626
25640
  }
25627
25641
  const left = until - Date.now();
25628
25642
  if (left <= 0 || waiting.giveUp?.() === true) {
25629
- return null;
25643
+ return [];
25630
25644
  }
25631
25645
  await delay(Math.min(LOOK_EVERY_MS, left));
25632
25646
  }
@@ -25761,7 +25775,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs, keepListening = fa
25761
25775
  let late = false;
25762
25776
  let buffered = "";
25763
25777
  let welcomed = false;
25764
- const socket = createConnection({ port: endpoint.port, host: endpoint.address });
25778
+ const socket = createConnection({ port: endpoint.port, host: dialAddress(endpoint.address) });
25765
25779
  let lateTimer = null;
25766
25780
  const finish = (reply) => {
25767
25781
  if (late) {
@@ -26033,6 +26047,10 @@ function callSignal() {
26033
26047
  import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2 } from "node:fs";
26034
26048
  import { join as join6 } from "node:path";
26035
26049
  var DECLARATION = /^(?:@[a-z_]+(?:\([^)\n]*\))?\s+)*class_name\s+([A-Za-z_][A-Za-z0-9_]*)/m;
26050
+ var TOOL = /^[ \t]*(?:@[a-z_]+(?:\([^)\n]*\))?[ \t]+)*@tool\b/m;
26051
+ function isToolScript(source) {
26052
+ return TOOL.test(codeOf(source));
26053
+ }
26036
26054
  function declaredClasses(projectPath) {
26037
26055
  const declared = new Map;
26038
26056
  eachScript(projectPath, (script, source) => {
@@ -26072,7 +26090,7 @@ function scriptsNaming(projectPath, classes) {
26072
26090
  }
26073
26091
  const code = codeOf(source);
26074
26092
  if (classesNamedIn(code, named).length > 0) {
26075
- naming.push({ script, tool: /^\s*@tool\b/m.test(code) });
26093
+ naming.push({ script, tool: TOOL.test(code) });
26076
26094
  }
26077
26095
  });
26078
26096
  return naming.sort((a, b) => a.script.localeCompare(b.script));
@@ -26279,6 +26297,19 @@ function classRemedy(entries) {
26279
26297
  const oneAtATime = declaring.length === 1 ? "" : " Take them one at a time: a rescan that brings in a class also reloads every script naming it and lists them under dependentsReloaded, so a script listed there needs no call of its own.";
26280
26298
  return `For ${entries.map(written).join(", ")}, run ${reload} first.${oneAtATime} In the one project ` + "that has reproduced a stale enum member, three times, the reload cleared it at once each time, " + "and the one plain editor_rescan tried left the diagnostic standing.";
26281
26299
  }
26300
+ function notReloadedNote(broughtIn, notReloaded) {
26301
+ const tried = notReloaded.filter((one) => one.tool !== true).map((one) => one.scriptPath);
26302
+ const tools = notReloaded.filter((one) => one.tool === true).map((one) => one.scriptPath);
26303
+ const all = [...tried, ...tools];
26304
+ const remedies = [];
26305
+ if (tried.length > 0) {
26306
+ remedies.push(`For ${tried.join(", ")}: editor_rescan with reloadScript on ${tried.length === 1 ? "it" : "each"}, or editor_launch restart.`);
26307
+ }
26308
+ if (tools.length > 0) {
26309
+ remedies.push(`For ${tools.join(", ")}, ${tools.length === 1 ? "a @tool script" : "@tool scripts"}, which ${tools.length === 1 ? "runs" : "run"} inside the editor and ${tools.length === 1 ? "is" : "are"} not reloaded from inside a call: editor_launch restart.`);
26310
+ }
26311
+ return `The scan brought in ${broughtIn.join(", ")}, and ${all.join(", ")} ${all.length === 1 ? "names one of them and" : "name them and"} could not be reloaded, so the editor still holds what it compiled while it could not see the class and reports that to the language server. ${remedies.join(" ")}`;
26312
+ }
26282
26313
  function failedReloadNote(failed) {
26283
26314
  const missing = (before, after) => before.filter((name) => !after.includes(name));
26284
26315
  const lost = [
@@ -26287,7 +26318,17 @@ function failedReloadNote(failed) {
26287
26318
  ];
26288
26319
  const unchanged = lost.length === 0 && missing(failed.heldAfter, failed.heldBefore).length === 0 && missing(failed.constantsAfter, failed.constantsBefore).length === 0;
26289
26320
  const answered = failed.codeName === "" ? `error ${failed.code}` : `error ${failed.code}, ${failed.codeName}`;
26290
- const copy = unchanged ? "Nothing was reloaded: the copy the editor holds is as it was, with the members under heldBeforeReload and heldConstantsBeforeReload." : `The copy the editor holds changed even so${lost.length === 0 ? "" : `, and has lost ${lost.join(", ")}`}: anything diagnosed against it now is answered from what is left, and reloading it once it compiles rebuilds it.`;
26321
+ const unusable = "A failed compile leaves the copy the editor holds unusable, measured on 4.7.2: the editor can make no instance of it, and one it already made has lost its methods, while its member lists read as before.";
26322
+ let copy;
26323
+ if (failed.restored === null) {
26324
+ copy = `${unusable} This editor's addon does not put it back, so it stays that way until the script compiles: reload it then, or restart the editor with editor_launch restart.`;
26325
+ } else if (!failed.restored) {
26326
+ copy = `${unusable} The editor compiled it again from the text it was built from, and that failed too${failed.restoredAs === "" ? "" : ` (${failed.restoredAs})`}, so it stays that way until the script compiles: reload it then, or restart the editor with editor_launch restart.`;
26327
+ } else if (unchanged) {
26328
+ copy = "The copy the editor holds was compiled again from the text it was built from, which puts it back as it was, with the members under heldBeforeReload and heldConstantsBeforeReload.";
26329
+ } else {
26330
+ copy = `The copy the editor holds was compiled again from the text it was built from and changed even so${lost.length === 0 ? "" : `, and has lost ${lost.join(", ")}`}: anything diagnosed against it now is answered from what is left, and reloading it once it compiles rebuilds it.`;
26331
+ }
26291
26332
  const order = failed.names.length === 0 ? "" : ` If they deny a member of ${failed.names.join(" or ")} that its file declares, the editor's copy of that class is behind: reload that class first with reloadScript and then this script, the order that cleared it in the one project that has met this.`;
26292
26333
  return `${failed.script} did not compile: Godot answered ${answered}. ${copy} ` + `script_diagnostics on it gives the errors.${order}`;
26293
26334
  }
@@ -26526,7 +26567,14 @@ function bindable(port, host) {
26526
26567
  var DEFAULT_DAP_PORT = 6006;
26527
26568
  var DAP_REQUEST_TIMEOUT_MS = 1e4;
26528
26569
  var SCOPES_TIMEOUT_MS = 20000;
26570
+ var STEP_LANDS_WITHIN_MS = 1e4;
26529
26571
  var VARIABLES_POLL_TIMEOUT_MS = 2000;
26572
+ function fileKey(path) {
26573
+ return process.platform === "win32" ? path.replaceAll("\\", "/").toLowerCase() : path;
26574
+ }
26575
+
26576
+ class ReadRefusal extends Refusal {
26577
+ }
26530
26578
  function said(body, field) {
26531
26579
  const value = body?.[field];
26532
26580
  return typeof value === "string" ? value : "";
@@ -26543,17 +26591,25 @@ class GodotDAPClient {
26543
26591
  outputBuffer = [];
26544
26592
  onOutput = null;
26545
26593
  maxOutputLines = 1000;
26594
+ outputDropped = 0;
26595
+ outputTaken = 0;
26546
26596
  initialized = false;
26547
26597
  attached = false;
26548
26598
  lastThreadId = 1;
26549
26599
  halt = null;
26550
26600
  holdKnown = false;
26601
+ stops = 0;
26551
26602
  breakpoints = new Map;
26603
+ spelled = new Map;
26552
26604
  inEditor = new Map;
26553
26605
  onBreakpointsChanged = null;
26554
- constructor(port = portFromEnv("GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT), host = "127.0.0.1") {
26606
+ scopesPatienceMs;
26607
+ stepPatienceMs;
26608
+ constructor(port = portFromEnv("GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT), host = "127.0.0.1", patience = {}) {
26555
26609
  this.port = port;
26556
26610
  this.host = host;
26611
+ this.scopesPatienceMs = patience.scopesMs ?? SCOPES_TIMEOUT_MS;
26612
+ this.stepPatienceMs = patience.stepMs ?? STEP_LANDS_WITHIN_MS;
26557
26613
  this.pendingRequests = new Map;
26558
26614
  }
26559
26615
  async connect() {
@@ -26714,6 +26770,7 @@ class GodotDAPClient {
26714
26770
  }
26715
26771
  }
26716
26772
  if (this.outputBuffer.length > this.maxOutputLines) {
26773
+ this.outputDropped += this.outputBuffer.length - this.maxOutputLines;
26717
26774
  this.outputBuffer = this.outputBuffer.slice(this.outputBuffer.length - this.maxOutputLines);
26718
26775
  }
26719
26776
  return;
@@ -26730,6 +26787,7 @@ class GodotDAPClient {
26730
26787
  text: said(body, "text")
26731
26788
  };
26732
26789
  this.holdKnown = true;
26790
+ this.stops += 1;
26733
26791
  return;
26734
26792
  }
26735
26793
  if (eventName === "continued") {
@@ -26746,20 +26804,24 @@ class GodotDAPClient {
26746
26804
  if (typeof path !== "string" || typeof line !== "number") {
26747
26805
  return;
26748
26806
  }
26807
+ const key = fileKey(path);
26808
+ if (!this.spelled.has(key)) {
26809
+ this.spelled.set(key, path);
26810
+ }
26749
26811
  if (said(body, "reason") === "removed") {
26750
- this.inEditor.get(path)?.delete(line);
26751
- const held = this.breakpoints.get(path);
26812
+ this.inEditor.get(key)?.delete(line);
26813
+ const held = this.breakpoints.get(key);
26752
26814
  if (held?.delete(line) === true) {
26753
26815
  if (held.size === 0) {
26754
- this.breakpoints.delete(path);
26816
+ this.breakpoints.delete(key);
26755
26817
  }
26756
26818
  this.onBreakpointsChanged?.(this.breakpointsHeld());
26757
26819
  }
26758
26820
  return;
26759
26821
  }
26760
- const known = this.inEditor.get(path) ?? new Set;
26822
+ const known = this.inEditor.get(key) ?? new Set;
26761
26823
  known.add(line);
26762
- this.inEditor.set(path, known);
26824
+ this.inEditor.set(key, known);
26763
26825
  return;
26764
26826
  }
26765
26827
  if (eventName === "terminated" || eventName === "exited") {
@@ -26842,24 +26904,44 @@ class GodotDAPClient {
26842
26904
  getOutput(clear = false) {
26843
26905
  const lines = [...this.outputBuffer];
26844
26906
  if (clear) {
26907
+ this.outputTaken += lines.length;
26845
26908
  this.outputBuffer = [];
26846
26909
  }
26847
26910
  return lines;
26848
26911
  }
26912
+ outputSoFar() {
26913
+ const output = this.getOutput(false);
26914
+ const notShown = this.outputDropped + this.outputTaken;
26915
+ if (notShown === 0) {
26916
+ return { lines: output.length, output };
26917
+ }
26918
+ const where = [
26919
+ this.outputDropped > 0 ? `${this.outputDropped} fell out of this buffer, which keeps the last ${this.maxOutputLines}` : "",
26920
+ this.outputTaken > 0 ? `${this.outputTaken} were moved into the run's log by an earlier read` : ""
26921
+ ].filter((part) => part !== "");
26922
+ return {
26923
+ lines: output.length,
26924
+ output,
26925
+ notShown,
26926
+ note: `${notShown} earlier ${notShown === 1 ? "line is" : "lines are"} not in this answer: ${where.join(", and ")}. editor_output reads what the server kept of the run's console.`
26927
+ };
26928
+ }
26849
26929
  async setBreakpoint(filePath, line) {
26850
- const wanted = new Set(this.breakpoints.get(filePath) ?? []);
26930
+ const wanted = new Set(this.breakpoints.get(fileKey(filePath)) ?? []);
26851
26931
  wanted.add(line);
26852
26932
  return await this.sendBreakpoints(filePath, wanted);
26853
26933
  }
26854
26934
  async removeBreakpoint(filePath, line) {
26855
- const wanted = new Set(this.breakpoints.get(filePath) ?? []);
26935
+ const wanted = new Set(this.breakpoints.get(fileKey(filePath)) ?? []);
26856
26936
  wanted.delete(line);
26857
26937
  return await this.sendBreakpoints(filePath, wanted, line);
26858
26938
  }
26859
26939
  async sendBreakpoints(filePath, mine, clearing) {
26860
26940
  await this.ensureConnected();
26861
26941
  await this.initialize();
26862
- const lines = new Set([...mine, ...this.inEditor.get(filePath) ?? []]);
26942
+ const key = fileKey(filePath);
26943
+ this.spelled.set(key, filePath);
26944
+ const lines = new Set([...mine, ...this.inEditor.get(key) ?? []]);
26863
26945
  if (clearing !== undefined) {
26864
26946
  lines.delete(clearing);
26865
26947
  }
@@ -26869,14 +26951,14 @@ class GodotDAPClient {
26869
26951
  breakpoints: sorted.map((breakpointLine) => ({ line: breakpointLine }))
26870
26952
  });
26871
26953
  if (mine.size === 0) {
26872
- this.breakpoints.delete(filePath);
26954
+ this.breakpoints.delete(key);
26873
26955
  } else {
26874
- this.breakpoints.set(filePath, new Set(mine));
26956
+ this.breakpoints.set(key, new Set(mine));
26875
26957
  }
26876
26958
  if (lines.size === 0) {
26877
- this.inEditor.delete(filePath);
26959
+ this.inEditor.delete(key);
26878
26960
  } else {
26879
- this.inEditor.set(filePath, lines);
26961
+ this.inEditor.set(key, lines);
26880
26962
  }
26881
26963
  return answer;
26882
26964
  }
@@ -26885,11 +26967,11 @@ class GodotDAPClient {
26885
26967
  }
26886
26968
  breakpointsInEditor() {
26887
26969
  const theirs = [];
26888
- for (const [scriptPath, lines] of this.inEditor) {
26889
- const mine = this.breakpoints.get(scriptPath);
26970
+ for (const [key, lines] of this.inEditor) {
26971
+ const mine = this.breakpoints.get(key);
26890
26972
  const rest = Array.from(lines).filter((line) => !mine?.has(line)).sort((a, b) => a - b);
26891
26973
  if (rest.length > 0) {
26892
- theirs.push({ scriptPath, lines: rest });
26974
+ theirs.push({ scriptPath: this.spelled.get(key) ?? key, lines: rest });
26893
26975
  }
26894
26976
  }
26895
26977
  return theirs;
@@ -26897,12 +26979,13 @@ class GodotDAPClient {
26897
26979
  async reapplyBreakpoints() {
26898
26980
  const applied = [];
26899
26981
  const refused = [];
26900
- for (const [filePath, lines] of Array.from(this.breakpoints)) {
26982
+ for (const [key, lines] of Array.from(this.breakpoints)) {
26983
+ const filePath = this.spelled.get(key) ?? key;
26901
26984
  try {
26902
26985
  await this.sendBreakpoints(filePath, lines);
26903
26986
  applied.push({ scriptPath: filePath, lines: Array.from(lines).sort((a, b) => a - b) });
26904
26987
  } catch (error) {
26905
- this.breakpoints.delete(filePath);
26988
+ this.breakpoints.delete(key);
26906
26989
  refused.push({
26907
26990
  scriptPath: filePath,
26908
26991
  reason: error instanceof Error ? error.message : String(error)
@@ -26915,19 +26998,21 @@ class GodotDAPClient {
26915
26998
  return { applied, refused };
26916
26999
  }
26917
27000
  breakpointsHeld() {
26918
- return Array.from(this.breakpoints, ([scriptPath, lines]) => ({
26919
- scriptPath,
27001
+ return Array.from(this.breakpoints, ([key, lines]) => ({
27002
+ scriptPath: this.spelled.get(key) ?? key,
26920
27003
  lines: Array.from(lines).sort((a, b) => a - b)
26921
27004
  }));
26922
27005
  }
26923
27006
  holdBreakpoints(held) {
26924
27007
  for (const { scriptPath, lines } of held) {
26925
- const fileBreakpoints = this.breakpoints.get(scriptPath) ?? new Set;
27008
+ const key = fileKey(scriptPath);
27009
+ const fileBreakpoints = this.breakpoints.get(key) ?? new Set;
26926
27010
  for (const line of lines) {
26927
27011
  fileBreakpoints.add(line);
26928
27012
  }
26929
27013
  if (fileBreakpoints.size > 0) {
26930
- this.breakpoints.set(scriptPath, fileBreakpoints);
27014
+ this.breakpoints.set(key, fileBreakpoints);
27015
+ this.spelled.set(key, scriptPath);
26931
27016
  }
26932
27017
  }
26933
27018
  }
@@ -26938,15 +27023,16 @@ class GodotDAPClient {
26938
27023
  this.halt = null;
26939
27024
  this.holdKnown = true;
26940
27025
  }
26941
- async stepOver(threadId) {
26942
- await this.attach();
26943
- const resolvedThreadId = await this.resolveThreadId(threadId);
26944
- await this.sendRequest("next", { threadId: resolvedThreadId });
26945
- }
26946
- async stepInto(threadId) {
27026
+ async step(command) {
26947
27027
  await this.attach();
26948
- const resolvedThreadId = await this.resolveThreadId(threadId);
26949
- await this.sendRequest("stepIn", { threadId: resolvedThreadId });
27028
+ const resolvedThreadId = await this.resolveThreadId();
27029
+ const before = this.stops;
27030
+ await this.sendRequest(command, { threadId: resolvedThreadId });
27031
+ const deadline = Date.now() + this.stepPatienceMs;
27032
+ while (this.stops === before && this.connected && Date.now() < deadline) {
27033
+ await delay2(50);
27034
+ }
27035
+ return this.stops !== before;
26950
27036
  }
26951
27037
  async getStackTrace(threadId) {
26952
27038
  await this.attach();
@@ -26972,16 +27058,16 @@ class GodotDAPClient {
26972
27058
  } catch {}
26973
27059
  await delay2(200);
26974
27060
  }
26975
- return [];
27061
+ return null;
26976
27062
  }
26977
27063
  async getScopes(frameId) {
26978
27064
  await this.attach();
26979
27065
  const frames = await this.getStackTrace();
26980
27066
  const frame = frameId === undefined ? frames[0] : frames.find((each) => each["id"] === frameId);
26981
27067
  if (frame === undefined) {
26982
- return [];
27068
+ throw new ReadRefusal(frames.length === 0 ? "The adapter gave no stack, so there is no frame to read variables from" : `The stack the adapter gives now has no frame ${String(frameId)}; its frames are ${frames.map((each) => String(each["id"])).join(", ")}. Read the stack again for the frames the game is at now`);
26983
27069
  }
26984
- const deadline = Date.now() + SCOPES_TIMEOUT_MS;
27070
+ const deadline = Date.now() + this.scopesPatienceMs;
26985
27071
  let scopes = [];
26986
27072
  while (scopes.length === 0 && Date.now() < deadline) {
26987
27073
  try {
@@ -26993,6 +27079,9 @@ class GodotDAPClient {
26993
27079
  } catch {}
26994
27080
  await delay2(200);
26995
27081
  }
27082
+ if (scopes.length === 0) {
27083
+ throw new ReadRefusal(`The scopes of frame ${String(frame["id"])} did not arrive from the game within ${this.scopesPatienceMs / 1000}s. They come from the game through the editor, so a busy machine can take longer: ask again`);
27084
+ }
26996
27085
  const named = [];
26997
27086
  for (const scope of scopes) {
26998
27087
  const reference = scope["variablesReference"];
@@ -27056,9 +27145,8 @@ async function handleDAPTool(client, toolName, args) {
27056
27145
  try {
27057
27146
  switch (toolName) {
27058
27147
  case "dap_get_output": {
27059
- const output = client.getOutput(false);
27060
27148
  return {
27061
- content: [{ type: "text", text: JSON.stringify({ lines: output.length, output }, null, 2) }]
27149
+ content: [{ type: "text", text: JSON.stringify(client.outputSoFar(), null, 2) }]
27062
27150
  };
27063
27151
  }
27064
27152
  case "dap_set_breakpoint": {
@@ -27085,19 +27173,13 @@ async function handleDAPTool(client, toolName, args) {
27085
27173
  }
27086
27174
  case "dap_step_over":
27087
27175
  case "dap_step_into": {
27088
- if (toolName === "dap_step_into") {
27089
- await client.stepInto();
27090
- } else {
27091
- await client.stepOver();
27092
- }
27093
- return {
27094
- content: [
27095
- {
27096
- type: "text",
27097
- text: JSON.stringify({ stepped: true, stack: await client.getStackTrace() }, null, 2)
27098
- }
27099
- ]
27176
+ const heldAgain = await client.step(toolName === "dap_step_into" ? "stepIn" : "next");
27177
+ const answer = heldAgain ? { stepped: true, heldAgain, stack: await client.getStackTrace() } : {
27178
+ stepped: true,
27179
+ heldAgain,
27180
+ note: `The game had not stopped again ${client.stepPatienceMs / 1000}s after the step: the step is still running, it went on without stopping anywhere, or the game was not held when it was asked. debug_state stack answers once it stops.`
27100
27181
  };
27182
+ return { content: [{ type: "text", text: JSON.stringify(answer, null, 2) }] };
27101
27183
  }
27102
27184
  case "dap_get_stack_trace": {
27103
27185
  const stack = await client.getStackTrace();
@@ -27108,8 +27190,14 @@ async function handleDAPTool(client, toolName, args) {
27108
27190
  case "dap_get_variables": {
27109
27191
  const frameId = typeof safeArgs.frameId === "number" ? safeArgs.frameId : undefined;
27110
27192
  const scopes = await client.getScopes(frameId);
27193
+ const missing = scopes.filter((scope) => scope.variables === null).map((scope) => scope.name);
27194
+ const answer = missing.length === 0 ? { scopes } : {
27195
+ scopes,
27196
+ valuesMissing: missing,
27197
+ note: `The values of ${missing.join(", ")} did not arrive from the game within ${client.scopesPatienceMs / 1000}s, so they are unknown rather than empty. Ask again.`
27198
+ };
27111
27199
  return {
27112
- content: [{ type: "text", text: JSON.stringify({ scopes }, null, 2) }]
27200
+ content: [{ type: "text", text: JSON.stringify(answer, null, 2) }]
27113
27201
  };
27114
27202
  }
27115
27203
  default:
@@ -27117,10 +27205,12 @@ async function handleDAPTool(client, toolName, args) {
27117
27205
  }
27118
27206
  } catch (error) {
27119
27207
  const message = error instanceof Error ? error.message : String(error);
27120
- const cure = toolName === "dap_set_breakpoint" || toolName === "dap_remove_breakpoint" ? "Breakpoints need the editor open, not a running game: editor_status says whether one is connected, and the path is the script as the project spells it." : "The debug tools answer for a game the editor is playing: start one with editor_run.";
27208
+ const cure = error instanceof ReadRefusal ? "" : toolName === "dap_set_breakpoint" || toolName === "dap_remove_breakpoint" ? "Breakpoints need the editor open, not a running game: editor_status says whether one is connected, and the path is the script as the project spells it." : "The debug tools answer for a game the editor is playing: start one with editor_run.";
27121
27209
  return {
27122
27210
  isError: true,
27123
- content: [{ type: "text", text: `DAP tool '${toolName}' failed: ${message}. ${cure}` }]
27211
+ content: [
27212
+ { type: "text", text: `DAP tool '${toolName}' failed: ${message}.${cure === "" ? "" : ` ${cure}`}` }
27213
+ ]
27124
27214
  };
27125
27215
  }
27126
27216
  }
@@ -27482,6 +27572,47 @@ function linesBefore(entries, at, collapsed, wanted) {
27482
27572
  return found.reverse();
27483
27573
  }
27484
27574
 
27575
+ // src/engine-run.ts
27576
+ import { spawn as spawn2 } from "node:child_process";
27577
+ function runEngine(executable, args, options = {}) {
27578
+ return new Promise((resolve) => {
27579
+ const log = new GameLog;
27580
+ let failure = null;
27581
+ const signal = callSignal();
27582
+ const child = spawn2(executable, [...args], {
27583
+ stdio: ["ignore", "pipe", "pipe"],
27584
+ windowsHide: true,
27585
+ ...signal === undefined ? {} : { signal }
27586
+ });
27587
+ child.stdout.on("data", (chunk) => {
27588
+ log.append("stdout", chunk);
27589
+ });
27590
+ child.stderr.on("data", (chunk) => {
27591
+ log.append("stderr", chunk);
27592
+ });
27593
+ const { timeout } = options;
27594
+ const timer = timeout === undefined ? undefined : setTimeout(() => {
27595
+ failure ??= `it ran past its ${timeout.said}`;
27596
+ child.kill();
27597
+ }, timeout.ms);
27598
+ child.on("error", (error) => {
27599
+ failure ??= error.name === "AbortError" ? "the call was cancelled" : `it could not be started: ${error.message}`;
27600
+ });
27601
+ child.on("close", (code, closedBy) => {
27602
+ clearTimeout(timer);
27603
+ log.finish();
27604
+ const started = child.pid !== undefined;
27605
+ resolve({ log, exitCode: started ? code : null, exitSignal: closedBy, failure });
27606
+ });
27607
+ });
27608
+ }
27609
+ function howItEnded(run) {
27610
+ if (run.failure !== null) {
27611
+ return run.exitSignal === null ? run.failure : `ended by ${run.exitSignal}, because ${run.failure}`;
27612
+ }
27613
+ return run.exitSignal === null ? `exit code ${run.exitCode ?? "unknown"}` : `ended by ${run.exitSignal}`;
27614
+ }
27615
+
27485
27616
  // src/godot-bridge.ts
27486
27617
  import { randomUUID } from "node:crypto";
27487
27618
  import { EventEmitter } from "node:events";
@@ -27519,7 +27650,10 @@ function mayYetConnect(listeningSince, now = Date.now()) {
27519
27650
  return listeningSince === undefined || now - listeningSince.getTime() < CONNECT_WINDOW_MS;
27520
27651
  }
27521
27652
  function theEditorHasComeBack(status, startedAt) {
27522
- return status.connected && (status.connectedAt?.getTime() ?? 0) > startedAt && status.addonVersion !== undefined;
27653
+ return hasSaidWhoItIs(status) && (status.connectedAt?.getTime() ?? 0) > startedAt;
27654
+ }
27655
+ function hasSaidWhoItIs(status) {
27656
+ return status.connected && status.addonVersion !== undefined;
27523
27657
  }
27524
27658
  function anEditorIsStillComing(listeningSince, launchedEditorIsAlive, now = Date.now()) {
27525
27659
  return mayYetConnect(listeningSince, now) || launchedEditorIsAlive;
@@ -27669,6 +27803,7 @@ class GodotBridge extends EventEmitter {
27669
27803
  dapPort: this.connectionInfo?.dapPort,
27670
27804
  debugPort: this.connectionInfo?.debugPort,
27671
27805
  openedByAServer: this.connectionInfo?.openedByAServer,
27806
+ startedByAnEditor: this.connectionInfo?.startedByAnEditor,
27672
27807
  syncsBreakpoints: this.connectionInfo?.syncsBreakpoints,
27673
27808
  pendingRequests: this.pendingRequests.size,
27674
27809
  queuedResources: this.resourceQueues.size,
@@ -27814,6 +27949,7 @@ class GodotBridge extends EventEmitter {
27814
27949
  this.connectionInfo.dapPort = servedPort(message.dap_port);
27815
27950
  this.connectionInfo.debugPort = servedPort(message.debug_port);
27816
27951
  this.connectionInfo.openedByAServer = message.opened_by_a_server === true;
27952
+ this.connectionInfo.startedByAnEditor = typeof message.started_by_an_editor === "boolean" ? message.started_by_an_editor : undefined;
27817
27953
  this.connectionInfo.syncsBreakpoints = typeof message.syncs_breakpoints === "boolean" ? message.syncs_breakpoints : undefined;
27818
27954
  this.log("info", `Godot ready: ${message.project_path}`);
27819
27955
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
@@ -28148,10 +28284,8 @@ class GodotLocator {
28148
28284
  }
28149
28285
 
28150
28286
  // src/headless.ts
28151
- import { execFile as execFile3 } from "node:child_process";
28152
- import { writeFileSync as writeFileSync8 } from "node:fs";
28153
- import { join as join13 } from "node:path";
28154
- import { promisify as promisify3 } from "node:util";
28287
+ import { existsSync as existsSync10, readFileSync as readFileSync12, realpathSync as realpathSync2, writeFileSync as writeFileSync8 } from "node:fs";
28288
+ import { dirname as dirname7, join as join13 } from "node:path";
28155
28289
 
28156
28290
  // src/reimport.ts
28157
28291
  import { createHash as createHash2 } from "node:crypto";
@@ -28277,7 +28411,6 @@ function lastTryOnExit() {
28277
28411
  }
28278
28412
 
28279
28413
  // src/headless.ts
28280
- var run3 = promisify3(execFile3);
28281
28414
  function snakeCased(params) {
28282
28415
  const result = emptyRecord();
28283
28416
  for (const [key, value] of Object.entries(params)) {
@@ -28286,34 +28419,58 @@ function snakeCased(params) {
28286
28419
  }
28287
28420
  return result;
28288
28421
  }
28289
- function lastJsonObject(stdout) {
28290
- const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("{"));
28291
- for (const line of lines.reverse()) {
28292
- try {
28293
- const parsed = JSON.parse(line);
28294
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
28295
- return parsed;
28296
- }
28297
- } catch {}
28298
- }
28299
- return null;
28422
+ function engineExtras(outcome) {
28423
+ return {
28424
+ ...outcome.messages.length > 0 ? { engine_messages: outcome.messages } : {},
28425
+ ...outcome.messagesOmitted === undefined ? {} : { engine_messages_omitted: outcome.messagesOmitted },
28426
+ ...outcome.afterAnswer === undefined ? {} : { engine_after_answer: outcome.afterAnswer }
28427
+ };
28300
28428
  }
28301
- function problems(stderr) {
28302
- const log = new GameLog;
28303
- log.append("stderr", stderr);
28304
- log.finish();
28305
- return log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
28429
+ var OWN_ERROR = "[ERROR] ";
28430
+ var MESSAGES_KEPT = 200;
28431
+ var ERRORS_NAMED = 3;
28432
+ function problemsIn(log) {
28433
+ return log.everything().flatMap((entry) => {
28434
+ if (entry.severity !== "info") {
28435
+ return [entry];
28436
+ }
28437
+ if (entry.source === "stderr" && entry.text.startsWith(OWN_ERROR)) {
28438
+ return [{ ...entry, severity: "error", text: entry.text.slice(OWN_ERROR.length) }];
28439
+ }
28440
+ return [];
28441
+ });
28306
28442
  }
28307
- function reason(stdout, stderr) {
28308
- const own = stderr.split(/\r?\n/).filter((line) => line.startsWith("[ERROR] ")).map((line) => line.slice("[ERROR] ".length));
28443
+ function reported(problems) {
28444
+ const omitted = Math.max(0, problems.length - MESSAGES_KEPT);
28445
+ return omitted === 0 ? { messages: problems } : { messages: problems.slice(omitted), messagesOmitted: omitted };
28446
+ }
28447
+ function reason(log, problems) {
28448
+ const own = log.everything().filter((entry) => entry.source === "stderr" && entry.severity === "info" && entry.text.startsWith(OWN_ERROR));
28309
28449
  if (own.length > 0) {
28310
- return own.join("; ");
28311
- }
28312
- const engine = problems(stderr).filter((entry) => entry.severity === "error").map((entry) => entry.text);
28313
- if (engine.length > 0) {
28314
- return engine.join("; ");
28450
+ return own.map((entry) => entry.text.slice(OWN_ERROR.length)).join("; ");
28451
+ }
28452
+ const errors = problems.filter((entry) => entry.severity === "error").map((entry) => entry.text);
28453
+ if (errors.length > 0) {
28454
+ const rest = errors.length - ERRORS_NAMED;
28455
+ return rest > 0 ? `${errors.slice(0, ERRORS_NAMED).join("; ")}; and ${rest} more error${rest === 1 ? "" : "s"}` : errors.join("; ");
28456
+ }
28457
+ const printed = log.everything().filter((entry) => entry.source === "stdout");
28458
+ return printed.at(-1)?.text ?? "no output at all";
28459
+ }
28460
+ function scriptFault(problems, script) {
28461
+ const spelled = (path) => `(${path.replaceAll("\\", "/").toLowerCase()}/`;
28462
+ const directories = [...new Set([spelled(dirname7(script)), spelled(resolvedOr(dirname7(script)))])];
28463
+ return problems.find((entry) => {
28464
+ const at = entry.detail.find((line) => line.startsWith("at: "))?.replaceAll("\\", "/").toLowerCase();
28465
+ return entry.severity === "error" && at !== undefined && directories.some((directory) => at.includes(directory));
28466
+ });
28467
+ }
28468
+ function resolvedOr(path) {
28469
+ try {
28470
+ return realpathSync2.native(path);
28471
+ } catch {
28472
+ return path;
28315
28473
  }
28316
- return stdout.trim().split(/\r?\n/).at(-1) ?? "no output at all";
28317
28474
  }
28318
28475
  function librariesNotCopied(messages) {
28319
28476
  const found = new Set;
@@ -28353,30 +28510,33 @@ async function runImport(godotPath, projectPath, options = {}) {
28353
28510
  }
28354
28511
  async function importOnce(godotPath, projectPath) {
28355
28512
  const logDir = scratchDirectory("import");
28513
+ let ran;
28356
28514
  try {
28357
- const { stderr } = await run3(godotPath, ["--headless", "--log-file", join13(logDir, "engine.log"), "--path", projectPath, "--import"], { signal: callSignal() });
28358
- return { ok: true, messages: problems(stderr) };
28359
- } catch (error) {
28360
- if (error instanceof Error && "stdout" in error && "stderr" in error) {
28361
- const failed = error;
28362
- return {
28363
- ok: false,
28364
- message: `the import pass failed (exit ${failed.code ?? "unknown"}): ${reason(failed.stdout, failed.stderr)}`,
28365
- messages: problems(failed.stderr)
28366
- };
28367
- }
28368
- return {
28369
- ok: false,
28370
- message: `the import pass could not be run: ${error instanceof Error ? error.message : String(error)}`,
28371
- messages: []
28372
- };
28515
+ ran = await runEngine(godotPath, [
28516
+ "--headless",
28517
+ "--log-file",
28518
+ join13(logDir, "engine.log"),
28519
+ "--path",
28520
+ projectPath,
28521
+ "--import"
28522
+ ]);
28373
28523
  } finally {
28374
28524
  discard(logDir);
28375
28525
  }
28526
+ const problems = problemsIn(ran.log);
28527
+ if (ran.exitCode === 0 && ran.failure === null) {
28528
+ return { ok: true, ...reported(problems) };
28529
+ }
28530
+ return {
28531
+ ok: false,
28532
+ message: `the import pass failed (${howItEnded(ran)}): ${reason(ran.log, problems)}`,
28533
+ ...reported(problems)
28534
+ };
28376
28535
  }
28377
28536
  async function runOperation(engine, operation, params, projectPath) {
28378
28537
  const paramsDir = scratchDirectory("params");
28379
28538
  const paramsFile = join13(paramsDir, `${operation}.json`);
28539
+ const answerFile = join13(paramsDir, "answer.json");
28380
28540
  writeFileSync8(paramsFile, JSON.stringify(snakeCased(params)), "utf8");
28381
28541
  const args = [
28382
28542
  "--headless",
@@ -28388,38 +28548,63 @@ async function runOperation(engine, operation, params, projectPath) {
28388
28548
  engine.script,
28389
28549
  operation,
28390
28550
  `@file:${paramsFile}`,
28551
+ answerFile,
28391
28552
  ...engine.debug ? ["--debug-godot"] : []
28392
28553
  ];
28393
- let stdout;
28394
- let stderr;
28554
+ let ran;
28555
+ let answer;
28395
28556
  try {
28396
- ({ stdout, stderr } = await run3(engine.godotPath, args, { signal: callSignal() }));
28397
- } catch (error) {
28398
- if (error instanceof Error && "stdout" in error && "stderr" in error) {
28399
- const failed = error;
28400
- return {
28401
- ok: false,
28402
- message: `${operation} failed (exit ${failed.code ?? "unknown"}): ${reason(failed.stdout, failed.stderr)}`,
28403
- messages: problems(failed.stderr)
28404
- };
28405
- }
28557
+ ran = await runEngine(engine.godotPath, args);
28558
+ answer = answerIn(answerFile);
28559
+ } finally {
28560
+ discard(paramsDir);
28561
+ }
28562
+ const problems = problemsIn(ran.log);
28563
+ if (answer.kind === "unreadable") {
28406
28564
  return {
28407
28565
  ok: false,
28408
- message: `${operation} could not be run: ${error instanceof Error ? error.message : String(error)}`,
28409
- messages: []
28566
+ message: `${operation} wrote an answer that could not be read (${answer.why})`,
28567
+ ...reported(problems)
28568
+ };
28569
+ }
28570
+ if (answer.kind === "none") {
28571
+ const ending = ran.exitCode === 0 && ran.failure === null ? "produced no result" : `failed (${howItEnded(ran)})`;
28572
+ return {
28573
+ ok: false,
28574
+ message: `${operation} ${ending}: ${reason(ran.log, problems)}`,
28575
+ ...reported(problems)
28410
28576
  };
28411
- } finally {
28412
- discard(paramsDir);
28413
28577
  }
28414
- const payload = lastJsonObject(stdout);
28415
- if (payload === null) {
28578
+ const fault = scriptFault(problems, engine.script);
28579
+ if (fault !== undefined) {
28416
28580
  return {
28417
28581
  ok: false,
28418
- message: `${operation} produced no result: ${reason(stdout, stderr)}`,
28419
- messages: problems(stderr)
28582
+ message: `${operation} hit an error in the operations script, so its answer is left out: part of it was ` + `never computed. ${fault.text} (${fault.detail[0]?.slice("at: ".length) ?? "no location given"})`,
28583
+ ...reported(problems)
28584
+ };
28585
+ }
28586
+ const afterAnswer = ran.exitCode === 0 && ran.failure === null ? undefined : `The operation finished and wrote this answer, and then the engine did not exit cleanly (${howItEnded(ran)}). Anything it reported on the way out is under engine_messages.`;
28587
+ return {
28588
+ ok: true,
28589
+ payload: answer.payload,
28590
+ ...reported(problems),
28591
+ ...afterAnswer === undefined ? {} : { afterAnswer }
28592
+ };
28593
+ }
28594
+ function answerIn(file) {
28595
+ if (!existsSync10(file)) {
28596
+ return { kind: "none" };
28597
+ }
28598
+ const text = readFileSync12(file, "utf8");
28599
+ try {
28600
+ const parsed = JSON.parse(text);
28601
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? { kind: "answer", payload: parsed } : { kind: "unreadable", why: `it is JSON but not an object: ${text.slice(0, 200)}` };
28602
+ } catch (error) {
28603
+ return {
28604
+ kind: "unreadable",
28605
+ why: `${error instanceof Error ? error.message : String(error)}: ${text.slice(0, 200)}`
28420
28606
  };
28421
28607
  }
28422
- return { ok: true, payload, messages: problems(stderr) };
28423
28608
  }
28424
28609
 
28425
28610
  // src/headless-operations.ts
@@ -28620,13 +28805,18 @@ function parseJUnit(xml) {
28620
28805
  const suiteElements = top.name === "testsuite" ? [top] : top.children.filter((child) => child.name === "testsuite");
28621
28806
  const suites = suiteElements.map((suite) => {
28622
28807
  const cases = suite.children.filter((child) => child.name === "testcase").map((element) => caseOf(suite, element));
28808
+ const failures = cases.filter((entry) => entry.status === "failed").length;
28809
+ const errors = cases.filter((entry) => entry.status === "error").length;
28810
+ const written = (kind) => suite.children.filter((child) => child.name === "testcase").reduce((sum, element) => sum + element.children.filter((child) => child.name === kind).length, 0);
28623
28811
  return {
28624
28812
  name: suite.attributes["name"] ?? "",
28625
28813
  path: pathOf(suite),
28626
28814
  tests: cases.length,
28627
28815
  discovered: count(suite.attributes, "tests"),
28628
- failures: cases.filter((entry) => entry.status === "failed").length,
28629
- errors: cases.filter((entry) => entry.status === "error").length,
28816
+ failures,
28817
+ errors,
28818
+ hookFailures: Math.max(0, count(suite.attributes, "failures") - written("failure")),
28819
+ hookErrors: Math.max(0, count(suite.attributes, "errors") - written("error")),
28630
28820
  skipped: cases.filter((entry) => entry.status === "skipped").length,
28631
28821
  time: count(suite.attributes, "time"),
28632
28822
  cases
@@ -28637,13 +28827,22 @@ function parseJUnit(xml) {
28637
28827
  tests: all.length,
28638
28828
  failures: all.filter((entry) => entry.status === "failed").length,
28639
28829
  errors: all.filter((entry) => entry.status === "error").length,
28830
+ hookFailures: suites.reduce((sum, suite) => sum + suite.hookFailures, 0),
28831
+ hookErrors: suites.reduce((sum, suite) => sum + suite.hookErrors, 0),
28640
28832
  skipped: all.filter((entry) => entry.status === "skipped").length,
28641
28833
  time: suites.reduce((sum, suite) => sum + suite.time, 0),
28642
28834
  suites
28643
28835
  };
28644
28836
  }
28837
+ var HOOK_FAILURE = /^\s*(.*?)\s*\tat '(before|after)' in (res:\/\/.+?):(\d+)\s*$/;
28838
+ function hookFailuresPrinted(printed) {
28839
+ return printed.flatMap((line) => {
28840
+ const found = HOOK_FAILURE.exec(line);
28841
+ return found === null ? [] : [{ hook: found[2] ?? "", path: found[3] ?? "", line: Number(found[4]), message: found[1] ?? "" }];
28842
+ });
28843
+ }
28645
28844
  var NOTHING_RAN = /no test cases found/i;
28646
- var NO_SUCH_PATH = /given directory or file does not exists:\s*(\S+)/i;
28845
+ var NO_SUCH_PATH = /given directory or file does not exists:\s*(.+?)\s*$/i;
28647
28846
  function whyNoReport(printed, asked) {
28648
28847
  const missing = printed.map((line) => NO_SUCH_PATH.exec(line)).find((found) => found !== null);
28649
28848
  if (missing !== undefined) {
@@ -28730,13 +28929,17 @@ function withActualsPrinted(failed, printed) {
28730
28929
  at: match[5] ?? "",
28731
28930
  ...readDiff(match[1] ?? "")
28732
28931
  }));
28932
+ const used = new Set;
28733
28933
  return failed.map((entry) => {
28734
28934
  if (entry.detail === null) {
28735
28935
  return entry;
28736
28936
  }
28737
28937
  let detail = entry.detail;
28738
28938
  let recovered = false;
28739
- for (const diff of diffs) {
28939
+ for (const [index, diff] of diffs.entries()) {
28940
+ if (used.has(index)) {
28941
+ continue;
28942
+ }
28740
28943
  const wrong = ` but was
28741
28944
  '${diff.merged.replaceAll("\r", "")}'${diff.suffix}
28742
28945
  at ${diff.at}`;
@@ -28745,6 +28948,7 @@ function withActualsPrinted(failed, printed) {
28745
28948
  '${diff.actual}'${diff.suffix}
28746
28949
  at ${diff.at}`);
28747
28950
  recovered = true;
28951
+ used.add(index);
28748
28952
  }
28749
28953
  }
28750
28954
  const quoted = QUOTED_EQUALITY.exec(detail);
@@ -28768,7 +28972,7 @@ function masked(expected, actual) {
28768
28972
  return `${expected}${actual}`.includes(MASKED_BRACKET) && allSpelled(expected) && allSpelled(actual);
28769
28973
  }
28770
28974
  var PRINTED_DICTIONARY = /^\{\n\t[\s\S]*\n {2}\}$/;
28771
- var RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
28975
+ var RUNNING_SUITE = /Run Test Suite:\s*(res:\/\/.+?\.(?:gd|cs))(?=\s|$)/;
28772
28976
  var ORPHANS = /(\d+)\s+orphans/;
28773
28977
  var OVERALL = "Overall Summary";
28774
28978
  function orphansPrinted(printed) {
@@ -28803,13 +29007,24 @@ function orphansPrinted(printed) {
28803
29007
  }
28804
29008
 
28805
29009
  // src/lsp_client.ts
28806
- import { realpathSync as realpathSync2 } from "node:fs";
29010
+ import { existsSync as existsSync11, realpathSync as realpathSync3 } from "node:fs";
28807
29011
  import { readFile, realpath } from "node:fs/promises";
28808
29012
  import { createConnection as createConnection3 } from "node:net";
28809
- import { dirname as dirname7, resolve as resolve3 } from "node:path";
29013
+ import { dirname as dirname8, join as join14, resolve as resolve3 } from "node:path";
28810
29014
  import { fileURLToPath, pathToFileURL } from "node:url";
28811
29015
  var DEFAULT_LSP_PORT = 6005;
28812
29016
  var DIAGNOSTICS_TIMEOUT_MS = 5000;
29017
+
29018
+ class ArgumentRefusal extends Refusal {
29019
+ }
29020
+
29021
+ class AnotherProjectsServer extends Refusal {
29022
+ serves;
29023
+ constructor(port, serves, asked) {
29024
+ super(`The language server on port ${port} belongs to the editor of ${serves}, not ${asked}.`);
29025
+ this.serves = serves;
29026
+ }
29027
+ }
28813
29028
  function diagnosticsKey(uri) {
28814
29029
  let decoded;
28815
29030
  try {
@@ -28821,7 +29036,7 @@ function diagnosticsKey(uri) {
28821
29036
  return decoded;
28822
29037
  }
28823
29038
  try {
28824
- return realpathSync2.native(fileURLToPath(decoded)).toLowerCase();
29039
+ return realpathSync3.native(fileURLToPath(decoded)).toLowerCase();
28825
29040
  } catch {
28826
29041
  return decoded.toLowerCase();
28827
29042
  }
@@ -28838,8 +29053,10 @@ class GodotLSPClient {
28838
29053
  connectPromise = null;
28839
29054
  initialized = false;
28840
29055
  rootPath = null;
29056
+ servedWorkspace = null;
28841
29057
  diagnosticsWaiters = new Map;
28842
29058
  documentVersions = new Map;
29059
+ documentTurns = new Map;
28843
29060
  constructor(port = portFromEnv("GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT), host = "127.0.0.1") {
28844
29061
  this.port = port;
28845
29062
  this.host = host;
@@ -28936,7 +29153,7 @@ class GodotLSPClient {
28936
29153
  if (this.initialized) {
28937
29154
  return;
28938
29155
  }
28939
- const rootPath = this.rootPath ?? dirname7(resolve3(filePath));
29156
+ const rootPath = this.rootPath ?? dirname8(resolve3(filePath));
28940
29157
  await this.initialize(rootPath);
28941
29158
  }
28942
29159
  async sendRequest(method, params) {
@@ -29011,6 +29228,10 @@ class GodotLSPClient {
29011
29228
  }
29012
29229
  }
29013
29230
  }
29231
+ if (message["method"] === "gdscript_client/changeWorkspace") {
29232
+ const path = message["params"]?.["path"];
29233
+ this.servedWorkspace = typeof path === "string" ? path : null;
29234
+ }
29014
29235
  if (message["method"] === "textDocument/publishDiagnostics") {
29015
29236
  const params = message["params"];
29016
29237
  const paramsObject = params && typeof params === "object" ? params : null;
@@ -29113,10 +29334,14 @@ class GodotLSPClient {
29113
29334
  this.sendNotification("textDocument/didClose", { textDocument: { uri } });
29114
29335
  } catch {}
29115
29336
  }
29337
+ forgetServedWorkspace() {
29338
+ this.servedWorkspace = null;
29339
+ }
29116
29340
  async initialize(rootPath) {
29117
29341
  await this.ensureConnected();
29118
29342
  const resolvedRootPath = resolve3(rootPath);
29119
29343
  const rootUri = pathToFileURL(resolvedRootPath).href;
29344
+ this.forgetServedWorkspace();
29120
29345
  const result = await this.sendRequest("initialize", {
29121
29346
  processId: process.pid,
29122
29347
  rootPath: resolvedRootPath,
@@ -29142,60 +29367,82 @@ class GodotLSPClient {
29142
29367
  }
29143
29368
  ]
29144
29369
  });
29370
+ const served = this.servedWorkspace;
29371
+ if (served !== null && !isSameDirectory(served, resolvedRootPath)) {
29372
+ throw new AnotherProjectsServer(this.port, served, resolvedRootPath);
29373
+ }
29145
29374
  this.sendNotification("initialized", {});
29146
29375
  this.initialized = true;
29147
29376
  this.rootPath = resolvedRootPath;
29148
29377
  return result;
29149
29378
  }
29150
- async getDiagnostics(filePath, content) {
29151
- await this.ensureConnected();
29152
- await this.ensureInitializedForFile(filePath);
29379
+ async withDocument(filePath, work) {
29153
29380
  const key = diagnosticsKey(this.toFileUri(filePath));
29154
- const diagnosticsPromise = new Promise((resolveDiagnostics, rejectDiagnostics) => {
29155
- const existing = this.diagnosticsWaiters.get(key);
29156
- if (existing) {
29157
- clearTimeout(existing.timer);
29158
- }
29159
- const timer = setTimeout(() => {
29160
- this.diagnosticsWaiters.delete(key);
29161
- rejectDiagnostics(new Error(`Godot published no diagnostics for ${key} within ${DIAGNOSTICS_TIMEOUT_MS}ms. ` + "The language server may not be running, or may not have this file in its workspace."));
29162
- }, DIAGNOSTICS_TIMEOUT_MS);
29163
- this.diagnosticsWaiters.set(key, {
29164
- resolve: resolveDiagnostics,
29165
- reject: rejectDiagnostics,
29166
- timer
29167
- });
29168
- });
29169
- let opened = null;
29381
+ const before = this.documentTurns.get(key) ?? Promise.resolve();
29382
+ let done = () => {};
29383
+ const mine = new Promise((finished) => {
29384
+ done = finished;
29385
+ });
29386
+ const turn = before.then(() => mine);
29387
+ this.documentTurns.set(key, turn);
29388
+ await before;
29170
29389
  try {
29171
- opened = this.syncDocument(filePath, content);
29172
- return await diagnosticsPromise;
29173
- } catch (error) {
29174
- const waiter = this.diagnosticsWaiters.get(key);
29175
- if (waiter) {
29176
- clearTimeout(waiter.timer);
29177
- this.diagnosticsWaiters.delete(key);
29178
- }
29179
- throw error;
29390
+ return await work();
29180
29391
  } finally {
29181
- if (opened !== null) {
29182
- this.closeDocument(opened);
29392
+ done();
29393
+ if (this.documentTurns.get(key) === turn) {
29394
+ this.documentTurns.delete(key);
29183
29395
  }
29184
29396
  }
29185
29397
  }
29186
- async getCompletions(filePath, content, line, character) {
29398
+ async getDiagnostics(filePath, content) {
29187
29399
  await this.ensureConnected();
29188
29400
  await this.ensureInitializedForFile(filePath);
29189
- const uri = this.syncDocument(filePath, content);
29190
- let result;
29191
- try {
29192
- result = await this.sendRequest("textDocument/completion", {
29193
- textDocument: { uri },
29194
- position: { line, character }
29401
+ return this.withDocument(filePath, async () => {
29402
+ const key = diagnosticsKey(this.toFileUri(filePath));
29403
+ const diagnosticsPromise = new Promise((resolveDiagnostics, rejectDiagnostics) => {
29404
+ const timer = setTimeout(() => {
29405
+ this.diagnosticsWaiters.delete(key);
29406
+ rejectDiagnostics(new Error(`Godot published no diagnostics for ${key} within ${DIAGNOSTICS_TIMEOUT_MS}ms. ` + "The language server may not be running, or may not have this file in its workspace."));
29407
+ }, DIAGNOSTICS_TIMEOUT_MS);
29408
+ this.diagnosticsWaiters.set(key, {
29409
+ resolve: resolveDiagnostics,
29410
+ reject: rejectDiagnostics,
29411
+ timer
29412
+ });
29195
29413
  });
29196
- } finally {
29197
- this.closeDocument(uri);
29198
- }
29414
+ let opened = null;
29415
+ try {
29416
+ opened = this.syncDocument(filePath, content);
29417
+ return await diagnosticsPromise;
29418
+ } catch (error) {
29419
+ const waiter = this.diagnosticsWaiters.get(key);
29420
+ if (waiter) {
29421
+ clearTimeout(waiter.timer);
29422
+ this.diagnosticsWaiters.delete(key);
29423
+ }
29424
+ throw error;
29425
+ } finally {
29426
+ if (opened !== null) {
29427
+ this.closeDocument(opened);
29428
+ }
29429
+ }
29430
+ });
29431
+ }
29432
+ async getCompletions(filePath, content, line, character) {
29433
+ await this.ensureConnected();
29434
+ await this.ensureInitializedForFile(filePath);
29435
+ const result = await this.withDocument(filePath, async () => {
29436
+ const uri = this.syncDocument(filePath, content);
29437
+ try {
29438
+ return await this.sendRequest("textDocument/completion", {
29439
+ textDocument: { uri },
29440
+ position: { line, character }
29441
+ });
29442
+ } finally {
29443
+ this.closeDocument(uri);
29444
+ }
29445
+ });
29199
29446
  if (Array.isArray(result)) {
29200
29447
  return result;
29201
29448
  }
@@ -29210,28 +29457,31 @@ class GodotLSPClient {
29210
29457
  async getHover(filePath, content, line, character) {
29211
29458
  await this.ensureConnected();
29212
29459
  await this.ensureInitializedForFile(filePath);
29213
- const uri = this.syncDocument(filePath, content);
29214
- try {
29215
- return await this.sendRequest("textDocument/hover", {
29216
- textDocument: { uri },
29217
- position: { line, character }
29218
- });
29219
- } finally {
29220
- this.closeDocument(uri);
29221
- }
29460
+ return this.withDocument(filePath, async () => {
29461
+ const uri = this.syncDocument(filePath, content);
29462
+ try {
29463
+ return await this.sendRequest("textDocument/hover", {
29464
+ textDocument: { uri },
29465
+ position: { line, character }
29466
+ });
29467
+ } finally {
29468
+ this.closeDocument(uri);
29469
+ }
29470
+ });
29222
29471
  }
29223
29472
  async getDocumentSymbols(filePath, content) {
29224
29473
  await this.ensureConnected();
29225
29474
  await this.ensureInitializedForFile(filePath);
29226
- const uri = this.syncDocument(filePath, content);
29227
- let result;
29228
- try {
29229
- result = await this.sendRequest("textDocument/documentSymbol", {
29230
- textDocument: { uri }
29231
- });
29232
- } finally {
29233
- this.closeDocument(uri);
29234
- }
29475
+ const result = await this.withDocument(filePath, async () => {
29476
+ const uri = this.syncDocument(filePath, content);
29477
+ try {
29478
+ return await this.sendRequest("textDocument/documentSymbol", {
29479
+ textDocument: { uri }
29480
+ });
29481
+ } finally {
29482
+ this.closeDocument(uri);
29483
+ }
29484
+ });
29235
29485
  return Array.isArray(result) ? result : [];
29236
29486
  }
29237
29487
  isConnected() {
@@ -29263,36 +29513,39 @@ async function resolveLSPPaths(projectPathValue, scriptPathValue) {
29263
29513
  try {
29264
29514
  projectPath = await realpath(requestedProjectPath);
29265
29515
  } catch {
29266
- throw new Refusal(`Project path does not exist: ${requestedProjectPath}`);
29516
+ throw new ArgumentRefusal(`Project path does not exist: ${requestedProjectPath}`);
29517
+ }
29518
+ if (!existsSync11(join14(projectPath, "project.godot"))) {
29519
+ throw new ArgumentRefusal(`Not a Godot project: ${projectPath}. Point projectPath at the directory holding project.godot.`);
29267
29520
  }
29268
29521
  const contained = resolveWithinProject(projectPath, scriptPathValue);
29269
29522
  if (!contained.ok) {
29270
- throw new Refusal(contained.reason);
29523
+ throw new ArgumentRefusal(contained.reason);
29271
29524
  }
29272
29525
  let scriptPath;
29273
29526
  try {
29274
29527
  scriptPath = await realpath(contained.absolutePath);
29275
29528
  } catch {
29276
- throw new Refusal(`Script file does not exist: ${contained.absolutePath}`);
29529
+ throw new ArgumentRefusal(`Script file does not exist: ${contained.absolutePath}`);
29277
29530
  }
29278
29531
  if (!isWithinRoot(projectPath, scriptPath)) {
29279
- throw new Refusal("scriptPath resolves outside the project root boundary.");
29532
+ throw new ArgumentRefusal("scriptPath resolves outside the project root boundary.");
29280
29533
  }
29281
29534
  return { projectPath, scriptPath };
29282
29535
  }
29283
29536
  async function handleLSPTool(client, toolName, args) {
29284
29537
  try {
29285
29538
  if (!args || typeof args !== "object") {
29286
- throw new Refusal("Tool arguments must be an object.");
29539
+ throw new ArgumentRefusal("Tool arguments must be an object.");
29287
29540
  }
29288
29541
  const parsedArgs = args;
29289
29542
  const projectPathValue = parsedArgs["projectPath"];
29290
29543
  const scriptPathValue = parsedArgs["scriptPath"];
29291
29544
  if (typeof projectPathValue !== "string" || projectPathValue.length === 0) {
29292
- throw new Refusal("Missing required argument: projectPath");
29545
+ throw new ArgumentRefusal("Missing required argument: projectPath");
29293
29546
  }
29294
29547
  if (typeof scriptPathValue !== "string" || scriptPathValue.length === 0) {
29295
- throw new Refusal("Missing required argument: scriptPath");
29548
+ throw new ArgumentRefusal("Missing required argument: scriptPath");
29296
29549
  }
29297
29550
  const { projectPath, scriptPath } = await resolveLSPPaths(projectPathValue, scriptPathValue);
29298
29551
  const content = await readFile(scriptPath, "utf8");
@@ -29306,7 +29559,7 @@ async function handleLSPTool(client, toolName, args) {
29306
29559
  const line = Number(parsedArgs["line"]);
29307
29560
  const character = Number(parsedArgs["character"]);
29308
29561
  if (!Number.isFinite(line) || !Number.isFinite(character)) {
29309
- throw new Refusal("Arguments line and character must be numbers.");
29562
+ throw new ArgumentRefusal("Arguments line and character must be numbers.");
29310
29563
  }
29311
29564
  const completions = await client.getCompletions(scriptPath, content, line, character);
29312
29565
  return asToolResponse({ completions });
@@ -29315,7 +29568,7 @@ async function handleLSPTool(client, toolName, args) {
29315
29568
  const line = Number(parsedArgs["line"]);
29316
29569
  const character = Number(parsedArgs["character"]);
29317
29570
  if (!Number.isFinite(line) || !Number.isFinite(character)) {
29318
- throw new Refusal("Arguments line and character must be numbers.");
29571
+ throw new ArgumentRefusal("Arguments line and character must be numbers.");
29319
29572
  }
29320
29573
  const hover = await client.getHover(scriptPath, content, line, character);
29321
29574
  return asToolResponse({ hover });
@@ -29329,13 +29582,15 @@ async function handleLSPTool(client, toolName, args) {
29329
29582
  }
29330
29583
  } catch (error) {
29331
29584
  return asToolResponse({
29332
- error: normalizeLSPError(error, client.port)
29585
+ error: normalizeLSPError(error, client.port),
29586
+ ...error instanceof AnotherProjectsServer ? { serves: error.serves } : {},
29587
+ ...error instanceof ArgumentRefusal ? { refusedArguments: true } : {}
29333
29588
  });
29334
29589
  }
29335
29590
  }
29336
29591
 
29337
29592
  // src/outside.ts
29338
- import { spawn as spawn2 } from "node:child_process";
29593
+ import { spawn as spawn3 } from "node:child_process";
29339
29594
  import { fileURLToPath as fileURLToPath2 } from "node:url";
29340
29595
  function changesFrom(env, base) {
29341
29596
  const changes = {};
@@ -29369,7 +29624,7 @@ function readLaunched(printed) {
29369
29624
  }
29370
29625
  async function launchOutsideTheTree(spec, runtime = process.execPath, keeper = KEEPER_SCRIPT) {
29371
29626
  return await new Promise((resolve) => {
29372
- const launcher = spawn2(runtime, [keeper, "launch"], {
29627
+ const launcher = spawn3(runtime, [keeper, "launch"], {
29373
29628
  stdio: ["pipe", "pipe", "pipe"],
29374
29629
  windowsHide: true
29375
29630
  });
@@ -29400,9 +29655,9 @@ async function launchOutsideTheTree(spec, runtime = process.execPath, keeper = K
29400
29655
  }
29401
29656
 
29402
29657
  // src/process-time.ts
29403
- import { execFile as execFile4 } from "node:child_process";
29404
- import { promisify as promisify4 } from "node:util";
29405
- var run4 = promisify4(execFile4);
29658
+ import { execFile as execFile3 } from "node:child_process";
29659
+ import { promisify as promisify3 } from "node:util";
29660
+ var run3 = promisify3(execFile3);
29406
29661
  var ASK_TIMEOUT_MS2 = 6000;
29407
29662
  async function cpuSecondsOf(pid) {
29408
29663
  try {
@@ -29417,7 +29672,7 @@ async function secondsOnWindows(pid) {
29417
29672
  return Number.isFinite(seconds) ? seconds : undefined;
29418
29673
  }
29419
29674
  async function askPosix2(pid) {
29420
- const { stdout } = await run4("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS2 });
29675
+ const { stdout } = await run3("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS2 });
29421
29676
  return secondsFromClock(stdout.trim());
29422
29677
  }
29423
29678
  function secondsFromClock(printed) {
@@ -29437,16 +29692,46 @@ function secondsFromClock(printed) {
29437
29692
  }
29438
29693
 
29439
29694
  // src/project-scan.ts
29440
- import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync12 } from "node:fs";
29441
- import { join as join14 } from "node:path";
29695
+ import { existsSync as existsSync12, readdirSync as readdirSync5, readFileSync as readFileSync13 } from "node:fs";
29696
+ import { join as join15 } from "node:path";
29442
29697
  var SKIPPED = new Set(["node_modules"]);
29443
29698
  function skipped(name) {
29444
29699
  return name.startsWith(".") || SKIPPED.has(name);
29445
29700
  }
29446
29701
  function steppedOver(directory) {
29447
- return existsSync10(join14(directory, ".gdignore"));
29448
- }
29449
- var ASSET_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "otf", "wav", "mp3", "ogg"]);
29702
+ return existsSync12(join15(directory, ".gdignore"));
29703
+ }
29704
+ var SCENE_EXTENSIONS = new Set(["tscn", "scn"]);
29705
+ var ASSET_EXTENSIONS = new Set([
29706
+ "png",
29707
+ "jpg",
29708
+ "jpeg",
29709
+ "webp",
29710
+ "svg",
29711
+ "bmp",
29712
+ "tga",
29713
+ "exr",
29714
+ "hdr",
29715
+ "ktx",
29716
+ "dds",
29717
+ "ttf",
29718
+ "otf",
29719
+ "woff",
29720
+ "woff2",
29721
+ "fnt",
29722
+ "wav",
29723
+ "mp3",
29724
+ "ogg",
29725
+ "glb",
29726
+ "gltf",
29727
+ "obj",
29728
+ "fbx",
29729
+ "blend",
29730
+ "dae",
29731
+ "ogv",
29732
+ "gdshader",
29733
+ "shader"
29734
+ ]);
29450
29735
  function projectStructure(projectPath) {
29451
29736
  const structure = { scenes: 0, scripts: 0, assets: 0, other: 0 };
29452
29737
  const visit = (directory) => {
@@ -29458,10 +29743,10 @@ function projectStructure(projectPath) {
29458
29743
  continue;
29459
29744
  }
29460
29745
  if (entry.isDirectory()) {
29461
- visit(join14(directory, entry.name));
29746
+ visit(join15(directory, entry.name));
29462
29747
  } else if (entry.isFile()) {
29463
29748
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
29464
- if (extension === "tscn") {
29749
+ if (SCENE_EXTENSIONS.has(extension)) {
29465
29750
  structure.scenes += 1;
29466
29751
  } else if (extension === "gd" || extension === "cs") {
29467
29752
  structure.scripts += 1;
@@ -29489,14 +29774,14 @@ function scriptsWithoutUid(projectPath) {
29489
29774
  }
29490
29775
  const spelled = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
29491
29776
  if (entry.isDirectory()) {
29492
- visit(join14(directory, entry.name), spelled);
29777
+ visit(join15(directory, entry.name), spelled);
29493
29778
  continue;
29494
29779
  }
29495
29780
  if (!entry.isFile()) {
29496
29781
  continue;
29497
29782
  }
29498
29783
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
29499
- if (CARRIES_A_UID.has(extension) && !existsSync10(join14(directory, `${entry.name}.uid`))) {
29784
+ if (CARRIES_A_UID.has(extension) && !existsSync12(join15(directory, `${entry.name}.uid`))) {
29500
29785
  found.push(spelled);
29501
29786
  }
29502
29787
  }
@@ -29504,8 +29789,11 @@ function scriptsWithoutUid(projectPath) {
29504
29789
  visit(projectPath, "");
29505
29790
  return found.sort();
29506
29791
  }
29792
+ function isBinary(bytes) {
29793
+ return bytes.subarray(0, 8000).includes(0);
29794
+ }
29507
29795
  function searchProject(projectPath, options) {
29508
- const extensions = new Set(options.fileTypes.map((ext) => ext.replace(/^\./, "").toLowerCase()).filter(Boolean));
29796
+ const extensions = options.fileTypes === undefined ? null : new Set(options.fileTypes.map((ext) => ext.replace(/^\./, "").toLowerCase()).filter(Boolean));
29509
29797
  const result = {
29510
29798
  query: options.query,
29511
29799
  results: [],
@@ -29513,13 +29801,7 @@ function searchProject(projectPath, options) {
29513
29801
  };
29514
29802
  const regex = options.regex ? new RegExp(options.query, options.caseSensitive ? "" : "i") : null;
29515
29803
  const needle = options.caseSensitive ? options.query : options.query.toLowerCase();
29516
- const full = () => {
29517
- if (result.summary.total_matches >= options.maxResults) {
29518
- result.summary.truncated = true;
29519
- return true;
29520
- }
29521
- return false;
29522
- };
29804
+ const full = () => result.summary.truncated;
29523
29805
  const visit = (directory) => {
29524
29806
  if (steppedOver(directory)) {
29525
29807
  return;
@@ -29531,27 +29813,32 @@ function searchProject(projectPath, options) {
29531
29813
  if (skipped(entry.name)) {
29532
29814
  continue;
29533
29815
  }
29534
- const entryPath = join14(directory, entry.name);
29816
+ const entryPath = join15(directory, entry.name);
29535
29817
  if (entry.isDirectory()) {
29536
29818
  visit(entryPath);
29537
29819
  continue;
29538
29820
  }
29539
29821
  const extension = entry.name.includes(".") ? entry.name.split(".").pop()?.toLowerCase() ?? "" : "";
29540
- if (!entry.isFile() || !extensions.has(extension)) {
29822
+ if (!entry.isFile() || extensions !== null && !extensions.has(extension)) {
29823
+ continue;
29824
+ }
29825
+ const bytes = readFileSync13(entryPath);
29826
+ if (extensions === null && isBinary(bytes)) {
29541
29827
  continue;
29542
29828
  }
29543
29829
  result.summary.files_searched += 1;
29544
29830
  const matches = [];
29545
- for (const [index, line] of readFileSync12(entryPath, "utf8").split(`
29546
- `).entries()) {
29547
- if (full()) {
29548
- break;
29831
+ for (const [index, line] of bytes.toString("utf8").split(/\r?\n/).entries()) {
29832
+ const found = regex ? regex.exec(line) : (options.caseSensitive ? line : line.toLowerCase()).includes(needle) ? [options.query] : null;
29833
+ if (found === null) {
29834
+ continue;
29549
29835
  }
29550
- const match = regex ? regex.exec(line)?.[0] : (options.caseSensitive ? line : line.toLowerCase()).includes(needle) ? options.query : "";
29551
- if (match) {
29552
- matches.push({ line: index + 1, content: line.trim(), match });
29553
- result.summary.total_matches += 1;
29836
+ if (result.summary.total_matches >= options.maxResults) {
29837
+ result.summary.truncated = true;
29838
+ break;
29554
29839
  }
29840
+ matches.push({ line: index + 1, content: line.trim(), match: found[0] ?? "" });
29841
+ result.summary.total_matches += 1;
29555
29842
  }
29556
29843
  if (matches.length > 0) {
29557
29844
  const relativePath = entryPath.slice(projectPath.length + 1).replace(/\\/g, "/");
@@ -29565,7 +29852,7 @@ function searchProject(projectPath, options) {
29565
29852
  }
29566
29853
 
29567
29854
  // src/resources.ts
29568
- import { readFileSync as readFileSync13 } from "node:fs";
29855
+ import { readFileSync as readFileSync14, realpathSync as realpathSync4 } from "node:fs";
29569
29856
  import { extname, resolve as resolve4 } from "node:path";
29570
29857
  var STATIC_RESOURCES = [
29571
29858
  {
@@ -29599,7 +29886,7 @@ var RESOURCE_COUNT = STATIC_RESOURCES.length + RESOURCE_TEMPLATES.length;
29599
29886
  function ensureProjectPath(getProjectPath) {
29600
29887
  const projectPath = getProjectPath();
29601
29888
  if (!projectPath) {
29602
- throw new Refusal("Project path is not set. Set a Godot project path first.");
29889
+ throw new Refusal("This server knows no project to read from: start it with GDHARNESS_PROJECT, connect an editor, or make any tool call naming a projectPath that holds project.godot.");
29603
29890
  }
29604
29891
  return resolve4(projectPath);
29605
29892
  }
@@ -29615,7 +29902,22 @@ function resolveProjectFile(projectPath, resourcePath) {
29615
29902
  if (!contained.ok) {
29616
29903
  throw new Refusal(contained.reason);
29617
29904
  }
29618
- return contained.absolutePath;
29905
+ let root;
29906
+ try {
29907
+ root = realpathSync4.native(projectPath);
29908
+ } catch {
29909
+ throw new Refusal(`The project directory ${projectPath} is not there.`);
29910
+ }
29911
+ let real;
29912
+ try {
29913
+ real = realpathSync4.native(contained.absolutePath);
29914
+ } catch {
29915
+ throw new Refusal(`No file at ${resourcePath} in the project.`);
29916
+ }
29917
+ if (!isWithinRoot(root, real)) {
29918
+ throw new Refusal(`${resourcePath} leads outside the project through a link, so it is not read.`);
29919
+ }
29920
+ return real;
29619
29921
  }
29620
29922
  function parseGodotUri(uri) {
29621
29923
  if (uri === "godot://project/info") {
@@ -29760,7 +30062,7 @@ function readResourceText(uri, getProjectPath) {
29760
30062
  const parsedUri = parseGodotUri(uri);
29761
30063
  if (parsedUri.kind === "project-info") {
29762
30064
  const projectFilePath = resolveProjectFile(projectPath, "project.godot");
29763
- const rawProject = readFileSync13(projectFilePath, "utf-8");
30065
+ const rawProject = readFileSync14(projectFilePath, "utf-8");
29764
30066
  const parsedProject = parseProjectGodot(rawProject);
29765
30067
  return {
29766
30068
  mimeType: "application/json",
@@ -29769,7 +30071,7 @@ function readResourceText(uri, getProjectPath) {
29769
30071
  }
29770
30072
  const filePath = resolveProjectFile(projectPath, parsedUri.resourcePath);
29771
30073
  ensureAllowedExtension(parsedUri.kind, filePath);
29772
- const text = readFileSync13(filePath, "utf-8");
30074
+ const text = readFileSync14(filePath, "utf-8");
29773
30075
  return {
29774
30076
  mimeType: parsedUri.kind === "script" ? "text/x-gdscript" : "text/plain",
29775
30077
  text
@@ -29806,15 +30108,15 @@ function setupResourceHandlers(mcp, getProjectPath) {
29806
30108
  }
29807
30109
 
29808
30110
  // src/restart-note.ts
29809
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync14, rmSync as rmSync6, writeFileSync as writeFileSync9 } from "node:fs";
29810
- import { dirname as dirname8, join as join15 } from "node:path";
30111
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync15, rmSync as rmSync6, writeFileSync as writeFileSync9 } from "node:fs";
30112
+ import { dirname as dirname9, join as join16 } from "node:path";
29811
30113
  function restartNotePath(projectPath) {
29812
- return join15(projectPath, ".godot", "gdharness-restart.json");
30114
+ return join16(projectPath, ".godot", "gdharness-restart.json");
29813
30115
  }
29814
30116
  function noteRestartBegun(note) {
29815
30117
  const path = restartNotePath(note.projectPath);
29816
30118
  try {
29817
- mkdirSync7(dirname8(path), { recursive: true });
30119
+ mkdirSync7(dirname9(path), { recursive: true });
29818
30120
  writeFileSync9(path, `${JSON.stringify(note, null, 2)}
29819
30121
  `, "utf8");
29820
30122
  } catch (error) {
@@ -29823,7 +30125,7 @@ function noteRestartBegun(note) {
29823
30125
  }
29824
30126
  function restartOwed(projectPath) {
29825
30127
  try {
29826
- const parsed = JSON.parse(readFileSync14(restartNotePath(projectPath), "utf8"));
30128
+ const parsed = JSON.parse(readFileSync15(restartNotePath(projectPath), "utf8"));
29827
30129
  if (typeof parsed !== "object" || parsed === null) {
29828
30130
  return null;
29829
30131
  }
@@ -29845,32 +30147,32 @@ function restartSettled(projectPath) {
29845
30147
  }
29846
30148
 
29847
30149
  // src/run-record.ts
29848
- import { execFile as execFile5 } from "node:child_process";
30150
+ import { execFile as execFile4 } from "node:child_process";
29849
30151
  import { createHash as createHash3 } from "node:crypto";
29850
30152
  import {
29851
30153
  mkdirSync as mkdirSync8,
29852
30154
  openSync as openSync2,
29853
30155
  readdirSync as readdirSync6,
29854
- readFileSync as readFileSync15,
29855
- realpathSync as realpathSync3,
30156
+ readFileSync as readFileSync16,
30157
+ realpathSync as realpathSync5,
29856
30158
  rmSync as rmSync7,
29857
30159
  statSync as statSync6,
29858
30160
  writeFileSync as writeFileSync10
29859
30161
  } from "node:fs";
29860
- import { basename as basename3, dirname as dirname9, join as join16, resolve as resolve5 } from "node:path";
29861
- import { promisify as promisify5 } from "node:util";
29862
- var execFileAsync = promisify5(execFile5);
30162
+ import { basename as basename3, dirname as dirname10, join as join17, resolve as resolve5 } from "node:path";
30163
+ import { promisify as promisify4 } from "node:util";
30164
+ var execFileAsync = promisify4(execFile4);
29863
30165
  function runsDirectory() {
29864
- return join16(runtimeDirectory(), "runs");
30166
+ return join17(runtimeDirectory(), "runs");
29865
30167
  }
29866
30168
  function realAsFarAsItExists(path) {
29867
30169
  const missing = [];
29868
30170
  let at = path;
29869
30171
  for (;; ) {
29870
30172
  try {
29871
- return join16(realpathSync3.native(at), ...missing);
30173
+ return join17(realpathSync5.native(at), ...missing);
29872
30174
  } catch {
29873
- const parent = dirname9(at);
30175
+ const parent = dirname10(at);
29874
30176
  if (parent === at) {
29875
30177
  return path;
29876
30178
  }
@@ -29885,16 +30187,19 @@ function projectKey(projectPath) {
29885
30187
  return createHash3("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
29886
30188
  }
29887
30189
  function runRecordPath(projectPath, directory = runtimeDirectory()) {
29888
- return join16(directory, "runs", `note-${projectKey(projectPath)}.json`);
30190
+ return join17(directory, "runs", `note-${projectKey(projectPath)}.json`);
29889
30191
  }
29890
30192
  var SHARED_NOTE = "run.json";
29891
30193
  function openTranscript(startedAt) {
29892
30194
  mkdirSync8(runsDirectory(), { recursive: true });
29893
- const path = join16(runsDirectory(), `run-${startedAt}.log`);
30195
+ const path = join17(runsDirectory(), `run-${startedAt}.log`);
29894
30196
  return { path, fd: openSync2(path, "a") };
29895
30197
  }
30198
+ function noteIsAbout(note, playing) {
30199
+ return note.play !== undefined && playing !== null && note.play.play === playing.play && note.play.editorPid === playing.editorPid;
30200
+ }
29896
30201
  function editorNotePath(projectPath, directory = runtimeDirectory()) {
29897
- return join16(directory, "runs", `editor-${projectKey(projectPath)}.json`);
30202
+ return join17(directory, "runs", `editor-${projectKey(projectPath)}.json`);
29898
30203
  }
29899
30204
  function writeEditorRunNote(note) {
29900
30205
  mkdirSync8(runsDirectory(), { recursive: true });
@@ -29902,7 +30207,7 @@ function writeEditorRunNote(note) {
29902
30207
  }
29903
30208
  function readEditorRunNote(projectPath) {
29904
30209
  for (const directory of runtimeDirectories()) {
29905
- const note = editorNoteAt(editorNotePath(projectPath, directory)) ?? editorNoteAt(join16(directory, "runs", "editor-run.json"));
30210
+ const note = editorNoteAt(editorNotePath(projectPath, directory)) ?? editorNoteAt(join17(directory, "runs", "editor-run.json"));
29906
30211
  if (note !== null && isSameDirectory(note.projectPath, projectPath)) {
29907
30212
  return note;
29908
30213
  }
@@ -29912,7 +30217,7 @@ function readEditorRunNote(projectPath) {
29912
30217
  function editorNoteAt(path) {
29913
30218
  let parsed;
29914
30219
  try {
29915
- parsed = JSON.parse(readFileSync15(path, "utf8"));
30220
+ parsed = JSON.parse(readFileSync16(path, "utf8"));
29916
30221
  } catch {
29917
30222
  return null;
29918
30223
  }
@@ -29926,7 +30231,9 @@ function editorNoteAt(path) {
29926
30231
  if (typeof projectPath !== "string" || typeof transcript !== "string" || typeof startedAt !== "number") {
29927
30232
  return null;
29928
30233
  }
29929
- return { projectPath, transcript, startedAt };
30234
+ const play = fields["play"];
30235
+ const which = typeof play === "object" && play !== null && typeof play["play"] === "number" && typeof play["editorPid"] === "number" ? play : undefined;
30236
+ return { projectPath, transcript, startedAt, ...which === undefined ? {} : { play: which } };
29930
30237
  }
29931
30238
  function readRunRecord(projectPath) {
29932
30239
  for (const directory of runtimeDirectories()) {
@@ -29934,7 +30241,7 @@ function readRunRecord(projectPath) {
29934
30241
  if (own !== null) {
29935
30242
  return own;
29936
30243
  }
29937
- const shared = recordAt(join16(directory, "runs", SHARED_NOTE));
30244
+ const shared = recordAt(join17(directory, "runs", SHARED_NOTE));
29938
30245
  if (shared !== null && shared.projectPath !== "" && isSameDirectory(shared.projectPath, projectPath)) {
29939
30246
  return shared;
29940
30247
  }
@@ -29946,13 +30253,13 @@ function everyRunRecord() {
29946
30253
  for (const directory of runtimeDirectories()) {
29947
30254
  let entries;
29948
30255
  try {
29949
- entries = readdirSync6(join16(directory, "runs"));
30256
+ entries = readdirSync6(join17(directory, "runs"));
29950
30257
  } catch {
29951
30258
  continue;
29952
30259
  }
29953
30260
  for (const entry of entries) {
29954
30261
  if (entry === SHARED_NOTE || entry.startsWith("note-") && entry.endsWith(".json")) {
29955
- const record = recordAt(join16(directory, "runs", entry));
30262
+ const record = recordAt(join17(directory, "runs", entry));
29956
30263
  if (record !== null) {
29957
30264
  found.push(record);
29958
30265
  }
@@ -29964,7 +30271,7 @@ function everyRunRecord() {
29964
30271
  function recordAt(path) {
29965
30272
  let parsed;
29966
30273
  try {
29967
- parsed = JSON.parse(readFileSync15(path, "utf8"));
30274
+ parsed = JSON.parse(readFileSync16(path, "utf8"));
29968
30275
  } catch {
29969
30276
  return null;
29970
30277
  }
@@ -29983,7 +30290,9 @@ function recordAt(path) {
29983
30290
  pid,
29984
30291
  transcript,
29985
30292
  startedAt,
30293
+ ...typeof fields["startedBy"] === "number" ? { startedBy: fields["startedBy"] } : {},
29986
30294
  projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
30295
+ ...typeof fields["servedBy"] === "string" && fields["servedBy"] !== "" ? { servedBy: fields["servedBy"] } : {},
29987
30296
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
29988
30297
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
29989
30298
  ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
@@ -29992,7 +30301,8 @@ function recordAt(path) {
29992
30301
  }
29993
30302
  async function runningAs(pid) {
29994
30303
  if (process.platform === "win32") {
29995
- const answer = await windowsCommandLine(pid);
30304
+ const read = await readWindowsCommandLine(pid);
30305
+ const answer = "answer" in read ? read.answer : null;
29996
30306
  if (answer !== null) {
29997
30307
  const [, ...rest] = answer.split(`
29998
30308
  `);
@@ -30004,13 +30314,17 @@ async function runningAs(pid) {
30004
30314
  }
30005
30315
  }
30006
30316
  const image = await windowsImage(pid);
30007
- return image === null ? null : { kind: "image", text: image };
30317
+ return image === null ? null : {
30318
+ kind: "image",
30319
+ text: image,
30320
+ unread: "unread" in read ? read.unread : "Windows gave an empty command line"
30321
+ };
30008
30322
  }
30009
30323
  try {
30010
30324
  const began = await startedAt(pid, null);
30011
30325
  const when = began === null ? {} : { startedAt: began };
30012
30326
  if (process.platform === "linux") {
30013
- const raw = readFileSync15(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
30327
+ const raw = readFileSync16(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
30014
30328
  return raw === "" ? null : { kind: "commandLine", text: raw, ...when };
30015
30329
  }
30016
30330
  const args = (await execFileAsync("ps", ["-p", String(pid), "-o", "args="], { encoding: "utf8", timeout: 15000 })).stdout.trim();
@@ -30055,13 +30369,18 @@ function listeningPidInNetstat(printed, port) {
30055
30369
  }
30056
30370
  return null;
30057
30371
  }
30058
- async function windowsCommandLine(pid) {
30059
- try {
30060
- const answer = (await askWindows(`$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { $p.CreationDate.ToUniversalTime().ToString("o"); $p.CommandLine }`, 15000)).trim();
30061
- return answer === "" ? null : answer;
30062
- } catch {
30063
- return null;
30372
+ async function readWindowsCommandLine(pid, ask = askWindows) {
30373
+ const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { $p.CreationDate.ToUniversalTime().ToString("o"); $p.CommandLine }`;
30374
+ let unread = "";
30375
+ for (const withinMs of [15000, 45000]) {
30376
+ try {
30377
+ const answer = (await ask(query, withinMs)).trim();
30378
+ return answer === "" ? { unread: "Windows gave no command line for it" } : { answer };
30379
+ } catch (error) {
30380
+ unread = errorMessage(error);
30381
+ }
30064
30382
  }
30383
+ return { unread };
30065
30384
  }
30066
30385
  async function startedAt(pid, windowsAnswer) {
30067
30386
  if (process.platform === "win32") {
@@ -30089,37 +30408,44 @@ async function windowsImage(pid) {
30089
30408
  return null;
30090
30409
  }
30091
30410
  }
30092
- async function stillTheRecordedRun(record) {
30093
- return judgeRun(record, await runningAs(record.pid), "confirmed");
30411
+ async function whyNotStillTheRecordedRun(record) {
30412
+ return whyNotTheRun(record, await runningAs(record.pid), "confirmed");
30094
30413
  }
30095
30414
  async function couldStillBeTheRecordedRun(record) {
30096
30415
  return judgeRun(record, await runningAs(record.pid), "possible");
30097
30416
  }
30098
30417
  var SAME_RUN_WINDOW_MS = 1e4;
30099
30418
  function judgeRun(record, running, needed) {
30419
+ return whyNotTheRun(record, running, needed) === null;
30420
+ }
30421
+ function whyNotTheRun(record, running, needed) {
30100
30422
  if (running === null) {
30101
- return false;
30423
+ return "the operating system would not describe that process";
30102
30424
  }
30103
- if (running.startedAt !== undefined && running.startedAt - record.startedAt > SAME_RUN_WINDOW_MS) {
30104
- return false;
30425
+ const after = running.startedAt === undefined ? 0 : running.startedAt - (record.startedBy ?? record.startedAt);
30426
+ if (after > SAME_RUN_WINDOW_MS) {
30427
+ return `that process started ${Math.round(after / 1000)} seconds after the run had`;
30105
30428
  }
30106
30429
  const engine = record.command === undefined ? null : basename3(record.command);
30107
30430
  const said = process.platform === "win32" ? running.text.toLowerCase() : running.text;
30108
30431
  const wanted = engine === null || process.platform !== "win32" ? engine : engine.toLowerCase();
30109
30432
  if (wanted !== null && !said.includes(wanted)) {
30110
- return false;
30433
+ return `that process is not ${engine ?? "the engine"}, which the run was started with`;
30111
30434
  }
30112
30435
  if (running.kind === "commandLine" && readCommandLine(running.text).editor) {
30113
- return false;
30436
+ return "that process is an editor";
30114
30437
  }
30115
30438
  if (record.projectPath === "") {
30116
- return wanted !== null;
30439
+ return wanted !== null ? null : "the record names neither the project nor the engine";
30117
30440
  }
30118
30441
  if (running.kind === "image") {
30119
- return needed === "possible" && wanted !== null;
30442
+ if (wanted === null) {
30443
+ return "the operating system named only its executable, and the record names no engine";
30444
+ }
30445
+ return needed === "possible" ? null : `the operating system named only its executable and not its command line (${running.unread ?? "no reason given"}), which is not enough to signal it on`;
30120
30446
  }
30121
30447
  const project = process.platform === "win32" ? record.projectPath.toLowerCase() : record.projectPath;
30122
- return said.includes(project);
30448
+ return said.includes(project) ? null : `that process's command line does not name ${record.projectPath}`;
30123
30449
  }
30124
30450
  async function stillTheAnnouncedGame(pid, projectPath, announcedAt) {
30125
30451
  return judgeAnnouncedGame(await runningAs(pid), projectPath, announcedAt);
@@ -30139,7 +30465,7 @@ function judgeAnnouncedGame(running, projectPath, announcedAt) {
30139
30465
  }
30140
30466
  function clearRunRecord(projectPath) {
30141
30467
  for (const directory of runtimeDirectories()) {
30142
- const shared = join16(directory, "runs", SHARED_NOTE);
30468
+ const shared = join17(directory, "runs", SHARED_NOTE);
30143
30469
  const sharedRecord = recordAt(shared);
30144
30470
  const ours = sharedRecord !== null && sharedRecord.projectPath !== "" && isSameDirectory(sharedRecord.projectPath, projectPath);
30145
30471
  for (const path of [runRecordPath(projectPath, directory), ...ours ? [shared] : []]) {
@@ -30169,7 +30495,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
30169
30495
  if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
30170
30496
  continue;
30171
30497
  }
30172
- const path = join16(directory, entry);
30498
+ const path = join17(directory, entry);
30173
30499
  if (kept.has(path) || ageMs(path, now) < keepMs) {
30174
30500
  continue;
30175
30501
  }
@@ -30180,19 +30506,20 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
30180
30506
  }
30181
30507
 
30182
30508
  // src/setup.ts
30509
+ import { spawnSync } from "node:child_process";
30183
30510
  import { createHash as createHash4 } from "node:crypto";
30184
30511
  import {
30185
30512
  cpSync,
30186
- existsSync as existsSync11,
30513
+ existsSync as existsSync13,
30187
30514
  mkdirSync as mkdirSync9,
30188
30515
  readdirSync as readdirSync7,
30189
- readFileSync as readFileSync16,
30516
+ readFileSync as readFileSync17,
30190
30517
  rmdirSync,
30191
30518
  rmSync as rmSync8,
30192
30519
  statSync as statSync7,
30193
30520
  writeFileSync as writeFileSync11
30194
30521
  } from "node:fs";
30195
- import { dirname as dirname10, join as join17 } from "node:path";
30522
+ import { dirname as dirname11, join as join18 } from "node:path";
30196
30523
  import { fileURLToPath as fileURLToPath3 } from "node:url";
30197
30524
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
30198
30525
  var EDITOR_PLUGINS = ["gdharness_editor", "auto_reload"];
@@ -30209,21 +30536,21 @@ function editorAddonDigest(from = shippedAddonsDirectory()) {
30209
30536
  if (name === VERSION_MARKER || name === DIGEST_MARKER) {
30210
30537
  continue;
30211
30538
  }
30212
- const path = join17(directory, name);
30539
+ const path = join18(directory, name);
30213
30540
  const inside = `${relative}/${name}`;
30214
30541
  if (statSync7(path).isDirectory()) {
30215
30542
  walk(path, inside);
30216
30543
  } else {
30217
30544
  hash.update(`${inside}
30218
30545
  `);
30219
- hash.update(readFileSync16(path));
30546
+ hash.update(readFileSync17(path));
30220
30547
  hash.update(`
30221
30548
  `);
30222
30549
  }
30223
30550
  }
30224
30551
  };
30225
30552
  for (const name of EDITOR_PLUGINS) {
30226
- walk(join17(from, name), name);
30553
+ walk(join18(from, name), name);
30227
30554
  }
30228
30555
  return hash.digest("hex");
30229
30556
  }
@@ -30238,17 +30565,132 @@ function shippedEditorDigest() {
30238
30565
  }
30239
30566
  return shippedDigest ?? undefined;
30240
30567
  }
30568
+ function installedEditorDigest(projectPath) {
30569
+ const marker = join18(projectPath, "addons", ADDONS[0], DIGEST_MARKER);
30570
+ if (!existsSync13(marker)) {
30571
+ return null;
30572
+ }
30573
+ const said = readFileSync17(marker, "utf8").trim();
30574
+ return said === "" ? null : said;
30575
+ }
30241
30576
  function shippedAddonsDirectory() {
30242
- return join17(dirname10(fileURLToPath3(import.meta.url)), "godot", "addons");
30577
+ return join18(dirname11(fileURLToPath3(import.meta.url)), "godot", "addons");
30243
30578
  }
30244
30579
  function installedAddonVersion(projectPath) {
30245
- const marker = join17(projectPath, "addons", ADDONS[0], VERSION_MARKER);
30246
- if (!existsSync11(marker)) {
30580
+ const marker = join18(projectPath, "addons", ADDONS[0], VERSION_MARKER);
30581
+ if (!existsSync13(marker)) {
30247
30582
  return null;
30248
30583
  }
30249
- const said = readFileSync16(marker, "utf8").trim();
30584
+ const said = readFileSync17(marker, "utf8").trim();
30250
30585
  return said === "" ? null : said;
30251
30586
  }
30587
+ function loaderAmong(projectPath, named) {
30588
+ let byName = null;
30589
+ for (const [entry, path] of named) {
30590
+ if (entry === RUNTIME_AUTOLOAD.name || path === RUNTIME_AUTOLOAD.path) {
30591
+ continue;
30592
+ }
30593
+ let source = null;
30594
+ try {
30595
+ source = readFileSync17(join18(projectPath, path), "utf8");
30596
+ } catch {}
30597
+ if (source?.includes(RUNTIME_AUTOLOAD.path)) {
30598
+ return { name: entry, path, exists: true };
30599
+ }
30600
+ byName ??= path.toLowerCase().includes("gdharness") ? { name: entry, path, exists: source !== null } : null;
30601
+ }
30602
+ return byName;
30603
+ }
30604
+ function autoloadPaths(settings) {
30605
+ const named = new Map;
30606
+ for (const [name, value] of Object.entries(settings["autoload"] ?? {})) {
30607
+ const said = typeof value === "string" ? value.replace(/^\*/, "") : "";
30608
+ if (said.startsWith("res://")) {
30609
+ named.set(name, said.slice("res://".length));
30610
+ }
30611
+ }
30612
+ return named;
30613
+ }
30614
+ function trackedByGit(projectPath, wanted) {
30615
+ if (wanted.length === 0) {
30616
+ return new Set;
30617
+ }
30618
+ const asked = spawnSync("git", ["ls-files", "-z", "--", ...wanted], {
30619
+ cwd: projectPath,
30620
+ encoding: "utf8"
30621
+ });
30622
+ if (asked.status !== 0) {
30623
+ return null;
30624
+ }
30625
+ return new Set(asked.stdout.split("\x00").filter(Boolean));
30626
+ }
30627
+ function enabledPlugins(settings) {
30628
+ const raw = settings["editor_plugins"]?.["enabled"];
30629
+ if (typeof raw !== "string") {
30630
+ return [];
30631
+ }
30632
+ return [...raw.matchAll(/res:\/\/addons\/([^/"]+)\/plugin\.cfg/g)].map((match) => match[1] ?? "").filter(Boolean);
30633
+ }
30634
+ function inspectProject(projectPath) {
30635
+ const problems = [];
30636
+ const addons = ADDONS.map((name) => {
30637
+ const directory = join18(projectPath, "addons", name);
30638
+ const marker = join18(directory, VERSION_MARKER);
30639
+ const installed = existsSync13(directory);
30640
+ const version = existsSync13(marker) ? readFileSync17(marker, "utf8").trim() : null;
30641
+ const current = version === SERVER_VERSION;
30642
+ if (!installed) {
30643
+ problems.push(`addons/${name} is not installed; run gdharness setup`);
30644
+ } else if (!current) {
30645
+ problems.push(`addons/${name} is ${version ?? "of an unknown version"}, this gdharness is ${SERVER_VERSION}; run gdharness setup`);
30646
+ }
30647
+ return { name, installed, version, current };
30648
+ });
30649
+ const settings = parseProjectGodot(readFileSync17(join18(projectPath, "project.godot"), "utf8"));
30650
+ const pluginsEnabled = enabledPlugins(settings);
30651
+ for (const name of EDITOR_PLUGINS) {
30652
+ if (!pluginsEnabled.includes(name)) {
30653
+ problems.push(`the ${name} plugin is not enabled in project.godot; run gdharness setup`);
30654
+ }
30655
+ }
30656
+ const autoload = settings["autoload"];
30657
+ const registered = autoload === undefined ? undefined : readString(autoload, RUNTIME_AUTOLOAD.name);
30658
+ const runtimeAutoloadPath = typeof registered === "string" ? registered.replace(/^\*/, "") : null;
30659
+ const named = autoloadPaths(settings);
30660
+ const runtimeLoaderAutoload = loaderAmong(projectPath, named);
30661
+ const runtimeAutoload = runtimeAutoloadPath !== null || runtimeLoaderAutoload !== null;
30662
+ const tracked = trackedByGit(projectPath, [...named.values()]);
30663
+ const addonMissing = (path) => addons.some((addon) => !addon.installed && path.startsWith(`addons/${addon.name}/`));
30664
+ for (const [name, path] of named) {
30665
+ if (!existsSync13(join18(projectPath, path))) {
30666
+ if (!addonMissing(path)) {
30667
+ problems.push(`the ${name} autoload names res://${path}, which is not in this project, so it boots with a missing script; restore that file, or point the autoload at one that is here`);
30668
+ }
30669
+ continue;
30670
+ }
30671
+ if (tracked !== null && !tracked.has(path)) {
30672
+ problems.push(`the ${name} autoload names res://${path}, which git does not carry, so a clone boots with a missing script; commit that file, or point the autoload at one the project tracks`);
30673
+ }
30674
+ }
30675
+ const cached = cachedClasses(projectPath);
30676
+ const staleClasses = cached === null ? [] : staleAgainst(cached, projectPath);
30677
+ if (cached === null) {
30678
+ problems.push("no .godot/global_script_class_cache.cfg: the engine knows no class_name; run project_import refresh_classes or open the editor");
30679
+ } else if (staleClasses.length > 0) {
30680
+ problems.push(`the class cache does not list ${staleClasses.join(", ")}; run project_import refresh_classes or gdharness classes`);
30681
+ }
30682
+ return {
30683
+ projectPath,
30684
+ addons,
30685
+ pluginsEnabled,
30686
+ runtimeAutoload,
30687
+ runtimeAutoloadPath,
30688
+ runtimeLoaderAutoload,
30689
+ staleClasses,
30690
+ classCacheExists: cached !== null,
30691
+ problems
30692
+ };
30693
+ }
30252
30694
 
30253
30695
  // src/tool-definitions.ts
30254
30696
  var PROJECT_INFO_SECTIONS = [
@@ -30299,7 +30741,7 @@ var NODE_PATH = {
30299
30741
  };
30300
30742
  var PROPERTIES = {
30301
30743
  type: "object",
30302
- description: 'Properties to set, keyed by Godot property name. Vectors, colours and the like may be written as {"x": 1, "y": 2} or tagged {"_type": "Vector2", "x": 1, "y": 2}, which is the form a read answers with, so a value read off one node can be written straight to another.',
30744
+ description: 'Properties to set, keyed by Godot property name. Vectors, colours and the like may be written as {"x": 1, "y": 2} or tagged {"_type": "Vector2", "x": 1, "y": 2}, which is the form a read answers with, so a value read off one node can be written straight to another. A property holding a node takes the path to it from the node being set, such as "../Player". Each value is read back once set, and one the engine changed or refused is refused with what it holds.',
30303
30745
  additionalProperties: true
30304
30746
  };
30305
30747
  var XY = {
@@ -30454,11 +30896,18 @@ var TOOL_SPECS = [
30454
30896
  },
30455
30897
  value: {
30456
30898
  blank: true,
30457
- description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}. "" writes an empty string.'
30899
+ description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}, and infinity as {"_type": "float", "value": "inf"}, the form a read answers with. "" writes an empty string.'
30458
30900
  },
30459
30901
  name: { type: "string", description: "Autoload name." },
30460
30902
  path: { type: "string", description: "Autoload script or scene inside the project." },
30461
- enabled: { type: "boolean", description: "Autoloads: register enabled. Default true." },
30903
+ enabled: {
30904
+ type: "boolean",
30905
+ description: "Autoloads: Godot 4 loads every autoload in the list, so false is refused. Use global: false to keep the name out of the global scope, or remove_autoload to stop it loading."
30906
+ },
30907
+ global: {
30908
+ type: "boolean",
30909
+ description: `Autoloads: make the name a global variable, the * in project.godot and the editor's "Global Variable" column. Default true. The autoload loads either way.`
30910
+ },
30462
30911
  scenePath: SCENE_PATH,
30463
30912
  actionName: { type: "string", description: 'Input action name, such as "jump".' },
30464
30913
  events: INPUT_EVENTS,
@@ -30470,9 +30919,15 @@ var TOOL_SPECS = [
30470
30919
  },
30471
30920
  pluginName: { type: "string", description: "Folder name under addons/." },
30472
30921
  busName: { type: "string", description: "Audio bus name." },
30473
- parentBusIndex: { type: "number", description: "Audio buses: the bus to send to. Default 0, Master." },
30922
+ parentBusIndex: {
30923
+ type: "number",
30924
+ description: "Audio buses: the bus to send to. Default 0, Master. The new bus goes right after it, so every later bus moves one along, and the answer lists them all."
30925
+ },
30474
30926
  busIndex: { type: "number", description: "Audio bus index." },
30475
- effectIndex: { type: "number", description: "Slot on the bus for the effect." },
30927
+ effectIndex: {
30928
+ type: "number",
30929
+ description: "Slot on the bus for the effect: the effect there is replaced, and one past the last adds one. Any other slot is refused."
30930
+ },
30476
30931
  effectType: { type: "string", description: 'Effect class, such as "AudioEffectReverb".' },
30477
30932
  volumeDb: { type: "number", description: "Bus volume in decibels." }
30478
30933
  },
@@ -30491,7 +30946,7 @@ var TOOL_SPECS = [
30491
30946
  disable_plugin: { summary: "disable an addon", requires: ["pluginName"] },
30492
30947
  add_audio_bus: { summary: "add an audio bus", requires: ["busName"] },
30493
30948
  set_audio_bus_effect: {
30494
- summary: "add or configure an effect on a bus",
30949
+ summary: "put an effect in a slot on a bus",
30495
30950
  requires: ["busIndex", "effectIndex", "effectType"]
30496
30951
  },
30497
30952
  set_audio_bus_volume: { summary: "set a bus volume", requires: ["busIndex", "volumeDb"] }
@@ -30530,7 +30985,11 @@ var TOOL_SPECS = [
30530
30985
  type: "boolean",
30531
30986
  description: "forward: include the engine's own res://. resources. Default false."
30532
30987
  },
30533
- fileTypes: { type: "array", items: { type: "string" }, description: "reverse: extensions to look in." }
30988
+ fileTypes: {
30989
+ type: "array",
30990
+ items: { type: "string" },
30991
+ description: 'reverse: extensions to look in, plus "godot" for project.godot, "cfg" for plugin.cfg and export_presets.cfg, and "import" for import sidecars. Default all of tscn, tres, gd, gdshader, godot, cfg and import, since an autoload, the main scene and a plugin script are named only in the settings files.'
30992
+ }
30534
30993
  },
30535
30994
  requires: ["projectPath", "resourcePath"]
30536
30995
  },
@@ -30575,7 +31034,10 @@ var TOOL_SPECS = [
30575
31034
  summary: "reimport what status says is outdated or failed, one resource with resourcePath, or what is current as well with force. Through the open editor when one serves the project, so it reloads what it holds, waiting for it to finish; otherwise through the engine's own import pass. Answered from a second status read: reimported lists what is current now, notReimported what is not, with its status and reason, and via says which of the two did the work. stillImporting is an editor that had not finished within timeoutMs",
30576
31035
  requires: []
30577
31036
  },
30578
- uid: { summary: "the UID of one file", requires: ["resourcePath"] },
31037
+ uid: {
31038
+ summary: "the UID of one file and where it was read: a script's or shader's .uid file, an imported file's .import, or a scene's or resource's own header",
31039
+ requires: ["resourcePath"]
31040
+ },
30579
31041
  refresh_uids: {
30580
31042
  summary: "import the project so every script and shader has its .uid sidecar, and name under uidsCreated the ones this made and under stillWithoutUid the ones the engine would not import: it writes no scene and no script, so a project whose sidecars are all present is left untouched",
30581
31043
  requires: []
@@ -30636,11 +31098,11 @@ var TOOL_SPECS = [
30636
31098
  },
30637
31099
  {
30638
31100
  name: "scene_create",
30639
- description: "Creates a scene file, saves one, or saves a copy under a new path. Needs the editor connected.",
31101
+ description: "Creates a scene file where none is, saves one, or saves a copy under a new path. A save of a scene open in the editor saves the editor's copy, unsaved changes included. Needs the editor connected.",
30640
31102
  parameters: {
30641
31103
  projectPath: PROJECT_PATH,
30642
31104
  scenePath: SCENE_PATH,
30643
- rootNodeType: { type: "string", description: "create: the root node class. Default Node2D." },
31105
+ rootNodeType: { type: "string", description: "create: the root node class. Default Node." },
30644
31106
  newPath: { type: "string", description: "save_as: where the copy goes." }
30645
31107
  },
30646
31108
  requires: ["projectPath", "scenePath"],
@@ -30664,7 +31126,7 @@ var TOOL_SPECS = [
30664
31126
  },
30665
31127
  {
30666
31128
  name: "scene_node",
30667
- description: "One node in a scene file: add, read, set, duplicate, reparent or delete it, or paint TileMap cells. Any ClassDB node type can be added, so a NavigationRegion2D, an AnimationTree or a Camera3D is an add with that nodeType and its properties. A property holding a Resource takes the res:// path of one, so a texture, a material or a theme is a set like any other. Needs the editor connected.",
31129
+ description: "One node in a scene file: add, read, set, duplicate, reparent or delete it, or paint TileMap cells. Any ClassDB node type can be added, so a NavigationRegion2D, an AnimationTree or a Camera3D is an add with that nodeType and its properties. A property holding a Resource takes the res:// path of one, so a texture, a material or a theme is a set like any other. A write is refused while the editor holds unsaved changes to the scene, and for a node that belongs to a scene this one instances or inherits. Needs the editor connected.",
30668
31130
  parameters: {
30669
31131
  projectPath: PROJECT_PATH,
30670
31132
  scenePath: SCENE_PATH,
@@ -30682,7 +31144,10 @@ var TOOL_SPECS = [
30682
31144
  type: "boolean",
30683
31145
  description: "get: include properties still at their default. Default false."
30684
31146
  },
30685
- layer: { type: "number", description: "set_tilemap_cells: the TileMap layer. Default 0." },
31147
+ layer: {
31148
+ type: "number",
31149
+ description: "set_tilemap_cells: the layer of a TileMap. Default 0. A TileMapLayer is one layer."
31150
+ },
30686
31151
  cells: TILEMAP_CELLS
30687
31152
  },
30688
31153
  requires: ["projectPath", "scenePath"],
@@ -30693,7 +31158,10 @@ var TOOL_SPECS = [
30693
31158
  duplicate: { summary: "copy a node and its children", requires: ["nodePath", "newName"] },
30694
31159
  reparent: { summary: "move a node under another parent", requires: ["nodePath", "newParentPath"] },
30695
31160
  delete: { summary: "remove a node and its children", requires: ["nodePath"] },
30696
- set_tilemap_cells: { summary: "place tiles in a TileMap", requires: ["nodePath", "cells"] }
31161
+ set_tilemap_cells: {
31162
+ summary: "place tiles in a TileMapLayer or TileMap",
31163
+ requires: ["nodePath", "cells"]
31164
+ }
30697
31165
  }
30698
31166
  },
30699
31167
  {
@@ -30781,13 +31249,16 @@ var TOOL_SPECS = [
30781
31249
  },
30782
31250
  {
30783
31251
  name: "script_edit",
30784
- description: "Creates a GDScript file, or adds functions, variables and signals to one. Every declaration written carries a type. create loads what it wrote under the project's own warning settings and answers with parses; the engine's reasons for a refusal come back under engine_messages.",
31252
+ description: "Creates a GDScript file, or adds functions, variables and signals to one. Every declaration written carries a type. Both load what they wrote under the project's own warning settings and answer with parses; the engine's reasons for a refusal come back under engine_messages. create answers with the extends and class_name the engine read, and a script declaring a class_name is put in the project's class list, with registered saying whether it is there. modify makes every addition or none, and answers with the line each one is on in the file written.",
30785
31253
  parameters: {
30786
31254
  projectPath: PROJECT_PATH,
30787
31255
  scriptPath: SCRIPT_PATH,
30788
- className: { type: "string", description: "create: a class_name for the script." },
30789
- extends: { type: "string", description: "create: the base class. Default Node." },
30790
- content: { type: "string", description: "create: the whole file, instead of a template." },
31256
+ className: { type: "string", description: "create: a class_name for the script. Not with content." },
31257
+ extends: { type: "string", description: "create: the base class. Default Node. Not with content." },
31258
+ content: {
31259
+ type: "string",
31260
+ description: "create: the whole file, written as given, instead of a template, className and extends."
31261
+ },
30791
31262
  template: {
30792
31263
  type: "string",
30793
31264
  enum: ["singleton", "state_machine", "component", "resource"],
@@ -30878,13 +31349,13 @@ var TOOL_SPECS = [
30878
31349
  },
30879
31350
  {
30880
31351
  name: "editor_launch",
30881
- description: "Opens the Godot editor on a project, or restarts the one already connected. The editor is an ordinary visible one on the desktop in use, and on Windows it is shown without being activated, so opening or reopening it does not take the keyboard from whatever the user is typing in. hidden: true opens it headless instead and, on Windows, on a desktop of its own, where the games it plays go too; a hidden editor serves the bridge, the language server and the debug adapter as a visible one does. A game a visible editor plays opens as a window on the desktop in use. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect, and nothing else here restarts one; it saves open scenes on the way out and answers with the version that came back. An editor a server opened is started again by the server, visible unless hidden: true is passed again. One opened by hand restarts itself, which only one with a window can do, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon. Opening a project is a save and so is closing one, which is the part that is not obvious from outside: the editor imports the project on open and writes project.godot back, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped either way. A restart says which under settingsDropped in its own answer because it waits for the editor to return. An open cannot, since it answers as soon as the process exists and the save happens during the import minutes later, so editor_status carries that reading once the editor has connected. Nothing else will say a key went until something depends on one. A restart of an editor this server opened is a quit and then a launch, and this server can be ended between them, usually by the reconnect the restart was being taken for: the editor is gone and nothing replaces it. That is written down before the quit, so the next server says so under restartInterrupted in editor_status rather than waiting for an editor that is not coming, and open finishes it, saying so under finishesRestart.",
31352
+ description: "Opens the Godot editor on a project, or restarts the one already connected. The editor is an ordinary visible one on the desktop in use, and on Windows it is shown without being activated, so opening or reopening it does not take the keyboard from whatever the user is typing in. hidden: true opens it headless instead and, on Windows, on a desktop of its own, where the games it plays go too; a hidden editor serves the bridge, the language server and the debug adapter as a visible one does. A game a visible editor plays opens as a window on the desktop in use. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect, and nothing else here restarts one; it saves open scenes on the way out and answers with the version that came back. An editor a server opened is started again by the server, visible unless hidden: true is passed again, once the old one has gone: it waits 90 seconds for that, or GDHARNESS_EDITOR_RESTART_TIMEOUT_MS, and an old editor still running after it is not replaced, since the new one would find its ports held. One opened by hand restarts itself, which only one with a window can do, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon. Opening a project is a save and so is closing one, which is the part that is not obvious from outside: the editor imports the project on open and writes project.godot back, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped either way. A restart says which under settingsDropped in its own answer because it waits for the editor to return. An open cannot, since it answers as soon as the process exists and the save happens during the import minutes later, so editor_status carries that reading once the editor has connected. Nothing else will say a key went until something depends on one. A restart of an editor this server opened is a quit and then a launch, and this server can be ended between them, usually by the reconnect the restart was being taken for: the editor is gone and nothing replaces it. That is written down before the quit, so the next server says so under restartInterrupted in editor_status rather than waiting for an editor that is not coming, and open finishes it, saying so under finishesRestart. An open is refused when the editor it would start could never reach this server: a project other than the one this server serves, whose editor dials that project's own server; a project without the gdharness editor addon installed and enabled; and a project whose editor a restart is already bringing back.",
30882
31353
  parameters: {
30883
31354
  projectPath: { ...PROJECT_PATH, ops: ["open"] },
30884
31355
  hidden: {
30885
31356
  type: "boolean",
30886
31357
  ops: ["open", "restart"],
30887
- description: "Open the editor headless and, on Windows, on a desktop of its own, so no window of it or of the games it plays appears. Default false: one visible editor, shown without taking the focus."
31358
+ description: "Open the editor headless and, on Windows, on a desktop of its own, so no window of it or of the games it plays appears. Default false: one visible editor, shown without taking the focus. On restart only for an editor a server opened; one opened by hand comes back as it was, so hidden is refused there."
30888
31359
  }
30889
31360
  },
30890
31361
  requires: [],
@@ -30972,7 +31443,7 @@ var TOOL_SPECS = [
30972
31443
  requires: ["projectPath"]
30973
31444
  },
30974
31445
  stop: {
30975
- summary: "end the run and answer with what it printed last, naming the process ended under endedPid: for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. A game that started processes of its own keeps them unless andChildren says to end those too. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled",
31446
+ summary: "end the run and answer with what it printed last, naming the process ended under endedPid: for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. A game that started processes of its own keeps them unless andChildren says to end those too. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled, and false with the number under stillGoing when what was told to end was still running ten seconds later",
30976
31447
  requires: []
30977
31448
  },
30978
31449
  check: {
@@ -30985,7 +31456,7 @@ var TOOL_SPECS = [
30985
31456
  },
30986
31457
  {
30987
31458
  name: "editor_output",
30988
- description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. For a run the editor plays the reports come from the game itself, through the runtime addon, since the editor's debugger relays what a game prints and not what it reports; a played game without the runtime addon answers here with its prints alone. Answers with the counts and the verdict as well as the entries. heldAt is three answers: null for a run this server knows is not held, running or over, an object for one it knows is held at a breakpoint, whether the adapter said so or the runtime did by accepting a connection and never answering, and absent with heldUnknown beside it for a server that connected to the debugger after the stop and has no runtime to ask, since the adapter reports a stop only to the sessions connected when it happened. A run that has quit still answers here, with running false and its exit code, until the next one starts; a run ended by a signal has no exit code, and exitSignal names the signal instead. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering. op: \"editor\" answers about the editor itself instead, which is a different log and the only way anything here can read what an editor printed: its console reaches no plugin, so the addon cannot scrape it, and an editor gdharness opened is asked for a log file as it starts. That covers the whole session from the editor's first line, which is where the output worth reading usually is, since the class cache, the plugins and the language server all say what they have to say before any harness has connected. The answer carries the entries in the order they were printed and, beside them under repeated, each message shape several lines share: how many, what varied in each slot of it and how many different values stood there, the first occurrence, and the ordinary lines above the group, found by walking back past the group rather than by counting lines. An editor somebody opened by hand has no console here and is refused saying so rather than answered empty, because an editor that printed nothing and an editor nobody captured are not the same thing.",
31459
+ description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. For a run the editor plays the reports come from the game itself, through the runtime addon, since the editor's debugger relays what a game prints and not what it reports; a played game without the runtime addon answers here with its prints alone, with errorsUnread true and no clean, since what it raised reaches nothing here. Answers with the counts and the verdict as well as the entries. heldAt is three answers: null for a run this server knows is not held, running or over, an object for one it knows is held at a breakpoint, whether the adapter said so or the runtime did by accepting a connection and never answering, and absent with heldUnknown beside it for a server that connected to the debugger after the stop and has no runtime to ask, since the adapter reports a stop only to the sessions connected when it happened. A run that has quit still answers here, with running false and its exit code, until the next one starts; a run ended by a signal has no exit code, and exitSignal names the signal instead. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering. op: \"editor\" answers about the editor itself instead, which is a different log and the only way anything here can read what an editor printed: its console reaches no plugin, so the addon cannot scrape it, and an editor gdharness opened is asked for a log file as it starts. That covers the whole session from the editor's first line, which is where the output worth reading usually is, since the class cache, the plugins and the language server all say what they have to say before any harness has connected. The answer carries the entries in the order they were printed and, beside them under repeated, each message shape several lines share: how many, what varied in each slot of it and how many different values stood there, the first occurrence, and the ordinary lines above the group, found by walking back past the group rather than by counting lines. An editor somebody opened by hand has no console here and is refused saying so rather than answered empty, because an editor that printed nothing and an editor nobody captured are not the same thing.",
30989
31460
  parameters: {
30990
31461
  severity: {
30991
31462
  type: "string",
@@ -31033,7 +31504,7 @@ var TOOL_SPECS = [
31033
31504
  },
31034
31505
  {
31035
31506
  name: "editor_status",
31036
- 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. 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. 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. 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.",
31507
+ 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. 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. 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.",
31037
31508
  parameters: {},
31038
31509
  requires: []
31039
31510
  },
@@ -31049,7 +31520,7 @@ var TOOL_SPECS = [
31049
31520
  },
31050
31521
  reloadScript: {
31051
31522
  type: "string",
31052
- description: "A script to recompile in the editor after the scan, for the half a scan does not reach: a script the editor has loaded keeps the copy it built, that copy is refreshed when one of its dependencies changes rather than when it changes itself, and a scan leaves it alone. Measured here, a built copy comes back without a method added to it while script_diagnostics on the same script reads clean in the same window, so the copy the editor built and whatever the analyser resolves types against are not known to be one thing: reload for the copy, and read the diagnostics as a separate answer rather than as this one confirmed. This recompiles into the same object, so the holders that kept the stale copy alive see the new one. The methods it has afterwards come back under reloadedMethods, because a reload that compiled nothing and answered OK is the failure worth catching, and what it held before under heldBeforeReload. Its constants come back the same way, under heldConstantsBeforeReload and reloadedConstants, with each enum value as Kind.SHORT: an enum member is held as a constant and never shows among the methods. A script that does not compile is not reloaded: reloadProblem gives Godot's error, says whether the copy the editor holds survived, and names the classes the file uses, whose held copies being behind is what failed one before."
31523
+ description: "A script to recompile in the editor after the scan, for the half a scan does not reach: a script the editor has loaded keeps the copy it built, that copy is refreshed when one of its dependencies changes rather than when it changes itself, and a scan leaves it alone. Measured here, a built copy comes back without a method added to it while script_diagnostics on the same script reads clean in the same window, so the copy the editor built and whatever the analyser resolves types against are not known to be one thing: reload for the copy, and read the diagnostics as a separate answer rather than as this one confirmed. This recompiles into the same object, so the holders that kept the stale copy alive see the new one. The methods it has afterwards come back under reloadedMethods, because a reload that compiled nothing and answered OK is the failure worth catching, and what it held before under heldBeforeReload. Its constants come back the same way, under heldConstantsBeforeReload and reloadedConstants, with each enum value as Kind.SHORT: an enum member is held as a constant and never shows among the methods. A script that does not compile is not reloaded: reloadProblem gives Godot's error, says whether the copy the editor holds survived, and names the classes the file uses, whose held copies being behind is what failed one before. A @tool script is not reloaded either, since it runs inside the editor and reloading one from inside a call can take the editor down: reloadProblem says so, and editor_launch restart recompiles it. A path spelled in another case than the file is reloaded under the spelling on disk, which is the one the editor holds its copy under, and that spelling comes back under reloadedAs."
31053
31524
  }
31054
31525
  },
31055
31526
  requires: ["projectPath"]
@@ -31072,6 +31543,7 @@ var TOOL_SPECS = [
31072
31543
  "physics2d",
31073
31544
  "physics3d",
31074
31545
  "audio",
31546
+ "visual",
31075
31547
  "animation",
31076
31548
  "ui"
31077
31549
  ],
@@ -31111,7 +31583,7 @@ var TOOL_SPECS = [
31111
31583
  property: {
31112
31584
  type: "string",
31113
31585
  ops: ["property", "find"],
31114
- description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A packed list, such as a PackedStringArray, steps the same way. A negative number counts from the end. A step written as a call, "get_viewport():gui_get_focus_owner()", calls a method that takes no arguments and walks into what it returned, which is how a question only a method answers is read in one call: which control holds the focus in the viewport this one is in. A list or a map answers the calls that read it and take no arguments, "_game:run:board:size()" or "_game:inbox:keys()", and not those that change it, such as clear() or sort(). A step that is not there is named, and says what was there instead.'
31586
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A packed list, such as a PackedStringArray, steps the same way. A negative number counts from the end. A step written as a call, "get_viewport():gui_get_focus_owner()", calls a method that takes no arguments and only reads (an engine method the engine marks as reading, or a game method not declared -> void, so queue_free() is refused) and walks into what it returned, which is how a question only a method answers is read in one call: which control holds the focus in the viewport this one is in. A list or a map answers the calls that read it and take no arguments, "_game:run:board:size()" or "_game:inbox:keys()", and not those that change it, such as clear() or sort(). A step that is not there is named, and says what was there instead.'
31115
31587
  },
31116
31588
  depth: {
31117
31589
  type: "integer",
@@ -31150,13 +31622,14 @@ var TOOL_SPECS = [
31150
31622
  limit: {
31151
31623
  type: "integer",
31152
31624
  minimum: 1,
31625
+ maximum: 5000,
31153
31626
  ops: ["find", "text"],
31154
- description: "find: the most nodes to answer with, default 100. text: the most lines, default 500, with omitted saying how many lines that left behind. A screen whose dialog sits under a long list is a screen read with omitted greater than zero, so raise this or point root at the dialog rather than reading the answer as what is on screen."
31627
+ description: "find: the most nodes to answer with, default 100. text: the most lines, default 500, with omitted saying how many lines that left behind. At most 5000 for either. A screen whose dialog sits under a long list is a screen read with omitted greater than zero, so raise this or point root at the dialog rather than reading the answer as what is on screen."
31155
31628
  },
31156
31629
  includeHidden: {
31157
31630
  type: "boolean",
31158
31631
  ops: ["text", "find"],
31159
- description: "text: read hidden nodes as well, for checking that something is not showing. Default false. find: default true, since a find means the node whether or not it is drawn; false answers with what the player can actually see, which is how a panel that keeps a label for every line and hides the ones that do not is read. A node counts as hidden when anything above it is, and how many matches were left out comes back under hidden."
31632
+ description: "text: read hidden nodes as well, for checking that something is not showing. Default false. find: default true, since a find means the node whether or not it is drawn; false answers with what the player can actually see, which is how a panel that keeps a label for every line and hides the ones that do not is read. A node counts as hidden when the engine would not draw it: a hidden Control, Node2D or Node3D above it hides it, a plain Node in between breaks that chain as it does for the engine, and a hidden canvas layer or window hides everything in it. How many matches were left out comes back under hidden."
31160
31633
  },
31161
31634
  metrics: {
31162
31635
  type: "array",
@@ -31195,7 +31668,7 @@ var TOOL_SPECS = [
31195
31668
  },
31196
31669
  {
31197
31670
  name: "runtime_invoke",
31198
- description: "Sets a property or calls a method on a node in the running game. Needs the game running with the runtime addon. The answer carries elapsed_usec, how long the call or the write took inside the game, measured around it there: the round trip to the game is a second or more, so timing from outside measures that instead. A call that takes longer than timeoutMs is not cancelled: the answer is pending: true with a requestId, the call goes on and does everything it was asked, and op result with that requestId collects its reply once it comes.",
31671
+ description: "Sets a property or calls a method on a node in the running game. Needs the game running with the runtime addon. The answer carries elapsed_usec, how long the call or the write took inside the game, measured around it there: the round trip to the game is a second or more, so timing from outside measures that instead. Errors and warnings the engine reported while a called method ran come back under errors, the first 20, with errors_not_shown counting the rest. A call that takes longer than timeoutMs is not cancelled: the answer is pending: true with a requestId, the call goes on and does everything it was asked, and op result with that requestId collects its reply once it comes.",
31199
31672
  parameters: {
31200
31673
  projectPath: RUNNING_PROJECT_PATH,
31201
31674
  pid: RUNNING_PID,
@@ -31207,12 +31680,12 @@ var TOOL_SPECS = [
31207
31680
  property: {
31208
31681
  type: "string",
31209
31682
  ops: ["set"],
31210
- description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end, and a step written as a call, "get_viewport():gui_embed_subwindows", walks through what a method taking no arguments returned. The answer reads back off the same holder, and a write that leaves the property as it was, because the engine would not take it, is refused and says what the property still holds. A write of the value the property already holds, at its own precision, as a float given in 64 bits to a property keeping 32, is not refused: it answers unchanged: true.'
31683
+ description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end, and a step written as a call, "get_viewport():gui_embed_subwindows", walks through what a method taking no arguments returned. A map keyed by numbers is stepped into by the number, "_game:units:3". The answer reads back off the same holder, and a write that leaves the property as it was is refused and says what it still holds: the engine not taking it and a setter keeping the value look the same, so the refusal names neither. A write of the value the property already holds, at its own precision, as a float given in 64 bits to a property keeping 32, is not refused: it answers unchanged: true.'
31211
31684
  },
31212
31685
  value: {
31213
31686
  ops: ["set"],
31214
31687
  blank: true,
31215
- description: `set: the value, fitted to the property's type. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance, or null, which the text null also means, to empty it. A typed list or map, such as Array[int] or Dictionary[String, int], takes a JSON list or object with each element fitted to its element type, and an element that cannot become one is refused by its index or key.`
31688
+ description: `set: the value, fitted to the property's type. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance, or null, which the text null also means, to empty it. One declared as Variant or untyped takes anything, as the engine does: a value of the kind it holds is fitted to it, an int staying an int, and any other is stored as sent. A typed list or map, such as Array[int] or Dictionary[String, int], takes a JSON list or object with each element fitted to its element type, and an element that cannot become one is refused by its index or key, as is an object of another script than the one the list holds, whether or not that script has a class_name.`
31216
31689
  },
31217
31690
  method: {
31218
31691
  type: "string",
@@ -31222,7 +31695,7 @@ var TOOL_SPECS = [
31222
31695
  args: {
31223
31696
  type: "array",
31224
31697
  ops: ["call"],
31225
- description: `call: the arguments, fitted to the method's parameter types. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused. A parameter typed as a list or map, such as Array[int] or Array[Gear], is built from a JSON list or object element by element, objects named by their paths.`
31698
+ description: `call: the arguments, fitted to the method's parameter types. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused, and so are too few or too many arguments. The engine names a parameter typed as a script class without class_name by the class the script extends, so an object of that class with another script passes that check and the engine refuses the call: the answer says the method did not run, with the engine's reason. A parameter typed as a list or map, such as Array[int] or Array[Gear], is built from a JSON list or object element by element, objects named by their paths.`
31226
31699
  },
31227
31700
  timeoutMs: {
31228
31701
  type: "integer",
@@ -31255,7 +31728,7 @@ var TOOL_SPECS = [
31255
31728
  },
31256
31729
  {
31257
31730
  name: "runtime_capture",
31258
- description: "A picture of the running game: the whole screen or one viewport, as an image. 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.",
31731
+ 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.",
31259
31732
  parameters: {
31260
31733
  projectPath: RUNNING_PROJECT_PATH,
31261
31734
  pid: RUNNING_PID,
@@ -31300,7 +31773,7 @@ var TOOL_SPECS = [
31300
31773
  says: {
31301
31774
  type: "string",
31302
31775
  ops: ["click"],
31303
- description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it, is not on screen. The matches are ranked the way a person picks what to press: a button before text that cannot be pressed, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one."
31776
+ description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one."
31304
31777
  },
31305
31778
  action: {
31306
31779
  type: "string",
@@ -31333,7 +31806,7 @@ var TOOL_SPECS = [
31333
31806
  replace: {
31334
31807
  type: "boolean",
31335
31808
  ops: ["text"],
31336
- description: 'text: true writes over what the field already says, which is what filling one in means. Default false types at the caret, so a field reading 2.1 typed "0.3" at reads 2.10.3. The answer says what the field holds afterwards either way.'
31809
+ description: 'text: true writes over what the field already says, which is what filling one in means. Default false types at the caret, so a field reading 2.1 typed "0.3" at reads 2.10.3. The answer says what the field holds afterwards either way. A read-only field is refused.'
31337
31810
  },
31338
31811
  index: {
31339
31812
  type: "number",
@@ -31377,11 +31850,11 @@ var TOOL_SPECS = [
31377
31850
  requires: [],
31378
31851
  operations: {
31379
31852
  click: {
31380
- 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. The control is named by nodePath, or by says with the words on it, found under nodePath when both are given, and the answer then says under found which match it was. 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",
31853
+ 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. The control is named by nodePath, or by says with the words on it, found under nodePath when both are given, and the answer then says under found which match it was. A control out of sight inside a ScrollContainer is scrolled to first, each container out from it a frame after the one inside, and scrolled_into_view says whether a scroll value changed. A disabled button, and a control that does not process (the game paused, say), are refused: the pointer reaches them and the press does nothing. A 3D node is clicked where it is drawn, and landed then says the interface did not swallow the press: no control under the pointer stopped it, a control letting the pointer pass included, and every viewport on the way out from the room had the pointer over what shows the room",
31381
31854
  requires: []
31382
31855
  },
31383
31856
  choose: {
31384
- summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen, read before the press, what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick), and what the button in front of it shows now when it is still there",
31857
+ 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 menu is opened, the item is looked for in it as it stands then, since a game can fill a menu as it opens, and the item takes the focus and ui_accept presses it, which is the engine's own path, needs no window, and does not depend on what ui_accept is bound to. A disabled or hidden button in front of the menu is refused, and so is a press the menu did not take, which is heard rather than assumed. Answers with what was chosen, read before the press, what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick), and what the button in front of it shows now when it is still there",
31385
31858
  requires: ["nodePath"]
31386
31859
  },
31387
31860
  action: { summary: "press an action, or hold it", requires: ["action"] },
@@ -31390,7 +31863,7 @@ var TOOL_SPECS = [
31390
31863
  requires: ["keycode"]
31391
31864
  },
31392
31865
  text: {
31393
- summary: "type a string into the field being edited, a character at a time, and say what it landed in and what it holds afterwards, a secret field as its mask",
31866
+ summary: "type a string into the field being edited, a character at a time, and say what it landed in and what it holds afterwards, a secret field as its mask. The field is the focus of the dialog that has the focus when one does, which is where the keys go",
31394
31867
  requires: ["text"]
31395
31868
  },
31396
31869
  mouse_click: {
@@ -31429,7 +31902,7 @@ var TOOL_SPECS = [
31429
31902
  },
31430
31903
  value: {
31431
31904
  ops: ["until"],
31432
- description: "until: the value to wait for, fitted to the property's type. On a property holding an object, whether declared to or untyped and holding one or null, the text null means null. One declared to hold an object refuses any value other than null, since it can never hold it."
31905
+ description: "until: the value to wait for, fitted to the property's type and compared at the precision the property keeps, so -1.1 meets a 32-bit float holding -1.10000002. A value the property would have to cut is refused: a fraction on a whole number, a number other than 0 or 1 on a bool. On a property holding an object, whether declared to or untyped and holding one or null, the text null means null. One declared to hold an object refuses any value other than null, since it can never hold it."
31433
31906
  },
31434
31907
  says: {
31435
31908
  type: "string",
@@ -31478,7 +31951,7 @@ var TOOL_SPECS = [
31478
31951
  },
31479
31952
  {
31480
31953
  name: "debug_control",
31481
- description: "Continues or steps the debugged game through the editor's debug adapter, answering with the stack where it ended up. There is no pause and no step_out: Godot's adapter answers a pause by reporting the game stopped and leaving it running, and implements no stepOut at all, so hold the game where you want it with a breakpoint and step over or into from there.",
31954
+ description: "Continues or steps the debugged game through the editor's debug adapter. A step waits for the game to stop again and answers with the stack where it ended up; one that has not stopped the game again within ten seconds answers heldAgain false and no stack. There is no pause and no step_out: Godot's adapter answers a pause by reporting the game stopped and leaving it running, and implements no stepOut at all, so hold the game where you want it with a breakpoint and step over or into from there.",
31482
31955
  parameters: {},
31483
31956
  requires: [],
31484
31957
  operations: {
@@ -31489,12 +31962,12 @@ var TOOL_SPECS = [
31489
31962
  },
31490
31963
  {
31491
31964
  name: "debug_state",
31492
- description: "Where the debugged game is stopped: the stack trace, what is in scope at a frame with the values, or the debug adapter's console output so far. A stop is reported by the editor's adapter only to the sessions connected when it happened, so a server that connected afterwards, which is every server after a reconnect, is shown a thread and no frames about a game still sitting at its breakpoint. That server asks the runtime instead: a held game accepts a connection and never answers, and the refusal then says held rather than running, and that the stack cannot be read from here. debug_control continue lets it go, and the next stop is one this session is told about, with its stack.",
31965
+ description: "Where the debugged game is stopped: the stack trace, what is in scope at a frame with the values, or the debug adapter's console output so far, its last thousand lines, with any earlier ones counted under notShown. A stop is reported by the editor's adapter only to the sessions connected when it happened, so a server that connected afterwards, which is every server after a reconnect, is shown a thread and no frames about a game still sitting at its breakpoint. That server asks the runtime instead: a held game accepts a connection and never answers, and the refusal then says held rather than running, and that the stack cannot be read from here. debug_control continue lets it go, and the next stop is one this session is told about, with its stack.",
31493
31966
  parameters: {
31494
31967
  frameId: {
31495
31968
  type: "number",
31496
31969
  ops: ["variables"],
31497
- description: "variables: which frame, from a stack answer. Default the innermost."
31970
+ description: "variables: which frame, from a stack answer. Default the innermost. A frame the stack no longer holds is refused, and a scope whose values did not arrive in time answers variables null and is named under valuesMissing."
31498
31971
  }
31499
31972
  },
31500
31973
  requires: [],
@@ -31555,9 +32028,16 @@ function buildToolDefinitions() {
31555
32028
  // src/server.ts
31556
32029
  var UPDATE_NOTICE_EVERY = 500;
31557
32030
  var FEEDBACK_NOTICE_EVERY = 250;
31558
- var run5 = promisify6(execFile6);
31559
- var __dirname2 = dirname11(fileURLToPath4(import.meta.url));
31560
- var EDITOR_RESTART_TIMEOUT_MS = 90000;
32031
+ var run4 = promisify5(execFile5);
32032
+ var __dirname2 = dirname12(fileURLToPath4(import.meta.url));
32033
+ function editorRestartTimeoutMs() {
32034
+ const override = Number.parseInt(envValue("GDHARNESS_EDITOR_RESTART_TIMEOUT_MS") ?? "", 10);
32035
+ return Number.isInteger(override) && override > 0 ? override : 90000;
32036
+ }
32037
+ var GREETING_WAIT_MS = 1e4;
32038
+ function restartUnderway(note, now = Date.now()) {
32039
+ return alive(note.byPid) && now - Date.parse(note.quitAt) < editorRestartTimeoutMs() + 30000;
32040
+ }
31561
32041
  var HOLD_PING_MS = 2000;
31562
32042
  var SLOWEST_FRAME_RATE = 20;
31563
32043
  function captureDestinationRefusal(outputPath, projects) {
@@ -31570,16 +32050,16 @@ function captureDestinationRefusal(outputPath, projects) {
31570
32050
  if (extname2(outputPath).toLowerCase() !== ".png") {
31571
32051
  return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
31572
32052
  }
31573
- const directory = realPathOr(dirname11(outputPath));
31574
- if (!existsSync12(directory)) {
31575
- return `outputPath's directory ${dirname11(outputPath)} does not exist; a capture creates no directories.`;
32053
+ const directory = realPathOr(dirname12(outputPath));
32054
+ if (!existsSync14(directory)) {
32055
+ return `outputPath's directory ${dirname12(outputPath)} does not exist; a capture creates no directories.`;
31576
32056
  }
31577
- const where = join18(directory, basename4(outputPath));
32057
+ const where = join19(directory, basename4(outputPath));
31578
32058
  const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
31579
32059
  if (inside !== undefined) {
31580
32060
  return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
31581
32061
  }
31582
- if (existsSync12(where)) {
32062
+ if (existsSync14(where)) {
31583
32063
  return `outputPath ${outputPath} already exists; a capture is not written over a file. Name a new one.`;
31584
32064
  }
31585
32065
  return null;
@@ -31610,16 +32090,18 @@ function describeHalt(halt) {
31610
32090
  function exited(run) {
31611
32091
  return run.exitCode !== null || run.exitSignal !== null;
31612
32092
  }
31613
- function endingOf(failed) {
31614
- return {
31615
- exitCode: typeof failed.code === "number" ? failed.code : null,
31616
- exitSignal: typeof failed.signal === "string" ? failed.signal : null,
31617
- failure: typeof failed.code === "string" ? failed.code : null
31618
- };
31619
- }
31620
32093
  function howItExited(run) {
31621
32094
  return run.exitSignal === null ? `exit code ${run.exitCode ?? "unknown"}` : `ended by ${run.exitSignal}, with no exit code`;
31622
32095
  }
32096
+ function errorsUnread(run) {
32097
+ return run.throughEditor && typeof run.errorReport !== "string";
32098
+ }
32099
+ function cleanVerdict(run) {
32100
+ if (run.consoleLost === true || errorsUnread(run)) {
32101
+ return;
32102
+ }
32103
+ return run.log.count("error") === 0;
32104
+ }
31623
32105
  function stillRunning(run) {
31624
32106
  if (run === null || exited(run) || run.endedUnwatched === true) {
31625
32107
  return false;
@@ -31636,6 +32118,11 @@ function stillRunning(run) {
31636
32118
  return true;
31637
32119
  }
31638
32120
  var PLAY_STARTS_WITHIN_MS = 30000;
32121
+ function playOf(answer) {
32122
+ const play = readNumber(answer, "play");
32123
+ const editorPid = readNumber(answer, "editorPid");
32124
+ return play === undefined || editorPid === undefined ? null : { play, editorPid };
32125
+ }
31639
32126
  function runIsUp(run, editorSays, now = Date.now()) {
31640
32127
  if (run?.throughEditor !== true) {
31641
32128
  return stillRunning(run);
@@ -31685,11 +32172,19 @@ function stopVerdict(stop) {
31685
32172
  return {
31686
32173
  stopped: true,
31687
32174
  endedPid: stop.endedPid,
31688
- clean: stop.errors === 0,
32175
+ clean: stop.clean,
31689
32176
  note: "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running.",
31690
32177
  withChildren: false
31691
32178
  };
31692
32179
  }
32180
+ if (stop.ending === "unreached") {
32181
+ return {
32182
+ stopped: false,
32183
+ notSignalled: stop.endedPid,
32184
+ note: "Nothing was ended: the editor could not be asked to stop the scene it is playing, so the game may still be running. editor_status says whether the editor is connected and playing.",
32185
+ withChildren: false
32186
+ };
32187
+ }
31693
32188
  if (stop.ending === "refused") {
31694
32189
  return {
31695
32190
  stopped: false,
@@ -31698,22 +32193,31 @@ function stopVerdict(stop) {
31698
32193
  withChildren: false
31699
32194
  };
31700
32195
  }
32196
+ if (stop.ending === "lingering") {
32197
+ const told = stop.throughEditor ? stop.endedPid === null ? "The editor was asked to stop the scene it is playing and was still playing it" : `The editor was asked to stop the scene it is playing, and its game, pid ${stop.endedPid}, was still running` : `pid ${stop.endedPid ?? "unknown"} was told to end and was still running`;
32198
+ return {
32199
+ stopped: false,
32200
+ stillGoing: stop.endedPid,
32201
+ note: `${told} ${STOP_WAIT_MS / 1000} seconds later, so it has not stopped. editor_status says when it has gone, and editor_run stop asks again.`,
32202
+ withChildren: true
32203
+ };
32204
+ }
31701
32205
  return {
31702
32206
  stopped: true,
31703
32207
  endedPid: stop.endedPid,
31704
- clean: stop.errors === 0,
31705
- note: `${stop.throughEditor ? stop.endedPid === null ? "The editor was asked to stop the scene it is playing. Its game had not announced a runtime, so no process was named under endedPid, and its exit code stays with the editor." : "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."}${stop.exitSignal === null ? "" : ` It was ended by ${stop.exitSignal} and so has no exit code.`}${stop.ending === "gone" ? "" : ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be going: editor_status says whether it is.`}`,
32208
+ clean: stop.clean,
32209
+ note: `${stop.throughEditor ? stop.endedPid === null ? "The editor was asked to stop the scene it is playing. Its game had not announced a runtime, so no process was named under endedPid, and its exit code stays with the editor." : "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."}${stop.exitSignal === null ? "" : ` It was ended by ${stop.exitSignal} and so has no exit code.`}`,
31706
32210
  withChildren: true
31707
32211
  };
31708
32212
  }
31709
32213
  function endedPreviousRun(ended) {
31710
- if (ended === null || ended.ending === "refused") {
32214
+ if (ended?.ending !== "gone") {
31711
32215
  return;
31712
32216
  }
31713
32217
  return ended.pid ?? true;
31714
32218
  }
31715
32219
  function previousRunLeft(ended) {
31716
- return ended?.ending === "refused" && ended.pid !== null ? ended.pid : undefined;
32220
+ return (ended?.ending === "refused" || ended?.ending === "lingering") && ended.pid !== null ? ended.pid : undefined;
31717
32221
  }
31718
32222
  var CONSOLE_WRAPPER = /_console(\.exe)?$/i;
31719
32223
  function engineNamesOf(executable) {
@@ -31790,8 +32294,13 @@ function endedToStartThis(ended) {
31790
32294
  if (ended.ending === "refused") {
31791
32295
  return ` ${which} was not ended: that pid no longer answers as the process the run was started as, so nothing was signalled. If it is still the game, it is running beside this one and you have to end it yourself; if it is not, it belongs to something else.`;
31792
32296
  }
31793
- const after = ended.ending === "lingering" ? ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be running beside this one: editor_status says whether it is.` : "";
31794
- return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.${after}`;
32297
+ if (ended.ending === "unreached") {
32298
+ return ` ${which} was not ended here: the editor could not be asked to stop it. The editor stops a play before starting the next, so it is not running beside this one if this start reached the editor.`;
32299
+ }
32300
+ if (ended.ending === "lingering") {
32301
+ return ` ${which} was told to end to start this one and was still running ${STOP_WAIT_MS / 1000} seconds later, so it is running beside this one: editor_status says when it has gone.`;
32302
+ }
32303
+ return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.`;
31795
32304
  }
31796
32305
  var LONGEST_BOOT_NOTED_MS = 5 * 60000;
31797
32306
  var MOST_GROUPS = 20;
@@ -31919,6 +32428,7 @@ var HANDOVER_MS = SUCCESSOR_CHECK_MS + 5000;
31919
32428
  var HANDOVER_POLL_MS = 250;
31920
32429
  var CACHE_WRITE_MS = 2000;
31921
32430
  var SCAN_WAIT_MS = 30000;
32431
+ var STREAMS_GRACE_MS = 2000;
31922
32432
  function scanWaitIn(payload) {
31923
32433
  const waited = readNumber(payload, "waitedForEditorScanMs");
31924
32434
  const note = readString(payload, "scanNote");
@@ -31931,19 +32441,58 @@ function withScanWait(outcome, scanned) {
31931
32441
  if (scanned.waitedMs === 0) {
31932
32442
  return outcome;
31933
32443
  }
31934
- const said = scanWaitAnswer(scanned);
32444
+ const said = scanWaitAnswer(scanned, "engine");
31935
32445
  if (outcome.ok) {
31936
32446
  return { ...outcome, payload: { ...outcome.payload, ...said } };
31937
32447
  }
31938
32448
  return said.scanNote === undefined ? outcome : { ...outcome, message: `${outcome.message} ${said.scanNote}` };
31939
32449
  }
31940
- function scanWaitAnswer(scanned) {
32450
+ function notRunNote(notRun, failFast) {
32451
+ if (notRun <= 0) {
32452
+ return;
32453
+ }
32454
+ const cases = `${notRun} case${notRun === 1 ? "" : "s"}`;
32455
+ return failFast ? `This run stopped at the first failure in each suite it failed in, so ${cases} never ran and count as neither passed nor failed. Leave failFast out to run every case.` : `${cases} counted in ${notRun === 1 ? "its suite" : "their suites"} never ran and count as neither passed nor failed. failFast was not set, so it was not that: the suites below with notRun are the ones to read.`;
32456
+ }
32457
+ function exportAnswer(ending, asked, before, after) {
32458
+ const { log } = ending;
32459
+ const written = after !== null && after !== before;
32460
+ const problems = log.select({ severity: "warning", sinceLastCall: false, limit: 200 });
32461
+ const verdict = {
32462
+ exported: ending.exitCode === 0 && ending.failure === null && log.count("error") === 0 && written,
32463
+ ...asked,
32464
+ exitCode: ending.exitCode,
32465
+ exitSignal: ending.exitSignal ?? undefined,
32466
+ failure: ending.failure ?? undefined,
32467
+ errors: log.count("error"),
32468
+ warnings: log.count("warning"),
32469
+ entries: forAnswer(problems.entries),
32470
+ ...problems.omitted === 0 ? {} : { entriesOmitted: problems.omitted }
32471
+ };
32472
+ if (verdict.exported) {
32473
+ return { content: [{ type: "text", text: JSON.stringify(verdict, null, 2) }] };
32474
+ }
32475
+ const stale = !written && after !== null ? ` The ${asked.outputPath} there is from before this export, which did not write it.` : "";
32476
+ const why = ending.exitSignal === null ? stale : ` The engine was ${howItEnded(ending)}, so it has no exit code.${stale}`;
32477
+ return {
32478
+ content: [
32479
+ {
32480
+ type: "text",
32481
+ text: `Export with preset '${asked.preset}' did not produce ${asked.outputPath}.${why}`
32482
+ },
32483
+ { type: "text", text: JSON.stringify(verdict, null, 2) }
32484
+ ],
32485
+ isError: true
32486
+ };
32487
+ }
32488
+ function scanWaitAnswer(scanned, started = "game") {
31941
32489
  if (scanned.waitedMs === 0) {
31942
32490
  return {};
31943
32491
  }
32492
+ const still = `The editor was still scanning the project after ${SCAN_WAIT_MS / 1000} seconds, so`;
31944
32493
  return {
31945
32494
  waitedForEditorScanMs: scanned.waitedMs,
31946
- scanNote: scanned.stillScanning ? `The editor was still scanning the project after ${SCAN_WAIT_MS / 1000} seconds, so this ` + "game started during the scan and may have read a class cache being rewritten. If " + 'editor_output shows "Could not find type" for a class that is declared, editor_rescan ' + "waits for the scan to finish, and a start after it reads the finished cache." : undefined
32495
+ scanNote: !scanned.stillScanning ? undefined : started === "game" ? `${still} this game started during the scan and may have read a class cache being rewritten. If ` + 'editor_output shows "Could not find type" for a class that is declared, editor_rescan ' + "waits for the scan to finish, and a start after it reads the finished cache." : `${still} the engine answering this started during the scan and may have read a class cache being ` + "rewritten, and a class cache it wrote can be written over when the scan finishes. If " + 'engine_messages shows "Could not find type" for a class that is declared, editor_rescan waits ' + "for the scan to finish, and the same call after it reads the finished cache."
31947
32496
  };
31948
32497
  }
31949
32498
  var PLAYING_STATUS_MS = 1000;
@@ -32023,16 +32572,17 @@ var PROJECT_FILE_ARGUMENTS = [
32023
32572
  "path",
32024
32573
  "script"
32025
32574
  ];
32026
- function escapingPropertyValue(properties) {
32027
- if (typeof properties !== "object" || properties === null) {
32575
+ function escapingPropertyValue(value) {
32576
+ if (typeof value === "string") {
32577
+ return /^(res|uid):\/\//.test(value) && value.split(/[/\\]/).includes("..") ? JSON.stringify(value) : undefined;
32578
+ }
32579
+ if (typeof value !== "object" || value === null) {
32028
32580
  return;
32029
32581
  }
32030
- for (const value of Object.values(properties)) {
32031
- if (typeof value !== "string" || !value.includes("/")) {
32032
- continue;
32033
- }
32034
- if (value.split(/[/\\]/).includes("..")) {
32035
- return JSON.stringify(value);
32582
+ for (const item of Object.values(value)) {
32583
+ const found = escapingPropertyValue(item);
32584
+ if (found !== undefined) {
32585
+ return found;
32036
32586
  }
32037
32587
  }
32038
32588
  return;
@@ -32070,7 +32620,7 @@ function aboveTheRunner(entry) {
32070
32620
  }
32071
32621
  function elsewhereIn(projectPath, asked) {
32072
32622
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
32073
- if (wanted === "" || existsSync12(join18(projectPath, wanted))) {
32623
+ if (wanted === "" || existsSync14(join19(projectPath, wanted))) {
32074
32624
  return "";
32075
32625
  }
32076
32626
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
@@ -32084,7 +32634,7 @@ function elsewhereIn(projectPath, asked) {
32084
32634
  return near.length === 0 ? "" : ` The project does have ${there}.`;
32085
32635
  }
32086
32636
  function hasMainScene(projectFile) {
32087
- const scene = parseProjectGodot(readFileSync17(projectFile, "utf8"))["application"]?.["run/main_scene"];
32637
+ const scene = parseProjectGodot(readFileSync18(projectFile, "utf8"))["application"]?.["run/main_scene"];
32088
32638
  return typeof scene === "string" && scene !== "";
32089
32639
  }
32090
32640
  function runtimeAddonAnnouncesEditors(projectPath) {
@@ -32092,13 +32642,13 @@ function runtimeAddonAnnouncesEditors(projectPath) {
32092
32642
  return false;
32093
32643
  }
32094
32644
  try {
32095
- return readFileSync17(join18(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
32645
+ return readFileSync18(join19(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
32096
32646
  } catch {
32097
32647
  return false;
32098
32648
  }
32099
32649
  }
32100
32650
  function editorPlaysHeadless(projectFile) {
32101
- const runArgs = parseProjectGodot(readFileSync17(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
32651
+ const runArgs = parseProjectGodot(readFileSync18(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
32102
32652
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
32103
32653
  }
32104
32654
  function camelCased(params) {
@@ -32117,7 +32667,7 @@ function needsItsOwnProcess(asked) {
32117
32667
  class GodotServer {
32118
32668
  mcp;
32119
32669
  locator = new GodotLocator;
32120
- operationsScript = join18(__dirname2, "godot", "operations", "godot_operations.gd");
32670
+ operationsScript = join19(__dirname2, "godot", "operations", "godot_operations.gd");
32121
32671
  godotBridge;
32122
32672
  tools = buildToolDefinitions();
32123
32673
  activeProcess = null;
@@ -32139,6 +32689,7 @@ class GodotServer {
32139
32689
  shutdownInitiated = false;
32140
32690
  launchedEditor = null;
32141
32691
  launchedFrom = null;
32692
+ launchedDropped = null;
32142
32693
  ownProject;
32143
32694
  announcedAt = null;
32144
32695
  handingOverFrom = null;
@@ -32146,9 +32697,12 @@ class GodotServer {
32146
32697
  constructor() {
32147
32698
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
32148
32699
  this.godotBridge = getDefaultBridge(this.ownProject);
32700
+ this.godotBridge.on("godot_connected", () => {
32701
+ this.settleTheLaunchReading();
32702
+ });
32149
32703
  this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
32150
32704
  this.setupToolHandlers();
32151
- setupResourceHandlers(this.mcp, () => this.lastProjectPath);
32705
+ setupResourceHandlers(this.mcp, () => this.ourProject() ?? this.lastProjectPath);
32152
32706
  this.mcp.server.onerror = (error) => {
32153
32707
  console.error("[MCP Error]", error);
32154
32708
  };
@@ -32414,8 +32968,9 @@ class GodotServer {
32414
32968
  if (!checked.ok) {
32415
32969
  return checked.response;
32416
32970
  }
32417
- if (typeof args["projectPath"] === "string") {
32418
- this.lastProjectPath = args["projectPath"];
32971
+ const named = args["projectPath"];
32972
+ if (typeof named === "string" && existsSync14(join19(named, "project.godot"))) {
32973
+ this.lastProjectPath = named;
32419
32974
  }
32420
32975
  this.updates.refresh();
32421
32976
  const answer = await withCallSignal(extra.signal, () => this.answered(spec.name, checked.op ?? "", args));
@@ -32455,6 +33010,17 @@ class GodotServer {
32455
33010
  return answer;
32456
33011
  }
32457
33012
  const moved = this.projectHasMovedOn();
33013
+ if (moved !== null && !isNewer(moved, SERVER_VERSION)) {
33014
+ this.noticedUpdate = true;
33015
+ this.callsSinceNotice = 0;
33016
+ return this.saying(answer, {
33017
+ project_addons_behind_this_server: {
33018
+ server_is: SERVER_VERSION,
33019
+ project_is: moved,
33020
+ what_to_do: `The project's gdharness addons are ${moved}, older than this ${SERVER_VERSION} server, so an ` + "editor restart loads the old code and reconnecting spawns this same server again. Tell the " + "user, and offer to run gdharness upgrade in the project, which replaces the addons with " + "this server's; ask first, since it changes files in their project. editor_launch restart " + "then loads them."
33021
+ }
33022
+ });
33023
+ }
32458
33024
  if (moved !== null) {
32459
33025
  this.noticedUpdate = true;
32460
33026
  this.callsSinceNotice = 0;
@@ -32487,7 +33053,7 @@ class GodotServer {
32487
33053
  return this.saying(answer, {
32488
33054
  update_available: {
32489
33055
  ...notice,
32490
- what_to_do: "Tell the user a newer gdharness is out, with what changed, and offer to take it. " + "Only run the upgrade command if they say yes: it restarts their editor and the " + "MCP server has to be reconnected afterwards."
33056
+ what_to_do: "Tell the user a newer gdharness is out, with what changed, and offer to take it. " + "Only run the upgrade command if they say yes: it replaces the addons in their project, " + "and afterwards the MCP server has to be reconnected and the editor restarted with " + "editor_launch restart."
32491
33057
  }
32492
33058
  });
32493
33059
  }
@@ -32504,6 +33070,11 @@ class GodotServer {
32504
33070
  const installed = installedAddonVersion(this.ownProject);
32505
33071
  return installed === null || installed === SERVER_VERSION ? null : installed;
32506
33072
  }
33073
+ editorCodeOnDisk() {
33074
+ const status = this.godotBridge.getStatus();
33075
+ const project = (status.connected ? status.projectPath : undefined) ?? this.ownProject;
33076
+ return project === null || project === "" ? undefined : installedEditorDigest(project);
33077
+ }
32507
33078
  configDisagreement() {
32508
33079
  return this.ownProject === null ? null : configDisagrees(this.ownProject, SERVER_VERSION);
32509
33080
  }
@@ -32617,6 +33188,9 @@ class GodotServer {
32617
33188
  const contained = this.containProjectFiles(asks);
32618
33189
  return contained.ok ? await this.handleViaBridge(editorSide, contained.value) : contained.response;
32619
33190
  }
33191
+ if (headless === "create_script") {
33192
+ return await this.handleCreateScript(answerable);
33193
+ }
32620
33194
  if (headless !== "refresh_class_cache") {
32621
33195
  return await this.headless(headless, answerable);
32622
33196
  }
@@ -32787,7 +33361,7 @@ class GodotServer {
32787
33361
  return await this.handleRuntimeCommand("click", {
32788
33362
  ...whichGame(args),
32789
33363
  path: readNonEmptyString(args, "nodePath") ?? "",
32790
- button: readString(args, "button") ?? "left",
33364
+ button: args["button"] ?? "left",
32791
33365
  double: readBoolean(args, "doubleClick") ?? false,
32792
33366
  ...readNonEmptyString(args, "says") === undefined ? {} : { says: readNonEmptyString(args, "says") },
32793
33367
  ...args["index"] === undefined ? {} : { index: args["index"] }
@@ -32859,8 +33433,8 @@ class GodotServer {
32859
33433
  if (path === undefined) {
32860
33434
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
32861
33435
  }
32862
- const file = join18(path, "project.godot");
32863
- if (!existsSync12(file)) {
33436
+ const file = join19(path, "project.godot");
33437
+ if (!existsSync14(file)) {
32864
33438
  return {
32865
33439
  ok: false,
32866
33440
  response: this.createErrorResponse(`Not a Godot project: ${path}`, [
@@ -33070,13 +33644,30 @@ class GodotServer {
33070
33644
  }
33071
33645
  contained[key] = `res://${location.relativePath}`;
33072
33646
  }
33073
- const escaping = escapingPropertyValue(args["properties"]);
33647
+ const escaping = escapingPropertyValue([args["properties"], args["track"]]);
33074
33648
  if (escaping !== undefined) {
33075
33649
  return {
33076
33650
  ok: false,
33077
33651
  response: this.createErrorResponse(`The property value ${escaping} resolves outside the project directory.`, PATH_SOLUTIONS)
33078
33652
  };
33079
33653
  }
33654
+ const sources = args["sources"];
33655
+ if (Array.isArray(sources)) {
33656
+ const resolved = [];
33657
+ for (const source of sources) {
33658
+ const texture = typeof source === "object" && source !== null ? source["texture"] : undefined;
33659
+ if (typeof texture !== "string") {
33660
+ resolved.push(source);
33661
+ continue;
33662
+ }
33663
+ const location = resolveWithinProject(projectPath, texture);
33664
+ if (!location.ok) {
33665
+ return { ok: false, response: this.createErrorResponse(location.reason, PATH_SOLUTIONS) };
33666
+ }
33667
+ resolved.push({ ...source, texture: `res://${location.relativePath}` });
33668
+ }
33669
+ contained["sources"] = resolved;
33670
+ }
33080
33671
  const pluginName = readNonEmptyString(args, "pluginName");
33081
33672
  if (pluginName !== undefined) {
33082
33673
  const plugin = resolveWithinProject(projectPath, `addons/${pluginName}/plugin.cfg`);
@@ -33124,6 +33715,7 @@ class GodotServer {
33124
33715
  return this.answer(withScanWait({
33125
33716
  ok: true,
33126
33717
  messages: imported.messages,
33718
+ ...imported.messagesOmitted === undefined ? {} : { messagesOmitted: imported.messagesOmitted },
33127
33719
  payload: {
33128
33720
  uidsCreated: given,
33129
33721
  stillWithoutUid: after,
@@ -33174,16 +33766,25 @@ class GodotServer {
33174
33766
  if (changed.length === 0) {
33175
33767
  return written;
33176
33768
  }
33177
- const taken = await this.editorTakes("adopt_project_settings", { settings: changed });
33769
+ const adopting = {};
33770
+ const taken = await this.editorTakes("adopt_project_settings", { settings: changed }, adopting);
33771
+ if (taken["editorNote"] !== undefined) {
33772
+ return this.jsonTextResponse({ ...answer, ...taken });
33773
+ }
33774
+ const adopted = Array.isArray(adopting["adopted"]) ? adopting["adopted"].map(String) : [];
33775
+ const passedOver = changed.filter((name) => !adopted.includes(name));
33178
33776
  return this.jsonTextResponse({
33179
33777
  ...answer,
33180
- ...taken,
33181
- ...taken["editorNote"] === undefined ? { editorAdopted: changed } : {}
33778
+ editorAdopted: adopted,
33779
+ ...passedOver.length > 0 ? { editorPassedOver: passedOver } : {}
33182
33780
  });
33183
33781
  }
33184
- async editorTakes(command, args) {
33782
+ async editorTakes(command, args, answered = {}) {
33185
33783
  try {
33186
- await this.godotBridge.invokeTool(command, args);
33784
+ const result = await this.godotBridge.invokeTool(command, args);
33785
+ if (typeof result === "object" && result !== null) {
33786
+ Object.assign(answered, result);
33787
+ }
33187
33788
  return {};
33188
33789
  } catch (error) {
33189
33790
  const message = errorMessage(error);
@@ -33221,7 +33822,19 @@ class GodotServer {
33221
33822
  }
33222
33823
  const applied = await this.reimport(project.value.path, readNonEmptyString(contained.value, "resourcePath"), true, readPositiveNumber(args, "timeoutMs") ?? REIMPORT_TIMEOUT_MS);
33223
33824
  if (!applied.ok) {
33224
- return applied.response;
33825
+ const listed = written.payload["updated_options"];
33826
+ const updated = Array.isArray(listed) ? listed.map(String) : [];
33827
+ const which = updated.length === 0 ? "The options were" : `${updated.join(", ")} ${updated.length === 1 ? "was" : "were"}`;
33828
+ return {
33829
+ ...applied.response,
33830
+ content: [
33831
+ {
33832
+ type: "text",
33833
+ text: `${which} written to the import file, and the reimport that applies them failed, so the resource is still imported the old way until the editor's next scan or project_import reimport imports it. Why the reimport failed:`
33834
+ },
33835
+ ...applied.response.content
33836
+ ]
33837
+ };
33225
33838
  }
33226
33839
  return this.jsonTextResponse({ ...written.payload, reimport: applied.payload });
33227
33840
  }
@@ -33239,7 +33852,14 @@ class GodotServer {
33239
33852
  response: this.createErrorResponse(`${missingSource[0]} is not on disk, so there is nothing to import.`)
33240
33853
  };
33241
33854
  }
33242
- const targets = listed.filter((one) => one.status !== "missing_source" && (force || one.status !== "up_to_date")).map((one) => one.path);
33855
+ const never = listed.find((one) => one.status === "not_imported");
33856
+ if (resourcePath !== undefined && never !== undefined) {
33857
+ return {
33858
+ ok: false,
33859
+ response: this.createErrorResponse(`${never.path} is not something the engine imports: ${never.reason ?? "it has no importer"}.`)
33860
+ };
33861
+ }
33862
+ const targets = listed.filter((one) => one.status !== "missing_source" && one.status !== "not_imported" && (force || one.status !== "up_to_date")).map((one) => one.path);
33243
33863
  if (targets.length === 0) {
33244
33864
  return {
33245
33865
  ok: true,
@@ -33278,9 +33898,7 @@ class GodotServer {
33278
33898
  if (!imported.ok) {
33279
33899
  return { ok: false, response: this.answer(withScanWait(imported, scanned)) };
33280
33900
  }
33281
- if (imported.messages.length > 0) {
33282
- extra["engine_messages"] = imported.messages;
33283
- }
33901
+ Object.assign(extra, engineExtras(imported));
33284
33902
  if (imported.extensionNote !== undefined) {
33285
33903
  extra["extensionNote"] = imported.extensionNote;
33286
33904
  }
@@ -33363,17 +33981,15 @@ class GodotServer {
33363
33981
  return withScanWait(outcome, scanned);
33364
33982
  }
33365
33983
  answer(outcome) {
33984
+ const extras = engineExtras(outcome);
33366
33985
  if (!outcome.ok) {
33367
33986
  const response = this.createErrorResponse(outcome.message);
33368
- if (outcome.messages.length > 0) {
33369
- response.content.push({
33370
- type: "text",
33371
- text: JSON.stringify({ engine_messages: outcome.messages }, null, 2)
33372
- });
33987
+ if (Object.keys(extras).length > 0) {
33988
+ response.content.push({ type: "text", text: JSON.stringify(extras, null, 2) });
33373
33989
  }
33374
33990
  return response;
33375
33991
  }
33376
- return this.jsonTextResponse(outcome.messages.length > 0 ? { ...outcome.payload, engine_messages: outcome.messages } : outcome.payload);
33992
+ return this.jsonTextResponse({ ...outcome.payload, ...extras });
33377
33993
  }
33378
33994
  async handleProjectInfo(args) {
33379
33995
  const include = readStringArray(args, "include") ?? [];
@@ -33389,7 +34005,7 @@ class GodotServer {
33389
34005
  if (!engine.ok) {
33390
34006
  return engine.response;
33391
34007
  }
33392
- const application = parseProjectGodot(readFileSync17(project.value.file, "utf8"))["application"] ?? {};
34008
+ const application = parseProjectGodot(readFileSync18(project.value.file, "utf8"))["application"] ?? {};
33393
34009
  const name = application["config/name"];
33394
34010
  const mainScene = application["run/main_scene"];
33395
34011
  const info = {
@@ -33406,10 +34022,14 @@ class GodotServer {
33406
34022
  continue;
33407
34023
  }
33408
34024
  const outcome = await this.operation(spec.operation, spec.params(args, detailed), project.value.path);
33409
- info[section] = outcome.ok ? outcome.messages.length > 0 ? { ...outcome.payload, engine_messages: outcome.messages } : outcome.payload : { error: outcome.message, engine_messages: outcome.messages };
34025
+ info[section] = outcome.ok ? { ...outcome.payload, ...engineExtras(outcome) } : { error: outcome.message, ...engineExtras(outcome) };
33410
34026
  }
33411
34027
  return this.jsonTextResponse(info);
33412
34028
  }
34029
+ fileTypes(args) {
34030
+ const named = readStringArray(args, "fileTypes");
34031
+ return named === undefined ? {} : { fileTypes: named };
34032
+ }
33413
34033
  handleSearchProject(args) {
33414
34034
  const project = this.project(args);
33415
34035
  if (!project.ok) {
@@ -33418,16 +34038,7 @@ class GodotServer {
33418
34038
  try {
33419
34039
  return this.jsonTextResponse(searchProject(project.value.path, {
33420
34040
  query: readString(args, "query") ?? "",
33421
- fileTypes: readStringArray(args, "fileTypes") ?? [
33422
- "gd",
33423
- "tscn",
33424
- "tres",
33425
- "gdshader",
33426
- "cfg",
33427
- "md",
33428
- "txt",
33429
- "json"
33430
- ],
34041
+ ...this.fileTypes(args),
33431
34042
  regex: readBoolean(args, "regex") ?? false,
33432
34043
  caseSensitive: readBoolean(args, "caseSensitive") ?? false,
33433
34044
  maxResults: readPositiveNumber(args, "maxResults") ?? 100
@@ -33450,18 +34061,18 @@ class GodotServer {
33450
34061
  if (!engine.ok) {
33451
34062
  return engine.response;
33452
34063
  }
33453
- const folder = dirname11(output.absolutePath);
34064
+ const folder = dirname12(output.absolutePath);
33454
34065
  try {
33455
34066
  mkdirSync10(folder, { recursive: true });
33456
34067
  } catch (error) {
33457
- return this.createErrorResponse(`Cannot create ${dirname11(output.relativePath)} to export into: ${errorMessage(error)}`);
34068
+ return this.createErrorResponse(`Cannot create ${dirname12(output.relativePath)} to export into: ${errorMessage(error)}`);
33458
34069
  }
33459
34070
  const debug = readBoolean(args, "debug") ?? false;
33460
34071
  const exportLogs = scratchDirectory("export");
33461
34072
  const exportArgs = [
33462
34073
  "--headless",
33463
34074
  "--log-file",
33464
- join18(exportLogs, "engine.log"),
34075
+ join19(exportLogs, "engine.log"),
33465
34076
  "--path",
33466
34077
  project.value.path,
33467
34078
  debug ? "--export-debug" : "--export-release",
@@ -33469,55 +34080,18 @@ class GodotServer {
33469
34080
  output.absolutePath
33470
34081
  ];
33471
34082
  this.logDebug(`Exporting: ${engine.value} ${exportArgs.join(" ")}`);
33472
- const log = new GameLog;
33473
- let ending = { exitCode: 0, exitSignal: null, failure: null };
34083
+ const writtenAt = () => existsSync14(output.absolutePath) ? statSync8(output.absolutePath).mtimeMs : null;
34084
+ const before = writtenAt();
34085
+ let ending;
33474
34086
  try {
33475
- const { stdout, stderr } = await run5(engine.value, exportArgs, {
33476
- timeout: 300000,
33477
- maxBuffer: 64 * 1024 * 1024,
33478
- signal: callSignal()
33479
- });
33480
- log.append("stdout", stdout);
33481
- log.append("stderr", stderr);
33482
- } catch (error) {
33483
- if (!(error instanceof Error && ("stdout" in error) && ("stderr" in error))) {
33484
- return this.createErrorResponse(`Export could not be run: ${errorMessage(error)}`);
33485
- }
33486
- const failed = error;
33487
- log.append("stdout", failed.stdout);
33488
- log.append("stderr", failed.stderr);
33489
- ending = endingOf(failed);
34087
+ ending = await runEngine(engine.value, exportArgs, { timeout: { ms: 300000, said: "five minutes" } });
33490
34088
  } finally {
33491
34089
  discard(exportLogs);
33492
34090
  }
33493
- log.finish();
33494
- const problems = log.select({ severity: "warning", sinceLastCall: false, limit: 200 });
33495
- const verdict = {
33496
- exported: ending.exitCode === 0 && log.count("error") === 0 && existsSync12(output.absolutePath),
33497
- preset,
33498
- outputPath: output.relativePath,
33499
- debug,
33500
- exitCode: ending.exitCode,
33501
- exitSignal: ending.exitSignal ?? undefined,
33502
- failure: ending.failure ?? undefined,
33503
- errors: log.count("error"),
33504
- warnings: log.count("warning"),
33505
- entries: forAnswer(problems.entries)
33506
- };
33507
- if (!verdict.exported) {
33508
- const why = ending.exitSignal === null ? ending.failure === null ? "" : ` The engine was ended by Node (${ending.failure}), so it has no exit code.` : ` The engine was ended by ${ending.exitSignal}, so it has no exit code: the export ran past its five minutes or the call was cancelled.`;
33509
- return {
33510
- content: [
33511
- {
33512
- type: "text",
33513
- text: `Export with preset '${preset}' did not produce ${output.relativePath}.${why}`
33514
- },
33515
- { type: "text", text: JSON.stringify(verdict, null, 2) }
33516
- ],
33517
- isError: true
33518
- };
34091
+ if (ending.exitSignal === null && ending.exitCode === null) {
34092
+ return this.createErrorResponse(`Export could not be run: ${ending.failure ?? "the engine gave no exit status"}`);
33519
34093
  }
33520
- return this.jsonTextResponse(verdict);
34094
+ return exportAnswer(ending, { preset, outputPath: output.relativePath, debug }, before, writtenAt());
33521
34095
  }
33522
34096
  async handleRunTests(args) {
33523
34097
  const project = this.project(args);
@@ -33529,7 +34103,7 @@ class GodotServer {
33529
34103
  return contained.response;
33530
34104
  }
33531
34105
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
33532
- if (!existsSync12(join18(project.value.path, runner))) {
34106
+ if (!existsSync14(join19(project.value.path, runner))) {
33533
34107
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
33534
34108
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
33535
34109
  ]);
@@ -33595,21 +34169,22 @@ class GodotServer {
33595
34169
  if (timedOut && run.process.pid !== undefined) {
33596
34170
  await untilGone([run.process.pid]);
33597
34171
  }
33598
- const reportsDir = join18(project.value.path, ".godot", ...ours.split("/"));
34172
+ await run.settled;
34173
+ const reportsDir = join19(project.value.path, ".godot", ...ours.split("/"));
33599
34174
  let report = null;
33600
34175
  let reportProblem = null;
33601
34176
  try {
33602
- const written = existsSync12(reportsDir) ? readdirSync8(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
34177
+ const written = existsSync14(reportsDir) ? readdirSync8(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
33603
34178
  const newest = written.at(-1);
33604
34179
  if (newest !== undefined) {
33605
- report = parseJUnit(readFileSync17(join18(reportsDir, newest, "results.xml"), "utf8"));
34180
+ report = parseJUnit(readFileSync18(join19(reportsDir, newest, "results.xml"), "utf8"));
33606
34181
  }
33607
34182
  } catch (error) {
33608
34183
  reportProblem = errorMessage(error);
33609
34184
  } finally {
33610
34185
  discard(reportsDir, userData);
33611
34186
  try {
33612
- rmdirSync2(dirname11(reportsDir));
34187
+ rmdirSync2(dirname12(reportsDir));
33613
34188
  } catch {}
33614
34189
  }
33615
34190
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
@@ -33622,7 +34197,11 @@ class GodotServer {
33622
34197
  105: "script errors"
33623
34198
  };
33624
34199
  const exitCode = run.exitCode;
33625
- const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
34200
+ const printed = run.log.select({
34201
+ severity: "info",
34202
+ sinceLastCall: false,
34203
+ limit: Number.POSITIVE_INFINITY
34204
+ }).entries;
33626
34205
  const said = printed.map((entry) => entry.text);
33627
34206
  const nothingRan = timedOut ? null : whyNoReport(said, asked);
33628
34207
  const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
@@ -33638,7 +34217,7 @@ class GodotServer {
33638
34217
  ...echoed.omitted > 0 ? { printedOmitted: echoed.omitted } : {}
33639
34218
  };
33640
34219
  if (report === null) {
33641
- const note = nothingRan !== null ? `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).`;
34220
+ const note = nothingRan !== null ? `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${verdict}.` : `The test run wrote no report (${verdict}).`;
33642
34221
  return {
33643
34222
  content: [
33644
34223
  { type: "text", text: note },
@@ -33675,10 +34254,21 @@ class GodotServer {
33675
34254
  orphans: suite.orphans,
33676
34255
  message: `${suite.orphans} node${suite.orphans === 1 ? "" : "s"} were still in the tree when this suite finished. A node built in a test and neither freed nor added to the tree is the usual cause; free it, or add it with auto_free().`
33677
34256
  }));
33678
- const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
34257
+ const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.hookFailures > 0 || suite.hookErrors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
34258
+ const hooksPrinted = hookFailuresPrinted(said);
34259
+ const hookFailures = report.suites.filter((suite) => suite.hookFailures + suite.hookErrors > 0).flatMap((suite) => {
34260
+ const printedHere = hooksPrinted.filter((hook) => hook.path === suite.path);
34261
+ return printedHere.length > 0 ? printedHere.map((hook) => ({ suite: suite.name, ...hook })) : [
34262
+ {
34263
+ suite: suite.name,
34264
+ path: suite.path,
34265
+ message: "gdUnit4 counted a failure in this suite that none of its cases carry, which is what a failed before() or after() leaves, and printed no line saying which."
34266
+ }
34267
+ ];
34268
+ });
33679
34269
  const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
33680
34270
  return this.jsonTextResponse({
33681
- passed: !timedOut && exitCode === 0 && report.failures === 0 && report.errors === 0,
34271
+ passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0,
33682
34272
  verdict,
33683
34273
  ...timedOut ? { timedOut, hung, silentForMs } : {},
33684
34274
  exitCode,
@@ -33689,13 +34279,14 @@ class GodotServer {
33689
34279
  skipped: report.skipped,
33690
34280
  time: report.time,
33691
34281
  failed,
34282
+ hookFailures: hookFailures.length > 0 ? hookFailures : undefined,
33692
34283
  warnings: warnings.length > 0 ? warnings : undefined,
33693
34284
  orphans: orphans.total > 0 ? orphans.total : undefined,
33694
34285
  notRun: notRun > 0 ? notRun : undefined,
33695
34286
  ...printedLines,
33696
34287
  savesNote,
33697
34288
  ...scanned,
33698
- note: verdict.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRun > 0 ? `This run stopped at the first failure in each suite it failed in, so ${notRun} case${notRun === 1 ? "" : "s"} never ran and count as neither passed nor failed. Leave failFast out to run every case.` : undefined,
34289
+ note: verdict.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRunNote(notRun, readBoolean(args, "failFast") === true),
33699
34290
  suites: unclean.map((suite) => ({
33700
34291
  name: suite.name,
33701
34292
  path: suite.path,
@@ -33703,6 +34294,7 @@ class GodotServer {
33703
34294
  notRun: suite.discovered > suite.tests ? suite.discovered - suite.tests : undefined,
33704
34295
  failures: suite.failures,
33705
34296
  errors: suite.errors,
34297
+ hookFailures: suite.hookFailures + suite.hookErrors > 0 ? suite.hookFailures + suite.hookErrors : undefined,
33706
34298
  skipped: suite.skipped,
33707
34299
  orphans: orphansIn(suite.path) > 0 ? orphansIn(suite.path) : undefined,
33708
34300
  time: suite.time
@@ -33716,7 +34308,7 @@ class GodotServer {
33716
34308
  }
33717
34309
  async godotVersion(godotPath) {
33718
34310
  try {
33719
- const { stdout } = await run5(godotPath, ["--version"], { timeout: 1e4 });
34311
+ const { stdout } = await run4(godotPath, ["--version"], { timeout: 1e4 });
33720
34312
  this.lastGodotVersion = stdout.trim();
33721
34313
  return this.lastGodotVersion;
33722
34314
  } catch (error) {
@@ -33728,8 +34320,18 @@ class GodotServer {
33728
34320
  const answer = await this.handleLSP("lsp_get_diagnostics", args);
33729
34321
  const payload = asParams(JSON.parse(answer.content[0]?.text ?? "{}"));
33730
34322
  if (payload["error"] !== undefined) {
33731
- const reason = payload["error"];
33732
- return this.createErrorResponse(`Diagnostics unavailable: ${typeof reason === "string" ? reason : JSON.stringify(reason)}`, [
34323
+ const reason = typeof payload["error"] === "string" ? payload["error"] : JSON.stringify(payload["error"]);
34324
+ if (payload["refusedArguments"] === true) {
34325
+ return this.createErrorResponse(reason);
34326
+ }
34327
+ if (typeof payload["serves"] === "string") {
34328
+ return this.createErrorResponse(`Diagnostics unavailable: ${reason}`, [
34329
+ "editor_status names the editor on the bridge and the port it says it serves",
34330
+ "editor_launch opens this project's own editor, on a language server port of its own",
34331
+ "GDHARNESS_LSP_PORT points this server at the right one"
34332
+ ]);
34333
+ }
34334
+ return this.createErrorResponse(`Diagnostics unavailable: ${reason}`, [
33733
34335
  `Ensure the Godot editor is running with its language server enabled, on port ${this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}`,
33734
34336
  "GDHARNESS_LSP_PORT points this server at another one"
33735
34337
  ]);
@@ -33747,7 +34349,7 @@ class GodotServer {
33747
34349
  warnings: diagnostics.length - errors,
33748
34350
  diagnostics,
33749
34351
  ...disagrees
33750
- }, this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest()));
34352
+ }, this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()));
33751
34353
  }
33752
34354
  whereTheFileDisagrees(diagnostics, args) {
33753
34355
  const messages = diagnostics.map((entry) => asParams(entry)["message"]).filter((message) => typeof message === "string");
@@ -33760,11 +34362,11 @@ class GodotServer {
33760
34362
  const cached = cachedClasses(projectPath);
33761
34363
  const contradicted = cached === null ? [] : contradictedDiagnostics(messages, cached, (resourcePath) => {
33762
34364
  const contained = resolveWithinProject(projectPath, resourcePath);
33763
- if (!contained.ok || !existsSync12(contained.absolutePath)) {
34365
+ if (!contained.ok || !existsSync14(contained.absolutePath)) {
33764
34366
  return null;
33765
34367
  }
33766
34368
  try {
33767
- return readFileSync17(contained.absolutePath, "utf8");
34369
+ return readFileSync18(contained.absolutePath, "utf8");
33768
34370
  } catch {
33769
34371
  return null;
33770
34372
  }
@@ -33820,11 +34422,11 @@ class GodotServer {
33820
34422
  }
33821
34423
  sourceOfClass(projectPath, resourcePath) {
33822
34424
  const contained = resolveWithinProject(projectPath, resourcePath);
33823
- if (!contained.ok || !existsSync12(contained.absolutePath)) {
34425
+ if (!contained.ok || !existsSync14(contained.absolutePath)) {
33824
34426
  return null;
33825
34427
  }
33826
34428
  try {
33827
- return readFileSync17(contained.absolutePath, "utf8");
34429
+ return readFileSync18(contained.absolutePath, "utf8");
33828
34430
  } catch {
33829
34431
  return null;
33830
34432
  }
@@ -33898,7 +34500,7 @@ class GodotServer {
33898
34500
  }
33899
34501
  breakpointsAtRisk() {
33900
34502
  const status = this.godotBridge.getStatus();
33901
- if (!status.connected || status.syncsBreakpoints === true) {
34503
+ if (!hasSaidWhoItIs(status) || status.syncsBreakpoints === true) {
33902
34504
  return {};
33903
34505
  }
33904
34506
  return {
@@ -33953,26 +34555,34 @@ class GodotServer {
33953
34555
  restartSettled(this.ownProject);
33954
34556
  return null;
33955
34557
  }
33956
- return owed;
34558
+ return { note: owed, underway: restartUnderway(owed) };
33957
34559
  }
33958
34560
  async getEditorStatusPayload() {
33959
34561
  const status = this.godotBridge.getStatus();
33960
34562
  const launchedIsUp = !status.connected && await this.launchedEditorIsUp();
33961
34563
  const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
33962
- const stale = status.connected && editorIsStale(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest());
34564
+ const greeted = hasSaidWhoItIs(status);
34565
+ const stale = greeted && editorIsStale(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest());
33963
34566
  const unfinished = this.restartLeftUnfinished(status.connected);
33964
34567
  return {
33965
34568
  ...status,
33966
34569
  serverVersion: SERVER_VERSION,
33967
- addonIsStale: status.connected ? stale : undefined,
34570
+ addonIsStale: greeted ? stale : undefined,
34571
+ greeting: status.connected && !greeted ? "The editor has connected and has not yet said who it is: its project, pid, addon version and ports arrive with its greeting a moment after the socket opens, later while it imports. Ask again for them." : undefined,
33968
34572
  bridgeAvailable: this.bridgeStartupError === null,
33969
- mayYetConnect: status.connected ? undefined : unfinished !== null ? false : anEditorIsStillComing(status.listeningSince, launchedIsUp),
33970
- restartInterrupted: unfinished === null ? undefined : {
33971
- quitEditorPid: unfinished.editorPid,
33972
- quitAt: unfinished.quitAt,
33973
- byServerPid: unfinished.byPid,
34573
+ mayYetConnect: status.connected ? undefined : unfinished !== null ? unfinished.underway : anEditorIsStillComing(status.listeningSince, launchedIsUp),
34574
+ restartInterrupted: unfinished === null || unfinished.underway ? undefined : {
34575
+ quitEditorPid: unfinished.note.editorPid,
34576
+ quitAt: unfinished.note.quitAt,
34577
+ byServerPid: unfinished.note.byPid,
33974
34578
  note: "A restart begun by a previous gdharness server asked this project's editor to quit, and that server was ended before it could start the editor again, so nothing is coming. editor_launch open starts one, and clears this."
33975
34579
  },
34580
+ restartUnderway: unfinished === null || !unfinished.underway ? undefined : {
34581
+ quitEditorPid: unfinished.note.editorPid,
34582
+ quitAt: unfinished.note.quitAt,
34583
+ byServerPid: unfinished.note.byPid,
34584
+ note: `A restart of this project's editor is under way: gdharness server pid ${unfinished.note.byPid} asked it to quit at ${unfinished.note.quitAt} and is starting it again. Wait for it rather than opening another.`
34585
+ },
33976
34586
  awaitingLaunchedEditor: status.connected || !launchedIsUp ? undefined : this.launchedEditor?.pid ?? undefined,
33977
34587
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
33978
34588
  startupError: this.bridgeStartupError,
@@ -33982,8 +34592,9 @@ class GodotServer {
33982
34592
  port: this.godotBridge.configuredPort,
33983
34593
  note: `The gdharness server this one replaced, pid ${this.handingOverFrom}, still holds port ${this.godotBridge.configuredPort} and is standing down; this server takes the port when it lets go, and takes another after ${HANDOVER_MS / 1000} seconds if it does not.`
33984
34594
  },
33985
- staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
33986
- addonNote: status.connected ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
34595
+ staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()) : undefined,
34596
+ addonNote: greeted ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
34597
+ startedByAnEditorNote: status.startedByAnEditor === true ? "Another editor started this one: Godot's own restart, or the project manager opening a project. None of the arguments a server gave the editor before it reached this one, so it serves the ports its editor settings name (and none at all while another editor holds those, when the script and debug tools reach that editor instead), its console is not captured, and it restarts itself. Close it and use editor_launch open to have an editor this server started." : undefined,
33987
34598
  ...this.breakpointsAtRisk(),
33988
34599
  retryingBridge: this.bridgeRetry === null ? undefined : true,
33989
34600
  announcedAt: this.announcedAt ?? undefined,
@@ -34012,7 +34623,8 @@ class GodotServer {
34012
34623
  return {
34013
34624
  playing: readBoolean(asParams(answer), "playing") ?? false,
34014
34625
  scene: readString(asParams(answer), "scenePath") ?? "",
34015
- debugPort: readNumber(asParams(answer), "debugPort")
34626
+ debugPort: readNumber(asParams(answer), "debugPort"),
34627
+ play: playOf(asParams(answer))
34016
34628
  };
34017
34629
  } catch {
34018
34630
  return null;
@@ -34108,7 +34720,7 @@ class GodotServer {
34108
34720
  return null;
34109
34721
  }
34110
34722
  try {
34111
- return settingKeys(readFileSync17(join18(projectPath, "project.godot"), "utf8"));
34723
+ return settingKeys(readFileSync18(join19(projectPath, "project.godot"), "utf8"));
34112
34724
  } catch {
34113
34725
  return null;
34114
34726
  }
@@ -34121,17 +34733,33 @@ class GodotServer {
34121
34733
  "editor_status says whether the bridge is up and what has reached it"
34122
34734
  ]);
34123
34735
  }
34124
- const settingsBefore = this.settingKeysOf(before.projectPath);
34736
+ if (!hasSaidWhoItIs(before)) {
34737
+ const settled = await this.waitForBridge(() => hasSaidWhoItIs(this.godotBridge.getStatus()) || !this.godotBridge.isConnected(), Date.now() + GREETING_WAIT_MS);
34738
+ if (!settled) {
34739
+ return this.createErrorResponse(`The editor has connected and has not said who it is within ${GREETING_WAIT_MS / 1000}s, so whether this server or the editor itself restarts it cannot be told.`, [
34740
+ "editor_status says when it has, under greeting while it has not",
34741
+ "Then ask for the restart again"
34742
+ ]);
34743
+ }
34744
+ return this.handleRestartEditor(args);
34745
+ }
34125
34746
  const mine = before.openedByAServer === true && before.projectPath !== undefined;
34747
+ if (!mine && args["hidden"] === true) {
34748
+ return this.createErrorResponse("This editor was opened by hand, and restarts itself as it was, so it cannot come back hidden.", [
34749
+ "Close it, then editor_launch open with hidden: true starts a hidden one this server can restart",
34750
+ "editor_launch restart without hidden restarts it as it is"
34751
+ ]);
34752
+ }
34753
+ const settingsBefore = this.settingKeysOf(before.projectPath);
34126
34754
  const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid, args["hidden"] === true) : await this.handleViaBridge("restart_editor", {});
34127
34755
  if (asked.isError === true) {
34128
34756
  return asked;
34129
34757
  }
34130
34758
  const startedAt = before.connectedAt?.getTime() ?? 0;
34131
34759
  const began = Date.now();
34132
- const back = await this.waitForBridge(() => theEditorHasComeBack(this.godotBridge.getStatus(), startedAt), began + EDITOR_RESTART_TIMEOUT_MS);
34760
+ const back = await this.waitForBridge(() => theEditorHasComeBack(this.godotBridge.getStatus(), startedAt), began + editorRestartTimeoutMs());
34133
34761
  if (!back) {
34134
- return this.createErrorResponse(`The editor was asked to restart and has not come back within ${EDITOR_RESTART_TIMEOUT_MS / 1000}s.`, [
34762
+ return this.createErrorResponse(`The editor was asked to restart and has not come back within ${editorRestartTimeoutMs() / 1000}s.`, [
34135
34763
  "It may be asking what to do about an unsaved scene: look at the editor window",
34136
34764
  "An addon that no longer parses stops the editor reaching this server",
34137
34765
  "editor_status says whether anything has reached the bridge since"
@@ -34145,18 +34773,27 @@ class GodotServer {
34145
34773
  addonVersion: now.addonVersion,
34146
34774
  serverVersion: SERVER_VERSION,
34147
34775
  addonIsStale: editorIsStale(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest()),
34148
- staleNote: addonMismatch(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest()),
34776
+ staleNote: addonMismatch(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()),
34149
34777
  ...this.whatTheEditorDropped(settingsBefore, after),
34150
34778
  tookMs: Date.now() - began
34151
34779
  });
34152
34780
  }
34153
- whatTheLaunchedEditorDropped() {
34781
+ settleTheLaunchReading() {
34154
34782
  const watched = this.launchedFrom;
34155
- if (watched === null) {
34156
- return {};
34783
+ const status = this.godotBridge.getStatus();
34784
+ if (watched === null || !hasSaidWhoItIs(status)) {
34785
+ return;
34157
34786
  }
34158
34787
  this.launchedFrom = null;
34159
- return this.whatTheEditorDropped(watched.before, this.settingKeysOf(watched.projectPath));
34788
+ if (status.editorPid === undefined || status.editorPid !== this.launchedEditor?.pid) {
34789
+ return;
34790
+ }
34791
+ this.launchedDropped = this.whatTheEditorDropped(watched.before, this.settingKeysOf(watched.projectPath));
34792
+ }
34793
+ whatTheLaunchedEditorDropped() {
34794
+ const dropped = this.launchedDropped ?? {};
34795
+ this.launchedDropped = null;
34796
+ return dropped;
34160
34797
  }
34161
34798
  whatTheEditorDropped(before, after) {
34162
34799
  return settingsDroppedReport(before, after);
@@ -34182,7 +34819,14 @@ class GodotServer {
34182
34819
  restartSettled(projectPath);
34183
34820
  return asked;
34184
34821
  }
34185
- await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
34822
+ const gone = await this.waitForBridge(() => !alive(editorPid), Date.now() + editorRestartTimeoutMs());
34823
+ if (!gone) {
34824
+ restartSettled(projectPath);
34825
+ return this.createErrorResponse(`The editor was asked to go and pid ${editorPid ?? "unknown"} is still running after ${editorRestartTimeoutMs() / 1000}s, so no replacement was started: it would find the language server and debug adapter ports still held.`, [
34826
+ "It may be asking what to do about unsaved changes, or a script may be holding up its exit",
34827
+ `editor_launch open starts one on ${projectPath} once it has gone`
34828
+ ]);
34829
+ }
34186
34830
  const opened = await this.openAnEditor(engine.value, projectPath, ports, hidden);
34187
34831
  restartSettled(projectPath);
34188
34832
  if (opened.error !== null) {
@@ -34213,6 +34857,10 @@ class GodotServer {
34213
34857
  "Another project wants its own gdharness server, which is its own harness session"
34214
34858
  ]);
34215
34859
  }
34860
+ const refused = this.anEditorThatCannotConnect(project.value.path);
34861
+ if (refused !== null) {
34862
+ return refused;
34863
+ }
34216
34864
  const engine = await this.engine();
34217
34865
  if (!engine.ok) {
34218
34866
  return engine.response;
@@ -34237,6 +34885,27 @@ class GodotServer {
34237
34885
  settingsNote: before === null ? "project.godot could not be read before this editor was opened, so nothing here will be able to say what the import saved away. Opening a project rewrites the file and drops any key sitting at its own default." : before.size === 0 ? undefined : "Opening a project imports it and saves project.godot, and Godot drops any key sitting at its own default. editor_status names anything lost under settingsDropped once this editor has connected."
34238
34886
  });
34239
34887
  }
34888
+ anEditorThatCannotConnect(projectPath) {
34889
+ if (this.ownProject !== null && !isSameDirectory(this.ownProject, projectPath)) {
34890
+ return this.createErrorResponse(`This server serves ${this.ownProject}, and an editor of ${projectPath} would dial that project's own server rather than this one.`, [
34891
+ "Open it from that project's own gdharness session",
34892
+ `editor_launch with projectPath ${this.ownProject} opens the editor this server serves`
34893
+ ]);
34894
+ }
34895
+ const report = inspectProject(projectPath);
34896
+ const installed = report.addons.some((addon) => addon.name === "gdharness_editor" && addon.installed);
34897
+ if (!installed || !report.pluginsEnabled.includes("gdharness_editor")) {
34898
+ return this.createErrorResponse(`The gdharness editor addon is not ${installed ? "enabled" : "installed"} in ${projectPath}, so an editor opened on it would never reach this server.`, installed ? [
34899
+ "gdharness setup in the project enables it",
34900
+ "Or enable it under Project Settings, Plugins, in an editor opened by hand"
34901
+ ] : ["gdharness setup in the project installs and enables it"]);
34902
+ }
34903
+ const owed = restartOwed(projectPath);
34904
+ if (owed !== null && restartUnderway(owed)) {
34905
+ return this.createErrorResponse(`A restart of this project's editor is under way: gdharness server pid ${owed.byPid} asked it to quit at ${owed.quitAt} and is starting it again, so opening another would put two editors on one project.`, ["editor_status says when it has connected"]);
34906
+ }
34907
+ return null;
34908
+ }
34240
34909
  async openAnEditor(engine, projectPath, ports, hidden) {
34241
34910
  clearEditorLog(projectPath);
34242
34911
  const launched = await launchOutsideTheTree({
@@ -34271,6 +34940,13 @@ class GodotServer {
34271
34940
  if (!project.ok) {
34272
34941
  return project.response;
34273
34942
  }
34943
+ const open = this.godotBridge.isConnected() ? this.godotBridge.getStatus().projectPath : undefined;
34944
+ if (op !== "check" && open !== undefined && open !== "" && !isSameDirectory(open, project.value.path)) {
34945
+ return this.createErrorResponse(`This start names ${project.value.path}, and the editor on this bridge has ${open} open.`, [
34946
+ "editor_status names the project this server's editor is showing",
34947
+ "One server serves one editor: start a second server for the other project"
34948
+ ]);
34949
+ }
34274
34950
  const scene = readNonEmptyString(args, "scene");
34275
34951
  const sceneToRun = scene !== undefined ? resolveWithinProject(project.value.path, scene) : null;
34276
34952
  if (sceneToRun && !sceneToRun.ok) {
@@ -34365,7 +35041,7 @@ class GodotServer {
34365
35041
  }
34366
35042
  async runtimeUp(projectPath, before, budget, withArgs) {
34367
35043
  const budgetMs = budget.ms;
34368
- if (!existsSync12(join18(projectPath, RUNTIME_AUTOLOAD.path))) {
35044
+ if (!existsSync14(join19(projectPath, RUNTIME_AUTOLOAD.path))) {
34369
35045
  return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false, withArgs });
34370
35046
  }
34371
35047
  let asking = null;
@@ -34374,7 +35050,7 @@ class GodotServer {
34374
35050
  let editorAsks = 0;
34375
35051
  const waitBegan = Date.now();
34376
35052
  const lastSaid = () => editorSaysGoing ? "playing" : "not playing";
34377
- const endpoint = await announcedSince(projectPath, before, {
35053
+ const fresh = await announcedSince(projectPath, before, {
34378
35054
  budgetMs,
34379
35055
  accept: (one) => this.isTheGameOf(this.activeProcess, one, projectPath),
34380
35056
  giveUp: () => {
@@ -34398,11 +35074,19 @@ class GodotServer {
34398
35074
  }
34399
35075
  });
34400
35076
  const asked = editorAsks === 0 ? "editor not asked" : `editor asked ${editorAsks === 1 ? "once" : `${editorAsks} times`}, last said ${lastSaid()}`;
34401
- this.logDebug(`announce wait ended after ${Date.now() - waitBegan}ms: ${endpoint === null ? "nothing announced" : `pid ${endpoint.pid} announced`}, ${asked}`);
35077
+ this.logDebug(`announce wait ended after ${Date.now() - waitBegan}ms: ${fresh.length === 0 ? "nothing announced" : `pid ${fresh.map((one) => one.pid).join(", ")} announced`}, ${asked}`);
34402
35078
  const going = await this.currentRun();
35079
+ const endpoint = await this.theRunsAmong(going, fresh);
34403
35080
  if (endpoint !== null && going !== null && going.announcedPid === undefined) {
34404
35081
  this.tie(going, endpoint);
34405
35082
  }
35083
+ if (endpoint === null && fresh.length > 1) {
35084
+ return {
35085
+ listening: true,
35086
+ pids: fresh.map((one) => one.pid),
35087
+ note: `${fresh.length} games of this project announced at once and none could be told from the process tree as this run's own game rather than one it started, so none is taken as its runtime. editor_status lists them under runtimes.`
35088
+ };
35089
+ }
34406
35090
  return runtimeVerdict(endpoint, {
34407
35091
  addon: true,
34408
35092
  budgetMs,
@@ -34486,7 +35170,13 @@ class GodotServer {
34486
35170
  };
34487
35171
  this.activeProcess = played;
34488
35172
  this.watchThePlayedConsole();
34489
- writeEditorRunNote({ projectPath, transcript: transcript.path, startedAt });
35173
+ const which = playOf(playAnswer);
35174
+ writeEditorRunNote({
35175
+ projectPath,
35176
+ transcript: transcript.path,
35177
+ startedAt,
35178
+ ...which === null ? {} : { play: which }
35179
+ });
34490
35180
  sweepTranscripts();
34491
35181
  return this.jsonTextResponse({
34492
35182
  started: true,
@@ -34601,7 +35291,7 @@ class GodotServer {
34601
35291
  return 0;
34602
35292
  }
34603
35293
  try {
34604
- return reportAlreadyInTranscript(readFileSync17(game.transcript), readFileSync17(report));
35294
+ return reportAlreadyInTranscript(readFileSync18(game.transcript), readFileSync18(report));
34605
35295
  } catch (error) {
34606
35296
  this.logDebug(`Could not compare a picked-up run's report with its transcript: ${errorMessage(error)}`);
34607
35297
  return 0;
@@ -34615,6 +35305,18 @@ class GodotServer {
34615
35305
  game.brokeOn = halt.text;
34616
35306
  game.log.record("error", halt.text);
34617
35307
  }
35308
+ async untilTheEditorStopsPlaying(withinMs = STOP_WAIT_MS) {
35309
+ const deadline = Date.now() + withinMs;
35310
+ for (;; ) {
35311
+ if ((await this.editorPlayingState())?.playing === false) {
35312
+ return true;
35313
+ }
35314
+ if (Date.now() >= deadline) {
35315
+ return false;
35316
+ }
35317
+ await delay3(100);
35318
+ }
35319
+ }
34618
35320
  async endActiveGame(reason, going) {
34619
35321
  const running = this.activeProcess;
34620
35322
  const project = running?.projectPath ?? this.ourProject();
@@ -34633,8 +35335,16 @@ class GodotServer {
34633
35335
  running.endedHere = reason;
34634
35336
  const announced = this.announcedPidOf(running);
34635
35337
  if (running.throughEditor) {
34636
- await this.handleViaBridge("stop_playing", {});
34637
- return await untilGone(announced === undefined ? [] : [announced]) ? "gone" : "lingering";
35338
+ const asked = await this.handleViaBridge("stop_playing", {});
35339
+ if (asked.isError === true) {
35340
+ running.endedHere = null;
35341
+ running.log.record("warning", `This run was not ended here: the editor could not be asked to stop it (${asked.content[0]?.text ?? "no reason given"}).`);
35342
+ return "unreached";
35343
+ }
35344
+ if (announced === undefined) {
35345
+ return await this.untilTheEditorStopsPlaying() ? "gone" : "lingering";
35346
+ }
35347
+ return await untilGone([announced]) ? "gone" : "lingering";
34638
35348
  }
34639
35349
  if (running.pid === null) {
34640
35350
  return "gone";
@@ -34643,23 +35353,30 @@ class GodotServer {
34643
35353
  pid: running.pid,
34644
35354
  transcript: running.transcript ?? "",
34645
35355
  startedAt: running.startedAt,
35356
+ ...running.startedBy === undefined ? {} : { startedBy: running.startedBy },
34646
35357
  projectPath: running.projectPath ?? "",
34647
35358
  arguments: [],
34648
35359
  ...running.command === undefined ? {} : { command: running.command }
34649
35360
  };
34650
- if (!await stillTheRecordedRun(startedAs)) {
35361
+ const why = await whyNotStillTheRecordedRun(startedAs);
35362
+ if (why !== null) {
34651
35363
  clearTheRecord();
34652
35364
  running.endedHere = null;
34653
- running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the process the run was started as, so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
35365
+ running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the process the run was started as (${why}), so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
34654
35366
  return "refused";
34655
35367
  }
34656
35368
  const signalled = await runningAs(running.pid);
34657
35369
  const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
34658
35370
  let landed = true;
35371
+ let declined = null;
34659
35372
  try {
34660
35373
  process.kill(running.pid);
34661
- } catch {
35374
+ } catch (error) {
34662
35375
  landed = false;
35376
+ const code = error.code;
35377
+ if (code !== "ESRCH") {
35378
+ declined = code ?? errorMessage(error);
35379
+ }
34663
35380
  }
34664
35381
  const gone = await untilGone([running.pid]);
34665
35382
  if (gone) {
@@ -34670,11 +35387,11 @@ class GodotServer {
34670
35387
  running.exitSignal = "SIGTERM";
34671
35388
  }
34672
35389
  clearTheRecord();
34673
- running.log.record("info", landed ? `gdharness ended pid ${running.pid}, which the operating system described as ${described}.` : `gdharness signalled pid ${running.pid} and it was already gone; the operating system had described it as ${described}.`);
35390
+ running.log.record(gone ? "info" : "warning", declined !== null ? `gdharness could not signal pid ${running.pid}: the operating system refused it (${declined}). It had described that process as ${described}.` : !gone ? `gdharness signalled pid ${running.pid}, which the operating system described as ${described}, and it was still running ${STOP_WAIT_MS / 1000} seconds later.` : landed ? `gdharness ended pid ${running.pid}, which the operating system described as ${described}.` : `gdharness signalled pid ${running.pid} and it was already gone; the operating system had described it as ${described}.`);
34674
35391
  return gone ? "gone" : "lingering";
34675
35392
  }
34676
35393
  spawnGame(godotPath, cmdArgs, env) {
34677
- const child = spawn3(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
35394
+ const child = spawn4(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
34678
35395
  const cancelled = callSignal();
34679
35396
  if (cancelled !== undefined) {
34680
35397
  const end = () => {
@@ -34714,7 +35431,6 @@ class GodotServer {
34714
35431
  });
34715
35432
  child.on("exit", (code, signal) => {
34716
35433
  this.logDebug(`Godot process exited with code ${code ?? "none"}, signal ${signal ?? "none"}`);
34717
- log.finish();
34718
35434
  started.exitCode = code;
34719
35435
  started.exitSignal = code === null ? signal : null;
34720
35436
  });
@@ -34724,7 +35440,28 @@ class GodotServer {
34724
35440
  `);
34725
35441
  started.exitCode = -1;
34726
35442
  });
34727
- return started;
35443
+ const settled = new Promise((resolve) => {
35444
+ let open = 2;
35445
+ const done = () => {
35446
+ log.finish();
35447
+ resolve();
35448
+ };
35449
+ const closed = () => {
35450
+ open -= 1;
35451
+ if (open === 0) {
35452
+ done();
35453
+ }
35454
+ };
35455
+ child.stdout.once("close", closed);
35456
+ child.stderr.once("close", closed);
35457
+ child.once("exit", () => {
35458
+ setTimeout(done, STREAMS_GRACE_MS).unref();
35459
+ });
35460
+ child.once("error", () => {
35461
+ done();
35462
+ });
35463
+ });
35464
+ return Object.assign(started, { settled });
34728
35465
  }
34729
35466
  async launchKeptGame(godotPath, cmdArgs, projectPath, env, desktop) {
34730
35467
  const startedAt = Date.now();
@@ -34734,12 +35471,13 @@ class GodotServer {
34734
35471
  command: godotPath,
34735
35472
  args: cmdArgs,
34736
35473
  ...env === undefined ? {} : { env },
34737
- run: { transcript: transcript.path, startedAt, projectPath },
35474
+ run: { transcript: transcript.path, startedAt, projectPath, ...this.servedBy() },
34738
35475
  ...desktop === undefined ? {} : { desktop }
34739
35476
  });
34740
35477
  if ("error" in launched) {
34741
35478
  return launched;
34742
35479
  }
35480
+ const startedBy = Date.now();
34743
35481
  sweepTranscripts();
34744
35482
  return {
34745
35483
  pid: launched.pid,
@@ -34748,6 +35486,7 @@ class GodotServer {
34748
35486
  readOffset: 0,
34749
35487
  projectPath,
34750
35488
  startedAt,
35489
+ startedBy,
34751
35490
  command: godotPath,
34752
35491
  exitCode: null,
34753
35492
  exitSignal: null,
@@ -34806,7 +35545,8 @@ class GodotServer {
34806
35545
  this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
34807
35546
  }
34808
35547
  const project = this.godotBridge.getStatus().projectPath ?? null;
34809
- const note = project === null ? null : readEditorRunNote(project);
35548
+ const recorded = project === null ? null : readEditorRunNote(project);
35549
+ const note = recorded !== null && noteIsAbout(recorded, playing.play) ? recorded : null;
34810
35550
  const picked = {
34811
35551
  pid: null,
34812
35552
  log: new GameLog,
@@ -34863,6 +35603,31 @@ class GodotServer {
34863
35603
  const its = await this.tiedThroughTheTree(run, running);
34864
35604
  return its !== undefined && running.some((one) => one.pid === its) ? its : undefined;
34865
35605
  }
35606
+ async theRunsAmong(run, fresh) {
35607
+ if (fresh.length === 1) {
35608
+ return fresh[0] ?? null;
35609
+ }
35610
+ if (fresh.length === 0 || run === null) {
35611
+ return null;
35612
+ }
35613
+ const pid = run.announcedPid ?? await this.tiedThroughTheTree(run, fresh);
35614
+ return fresh.find((one) => one.pid === pid) ?? null;
35615
+ }
35616
+ async tieThePlayedGame(run) {
35617
+ if (!run.throughEditor || run.errorReport !== undefined || run.announcedPid !== undefined) {
35618
+ return;
35619
+ }
35620
+ const announced = runtimesAnnounced().running;
35621
+ if (this.announcedPidOf(run, announced) !== undefined || !stillRunning(run)) {
35622
+ return;
35623
+ }
35624
+ const among = this.announcedOfTheRunsProject(run, announced).map((one) => one.pid).sort((one, other) => one - other).join(",");
35625
+ if (among === "" || run.treeReadFor === among) {
35626
+ return;
35627
+ }
35628
+ run.treeReadFor = among;
35629
+ await this.tiedThroughTheTree(run, announced);
35630
+ }
34866
35631
  async tiedThroughTheTree(run, announced) {
34867
35632
  if (run.announcedPid !== undefined) {
34868
35633
  return run.announcedPid;
@@ -34941,8 +35706,9 @@ class GodotServer {
34941
35706
  }
34942
35707
  async adoptRecordedRun() {
34943
35708
  const project = this.ourProject();
34944
- const record = project === null ? null : readRunRecord(project);
34945
- if (record === null || !this.couldBeOurs(record.projectPath)) {
35709
+ const found = project === null ? [] : [readRunRecord(project), this.startedForUs(project)];
35710
+ const record = found.reduce((newest, one) => one !== null && (newest === null || one.startedAt > newest.startedAt) ? one : newest, null);
35711
+ if (record === null || !(this.couldBeOurs(record.projectPath) || this.couldBeOurs(record.servedBy ?? ""))) {
34946
35712
  return null;
34947
35713
  }
34948
35714
  const adopted = {
@@ -34952,6 +35718,7 @@ class GodotServer {
34952
35718
  readOffset: 0,
34953
35719
  projectPath: record.projectPath === "" ? null : record.projectPath,
34954
35720
  startedAt: record.startedAt,
35721
+ ...record.startedBy === undefined ? {} : { startedBy: record.startedBy },
34955
35722
  ...record.command === undefined ? {} : { command: record.command },
34956
35723
  exitCode: null,
34957
35724
  exitSignal: null,
@@ -34972,6 +35739,13 @@ class GodotServer {
34972
35739
  this.activeProcess = adopted;
34973
35740
  return adopted;
34974
35741
  }
35742
+ servedBy() {
35743
+ const mine = this.ourProject();
35744
+ return mine === null ? {} : { servedBy: mine };
35745
+ }
35746
+ startedForUs(project) {
35747
+ return everyRunRecord().find((one) => one.servedBy !== undefined && isSameDirectory(one.servedBy, project) && !(one.projectPath !== "" && isSameDirectory(one.projectPath, project))) ?? null;
35748
+ }
34975
35749
  nothingOfOursIsRunning() {
34976
35750
  const status = this.godotBridge.getStatus();
34977
35751
  const noEditor = !status.connected ? " No editor is connected, so a game the editor is playing cannot be seen from here at all:" + " editor_status says whether one is on its way, and a run of yours that is still up is" + " reachable again once it is." : "";
@@ -35063,6 +35837,7 @@ class GodotServer {
35063
35837
  resolve(false);
35064
35838
  });
35065
35839
  });
35840
+ await boot.settled;
35066
35841
  const errors = boot.log.count("error");
35067
35842
  const warnings = boot.log.count("warning");
35068
35843
  return this.jsonTextResponse({
@@ -35137,22 +35912,20 @@ class GodotServer {
35137
35912
  }
35138
35913
  async handleEditorConsole(args) {
35139
35914
  const status = this.godotBridge.getStatus();
35140
- const projectPath = status.projectPath ?? this.ownProject ?? undefined;
35141
- if (projectPath === undefined) {
35142
- return this.createErrorResponse("No editor is connected, so there is no console to read.", [
35143
- "editor_status says whether one is connected and whether it may yet be",
35144
- "editor_launch opens one, and an editor opened here has its console captured"
35145
- ]);
35915
+ const greeted = hasSaidWhoItIs(status);
35916
+ const launched = greeted ? null : this.launchedEditor?.pid ?? null;
35917
+ if (!greeted && launched === null && status.connected) {
35918
+ return this.createErrorResponse("The editor has connected and has not yet said who it is, so whose console to read cannot be told yet.", ["Ask again in a moment; editor_status shows greeting until it has"]);
35146
35919
  }
35147
- const launched = status.connected ? null : this.launchedEditor?.pid ?? null;
35148
- if (!status.connected && launched === null) {
35920
+ const projectPath = status.projectPath ?? this.ownProject ?? undefined;
35921
+ if (projectPath === undefined || !greeted && launched === null) {
35149
35922
  return this.createErrorResponse("No editor is connected, so there is no console to read.", [
35150
35923
  "editor_status says whether one is connected and whether it may yet be",
35151
35924
  "editor_launch opens one, and an editor opened here has its console captured"
35152
35925
  ]);
35153
35926
  }
35154
- const editorPid = status.connected ? status.editorPid ?? null : launched;
35155
- const console2 = editorConsole(projectPath, editorPid, status.connected ? status.openedByAServer === true : true);
35927
+ const editorPid = greeted ? status.editorPid ?? null : launched;
35928
+ const console2 = editorConsole(projectPath, editorPid, greeted ? status.openedByAServer === true : true);
35156
35929
  if ("kind" in console2) {
35157
35930
  return this.createErrorResponse(theConsoleWasNotCaptured(console2, projectPath), [
35158
35931
  "editor_launch restart replaces this editor with one whose console is captured",
@@ -35204,6 +35977,7 @@ class GodotServer {
35204
35977
  if (!run) {
35205
35978
  return this.createErrorResponse(this.nothingOfOursIsRunning());
35206
35979
  }
35980
+ await this.tieThePlayedGame(run);
35207
35981
  this.drainEditorOutput(run);
35208
35982
  this.drainTranscript(run);
35209
35983
  const going = await this.runStillGoing(run);
@@ -35239,7 +36013,7 @@ class GodotServer {
35239
36013
  notes.push(endedWithoutACode({
35240
36014
  throughEditor: run.throughEditor,
35241
36015
  announced: announced !== undefined || run.announcedPid !== undefined,
35242
- couldAnnounce: run.projectPath !== null && existsSync12(join18(run.projectPath, RUNTIME_AUTOLOAD.path)),
36016
+ couldAnnounce: run.projectPath !== null && existsSync14(join19(run.projectPath, RUNTIME_AUTOLOAD.path)),
35243
36017
  endedHere: typeof run.endedHere === "string",
35244
36018
  pickedUp: run.pickedUp === true
35245
36019
  }));
@@ -35247,6 +36021,9 @@ class GodotServer {
35247
36021
  if (run.pickedUpPlaying === true) {
35248
36022
  notes.push(run.transcript !== null ? "The editor was already playing this when this server reached it, and what it printed before that has been read back from its transcript. It is the run the editor is holding now, which is the one the debug_* tools answer for." : run.consoleFromReport === true ? "The editor was already playing this when this server reached it, and the server that started it left no transcript, so what is below was read back from the file the game's runtime writes its console to, which begins where the runtime started: the engine's own start-up before that is only in the editor. It is the run the editor is holding now, which is the one the debug_* tools answer for." : "The editor was already playing this when this server reached it, and the server that started it left no transcript, so this is not the run from its start: what it printed before that is only in the editor. It is the run the editor is holding now, which is the one the debug_* tools answer for.");
35249
36023
  }
36024
+ if (errorsUnread(run)) {
36025
+ notes.push("The editor plays this run and no report from its game's runtime addon has been found, so what the game raised with push_error and the engine's own errors are not counted here: the editor's debugger relays what a game prints and not what it reports. errors counts what was heard, and clean is left out rather than said. A game with the runtime addon writes that report once it announces itself.");
36026
+ }
35250
36027
  if (run.consoleLost === true) {
35251
36028
  const back = this.dapClient?.isConnected() === true;
35252
36029
  notes.push(`The debug adapter this run's console arrives over went away while the run was going, so what is below has a hole in it and the counts are of what was heard rather than of what was printed. ${back ? "It is connected again, and the line marking where is in the log." : "It is still gone, so nothing further will arrive here."} The project's own user://logs/godot.log has the rest, and "no entries" here means "not heard" rather than a quiet run.`);
@@ -35262,8 +36039,9 @@ class GodotServer {
35262
36039
  pid: run.pid ?? announced ?? run.announcedPid ?? null,
35263
36040
  errors: run.log.count("error"),
35264
36041
  warnings: run.log.count("warning"),
35265
- clean: run.consoleLost === true ? undefined : run.log.count("error") === 0,
36042
+ clean: cleanVerdict(run),
35266
36043
  consoleLost: run.consoleLost === true ? true : undefined,
36044
+ errorsUnread: errorsUnread(run) ? true : undefined,
35267
36045
  ...hold,
35268
36046
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
35269
36047
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
@@ -35298,6 +36076,7 @@ class GodotServer {
35298
36076
  if (elsewhere !== null) {
35299
36077
  return elsewhere;
35300
36078
  }
36079
+ await this.tieThePlayedGame(stopped);
35301
36080
  this.drainEditorOutput(stopped);
35302
36081
  this.drainTranscript(stopped);
35303
36082
  const wasRunning = await this.runStillGoing(stopped);
@@ -35306,7 +36085,7 @@ class GodotServer {
35306
36085
  const children = readBoolean(args, "andChildren") === true && wasRunning ? await this.whatTheRunStarted(stopped) : null;
35307
36086
  this.logDebug("Stopping the running game");
35308
36087
  const ending = await this.endActiveGame("editor_run stop", wasRunning);
35309
- const refused = ending === "refused";
36088
+ const refused = ending === "refused" || ending === "unreached";
35310
36089
  const ended = children === null || refused ? null : endChildrenAmong(children);
35311
36090
  if (wasRunning && !refused) {
35312
36091
  for (const pid of new Set([endedPid, announcedPid])) {
@@ -35321,13 +36100,14 @@ class GodotServer {
35321
36100
  endedPid,
35322
36101
  throughEditor: stopped.throughEditor,
35323
36102
  exitSignal: stopped.exitSignal,
35324
- errors: stopped.log.count("error")
36103
+ clean: cleanVerdict(stopped)
35325
36104
  });
35326
36105
  return this.jsonTextResponse({
35327
36106
  stopped: verdict.stopped,
35328
36107
  through: stopped.throughEditor ? "editor" : "gdharness",
35329
36108
  endedPid: verdict.endedPid,
35330
36109
  notSignalled: verdict.notSignalled,
36110
+ stillGoing: verdict.stillGoing,
35331
36111
  ...childrenFields(ended),
35332
36112
  exitedBeforeStop: !wasRunning,
35333
36113
  exitCode: stopped.exitCode,
@@ -35335,6 +36115,7 @@ class GodotServer {
35335
36115
  errors: stopped.log.count("error"),
35336
36116
  warnings: stopped.log.count("warning"),
35337
36117
  clean: verdict.clean,
36118
+ errorsUnread: errorsUnread(stopped) ? true : undefined,
35338
36119
  note: verdict.withChildren ? `${verdict.note} ${aboutTheChildren(ended, stopped.throughEditor)}` : verdict.note,
35339
36120
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
35340
36121
  });
@@ -35384,12 +36165,13 @@ class GodotServer {
35384
36165
  await announcementEnded(pid);
35385
36166
  }
35386
36167
  return this.jsonTextResponse({
35387
- stopped: true,
36168
+ stopped: gone,
35388
36169
  through: "gdharness",
35389
- endedPid: pid,
36170
+ endedPid: gone ? pid : undefined,
36171
+ stillGoing: gone ? undefined : pid,
35390
36172
  ...childrenFields(ended),
35391
36173
  exitedBeforeStop: !landed,
35392
- note: `${landed ? `pid ${pid} was ended: a game of this project that no server here was holding,` : `pid ${pid} had already gone when it was signalled: a game of this project that no server here was holding,`} identified by its announcement and by its command line, ${read.executable} run with --path ${read.projectPath ?? ""}. Nothing here read what it printed or holds its exit code, so neither is in this answer.${gone ? "" : ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be going: editor_status says whether it is.`} ${aboutTheChildren(ended, false)}`
36174
+ note: `${!gone ? `pid ${pid} was told to end and was still running ${STOP_WAIT_MS / 1000} seconds later, so it has not stopped: a game of this project that no server here was holding,` : landed ? `pid ${pid} was ended: a game of this project that no server here was holding,` : `pid ${pid} had already gone when it was signalled: a game of this project that no server here was holding,`} identified by its announcement and by its command line, ${read.executable} run with --path ${read.projectPath ?? ""}. Nothing here read what it printed or holds its exit code, so neither is in this answer.${gone ? "" : " editor_status says when it has gone."} ${aboutTheChildren(ended, false)}`
35393
36175
  });
35394
36176
  }
35395
36177
  async whatTheRunStarted(run) {
@@ -35536,7 +36318,8 @@ class GodotServer {
35536
36318
  }
35537
36319
  }
35538
36320
  const after = projectPath === "" || busy ? null : cachedClasses(projectPath);
35539
- const lost = before === null || after === null ? [] : [...before.keys()].filter((name) => !after.has(name));
36321
+ const declaredNow = before === null || after === null ? null : declaredClasses(projectPath);
36322
+ const lost = before === null || after === null || declaredNow === null ? [] : [...before.keys()].filter((name) => !after.has(name) && declaredNow.has(name));
35540
36323
  if (lost.length > 0) {
35541
36324
  this.noteAShortListEditor(projectPath);
35542
36325
  }
@@ -35547,7 +36330,12 @@ class GodotServer {
35547
36330
  const stillAtMissingPaths = atMissingPaths.filter((name) => !dropped.includes(name));
35548
36331
  const reloading = readNonEmptyString(args, "reloadScript");
35549
36332
  let reloaded = {};
35550
- if (reloading !== undefined) {
36333
+ const reloadingSource = reloading === undefined || projectPath === "" ? null : this.sourceOfClass(projectPath, reloading);
36334
+ if (reloading !== undefined && reloadingSource !== null && isToolScript(reloadingSource)) {
36335
+ reloaded = {
36336
+ problem: `${reloading} is a @tool script, which runs inside the editor, so it is not reloaded from inside a call: editor_launch restart recompiles it.`
36337
+ };
36338
+ } else if (reloading !== undefined) {
35551
36339
  try {
35552
36340
  const answer = asParams(await this.godotBridge.invokeTool("reload_script", { ...args, scriptPath: reloading }));
35553
36341
  const methods = readArray(answer, "methods");
@@ -35555,29 +36343,36 @@ class GodotServer {
35555
36343
  const found = readArray(answer, field);
35556
36344
  return found ? { [field]: found.map(String) } : {};
35557
36345
  };
36346
+ const asResource = (path) => path.startsWith("res://") ? path : `res://${path.replace(/^\/+/, "")}`;
36347
+ const script = asResource(readString(answer, "script") ?? reloading);
36348
+ const spelling = script === asResource(reloading) ? {} : { as: script };
35558
36349
  const failed = answer["failed"];
35559
36350
  if (typeof failed === "number" && failed !== 0) {
35560
36351
  reloaded = {
36352
+ ...spelling,
35561
36353
  ...listed("heldBefore"),
35562
36354
  ...listed("heldConstantsBefore"),
35563
36355
  problem: failedReloadNote({
35564
- script: reloading,
36356
+ script,
35565
36357
  code: failed,
35566
36358
  codeName: readString(answer, "failedAs") ?? "",
35567
36359
  heldBefore: listed("heldBefore")["heldBefore"] ?? [],
35568
36360
  heldAfter: methods?.map(String) ?? [],
35569
36361
  constantsBefore: listed("heldConstantsBefore")["heldConstantsBefore"] ?? [],
35570
36362
  constantsAfter: listed("constants")["constants"] ?? [],
35571
- names: projectPath === "" ? [] : this.classesAScriptNames(projectPath, reloading)
36363
+ names: projectPath === "" ? [] : this.classesAScriptNames(projectPath, script),
36364
+ restored: typeof answer["restored"] === "boolean" ? answer["restored"] : null,
36365
+ restoredAs: readString(answer, "restoredAs") ?? ""
35572
36366
  })
35573
36367
  };
35574
36368
  } else {
35575
36369
  reloaded = methods ? {
36370
+ ...spelling,
35576
36371
  methods: methods.map(String),
35577
36372
  ...listed("heldBefore"),
35578
36373
  ...listed("constants"),
35579
36374
  ...listed("heldConstantsBefore")
35580
- } : { problem: `the editor would not say what ${reloading} has after reloading it` };
36375
+ } : { problem: `the editor would not say what ${script} has after reloading it` };
35581
36376
  }
35582
36377
  } catch (error) {
35583
36378
  reloaded = { problem: `${reloading} could not be reloaded: ${errorMessage(error)}` };
@@ -35585,7 +36380,7 @@ class GodotServer {
35585
36380
  }
35586
36381
  const checked = busy ? { unseen: [] } : await this.classesTheEditorCannotSee(args);
35587
36382
  const unseen = checked.unseen;
35588
- const broughtIn = blind.unseen.map((one) => one.className).filter((name) => !unseen.some((still) => still.className === name));
36383
+ const broughtIn = busy ? [] : blind.unseen.map((one) => one.className).filter((name) => !unseen.some((still) => still.className === name));
35589
36384
  const dependents = busy || projectPath === "" || broughtIn.length === 0 ? null : await this.reloadDependents(projectPath, broughtIn);
35590
36385
  const stillGone = lost.filter((name) => !restored.includes(name));
35591
36386
  const notes = [];
@@ -35599,7 +36394,7 @@ class GodotServer {
35599
36394
  notes.push("The scan finished and these classes are still not in the list the editor resolves against, so every use of them reads as an unknown identifier. Its walk skips a file another engine has already imported. Rescan again on its own, which is the measured cure and needs no change to the declaring script, or restart the editor with editor_launch restart.");
35600
36395
  }
35601
36396
  if (dependents !== null && dependents.notReloaded.length > 0) {
35602
- notes.push(`The scan brought in ${broughtIn.join(", ")}, and ${dependents.notReloaded.map((one) => one.scriptPath).join(", ")} ${dependents.notReloaded.length === 1 ? "names one of them and" : "name them and"} could not be reloaded, so the editor still holds what it compiled while it could not see the class and reports that to the language server. editor_rescan with reloadScript on each, or editor_launch restart.`);
36397
+ notes.push(notReloadedNote(broughtIn, dependents.notReloaded));
35603
36398
  }
35604
36399
  for (const group of uidsDuplicatedOnDisk) {
35605
36400
  notes.push(`${group.join(" and ")} name one UID in their .uid files, so Godot keeps one path for it and every engine warns "UID duplicate detected". Delete the .uid of the one that is a copy and rescan: the editor writes it a UID of its own.`);
@@ -35614,6 +36409,7 @@ class GodotServer {
35614
36409
  stillWorking: busy,
35615
36410
  waitedMs: Date.now() - started,
35616
36411
  waitedForEditorScanMs: waitedForEditorMs > 0 ? waitedForEditorMs : undefined,
36412
+ reloadedAs: reloaded.as,
35617
36413
  heldBeforeReload: reloaded.heldBefore,
35618
36414
  reloadedMethods: reloaded.methods,
35619
36415
  heldConstantsBeforeReload: reloaded.heldConstantsBefore,
@@ -35640,7 +36436,8 @@ class GodotServer {
35640
36436
  if (tool) {
35641
36437
  notReloaded.push({
35642
36438
  scriptPath,
35643
- problem: "a @tool script, which runs inside the editor, so it is not reloaded from here; editor_launch restart recompiles it"
36439
+ problem: "a @tool script, which runs inside the editor, so it is not reloaded from here; editor_launch restart recompiles it",
36440
+ tool: true
35644
36441
  });
35645
36442
  continue;
35646
36443
  }
@@ -35648,9 +36445,10 @@ class GodotServer {
35648
36445
  const answer = asParams(await this.godotBridge.invokeTool("reload_script", { scriptPath }));
35649
36446
  const failed = answer["failed"];
35650
36447
  if (typeof failed === "number" && failed !== 0) {
36448
+ const left = answer["restored"] === true ? "" : answer["restored"] === false ? "; compiling it again from the text it was built from failed too, so the editor holds it unusable until it compiles" : "; a failed compile leaves the copy the editor holds unusable until it compiles, and this editor does not put it back";
35651
36449
  notReloaded.push({
35652
36450
  scriptPath,
35653
- problem: `it did not compile: Godot answered error ${failed}${readString(answer, "failedAs") ? `, ${readString(answer, "failedAs")}` : ""}`
36451
+ problem: `it did not compile: Godot answered error ${failed}${readString(answer, "failedAs") ? `, ${readString(answer, "failedAs")}` : ""}${left}`
35654
36452
  });
35655
36453
  } else if (readArray(answer, "methods") === undefined) {
35656
36454
  notReloaded.push({
@@ -35722,6 +36520,35 @@ class GodotServer {
35722
36520
  }
35723
36521
  };
35724
36522
  }
36523
+ async handleCreateScript(args) {
36524
+ const project = this.project(args);
36525
+ if (!project.ok) {
36526
+ return project.response;
36527
+ }
36528
+ const contained = this.containProjectFiles(args);
36529
+ if (!contained.ok) {
36530
+ return contained.response;
36531
+ }
36532
+ const { op: _op, projectPath: _projectPath, ...params } = contained.value;
36533
+ const created = await this.operation("create_script", params, project.value.path);
36534
+ const declared = created.ok ? readString(created.payload, "class_name") ?? "" : "";
36535
+ if (!created.ok || declared === "") {
36536
+ return this.answer(created);
36537
+ }
36538
+ const rebuilt = await this.rebuildClassCache(project.value.path);
36539
+ const registered = cachedClasses(project.value.path)?.get(declared) === readString(created.payload, "full_path");
36540
+ const answered = this.answer({
36541
+ ...created,
36542
+ payload: {
36543
+ ...created.payload,
36544
+ registered,
36545
+ ...registered ? {} : {
36546
+ registrationNote: rebuilt.ok ? `The class list was rebuilt and does not hold ${declared} at this script, so nothing started next resolves it by name. project_import refresh_classes says what the list holds and why.` : `The class list could not be rebuilt, so nothing started next resolves ${declared} by name: ${rebuilt.message}`
36547
+ }
36548
+ }
36549
+ });
36550
+ return this.godotBridge.isConnected() ? await this.alsoSayWhatTheEditorCannotSee(answered, args) : answered;
36551
+ }
35725
36552
  async alsoSayWhatTheEditorCannotSee(answered, args) {
35726
36553
  const first = answered.content[0];
35727
36554
  if (answered.isError || first?.type !== "text" || typeof first.text !== "string") {
@@ -35794,7 +36621,7 @@ class GodotServer {
35794
36621
  ]);
35795
36622
  }
35796
36623
  try {
35797
- return this.jsonTextResponse(markIfStale(await this.godotBridge.invokeTool(toolName, args), this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest()));
36624
+ return this.jsonTextResponse(markIfStale(await this.godotBridge.invokeTool(toolName, args), this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()));
35798
36625
  } catch (error) {
35799
36626
  return this.createErrorResponse(`The editor answered ${toolName} with an error: ${errorMessage(error)}`);
35800
36627
  }
@@ -35850,7 +36677,7 @@ class GodotServer {
35850
36677
  async ownRunOnItsWay(projectPath, budgetMs) {
35851
36678
  const run = await this.currentRun();
35852
36679
  const project = projectPath ?? run?.projectPath ?? null;
35853
- if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync12(join18(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
36680
+ if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync14(join19(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
35854
36681
  return null;
35855
36682
  }
35856
36683
  const already = runtimesAnnounced().running;
@@ -35874,11 +36701,15 @@ class GodotServer {
35874
36701
  const seconds = Math.round((Date.now() - run.startedAt) / 1000);
35875
36702
  return `The game this server started (${which}) has been running for ${seconds}s and has not announced its runtime, so there is nothing to talk to. A game that keeps the runtime off, as a project's own loader can for some scenes, never announces; editor_output says what it has printed.`;
35876
36703
  }
35877
- const endpoint = await announcedSince(project, new Set(already.map((one) => one.pid)), {
36704
+ const fresh = await announcedSince(project, new Set(already.map((one) => one.pid)), {
35878
36705
  budgetMs: Math.min(budgetMs, remaining),
35879
36706
  accept: (one) => this.isTheGameOf(this.activeProcess, one, project),
35880
36707
  giveUp: () => this.activeProcess !== run || this.dapClient?.isStopped() === true || !stillRunning(run)
35881
36708
  });
36709
+ const endpoint = await this.theRunsAmong(run, fresh);
36710
+ if (fresh.length > 0 && endpoint === null) {
36711
+ return null;
36712
+ }
35882
36713
  if (endpoint !== null) {
35883
36714
  const tiedMeanwhile = () => run.announcedPid !== undefined;
35884
36715
  if (!tiedMeanwhile()) {
@@ -35930,7 +36761,7 @@ class GodotServer {
35930
36761
  }
35931
36762
  }
35932
36763
  const screenshotDir = expectsScreenshot ? scratchDirectory("runtime-screenshot") : null;
35933
- const screenshotPath = screenshotDir ? join18(screenshotDir, "capture.png") : null;
36764
+ const screenshotPath = screenshotDir ? join19(screenshotDir, "capture.png") : null;
35934
36765
  try {
35935
36766
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs, !expectsScreenshot);
35936
36767
  if (!reply.ok && reply.requestId !== undefined) {
@@ -35971,7 +36802,7 @@ class GodotServer {
35971
36802
  type: "text",
35972
36803
  text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}`
35973
36804
  },
35974
- { type: "image", data: readFileSync17(screenshotPath).toString("base64"), mimeType: "image/png" }
36805
+ { type: "image", data: readFileSync18(screenshotPath).toString("base64"), mimeType: "image/png" }
35975
36806
  ]
35976
36807
  };
35977
36808
  } finally {
@@ -36068,6 +36899,7 @@ class GodotServer {
36068
36899
  }
36069
36900
  }
36070
36901
  async function runGodotServer() {
36902
+ delete process.env[EDITOR_PID_VARIABLE];
36071
36903
  const server = new GodotServer;
36072
36904
  await server.run();
36073
36905
  }