gdharness 1.0.28 → 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
  },
@@ -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
+ }
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
  },
@@ -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.28",
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",