gdharness 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -7673,6 +7673,9 @@ function cacheWrittenAt(projectPath) {
7673
7673
  return null;
7674
7674
  }
7675
7675
  }
7676
+ function cachedAtMissingPaths(cached, projectPath) {
7677
+ return [...cached].filter(([, path]) => path.startsWith("res://") && !existsSync6(join6(projectPath, path.slice("res://".length)))).map(([name]) => name).sort();
7678
+ }
7676
7679
  function staleAgainst(cached, projectPath) {
7677
7680
  return [...declaredClasses(projectPath)].filter(([name, path]) => cached.get(name) !== path).map(([name]) => name);
7678
7681
  }
@@ -16084,7 +16087,7 @@ var init_tool_definitions = __esm(() => {
16084
16087
  },
16085
16088
  {
16086
16089
  name: "editor_rescan",
16087
- description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. The scan is a change-detecting walk rather than an unconditional reparse, so a file another engine has already imported reads as settled and the walk does not look inside it: its class_name then stays out of the list the editor resolves against, however many times you scan. Any class in that state is named under unseenByEditor, which is what no check on disk can see, since the declaration and the cache are both correct there and only the editor disagrees. The cure is this call on its own, with no change to the declaring script: measured against a real editor on three platforms, with idle waits of the same length ruled out so the scan is credited rather than the time it takes, and reproduced in a second project against its own reproduction in 203ms. editor_launch restart also does it and costs a window, and project_import refresh_classes does not: it rewrites the cache and does not touch what the editor is holding. A scan writes .godot/global_script_class_cache.cfg from the list the editor is holding, so a class the editor cannot resolve goes out of that file with it. That is not a reason to refuse the scan, because on a class the editor has merely not walked yet the scan is the cure; what the answer does instead is name the loss under cacheLost and rebuild the cache from the files, naming what came back under cacheRestored, so no fresh engine, CI run or clone inherits the short file. An editor that lost classes is still holding the short list, so the note says to restart it before scanning again. Needs the editor connected.",
16090
+ description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. The scan is a change-detecting walk rather than an unconditional reparse, so a file another engine has already imported reads as settled and the walk does not look inside it: its class_name then stays out of the list the editor resolves against, however many times you scan. Any class in that state is named under unseenByEditor, which is what no check on disk can see, since the declaration and the cache are both correct there and only the editor disagrees. The cure is this call on its own, with no change to the declaring script: measured against a real editor on three platforms, with idle waits of the same length ruled out so the scan is credited rather than the time it takes, and reproduced in a second project against its own reproduction in 203ms. editor_launch restart also does it and costs a window, and project_import refresh_classes does not: it rewrites the cache and does not touch what the editor is holding. A scan writes .godot/global_script_class_cache.cfg from the list the editor is holding, so a class the editor cannot resolve goes out of that file with it. That is not a reason to refuse the scan, because on a class the editor has merely not walked yet the scan is the cure; what the answer does instead is name the loss under cacheLost and rebuild the cache from the files, naming what came back under cacheRestored, so no fresh engine, CI run or clone inherits the short file. An editor that lost classes is still holding the short list, so the note says to restart it before scanning again. The other direction is guarded the same way: a class the editor still holds for a script that was renamed or deleted comes back into the cache at a path that is not there, and the next engine to read it fails on \"Could not parse global class\" in whichever correct script shares the bare name, so the cache is rebuilt without it and the answer names it under cacheDropped; the editor goes on holding it until it is restarted, and every scan until then ends in that rebuild. A script or shader written outside the editor has no .uid sidecar until something imports it, and the scan writes one beside it, which a project that commits sidecars needs before the commit. Needs the editor connected.",
16088
16091
  parameters: {
16089
16092
  projectPath: PROJECT_PATH,
16090
16093
  timeoutMs: { type: "number", description: "How long to wait for the scan. Default 30000." },
@@ -16120,12 +16123,20 @@ var init_tool_definitions = __esm(() => {
16120
16123
  },
16121
16124
  instantiableOnly: { type: "boolean", description: "query: leave out abstract classes. Default false." },
16122
16125
  className: { type: "string", description: "info, inheritance: the class." },
16123
- includeInherited: { type: "boolean", description: "info: include inherited members. Default false." }
16126
+ includeInherited: { type: "boolean", description: "info: include inherited members. Default false." },
16127
+ member: {
16128
+ type: "string",
16129
+ ops: ["info"],
16130
+ description: 'info: one member by name, a method, property, signal, enum or constant, with its signature and which class declares it, looked for on the class and every ancestor. The whole of Control is seventy thousand characters; the question "does this engine have it, and what does it take" is this. A name nothing declares is refused with the members whose names contain it.'
16131
+ }
16124
16132
  },
16125
16133
  requires: ["projectPath"],
16126
16134
  operations: {
16127
16135
  query: { summary: "classes matching a filter or category", requires: [] },
16128
- info: { summary: "methods, properties, signals and enums of one class", requires: ["className"] },
16136
+ info: {
16137
+ summary: "methods, properties, signals and enums of one class, or one member of it by name",
16138
+ requires: ["className"]
16139
+ },
16129
16140
  inheritance: { summary: "ancestors and descendants of one class", requires: ["className"] }
16130
16141
  },
16131
16142
  defaultOperation: "query"
@@ -16150,7 +16161,13 @@ var init_tool_definitions = __esm(() => {
16150
16161
  includeProperties: {
16151
16162
  type: "boolean",
16152
16163
  ops: ["tree"],
16153
- description: "tree: include each node's properties. Default false."
16164
+ description: "tree: include every stored property of each node. Default false, and one row of a form answered seventy-six thousand characters with it; properties names the few that are wanted."
16165
+ },
16166
+ properties: {
16167
+ type: "array",
16168
+ items: { type: "string" },
16169
+ ops: ["tree"],
16170
+ description: "tree: the properties to read off each node, by name, so the text of every label and button under a panel is one call: a node that has not got one leaves it out. Colons read through what a node holds, as property does for find."
16154
16171
  },
16155
16172
  className: {
16156
16173
  type: "string",
@@ -16339,7 +16356,7 @@ var init_tool_definitions = __esm(() => {
16339
16356
  relativeX: {
16340
16357
  type: "number",
16341
16358
  ops: ["mouse_motion"],
16342
- description: "mouse_motion: the movement the event carries. Left out, it is the distance from where the pointer last was, which is what a control that drags reads; give it to send a motion the position does not show."
16359
+ description: "mouse_motion: the movement the event carries. Left out, it is the distance from where the last injected pointer event put the pointer, and none for the first; that is what a control that drags reads. Give it to send a motion the position does not show."
16343
16360
  },
16344
16361
  relativeY: {
16345
16362
  type: "number",
@@ -16359,7 +16376,7 @@ var init_tool_definitions = __esm(() => {
16359
16376
  },
16360
16377
  action: { summary: "press an action, or hold it", requires: ["action"] },
16361
16378
  key: {
16362
- summary: "press a key, or hold it. Not for a menu a click has opened: an OptionButton or PopupMenu pops up as a window of its own that a key sent to the game never reaches, so an arrow moves nothing in it and Enter closes it with nothing chosen. choose is what selects there",
16379
+ summary: "press a key, or hold it. Not for a menu a click has opened: an OptionButton or PopupMenu pops up as a window of its own that a key sent to the game never reaches, so an arrow moves nothing in it and Enter closes it with nothing chosen. choose is what selects there. The limit is that menu's own navigation, not windows in general: an embedded dialog whose owner reads keys in _input gets them, and a binding catcher up in one takes the key first time",
16363
16380
  requires: ["keycode"]
16364
16381
  },
16365
16382
  text: {
@@ -16371,7 +16388,7 @@ var init_tool_definitions = __esm(() => {
16371
16388
  requires: ["x", "y"]
16372
16389
  },
16373
16390
  mouse_motion: {
16374
- summary: "move the mouse to a position, carrying the distance from where it last was unless relativeX and relativeY say otherwise, and the buttons held, so a control that drags moves under a run of these between a held mouse_click and its release",
16391
+ summary: "move the mouse to a position, carrying the distance from where the last injected event put it unless relativeX and relativeY say otherwise, and the buttons held, so a control that drags moves under a run of these between a held mouse_click and its release",
16375
16392
  requires: ["x", "y"]
16376
16393
  }
16377
16394
  }
@@ -16394,7 +16411,12 @@ var init_tool_definitions = __esm(() => {
16394
16411
  says: {
16395
16412
  type: "string",
16396
16413
  ops: ["until"],
16397
- description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, hidden nodes included; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
16414
+ description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
16415
+ },
16416
+ includeHidden: {
16417
+ type: "boolean",
16418
+ ops: ["until"],
16419
+ description: "until, with says: count words on a hidden node too. Default false, since a wait for words is a wait for them to be shown: a button that exists hidden through a whole animation carries its words the whole time and satisfied the wait at once, and every wait on that screen fell back to counting frames."
16398
16420
  },
16399
16421
  timeoutMs: {
16400
16422
  type: "number",
@@ -37458,13 +37480,16 @@ function stillRunning(run) {
37458
37480
  }
37459
37481
  return true;
37460
37482
  }
37461
- function runIsUp(run, editorSays) {
37483
+ function runIsUp(run, editorSays, now = Date.now()) {
37462
37484
  if (run?.throughEditor !== true) {
37463
37485
  return stillRunning(run);
37464
37486
  }
37465
37487
  if (run.announcedPid !== undefined && !alive(run.announcedPid)) {
37466
37488
  return false;
37467
37489
  }
37490
+ if (editorSays === false && run.seenPlaying !== true && now - run.startedAt < PLAY_STARTS_WITHIN_MS) {
37491
+ return true;
37492
+ }
37468
37493
  return editorSays ?? stillRunning(run);
37469
37494
  }
37470
37495
  function endedPreviousRun(ended) {
@@ -37538,10 +37563,11 @@ function runtimeVerdict(endpoint, after) {
37538
37563
  heldAt: after.heldAt
37539
37564
  };
37540
37565
  }
37566
+ const state = after.playingSeen === false ? "the editor has not yet reported the game as playing, which a restarted editor does not until its scan is over" : "the game is still running";
37541
37567
  return {
37542
37568
  listening: false,
37543
37569
  mayYetAnnounce: after.running,
37544
- note: after.running ? `nothing announced itself within ${after.budgetMs}ms and the game is still running, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this${after.withArgs ? ", which a run carrying its own arguments may well need, since whatever they ask the game to do before its first frame is inside this wait" : ""}` : `nothing announced itself within ${after.budgetMs}ms and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
37570
+ note: after.running ? `nothing announced itself within ${after.budgetMs}ms and ${state}, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this${after.withArgs ? ", which a run carrying its own arguments may well need, since whatever they ask the game to do before its first frame is inside this wait" : ""}` : `nothing announced itself within ${after.budgetMs}ms and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
37545
37571
  heldAt: null
37546
37572
  };
37547
37573
  }
@@ -38230,7 +38256,8 @@ class GodotServer {
38230
38256
  ...whichGame(args),
38231
38257
  root: readNonEmptyString(args, "nodePath") ?? "/root",
38232
38258
  depth: readPositiveNumber(args, "depth") ?? 3,
38233
- include_properties: readBoolean(args, "includeProperties") ?? false
38259
+ include_properties: readBoolean(args, "includeProperties") ?? false,
38260
+ properties: readArray(args, "properties") ?? []
38234
38261
  });
38235
38262
  case "find":
38236
38263
  return await this.handleFindRuntimeNodes(args);
@@ -39445,7 +39472,8 @@ class GodotServer {
39445
39472
  budgetMs,
39446
39473
  heldAt: this.dapClient?.whereItStopped() ?? null,
39447
39474
  running: endpoint !== null || going !== null && await this.runStillGoing(going),
39448
- withArgs
39475
+ withArgs,
39476
+ ...going?.throughEditor === true ? { playingSeen: going.seenPlaying === true } : {}
39449
39477
  });
39450
39478
  }
39451
39479
  async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
@@ -39475,6 +39503,7 @@ class GodotServer {
39475
39503
  const startedAt = Date.now();
39476
39504
  const transcript = openTranscript(startedAt);
39477
39505
  closeSync(transcript.fd);
39506
+ const playAnswer = asParams(JSON.parse(answer.content[0]?.text ?? "{}"));
39478
39507
  const played = {
39479
39508
  process: null,
39480
39509
  pid: null,
@@ -39486,7 +39515,8 @@ class GodotServer {
39486
39515
  exitCode: null,
39487
39516
  throughEditor: true,
39488
39517
  brokeOn: null,
39489
- announcedBefore: alreadyPlaying
39518
+ announcedBefore: alreadyPlaying,
39519
+ seenPlaying: readBoolean(playAnswer, "playing") === true
39490
39520
  };
39491
39521
  this.activeProcess = played;
39492
39522
  writeEditorRunNote({ projectPath, transcript: transcript.path, startedAt });
@@ -39496,7 +39526,7 @@ class GodotServer {
39496
39526
  through: "editor",
39497
39527
  scene: scene === null ? "the main scene" : `res://${scene}`,
39498
39528
  refreshedClasses,
39499
- debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
39529
+ debugPort: readNumber(playAnswer, "debugPort"),
39500
39530
  endedPreviousRun: endedPreviousRun(ended),
39501
39531
  breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
39502
39532
  breakpointsRefused: breakpoints.refused.length === 0 ? undefined : breakpoints.refused.map((one) => ({
@@ -40022,7 +40052,11 @@ class GodotServer {
40022
40052
  if (announced !== undefined && editorSays === undefined) {
40023
40053
  return stillRunning(run);
40024
40054
  }
40025
- return runIsUp(run, editorSays === undefined ? (await this.editorPlayingState())?.playing ?? null : editorSays);
40055
+ const said = editorSays === undefined ? (await this.editorPlayingState())?.playing ?? null : editorSays;
40056
+ if (said === true) {
40057
+ run.seenPlaying = true;
40058
+ }
40059
+ return runIsUp(run, said);
40026
40060
  }
40027
40061
  async handleGetDebugOutput(args, waitedMs) {
40028
40062
  await this.pickUpWhatTheEditorIsPlaying();
@@ -40205,6 +40239,7 @@ class GodotServer {
40205
40239
  const writtenBefore = projectPath === "" ? null : cacheWrittenAt(projectPath);
40206
40240
  const blind = await this.classesTheEditorCannotSee(args);
40207
40241
  const atRisk = before === null ? [] : blind.unseen.filter((one) => before.has(one.className));
40242
+ const ghosts = blind.stillHeld ?? [];
40208
40243
  const first = await this.handleViaBridge("rescan_filesystem", args);
40209
40244
  if (first.isError) {
40210
40245
  return first;
@@ -40216,7 +40251,7 @@ class GodotServer {
40216
40251
  const pending = Boolean(status["pending"]) && Date.now() - started < SCAN_START_MS;
40217
40252
  busy = Boolean(status["scanning"]) || Boolean(status["importing"]) || pending;
40218
40253
  }
40219
- if (!busy && projectPath !== "" && (atRisk.length > 0 || blind.unchecked !== undefined)) {
40254
+ if (!busy && projectPath !== "" && (atRisk.length > 0 || ghosts.length > 0 || blind.unchecked !== undefined)) {
40220
40255
  const until = Date.now() + CACHE_WRITE_MS;
40221
40256
  while (Date.now() < until && cacheWrittenAt(projectPath) === writtenBefore) {
40222
40257
  await new Promise((settle) => setTimeout(settle, 100));
@@ -40224,7 +40259,11 @@ class GodotServer {
40224
40259
  }
40225
40260
  const after = projectPath === "" || busy ? null : cachedClasses(projectPath);
40226
40261
  const lost = before === null || after === null ? [] : [...before.keys()].filter((name) => !after.has(name));
40227
- const restored = lost.length > 0 ? await this.rebuildCacheAfterLoss(projectPath, lost) : [];
40262
+ const atMissingPaths = after === null ? [] : cachedAtMissingPaths(after, projectPath);
40263
+ const restored = lost.length > 0 || atMissingPaths.length > 0 ? await this.rebuildCacheAfterLoss(projectPath, lost) : [];
40264
+ const rebuilt = atMissingPaths.length > 0 ? cachedClasses(projectPath) : null;
40265
+ const dropped = rebuilt === null ? [] : atMissingPaths.filter((name) => !cachedAtMissingPaths(rebuilt, projectPath).includes(name));
40266
+ const stillAtMissingPaths = atMissingPaths.filter((name) => !dropped.includes(name));
40228
40267
  const reloading = readNonEmptyString(args, "reloadScript");
40229
40268
  let reloaded = {};
40230
40269
  if (reloading !== undefined) {
@@ -40240,8 +40279,23 @@ class GodotServer {
40240
40279
  const checked = busy ? { unseen: [] } : await this.classesTheEditorCannotSee(args);
40241
40280
  const unseen = checked.unseen;
40242
40281
  const stillGone = lost.filter((name) => !restored.includes(name));
40282
+ const notes = [];
40283
+ if (busy) {
40284
+ notes.push("The editor was still scanning or importing when the wait ran out, so new files may not be visible yet.");
40285
+ } else if (stillGone.length > 0) {
40286
+ notes.push("The scan wrote the class cache from the list this editor is holding, that list is shorter than the file was, and rebuilding the cache from the files did not bring these back. Every engine that reads the cache next, including a test run, will report them as unknown identifiers. Restart the editor with editor_launch restart before scanning again.");
40287
+ } else if (lost.length > 0) {
40288
+ notes.push("The scan wrote the class cache from the list this editor is holding, which is shorter than the file was, so the cache was rebuilt from the files and these classes are back in it. Nothing was lost, but this editor is still holding the short list: do not rescan again until it has been restarted with editor_launch restart, because it will write the same list over the file each time.");
40289
+ } else if (unseen.length > 0) {
40290
+ 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.");
40291
+ }
40292
+ if (stillAtMissingPaths.length > 0) {
40293
+ notes.push(`The scan wrote the class cache with ${stillAtMissingPaths.join(", ")} at a path that is not on disk, and rebuilding the cache from the files did not take it out. The next engine to read the cache fails on "Could not parse global class" in whichever correct script shares the bare name. Restart the editor with editor_launch restart, then run project_import refresh_classes.`);
40294
+ } else if (dropped.length > 0) {
40295
+ notes.push(`The scan wrote the class cache from the list this editor is holding, and that list still has ${dropped.join(", ")} for a script that is gone, so the cache was rebuilt from the files without it. The editor goes on holding it and writes it back on every scan until it is restarted with editor_launch restart; until then a scan is followed by this same rebuild.`);
40296
+ }
40243
40297
  return this.jsonTextResponse({
40244
- ok: !busy && unseen.length === 0 && stillGone.length === 0 && checked.unchecked === undefined,
40298
+ ok: !busy && unseen.length === 0 && stillGone.length === 0 && stillAtMissingPaths.length === 0 && checked.unchecked === undefined,
40245
40299
  stillWorking: busy,
40246
40300
  waitedMs: Date.now() - started,
40247
40301
  heldBeforeReload: reloaded.heldBefore,
@@ -40250,8 +40304,10 @@ class GodotServer {
40250
40304
  unseenByEditor: unseen.length > 0 ? unseen : undefined,
40251
40305
  cacheLost: lost.length > 0 ? lost : undefined,
40252
40306
  cacheRestored: restored.length > 0 ? restored : undefined,
40307
+ cacheDropped: dropped.length > 0 ? dropped : undefined,
40308
+ cacheAtMissingPaths: stillAtMissingPaths.length > 0 ? stillAtMissingPaths : undefined,
40253
40309
  classesUnchecked: checked.unchecked,
40254
- note: busy ? "The editor was still scanning or importing when the wait ran out, so new files may not be visible yet." : stillGone.length > 0 ? "The scan wrote the class cache from the list this editor is holding, that list is shorter than the file was, and rebuilding the cache from the files did not bring these back. Every engine that reads the cache next, including a test run, will report them as unknown identifiers. Restart the editor with editor_launch restart before scanning again." : lost.length > 0 ? "The scan wrote the class cache from the list this editor is holding, which is shorter than the file was, so the cache was rebuilt from the files and these classes are back in it. Nothing was lost, but this editor is still holding the short list: do not rescan again until it has been restarted with editor_launch restart, because it will write the same list over the file each time." : unseen.length > 0 ? "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." : undefined
40310
+ note: notes.length > 0 ? notes.join(" ") : undefined
40255
40311
  });
40256
40312
  }
40257
40313
  async rebuildCacheAfterLoss(projectPath, lost) {
@@ -40492,6 +40548,7 @@ class GodotServer {
40492
40548
  property: readString(args, "property") ?? "",
40493
40549
  value: args["value"],
40494
40550
  ...readNonEmptyString(args, "says") === undefined ? {} : { says: readNonEmptyString(args, "says") },
40551
+ ...readBoolean(args, "includeHidden") === undefined ? {} : { include_hidden: readBoolean(args, "includeHidden") },
40495
40552
  timeout_ms: timeoutMs
40496
40553
  }, patience);
40497
40554
  }
@@ -40500,7 +40557,7 @@ async function runGodotServer() {
40500
40557
  const server = new GodotServer;
40501
40558
  await server.run();
40502
40559
  }
40503
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, CONSOLE_WRAPPER, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CACHE_WRITE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
40560
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, CONSOLE_WRAPPER, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CACHE_WRITE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
40504
40561
  var init_server3 = __esm(() => {
40505
40562
  init_mcp();
40506
40563
  init_stdio2();
@@ -42093,7 +42150,9 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
42093
42150
  | Answer a dialog | \`runtime_input click\` on its button, or \`key\` Escape to dismiss it: an [AcceptDialog] reads that key itself and never asks the InputMap, so \`ui_cancel\` leaves it standing |
42094
42151
  | Press a bound key | \`runtime_input action\` or \`key\`, a whole press unless you hold it |
42095
42152
  | Where a 3D node is on screen | \`runtime_inspect\` \`rect\`, rather than unprojecting by hand |
42096
- | Wait for something in a running game | \`runtime_wait\`, never a sleep; \`until\` with \`says\` for a panel that rebuilds its own labels |
42153
+ | Wait for something in a running game | \`runtime_wait\`, never a sleep; \`until\` with \`says\` for a panel that rebuilds its own labels. The words have to be shown: a button that exists hidden through an animation does not satisfy it until it appears, unless \`includeHidden\` says otherwise |
42154
+ | What every label on a screen says | \`runtime_inspect tree\` with \`properties\` naming \`text\`, which reads it off every node that has one; \`includeProperties\` is every stored property of every node and ran to seventy-six thousand characters for one row |
42155
+ | Whether this engine has a method or property, and what it takes | \`editor_classes info\` with \`member\`, which answers that one member and which class declares it rather than the whole class |
42097
42156
  | Wait for a run to finish | \`editor_run wait\`, never a sleep or a shell loop on the pid: it answers as \`editor_output\` does, once the run is over |
42098
42157
  | End a bench and the workers it opened | \`editor_run stop\` with \`andChildren\`, never a process listing by scene name: it ends every process under the run's, to any depth, that is a game of the project, announced as one or the project's engine run with \`--path\` on it, names them under \`endedChildren\` with what identified each, and leaves and names any other |
42099
42158
  | A picture, for a person who asked to see one | \`runtime_capture\` |
@@ -25,12 +25,31 @@ const HEADLESS_VIEWPORT: Vector2 = Vector2(64, 64)
25
25
  var _host: Node
26
26
  var _values: Values
27
27
 
28
+ ## Where the last injected pointer event put the pointer, once one has.
29
+ ##
30
+ ## Kept here rather than asked of the viewport: the root viewport answers get_mouse_position
31
+ ## from the operating system's pointer, which an injected event never moves, so on a desktop it
32
+ ## is wherever the user's mouse is, another monitor included, and every motion sent through the
33
+ ## bridge carried the distance from there. Only a headless run, with no pointer to read, followed
34
+ ## the injected events, which is why that reading measured right and a played game read it wrong.
35
+ var _pointer: Vector2 = Vector2.ZERO
36
+ var _pointer_placed: bool = false
37
+
28
38
 
29
39
  func _init(host: Node, values: Values) -> void:
30
40
  _host = host
31
41
  _values = values
32
42
 
33
43
 
44
+ ## The movement a pointer arriving at [param position] carries, which is the distance from where
45
+ ## the last injected event put it, and none at all for the first. The arrival is recorded.
46
+ func _arrive(position: Vector2) -> Vector2:
47
+ var relative: Vector2 = position - _pointer if _pointer_placed else Vector2.ZERO
48
+ _pointer = position
49
+ _pointer_placed = true
50
+ return relative
51
+
52
+
34
53
  ## Presses [param params].action, and lets go of it again unless asked to hold it.
35
54
  ##
36
55
  ## The whole press by default, for the reason [method click] is the whole click: an action left
@@ -329,6 +348,9 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
329
348
  var pressed: bool = Read.as_bool(params.get("pressed", true), true)
330
349
  var double: bool = Read.as_bool(params.get("doubleClick", false))
331
350
 
351
+ # A button pressed somewhere puts the pointer there: the motion after a held press measures
352
+ # its drag from the press, which is where a real pointer would be.
353
+ var _moved: Vector2 = _arrive(position)
332
354
  Input.parse_input_event(_button(position, button, pressed, double))
333
355
 
334
356
  return {
@@ -342,19 +364,19 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
342
364
 
343
365
 
344
366
  ## Moves the pointer to a position, with the movement the event carries taken from where the
345
- ## pointer last was unless the caller says otherwise.
367
+ ## last injected event put it unless the caller says otherwise.
346
368
  ##
347
369
  ## A real pointer never arrives without a `relative`, and a control that drags reads that field
348
370
  ## rather than the position, so a motion carrying none leaves a grip where it was however far the
349
- ## position moved. The viewport remembers where the last motion put the pointer, injected or not,
350
- ## so the difference from there is what a real move would have carried. A relative the caller gives
351
- ## is kept, since a game may want to read a motion the position does not show.
371
+ ## position moved. The distance from where the bridge last put the pointer is what a real move
372
+ ## would have carried. A relative the caller gives is kept, since a game may want to read a motion
373
+ ## the position does not show.
352
374
  func inject_mouse_motion(params: Dictionary) -> Dictionary:
353
375
  var point: Variant = _read_point(params, "x", "y", "position")
354
376
  if point is String:
355
377
  return {"type": "error", "message": point}
356
378
  var position: Vector2 = point
357
- var relative: Vector2 = position - _host.get_viewport().get_mouse_position()
379
+ var relative: Vector2 = _arrive(position)
358
380
  if params.has("relativeX") or params.has("relativeY"):
359
381
  relative = Vector2(
360
382
  Read.as_float(params.get("relativeX", 0.0)), Read.as_float(params.get("relativeY", 0.0))
@@ -516,7 +538,7 @@ func click(params: Dictionary) -> Dictionary:
516
538
  # Pushed into the viewport rather than through Input: Input accumulates events and flushes
517
539
  # them at the next frame, so the hovered control read below would be the one from before
518
540
  # the pointer moved. The viewport delivers it to the GUI the same way a real one arrives.
519
- viewport.push_input(_motion(position, Vector2.ZERO))
541
+ viewport.push_input(_motion(position, _arrive(position)))
520
542
  # What the engine itself thinks is under the pointer, which is the answer to "did it land",
521
543
  # read before the press so the caller learns about a control on top rather than a click
522
544
  # that went to it. Read in full here, path included, because nothing about that control
@@ -776,7 +798,7 @@ func _click_in_the_world(node_path: String, item: Node3D, params: Dictionary) ->
776
798
  return _no_such_button(params.get("button"))
777
799
  var double: bool = Read.as_bool(params.get("double", false))
778
800
 
779
- viewport.push_input(_motion(position, Vector2.ZERO))
801
+ viewport.push_input(_motion(position, _arrive(position)))
780
802
  # Read before the press, for the reason the Control click reads it: what the caller needs to
781
803
  # know is whether a panel is sitting over the room, and the press is what would change it.
782
804
  var hovered: Control = viewport.gui_get_hovered_control()
@@ -30,17 +30,33 @@ func _init(host: Node, values: Values) -> void:
30
30
  _values = values
31
31
 
32
32
 
33
+ ## The tree under a node, to a depth, with every stored property on each node when asked for all
34
+ ## of them or the named ones when a list is given.
35
+ ##
36
+ ## The list is the shape most questions about a screen take: the `text` of every label and
37
+ ## button under a panel. All of them for one row of a form answered seventy-six thousand
38
+ ## characters, and the same question as a find with one property answered two.
33
39
  func get_tree(params: Dictionary) -> Dictionary:
34
40
  var root_path: String = str(params.get("root", "/root"))
35
41
  var max_depth: int = Read.as_int(params.get("depth", 3), 3)
36
42
  var include_properties: bool = Read.as_bool(params.get("include_properties", false))
43
+ var named: Array[String] = []
44
+ var listed: Variant = params.get("properties", [])
45
+ if listed is Array:
46
+ for each: Variant in listed:
47
+ named.append(str(each))
48
+ else:
49
+ return {"type": "error", "message": "properties must be a list of property names"}
37
50
 
38
51
  var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
39
52
  if reached.has("message"):
40
53
  return reached
41
54
  var root: Node = reached["node"]
42
55
 
43
- return {"type": "tree", "root": _serialize_node_tree(root, 0, max_depth, include_properties)}
56
+ return {
57
+ "type": "tree",
58
+ "root": _serialize_node_tree(root, 0, max_depth, include_properties, named),
59
+ }
44
60
 
45
61
 
46
62
  ## Nodes matching every filter given, as paths, so a caller can name what it wants without
@@ -91,7 +107,7 @@ func find_nodes(params: Dictionary) -> Dictionary:
91
107
  var node: Node = pending.pop_front()
92
108
  var rest: bool = _matches_apart_from_name(node, wanted)
93
109
  if rest and _named(node, wanted["name"]):
94
- if not include_hidden and not _shown(node):
110
+ if not include_hidden and not shown(node):
95
111
  hidden += 1
96
112
  elif found.size() >= limit:
97
113
  truncated = true
@@ -144,8 +160,8 @@ func find_nodes(params: Dictionary) -> Dictionary:
144
160
  ## a plain Node sitting between a hidden panel and a label has no visibility to ask about, and the
145
161
  ## label answers that it is visible while nothing of it is on screen. Walking up is what makes a
146
162
  ## row hidden because the panel holding it is hidden, which is what somebody checking a screen is
147
- ## asking about.
148
- static func _shown(node: Node) -> bool:
163
+ ## asking about. A wait on words asks the same, so it reads this too.
164
+ static func shown(node: Node) -> bool:
149
165
  var walk: Node = node
150
166
  while walk != null:
151
167
  if not _drawn(walk):
@@ -846,8 +862,12 @@ func get_metrics(params: Dictionary) -> Dictionary:
846
862
  return {"type": "metrics", "data": selected}
847
863
 
848
864
 
849
- func _serialize_node_tree(node: Node, depth: int, max_depth: int, include_properties: bool) -> Dictionary:
865
+ func _serialize_node_tree(
866
+ node: Node, depth: int, max_depth: int, include_properties: bool, named: Array[String]
867
+ ) -> Dictionary:
850
868
  var result: Dictionary = _serialize_node(node, include_properties)
869
+ if not named.is_empty():
870
+ result["properties"] = _named_properties_of(node, str(result["path"]), named)
851
871
 
852
872
  if depth < max_depth:
853
873
  var children: Array = []
@@ -856,12 +876,28 @@ func _serialize_node_tree(node: Node, depth: int, max_depth: int, include_proper
856
876
  # it is wrong, and it is what sends somebody looking for another way to press the button.
857
877
  # `depth` is what keeps the answer a size worth reading.
858
878
  for child: Node in node.get_children(true):
859
- children.append(_serialize_node_tree(child, depth + 1, max_depth, include_properties))
879
+ children.append(_serialize_node_tree(child, depth + 1, max_depth, include_properties, named))
860
880
  result["children"] = children
861
881
 
862
882
  return result
863
883
 
864
884
 
885
+ ## The named properties a node has, read the way a find reads its one: through a colon path as
886
+ ## well, and a name this node has not got is left out rather than answered null, since a tree
887
+ ## holds nodes of every class and a label's `text` is not a container's.
888
+ func _named_properties_of(node: Node, path: String, named: Array[String]) -> Dictionary:
889
+ var properties: Dictionary = {}
890
+ for wanted: String in named:
891
+ var reached: Dictionary = _reached(node, path, wanted)
892
+ if reached.has("message"):
893
+ continue
894
+ var holder: Variant = reached["holder"]
895
+ var name: String = reached["name"]
896
+ if _can_read(holder, name):
897
+ properties[wanted] = _values.serialize(_read(holder, name))
898
+ return properties
899
+
900
+
865
901
  func _serialize_node(node: Node, include_properties: bool) -> Dictionary:
866
902
  var result: Dictionary = {"name": node.name, "type": node.get_class(), "path": str(node.get_path())}
867
903
 
@@ -115,7 +115,8 @@ func wait_until(params: Dictionary) -> Dictionary:
115
115
  )
116
116
  }
117
117
  if not says.is_empty():
118
- return await _wait_until_said(node_path, says, timeout_ms)
118
+ var include_hidden: bool = Read.as_bool(params.get("include_hidden", false))
119
+ return await _wait_until_said(node_path, says, timeout_ms, include_hidden)
119
120
  if property.is_empty():
120
121
  return {"type": "error", "message": "A property and a value, or says, are required"}
121
122
  if not params.has("value"):
@@ -196,34 +197,40 @@ func wait_until(params: Dictionary) -> Dictionary:
196
197
  ## on a node that was freed a frame later, and that is what it answered: the date along the top of a
197
198
  ## hall could not be waited on at all. What a caller is watching for is a word arriving on a screen,
198
199
  ## and the screen is the part that stays put.
199
- func _wait_until_said(node_path: String, said: String, timeout_ms: int) -> Dictionary:
200
+ ##
201
+ ## On the screen, which is to say shown: a control carrying the words while hidden satisfied the
202
+ ## wait at once, so a duel screen whose "Carry on" button exists hidden through the whole sweep
203
+ ## answered met in two milliseconds while the sweep was still animating, and every wait on that
204
+ ## screen fell back to counting frames. [param include_hidden] asks the other question, for a
205
+ ## caller waiting on words a hidden node holds.
206
+ func _wait_until_said(node_path: String, said: String, timeout_ms: int, include_hidden: bool) -> Dictionary:
200
207
  var started: int = Time.get_ticks_msec()
201
208
  var words: String = Queries.as_said(said)
202
- var found: bool = _anything_says(node_path, words)
209
+ var found: bool = _anything_says(node_path, words, include_hidden)
203
210
  while not found and Time.get_ticks_msec() - started < timeout_ms:
204
211
  await _host.get_tree().process_frame
205
- found = _anything_says(node_path, words)
212
+ found = _anything_says(node_path, words, include_hidden)
206
213
 
207
214
  return {
208
215
  "type": "condition",
209
216
  "path": node_path,
210
217
  "says": said,
211
218
  "met": found,
219
+ "include_hidden": include_hidden,
212
220
  "elapsed_ms": Time.get_ticks_msec() - started,
213
221
  }
214
222
 
215
223
 
216
- ## Whether anything under [param node_path] says [param said], the node itself included. Hidden
217
- ## nodes count, for the reason a find answers off them: a caller may be waiting for a dialog that
218
- ## is built before it is shown.
219
- func _anything_says(node_path: String, said: String) -> bool:
224
+ ## Whether anything under [param node_path] says [param said], the node itself included, and shown
225
+ ## unless hidden ones are wanted too.
226
+ func _anything_says(node_path: String, said: String, include_hidden: bool) -> bool:
220
227
  var root: Node = _host.get_tree().root.get_node_or_null(node_path)
221
228
  if root == null:
222
229
  return false
223
230
  var pending: Array[Node] = [root]
224
231
  while not pending.is_empty():
225
232
  var node: Node = pending.pop_back()
226
- if Queries.said_by(node).containsn(said):
233
+ if Queries.said_by(node).containsn(said) and (include_hidden or Queries.shown(node)):
227
234
  return true
228
235
  pending.append_array(node.get_children(true))
229
236
  return false
@@ -92,6 +92,10 @@ func query_class_info(params: Dictionary) -> Dictionary:
92
92
  if not ClassDB.class_exists(class_name_str):
93
93
  return _log.failure("Class not found: " + class_name_str)
94
94
 
95
+ var member: String = str(params.get("member", ""))
96
+ if not member.is_empty():
97
+ return _member_of(class_name_str, member)
98
+
95
99
  var methods: Array[Dictionary] = _methods_of(class_name_str, include_inherited)
96
100
  var properties: Array[Dictionary] = _properties_of(class_name_str, include_inherited)
97
101
  var signals: Array[Dictionary] = _signals_of(class_name_str, include_inherited)
@@ -130,6 +134,77 @@ func query_class_info(params: Dictionary) -> Dictionary:
130
134
  }
131
135
 
132
136
 
137
+ # One member of a class by name, wherever in its ancestry it is declared.
138
+ #
139
+ # The question a member is asked by name for is "does this engine have it, and what does it take":
140
+ # the whole class in answer to that ran to seventy thousand characters for Control, and the member
141
+ # is as often an ancestor's as the class's own, so the ancestry is walked without being asked. Each
142
+ # match says which class declares it. Nothing found names the members whose names contain the one
143
+ # asked for, since the usual miss is a spelling.
144
+ func _member_of(class_name_str: String, member: String) -> Dictionary:
145
+ var found: Array[Dictionary] = []
146
+ var current: String = class_name_str
147
+ while not current.is_empty():
148
+ for m: Dictionary in _methods_of(current, false):
149
+ if m["name"] == member:
150
+ found.append({"kind": "method", "declared_in": current, "member": m})
151
+ for p: Dictionary in _properties_of(current, false):
152
+ if p["name"] == member:
153
+ found.append({"kind": "property", "declared_in": current, "member": p})
154
+ for s: Dictionary in _signals_of(current, false):
155
+ if s["name"] == member:
156
+ found.append({"kind": "signal", "declared_in": current, "member": s})
157
+ for e: String in ClassDB.class_get_enum_list(current, true):
158
+ if e == member:
159
+ var values: Dictionary = {}
160
+ for c: String in ClassDB.class_get_enum_constants(current, e, true):
161
+ values[c] = ClassDB.class_get_integer_constant(current, c)
162
+ found.append(
163
+ {"kind": "enum", "declared_in": current, "member": {"name": e, "values": values}}
164
+ )
165
+ for c: String in ClassDB.class_get_integer_constant_list(current, true):
166
+ if c == member:
167
+ var value: int = ClassDB.class_get_integer_constant(current, c)
168
+ found.append(
169
+ {"kind": "constant", "declared_in": current, "member": {"name": c, "value": value}}
170
+ )
171
+ current = ClassDB.get_parent_class(current)
172
+
173
+ if found.is_empty():
174
+ var alike: Array[String] = _members_alike(class_name_str, member)
175
+ var hint: String = (
176
+ "" if alike.is_empty() else " Members with names containing it: " + ", ".join(alike) + "."
177
+ )
178
+ return _log.failure(
179
+ "%s and its ancestors declare no member named %s.%s" % [class_name_str, member, hint]
180
+ )
181
+
182
+ return {"class_name": class_name_str, "member": member, "found": found}
183
+
184
+
185
+ # The names, across a class and its ancestors, that contain the asked-for one, case-insensitively.
186
+ func _members_alike(class_name_str: String, member: String) -> Array[String]:
187
+ var alike: Array[String] = []
188
+ var wanted: String = member.to_lower()
189
+ var current: String = class_name_str
190
+ while not current.is_empty():
191
+ for m: Dictionary in _methods_of(current, false):
192
+ var name: String = m["name"]
193
+ if name.to_lower().contains(wanted) and not alike.has(name):
194
+ alike.append(name)
195
+ for p: Dictionary in _properties_of(current, false):
196
+ var name: String = p["name"]
197
+ if name.to_lower().contains(wanted) and not alike.has(name):
198
+ alike.append(name)
199
+ for s: Dictionary in _signals_of(current, false):
200
+ var name: String = s["name"]
201
+ if name.to_lower().contains(wanted) and not alike.has(name):
202
+ alike.append(name)
203
+ current = ClassDB.get_parent_class(current)
204
+ alike.sort()
205
+ return alike
206
+
207
+
133
208
  # Inspect class inheritance hierarchy
134
209
  func inspect_inheritance(params: Dictionary) -> Dictionary:
135
210
  var class_name_str: String = str(params.get("class_name", ""))
package/build/index.js CHANGED
@@ -25052,6 +25052,9 @@ function cacheWrittenAt(projectPath) {
25052
25052
  return null;
25053
25053
  }
25054
25054
  }
25055
+ function cachedAtMissingPaths(cached, projectPath) {
25056
+ return [...cached].filter(([, path]) => path.startsWith("res://") && !existsSync2(join4(projectPath, path.slice("res://".length)))).map(([name]) => name).sort();
25057
+ }
25055
25058
  function staleAgainst(cached, projectPath) {
25056
25059
  return [...declaredClasses(projectPath)].filter(([name, path]) => cached.get(name) !== path).map(([name]) => name);
25057
25060
  }
@@ -29687,7 +29690,7 @@ var TOOL_SPECS = [
29687
29690
  },
29688
29691
  {
29689
29692
  name: "editor_rescan",
29690
- description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. The scan is a change-detecting walk rather than an unconditional reparse, so a file another engine has already imported reads as settled and the walk does not look inside it: its class_name then stays out of the list the editor resolves against, however many times you scan. Any class in that state is named under unseenByEditor, which is what no check on disk can see, since the declaration and the cache are both correct there and only the editor disagrees. The cure is this call on its own, with no change to the declaring script: measured against a real editor on three platforms, with idle waits of the same length ruled out so the scan is credited rather than the time it takes, and reproduced in a second project against its own reproduction in 203ms. editor_launch restart also does it and costs a window, and project_import refresh_classes does not: it rewrites the cache and does not touch what the editor is holding. A scan writes .godot/global_script_class_cache.cfg from the list the editor is holding, so a class the editor cannot resolve goes out of that file with it. That is not a reason to refuse the scan, because on a class the editor has merely not walked yet the scan is the cure; what the answer does instead is name the loss under cacheLost and rebuild the cache from the files, naming what came back under cacheRestored, so no fresh engine, CI run or clone inherits the short file. An editor that lost classes is still holding the short list, so the note says to restart it before scanning again. Needs the editor connected.",
29693
+ description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. The scan is a change-detecting walk rather than an unconditional reparse, so a file another engine has already imported reads as settled and the walk does not look inside it: its class_name then stays out of the list the editor resolves against, however many times you scan. Any class in that state is named under unseenByEditor, which is what no check on disk can see, since the declaration and the cache are both correct there and only the editor disagrees. The cure is this call on its own, with no change to the declaring script: measured against a real editor on three platforms, with idle waits of the same length ruled out so the scan is credited rather than the time it takes, and reproduced in a second project against its own reproduction in 203ms. editor_launch restart also does it and costs a window, and project_import refresh_classes does not: it rewrites the cache and does not touch what the editor is holding. A scan writes .godot/global_script_class_cache.cfg from the list the editor is holding, so a class the editor cannot resolve goes out of that file with it. That is not a reason to refuse the scan, because on a class the editor has merely not walked yet the scan is the cure; what the answer does instead is name the loss under cacheLost and rebuild the cache from the files, naming what came back under cacheRestored, so no fresh engine, CI run or clone inherits the short file. An editor that lost classes is still holding the short list, so the note says to restart it before scanning again. The other direction is guarded the same way: a class the editor still holds for a script that was renamed or deleted comes back into the cache at a path that is not there, and the next engine to read it fails on \"Could not parse global class\" in whichever correct script shares the bare name, so the cache is rebuilt without it and the answer names it under cacheDropped; the editor goes on holding it until it is restarted, and every scan until then ends in that rebuild. A script or shader written outside the editor has no .uid sidecar until something imports it, and the scan writes one beside it, which a project that commits sidecars needs before the commit. Needs the editor connected.",
29691
29694
  parameters: {
29692
29695
  projectPath: PROJECT_PATH,
29693
29696
  timeoutMs: { type: "number", description: "How long to wait for the scan. Default 30000." },
@@ -29723,12 +29726,20 @@ var TOOL_SPECS = [
29723
29726
  },
29724
29727
  instantiableOnly: { type: "boolean", description: "query: leave out abstract classes. Default false." },
29725
29728
  className: { type: "string", description: "info, inheritance: the class." },
29726
- includeInherited: { type: "boolean", description: "info: include inherited members. Default false." }
29729
+ includeInherited: { type: "boolean", description: "info: include inherited members. Default false." },
29730
+ member: {
29731
+ type: "string",
29732
+ ops: ["info"],
29733
+ description: 'info: one member by name, a method, property, signal, enum or constant, with its signature and which class declares it, looked for on the class and every ancestor. The whole of Control is seventy thousand characters; the question "does this engine have it, and what does it take" is this. A name nothing declares is refused with the members whose names contain it.'
29734
+ }
29727
29735
  },
29728
29736
  requires: ["projectPath"],
29729
29737
  operations: {
29730
29738
  query: { summary: "classes matching a filter or category", requires: [] },
29731
- info: { summary: "methods, properties, signals and enums of one class", requires: ["className"] },
29739
+ info: {
29740
+ summary: "methods, properties, signals and enums of one class, or one member of it by name",
29741
+ requires: ["className"]
29742
+ },
29732
29743
  inheritance: { summary: "ancestors and descendants of one class", requires: ["className"] }
29733
29744
  },
29734
29745
  defaultOperation: "query"
@@ -29753,7 +29764,13 @@ var TOOL_SPECS = [
29753
29764
  includeProperties: {
29754
29765
  type: "boolean",
29755
29766
  ops: ["tree"],
29756
- description: "tree: include each node's properties. Default false."
29767
+ description: "tree: include every stored property of each node. Default false, and one row of a form answered seventy-six thousand characters with it; properties names the few that are wanted."
29768
+ },
29769
+ properties: {
29770
+ type: "array",
29771
+ items: { type: "string" },
29772
+ ops: ["tree"],
29773
+ description: "tree: the properties to read off each node, by name, so the text of every label and button under a panel is one call: a node that has not got one leaves it out. Colons read through what a node holds, as property does for find."
29757
29774
  },
29758
29775
  className: {
29759
29776
  type: "string",
@@ -29942,7 +29959,7 @@ var TOOL_SPECS = [
29942
29959
  relativeX: {
29943
29960
  type: "number",
29944
29961
  ops: ["mouse_motion"],
29945
- description: "mouse_motion: the movement the event carries. Left out, it is the distance from where the pointer last was, which is what a control that drags reads; give it to send a motion the position does not show."
29962
+ description: "mouse_motion: the movement the event carries. Left out, it is the distance from where the last injected pointer event put the pointer, and none for the first; that is what a control that drags reads. Give it to send a motion the position does not show."
29946
29963
  },
29947
29964
  relativeY: {
29948
29965
  type: "number",
@@ -29962,7 +29979,7 @@ var TOOL_SPECS = [
29962
29979
  },
29963
29980
  action: { summary: "press an action, or hold it", requires: ["action"] },
29964
29981
  key: {
29965
- summary: "press a key, or hold it. Not for a menu a click has opened: an OptionButton or PopupMenu pops up as a window of its own that a key sent to the game never reaches, so an arrow moves nothing in it and Enter closes it with nothing chosen. choose is what selects there",
29982
+ summary: "press a key, or hold it. Not for a menu a click has opened: an OptionButton or PopupMenu pops up as a window of its own that a key sent to the game never reaches, so an arrow moves nothing in it and Enter closes it with nothing chosen. choose is what selects there. The limit is that menu's own navigation, not windows in general: an embedded dialog whose owner reads keys in _input gets them, and a binding catcher up in one takes the key first time",
29966
29983
  requires: ["keycode"]
29967
29984
  },
29968
29985
  text: {
@@ -29974,7 +29991,7 @@ var TOOL_SPECS = [
29974
29991
  requires: ["x", "y"]
29975
29992
  },
29976
29993
  mouse_motion: {
29977
- summary: "move the mouse to a position, carrying the distance from where it last was unless relativeX and relativeY say otherwise, and the buttons held, so a control that drags moves under a run of these between a held mouse_click and its release",
29994
+ summary: "move the mouse to a position, carrying the distance from where the last injected event put it unless relativeX and relativeY say otherwise, and the buttons held, so a control that drags moves under a run of these between a held mouse_click and its release",
29978
29995
  requires: ["x", "y"]
29979
29996
  }
29980
29997
  }
@@ -29997,7 +30014,12 @@ var TOOL_SPECS = [
29997
30014
  says: {
29998
30015
  type: "string",
29999
30016
  ops: ["until"],
30000
- description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, hidden nodes included; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
30017
+ description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
30018
+ },
30019
+ includeHidden: {
30020
+ type: "boolean",
30021
+ ops: ["until"],
30022
+ description: "until, with says: count words on a hidden node too. Default false, since a wait for words is a wait for them to be shown: a button that exists hidden through a whole animation carries its words the whole time and satisfied the wait at once, and every wait on that screen fell back to counting frames."
30001
30023
  },
30002
30024
  timeoutMs: {
30003
30025
  type: "number",
@@ -30151,13 +30173,17 @@ function stillRunning(run) {
30151
30173
  }
30152
30174
  return true;
30153
30175
  }
30154
- function runIsUp(run, editorSays) {
30176
+ var PLAY_STARTS_WITHIN_MS = 30000;
30177
+ function runIsUp(run, editorSays, now = Date.now()) {
30155
30178
  if (run?.throughEditor !== true) {
30156
30179
  return stillRunning(run);
30157
30180
  }
30158
30181
  if (run.announcedPid !== undefined && !alive(run.announcedPid)) {
30159
30182
  return false;
30160
30183
  }
30184
+ if (editorSays === false && run.seenPlaying !== true && now - run.startedAt < PLAY_STARTS_WITHIN_MS) {
30185
+ return true;
30186
+ }
30161
30187
  return editorSays ?? stillRunning(run);
30162
30188
  }
30163
30189
  function endedPreviousRun(ended) {
@@ -30232,10 +30258,11 @@ function runtimeVerdict(endpoint, after) {
30232
30258
  heldAt: after.heldAt
30233
30259
  };
30234
30260
  }
30261
+ const state = after.playingSeen === false ? "the editor has not yet reported the game as playing, which a restarted editor does not until its scan is over" : "the game is still running";
30235
30262
  return {
30236
30263
  listening: false,
30237
30264
  mayYetAnnounce: after.running,
30238
- note: after.running ? `nothing announced itself within ${after.budgetMs}ms and the game is still running, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this${after.withArgs ? ", which a run carrying its own arguments may well need, since whatever they ask the game to do before its first frame is inside this wait" : ""}` : `nothing announced itself within ${after.budgetMs}ms and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
30265
+ note: after.running ? `nothing announced itself within ${after.budgetMs}ms and ${state}, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this${after.withArgs ? ", which a run carrying its own arguments may well need, since whatever they ask the game to do before its first frame is inside this wait" : ""}` : `nothing announced itself within ${after.budgetMs}ms and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
30239
30266
  heldAt: null
30240
30267
  };
30241
30268
  }
@@ -30966,7 +30993,8 @@ class GodotServer {
30966
30993
  ...whichGame(args),
30967
30994
  root: readNonEmptyString(args, "nodePath") ?? "/root",
30968
30995
  depth: readPositiveNumber(args, "depth") ?? 3,
30969
- include_properties: readBoolean(args, "includeProperties") ?? false
30996
+ include_properties: readBoolean(args, "includeProperties") ?? false,
30997
+ properties: readArray(args, "properties") ?? []
30970
30998
  });
30971
30999
  case "find":
30972
31000
  return await this.handleFindRuntimeNodes(args);
@@ -32181,7 +32209,8 @@ class GodotServer {
32181
32209
  budgetMs,
32182
32210
  heldAt: this.dapClient?.whereItStopped() ?? null,
32183
32211
  running: endpoint !== null || going !== null && await this.runStillGoing(going),
32184
- withArgs
32212
+ withArgs,
32213
+ ...going?.throughEditor === true ? { playingSeen: going.seenPlaying === true } : {}
32185
32214
  });
32186
32215
  }
32187
32216
  async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
@@ -32211,6 +32240,7 @@ class GodotServer {
32211
32240
  const startedAt = Date.now();
32212
32241
  const transcript = openTranscript(startedAt);
32213
32242
  closeSync(transcript.fd);
32243
+ const playAnswer = asParams(JSON.parse(answer.content[0]?.text ?? "{}"));
32214
32244
  const played = {
32215
32245
  process: null,
32216
32246
  pid: null,
@@ -32222,7 +32252,8 @@ class GodotServer {
32222
32252
  exitCode: null,
32223
32253
  throughEditor: true,
32224
32254
  brokeOn: null,
32225
- announcedBefore: alreadyPlaying
32255
+ announcedBefore: alreadyPlaying,
32256
+ seenPlaying: readBoolean(playAnswer, "playing") === true
32226
32257
  };
32227
32258
  this.activeProcess = played;
32228
32259
  writeEditorRunNote({ projectPath, transcript: transcript.path, startedAt });
@@ -32232,7 +32263,7 @@ class GodotServer {
32232
32263
  through: "editor",
32233
32264
  scene: scene === null ? "the main scene" : `res://${scene}`,
32234
32265
  refreshedClasses,
32235
- debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
32266
+ debugPort: readNumber(playAnswer, "debugPort"),
32236
32267
  endedPreviousRun: endedPreviousRun(ended),
32237
32268
  breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
32238
32269
  breakpointsRefused: breakpoints.refused.length === 0 ? undefined : breakpoints.refused.map((one) => ({
@@ -32758,7 +32789,11 @@ class GodotServer {
32758
32789
  if (announced !== undefined && editorSays === undefined) {
32759
32790
  return stillRunning(run);
32760
32791
  }
32761
- return runIsUp(run, editorSays === undefined ? (await this.editorPlayingState())?.playing ?? null : editorSays);
32792
+ const said = editorSays === undefined ? (await this.editorPlayingState())?.playing ?? null : editorSays;
32793
+ if (said === true) {
32794
+ run.seenPlaying = true;
32795
+ }
32796
+ return runIsUp(run, said);
32762
32797
  }
32763
32798
  async handleGetDebugOutput(args, waitedMs) {
32764
32799
  await this.pickUpWhatTheEditorIsPlaying();
@@ -32941,6 +32976,7 @@ class GodotServer {
32941
32976
  const writtenBefore = projectPath === "" ? null : cacheWrittenAt(projectPath);
32942
32977
  const blind = await this.classesTheEditorCannotSee(args);
32943
32978
  const atRisk = before === null ? [] : blind.unseen.filter((one) => before.has(one.className));
32979
+ const ghosts = blind.stillHeld ?? [];
32944
32980
  const first = await this.handleViaBridge("rescan_filesystem", args);
32945
32981
  if (first.isError) {
32946
32982
  return first;
@@ -32952,7 +32988,7 @@ class GodotServer {
32952
32988
  const pending = Boolean(status["pending"]) && Date.now() - started < SCAN_START_MS;
32953
32989
  busy = Boolean(status["scanning"]) || Boolean(status["importing"]) || pending;
32954
32990
  }
32955
- if (!busy && projectPath !== "" && (atRisk.length > 0 || blind.unchecked !== undefined)) {
32991
+ if (!busy && projectPath !== "" && (atRisk.length > 0 || ghosts.length > 0 || blind.unchecked !== undefined)) {
32956
32992
  const until = Date.now() + CACHE_WRITE_MS;
32957
32993
  while (Date.now() < until && cacheWrittenAt(projectPath) === writtenBefore) {
32958
32994
  await new Promise((settle) => setTimeout(settle, 100));
@@ -32960,7 +32996,11 @@ class GodotServer {
32960
32996
  }
32961
32997
  const after = projectPath === "" || busy ? null : cachedClasses(projectPath);
32962
32998
  const lost = before === null || after === null ? [] : [...before.keys()].filter((name) => !after.has(name));
32963
- const restored = lost.length > 0 ? await this.rebuildCacheAfterLoss(projectPath, lost) : [];
32999
+ const atMissingPaths = after === null ? [] : cachedAtMissingPaths(after, projectPath);
33000
+ const restored = lost.length > 0 || atMissingPaths.length > 0 ? await this.rebuildCacheAfterLoss(projectPath, lost) : [];
33001
+ const rebuilt = atMissingPaths.length > 0 ? cachedClasses(projectPath) : null;
33002
+ const dropped = rebuilt === null ? [] : atMissingPaths.filter((name) => !cachedAtMissingPaths(rebuilt, projectPath).includes(name));
33003
+ const stillAtMissingPaths = atMissingPaths.filter((name) => !dropped.includes(name));
32964
33004
  const reloading = readNonEmptyString(args, "reloadScript");
32965
33005
  let reloaded = {};
32966
33006
  if (reloading !== undefined) {
@@ -32976,8 +33016,23 @@ class GodotServer {
32976
33016
  const checked = busy ? { unseen: [] } : await this.classesTheEditorCannotSee(args);
32977
33017
  const unseen = checked.unseen;
32978
33018
  const stillGone = lost.filter((name) => !restored.includes(name));
33019
+ const notes = [];
33020
+ if (busy) {
33021
+ notes.push("The editor was still scanning or importing when the wait ran out, so new files may not be visible yet.");
33022
+ } else if (stillGone.length > 0) {
33023
+ notes.push("The scan wrote the class cache from the list this editor is holding, that list is shorter than the file was, and rebuilding the cache from the files did not bring these back. Every engine that reads the cache next, including a test run, will report them as unknown identifiers. Restart the editor with editor_launch restart before scanning again.");
33024
+ } else if (lost.length > 0) {
33025
+ notes.push("The scan wrote the class cache from the list this editor is holding, which is shorter than the file was, so the cache was rebuilt from the files and these classes are back in it. Nothing was lost, but this editor is still holding the short list: do not rescan again until it has been restarted with editor_launch restart, because it will write the same list over the file each time.");
33026
+ } else if (unseen.length > 0) {
33027
+ 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.");
33028
+ }
33029
+ if (stillAtMissingPaths.length > 0) {
33030
+ notes.push(`The scan wrote the class cache with ${stillAtMissingPaths.join(", ")} at a path that is not on disk, and rebuilding the cache from the files did not take it out. The next engine to read the cache fails on "Could not parse global class" in whichever correct script shares the bare name. Restart the editor with editor_launch restart, then run project_import refresh_classes.`);
33031
+ } else if (dropped.length > 0) {
33032
+ notes.push(`The scan wrote the class cache from the list this editor is holding, and that list still has ${dropped.join(", ")} for a script that is gone, so the cache was rebuilt from the files without it. The editor goes on holding it and writes it back on every scan until it is restarted with editor_launch restart; until then a scan is followed by this same rebuild.`);
33033
+ }
32979
33034
  return this.jsonTextResponse({
32980
- ok: !busy && unseen.length === 0 && stillGone.length === 0 && checked.unchecked === undefined,
33035
+ ok: !busy && unseen.length === 0 && stillGone.length === 0 && stillAtMissingPaths.length === 0 && checked.unchecked === undefined,
32981
33036
  stillWorking: busy,
32982
33037
  waitedMs: Date.now() - started,
32983
33038
  heldBeforeReload: reloaded.heldBefore,
@@ -32986,8 +33041,10 @@ class GodotServer {
32986
33041
  unseenByEditor: unseen.length > 0 ? unseen : undefined,
32987
33042
  cacheLost: lost.length > 0 ? lost : undefined,
32988
33043
  cacheRestored: restored.length > 0 ? restored : undefined,
33044
+ cacheDropped: dropped.length > 0 ? dropped : undefined,
33045
+ cacheAtMissingPaths: stillAtMissingPaths.length > 0 ? stillAtMissingPaths : undefined,
32989
33046
  classesUnchecked: checked.unchecked,
32990
- note: busy ? "The editor was still scanning or importing when the wait ran out, so new files may not be visible yet." : stillGone.length > 0 ? "The scan wrote the class cache from the list this editor is holding, that list is shorter than the file was, and rebuilding the cache from the files did not bring these back. Every engine that reads the cache next, including a test run, will report them as unknown identifiers. Restart the editor with editor_launch restart before scanning again." : lost.length > 0 ? "The scan wrote the class cache from the list this editor is holding, which is shorter than the file was, so the cache was rebuilt from the files and these classes are back in it. Nothing was lost, but this editor is still holding the short list: do not rescan again until it has been restarted with editor_launch restart, because it will write the same list over the file each time." : unseen.length > 0 ? "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." : undefined
33047
+ note: notes.length > 0 ? notes.join(" ") : undefined
32991
33048
  });
32992
33049
  }
32993
33050
  async rebuildCacheAfterLoss(projectPath, lost) {
@@ -33228,6 +33285,7 @@ class GodotServer {
33228
33285
  property: readString(args, "property") ?? "",
33229
33286
  value: args["value"],
33230
33287
  ...readNonEmptyString(args, "says") === undefined ? {} : { says: readNonEmptyString(args, "says") },
33288
+ ...readBoolean(args, "includeHidden") === undefined ? {} : { include_hidden: readBoolean(args, "includeHidden") },
33231
33289
  timeout_ms: timeoutMs
33232
33290
  }, patience);
33233
33291
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",