@satelliteoflove/godot-mcp 3.12.0 → 3.14.0
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/addon/commands/debug_commands.gd +9 -0
- package/addon/commands/game_time_commands.gd +14 -6
- package/addon/commands/input_commands.gd +48 -22
- package/addon/commands/project_commands.gd +9 -1
- package/addon/core/mcp_utils.gd +171 -0
- package/addon/game_bridge/mcp_game_bridge.gd +22 -12
- package/addon/plugin.cfg +1 -1
- package/dist/__tests__/connection/timeouts.test.d.ts +2 -0
- package/dist/__tests__/connection/timeouts.test.d.ts.map +1 -0
- package/dist/__tests__/connection/timeouts.test.js +58 -0
- package/dist/__tests__/connection/timeouts.test.js.map +1 -0
- package/dist/__tests__/connection/websocket.test.js +58 -0
- package/dist/__tests__/connection/websocket.test.js.map +1 -1
- package/dist/__tests__/helpers/mock-godot.d.ts +3 -0
- package/dist/__tests__/helpers/mock-godot.d.ts.map +1 -1
- package/dist/__tests__/helpers/mock-godot.js +2 -2
- package/dist/__tests__/helpers/mock-godot.js.map +1 -1
- package/dist/__tests__/tools/editor.test.js +36 -0
- package/dist/__tests__/tools/editor.test.js.map +1 -1
- package/dist/__tests__/tools/game-time.test.js +45 -5
- package/dist/__tests__/tools/game-time.test.js.map +1 -1
- package/dist/__tests__/tools/input.test.js +75 -0
- package/dist/__tests__/tools/input.test.js.map +1 -1
- package/dist/__tests__/tools/project.test.d.ts +2 -0
- package/dist/__tests__/tools/project.test.d.ts.map +1 -0
- package/dist/__tests__/tools/project.test.js +53 -0
- package/dist/__tests__/tools/project.test.js.map +1 -0
- package/dist/connection/timeouts.d.ts +47 -0
- package/dist/connection/timeouts.d.ts.map +1 -0
- package/dist/connection/timeouts.js +72 -0
- package/dist/connection/timeouts.js.map +1 -0
- package/dist/connection/websocket.d.ts +3 -1
- package/dist/connection/websocket.d.ts.map +1 -1
- package/dist/connection/websocket.js +8 -4
- package/dist/connection/websocket.js.map +1 -1
- package/dist/tools/editor.d.ts.map +1 -1
- package/dist/tools/editor.js +8 -2
- package/dist/tools/editor.js.map +1 -1
- package/dist/tools/game-time.d.ts.map +1 -1
- package/dist/tools/game-time.js +33 -8
- package/dist/tools/game-time.js.map +1 -1
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +37 -4
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/project-staleness.d.ts +15 -0
- package/dist/tools/project-staleness.d.ts.map +1 -0
- package/dist/tools/project-staleness.js +16 -0
- package/dist/tools/project-staleness.js.map +1 -0
- package/dist/tools/project.d.ts +2 -0
- package/dist/tools/project.d.ts.map +1 -1
- package/dist/tools/project.js +14 -0
- package/dist/tools/project.js.map +1 -1
- package/package.json +1 -1
|
@@ -125,6 +125,15 @@ func get_log_messages(params: Dictionary) -> Dictionary:
|
|
|
125
125
|
if clear:
|
|
126
126
|
MCPLogger.clear_errors()
|
|
127
127
|
|
|
128
|
+
# The phantom "Identifier not found: <autoload>" errors that mislead agents
|
|
129
|
+
# come from the editor running stale after project.godot was edited on disk
|
|
130
|
+
# (#245). When that divergence is present, attach it here so the caller reads
|
|
131
|
+
# the log and the "your editor is stale, restart it" advisory in one shot,
|
|
132
|
+
# instead of chasing compile errors that do not exist at runtime.
|
|
133
|
+
var staleness := MCPUtils.detect_project_staleness()
|
|
134
|
+
if staleness.get("stale", false):
|
|
135
|
+
result["staleness"] = staleness
|
|
136
|
+
|
|
128
137
|
return _success(result)
|
|
129
138
|
|
|
130
139
|
|
|
@@ -4,10 +4,11 @@ class_name MCPGameTimeCommands
|
|
|
4
4
|
|
|
5
5
|
# Game-time control relay: freeze / step / step_until / thaw / status execute in
|
|
6
6
|
# the game bridge (see mcp_game_bridge.gd); this side only forwards over the
|
|
7
|
-
# debugger channel and waits. Timeout cascade:
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# debugger channel and waits. Timeout cascade (#276): the server derives the
|
|
8
|
+
# whole stagger from the call's in-game budget and pushes relay_timeout_ms down
|
|
9
|
+
# in params; we wait exactly that long, so the bridge (which returns by its
|
|
10
|
+
# pushed wall budget) answers first and errors surface typed. BASE_TIMEOUT and
|
|
11
|
+
# STEP_TIMEOUT are fallbacks only — for an older server that pushes no budget.
|
|
11
12
|
const BASE_TIMEOUT := 10.0
|
|
12
13
|
const STEP_TIMEOUT := 28.0
|
|
13
14
|
|
|
@@ -29,11 +30,11 @@ func game_time_freeze(params: Dictionary) -> Dictionary:
|
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
func game_time_step(params: Dictionary) -> Dictionary:
|
|
32
|
-
return await _relay("game_time_step", [params], STEP_TIMEOUT)
|
|
33
|
+
return await _relay("game_time_step", [params], _relay_timeout(params, STEP_TIMEOUT))
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
func game_time_step_until(params: Dictionary) -> Dictionary:
|
|
36
|
-
return await _relay("game_time_step_until", [params], STEP_TIMEOUT)
|
|
37
|
+
return await _relay("game_time_step_until", [params], _relay_timeout(params, STEP_TIMEOUT))
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
func game_time_thaw(params: Dictionary) -> Dictionary:
|
|
@@ -44,6 +45,13 @@ func game_time_status(params: Dictionary) -> Dictionary:
|
|
|
44
45
|
return await _relay("game_time_status", [params], BASE_TIMEOUT)
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
func _relay_timeout(params: Dictionary, fallback: float) -> float:
|
|
49
|
+
# Use the server-pushed relay budget when present (#276); the local constant
|
|
50
|
+
# is only a fallback for an older server that does not derive the cascade.
|
|
51
|
+
var ms: float = float(params.get("relay_timeout_ms", fallback * 1000.0))
|
|
52
|
+
return ms / 1000.0
|
|
53
|
+
|
|
54
|
+
|
|
47
55
|
func _relay(msg_type: String, args: Array, timeout: float) -> Dictionary:
|
|
48
56
|
var response = await _send_and_wait(msg_type, args, timeout)
|
|
49
57
|
if response == null:
|
|
@@ -34,19 +34,30 @@ func get_commands() -> Dictionary:
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
# Total wall budget for a long-running input command. The server derives the
|
|
38
|
+
# whole cascade and pushes relay_timeout_ms down in params (#276); the local
|
|
39
|
+
# fallback is used only for an older server that pushes no budget.
|
|
40
|
+
func _pushed_budget(params: Dictionary, fallback: float) -> float:
|
|
41
|
+
if params.has("relay_timeout_ms"):
|
|
42
|
+
return float(params["relay_timeout_ms"]) / 1000.0
|
|
43
|
+
return fallback
|
|
44
|
+
|
|
45
|
+
|
|
37
46
|
# Block until the running game's bridge reports it can consume input, bounded by
|
|
38
|
-
# READY_TIMEOUT
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
# READY_TIMEOUT and the shared call deadline (op_start + total_budget) so the
|
|
48
|
+
# ready-wait can never eat the budget the command itself needs (#276). Returns
|
|
49
|
+
# true once ready, false if the game stops or never comes up in time. In the
|
|
50
|
+
# common case (game already running) this returns immediately without waiting a
|
|
51
|
+
# frame. Gating input on this is the fix for #241: the debug session connects
|
|
52
|
+
# before the main scene loads, so input dispatched on has_active_session() alone
|
|
53
|
+
# lands in a game with nothing to receive it.
|
|
54
|
+
func _await_bridge_ready(debugger_plugin, op_start: int, total_budget: float) -> bool:
|
|
45
55
|
while not debugger_plugin.is_bridge_ready():
|
|
46
56
|
if not EditorInterface.is_playing_scene():
|
|
47
57
|
return false # game stopped or crashed while we waited
|
|
48
58
|
await Engine.get_main_loop().process_frame
|
|
49
|
-
|
|
59
|
+
var elapsed := (Time.get_ticks_msec() - op_start) / 1000.0
|
|
60
|
+
if elapsed > READY_TIMEOUT or elapsed > total_budget:
|
|
50
61
|
return false
|
|
51
62
|
return true
|
|
52
63
|
|
|
@@ -90,7 +101,16 @@ func _get_editor_input_map() -> Dictionary:
|
|
|
90
101
|
"name": action_name,
|
|
91
102
|
"events": event_strings,
|
|
92
103
|
})
|
|
93
|
-
|
|
104
|
+
# This map is read from the editor's in-memory InputMap, which is loaded at
|
|
105
|
+
# startup and goes stale if project.godot's [input] section is edited on disk
|
|
106
|
+
# (#245). Flag that so the caller knows the map may be incomplete and can
|
|
107
|
+
# recover with `godot_editor restart`. The game-running path above reads fresh
|
|
108
|
+
# from the bridge, so it never carries this.
|
|
109
|
+
var result := {"actions": actions, "source": "editor"}
|
|
110
|
+
var staleness := MCPUtils.detect_project_staleness()
|
|
111
|
+
if staleness.get("stale", false):
|
|
112
|
+
result["staleness"] = staleness
|
|
113
|
+
return _success(result)
|
|
94
114
|
|
|
95
115
|
|
|
96
116
|
func _event_to_string(event: InputEvent) -> String:
|
|
@@ -146,11 +166,12 @@ func execute_input_sequence(params: Dictionary) -> Dictionary:
|
|
|
146
166
|
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
|
147
167
|
if debugger_plugin == null:
|
|
148
168
|
return _error("NO_SESSION", "No active debug session")
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
#
|
|
153
|
-
#
|
|
169
|
+
# One deadline for the whole call, stamped BEFORE the ready-wait so the
|
|
170
|
+
# bridge-ready gap is folded into the budget instead of stacking on top of
|
|
171
|
+
# it (#276). Prefer the server-pushed budget; the fallback (older server) is
|
|
172
|
+
# the longest input/capture offset plus headroom, floored at INPUT_TIMEOUT,
|
|
173
|
+
# plus the ready-wait that now counts against the same deadline.
|
|
174
|
+
var op_start := Time.get_ticks_msec()
|
|
154
175
|
var max_end_time: float = 0.0
|
|
155
176
|
for input in inputs:
|
|
156
177
|
var start_ms: float = input.get("start_ms", 0.0)
|
|
@@ -158,8 +179,11 @@ func execute_input_sequence(params: Dictionary) -> Dictionary:
|
|
|
158
179
|
max_end_time = max(max_end_time, start_ms + duration_ms)
|
|
159
180
|
for shot_ms in screenshots:
|
|
160
181
|
max_end_time = max(max_end_time, float(shot_ms))
|
|
182
|
+
var fallback: float = max(INPUT_TIMEOUT, (max_end_time / 1000.0) + 5.0) + READY_TIMEOUT
|
|
183
|
+
var timeout := _pushed_budget(params, fallback)
|
|
161
184
|
|
|
162
|
-
|
|
185
|
+
if not await _await_bridge_ready(debugger_plugin, op_start, timeout):
|
|
186
|
+
return _error("BRIDGE_NOT_READY", _BRIDGE_NOT_READY_MSG)
|
|
163
187
|
|
|
164
188
|
_sequence_pending = true
|
|
165
189
|
_sequence_result = {}
|
|
@@ -171,10 +195,9 @@ func execute_input_sequence(params: Dictionary) -> Dictionary:
|
|
|
171
195
|
debugger_plugin.input_sequence_completed.connect(_on_sequence_completed, CONNECT_ONE_SHOT)
|
|
172
196
|
debugger_plugin.request_input_sequence(inputs, report, screenshots, screenshot_max_width)
|
|
173
197
|
|
|
174
|
-
var start_time := Time.get_ticks_msec()
|
|
175
198
|
while _sequence_pending:
|
|
176
199
|
await Engine.get_main_loop().process_frame
|
|
177
|
-
if (Time.get_ticks_msec() -
|
|
200
|
+
if (Time.get_ticks_msec() - op_start) / 1000.0 > timeout:
|
|
178
201
|
_sequence_pending = false
|
|
179
202
|
if debugger_plugin.input_sequence_completed.is_connected(_on_sequence_completed):
|
|
180
203
|
debugger_plugin.input_sequence_completed.disconnect(_on_sequence_completed)
|
|
@@ -225,10 +248,14 @@ func type_text(params: Dictionary) -> Dictionary:
|
|
|
225
248
|
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
|
226
249
|
if debugger_plugin == null:
|
|
227
250
|
return _error("NO_SESSION", "No active debug session")
|
|
228
|
-
|
|
229
|
-
|
|
251
|
+
# Shared deadline (ready-wait + typing), stamped before the ready-wait so the
|
|
252
|
+
# gap is folded into the budget (#276); server-pushed budget or local fallback.
|
|
253
|
+
var op_start := Time.get_ticks_msec()
|
|
254
|
+
var fallback: float = max(INPUT_TIMEOUT, (text.length() * delay_ms / 1000.0) + 5.0) + READY_TIMEOUT
|
|
255
|
+
var timeout := _pushed_budget(params, fallback)
|
|
230
256
|
|
|
231
|
-
|
|
257
|
+
if not await _await_bridge_ready(debugger_plugin, op_start, timeout):
|
|
258
|
+
return _error("BRIDGE_NOT_READY", _BRIDGE_NOT_READY_MSG)
|
|
232
259
|
|
|
233
260
|
_type_text_pending = true
|
|
234
261
|
_type_text_result = {}
|
|
@@ -236,10 +263,9 @@ func type_text(params: Dictionary) -> Dictionary:
|
|
|
236
263
|
debugger_plugin.type_text_completed.connect(_on_type_text_completed, CONNECT_ONE_SHOT)
|
|
237
264
|
debugger_plugin.request_type_text(text, delay_ms, submit)
|
|
238
265
|
|
|
239
|
-
var start_time := Time.get_ticks_msec()
|
|
240
266
|
while _type_text_pending:
|
|
241
267
|
await Engine.get_main_loop().process_frame
|
|
242
|
-
if (Time.get_ticks_msec() -
|
|
268
|
+
if (Time.get_ticks_msec() - op_start) / 1000.0 > timeout:
|
|
243
269
|
_type_text_pending = false
|
|
244
270
|
if debugger_plugin.type_text_completed.is_connected(_on_type_text_completed):
|
|
245
271
|
debugger_plugin.type_text_completed.disconnect(_on_type_text_completed)
|
|
@@ -6,10 +6,18 @@ class_name MCPProjectCommands
|
|
|
6
6
|
func get_commands() -> Dictionary:
|
|
7
7
|
return {
|
|
8
8
|
"get_project_info": get_project_info,
|
|
9
|
-
"get_project_settings": get_project_settings
|
|
9
|
+
"get_project_settings": get_project_settings,
|
|
10
|
+
"get_project_staleness": get_project_staleness,
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
# Detect whether project.godot was edited on disk after the editor loaded it,
|
|
15
|
+
# leaving the editor's in-memory ProjectSettings / InputMap stale (#245). Always
|
|
16
|
+
# returns the full report (stale or not); recovery is `godot_editor restart`.
|
|
17
|
+
func get_project_staleness(_params: Dictionary) -> Dictionary:
|
|
18
|
+
return _success(MCPUtils.detect_project_staleness())
|
|
19
|
+
|
|
20
|
+
|
|
13
21
|
func get_project_info(_params: Dictionary) -> Dictionary:
|
|
14
22
|
return _success({
|
|
15
23
|
"name": ProjectSettings.get_setting("application/config/name", "Unknown"),
|
package/addon/core/mcp_utils.gd
CHANGED
|
@@ -127,3 +127,174 @@ static func ensure_dir_exists(path: String) -> Error:
|
|
|
127
127
|
return ERR_CANT_OPEN
|
|
128
128
|
return dir.make_dir_recursive(path.trim_prefix("res://"))
|
|
129
129
|
return DirAccess.make_dir_recursive_absolute(path)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── project.godot staleness (#245) ────────────────────────────────────────────
|
|
133
|
+
# The editor caches ProjectSettings / InputMap in memory at load. An agent that
|
|
134
|
+
# edits project.godot as a file (batch-writing autoloads / input map) leaves the
|
|
135
|
+
# editor stale: its log fills with phantom "Identifier not found: <autoload>"
|
|
136
|
+
# errors that do not exist at runtime, and its input map is out of date — while
|
|
137
|
+
# spawned games (which read disk fresh at launch) work fine. We detect that by
|
|
138
|
+
# content-diffing the two sections that actually cause the trap, [autoload] and
|
|
139
|
+
# [input], disk vs the editor's in-memory state. Recovery is `godot_editor
|
|
140
|
+
# restart` (#250). A content diff (not an mtime check) is used deliberately: it
|
|
141
|
+
# never false-positives when the editor itself saves project.godot (e.g. the
|
|
142
|
+
# plugin's own startup autoload write), because disk is then written FROM memory.
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Pure: diff already-read disk vs in-memory sets. No engine access, so this is
|
|
146
|
+
# unit-testable headless. Autoloads are symmetric (added/removed/value-changed,
|
|
147
|
+
# raw-string compare incl. the "*" singleton prefix). Input is additive-only
|
|
148
|
+
# (actions present on disk but not loaded — the trap); the reverse direction and
|
|
149
|
+
# event values are intentionally ignored (built-in ui_* noise / fragile compares).
|
|
150
|
+
static func diff_project_staleness(
|
|
151
|
+
disk_autoloads: Dictionary, mem_autoloads: Dictionary,
|
|
152
|
+
disk_input_keys: Array, mem_input_keys: Array) -> Dictionary:
|
|
153
|
+
var autoload_added := []
|
|
154
|
+
var autoload_removed := []
|
|
155
|
+
var autoload_changed := []
|
|
156
|
+
for key in disk_autoloads:
|
|
157
|
+
if not mem_autoloads.has(key):
|
|
158
|
+
autoload_added.append(key)
|
|
159
|
+
elif str(disk_autoloads[key]) != str(mem_autoloads[key]):
|
|
160
|
+
autoload_changed.append(key)
|
|
161
|
+
for key in mem_autoloads:
|
|
162
|
+
if not disk_autoloads.has(key):
|
|
163
|
+
autoload_removed.append(key)
|
|
164
|
+
|
|
165
|
+
var mem_input_set := {}
|
|
166
|
+
for k in mem_input_keys:
|
|
167
|
+
mem_input_set[k] = true
|
|
168
|
+
var input_added := []
|
|
169
|
+
for k in disk_input_keys:
|
|
170
|
+
if not mem_input_set.has(k):
|
|
171
|
+
input_added.append(k)
|
|
172
|
+
|
|
173
|
+
autoload_added.sort()
|
|
174
|
+
autoload_removed.sort()
|
|
175
|
+
autoload_changed.sort()
|
|
176
|
+
input_added.sort()
|
|
177
|
+
|
|
178
|
+
var stale: bool = not (autoload_added.is_empty() and autoload_removed.is_empty()
|
|
179
|
+
and autoload_changed.is_empty() and input_added.is_empty())
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"stale": stale,
|
|
183
|
+
"autoload": {
|
|
184
|
+
"added": autoload_added,
|
|
185
|
+
"removed": autoload_removed,
|
|
186
|
+
"changed": autoload_changed,
|
|
187
|
+
},
|
|
188
|
+
"input": {"added": input_added},
|
|
189
|
+
"summary": _staleness_summary(autoload_added, autoload_removed, autoload_changed, input_added),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
static func _staleness_summary(a_added: Array, a_removed: Array, a_changed: Array, i_added: Array) -> String:
|
|
194
|
+
var parts := []
|
|
195
|
+
if not a_added.is_empty():
|
|
196
|
+
parts.append("%d autoload(s) added on disk (%s)" % [a_added.size(), ", ".join(a_added)])
|
|
197
|
+
if not a_removed.is_empty():
|
|
198
|
+
parts.append("%d autoload(s) removed on disk (%s)" % [a_removed.size(), ", ".join(a_removed)])
|
|
199
|
+
if not a_changed.is_empty():
|
|
200
|
+
parts.append("%d autoload(s) changed on disk (%s)" % [a_changed.size(), ", ".join(a_changed)])
|
|
201
|
+
if not i_added.is_empty():
|
|
202
|
+
parts.append("%d input action(s) added on disk (%s)" % [i_added.size(), ", ".join(i_added)])
|
|
203
|
+
if parts.is_empty():
|
|
204
|
+
return "project.godot on disk matches the editor's loaded settings."
|
|
205
|
+
return ("project.godot was edited on disk after the editor loaded it: %s. The editor's in-memory "
|
|
206
|
+
+ "settings are stale (its log may show phantom \"Identifier not found\" errors that do not "
|
|
207
|
+
+ "exist at runtime). Run `godot_editor restart` to reload project.godot (save:false to discard "
|
|
208
|
+
+ "unsaved editor changes).") % "; ".join(parts)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# Editor-context orchestrator. Reads project.godot from disk (a plain text scan,
|
|
212
|
+
# NOT ConfigFile — which would eagerly instantiate the [input] InputEvent
|
|
213
|
+
# sub-objects) and the in-memory ProjectSettings, then diffs. Never throws: any
|
|
214
|
+
# read failure returns {stale=false, note=...} so a transient I/O hiccup can
|
|
215
|
+
# never produce a false "stale" (recovery is disruptive — silence beats crying
|
|
216
|
+
# wolf). The {stale, autoload, input, summary} shape mirrors diff_project_staleness.
|
|
217
|
+
static func detect_project_staleness() -> Dictionary:
|
|
218
|
+
var disk := _read_disk_project_sections()
|
|
219
|
+
if disk.is_empty():
|
|
220
|
+
return {"stale": false, "note": "Could not read res://project.godot to check staleness."}
|
|
221
|
+
|
|
222
|
+
var mem := _read_mem_sections()
|
|
223
|
+
var mem_autoloads: Dictionary = mem["autoload"]
|
|
224
|
+
var disk_autoloads: Dictionary = disk.get("autoload", {})
|
|
225
|
+
# A project with autoloads always writes the [autoload] section (the addon
|
|
226
|
+
# writes its own bridge autoload at startup). If the section is absent, treat
|
|
227
|
+
# it as "nothing to compare" rather than "everything removed".
|
|
228
|
+
if not disk.get("has_autoload_section", false):
|
|
229
|
+
disk_autoloads = mem_autoloads.duplicate()
|
|
230
|
+
|
|
231
|
+
return diff_project_staleness(
|
|
232
|
+
disk_autoloads, mem_autoloads,
|
|
233
|
+
disk.get("input_keys", []), mem["input_keys"])
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# Scan project.godot once, section-aware. Returns
|
|
237
|
+
# { autoload: {Name: "value"}, input_keys: [action,...], has_autoload_section }
|
|
238
|
+
# or {} if the file can't be read. Only [autoload] (name + unquoted value) and
|
|
239
|
+
# [input] (action NAMES only — the InputEvent dicts are never parsed) are read.
|
|
240
|
+
static func _read_disk_project_sections() -> Dictionary:
|
|
241
|
+
if not FileAccess.file_exists("res://project.godot"):
|
|
242
|
+
MCPLog.warn("project.godot not found; skipping staleness check")
|
|
243
|
+
return {}
|
|
244
|
+
var text := FileAccess.get_file_as_string("res://project.godot")
|
|
245
|
+
if text.is_empty():
|
|
246
|
+
MCPLog.warn("Could not read project.godot for staleness check")
|
|
247
|
+
return {}
|
|
248
|
+
|
|
249
|
+
var autoloads := {}
|
|
250
|
+
var input_keys := []
|
|
251
|
+
var has_autoload := false
|
|
252
|
+
var section := ""
|
|
253
|
+
# A top-level key line: identifier `=` value, at column 0. Dict-body lines of
|
|
254
|
+
# an [input] action start with `"`/`}`/`]` (or are indented), so they never match.
|
|
255
|
+
var key_re := RegEx.new()
|
|
256
|
+
key_re.compile("^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
|
257
|
+
|
|
258
|
+
for raw in text.split("\n"):
|
|
259
|
+
var line := raw.strip_edges(false, true) # trailing only (drops CR / spaces)
|
|
260
|
+
if line.begins_with("[") and line.ends_with("]"):
|
|
261
|
+
section = line.substr(1, line.length() - 2)
|
|
262
|
+
if section == "autoload":
|
|
263
|
+
has_autoload = true
|
|
264
|
+
continue
|
|
265
|
+
if section != "autoload" and section != "input":
|
|
266
|
+
continue
|
|
267
|
+
var m := key_re.search(line)
|
|
268
|
+
if m == null:
|
|
269
|
+
continue
|
|
270
|
+
var key := m.get_string(1)
|
|
271
|
+
if section == "autoload":
|
|
272
|
+
autoloads[key] = _unquote(m.get_string(2))
|
|
273
|
+
elif not key.begins_with("ui_"):
|
|
274
|
+
input_keys.append(key)
|
|
275
|
+
|
|
276
|
+
return {"autoload": autoloads, "input_keys": input_keys, "has_autoload_section": has_autoload}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# Single pass over ProjectSettings (it carries hundreds of entries) collecting
|
|
280
|
+
# both the in-memory autoloads ({Name: value}) and the non-builtin input action
|
|
281
|
+
# names. Returns { autoload: Dictionary, input_keys: Array }.
|
|
282
|
+
static func _read_mem_sections() -> Dictionary:
|
|
283
|
+
var autoloads := {}
|
|
284
|
+
var input_keys := []
|
|
285
|
+
for prop in ProjectSettings.get_property_list():
|
|
286
|
+
var pname: String = prop["name"]
|
|
287
|
+
if pname.begins_with("autoload/"):
|
|
288
|
+
autoloads[pname.substr(9)] = str(ProjectSettings.get_setting(pname, ""))
|
|
289
|
+
elif pname.begins_with("input/"):
|
|
290
|
+
var action := pname.substr(6)
|
|
291
|
+
if not action.begins_with("ui_"):
|
|
292
|
+
input_keys.append(action)
|
|
293
|
+
return {"autoload": autoloads, "input_keys": input_keys}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
static func _unquote(s: String) -> String:
|
|
297
|
+
var t := s.strip_edges()
|
|
298
|
+
if t.length() >= 2 and t.begins_with("\"") and t.ends_with("\""):
|
|
299
|
+
return t.substr(1, t.length() - 2)
|
|
300
|
+
return t
|
|
@@ -283,11 +283,13 @@ var _sequence_capture_offsets: Array = []
|
|
|
283
283
|
var _sequence_captures_pending: int = 0
|
|
284
284
|
var _sequence_capture_max_width: int = 640
|
|
285
285
|
const SEQUENCE_MAX_CAPTURES := 8
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
#
|
|
290
|
-
|
|
286
|
+
# Non-binding sanity backstop only (#276). The server derives the per-call
|
|
287
|
+
# timeout from the sequence span and rejects offsets beyond what the ceiling
|
|
288
|
+
# permits before they ever reach here, so this just guards a malformed direct
|
|
289
|
+
# message. Kept far above any server-permitted budget so it never silently
|
|
290
|
+
# clamps a legitimate offset (which would reintroduce the cross-layer drift
|
|
291
|
+
# that #276 removed).
|
|
292
|
+
const SEQUENCE_MAX_CAPTURE_OFFSET_MS := 300000
|
|
291
293
|
# Actions whose press has been injected but whose paired release has not yet
|
|
292
294
|
# fired. Used to guarantee a release even if the queue is cleared mid-flight
|
|
293
295
|
# (new sequence) or the node leaves the tree — otherwise the dropped release
|
|
@@ -1332,10 +1334,15 @@ func _type_text_async(text: String, delay_ms: int, submit: bool) -> void:
|
|
|
1332
1334
|
# ---------------------------------------------------------------------------
|
|
1333
1335
|
|
|
1334
1336
|
const LAUNCH_FROZEN_ENV := "GODOT_MCP_LAUNCH_FROZEN"
|
|
1335
|
-
# Timeout cascade:
|
|
1336
|
-
#
|
|
1337
|
-
#
|
|
1338
|
-
|
|
1337
|
+
# Timeout cascade (#276): the server derives the whole stagger from the call's
|
|
1338
|
+
# in-game budget and pushes wall_budget_ms down here. The bridge returns by that
|
|
1339
|
+
# wall budget, the editor relay waits a margin longer, the server socket a
|
|
1340
|
+
# margin longer still — each answers before the one above gives up.
|
|
1341
|
+
# STEP_MAX_MS non-binding sanity backstop (the server already clamps the request)
|
|
1342
|
+
# STEP_DEFAULT_MS budget used when a call omits max_ms (older server that sends no default)
|
|
1343
|
+
# STEP_WALL_BUDGET_MS wall-budget fallback when the server pushes no wall_budget_ms
|
|
1344
|
+
const STEP_MAX_MS := 300000
|
|
1345
|
+
const STEP_DEFAULT_MS := 20000
|
|
1339
1346
|
const STEP_MAX_FRAMES := 1200
|
|
1340
1347
|
const STEP_WALL_BUDGET_MS := 25000
|
|
1341
1348
|
const STEP_MAX_TRANSITIONS := 50
|
|
@@ -1358,6 +1365,7 @@ var _step_gameplay_ms := 0.0 # the unpaused portion: what gameplay actually exp
|
|
|
1358
1365
|
var _step_frames := 0
|
|
1359
1366
|
var _step_physics_ticks := 0
|
|
1360
1367
|
var _step_wall_start := 0
|
|
1368
|
+
var _step_wall_budget_ms := STEP_WALL_BUDGET_MS # set per-call from the server-pushed wall_budget_ms (#276)
|
|
1361
1369
|
var _step_events: Array = [] # in-step input timeline, scheduled on the game-time clock
|
|
1362
1370
|
var _step_events_fired := 0
|
|
1363
1371
|
var _step_transitions: Array = []
|
|
@@ -1515,6 +1523,7 @@ func _handle_game_time_step(data: Array) -> void:
|
|
|
1515
1523
|
_step_finish_pending = false
|
|
1516
1524
|
_step_wall_exceeded = false
|
|
1517
1525
|
_step_wall_start = Time.get_ticks_msec()
|
|
1526
|
+
_step_wall_budget_ms = int(params.get("wall_budget_ms", STEP_WALL_BUDGET_MS))
|
|
1518
1527
|
_step_predicate = null
|
|
1519
1528
|
_step_response_type = "game_time_step"
|
|
1520
1529
|
_step_active = true
|
|
@@ -1631,9 +1640,9 @@ func _handle_game_time_step_until(data: Array) -> void:
|
|
|
1631
1640
|
_send_game_time_response("game_time_step_until", {"error": "step_until requires a non-empty `until` expression"})
|
|
1632
1641
|
return
|
|
1633
1642
|
|
|
1634
|
-
var max_ms: int = int(params.get("max_ms",
|
|
1643
|
+
var max_ms: int = int(params.get("max_ms", STEP_DEFAULT_MS))
|
|
1635
1644
|
if max_ms <= 0:
|
|
1636
|
-
max_ms =
|
|
1645
|
+
max_ms = STEP_DEFAULT_MS
|
|
1637
1646
|
max_ms = mini(max_ms, STEP_MAX_MS)
|
|
1638
1647
|
|
|
1639
1648
|
# Compile and validate the predicate against the live tree before committing
|
|
@@ -1696,6 +1705,7 @@ func _handle_game_time_step_until(data: Array) -> void:
|
|
|
1696
1705
|
_step_finish_pending = false
|
|
1697
1706
|
_step_wall_exceeded = false
|
|
1698
1707
|
_step_wall_start = Time.get_ticks_msec()
|
|
1708
|
+
_step_wall_budget_ms = int(params.get("wall_budget_ms", STEP_WALL_BUDGET_MS))
|
|
1699
1709
|
_step_predicate = expr
|
|
1700
1710
|
_step_predicate_inputs = ctx_inputs
|
|
1701
1711
|
_step_predicate_met = false
|
|
@@ -1767,7 +1777,7 @@ func _step_process(delta: float) -> void:
|
|
|
1767
1777
|
_step_predicate_met = true
|
|
1768
1778
|
done = true
|
|
1769
1779
|
|
|
1770
|
-
if Time.get_ticks_msec() - _step_wall_start >
|
|
1780
|
+
if Time.get_ticks_msec() - _step_wall_start > _step_wall_budget_ms:
|
|
1771
1781
|
# Slow-mo, Engine.time_scale = 0, or a pause-held window can starve
|
|
1772
1782
|
# the game-time clock; the wall budget guarantees the call returns
|
|
1773
1783
|
# (partial, honestly reported) before the editor relay gives up.
|
package/addon/plugin.cfg
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timeouts.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/connection/timeouts.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { deriveTimeouts, maxInGameBudgetMs, QUICK_TIMEOUT_MS, ABSOLUTE_CEILING_MS, READY_WAIT_MS, BRIDGE_WALL_SLOP_MS, STEP_BUDGET_CAP_MS, INPUT_BUDGET_CAP_MS, } from '../../connection/timeouts.js';
|
|
3
|
+
describe('timeout cascade (#276)', () => {
|
|
4
|
+
describe('deriveTimeouts stagger invariant', () => {
|
|
5
|
+
// The whole point: bridge answers before relay answers before the server
|
|
6
|
+
// gives up, and the server never exceeds the absolute ceiling — for ANY
|
|
7
|
+
// budget, clamped or not, with or without the ready-wait.
|
|
8
|
+
const cases = [
|
|
9
|
+
{ label: 'zero budget, no ready', budget: 0, readyWait: false },
|
|
10
|
+
{ label: 'mid budget, no ready', budget: 5_000, readyWait: false },
|
|
11
|
+
{ label: 'at step cap', budget: STEP_BUDGET_CAP_MS, readyWait: false },
|
|
12
|
+
{ label: 'over cap, no ready', budget: 10_000_000, readyWait: false },
|
|
13
|
+
{ label: 'zero budget, ready', budget: 0, readyWait: true },
|
|
14
|
+
{ label: 'at input cap', budget: INPUT_BUDGET_CAP_MS, readyWait: true },
|
|
15
|
+
{ label: 'over cap, ready', budget: 999_999, readyWait: true },
|
|
16
|
+
];
|
|
17
|
+
for (const c of cases) {
|
|
18
|
+
it(`${c.label}: bridgeWall < relay < server <= ceiling`, () => {
|
|
19
|
+
const t = deriveTimeouts(c.budget, { readyWait: c.readyWait });
|
|
20
|
+
expect(t.bridgeWallMs).toBeLessThan(t.relayMs);
|
|
21
|
+
expect(t.relayMs).toBeLessThan(t.serverMs);
|
|
22
|
+
expect(t.serverMs).toBeLessThanOrEqual(ABSOLUTE_CEILING_MS);
|
|
23
|
+
expect(t.clampedBudgetMs).toBeGreaterThanOrEqual(0);
|
|
24
|
+
expect(t.clampedBudgetMs).toBeLessThanOrEqual(maxInGameBudgetMs({ readyWait: c.readyWait }));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
it('produces a proportionally small (sub-ceiling) timeout for a small budget', () => {
|
|
29
|
+
const t = deriveTimeouts(3_000, { readyWait: false });
|
|
30
|
+
// bridgeWall = 3000 + slop; relay = +2000; server = +2000.
|
|
31
|
+
expect(t.bridgeWallMs).toBe(3_000 + BRIDGE_WALL_SLOP_MS);
|
|
32
|
+
expect(t.serverMs).toBeLessThan(ABSOLUTE_CEILING_MS);
|
|
33
|
+
});
|
|
34
|
+
it('reserves an extra READY_WAIT_MS of headroom when the ready-wait applies', () => {
|
|
35
|
+
expect(maxInGameBudgetMs({ readyWait: false }) - maxInGameBudgetMs({ readyWait: true })).toBe(READY_WAIT_MS);
|
|
36
|
+
});
|
|
37
|
+
it('clamps an over-budget request to the ceiling, never beyond', () => {
|
|
38
|
+
expect(deriveTimeouts(Number.MAX_SAFE_INTEGER, { readyWait: true }).serverMs).toBe(ABSOLUTE_CEILING_MS);
|
|
39
|
+
expect(deriveTimeouts(Number.MAX_SAFE_INTEGER, { readyWait: false }).serverMs).toBe(ABSOLUTE_CEILING_MS);
|
|
40
|
+
});
|
|
41
|
+
it('handles a non-finite budget defensively (treats it as zero)', () => {
|
|
42
|
+
const t = deriveTimeouts(NaN, { readyWait: false });
|
|
43
|
+
expect(t.clampedBudgetMs).toBe(0);
|
|
44
|
+
expect(t.serverMs).toBeGreaterThan(0);
|
|
45
|
+
});
|
|
46
|
+
describe('published caps stay under the ceiling so they can never time out server-side', () => {
|
|
47
|
+
it('step cap <= max in-game budget (no ready-wait)', () => {
|
|
48
|
+
expect(STEP_BUDGET_CAP_MS).toBeLessThanOrEqual(maxInGameBudgetMs({ readyWait: false }));
|
|
49
|
+
});
|
|
50
|
+
it('input cap <= max in-game budget (with ready-wait)', () => {
|
|
51
|
+
expect(INPUT_BUDGET_CAP_MS).toBeLessThanOrEqual(maxInGameBudgetMs({ readyWait: true }));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
it('keeps the quick default unchanged at 30s', () => {
|
|
55
|
+
expect(QUICK_TIMEOUT_MS).toBe(30_000);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
//# sourceMappingURL=timeouts.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timeouts.test.js","sourceRoot":"","sources":["../../../src/__tests__/connection/timeouts.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
2
2
|
import { once } from 'node:events';
|
|
3
3
|
import { WebSocketServer } from 'ws';
|
|
4
4
|
import { GodotConnection } from '../../connection/websocket.js';
|
|
5
|
+
import { getServerVersion } from '../../version.js';
|
|
5
6
|
// Keep diagnostics hermetic (no WSL detection / no child processes) and quiet.
|
|
6
7
|
vi.mock('../../utils/connection-strategy.js', () => ({
|
|
7
8
|
getTargetHost: () => '127.0.0.1',
|
|
@@ -105,4 +106,61 @@ describe('GodotConnection contention handling (#237)', () => {
|
|
|
105
106
|
expect(message).toMatch(/reconnect/i);
|
|
106
107
|
});
|
|
107
108
|
});
|
|
109
|
+
describe('GodotConnection per-request timeout (#276)', () => {
|
|
110
|
+
let connection = null;
|
|
111
|
+
let wss = null;
|
|
112
|
+
afterEach(async () => {
|
|
113
|
+
connection?.disconnect();
|
|
114
|
+
connection = null;
|
|
115
|
+
if (wss) {
|
|
116
|
+
await new Promise((resolve) => wss.close(() => resolve()));
|
|
117
|
+
wss = null;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
// Reply to the handshake (so connect() resolves immediately and without a
|
|
121
|
+
// version-mismatch), then let the supplied handler deal with real commands.
|
|
122
|
+
async function bridgeAfterHandshake(onCommand) {
|
|
123
|
+
return startFakeBridge((socket) => {
|
|
124
|
+
socket.on('message', (raw) => {
|
|
125
|
+
let msg;
|
|
126
|
+
try {
|
|
127
|
+
msg = JSON.parse(raw.toString());
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!msg)
|
|
133
|
+
return;
|
|
134
|
+
if (msg.command === 'mcp_handshake') {
|
|
135
|
+
socket.send(JSON.stringify({ id: msg.id, status: 'success', result: { addon_version: getServerVersion() } }));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
onCommand(socket, msg);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
it('rejects after opts.timeoutMs when the bridge never answers the command', async () => {
|
|
143
|
+
// Drop every non-handshake command on the floor.
|
|
144
|
+
const bridge = await bridgeAfterHandshake(() => { });
|
|
145
|
+
wss = bridge.wss;
|
|
146
|
+
connection = new GodotConnection({ host: '127.0.0.1', port: bridge.port, autoReconnect: false });
|
|
147
|
+
await connection.connect();
|
|
148
|
+
const start = Date.now();
|
|
149
|
+
await expect(connection.sendCommand('get_runtime_state', {}, { timeoutMs: 150 })).rejects.toThrow();
|
|
150
|
+
const elapsed = Date.now() - start;
|
|
151
|
+
expect(elapsed).toBeGreaterThanOrEqual(120);
|
|
152
|
+
expect(elapsed).toBeLessThan(2000);
|
|
153
|
+
});
|
|
154
|
+
it('does not fire early when given a generous opts.timeoutMs', async () => {
|
|
155
|
+
// Answer the command after a short delay; a 1s budget must not trip on it.
|
|
156
|
+
const bridge = await bridgeAfterHandshake((socket, msg) => {
|
|
157
|
+
setTimeout(() => socket.send(JSON.stringify({ id: msg.id, status: 'success', result: { ok: true } })), 100);
|
|
158
|
+
});
|
|
159
|
+
wss = bridge.wss;
|
|
160
|
+
connection = new GodotConnection({ host: '127.0.0.1', port: bridge.port, autoReconnect: false });
|
|
161
|
+
await connection.connect();
|
|
162
|
+
const result = await connection.sendCommand('get_runtime_state', {}, { timeoutMs: 1000 });
|
|
163
|
+
expect(result.ok).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
108
166
|
//# sourceMappingURL=websocket.test.js.map
|