gdharness 1.3.28 → 1.3.30

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
@@ -10176,6 +10176,36 @@ function walkStoppedPartWay(lines) {
10176
10176
  }
10177
10177
  return began;
10178
10178
  }
10179
+ function heldAtExit(lines) {
10180
+ const objects = new Map;
10181
+ const inUse = [];
10182
+ for (const line of lines) {
10183
+ const object = LEAKED_INSTANCE.exec(line.trim())?.[1];
10184
+ if (object !== undefined) {
10185
+ objects.set(object, (objects.get(object) ?? 0) + 1);
10186
+ }
10187
+ const resource = STILL_IN_USE.exec(line.trim())?.[1];
10188
+ if (resource !== undefined) {
10189
+ inUse.push(resource);
10190
+ }
10191
+ }
10192
+ if (objects.size === 0 && inUse.length === 0) {
10193
+ return;
10194
+ }
10195
+ const counted = [...objects].sort(([a, many], [b, more]) => more - many || a.localeCompare(b));
10196
+ const objectsSaid = counted.map(([name, many]) => `${String(many)} ${name}`);
10197
+ const ownFirst = [
10198
+ ...inUse.filter((path) => !path.startsWith("res://addons/")),
10199
+ ...inUse.filter((path) => path.startsWith("res://addons/"))
10200
+ ];
10201
+ const named = ownFirst.slice(0, IN_USE_NAMED);
10202
+ const more = ownFirst.length - named.length;
10203
+ const parts = [
10204
+ objectsSaid.length === 0 ? undefined : `objects left alive: ${objectsSaid.join(", ")}`,
10205
+ named.length === 0 ? undefined : `resources still in use: ${named.join(", ")}${more > 0 ? `, and ${String(more)} more` : ""}`
10206
+ ].filter((part) => part !== undefined);
10207
+ return `The engine named them: ${parts.join("; ")}.`;
10208
+ }
10179
10209
  function selfTypedExports(sources) {
10180
10210
  const found = [];
10181
10211
  for (const [script, source] of sources) {
@@ -10208,11 +10238,13 @@ function selfTypedSentence(found) {
10208
10238
  const one = found.length === 1;
10209
10239
  return `Godot never frees a script exporting an array or dictionary typed with its own class once a run ` + `has loaded it, and reports it as leaked at exit: ${named} ${one ? "does" : "do"}, so a run loading ` + `${one ? "it" : "one"} ends with this report whatever else it frees. Typed with a class it extends ` + `instead, as Array[Resource], ${one ? "it is" : "each is"} freed.`;
10210
10240
  }
10211
- var EXIT_LEAK, WALK_BEGAN = "gdharness: the engine's shutdown began clearing scripts", WALK_ENDED = "gdharness: the engine's shutdown cleared every script", WALK_STOPPED_SENTENCE, NAME2 = "[\\p{L}_][\\p{L}\\p{N}_]*", EXPORTED;
10241
+ var EXIT_LEAK, WALK_BEGAN = "gdharness: the engine's shutdown began clearing scripts", WALK_ENDED = "gdharness: the engine's shutdown cleared every script", LEAKED_INSTANCE, STILL_IN_USE, IN_USE_NAMED = 8, WALK_STOPPED_SENTENCE, NAME2 = "[\\p{L}_][\\p{L}\\p{N}_]*", EXPORTED;
10212
10242
  var init_engine_leaks = __esm(() => {
10213
10243
  init_class_cache();
10214
10244
  init_gdscript_source();
10215
10245
  EXIT_LEAK = /ObjectDB instances? (?:was |were )?leaked at exit|resources? still in use at exit|Pages in use exist at exit|RIDs? of type ["'][^"']+["'] (?:was|were) leaked/;
10246
+ LEAKED_INSTANCE = /^Leaked instance: (\w+):/;
10247
+ STILL_IN_USE = /^Resource still in use: (.+?) \(\w+\)$/;
10216
10248
  WALK_STOPPED_SENTENCE = "The engine's shutdown stopped part way through its scripts (godot#117991, fixed in 4.8 by " + "godot#120976), so it reported as leaked at exit what it never got to free, and the report is the " + "engine's rather than the run's. gdharness holds every script loaded from a file past that walk, " + "which keeps it whole, so this one stopped on a script it could not hold, such as one made in code. " + "A leak of the run's own is not told apart in such a run.";
10217
10249
  EXPORTED = new RegExp(`@export(?!_(?:group|subgroup|category)\\b)\\w*(?:\\([^)]*\\))?\\s+(?:@${NAME2}(?:\\([^)]*\\))?\\s+)*` + `(?:static\\s+)?var\\s+(${NAME2})\\s*:\\s*(?:Array|Dictionary)\\s*\\[([^\\]]*)\\]`, "gu");
10218
10250
  });
@@ -11104,9 +11136,11 @@ function judgeEngine(printed, handed, end) {
11104
11136
  if (printed.leaks.length > 0 && walkStoppedPartWay(printed.lines)) {
11105
11137
  notes.push(`${WALK_STOPPED_SENTENCE} It said: ${printed.leaks.join(" ")}`);
11106
11138
  } else {
11139
+ const held = heldAtExit(printed.lines);
11107
11140
  printed.leaks.forEach((leak, at) => {
11108
11141
  const last = at === printed.leaks.length - 1;
11109
- faults.push(`the engine exited holding what it should have freed: ${leak}${last && selfTyped !== undefined ? ` ${selfTyped}` : ""}`);
11142
+ const after = last ? [held, selfTyped].filter((part) => part !== undefined) : [];
11143
+ faults.push(`the engine exited holding what it should have freed: ${[leak, ...after].join(" ")}`);
11110
11144
  });
11111
11145
  }
11112
11146
  const memoryExit = end.code !== null && MEMORY_EXITS.has(end.code >>> 0);
@@ -11734,12 +11768,9 @@ class EngineSlots {
11734
11768
  try {
11735
11769
  for (;; ) {
11736
11770
  signal?.throwIfAborted();
11737
- for (let slot = 0;slot < this.count; slot += 1) {
11738
- const lock = join8(this.directory, `${String(slot)}.lock`);
11739
- if (this.made(lock) || await this.takenOver(lock)) {
11740
- this.held.add(lock);
11741
- return lock;
11742
- }
11771
+ const lock = await this.takeFree();
11772
+ if (lock !== null) {
11773
+ return lock;
11743
11774
  }
11744
11775
  mark ??= this.waitMarked();
11745
11776
  const now = Date.now();
@@ -11760,6 +11791,17 @@ class EngineSlots {
11760
11791
  }
11761
11792
  }
11762
11793
  }
11794
+ async takeFree() {
11795
+ mkdirSync3(this.directory, { recursive: true });
11796
+ for (let slot = 0;slot < this.count; slot += 1) {
11797
+ const lock = join8(this.directory, `${String(slot)}.lock`);
11798
+ if (this.made(lock) || await this.takenOver(lock)) {
11799
+ this.held.add(lock);
11800
+ return lock;
11801
+ }
11802
+ }
11803
+ return null;
11804
+ }
11763
11805
  waitMarked() {
11764
11806
  const marks = join8(this.directory, WAITING_FOLDER);
11765
11807
  waitsMarked += 1;
@@ -12072,6 +12114,37 @@ function editorArguments(projectPath, ports, logFile, hidden, silent) {
12072
12114
  logFile
12073
12115
  ];
12074
12116
  }
12117
+ function startedAs(commandLine) {
12118
+ const words = commandLineWords(commandLine);
12119
+ const hidden = words.includes("--headless");
12120
+ const driver = words.indexOf("--audio-driver");
12121
+ return { hidden, silent: hidden || driver >= 0 && words[driver + 1] === "Dummy" };
12122
+ }
12123
+ function commandLineWords(line) {
12124
+ const words = [];
12125
+ let word = "";
12126
+ let inWord = false;
12127
+ let quoted = false;
12128
+ for (const character of line) {
12129
+ if (character === '"') {
12130
+ quoted = !quoted;
12131
+ inWord = true;
12132
+ } else if (!quoted && /\s/.test(character)) {
12133
+ if (inWord) {
12134
+ words.push(word);
12135
+ }
12136
+ word = "";
12137
+ inWord = false;
12138
+ } else {
12139
+ word += character;
12140
+ inWord = true;
12141
+ }
12142
+ }
12143
+ if (inWord) {
12144
+ words.push(word);
12145
+ }
12146
+ return words;
12147
+ }
12075
12148
  function userDataIn(home, variables = process.env) {
12076
12149
  const moved = ["APPDATA", "XDG_DATA_HOME"];
12077
12150
  const carried = {};
@@ -17229,19 +17302,19 @@ var init_tool_definitions = __esm(() => {
17229
17302
  {
17230
17303
  name: "editor_launch",
17231
17304
  title: "Open, restart or close the editor, or bind its debug adapter again",
17232
- description: "Opens the Godot editor on a project, or restarts or closes the one already connected, or binds its debug adapter again. close saves open scenes, quits the editor and waits for its process to go, which is the only way to end a hidden one short of killing it; an editor this server opened that has not connected yet has nothing of this server's to save, so close stops its process instead. open answers once the process has started, and the editor dials in after its import; a call needing the editor made before then waits for the one this server opened, for as long as a restart waits, and is refused naming it if it has not arrived. open refuses while an editor of the project is already running and has not reached this server, naming its pid, rather than putting a second on the project. The editor is an ordinary visible one on the desktop in use, and on Windows it is shown without being activated, so opening or reopening it does not take the keyboard from whatever the user is typing in. hidden: true opens it headless instead and, on Windows, on a desktop of its own, where the games it plays go too; a hidden editor serves the bridge, the language server and the debug adapter as a visible one does. A game a visible editor plays opens as a window on the desktop in use. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect, and nothing else here restarts one; it saves open scenes on the way out. An editor a server opened is started again by the server, visible unless hidden: true is passed again, once the old one has gone: it waits 90 seconds for that, or GDHARNESS_EDITOR_RESTART_TIMEOUT_MS, and an old editor still running after it is not replaced, since the new one would find its ports held. One opened by hand restarts itself, which only one with a window can do, because the engine hands back none of the arguments it was started with. Godot tries each server port once, as the editor starts, so a restart binds a debug adapter that came back without its port again, in the editor that came back. rebind does the same for the connected editor without restarting it, whoever opened it; a debug session open on the adapter ends with it and is opened again by the next debug call. editor_status says which editor is connected and whether it is holding an old addon. Opening a project is a save and so is closing one, which is the part that is not obvious from outside: the editor imports the project on open and writes project.godot back, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped either way. A restart of an editor this server opened is a quit and then a launch, and this server can be ended between them, usually by the reconnect the restart was being taken for: the editor is gone and nothing replaces it. That is written down before the quit, so the next server says so in editor_status rather than waiting for an editor that is not coming, and open finishes it. An open is refused when the editor it would start could never reach this server: a project other than the one this server serves, whose editor dials that project's own server; a project without the gdharness editor addon installed and enabled; and a project whose editor a restart is already bringing back.",
17233
- answer: "close answers settingsDropped, naming what its save took out of project.godot and left out when it took nothing, and settingsNote when the file could not be read to tell; an editor stopped before it connected is answered with beforeConnecting. close and restart name under savedScenes the open scenes that had unsaved changes and were saved on the way out, an empty list when none had; a scene that would not save refuses the close or restart and leaves the editor running. Both answer tookMs, how long the editor took to go or to come back. restart answers with the version that came back, and says under settingsDropped which keys its save dropped, because it waits for the editor to return. An open cannot, since it answers as soon as the process exists and the save happens during the import minutes later, so editor_status carries that reading once the editor has connected. Nothing else will say a key went until something depends on one. A restart answers lspListening and dapListening once the editor's servers are up. When either came back without its port, serversSaid gives the lines the editor's log panel has about its servers, the one place Godot says why a port would not bind, and when it was the debug adapter, which is bound again in the editor that came back, debugAdapterRebound says whether that took; neither is there when both came up. Godot clears that panel whenever a game is played, unless its run/output/always_clear_output_on_play setting is off, so after a play the lines from the editor's own start are gone. rebind answers rebound, with lspListening, dapListening and serversSaid, the lines that rebind made the panel print. A restart that a server's end interrupted shows as restartInterrupted in editor_status, and the open that finishes it says so under finishesRestart.",
17305
+ description: "Opens the Godot editor on a project, or restarts or closes the one already connected, or binds its debug adapter again. close saves open scenes, quits the editor and waits for its process to go, which is the only way to end a hidden one short of killing it; an editor this server opened that has not connected yet has nothing of this server's to save, so close stops its process instead. open answers once the process has started, and the editor dials in after its import; a call needing the editor made before then waits for the one this server opened, for as long as a restart waits, and is refused naming it if it has not arrived. open refuses while an editor of the project is already running and has not reached this server, naming its pid, rather than putting a second on the project. The editor is an ordinary visible one on the desktop in use, and on Windows it is shown without being activated, so opening or reopening it does not take the keyboard from whatever the user is typing in. hidden: true opens it headless instead and, on Windows, on a desktop of its own, where the games it plays go too; a hidden editor serves the bridge, the language server and the debug adapter as a visible one does. A game a visible editor plays opens as a window on the desktop in use. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect, and nothing else here restarts one; it saves open scenes on the way out. An editor a server opened is started again by the server, hidden or visible and silent or not as it was started unless hidden or silent says otherwise, once the old one has gone: it waits 90 seconds for that, or GDHARNESS_EDITOR_RESTART_TIMEOUT_MS, and an old editor still running after it is not replaced, since the new one would find its ports held. One opened by hand restarts itself, which only one with a window can do, because the engine hands back none of the arguments it was started with. Godot tries each server port once, as the editor starts, so a restart binds a debug adapter that came back without its port again, in the editor that came back. rebind does the same for the connected editor without restarting it, whoever opened it; a debug session open on the adapter ends with it and is opened again by the next debug call. editor_status says which editor is connected and whether it is holding an old addon. Opening a project is a save and so is closing one, which is the part that is not obvious from outside: the editor imports the project on open and writes project.godot back, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped either way. A restart of an editor this server opened is a quit and then a launch, and this server can be ended between them, usually by the reconnect the restart was being taken for: the editor is gone and nothing replaces it. That is written down before the quit, so the next server says so in editor_status rather than waiting for an editor that is not coming, and open finishes it. An open is refused when the editor it would start could never reach this server: a project other than the one this server serves, whose editor dials that project's own server; a project without the gdharness editor addon installed and enabled; and a project whose editor a restart is already bringing back.",
17306
+ answer: "close answers settingsDropped, naming what its save took out of project.godot and left out when it took nothing, and settingsNote when the file could not be read to tell; an editor stopped before it connected is answered with beforeConnecting. close and restart name under savedScenes the open scenes that had unsaved changes and were saved on the way out, an empty list when none had; a scene that would not save refuses the close or restart and leaves the editor running. Both answer tookMs, how long the editor took to go or to come back. restart answers with the version that came back, and hidden, whether it came back with no window, and says under settingsDropped which keys its save dropped, because it waits for the editor to return. An open cannot, since it answers as soon as the process exists and the save happens during the import minutes later, so editor_status carries that reading once the editor has connected. Nothing else will say a key went until something depends on one. A restart answers lspListening and dapListening once the editor's servers are up. When either came back without its port, serversSaid gives the lines the editor's log panel has about its servers, the one place Godot says why a port would not bind, and when it was the debug adapter, which is bound again in the editor that came back, debugAdapterRebound says whether that took; neither is there when both came up. Godot clears that panel whenever a game is played, unless its run/output/always_clear_output_on_play setting is off, so after a play the lines from the editor's own start are gone. rebind answers rebound, with lspListening, dapListening and serversSaid, the lines that rebind made the panel print. A restart that a server's end interrupted shows as restartInterrupted in editor_status, and the open that finishes it says so under finishesRestart.",
17234
17307
  parameters: {
17235
17308
  projectPath: { ...PROJECT_PATH, ops: ["open"] },
17236
17309
  hidden: {
17237
17310
  type: "boolean",
17238
17311
  ops: ["open", "restart"],
17239
- description: "Open the editor headless and, on Windows, on a desktop of its own, so no window of it or of the games it plays appears. Default false: one visible editor, shown without taking the focus. On restart only for an editor a server opened; one opened by hand comes back as it was, so hidden is refused there."
17312
+ description: "Open the editor headless and, on Windows, on a desktop of its own, so no window of it or of the games it plays appears. Default false on open: one visible editor, shown without taking the focus. On restart only for an editor a server opened, where the default is how the editor being replaced was started, read from its process; one whose start cannot be read is left running and refused until hidden is passed. One opened by hand comes back as it was, so hidden is refused there."
17240
17313
  },
17241
17314
  silent: {
17242
17315
  type: "boolean",
17243
17316
  ops: ["open", "restart"],
17244
- description: "Open the editor with no sound out of the machine, started with --audio-driver Dummy, so a tool script, a preview or a scene playing in its viewport is heard by nobody. Default true: pass false to hear it. The answer's arguments are what the editor was started with. The editor hands no audio driver to a game it plays, so editor_run's own silent decides that game's sound. A hidden editor is headless and silent either way. On restart only for an editor a server opened; one opened by hand comes back as it was."
17317
+ description: "Open the editor with no sound out of the machine, started with --audio-driver Dummy, so a tool script, a preview or a scene playing in its viewport is heard by nobody. Default true on open, and on restart how the editor being replaced was started: pass false to hear it. The answer's arguments are what the editor was started with. The editor hands no audio driver to a game it plays, so editor_run's own silent decides that game's sound. A hidden editor is headless and silent either way. On restart only for an editor a server opened; one opened by hand comes back as it was."
17245
17318
  }
17246
17319
  },
17247
17320
  requires: [],
@@ -17418,7 +17491,7 @@ var init_tool_definitions = __esm(() => {
17418
17491
  requires: []
17419
17492
  }
17420
17493
  },
17421
- answer: "start: through is gdharness for a game this server started itself, and editor for one the editor plays. scene is the scene the game started in, the project's main scene when none was given. runtime carries addonIsStale and staleNote when the game announced on a runtime addon other than this server's, which no runtime_* call is then sent to, and staleNote says what brings it into step. refreshedClasses names the class_name declarations on disk that the engine's class cache was missing, which were added to it before the game started so the game could resolve them; it is empty when the cache was current. stop: endedPid is, for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled, the note naming which property it failed on, and false with the number under stillGoing when what was told to end was still running ten seconds later. endedThrough says how the stop ended a run that was going: close (the game was asked to close and exited, so exitCode is its own), keeper (the process that started the game ended it through the handle it holds), signal (its pid was signalled once shown to still be the run), or editor (the editor playing it was asked). exitedBeforeStop is true when the run was already over when the stop came, so nothing was ended and exitCode, when anything collected it, is the one it ended with. A start that ends the run before it says how under endedPreviousRunThrough, with the same values, and one that refused to signal that run names the property it failed on under previousRunLeftBecause. check: hung is a run killed at timeoutMs, and errors and warnings count what it printed, which comes back under entries with repeats folded together. entries holds the errors and warnings and not the ordinary lines the run printed, which printedLinesNotShown counts; a check given --verbose holds those too, and editor_run start then editor_output reads everything. An engine crash is an error whatever it prints after: the crash handler's report is the entry, and the engine version and the native and GDScript backtraces are its detail, so a crashed run is never booted or clean. arguments is the engine command line the check ran, as a start's is, and durationMs runs from that engine's start to the check's answer, after its exit, so it holds no import that came first. start and check: " + IMPORTED_FIRST + " An exported build carries its own imports and is not asked. waitedForEditorScanMs and scanNote are left out when no editor scan was waited for. A silent windowed run has --audio-driver Dummy in its arguments; a headless run is silent without it, and one the editor plays with sound has no arguments of its own. profile: per function its script, the function (Class.name when the script declares a class_name), the line its body's first statement is on, for a lambda the named function it is written in (within), calls, and self and total milliseconds, with functionsCounted, scriptMs (the self time of them all) and coveredMs. The functions of gdharness's own runtime are not among them: the runtime addon's, which answer its tools inside the game, and those of res://gdharness/loader.gd, which starts it. Their self time is runtimeAddonMs, over runtimeAddonFunctions functions. complete is true when the engine's own totals arrived, which it sends only when the profiler is switched off: the runtime addon does that as a game quits, so a scene that does its work in _ready and quits is covered whole; otherwise the answer is the frames summed so far and the note says what that misses. frames counts the frames of the profile that arrived, and the engine's totals also hold the work after the last of them, so once complete a function run every frame can show a call more than frames. Self time counts a call into the engine's own methods as the caller's, and a call through super too, while the overridden function's row counts it again: such a row carries selfIncludesSuper, so self times along an override chain overlap. The profiler's own cost of timing a call lands in the self time of the function called; with the runtime addon it is measured each run on an empty function, as perCallOverheadUs, and a row with at least half its selfMs in it carries overheadMs, since a small function called often costs that much less without the profiler. A function the profiler started inside is left out, since its time would be counted from the engine's start. The milliseconds are time passed while the function ran, not processor time given to it, so they grow when other processes compete for the processor: compare two profiles taken under the same load. They are also summed over every thread: a function run by WorkerThreadPool or a Thread on several threads at once counts each thread's time, the engine does not say which thread a call ran on, and a function waiting for them has the wait in its own self time, so selfMs is not time taken from frames, and scriptMs can exceed coveredMs, which the note then says. A property's get and set blocks are named Class.@name_getter and Class.@name_setter, as the engine names them, and their rows carry property, the property's name. A game the editor plays that was not started with profile: true says, in the answer, that it was profiled from the moment it was asked. A profile with mark answers marked: true; one with since answers since: \"mark\" and markSetMsAgo, and lessThanAtMark counts the functions left out for reading less than at the mark, which the engine's own totals, capped, can do when they replace the frames' sum.",
17494
+ answer: "start: through is gdharness for a game this server started itself, and editor for one the editor plays. scene is the scene the game started in, the project's main scene when none was given. runtime carries addonIsStale and staleNote when the game announced on a runtime addon other than this server's, which no runtime_* call is then sent to, and staleNote says what brings it into step. refreshedClasses names the class_name declarations on disk that the engine's class cache was missing, which were added to it before the game started so the game could resolve them; it is empty when the cache was current. stop: endedPid is, for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled, the note naming which property it failed on, and false with the number under stillGoing when what was told to end was still running ten seconds later. endedThrough says how the stop ended a run that was going: close (the game was asked to close and exited, so exitCode is its own), keeper (the process that started the game ended it through the handle it holds), signal (its pid was signalled once shown to still be the run), or editor (the editor playing it was asked). exitedBeforeStop is true when the run was already over when the stop came, so nothing was ended and exitCode, when anything collected it, is the one it ended with. A start that ends the run before it says how under endedPreviousRunThrough, with the same values, and one that refused to signal that run names the property it failed on under previousRunLeftBecause. check: hung is a run killed at timeoutMs, and errors and warnings count what it printed, which comes back under entries with repeats folded together. entries holds the errors and warnings and not the ordinary lines the run printed, which printedLinesNotShown counts; a check given --verbose holds those too, and editor_run start then editor_output reads everything. An engine crash is an error whatever it prints after: the crash handler's report is the entry, and the engine version and the native and GDScript backtraces are its detail, so a crashed run is never booted or clean. arguments is the engine command line the check ran, as a start's is, and durationMs runs from that engine's start to the check's answer, after its exit, so it holds no import that came first. start and check: " + IMPORTED_FIRST + " An exported build carries its own imports and is not asked. waitedForEditorScanMs and scanNote are left out when no editor scan was waited for. A silent windowed run has --audio-driver Dummy in its arguments; a headless run is silent without it, and one the editor plays with sound has no arguments of its own. profile: per function its script, the function (Class.name when the script declares a class_name), the line its body's first statement is on, for a lambda the named function it is written in (within), calls, and self and total milliseconds, with functionsCounted, scriptMs (the self time of them all) and coveredMs. The functions of gdharness's own runtime are not among them: the runtime addon's, which answer its tools inside the game, and those of res://gdharness/loader.gd, which starts it. Their self time is runtimeAddonMs, over runtimeAddonFunctions functions. complete is true when the engine's own totals arrived, which it sends only when the profiler is switched off: the runtime addon does that as a game quits, so a scene that does its work in _ready and quits is covered whole; otherwise the answer is the frames summed so far and the note says what that misses. frames counts the frames of the profile that arrived, and the engine's totals also hold the work after the last of them, so once complete a function run every frame can show a call more than frames. firstFrame and lastFrame are the game's own numbers, counted from 0, for the first and the last of them, in a run this server started; a firstFrame above 0 is frames run before the profiler came on, and a gap between the two is frames that never arrived, which the note says. Self time counts a call into the engine's own methods as the caller's, and a call through super too, while the overridden function's row counts it again: such a row carries selfIncludesSuper, so self times along an override chain overlap. The profiler's own cost of timing a call lands in the self time of the function called; with the runtime addon it is measured each run on an empty function, as perCallOverheadUs, and a row with at least half its selfMs in it carries overheadMs, since a small function called often costs that much less without the profiler. A function the profiler started inside is left out, since its time would be counted from the engine's start. The milliseconds are time passed while the function ran, not processor time given to it, so they grow when other processes compete for the processor: compare two profiles taken under the same load. They are also summed over every thread: a function run by WorkerThreadPool or a Thread on several threads at once counts each thread's time, the engine does not say which thread a call ran on, and a function waiting for them has the wait in its own self time, so selfMs is not time taken from frames, and scriptMs can exceed coveredMs, which the note then says. A property's get and set blocks are named Class.@name_getter and Class.@name_setter, as the engine names them, and their rows carry property, the property's name. A game the editor plays that was not started with profile: true says, in the answer, that it was profiled from the moment it was asked. A profile with mark answers marked: true; one with since answers since: \"mark\" and markSetMsAgo, and lessThanAtMark counts the functions left out for reading less than at the mark, which the engine's own totals, capped, can do when they replace the frames' sum.",
17422
17495
  defaultOperation: "start"
17423
17496
  },
17424
17497
  {
@@ -17662,7 +17735,7 @@ var init_tool_definitions = __esm(() => {
17662
17735
  },
17663
17736
  find: {
17664
17737
  effect: "read",
17665
- summary: "the paths of every node matching className, script, namePattern, group or says, with property read off each, hidden ones included unless includeHidden says otherwise; with it false, a control drawn wholly outside the viewport or a ScrollContainer is left out too. A match drawn under a matching node saying the same words, as an outline or a glow copy is, names that node under copyOf, and the answer counts the rest under distinct",
17738
+ summary: "the paths of every node matching className, script, namePattern, group or says, with property read off each, hidden ones included unless includeHidden says otherwise; with it false, a control drawn wholly outside the viewport or a ScrollContainer is left out too. A match drawn under a matching node drawing the same words, as an outline or a glow copy is, names that node under copyOf (a name given only for a screen reader is not drawn, so a node holding one is never what a copy copies), and the answer counts the rest under distinct",
17666
17739
  requires: []
17667
17740
  },
17668
17741
  rect: {
@@ -18607,7 +18680,7 @@ function dealt(units, declared, most) {
18607
18680
  return [];
18608
18681
  }
18609
18682
  const windowed = units.filter((unit) => unit.windowed);
18610
- const headless = units.filter((unit) => !unit.windowed).sort((a, b) => b.cost - a.cost || a.suite.localeCompare(b.suite) || (a.cases[0] ?? "").localeCompare(b.cases[0] ?? "") || (a.row?.index ?? 0) - (b.row?.index ?? 0));
18683
+ const headless = units.filter((unit) => !unit.windowed).sort(slowestFirst);
18611
18684
  const together = windowed.reduce((sum, unit) => sum + unit.cost, 0);
18612
18685
  const restart = windowed.length > 0 ? ENGINE_START_SECONDS : 0;
18613
18686
  const total = units.reduce((sum, unit) => sum + unit.cost, 0);
@@ -18625,6 +18698,52 @@ function dealt(units, declared, most) {
18625
18698
  ...bins.flatMap((bin, lane) => bin.length === 0 ? [] : [planOf(bin, declared, false, lane, units)])
18626
18699
  ];
18627
18700
  }
18701
+ function slowestFirst(a, b) {
18702
+ return b.cost - a.cost || a.suite.localeCompare(b.suite) || (a.cases[0] ?? "").localeCompare(b.cases[0] ?? "") || (a.row?.index ?? 0) - (b.row?.index ?? 0);
18703
+ }
18704
+
18705
+ class Shares {
18706
+ windowed;
18707
+ headless;
18708
+ declared;
18709
+ all;
18710
+ lanes;
18711
+ constructor(units, declared, lanes) {
18712
+ this.windowed = units.filter((unit) => unit.windowed);
18713
+ this.headless = units.filter((unit) => !unit.windowed).sort(slowestFirst);
18714
+ this.declared = declared;
18715
+ this.all = units;
18716
+ this.lanes = Math.max(1, lanes);
18717
+ }
18718
+ get empty() {
18719
+ return this.windowed.length === 0 && this.headless.length === 0;
18720
+ }
18721
+ next(lane) {
18722
+ if (this.windowed.length > 0) {
18723
+ const windowed = this.windowed;
18724
+ this.windowed = [];
18725
+ return planOf(windowed, this.declared, true, lane, this.all);
18726
+ }
18727
+ if (this.headless.length === 0) {
18728
+ return null;
18729
+ }
18730
+ const left = this.headless.reduce((sum, unit) => sum + unit.cost, 0);
18731
+ const target = Math.max(SHARE_FLOOR_SECONDS, left / this.lanes);
18732
+ const share = [];
18733
+ const rest = [];
18734
+ let cost = 0;
18735
+ for (const unit of this.headless) {
18736
+ if (share.length === 0 || cost + unit.cost <= target) {
18737
+ share.push(unit);
18738
+ cost += unit.cost;
18739
+ } else {
18740
+ rest.push(unit);
18741
+ }
18742
+ }
18743
+ this.headless = rest;
18744
+ return planOf(share, this.declared, false, lane, this.all);
18745
+ }
18746
+ }
18628
18747
  function lanesOf(plans) {
18629
18748
  return new Set(plans.map((plan) => plan.lane)).size;
18630
18749
  }
@@ -18695,7 +18814,7 @@ function viewportOf(projectPath) {
18695
18814
  const height = /^window\/size\/viewport_height\s*=\s*(\d+)/m.exec(text)?.[1];
18696
18815
  return width === undefined || height === undefined ? null : { width: Number(width), height: Number(height) };
18697
18816
  }
18698
- var SUITE_BASE = "GdUnitTestSuite", CASE_PREFIX = "test_", NEEDS_WINDOW, TEST_DIRECTORIES, SCENE_RUNNER, NOT_SEARCHED_SETTING = "gdharness/tests/not_searched", GUESS_WINDOW_SETTING = "gdharness/tests/scene_runner_needs_window", UNTIMED_CASE = 0.02, SPLIT_PAST_SECONDS = 10, ENGINE_START_SECONDS = 5;
18817
+ var SUITE_BASE = "GdUnitTestSuite", CASE_PREFIX = "test_", NEEDS_WINDOW, TEST_DIRECTORIES, SCENE_RUNNER, NOT_SEARCHED_SETTING = "gdharness/tests/not_searched", GUESS_WINDOW_SETTING = "gdharness/tests/scene_runner_needs_window", UNTIMED_CASE = 0.02, SPLIT_PAST_SECONDS = 10, ENGINE_START_SECONDS = 5, SHARE_FLOOR_SECONDS;
18699
18818
  var init_suite_plan = __esm(() => {
18700
18819
  init_class_cache();
18701
18820
  init_gdscript_source();
@@ -18704,6 +18823,7 @@ var init_suite_plan = __esm(() => {
18704
18823
  NEEDS_WINDOW = /^const\s+GDHARNESS_NEEDS_WINDOW\b[^=\n]*=\s*(true|false)\b/m;
18705
18824
  TEST_DIRECTORIES = ["tests", "test"];
18706
18825
  SCENE_RUNNER = /\bscene_runner\s*\(/;
18826
+ SHARE_FLOOR_SECONDS = 6 * ENGINE_START_SECONDS;
18707
18827
  });
18708
18828
 
18709
18829
  // node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
@@ -48345,7 +48465,7 @@ function totalsSince(now, mark) {
48345
48465
  }
48346
48466
  }
48347
48467
  shrunk += Object.keys(mark.functions).filter((signature) => now.functions[signature] === undefined && (mark.functions[signature]?.calls ?? 0) > 0).length;
48348
- const { dropped: droppedInAll, ...kept } = now;
48468
+ const { dropped: droppedInAll, firstFrame: _first, lastFrame: _last, ...kept } = now;
48349
48469
  const dropped = (droppedInAll ?? 0) - (mark.dropped ?? 0);
48350
48470
  return {
48351
48471
  totals: {
@@ -49830,6 +49950,15 @@ function profileAnswer(reading, limit) {
49830
49950
  } else {
49831
49951
  notes.push(`These are the ${totals.frames === 1 ? "one frame" : `${totals.frames} frames`} that arrived, summed, without the engine's own totals, which it sends only when the profiler is switched off.${totalsWithheld} Not in them is any script time after the last frame sent: a frame still queued as the game exited, since the engine stops sending without emptying its queue, and what scripts did as it quit, such as in _exit_tree. That can be nothing: a scene that does its work in _ready and quits has all of it in its first frame.`);
49832
49952
  }
49953
+ const unprofiled = totals.firstFrame ?? 0;
49954
+ if (reading.through === "gdharness" && unprofiled > 0) {
49955
+ notes.push(`The profiler came on at the game's frame ${unprofiled}, so ${unprofiled === 1 ? "its first frame is" : `its first ${unprofiled} frames are`} not in these, the main scene's _ready among them${reading.runtimeAddon === true ? "" : ": without the runtime addon nothing holds the game's start until the profiler is on"}.`);
49956
+ }
49957
+ const unarrived = totals.firstFrame === undefined || totals.lastFrame === undefined ? 0 : totals.lastFrame - totals.firstFrame + 1 - totals.frames - totals.unreadable;
49958
+ if (unarrived > 0) {
49959
+ const howMany = unarrived === 1 ? "One" : String(unarrived);
49960
+ notes.push(`${howMany} of the game's frames between the first and the last that arrived did not arrive, so ${unarrived === 1 ? "its" : "their"} script time is not in these.`);
49961
+ }
49833
49962
  if (table.superCallers > 0) {
49834
49963
  notes.push(`${table.superCallers === 1 ? "One function calls" : `${table.superCallers} functions call`} through super, marked selfIncludesSuper: the engine takes every other call out of the caller's self time, and not a super call, so ${table.superCallers === 1 ? "its" : "their"} selfMs holds the overridden function's time, which that function's own row counts again. Self time therefore cannot be added along an override chain, and scriptMs counts the time under a super call once more for each level above it.`);
49835
49964
  }
@@ -49853,6 +49982,8 @@ function profileAnswer(reading, limit) {
49853
49982
  connected: true,
49854
49983
  complete: totals.complete,
49855
49984
  frames: totals.frames,
49985
+ firstFrame: totals.firstFrame,
49986
+ lastFrame: totals.lastFrame,
49856
49987
  coveredMs: reading.coveredMs,
49857
49988
  scriptMs: table.scriptMs,
49858
49989
  perCallOverheadUs: table.perCallUs ?? undefined,
@@ -53601,8 +53732,21 @@ ${error.text}`;
53601
53732
  "editor_launch restart without hidden restarts it as it is"
53602
53733
  ]);
53603
53734
  }
53735
+ let hidden = args["hidden"] === true;
53736
+ let silent = resolveSilent(args["silent"]);
53737
+ if (mine && (typeof args["hidden"] !== "boolean" || typeof args["silent"] !== "boolean")) {
53738
+ const was = before.editorPid === undefined ? null : await runningAs(before.editorPid);
53739
+ if (was?.kind === "commandLine") {
53740
+ const as = startedAs(was.text);
53741
+ hidden = typeof args["hidden"] === "boolean" ? args["hidden"] : as.hidden;
53742
+ silent = typeof args["silent"] === "boolean" ? args["silent"] : as.silent;
53743
+ } else if (typeof args["hidden"] !== "boolean") {
53744
+ const why = was === null ? "the operating system would not say" : was.unread ?? "it gave no command line";
53745
+ return this.createErrorResponse(`How the editor (pid ${before.editorPid ?? "unknown"}) was started could not be read (${why}), so whether it was hidden cannot be told and it was left running.`, ["Pass hidden: true to bring it back with no window, or hidden: false to bring it back visible"]);
53746
+ }
53747
+ }
53604
53748
  const settingsBefore = this.settingKeysOf(before.projectPath);
53605
- const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid, args["hidden"] === true, resolveSilent(args["silent"])) : await this.handleViaBridge("restart_editor", {});
53749
+ const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid, hidden, silent) : await this.handleViaBridge("restart_editor", {});
53606
53750
  if (asked.isError === true) {
53607
53751
  return asked;
53608
53752
  }
@@ -53621,6 +53765,7 @@ ${error.text}`;
53621
53765
  const after = this.settingKeysOf(before.projectPath);
53622
53766
  return this.jsonTextResponse({
53623
53767
  restarted: true,
53768
+ hidden: mine && hidden,
53624
53769
  editorPid: now.editorPid,
53625
53770
  addonVersion: now.addonVersion,
53626
53771
  serverVersion: SERVER_VERSION,
@@ -58408,7 +58553,7 @@ var VALUED2 = new Set([
58408
58553
  "--order",
58409
58554
  "--result"
58410
58555
  ]);
58411
- var SWITCHES = new Set(["--changed-only", "--import", "--print-output"]);
58556
+ var SWITCHES = new Set(["--changed-only", "--import", "--print-output", "--verbose"]);
58412
58557
  function testOptionsOf(args, cwd) {
58413
58558
  const valued = new Map;
58414
58559
  const switches = new Set;
@@ -58456,6 +58601,7 @@ function testOptionsOf(args, cwd) {
58456
58601
  importFirst: switches.has("--import"),
58457
58602
  order: order === undefined ? null : order === "reversed" ? "reversed" : seedOf(order),
58458
58603
  printOutput: switches.has("--print-output"),
58604
+ verbose: switches.has("--verbose"),
58459
58605
  resultFile: last("--result") === undefined ? null : resolve10(cwd, last("--result") ?? "")
58460
58606
  };
58461
58607
  }
@@ -58615,7 +58761,8 @@ async function runTestCommand(options, godotPath) {
58615
58761
  running,
58616
58762
  selfTyped: selfTypedExports(scripts.sources),
58617
58763
  godotVersion: options.godotVersion,
58618
- cores: options.cores
58764
+ cores: options.cores,
58765
+ verbose: options.verbose
58619
58766
  };
58620
58767
  try {
58621
58768
  if (options.importFirst) {
@@ -58687,7 +58834,19 @@ ${existsSync23(path) ? readFileSync22(path, "latin1") : "missing"}`)
58687
58834
  console.log(`Dealt out row by row, since ${byRows.length === 1 ? "it" : "each"} took more than ${String(SPLIT_PAST_SECONDS)} s last time: ${named(byRows)}.`);
58688
58835
  }
58689
58836
  const lanes = [...new Set(plans.map((plan) => plan.lane))].map((lane) => plans.flatMap((plan, index) => plan.lane === lane ? [{ plan, index }] : []));
58690
- let ran = (await inTurns(lanes, room.engines, (lane) => runLane(launch, lane, times))).flat().sort((a, b) => a.index - b.index);
58837
+ const taken = [];
58838
+ while (taken.length < lanes.length) {
58839
+ const lock = await slots.takeFree();
58840
+ if (lock === null) {
58841
+ break;
58842
+ }
58843
+ taken.push(lock);
58844
+ }
58845
+ const shared = options.order === null && lanes.length > 1 && taken.length < lanes.length;
58846
+ if (shared) {
58847
+ console.log(sharesSaid(taken.length, lanes.length));
58848
+ }
58849
+ let ran = shared ? await runShares(launch, units, declared, lanes.length, taken, times) : (await Promise.all(lanes.map((lane, at) => runLane(launch, lane, times, taken[at] ?? null)))).flat().sort((a, b) => a.index - b.index);
58691
58850
  if (ran.some((engine) => engine.verdict.starved) && ran.some((engine) => !engine.verdict.passed)) {
58692
58851
  console.log("An engine ran out of memory, so every engine that failed runs again, one at a time.");
58693
58852
  const again = [];
@@ -58705,6 +58864,9 @@ ${existsSync23(path) ? readFileSync22(path, "latin1") : "missing"}`)
58705
58864
  process14.off("SIGTERM", interrupted);
58706
58865
  }
58707
58866
  }
58867
+ function sharesSaid(free, lanes) {
58868
+ return `${free === 0 ? "None" : String(free)} of the ${String(lanes)} slots this run uses ${free === 1 ? "was" : "were"} free as it began, so its engines are handed the suites a share at a time as each starts, and an engine starts as each slot comes free.`;
58869
+ }
58708
58870
  function roomSaid(engines, lanes, unheld, held, asked) {
58709
58871
  if (held === undefined || lanes >= unheld) {
58710
58872
  return "";
@@ -58783,8 +58945,8 @@ async function importProject(launch) {
58783
58945
  launch.slots.release(lock);
58784
58946
  }
58785
58947
  }
58786
- async function runLane(launch, lane, times) {
58787
- const lock = await launch.slots.take();
58948
+ async function runLane(launch, lane, times, taken) {
58949
+ const lock = taken ?? await launch.slots.take();
58788
58950
  try {
58789
58951
  const ran = [];
58790
58952
  for (const { plan, index } of lane) {
@@ -58795,6 +58957,39 @@ async function runLane(launch, lane, times) {
58795
58957
  launch.slots.release(lock);
58796
58958
  }
58797
58959
  }
58960
+ async function runShares(launch, units, declared, lanes, taken, times) {
58961
+ const shares = new Shares(units, declared, lanes);
58962
+ const handedOut = new AbortController;
58963
+ let engines = 0;
58964
+ const lane = async (at) => {
58965
+ let lock = taken[at] ?? null;
58966
+ if (lock === null) {
58967
+ try {
58968
+ lock = await launch.slots.take(handedOut.signal);
58969
+ } catch (error) {
58970
+ if (handedOut.signal.aborted) {
58971
+ return [];
58972
+ }
58973
+ throw error;
58974
+ }
58975
+ }
58976
+ try {
58977
+ const ran = [];
58978
+ for (let plan = shares.next(at);plan !== null; plan = shares.next(at)) {
58979
+ if (shares.empty) {
58980
+ handedOut.abort();
58981
+ }
58982
+ const index = engines;
58983
+ engines += 1;
58984
+ ran.push(await runEngine2(launch, plan, index, times, "", lock));
58985
+ }
58986
+ return ran;
58987
+ } finally {
58988
+ launch.slots.release(lock);
58989
+ }
58990
+ };
58991
+ return (await Promise.all(Array.from({ length: lanes }, (_, at) => lane(at)))).flat().sort((a, b) => a.index - b.index);
58992
+ }
58798
58993
  async function runEngine2(launch, plan, index, times, again = "", slot = null) {
58799
58994
  const directory = join27(launch.runDirectory, `engine-${String(index)}${again === "" ? "" : `-${again}`}`);
58800
58995
  mkdirSync10(join27(directory, "user"), { recursive: true });
@@ -58822,6 +59017,7 @@ async function runEngine2(launch, plan, index, times, again = "", slot = null) {
58822
59017
  "Dummy",
58823
59018
  "--path",
58824
59019
  launch.projectPath,
59020
+ ...launch.verbose ? ["--verbose"] : [],
58825
59021
  "-s",
58826
59022
  shippedListedRunner(),
58827
59023
  ...plan.windowed ? [] : ["--ignoreHeadlessMode"],
@@ -59474,7 +59670,7 @@ Usage:
59474
59670
  gdharness test [project] [suite ...] [--engines N] [--slots DIR] [--slot-count N]
59475
59671
  [--silence S] [--deadline S] [--tests DIR] [--changed-only] [--reach-also PATH]
59476
59672
  [--import] [--godot PATH] [--godot-version V] [--cores N]
59477
- [--order reversed|SEED] [--print-output] [--result FILE] [--runner]
59673
+ [--order reversed|SEED] [--print-output] [--verbose] [--result FILE] [--runner]
59478
59674
  Run the project's gdUnit4 suites over several engines and
59479
59675
  judge every line they print: exit 1 on a failing case, an
59480
59676
  engine error no case declared, a case that never finished
@@ -59483,7 +59679,8 @@ Usage:
59483
59679
  suites that passed with everything they reach unchanged; it
59484
59680
  is for iterating, and no gate passes it. --print-output
59485
59681
  prints what every engine printed, for a
59486
- benchmark run by hand. --result writes the outcome, each
59682
+ benchmark run by hand. --verbose starts every engine verbose,
59683
+ so a leak at exit is named. --result writes the outcome, each
59487
59684
  suite's time and every problem as JSON for a gate. --runner
59488
59685
  prints the shipped runner's path. docs/test-runs.md has the
59489
59686
  rest.
@@ -133,6 +133,21 @@ func _reload_script(path: String) -> void:
133
133
 
134
134
  func _reload_scene(path: String) -> void:
135
135
  var edited_scene: Node = EditorInterface.get_edited_scene_root()
136
- if edited_scene and edited_scene.scene_file_path == path:
137
- print("[gdharness] Scene changed on disk, reloading: ", path)
138
- EditorInterface.reload_scene_from_path(path)
136
+ if not edited_scene or edited_scene.scene_file_path != path:
137
+ return
138
+ # A reload from the file drops the edits the editor holds, without a prompt. gdharness refuses to
139
+ # write over them, so what is seen here is a write by somebody else or one the editor's tab has
140
+ # already taken in, made just before an edit inside the same poll, and neither is worth the edit.
141
+ if EditorInterface.get_unsaved_scenes().has(path):
142
+ push_warning(
143
+ (
144
+ (
145
+ "gdharness: %s changed on disk while the editor holds unsaved changes to it, so it "
146
+ + "was not reloaded; saving it writes the editor's copy over the file"
147
+ )
148
+ % path
149
+ )
150
+ )
151
+ return
152
+ print("[gdharness] Scene changed on disk, reloading: ", path)
153
+ EditorInterface.reload_scene_from_path(path)
@@ -20,21 +20,27 @@ extends RefCounted
20
20
  ## A label is asked about first and read directly, because a screen is mostly labels and this runs
21
21
  ## for each of them on every look a wait takes.
22
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
- var own: String = _text_of(node)
23
+ var own: String = drawn_by(node)
29
24
  # A control showing a picture rather than words is named for a player who cannot see it, and that
30
25
  # name is the one the words a caller asks by are meant for: the rail's "Out" tab is a square with
31
26
  # an icon on it, and a click on "Out" found only the words around it.
32
- if own.is_empty() and node is Control:
27
+ if own.is_empty() and node is Control and not node is Label and not draws_a_list(node):
33
28
  var control: Control = node
34
29
  return control.atr(control.accessibility_name).strip_edges()
35
30
  return own
36
31
 
37
32
 
33
+ ## The words [param node] draws: what [method said_by] answers, without the name a control is given
34
+ ## for a player who cannot see it, which nothing on the screen shows.
35
+ static func drawn_by(node: Node) -> String:
36
+ var label: Label = node as Label
37
+ if label != null:
38
+ return _label_says(label)
39
+ if draws_a_list(node):
40
+ return "\n".join(lines_said_by(node))
41
+ return _text_of(node)
42
+
43
+
38
44
  ## Every line [param node] draws for a player to read, one to an entry: a label's one line, or each
39
45
  ## item of a control that draws a list of them. Tab titles, list items, tree rows, menu titles and
40
46
  ## the items of an open menu are held by the control rather than in any `text`, so a screen read as
@@ -55,10 +61,12 @@ static func lines_said_by(node: Node) -> Array[String]:
55
61
  return lines
56
62
 
57
63
 
58
- ## The node [param node] is drawn as a copy of, or null: the nearest node above it that says
59
- ## anything, when it says the same words, followed up while that one is a copy in turn. A word drawn
64
+ ## The node [param node] is drawn as a copy of, or null: the nearest node above it that draws any
65
+ ## words, when it draws the same ones, followed up while that one is a copy in turn. A word drawn
60
66
  ## with an outline or a glow is often a label holding copies of itself, offset or tinted, and a
61
- ## player reads one sign where a find counted four.
67
+ ## player reads one sign where a find counted four. Drawn words only: a page's root named for a
68
+ ## screen reader by the title its heading shows draws nothing, and taken for the original it left
69
+ ## the heading read as a copy of words nobody reads.
62
70
  static func copied_from(node: Node) -> Node:
63
71
  var chain: Array[Node] = copied_through(node)
64
72
  return null if chain.is_empty() else chain.back()
@@ -68,12 +76,12 @@ static func copied_from(node: Node) -> Node:
68
76
  ## [method copied_from], which is the last of them.
69
77
  static func copied_through(node: Node) -> Array[Node]:
70
78
  var chain: Array[Node] = []
71
- var words: String = said_by(node)
79
+ var words: String = drawn_by(node)
72
80
  if words.is_empty():
73
81
  return chain
74
82
  var above: Node = node.get_parent()
75
83
  while above != null:
76
- var said: String = said_by(above)
84
+ var said: String = drawn_by(above)
77
85
  if not said.is_empty():
78
86
  if said != words:
79
87
  break
package/build/keeper.js CHANGED
@@ -9795,6 +9795,8 @@ class ProfileAggregate {
9795
9795
  complete = false;
9796
9796
  frames = 0;
9797
9797
  lastFrameAt = null;
9798
+ firstFrame = null;
9799
+ lastFrame = null;
9798
9800
  unreadable = 0;
9799
9801
  cappedFrames = 0;
9800
9802
  totalCapped = false;
@@ -9826,6 +9828,11 @@ class ProfileAggregate {
9826
9828
  this.frames += 1;
9827
9829
  this.lastFrameAt = now;
9828
9830
  this.cappedFrames += carried >= this.frameFunctions ? 1 : 0;
9831
+ const numbered = received.data[0];
9832
+ if (typeof numbered === "number") {
9833
+ this.firstFrame ??= numbered;
9834
+ this.lastFrame = numbered;
9835
+ }
9829
9836
  }
9830
9837
  } else if (received.message === "servers:profile_total") {
9831
9838
  const whole = new Map;
@@ -9884,7 +9891,8 @@ class ProfileAggregate {
9884
9891
  frameFunctions: this.frameFunctions,
9885
9892
  cappedFrames: this.cappedFrames,
9886
9893
  totalCapped: this.totalCapped,
9887
- ...this.dropped > 0 ? { dropped: this.dropped } : {}
9894
+ ...this.dropped > 0 ? { dropped: this.dropped } : {},
9895
+ ...this.firstFrame === null || this.lastFrame === null ? {} : { firstFrame: this.firstFrame, lastFrame: this.lastFrame }
9888
9896
  };
9889
9897
  }
9890
9898
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.3.28",
3
+ "version": "1.3.30",
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",