@satelliteoflove/godot-mcp 3.11.0 → 3.13.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/game_time_commands.gd +14 -6
- package/addon/commands/input_commands.gd +70 -20
- package/addon/commands/screenshot_commands.gd +7 -8
- package/addon/core/mcp_debugger_plugin.gd +22 -4
- package/addon/game_bridge/mcp_game_bridge.gd +117 -22
- 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 +1 -1
- 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 +115 -0
- package/dist/__tests__/tools/input.test.js.map +1 -1
- 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/core/types.d.ts +2 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/tools/editor.d.ts +0 -2
- package/dist/tools/editor.d.ts.map +1 -1
- package/dist/tools/editor.js +7 -9
- 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 +2 -0
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +78 -3
- package/dist/tools/input.js.map +1 -1
- package/package.json +1 -1
|
@@ -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:
|
|
@@ -14,6 +14,9 @@ var _input_map_pending: bool = false
|
|
|
14
14
|
|
|
15
15
|
var _sequence_result: Dictionary = {}
|
|
16
16
|
var _sequence_pending: bool = false
|
|
17
|
+
# Frames captured mid-sequence (#239), collected from sequence_capture_received
|
|
18
|
+
# signals and attached to the result once the sequence completes.
|
|
19
|
+
var _sequence_captures: Array = []
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
var _type_text_result: Dictionary = {}
|
|
@@ -31,19 +34,30 @@ func get_commands() -> Dictionary:
|
|
|
31
34
|
}
|
|
32
35
|
|
|
33
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
|
+
|
|
34
46
|
# Block until the running game's bridge reports it can consume input, bounded by
|
|
35
|
-
# READY_TIMEOUT
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
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:
|
|
42
55
|
while not debugger_plugin.is_bridge_ready():
|
|
43
56
|
if not EditorInterface.is_playing_scene():
|
|
44
57
|
return false # game stopped or crashed while we waited
|
|
45
58
|
await Engine.get_main_loop().process_frame
|
|
46
|
-
|
|
59
|
+
var elapsed := (Time.get_ticks_msec() - op_start) / 1000.0
|
|
60
|
+
if elapsed > READY_TIMEOUT or elapsed > total_budget:
|
|
47
61
|
return false
|
|
48
62
|
return true
|
|
49
63
|
|
|
@@ -132,6 +146,8 @@ func _on_input_map_received(actions: Array, error: String) -> void:
|
|
|
132
146
|
func execute_input_sequence(params: Dictionary) -> Dictionary:
|
|
133
147
|
var inputs: Array = params.get("inputs", [])
|
|
134
148
|
var report: Array = params.get("report", [])
|
|
149
|
+
var screenshots: Array = params.get("screenshot_at_ms", [])
|
|
150
|
+
var screenshot_max_width: int = int(params.get("screenshot_max_width", 640))
|
|
135
151
|
if inputs.is_empty():
|
|
136
152
|
return _error("INVALID_PARAMS", "inputs array is required and must not be empty")
|
|
137
153
|
|
|
@@ -141,35 +157,54 @@ func execute_input_sequence(params: Dictionary) -> Dictionary:
|
|
|
141
157
|
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
|
142
158
|
if debugger_plugin == null:
|
|
143
159
|
return _error("NO_SESSION", "No active debug session")
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
160
|
+
# One deadline for the whole call, stamped BEFORE the ready-wait so the
|
|
161
|
+
# bridge-ready gap is folded into the budget instead of stacking on top of
|
|
162
|
+
# it (#276). Prefer the server-pushed budget; the fallback (older server) is
|
|
163
|
+
# the longest input/capture offset plus headroom, floored at INPUT_TIMEOUT,
|
|
164
|
+
# plus the ready-wait that now counts against the same deadline.
|
|
165
|
+
var op_start := Time.get_ticks_msec()
|
|
147
166
|
var max_end_time: float = 0.0
|
|
148
167
|
for input in inputs:
|
|
149
168
|
var start_ms: float = input.get("start_ms", 0.0)
|
|
150
169
|
var duration_ms: float = input.get("duration_ms", 0.0)
|
|
151
170
|
max_end_time = max(max_end_time, start_ms + duration_ms)
|
|
171
|
+
for shot_ms in screenshots:
|
|
172
|
+
max_end_time = max(max_end_time, float(shot_ms))
|
|
173
|
+
var fallback: float = max(INPUT_TIMEOUT, (max_end_time / 1000.0) + 5.0) + READY_TIMEOUT
|
|
174
|
+
var timeout := _pushed_budget(params, fallback)
|
|
152
175
|
|
|
153
|
-
|
|
176
|
+
if not await _await_bridge_ready(debugger_plugin, op_start, timeout):
|
|
177
|
+
return _error("BRIDGE_NOT_READY", _BRIDGE_NOT_READY_MSG)
|
|
154
178
|
|
|
155
179
|
_sequence_pending = true
|
|
156
180
|
_sequence_result = {}
|
|
181
|
+
_sequence_captures = []
|
|
157
182
|
|
|
183
|
+
# Captures stream in as separate signals before the final result; collect
|
|
184
|
+
# them for the duration of the wait (not one-shot), then detach.
|
|
185
|
+
debugger_plugin.sequence_capture_received.connect(_on_sequence_capture)
|
|
158
186
|
debugger_plugin.input_sequence_completed.connect(_on_sequence_completed, CONNECT_ONE_SHOT)
|
|
159
|
-
debugger_plugin.request_input_sequence(inputs, report)
|
|
187
|
+
debugger_plugin.request_input_sequence(inputs, report, screenshots, screenshot_max_width)
|
|
160
188
|
|
|
161
|
-
var start_time := Time.get_ticks_msec()
|
|
162
189
|
while _sequence_pending:
|
|
163
190
|
await Engine.get_main_loop().process_frame
|
|
164
|
-
if (Time.get_ticks_msec() -
|
|
191
|
+
if (Time.get_ticks_msec() - op_start) / 1000.0 > timeout:
|
|
165
192
|
_sequence_pending = false
|
|
166
193
|
if debugger_plugin.input_sequence_completed.is_connected(_on_sequence_completed):
|
|
167
194
|
debugger_plugin.input_sequence_completed.disconnect(_on_sequence_completed)
|
|
195
|
+
if debugger_plugin.sequence_capture_received.is_connected(_on_sequence_capture):
|
|
196
|
+
debugger_plugin.sequence_capture_received.disconnect(_on_sequence_capture)
|
|
168
197
|
return _error("TIMEOUT", "Timed out waiting for input sequence to complete")
|
|
169
198
|
|
|
199
|
+
if debugger_plugin.sequence_capture_received.is_connected(_on_sequence_capture):
|
|
200
|
+
debugger_plugin.sequence_capture_received.disconnect(_on_sequence_capture)
|
|
201
|
+
|
|
170
202
|
if _sequence_result.has("error"):
|
|
171
203
|
return _error("SEQUENCE_ERROR", _sequence_result.get("error", "Unknown error"))
|
|
172
204
|
|
|
205
|
+
if not _sequence_captures.is_empty():
|
|
206
|
+
_sequence_result["captures"] = _sequence_captures
|
|
207
|
+
|
|
173
208
|
return _success(_sequence_result)
|
|
174
209
|
|
|
175
210
|
|
|
@@ -178,6 +213,18 @@ func _on_sequence_completed(result: Dictionary) -> void:
|
|
|
178
213
|
_sequence_result = result
|
|
179
214
|
|
|
180
215
|
|
|
216
|
+
func _on_sequence_capture(requested_ms: int, actual_ms: int, ok: bool, image_base64: String, width: int, height: int, error: String) -> void:
|
|
217
|
+
_sequence_captures.append({
|
|
218
|
+
"requested_ms": requested_ms,
|
|
219
|
+
"actual_ms": actual_ms,
|
|
220
|
+
"ok": ok,
|
|
221
|
+
"image_base64": image_base64,
|
|
222
|
+
"width": width,
|
|
223
|
+
"height": height,
|
|
224
|
+
"error": error,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
|
|
181
228
|
func type_text(params: Dictionary) -> Dictionary:
|
|
182
229
|
var text: String = params.get("text", "")
|
|
183
230
|
var delay_ms: int = int(params.get("delay_ms", 50))
|
|
@@ -192,10 +239,14 @@ func type_text(params: Dictionary) -> Dictionary:
|
|
|
192
239
|
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
|
193
240
|
if debugger_plugin == null:
|
|
194
241
|
return _error("NO_SESSION", "No active debug session")
|
|
195
|
-
|
|
196
|
-
|
|
242
|
+
# Shared deadline (ready-wait + typing), stamped before the ready-wait so the
|
|
243
|
+
# gap is folded into the budget (#276); server-pushed budget or local fallback.
|
|
244
|
+
var op_start := Time.get_ticks_msec()
|
|
245
|
+
var fallback: float = max(INPUT_TIMEOUT, (text.length() * delay_ms / 1000.0) + 5.0) + READY_TIMEOUT
|
|
246
|
+
var timeout := _pushed_budget(params, fallback)
|
|
197
247
|
|
|
198
|
-
|
|
248
|
+
if not await _await_bridge_ready(debugger_plugin, op_start, timeout):
|
|
249
|
+
return _error("BRIDGE_NOT_READY", _BRIDGE_NOT_READY_MSG)
|
|
199
250
|
|
|
200
251
|
_type_text_pending = true
|
|
201
252
|
_type_text_result = {}
|
|
@@ -203,10 +254,9 @@ func type_text(params: Dictionary) -> Dictionary:
|
|
|
203
254
|
debugger_plugin.type_text_completed.connect(_on_type_text_completed, CONNECT_ONE_SHOT)
|
|
204
255
|
debugger_plugin.request_type_text(text, delay_ms, submit)
|
|
205
256
|
|
|
206
|
-
var start_time := Time.get_ticks_msec()
|
|
207
257
|
while _type_text_pending:
|
|
208
258
|
await Engine.get_main_loop().process_frame
|
|
209
|
-
if (Time.get_ticks_msec() -
|
|
259
|
+
if (Time.get_ticks_msec() - op_start) / 1000.0 > timeout:
|
|
210
260
|
_type_text_pending = false
|
|
211
261
|
if debugger_plugin.type_text_completed.is_connected(_on_type_text_completed):
|
|
212
262
|
debugger_plugin.type_text_completed.disconnect(_on_type_text_completed)
|
|
@@ -3,7 +3,6 @@ extends MCPBaseCommand
|
|
|
3
3
|
class_name MCPScreenshotCommands
|
|
4
4
|
|
|
5
5
|
const DEFAULT_MAX_WIDTH := 900
|
|
6
|
-
const DEFAULT_JPEG_QUALITY := 75
|
|
7
6
|
const SCREENSHOT_TIMEOUT := 5.0
|
|
8
7
|
|
|
9
8
|
var _screenshot_result: Dictionary = {}
|
|
@@ -22,7 +21,6 @@ func capture_game_screenshot(params: Dictionary) -> Dictionary:
|
|
|
22
21
|
return _error("NOT_RUNNING", "No game is currently running. Use run_project first.")
|
|
23
22
|
|
|
24
23
|
var max_width: int = params.get("max_width", DEFAULT_MAX_WIDTH)
|
|
25
|
-
var quality: float = params.get("quality", DEFAULT_JPEG_QUALITY) / 100.0
|
|
26
24
|
|
|
27
25
|
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
|
28
26
|
if debugger_plugin == null:
|
|
@@ -35,7 +33,7 @@ func capture_game_screenshot(params: Dictionary) -> Dictionary:
|
|
|
35
33
|
_screenshot_result = {}
|
|
36
34
|
|
|
37
35
|
debugger_plugin.screenshot_received.connect(_on_screenshot_received, CONNECT_ONE_SHOT)
|
|
38
|
-
debugger_plugin.request_screenshot(max_width
|
|
36
|
+
debugger_plugin.request_screenshot(max_width)
|
|
39
37
|
|
|
40
38
|
var start_time := Time.get_ticks_msec()
|
|
41
39
|
while _screenshot_pending:
|
|
@@ -64,7 +62,6 @@ func _on_screenshot_received(success: bool, image_base64: String, width: int, he
|
|
|
64
62
|
func capture_editor_screenshot(params: Dictionary) -> Dictionary:
|
|
65
63
|
var viewport_type: String = params.get("viewport", "")
|
|
66
64
|
var max_width: int = params.get("max_width", DEFAULT_MAX_WIDTH)
|
|
67
|
-
var quality: float = params.get("quality", DEFAULT_JPEG_QUALITY) / 100.0
|
|
68
65
|
|
|
69
66
|
var viewport: SubViewport = null
|
|
70
67
|
|
|
@@ -80,10 +77,12 @@ func capture_editor_screenshot(params: Dictionary) -> Dictionary:
|
|
|
80
77
|
return _error("NO_VIEWPORT", "Could not find editor viewport")
|
|
81
78
|
|
|
82
79
|
var image := viewport.get_texture().get_image()
|
|
83
|
-
return _process_and_encode_image(image, max_width
|
|
80
|
+
return _process_and_encode_image(image, max_width)
|
|
84
81
|
|
|
85
82
|
|
|
86
|
-
|
|
83
|
+
# Lossless PNG, not JPEG: vision-token cost is set by resolution, not codec, so
|
|
84
|
+
# JPEG only added compression artifacts. max_width bounds the resolution cost.
|
|
85
|
+
func _process_and_encode_image(image: Image, max_width: int) -> Dictionary:
|
|
87
86
|
if image == null:
|
|
88
87
|
return _error("CAPTURE_FAILED", "Failed to capture image from viewport")
|
|
89
88
|
|
|
@@ -92,8 +91,8 @@ func _process_and_encode_image(image: Image, max_width: int, quality: float = 0.
|
|
|
92
91
|
var new_height := int(image.get_height() * scale_factor)
|
|
93
92
|
image.resize(max_width, new_height, Image.INTERPOLATE_LANCZOS)
|
|
94
93
|
|
|
95
|
-
var
|
|
96
|
-
var base64 := Marshalls.raw_to_base64(
|
|
94
|
+
var png_buffer := image.save_png_to_buffer()
|
|
95
|
+
var base64 := Marshalls.raw_to_base64(png_buffer)
|
|
97
96
|
|
|
98
97
|
return _success({
|
|
99
98
|
"image_base64": base64,
|
|
@@ -8,6 +8,7 @@ signal performance_metrics_received(metrics: Dictionary)
|
|
|
8
8
|
signal find_nodes_received(matches: Array, count: int, error: String)
|
|
9
9
|
signal input_map_received(actions: Array, error: String)
|
|
10
10
|
signal input_sequence_completed(result: Dictionary)
|
|
11
|
+
signal sequence_capture_received(requested_ms: int, actual_ms: int, ok: bool, image_base64: String, width: int, height: int, error: String)
|
|
11
12
|
signal type_text_completed(result: Dictionary)
|
|
12
13
|
signal game_response(message_type: String, data: Variant)
|
|
13
14
|
signal bridge_ready()
|
|
@@ -52,6 +53,9 @@ func _capture(message: String, data: Array, session_id: int) -> bool:
|
|
|
52
53
|
"godot_mcp:input_sequence_result":
|
|
53
54
|
_handle_input_sequence_result(data)
|
|
54
55
|
return true
|
|
56
|
+
"godot_mcp:sequence_capture":
|
|
57
|
+
_handle_sequence_capture(data)
|
|
58
|
+
return true
|
|
55
59
|
"godot_mcp:type_text_result":
|
|
56
60
|
_handle_type_text_result(data)
|
|
57
61
|
return true
|
|
@@ -124,14 +128,14 @@ func is_bridge_ready() -> bool:
|
|
|
124
128
|
return _bridge_ready and has_active_session()
|
|
125
129
|
|
|
126
130
|
|
|
127
|
-
func request_screenshot(max_width: int = 1024
|
|
131
|
+
func request_screenshot(max_width: int = 1024) -> void:
|
|
128
132
|
if _active_session_id < 0:
|
|
129
133
|
screenshot_received.emit(false, "", 0, 0, "No active game session")
|
|
130
134
|
return
|
|
131
135
|
_pending_screenshot = true
|
|
132
136
|
var session := get_session(_active_session_id)
|
|
133
137
|
if session:
|
|
134
|
-
session.send_message("godot_mcp:take_screenshot", [max_width
|
|
138
|
+
session.send_message("godot_mcp:take_screenshot", [max_width])
|
|
135
139
|
else:
|
|
136
140
|
_pending_screenshot = false
|
|
137
141
|
screenshot_received.emit(false, "", 0, 0, "Could not get debugger session")
|
|
@@ -229,14 +233,14 @@ func _handle_input_map_result(data: Array) -> void:
|
|
|
229
233
|
input_map_received.emit(actions, error)
|
|
230
234
|
|
|
231
235
|
|
|
232
|
-
func request_input_sequence(inputs: Array, report: Array = []) -> void:
|
|
236
|
+
func request_input_sequence(inputs: Array, report: Array = [], screenshots: Array = [], screenshot_max_width: int = 640) -> void:
|
|
233
237
|
if _active_session_id < 0:
|
|
234
238
|
input_sequence_completed.emit({"error": "No active game session"})
|
|
235
239
|
return
|
|
236
240
|
_pending_input_sequence = true
|
|
237
241
|
var session := get_session(_active_session_id)
|
|
238
242
|
if session:
|
|
239
|
-
session.send_message("godot_mcp:execute_input_sequence", [inputs, report])
|
|
243
|
+
session.send_message("godot_mcp:execute_input_sequence", [inputs, report, screenshots, screenshot_max_width])
|
|
240
244
|
else:
|
|
241
245
|
_pending_input_sequence = false
|
|
242
246
|
input_sequence_completed.emit({"error": "Could not get debugger session"})
|
|
@@ -248,6 +252,20 @@ func _handle_input_sequence_result(data: Array) -> void:
|
|
|
248
252
|
input_sequence_completed.emit(result)
|
|
249
253
|
|
|
250
254
|
|
|
255
|
+
# A mid-sequence frame capture (#239) arriving on its own message. Re-emitted for
|
|
256
|
+
# the editor command to collect; the final input_sequence_result follows once the
|
|
257
|
+
# bridge has sent every requested frame.
|
|
258
|
+
func _handle_sequence_capture(data: Array) -> void:
|
|
259
|
+
var requested_ms: int = int(data[0]) if data.size() > 0 else 0
|
|
260
|
+
var actual_ms: int = int(data[1]) if data.size() > 1 else 0
|
|
261
|
+
var ok: bool = bool(data[2]) if data.size() > 2 else false
|
|
262
|
+
var base64: String = String(data[3]) if data.size() > 3 else ""
|
|
263
|
+
var width: int = int(data[4]) if data.size() > 4 else 0
|
|
264
|
+
var height: int = int(data[5]) if data.size() > 5 else 0
|
|
265
|
+
var error: String = String(data[6]) if data.size() > 6 else ""
|
|
266
|
+
sequence_capture_received.emit(requested_ms, actual_ms, ok, base64, width, height, error)
|
|
267
|
+
|
|
268
|
+
|
|
251
269
|
func request_type_text(text: String, delay_ms: int, submit: bool) -> void:
|
|
252
270
|
if _active_session_id < 0:
|
|
253
271
|
type_text_completed.emit({"error": "No active game session"})
|
|
@@ -2,7 +2,6 @@ extends Node
|
|
|
2
2
|
class_name MCPGameBridge
|
|
3
3
|
|
|
4
4
|
const DEFAULT_MAX_WIDTH := 1024
|
|
5
|
-
const DEFAULT_JPEG_QUALITY := 0.75
|
|
6
5
|
const Onscreen := preload("onscreen.gd")
|
|
7
6
|
|
|
8
7
|
# Cap on frames waited for the main scene to appear before announcing ready
|
|
@@ -119,8 +118,12 @@ func _sequence_process(delta: float) -> void:
|
|
|
119
118
|
if _sequence_draining:
|
|
120
119
|
if tree and not tree.paused:
|
|
121
120
|
_sequence_gameplay_ms += delta * 1000.0
|
|
122
|
-
_sequence_settle_remaining
|
|
123
|
-
|
|
121
|
+
if _sequence_settle_remaining > 0:
|
|
122
|
+
_sequence_settle_remaining -= 1
|
|
123
|
+
# Finalize only once the settle frames have elapsed (so an effect probe's
|
|
124
|
+
# `after` reflects the final input) AND every deferred frame capture has
|
|
125
|
+
# been sent back.
|
|
126
|
+
if _sequence_settle_remaining <= 0 and _sequence_captures_pending == 0:
|
|
124
127
|
_emit_sequence_result()
|
|
125
128
|
return
|
|
126
129
|
|
|
@@ -145,13 +148,28 @@ func _sequence_process(delta: float) -> void:
|
|
|
145
148
|
_held_actions.erase(seq_event.action)
|
|
146
149
|
_actions_completed += 1
|
|
147
150
|
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
# Trigger any frame captures whose offset has arrived (#239). Capture is
|
|
152
|
+
# deferred to frame_post_draw, so it completes a frame or two later; the
|
|
153
|
+
# pending count keeps the result from being sent until every frame is in.
|
|
154
|
+
while _sequence_capture_offsets.size() > 0 and int(_sequence_capture_offsets[0]) <= elapsed:
|
|
155
|
+
var off: int = int(_sequence_capture_offsets.pop_front())
|
|
156
|
+
_sequence_captures_pending += 1
|
|
157
|
+
_capture_sequence_frame.call_deferred(off)
|
|
158
|
+
|
|
159
|
+
# Done when both the input timeline and the capture schedule are exhausted;
|
|
160
|
+
# captures scheduled past the last input keep the window open until their
|
|
161
|
+
# offsets arrive.
|
|
162
|
+
if _sequence_events.is_empty() and _sequence_capture_offsets.is_empty():
|
|
163
|
+
if not _sequence_report.is_empty():
|
|
164
|
+
# Defer so the effect probe's `after` reflects the final input.
|
|
165
|
+
_sequence_draining = true
|
|
166
|
+
_sequence_settle_remaining = SEQUENCE_SETTLE_FRAMES
|
|
167
|
+
elif _sequence_captures_pending == 0:
|
|
150
168
|
_emit_sequence_result()
|
|
151
169
|
else:
|
|
152
|
-
#
|
|
170
|
+
# No probe, but captures are still resolving — wait for them.
|
|
153
171
|
_sequence_draining = true
|
|
154
|
-
_sequence_settle_remaining =
|
|
172
|
+
_sequence_settle_remaining = 0
|
|
155
173
|
|
|
156
174
|
|
|
157
175
|
# Assemble and send the input-sequence result, then reset probe state. Carries an
|
|
@@ -203,6 +221,42 @@ func _compute_report_deltas(before: Dictionary, after: Dictionary) -> Dictionary
|
|
|
203
221
|
return {"report": deltas, "any_changed": any_changed}
|
|
204
222
|
|
|
205
223
|
|
|
224
|
+
# Capture one frame mid-sequence (#239) and stream it back on its own message.
|
|
225
|
+
# Deferred from _sequence_process to frame_post_draw so it reads the rendered
|
|
226
|
+
# frame nearest the requested offset; the actual elapsed offset is reported
|
|
227
|
+
# alongside so the agent knows exactly when each frame landed. Each capture rides
|
|
228
|
+
# its own message, and the count gates the result.
|
|
229
|
+
#
|
|
230
|
+
# Encoded as lossless PNG, deliberately not JPEG: vision-token cost is a function
|
|
231
|
+
# of resolution (≈ width*height/750), not of file size or codec, so JPEG would
|
|
232
|
+
# only add compression artifacts for zero token saving. The token lever is
|
|
233
|
+
# _sequence_capture_max_width (resolution); PNG just costs more transport bytes.
|
|
234
|
+
func _capture_sequence_frame(requested_offset_ms: int) -> void:
|
|
235
|
+
await RenderingServer.frame_post_draw
|
|
236
|
+
var actual_ms := Time.get_ticks_msec() - _sequence_start_time
|
|
237
|
+
var viewport := get_viewport()
|
|
238
|
+
if viewport == null:
|
|
239
|
+
_send_sequence_capture(requested_offset_ms, actual_ms, false, "", 0, 0, "NO_VIEWPORT: could not get game viewport")
|
|
240
|
+
return
|
|
241
|
+
var image := viewport.get_texture().get_image()
|
|
242
|
+
if image == null:
|
|
243
|
+
_send_sequence_capture(requested_offset_ms, actual_ms, false, "", 0, 0, "CAPTURE_FAILED: could not read viewport image")
|
|
244
|
+
return
|
|
245
|
+
if _sequence_capture_max_width > 0 and image.get_width() > _sequence_capture_max_width:
|
|
246
|
+
var scale_factor := float(_sequence_capture_max_width) / float(image.get_width())
|
|
247
|
+
image.resize(_sequence_capture_max_width, int(image.get_height() * scale_factor), Image.INTERPOLATE_LANCZOS)
|
|
248
|
+
var png_buffer := image.save_png_to_buffer()
|
|
249
|
+
var base64 := Marshalls.raw_to_base64(png_buffer)
|
|
250
|
+
_send_sequence_capture(requested_offset_ms, actual_ms, true, base64, image.get_width(), image.get_height(), "")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
func _send_sequence_capture(requested_ms: int, actual_ms: int, ok: bool, base64: String, width: int, height: int, error: String) -> void:
|
|
254
|
+
# Decrement first: the result is gated on this reaching zero, and a capture
|
|
255
|
+
# that errors must still release its slot or the sequence would never finish.
|
|
256
|
+
_sequence_captures_pending = maxi(0, _sequence_captures_pending - 1)
|
|
257
|
+
EngineDebugger.send_message("godot_mcp:sequence_capture", [requested_ms, actual_ms, ok, base64, width, height, error])
|
|
258
|
+
|
|
259
|
+
|
|
206
260
|
var _sequence_events: Array = []
|
|
207
261
|
var _sequence_start_time: int = 0
|
|
208
262
|
var _sequence_running: bool = false
|
|
@@ -222,6 +276,20 @@ const SEQUENCE_SETTLE_FRAMES := 2
|
|
|
222
276
|
var _sequence_report: Array = []
|
|
223
277
|
var _sequence_report_inputs: Array = []
|
|
224
278
|
var _sequence_report_before: Dictionary = {}
|
|
279
|
+
# Mid-sequence frame capture (#239): offsets (ms from start, sorted) still to be
|
|
280
|
+
# captured during the real-time run, the capture params, and the count of
|
|
281
|
+
# deferred captures not yet sent — the result is held until this reaches zero.
|
|
282
|
+
var _sequence_capture_offsets: Array = []
|
|
283
|
+
var _sequence_captures_pending: int = 0
|
|
284
|
+
var _sequence_capture_max_width: int = 640
|
|
285
|
+
const SEQUENCE_MAX_CAPTURES := 8
|
|
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
|
|
225
293
|
# Actions whose press has been injected but whose paired release has not yet
|
|
226
294
|
# fired. Used to guarantee a release even if the queue is cleared mid-flight
|
|
227
295
|
# (new sequence) or the node leaves the tree — otherwise the dropped release
|
|
@@ -311,12 +379,14 @@ func _on_debugger_message(message: String, data: Array) -> bool:
|
|
|
311
379
|
|
|
312
380
|
func _take_screenshot_deferred(data: Array) -> void:
|
|
313
381
|
var max_width: int = data[0] if data.size() > 0 else DEFAULT_MAX_WIDTH
|
|
314
|
-
var quality: float = data[1] if data.size() > 1 else DEFAULT_JPEG_QUALITY
|
|
315
382
|
await RenderingServer.frame_post_draw
|
|
316
|
-
_capture_and_send_screenshot(max_width
|
|
383
|
+
_capture_and_send_screenshot(max_width)
|
|
317
384
|
|
|
318
385
|
|
|
319
|
-
|
|
386
|
+
# Lossless PNG, not JPEG: image vision-token cost scales with resolution, not
|
|
387
|
+
# codec, so JPEG only traded fidelity (compression artifacts) for nothing. Width
|
|
388
|
+
# is downscaled to max_width to bound that resolution-driven cost.
|
|
389
|
+
func _capture_and_send_screenshot(max_width: int) -> void:
|
|
320
390
|
var viewport := get_viewport()
|
|
321
391
|
if viewport == null:
|
|
322
392
|
_send_screenshot_error("NO_VIEWPORT", "Could not get game viewport")
|
|
@@ -329,8 +399,8 @@ func _capture_and_send_screenshot(max_width: int, quality: float = DEFAULT_JPEG_
|
|
|
329
399
|
var scale_factor := float(max_width) / float(image.get_width())
|
|
330
400
|
var new_height := int(image.get_height() * scale_factor)
|
|
331
401
|
image.resize(max_width, new_height, Image.INTERPOLATE_LANCZOS)
|
|
332
|
-
var
|
|
333
|
-
var base64 := Marshalls.raw_to_base64(
|
|
402
|
+
var png_buffer := image.save_png_to_buffer()
|
|
403
|
+
var base64 := Marshalls.raw_to_base64(png_buffer)
|
|
334
404
|
EngineDebugger.send_message("godot_mcp:screenshot_result", [
|
|
335
405
|
true,
|
|
336
406
|
base64,
|
|
@@ -1084,6 +1154,8 @@ func _event_to_string(event: InputEvent) -> String:
|
|
|
1084
1154
|
func _handle_execute_input_sequence(data: Array) -> void:
|
|
1085
1155
|
var inputs: Array = data[0] if data.size() > 0 else []
|
|
1086
1156
|
var report: Array = data[1] if data.size() > 1 and data[1] is Array else []
|
|
1157
|
+
var screenshot_offsets: Array = data[2] if data.size() > 2 and data[2] is Array else []
|
|
1158
|
+
var cap_max_width: int = int(data[3]) if data.size() > 3 else 640
|
|
1087
1159
|
|
|
1088
1160
|
if inputs.is_empty():
|
|
1089
1161
|
EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
|
|
@@ -1091,6 +1163,15 @@ func _handle_execute_input_sequence(data: Array) -> void:
|
|
|
1091
1163
|
}])
|
|
1092
1164
|
return
|
|
1093
1165
|
|
|
1166
|
+
# Normalize the optional frame-capture schedule (#239): clamp each offset,
|
|
1167
|
+
# cap the count, and sort so _sequence_process can pop them in order.
|
|
1168
|
+
var capture_offsets: Array = []
|
|
1169
|
+
for o in screenshot_offsets:
|
|
1170
|
+
if capture_offsets.size() >= SEQUENCE_MAX_CAPTURES:
|
|
1171
|
+
break
|
|
1172
|
+
capture_offsets.append(clampi(int(o), 0, SEQUENCE_MAX_CAPTURE_OFFSET_MS))
|
|
1173
|
+
capture_offsets.sort()
|
|
1174
|
+
|
|
1094
1175
|
# Compile the optional effect probe up front, before touching any input state,
|
|
1095
1176
|
# so a bad expression rejects the call cleanly (same contract as step_until's
|
|
1096
1177
|
# report). Reuses the predicate context: autoloads by name, plus `tree`/`root`.
|
|
@@ -1117,12 +1198,15 @@ func _handle_execute_input_sequence(data: Array) -> void:
|
|
|
1117
1198
|
_sequence_gameplay_ms = 0.0
|
|
1118
1199
|
_sequence_draining = false
|
|
1119
1200
|
_sequence_settle_remaining = 0
|
|
1120
|
-
# Clear probe state up front so an early return below (unknown
|
|
1121
|
-
# leave a stale report
|
|
1122
|
-
# re-armed
|
|
1201
|
+
# Clear probe and capture state up front so an early return below (unknown
|
|
1202
|
+
# action) cannot leave a stale report or capture schedule to be acted on
|
|
1203
|
+
# against an interrupted window. Both are re-armed once the timeline validates.
|
|
1123
1204
|
_sequence_report = []
|
|
1124
1205
|
_sequence_report_inputs = []
|
|
1125
1206
|
_sequence_report_before = {}
|
|
1207
|
+
_sequence_capture_offsets = []
|
|
1208
|
+
_sequence_captures_pending = 0
|
|
1209
|
+
_sequence_capture_max_width = cap_max_width
|
|
1126
1210
|
|
|
1127
1211
|
for input in inputs:
|
|
1128
1212
|
var action_name: String = input.get("action_name", "")
|
|
@@ -1158,6 +1242,9 @@ func _handle_execute_input_sequence(data: Array) -> void:
|
|
|
1158
1242
|
_sequence_report_inputs = report_inputs
|
|
1159
1243
|
_sequence_report_before = _evaluate_report(report_compiled, report_inputs) if not report_compiled.is_empty() else {}
|
|
1160
1244
|
|
|
1245
|
+
# Arm the capture schedule (validated and sorted above).
|
|
1246
|
+
_sequence_capture_offsets = capture_offsets
|
|
1247
|
+
|
|
1161
1248
|
_sequence_start_time = Time.get_ticks_msec()
|
|
1162
1249
|
_sequence_running = true
|
|
1163
1250
|
_update_processing()
|
|
@@ -1247,10 +1334,15 @@ func _type_text_async(text: String, delay_ms: int, submit: bool) -> void:
|
|
|
1247
1334
|
# ---------------------------------------------------------------------------
|
|
1248
1335
|
|
|
1249
1336
|
const LAUNCH_FROZEN_ENV := "GODOT_MCP_LAUNCH_FROZEN"
|
|
1250
|
-
# Timeout cascade:
|
|
1251
|
-
#
|
|
1252
|
-
#
|
|
1253
|
-
|
|
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
|
|
1254
1346
|
const STEP_MAX_FRAMES := 1200
|
|
1255
1347
|
const STEP_WALL_BUDGET_MS := 25000
|
|
1256
1348
|
const STEP_MAX_TRANSITIONS := 50
|
|
@@ -1273,6 +1365,7 @@ var _step_gameplay_ms := 0.0 # the unpaused portion: what gameplay actually exp
|
|
|
1273
1365
|
var _step_frames := 0
|
|
1274
1366
|
var _step_physics_ticks := 0
|
|
1275
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)
|
|
1276
1369
|
var _step_events: Array = [] # in-step input timeline, scheduled on the game-time clock
|
|
1277
1370
|
var _step_events_fired := 0
|
|
1278
1371
|
var _step_transitions: Array = []
|
|
@@ -1430,6 +1523,7 @@ func _handle_game_time_step(data: Array) -> void:
|
|
|
1430
1523
|
_step_finish_pending = false
|
|
1431
1524
|
_step_wall_exceeded = false
|
|
1432
1525
|
_step_wall_start = Time.get_ticks_msec()
|
|
1526
|
+
_step_wall_budget_ms = int(params.get("wall_budget_ms", STEP_WALL_BUDGET_MS))
|
|
1433
1527
|
_step_predicate = null
|
|
1434
1528
|
_step_response_type = "game_time_step"
|
|
1435
1529
|
_step_active = true
|
|
@@ -1546,9 +1640,9 @@ func _handle_game_time_step_until(data: Array) -> void:
|
|
|
1546
1640
|
_send_game_time_response("game_time_step_until", {"error": "step_until requires a non-empty `until` expression"})
|
|
1547
1641
|
return
|
|
1548
1642
|
|
|
1549
|
-
var max_ms: int = int(params.get("max_ms",
|
|
1643
|
+
var max_ms: int = int(params.get("max_ms", STEP_DEFAULT_MS))
|
|
1550
1644
|
if max_ms <= 0:
|
|
1551
|
-
max_ms =
|
|
1645
|
+
max_ms = STEP_DEFAULT_MS
|
|
1552
1646
|
max_ms = mini(max_ms, STEP_MAX_MS)
|
|
1553
1647
|
|
|
1554
1648
|
# Compile and validate the predicate against the live tree before committing
|
|
@@ -1611,6 +1705,7 @@ func _handle_game_time_step_until(data: Array) -> void:
|
|
|
1611
1705
|
_step_finish_pending = false
|
|
1612
1706
|
_step_wall_exceeded = false
|
|
1613
1707
|
_step_wall_start = Time.get_ticks_msec()
|
|
1708
|
+
_step_wall_budget_ms = int(params.get("wall_budget_ms", STEP_WALL_BUDGET_MS))
|
|
1614
1709
|
_step_predicate = expr
|
|
1615
1710
|
_step_predicate_inputs = ctx_inputs
|
|
1616
1711
|
_step_predicate_met = false
|
|
@@ -1682,7 +1777,7 @@ func _step_process(delta: float) -> void:
|
|
|
1682
1777
|
_step_predicate_met = true
|
|
1683
1778
|
done = true
|
|
1684
1779
|
|
|
1685
|
-
if Time.get_ticks_msec() - _step_wall_start >
|
|
1780
|
+
if Time.get_ticks_msec() - _step_wall_start > _step_wall_budget_ms:
|
|
1686
1781
|
# Slow-mo, Engine.time_scale = 0, or a pause-held window can starve
|
|
1687
1782
|
# the game-time clock; the wall budget guarantees the call returns
|
|
1688
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":""}
|