gdharness 1.0.27 → 1.0.29

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
@@ -16309,7 +16309,7 @@ var init_tool_definitions = __esm(() => {
16309
16309
  },
16310
16310
  {
16311
16311
  name: "editor_run",
16312
- description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for. A start this server made also answers with transcript, the file both the run's streams are written to, so a watch on it can be armed off the start rather than off a second call.",
16312
+ description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. start and check both wait first for a scan the open editor is running and for the class cache it writes just after, since a game booted in between resolves no global class, as does every other engine this server starts on the project; waitedForEditorScanMs says how long, and scanNote says when a scan ran past thirty seconds and the game started during it. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for. A start this server made also answers with transcript, the file both the run's streams are written to, so a watch on it can be armed off the start rather than off a second call.",
16313
16313
  parameters: {
16314
16314
  projectPath: { ...PROJECT_PATH, ops: ["start", "check"] },
16315
16315
  scene: {
@@ -16426,7 +16426,7 @@ var init_tool_definitions = __esm(() => {
16426
16426
  },
16427
16427
  {
16428
16428
  name: "editor_status",
16429
- 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. 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.",
16429
+ 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.",
16430
16430
  parameters: {},
16431
16431
  requires: []
16432
16432
  },
@@ -16538,7 +16538,7 @@ var init_tool_definitions = __esm(() => {
16538
16538
  says: {
16539
16539
  type: "string",
16540
16540
  ops: ["find"],
16541
- description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one."
16541
+ description: "find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one."
16542
16542
  },
16543
16543
  limit: {
16544
16544
  type: "integer",
@@ -16567,7 +16567,7 @@ var init_tool_definitions = __esm(() => {
16567
16567
  operations: {
16568
16568
  tree: { summary: "the live scene tree", requires: [] },
16569
16569
  text: {
16570
- summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it",
16570
+ summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it. Every line reads as drawn: rich text without its tags, text only as far as it has been typed out, a translation rather than its key, an upper-case label in capitals and a secret field as its mask. Tab titles, menu bar titles, list items, a tree's column titles and rows, and an open menu's items read a line each",
16571
16571
  requires: []
16572
16572
  },
16573
16573
  find: {
@@ -16716,7 +16716,7 @@ var init_tool_definitions = __esm(() => {
16716
16716
  type: "string",
16717
16717
  ops: ["text", "choose"],
16718
16718
  blank: true,
16719
- description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says.'
16719
+ description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says as drawn, which in a game with translations is the translation; the key it holds is matched after that.'
16720
16720
  },
16721
16721
  replace: {
16722
16722
  type: "boolean",
@@ -16778,7 +16778,7 @@ var init_tool_definitions = __esm(() => {
16778
16778
  requires: ["keycode"]
16779
16779
  },
16780
16780
  text: {
16781
- summary: "type a string into the field being edited, a character at a time, and say what it landed in",
16781
+ 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",
16782
16782
  requires: ["text"]
16783
16783
  },
16784
16784
  mouse_click: {
@@ -38557,6 +38557,33 @@ function outsideTheirRanges(spec, args) {
38557
38557
  }
38558
38558
  return complaints;
38559
38559
  }
38560
+ function scanWaitIn(payload) {
38561
+ const waited = readNumber(payload, "waitedForEditorScanMs");
38562
+ const note = readString(payload, "scanNote");
38563
+ return {
38564
+ ...waited === undefined ? {} : { waitedForEditorScanMs: waited },
38565
+ ...note === undefined ? {} : { scanNote: note }
38566
+ };
38567
+ }
38568
+ function withScanWait(outcome, scanned) {
38569
+ if (scanned.waitedMs === 0) {
38570
+ return outcome;
38571
+ }
38572
+ const said = scanWaitAnswer(scanned);
38573
+ if (outcome.ok) {
38574
+ return { ...outcome, payload: { ...outcome.payload, ...said } };
38575
+ }
38576
+ return said.scanNote === undefined ? outcome : { ...outcome, message: `${outcome.message} ${said.scanNote}` };
38577
+ }
38578
+ function scanWaitAnswer(scanned) {
38579
+ if (scanned.waitedMs === 0) {
38580
+ return {};
38581
+ }
38582
+ return {
38583
+ waitedForEditorScanMs: scanned.waitedMs,
38584
+ 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
38585
+ };
38586
+ }
38560
38587
  function escapingPropertyValue(properties) {
38561
38588
  if (typeof properties !== "object" || properties === null) {
38562
38589
  return;
@@ -39491,6 +39518,47 @@ class GodotServer {
39491
39518
  }
39492
39519
  return { ok: true, value: stale };
39493
39520
  }
39521
+ async untilTheEditorsScanIsWritten(projectPath) {
39522
+ const open = this.godotBridge.getStatus().projectPath;
39523
+ if (!this.godotBridge.isConnected() || open === undefined || !isSameDirectory(open, projectPath)) {
39524
+ return { waitedMs: 0, stillScanning: false };
39525
+ }
39526
+ const started = Date.now();
39527
+ let sawAScan = false;
39528
+ let scanEndedAt = null;
39529
+ for (;; ) {
39530
+ const asked = Date.now();
39531
+ let status;
39532
+ try {
39533
+ status = asParams(await this.godotBridge.invokeTool("scan_status", {}));
39534
+ } catch {
39535
+ return { waitedMs: Date.now() - started, stillScanning: false };
39536
+ }
39537
+ if (status["scanning"] === true || status["importing"] === true || status["pending"] === true) {
39538
+ sawAScan = true;
39539
+ if (Date.now() - started >= SCAN_WAIT_MS) {
39540
+ return { waitedMs: Date.now() - started, stillScanning: true };
39541
+ }
39542
+ await new Promise((settle) => setTimeout(settle, 100));
39543
+ continue;
39544
+ }
39545
+ const since = readNumber(status, "sinceScanFinishedMs");
39546
+ scanEndedAt = since !== undefined && since >= 0 ? asked - since : sawAScan ? asked : null;
39547
+ break;
39548
+ }
39549
+ let waitedForTheWrite = false;
39550
+ if (scanEndedAt !== null) {
39551
+ const writeDue = scanEndedAt + CACHE_WRITE_MS;
39552
+ while (Date.now() < writeDue && (cacheWrittenAt(projectPath) ?? 0) < scanEndedAt) {
39553
+ waitedForTheWrite = true;
39554
+ await new Promise((settle) => setTimeout(settle, 50));
39555
+ }
39556
+ }
39557
+ return {
39558
+ waitedMs: sawAScan || waitedForTheWrite ? Date.now() - started : 0,
39559
+ stillScanning: false
39560
+ };
39561
+ }
39494
39562
  containProjectFiles(args) {
39495
39563
  const projectPath = readString(args, "projectPath");
39496
39564
  if (!projectPath) {
@@ -39551,14 +39619,15 @@ class GodotServer {
39551
39619
  return engine.response;
39552
39620
  }
39553
39621
  const projectPath = project.value.path;
39622
+ const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
39554
39623
  const before = scriptsWithoutUid(projectPath);
39555
39624
  const imported = await runImport(engine.value, projectPath);
39556
39625
  if (!imported.ok) {
39557
- return this.answer(imported);
39626
+ return this.answer(withScanWait(imported, scanned));
39558
39627
  }
39559
39628
  const after = scriptsWithoutUid(projectPath);
39560
39629
  const given = before.filter((script) => !after.includes(script));
39561
- return this.answer({
39630
+ return this.answer(withScanWait({
39562
39631
  ok: true,
39563
39632
  messages: imported.messages,
39564
39633
  payload: {
@@ -39566,7 +39635,7 @@ class GodotServer {
39566
39635
  stillWithoutUid: after,
39567
39636
  note: uidsLeftNote(after.length)
39568
39637
  }
39569
- });
39638
+ }, scanned));
39570
39639
  }
39571
39640
  async operation(operation, params, projectPath) {
39572
39641
  const engine = await this.engine();
@@ -39577,8 +39646,10 @@ class GodotServer {
39577
39646
  messages: []
39578
39647
  };
39579
39648
  }
39649
+ const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
39580
39650
  this.logDebug(`Running ${operation} in ${projectPath}: ${JSON.stringify(params)}`);
39581
- return await runOperation2({ godotPath: engine.value, script: this.operationsScript, debug: GODOT_DEBUG_MODE_DEFAULT }, operation, params, projectPath);
39651
+ const outcome = await runOperation2({ godotPath: engine.value, script: this.operationsScript, debug: GODOT_DEBUG_MODE_DEFAULT }, operation, params, projectPath);
39652
+ return withScanWait(outcome, scanned);
39582
39653
  }
39583
39654
  answer(outcome) {
39584
39655
  if (!outcome.ok) {
@@ -39760,6 +39831,7 @@ class GodotServer {
39760
39831
  if (!classes.ok) {
39761
39832
  return this.answer(classes);
39762
39833
  }
39834
+ const scanned = scanWaitIn(classes.payload);
39763
39835
  const ours = `gdharness-reports/${randomUUID2()}`;
39764
39836
  const reports = `res://.godot/${ours}`;
39765
39837
  const ignored = readStringArray(args, "ignore") ?? [];
@@ -39850,7 +39922,8 @@ class GodotServer {
39850
39922
  hung,
39851
39923
  arguments: cmdArgs,
39852
39924
  entries: forAnswer(printed.slice(0, 60)),
39853
- savesNote
39925
+ savesNote,
39926
+ ...scanned
39854
39927
  }, null, 2)
39855
39928
  }
39856
39929
  ],
@@ -39885,6 +39958,7 @@ class GodotServer {
39885
39958
  orphans: orphans.total > 0 ? orphans.total : undefined,
39886
39959
  notRun: notRun > 0 ? notRun : undefined,
39887
39960
  savesNote,
39961
+ ...scanned,
39888
39962
  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,
39889
39963
  suites: unclean.map((suite) => ({
39890
39964
  name: suite.name,
@@ -40183,6 +40257,28 @@ class GodotServer {
40183
40257
  return null;
40184
40258
  }
40185
40259
  }
40260
+ async editorScanState() {
40261
+ if (!this.godotBridge.getStatus().connected) {
40262
+ return null;
40263
+ }
40264
+ try {
40265
+ const answer = await Promise.race([
40266
+ this.godotBridge.invokeTool("scan_status", {}),
40267
+ delay3(PLAYING_STATUS_MS).then(() => null)
40268
+ ]);
40269
+ if (answer === null) {
40270
+ return null;
40271
+ }
40272
+ const since = readNumber(asParams(answer), "sinceScanFinishedMs");
40273
+ return {
40274
+ scanning: readBoolean(asParams(answer), "scanning") ?? false,
40275
+ importing: readBoolean(asParams(answer), "importing") ?? false,
40276
+ finishedMsAgo: since === undefined || since < 0 ? null : since
40277
+ };
40278
+ } catch {
40279
+ return null;
40280
+ }
40281
+ }
40186
40282
  async handleEditorStatus() {
40187
40283
  const located = await this.locator.find();
40188
40284
  const announced = runtimesAnnounced();
@@ -40213,11 +40309,16 @@ class GodotServer {
40213
40309
  hold: current === null ? {} : await this.holdOf(current, active)
40214
40310
  };
40215
40311
  })();
40216
- const [games, { playing, active, hold }] = await Promise.all([pinged, asked]);
40312
+ const [games, { playing, active, hold }, scan] = await Promise.all([
40313
+ pinged,
40314
+ asked,
40315
+ this.editorScanState()
40316
+ ]);
40217
40317
  return this.jsonTextResponse({
40218
40318
  editor: {
40219
40319
  ...this.getEditorStatusPayload(),
40220
- debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
40320
+ debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort,
40321
+ scan
40221
40322
  },
40222
40323
  godot: {
40223
40324
  path: located.ok ? located.path : null,
@@ -40438,13 +40539,14 @@ class GodotServer {
40438
40539
  if (!engine.ok) {
40439
40540
  return engine.response;
40440
40541
  }
40542
+ const scanned = await this.untilTheEditorsScanIsWritten(project.value.path);
40441
40543
  const refreshed = await this.refreshStaleClasses(project.value.path);
40442
40544
  if (!refreshed.ok) {
40443
40545
  return refreshed.response;
40444
40546
  }
40445
40547
  const sceneArgument = sceneToRun?.ok ? sceneToRun.relativePath : null;
40446
40548
  if (op === "check") {
40447
- return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, asked.value);
40549
+ return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, scanned, asked.value);
40448
40550
  }
40449
40551
  await this.pickUpWhatTheEditorIsPlaying();
40450
40552
  let ended = null;
@@ -40463,7 +40565,7 @@ class GodotServer {
40463
40565
  const runtimeWaitMs = this.announceBudget(args, project.value.path);
40464
40566
  const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
40465
40567
  if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0 && !needsItsOwnProcess(asked.value)) {
40466
- return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
40568
+ return await this.playThroughEditor(sceneArgument, refreshed.value, scanned, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
40467
40569
  }
40468
40570
  const cmdArgs = runArguments({
40469
40571
  projectPath: project.value.path,
@@ -40488,6 +40590,7 @@ class GodotServer {
40488
40590
  arguments: cmdArgs,
40489
40591
  transcript: started.transcript,
40490
40592
  refreshedClasses: refreshed.value,
40593
+ ...scanWaitAnswer(scanned),
40491
40594
  savesNote: asked.value.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
40492
40595
  endedPreviousRun: endedPreviousRun(ended),
40493
40596
  runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
@@ -40569,7 +40672,7 @@ class GodotServer {
40569
40672
  writeBootNote(run.projectPath, took);
40570
40673
  }
40571
40674
  }
40572
- async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
40675
+ async playThroughEditor(scene, refreshedClasses, scanned, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
40573
40676
  const log = new GameLog;
40574
40677
  try {
40575
40678
  await this.dap().connect();
@@ -40620,6 +40723,7 @@ class GodotServer {
40620
40723
  through: "editor",
40621
40724
  scene: scene === null ? "the main scene" : `res://${scene}`,
40622
40725
  refreshedClasses,
40726
+ ...scanWaitAnswer(scanned),
40623
40727
  debugPort: readNumber(playAnswer, "debugPort"),
40624
40728
  endedPreviousRun: endedPreviousRun(ended),
40625
40729
  breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
@@ -41123,7 +41227,7 @@ class GodotServer {
41123
41227
  currentRun() {
41124
41228
  return this.activeProcess ?? this.adoptRecordedRun();
41125
41229
  }
41126
- async checkBoot(godotPath, projectPath, scene, args, userArgs, asked = {}) {
41230
+ async checkBoot(godotPath, projectPath, scene, args, userArgs, scanned, asked = {}) {
41127
41231
  const frames = readPositiveNumber(args, "frames") ?? 3;
41128
41232
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
41129
41233
  const cmdArgs = runArguments({ projectPath, headless: true, scene, quitAfter: frames, userArgs });
@@ -41153,6 +41257,7 @@ class GodotServer {
41153
41257
  frames,
41154
41258
  errors,
41155
41259
  warnings,
41260
+ ...scanWaitAnswer(scanned),
41156
41261
  savesNote: asked.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
41157
41262
  entries: forAnswer(boot.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
41158
41263
  });
@@ -41536,6 +41641,10 @@ class GodotServer {
41536
41641
  return now === null ? [] : lost.filter((name) => now.has(name));
41537
41642
  }
41538
41643
  async rebuildClassCache(projectPath) {
41644
+ const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
41645
+ return withScanWait(await this.rebuildClassCacheFromDisk(projectPath), scanned);
41646
+ }
41647
+ async rebuildClassCacheFromDisk(projectPath) {
41539
41648
  const before = cachedClasses(projectPath);
41540
41649
  const writtenBefore = cacheWrittenAt(projectPath);
41541
41650
  const noted = readClassNote(projectPath);
@@ -41924,7 +42033,7 @@ async function runGodotServer() {
41924
42033
  const server = new GodotServer;
41925
42034
  await server.run();
41926
42035
  }
41927
- 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, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, HANDOVER_MS, HANDOVER_POLL_MS = 250, 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/";
42036
+ 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, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, 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/";
41928
42037
  var init_server3 = __esm(() => {
41929
42038
  init_mcp();
41930
42039
  init_stdio2();
@@ -43697,6 +43806,13 @@ a class it cannot resolve would go out of that file with it. The answer names th
43697
43806
  \`cacheRestored\`, so no fresh engine, CI run or clone inherits the short file. That is the same
43698
43807
  class \`unseenByEditor\` names, and the loss is not a reason to refuse the scan.
43699
43808
 
43809
+ The editor also scans on its own, when files change under it, and it rewrites that file just after
43810
+ the scan ends. Every engine this server starts on the project (a run, a boot check, a test run, a
43811
+ headless operation) waits until a scan in progress and the write after it are done, and says how
43812
+ long under \`waitedForEditorScanMs\`. A \`scanNote\` means a scan was still going after thirty
43813
+ seconds and the engine started anyway: a "Could not find type" from that engine is the scan, not
43814
+ the script, and \`editor_rescan\` followed by the same call again is the cure.
43815
+
43700
43816
  A diagnostic about a member rather than a type, \`Static function "x()" not found in base "Y"\`
43701
43817
  about something the engine compiles, is a different thing and is no longer stale here: each ask
43702
43818
  gives the document back when its answer arrives, so the next one is read off disk. If one turns
@@ -79,6 +79,7 @@ func _init_tools() -> void:
79
79
  "disconnect_signal": [_scene_tools, "disconnect_signal"],
80
80
  "list_connections": [_scene_tools, "list_connections"],
81
81
  "rescan_filesystem": [_scene_tools, "rescan_filesystem"],
82
+ "scan_status": [_scene_tools, "scan_status"],
82
83
  "global_classes": [_class_tools, "global_classes"],
83
84
  "reload_script": [_class_tools, "reload_script"],
84
85
  # Settings, for a caller that asked the editor rather than the file on disk
@@ -18,6 +18,12 @@ var _scans_finished: int = 0
18
18
  var _scans_finished_at_request: int = 0
19
19
  var _scan_pending: bool = false
20
20
 
21
+ ## When the last scan finished, on the engine's clock, or -1 before the first. The editor writes
22
+ ## the class cache a frame after it stops reporting a scan, measured on 4.7.2, so a game started
23
+ ## in between reads a file being rewritten; how long ago the scan ended is what tells a caller
24
+ ## whether that write can still be coming.
25
+ var _scan_finished_at_msec: int = -1
26
+
21
27
 
22
28
  func set_editor_plugin(plugin: EditorPlugin) -> void:
23
29
  _editor_plugin = plugin
@@ -31,6 +37,7 @@ func set_editor_plugin(plugin: EditorPlugin) -> void:
31
37
  func _scan_finished() -> void:
32
38
  _scans_finished += 1
33
39
  _scan_pending = false
40
+ _scan_finished_at_msec = Time.get_ticks_msec()
34
41
 
35
42
 
36
43
  func _refresh_and_reload(scene_path: String) -> void:
@@ -675,3 +682,22 @@ func rescan_filesystem(args: Dictionary) -> Dictionary:
675
682
  "pending": _scan_pending,
676
683
  "scansFinished": _scans_finished,
677
684
  }
685
+
686
+
687
+ ## Whether the editor is scanning, and how long ago its last scan finished, without asking for one.
688
+ ##
689
+ ## For a caller about to start a game: the editor rewrites the class cache a frame after a scan
690
+ ## stops reporting, so a game booted during the scan or in that frame reads a file being rewritten.
691
+ ## Apart from `rescan_filesystem` because asking must not start or count anything.
692
+ func scan_status(_args: Dictionary) -> Dictionary:
693
+ if not _editor_plugin:
694
+ return {"ok": false, "error": "Editor plugin unavailable"}
695
+ var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
696
+ return {
697
+ "ok": true,
698
+ "scanning": filesystem.is_scanning(),
699
+ "importing": filesystem.is_importing(),
700
+ "pending": _scan_pending,
701
+ "sinceScanFinishedMs":
702
+ -1 if _scan_finished_at_msec < 0 else Time.get_ticks_msec() - _scan_finished_at_msec,
703
+ }
@@ -10,6 +10,7 @@ const Values = preload("runtime_values.gd")
10
10
  ## reports are the same place by construction rather than by agreement.
11
11
  const Queries = preload("runtime_queries.gd")
12
12
  const Read = preload("reading.gd")
13
+ const Words = preload("runtime_words.gd")
13
14
 
14
15
  ## The distance from a capital letter to its small one in Unicode. A keycode holds the capital.
15
16
  const TO_SMALL: int = 32
@@ -239,11 +240,12 @@ static func _select_everything(focused: Control) -> bool:
239
240
  ## What the field says now, or null where the focus is not a field at all.
240
241
  ##
241
242
  ## A spin box rewrites its own text out of the number it parsed, so this is the only thing that
242
- ## says whether what was typed became what the field means.
243
+ ## says whether what was typed became what the field means. A secret field answers with its mask,
244
+ ## which still says how many characters it took.
243
245
  static func _what_it_holds(focused: Control) -> Variant:
244
246
  var field: LineEdit = focused as LineEdit
245
247
  if field != null:
246
- return field.text
248
+ return Words.masked(field) if field.secret else field.text
247
249
  var box: TextEdit = focused as TextEdit
248
250
  if box == null:
249
251
  return null
@@ -666,7 +668,7 @@ func choose(params: Dictionary) -> Dictionary:
666
668
  if menu.is_item_disabled(index):
667
669
  return {
668
670
  "type": "error",
669
- "message": "%s item %d, %s, is disabled" % [node_path, index, menu.get_item_text(index)]
671
+ "message": "%s item %d, %s, is disabled" % [node_path, index, Words.item_says(menu, index)]
670
672
  }
671
673
 
672
674
  # Shown first, because a menu nobody has opened has no focus to move and Enter would go to
@@ -689,7 +691,7 @@ func choose(params: Dictionary) -> Dictionary:
689
691
  "type": "chosen",
690
692
  "path": node_path,
691
693
  "index": index,
692
- "text": menu.get_item_text(index),
694
+ "text": Words.item_says(menu, index),
693
695
  "id": menu.get_item_id(index),
694
696
  "opened": opened,
695
697
  "menu": str(menu.get_path()),
@@ -699,7 +701,7 @@ func choose(params: Dictionary) -> Dictionary:
699
701
  var chooser: OptionButton = node as OptionButton
700
702
  if chooser != null:
701
703
  answer["selected"] = chooser.get_selected()
702
- answer["shows"] = chooser.text
704
+ answer["shows"] = Words.said_by(chooser)
703
705
  return answer
704
706
 
705
707
 
@@ -718,6 +720,11 @@ static func _menu_of(node: Node) -> PopupMenu:
718
720
 
719
721
  ## Which item was asked for: `text`, matched exactly and then case-insensitively, or `index`.
720
722
  ## Minus one when neither names one that is there.
723
+ ##
724
+ ## `text` is matched against the words the item shows before the ones it holds. In a game with
725
+ ## translations they differ, and the shown ones are what a caller has read off the screen: an item
726
+ ## held as `ACT_ENGAGE` and shown as "Hire" refused "Hire" and listed the keys. The held words
727
+ ## still count after them, for a caller that has the key from the source.
721
728
  static func _wanted_item(menu: PopupMenu, params: Dictionary) -> int:
722
729
  if params.has("index"):
723
730
  var asked: int = Read.as_int(params.get("index", -1), -1)
@@ -725,20 +732,26 @@ static func _wanted_item(menu: PopupMenu, params: Dictionary) -> int:
725
732
  var wanted: String = str(params.get("text", ""))
726
733
  if wanted.is_empty():
727
734
  return -1
735
+ var shown: Array[String] = []
736
+ var held: Array[String] = []
728
737
  for index: int in menu.get_item_count():
729
- if menu.get_item_text(index) == wanted:
730
- return index
731
- for index: int in menu.get_item_count():
732
- if menu.get_item_text(index).nocasecmp_to(wanted) == 0:
733
- return index
738
+ shown.append(Words.item_says(menu, index))
739
+ held.append(menu.get_item_text(index))
740
+ for words: Array[String] in [shown, held]:
741
+ for index: int in words.size():
742
+ if words[index] == wanted:
743
+ return index
744
+ for index: int in words.size():
745
+ if words[index].nocasecmp_to(wanted) == 0:
746
+ return index
734
747
  return -1
735
748
 
736
749
 
737
- ## What the menu says, for a refusal that names the choices rather than the miss.
750
+ ## What the menu shows, for a refusal that names the choices rather than the miss.
738
751
  static func _items_of(menu: PopupMenu) -> Array[String]:
739
752
  var said: Array[String] = []
740
753
  for index: int in menu.get_item_count():
741
- said.append("%d: %s" % [index, menu.get_item_text(index)])
754
+ said.append("%d: %s" % [index, Words.item_says(menu, index)])
742
755
  return said
743
756
 
744
757
 
@@ -6,6 +6,7 @@ extends RefCounted
6
6
  const Paths = preload("runtime_paths.gd")
7
7
  const Read = preload("reading.gd")
8
8
  const Values = preload("runtime_values.gd")
9
+ const Words = preload("runtime_words.gd")
9
10
 
10
11
  ## The most nodes one find answers with, unless asked for fewer: enough for any real query and
11
12
  ## far short of the tree dump a query exists to avoid.
@@ -251,7 +252,7 @@ func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) ->
251
252
  # What this node says, rather than everything said underneath it. A row is then found by the
252
253
  # label in it, and the path answered is that label's, which is where the words a caller is
253
254
  # looking at actually are: matching every container above it would answer with the screen.
254
- if not wanted["says"].is_empty() and not _says(said_by(node), wanted["says"]):
255
+ if not wanted["says"].is_empty() and not _says(Words.said_by(node), wanted["says"]):
255
256
  return false
256
257
  var script: Variant = node.get_script()
257
258
  if not wanted["script"].is_empty():
@@ -346,8 +347,7 @@ func _read_into(node: Node, include_hidden: bool, most: int, into: Array[String]
346
347
  if not include_hidden and not _drawn(node):
347
348
  return 0
348
349
  var left_out: int = 0
349
- var said: String = said_by(node)
350
- if not said.is_empty():
350
+ for said: String in Words.lines_said_by(node):
351
351
  if into.size() < most:
352
352
  into.append(said)
353
353
  else:
@@ -373,25 +373,6 @@ static func _drawn(node: Node) -> bool:
373
373
  return window == null or window.visible
374
374
 
375
375
 
376
- ## What one node says, or "" for a node that says nothing. Anything with a `text` property, which
377
- ## is every label, button and field the interface is built out of.
378
- ##
379
- ## Public because three questions are the same question: what a screen reads as, which nodes say a
380
- ## given word, and whether anything has come to say it yet. Two copies of what a node says is how
381
- ## the three of them come to disagree about a SpinBox.
382
- ##
383
- ## Read rather than looked up in the property list, which the engine builds afresh on every call: a
384
- ## wait asks this of every node on the screen every frame, and on a hall of 3,500 nodes the lookup
385
- ## took the game it was watching from 60 frames a second to 11. A node without the property reads
386
- ## as null, which is not a string.
387
- static func said_by(node: Node) -> String:
388
- var text: Variant = node.get("text")
389
- if not text is String:
390
- return ""
391
- var words: String = text
392
- return words.strip_edges()
393
-
394
-
395
376
  func get_rect(params: Dictionary) -> Dictionary:
396
377
  var node_path: String = str(params.get("path", ""))
397
378
  if node_path.is_empty():
@@ -7,6 +7,7 @@ const Paths = preload("runtime_paths.gd")
7
7
  const Queries = preload("runtime_queries.gd")
8
8
  const Read = preload("reading.gd")
9
9
  const Values = preload("runtime_values.gd")
10
+ const Words = preload("runtime_words.gd")
10
11
 
11
12
  ## The longest one wait may last, whatever the request says: past this the server has long
12
13
  ## since given up on the reply.
@@ -291,7 +292,7 @@ func _anything_says(node_path: String, said: String, include_hidden: bool) -> bo
291
292
  var pending: Array[Node] = [root]
292
293
  while not pending.is_empty():
293
294
  var node: Node = pending.pop_back()
294
- if Queries.said_by(node).containsn(said) and (include_hidden or Queries.shown(node)):
295
+ if Words.said_by(node).containsn(said) and (include_hidden or Queries.shown(node)):
295
296
  return true
296
297
  pending.append_array(node.get_children(true))
297
298
  return false
@@ -0,0 +1,163 @@
1
+ extends RefCounted
2
+
3
+ ## What a node says to a player, read the way it is drawn: the parsed words of rich text, as much of
4
+ ## a line as is typed out so far, the translation rather than its key, a mask over a secret, and the
5
+ ## items of the controls that draw a list.
6
+ ##
7
+ ## One module because three questions are the same question: what a screen reads as, which nodes say
8
+ ## a given word, and whether anything has come to say it yet. Two copies of what a node says is how
9
+ ## the three of them come to disagree about a SpinBox.
10
+
11
+
12
+ ## What one node says, or "" for a node that says nothing. Anything with a `text` property, which
13
+ ## is every label, button and field the interface is built out of.
14
+ ##
15
+ ## Read rather than looked up in the property list, which the engine builds afresh on every call: a
16
+ ## wait asks this of every node on the screen every frame, and on a hall of 3,500 nodes the lookup
17
+ ## took the game it was watching from 60 frames a second to 11. A node without the property reads
18
+ ## as null, which is not a string.
19
+ ##
20
+ ## A label is asked about first and read directly, because a screen is mostly labels and this runs
21
+ ## for each of them on every look a wait takes.
22
+ static func said_by(node: Node) -> String:
23
+ var label: Label = node as Label
24
+ if label != null:
25
+ return _label_says(label)
26
+ if _draws_a_list(node):
27
+ return "\n".join(lines_said_by(node))
28
+ return _text_of(node)
29
+
30
+
31
+ ## Every line [param node] draws for a player to read, one to an entry: a label's one line, or each
32
+ ## item of a control that draws a list of them. Tab titles, list items, tree rows, menu titles and
33
+ ## the items of an open menu are held by the control rather than in any `text`, so a screen read as
34
+ ## every label on it and none of those, and a wait for a tab's title or a list item was never met.
35
+ static func lines_said_by(node: Node) -> Array[String]:
36
+ var lines: Array[String] = []
37
+ if node is TabBar:
38
+ var bar: TabBar = node
39
+ for tab: int in bar.tab_count:
40
+ if not bar.is_tab_hidden(tab):
41
+ _keep(node.atr(bar.get_tab_title(tab)), lines)
42
+ elif node is MenuBar:
43
+ var menus: MenuBar = node
44
+ for menu: int in menus.get_menu_count():
45
+ if not menus.is_menu_hidden(menu):
46
+ _keep(node.atr(menus.get_menu_title(menu)), lines)
47
+ elif node is ItemList:
48
+ var list: ItemList = node
49
+ for item: int in list.item_count:
50
+ _keep(_as_drawn(node, list.get_item_text(item), list.get_item_auto_translate_mode(item)), lines)
51
+ elif node is PopupMenu:
52
+ var menu: PopupMenu = node
53
+ for item: int in menu.item_count:
54
+ if not menu.is_item_separator(item):
55
+ _keep(item_says(menu, item), lines)
56
+ elif node is Tree:
57
+ var tree: Tree = node
58
+ if tree.column_titles_visible:
59
+ var titles: Array[String] = []
60
+ for column: int in tree.columns:
61
+ _keep(node.atr(tree.get_column_title(column)), titles)
62
+ _keep(" ".join(titles), lines)
63
+ _tree_rows(tree, tree.get_root(), tree.hide_root, lines)
64
+ else:
65
+ _keep(_text_of(node), lines)
66
+ return lines
67
+
68
+
69
+ ## What item [param index] of [param menu] shows: the translation where the item has one. A menu is
70
+ ## chosen from by these words, since they are the ones a caller has read off the screen.
71
+ static func item_says(menu: PopupMenu, index: int) -> String:
72
+ return _as_drawn(menu, menu.get_item_text(index), menu.get_item_auto_translate_mode(index))
73
+
74
+
75
+ ## What a secret [param field] draws: one mask character for each character it holds. Reading the
76
+ ## words back would hand a password to whoever reads the answer, which the player cannot see either.
77
+ static func masked(field: LineEdit) -> String:
78
+ return field.secret_character.repeat(field.text.length())
79
+
80
+
81
+ static func _draws_a_list(node: Node) -> bool:
82
+ return node is TabBar or node is MenuBar or node is ItemList or node is PopupMenu or node is Tree
83
+
84
+
85
+ static func _keep(line: String, into: Array[String]) -> void:
86
+ var trimmed: String = line.strip_edges()
87
+ if not trimmed.is_empty():
88
+ into.append(trimmed)
89
+
90
+
91
+ ## [param words] as [param node] draws them, which for a game with translations is the translation
92
+ ## rather than the key it holds: a control set to `UI_HIRE` shows "Hire", and a wait for "Hire" was
93
+ ## never met. [param mode] is an item's own setting, which the engine reads before the node's.
94
+ static func _as_drawn(
95
+ node: Node, words: String, mode: Node.AutoTranslateMode = Node.AUTO_TRANSLATE_MODE_INHERIT
96
+ ) -> String:
97
+ match mode:
98
+ Node.AUTO_TRANSLATE_MODE_ALWAYS:
99
+ return node.tr(words)
100
+ Node.AUTO_TRANSLATE_MODE_DISABLED:
101
+ return words
102
+ return node.atr(words)
103
+
104
+
105
+ ## The rows of a tree from [param item] down, a row being its columns' text side by side, and
106
+ ## nothing under a collapsed row or a hidden one, since neither is drawn. A hidden root is only the
107
+ ## place its children hang from.
108
+ static func _tree_rows(tree: Tree, item: TreeItem, skip: bool, into: Array[String]) -> void:
109
+ if item == null or not item.visible:
110
+ return
111
+ if not skip:
112
+ var cells: Array[String] = []
113
+ for column: int in tree.columns:
114
+ _keep(_as_drawn(tree, item.get_text(column), item.get_auto_translate_mode(column)), cells)
115
+ _keep(" ".join(cells), into)
116
+ if item.collapsed:
117
+ return
118
+ var child: TreeItem = item.get_first_child()
119
+ while child != null:
120
+ _tree_rows(tree, child, false, into)
121
+ child = child.get_next()
122
+
123
+
124
+ ## What a node with a `text` says, as it is drawn.
125
+ static func _text_of(node: Node) -> String:
126
+ var label: Label = node as Label
127
+ if label != null:
128
+ return _label_says(label)
129
+ # A RichTextLabel's text is its markup when it reads BBCode, and nobody reads the tags: a
130
+ # dossier line came back as "[b][color=#4fc2d4]Mollum Telken[/color], [/b]..." and a phrase
131
+ # running across a tag could not be found or waited for. The parsed text is what is drawn, and
132
+ # it also holds what a game added with append_text(), which the property never shows. It is
133
+ # parsed from the translation already.
134
+ if node is RichTextLabel:
135
+ var rich: RichTextLabel = node
136
+ return _shown_part(rich.get_parsed_text(), rich.visible_characters)
137
+ var text: Variant = node.get("text")
138
+ if not text is String:
139
+ return ""
140
+ var words: String = text
141
+ # What somebody typed is drawn as typed, never translated.
142
+ if node is LineEdit:
143
+ var field: LineEdit = node
144
+ return masked(field) if field.secret else words.strip_edges()
145
+ if node is TextEdit:
146
+ return words.strip_edges()
147
+ words = node.atr(words)
148
+ if node is Label3D:
149
+ var sign_text: Label3D = node
150
+ return (words.to_upper() if sign_text.uppercase else words).strip_edges()
151
+ return words.strip_edges()
152
+
153
+
154
+ static func _label_says(label: Label) -> String:
155
+ var words: String = label.atr(label.text)
156
+ return _shown_part(words.to_upper() if label.uppercase else words, label.visible_characters)
157
+
158
+
159
+ ## As much of [param words] as is drawn: a label typing a line out shows [param drawn] characters
160
+ ## of it, and -1 is all of them. Read whole, a words wait was met on the first frame of a line being
161
+ ## typed out, before the player could read any of it.
162
+ static func _shown_part(words: String, drawn: int) -> String:
163
+ return (words if drawn < 0 else words.substr(0, drawn)).strip_edges()
@@ -0,0 +1 @@
1
+ uid://u3ml7sk81frh
package/build/index.js CHANGED
@@ -30316,7 +30316,7 @@ var TOOL_SPECS = [
30316
30316
  },
30317
30317
  {
30318
30318
  name: "editor_run",
30319
- description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for. A start this server made also answers with transcript, the file both the run's streams are written to, so a watch on it can be armed off the start rather than off a second call.",
30319
+ description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. start and check both wait first for a scan the open editor is running and for the class cache it writes just after, since a game booted in between resolves no global class, as does every other engine this server starts on the project; waitedForEditorScanMs says how long, and scanNote says when a scan ran past thirty seconds and the game started during it. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for. A start this server made also answers with transcript, the file both the run's streams are written to, so a watch on it can be armed off the start rather than off a second call.",
30320
30320
  parameters: {
30321
30321
  projectPath: { ...PROJECT_PATH, ops: ["start", "check"] },
30322
30322
  scene: {
@@ -30433,7 +30433,7 @@ var TOOL_SPECS = [
30433
30433
  },
30434
30434
  {
30435
30435
  name: "editor_status",
30436
- 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. 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.",
30436
+ 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.",
30437
30437
  parameters: {},
30438
30438
  requires: []
30439
30439
  },
@@ -30545,7 +30545,7 @@ var TOOL_SPECS = [
30545
30545
  says: {
30546
30546
  type: "string",
30547
30547
  ops: ["find"],
30548
- description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one."
30548
+ description: "find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one."
30549
30549
  },
30550
30550
  limit: {
30551
30551
  type: "integer",
@@ -30574,7 +30574,7 @@ var TOOL_SPECS = [
30574
30574
  operations: {
30575
30575
  tree: { summary: "the live scene tree", requires: [] },
30576
30576
  text: {
30577
- summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it",
30577
+ summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it. Every line reads as drawn: rich text without its tags, text only as far as it has been typed out, a translation rather than its key, an upper-case label in capitals and a secret field as its mask. Tab titles, menu bar titles, list items, a tree's column titles and rows, and an open menu's items read a line each",
30578
30578
  requires: []
30579
30579
  },
30580
30580
  find: {
@@ -30723,7 +30723,7 @@ var TOOL_SPECS = [
30723
30723
  type: "string",
30724
30724
  ops: ["text", "choose"],
30725
30725
  blank: true,
30726
- description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says.'
30726
+ description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says as drawn, which in a game with translations is the translation; the key it holds is matched after that.'
30727
30727
  },
30728
30728
  replace: {
30729
30729
  type: "boolean",
@@ -30785,7 +30785,7 @@ var TOOL_SPECS = [
30785
30785
  requires: ["keycode"]
30786
30786
  },
30787
30787
  text: {
30788
- summary: "type a string into the field being edited, a character at a time, and say what it landed in",
30788
+ 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",
30789
30789
  requires: ["text"]
30790
30790
  },
30791
30791
  mouse_click: {
@@ -31263,6 +31263,34 @@ var SUCCESSOR_CHECK_MS = 1e4;
31263
31263
  var HANDOVER_MS = SUCCESSOR_CHECK_MS + 5000;
31264
31264
  var HANDOVER_POLL_MS = 250;
31265
31265
  var CACHE_WRITE_MS = 2000;
31266
+ var SCAN_WAIT_MS = 30000;
31267
+ function scanWaitIn(payload) {
31268
+ const waited = readNumber(payload, "waitedForEditorScanMs");
31269
+ const note = readString(payload, "scanNote");
31270
+ return {
31271
+ ...waited === undefined ? {} : { waitedForEditorScanMs: waited },
31272
+ ...note === undefined ? {} : { scanNote: note }
31273
+ };
31274
+ }
31275
+ function withScanWait(outcome, scanned) {
31276
+ if (scanned.waitedMs === 0) {
31277
+ return outcome;
31278
+ }
31279
+ const said = scanWaitAnswer(scanned);
31280
+ if (outcome.ok) {
31281
+ return { ...outcome, payload: { ...outcome.payload, ...said } };
31282
+ }
31283
+ return said.scanNote === undefined ? outcome : { ...outcome, message: `${outcome.message} ${said.scanNote}` };
31284
+ }
31285
+ function scanWaitAnswer(scanned) {
31286
+ if (scanned.waitedMs === 0) {
31287
+ return {};
31288
+ }
31289
+ return {
31290
+ waitedForEditorScanMs: scanned.waitedMs,
31291
+ 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
31292
+ };
31293
+ }
31266
31294
  var PLAYING_STATUS_MS = 1000;
31267
31295
  var SCAN_START_MS = 2000;
31268
31296
  var WAIT_FOR_RUN_MS = 600000;
@@ -32230,6 +32258,47 @@ class GodotServer {
32230
32258
  }
32231
32259
  return { ok: true, value: stale };
32232
32260
  }
32261
+ async untilTheEditorsScanIsWritten(projectPath) {
32262
+ const open = this.godotBridge.getStatus().projectPath;
32263
+ if (!this.godotBridge.isConnected() || open === undefined || !isSameDirectory(open, projectPath)) {
32264
+ return { waitedMs: 0, stillScanning: false };
32265
+ }
32266
+ const started = Date.now();
32267
+ let sawAScan = false;
32268
+ let scanEndedAt = null;
32269
+ for (;; ) {
32270
+ const asked = Date.now();
32271
+ let status;
32272
+ try {
32273
+ status = asParams(await this.godotBridge.invokeTool("scan_status", {}));
32274
+ } catch {
32275
+ return { waitedMs: Date.now() - started, stillScanning: false };
32276
+ }
32277
+ if (status["scanning"] === true || status["importing"] === true || status["pending"] === true) {
32278
+ sawAScan = true;
32279
+ if (Date.now() - started >= SCAN_WAIT_MS) {
32280
+ return { waitedMs: Date.now() - started, stillScanning: true };
32281
+ }
32282
+ await new Promise((settle) => setTimeout(settle, 100));
32283
+ continue;
32284
+ }
32285
+ const since = readNumber(status, "sinceScanFinishedMs");
32286
+ scanEndedAt = since !== undefined && since >= 0 ? asked - since : sawAScan ? asked : null;
32287
+ break;
32288
+ }
32289
+ let waitedForTheWrite = false;
32290
+ if (scanEndedAt !== null) {
32291
+ const writeDue = scanEndedAt + CACHE_WRITE_MS;
32292
+ while (Date.now() < writeDue && (cacheWrittenAt(projectPath) ?? 0) < scanEndedAt) {
32293
+ waitedForTheWrite = true;
32294
+ await new Promise((settle) => setTimeout(settle, 50));
32295
+ }
32296
+ }
32297
+ return {
32298
+ waitedMs: sawAScan || waitedForTheWrite ? Date.now() - started : 0,
32299
+ stillScanning: false
32300
+ };
32301
+ }
32233
32302
  containProjectFiles(args) {
32234
32303
  const projectPath = readString(args, "projectPath");
32235
32304
  if (!projectPath) {
@@ -32290,14 +32359,15 @@ class GodotServer {
32290
32359
  return engine.response;
32291
32360
  }
32292
32361
  const projectPath = project.value.path;
32362
+ const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
32293
32363
  const before = scriptsWithoutUid(projectPath);
32294
32364
  const imported = await runImport(engine.value, projectPath);
32295
32365
  if (!imported.ok) {
32296
- return this.answer(imported);
32366
+ return this.answer(withScanWait(imported, scanned));
32297
32367
  }
32298
32368
  const after = scriptsWithoutUid(projectPath);
32299
32369
  const given = before.filter((script) => !after.includes(script));
32300
- return this.answer({
32370
+ return this.answer(withScanWait({
32301
32371
  ok: true,
32302
32372
  messages: imported.messages,
32303
32373
  payload: {
@@ -32305,7 +32375,7 @@ class GodotServer {
32305
32375
  stillWithoutUid: after,
32306
32376
  note: uidsLeftNote(after.length)
32307
32377
  }
32308
- });
32378
+ }, scanned));
32309
32379
  }
32310
32380
  async operation(operation, params, projectPath) {
32311
32381
  const engine = await this.engine();
@@ -32316,8 +32386,10 @@ class GodotServer {
32316
32386
  messages: []
32317
32387
  };
32318
32388
  }
32389
+ const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
32319
32390
  this.logDebug(`Running ${operation} in ${projectPath}: ${JSON.stringify(params)}`);
32320
- return await runOperation({ godotPath: engine.value, script: this.operationsScript, debug: GODOT_DEBUG_MODE_DEFAULT }, operation, params, projectPath);
32391
+ const outcome = await runOperation({ godotPath: engine.value, script: this.operationsScript, debug: GODOT_DEBUG_MODE_DEFAULT }, operation, params, projectPath);
32392
+ return withScanWait(outcome, scanned);
32321
32393
  }
32322
32394
  answer(outcome) {
32323
32395
  if (!outcome.ok) {
@@ -32499,6 +32571,7 @@ class GodotServer {
32499
32571
  if (!classes.ok) {
32500
32572
  return this.answer(classes);
32501
32573
  }
32574
+ const scanned = scanWaitIn(classes.payload);
32502
32575
  const ours = `gdharness-reports/${randomUUID2()}`;
32503
32576
  const reports = `res://.godot/${ours}`;
32504
32577
  const ignored = readStringArray(args, "ignore") ?? [];
@@ -32589,7 +32662,8 @@ class GodotServer {
32589
32662
  hung,
32590
32663
  arguments: cmdArgs,
32591
32664
  entries: forAnswer(printed.slice(0, 60)),
32592
- savesNote
32665
+ savesNote,
32666
+ ...scanned
32593
32667
  }, null, 2)
32594
32668
  }
32595
32669
  ],
@@ -32624,6 +32698,7 @@ class GodotServer {
32624
32698
  orphans: orphans.total > 0 ? orphans.total : undefined,
32625
32699
  notRun: notRun > 0 ? notRun : undefined,
32626
32700
  savesNote,
32701
+ ...scanned,
32627
32702
  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,
32628
32703
  suites: unclean.map((suite) => ({
32629
32704
  name: suite.name,
@@ -32922,6 +32997,28 @@ class GodotServer {
32922
32997
  return null;
32923
32998
  }
32924
32999
  }
33000
+ async editorScanState() {
33001
+ if (!this.godotBridge.getStatus().connected) {
33002
+ return null;
33003
+ }
33004
+ try {
33005
+ const answer = await Promise.race([
33006
+ this.godotBridge.invokeTool("scan_status", {}),
33007
+ delay3(PLAYING_STATUS_MS).then(() => null)
33008
+ ]);
33009
+ if (answer === null) {
33010
+ return null;
33011
+ }
33012
+ const since = readNumber(asParams(answer), "sinceScanFinishedMs");
33013
+ return {
33014
+ scanning: readBoolean(asParams(answer), "scanning") ?? false,
33015
+ importing: readBoolean(asParams(answer), "importing") ?? false,
33016
+ finishedMsAgo: since === undefined || since < 0 ? null : since
33017
+ };
33018
+ } catch {
33019
+ return null;
33020
+ }
33021
+ }
32925
33022
  async handleEditorStatus() {
32926
33023
  const located = await this.locator.find();
32927
33024
  const announced = runtimesAnnounced();
@@ -32952,11 +33049,16 @@ class GodotServer {
32952
33049
  hold: current === null ? {} : await this.holdOf(current, active)
32953
33050
  };
32954
33051
  })();
32955
- const [games, { playing, active, hold }] = await Promise.all([pinged, asked]);
33052
+ const [games, { playing, active, hold }, scan] = await Promise.all([
33053
+ pinged,
33054
+ asked,
33055
+ this.editorScanState()
33056
+ ]);
32956
33057
  return this.jsonTextResponse({
32957
33058
  editor: {
32958
33059
  ...this.getEditorStatusPayload(),
32959
- debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
33060
+ debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort,
33061
+ scan
32960
33062
  },
32961
33063
  godot: {
32962
33064
  path: located.ok ? located.path : null,
@@ -33177,13 +33279,14 @@ class GodotServer {
33177
33279
  if (!engine.ok) {
33178
33280
  return engine.response;
33179
33281
  }
33282
+ const scanned = await this.untilTheEditorsScanIsWritten(project.value.path);
33180
33283
  const refreshed = await this.refreshStaleClasses(project.value.path);
33181
33284
  if (!refreshed.ok) {
33182
33285
  return refreshed.response;
33183
33286
  }
33184
33287
  const sceneArgument = sceneToRun?.ok ? sceneToRun.relativePath : null;
33185
33288
  if (op === "check") {
33186
- return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, asked.value);
33289
+ return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, scanned, asked.value);
33187
33290
  }
33188
33291
  await this.pickUpWhatTheEditorIsPlaying();
33189
33292
  let ended = null;
@@ -33202,7 +33305,7 @@ class GodotServer {
33202
33305
  const runtimeWaitMs = this.announceBudget(args, project.value.path);
33203
33306
  const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
33204
33307
  if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0 && !needsItsOwnProcess(asked.value)) {
33205
- return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
33308
+ return await this.playThroughEditor(sceneArgument, refreshed.value, scanned, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
33206
33309
  }
33207
33310
  const cmdArgs = runArguments({
33208
33311
  projectPath: project.value.path,
@@ -33227,6 +33330,7 @@ class GodotServer {
33227
33330
  arguments: cmdArgs,
33228
33331
  transcript: started.transcript,
33229
33332
  refreshedClasses: refreshed.value,
33333
+ ...scanWaitAnswer(scanned),
33230
33334
  savesNote: asked.value.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
33231
33335
  endedPreviousRun: endedPreviousRun(ended),
33232
33336
  runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
@@ -33308,7 +33412,7 @@ class GodotServer {
33308
33412
  writeBootNote(run.projectPath, took);
33309
33413
  }
33310
33414
  }
33311
- async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
33415
+ async playThroughEditor(scene, refreshedClasses, scanned, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
33312
33416
  const log = new GameLog;
33313
33417
  try {
33314
33418
  await this.dap().connect();
@@ -33359,6 +33463,7 @@ class GodotServer {
33359
33463
  through: "editor",
33360
33464
  scene: scene === null ? "the main scene" : `res://${scene}`,
33361
33465
  refreshedClasses,
33466
+ ...scanWaitAnswer(scanned),
33362
33467
  debugPort: readNumber(playAnswer, "debugPort"),
33363
33468
  endedPreviousRun: endedPreviousRun(ended),
33364
33469
  breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
@@ -33862,7 +33967,7 @@ class GodotServer {
33862
33967
  currentRun() {
33863
33968
  return this.activeProcess ?? this.adoptRecordedRun();
33864
33969
  }
33865
- async checkBoot(godotPath, projectPath, scene, args, userArgs, asked = {}) {
33970
+ async checkBoot(godotPath, projectPath, scene, args, userArgs, scanned, asked = {}) {
33866
33971
  const frames = readPositiveNumber(args, "frames") ?? 3;
33867
33972
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
33868
33973
  const cmdArgs = runArguments({ projectPath, headless: true, scene, quitAfter: frames, userArgs });
@@ -33892,6 +33997,7 @@ class GodotServer {
33892
33997
  frames,
33893
33998
  errors,
33894
33999
  warnings,
34000
+ ...scanWaitAnswer(scanned),
33895
34001
  savesNote: asked.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
33896
34002
  entries: forAnswer(boot.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
33897
34003
  });
@@ -34275,6 +34381,10 @@ class GodotServer {
34275
34381
  return now === null ? [] : lost.filter((name) => now.has(name));
34276
34382
  }
34277
34383
  async rebuildClassCache(projectPath) {
34384
+ const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
34385
+ return withScanWait(await this.rebuildClassCacheFromDisk(projectPath), scanned);
34386
+ }
34387
+ async rebuildClassCacheFromDisk(projectPath) {
34278
34388
  const before = cachedClasses(projectPath);
34279
34389
  const writtenBefore = cacheWrittenAt(projectPath);
34280
34390
  const noted = readClassNote(projectPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
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",