gdharness 1.2.22 → 1.2.24

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
@@ -651,18 +651,18 @@ function engineFlags(given) {
651
651
  const setBy = Object.hasOwn(SET_BY_ARGUMENT, flag) ? SET_BY_ARGUMENT[flag] : undefined;
652
652
  return {
653
653
  ok: false,
654
- reason: setBy !== undefined ? `${flag} is set by this tool's own argument, ${setBy}, rather than through engineArgs.` : `${flag} is not one of the engine flags engineArgs takes, which are those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames: ${offered}. A value goes as the next entry, as in ["--fixed-fps", "60"].`
654
+ reason: setBy !== undefined ? `${flag} is set by this tool's own argument, ${setBy}, rather than through engineArgs.` : `${flag} is not one of the engine flags engineArgs takes, which are those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames, and Movie Maker's --write-movie: ${offered}. A value goes as the next entry, as in ["--fixed-fps", "60"].`
655
655
  };
656
656
  }
657
657
  if (takes === "none") {
658
658
  continue;
659
659
  }
660
660
  const value = given[at + 1];
661
- const fits = value !== undefined && (takes === "number" ? /^\d+(?:\.\d+)?$/.test(value) : /^\d+x\d+$/.test(value));
662
- if (!fits) {
661
+ const kind = VALUE_KIND[takes];
662
+ if (value === undefined || !kind.pattern.test(value)) {
663
663
  return {
664
664
  ok: false,
665
- reason: `${flag} takes ${takes === "number" ? "a number" : "a size such as 1280x720"} as the next entry, and ${value === undefined ? "nothing followed it" : `${JSON.stringify(value)} is not one`}.`
665
+ reason: `${flag} takes ${kind.named} as the next entry, and ${value === undefined ? "nothing followed it" : `${JSON.stringify(value)} is not one`}.`
666
666
  };
667
667
  }
668
668
  at += 1;
@@ -725,7 +725,7 @@ function environmentFor(options, variables = process.env) {
725
725
  const base = options.savesIn === undefined ? { ...variables } : userDataIn(options.savesIn, variables);
726
726
  return { ...base, ...options.env, GDHARNESS_RUNTIME_DIR: options.runtimeDirectory };
727
727
  }
728
- var ENGINE_FLAGS, SET_BY_ARGUMENT, OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER", EDITOR_PID_VARIABLE = "GDHARNESS_EDITOR_PID", RESERVED_VARIABLE_PREFIX = "GDHARNESS_", SAVES_NOT_MOVED_NOTE = "On macOS the engine reads user:// off HOME and ignores the variables that move it elsewhere, measured on Godot 4.7.2, so this run wrote its saves where the player keeps theirs. A suite that saves a game has written into the same folder as the copy being played, and one that tidies up after itself may have removed real saves.";
728
+ var ENGINE_FLAGS, VALUE_KIND, SET_BY_ARGUMENT, OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER", EDITOR_PID_VARIABLE = "GDHARNESS_EDITOR_PID", RESERVED_VARIABLE_PREFIX = "GDHARNESS_", SAVES_NOT_MOVED_NOTE = "On macOS the engine reads user:// off HOME and ignores the variables that move it elsewhere, measured on Godot 4.7.2, so this run wrote its saves where the player keeps theirs. A suite that saves a game has written into the same folder as the copy being played, and one that tidies up after itself may have removed real saves.";
729
729
  var init_launch = __esm(() => {
730
730
  ENGINE_FLAGS = Object.freeze({
731
731
  "--verbose": "none",
@@ -746,8 +746,14 @@ var init_launch = __esm(() => {
746
746
  "--time-scale": "number",
747
747
  "--fixed-fps": "number",
748
748
  "--max-fps": "number",
749
- "--resolution": "size"
749
+ "--resolution": "size",
750
+ "--write-movie": "file"
750
751
  });
752
+ VALUE_KIND = {
753
+ number: { pattern: /^\d+(?:\.\d+)?$/, named: "a number" },
754
+ size: { pattern: /^\d+x\d+$/, named: "a size such as 1280x720" },
755
+ file: { pattern: /^[^-]/, named: "a file path" }
756
+ };
751
757
  SET_BY_ARGUMENT = Object.freeze({
752
758
  "--headless": "headless",
753
759
  "--audio-driver": "silent",
@@ -11372,7 +11378,12 @@ var init_tool_definitions = __esm(() => {
11372
11378
  ignore: {
11373
11379
  type: "array",
11374
11380
  items: { type: "string" },
11375
- description: 'Suites or cases to leave out, as "suite_name" or "suite_name:test_name".'
11381
+ description: 'Suites or cases to leave out, as "suite_name" or "suite_name:test_name". A bare test name is read as a suite name and leaves out nothing; an entry that named a case which ran all the same comes back under ignoreNotApplied with the form that would have left it out.'
11382
+ },
11383
+ engineArgs: {
11384
+ type: "array",
11385
+ items: { type: "string" },
11386
+ description: `The engine's own flags for the run, such as ["--verbose"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused. With --verbose, engineEntries holds the ordinary lines the run printed as well, which is where the engine names the instances leaked and the resources still in use at exit.`
11376
11387
  },
11377
11388
  failFast: {
11378
11389
  type: "boolean",
@@ -11422,7 +11433,7 @@ var init_tool_definitions = __esm(() => {
11422
11433
  name: "scene_tree",
11423
11434
  title: "Scene tree",
11424
11435
  effect: "read",
11425
- description: "The nodes of a scene file: names, classes and hierarchy, with properties when asked. Needs the editor connected.",
11436
+ description: "The nodes of a scene file: names, classes and hierarchy, with properties when asked. Needs the editor connected. For the nodes of a game that is running, runtime_inspect tree, which needs no editor.",
11426
11437
  parameters: {
11427
11438
  projectPath: PROJECT_PATH,
11428
11439
  scenePath: SCENE_PATH,
@@ -11849,7 +11860,7 @@ var init_tool_definitions = __esm(() => {
11849
11860
  type: "array",
11850
11861
  items: { type: "string" },
11851
11862
  ops: ["start", "check"],
11852
- description: `The engine's own flags, before the separator, such as ["--verbose"] or ["--fixed-fps", "60"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused, since the engine's command line can also run scripts, open other projects and write files. A run with any is started by this server, as one with args is. A check given --verbose answers with everything printed rather than only errors and warnings, since what verbose adds, the instances leaked at exit among it, is printed as ordinary lines.`
11863
+ description: `The engine's own flags, before the separator, such as ["--verbose"] or ["--fixed-fps", "60"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames, and Movie Maker's --write-movie: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused, since the engine's command line can also run scripts, open other projects and write files. --write-movie takes an absolute path outside the project, in a directory that exists, ending in .avi (one file, video and sound) or .png (one numbered PNG per frame and a .wav beside them), where nothing it would write is there already; it needs a start with headless: false, since the engine crashes writing a movie headless, and it records at the project's editor/movie_writer/fps unless --fixed-fps says otherwise. A run with any is started by this server, as one with args is. A check given --verbose answers with everything printed rather than only errors and warnings, since what verbose adds, the instances leaked at exit among it, is printed as ordinary lines.`
11853
11864
  },
11854
11865
  headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
11855
11866
  visible: {
@@ -39747,6 +39758,23 @@ function count(attributes, name) {
39747
39758
  const value = Number(attributes[name] ?? "0");
39748
39759
  return Number.isFinite(value) ? value : 0;
39749
39760
  }
39761
+ function ignoredYetRan(report, ignored) {
39762
+ const found = [];
39763
+ for (const entry of ignored) {
39764
+ const bare = entry.replace("res://", "");
39765
+ const at = bare.lastIndexOf(":");
39766
+ const suiteNamed = at === -1 ? null : bare.slice(0, at);
39767
+ const testNamed = at === -1 ? bare : bare.slice(at + 1);
39768
+ for (const suite of report.suites) {
39769
+ const suiteMatches = suiteNamed === null || suite.name === suiteNamed || (suite.path ?? "").includes(suiteNamed);
39770
+ const ran = suiteMatches ? suite.cases.find((one) => one.name === testNamed) : undefined;
39771
+ if (ran !== undefined) {
39772
+ found.push({ entry, ran: `${suite.name}:${ran.name}` });
39773
+ }
39774
+ }
39775
+ }
39776
+ return found;
39777
+ }
39750
39778
  function caseOf(suite, element) {
39751
39779
  const of = (kind) => element.children.filter((child) => child.name === kind).map((child) => {
39752
39780
  const detail = child.text.trim();
@@ -43244,29 +43272,62 @@ function restartUnderway(note, now = Date.now()) {
43244
43272
  return alive(note.byPid) && now - Date.parse(note.quitAt) < editorRestartTimeoutMs() + 30000;
43245
43273
  }
43246
43274
  function captureDestinationRefusal(outputPath, projects) {
43247
- if (/^(res|user):\/\//.test(outputPath)) {
43248
- return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
43275
+ return writtenFileRefusal(outputPath, {
43276
+ named: "outputPath",
43277
+ what: "a capture",
43278
+ extensions: [".png"],
43279
+ projects,
43280
+ writes: (where) => [where]
43281
+ });
43282
+ }
43283
+ function writtenFileRefusal(path, rule) {
43284
+ const { named, what } = rule;
43285
+ if (/^(res|user):\/\//.test(path)) {
43286
+ return `${named} ${path} is a Godot path; ${what} is saved outside the project, at an absolute path on this machine.`;
43249
43287
  }
43250
- if (!isAbsolute3(outputPath)) {
43251
- return `outputPath ${outputPath} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
43288
+ if (!isAbsolute3(path)) {
43289
+ return `${named} ${path} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
43252
43290
  }
43253
- if (extname3(outputPath).toLowerCase() !== ".png") {
43254
- return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
43291
+ if (!rule.extensions.includes(extname3(path).toLowerCase())) {
43292
+ return `${named} ${path} does not end in ${rule.extensions.join(" or ")}, which is what ${what} is.`;
43255
43293
  }
43256
- const directory = realPathOr(dirname19(outputPath));
43294
+ const directory = realPathOr(dirname19(path));
43257
43295
  if (!existsSync20(directory)) {
43258
- return `outputPath's directory ${dirname19(outputPath)} does not exist; a capture creates no directories.`;
43296
+ return `${named}'s directory ${dirname19(path)} does not exist; ${what} creates no directories.`;
43259
43297
  }
43260
- const where = join26(directory, basename7(outputPath));
43261
- const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
43298
+ const where = join26(directory, basename7(path));
43299
+ const inside = rule.projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
43262
43300
  if (inside !== undefined) {
43263
- return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
43301
+ return `${named} ${path} is inside the project at ${inside}; ${what} is saved outside it, so it never becomes part of the game.`;
43264
43302
  }
43265
- if (existsSync20(where)) {
43266
- return `outputPath ${outputPath} already exists; a capture is not written over a file. Name a new one.`;
43303
+ const taken = rule.writes(where).find((file) => existsSync20(file));
43304
+ if (taken !== undefined) {
43305
+ return taken === where ? `${named} ${path} already exists; ${what} is not written over a file. Name a new one.` : `${named} ${path} would write ${taken}, which already exists; ${what} is not written over a file. Name a new one.`;
43267
43306
  }
43268
43307
  return null;
43269
43308
  }
43309
+ function movieRefusal(engineArgs, projects, headless) {
43310
+ const at = engineArgs.indexOf("--write-movie");
43311
+ if (at === -1) {
43312
+ return null;
43313
+ }
43314
+ if (headless) {
43315
+ return "--write-movie needs a run that draws, and this one is headless: Movie Maker reads every frame back from the renderer, and the engine crashed on the first frame when asked headless. Start the run with headless: false; it is drawn on a desktop of its own and does not show.";
43316
+ }
43317
+ return writtenFileRefusal(engineArgs[at + 1] ?? "", {
43318
+ named: "--write-movie",
43319
+ what: "a movie",
43320
+ extensions: [".avi", ".png"],
43321
+ projects,
43322
+ writes: (where) => {
43323
+ if (extname3(where).toLowerCase() !== ".png") {
43324
+ return [where];
43325
+ }
43326
+ const base = where.slice(0, -".png".length);
43327
+ return [`${base}00000000.png`, `${base}.wav`];
43328
+ }
43329
+ });
43330
+ }
43270
43331
  function captureDetail(payload) {
43271
43332
  const parts = [];
43272
43333
  const region = payload["region"];
@@ -44893,7 +44954,7 @@ class GodotServer {
44893
44954
  const { op: _op, ...arguments_ } = args;
44894
44955
  const bridge = async (command, extra = {}) => {
44895
44956
  const contained = this.containProjectFiles({ ...arguments_, ...extra });
44896
- return contained.ok ? await this.handleViaBridge(command, contained.value) : contained.response;
44957
+ return contained.ok ? await this.handleViaBridge(command, contained.value, tool) : contained.response;
44897
44958
  };
44898
44959
  switch (tool) {
44899
44960
  case "project_info":
@@ -45899,6 +45960,14 @@ class GodotServer {
45899
45960
  if (!paths.ok) {
45900
45961
  return this.createErrorResponse(paths.reason, PATH_SOLUTIONS);
45901
45962
  }
45963
+ const flags = this.engineArguments(args);
45964
+ if (!flags.ok) {
45965
+ return flags.response;
45966
+ }
45967
+ const movie = movieRefusal(flags.value, [project.value.path], true);
45968
+ if (movie !== null) {
45969
+ return this.createErrorResponse(movie);
45970
+ }
45902
45971
  const missing = paths.value.filter((path) => !existsSync20(join26(project.value.path, path.slice("res://".length))));
45903
45972
  const asked = paths.value.filter((path) => !missing.includes(path));
45904
45973
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
@@ -45946,6 +46015,7 @@ class GodotServer {
45946
46015
  "--headless",
45947
46016
  "--path",
45948
46017
  project.value.path,
46018
+ ...flags.value,
45949
46019
  "-s",
45950
46020
  `res://${runner}`,
45951
46021
  "--ignoreHeadlessMode",
@@ -46009,7 +46079,11 @@ class GodotServer {
46009
46079
  rmdirSync2(dirname19(reportsDir));
46010
46080
  } catch {}
46011
46081
  }
46012
- const engineEntries = foldedRepeats(run.log.select({ severity: "warning", sinceLastCall: false, limit: Number.POSITIVE_INFINITY }).entries.map(aboveTheRunner));
46082
+ const engineEntries = foldedRepeats(run.log.select({
46083
+ severity: flags.value.includes("--verbose") ? "info" : "warning",
46084
+ sinceLastCall: false,
46085
+ limit: Number.POSITIVE_INFINITY
46086
+ }).entries.map(aboveTheRunner));
46013
46087
  const engineEntriesShown = engineEntries.slice(-MOST_ENGINE_ENTRIES);
46014
46088
  const verdicts = {
46015
46089
  0: "passed",
@@ -46120,6 +46194,7 @@ class GodotServer {
46120
46194
  const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
46121
46195
  const uncaughtFolded = foldedRepeats(uncaught.map(aboveTheRunner));
46122
46196
  const listedAsUncaught = new Set(uncaughtFolded.map((entry) => entry.index));
46197
+ const notApplied = ignoredYetRan(report, ignored);
46123
46198
  return this.jsonTextResponse({
46124
46199
  passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0 && uncaught.length === 0 && missing.length === 0,
46125
46200
  verdict,
@@ -46138,11 +46213,13 @@ class GodotServer {
46138
46213
  warnings: warnings.length > 0 ? warnings : undefined,
46139
46214
  orphans: orphans.total > 0 ? orphans.total : undefined,
46140
46215
  notRun: notRun > 0 ? notRun : undefined,
46216
+ ignoreNotApplied: notApplied.length > 0 ? notApplied : undefined,
46141
46217
  ...printedLines,
46142
46218
  savesNote,
46143
46219
  ...scanned,
46144
46220
  note: [
46145
46221
  missingNote,
46222
+ notApplied.length > 0 ? "An ignore entry named a case that ran all the same: gdUnit4 leaves a case out only as suite_name:test_name, and reads an entry with no colon as a suite name or a piece of a suite path. ignoreNotApplied gives the form for each case that ran." : undefined,
46146
46223
  uncaughtNote(uncaught),
46147
46224
  ranAs.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRunNote(notRun, readBoolean(args, "failFast") === true)
46148
46225
  ].filter((part) => part !== undefined).join(" ") || undefined,
@@ -47002,6 +47079,10 @@ class GodotServer {
47002
47079
  if (!flags.ok) {
47003
47080
  return flags.response;
47004
47081
  }
47082
+ const movie = movieRefusal(flags.value, [project.value.path], op === "check" || resolveHeadless(args["headless"], { platform: process.platform, variables: process.env }));
47083
+ if (movie !== null) {
47084
+ return this.createErrorResponse(movie);
47085
+ }
47005
47086
  const asked = this.runEnvironment(args);
47006
47087
  if (!asked.ok) {
47007
47088
  return asked.response;
@@ -49101,9 +49182,11 @@ class GodotServer {
49101
49182
  const holds = held["classes"].map(String);
49102
49183
  return { unseen: unseenByEditor(projectPath, holds), stillHeld: heldButGone(projectPath, holds) };
49103
49184
  }
49104
- async handleViaBridge(toolName, args) {
49185
+ async handleViaBridge(toolName, args, askedAs) {
49105
49186
  if (!this.godotBridge.isConnected()) {
49187
+ const instead = askedAs !== undefined && Object.hasOwn(EDITOR_FREE_INSTEAD, askedAs) ? EDITOR_FREE_INSTEAD[askedAs] : undefined;
49106
49188
  return this.createErrorResponse("Editor not connected: no Godot editor with the gdharness editor addon has reached this server.", [
49189
+ ...instead === undefined ? [] : [instead],
49107
49190
  "Open the project in the editor with editor_launch, or by hand",
49108
49191
  "Enable the addon under Project > Project Settings > Plugins",
49109
49192
  "editor_status says whether the bridge is up and what it is listening on"
@@ -49416,7 +49499,7 @@ async function runGodotServer() {
49416
49499
  const server = new GodotServer;
49417
49500
  await server.run();
49418
49501
  }
49419
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run4, __dirname2, EDITOR_SERVERS_START_MS = 20000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, KEEPER_ANSWER_MS = 15000, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, PROFILE_SEND_MS = 5000, MOST_GROUPS = 20, RUNTIME_LISTENING_LINE = "[gdharness] runtime listening on ", 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, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, OPS_ELSEWHERE, 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, PROPERTY_PLACES = "It is a property, so each place says whether it writes it (access write), which runs its set block, or reads it (access read), which runs its get block: withinCalls on a read says nothing of the set block, nor on a write of the get block unless the write is a compound one such as +=. Inside the property's own blocks its name is the value stored, which the engine reads and writes without running them, so those lines are not listed.", CALLERS_FOUND_BY_NAME = "Found by name in the project's scripts, since the engine's profiler counts calls and time per function and records nothing about who made them: a method of the same name on another class is listed too, unless one class was asked about and it is called through another class by name or through a variable declared as one. Each place carries the profile's numbers for the function it is written in.", MOST_ENGINE_ENTRIES = 200, ERROR_ASSERT = "GdUnitGodotErrorAssertImpl.gd", EXTERNAL_MEMBER = 'Could not resolve external class member "', STATIC_LIST_HINT;
49502
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run4, __dirname2, EDITOR_SERVERS_START_MS = 20000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, KEEPER_ANSWER_MS = 15000, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, PROFILE_SEND_MS = 5000, MOST_GROUPS = 20, RUNTIME_LISTENING_LINE = "[gdharness] runtime listening on ", 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, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, EDITOR_FREE_INSTEAD, OPS_ELSEWHERE, 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, PROPERTY_PLACES = "It is a property, so each place says whether it writes it (access write), which runs its set block, or reads it (access read), which runs its get block: withinCalls on a read says nothing of the set block, nor on a write of the get block unless the write is a compound one such as +=. Inside the property's own blocks its name is the value stored, which the engine reads and writes without running them, so those lines are not listed.", CALLERS_FOUND_BY_NAME = "Found by name in the project's scripts, since the engine's profiler counts calls and time per function and records nothing about who made them: a method of the same name on another class is listed too, unless one class was asked about and it is called through another class by name or through a variable declared as one. Each place carries the profile's numbers for the function it is written in.", MOST_ENGINE_ENTRIES = 200, ERROR_ASSERT = "GdUnitGodotErrorAssertImpl.gd", EXTERNAL_MEMBER = 'Could not resolve external class member "', STATIC_LIST_HINT;
49420
49503
  var init_server = __esm(() => {
49421
49504
  init_dist();
49422
49505
  init_stdio();
@@ -49480,6 +49563,9 @@ var init_server = __esm(() => {
49480
49563
  object: "an object"
49481
49564
  };
49482
49565
  HANDOVER_MS = SUCCESSOR_CHECK_MS + 5000;
49566
+ EDITOR_FREE_INSTEAD = {
49567
+ scene_tree: "runtime_inspect tree reads the nodes of a game that is running, and needs no editor; scene_tree reads a scene file through the editor"
49568
+ };
49483
49569
  OPS_ELSEWHERE = {
49484
49570
  runtime_invoke: { get: "runtime_inspect property", property: "runtime_inspect property" }
49485
49571
  };
@@ -284,6 +284,15 @@ static func _code_of(line: String) -> String:
284
284
  return bare if mark == -1 else bare.substr(0, mark)
285
285
 
286
286
 
287
+ # [param content] with each line's comment taken off and its strings kept, which is where the paths
288
+ # a script loads are written.
289
+ static func _code_lines(content: String) -> String:
290
+ var kept: Array[String] = []
291
+ for line: String in content.split("\n"):
292
+ kept.append(Patterns.without_comment(line))
293
+ return "\n".join(kept)
294
+
295
+
287
296
  # The comment on [param line], strings emptied first so a # inside one does not start it.
288
297
  static func _comment_of(line: String) -> String:
289
298
  var bare: String = Patterns.compiled(STRING_LITERAL).sub(line, '""', true)
@@ -361,6 +370,11 @@ func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -
361
370
  if file:
362
371
  var content: String = file.get_as_text()
363
372
  file.close()
373
+ # A script's comments name paths as examples, and read as references they were answered as
374
+ # dependencies, one of a file that does not exist: the shipped serialisation module's comment
375
+ # about `["res://addons/x/plugin.cfg"]` was a dependency of every project with the addon.
376
+ if path.get_extension() == "gd":
377
+ content = _code_lines(content)
364
378
 
365
379
  for pattern: String in REFERENCE_PATTERNS:
366
380
  var regex: RegEx = Patterns.compiled(pattern)
@@ -59,7 +59,7 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
59
59
  if _triple_quotes(raw) % 2 == 1:
60
60
  in_multiline_string = false
61
61
  continue
62
- var stripped: String = _without_comment(raw)
62
+ var stripped: String = Patterns.without_comment(raw)
63
63
 
64
64
  if stripped.is_empty():
65
65
  continue
@@ -101,7 +101,7 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
101
101
  if _can_wrap(header):
102
102
  while _bracket_depth(stripped) > 0 and i + 1 < lines.size():
103
103
  i += 1
104
- stripped = (stripped + " " + _without_comment(lines[i])).strip_edges()
104
+ stripped = (stripped + " " + Patterns.without_comment(lines[i])).strip_edges()
105
105
  header = Patterns.without_annotations(stripped)
106
106
  var declaration: RegExMatch = Patterns.declared_class(header)
107
107
  if declaration != null:
@@ -508,28 +508,6 @@ func _up_to(text: String, needle: String) -> String:
508
508
  return text
509
509
 
510
510
 
511
- # The line with any trailing comment taken off, quote-aware so a `#` inside a string stays.
512
- #
513
- # The only one of these scanners that does not count brackets, on purpose: a `#` starts a comment
514
- # wherever it is, and there is no depth at which it does not. Giving this one the depth its
515
- # neighbours have puts the comment into the value of `var table := { # keyed by name`, because the
516
- # brace opens before the `#` and closes on a later line. Held by a case, since the argument for the
517
- # difference is not visible from the code and reads like an oversight beside its neighbours.
518
- func _without_comment(line: String) -> String:
519
- var quote: String = ""
520
- for i: int in range(line.length()):
521
- var ch: String = line[i]
522
- if not quote.is_empty():
523
- if ch == quote and line[i - 1] != "\\":
524
- quote = ""
525
- continue
526
- if ch == "#":
527
- return line.substr(0, i).strip_edges()
528
- if ch == '"' or ch == "'":
529
- quote = ch
530
- return line.strip_edges()
531
-
532
-
533
511
  # Where the bracket opened at [from] closes, or -1 if it does not.
534
512
  #
535
513
  # `rfind(")")` took the last one on the line, which is the right answer until something after the
@@ -84,3 +84,25 @@ static func without_annotations(line: String) -> String:
84
84
  rest = rest.substr(found.get_end()).strip_edges()
85
85
  found = annotation.search(rest)
86
86
  return rest
87
+
88
+
89
+ # The line with any trailing comment taken off, quote-aware so a `#` inside a string stays.
90
+ #
91
+ # The only one of the scanners here that does not count brackets, on purpose: a `#` starts a comment
92
+ # wherever it is, and there is no depth at which it does not. Giving this one the depth its
93
+ # neighbours have puts the comment into the value of `var table := { # keyed by name`, because the
94
+ # brace opens before the `#` and closes on a later line. Held by a case, since the argument for the
95
+ # difference is not visible from the code and reads like an oversight beside its neighbours.
96
+ static func without_comment(line: String) -> String:
97
+ var quote: String = ""
98
+ for i: int in range(line.length()):
99
+ var ch: String = line[i]
100
+ if not quote.is_empty():
101
+ if ch == quote and line[i - 1] != "\\":
102
+ quote = ""
103
+ continue
104
+ if ch == "#":
105
+ return line.substr(0, i).strip_edges()
106
+ if ch == '"' or ch == "'":
107
+ quote = ch
108
+ return line.strip_edges()
package/build/index.js CHANGED
@@ -24932,8 +24932,14 @@ var ENGINE_FLAGS = Object.freeze({
24932
24932
  "--time-scale": "number",
24933
24933
  "--fixed-fps": "number",
24934
24934
  "--max-fps": "number",
24935
- "--resolution": "size"
24935
+ "--resolution": "size",
24936
+ "--write-movie": "file"
24936
24937
  });
24938
+ var VALUE_KIND = {
24939
+ number: { pattern: /^\d+(?:\.\d+)?$/, named: "a number" },
24940
+ size: { pattern: /^\d+x\d+$/, named: "a size such as 1280x720" },
24941
+ file: { pattern: /^[^-]/, named: "a file path" }
24942
+ };
24937
24943
  var SET_BY_ARGUMENT = Object.freeze({
24938
24944
  "--headless": "headless",
24939
24945
  "--audio-driver": "silent",
@@ -24948,18 +24954,18 @@ function engineFlags(given) {
24948
24954
  const setBy = Object.hasOwn(SET_BY_ARGUMENT, flag) ? SET_BY_ARGUMENT[flag] : undefined;
24949
24955
  return {
24950
24956
  ok: false,
24951
- reason: setBy !== undefined ? `${flag} is set by this tool's own argument, ${setBy}, rather than through engineArgs.` : `${flag} is not one of the engine flags engineArgs takes, which are those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames: ${offered}. A value goes as the next entry, as in ["--fixed-fps", "60"].`
24957
+ reason: setBy !== undefined ? `${flag} is set by this tool's own argument, ${setBy}, rather than through engineArgs.` : `${flag} is not one of the engine flags engineArgs takes, which are those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames, and Movie Maker's --write-movie: ${offered}. A value goes as the next entry, as in ["--fixed-fps", "60"].`
24952
24958
  };
24953
24959
  }
24954
24960
  if (takes === "none") {
24955
24961
  continue;
24956
24962
  }
24957
24963
  const value = given[at + 1];
24958
- const fits = value !== undefined && (takes === "number" ? /^\d+(?:\.\d+)?$/.test(value) : /^\d+x\d+$/.test(value));
24959
- if (!fits) {
24964
+ const kind = VALUE_KIND[takes];
24965
+ if (value === undefined || !kind.pattern.test(value)) {
24960
24966
  return {
24961
24967
  ok: false,
24962
- reason: `${flag} takes ${takes === "number" ? "a number" : "a size such as 1280x720"} as the next entry, and ${value === undefined ? "nothing followed it" : `${JSON.stringify(value)} is not one`}.`
24968
+ reason: `${flag} takes ${kind.named} as the next entry, and ${value === undefined ? "nothing followed it" : `${JSON.stringify(value)} is not one`}.`
24963
24969
  };
24964
24970
  }
24965
24971
  at += 1;
@@ -29963,6 +29969,23 @@ function count(attributes, name) {
29963
29969
  const value = Number(attributes[name] ?? "0");
29964
29970
  return Number.isFinite(value) ? value : 0;
29965
29971
  }
29972
+ function ignoredYetRan(report, ignored) {
29973
+ const found = [];
29974
+ for (const entry of ignored) {
29975
+ const bare = entry.replace("res://", "");
29976
+ const at = bare.lastIndexOf(":");
29977
+ const suiteNamed = at === -1 ? null : bare.slice(0, at);
29978
+ const testNamed = at === -1 ? bare : bare.slice(at + 1);
29979
+ for (const suite of report.suites) {
29980
+ const suiteMatches = suiteNamed === null || suite.name === suiteNamed || (suite.path ?? "").includes(suiteNamed);
29981
+ const ran = suiteMatches ? suite.cases.find((one) => one.name === testNamed) : undefined;
29982
+ if (ran !== undefined) {
29983
+ found.push({ entry, ran: `${suite.name}:${ran.name}` });
29984
+ }
29985
+ }
29986
+ }
29987
+ return found;
29988
+ }
29966
29989
  function caseOf(suite, element) {
29967
29990
  const of = (kind) => element.children.filter((child) => child.name === kind).map((child) => {
29968
29991
  const detail = child.text.trim();
@@ -34404,7 +34427,12 @@ var TOOL_SPECS = [
34404
34427
  ignore: {
34405
34428
  type: "array",
34406
34429
  items: { type: "string" },
34407
- description: 'Suites or cases to leave out, as "suite_name" or "suite_name:test_name".'
34430
+ description: 'Suites or cases to leave out, as "suite_name" or "suite_name:test_name". A bare test name is read as a suite name and leaves out nothing; an entry that named a case which ran all the same comes back under ignoreNotApplied with the form that would have left it out.'
34431
+ },
34432
+ engineArgs: {
34433
+ type: "array",
34434
+ items: { type: "string" },
34435
+ description: `The engine's own flags for the run, such as ["--verbose"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused. With --verbose, engineEntries holds the ordinary lines the run printed as well, which is where the engine names the instances leaked and the resources still in use at exit.`
34408
34436
  },
34409
34437
  failFast: {
34410
34438
  type: "boolean",
@@ -34454,7 +34482,7 @@ var TOOL_SPECS = [
34454
34482
  name: "scene_tree",
34455
34483
  title: "Scene tree",
34456
34484
  effect: "read",
34457
- description: "The nodes of a scene file: names, classes and hierarchy, with properties when asked. Needs the editor connected.",
34485
+ description: "The nodes of a scene file: names, classes and hierarchy, with properties when asked. Needs the editor connected. For the nodes of a game that is running, runtime_inspect tree, which needs no editor.",
34458
34486
  parameters: {
34459
34487
  projectPath: PROJECT_PATH2,
34460
34488
  scenePath: SCENE_PATH,
@@ -34881,7 +34909,7 @@ var TOOL_SPECS = [
34881
34909
  type: "array",
34882
34910
  items: { type: "string" },
34883
34911
  ops: ["start", "check"],
34884
- description: `The engine's own flags, before the separator, such as ["--verbose"] or ["--fixed-fps", "60"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused, since the engine's command line can also run scripts, open other projects and write files. A run with any is started by this server, as one with args is. A check given --verbose answers with everything printed rather than only errors and warnings, since what verbose adds, the instances leaked at exit among it, is printed as ordinary lines.`
34912
+ description: `The engine's own flags, before the separator, such as ["--verbose"] or ["--fixed-fps", "60"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU or how it times frames, and Movie Maker's --write-movie: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused, since the engine's command line can also run scripts, open other projects and write files. --write-movie takes an absolute path outside the project, in a directory that exists, ending in .avi (one file, video and sound) or .png (one numbered PNG per frame and a .wav beside them), where nothing it would write is there already; it needs a start with headless: false, since the engine crashes writing a movie headless, and it records at the project's editor/movie_writer/fps unless --fixed-fps says otherwise. A run with any is started by this server, as one with args is. A check given --verbose answers with everything printed rather than only errors and warnings, since what verbose adds, the instances leaked at exit among it, is printed as ordinary lines.`
34885
34913
  },
34886
34914
  headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
34887
34915
  visible: {
@@ -35911,29 +35939,62 @@ function restartUnderway(note, now = Date.now()) {
35911
35939
  var HOLD_PING_MS = 2000;
35912
35940
  var SLOWEST_FRAME_RATE = 20;
35913
35941
  function captureDestinationRefusal(outputPath, projects) {
35914
- if (/^(res|user):\/\//.test(outputPath)) {
35915
- return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
35942
+ return writtenFileRefusal(outputPath, {
35943
+ named: "outputPath",
35944
+ what: "a capture",
35945
+ extensions: [".png"],
35946
+ projects,
35947
+ writes: (where) => [where]
35948
+ });
35949
+ }
35950
+ function writtenFileRefusal(path, rule) {
35951
+ const { named, what } = rule;
35952
+ if (/^(res|user):\/\//.test(path)) {
35953
+ return `${named} ${path} is a Godot path; ${what} is saved outside the project, at an absolute path on this machine.`;
35916
35954
  }
35917
- if (!isAbsolute3(outputPath)) {
35918
- return `outputPath ${outputPath} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
35955
+ if (!isAbsolute3(path)) {
35956
+ return `${named} ${path} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
35919
35957
  }
35920
- if (extname3(outputPath).toLowerCase() !== ".png") {
35921
- return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
35958
+ if (!rule.extensions.includes(extname3(path).toLowerCase())) {
35959
+ return `${named} ${path} does not end in ${rule.extensions.join(" or ")}, which is what ${what} is.`;
35922
35960
  }
35923
- const directory = realPathOr(dirname15(outputPath));
35961
+ const directory = realPathOr(dirname15(path));
35924
35962
  if (!existsSync17(directory)) {
35925
- return `outputPath's directory ${dirname15(outputPath)} does not exist; a capture creates no directories.`;
35963
+ return `${named}'s directory ${dirname15(path)} does not exist; ${what} creates no directories.`;
35926
35964
  }
35927
- const where = join22(directory, basename5(outputPath));
35928
- const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
35965
+ const where = join22(directory, basename5(path));
35966
+ const inside = rule.projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
35929
35967
  if (inside !== undefined) {
35930
- return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
35968
+ return `${named} ${path} is inside the project at ${inside}; ${what} is saved outside it, so it never becomes part of the game.`;
35931
35969
  }
35932
- if (existsSync17(where)) {
35933
- return `outputPath ${outputPath} already exists; a capture is not written over a file. Name a new one.`;
35970
+ const taken = rule.writes(where).find((file) => existsSync17(file));
35971
+ if (taken !== undefined) {
35972
+ return taken === where ? `${named} ${path} already exists; ${what} is not written over a file. Name a new one.` : `${named} ${path} would write ${taken}, which already exists; ${what} is not written over a file. Name a new one.`;
35934
35973
  }
35935
35974
  return null;
35936
35975
  }
35976
+ function movieRefusal(engineArgs, projects, headless) {
35977
+ const at = engineArgs.indexOf("--write-movie");
35978
+ if (at === -1) {
35979
+ return null;
35980
+ }
35981
+ if (headless) {
35982
+ return "--write-movie needs a run that draws, and this one is headless: Movie Maker reads every frame back from the renderer, and the engine crashed on the first frame when asked headless. Start the run with headless: false; it is drawn on a desktop of its own and does not show.";
35983
+ }
35984
+ return writtenFileRefusal(engineArgs[at + 1] ?? "", {
35985
+ named: "--write-movie",
35986
+ what: "a movie",
35987
+ extensions: [".avi", ".png"],
35988
+ projects,
35989
+ writes: (where) => {
35990
+ if (extname3(where).toLowerCase() !== ".png") {
35991
+ return [where];
35992
+ }
35993
+ const base = where.slice(0, -".png".length);
35994
+ return [`${base}00000000.png`, `${base}.wav`];
35995
+ }
35996
+ });
35997
+ }
35937
35998
  function captureDetail(payload) {
35938
35999
  const parts = [];
35939
36000
  const region = payload["region"];
@@ -36448,6 +36509,9 @@ function engineFlagsNote(args) {
36448
36509
  const named = among.join(", ");
36449
36510
  return `${named} ${among.length === 1 ? "is a flag" : "are flags"} the engine reads, and args goes to the game after --, where the engine does not read ${among.length === 1 ? "it" : "them"}. engineArgs hands the engine its own flags; ${among.length === 1 ? "leave it" : "leave them"} in args only for a game that reads ${among.length === 1 ? "that flag" : "those flags"} itself.`;
36450
36511
  }
36512
+ var EDITOR_FREE_INSTEAD = {
36513
+ scene_tree: "runtime_inspect tree reads the nodes of a game that is running, and needs no editor; scene_tree reads a scene file through the editor"
36514
+ };
36451
36515
  var OPS_ELSEWHERE = {
36452
36516
  runtime_invoke: { get: "runtime_inspect property", property: "runtime_inspect property" }
36453
36517
  };
@@ -37638,7 +37702,7 @@ class GodotServer {
37638
37702
  const { op: _op, ...arguments_ } = args;
37639
37703
  const bridge = async (command, extra = {}) => {
37640
37704
  const contained = this.containProjectFiles({ ...arguments_, ...extra });
37641
- return contained.ok ? await this.handleViaBridge(command, contained.value) : contained.response;
37705
+ return contained.ok ? await this.handleViaBridge(command, contained.value, tool) : contained.response;
37642
37706
  };
37643
37707
  switch (tool) {
37644
37708
  case "project_info":
@@ -38644,6 +38708,14 @@ class GodotServer {
38644
38708
  if (!paths.ok) {
38645
38709
  return this.createErrorResponse(paths.reason, PATH_SOLUTIONS);
38646
38710
  }
38711
+ const flags = this.engineArguments(args);
38712
+ if (!flags.ok) {
38713
+ return flags.response;
38714
+ }
38715
+ const movie = movieRefusal(flags.value, [project.value.path], true);
38716
+ if (movie !== null) {
38717
+ return this.createErrorResponse(movie);
38718
+ }
38647
38719
  const missing = paths.value.filter((path) => !existsSync17(join22(project.value.path, path.slice("res://".length))));
38648
38720
  const asked = paths.value.filter((path) => !missing.includes(path));
38649
38721
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
@@ -38691,6 +38763,7 @@ class GodotServer {
38691
38763
  "--headless",
38692
38764
  "--path",
38693
38765
  project.value.path,
38766
+ ...flags.value,
38694
38767
  "-s",
38695
38768
  `res://${runner}`,
38696
38769
  "--ignoreHeadlessMode",
@@ -38754,7 +38827,11 @@ class GodotServer {
38754
38827
  rmdirSync2(dirname15(reportsDir));
38755
38828
  } catch {}
38756
38829
  }
38757
- const engineEntries = foldedRepeats(run.log.select({ severity: "warning", sinceLastCall: false, limit: Number.POSITIVE_INFINITY }).entries.map(aboveTheRunner));
38830
+ const engineEntries = foldedRepeats(run.log.select({
38831
+ severity: flags.value.includes("--verbose") ? "info" : "warning",
38832
+ sinceLastCall: false,
38833
+ limit: Number.POSITIVE_INFINITY
38834
+ }).entries.map(aboveTheRunner));
38758
38835
  const engineEntriesShown = engineEntries.slice(-MOST_ENGINE_ENTRIES);
38759
38836
  const verdicts = {
38760
38837
  0: "passed",
@@ -38865,6 +38942,7 @@ class GodotServer {
38865
38942
  const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
38866
38943
  const uncaughtFolded = foldedRepeats(uncaught.map(aboveTheRunner));
38867
38944
  const listedAsUncaught = new Set(uncaughtFolded.map((entry) => entry.index));
38945
+ const notApplied = ignoredYetRan(report, ignored);
38868
38946
  return this.jsonTextResponse({
38869
38947
  passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0 && uncaught.length === 0 && missing.length === 0,
38870
38948
  verdict,
@@ -38883,11 +38961,13 @@ class GodotServer {
38883
38961
  warnings: warnings.length > 0 ? warnings : undefined,
38884
38962
  orphans: orphans.total > 0 ? orphans.total : undefined,
38885
38963
  notRun: notRun > 0 ? notRun : undefined,
38964
+ ignoreNotApplied: notApplied.length > 0 ? notApplied : undefined,
38886
38965
  ...printedLines,
38887
38966
  savesNote,
38888
38967
  ...scanned,
38889
38968
  note: [
38890
38969
  missingNote,
38970
+ notApplied.length > 0 ? "An ignore entry named a case that ran all the same: gdUnit4 leaves a case out only as suite_name:test_name, and reads an entry with no colon as a suite name or a piece of a suite path. ignoreNotApplied gives the form for each case that ran." : undefined,
38891
38971
  uncaughtNote(uncaught),
38892
38972
  ranAs.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRunNote(notRun, readBoolean(args, "failFast") === true)
38893
38973
  ].filter((part) => part !== undefined).join(" ") || undefined,
@@ -39747,6 +39827,10 @@ class GodotServer {
39747
39827
  if (!flags.ok) {
39748
39828
  return flags.response;
39749
39829
  }
39830
+ const movie = movieRefusal(flags.value, [project.value.path], op === "check" || resolveHeadless(args["headless"], { platform: process.platform, variables: process.env }));
39831
+ if (movie !== null) {
39832
+ return this.createErrorResponse(movie);
39833
+ }
39750
39834
  const asked = this.runEnvironment(args);
39751
39835
  if (!asked.ok) {
39752
39836
  return asked.response;
@@ -41846,9 +41930,11 @@ class GodotServer {
41846
41930
  const holds = held["classes"].map(String);
41847
41931
  return { unseen: unseenByEditor(projectPath, holds), stillHeld: heldButGone(projectPath, holds) };
41848
41932
  }
41849
- async handleViaBridge(toolName, args) {
41933
+ async handleViaBridge(toolName, args, askedAs) {
41850
41934
  if (!this.godotBridge.isConnected()) {
41935
+ const instead = askedAs !== undefined && Object.hasOwn(EDITOR_FREE_INSTEAD, askedAs) ? EDITOR_FREE_INSTEAD[askedAs] : undefined;
41851
41936
  return this.createErrorResponse("Editor not connected: no Godot editor with the gdharness editor addon has reached this server.", [
41937
+ ...instead === undefined ? [] : [instead],
41852
41938
  "Open the project in the editor with editor_launch, or by hand",
41853
41939
  "Enable the addon under Project > Project Settings > Plugins",
41854
41940
  "editor_status says whether the bridge is up and what it is listening on"
package/build/keeper.js CHANGED
@@ -382,7 +382,8 @@ var ENGINE_FLAGS = Object.freeze({
382
382
  "--time-scale": "number",
383
383
  "--fixed-fps": "number",
384
384
  "--max-fps": "number",
385
- "--resolution": "size"
385
+ "--resolution": "size",
386
+ "--write-movie": "file"
386
387
  });
387
388
  var SET_BY_ARGUMENT = Object.freeze({
388
389
  "--headless": "headless",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.2.22",
3
+ "version": "1.2.24",
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",