gdharness 1.2.18 → 1.2.20

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
@@ -642,6 +642,36 @@ function resolveSilent(requested, variables) {
642
642
  const said = envValue("GDHARNESS_SILENT", variables)?.toLowerCase();
643
643
  return said !== undefined && said !== "0" && said !== "false";
644
644
  }
645
+ function engineFlags(given) {
646
+ const offered = Object.keys(ENGINE_FLAGS).join(", ");
647
+ for (let at = 0;at < given.length; at += 1) {
648
+ const flag = given[at] ?? "";
649
+ const takes = Object.hasOwn(ENGINE_FLAGS, flag) ? ENGINE_FLAGS[flag] : undefined;
650
+ if (takes === undefined) {
651
+ const setBy = Object.hasOwn(SET_BY_ARGUMENT, flag) ? SET_BY_ARGUMENT[flag] : undefined;
652
+ return {
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"].`
655
+ };
656
+ }
657
+ if (takes === "none") {
658
+ continue;
659
+ }
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) {
663
+ return {
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`}.`
666
+ };
667
+ }
668
+ at += 1;
669
+ }
670
+ return { ok: true };
671
+ }
672
+ function engineFlagsAmongGameArgs(args) {
673
+ return args.filter((one) => Object.hasOwn(ENGINE_FLAGS, one) || Object.hasOwn(SET_BY_ARGUMENT, one));
674
+ }
645
675
  function runArguments(options) {
646
676
  const args = options.headless ? ["--headless", "--path", options.projectPath] : ["--path", options.projectPath];
647
677
  if (options.silent === true && !options.headless) {
@@ -650,6 +680,9 @@ function runArguments(options) {
650
680
  if (options.quitAfter !== undefined && options.quitAfter !== null) {
651
681
  args.push("--quit-after", String(options.quitAfter));
652
682
  }
683
+ if (options.engineArgs !== undefined) {
684
+ args.push(...options.engineArgs);
685
+ }
653
686
  if (options.scene !== null) {
654
687
  args.push(`res://${options.scene}`);
655
688
  }
@@ -692,7 +725,35 @@ function environmentFor(options, variables = process.env) {
692
725
  const base = options.savesIn === undefined ? { ...variables } : userDataIn(options.savesIn, variables);
693
726
  return { ...base, ...options.env, GDHARNESS_RUNTIME_DIR: options.runtimeDirectory };
694
727
  }
695
- var 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, 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
+ var init_launch = __esm(() => {
730
+ ENGINE_FLAGS = Object.freeze({
731
+ "--verbose": "none",
732
+ "--quiet": "none",
733
+ "--debug-collisions": "none",
734
+ "--debug-paths": "none",
735
+ "--debug-navigation": "none",
736
+ "--debug-avoidance": "none",
737
+ "--debug-stringnames": "none",
738
+ "--debug-canvas-item-redraw": "none",
739
+ "--debug-mute-audio": "none",
740
+ "--print-fps": "none",
741
+ "--gpu-profile": "none",
742
+ "--gpu-validation": "none",
743
+ "--gpu-abort": "none",
744
+ "--disable-vsync": "none",
745
+ "--frame-delay": "number",
746
+ "--time-scale": "number",
747
+ "--fixed-fps": "number",
748
+ "--max-fps": "number",
749
+ "--resolution": "size"
750
+ });
751
+ SET_BY_ARGUMENT = Object.freeze({
752
+ "--headless": "headless",
753
+ "--audio-driver": "silent",
754
+ "--quit-after": "frames, on editor_run check"
755
+ });
756
+ });
696
757
 
697
758
  // src/godot-path.ts
698
759
  import { execFile as execFile2 } from "node:child_process";
@@ -769,6 +830,7 @@ var run2;
769
830
  var init_godot_path = __esm(() => {
770
831
  init_detection();
771
832
  init_errors();
833
+ init_launch();
772
834
  run2 = promisify2(execFile2);
773
835
  });
774
836
 
@@ -10840,6 +10902,7 @@ function annotationsOf(spec) {
10840
10902
  }
10841
10903
  var PROJECT_INFO_SECTIONS, PROJECT_PATH, RUNNING_PROJECT_PATH, RUNNING_PID, SCENE_PATH, SCRIPT_PATH, RESOURCE_PATH, NODE_PATH, PROPERTIES, XY, ANIMATION_TRACK, SCRIPT_MODIFICATIONS, INPUT_EVENTS, TILESET_SOURCES, TILEMAP_CELLS, COLOUR, TOOL_SPECS, SPECS_BY_NAME;
10842
10904
  var init_tool_definitions = __esm(() => {
10905
+ init_launch();
10843
10906
  PROJECT_INFO_SECTIONS = [
10844
10907
  "autoloads",
10845
10908
  "plugins",
@@ -11758,7 +11821,13 @@ var init_tool_definitions = __esm(() => {
11758
11821
  type: "array",
11759
11822
  items: { type: "string" },
11760
11823
  ops: ["start", "check"],
11761
- description: `The game's own arguments, what OS.get_cmdline_user_args() answers, such as ["--level=2"]. The separator is added here. A run with any is started by this server rather than by the editor, which fixes the game's command line when it opens the project, so the debug_* tools do not answer for it.`
11824
+ description: `The game's own arguments, what OS.get_cmdline_user_args() answers, such as ["--level=2"]. The separator is added here, so a flag the engine reads goes to the game instead, and the answer says so under argsNote. A run with any is started by this server rather than by the editor, which fixes the game's command line when it opens the project, so the debug_* tools do not answer for it.`
11825
+ },
11826
+ engineArgs: {
11827
+ type: "array",
11828
+ items: { type: "string" },
11829
+ ops: ["start", "check"],
11830
+ 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.`
11762
11831
  },
11763
11832
  headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
11764
11833
  visible: {
@@ -11855,7 +11924,7 @@ var init_tool_definitions = __esm(() => {
11855
11924
  requires: []
11856
11925
  }
11857
11926
  },
11858
- answer: "stop: endedPid is, for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled, the note naming which property it failed on, and false with the number under stillGoing when what was told to end was still running ten seconds later. endedThrough says how the stop ended a run that was going: keeper (the process that started the game ended it through the handle it holds), signal (its pid was signalled once shown to still be the run), or editor (the editor playing it was asked). A start that ends the run before it says how under endedPreviousRunThrough, with the same values, and one that refused to signal that run names the property it failed on under previousRunLeftBecause. check: hung is a run killed at timeoutMs, and errors and warnings count what it printed, which comes back under entries with repeats folded together. profile: per function its script, the function (Class.name when the script declares a class_name), the line its body's first statement is on, for a lambda the named function it is written in (within), calls, and self and total milliseconds, with functionsCounted, scriptMs (the self time of them all) and coveredMs. 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 call through super too, while the overridden function's row counts it again: such a row carries selfIncludesSuper, so self times along an override chain overlap. The profiler's own cost of timing a call lands in the self time of the function called; with the runtime addon it is measured each run on an empty function, as perCallOverheadUs, and a row with at least half its selfMs in it carries overheadMs, since a small function called often costs that much less without the profiler. A function the profiler started inside is left out, since its time would be counted from the engine's start. The milliseconds are time passed while the function ran, not processor time given to it, so they grow when other processes compete for the processor: compare two profiles taken under the same load. They are also summed over every thread: a function run by WorkerThreadPool or a Thread on several threads at once counts each thread's time, the engine does not say which thread a call ran on, and a function waiting for them has the wait in its own self time, so selfMs is not time taken from frames, and scriptMs can exceed coveredMs, which the note then says. A property's get and set blocks are named Class.@name_getter and Class.@name_setter, as the engine names them, and their rows carry property, the property's name. A game the editor plays that was not started with profile: true says, in the answer, that it was profiled from the moment it was asked.",
11927
+ answer: "stop: endedPid is, for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled, the note naming which property it failed on, and false with the number under stillGoing when what was told to end was still running ten seconds later. endedThrough says how the stop ended a run that was going: keeper (the process that started the game ended it through the handle it holds), signal (its pid was signalled once shown to still be the run), or editor (the editor playing it was asked). A start that ends the run before it says how under endedPreviousRunThrough, with the same values, and one that refused to signal that run names the property it failed on under previousRunLeftBecause. check: hung is a run killed at timeoutMs, and errors and warnings count what it printed, which comes back under entries with repeats folded together. arguments is the engine command line the check ran, as a start's is. profile: per function its script, the function (Class.name when the script declares a class_name), the line its body's first statement is on, for a lambda the named function it is written in (within), calls, and self and total milliseconds, with functionsCounted, scriptMs (the self time of them all) and coveredMs. 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 call through super too, while the overridden function's row counts it again: such a row carries selfIncludesSuper, so self times along an override chain overlap. The profiler's own cost of timing a call lands in the self time of the function called; with the runtime addon it is measured each run on an empty function, as perCallOverheadUs, and a row with at least half its selfMs in it carries overheadMs, since a small function called often costs that much less without the profiler. A function the profiler started inside is left out, since its time would be counted from the engine's start. The milliseconds are time passed while the function ran, not processor time given to it, so they grow when other processes compete for the processor: compare two profiles taken under the same load. They are also summed over every thread: a function run by WorkerThreadPool or a Thread on several threads at once counts each thread's time, the engine does not say which thread a call ran on, and a function waiting for them has the wait in its own self time, so selfMs is not time taken from frames, and scriptMs can exceed coveredMs, which the note then says. A property's get and set blocks are named Class.@name_getter and Class.@name_setter, as the engine names them, and their rows carry property, the property's name. A game the editor plays that was not started with profile: true says, in the answer, that it was profiled from the moment it was asked.",
11859
11928
  defaultOperation: "start"
11860
11929
  },
11861
11930
  {
@@ -34734,6 +34803,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs, keepListening = fa
34734
34803
  var RUNTIME_PROTOCOL = 2, ANNOUNCEMENT_PATTERN, ERROR_REPORT_PATTERN, CONSOLE_BOUNDARY = "[gdharness] runtime listening on ", ERROR_REPORT_KEEP_MS, GONE_WINDOW_MS = 120000, GONE_KEPT = 32, gone, STARTED_AFTER_ANNOUNCING_MS = 2000, CONFIRMED_FOR_MS = 60000, JUDGED_AFTER_MS = 60000, verdicts, judging = null, ENDED_WITHIN_MS = 3000, LOOK_EVERY_MS = 50, ANNOUNCE_BUDGET_MS = 5000, nextRequestId = 1, LATE_ANSWER_MS, LATE_KEPT = 50, lateAnswers;
34735
34804
  var init_runtime_client = __esm(() => {
34736
34805
  init_alive();
34806
+ init_launch();
34737
34807
  init_paths();
34738
34808
  init_process_children();
34739
34809
  ANNOUNCEMENT_PATTERN = /^runtime-(\d+)\.json$/;
@@ -40746,6 +40816,7 @@ var init_lsp_client = __esm(() => {
40746
40816
  init_answer_json();
40747
40817
  init_errors();
40748
40818
  init_framing();
40819
+ init_launch();
40749
40820
  init_paths();
40750
40821
  init_ports();
40751
40822
  LSPTimeout = class LSPTimeout extends Error {
@@ -42409,19 +42480,41 @@ var init_restart_note = __esm(() => {
42409
42480
  init_errors();
42410
42481
  });
42411
42482
 
42483
+ // src/whole-file.ts
42484
+ import { renameSync as renameSync2, rmSync as rmSync12, writeFileSync as writeFileSync16 } from "node:fs";
42485
+ function writeWhole(path, text) {
42486
+ const partial = `${path}.${process.pid}.partial`;
42487
+ try {
42488
+ writeFileSync16(partial, text, "utf8");
42489
+ } catch {
42490
+ return false;
42491
+ }
42492
+ const until = Date.now() + HELD_FILE_MS;
42493
+ for (;; ) {
42494
+ try {
42495
+ renameSync2(partial, path);
42496
+ return true;
42497
+ } catch (error) {
42498
+ const code = error.code ?? "";
42499
+ if (!HELD_BY_A_READER.has(code) || Date.now() >= until) {
42500
+ try {
42501
+ rmSync12(partial, { force: true });
42502
+ } catch {}
42503
+ return false;
42504
+ }
42505
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, HELD_FILE_RETRY_MS);
42506
+ }
42507
+ }
42508
+ }
42509
+ var HELD_FILE_MS = 1000, HELD_FILE_RETRY_MS = 10, HELD_BY_A_READER;
42510
+ var init_whole_file = __esm(() => {
42511
+ HELD_BY_A_READER = new Set(["EPERM", "EACCES", "EBUSY"]);
42512
+ });
42513
+
42412
42514
  // src/run-record.ts
42413
42515
  import { execFile as execFile4 } from "node:child_process";
42414
42516
  import { createHash as createHash4 } from "node:crypto";
42415
- import {
42416
- mkdirSync as mkdirSync14,
42417
- openSync as openSync2,
42418
- readdirSync as readdirSync10,
42419
- readFileSync as readFileSync21,
42420
- realpathSync as realpathSync6,
42421
- rmSync as rmSync12,
42422
- statSync as statSync8,
42423
- writeFileSync as writeFileSync16
42424
- } from "node:fs";
42517
+ import { mkdirSync as mkdirSync14, openSync as openSync2, readdirSync as readdirSync10, readFileSync as readFileSync21, realpathSync as realpathSync6, rmSync as rmSync13, statSync as statSync8 } from "node:fs";
42425
42518
  import { basename as basename6, dirname as dirname18, join as join24, resolve as resolve7 } from "node:path";
42426
42519
  import { promisify as promisify4 } from "node:util";
42427
42520
  function runsDirectory() {
@@ -42457,7 +42550,7 @@ function latestRunPath(servedBy, directory = runtimeDirectory()) {
42457
42550
  function writeLatestRun(servedBy, latest) {
42458
42551
  try {
42459
42552
  mkdirSync14(runsDirectory(), { recursive: true });
42460
- writeFileSync16(latestRunPath(servedBy), JSON.stringify(latest, null, 2), "utf8");
42553
+ writeWhole(latestRunPath(servedBy), JSON.stringify(latest, null, 2));
42461
42554
  } catch {}
42462
42555
  }
42463
42556
  function readLatestRun(servedBy, directory = runtimeDirectory()) {
@@ -42491,7 +42584,10 @@ function editorNotePath(projectPath, directory = runtimeDirectory()) {
42491
42584
  }
42492
42585
  function writeEditorRunNote(note) {
42493
42586
  mkdirSync14(runsDirectory(), { recursive: true });
42494
- writeFileSync16(editorNotePath(note.projectPath), JSON.stringify(note, null, 2), "utf8");
42587
+ const path = editorNotePath(note.projectPath);
42588
+ if (!writeWhole(path, JSON.stringify(note, null, 2))) {
42589
+ throw new Error(`the editor run note could not be written at ${path}`);
42590
+ }
42495
42591
  }
42496
42592
  function readEditorRunNote(projectPath) {
42497
42593
  for (const directory of runtimeDirectories()) {
@@ -42762,7 +42858,7 @@ function clearRunRecord(projectPath) {
42762
42858
  const ours = sharedRecord !== null && sharedRecord.projectPath !== "" && isSameDirectory(sharedRecord.projectPath, projectPath);
42763
42859
  for (const path of [runRecordPath(projectPath, directory), ...ours ? [shared] : []]) {
42764
42860
  try {
42765
- rmSync12(path, { force: true });
42861
+ rmSync13(path, { force: true });
42766
42862
  } catch {}
42767
42863
  }
42768
42864
  }
@@ -42787,7 +42883,8 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
42787
42883
  return;
42788
42884
  }
42789
42885
  for (const entry of entries) {
42790
- if (!entry.startsWith("run-") || !(entry.endsWith(".log") || entry.endsWith(".profile.json"))) {
42886
+ const transcript = entry.startsWith("run-") && (entry.endsWith(".log") || entry.endsWith(".profile.json"));
42887
+ if (!transcript && !entry.endsWith(".partial")) {
42791
42888
  continue;
42792
42889
  }
42793
42890
  const path = join24(directory, entry);
@@ -42795,7 +42892,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
42795
42892
  continue;
42796
42893
  }
42797
42894
  try {
42798
- rmSync12(path);
42895
+ rmSync13(path);
42799
42896
  } catch {}
42800
42897
  }
42801
42898
  }
@@ -42805,6 +42902,7 @@ var init_run_record = __esm(() => {
42805
42902
  init_paths();
42806
42903
  init_process_children();
42807
42904
  init_runtime_client();
42905
+ init_whole_file();
42808
42906
  init_windows_ask();
42809
42907
  execFileAsync = promisify4(execFile4);
42810
42908
  });
@@ -42915,7 +43013,7 @@ var init_godot_variant = __esm(() => {
42915
43013
  });
42916
43014
 
42917
43015
  // src/script-profile.ts
42918
- import { readFileSync as readFileSync22, renameSync as renameSync2, writeFileSync as writeFileSync17 } from "node:fs";
43016
+ import { readFileSync as readFileSync22 } from "node:fs";
42919
43017
  function readProfileFile(path) {
42920
43018
  try {
42921
43019
  const parsed = JSON.parse(readFileSync22(path, "utf8"));
@@ -43069,6 +43167,7 @@ var init_script_profile = __esm(() => {
43069
43167
  init_gdscript_source();
43070
43168
  init_godot_variant();
43071
43169
  init_resources();
43170
+ init_whole_file();
43072
43171
  });
43073
43172
 
43074
43173
  // src/server-instructions.ts
@@ -43618,6 +43717,14 @@ function scanWaitAnswer(scanned, started = "game") {
43618
43717
  scanNote: !scanned.stillScanning ? undefined : started === "game" ? `${still} this game started during the scan and may have read a class cache being rewritten. If ` + 'editor_output shows "Could not find type" for a class that is declared, editor_rescan ' + "waits for the scan to finish, and a start after it reads the finished cache." : `${still} the engine answering this started during the scan and may have read a class cache being ` + "rewritten, and a class cache it wrote can be written over when the scan finishes. If " + 'engine_messages shows "Could not find type" for a class that is declared, editor_rescan waits ' + "for the scan to finish, and the same call after it reads the finished cache."
43619
43718
  };
43620
43719
  }
43720
+ function engineFlagsNote(args) {
43721
+ const among = engineFlagsAmongGameArgs(args);
43722
+ if (among.length === 0) {
43723
+ return;
43724
+ }
43725
+ const named = among.join(", ");
43726
+ 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.`;
43727
+ }
43621
43728
  function opElsewhere(tool, requested) {
43622
43729
  const ops = Object.hasOwn(OPS_ELSEWHERE, tool) ? OPS_ELSEWHERE[tool] : undefined;
43623
43730
  const where = ops !== undefined && typeof requested === "string" && Object.hasOwn(ops, requested) ? ops[requested] : undefined;
@@ -43999,9 +44106,9 @@ function callersAnswer(totals, asked) {
43999
44106
  note: found.sites.length === 0 ? `No script in the project names ${asked.name} in code, outside its declaration: a call made through a string, such as call("name"), is not found by name.` : found.property ? `${byName} ${PROPERTY_PLACES}` : byName
44000
44107
  };
44001
44108
  }
44002
- function foldedEntriesAnswer(log) {
44109
+ function foldedEntriesAnswer(log, floor = "warning") {
44003
44110
  const problems = log.selectFolded({
44004
- severity: "warning",
44111
+ severity: floor,
44005
44112
  sinceLastCall: false,
44006
44113
  limit: MOST_ENGINE_ENTRIES
44007
44114
  });
@@ -45026,6 +45133,25 @@ class GodotServer {
45026
45133
  }
45027
45134
  return { ok: true, value: given };
45028
45135
  }
45136
+ engineArguments(args) {
45137
+ if (args["engineArgs"] === undefined) {
45138
+ return { ok: true, value: [] };
45139
+ }
45140
+ const given = readStringArray(args, "engineArgs");
45141
+ if (given === undefined) {
45142
+ return {
45143
+ ok: false,
45144
+ response: this.createErrorResponse("engineArgs must be a list of strings.", [
45145
+ 'Pass engine flags, such as ["--verbose"] or ["--fixed-fps", "60"]'
45146
+ ])
45147
+ };
45148
+ }
45149
+ const checked = engineFlags(given);
45150
+ if (!checked.ok) {
45151
+ return { ok: false, response: this.createErrorResponse(checked.reason) };
45152
+ }
45153
+ return { ok: true, value: given };
45154
+ }
45029
45155
  runEnvironment(args) {
45030
45156
  const savesIn = readNonEmptyString(args, "savesIn");
45031
45157
  if (savesIn !== undefined && !isAbsolute3(savesIn)) {
@@ -46842,6 +46968,10 @@ class GodotServer {
46842
46968
  if (!given.ok) {
46843
46969
  return given.response;
46844
46970
  }
46971
+ const flags = this.engineArguments(args);
46972
+ if (!flags.ok) {
46973
+ return flags.response;
46974
+ }
46845
46975
  const asked = this.runEnvironment(args);
46846
46976
  if (!asked.ok) {
46847
46977
  return asked.response;
@@ -46863,7 +46993,7 @@ class GodotServer {
46863
46993
  }
46864
46994
  const sceneArgument = sceneToRun?.ok ? sceneToRun.relativePath : null;
46865
46995
  if (op === "check") {
46866
- return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, scanned, asked.value);
46996
+ return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, scanned, asked.value, flags.value);
46867
46997
  }
46868
46998
  await this.pickUpWhatTheEditorIsPlaying();
46869
46999
  let ended = null;
@@ -46890,7 +47020,7 @@ class GodotServer {
46890
47020
  const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
46891
47021
  const silent = resolveSilent(args["silent"], process.env) && !headless;
46892
47022
  const profiled = readBoolean(args, "profile") === true;
46893
- if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0 && !needsItsOwnProcess(asked.value) && !silent) {
47023
+ if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0 && flags.value.length === 0 && !needsItsOwnProcess(asked.value) && !silent) {
46894
47024
  return await this.playThroughEditor(sceneArgument, refreshed.value, scanned, project.value.path, alreadyPlaying, runtimeWaitMs, ended, profiled);
46895
47025
  }
46896
47026
  const cmdArgs = runArguments({
@@ -46898,6 +47028,7 @@ class GodotServer {
46898
47028
  headless,
46899
47029
  scene: sceneArgument,
46900
47030
  userArgs: given.value,
47031
+ engineArgs: flags.value,
46901
47032
  silent
46902
47033
  });
46903
47034
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
@@ -46910,7 +47041,7 @@ class GodotServer {
46910
47041
  started.announcedBefore = alreadyPlaying;
46911
47042
  started.bootScene = runtimeWaitMs.scene;
46912
47043
  this.activeProcess = started;
46913
- const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : this.godotBridge.isConnected() && editorWouldPlay && needsItsOwnProcess(asked.value) ? " The editor plays a game with its own environment, so a run given one was started " + "here: the debug_* tools answer only for a game the editor is playing." : this.godotBridge.isConnected() && editorWouldPlay && silent ? " The editor cannot play a game silent, which takes an engine flag, so this one was " + `started here${args["silent"] === true ? "" : ", silent because GDHARNESS_SILENT says so"}: the debug_* tools answer only for a game the editor is playing, and silent: false plays it through the editor.` : "";
47044
+ const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && (given.value.length > 0 || flags.value.length > 0) ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : this.godotBridge.isConnected() && editorWouldPlay && needsItsOwnProcess(asked.value) ? " The editor plays a game with its own environment, so a run given one was started " + "here: the debug_* tools answer only for a game the editor is playing." : this.godotBridge.isConnected() && editorWouldPlay && silent ? " The editor cannot play a game silent, which takes an engine flag, so this one was " + `started here${args["silent"] === true ? "" : ", silent because GDHARNESS_SILENT says so"}: the debug_* tools answer only for a game the editor is playing, and silent: false plays it through the editor.` : "";
46914
47045
  const endedForThis = endedToStartThis(ended);
46915
47046
  const stranded = wereHereFirst.filter((one) => one.pid !== ended?.pid && alive(one.pid));
46916
47047
  const alsoRunning = leftRunningNote(stranded);
@@ -46920,6 +47051,7 @@ class GodotServer {
46920
47051
  alsoRunning: stranded.length === 0 ? undefined : stranded.map((one) => one.pid),
46921
47052
  pid: started.pid,
46922
47053
  arguments: cmdArgs,
47054
+ argsNote: engineFlagsNote(given.value),
46923
47055
  transcript: started.transcript,
46924
47056
  profile: profileOnStart(profiled, "gdharness", runtimeAddonRegistered(project.value.path)),
46925
47057
  refreshedClasses: refreshed.value,
@@ -47822,10 +47954,17 @@ class GodotServer {
47822
47954
  async currentRun() {
47823
47955
  return this.activeProcess ?? await this.adoptRecordedRun();
47824
47956
  }
47825
- async checkBoot(godotPath, projectPath, scene, args, userArgs, scanned, asked = {}) {
47957
+ async checkBoot(godotPath, projectPath, scene, args, userArgs, scanned, asked = {}, engineArgs = []) {
47826
47958
  const frames = readPositiveNumber(args, "frames") ?? 3;
47827
47959
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
47828
- const cmdArgs = runArguments({ projectPath, headless: true, scene, quitAfter: frames, userArgs });
47960
+ const cmdArgs = runArguments({
47961
+ projectPath,
47962
+ headless: true,
47963
+ scene,
47964
+ quitAfter: frames,
47965
+ userArgs,
47966
+ engineArgs
47967
+ });
47829
47968
  const boot = this.spawnGame(godotPath, cmdArgs, needsItsOwnProcess(asked) ? environmentFor({ ...asked, runtimeDirectory: runtimeDirectory() }) : undefined);
47830
47969
  const hung = await new Promise((resolve) => {
47831
47970
  const timer = setTimeout(() => {
@@ -47853,9 +47992,11 @@ class GodotServer {
47853
47992
  frames,
47854
47993
  errors,
47855
47994
  warnings,
47995
+ arguments: cmdArgs,
47996
+ argsNote: engineFlagsNote(userArgs),
47856
47997
  ...scanWaitAnswer(scanned),
47857
47998
  savesNote: asked.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
47858
- ...foldedEntriesAnswer(boot.log)
47999
+ ...foldedEntriesAnswer(boot.log, engineArgs.includes("--verbose") ? "info" : "warning")
47859
48000
  });
47860
48001
  }
47861
48002
  async handleRunProfile(args) {
@@ -49261,6 +49402,7 @@ var init_server = __esm(() => {
49261
49402
  init_issues();
49262
49403
  init_junit();
49263
49404
  init_keeper_channel();
49405
+ init_launch();
49264
49406
  init_lsp_client();
49265
49407
  init_outside();
49266
49408
  init_paths();
package/build/index.js CHANGED
@@ -24913,6 +24913,62 @@ function resolveSilent(requested, variables) {
24913
24913
  const said = envValue("GDHARNESS_SILENT", variables)?.toLowerCase();
24914
24914
  return said !== undefined && said !== "0" && said !== "false";
24915
24915
  }
24916
+ var ENGINE_FLAGS = Object.freeze({
24917
+ "--verbose": "none",
24918
+ "--quiet": "none",
24919
+ "--debug-collisions": "none",
24920
+ "--debug-paths": "none",
24921
+ "--debug-navigation": "none",
24922
+ "--debug-avoidance": "none",
24923
+ "--debug-stringnames": "none",
24924
+ "--debug-canvas-item-redraw": "none",
24925
+ "--debug-mute-audio": "none",
24926
+ "--print-fps": "none",
24927
+ "--gpu-profile": "none",
24928
+ "--gpu-validation": "none",
24929
+ "--gpu-abort": "none",
24930
+ "--disable-vsync": "none",
24931
+ "--frame-delay": "number",
24932
+ "--time-scale": "number",
24933
+ "--fixed-fps": "number",
24934
+ "--max-fps": "number",
24935
+ "--resolution": "size"
24936
+ });
24937
+ var SET_BY_ARGUMENT = Object.freeze({
24938
+ "--headless": "headless",
24939
+ "--audio-driver": "silent",
24940
+ "--quit-after": "frames, on editor_run check"
24941
+ });
24942
+ function engineFlags(given) {
24943
+ const offered = Object.keys(ENGINE_FLAGS).join(", ");
24944
+ for (let at = 0;at < given.length; at += 1) {
24945
+ const flag = given[at] ?? "";
24946
+ const takes = Object.hasOwn(ENGINE_FLAGS, flag) ? ENGINE_FLAGS[flag] : undefined;
24947
+ if (takes === undefined) {
24948
+ const setBy = Object.hasOwn(SET_BY_ARGUMENT, flag) ? SET_BY_ARGUMENT[flag] : undefined;
24949
+ return {
24950
+ 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"].`
24952
+ };
24953
+ }
24954
+ if (takes === "none") {
24955
+ continue;
24956
+ }
24957
+ const value = given[at + 1];
24958
+ const fits = value !== undefined && (takes === "number" ? /^\d+(?:\.\d+)?$/.test(value) : /^\d+x\d+$/.test(value));
24959
+ if (!fits) {
24960
+ return {
24961
+ 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`}.`
24963
+ };
24964
+ }
24965
+ at += 1;
24966
+ }
24967
+ return { ok: true };
24968
+ }
24969
+ function engineFlagsAmongGameArgs(args) {
24970
+ return args.filter((one) => Object.hasOwn(ENGINE_FLAGS, one) || Object.hasOwn(SET_BY_ARGUMENT, one));
24971
+ }
24916
24972
  function runArguments(options) {
24917
24973
  const args = options.headless ? ["--headless", "--path", options.projectPath] : ["--path", options.projectPath];
24918
24974
  if (options.silent === true && !options.headless) {
@@ -24921,6 +24977,9 @@ function runArguments(options) {
24921
24977
  if (options.quitAfter !== undefined && options.quitAfter !== null) {
24922
24978
  args.push("--quit-after", String(options.quitAfter));
24923
24979
  }
24980
+ if (options.engineArgs !== undefined) {
24981
+ args.push(...options.engineArgs);
24982
+ }
24924
24983
  if (options.scene !== null) {
24925
24984
  args.push(`res://${options.scene}`);
24926
24985
  }
@@ -33162,18 +33221,41 @@ function restartSettled(projectPath) {
33162
33221
  // src/run-record.ts
33163
33222
  import { execFile as execFile4 } from "node:child_process";
33164
33223
  import { createHash as createHash3 } from "node:crypto";
33165
- import {
33166
- mkdirSync as mkdirSync11,
33167
- openSync as openSync2,
33168
- readdirSync as readdirSync7,
33169
- readFileSync as readFileSync18,
33170
- realpathSync as realpathSync6,
33171
- rmSync as rmSync9,
33172
- statSync as statSync6,
33173
- writeFileSync as writeFileSync13
33174
- } from "node:fs";
33224
+ import { mkdirSync as mkdirSync11, openSync as openSync2, readdirSync as readdirSync7, readFileSync as readFileSync18, realpathSync as realpathSync6, rmSync as rmSync10, statSync as statSync6 } from "node:fs";
33175
33225
  import { basename as basename4, dirname as dirname13, join as join19, resolve as resolve6 } from "node:path";
33176
33226
  import { promisify as promisify4 } from "node:util";
33227
+
33228
+ // src/whole-file.ts
33229
+ import { renameSync as renameSync2, rmSync as rmSync9, writeFileSync as writeFileSync13 } from "node:fs";
33230
+ var HELD_FILE_MS = 1000;
33231
+ var HELD_FILE_RETRY_MS = 10;
33232
+ var HELD_BY_A_READER = new Set(["EPERM", "EACCES", "EBUSY"]);
33233
+ function writeWhole(path, text) {
33234
+ const partial = `${path}.${process.pid}.partial`;
33235
+ try {
33236
+ writeFileSync13(partial, text, "utf8");
33237
+ } catch {
33238
+ return false;
33239
+ }
33240
+ const until = Date.now() + HELD_FILE_MS;
33241
+ for (;; ) {
33242
+ try {
33243
+ renameSync2(partial, path);
33244
+ return true;
33245
+ } catch (error) {
33246
+ const code = error.code ?? "";
33247
+ if (!HELD_BY_A_READER.has(code) || Date.now() >= until) {
33248
+ try {
33249
+ rmSync9(partial, { force: true });
33250
+ } catch {}
33251
+ return false;
33252
+ }
33253
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, HELD_FILE_RETRY_MS);
33254
+ }
33255
+ }
33256
+ }
33257
+
33258
+ // src/run-record.ts
33177
33259
  var execFileAsync = promisify4(execFile4);
33178
33260
  function runsDirectory() {
33179
33261
  return join19(runtimeDirectory(), "runs");
@@ -33208,7 +33290,7 @@ function latestRunPath(servedBy, directory = runtimeDirectory()) {
33208
33290
  function writeLatestRun(servedBy, latest) {
33209
33291
  try {
33210
33292
  mkdirSync11(runsDirectory(), { recursive: true });
33211
- writeFileSync13(latestRunPath(servedBy), JSON.stringify(latest, null, 2), "utf8");
33293
+ writeWhole(latestRunPath(servedBy), JSON.stringify(latest, null, 2));
33212
33294
  } catch {}
33213
33295
  }
33214
33296
  function readLatestRun(servedBy, directory = runtimeDirectory()) {
@@ -33243,7 +33325,10 @@ function editorNotePath(projectPath, directory = runtimeDirectory()) {
33243
33325
  }
33244
33326
  function writeEditorRunNote(note) {
33245
33327
  mkdirSync11(runsDirectory(), { recursive: true });
33246
- writeFileSync13(editorNotePath(note.projectPath), JSON.stringify(note, null, 2), "utf8");
33328
+ const path = editorNotePath(note.projectPath);
33329
+ if (!writeWhole(path, JSON.stringify(note, null, 2))) {
33330
+ throw new Error(`the editor run note could not be written at ${path}`);
33331
+ }
33247
33332
  }
33248
33333
  function readEditorRunNote(projectPath) {
33249
33334
  for (const directory of runtimeDirectories()) {
@@ -33515,7 +33600,7 @@ function clearRunRecord(projectPath) {
33515
33600
  const ours = sharedRecord !== null && sharedRecord.projectPath !== "" && isSameDirectory(sharedRecord.projectPath, projectPath);
33516
33601
  for (const path of [runRecordPath(projectPath, directory), ...ours ? [shared] : []]) {
33517
33602
  try {
33518
- rmSync9(path, { force: true });
33603
+ rmSync10(path, { force: true });
33519
33604
  } catch {}
33520
33605
  }
33521
33606
  }
@@ -33540,7 +33625,8 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
33540
33625
  return;
33541
33626
  }
33542
33627
  for (const entry of entries) {
33543
- if (!entry.startsWith("run-") || !(entry.endsWith(".log") || entry.endsWith(".profile.json"))) {
33628
+ const transcript = entry.startsWith("run-") && (entry.endsWith(".log") || entry.endsWith(".profile.json"));
33629
+ if (!transcript && !entry.endsWith(".partial")) {
33544
33630
  continue;
33545
33631
  }
33546
33632
  const path = join19(directory, entry);
@@ -33548,7 +33634,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
33548
33634
  continue;
33549
33635
  }
33550
33636
  try {
33551
- rmSync9(path);
33637
+ rmSync10(path);
33552
33638
  } catch {}
33553
33639
  }
33554
33640
  }
@@ -33647,7 +33733,7 @@ function withoutListed(entries, errors) {
33647
33733
  }
33648
33734
 
33649
33735
  // src/script-profile.ts
33650
- import { readFileSync as readFileSync19, renameSync as renameSync2, writeFileSync as writeFileSync14 } from "node:fs";
33736
+ import { readFileSync as readFileSync19 } from "node:fs";
33651
33737
 
33652
33738
  // src/godot-variant.ts
33653
33739
  var FLAG_64 = 1 << 16;
@@ -34767,7 +34853,13 @@ var TOOL_SPECS = [
34767
34853
  type: "array",
34768
34854
  items: { type: "string" },
34769
34855
  ops: ["start", "check"],
34770
- description: `The game's own arguments, what OS.get_cmdline_user_args() answers, such as ["--level=2"]. The separator is added here. A run with any is started by this server rather than by the editor, which fixes the game's command line when it opens the project, so the debug_* tools do not answer for it.`
34856
+ description: `The game's own arguments, what OS.get_cmdline_user_args() answers, such as ["--level=2"]. The separator is added here, so a flag the engine reads goes to the game instead, and the answer says so under argsNote. A run with any is started by this server rather than by the editor, which fixes the game's command line when it opens the project, so the debug_* tools do not answer for it.`
34857
+ },
34858
+ engineArgs: {
34859
+ type: "array",
34860
+ items: { type: "string" },
34861
+ ops: ["start", "check"],
34862
+ 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.`
34771
34863
  },
34772
34864
  headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
34773
34865
  visible: {
@@ -34864,7 +34956,7 @@ var TOOL_SPECS = [
34864
34956
  requires: []
34865
34957
  }
34866
34958
  },
34867
- answer: "stop: endedPid is, for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled, the note naming which property it failed on, and false with the number under stillGoing when what was told to end was still running ten seconds later. endedThrough says how the stop ended a run that was going: keeper (the process that started the game ended it through the handle it holds), signal (its pid was signalled once shown to still be the run), or editor (the editor playing it was asked). A start that ends the run before it says how under endedPreviousRunThrough, with the same values, and one that refused to signal that run names the property it failed on under previousRunLeftBecause. check: hung is a run killed at timeoutMs, and errors and warnings count what it printed, which comes back under entries with repeats folded together. profile: per function its script, the function (Class.name when the script declares a class_name), the line its body's first statement is on, for a lambda the named function it is written in (within), calls, and self and total milliseconds, with functionsCounted, scriptMs (the self time of them all) and coveredMs. 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 call through super too, while the overridden function's row counts it again: such a row carries selfIncludesSuper, so self times along an override chain overlap. The profiler's own cost of timing a call lands in the self time of the function called; with the runtime addon it is measured each run on an empty function, as perCallOverheadUs, and a row with at least half its selfMs in it carries overheadMs, since a small function called often costs that much less without the profiler. A function the profiler started inside is left out, since its time would be counted from the engine's start. The milliseconds are time passed while the function ran, not processor time given to it, so they grow when other processes compete for the processor: compare two profiles taken under the same load. They are also summed over every thread: a function run by WorkerThreadPool or a Thread on several threads at once counts each thread's time, the engine does not say which thread a call ran on, and a function waiting for them has the wait in its own self time, so selfMs is not time taken from frames, and scriptMs can exceed coveredMs, which the note then says. A property's get and set blocks are named Class.@name_getter and Class.@name_setter, as the engine names them, and their rows carry property, the property's name. A game the editor plays that was not started with profile: true says, in the answer, that it was profiled from the moment it was asked.",
34959
+ answer: "stop: endedPid is, for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled, the note naming which property it failed on, and false with the number under stillGoing when what was told to end was still running ten seconds later. endedThrough says how the stop ended a run that was going: keeper (the process that started the game ended it through the handle it holds), signal (its pid was signalled once shown to still be the run), or editor (the editor playing it was asked). A start that ends the run before it says how under endedPreviousRunThrough, with the same values, and one that refused to signal that run names the property it failed on under previousRunLeftBecause. check: hung is a run killed at timeoutMs, and errors and warnings count what it printed, which comes back under entries with repeats folded together. arguments is the engine command line the check ran, as a start's is. profile: per function its script, the function (Class.name when the script declares a class_name), the line its body's first statement is on, for a lambda the named function it is written in (within), calls, and self and total milliseconds, with functionsCounted, scriptMs (the self time of them all) and coveredMs. 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 call through super too, while the overridden function's row counts it again: such a row carries selfIncludesSuper, so self times along an override chain overlap. The profiler's own cost of timing a call lands in the self time of the function called; with the runtime addon it is measured each run on an empty function, as perCallOverheadUs, and a row with at least half its selfMs in it carries overheadMs, since a small function called often costs that much less without the profiler. A function the profiler started inside is left out, since its time would be counted from the engine's start. The milliseconds are time passed while the function ran, not processor time given to it, so they grow when other processes compete for the processor: compare two profiles taken under the same load. They are also summed over every thread: a function run by WorkerThreadPool or a Thread on several threads at once counts each thread's time, the engine does not say which thread a call ran on, and a function waiting for them has the wait in its own self time, so selfMs is not time taken from frames, and scriptMs can exceed coveredMs, which the note then says. A property's get and set blocks are named Class.@name_getter and Class.@name_setter, as the engine names them, and their rows carry property, the property's name. A game the editor plays that was not started with profile: true says, in the answer, that it was profiled from the moment it was asked.",
34868
34960
  defaultOperation: "start"
34869
34961
  },
34870
34962
  {
@@ -35558,9 +35650,9 @@ import {
35558
35650
  readdirSync as readdirSync8,
35559
35651
  readFileSync as readFileSync20,
35560
35652
  rmdirSync,
35561
- rmSync as rmSync10,
35653
+ rmSync as rmSync11,
35562
35654
  statSync as statSync7,
35563
- writeFileSync as writeFileSync15
35655
+ writeFileSync as writeFileSync14
35564
35656
  } from "node:fs";
35565
35657
  import { dirname as dirname14, join as join21 } from "node:path";
35566
35658
  import { fileURLToPath as fileURLToPath3 } from "node:url";
@@ -36317,6 +36409,14 @@ var PLAYING_STATUS_MS = 1000;
36317
36409
  var SCAN_START_MS = 2000;
36318
36410
  var SCAN_SETTLED_MS = 500;
36319
36411
  var REIMPORT_TIMEOUT_MS = 300000;
36412
+ function engineFlagsNote(args) {
36413
+ const among = engineFlagsAmongGameArgs(args);
36414
+ if (among.length === 0) {
36415
+ return;
36416
+ }
36417
+ const named = among.join(", ");
36418
+ 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.`;
36419
+ }
36320
36420
  var OPS_ELSEWHERE = {
36321
36421
  runtime_invoke: { get: "runtime_inspect property", property: "runtime_inspect property" }
36322
36422
  };
@@ -36747,9 +36847,9 @@ function callersAnswer(totals, asked) {
36747
36847
  };
36748
36848
  }
36749
36849
  var MOST_ENGINE_ENTRIES = 200;
36750
- function foldedEntriesAnswer(log) {
36850
+ function foldedEntriesAnswer(log, floor = "warning") {
36751
36851
  const problems = log.selectFolded({
36752
- severity: "warning",
36852
+ severity: floor,
36753
36853
  sinceLastCall: false,
36754
36854
  limit: MOST_ENGINE_ENTRIES
36755
36855
  });
@@ -37777,6 +37877,25 @@ class GodotServer {
37777
37877
  }
37778
37878
  return { ok: true, value: given };
37779
37879
  }
37880
+ engineArguments(args) {
37881
+ if (args["engineArgs"] === undefined) {
37882
+ return { ok: true, value: [] };
37883
+ }
37884
+ const given = readStringArray(args, "engineArgs");
37885
+ if (given === undefined) {
37886
+ return {
37887
+ ok: false,
37888
+ response: this.createErrorResponse("engineArgs must be a list of strings.", [
37889
+ 'Pass engine flags, such as ["--verbose"] or ["--fixed-fps", "60"]'
37890
+ ])
37891
+ };
37892
+ }
37893
+ const checked = engineFlags(given);
37894
+ if (!checked.ok) {
37895
+ return { ok: false, response: this.createErrorResponse(checked.reason) };
37896
+ }
37897
+ return { ok: true, value: given };
37898
+ }
37780
37899
  runEnvironment(args) {
37781
37900
  const savesIn = readNonEmptyString(args, "savesIn");
37782
37901
  if (savesIn !== undefined && !isAbsolute3(savesIn)) {
@@ -39593,6 +39712,10 @@ class GodotServer {
39593
39712
  if (!given.ok) {
39594
39713
  return given.response;
39595
39714
  }
39715
+ const flags = this.engineArguments(args);
39716
+ if (!flags.ok) {
39717
+ return flags.response;
39718
+ }
39596
39719
  const asked = this.runEnvironment(args);
39597
39720
  if (!asked.ok) {
39598
39721
  return asked.response;
@@ -39614,7 +39737,7 @@ class GodotServer {
39614
39737
  }
39615
39738
  const sceneArgument = sceneToRun?.ok ? sceneToRun.relativePath : null;
39616
39739
  if (op === "check") {
39617
- return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, scanned, asked.value);
39740
+ return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, scanned, asked.value, flags.value);
39618
39741
  }
39619
39742
  await this.pickUpWhatTheEditorIsPlaying();
39620
39743
  let ended = null;
@@ -39641,7 +39764,7 @@ class GodotServer {
39641
39764
  const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
39642
39765
  const silent = resolveSilent(args["silent"], process.env) && !headless;
39643
39766
  const profiled = readBoolean(args, "profile") === true;
39644
- if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0 && !needsItsOwnProcess(asked.value) && !silent) {
39767
+ if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0 && flags.value.length === 0 && !needsItsOwnProcess(asked.value) && !silent) {
39645
39768
  return await this.playThroughEditor(sceneArgument, refreshed.value, scanned, project.value.path, alreadyPlaying, runtimeWaitMs, ended, profiled);
39646
39769
  }
39647
39770
  const cmdArgs = runArguments({
@@ -39649,6 +39772,7 @@ class GodotServer {
39649
39772
  headless,
39650
39773
  scene: sceneArgument,
39651
39774
  userArgs: given.value,
39775
+ engineArgs: flags.value,
39652
39776
  silent
39653
39777
  });
39654
39778
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
@@ -39661,7 +39785,7 @@ class GodotServer {
39661
39785
  started.announcedBefore = alreadyPlaying;
39662
39786
  started.bootScene = runtimeWaitMs.scene;
39663
39787
  this.activeProcess = started;
39664
- const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : this.godotBridge.isConnected() && editorWouldPlay && needsItsOwnProcess(asked.value) ? " The editor plays a game with its own environment, so a run given one was started " + "here: the debug_* tools answer only for a game the editor is playing." : this.godotBridge.isConnected() && editorWouldPlay && silent ? " The editor cannot play a game silent, which takes an engine flag, so this one was " + `started here${args["silent"] === true ? "" : ", silent because GDHARNESS_SILENT says so"}: the debug_* tools answer only for a game the editor is playing, and silent: false plays it through the editor.` : "";
39788
+ const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && (given.value.length > 0 || flags.value.length > 0) ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : this.godotBridge.isConnected() && editorWouldPlay && needsItsOwnProcess(asked.value) ? " The editor plays a game with its own environment, so a run given one was started " + "here: the debug_* tools answer only for a game the editor is playing." : this.godotBridge.isConnected() && editorWouldPlay && silent ? " The editor cannot play a game silent, which takes an engine flag, so this one was " + `started here${args["silent"] === true ? "" : ", silent because GDHARNESS_SILENT says so"}: the debug_* tools answer only for a game the editor is playing, and silent: false plays it through the editor.` : "";
39665
39789
  const endedForThis = endedToStartThis(ended);
39666
39790
  const stranded = wereHereFirst.filter((one) => one.pid !== ended?.pid && alive(one.pid));
39667
39791
  const alsoRunning = leftRunningNote(stranded);
@@ -39671,6 +39795,7 @@ class GodotServer {
39671
39795
  alsoRunning: stranded.length === 0 ? undefined : stranded.map((one) => one.pid),
39672
39796
  pid: started.pid,
39673
39797
  arguments: cmdArgs,
39798
+ argsNote: engineFlagsNote(given.value),
39674
39799
  transcript: started.transcript,
39675
39800
  profile: profileOnStart(profiled, "gdharness", runtimeAddonRegistered(project.value.path)),
39676
39801
  refreshedClasses: refreshed.value,
@@ -40573,10 +40698,17 @@ class GodotServer {
40573
40698
  async currentRun() {
40574
40699
  return this.activeProcess ?? await this.adoptRecordedRun();
40575
40700
  }
40576
- async checkBoot(godotPath, projectPath, scene, args, userArgs, scanned, asked = {}) {
40701
+ async checkBoot(godotPath, projectPath, scene, args, userArgs, scanned, asked = {}, engineArgs = []) {
40577
40702
  const frames = readPositiveNumber(args, "frames") ?? 3;
40578
40703
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
40579
- const cmdArgs = runArguments({ projectPath, headless: true, scene, quitAfter: frames, userArgs });
40704
+ const cmdArgs = runArguments({
40705
+ projectPath,
40706
+ headless: true,
40707
+ scene,
40708
+ quitAfter: frames,
40709
+ userArgs,
40710
+ engineArgs
40711
+ });
40580
40712
  const boot = this.spawnGame(godotPath, cmdArgs, needsItsOwnProcess(asked) ? environmentFor({ ...asked, runtimeDirectory: runtimeDirectory() }) : undefined);
40581
40713
  const hung = await new Promise((resolve) => {
40582
40714
  const timer = setTimeout(() => {
@@ -40604,9 +40736,11 @@ class GodotServer {
40604
40736
  frames,
40605
40737
  errors,
40606
40738
  warnings,
40739
+ arguments: cmdArgs,
40740
+ argsNote: engineFlagsNote(userArgs),
40607
40741
  ...scanWaitAnswer(scanned),
40608
40742
  savesNote: asked.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
40609
- ...foldedEntriesAnswer(boot.log)
40743
+ ...foldedEntriesAnswer(boot.log, engineArgs.includes("--verbose") ? "info" : "warning")
40610
40744
  });
40611
40745
  }
40612
40746
  async handleRunProfile(args) {
package/build/keeper.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/keeper.ts
4
4
  import { spawn } from "node:child_process";
5
- import { closeSync, existsSync as existsSync2, mkdtempSync as mkdtempSync3, openSync as openSync2, readFileSync as readFileSync3, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
5
+ import { closeSync, existsSync as existsSync2, mkdtempSync as mkdtempSync3, openSync as openSync2, readFileSync as readFileSync2, rmSync as rmSync5, writeFileSync as writeFileSync3 } from "node:fs";
6
6
  import { tmpdir as tmpdir4 } from "node:os";
7
7
  import { join as join5 } from "node:path";
8
8
  import { setTimeout as delay } from "node:timers/promises";
@@ -230,6 +230,14 @@ import { connect, createServer } from "node:net";
230
230
  import { tmpdir } from "node:os";
231
231
  import { join } from "node:path";
232
232
  import process2 from "node:process";
233
+ function endedOrWhyNot(end) {
234
+ try {
235
+ return end();
236
+ } catch (error) {
237
+ return `could not end it: ${error instanceof Error ? error.message : String(error)}`.replaceAll(`
238
+ `, " ");
239
+ }
240
+ }
233
241
  async function listenForAStop(end) {
234
242
  let directory = null;
235
243
  try {
@@ -244,7 +252,7 @@ async function listenForAStop(end) {
244
252
  if (asked.includes(`
245
253
  `)) {
246
254
  socket.end(asked.startsWith(`stop
247
- `) ? `${end()}
255
+ `) ? `${endedOrWhyNot(end)}
248
256
  ` : `unknown
249
257
  `);
250
258
  }
@@ -315,16 +323,7 @@ function readLaunched(printed) {
315
323
  // src/run-record.ts
316
324
  import { execFile as execFile2 } from "node:child_process";
317
325
  import { createHash as createHash2 } from "node:crypto";
318
- import {
319
- mkdirSync,
320
- openSync,
321
- readdirSync,
322
- readFileSync,
323
- realpathSync,
324
- rmSync as rmSync2,
325
- statSync,
326
- writeFileSync as writeFileSync2
327
- } from "node:fs";
326
+ import { mkdirSync, openSync, readdirSync, readFileSync, realpathSync, rmSync as rmSync3, statSync } from "node:fs";
328
327
  import { basename, dirname as dirname2, join as join3, resolve as resolve2 } from "node:path";
329
328
  import { promisify as promisify2 } from "node:util";
330
329
 
@@ -364,6 +363,32 @@ function envValue(name, variables = process.env) {
364
363
  const value = variables[name];
365
364
  return value === undefined || value === "" ? undefined : value;
366
365
  }
366
+ var ENGINE_FLAGS = Object.freeze({
367
+ "--verbose": "none",
368
+ "--quiet": "none",
369
+ "--debug-collisions": "none",
370
+ "--debug-paths": "none",
371
+ "--debug-navigation": "none",
372
+ "--debug-avoidance": "none",
373
+ "--debug-stringnames": "none",
374
+ "--debug-canvas-item-redraw": "none",
375
+ "--debug-mute-audio": "none",
376
+ "--print-fps": "none",
377
+ "--gpu-profile": "none",
378
+ "--gpu-validation": "none",
379
+ "--gpu-abort": "none",
380
+ "--disable-vsync": "none",
381
+ "--frame-delay": "number",
382
+ "--time-scale": "number",
383
+ "--fixed-fps": "number",
384
+ "--max-fps": "number",
385
+ "--resolution": "size"
386
+ });
387
+ var SET_BY_ARGUMENT = Object.freeze({
388
+ "--headless": "headless",
389
+ "--audio-driver": "silent",
390
+ "--quit-after": "frames, on editor_run check"
391
+ });
367
392
 
368
393
  // src/runtime-client.ts
369
394
  function runtimeDirectory(variables = process.env) {
@@ -379,6 +404,36 @@ var verdicts = new Map;
379
404
  var LATE_ANSWER_MS = 30 * 60000;
380
405
  var lateAnswers = new Map;
381
406
 
407
+ // src/whole-file.ts
408
+ import { renameSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
409
+ var HELD_FILE_MS = 1000;
410
+ var HELD_FILE_RETRY_MS = 10;
411
+ var HELD_BY_A_READER = new Set(["EPERM", "EACCES", "EBUSY"]);
412
+ function writeWhole(path, text) {
413
+ const partial = `${path}.${process.pid}.partial`;
414
+ try {
415
+ writeFileSync2(partial, text, "utf8");
416
+ } catch {
417
+ return false;
418
+ }
419
+ const until = Date.now() + HELD_FILE_MS;
420
+ for (;; ) {
421
+ try {
422
+ renameSync(partial, path);
423
+ return true;
424
+ } catch (error) {
425
+ const code = error.code ?? "";
426
+ if (!HELD_BY_A_READER.has(code) || Date.now() >= until) {
427
+ try {
428
+ rmSync2(partial, { force: true });
429
+ } catch {}
430
+ return false;
431
+ }
432
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, HELD_FILE_RETRY_MS);
433
+ }
434
+ }
435
+ }
436
+
382
437
  // src/run-record.ts
383
438
  var execFileAsync = promisify2(execFile2);
384
439
  function runsDirectory() {
@@ -410,7 +465,10 @@ function runRecordPath(projectPath, directory = runtimeDirectory()) {
410
465
  }
411
466
  function writeRunRecord(record) {
412
467
  mkdirSync(runsDirectory(), { recursive: true });
413
- writeFileSync2(runRecordPath(record.projectPath), JSON.stringify(record, null, 2), "utf8");
468
+ const path = runRecordPath(record.projectPath);
469
+ if (!writeWhole(path, JSON.stringify(record, null, 2))) {
470
+ throw new Error(`the run note could not be written at ${path}`);
471
+ }
414
472
  }
415
473
  function recordRunEnded(projectPath, pid, ending) {
416
474
  const path = runRecordPath(projectPath);
@@ -424,9 +482,7 @@ function recordRunEnded(projectPath, pid, ending) {
424
482
  ...ending.exitSignal === null ? {} : { exitSignal: ending.exitSignal },
425
483
  endedAt: Date.now()
426
484
  };
427
- try {
428
- writeFileSync2(path, JSON.stringify(written, null, 2), "utf8");
429
- } catch {}
485
+ writeWhole(path, JSON.stringify(written, null, 2));
430
486
  }
431
487
  function recordAt(path) {
432
488
  let parsed;
@@ -464,7 +520,7 @@ function recordAt(path) {
464
520
  }
465
521
 
466
522
  // src/scratch.ts
467
- import { existsSync, mkdtempSync as mkdtempSync2, readdirSync as readdirSync2, rmSync as rmSync3 } from "node:fs";
523
+ import { existsSync, mkdtempSync as mkdtempSync2, readdirSync as readdirSync2, rmSync as rmSync4 } from "node:fs";
468
524
  import { tmpdir as tmpdir3 } from "node:os";
469
525
  import { join as join4 } from "node:path";
470
526
  import process3 from "node:process";
@@ -497,7 +553,7 @@ function discard(...paths) {
497
553
  return left;
498
554
  }
499
555
  function remove(path) {
500
- rmSync3(path, SWEPT);
556
+ rmSync4(path, SWEPT);
501
557
  }
502
558
  function tryAgain(path, attempt) {
503
559
  if (attempt === 0) {
@@ -541,7 +597,6 @@ function lastTryOnExit() {
541
597
  }
542
598
 
543
599
  // src/script-profile.ts
544
- import { readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync3 } from "node:fs";
545
600
  import { createServer as createServer2 } from "node:net";
546
601
 
547
602
  // src/gdscript-source.ts
@@ -1073,9 +1128,7 @@ function parseIniLikeValue(value) {
1073
1128
 
1074
1129
  // src/script-profile.ts
1075
1130
  function writeProfileFile(path, file) {
1076
- const partial = `${path}.${process.pid}.partial`;
1077
- writeFileSync3(partial, JSON.stringify(file), "utf8");
1078
- renameSync(partial, path);
1131
+ return writeWhole(path, JSON.stringify(file));
1079
1132
  }
1080
1133
  var QUEUE_SETTING = "limits/debugger/max_queued_messages";
1081
1134
  var QUEUE_DEFAULT = 2048;
@@ -1315,12 +1368,12 @@ async function startThroughHelper(spec, output, stopFile) {
1315
1368
  exited.now = true;
1316
1369
  });
1317
1370
  for (let waited = 0;waited < DESKTOP_START_MS; waited += 50) {
1318
- const pid = existsSync2(pidFile) ? Number(readFileSync3(pidFile, "utf8").trim()) : Number.NaN;
1371
+ const pid = existsSync2(pidFile) ? Number(readFileSync2(pidFile, "utf8").trim()) : Number.NaN;
1319
1372
  if (Number.isInteger(pid) && pid > 0) {
1320
1373
  return { child: started.child, pid };
1321
1374
  }
1322
1375
  if (exited.now) {
1323
- const why = existsSync2(errorFile) ? readFileSync3(errorFile, "utf8").trim() : "it exited saying nothing";
1376
+ const why = existsSync2(errorFile) ? readFileSync2(errorFile, "utf8").trim() : "it exited saying nothing";
1324
1377
  return { error: `it could not be started ${where}: ${why}` };
1325
1378
  }
1326
1379
  await delay(50);
@@ -1332,18 +1385,19 @@ async function startThroughHelper(spec, output, stopFile) {
1332
1385
  }
1333
1386
  }
1334
1387
  var PROFILE_WRITE_MS = 500;
1388
+ var LAST_WRITE_TRIES = 5;
1335
1389
  async function profiledBy(path, projectPath) {
1336
1390
  let latest = null;
1337
1391
  let timer = null;
1338
1392
  const write = () => {
1339
1393
  timer = null;
1340
- if (latest !== null) {
1341
- writeProfileFile(path, { ...latest, connected: true });
1394
+ if (latest !== null && !writeProfileFile(path, { ...latest, connected: true })) {
1395
+ timer = setTimeout(write, PROFILE_WRITE_MS);
1342
1396
  }
1343
1397
  };
1344
1398
  let projectGodot = null;
1345
1399
  try {
1346
- projectGodot = readFileSync3(join5(projectPath, "project.godot"), "utf8");
1400
+ projectGodot = readFileSync2(join5(projectPath, "project.godot"), "utf8");
1347
1401
  } catch {}
1348
1402
  const listener = await ProfileListener.open((totals) => {
1349
1403
  const first = latest === null;
@@ -1362,7 +1416,10 @@ async function profiledBy(path, projectPath) {
1362
1416
  clearTimeout(timer);
1363
1417
  }
1364
1418
  latest = listener.totals() ?? latest;
1365
- write();
1419
+ let tries = 0;
1420
+ while (latest !== null && !writeProfileFile(path, { ...latest, connected: true }) && tries < LAST_WRITE_TRIES) {
1421
+ tries += 1;
1422
+ }
1366
1423
  listener.close();
1367
1424
  }
1368
1425
  };
@@ -1427,6 +1484,11 @@ async function launch() {
1427
1484
  process.exit(0);
1428
1485
  });
1429
1486
  }
1487
+ function writeNoteIfItCan(record) {
1488
+ try {
1489
+ writeRunRecord(record);
1490
+ } catch {}
1491
+ }
1430
1492
  async function keep() {
1431
1493
  const spec = await receivedSpec();
1432
1494
  const run = spec.run;
@@ -1467,7 +1529,7 @@ async function keep() {
1467
1529
  command: spec.command
1468
1530
  };
1469
1531
  if (record !== null) {
1470
- writeRunRecord(record);
1532
+ writeNoteIfItCan(record);
1471
1533
  }
1472
1534
  let listening = null;
1473
1535
  child.once("exit", (code, signal) => {
@@ -1477,20 +1539,20 @@ async function keep() {
1477
1539
  profiled?.finish();
1478
1540
  listening?.close();
1479
1541
  if (own !== null) {
1480
- rmSync4(own, { recursive: true, force: true });
1542
+ rmSync5(own, { recursive: true, force: true });
1481
1543
  }
1482
1544
  process.exit(0);
1483
1545
  });
1484
1546
  if (record !== null) {
1485
1547
  listening = await listenForAStop(() => {
1486
1548
  if (stopFile !== "") {
1487
- writeFileSync4(stopFile, "");
1549
+ writeFileSync3(stopFile, "");
1488
1550
  return "signalled";
1489
1551
  }
1490
1552
  return child.kill() ? "signalled" : "gone";
1491
1553
  });
1492
1554
  if (listening !== null) {
1493
- writeRunRecord({ ...record, keeper: listening.address });
1555
+ writeNoteIfItCan({ ...record, keeper: listening.address });
1494
1556
  }
1495
1557
  }
1496
1558
  process.stdout.write(`pid ${pid}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.2.18",
3
+ "version": "1.2.20",
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",