gdharness 1.1.56 → 1.1.57

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
@@ -7851,30 +7851,66 @@ function reported(entry) {
7851
7851
  }
7852
7852
  function framesCollapsed(detail) {
7853
7853
  const out = [];
7854
- let pending;
7855
- for (const line of detail) {
7856
- const match = FRAME.exec(line);
7857
- const number = match?.[1];
7858
- const frame = match?.[2];
7859
- if (frame !== undefined && pending?.frame === frame && Number(number) === Number(pending.last) + 1) {
7860
- pending.last = String(number);
7854
+ let at = 0;
7855
+ while (at < detail.length) {
7856
+ const frames = consecutiveFrames(detail, at);
7857
+ if (frames.length === 0) {
7858
+ out.push(detail[at] ?? "");
7859
+ at += 1;
7861
7860
  continue;
7862
7861
  }
7863
- if (pending !== undefined) {
7864
- out.push(writtenRun(pending));
7865
- }
7866
- pending = number === undefined || frame === undefined ? undefined : { first: number, last: number, frame };
7867
- if (pending === undefined) {
7868
- out.push(line);
7862
+ let index = 0;
7863
+ while (index < frames.length) {
7864
+ const cycle = repeatedCycle(frames, index);
7865
+ const first = frames[index];
7866
+ if (cycle === null || first === undefined) {
7867
+ out.push(`[${first?.number ?? ""}] ${first?.frame ?? ""}`);
7868
+ index += 1;
7869
+ continue;
7870
+ }
7871
+ const last = frames[index + cycle.period * cycle.times - 1];
7872
+ const span = `[${first.number}-${last?.number ?? first.number}]`;
7873
+ if (cycle.period === 1) {
7874
+ out.push(`${span} ${first.frame}`);
7875
+ } else {
7876
+ out.push(`${span} these ${cycle.period} frames, ${cycle.times} times over:`);
7877
+ for (const frame of frames.slice(index, index + cycle.period)) {
7878
+ out.push(` ${frame.frame}`);
7879
+ }
7880
+ }
7881
+ index += cycle.period * cycle.times;
7869
7882
  }
7870
- }
7871
- if (pending !== undefined) {
7872
- out.push(writtenRun(pending));
7883
+ at += frames.length;
7873
7884
  }
7874
7885
  return out;
7875
7886
  }
7876
- function writtenRun(run) {
7877
- return run.first === run.last ? `[${run.first}] ${run.frame}` : `[${run.first}-${run.last}] ${run.frame}`;
7887
+ function consecutiveFrames(detail, at) {
7888
+ const frames = [];
7889
+ for (const line of detail.slice(at)) {
7890
+ const match = FRAME.exec(line);
7891
+ const number = Number(match?.[1]);
7892
+ const frame = match?.[2];
7893
+ const before = frames.at(-1);
7894
+ if (frame === undefined || !Number.isInteger(number) || before !== undefined && number !== before.number + 1) {
7895
+ break;
7896
+ }
7897
+ frames.push({ number, frame });
7898
+ }
7899
+ return frames;
7900
+ }
7901
+ function repeatedCycle(frames, from) {
7902
+ let best = null;
7903
+ for (let period = 1;period <= LONGEST_CYCLE && from + 2 * period <= frames.length; period += 1) {
7904
+ let length = period;
7905
+ while (from + length < frames.length && frames[from + length]?.frame === frames[from + length - period]?.frame) {
7906
+ length += 1;
7907
+ }
7908
+ const times = Math.floor(length / period);
7909
+ if (times >= 2 && (best === null || period * times > best.period * best.times)) {
7910
+ best = { period, times };
7911
+ }
7912
+ }
7913
+ return best;
7878
7914
  }
7879
7915
  function foldedRepeats(entries) {
7880
7916
  const first = new Map;
@@ -7998,7 +8034,7 @@ function answersTo(entry, severity, contains) {
7998
8034
  const needle = contains.toLowerCase();
7999
8035
  return [entry.text, ...entry.detail].some((line) => line.toLowerCase().includes(needle));
8000
8036
  }
8001
- var HEADLINE, COLOUR_CODE, STREAMS, ADMITTED, FRAME;
8037
+ var HEADLINE, COLOUR_CODE, STREAMS, ADMITTED, FRAME, LONGEST_CYCLE = 64;
8002
8038
  var init_game_log = __esm(() => {
8003
8039
  HEADLINE = /^(USER )?(SCRIPT ERROR|SHADER ERROR|ERROR|WARNING):\s?(.*)$/;
8004
8040
  COLOUR_CODE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
@@ -17707,7 +17743,7 @@ var init_tool_definitions = __esm(() => {
17707
17743
  },
17708
17744
  {
17709
17745
  name: "project_test",
17710
- description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines; identical engine messages come back once under engineEntries, with times saying how often they were printed, and engineErrors counts every one. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. When they failed on a class whose own script has a parser error, which gdUnit4 never names, that script is checked by the engine and its error comes first under scriptErrors, with the class it declares under declares, and the errors a script has only because that class did not resolve, which the engine reads as Variant, are kept apart under followFromUnresolved. A client that sends a progress token is told as each suite finishes, and at least every twenty seconds through a long one, so an idle limit does not end a long tier. A run that ends without a report, at its timeout or otherwise, names the suites it finished under suitesFinished, the failing ones with their failed cases under suitesFailing, and the one it was in under suiteRunning. When a client cuts a run off, that is kept in the project and the next project_test there carries it under previousRunCut. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. A run whose every case passed is still not called passed when it printed a script error that no case failed on: gdUnit4 fails a case on the script errors its logger sees, and it cannot see a stack overflow, so the cases pass on whatever the faulted calls returned. Those errors come back under uncaughtScriptErrors. One raised inside assert_error(...).is_runtime_error is the test's own and does not count, and none count when the project turns off gdUnit4's report/godot/script_error setting. Needs gdUnit4 under addons/gdUnit4.",
17746
+ description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines; identical engine messages come back once under engineEntries, with times saying how often they were printed, and engineErrors counts every one. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. When they failed on a class whose own script has a parser error, which gdUnit4 never names, that script is checked by the engine and its error comes first under scriptErrors, with the class it declares under declares, and the errors a script has only because that class did not resolve, which the engine reads as Variant, are kept apart under followFromUnresolved. A client that sends a progress token is told as each suite finishes, and at least every twenty seconds through a long one, so an idle limit does not end a long tier. A run that ends without a report, at its timeout or otherwise, names the suites it finished under suitesFinished, the failing ones with their failed cases under suitesFailing, and the one it was in under suiteRunning. When a client cuts a run off, that is kept in the project and the next project_test there carries it under previousRunCut. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. A run whose every case passed is still not called passed when it printed a script error that no case failed on: gdUnit4 fails a case on the script errors its logger sees, and it cannot see a stack overflow, so the cases pass on whatever the faulted calls returned. Those errors come back under uncaughtScriptErrors, and under engineEntries by index with detailUnder rather than with their backtrace a second time; a backtrace that repeats, as a recursion's does, has each repeated frame or cycle of frames written once with the frames it covers and how many times. One raised inside assert_error(...).is_runtime_error is the test's own and does not count, and none count when the project turns off gdUnit4's report/godot/script_error setting. Needs gdUnit4 under addons/gdUnit4.",
17711
17747
  parameters: {
17712
17748
  projectPath: PROJECT_PATH,
17713
17749
  path: {
@@ -18099,6 +18135,17 @@ var init_tool_definitions = __esm(() => {
18099
18135
  ops: ["check", "wait"],
18100
18136
  description: "check: how long to give the boot before it is called hung. Default 60000. wait: how long to wait for the run to end before answering anyway. Default 600000."
18101
18137
  },
18138
+ profile: {
18139
+ type: "boolean",
18140
+ ops: ["start"],
18141
+ description: "start: profile the run's GDScript with the engine's own script profiler from its start, for editor_run profile to answer which functions took the time. A run this server starts dials the process that keeps it as its debugger, on loopback, with breakpoints and error breaks skipped so it never stops to wait; a game the editor plays is profiled through the editor's debugger session. Needs an engine built with debugging, which the editor binary is and a release export template is not. Default false."
18142
+ },
18143
+ limit: {
18144
+ type: "integer",
18145
+ minimum: 1,
18146
+ ops: ["profile"],
18147
+ description: "profile: how many functions to answer with, those with the most self time. Default 30."
18148
+ },
18102
18149
  andChildren: {
18103
18150
  type: "boolean",
18104
18151
  ops: ["stop"],
@@ -18125,7 +18172,11 @@ var init_tool_definitions = __esm(() => {
18125
18172
  summary: "boot headless, quit after a few frames, and report the verdict",
18126
18173
  requires: ["projectPath"]
18127
18174
  },
18128
- wait: { summary: "wait for the run to end, then answer as editor_output does", requires: [] }
18175
+ wait: { summary: "wait for the run to end, then answer as editor_output does", requires: [] },
18176
+ profile: {
18177
+ summary: "where the run spends its time by GDScript function, while it runs or after it ended: per function its script, the function (Class.name when the script declares a class_name), the line its body's first statement is on, calls, and self and total milliseconds, sorted by self time, with functionsCounted, scriptMs (the self time of them all) and coveredMs. 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. Self time counts a call into the engine's own methods as the caller's, and a function the profiler started inside is left out, since its time would be counted from the engine's start. A run started with profile: true is profiled from its start. A game the editor plays that was not is profiled from the moment this is asked, and the answer says so; a run this server started without it is refused, since its debugger is fixed as it starts",
18178
+ requires: []
18179
+ }
18129
18180
  },
18130
18181
  defaultOperation: "start"
18131
18182
  },
@@ -18608,7 +18659,7 @@ var init_tool_definitions = __esm(() => {
18608
18659
  says: {
18609
18660
  type: "string",
18610
18661
  ops: ["until"],
18611
- description: 'until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Matched the way runtime_inspect find matches says: case-insensitive, a plain word anywhere in the text, a pattern with * or ? a glob over the whole of it, and alternatives separated by | met by whichever appears first ("*won*|*lost*"); on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property. It looks every frame while a look is cheap and less often on a screen large enough for a look to cost a real share of a frame, so the game keeps its speed; the answer counts the frames the wait spanned and the looks it took.'
18662
+ description: 'until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Matched the way runtime_inspect find matches says: case-insensitive, a plain word anywhere in the text, a pattern with * or ? a glob over the whole of it, and alternatives separated by | met by whichever appears first ("*won*|*lost*"); on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property. It looks every frame while a look is cheap and less often on a screen large enough for a look to cost a real share of a frame, so the game keeps its speed; the answer counts the frames the wait spanned and the looks it took, and says under looking_ms how long the looks took between them.'
18612
18663
  },
18613
18664
  includeHidden: {
18614
18665
  type: "boolean",
@@ -41767,6 +41818,9 @@ function openTranscript(startedAt) {
41767
41818
  const path = join24(runsDirectory(), `run-${startedAt}.log`);
41768
41819
  return { path, fd: openSync2(path, "a") };
41769
41820
  }
41821
+ function profilePath(startedAt) {
41822
+ return join24(runsDirectory(), `run-${startedAt}.profile.json`);
41823
+ }
41770
41824
  function noteIsAbout(note, playing) {
41771
41825
  return note.play !== undefined && playing !== null && note.play.play === playing.play && note.play.editorPid === playing.editorPid;
41772
41826
  }
@@ -41866,6 +41920,7 @@ function recordAt(path) {
41866
41920
  projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
41867
41921
  ...typeof fields["servedBy"] === "string" && fields["servedBy"] !== "" ? { servedBy: fields["servedBy"] } : {},
41868
41922
  ...typeof fields["keeper"] === "string" && fields["keeper"] !== "" ? { keeper: fields["keeper"] } : {},
41923
+ ...typeof fields["profile"] === "string" && fields["profile"] !== "" ? { profile: fields["profile"] } : {},
41869
41924
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
41870
41925
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
41871
41926
  ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
@@ -42058,7 +42113,10 @@ function ageMs(path, now) {
42058
42113
  }
42059
42114
  function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
42060
42115
  const directory = runsDirectory();
42061
- const kept = new Set(everyRunRecord().map((record) => record.transcript));
42116
+ const kept = new Set(everyRunRecord().flatMap((record) => [
42117
+ record.transcript,
42118
+ ...record.profile === undefined ? [] : [record.profile]
42119
+ ]));
42062
42120
  let entries;
42063
42121
  try {
42064
42122
  entries = readdirSync9(directory);
@@ -42066,7 +42124,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
42066
42124
  return;
42067
42125
  }
42068
42126
  for (const entry of entries) {
42069
- if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
42127
+ if (!entry.startsWith("run-") || !(entry.endsWith(".log") || entry.endsWith(".profile.json"))) {
42070
42128
  continue;
42071
42129
  }
42072
42130
  const path = join24(directory, entry);
@@ -42186,6 +42244,57 @@ var init_script_origins = __esm(() => {
42186
42244
  RELOADED_AT = /GDScript::reload \((res:\/\/.+):(\d+)\)$/;
42187
42245
  });
42188
42246
 
42247
+ // src/godot-variant.ts
42248
+ var FLAG_64, MOST_MESSAGE_BYTES;
42249
+ var init_godot_variant = __esm(() => {
42250
+ FLAG_64 = 1 << 16;
42251
+ MOST_MESSAGE_BYTES = (8 << 20) + 4;
42252
+ });
42253
+
42254
+ // src/script-profile.ts
42255
+ import { readFileSync as readFileSync22, renameSync as renameSync2, writeFileSync as writeFileSync17 } from "node:fs";
42256
+ function readProfileFile(path) {
42257
+ try {
42258
+ const parsed = JSON.parse(readFileSync22(path, "utf8"));
42259
+ return typeof parsed === "object" && typeof parsed.connected === "boolean" ? parsed : null;
42260
+ } catch {
42261
+ return null;
42262
+ }
42263
+ }
42264
+ function signatureParts(signature) {
42265
+ const parts = signature.split("::");
42266
+ if (parts.length < 3) {
42267
+ return { script: "", line: 0, function: signature };
42268
+ }
42269
+ const name = parts.at(-1) ?? "";
42270
+ const line = Number(parts.at(-2));
42271
+ return { script: parts.slice(0, -2).join("::"), line: Number.isFinite(line) ? line : 0, function: name };
42272
+ }
42273
+ function profiledFunctions(totals, limit) {
42274
+ const rows = Object.entries(totals.functions).filter(([, sum]) => sum.calls > 0).map(([signature, sum]) => {
42275
+ const parts = signatureParts(signature);
42276
+ return {
42277
+ script: parts.script,
42278
+ function: parts.function,
42279
+ line: parts.line,
42280
+ calls: sum.calls,
42281
+ selfMs: ms(sum.selfSeconds),
42282
+ totalMs: ms(sum.totalSeconds)
42283
+ };
42284
+ }).sort((a, b) => b.selfMs - a.selfMs || b.totalMs - a.totalMs || a.function.localeCompare(b.function));
42285
+ const scriptMs = ms(Object.values(totals.functions).reduce((sum, one) => sum + (one.calls > 0 ? one.selfSeconds : 0), 0));
42286
+ return {
42287
+ functions: rows.slice(0, limit),
42288
+ omitted: Math.max(0, rows.length - limit),
42289
+ scriptMs,
42290
+ counted: rows.length
42291
+ };
42292
+ }
42293
+ var ms = (seconds) => Math.round(seconds * 1e6) / 1000;
42294
+ var init_script_profile = __esm(() => {
42295
+ init_godot_variant();
42296
+ });
42297
+
42189
42298
  // src/server.ts
42190
42299
  import { execFile as execFile5, spawn as spawn4 } from "node:child_process";
42191
42300
  import { randomUUID as randomUUID2 } from "node:crypto";
@@ -42199,7 +42308,7 @@ import {
42199
42308
  mkdirSync as mkdirSync15,
42200
42309
  openSync as openSync3,
42201
42310
  readdirSync as readdirSync10,
42202
- readFileSync as readFileSync22,
42311
+ readFileSync as readFileSync23,
42203
42312
  readSync as readSync2,
42204
42313
  rmdirSync as rmdirSync2,
42205
42314
  statSync as statSync9
@@ -42866,6 +42975,44 @@ function aboveTheRunner(entry) {
42866
42975
  ]
42867
42976
  };
42868
42977
  }
42978
+ function profileAnswer(reading, limit) {
42979
+ const { totals } = reading;
42980
+ if (totals === null) {
42981
+ return {
42982
+ through: reading.through,
42983
+ running: reading.running,
42984
+ connected: false,
42985
+ functions: [],
42986
+ note: reading.running ? "The game has not connected to the profiler yet: it dials its debugger once, as it starts, before any script runs. Ask again in a moment." : "The game never connected to the profiler, so nothing was measured. A game dials its debugger once, as it starts, and an engine built without debugging (a release export template) refuses to: profiling needs the editor binary or a debug template."
42987
+ };
42988
+ }
42989
+ const table = profiledFunctions(totals, limit);
42990
+ const notes = [];
42991
+ const addonNote = reading.runtimeAddon ? "" : " This project has no gdharness runtime addon, which switches the profiler off as the game quits so the engine sends its totals: setup installs it.";
42992
+ if (totals.complete) {} else if (reading.running) {
42993
+ notes.push(`The run is still going, so these are the ${totals.frames === 1 ? "one frame" : `${totals.frames} frames`} summed so far${reading.through === "gdharness" ? ", written at most half a second behind" : ""}.`);
42994
+ } else if (totals.frames === 0) {
42995
+ notes.push(`The game connected and ended before a frame of the profile reached ${reading.through === "editor" ? "the editor" : "this server"}: the engine sends its totals only when the profiler is switched off, and stops sending as it exits without emptying what it has queued.${addonNote}`);
42996
+ } else {
42997
+ notes.push(`These are the ${totals.frames === 1 ? "one frame" : `${totals.frames} frames`} that arrived, summed, without the engine's own totals, so what the game did after the last of them is not in it: the engine sends its totals only when the profiler is switched off, and stops sending as it exits without emptying what it has queued.${addonNote}`);
42998
+ }
42999
+ if (totals.unreadable > 0) {
43000
+ notes.push(`${totals.unreadable} message${totals.unreadable === 1 ? "" : "s"} from the game could not be read, so the totals have that many gaps.`);
43001
+ }
43002
+ return {
43003
+ through: reading.through,
43004
+ running: reading.running,
43005
+ connected: true,
43006
+ complete: totals.complete,
43007
+ frames: totals.frames,
43008
+ coveredMs: reading.coveredMs,
43009
+ scriptMs: table.scriptMs,
43010
+ functionsCounted: table.counted,
43011
+ omitted: table.omitted > 0 ? table.omitted : undefined,
43012
+ functions: table.functions,
43013
+ note: notes.length > 0 ? notes.join(" ") : undefined
43014
+ };
43015
+ }
42869
43016
  function foldedEntriesAnswer(log) {
42870
43017
  const problems = log.selectFolded({
42871
43018
  severity: "warning",
@@ -42879,7 +43026,7 @@ function foldedEntriesAnswer(log) {
42879
43026
  }
42880
43027
  function gdUnitFailsOnScriptErrors(projectPath) {
42881
43028
  try {
42882
- return settingKeys(readFileSync22(join26(projectPath, "project.godot"), "utf8")).get("gdunit4/report/godot/script_error") !== false;
43029
+ return settingKeys(readFileSync23(join26(projectPath, "project.godot"), "utf8")).get("gdunit4/report/godot/script_error") !== false;
42883
43030
  } catch {
42884
43031
  return true;
42885
43032
  }
@@ -43008,7 +43155,7 @@ function scriptsUnder(directory, prefix) {
43008
43155
  return found;
43009
43156
  }
43010
43157
  function hasMainScene(projectFile) {
43011
- const scene = parseProjectGodot(readFileSync22(projectFile, "utf8"))["application"]?.["run/main_scene"];
43158
+ const scene = parseProjectGodot(readFileSync23(projectFile, "utf8"))["application"]?.["run/main_scene"];
43012
43159
  return typeof scene === "string" && scene !== "";
43013
43160
  }
43014
43161
  function runtimeAddonAnnouncesEditors(projectPath) {
@@ -43016,13 +43163,13 @@ function runtimeAddonAnnouncesEditors(projectPath) {
43016
43163
  return false;
43017
43164
  }
43018
43165
  try {
43019
- return readFileSync22(join26(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
43166
+ return readFileSync23(join26(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
43020
43167
  } catch {
43021
43168
  return false;
43022
43169
  }
43023
43170
  }
43024
43171
  function editorPlaysHeadless(projectFile) {
43025
- const runArgs = parseProjectGodot(readFileSync22(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
43172
+ const runArgs = parseProjectGodot(readFileSync23(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
43026
43173
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
43027
43174
  }
43028
43175
  function camelCased(params) {
@@ -43695,6 +43842,9 @@ class GodotServer {
43695
43842
  if (op === "wait") {
43696
43843
  return await this.handleWaitForRun(args);
43697
43844
  }
43845
+ if (op === "profile") {
43846
+ return await this.handleRunProfile(args);
43847
+ }
43698
43848
  return await this.handleRunProject(args, op);
43699
43849
  case "editor_output":
43700
43850
  return await this.handleGetDebugOutput(args);
@@ -44448,7 +44598,7 @@ class GodotServer {
44448
44598
  if (!engine.ok) {
44449
44599
  return engine.response;
44450
44600
  }
44451
- const application = parseProjectGodot(readFileSync22(project.value.file, "utf8"))["application"] ?? {};
44601
+ const application = parseProjectGodot(readFileSync23(project.value.file, "utf8"))["application"] ?? {};
44452
44602
  const name = application["config/name"];
44453
44603
  const mainScene = application["run/main_scene"];
44454
44604
  const info = {
@@ -44666,7 +44816,7 @@ class GodotServer {
44666
44816
  const written = existsSync20(reportsDir) ? readdirSync10(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
44667
44817
  const newest = written.at(-1);
44668
44818
  if (newest !== undefined) {
44669
- report = parseJUnit(readFileSync22(join26(reportsDir, newest, "results.xml"), "utf8"));
44819
+ report = parseJUnit(readFileSync23(join26(reportsDir, newest, "results.xml"), "utf8"));
44670
44820
  }
44671
44821
  } catch (error) {
44672
44822
  reportProblem = errorMessage(error);
@@ -44785,6 +44935,8 @@ class GodotServer {
44785
44935
  ];
44786
44936
  });
44787
44937
  const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
44938
+ const uncaughtFolded = foldedRepeats(uncaught.map(aboveTheRunner));
44939
+ const listedAsUncaught = new Set(uncaughtFolded.map((entry) => entry.index));
44788
44940
  return this.jsonTextResponse({
44789
44941
  passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0 && uncaught.length === 0 && missing.length === 0,
44790
44942
  verdict,
@@ -44799,7 +44951,7 @@ class GodotServer {
44799
44951
  time: report.time,
44800
44952
  failed,
44801
44953
  hookFailures: hookFailures.length > 0 ? hookFailures : undefined,
44802
- uncaughtScriptErrors: uncaught.length > 0 ? foldedRepeats(uncaught.map(aboveTheRunner)) : undefined,
44954
+ uncaughtScriptErrors: uncaughtFolded.length > 0 ? uncaughtFolded : undefined,
44803
44955
  warnings: warnings.length > 0 ? warnings : undefined,
44804
44956
  orphans: orphans.total > 0 ? orphans.total : undefined,
44805
44957
  notRun: notRun > 0 ? notRun : undefined,
@@ -44826,7 +44978,7 @@ class GodotServer {
44826
44978
  suitesPassed: report.suites.length - unclean.length,
44827
44979
  engineErrors: run.log.count("error"),
44828
44980
  engineWarnings: run.log.count("warning"),
44829
- engineEntries: engineEntriesShown,
44981
+ engineEntries: engineEntriesShown.map((entry) => listedAsUncaught.has(entry.index) ? { ...entry, detail: undefined, detailUnder: "uncaughtScriptErrors" } : entry),
44830
44982
  engineEntriesOmitted: engineEntries.length > engineEntriesShown.length ? engineEntries.length - engineEntriesShown.length : undefined,
44831
44983
  classes: withAddonClassesCounted(classes.payload, cachedClasses(project.value.path) ?? new Map)
44832
44984
  });
@@ -44950,7 +45102,7 @@ class GodotServer {
44950
45102
  return null;
44951
45103
  }
44952
45104
  try {
44953
- return readFileSync22(contained.absolutePath, "utf8");
45105
+ return readFileSync23(contained.absolutePath, "utf8");
44954
45106
  } catch {
44955
45107
  return null;
44956
45108
  }
@@ -45019,7 +45171,7 @@ class GodotServer {
45019
45171
  return null;
45020
45172
  }
45021
45173
  try {
45022
- return readFileSync22(contained.absolutePath, "utf8");
45174
+ return readFileSync23(contained.absolutePath, "utf8");
45023
45175
  } catch {
45024
45176
  return null;
45025
45177
  }
@@ -45338,7 +45490,7 @@ class GodotServer {
45338
45490
  return null;
45339
45491
  }
45340
45492
  try {
45341
- return settingKeys(readFileSync22(join26(projectPath, "project.godot"), "utf8"));
45493
+ return settingKeys(readFileSync23(join26(projectPath, "project.godot"), "utf8"));
45342
45494
  } catch {
45343
45495
  return null;
45344
45496
  }
@@ -45639,8 +45791,9 @@ class GodotServer {
45639
45791
  const runtimeWaitMs = this.announceBudget(args, project.value.path);
45640
45792
  const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
45641
45793
  const silent = resolveSilent(args["silent"], process.env) && !headless;
45794
+ const profiled = readBoolean(args, "profile") === true;
45642
45795
  if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0 && !needsItsOwnProcess(asked.value) && !silent) {
45643
- return await this.playThroughEditor(sceneArgument, refreshed.value, scanned, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
45796
+ return await this.playThroughEditor(sceneArgument, refreshed.value, scanned, project.value.path, alreadyPlaying, runtimeWaitMs, ended, profiled);
45644
45797
  }
45645
45798
  const cmdArgs = runArguments({
45646
45799
  projectPath: project.value.path,
@@ -45650,7 +45803,7 @@ class GodotServer {
45650
45803
  silent
45651
45804
  });
45652
45805
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
45653
- const started = await this.launchKeptGame(engine.value, cmdArgs, project.value.path, needsItsOwnProcess(asked.value) ? environmentFor({ ...asked.value, runtimeDirectory: runtimeDirectory() }) : undefined, headless || args["visible"] === true ? undefined : HIDDEN_DESKTOP);
45806
+ const started = await this.launchKeptGame(engine.value, cmdArgs, project.value.path, needsItsOwnProcess(asked.value) ? environmentFor({ ...asked.value, runtimeDirectory: runtimeDirectory() }) : undefined, headless || args["visible"] === true ? undefined : HIDDEN_DESKTOP, profiled);
45654
45807
  if ("error" in started) {
45655
45808
  return this.createErrorResponse(`The game could not be started: ${started.error}`, [
45656
45809
  "editor_status says which Godot this server runs and whether it answers"
@@ -45770,7 +45923,7 @@ class GodotServer {
45770
45923
  writeBootNote(run.projectPath, took);
45771
45924
  }
45772
45925
  }
45773
- async playThroughEditor(scene, refreshedClasses, scanned, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
45926
+ async playThroughEditor(scene, refreshedClasses, scanned, projectPath, alreadyPlaying, runtimeWaitMs, ended, profiled) {
45774
45927
  const log = new GameLog;
45775
45928
  const connectingFrom = Date.now();
45776
45929
  try {
@@ -45794,6 +45947,12 @@ class GodotServer {
45794
45947
  const sendingFrom = Date.now();
45795
45948
  const breakpoints = await this.dapHoldingBreakpointsOf(projectPath).reapplyBreakpoints();
45796
45949
  this.logDebug(`Breakpoints were sent again after ${Date.now() - sendingFrom}ms`);
45950
+ if (profiled) {
45951
+ const armed = await this.handleViaBridge("profile_start", { arm: true });
45952
+ if (armed.isError === true) {
45953
+ return this.profilerRefusal(armed);
45954
+ }
45955
+ }
45797
45956
  const answer = await this.handleViaBridge("play_scene", scene === null ? {} : { scenePath: `res://${scene}` });
45798
45957
  if (answer.isError === true) {
45799
45958
  return answer;
@@ -45940,7 +46099,7 @@ class GodotServer {
45940
46099
  return 0;
45941
46100
  }
45942
46101
  try {
45943
- return reportAlreadyInTranscript(readFileSync22(game.transcript), readFileSync22(report));
46102
+ return reportAlreadyInTranscript(readFileSync23(game.transcript), readFileSync23(report));
45944
46103
  } catch (error) {
45945
46104
  this.logDebug(`Could not compare a picked-up run's report with its transcript: ${errorMessage(error)}`);
45946
46105
  return 0;
@@ -46144,7 +46303,7 @@ class GodotServer {
46144
46303
  });
46145
46304
  return Object.assign(started, { settled });
46146
46305
  }
46147
- async launchKeptGame(godotPath, cmdArgs, projectPath, env, desktop) {
46306
+ async launchKeptGame(godotPath, cmdArgs, projectPath, env, desktop, profiled = false) {
46148
46307
  const startedAt = Date.now();
46149
46308
  const transcript = openTranscript(startedAt);
46150
46309
  closeSync2(transcript.fd);
@@ -46152,7 +46311,13 @@ class GodotServer {
46152
46311
  command: godotPath,
46153
46312
  args: cmdArgs,
46154
46313
  ...env === undefined ? {} : { env },
46155
- run: { transcript: transcript.path, startedAt, projectPath, ...this.servedBy() },
46314
+ run: {
46315
+ transcript: transcript.path,
46316
+ startedAt,
46317
+ projectPath,
46318
+ ...this.servedBy(),
46319
+ ...profiled ? { profile: profilePath(startedAt) } : {}
46320
+ },
46156
46321
  ...desktop === undefined ? {} : { desktop }
46157
46322
  });
46158
46323
  if ("error" in launched) {
@@ -46539,6 +46704,115 @@ class GodotServer {
46539
46704
  ...foldedEntriesAnswer(boot.log)
46540
46705
  });
46541
46706
  }
46707
+ async handleRunProfile(args) {
46708
+ await this.pickUpWhatTheEditorIsPlaying();
46709
+ const run = await this.currentRun();
46710
+ if (!run) {
46711
+ return this.createErrorResponse(this.nothingOfOursIsRunning());
46712
+ }
46713
+ const limit = readPositiveNumber(args, "limit") ?? PROFILE_FUNCTIONS;
46714
+ const going = await this.runStillGoing(run);
46715
+ const registered = run.projectPath === null ? null : inspectProject(run.projectPath);
46716
+ const runtimeAddon = registered !== null && (registered.runtimeAutoloadPath !== null || registered.runtimeLoaderAutoload !== null);
46717
+ if (run.throughEditor) {
46718
+ return await this.profileOfThePlayedGame(going, runtimeAddon, limit);
46719
+ }
46720
+ const record = run.projectPath === null ? null : readRunRecord(run.projectPath);
46721
+ const path = record !== null && record.pid === run.pid ? record.profile : undefined;
46722
+ if (path === undefined) {
46723
+ return this.createErrorResponse("This run was started without profile: true, and a game this server starts can be profiled only from its start: its debugger is whatever it dials as it starts, once.", ["editor_run start with profile: true starts the run again with the profiler on"]);
46724
+ }
46725
+ const file = readProfileFile(path);
46726
+ if (file === null) {
46727
+ return this.createErrorResponse(`The run's profile at ${path} could not be read.`, [
46728
+ "editor_run start with profile: true starts the run again with the profiler on"
46729
+ ]);
46730
+ }
46731
+ const totals = file.connected ? file : null;
46732
+ return this.jsonTextResponse(profileAnswer({
46733
+ through: "gdharness",
46734
+ running: going,
46735
+ totals,
46736
+ coveredMs: totals?.lastFrameAt == null ? 0 : totals.lastFrameAt - totals.startedAt,
46737
+ runtimeAddon
46738
+ }, limit));
46739
+ }
46740
+ async profileOfThePlayedGame(going, runtimeAddon, limit) {
46741
+ const read = await this.handleViaBridge("profile_read", {});
46742
+ if (read.isError === true) {
46743
+ return this.profilerRefusal(read);
46744
+ }
46745
+ const held = asParams(JSON.parse(read.content[0]?.text ?? "{}"));
46746
+ if (readBoolean(held, "profiled") !== true && readBoolean(held, "armed") !== true) {
46747
+ if (!going) {
46748
+ return this.createErrorResponse("The game the editor played was not profiled, and it is over.", [
46749
+ "editor_run start with profile: true plays it again with the profiler on from its start"
46750
+ ]);
46751
+ }
46752
+ const started = await this.handleViaBridge("profile_start", {});
46753
+ if (started.isError === true) {
46754
+ return this.profilerRefusal(started);
46755
+ }
46756
+ if (readBoolean(asParams(JSON.parse(started.content[0]?.text ?? "{}")), "started") !== true) {
46757
+ return this.createErrorResponse("The editor says it is playing and has no debugger session for the game, so there is nothing to switch the profiler on in yet.", ["Ask again in a moment, once the game is up"]);
46758
+ }
46759
+ const on = await this.firstProfiledFrame();
46760
+ return this.jsonTextResponse({
46761
+ through: "editor",
46762
+ running: true,
46763
+ started: true,
46764
+ functions: [],
46765
+ note: on ? "The profiler was switched on now, for the game the editor is playing, and its first frame has arrived, so it covers what the game does from here on. Ask again for the functions; editor_run start with profile: true covers a game from its start." : `The profiler was switched on for the game the editor is playing, and no frame of it had arrived ${PROFILER_ON_MS}ms later: what the game does before the first one is not counted. Ask again for the functions; editor_run start with profile: true covers a game from its start.`
46766
+ });
46767
+ }
46768
+ const functions = asParams(held["functions"]);
46769
+ const totals = readBoolean(held, "profiled") === true ? {
46770
+ startedAt: 0,
46771
+ lastFrameAt: null,
46772
+ frames: readNonNegativeNumber(held, "frames") ?? 0,
46773
+ complete: readBoolean(held, "complete") === true,
46774
+ unreadable: 0,
46775
+ functions: Object.fromEntries(Object.entries(functions).map(([signature, sum]) => {
46776
+ const [calls, self, total] = Array.isArray(sum) ? sum : [];
46777
+ return [
46778
+ signature,
46779
+ {
46780
+ calls: Number(calls ?? 0),
46781
+ selfSeconds: Number(self ?? 0),
46782
+ totalSeconds: Number(total ?? 0)
46783
+ }
46784
+ ];
46785
+ }))
46786
+ } : null;
46787
+ return this.jsonTextResponse(profileAnswer({
46788
+ through: "editor",
46789
+ running: going,
46790
+ totals,
46791
+ coveredMs: readNonNegativeNumber(held, "coveredMs") ?? 0,
46792
+ runtimeAddon
46793
+ }, limit));
46794
+ }
46795
+ async firstProfiledFrame() {
46796
+ const deadline = Date.now() + PROFILER_ON_MS;
46797
+ while (Date.now() < deadline) {
46798
+ const read = await this.handleViaBridge("profile_read", {});
46799
+ if (read.isError !== true) {
46800
+ const held = asParams(JSON.parse(read.content[0]?.text ?? "{}"));
46801
+ if ((readNonNegativeNumber(held, "frames") ?? 0) > 0) {
46802
+ return true;
46803
+ }
46804
+ }
46805
+ await delay4(100);
46806
+ }
46807
+ return false;
46808
+ }
46809
+ profilerRefusal(refused) {
46810
+ const said = refused.content[0]?.text ?? "";
46811
+ return said.includes("Unknown tool: profile_") ? this.createErrorResponse("The editor's gdharness addon is older than the profiler, so it cannot profile the game it plays.", [
46812
+ "editor_launch restart loads the addon this server installed",
46813
+ "editor_status says which addon the editor holds"
46814
+ ]) : refused;
46815
+ }
46542
46816
  async handleWaitForRun(args) {
46543
46817
  await this.pickUpWhatTheEditorIsPlaying();
46544
46818
  const run = await this.currentRun();
@@ -47683,7 +47957,7 @@ ${staleNote}`);
47683
47957
 
47684
47958
  ${staleNote}`}`
47685
47959
  },
47686
- { type: "image", data: readFileSync22(screenshotPath).toString("base64"), mimeType: "image/png" }
47960
+ { type: "image", data: readFileSync23(screenshotPath).toString("base64"), mimeType: "image/png" }
47687
47961
  ]
47688
47962
  };
47689
47963
  } finally {
@@ -47784,7 +48058,7 @@ async function runGodotServer() {
47784
48058
  const server = new GodotServer;
47785
48059
  await server.run();
47786
48060
  }
47787
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run4, __dirname2, GREETING_WAIT_MS = 1e4, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, KEEPER_ANSWER_MS = 15000, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, TEST_HEARTBEAT_MS = 20000, DEPENDED_FAILED = "Failed to compile depended scripts", WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/", MOST_ENGINE_ENTRIES = 200, ERROR_ASSERT = "GdUnitGodotErrorAssertImpl.gd", EXTERNAL_MEMBER = 'Could not resolve external class member "';
48061
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run4, __dirname2, GREETING_WAIT_MS = 1e4, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, KEEPER_ANSWER_MS = 15000, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, TEST_HEARTBEAT_MS = 20000, DEPENDED_FAILED = "Failed to compile depended scripts", WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/", PROFILE_FUNCTIONS = 30, PROFILER_ON_MS = 3000, MOST_ENGINE_ENTRIES = 200, ERROR_ASSERT = "GdUnitGodotErrorAssertImpl.gd", EXTERNAL_MEMBER = 'Could not resolve external class member "';
47788
48062
  var init_server3 = __esm(() => {
47789
48063
  init_mcp();
47790
48064
  init_stdio2();
@@ -47827,6 +48101,7 @@ var init_server3 = __esm(() => {
47827
48101
  init_runtime_client();
47828
48102
  init_scratch();
47829
48103
  init_script_origins();
48104
+ init_script_profile();
47830
48105
  init_server_version();
47831
48106
  init_setup();
47832
48107
  init_tool_definitions();
@@ -48914,6 +49189,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
48914
49189
  | What every label on a screen says | \`runtime_inspect tree\` with \`properties\` naming \`text\`, which reads it off every node that has one; \`includeProperties\` is every stored property of every node and ran to seventy-six thousand characters for one row |
48915
49190
  | Whether this engine has a method or property, and what it takes | \`editor_classes info\` with \`member\`, which answers that one member and which class declares it rather than the whole class |
48916
49191
  | Wait for a run to finish | \`editor_run wait\`, never a sleep or a shell loop on the pid: it answers as \`editor_output\` does, once the run is over |
49192
+ | Find which GDScript makes a run slow | \`editor_run start\` with \`profile\` set, then \`editor_run profile\`: the engine's own script profiler, per function with calls and self and total milliseconds, by self time, never \`Time.get_ticks_usec()\` laps edited into the game. A scene that does its work in \`_ready\` and quits is covered whole when the runtime autoload is registered, and a game the editor is already playing is profiled from the moment it is asked |
48917
49193
  | End a bench and the workers it opened | \`editor_run stop\` with \`andChildren\`, never a process listing by scene name: it ends every process under the run's, to any depth, that is a game of the project, announced as one or the project's engine run with \`--path\` on it, names them under \`endedChildren\` with what identified each, and leaves and names any other |
48918
49194
  | End a game of the project that no server is holding | \`editor_run stop\` with its \`pid\` from \`editor_status\` \`runtimes\`, never a process kill by hand: it is ended only when it announced itself for the project and its command line is the project's engine run |
48919
49195
  | Play a game without touching the player's saves | \`editor_run start\` with \`savesIn\`, a directory for this run's \`user://\`: the editor plays with its own environment, so a run given one is started by the server, and the answer says under \`savesNote\` when the platform ignores it |