gdharness 1.3.13 → 1.3.15
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 +31 -12
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +8 -0
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +63 -8
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +3 -1
- package/build/godot/loader/loader.gd +18 -6
- package/build/godot/operations/project_file.gd +35 -13
- package/build/keeper.js +10 -6
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -10896,7 +10896,13 @@ function exitNamed(code) {
|
|
|
10896
10896
|
return `0x${unsigned.toString(16).toUpperCase()} (${status})`;
|
|
10897
10897
|
}
|
|
10898
10898
|
const gdunit = GDUNIT_EXITS.get(code);
|
|
10899
|
-
|
|
10899
|
+
if (gdunit !== undefined) {
|
|
10900
|
+
return `${String(code)} (${gdunit})`;
|
|
10901
|
+
}
|
|
10902
|
+
if (code > 0 && code < 256 && !GODOT_EXITS.has(code)) {
|
|
10903
|
+
return `${String(code)} (a code neither Godot nor gdUnit4 ends with, so most likely a quit(${String(code)}) in the project's code the engine ran)`;
|
|
10904
|
+
}
|
|
10905
|
+
return String(code);
|
|
10900
10906
|
}
|
|
10901
10907
|
function rowsAcross(engines) {
|
|
10902
10908
|
const dealtOut = new Map;
|
|
@@ -11132,7 +11138,7 @@ ${String(error.warning)}`);
|
|
|
11132
11138
|
const neverPrinted = [...owed.values()].flatMap((debt) => debt.texts.map((text) => ({ suite: debt.suite, during: debt.during, text, warning: debt.warning })));
|
|
11133
11139
|
return { printed: unexpected, neverPrinted };
|
|
11134
11140
|
}
|
|
11135
|
-
var SUITE_OPENED, TIME, CASE_LINE, STATISTICS, ERROR_LINE, WARNING_LINE, EXECUTED, BANNER, LEFT_OUT, DRIVER_FALLBACK, MACHINE_WARNED, ORPHANS_REPORTED, STARVED, NESTED_ERROR = "While attempting to print an error, another error was printed:", WINDOWS_EXITS, MEMORY_EXITS, GDUNIT_FAILED = 100, GDUNIT_EXITS, TAIL = 60, MOST_NAMED = 8;
|
|
11141
|
+
var SUITE_OPENED, TIME, CASE_LINE, STATISTICS, ERROR_LINE, WARNING_LINE, EXECUTED, BANNER, LEFT_OUT, DRIVER_FALLBACK, MACHINE_WARNED, ORPHANS_REPORTED, STARVED, NESTED_ERROR = "While attempting to print an error, another error was printed:", WINDOWS_EXITS, MEMORY_EXITS, GDUNIT_FAILED = 100, GDUNIT_EXITS, GODOT_EXITS, TAIL = 60, MOST_NAMED = 8;
|
|
11136
11142
|
var init_suite_judge = __esm(() => {
|
|
11137
11143
|
init_engine_leaks();
|
|
11138
11144
|
init_junit();
|
|
@@ -11169,6 +11175,7 @@ var init_suite_judge = __esm(() => {
|
|
|
11169
11175
|
[104, "gdUnit4's code for a Godot version it does not support"],
|
|
11170
11176
|
[105, "gdUnit4's code for scripts that did not compile"]
|
|
11171
11177
|
]);
|
|
11178
|
+
GODOT_EXITS = new Set([1, 255]);
|
|
11172
11179
|
});
|
|
11173
11180
|
|
|
11174
11181
|
// src/engine-launch.ts
|
|
@@ -11247,16 +11254,24 @@ function resPathOf(entry, projectPath, cwd) {
|
|
|
11247
11254
|
function homeOf(projectPath) {
|
|
11248
11255
|
return mainCheckoutOf(projectPath) ?? projectPath;
|
|
11249
11256
|
}
|
|
11250
|
-
function sweepRunDirectories(runs) {
|
|
11257
|
+
function sweepRunDirectories(runs, kept = KEPT_RUN_DIRECTORIES) {
|
|
11251
11258
|
if (!existsSync5(runs)) {
|
|
11252
11259
|
return;
|
|
11253
11260
|
}
|
|
11261
|
+
const ended = [];
|
|
11254
11262
|
for (const entry of readdirSync3(runs)) {
|
|
11255
|
-
const
|
|
11256
|
-
|
|
11263
|
+
const named = /^(\d+)-(\d+)$/.exec(entry);
|
|
11264
|
+
const pid = Number(named?.[2]);
|
|
11265
|
+
if (!Number.isInteger(pid)) {
|
|
11257
11266
|
rmSync4(join7(runs, entry), { recursive: true, force: true });
|
|
11267
|
+
} else if (!alive(pid)) {
|
|
11268
|
+
ended.push({ entry, started: Number(named?.[1]) });
|
|
11258
11269
|
}
|
|
11259
11270
|
}
|
|
11271
|
+
ended.sort((a, b) => b.started - a.started);
|
|
11272
|
+
for (const { entry } of ended.slice(kept)) {
|
|
11273
|
+
rmSync4(join7(runs, entry), { recursive: true, force: true });
|
|
11274
|
+
}
|
|
11260
11275
|
}
|
|
11261
11276
|
function roomFor(most, processors, available, said = "of memory free", processorsSaid = `the machine has ${plural(processors, "processor")}`) {
|
|
11262
11277
|
const byMemory = Math.floor((available - MEMORY_KEPT) / MEMORY_PER_ENGINE);
|
|
@@ -11445,7 +11460,7 @@ function endTree(child) {
|
|
|
11445
11460
|
process6.kill(-child.pid, "SIGKILL");
|
|
11446
11461
|
} catch {}
|
|
11447
11462
|
}
|
|
11448
|
-
var EngineUsageError, MOST_ENGINES = 4, SILENCE_FLOOR_SECONDS = 300, CONTROL_SEQUENCE, WINDOWS_EXCEPTION = 3221225472, MEMORY_KEPT, MEMORY_PER_ENGINE, LOOK_MS = 500, COMMIT_ASK_MS = 15000, AUTOLOAD_FAILED = "Failed to instantiate an autoload";
|
|
11463
|
+
var EngineUsageError, MOST_ENGINES = 4, SILENCE_FLOOR_SECONDS = 300, CONTROL_SEQUENCE, WINDOWS_EXCEPTION = 3221225472, MEMORY_KEPT, MEMORY_PER_ENGINE, LOOK_MS = 500, COMMIT_ASK_MS = 15000, AUTOLOAD_FAILED = "Failed to instantiate an autoload", KEPT_RUN_DIRECTORIES = 5;
|
|
11449
11464
|
var init_engine_launch = __esm(() => {
|
|
11450
11465
|
init_alive();
|
|
11451
11466
|
init_desktop();
|
|
@@ -15995,7 +16010,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
15995
16010
|
{
|
|
15996
16011
|
name: "project_settings",
|
|
15997
16012
|
title: "Project settings",
|
|
15998
|
-
description: "Reads or writes project.godot (settings, autoloads, the main scene, input actions and plugins) and the audio bus layout, default_bus_layout.tres unless audio/buses/default_bus_layout names another. project_info include lists the autoloads, input actions and audio buses as the project holds them. A write with an editor open on the project is handed to the editor, which takes the settings the write changed or names from the file (named under editorAdopted), including a setting the file already held, since the editor can hold another value after the file was put back, or reloads the bus layout, so it answers the new values and does not save the old ones back over them. editorNote is an editor that could not take the write, or one on another release's addon that was not asked, which still holds the old values until editor_launch restart. An enabled plugin's code still loads on the editor's next start. set answers old_value and new_value, with was_new true when project.godot did not set the setting before, and was_default true when what it held was the engine's own default, which old_value then is. add_input_action on an action that exists, one of the engine's ui_ actions included, replaces its events, and its deadzone when one is given, and answers was_new false with what it held under old_events and old_deadzone. A set of the value the setting already holds, in the same type, writes nothing and answers unchanged true and saved false. unset takes a setting's line out of project.godot, the way back from a set of a misspelt name, and answers removed, old_value and default_value, the value that holds now (null for a setting the engine does not declare; for an override, name.feature, holds is the base setting's value its builds read now); on one the file does not set it answers removed false and writes nothing. Every write
|
|
16013
|
+
description: "Reads or writes project.godot (settings, autoloads, the main scene, input actions and plugins) and the audio bus layout, default_bus_layout.tres unless audio/buses/default_bus_layout names another. project_info include lists the autoloads, input actions and audio buses as the project holds them. A write with an editor open on the project is handed to the editor, which takes the settings the write changed or names from the file (named under editorAdopted), including a setting the file already held, since the editor can hold another value after the file was put back, or reloads the bus layout, so it answers the new values and does not save the old ones back over them. editorNote is an editor that could not take the write, or one on another release's addon that was not asked, which still holds the old values until editor_launch restart. An enabled plugin's code still loads on the editor's next start. set answers old_value and new_value, with was_new true when project.godot did not set the setting before, and was_default true when what it held was the engine's own default, which old_value then is. add_input_action on an action that exists, one of the engine's ui_ actions included, replaces its events, and its deadzone when one is given, and answers was_new false with what it held under old_events and old_deadzone. A set of the value the setting already holds, in the same type, writes nothing and answers unchanged true and saved false. unset takes a setting's line out of project.godot, the way back from a set of a misspelt name, and answers removed, old_value and default_value, the value that holds now (null for a setting the engine does not declare; for an override, name.feature, holds is the base setting's value its builds read now); on one the file does not set it answers removed false and writes nothing. Every write changes only the lines of the settings it names and keeps every other line as the file had it, in its place: the engine's own save would sort a dictionary's keys and write what a project_settings_override file laid over the project. get answers exists, true for any setting the engine knows, at_default, true when the value is the engine's own default and so has no line in project.godot, and overrides, the value each feature's override holds. An override answers override_of, its base setting, and one project.godot does not set answers exists false with holds, the base setting's value. Each setting under prefix carries at_default too, so a family read separates what the project sets from the engine's defaults; it is absent for a setting the engine registers no default for, such as an input action the project added, which only the project can have set.",
|
|
15999
16014
|
parameters: {
|
|
16000
16015
|
projectPath: PROJECT_PATH,
|
|
16001
16016
|
setting: {
|
|
@@ -17502,7 +17517,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
17502
17517
|
type: ["boolean", "array"],
|
|
17503
17518
|
items: { type: "string" },
|
|
17504
17519
|
ops: ["call"],
|
|
17505
|
-
description:
|
|
17520
|
+
description: `call: read the object the call answered with in the same answer, under result_properties: true for every variable its script declares, or a list of names, each a colon path walked from that object the way property paths are ("dead_allowed", "terms:0:name"). For a list or a dictionary the call answered with, they are read off each object in it: result_properties is then a list, or a dictionary under the same keys, with null for an element that is no object. A name that is not there is listed under result_properties_missing with why, led by the element's index or key for a list or dictionary, and a result with no object in it answers properties_note instead. Without it an object comes back as its class, its script_class or script, and id, which is the object's instance id (get_instance_id()) and never a property of its own named id: read that one through properties.`
|
|
17506
17521
|
},
|
|
17507
17522
|
timeoutMs: {
|
|
17508
17523
|
type: "integer",
|
|
@@ -17912,7 +17927,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
17912
17927
|
{
|
|
17913
17928
|
name: "debug_state",
|
|
17914
17929
|
title: "Debugger state",
|
|
17915
|
-
description: "Where the debugged game is stopped: the stack trace, what is in scope at a frame with the values, or the debugger's console output so far, its last thousand lines, with any earlier ones counted under notShown. For a game this server started the output is what the game printed; the errors and warnings it reported, with where each happened, are in editor_output. For a game this server started, its keeper's debugger answers: each frame names its script as the project spells it under script and on this machine under path, and held says which thread stopped and why. For a game the editor plays, the editor's debug adapter answers. A stop is reported by the editor's adapter only to the sessions connected when it happened, so a server that connected afterwards, which is every server after a reconnect, is shown a thread and no frames about a game still sitting at its breakpoint. That server asks the runtime instead: a held game accepts a connection and never answers, and the refusal then says held rather than running, and that the stack cannot be read from here. debug_control continue lets it go, and the next stop is one this session is told about, with its stack. A function that awaited is resumed with no caller below it, since the call that started it returned at the await, so its frame is the only one. The variables give an object as its class and id only, and evaluate reads into it: an expression worked out by the game at a frame, with the frame's locals, the globals and every global class in scope and the frame's instance as self, such as wave.spawned or wave.spawned.size(). The game runs what the expression calls, so a call that changes something changes the game. A frame with no instance, a static function's called on its class, is refused, since the engine lets the game go on rather than evaluate there; the frame below it has one. The compiler's own locals, named with @ such as a for loop's @counter_pos, are left out.",
|
|
17930
|
+
description: "Where the debugged game is stopped: the stack trace, what is in scope at a frame with the values, or the debugger's console output so far, its last thousand lines, with any earlier ones counted under notShown. For a game this server started the output is what the game printed; the errors and warnings it reported, with where each happened, are in editor_output. For a game this server started, its keeper's debugger answers: each frame names its script as the project spells it under script and on this machine under path, and held says which thread stopped and why. For a game the editor plays, the editor's debug adapter answers. A stop is reported by the editor's adapter only to the sessions connected when it happened, so a server that connected afterwards, which is every server after a reconnect, is shown a thread and no frames about a game still sitting at its breakpoint. That server asks the runtime instead: a held game accepts a connection and never answers, and the refusal then says held rather than running, and that the stack cannot be read from here. debug_control continue lets it go, and the next stop is one this session is told about, with its stack. A function that awaited is resumed with no caller below it, since the call that started it returned at the await, so its frame is the only one. The variables give an object as its class and instance id only, and evaluate reads into it: an expression worked out by the game at a frame, with the frame's locals, the globals and every global class in scope and the frame's instance as self, such as wave.spawned or wave.spawned.size(). The game runs what the expression calls, so a call that changes something changes the game. A frame with no instance, a static function's called on its class, is refused, since the engine lets the game go on rather than evaluate there; the frame below it has one. The compiler's own locals, named with @ such as a for loop's @counter_pos, are left out.",
|
|
17916
17931
|
parameters: {
|
|
17917
17932
|
frameId: {
|
|
17918
17933
|
type: "number",
|
|
@@ -18370,12 +18385,14 @@ function unitsOf(suites, times, splitPast = SPLIT_PAST_SECONDS) {
|
|
|
18370
18385
|
return suites.flatMap((suite) => {
|
|
18371
18386
|
const time = times[suite.path];
|
|
18372
18387
|
const costOf = (func) => time?.cases[func] ?? caseCost;
|
|
18388
|
+
const timedCase = (func) => time?.cases[func] !== undefined;
|
|
18373
18389
|
const around = time === undefined ? aroundCost : Math.max(0, time.seconds - Object.values(time.cases).reduce((sum, s) => sum + s, 0));
|
|
18374
18390
|
const whole = around + suite.cases.reduce((sum, func) => sum + costOf(func), 0);
|
|
18375
18391
|
const wholly = {
|
|
18376
18392
|
suite: suite.path,
|
|
18377
18393
|
cases: suite.cases,
|
|
18378
18394
|
cost: whole,
|
|
18395
|
+
timed: suite.cases.every(timedCase),
|
|
18379
18396
|
windowed: suite.windowed,
|
|
18380
18397
|
headless: suite.headless
|
|
18381
18398
|
};
|
|
@@ -18390,6 +18407,7 @@ function unitsOf(suites, times, splitPast = SPLIT_PAST_SECONDS) {
|
|
|
18390
18407
|
suite: suite.path,
|
|
18391
18408
|
cases: [func],
|
|
18392
18409
|
cost: around + costOf(func),
|
|
18410
|
+
timed: timedCase(func),
|
|
18393
18411
|
windowed: false,
|
|
18394
18412
|
headless: suite.headless
|
|
18395
18413
|
}
|
|
@@ -18400,6 +18418,7 @@ function unitsOf(suites, times, splitPast = SPLIT_PAST_SECONDS) {
|
|
|
18400
18418
|
cases: [func],
|
|
18401
18419
|
row: { index, rest: index === rows.length - 1 },
|
|
18402
18420
|
cost: around + seconds,
|
|
18421
|
+
timed: true,
|
|
18403
18422
|
windowed: false,
|
|
18404
18423
|
headless: suite.headless
|
|
18405
18424
|
}));
|
|
@@ -18417,7 +18436,7 @@ function dealt(units, declared, most) {
|
|
|
18417
18436
|
const restart = windowed.length > 0 ? ENGINE_START_SECONDS : 0;
|
|
18418
18437
|
const total = units.reduce((sum, unit) => sum + unit.cost, 0);
|
|
18419
18438
|
const slowest = Math.max(together, headless[0]?.cost ?? 0);
|
|
18420
|
-
const count = Math.max(1, Math.min(most, headless.length + (windowed.length > 0 ? 1 : 0), Math.ceil(total / Math.max(slowest, ENGINE_START_SECONDS))));
|
|
18439
|
+
const count = Math.max(1, Math.min(most, headless.length + (windowed.length > 0 ? 1 : 0), Math.ceil(total / Math.max(slowest, ENGINE_START_SECONDS)) + headless.filter((unit) => !unit.timed).length));
|
|
18421
18440
|
const bins = Array.from({ length: count }, () => []);
|
|
18422
18441
|
const loads = Array.from({ length: count }, (_, lane) => lane === 0 ? together + restart : 0);
|
|
18423
18442
|
for (const unit of headless) {
|
|
@@ -57961,7 +57980,7 @@ function failed(faults, engines, runDirectory, so) {
|
|
|
57961
57980
|
for (const engine of engines) {
|
|
57962
57981
|
console.log(`what ${engine.name} printed: ${engine.log}`);
|
|
57963
57982
|
}
|
|
57964
|
-
console.log(`Every engine's output is kept under ${runDirectory} until
|
|
57983
|
+
console.log(`Every engine's output is kept under ${runDirectory} until ${String(KEPT_RUN_DIRECTORIES)} later runs have failed.`);
|
|
57965
57984
|
return 1;
|
|
57966
57985
|
}
|
|
57967
57986
|
|
|
@@ -58748,7 +58767,7 @@ ${readFileSync22(engine.log, "utf8")}`);
|
|
|
58748
58767
|
}
|
|
58749
58768
|
const problems = failed.reduce((sum, engine) => sum + engine.verdict.faults.length, across.length + uncollected.length);
|
|
58750
58769
|
console.log(`Failed: ${plural(problems, "problem")}, listed above. Ran ${totals}`);
|
|
58751
|
-
console.log(`Every engine's output is kept under ${logs.shown} until
|
|
58770
|
+
console.log(`Every engine's output is kept under ${logs.shown} until ${String(KEPT_RUN_DIRECTORIES)} later runs have failed.`);
|
|
58752
58771
|
return 1;
|
|
58753
58772
|
}
|
|
58754
58773
|
function writeEarlyResult(file, startedAt, problems, logs) {
|
|
@@ -245,6 +245,14 @@ func _wait_for_the_profiler() -> void:
|
|
|
245
245
|
OS.delay_msec(1)
|
|
246
246
|
|
|
247
247
|
|
|
248
|
+
## Whether this runtime is listening for gdharness's server. It is not in a release build, a script
|
|
249
|
+
## run, a scene the project keeps quiet, an engine `gdharness spread` started, or a game that could
|
|
250
|
+
## not have its port, though the node is there in all of them, so ask this rather than whether the
|
|
251
|
+
## node exists.
|
|
252
|
+
func serving() -> bool:
|
|
253
|
+
return _enabled and _server != null and _server.is_listening()
|
|
254
|
+
|
|
255
|
+
|
|
248
256
|
func _ready() -> void:
|
|
249
257
|
name = "GdharnessRuntime"
|
|
250
258
|
# The TCP control loop runs in _process. With the default PROCESS_MODE_INHERIT it stops
|
|
@@ -694,12 +694,27 @@ static func _reads_the_result(asked: Variant) -> bool:
|
|
|
694
694
|
|
|
695
695
|
## What [param asked] reads off the object a call answered with: every variable its script declares
|
|
696
696
|
## when [param asked] is true, or the properties a list names, each a path walked from the object.
|
|
697
|
+
## Off each object of a list or a dictionary the call answered with too, in the result's shape.
|
|
697
698
|
##
|
|
698
|
-
## In the same answer because an object handed back is otherwise only its class and
|
|
699
|
-
## reading one field of it meant finding the same object again by a path into the game's
|
|
700
|
-
## state, which works only where the caller already knows a key
|
|
701
|
-
##
|
|
699
|
+
## In the same answer because an object handed back is otherwise only its class and its instance
|
|
700
|
+
## id, and reading one field of it meant finding the same object again by a path into the game's
|
|
701
|
+
## private state, which works only where the caller already knows a key; a catalogue of records
|
|
702
|
+
## handed back as a list took one call per record. A name that is not there is listed rather than
|
|
703
|
+
## refusing the answer, since the call has run by then and its result is what was asked.
|
|
702
704
|
func _read_off_result(result: Variant, asked: Variant) -> Dictionary:
|
|
705
|
+
var missing: Array[String] = []
|
|
706
|
+
if result is Array:
|
|
707
|
+
var list: Array = result
|
|
708
|
+
var read_each: Array = []
|
|
709
|
+
for index: int in list.size():
|
|
710
|
+
read_each.append(_read_off_element(list[index], "[%d]" % index, asked, missing))
|
|
711
|
+
return _read_off_many(read_each, list, "Array", missing)
|
|
712
|
+
if result is Dictionary:
|
|
713
|
+
var map: Dictionary = result
|
|
714
|
+
var read_by_key: Dictionary = {}
|
|
715
|
+
for key: Variant in map:
|
|
716
|
+
read_by_key[str(key)] = _read_off_element(map[key], "[%s]" % str(key), asked, missing)
|
|
717
|
+
return _read_off_many(read_by_key, map.values(), "Dictionary", missing)
|
|
703
718
|
if result is Object and not is_instance_valid(result):
|
|
704
719
|
return {
|
|
705
720
|
"properties_note": "the call answered an object that has since been freed, so nothing was read"
|
|
@@ -710,6 +725,49 @@ func _read_off_result(result: Variant, asked: Variant) -> Dictionary:
|
|
|
710
725
|
"the call answered %s, which has no properties to read" % type_string(typeof(result))
|
|
711
726
|
}
|
|
712
727
|
var object: Object = result
|
|
728
|
+
var one: Dictionary = _read_off_object(object, asked)
|
|
729
|
+
missing = one["missing"]
|
|
730
|
+
var answered: Dictionary = {"result_properties": one["read"]}
|
|
731
|
+
if not missing.is_empty():
|
|
732
|
+
answered["result_properties_missing"] = missing
|
|
733
|
+
return answered
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
## What was read off one element of a list or a dictionary a call answered with, or null for one
|
|
737
|
+
## that is no object; why a name was not there goes into [param missing], led by [param named].
|
|
738
|
+
func _read_off_element(element: Variant, named: String, asked: Variant, missing: Array[String]) -> Variant:
|
|
739
|
+
if not element is Object:
|
|
740
|
+
return null
|
|
741
|
+
if not is_instance_valid(element):
|
|
742
|
+
missing.append("%s is an object that has since been freed" % named)
|
|
743
|
+
return null
|
|
744
|
+
var object: Object = element
|
|
745
|
+
var one: Dictionary = _read_off_object(object, asked)
|
|
746
|
+
var whys: Array[String] = one["missing"]
|
|
747
|
+
for why: String in whys:
|
|
748
|
+
missing.append("%s %s" % [named, why])
|
|
749
|
+
return one["read"]
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
## The answer for a list or a dictionary, [param read] holding what each element gave, unless none
|
|
753
|
+
## of [param elements] is an object.
|
|
754
|
+
static func _read_off_many(
|
|
755
|
+
read: Variant, elements: Array, shape: String, missing: Array[String]
|
|
756
|
+
) -> Dictionary:
|
|
757
|
+
if not elements.any(func(element: Variant) -> bool: return element is Object):
|
|
758
|
+
return {
|
|
759
|
+
"properties_note":
|
|
760
|
+
"the call answered %s with no object in it, so there are no properties to read" % shape
|
|
761
|
+
}
|
|
762
|
+
var answered: Dictionary = {"result_properties": read}
|
|
763
|
+
if not missing.is_empty():
|
|
764
|
+
answered["result_properties_missing"] = missing
|
|
765
|
+
return answered
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
## What [param asked] reads off [param object], under read, and why each name it could not read is
|
|
769
|
+
## not there, under missing.
|
|
770
|
+
func _read_off_object(object: Object, asked: Variant) -> Dictionary:
|
|
713
771
|
var wanted: Array[String] = []
|
|
714
772
|
if asked is Array:
|
|
715
773
|
for name: Variant in asked:
|
|
@@ -724,10 +782,7 @@ func _read_off_result(result: Variant, asked: Variant) -> Dictionary:
|
|
|
724
782
|
missing.append(str(walked["message"]))
|
|
725
783
|
else:
|
|
726
784
|
read[path] = _values.serialize(walked["value"])
|
|
727
|
-
|
|
728
|
-
if not missing.is_empty():
|
|
729
|
-
answered["result_properties_missing"] = missing
|
|
730
|
-
return answered
|
|
785
|
+
return {"read": read, "missing": missing}
|
|
731
786
|
|
|
732
787
|
|
|
733
788
|
## The variables the script attached to [param object] declares, in the order it declares them.
|
|
@@ -892,10 +892,12 @@ class GdhTrace:
|
|
|
892
892
|
elif value is Dictionary:
|
|
893
893
|
var map: Dictionary = value
|
|
894
894
|
_last = map.duplicate(true)
|
|
895
|
+
# Game time unrounded: a frame can be a tenth of a millisecond long, and a rate worked out over
|
|
896
|
+
# one from times cut to a ten-thousandth was off by its own size.
|
|
895
897
|
var change: Dictionary = {
|
|
896
898
|
"frame": frame,
|
|
897
899
|
"elapsed_ms": elapsed,
|
|
898
|
-
"game_time":
|
|
900
|
+
"game_time": game_time,
|
|
899
901
|
"value": values.serialize(value),
|
|
900
902
|
}
|
|
901
903
|
if changes.size() < MOST_CHANGES:
|
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
extends Node
|
|
2
|
-
## Brings gdharness's runtime up in a debug build that has its addon, and does nothing
|
|
2
|
+
## Brings gdharness's runtime up in a debug build that has its addon, and does nothing elsewhere.
|
|
3
3
|
##
|
|
4
|
-
## `project.godot` names this file rather than the addon's script.
|
|
5
|
-
## and exports leave it out, so an entry naming the addon boots a fresh clone or an
|
|
6
|
-
## with a missing script. `gdharness setup` writes this file, and `gdharness doctor`
|
|
7
|
-
## not the one that gdharness ships. The runtime refuses a `-s` script run by itself
|
|
8
|
-
##
|
|
4
|
+
## `project.godot` names this file rather than the addon's script. The addons folder is often not
|
|
5
|
+
## committed and exports leave it out, so an entry naming the addon boots a fresh clone or an
|
|
6
|
+
## exported build with a missing script. `gdharness setup` writes this file, and `gdharness doctor`
|
|
7
|
+
## says when it is not the one that gdharness ships. The runtime refuses a `-s` script run by itself
|
|
8
|
+
## unless the project's serve_script_runs setting is on, so that is left to it.
|
|
9
9
|
|
|
10
10
|
## Loaded by path as it runs rather than preloaded: a preload resolves when this script is parsed,
|
|
11
11
|
## and in a build without the addon that is the missing script this file is here to avoid.
|
|
12
12
|
const RUNTIME: String = "res://addons/gdharness_runtime/runtime_autoload.gd"
|
|
13
13
|
|
|
14
|
+
var _runtime: Node
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## Whether gdharness's runtime is listening in this engine: false in a release build, a script run
|
|
18
|
+
## and an engine without the addon, though this node is in every one of them.
|
|
19
|
+
func serving() -> bool:
|
|
20
|
+
if not is_instance_valid(_runtime) or not _runtime.has_method("serving"):
|
|
21
|
+
return false
|
|
22
|
+
var answered: Variant = _runtime.call("serving")
|
|
23
|
+
return answered is bool and answered
|
|
24
|
+
|
|
14
25
|
|
|
15
26
|
func _ready() -> void:
|
|
16
27
|
if not OS.is_debug_build() or not ResourceLoader.exists(RUNTIME):
|
|
@@ -26,3 +37,4 @@ func _ready() -> void:
|
|
|
26
37
|
# autoloads, and a deferred add would bring the runtime up only after the main scene's _ready.
|
|
27
38
|
# Added now, it announces before the main scene runs, as it does when an entry names it.
|
|
28
39
|
add_child(runtime)
|
|
40
|
+
_runtime = runtime
|
|
@@ -5,10 +5,14 @@ extends RefCounted
|
|
|
5
5
|
## The engine writes each section's keys in the order it registered them, and an engine running a
|
|
6
6
|
## script registers them in another order than the editor does, so a one-setting change moved lines
|
|
7
7
|
## nobody touched across several sections and a commit of it read as a rewrite. The save is still the
|
|
8
|
-
## engine's, for what only it gets right: the header and
|
|
9
|
-
## the file as it was, and so is every line of a key the call did not change
|
|
10
|
-
##
|
|
11
|
-
## a
|
|
8
|
+
## engine's, for what only it gets right: the header and the text of each value the call changed.
|
|
9
|
+
## The order is taken from the file as it was, and so is every line of a key the call did not change,
|
|
10
|
+
## because the engine writes its settings as it holds them rather than as the file had them. It
|
|
11
|
+
## leaves out each key at its default, so a project holding `unsafe_call_argument=0` on purpose lost
|
|
12
|
+
## the line to a change of another key. It writes a dictionary with its keys sorted, so gdUnit4's
|
|
13
|
+
## session hooks, which run in the order listed, changed order. And it writes what a
|
|
14
|
+
## `project_settings_override` file laid over the project, so a player's window mode in the user
|
|
15
|
+
## directory became the game's default.
|
|
12
16
|
|
|
13
17
|
const PATH: String = "res://project.godot"
|
|
14
18
|
|
|
@@ -35,30 +39,48 @@ static func save(changed: PackedStringArray) -> Error:
|
|
|
35
39
|
return OK
|
|
36
40
|
|
|
37
41
|
|
|
38
|
-
## [param
|
|
39
|
-
##
|
|
40
|
-
## is
|
|
41
|
-
## engine wrote
|
|
42
|
+
## [param before] with the lines of the settings in [param changed] as [param written] has them: one
|
|
43
|
+
## the engine wrote goes in its place, or where the engine put it relative to its neighbours when it
|
|
44
|
+
## is new, and one the engine left out leaves the file. Every other line is [param before]'s, in its
|
|
45
|
+
## place, and a key the engine wrote that the file did not hold and the call did not change stays out.
|
|
46
|
+
## The header is [param written]'s.
|
|
42
47
|
static func in_order_of(before: String, written: String, changed: PackedStringArray) -> String:
|
|
43
48
|
var old: Dictionary = _parsed(before)
|
|
44
49
|
var fresh: Dictionary = _parsed(written)
|
|
45
50
|
var old_sections: Array[Dictionary] = old["sections"]
|
|
46
|
-
var
|
|
51
|
+
var all_fresh: Array[Dictionary] = fresh["sections"]
|
|
52
|
+
|
|
53
|
+
var old_text: Dictionary[String, Dictionary] = {}
|
|
54
|
+
for section: Dictionary in old_sections:
|
|
55
|
+
var texts: Dictionary[String, String] = {}
|
|
56
|
+
for entry: Dictionary in section["entries"]:
|
|
57
|
+
texts[str(entry["key"])] = str(entry["text"])
|
|
58
|
+
old_text[str(section["name"])] = texts
|
|
47
59
|
|
|
48
60
|
var by_name: Dictionary[String, Dictionary] = {}
|
|
49
61
|
var fresh_names: Array[String] = []
|
|
62
|
+
var fresh_sections: Array[Dictionary] = []
|
|
50
63
|
var entry_of: Dictionary[String, Dictionary] = {}
|
|
51
64
|
var fresh_keys: Dictionary[String, Array] = {}
|
|
52
|
-
for section: Dictionary in
|
|
65
|
+
for section: Dictionary in all_fresh:
|
|
53
66
|
var name: String = section["name"]
|
|
54
|
-
|
|
55
|
-
fresh_names.append(name)
|
|
67
|
+
var held: Dictionary = old_text.get(name, {})
|
|
56
68
|
var texts: Dictionary[String, String] = {}
|
|
57
69
|
var keys: Array[String] = []
|
|
58
70
|
for entry: Dictionary in section["entries"]:
|
|
59
71
|
var key: String = entry["key"]
|
|
60
|
-
|
|
72
|
+
if changed.has(_setting_of(name, key)):
|
|
73
|
+
texts[key] = str(entry["text"])
|
|
74
|
+
elif held.has(key):
|
|
75
|
+
texts[key] = str(held[key])
|
|
76
|
+
else:
|
|
77
|
+
continue
|
|
61
78
|
keys.append(key)
|
|
79
|
+
if keys.is_empty():
|
|
80
|
+
continue
|
|
81
|
+
by_name[name] = section
|
|
82
|
+
fresh_names.append(name)
|
|
83
|
+
fresh_sections.append(section)
|
|
62
84
|
entry_of[name] = texts
|
|
63
85
|
fresh_keys[name] = keys
|
|
64
86
|
|
package/build/keeper.js
CHANGED
|
@@ -8331,6 +8331,7 @@ var GDUNIT_EXITS = new Map([
|
|
|
8331
8331
|
[104, "gdUnit4's code for a Godot version it does not support"],
|
|
8332
8332
|
[105, "gdUnit4's code for scripts that did not compile"]
|
|
8333
8333
|
]);
|
|
8334
|
+
var GODOT_EXITS = new Set([1, 255]);
|
|
8334
8335
|
|
|
8335
8336
|
// src/windows-ask.ts
|
|
8336
8337
|
var SCRIPT = [
|
|
@@ -8386,6 +8387,9 @@ async function linuxStartTimes(pids) {
|
|
|
8386
8387
|
}
|
|
8387
8388
|
return began;
|
|
8388
8389
|
}
|
|
8390
|
+
async function linuxStartEarliest() {
|
|
8391
|
+
return 1000 + Math.ceil(1000 / (await ticksPerSecond() ?? 100));
|
|
8392
|
+
}
|
|
8389
8393
|
var ticksAnswered;
|
|
8390
8394
|
async function ticksPerSecond() {
|
|
8391
8395
|
if (ticksAnswered !== undefined) {
|
|
@@ -9906,11 +9910,10 @@ import { setTimeout as delay2 } from "node:timers/promises";
|
|
|
9906
9910
|
var RESTARTED_WITHIN_MS = 2000;
|
|
9907
9911
|
var LIST_WITHIN_MS = 15000;
|
|
9908
9912
|
var POLL_MS = 250;
|
|
9909
|
-
function successorAmong(listed, exitedPid, engine, notBefore, exitedAt,
|
|
9910
|
-
const slack = secondsOnly ? 1000 : 0;
|
|
9913
|
+
function successorAmong(listed, exitedPid, engine, notBefore, exitedAt, early) {
|
|
9911
9914
|
const fold = (name) => process.platform === "win32" ? name.toLowerCase() : name;
|
|
9912
9915
|
const wanted = fold(basename5(engine.replaceAll("\\", "/")));
|
|
9913
|
-
const found = listed.filter((one) => one.pid !== exitedPid && fold(readCommandLine(one.command).executable) === wanted && one.startedAt +
|
|
9916
|
+
const found = listed.filter((one) => one.pid !== exitedPid && fold(readCommandLine(one.command).executable) === wanted && one.startedAt + early >= notBefore && one.startedAt + early >= exitedAt - RESTARTED_WITHIN_MS);
|
|
9914
9917
|
return found.length === 1 ? found[0] ?? null : null;
|
|
9915
9918
|
}
|
|
9916
9919
|
async function followSuccessor(exitedPid, engine, notBefore, exitedAt, transcript) {
|
|
@@ -9981,7 +9984,7 @@ async function followOnWindows(exitedPid, engine, notBefore, exitedAt) {
|
|
|
9981
9984
|
candidates.push({ pid: Number(fields[1]), startedAt: Number(fields[2]), command: fields[3].trim() });
|
|
9982
9985
|
}
|
|
9983
9986
|
}
|
|
9984
|
-
const successor = successorAmong(candidates, exitedPid, engine, notBefore, exitedAt,
|
|
9987
|
+
const successor = successorAmong(candidates, exitedPid, engine, notBefore, exitedAt, 0);
|
|
9985
9988
|
holder.stdin.end(successor === null ? `
|
|
9986
9989
|
` : `${String(successor.pid)}
|
|
9987
9990
|
`);
|
|
@@ -9996,6 +9999,7 @@ async function followOnWindows(exitedPid, engine, notBefore, exitedAt) {
|
|
|
9996
9999
|
}
|
|
9997
10000
|
async function followOnPosix(exitedPid, engine, notBefore, exitedAt, transcript) {
|
|
9998
10001
|
const linux = process.platform === "linux";
|
|
10002
|
+
const early = linux ? await linuxStartEarliest() : 1000;
|
|
9999
10003
|
let printed;
|
|
10000
10004
|
try {
|
|
10001
10005
|
({ stdout: printed } = await runHidden("ps", ["-e", "-o", linux ? "pid=,args=" : "pid=,lstart=,args="], {
|
|
@@ -10029,7 +10033,7 @@ async function followOnPosix(exitedPid, engine, notBefore, exitedAt, transcript)
|
|
|
10029
10033
|
}
|
|
10030
10034
|
}
|
|
10031
10035
|
}
|
|
10032
|
-
const timely = listed.filter((one) => successorAmong([one], exitedPid, engine, notBefore, exitedAt,
|
|
10036
|
+
const timely = listed.filter((one) => successorAmong([one], exitedPid, engine, notBefore, exitedAt, early) !== null);
|
|
10033
10037
|
const ours = canonical(transcript);
|
|
10034
10038
|
const candidates = [];
|
|
10035
10039
|
for (const one of timely) {
|
|
@@ -10038,7 +10042,7 @@ async function followOnPosix(exitedPid, engine, notBefore, exitedAt, transcript)
|
|
|
10038
10042
|
candidates.push(one);
|
|
10039
10043
|
}
|
|
10040
10044
|
}
|
|
10041
|
-
const successor = successorAmong(candidates, exitedPid, engine, notBefore, exitedAt,
|
|
10045
|
+
const successor = successorAmong(candidates, exitedPid, engine, notBefore, exitedAt, early);
|
|
10042
10046
|
if (successor === null) {
|
|
10043
10047
|
return null;
|
|
10044
10048
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.15",
|
|
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",
|