gdharness 1.2.6 → 1.2.8

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
@@ -42623,7 +42623,8 @@ function profiledFunctions(totals, limit, sourceOf = () => null) {
42623
42623
  counted: rows.length,
42624
42624
  superCallers: rows.filter((row) => row.selfIncludesSuper === true).length,
42625
42625
  perCallUs,
42626
- overheadDominated: rows.filter((row) => row.overheadMs !== undefined).length
42626
+ overheadDominated: rows.filter((row) => row.overheadMs !== undefined).length,
42627
+ overheadNamed: rows.filter((row) => row.overheadMs !== undefined).map((row) => row.function)
42627
42628
  };
42628
42629
  }
42629
42630
  function isCalibration(signature) {
@@ -42721,7 +42722,7 @@ function callersOf(totals, name, scripts) {
42721
42722
  sites.sort((a, b) => (b.withinTotalMs ?? -1) - (a.withinTotalMs ?? -1) || a.script.localeCompare(b.script) || a.line - b.line);
42722
42723
  return { target, sites: sites.slice(0, CALL_SITES), omitted: Math.max(0, sites.length - CALL_SITES) };
42723
42724
  }
42724
- var QUEUE_SETTING = "limits/debugger/max_queued_messages", QUEUE_DEFAULT = 2048, QUEUE_HEADROOM = 512, UNNAMED_PREFIX = "#", ms = (seconds) => Math.round(seconds * 1e6) / 1000, RUNTIME_SCRIPT = "gdharness_runtime/runtime_autoload.gd", PROFILER_PROBE = "_profiler_probe", PROFILER_CALIBRATOR = "_calibrate_the_profiler", CALL_SITES = 40;
42725
+ var QUEUE_SETTING = "limits/debugger/max_queued_messages", QUEUE_DEFAULT = 2048, QUEUE_HEADROOM = 512, UNNAMED_PREFIX = "#", PROFILE_FROM_START = "GDHARNESS_PROFILE_FROM_START", ms = (seconds) => Math.round(seconds * 1e6) / 1000, RUNTIME_SCRIPT = "gdharness_runtime/runtime_autoload.gd", PROFILER_PROBE = "_profiler_probe", PROFILER_CALIBRATOR = "_calibrate_the_profiler", CALL_SITES = 40;
42725
42726
  var init_script_profile = __esm(() => {
42726
42727
  init_gdscript_source();
42727
42728
  init_godot_variant();
@@ -43542,7 +43543,7 @@ function profileAnswer(reading, limit) {
43542
43543
  notes.push(`${table.superCallers === 1 ? "One function calls" : `${table.superCallers} functions call`} through super, marked selfIncludesSuper: the engine takes every other call out of the caller's self time, and not a super call, so ${table.superCallers === 1 ? "its" : "their"} selfMs holds the overridden function's time, which that function's own row counts again. Self time therefore cannot be added along an override chain, and scriptMs counts the time under a super call once more for each level above it.`);
43543
43544
  }
43544
43545
  if (table.overheadDominated > 0 && table.perCallUs !== null) {
43545
- notes.push(`The profiler costs about ${table.perCallUs}µs a call in this run, measured on an empty function the runtime addon calls as it goes on, and that time lands in the self time of the function called. ${table.overheadDominated === 1 ? "One function has" : `${table.overheadDominated} functions have`} at least half ${table.overheadDominated === 1 ? "its" : "their"} selfMs in it, under overheadMs: that much of its time is the profiler's and goes once the profiler is off, whether the function is inlined or not. What inlining saves is the call itself, which costs something without the profiler too, not the work inside it.`);
43546
+ notes.push(`The profiler costs about ${table.perCallUs}µs a call in this run, measured on an empty function the runtime addon calls as it goes on, and that time lands in the self time of the function called. ${table.overheadDominated === 1 ? "One function has" : `${table.overheadDominated} functions have`} at least half ${table.overheadDominated === 1 ? "its" : "their"} selfMs in it, under overheadMs: ${namedFew(table.overheadNamed)}. That much of ${table.overheadDominated === 1 ? "its" : "their"} time is the profiler's and goes once the profiler is off, whether the function is inlined or not. What inlining saves is the call itself, which costs something without the profiler too, not the work inside it.`);
43546
43547
  }
43547
43548
  notes.push(...cappedNotes(totals));
43548
43549
  if (totals.unreadable > 0) {
@@ -43564,12 +43565,21 @@ function profileAnswer(reading, limit) {
43564
43565
  note: notes.length > 0 ? notes.join(" ") : undefined
43565
43566
  };
43566
43567
  }
43568
+ function namedFew(names) {
43569
+ const shown = names.slice(0, 5);
43570
+ const more = names.length - shown.length;
43571
+ if (more > 0) {
43572
+ return `${shown.join(", ")} and ${more} more`;
43573
+ }
43574
+ return shown.length > 1 ? `${shown.slice(0, -1).join(", ")} and ${shown.at(-1) ?? ""}` : shown[0] ?? "";
43575
+ }
43567
43576
  function cappedNotes(totals) {
43568
43577
  const notes = [];
43569
43578
  const most = totals.frameFunctions;
43570
43579
  const why = `${most} is kept below the game's debugger queue (network/limits/debugger/max_queued_messages), which drops the message naming a function when one frame names more than it holds, and a function whose name is dropped is never named afterwards.`;
43571
43580
  if (most !== undefined && totals.complete && totals.totalCapped === true) {
43572
- notes.push(`The engine's totals carried the most functions the profiler asks for, ${most}, so the functions with the least time are not in them: ${why}`);
43581
+ const leastKept = Math.min(...Object.values(totals.functions).map((one) => one.totalSeconds));
43582
+ notes.push(leastKept > 0 ? `The engine's totals carried the most functions the profiler asks for, ${most}, so the functions with the least time are not in them: ${why}` : `The engine's totals carried the most functions the profiler asks for, ${most}, and the last of them took no measurable time, so neither did any left out: the time is all here, and only a function too quick for the clock can be missing, with its calls. The engine lists every function it has compiled, run or not, and sends them busiest first. ${why}`);
43573
43583
  } else if (most !== undefined && !totals.complete && (totals.cappedFrames ?? 0) > 0) {
43574
43584
  const capped = totals.cappedFrames ?? 0;
43575
43585
  notes.push(`${capped === totals.frames ? capped === 1 ? "The one frame" : `All ${capped} frames` : `${capped} of the ${totals.frames} frames`} carried the most functions the profiler asks for in one, ${most}, so the functions with the least time in ${capped === 1 ? "it" : "them"} are not counted there: ${why}`);
@@ -46929,10 +46939,11 @@ class GodotServer {
46929
46939
  const startedAt = Date.now();
46930
46940
  const transcript = openTranscript(startedAt);
46931
46941
  closeSync2(transcript.fd);
46942
+ const gameEnv = profiled ? { ...env ?? process.env, [PROFILE_FROM_START]: "1" } : env;
46932
46943
  const launched = await launchOutsideTheTree({
46933
46944
  command: godotPath,
46934
46945
  args: cmdArgs,
46935
- ...env === undefined ? {} : { env },
46946
+ ...gameEnv === undefined ? {} : { env: gameEnv },
46936
46947
  run: {
46937
46948
  transcript: transcript.path,
46938
46949
  startedAt,
@@ -22,6 +22,9 @@ const DEBUGGER_CLASS: String = "ScriptEditorDebugger"
22
22
  const PROFILE_CAPTURE: String = "gdharness"
23
23
  const PROFILE_SENT: String = "gdharness:profile_sent"
24
24
  const PROFILE_RECEIVED: String = "gdharness:profile_received"
25
+ ## Set in the environment a played game inherits, when the play is to be profiled from its start.
26
+ ## Kept in step with the runtime addon's runtime_autoload.gd.
27
+ const PROFILE_FROM_START: String = "GDHARNESS_PROFILE_FROM_START"
25
28
 
26
29
 
27
30
  ## The editor's debugger sessions, numbered as the editor numbers its debugger tabs.
@@ -106,6 +109,10 @@ func profile_start(args: Dictionary) -> Dictionary:
106
109
  _frame_functions = asked if asked > 0 else FRAME_FUNCTIONS
107
110
  if Read.as_bool(args.get("arm", false)):
108
111
  _armed = true
112
+ # The game the editor starts next inherits the editor's environment, and this tells its
113
+ # runtime addon to hold its start until the profiler is on, which the session's start
114
+ # switches on with a message the game reads only on a later poll.
115
+ OS.set_environment(PROFILE_FROM_START, "1")
109
116
  return {"ok": true, "started": false, "armed": true, "session": -1}
110
117
  var active: int = _active_session()
111
118
  if active < 0:
@@ -137,6 +144,7 @@ func profile_read(_args: Dictionary) -> Dictionary:
137
144
 
138
145
  func _reset() -> void:
139
146
  _armed = false
147
+ OS.unset_environment(PROFILE_FROM_START)
140
148
  _session = -1
141
149
  _frames = 0
142
150
  _complete = false
@@ -172,6 +180,8 @@ func _on_session_started(session_id: int) -> void:
172
180
  _reset()
173
181
  return
174
182
  _armed = false
183
+ # The game has started with it by now, and a later play must not.
184
+ OS.unset_environment(PROFILE_FROM_START)
175
185
  if _hook() > 0:
176
186
  _switch_on(session_id)
177
187
 
@@ -47,6 +47,13 @@ const PROFILER_PROBE_CALLS: int = 10000
47
47
  ## on every 2048th call and only counts the others.
48
48
  const POLLS_PER_READ: int = 2048
49
49
 
50
+ ## Set in a game's environment by whatever starts it to be profiled from its start: the server for
51
+ ## a run it spawns, the editor for a play. Kept in step with `PROFILE_FROM_START` in the server.
52
+ const PROFILE_FROM_START: String = "GDHARNESS_PROFILE_FROM_START"
53
+
54
+ ## How long such a game waits for its debugger to switch the profiler on before carrying on.
55
+ const PROFILER_WAIT_MS: int = 5000
56
+
50
57
  ## How the engine is told to run a script instead of the game: `godot -s thing.gd`.
51
58
  const SCRIPT_FLAGS: PackedStringArray = ["-s", "--script"]
52
59
 
@@ -134,6 +141,37 @@ func _init() -> void:
134
141
  "wait_signal": _waits.wait_signal,
135
142
  "wait_until": _waits.wait_until,
136
143
  }
144
+ _wait_for_the_profiler()
145
+
146
+
147
+ ## Holds the game's start until the profiler is on, when whatever started the game asked for a
148
+ ## profile from its start. The debugger switches the profiler on with a message the game reads only
149
+ ## on a poll, which comes every [constant POLLS_PER_READ] lines of script, so the main scene's _ready
150
+ ## could run first: a game played on a macOS runner counted 25 calls of a function called 26 times,
151
+ ## the first of them from _ready. Run at the autoload's construction, before the main scene loads.
152
+ func _wait_for_the_profiler() -> void:
153
+ if OS.get_environment(PROFILE_FROM_START).is_empty():
154
+ return
155
+ # Read once: a process this game starts inherits its environment and must not wait as well.
156
+ OS.unset_environment(PROFILE_FROM_START)
157
+ if not EngineDebugger.is_active():
158
+ return
159
+ var deadline: int = Time.get_ticks_msec() + PROFILER_WAIT_MS
160
+ while not EngineDebugger.is_profiling("servers"):
161
+ if Time.get_ticks_msec() >= deadline:
162
+ push_warning(
163
+ (
164
+ (
165
+ "gdharness: this game was started to be profiled from its start, and the profiler"
166
+ + " was not on %d ms later, so what ran before it is not counted"
167
+ )
168
+ % PROFILER_WAIT_MS
169
+ )
170
+ )
171
+ return
172
+ for _poll: int in POLLS_PER_READ:
173
+ EngineDebugger.line_poll()
174
+ OS.delay_msec(1)
137
175
 
138
176
 
139
177
  func _ready() -> void:
package/build/index.js CHANGED
@@ -33393,6 +33393,7 @@ function queuedMessagesOf(projectGodot) {
33393
33393
  return typeof set === "number" && Number.isInteger(set) && set > 0 ? set : QUEUE_DEFAULT;
33394
33394
  }
33395
33395
  var UNNAMED_PREFIX = "#";
33396
+ var PROFILE_FROM_START = "GDHARNESS_PROFILE_FROM_START";
33396
33397
  function signatureParts(signature) {
33397
33398
  const parts = signature.split("::");
33398
33399
  if (parts.length < 3) {
@@ -33414,7 +33415,8 @@ function profiledFunctions(totals, limit, sourceOf = () => null) {
33414
33415
  counted: rows.length,
33415
33416
  superCallers: rows.filter((row) => row.selfIncludesSuper === true).length,
33416
33417
  perCallUs,
33417
- overheadDominated: rows.filter((row) => row.overheadMs !== undefined).length
33418
+ overheadDominated: rows.filter((row) => row.overheadMs !== undefined).length,
33419
+ overheadNamed: rows.filter((row) => row.overheadMs !== undefined).map((row) => row.function)
33418
33420
  };
33419
33421
  }
33420
33422
  var RUNTIME_SCRIPT = "gdharness_runtime/runtime_autoload.gd";
@@ -36036,7 +36038,7 @@ function profileAnswer(reading, limit) {
36036
36038
  notes.push(`${table.superCallers === 1 ? "One function calls" : `${table.superCallers} functions call`} through super, marked selfIncludesSuper: the engine takes every other call out of the caller's self time, and not a super call, so ${table.superCallers === 1 ? "its" : "their"} selfMs holds the overridden function's time, which that function's own row counts again. Self time therefore cannot be added along an override chain, and scriptMs counts the time under a super call once more for each level above it.`);
36037
36039
  }
36038
36040
  if (table.overheadDominated > 0 && table.perCallUs !== null) {
36039
- notes.push(`The profiler costs about ${table.perCallUs}µs a call in this run, measured on an empty function the runtime addon calls as it goes on, and that time lands in the self time of the function called. ${table.overheadDominated === 1 ? "One function has" : `${table.overheadDominated} functions have`} at least half ${table.overheadDominated === 1 ? "its" : "their"} selfMs in it, under overheadMs: that much of its time is the profiler's and goes once the profiler is off, whether the function is inlined or not. What inlining saves is the call itself, which costs something without the profiler too, not the work inside it.`);
36041
+ notes.push(`The profiler costs about ${table.perCallUs}µs a call in this run, measured on an empty function the runtime addon calls as it goes on, and that time lands in the self time of the function called. ${table.overheadDominated === 1 ? "One function has" : `${table.overheadDominated} functions have`} at least half ${table.overheadDominated === 1 ? "its" : "their"} selfMs in it, under overheadMs: ${namedFew(table.overheadNamed)}. That much of ${table.overheadDominated === 1 ? "its" : "their"} time is the profiler's and goes once the profiler is off, whether the function is inlined or not. What inlining saves is the call itself, which costs something without the profiler too, not the work inside it.`);
36040
36042
  }
36041
36043
  notes.push(...cappedNotes(totals));
36042
36044
  if (totals.unreadable > 0) {
@@ -36058,12 +36060,21 @@ function profileAnswer(reading, limit) {
36058
36060
  note: notes.length > 0 ? notes.join(" ") : undefined
36059
36061
  };
36060
36062
  }
36063
+ function namedFew(names) {
36064
+ const shown = names.slice(0, 5);
36065
+ const more = names.length - shown.length;
36066
+ if (more > 0) {
36067
+ return `${shown.join(", ")} and ${more} more`;
36068
+ }
36069
+ return shown.length > 1 ? `${shown.slice(0, -1).join(", ")} and ${shown.at(-1) ?? ""}` : shown[0] ?? "";
36070
+ }
36061
36071
  function cappedNotes(totals) {
36062
36072
  const notes = [];
36063
36073
  const most = totals.frameFunctions;
36064
36074
  const why = `${most} is kept below the game's debugger queue (network/limits/debugger/max_queued_messages), which drops the message naming a function when one frame names more than it holds, and a function whose name is dropped is never named afterwards.`;
36065
36075
  if (most !== undefined && totals.complete && totals.totalCapped === true) {
36066
- notes.push(`The engine's totals carried the most functions the profiler asks for, ${most}, so the functions with the least time are not in them: ${why}`);
36076
+ const leastKept = Math.min(...Object.values(totals.functions).map((one) => one.totalSeconds));
36077
+ notes.push(leastKept > 0 ? `The engine's totals carried the most functions the profiler asks for, ${most}, so the functions with the least time are not in them: ${why}` : `The engine's totals carried the most functions the profiler asks for, ${most}, and the last of them took no measurable time, so neither did any left out: the time is all here, and only a function too quick for the clock can be missing, with its calls. The engine lists every function it has compiled, run or not, and sends them busiest first. ${why}`);
36067
36078
  } else if (most !== undefined && !totals.complete && (totals.cappedFrames ?? 0) > 0) {
36068
36079
  const capped = totals.cappedFrames ?? 0;
36069
36080
  notes.push(`${capped === totals.frames ? capped === 1 ? "The one frame" : `All ${capped} frames` : `${capped} of the ${totals.frames} frames`} carried the most functions the profiler asks for in one, ${most}, so the functions with the least time in ${capped === 1 ? "it" : "them"} are not counted there: ${why}`);
@@ -39426,10 +39437,11 @@ class GodotServer {
39426
39437
  const startedAt = Date.now();
39427
39438
  const transcript = openTranscript(startedAt);
39428
39439
  closeSync2(transcript.fd);
39440
+ const gameEnv = profiled ? { ...env ?? process.env, [PROFILE_FROM_START]: "1" } : env;
39429
39441
  const launched = await launchOutsideTheTree({
39430
39442
  command: godotPath,
39431
39443
  args: cmdArgs,
39432
- ...env === undefined ? {} : { env },
39444
+ ...gameEnv === undefined ? {} : { env: gameEnv },
39433
39445
  run: {
39434
39446
  transcript: transcript.path,
39435
39447
  startedAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
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",