godot-mcp-runtime 3.0.0 → 3.1.1
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/README.md +55 -67
- package/dist/dispatch.js +27 -27
- package/dist/dispatch.js.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/scripts/godot_operations.gd +150 -115
- package/dist/scripts/mcp_bridge.gd +19 -24
- package/dist/tools/autoload-tools.d.ts +8 -8
- package/dist/tools/autoload-tools.d.ts.map +1 -1
- package/dist/tools/autoload-tools.js +15 -11
- package/dist/tools/autoload-tools.js.map +1 -1
- package/dist/tools/node-tools.d.ts.map +1 -1
- package/dist/tools/node-tools.js +105 -25
- package/dist/tools/node-tools.js.map +1 -1
- package/dist/tools/project-tools.d.ts.map +1 -1
- package/dist/tools/project-tools.js +52 -15
- package/dist/tools/project-tools.js.map +1 -1
- package/dist/tools/runtime-tools.d.ts.map +1 -1
- package/dist/tools/runtime-tools.js +224 -28
- package/dist/tools/runtime-tools.js.map +1 -1
- package/dist/tools/scene-tools.d.ts.map +1 -1
- package/dist/tools/scene-tools.js +30 -12
- package/dist/tools/scene-tools.js.map +1 -1
- package/dist/tools/validate-tools.d.ts.map +1 -1
- package/dist/tools/validate-tools.js +89 -30
- package/dist/tools/validate-tools.js.map +1 -1
- package/dist/utils/autoload-ini.d.ts +6 -0
- package/dist/utils/autoload-ini.d.ts.map +1 -1
- package/dist/utils/autoload-ini.js +13 -0
- package/dist/utils/autoload-ini.js.map +1 -1
- package/dist/utils/bridge-manager.d.ts +18 -1
- package/dist/utils/bridge-manager.d.ts.map +1 -1
- package/dist/utils/bridge-manager.js +61 -11
- package/dist/utils/bridge-manager.js.map +1 -1
- package/dist/utils/bridge-protocol.d.ts +6 -3
- package/dist/utils/bridge-protocol.d.ts.map +1 -1
- package/dist/utils/bridge-protocol.js +28 -15
- package/dist/utils/bridge-protocol.js.map +1 -1
- package/dist/utils/godot-runner.d.ts +47 -3
- package/dist/utils/godot-runner.d.ts.map +1 -1
- package/dist/utils/godot-runner.js +239 -56
- package/dist/utils/godot-runner.js.map +1 -1
- package/dist/utils/logger.d.ts +1 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +1 -1
- package/dist/utils/logger.js.map +1 -1
- package/package.json +2 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { fileURLToPath } from 'url';
|
|
2
|
-
import { join, dirname, normalize } from 'path';
|
|
2
|
+
import { join, dirname, normalize, resolve, sep } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import { spawn } from 'child_process';
|
|
5
5
|
import * as net from 'net';
|
|
6
6
|
import { randomBytes } from 'crypto';
|
|
7
7
|
import { BridgeManager } from './bridge-manager.js';
|
|
8
|
-
import { encodeFrame,
|
|
9
|
-
import { logDebug, logError } from './logger.js';
|
|
8
|
+
import { DEFAULT_BRIDGE_PORT, encodeFrame, findFreePort, parseFrames, FRAME_HEADER_BYTES, MAX_FRAME_BYTES, } from './bridge-protocol.js';
|
|
9
|
+
import { logDebug, logError, DEBUG_MODE } from './logger.js';
|
|
10
10
|
/**
|
|
11
11
|
* Thrown when the bridge socket closes (Godot exited, port closed, or peer
|
|
12
12
|
* dropped the connection mid-flight). Lets callers distinguish
|
|
@@ -21,7 +21,6 @@ export class BridgeDisconnectedError extends Error {
|
|
|
21
21
|
// Derive __filename and __dirname in ESM
|
|
22
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
23
23
|
const __dirname = dirname(__filename);
|
|
24
|
-
const DEBUG_MODE = process.env.DEBUG === 'true';
|
|
25
24
|
// Bridge readiness polling
|
|
26
25
|
export const BRIDGE_WAIT_SPAWNED_TIMEOUT_MS = 8000;
|
|
27
26
|
const BRIDGE_WAIT_SPAWNED_INTERVAL_MS = 300;
|
|
@@ -31,6 +30,7 @@ const BRIDGE_PING_TIMEOUT_MS = 1000;
|
|
|
31
30
|
const BRIDGE_SHUTDOWN_SPAWNED_TIMEOUT_MS = 500;
|
|
32
31
|
const BRIDGE_SHUTDOWN_ATTACHED_TIMEOUT_MS = 1500;
|
|
33
32
|
const BRIDGE_PROCESS_EXIT_TIMEOUT_MS = 2000;
|
|
33
|
+
const BRIDGE_RECONNECT_DELAY_MS = 1000;
|
|
34
34
|
/**
|
|
35
35
|
* Normalize a path for cross-platform comparison.
|
|
36
36
|
* Folds Windows backslashes to forward slashes and strips trailing slashes,
|
|
@@ -117,15 +117,22 @@ export function cleanStdout(stdout) {
|
|
|
117
117
|
return cleanOutput(stdout);
|
|
118
118
|
}
|
|
119
119
|
// Parameter mappings between snake_case and camelCase
|
|
120
|
+
// Add new entries whenever a tool surfaces a new compound parameter — the
|
|
121
|
+
// strict converter throws in test env on unmapped keys to catch oversights.
|
|
120
122
|
const parameterMappings = {
|
|
121
123
|
project_path: 'projectPath',
|
|
122
124
|
scene_path: 'scenePath',
|
|
123
125
|
root_node_type: 'rootNodeType',
|
|
124
126
|
parent_node_path: 'parentNodePath',
|
|
127
|
+
parent_path: 'parentPath',
|
|
125
128
|
node_type: 'nodeType',
|
|
126
129
|
node_name: 'nodeName',
|
|
127
130
|
texture_path: 'texturePath',
|
|
128
131
|
node_path: 'nodePath',
|
|
132
|
+
node_paths: 'nodePaths',
|
|
133
|
+
target_node_path: 'targetNodePath',
|
|
134
|
+
target_parent_path: 'targetParentPath',
|
|
135
|
+
new_name: 'newName',
|
|
129
136
|
output_path: 'outputPath',
|
|
130
137
|
mesh_item_names: 'meshItemNames',
|
|
131
138
|
new_path: 'newPath',
|
|
@@ -134,6 +141,13 @@ const parameterMappings = {
|
|
|
134
141
|
response_mode: 'responseMode',
|
|
135
142
|
preview_max_width: 'previewMaxWidth',
|
|
136
143
|
preview_max_height: 'previewMaxHeight',
|
|
144
|
+
bridge_port: 'bridgePort',
|
|
145
|
+
abort_on_error: 'abortOnError',
|
|
146
|
+
max_depth: 'maxDepth',
|
|
147
|
+
changed_only: 'changedOnly',
|
|
148
|
+
case_sensitive: 'caseSensitive',
|
|
149
|
+
file_types: 'fileTypes',
|
|
150
|
+
max_results: 'maxResults',
|
|
137
151
|
};
|
|
138
152
|
// Reverse mapping from camelCase to snake_case
|
|
139
153
|
const reverseParameterMappings = {};
|
|
@@ -162,29 +176,101 @@ export function normalizeParameters(params) {
|
|
|
162
176
|
}
|
|
163
177
|
return result;
|
|
164
178
|
}
|
|
179
|
+
function convertCamelToSnakeValue(value) {
|
|
180
|
+
if (Array.isArray(value)) {
|
|
181
|
+
return value.map((entry) => convertCamelToSnakeValue(entry));
|
|
182
|
+
}
|
|
183
|
+
if (typeof value === 'object' && value !== null) {
|
|
184
|
+
return convertCamelToSnakeCase(value);
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
165
188
|
export function convertCamelToSnakeCase(params) {
|
|
166
189
|
const result = {};
|
|
190
|
+
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.VITEST === 'true';
|
|
167
191
|
for (const key in params) {
|
|
168
192
|
if (Object.prototype.hasOwnProperty.call(params, key)) {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
193
|
+
const mapped = reverseParameterMappings[key];
|
|
194
|
+
let snakeKey;
|
|
195
|
+
if (mapped) {
|
|
196
|
+
snakeKey = mapped;
|
|
197
|
+
}
|
|
198
|
+
else if (/[A-Z]/.test(key)) {
|
|
199
|
+
// Unmapped camelCase key — tolerated in production via regex fallback,
|
|
200
|
+
// but in tests we throw to catch missing entries in parameterMappings.
|
|
201
|
+
if (isTestEnv) {
|
|
202
|
+
throw new Error(`convertCamelToSnakeCase: unmapped camelCase key '${key}'. ` +
|
|
203
|
+
`Add it to parameterMappings in src/utils/godot-runner.ts so snake/camel conversion stays explicit.`);
|
|
204
|
+
}
|
|
205
|
+
snakeKey = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
174
206
|
}
|
|
175
207
|
else {
|
|
176
|
-
|
|
208
|
+
snakeKey = key;
|
|
177
209
|
}
|
|
210
|
+
result[snakeKey] = convertCamelToSnakeValue(params[key]);
|
|
178
211
|
}
|
|
179
212
|
}
|
|
180
213
|
return result;
|
|
181
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Check whether a display server (X11 / Wayland) is available on the current
|
|
217
|
+
* platform. On macOS and Windows the display subsystem is always present;
|
|
218
|
+
* on Linux we probe the standard environment variables.
|
|
219
|
+
*/
|
|
220
|
+
export function checkDisplayAvailable() {
|
|
221
|
+
if (process.platform !== 'linux')
|
|
222
|
+
return true;
|
|
223
|
+
return !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
224
|
+
}
|
|
182
225
|
export function validatePath(path) {
|
|
183
226
|
if (!path || path.includes('..')) {
|
|
184
227
|
return false;
|
|
185
228
|
}
|
|
186
229
|
return true;
|
|
187
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Stricter check for paths that must stay inside `projectPath`. Rejects `..`
|
|
233
|
+
* (via `validatePath`) and absolute paths that escape the project root.
|
|
234
|
+
* `path.join('/project', '/etc/passwd')` resolves to `/etc/passwd`, so the
|
|
235
|
+
* basic `..`-substring check alone permits absolute-path traversal.
|
|
236
|
+
*
|
|
237
|
+
* Tolerates a leading `res://` (Godot's project-root URI) by stripping it
|
|
238
|
+
* before resolving — autoload entries and resource paths use this prefix.
|
|
239
|
+
*/
|
|
240
|
+
export function validateSubPath(projectPath, userPath) {
|
|
241
|
+
if (!validatePath(userPath))
|
|
242
|
+
return false;
|
|
243
|
+
const stripped = userPath.startsWith('res://') ? userPath.slice('res://'.length) : userPath;
|
|
244
|
+
if (!stripped)
|
|
245
|
+
return false;
|
|
246
|
+
const projectRoot = resolve(projectPath);
|
|
247
|
+
const resolved = resolve(projectRoot, stripped);
|
|
248
|
+
const tail = projectRoot === sep ? sep : projectRoot + sep;
|
|
249
|
+
return resolved === projectRoot || resolved.startsWith(tail);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Validate a Godot scene-tree path (NodePath). Scene-tree paths are a
|
|
253
|
+
* separate namespace from filesystem paths — they address nodes inside
|
|
254
|
+
* a scene, not files on disk, so the project-root containment check
|
|
255
|
+
* in `validateSubPath` does not apply.
|
|
256
|
+
*
|
|
257
|
+
* Rejects empty strings and `..` segments. Accepts both relative
|
|
258
|
+
* (`root/Player`) and absolute (`/root/Player`) Godot forms; the
|
|
259
|
+
* codebase convention is the relative form.
|
|
260
|
+
*/
|
|
261
|
+
export function validateNodePath(path) {
|
|
262
|
+
return typeof path === 'string' && path.length > 0 && !path.includes('..');
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* True when `child` resolves to `parent` or a path beneath it. Used by
|
|
266
|
+
* defense-in-depth checks on bridge-returned paths (e.g. screenshot files
|
|
267
|
+
* that must live under `.mcp/screenshots/`).
|
|
268
|
+
*/
|
|
269
|
+
export function isUnderDir(parent, child) {
|
|
270
|
+
const parentResolved = resolve(parent);
|
|
271
|
+
const childResolved = resolve(child);
|
|
272
|
+
return childResolved === parentResolved || childResolved.startsWith(parentResolved + sep);
|
|
273
|
+
}
|
|
188
274
|
/**
|
|
189
275
|
* Return `error.message` when `error` is an `Error`, otherwise `'Unknown error'`.
|
|
190
276
|
* Centralizes the catch-block boilerplate so handlers can build error responses
|
|
@@ -259,9 +345,9 @@ export function validateSceneArgs(args, opts) {
|
|
|
259
345
|
}
|
|
260
346
|
return { projectPath: projectResult.projectPath, scenePath: '' };
|
|
261
347
|
}
|
|
262
|
-
if (!
|
|
348
|
+
if (!validateSubPath(projectResult.projectPath, args.scenePath)) {
|
|
263
349
|
return createErrorResponse('Invalid scene path', [
|
|
264
|
-
'Provide a valid path without ".."
|
|
350
|
+
'Provide a valid relative path without ".." that stays inside the project directory',
|
|
265
351
|
]);
|
|
266
352
|
}
|
|
267
353
|
if (sceneRequired) {
|
|
@@ -275,6 +361,26 @@ export function validateSceneArgs(args, opts) {
|
|
|
275
361
|
}
|
|
276
362
|
return { projectPath: projectResult.projectPath, scenePath: args.scenePath };
|
|
277
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Read the first `n` bytes from a chunk array without concatenating the
|
|
366
|
+
* entire array. If the first chunk already has enough bytes, returns a
|
|
367
|
+
* zero-copy subarray; otherwise copies just `n` bytes into a fresh buffer.
|
|
368
|
+
* Caller must ensure total length across chunks is >= n.
|
|
369
|
+
*/
|
|
370
|
+
function readBytesFromChunks(chunks, n) {
|
|
371
|
+
if (chunks[0].length >= n)
|
|
372
|
+
return chunks[0].subarray(0, n);
|
|
373
|
+
const result = Buffer.allocUnsafe(n);
|
|
374
|
+
let copied = 0;
|
|
375
|
+
for (const c of chunks) {
|
|
376
|
+
const take = Math.min(c.length, n - copied);
|
|
377
|
+
c.copy(result, copied, 0, take);
|
|
378
|
+
copied += take;
|
|
379
|
+
if (copied >= n)
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
278
384
|
export class GodotRunner {
|
|
279
385
|
godotPath = null;
|
|
280
386
|
operationsScriptPath;
|
|
@@ -284,8 +390,14 @@ export class GodotRunner {
|
|
|
284
390
|
activeProcess = null;
|
|
285
391
|
activeProjectPath = null;
|
|
286
392
|
activeSessionMode = null;
|
|
393
|
+
activeBridgePort = null;
|
|
287
394
|
socket = null;
|
|
288
|
-
|
|
395
|
+
// Receive buffer kept as an array of chunks until at least one complete frame
|
|
396
|
+
// is available. Avoids re-copying accumulated bytes on every TCP data event
|
|
397
|
+
// (the old `Buffer.concat([rxBuffer, chunk])` pattern was O(n²) on large
|
|
398
|
+
// frames split across many chunks).
|
|
399
|
+
rxChunks = [];
|
|
400
|
+
rxTotal = 0;
|
|
289
401
|
inFlight = null;
|
|
290
402
|
constructor(config) {
|
|
291
403
|
this.operationsScriptPath = join(__dirname, '..', 'scripts', 'godot_operations.gd');
|
|
@@ -370,8 +482,17 @@ export class GodotRunner {
|
|
|
370
482
|
}
|
|
371
483
|
}
|
|
372
484
|
async detectGodotPath() {
|
|
373
|
-
|
|
374
|
-
|
|
485
|
+
// Explicit paths (constructor config or GODOT_PATH) are authoritative — leave
|
|
486
|
+
// godotPath null on failure rather than fabricating a platform default, so
|
|
487
|
+
// callers can produce actionable errors.
|
|
488
|
+
if (this.godotPath) {
|
|
489
|
+
if (await this.isValidGodotPath(this.godotPath)) {
|
|
490
|
+
logDebug(`Using existing Godot path: ${this.godotPath}`);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
logError(`Configured Godot path "${this.godotPath}" is not a working Godot executable. ` +
|
|
494
|
+
`Pass a valid Godot 4.x binary via the godotPath config option.`);
|
|
495
|
+
this.godotPath = null;
|
|
375
496
|
return;
|
|
376
497
|
}
|
|
377
498
|
if (process.env.GODOT_PATH) {
|
|
@@ -382,6 +503,9 @@ export class GodotRunner {
|
|
|
382
503
|
logDebug(`Using Godot path from environment: ${this.godotPath}`);
|
|
383
504
|
return;
|
|
384
505
|
}
|
|
506
|
+
logError(`GODOT_PATH is set to "${normalizedPath}" but no working Godot executable was found there. ` +
|
|
507
|
+
`Update GODOT_PATH to your Godot 4.x binary or unset it to auto-detect.`);
|
|
508
|
+
return;
|
|
385
509
|
}
|
|
386
510
|
const osPlatform = process.platform;
|
|
387
511
|
logDebug(`Auto-detecting Godot path for platform: ${osPlatform}`);
|
|
@@ -403,22 +527,20 @@ export class GodotRunner {
|
|
|
403
527
|
logDebug(`Found Godot at: ${winner.path}`);
|
|
404
528
|
return;
|
|
405
529
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (osPlatform === 'win32') {
|
|
409
|
-
this.godotPath = normalize('C:\\Program Files\\Godot\\Godot.exe');
|
|
410
|
-
}
|
|
411
|
-
else if (osPlatform === 'darwin') {
|
|
412
|
-
this.godotPath = normalize('/Applications/Godot.app/Contents/MacOS/Godot');
|
|
413
|
-
}
|
|
414
|
-
else {
|
|
415
|
-
this.godotPath = normalize('/usr/bin/godot');
|
|
416
|
-
}
|
|
417
|
-
logDebug(`Using default path: ${this.godotPath}, but this may not work.`);
|
|
530
|
+
logError(`Could not find Godot in common locations for ${osPlatform}. ` +
|
|
531
|
+
`Set GODOT_PATH to your Godot 4.x executable.`);
|
|
418
532
|
}
|
|
419
533
|
getGodotPath() {
|
|
420
534
|
return this.godotPath;
|
|
421
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* Read the port currently baked into the project's bridge script. Returns
|
|
538
|
+
* null if the file is missing or malformed. Thin pass-through to
|
|
539
|
+
* BridgeManager — used by bridge-wait-timeout race detection.
|
|
540
|
+
*/
|
|
541
|
+
readBakedBridgePort(projectPath) {
|
|
542
|
+
return this.bridge.readBakedPort(projectPath);
|
|
543
|
+
}
|
|
422
544
|
async getVersion() {
|
|
423
545
|
if (this.cachedVersion !== null) {
|
|
424
546
|
return this.cachedVersion;
|
|
@@ -484,13 +606,13 @@ export class GodotRunner {
|
|
|
484
606
|
}
|
|
485
607
|
launchEditor(projectPath) {
|
|
486
608
|
if (!this.godotPath) {
|
|
487
|
-
throw new Error('Godot
|
|
609
|
+
throw new Error('No Godot executable resolved. Set GODOT_PATH to a Godot 4.x binary, or pass godotPath via config.');
|
|
488
610
|
}
|
|
489
611
|
return spawn(this.godotPath, ['-e', '--path', projectPath], { stdio: 'pipe' });
|
|
490
612
|
}
|
|
491
|
-
runProject(projectPath, scene, background = false) {
|
|
613
|
+
async runProject(projectPath, scene, background = false, bridgePort) {
|
|
492
614
|
if (!this.godotPath) {
|
|
493
|
-
throw new Error('Godot
|
|
615
|
+
throw new Error('No Godot executable resolved. Set GODOT_PATH to a Godot 4.x binary, or pass godotPath via config.');
|
|
494
616
|
}
|
|
495
617
|
if (this.activeSessionMode === 'spawned' && this.activeProcess) {
|
|
496
618
|
logDebug('Killing existing Godot process before starting a new one');
|
|
@@ -506,8 +628,14 @@ export class GodotRunner {
|
|
|
506
628
|
this.closeConnection();
|
|
507
629
|
this.bridge.cleanup(this.activeProjectPath);
|
|
508
630
|
}
|
|
631
|
+
if (!checkDisplayAvailable()) {
|
|
632
|
+
throw new Error('No display server available (DISPLAY and WAYLAND_DISPLAY are both unset). ' +
|
|
633
|
+
'Godot requires a display to run a project window.');
|
|
634
|
+
}
|
|
635
|
+
const port = bridgePort ?? (await findFreePort());
|
|
636
|
+
this.activeBridgePort = port;
|
|
509
637
|
try {
|
|
510
|
-
this.bridge.inject(projectPath);
|
|
638
|
+
this.bridge.inject(projectPath, port);
|
|
511
639
|
}
|
|
512
640
|
catch (err) {
|
|
513
641
|
logDebug(`Non-fatal: Failed to inject bridge autoload: ${err}`);
|
|
@@ -515,15 +643,19 @@ export class GodotRunner {
|
|
|
515
643
|
this.activeProjectPath = projectPath;
|
|
516
644
|
this.activeSessionMode = 'spawned';
|
|
517
645
|
const cmdArgs = ['--path', projectPath];
|
|
518
|
-
if (scene &&
|
|
646
|
+
if (scene && validateSubPath(projectPath, scene)) {
|
|
519
647
|
logDebug(`Adding scene parameter: ${scene}`);
|
|
520
648
|
cmdArgs.push(scene);
|
|
521
649
|
}
|
|
522
|
-
|
|
650
|
+
const portSource = bridgePort !== undefined ? 'explicit' : 'auto';
|
|
651
|
+
logDebug(`Running Godot project: ${projectPath} (bridge port ${port}, ${portSource})`);
|
|
523
652
|
const sessionToken = randomBytes(16).toString('hex');
|
|
524
653
|
const spawnOptions = {
|
|
525
654
|
stdio: 'pipe',
|
|
526
|
-
env: {
|
|
655
|
+
env: {
|
|
656
|
+
...process.env,
|
|
657
|
+
MCP_SESSION_TOKEN: sessionToken,
|
|
658
|
+
},
|
|
527
659
|
};
|
|
528
660
|
if (background) {
|
|
529
661
|
spawnOptions.env = { ...spawnOptions.env, MCP_BACKGROUND: '1' };
|
|
@@ -545,12 +677,10 @@ export class GodotRunner {
|
|
|
545
677
|
output.push(...lines);
|
|
546
678
|
if (output.length > 500)
|
|
547
679
|
output.splice(0, output.length - 500);
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
});
|
|
553
|
-
}
|
|
680
|
+
lines.forEach((line) => {
|
|
681
|
+
if (line.trim())
|
|
682
|
+
logDebug(`[Godot stdout] ${line}`);
|
|
683
|
+
});
|
|
554
684
|
});
|
|
555
685
|
proc.stderr?.on('data', (data) => {
|
|
556
686
|
const lines = data.toString().split('\n');
|
|
@@ -558,12 +688,10 @@ export class GodotRunner {
|
|
|
558
688
|
errors.push(...lines);
|
|
559
689
|
if (errors.length > 500)
|
|
560
690
|
errors.splice(0, errors.length - 500);
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
});
|
|
566
|
-
}
|
|
691
|
+
lines.forEach((line) => {
|
|
692
|
+
if (line.trim())
|
|
693
|
+
logDebug(`[Godot stderr] ${line}`);
|
|
694
|
+
});
|
|
567
695
|
});
|
|
568
696
|
proc.on('exit', (code) => {
|
|
569
697
|
logDebug(`Godot process exited with code ${code}`);
|
|
@@ -579,7 +707,7 @@ export class GodotRunner {
|
|
|
579
707
|
this.activeProcess = godotProcess;
|
|
580
708
|
return this.activeProcess;
|
|
581
709
|
}
|
|
582
|
-
async attachProject(projectPath) {
|
|
710
|
+
async attachProject(projectPath, bridgePort) {
|
|
583
711
|
if (this.activeSessionMode === 'spawned' && this.activeProcess) {
|
|
584
712
|
await this.stopProject();
|
|
585
713
|
}
|
|
@@ -599,7 +727,11 @@ export class GodotRunner {
|
|
|
599
727
|
this.activeProjectPath = null;
|
|
600
728
|
this.activeSessionMode = null;
|
|
601
729
|
}
|
|
602
|
-
|
|
730
|
+
const port = bridgePort ?? (await findFreePort());
|
|
731
|
+
this.activeBridgePort = port;
|
|
732
|
+
this.bridge.inject(projectPath, port);
|
|
733
|
+
const portSource = bridgePort !== undefined ? 'explicit' : 'auto';
|
|
734
|
+
logDebug(`Attaching to Godot project: ${projectPath} (bridge port ${port}, ${portSource})`);
|
|
603
735
|
this.activeProjectPath = projectPath;
|
|
604
736
|
this.activeSessionMode = 'attached';
|
|
605
737
|
this.activeProcess = null;
|
|
@@ -625,6 +757,7 @@ export class GodotRunner {
|
|
|
625
757
|
}
|
|
626
758
|
this.activeProjectPath = null;
|
|
627
759
|
this.activeSessionMode = null;
|
|
760
|
+
this.activeBridgePort = null;
|
|
628
761
|
this.activeProcess = null;
|
|
629
762
|
return {
|
|
630
763
|
mode: 'attached',
|
|
@@ -677,6 +810,7 @@ export class GodotRunner {
|
|
|
677
810
|
this.activeProjectPath = null;
|
|
678
811
|
}
|
|
679
812
|
this.activeSessionMode = null;
|
|
813
|
+
this.activeBridgePort = null;
|
|
680
814
|
return result;
|
|
681
815
|
}
|
|
682
816
|
hasActiveRuntimeSession() {
|
|
@@ -727,7 +861,7 @@ export class GodotRunner {
|
|
|
727
861
|
sock.removeAllListeners();
|
|
728
862
|
sock.destroy();
|
|
729
863
|
}
|
|
730
|
-
this.
|
|
864
|
+
this.resetRxBuffer();
|
|
731
865
|
settle(new Error(`Command '${command}' timed out after ${timeoutMs}ms. Is the game running?`));
|
|
732
866
|
}, timeoutMs);
|
|
733
867
|
this.inFlight = { command, resolve, reject, timer };
|
|
@@ -736,18 +870,47 @@ export class GodotRunner {
|
|
|
736
870
|
cb();
|
|
737
871
|
return;
|
|
738
872
|
}
|
|
739
|
-
|
|
873
|
+
// Fallback to DEFAULT_BRIDGE_PORT is defensive — every entry point
|
|
874
|
+
// (runProject, attachProject) sets activeBridgePort before sendCommand
|
|
875
|
+
// can be reached, so this branch is not expected in practice.
|
|
876
|
+
const port = this.activeBridgePort ?? DEFAULT_BRIDGE_PORT;
|
|
740
877
|
const sock = net.connect(port, '127.0.0.1');
|
|
741
878
|
const onConnect = () => {
|
|
742
879
|
sock.setNoDelay(true);
|
|
743
880
|
sock.removeListener('error', onConnectError);
|
|
744
881
|
this.socket = sock;
|
|
745
|
-
this.
|
|
882
|
+
this.resetRxBuffer();
|
|
746
883
|
sock.on('data', (chunk) => {
|
|
747
|
-
this.
|
|
884
|
+
this.rxChunks.push(chunk);
|
|
885
|
+
this.rxTotal += chunk.length;
|
|
886
|
+
// Defer the (potentially expensive) concat until we know at least
|
|
887
|
+
// one complete frame is ready. Peek the 4-byte header without
|
|
888
|
+
// copying all accumulated chunks first.
|
|
889
|
+
if (this.rxTotal < FRAME_HEADER_BYTES)
|
|
890
|
+
return;
|
|
891
|
+
const header = readBytesFromChunks(this.rxChunks, FRAME_HEADER_BYTES);
|
|
892
|
+
const firstLen = header.readUInt32BE(0);
|
|
893
|
+
if (firstLen > MAX_FRAME_BYTES) {
|
|
894
|
+
this.socket = null;
|
|
895
|
+
sock.destroy();
|
|
896
|
+
settle(new BridgeDisconnectedError(`Bridge frame header advertises ${firstLen} bytes, exceeds limit ${MAX_FRAME_BYTES}`));
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (this.rxTotal < FRAME_HEADER_BYTES + firstLen)
|
|
900
|
+
return;
|
|
748
901
|
try {
|
|
749
|
-
const
|
|
750
|
-
|
|
902
|
+
const buffer = this.rxChunks.length === 1
|
|
903
|
+
? this.rxChunks[0]
|
|
904
|
+
: Buffer.concat(this.rxChunks, this.rxTotal);
|
|
905
|
+
const { frames, remainder } = parseFrames(buffer);
|
|
906
|
+
if (remainder.length === 0) {
|
|
907
|
+
this.rxChunks = [];
|
|
908
|
+
this.rxTotal = 0;
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
this.rxChunks = [remainder];
|
|
912
|
+
this.rxTotal = remainder.length;
|
|
913
|
+
}
|
|
751
914
|
for (const frame of frames) {
|
|
752
915
|
settle(null, frame.toString('utf8'));
|
|
753
916
|
}
|
|
@@ -814,7 +977,11 @@ export class GodotRunner {
|
|
|
814
977
|
sock.removeAllListeners();
|
|
815
978
|
sock.destroy();
|
|
816
979
|
}
|
|
817
|
-
this.
|
|
980
|
+
this.resetRxBuffer();
|
|
981
|
+
}
|
|
982
|
+
resetRxBuffer() {
|
|
983
|
+
this.rxChunks = [];
|
|
984
|
+
this.rxTotal = 0;
|
|
818
985
|
}
|
|
819
986
|
getErrorCount() {
|
|
820
987
|
return this.activeProcess?.totalErrorsWritten ?? 0;
|
|
@@ -832,12 +999,28 @@ export class GodotRunner {
|
|
|
832
999
|
// Only the explicit `SCRIPT ERROR:` / `USER SCRIPT ERROR:` markers belong here — the looser
|
|
833
1000
|
// `GDScript error` substring also matches user printerr output and produces false positives.
|
|
834
1001
|
static SCRIPT_ERROR_PATTERNS = ['SCRIPT ERROR:', 'USER SCRIPT ERROR:'];
|
|
1002
|
+
static RETRYABLE_BRIDGE_COMMANDS = new Set(['get_ui_elements', 'screenshot']);
|
|
835
1003
|
extractRuntimeErrors(lines) {
|
|
836
1004
|
return lines.filter((line) => GodotRunner.SCRIPT_ERROR_PATTERNS.some((p) => line.includes(p)));
|
|
837
1005
|
}
|
|
1006
|
+
async sendCommandWithReconnect(command, params = {}, timeoutMs = 10000) {
|
|
1007
|
+
try {
|
|
1008
|
+
return await this.sendCommand(command, params, timeoutMs);
|
|
1009
|
+
}
|
|
1010
|
+
catch (err) {
|
|
1011
|
+
if (err instanceof BridgeDisconnectedError &&
|
|
1012
|
+
this.activeSessionMode &&
|
|
1013
|
+
GodotRunner.RETRYABLE_BRIDGE_COMMANDS.has(command)) {
|
|
1014
|
+
this.closeConnection();
|
|
1015
|
+
await new Promise((r) => setTimeout(r, BRIDGE_RECONNECT_DELAY_MS));
|
|
1016
|
+
return this.sendCommand(command, params, timeoutMs);
|
|
1017
|
+
}
|
|
1018
|
+
throw err;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
838
1021
|
async sendCommandWithErrors(command, params = {}, timeoutMs = 10000) {
|
|
839
1022
|
const marker = this.getErrorCount();
|
|
840
|
-
const response = await this.
|
|
1023
|
+
const response = await this.sendCommandWithReconnect(command, params, timeoutMs);
|
|
841
1024
|
const newErrors = this.getErrorsSince(marker);
|
|
842
1025
|
const runtimeErrors = this.activeSessionMode === 'spawned' ? this.extractRuntimeErrors(newErrors) : [];
|
|
843
1026
|
return { response, runtimeErrors };
|