gdharness 1.0.13 → 1.0.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 +124 -19
- package/build/index.js +183 -81
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -16398,7 +16398,11 @@ var init_tool_definitions = __esm(() => {
|
|
|
16398
16398
|
description: "viewport: the Viewport node. Default the root viewport."
|
|
16399
16399
|
},
|
|
16400
16400
|
width: { type: "number", description: "Scale the image to this width." },
|
|
16401
|
-
height: { type: "number", description: "Scale the image to this height." }
|
|
16401
|
+
height: { type: "number", description: "Scale the image to this height." },
|
|
16402
|
+
outputPath: {
|
|
16403
|
+
type: "string",
|
|
16404
|
+
description: "Also save the picture as a PNG at this absolute path, for somebody to open later; the answer says where. Refused unless it ends in .png, its directory exists, it is outside the project, and no file is there already."
|
|
16405
|
+
}
|
|
16402
16406
|
},
|
|
16403
16407
|
requires: [],
|
|
16404
16408
|
operations: {
|
|
@@ -16421,7 +16425,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16421
16425
|
action: {
|
|
16422
16426
|
type: "string",
|
|
16423
16427
|
ops: ["action"],
|
|
16424
|
-
description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with key Escape, or click its button."
|
|
16428
|
+
description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with the key op and keycode Escape, or click its button."
|
|
16425
16429
|
},
|
|
16426
16430
|
pressed: {
|
|
16427
16431
|
type: "boolean",
|
|
@@ -16524,7 +16528,11 @@ var init_tool_definitions = __esm(() => {
|
|
|
16524
16528
|
ops: ["frames"],
|
|
16525
16529
|
description: "frames: how many to let pass, 1 to 600. More than that is refused."
|
|
16526
16530
|
},
|
|
16527
|
-
nodePath: {
|
|
16531
|
+
nodePath: {
|
|
16532
|
+
type: "string",
|
|
16533
|
+
ops: ["signal", "until"],
|
|
16534
|
+
description: "signal, until: the node. Needed for a signal and for a property; for says it defaults to /root, the whole screen."
|
|
16535
|
+
},
|
|
16528
16536
|
signal: { type: "string", ops: ["signal"], description: "signal: the signal name." },
|
|
16529
16537
|
property: {
|
|
16530
16538
|
type: "string",
|
|
@@ -16535,7 +16543,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16535
16543
|
says: {
|
|
16536
16544
|
type: "string",
|
|
16537
16545
|
ops: ["until"],
|
|
16538
|
-
description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
|
|
16546
|
+
description: "until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
|
|
16539
16547
|
},
|
|
16540
16548
|
includeHidden: {
|
|
16541
16549
|
type: "boolean",
|
|
@@ -16557,7 +16565,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16557
16565
|
},
|
|
16558
16566
|
until: {
|
|
16559
16567
|
summary: "wait for a property to read as a value, or for words to appear under a node, and answer with what it found",
|
|
16560
|
-
requires: [
|
|
16568
|
+
requires: []
|
|
16561
16569
|
}
|
|
16562
16570
|
}
|
|
16563
16571
|
},
|
|
@@ -31671,8 +31679,7 @@ function announcedAndGone(projectPath) {
|
|
|
31671
31679
|
return [];
|
|
31672
31680
|
}
|
|
31673
31681
|
const since = Date.now() - GONE_WINDOW_MS;
|
|
31674
|
-
|
|
31675
|
-
return gone.filter((one) => one.noticedAt >= since && resolve3(one.project) === wanted);
|
|
31682
|
+
return gone.filter((one) => one.noticedAt >= since && isSameDirectory(one.project, projectPath));
|
|
31676
31683
|
}
|
|
31677
31684
|
function strangersAmong(candidates, now, startTimes = startTimesOf) {
|
|
31678
31685
|
const toJudge = candidates.filter((one) => {
|
|
@@ -31778,11 +31785,10 @@ function sweepErrorReport(path, pid, now = Date.now()) {
|
|
|
31778
31785
|
} catch {}
|
|
31779
31786
|
}
|
|
31780
31787
|
async function announcedSince(projectPath, before, waiting = {}) {
|
|
31781
|
-
const wanted = resolve3(projectPath);
|
|
31782
31788
|
const directories = waiting.directories ?? runtimeDirectories();
|
|
31783
31789
|
const until = Date.now() + Math.max(waiting.budgetMs ?? ANNOUNCE_BUDGET_MS, 0);
|
|
31784
31790
|
for (;; ) {
|
|
31785
|
-
const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) &&
|
|
31791
|
+
const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && isSameDirectory(endpoint.project.path, projectPath) && (waiting.accept?.(endpoint) ?? true));
|
|
31786
31792
|
if (fresh !== undefined) {
|
|
31787
31793
|
return fresh;
|
|
31788
31794
|
}
|
|
@@ -31803,7 +31809,7 @@ function theOneProject(endpoints) {
|
|
|
31803
31809
|
if (endpoints.some((endpoint) => endpoint.project.path === "")) {
|
|
31804
31810
|
return;
|
|
31805
31811
|
}
|
|
31806
|
-
const paths = new Set(endpoints.map((endpoint) => resolve3(endpoint.project.path)));
|
|
31812
|
+
const paths = new Set(endpoints.map((endpoint) => realPathOr(resolve3(endpoint.project.path))));
|
|
31807
31813
|
const only = [...paths][0];
|
|
31808
31814
|
return paths.size === 1 ? only : undefined;
|
|
31809
31815
|
}
|
|
@@ -31832,7 +31838,7 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
|
|
|
31832
31838
|
if (pid !== undefined) {
|
|
31833
31839
|
const named = endpoints.find((endpoint) => endpoint.pid === pid);
|
|
31834
31840
|
if (named !== undefined) {
|
|
31835
|
-
if (projectPath !== undefined &&
|
|
31841
|
+
if (projectPath !== undefined && !isSameDirectory(named.project.path, projectPath)) {
|
|
31836
31842
|
return {
|
|
31837
31843
|
problem: `The game with pid ${pid} is running ${named.project.path}, not ${resolve3(projectPath)}. Running: ${endpoints.map(describe2).join("; ")}.`
|
|
31838
31844
|
};
|
|
@@ -31875,7 +31881,7 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
|
|
|
31875
31881
|
}
|
|
31876
31882
|
if (projectPath !== undefined) {
|
|
31877
31883
|
const wanted = resolve3(projectPath);
|
|
31878
|
-
const matching = endpoints.filter((endpoint) =>
|
|
31884
|
+
const matching = endpoints.filter((endpoint) => isSameDirectory(endpoint.project.path, wanted));
|
|
31879
31885
|
if (matching.length === 0) {
|
|
31880
31886
|
return {
|
|
31881
31887
|
problem: `No running game is from ${wanted}. Running: ${endpoints.map(describe2).join("; ")}.`
|
|
@@ -31986,6 +31992,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
|
|
|
31986
31992
|
}
|
|
31987
31993
|
var RUNTIME_PROTOCOL = 2, ANNOUNCEMENT_PATTERN, ERROR_REPORT_PATTERN, 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, ENDED_WITHIN_MS = 3000, LOOK_EVERY_MS = 50, ANNOUNCE_BUDGET_MS = 5000, nextRequestId = 1;
|
|
31988
31994
|
var init_runtime_client = __esm(() => {
|
|
31995
|
+
init_paths();
|
|
31989
31996
|
init_process_children();
|
|
31990
31997
|
ANNOUNCEMENT_PATTERN = /^runtime-(\d+)\.json$/;
|
|
31991
31998
|
ERROR_REPORT_PATTERN = /^runtime-(\d+)\.log$/;
|
|
@@ -38026,6 +38033,8 @@ import { randomUUID as randomUUID2 } from "node:crypto";
|
|
|
38026
38033
|
import {
|
|
38027
38034
|
appendFileSync,
|
|
38028
38035
|
closeSync as closeSync2,
|
|
38036
|
+
constants,
|
|
38037
|
+
copyFileSync,
|
|
38029
38038
|
existsSync as existsSync15,
|
|
38030
38039
|
fstatSync,
|
|
38031
38040
|
mkdirSync as mkdirSync13,
|
|
@@ -38038,10 +38047,34 @@ import {
|
|
|
38038
38047
|
statSync as statSync8
|
|
38039
38048
|
} from "node:fs";
|
|
38040
38049
|
import { tmpdir as tmpdir5 } from "node:os";
|
|
38041
|
-
import { basename as basename4, dirname as dirname15, isAbsolute as isAbsolute3, join as join22, normalize as normalize3, relative as relative3 } from "node:path";
|
|
38050
|
+
import { basename as basename4, dirname as dirname15, extname as extname2, isAbsolute as isAbsolute3, join as join22, normalize as normalize3, relative as relative3 } from "node:path";
|
|
38042
38051
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
38043
38052
|
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
|
38044
38053
|
import { promisify as promisify7 } from "node:util";
|
|
38054
|
+
function captureDestinationRefusal(outputPath, projects) {
|
|
38055
|
+
if (/^(res|user):\/\//.test(outputPath)) {
|
|
38056
|
+
return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
|
|
38057
|
+
}
|
|
38058
|
+
if (!isAbsolute3(outputPath)) {
|
|
38059
|
+
return `outputPath ${outputPath} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
|
|
38060
|
+
}
|
|
38061
|
+
if (extname2(outputPath).toLowerCase() !== ".png") {
|
|
38062
|
+
return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
|
|
38063
|
+
}
|
|
38064
|
+
const directory = realPathOr(dirname15(outputPath));
|
|
38065
|
+
if (!existsSync15(directory)) {
|
|
38066
|
+
return `outputPath's directory ${dirname15(outputPath)} does not exist; a capture creates no directories.`;
|
|
38067
|
+
}
|
|
38068
|
+
const where = join22(directory, basename4(outputPath));
|
|
38069
|
+
const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
|
|
38070
|
+
if (inside !== undefined) {
|
|
38071
|
+
return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
|
|
38072
|
+
}
|
|
38073
|
+
if (existsSync15(where)) {
|
|
38074
|
+
return `outputPath ${outputPath} already exists; a capture is not written over a file. Name a new one.`;
|
|
38075
|
+
}
|
|
38076
|
+
return null;
|
|
38077
|
+
}
|
|
38045
38078
|
function patienceForFrames(frames, atLeast) {
|
|
38046
38079
|
return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
|
|
38047
38080
|
}
|
|
@@ -41289,8 +41322,59 @@ class GodotServer {
|
|
|
41289
41322
|
}
|
|
41290
41323
|
return { heldAt: undefined, heldUnknown: true, heldNote: reply.message };
|
|
41291
41324
|
}
|
|
41325
|
+
async ownRunOnItsWay(projectPath, budgetMs) {
|
|
41326
|
+
const run = this.currentRun();
|
|
41327
|
+
const project = projectPath ?? run?.projectPath ?? null;
|
|
41328
|
+
if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync15(join22(project, RUNTIME_AUTOLOAD2.path)) || !await this.runStillGoing(run)) {
|
|
41329
|
+
return null;
|
|
41330
|
+
}
|
|
41331
|
+
const already = runtimesAnnounced().running;
|
|
41332
|
+
const announcedSinceTheStart = (one) => {
|
|
41333
|
+
if (run.announcedBefore !== undefined) {
|
|
41334
|
+
return !run.announcedBefore.has(one.pid);
|
|
41335
|
+
}
|
|
41336
|
+
try {
|
|
41337
|
+
return statSync8(one.file).mtimeMs >= run.startedAt;
|
|
41338
|
+
} catch {
|
|
41339
|
+
return false;
|
|
41340
|
+
}
|
|
41341
|
+
};
|
|
41342
|
+
if (already.some((one) => announcedSinceTheStart(one) && this.isTheGameOf(run, one, project))) {
|
|
41343
|
+
return null;
|
|
41344
|
+
}
|
|
41345
|
+
const which = run.throughEditor ? "the game the editor is playing for it" : `pid ${run.pid ?? "unknown"}`;
|
|
41346
|
+
const comingUpUntil = run.startedAt + waitSizedTo(readBootNote(project)) + budgetMs;
|
|
41347
|
+
const remaining = comingUpUntil - Date.now();
|
|
41348
|
+
if (remaining <= 0) {
|
|
41349
|
+
const seconds = Math.round((Date.now() - run.startedAt) / 1000);
|
|
41350
|
+
return `The game this server started (${which}) has been running for ${seconds}s and has not announced its runtime, so there is nothing to talk to. A game that keeps the runtime off, as a project's own loader can for some scenes, never announces; editor_output says what it has printed.`;
|
|
41351
|
+
}
|
|
41352
|
+
const endpoint = await announcedSince(project, new Set(already.map((one) => one.pid)), {
|
|
41353
|
+
budgetMs: Math.min(budgetMs, remaining),
|
|
41354
|
+
accept: (one) => this.isTheGameOf(this.currentRun(), one, project),
|
|
41355
|
+
giveUp: () => this.currentRun() !== run || this.dapClient?.isStopped() === true || !stillRunning(run)
|
|
41356
|
+
});
|
|
41357
|
+
if (endpoint !== null) {
|
|
41358
|
+
const tiedMeanwhile = () => run.announcedPid !== undefined;
|
|
41359
|
+
if (!tiedMeanwhile()) {
|
|
41360
|
+
this.tie(run, endpoint);
|
|
41361
|
+
}
|
|
41362
|
+
return null;
|
|
41363
|
+
}
|
|
41364
|
+
if (this.currentRun() !== run || !await this.runStillGoing(run)) {
|
|
41365
|
+
return null;
|
|
41366
|
+
}
|
|
41367
|
+
return `The game this server started (${which}) is still coming up and has not announced its runtime after a wait of ${Math.min(budgetMs, remaining)}ms, so there is nothing to talk to yet. It is still running: ask again in a moment rather than starting another.`;
|
|
41368
|
+
}
|
|
41292
41369
|
async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
|
|
41293
|
-
const { op: _op, projectPath, pid, ...params } = asParams(args);
|
|
41370
|
+
const { op: _op, projectPath, pid, outputPath, ...params } = asParams(args);
|
|
41371
|
+
const waited = typeof pid === "number" ? null : await this.ownRunOnItsWay(typeof projectPath === "string" ? projectPath : this.ownProject, timeoutMs);
|
|
41372
|
+
if (waited !== null) {
|
|
41373
|
+
return this.createErrorResponse(waited, [
|
|
41374
|
+
"editor_status lists it under runtimes once it has announced",
|
|
41375
|
+
"editor_output says what it has printed so far"
|
|
41376
|
+
]);
|
|
41377
|
+
}
|
|
41294
41378
|
const announced = runtimesAnnounced();
|
|
41295
41379
|
const wanted = typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined;
|
|
41296
41380
|
const own = typeof pid === "number" ? undefined : this.ownGameAmong(announced.running, wanted) ?? await this.ownGameThroughTheTree(announced.running, wanted);
|
|
@@ -41310,6 +41394,16 @@ class GodotServer {
|
|
|
41310
41394
|
]);
|
|
41311
41395
|
}
|
|
41312
41396
|
const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
|
|
41397
|
+
const saveTo = expectsScreenshot && typeof outputPath === "string" ? outputPath : null;
|
|
41398
|
+
if (saveTo !== null) {
|
|
41399
|
+
const refusal = captureDestinationRefusal(saveTo, [
|
|
41400
|
+
choice.endpoint.project.path,
|
|
41401
|
+
...this.ownProject === null ? [] : [this.ownProject]
|
|
41402
|
+
]);
|
|
41403
|
+
if (refusal !== null) {
|
|
41404
|
+
return this.createErrorResponse(refusal);
|
|
41405
|
+
}
|
|
41406
|
+
}
|
|
41313
41407
|
const screenshotDir = expectsScreenshot ? mkdtempSync3(join22(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
|
|
41314
41408
|
const screenshotPath = screenshotDir ? join22(screenshotDir, "capture.png") : null;
|
|
41315
41409
|
try {
|
|
@@ -41329,11 +41423,18 @@ class GodotServer {
|
|
|
41329
41423
|
return this.createErrorResponse(`Rejected screenshot file path outside the managed capture path: '${returnedPath}'`);
|
|
41330
41424
|
}
|
|
41331
41425
|
const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
|
|
41426
|
+
if (saveTo !== null) {
|
|
41427
|
+
try {
|
|
41428
|
+
copyFileSync(screenshotPath, saveTo, constants.COPYFILE_EXCL);
|
|
41429
|
+
} catch (error) {
|
|
41430
|
+
return this.createErrorResponse(`The capture was taken and could not be saved at ${saveTo}: ${errorMessage(error)}`);
|
|
41431
|
+
}
|
|
41432
|
+
}
|
|
41332
41433
|
return {
|
|
41333
41434
|
content: [
|
|
41334
41435
|
{
|
|
41335
41436
|
type: "text",
|
|
41336
|
-
text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}`
|
|
41437
|
+
text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}`
|
|
41337
41438
|
},
|
|
41338
41439
|
{ type: "image", data: readFileSync20(screenshotPath).toString("base64"), mimeType: "image/png" }
|
|
41339
41440
|
]
|
|
@@ -41380,7 +41481,11 @@ class GodotServer {
|
|
|
41380
41481
|
return await this.handleRuntimeCommand("wait_frames", { ...whichGame(args), frames }, waited);
|
|
41381
41482
|
}
|
|
41382
41483
|
const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 5000;
|
|
41383
|
-
const
|
|
41484
|
+
const asked = readNonEmptyString(args, "nodePath");
|
|
41485
|
+
if (asked === undefined && op === "until" && readNonEmptyString(args, "says") === undefined) {
|
|
41486
|
+
return this.createErrorResponse("runtime_wait until needs nodePath to wait for a property: the property belongs to that node.", ["With says instead of property, nodePath can be left out and the whole game is searched"]);
|
|
41487
|
+
}
|
|
41488
|
+
const nodePath = asked ?? "/root";
|
|
41384
41489
|
const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
|
|
41385
41490
|
return op === "signal" ? await this.handleRuntimeCommand("wait_signal", {
|
|
41386
41491
|
...whichGame(args),
|
|
@@ -43048,12 +43153,12 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
|
|
|
43048
43153
|
| What a property on an object a node holds reads | the same two, with colons: \`_game:clock:speed\` |
|
|
43049
43154
|
| Which control holds the focus, or anything only a method answers | the same path with a step written as a call, \`get_viewport():gui_get_focus_owner\`, which calls a method taking no arguments and walks into what it returned |
|
|
43050
43155
|
| Press a button | \`runtime_input click\`, which says what was under the pointer |
|
|
43051
|
-
| Press the next thing after a click that changed screen | \`runtime_wait until\` with \`says\` and a word the new screen shows, then the click. A click sent straight after a screen change lands on a control the new screen is about to free, answers \`landed: true\`, and does nothing; a frame count is a number nobody can pick correctly across a cold start and a warm one, and waiting for the words answered in about 130ms where twenty frames had not been enough |
|
|
43156
|
+
| Press the next thing after a click that changed screen | \`runtime_wait until\` with \`says\` and a word the new screen shows (no \`nodePath\` needed: without one the whole game is searched), then the click. A click sent straight after a screen change lands on a control the new screen is about to free, answers \`landed: true\`, and does nothing; a frame count is a number nobody can pick correctly across a cold start and a warm one, and waiting for the words answered in about 130ms where twenty frames had not been enough |
|
|
43052
43157
|
| Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
|
|
43053
43158
|
| Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
|
|
43054
43159
|
| Pick something out of a dropdown | \`runtime_input choose\` on it, by what the item says, rather than a click and keys: the menu opens as a window of its own that an injected key never reaches, so Enter closes it with nothing chosen |
|
|
43055
|
-
| Answer a dialog | \`runtime_input click\` on its button, or \`key\` Escape to dismiss it: an [AcceptDialog] reads that key itself and never asks the InputMap, so \`ui_cancel\` leaves it standing |
|
|
43056
|
-
| Press a bound key | \`runtime_input action\` or \`key\`, a whole press unless you hold it |
|
|
43160
|
+
| Answer a dialog | \`runtime_input click\` on its button, or \`runtime_input key\` with \`keycode\` \`Escape\` to dismiss it: an [AcceptDialog] reads that key itself and never asks the InputMap, so \`ui_cancel\` leaves it standing |
|
|
43161
|
+
| Press a bound key | \`runtime_input action\` with the action's name, or \`runtime_input key\` with \`keycode\`, a whole press unless you hold it |
|
|
43057
43162
|
| Where a 3D node is on screen | \`runtime_inspect\` \`rect\`, rather than unprojecting by hand |
|
|
43058
43163
|
| Wait for something in a running game | \`runtime_wait\`, never a sleep; \`until\` with \`says\` for a panel that rebuilds its own labels. The words have to be shown: a button that exists hidden through an animation does not satisfy it until it appears, unless \`includeHidden\` says otherwise |
|
|
43059
43164
|
| What every label on a screen says | \`runtime_inspect tree\` with \`properties\` naming \`text\`, which reads it off every node that has one; \`includeProperties\` is every stored property of every node and ran to seventy-six thousand characters for one row |
|
package/build/index.js
CHANGED
|
@@ -10182,6 +10182,8 @@ import { randomUUID as randomUUID2 } from "node:crypto";
|
|
|
10182
10182
|
import {
|
|
10183
10183
|
appendFileSync,
|
|
10184
10184
|
closeSync as closeSync2,
|
|
10185
|
+
constants,
|
|
10186
|
+
copyFileSync,
|
|
10185
10187
|
existsSync as existsSync10,
|
|
10186
10188
|
fstatSync,
|
|
10187
10189
|
mkdirSync as mkdirSync10,
|
|
@@ -10194,7 +10196,7 @@ import {
|
|
|
10194
10196
|
statSync as statSync7
|
|
10195
10197
|
} from "node:fs";
|
|
10196
10198
|
import { tmpdir as tmpdir4 } from "node:os";
|
|
10197
|
-
import { basename as basename3, dirname as dirname10, isAbsolute as isAbsolute3, join as join16, normalize as normalize2, relative as relative3 } from "node:path";
|
|
10199
|
+
import { basename as basename3, dirname as dirname10, extname as extname2, isAbsolute as isAbsolute3, join as join16, normalize as normalize2, relative as relative3 } from "node:path";
|
|
10198
10200
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
10199
10201
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
10200
10202
|
import { promisify as promisify5 } from "node:util";
|
|
@@ -24770,7 +24772,7 @@ import { dirname as dirname2, join as join3 } from "node:path";
|
|
|
24770
24772
|
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3, statSync, unlinkSync } from "node:fs";
|
|
24771
24773
|
import { createConnection } from "node:net";
|
|
24772
24774
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
24773
|
-
import { dirname, join as join2, resolve } from "node:path";
|
|
24775
|
+
import { dirname, join as join2, resolve as resolve2 } from "node:path";
|
|
24774
24776
|
import { setTimeout as delay } from "node:timers/promises";
|
|
24775
24777
|
|
|
24776
24778
|
// src/launch.ts
|
|
@@ -24837,6 +24839,65 @@ function environmentFor(options, variables = process.env) {
|
|
|
24837
24839
|
}
|
|
24838
24840
|
var 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.";
|
|
24839
24841
|
|
|
24842
|
+
// src/paths.ts
|
|
24843
|
+
import { realpathSync } from "node:fs";
|
|
24844
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
24845
|
+
var RESOURCE_SCHEME = "res://";
|
|
24846
|
+
var OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
|
|
24847
|
+
function realPathOr(path) {
|
|
24848
|
+
try {
|
|
24849
|
+
return realpathSync.native(path);
|
|
24850
|
+
} catch {
|
|
24851
|
+
return path;
|
|
24852
|
+
}
|
|
24853
|
+
}
|
|
24854
|
+
function isSameDirectory(onePath, otherPath) {
|
|
24855
|
+
return relative(realPathOr(resolve(onePath)), realPathOr(resolve(otherPath))) === "";
|
|
24856
|
+
}
|
|
24857
|
+
function isWithinRoot(rootPath, candidatePath) {
|
|
24858
|
+
const step = relative(resolve(rootPath), resolve(candidatePath));
|
|
24859
|
+
return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
|
|
24860
|
+
}
|
|
24861
|
+
function resolveWithinProject(projectPath, candidatePath) {
|
|
24862
|
+
const trimmed = candidatePath.trim();
|
|
24863
|
+
if (trimmed === "") {
|
|
24864
|
+
return { ok: false, reason: "Path is empty." };
|
|
24865
|
+
}
|
|
24866
|
+
if (trimmed.includes("\x00")) {
|
|
24867
|
+
return { ok: false, reason: "Path contains a null byte." };
|
|
24868
|
+
}
|
|
24869
|
+
const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
|
|
24870
|
+
const scheme = OTHER_SCHEME.exec(withoutScheme);
|
|
24871
|
+
if (scheme) {
|
|
24872
|
+
return {
|
|
24873
|
+
ok: false,
|
|
24874
|
+
reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
|
|
24875
|
+
};
|
|
24876
|
+
}
|
|
24877
|
+
if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
|
|
24878
|
+
return {
|
|
24879
|
+
ok: false,
|
|
24880
|
+
reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
|
|
24881
|
+
};
|
|
24882
|
+
}
|
|
24883
|
+
if (withoutScheme.includes(":")) {
|
|
24884
|
+
return {
|
|
24885
|
+
ok: false,
|
|
24886
|
+
reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
|
|
24887
|
+
};
|
|
24888
|
+
}
|
|
24889
|
+
const root = resolve(projectPath);
|
|
24890
|
+
const absolutePath = resolve(root, withoutScheme);
|
|
24891
|
+
if (!isWithinRoot(root, absolutePath)) {
|
|
24892
|
+
return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
|
|
24893
|
+
}
|
|
24894
|
+
const relativePath = relative(root, absolutePath).split(sep).join("/");
|
|
24895
|
+
if (relativePath === "") {
|
|
24896
|
+
return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
|
|
24897
|
+
}
|
|
24898
|
+
return { ok: true, absolutePath, relativePath };
|
|
24899
|
+
}
|
|
24900
|
+
|
|
24840
24901
|
// src/process-children.ts
|
|
24841
24902
|
import { execFile, execFileSync, spawnSync } from "node:child_process";
|
|
24842
24903
|
import { basename } from "node:path";
|
|
@@ -25110,7 +25171,7 @@ function runtimeDirectories(variables = process.env) {
|
|
|
25110
25171
|
for (const base of fallbacks) {
|
|
25111
25172
|
candidates.push(join2(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
|
|
25112
25173
|
}
|
|
25113
|
-
return [...new Set(candidates.map((path) =>
|
|
25174
|
+
return [...new Set(candidates.map((path) => resolve2(path)))];
|
|
25114
25175
|
}
|
|
25115
25176
|
var ERROR_REPORT_PATTERN = /^runtime-(\d+)\.log$/;
|
|
25116
25177
|
function errorReportOf(pid, directories = runtimeDirectories()) {
|
|
@@ -25205,8 +25266,7 @@ function announcedAndGone(projectPath) {
|
|
|
25205
25266
|
return [];
|
|
25206
25267
|
}
|
|
25207
25268
|
const since = Date.now() - GONE_WINDOW_MS;
|
|
25208
|
-
|
|
25209
|
-
return gone.filter((one) => one.noticedAt >= since && resolve(one.project) === wanted);
|
|
25269
|
+
return gone.filter((one) => one.noticedAt >= since && isSameDirectory(one.project, projectPath));
|
|
25210
25270
|
}
|
|
25211
25271
|
var STARTED_AFTER_ANNOUNCING_MS = 2000;
|
|
25212
25272
|
var CONFIRMED_FOR_MS = 60000;
|
|
@@ -25319,11 +25379,10 @@ function sweepErrorReport(path, pid, now = Date.now()) {
|
|
|
25319
25379
|
var LOOK_EVERY_MS = 50;
|
|
25320
25380
|
var ANNOUNCE_BUDGET_MS = 5000;
|
|
25321
25381
|
async function announcedSince(projectPath, before, waiting = {}) {
|
|
25322
|
-
const wanted = resolve(projectPath);
|
|
25323
25382
|
const directories = waiting.directories ?? runtimeDirectories();
|
|
25324
25383
|
const until = Date.now() + Math.max(waiting.budgetMs ?? ANNOUNCE_BUDGET_MS, 0);
|
|
25325
25384
|
for (;; ) {
|
|
25326
|
-
const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) &&
|
|
25385
|
+
const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && isSameDirectory(endpoint.project.path, projectPath) && (waiting.accept?.(endpoint) ?? true));
|
|
25327
25386
|
if (fresh !== undefined) {
|
|
25328
25387
|
return fresh;
|
|
25329
25388
|
}
|
|
@@ -25344,7 +25403,7 @@ function theOneProject(endpoints) {
|
|
|
25344
25403
|
if (endpoints.some((endpoint) => endpoint.project.path === "")) {
|
|
25345
25404
|
return;
|
|
25346
25405
|
}
|
|
25347
|
-
const paths = new Set(endpoints.map((endpoint) =>
|
|
25406
|
+
const paths = new Set(endpoints.map((endpoint) => realPathOr(resolve2(endpoint.project.path))));
|
|
25348
25407
|
const only = [...paths][0];
|
|
25349
25408
|
return paths.size === 1 ? only : undefined;
|
|
25350
25409
|
}
|
|
@@ -25373,9 +25432,9 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
|
|
|
25373
25432
|
if (pid !== undefined) {
|
|
25374
25433
|
const named = endpoints.find((endpoint) => endpoint.pid === pid);
|
|
25375
25434
|
if (named !== undefined) {
|
|
25376
|
-
if (projectPath !== undefined &&
|
|
25435
|
+
if (projectPath !== undefined && !isSameDirectory(named.project.path, projectPath)) {
|
|
25377
25436
|
return {
|
|
25378
|
-
problem: `The game with pid ${pid} is running ${named.project.path}, not ${
|
|
25437
|
+
problem: `The game with pid ${pid} is running ${named.project.path}, not ${resolve2(projectPath)}. Running: ${endpoints.map(describe2).join("; ")}.`
|
|
25379
25438
|
};
|
|
25380
25439
|
}
|
|
25381
25440
|
return { endpoint: named };
|
|
@@ -25415,8 +25474,8 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
|
|
|
25415
25474
|
};
|
|
25416
25475
|
}
|
|
25417
25476
|
if (projectPath !== undefined) {
|
|
25418
|
-
const wanted =
|
|
25419
|
-
const matching = endpoints.filter((endpoint) =>
|
|
25477
|
+
const wanted = resolve2(projectPath);
|
|
25478
|
+
const matching = endpoints.filter((endpoint) => isSameDirectory(endpoint.project.path, wanted));
|
|
25420
25479
|
if (matching.length === 0) {
|
|
25421
25480
|
return {
|
|
25422
25481
|
problem: `No running game is from ${wanted}. Running: ${endpoints.map(describe2).join("; ")}.`
|
|
@@ -25560,67 +25619,6 @@ function waitSizedTo(lastBootMs, usual = ANNOUNCE_BUDGET_MS) {
|
|
|
25560
25619
|
// src/breakpoint-note.ts
|
|
25561
25620
|
import { mkdirSync as mkdirSync3, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
25562
25621
|
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, relative as relative2 } from "node:path";
|
|
25563
|
-
|
|
25564
|
-
// src/paths.ts
|
|
25565
|
-
import { realpathSync } from "node:fs";
|
|
25566
|
-
import { isAbsolute, relative, resolve as resolve2, sep } from "node:path";
|
|
25567
|
-
var RESOURCE_SCHEME = "res://";
|
|
25568
|
-
var OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
|
|
25569
|
-
function realPathOr(path) {
|
|
25570
|
-
try {
|
|
25571
|
-
return realpathSync.native(path);
|
|
25572
|
-
} catch {
|
|
25573
|
-
return path;
|
|
25574
|
-
}
|
|
25575
|
-
}
|
|
25576
|
-
function isSameDirectory(onePath, otherPath) {
|
|
25577
|
-
return relative(realPathOr(resolve2(onePath)), realPathOr(resolve2(otherPath))) === "";
|
|
25578
|
-
}
|
|
25579
|
-
function isWithinRoot(rootPath, candidatePath) {
|
|
25580
|
-
const step = relative(resolve2(rootPath), resolve2(candidatePath));
|
|
25581
|
-
return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
|
|
25582
|
-
}
|
|
25583
|
-
function resolveWithinProject(projectPath, candidatePath) {
|
|
25584
|
-
const trimmed = candidatePath.trim();
|
|
25585
|
-
if (trimmed === "") {
|
|
25586
|
-
return { ok: false, reason: "Path is empty." };
|
|
25587
|
-
}
|
|
25588
|
-
if (trimmed.includes("\x00")) {
|
|
25589
|
-
return { ok: false, reason: "Path contains a null byte." };
|
|
25590
|
-
}
|
|
25591
|
-
const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
|
|
25592
|
-
const scheme = OTHER_SCHEME.exec(withoutScheme);
|
|
25593
|
-
if (scheme) {
|
|
25594
|
-
return {
|
|
25595
|
-
ok: false,
|
|
25596
|
-
reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
|
|
25597
|
-
};
|
|
25598
|
-
}
|
|
25599
|
-
if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
|
|
25600
|
-
return {
|
|
25601
|
-
ok: false,
|
|
25602
|
-
reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
|
|
25603
|
-
};
|
|
25604
|
-
}
|
|
25605
|
-
if (withoutScheme.includes(":")) {
|
|
25606
|
-
return {
|
|
25607
|
-
ok: false,
|
|
25608
|
-
reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
|
|
25609
|
-
};
|
|
25610
|
-
}
|
|
25611
|
-
const root = resolve2(projectPath);
|
|
25612
|
-
const absolutePath = resolve2(root, withoutScheme);
|
|
25613
|
-
if (!isWithinRoot(root, absolutePath)) {
|
|
25614
|
-
return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
|
|
25615
|
-
}
|
|
25616
|
-
const relativePath = relative(root, absolutePath).split(sep).join("/");
|
|
25617
|
-
if (relativePath === "") {
|
|
25618
|
-
return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
|
|
25619
|
-
}
|
|
25620
|
-
return { ok: true, absolutePath, relativePath };
|
|
25621
|
-
}
|
|
25622
|
-
|
|
25623
|
-
// src/breakpoint-note.ts
|
|
25624
25622
|
function breakpointNotePath(projectPath) {
|
|
25625
25623
|
return join4(projectPath, ".godot", "gdharness-breakpoints.json");
|
|
25626
25624
|
}
|
|
@@ -30476,7 +30474,11 @@ var TOOL_SPECS = [
|
|
|
30476
30474
|
description: "viewport: the Viewport node. Default the root viewport."
|
|
30477
30475
|
},
|
|
30478
30476
|
width: { type: "number", description: "Scale the image to this width." },
|
|
30479
|
-
height: { type: "number", description: "Scale the image to this height." }
|
|
30477
|
+
height: { type: "number", description: "Scale the image to this height." },
|
|
30478
|
+
outputPath: {
|
|
30479
|
+
type: "string",
|
|
30480
|
+
description: "Also save the picture as a PNG at this absolute path, for somebody to open later; the answer says where. Refused unless it ends in .png, its directory exists, it is outside the project, and no file is there already."
|
|
30481
|
+
}
|
|
30480
30482
|
},
|
|
30481
30483
|
requires: [],
|
|
30482
30484
|
operations: {
|
|
@@ -30499,7 +30501,7 @@ var TOOL_SPECS = [
|
|
|
30499
30501
|
action: {
|
|
30500
30502
|
type: "string",
|
|
30501
30503
|
ops: ["action"],
|
|
30502
|
-
description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with key Escape, or click its button."
|
|
30504
|
+
description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with the key op and keycode Escape, or click its button."
|
|
30503
30505
|
},
|
|
30504
30506
|
pressed: {
|
|
30505
30507
|
type: "boolean",
|
|
@@ -30602,7 +30604,11 @@ var TOOL_SPECS = [
|
|
|
30602
30604
|
ops: ["frames"],
|
|
30603
30605
|
description: "frames: how many to let pass, 1 to 600. More than that is refused."
|
|
30604
30606
|
},
|
|
30605
|
-
nodePath: {
|
|
30607
|
+
nodePath: {
|
|
30608
|
+
type: "string",
|
|
30609
|
+
ops: ["signal", "until"],
|
|
30610
|
+
description: "signal, until: the node. Needed for a signal and for a property; for says it defaults to /root, the whole screen."
|
|
30611
|
+
},
|
|
30606
30612
|
signal: { type: "string", ops: ["signal"], description: "signal: the signal name." },
|
|
30607
30613
|
property: {
|
|
30608
30614
|
type: "string",
|
|
@@ -30613,7 +30619,7 @@ var TOOL_SPECS = [
|
|
|
30613
30619
|
says: {
|
|
30614
30620
|
type: "string",
|
|
30615
30621
|
ops: ["until"],
|
|
30616
|
-
description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
|
|
30622
|
+
description: "until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
|
|
30617
30623
|
},
|
|
30618
30624
|
includeHidden: {
|
|
30619
30625
|
type: "boolean",
|
|
@@ -30635,7 +30641,7 @@ var TOOL_SPECS = [
|
|
|
30635
30641
|
},
|
|
30636
30642
|
until: {
|
|
30637
30643
|
summary: "wait for a property to read as a value, or for words to appear under a node, and answer with what it found",
|
|
30638
|
-
requires: [
|
|
30644
|
+
requires: []
|
|
30639
30645
|
}
|
|
30640
30646
|
}
|
|
30641
30647
|
},
|
|
@@ -30737,6 +30743,30 @@ var __dirname2 = dirname10(fileURLToPath3(import.meta.url));
|
|
|
30737
30743
|
var EDITOR_RESTART_TIMEOUT_MS = 90000;
|
|
30738
30744
|
var HOLD_PING_MS = 2000;
|
|
30739
30745
|
var SLOWEST_FRAME_RATE = 20;
|
|
30746
|
+
function captureDestinationRefusal(outputPath, projects) {
|
|
30747
|
+
if (/^(res|user):\/\//.test(outputPath)) {
|
|
30748
|
+
return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
|
|
30749
|
+
}
|
|
30750
|
+
if (!isAbsolute3(outputPath)) {
|
|
30751
|
+
return `outputPath ${outputPath} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
|
|
30752
|
+
}
|
|
30753
|
+
if (extname2(outputPath).toLowerCase() !== ".png") {
|
|
30754
|
+
return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
|
|
30755
|
+
}
|
|
30756
|
+
const directory = realPathOr(dirname10(outputPath));
|
|
30757
|
+
if (!existsSync10(directory)) {
|
|
30758
|
+
return `outputPath's directory ${dirname10(outputPath)} does not exist; a capture creates no directories.`;
|
|
30759
|
+
}
|
|
30760
|
+
const where = join16(directory, basename3(outputPath));
|
|
30761
|
+
const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
|
|
30762
|
+
if (inside !== undefined) {
|
|
30763
|
+
return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
|
|
30764
|
+
}
|
|
30765
|
+
if (existsSync10(where)) {
|
|
30766
|
+
return `outputPath ${outputPath} already exists; a capture is not written over a file. Name a new one.`;
|
|
30767
|
+
}
|
|
30768
|
+
return null;
|
|
30769
|
+
}
|
|
30740
30770
|
function patienceForFrames(frames, atLeast) {
|
|
30741
30771
|
return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
|
|
30742
30772
|
}
|
|
@@ -34030,8 +34060,59 @@ class GodotServer {
|
|
|
34030
34060
|
}
|
|
34031
34061
|
return { heldAt: undefined, heldUnknown: true, heldNote: reply.message };
|
|
34032
34062
|
}
|
|
34063
|
+
async ownRunOnItsWay(projectPath, budgetMs) {
|
|
34064
|
+
const run = this.currentRun();
|
|
34065
|
+
const project = projectPath ?? run?.projectPath ?? null;
|
|
34066
|
+
if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync10(join16(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
|
|
34067
|
+
return null;
|
|
34068
|
+
}
|
|
34069
|
+
const already = runtimesAnnounced().running;
|
|
34070
|
+
const announcedSinceTheStart = (one) => {
|
|
34071
|
+
if (run.announcedBefore !== undefined) {
|
|
34072
|
+
return !run.announcedBefore.has(one.pid);
|
|
34073
|
+
}
|
|
34074
|
+
try {
|
|
34075
|
+
return statSync7(one.file).mtimeMs >= run.startedAt;
|
|
34076
|
+
} catch {
|
|
34077
|
+
return false;
|
|
34078
|
+
}
|
|
34079
|
+
};
|
|
34080
|
+
if (already.some((one) => announcedSinceTheStart(one) && this.isTheGameOf(run, one, project))) {
|
|
34081
|
+
return null;
|
|
34082
|
+
}
|
|
34083
|
+
const which = run.throughEditor ? "the game the editor is playing for it" : `pid ${run.pid ?? "unknown"}`;
|
|
34084
|
+
const comingUpUntil = run.startedAt + waitSizedTo(readBootNote(project)) + budgetMs;
|
|
34085
|
+
const remaining = comingUpUntil - Date.now();
|
|
34086
|
+
if (remaining <= 0) {
|
|
34087
|
+
const seconds = Math.round((Date.now() - run.startedAt) / 1000);
|
|
34088
|
+
return `The game this server started (${which}) has been running for ${seconds}s and has not announced its runtime, so there is nothing to talk to. A game that keeps the runtime off, as a project's own loader can for some scenes, never announces; editor_output says what it has printed.`;
|
|
34089
|
+
}
|
|
34090
|
+
const endpoint = await announcedSince(project, new Set(already.map((one) => one.pid)), {
|
|
34091
|
+
budgetMs: Math.min(budgetMs, remaining),
|
|
34092
|
+
accept: (one) => this.isTheGameOf(this.currentRun(), one, project),
|
|
34093
|
+
giveUp: () => this.currentRun() !== run || this.dapClient?.isStopped() === true || !stillRunning(run)
|
|
34094
|
+
});
|
|
34095
|
+
if (endpoint !== null) {
|
|
34096
|
+
const tiedMeanwhile = () => run.announcedPid !== undefined;
|
|
34097
|
+
if (!tiedMeanwhile()) {
|
|
34098
|
+
this.tie(run, endpoint);
|
|
34099
|
+
}
|
|
34100
|
+
return null;
|
|
34101
|
+
}
|
|
34102
|
+
if (this.currentRun() !== run || !await this.runStillGoing(run)) {
|
|
34103
|
+
return null;
|
|
34104
|
+
}
|
|
34105
|
+
return `The game this server started (${which}) is still coming up and has not announced its runtime after a wait of ${Math.min(budgetMs, remaining)}ms, so there is nothing to talk to yet. It is still running: ask again in a moment rather than starting another.`;
|
|
34106
|
+
}
|
|
34033
34107
|
async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
|
|
34034
|
-
const { op: _op, projectPath, pid, ...params } = asParams(args);
|
|
34108
|
+
const { op: _op, projectPath, pid, outputPath, ...params } = asParams(args);
|
|
34109
|
+
const waited = typeof pid === "number" ? null : await this.ownRunOnItsWay(typeof projectPath === "string" ? projectPath : this.ownProject, timeoutMs);
|
|
34110
|
+
if (waited !== null) {
|
|
34111
|
+
return this.createErrorResponse(waited, [
|
|
34112
|
+
"editor_status lists it under runtimes once it has announced",
|
|
34113
|
+
"editor_output says what it has printed so far"
|
|
34114
|
+
]);
|
|
34115
|
+
}
|
|
34035
34116
|
const announced = runtimesAnnounced();
|
|
34036
34117
|
const wanted = typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined;
|
|
34037
34118
|
const own = typeof pid === "number" ? undefined : this.ownGameAmong(announced.running, wanted) ?? await this.ownGameThroughTheTree(announced.running, wanted);
|
|
@@ -34051,6 +34132,16 @@ class GodotServer {
|
|
|
34051
34132
|
]);
|
|
34052
34133
|
}
|
|
34053
34134
|
const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
|
|
34135
|
+
const saveTo = expectsScreenshot && typeof outputPath === "string" ? outputPath : null;
|
|
34136
|
+
if (saveTo !== null) {
|
|
34137
|
+
const refusal = captureDestinationRefusal(saveTo, [
|
|
34138
|
+
choice.endpoint.project.path,
|
|
34139
|
+
...this.ownProject === null ? [] : [this.ownProject]
|
|
34140
|
+
]);
|
|
34141
|
+
if (refusal !== null) {
|
|
34142
|
+
return this.createErrorResponse(refusal);
|
|
34143
|
+
}
|
|
34144
|
+
}
|
|
34054
34145
|
const screenshotDir = expectsScreenshot ? mkdtempSync2(join16(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
|
|
34055
34146
|
const screenshotPath = screenshotDir ? join16(screenshotDir, "capture.png") : null;
|
|
34056
34147
|
try {
|
|
@@ -34070,11 +34161,18 @@ class GodotServer {
|
|
|
34070
34161
|
return this.createErrorResponse(`Rejected screenshot file path outside the managed capture path: '${returnedPath}'`);
|
|
34071
34162
|
}
|
|
34072
34163
|
const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
|
|
34164
|
+
if (saveTo !== null) {
|
|
34165
|
+
try {
|
|
34166
|
+
copyFileSync(screenshotPath, saveTo, constants.COPYFILE_EXCL);
|
|
34167
|
+
} catch (error) {
|
|
34168
|
+
return this.createErrorResponse(`The capture was taken and could not be saved at ${saveTo}: ${errorMessage(error)}`);
|
|
34169
|
+
}
|
|
34170
|
+
}
|
|
34073
34171
|
return {
|
|
34074
34172
|
content: [
|
|
34075
34173
|
{
|
|
34076
34174
|
type: "text",
|
|
34077
|
-
text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}`
|
|
34175
|
+
text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}`
|
|
34078
34176
|
},
|
|
34079
34177
|
{ type: "image", data: readFileSync16(screenshotPath).toString("base64"), mimeType: "image/png" }
|
|
34080
34178
|
]
|
|
@@ -34121,7 +34219,11 @@ class GodotServer {
|
|
|
34121
34219
|
return await this.handleRuntimeCommand("wait_frames", { ...whichGame(args), frames }, waited);
|
|
34122
34220
|
}
|
|
34123
34221
|
const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 5000;
|
|
34124
|
-
const
|
|
34222
|
+
const asked = readNonEmptyString(args, "nodePath");
|
|
34223
|
+
if (asked === undefined && op === "until" && readNonEmptyString(args, "says") === undefined) {
|
|
34224
|
+
return this.createErrorResponse("runtime_wait until needs nodePath to wait for a property: the property belongs to that node.", ["With says instead of property, nodePath can be left out and the whole game is searched"]);
|
|
34225
|
+
}
|
|
34226
|
+
const nodePath = asked ?? "/root";
|
|
34125
34227
|
const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
|
|
34126
34228
|
return op === "signal" ? await this.handleRuntimeCommand("wait_signal", {
|
|
34127
34229
|
...whichGame(args),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.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",
|