1296 lines
51 KiB
GDScript
Vendored
1296 lines
51 KiB
GDScript
Vendored
extends Node
|
|
|
|
## Godot AI MCP — game-process helper.
|
|
##
|
|
## Registered as an autoload by plugin.gd when the Godot AI plugin is enabled.
|
|
## Runs in the running game process (separate from the editor) so the plugin
|
|
## can request the game's framebuffer over the editor-debugger channel.
|
|
##
|
|
## The editor never has direct access to the game's pixels: even when "Embed
|
|
## Game Mode" is on, the game is still a separate OS child process whose
|
|
## window is reparented into the editor via Win32 SetParent / X11
|
|
## XReparentWindow / macOS remote layer (Godot PR godotengine/godot#99010).
|
|
## So viewport-texture capture on the editor side never contains game pixels.
|
|
## This autoload solves that by replying to "mcp:take_screenshot" debug
|
|
## messages with a PNG of Viewport.get_texture() from inside the game.
|
|
##
|
|
## No-ops in the editor (Engine.is_editor_hint) and silently sits idle
|
|
## when the debugger channel is inactive (e.g. exported release builds)
|
|
## — register_message_capture is safe to call either way, it's
|
|
## send_message that requires an active channel.
|
|
|
|
const CAPTURE_PREFIX := "mcp"
|
|
## Cap per-frame flush so a runaway print loop can't blow the debugger's
|
|
## packet budget in a single send. Surplus stays queued for the next frame.
|
|
const FLUSH_BATCH_LIMIT := 200
|
|
## How long take_screenshot waits for the game's first real presentation
|
|
## before reading the viewport texture back. The "mcp" capture registers in
|
|
## this autoload's _ready(), which runs BEFORE the main scene enters the tree
|
|
## and before the renderer has presented anything — so a request arriving
|
|
## right after mcp:hello would otherwise read back the clear-color
|
|
## framebuffer (observed as a uniform RGB(77,77,77) PNG on GitHub's
|
|
## GPU-less paravirtualized macOS runners, where the first present lags
|
|
## seconds behind boot). MUST stay below the editor-side reply timer
|
|
## (DEFAULT_TIMEOUT_SEC = 8.0 in debugger/mcp_debugger_plugin.gd) so a
|
|
## game that genuinely can't render falls through to the existing
|
|
## texture/image error replies before the editor gives up with its
|
|
## generic timeout.
|
|
const FIRST_FRAME_WAIT_SEC := 6.0
|
|
## #777: how long the main loop can go without ticking _process before
|
|
## _handle_take_screenshot treats it as frozen and commits a synchronous
|
|
## stale-frame capture instead of awaiting frames that will never come.
|
|
## A backgrounded/minimized play-in-editor game stops iterating its main
|
|
## loop entirely, so any real threshold works; 1s keeps a merely-slow game
|
|
## (heavy frame, low FPS) on the fresh-frame await path.
|
|
const MAIN_LOOP_STALL_MSEC := 1000
|
|
## How long frames_drawn can stay flat before _handle_take_screenshot treats
|
|
## rendering as suppressed and commits the synchronous stale-frame capture.
|
|
## On Windows, minimizing the game window freezes frame presentation but NOT
|
|
## the main loop — _process keeps ticking, so the MAIN_LOOP_STALL_MSEC beacon
|
|
## never trips and every capture used to burn the full FIRST_FRAME_WAIT_SEC
|
|
## await before replying stale (issue #794 smoke, item 1b). Larger than the
|
|
## loop threshold so a heavy-but-rendering game (~1 FPS frame gaps) stays on
|
|
## the fresh-frame await path; a sub-0.7 FPS game that trips this still gets
|
|
## an honestly stale-flagged image immediately instead of a 6s wait.
|
|
const RENDER_STALL_MSEC := 1500
|
|
|
|
const GameLogger := preload("res://addons/godot_ai/runtime/game_logger.gd")
|
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
|
## Shared with the editor-side copy in editor_handler.gd (#716). Preload by
|
|
## path, not class_name: this autoload runs in the game process and must not
|
|
## depend on the editor's global-class cache being warm.
|
|
const ScreenshotEncode := preload("res://addons/godot_ai/utils/screenshot_encode.gd")
|
|
|
|
var _registered := false
|
|
## Captures game-process print, warning, and error output for the editor.
|
|
var _logger: Logger
|
|
var _logger_attached := false
|
|
## Entries drained from the logger but not yet sent over the debugger
|
|
## channel. Holds the tail of one drain() so we can bleed it out across
|
|
## frames at FLUSH_BATCH_LIMIT per frame rather than blasting the whole
|
|
## queue in a single _process tick.
|
|
var _pending_outbound: Array = []
|
|
## #490: in-flight evals, keyed by request_id (multiple deferred game_evals
|
|
## can run at once). Each entry: {node:Node, token:String, baseline:int}.
|
|
## `token` names this eval's unique wrapper function so a runtime error is
|
|
## attributed only to the eval that actually raised it — not an unrelated
|
|
## background game error, and not a sibling overlapping eval. `baseline` is the
|
|
## logger's script-error seq just before this eval ran. The editor's eval_check
|
|
## probe (and #488's in-flight poll loop, when the game is focused) consult
|
|
## these to report a runtime error that aborted execute() before the reply.
|
|
var _inflight_evals: Dictionary = {}
|
|
var _eval_token_counter: int = 0
|
|
## #777: last time _process ran, in ticks msec. The debugger message capture
|
|
## stays live while a backgrounded game's main loop is frozen, so this is how
|
|
## _handle_take_screenshot (running inside that capture) detects the freeze
|
|
## synchronously. -1 until the first tick.
|
|
var _last_loop_tick_msec: int = -1
|
|
## Rendering-freeze beacon for the Windows-minimize state (#794 smoke, 1b):
|
|
## the frames_drawn value last observed in _process, and when it last
|
|
## advanced. -1 until the first observed advance, so a booting or
|
|
## render-less game (frames_drawn stuck at 0) can never read as
|
|
## render-stalled and keeps the fresh-frame await path's error replies.
|
|
var _last_frames_drawn_seen: int = -1
|
|
var _last_frames_advance_msec: int = -1
|
|
|
|
|
|
func _ready() -> void:
|
|
## Only run in the game process, not in the editor. Use is_editor_hint
|
|
## — NOT OS.has_feature("editor"), which is a BUILD-config check
|
|
## (TOOLS_ENABLED) and returns true in the game subprocess too because
|
|
## the game is spawned with the same editor binary. is_editor_hint is
|
|
## the runtime-context check: true only inside the editor GUI, false
|
|
## in play-from-editor. The earlier has_feature check was causing us
|
|
## to skip registration in the game and time out every capture.
|
|
if Engine.is_editor_hint():
|
|
return
|
|
## Keep ticking while the tree is paused: _process both ferries game logs
|
|
## and timestamps main-loop liveness for the stalled-loop screenshot
|
|
## fallback (#777). A paused game still iterates its loop and renders, and
|
|
## must not be misread as frozen.
|
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
## register_message_capture is safe to call before the debugger
|
|
## handshake completes; the capture sits until a message arrives.
|
|
EngineDebugger.register_message_capture(CAPTURE_PREFIX, _on_debug_message)
|
|
_registered = true
|
|
## Capture print() / printerr() / push_error() / push_warning() and
|
|
## ferry them to the editor in mcp:log_batch messages flushed from
|
|
## _process.
|
|
_logger = GameLogger.new()
|
|
OS.add_logger(_logger)
|
|
_logger_attached = true
|
|
## Routed to the editor's Output panel via Godot's remote-stdout
|
|
## forwarder — handy when diagnosing why capture timed out.
|
|
print("[godot_ai game_helper] registered mcp capture (debugger active=%s, logger=%s)"
|
|
% [EngineDebugger.is_active(), _logger_attached])
|
|
## Boot beacon so the editor side can confirm the autoload ran even
|
|
## if no screenshot was ever requested.
|
|
if EngineDebugger.is_active():
|
|
EngineDebugger.send_message("mcp:hello", [])
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
## #777: liveness beacon for _handle_take_screenshot's stalled-loop check.
|
|
## Recorded before the early returns below so the signal stays truthful
|
|
## even when the logger or debugger channel is unavailable.
|
|
_last_loop_tick_msec = Time.get_ticks_msec()
|
|
## Rendering beacon: on Windows a minimized game keeps ticking _process
|
|
## while presentation stops, so frames_drawn stagnation — not loop
|
|
## silence — is the observable freeze signal there (#794 smoke, 1b).
|
|
var frames_now := Engine.get_frames_drawn()
|
|
if frames_now != _last_frames_drawn_seen:
|
|
_last_frames_drawn_seen = frames_now
|
|
_last_frames_advance_msec = _last_loop_tick_msec
|
|
## Drain the logger queue on the main thread (Logger virtuals can fire
|
|
## from any thread; EngineDebugger.send_message is only safe from main).
|
|
## Send at most one FLUSH_BATCH_LIMIT-sized batch per frame so a runaway
|
|
## print loop can't stall the game by shoving thousands of entries
|
|
## through the debugger packet path in a single tick. Surplus stays in
|
|
## `_pending_outbound` and bleeds out across subsequent frames.
|
|
if not _logger_attached or _logger == null:
|
|
return
|
|
if not EngineDebugger.is_active():
|
|
return
|
|
if _pending_outbound.is_empty():
|
|
if not _logger.has_pending():
|
|
return
|
|
_pending_outbound = _logger.drain()
|
|
var batch := _pending_outbound.slice(0, FLUSH_BATCH_LIMIT)
|
|
_pending_outbound = _pending_outbound.slice(FLUSH_BATCH_LIMIT)
|
|
EngineDebugger.send_message("mcp:log_batch", [batch])
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
if _registered:
|
|
EngineDebugger.unregister_message_capture(CAPTURE_PREFIX)
|
|
_registered = false
|
|
if _logger_attached and _logger != null:
|
|
OS.remove_logger(_logger)
|
|
_logger_attached = false
|
|
_logger = null
|
|
|
|
|
|
## Dispatched for messages prefixed "mcp:" on the debugger channel.
|
|
## Godot passes the full message ("mcp:take_screenshot") to the capture
|
|
## callable; trim defensively so tests can still call the helper with either
|
|
## form.
|
|
func _on_debug_message(message: String, data: Array) -> bool:
|
|
var action := message.trim_prefix("mcp:")
|
|
match action:
|
|
"take_screenshot":
|
|
_handle_take_screenshot(data)
|
|
return true
|
|
"eval":
|
|
_handle_eval(data)
|
|
return true
|
|
"eval_check":
|
|
_handle_eval_check(data)
|
|
return true
|
|
"game_command":
|
|
_handle_game_command(data)
|
|
return true
|
|
return false
|
|
|
|
|
|
func _handle_take_screenshot(data: Array) -> void:
|
|
var request_id: String = data[0] if data.size() > 0 else ""
|
|
var max_resolution: int = int(data[1]) if data.size() > 1 else 0
|
|
|
|
var tree := get_tree()
|
|
var viewport := tree.root if tree != null else null
|
|
if viewport == null:
|
|
_reply_error(request_id, "No game root viewport available")
|
|
return
|
|
|
|
## #777: this function runs inside the debugger message capture, which
|
|
## stays live even when a backgrounded/minimized play-in-editor game has
|
|
## frozen its main loop. In that state awaiting `process_frame` parks
|
|
## this coroutine forever — no reply is ever sent, and the game side
|
|
## cannot self-timeout because timers need the same frozen loop. Commit a
|
|
## synchronous capture of the last rendered frame instead: stale, but a
|
|
## real image, flagged as such in the reply. Only fall through to the
|
|
## fresh-frame awaits when the loop is demonstrably alive.
|
|
if _should_capture_stale_sync(
|
|
_main_loop_appears_stalled(),
|
|
_rendering_appears_stalled(),
|
|
tree.current_scene != null,
|
|
Engine.get_frames_drawn()
|
|
):
|
|
_capture_and_reply(request_id, viewport, max_resolution, Engine.get_frames_drawn())
|
|
return
|
|
|
|
## Wait (bounded — see FIRST_FRAME_WAIT_SEC) until the main scene is in
|
|
## the tree and at least one frame has been drawn after this request, so
|
|
## the readback never precedes the first real present. Past the deadline,
|
|
## fall through anyway: current_scene stays null under a custom main
|
|
## loop, and frames_drawn never advances in a render-less game — both
|
|
## are handled by the texture/image error replies below.
|
|
var deadline := Time.get_ticks_msec() + int(FIRST_FRAME_WAIT_SEC * 1000.0)
|
|
while tree.current_scene == null and Time.get_ticks_msec() < deadline:
|
|
await tree.process_frame
|
|
var frames_at_request := Engine.get_frames_drawn()
|
|
while Engine.get_frames_drawn() <= frames_at_request and Time.get_ticks_msec() < deadline:
|
|
await tree.process_frame
|
|
|
|
_capture_and_reply(request_id, viewport, max_resolution, frames_at_request)
|
|
|
|
|
|
## #777: pure decision for the synchronous stale-frame path. Sync capture is
|
|
## only worth committing when awaiting can't produce a fresh frame — the main
|
|
## loop is frozen (macOS/suspend), or the loop still ticks but presentation
|
|
## is suppressed (Windows minimize, #794 smoke 1b) — AND the viewport
|
|
## plausibly holds a real frame: the main scene is in the tree and at least
|
|
## one frame was presented. Without those, the stale readback would be the
|
|
## boot clear-color framebuffer — worse than the honest timeout.
|
|
static func _should_capture_stale_sync(
|
|
loop_stalled: bool, render_stalled: bool, has_current_scene: bool, frames_drawn: int
|
|
) -> bool:
|
|
return (loop_stalled or render_stalled) and has_current_scene and frames_drawn > 0
|
|
|
|
|
|
## #777: true when _process hasn't ticked within MAIN_LOOP_STALL_MSEC —
|
|
## i.e. the main loop is frozen (backgrounded window) or has never run.
|
|
func _main_loop_appears_stalled() -> bool:
|
|
if _last_loop_tick_msec < 0:
|
|
return true
|
|
return Time.get_ticks_msec() - _last_loop_tick_msec > MAIN_LOOP_STALL_MSEC
|
|
|
|
|
|
## True when frames_drawn has sat flat past RENDER_STALL_MSEC while _process
|
|
## kept ticking — Windows minimize suppresses presentation without freezing
|
|
## the loop, so the loop beacon alone misses it (#794 smoke, 1b). False until
|
|
## the first observed frame advance: a game that has never presented has no
|
|
## trustworthy frame to return, and must fall through to the await path's
|
|
## texture/image error replies instead.
|
|
func _rendering_appears_stalled() -> bool:
|
|
if _last_frames_advance_msec < 0:
|
|
return false
|
|
return Time.get_ticks_msec() - _last_frames_advance_msec > RENDER_STALL_MSEC
|
|
|
|
|
|
## Read back the viewport texture and reply — fully synchronous, so it is
|
|
## safe to call from the debugger capture while the main loop is frozen.
|
|
## `frames_at_request` is Engine.get_frames_drawn() at request receipt: if no
|
|
## further frame was drawn by capture time, the image predates the request
|
|
## and the reply is flagged stale.
|
|
func _capture_and_reply(
|
|
request_id: String, viewport: Viewport, max_resolution: int, frames_at_request: int
|
|
) -> void:
|
|
var texture := viewport.get_texture()
|
|
if texture == null:
|
|
_reply_error(request_id, "Root viewport has no texture (headless?)")
|
|
return
|
|
|
|
var image := texture.get_image()
|
|
if image == null or image.is_empty():
|
|
_reply_error(request_id, "Captured an empty image from game viewport")
|
|
return
|
|
|
|
var encoded: Dictionary = ScreenshotEncode.downscale_and_encode(image, max_resolution)
|
|
var frames_drawn := Engine.get_frames_drawn()
|
|
var stale := frames_drawn <= frames_at_request
|
|
|
|
_last_screenshot_reply = {
|
|
"kind": "response",
|
|
"request_id": request_id,
|
|
"frames_drawn": frames_drawn,
|
|
"stale": stale,
|
|
"width": encoded.width,
|
|
"height": encoded.height,
|
|
}
|
|
if EngineDebugger.is_active():
|
|
## Fields 7+8 are new in #777; older editors read the first six and
|
|
## ignore the rest.
|
|
EngineDebugger.send_message("mcp:screenshot_response", [
|
|
request_id,
|
|
encoded.base64,
|
|
encoded.width,
|
|
encoded.height,
|
|
encoded.original_width,
|
|
encoded.original_height,
|
|
frames_drawn,
|
|
stale,
|
|
])
|
|
|
|
|
|
## Testing seam: the last screenshot reply (response or error), recorded
|
|
## before hitting the EngineDebugger channel (inactive in the editor-side
|
|
## test harness). Mirrors _last_eval_reply.
|
|
var _last_screenshot_reply: Dictionary = {}
|
|
|
|
|
|
func _reply_error(request_id: String, message: String) -> void:
|
|
_last_screenshot_reply = {"kind": "error", "request_id": request_id, "message": message}
|
|
if EngineDebugger.is_active():
|
|
EngineDebugger.send_message("mcp:screenshot_error", [request_id, message])
|
|
|
|
|
|
## --- game_command: curated runtime inspection and input ---
|
|
|
|
func _handle_game_command(data: Array) -> void:
|
|
var request_id: String = data[0] if data.size() > 0 else ""
|
|
var op: String = data[1] if data.size() > 1 else ""
|
|
var params_json: String = data[2] if data.size() > 2 else "{}"
|
|
|
|
if request_id.is_empty():
|
|
return
|
|
if op.is_empty():
|
|
_reply_game_command_error(request_id, "No op provided")
|
|
return
|
|
|
|
var json := JSON.new()
|
|
var parse_err := json.parse(params_json)
|
|
if parse_err != OK or not (json.data is Dictionary):
|
|
_reply_game_command_error(request_id, "Invalid params JSON")
|
|
return
|
|
|
|
var result: Dictionary
|
|
match op:
|
|
"get_scene_tree":
|
|
result = _game_get_scene_tree(json.data)
|
|
"get_node_info":
|
|
result = _game_get_node_info(json.data)
|
|
"get_ui_elements":
|
|
result = _game_get_ui_elements(json.data)
|
|
"input_key":
|
|
result = _game_input_key(json.data)
|
|
"input_mouse":
|
|
result = _game_input_mouse(json.data)
|
|
"input_gamepad":
|
|
result = _game_input_gamepad(json.data)
|
|
"input_action":
|
|
result = _game_input_action(json.data)
|
|
"input_state":
|
|
result = _game_input_state(json.data)
|
|
"input_sequence":
|
|
## Async: steps frames and replies itself (deferred), so bail out
|
|
## before the synchronous send below — same shape as the eval and
|
|
## screenshot capture paths.
|
|
_run_input_sequence(request_id, json.data)
|
|
return
|
|
_:
|
|
_reply_game_command_error(request_id, "Unknown game op: %s" % op)
|
|
return
|
|
|
|
result["source"] = "game"
|
|
result["op"] = op
|
|
EngineDebugger.send_message("mcp:game_command_response",
|
|
[request_id, JSON.stringify(_variant_to_json(result))])
|
|
|
|
|
|
func _reply_game_command_error(request_id: String, message: String) -> void:
|
|
EngineDebugger.send_message("mcp:game_command_error", [request_id, message])
|
|
|
|
|
|
func _game_get_scene_tree(params: Dictionary) -> Dictionary:
|
|
var depth := maxi(0, int(params.get("depth", 10)))
|
|
var root := _resolve_runtime_node(str(params.get("root_path", "")))
|
|
if root == null:
|
|
return {"root": "", "nodes": [], "total_count": 0, "not_found": params.get("root_path", "")}
|
|
|
|
var nodes: Array[Dictionary] = []
|
|
_collect_runtime_nodes(root, 0, depth, nodes)
|
|
return {
|
|
"root": _runtime_path(root),
|
|
"nodes": nodes,
|
|
"total_count": nodes.size(),
|
|
}
|
|
|
|
|
|
func _collect_runtime_nodes(node: Node, current_depth: int, max_depth: int, out: Array[Dictionary]) -> void:
|
|
out.append({
|
|
"name": node.name,
|
|
"type": node.get_class(),
|
|
"path": _runtime_path(node),
|
|
"children_count": node.get_child_count(),
|
|
})
|
|
if current_depth >= max_depth:
|
|
return
|
|
for child in node.get_children():
|
|
if child is Node:
|
|
_collect_runtime_nodes(child, current_depth + 1, max_depth, out)
|
|
|
|
|
|
func _game_get_node_info(params: Dictionary) -> Dictionary:
|
|
var path := str(params.get("path", ""))
|
|
var node := _resolve_runtime_node(path)
|
|
if node == null:
|
|
return {"path": path, "found": false}
|
|
|
|
var info := {
|
|
"path": _runtime_path(node),
|
|
"name": node.name,
|
|
"type": node.get_class(),
|
|
"children_count": node.get_child_count(),
|
|
"groups": node.get_groups(),
|
|
"found": true,
|
|
}
|
|
if bool(params.get("include_properties", true)):
|
|
info["properties"] = _runtime_node_properties(node)
|
|
return info
|
|
|
|
|
|
func _game_get_ui_elements(params: Dictionary) -> Dictionary:
|
|
var max_depth := maxi(0, int(params.get("max_depth", 10)))
|
|
var include_hidden := bool(params.get("include_hidden", false))
|
|
var include_disabled := bool(params.get("include_disabled", true))
|
|
var root_path := str(params.get("root_path", ""))
|
|
var root := _resolve_runtime_node(root_path)
|
|
if root == null:
|
|
return {"root": "", "elements": [], "total_count": 0, "not_found": root_path}
|
|
|
|
var elements: Array[Dictionary] = []
|
|
_collect_ui_elements(root, 0, max_depth, include_hidden, include_disabled, elements)
|
|
return {
|
|
"root": _runtime_path(root),
|
|
"elements": elements,
|
|
"total_count": elements.size(),
|
|
}
|
|
|
|
|
|
func _collect_ui_elements(
|
|
node: Node,
|
|
current_depth: int,
|
|
max_depth: int,
|
|
include_hidden: bool,
|
|
include_disabled: bool,
|
|
out: Array[Dictionary]
|
|
) -> void:
|
|
if node is Control:
|
|
var control := node as Control
|
|
var visible := _control_visible_in_tree(control)
|
|
var disabled := _control_disabled(control)
|
|
if (include_hidden or visible) and (include_disabled or not disabled):
|
|
out.append(_ui_element_info(control, visible, disabled))
|
|
|
|
if current_depth >= max_depth:
|
|
return
|
|
for child in node.get_children():
|
|
if child is Node:
|
|
_collect_ui_elements(
|
|
child,
|
|
current_depth + 1,
|
|
max_depth,
|
|
include_hidden,
|
|
include_disabled,
|
|
out
|
|
)
|
|
|
|
|
|
func _ui_element_info(control: Control, visible: bool, disabled: bool) -> Dictionary:
|
|
var info := {
|
|
"path": _runtime_path(control),
|
|
"name": control.name,
|
|
"type": control.get_class(),
|
|
"visible": visible,
|
|
"disabled": disabled,
|
|
"rect": _variant_to_json(control.get_rect()),
|
|
"global_rect": _variant_to_json(control.get_global_rect()),
|
|
}
|
|
if _object_has_property(control, "text"):
|
|
info["text"] = str(control.get("text"))
|
|
return info
|
|
|
|
|
|
func _control_disabled(control: Control) -> bool:
|
|
if _object_has_property(control, "disabled"):
|
|
return bool(control.get("disabled"))
|
|
return false
|
|
|
|
|
|
func _control_visible_in_tree(control: Control) -> bool:
|
|
if not control.visible:
|
|
return false
|
|
var parent := control.get_parent()
|
|
while parent != null:
|
|
if parent is CanvasItem and not (parent as CanvasItem).visible:
|
|
return false
|
|
parent = parent.get_parent()
|
|
if Engine.is_editor_hint():
|
|
return true
|
|
return control.is_visible_in_tree()
|
|
|
|
|
|
static var _property_name_cache: Dictionary = {}
|
|
|
|
|
|
func _object_has_property(obj: Object, property_name: String) -> bool:
|
|
var key := _property_cache_key(obj)
|
|
if not _property_name_cache.has(key):
|
|
var names := {}
|
|
for prop in obj.get_property_list():
|
|
names[str(prop.get("name", ""))] = true
|
|
_property_name_cache[key] = names
|
|
return (_property_name_cache[key] as Dictionary).has(property_name)
|
|
|
|
|
|
func _property_cache_key(obj: Object) -> String:
|
|
var script = obj.get_script()
|
|
if script == null:
|
|
return obj.get_class()
|
|
var script_id := str(script.get_instance_id())
|
|
if not script.resource_path.is_empty():
|
|
script_id = script.resource_path
|
|
return "%s:%s" % [obj.get_class(), script_id]
|
|
|
|
|
|
func _runtime_node_properties(node: Node) -> Dictionary:
|
|
var props := {}
|
|
for p in node.get_property_list():
|
|
var name := str(p.get("name", ""))
|
|
var usage := int(p.get("usage", 0))
|
|
if name.is_empty() or (usage & PROPERTY_USAGE_EDITOR) == 0:
|
|
continue
|
|
props[name] = _variant_to_json(node.get(name))
|
|
return props
|
|
|
|
|
|
func _resolve_runtime_node(path: String) -> Node:
|
|
var scene_root := _current_scene_root()
|
|
if scene_root == null:
|
|
return null
|
|
if path.is_empty() or path == "/":
|
|
return scene_root
|
|
|
|
if path.begins_with("/root/"):
|
|
return get_tree().root.get_node_or_null(path.trim_prefix("/root/"))
|
|
|
|
var scene_path := path.trim_prefix("/")
|
|
if scene_path == str(scene_root.name):
|
|
return scene_root
|
|
var prefix := str(scene_root.name) + "/"
|
|
if scene_path.begins_with(prefix):
|
|
scene_path = scene_path.substr(prefix.length())
|
|
return scene_root.get_node_or_null(scene_path)
|
|
|
|
|
|
func _runtime_path(node: Node) -> String:
|
|
var scene_root := _current_scene_root()
|
|
if scene_root == null:
|
|
return str(node.get_path())
|
|
if node == scene_root:
|
|
return "/" + str(scene_root.name)
|
|
return "/" + str(scene_root.name) + "/" + str(scene_root.get_path_to(node))
|
|
|
|
|
|
func _current_scene_root() -> Node:
|
|
var tree := get_tree()
|
|
if tree == null:
|
|
return null
|
|
var scene_root := tree.current_scene
|
|
if scene_root == null and Engine.is_editor_hint():
|
|
# Look the editor singleton up by name rather than referencing the bare
|
|
# `EditorInterface` identifier: that identifier is compiled out of export
|
|
# templates, so the GDScript parser rejects it ("Identifier
|
|
# "EditorInterface" not declared in the current scope") in an exported
|
|
# build even though `Engine.is_editor_hint()` would never run it there.
|
|
# That parse failure stops this autoload from loading in every export.
|
|
var editor := Engine.get_singleton(&"EditorInterface")
|
|
if editor:
|
|
scene_root = editor.get_edited_scene_root()
|
|
return scene_root
|
|
|
|
|
|
func _game_input_key(params: Dictionary) -> Dictionary:
|
|
var key_name := str(params.get("key", ""))
|
|
var keycode := OS.find_keycode_from_string(key_name)
|
|
if keycode == KEY_NONE:
|
|
return {"sent": false, "error": "Unknown key: %s" % key_name}
|
|
var ev := InputEventKey.new()
|
|
ev.keycode = keycode
|
|
ev.physical_keycode = keycode
|
|
ev.pressed = bool(params.get("pressed", true))
|
|
ev.echo = bool(params.get("echo", false))
|
|
Input.parse_input_event(ev)
|
|
return {"sent": true, "key": key_name, "pressed": ev.pressed}
|
|
|
|
|
|
func _game_input_mouse(params: Dictionary) -> Dictionary:
|
|
var event := str(params.get("event", "button"))
|
|
var pos_result := _resolve_mouse_position(params.get("position"))
|
|
if pos_result.has("error"):
|
|
return {"sent": false, "event": event, "error": pos_result.error}
|
|
var pos: Vector2 = pos_result.position
|
|
match event:
|
|
"motion":
|
|
var motion := InputEventMouseMotion.new()
|
|
motion.position = pos
|
|
motion.global_position = pos
|
|
Input.parse_input_event(motion)
|
|
return {"sent": true, "event": "motion", "position": _variant_to_json(pos)}
|
|
"button":
|
|
var button_event := InputEventMouseButton.new()
|
|
button_event.position = pos
|
|
button_event.global_position = pos
|
|
button_event.button_index = _mouse_button_index(str(params.get("button", "left")))
|
|
button_event.pressed = bool(params.get("pressed", true))
|
|
Input.parse_input_event(button_event)
|
|
return {
|
|
"sent": true,
|
|
"event": "button",
|
|
"button": params.get("button", "left"),
|
|
"pressed": button_event.pressed,
|
|
"position": _variant_to_json(pos),
|
|
}
|
|
return {"sent": false, "error": "Invalid mouse event: %s" % event}
|
|
|
|
|
|
func _game_input_gamepad(params: Dictionary) -> Dictionary:
|
|
var device := int(params.get("device", 0))
|
|
var control := str(params.get("control", "button"))
|
|
match control:
|
|
"button":
|
|
var button := InputEventJoypadButton.new()
|
|
button.device = device
|
|
button.button_index = int(params.get("index", 0))
|
|
button.pressed = bool(params.get("pressed", true))
|
|
Input.parse_input_event(button)
|
|
return {"sent": true, "control": "button", "device": device, "index": button.button_index, "pressed": button.pressed}
|
|
"axis":
|
|
var axis := InputEventJoypadMotion.new()
|
|
axis.device = device
|
|
axis.axis = int(params.get("index", 0))
|
|
axis.axis_value = float(params.get("value", 0.0))
|
|
Input.parse_input_event(axis)
|
|
return {"sent": true, "control": "axis", "device": device, "index": axis.axis, "value": axis.axis_value}
|
|
return {"sent": false, "error": "Invalid gamepad control: %s" % control}
|
|
|
|
|
|
func _game_input_action(params: Dictionary) -> Dictionary:
|
|
var action := str(params.get("action", ""))
|
|
if action.is_empty():
|
|
return {"sent": false, "error": "Missing action"}
|
|
if not InputMap.has_action(action):
|
|
return {"sent": false, "action": action, "error": "Unknown action: %s" % action}
|
|
var pressed := bool(params.get("pressed", true))
|
|
var strength := clampf(float(params.get("strength", 1.0)), 0.0, 1.0)
|
|
if pressed:
|
|
Input.action_press(action, strength)
|
|
else:
|
|
Input.action_release(action)
|
|
return {
|
|
"sent": true,
|
|
"action": action,
|
|
"pressed": pressed,
|
|
"strength": strength,
|
|
"delivery": "action_state",
|
|
}
|
|
|
|
|
|
func _game_input_state(params: Dictionary) -> Dictionary:
|
|
var actions: Array = params.get("actions", [])
|
|
if actions.is_empty():
|
|
actions = InputMap.get_actions()
|
|
var states := {}
|
|
for action in actions:
|
|
var name := str(action)
|
|
states[name] = Input.is_action_pressed(name)
|
|
return {"actions": states}
|
|
|
|
|
|
## --- input_sequence: frame-timed action timeline (deferred) ---
|
|
##
|
|
## Per-step round-trips can't hit a target frame — network jitter lands each
|
|
## input on whatever frame its reply happens to arrive on, so a jump arc or a
|
|
## timed combo is unreproducible (#814). input_sequence takes the whole
|
|
## timeline in one call and drives it game-side, applying each step's action on
|
|
## its scheduled frame, then replies once (deferred). Frame count (not ms) is
|
|
## the timing basis: it's what reproduces identically across runs.
|
|
|
|
## Hard caps mirrored by the server-side schema (see game handlers). The game
|
|
## side re-checks them so a malformed direct message can't park the coroutine
|
|
## on an unbounded await; the server rejects the same cases up front with a
|
|
## clearer error.
|
|
##
|
|
## The frame cap bounds the sequence in *frames*, which is a wall-clock time
|
|
## only at a given FPS: 600 frames is ~10s at 60fps but longer under load or on
|
|
## a throttled runner. It is not sized to the ~30s deferred budget
|
|
## (editor_handler.INPUT_SEQUENCE_TIMEOUT_SEC) — the two are independent
|
|
## safeguards. If a genuinely slow run exceeds the budget, the dispatcher
|
|
## returns a clean DEFERRED_TIMEOUT rather than hanging, so the cap can stay a
|
|
## simple frame count.
|
|
const MAX_SEQUENCE_STEPS := 256
|
|
const MAX_SEQUENCE_FRAMES := 600
|
|
|
|
## Testing seam: the last input_sequence reply (response or error), recorded
|
|
## before the EngineDebugger channel (inactive in the editor-side test
|
|
## harness). Mirrors _last_screenshot_reply / _last_eval_reply.
|
|
var _last_game_command_reply: Dictionary = {}
|
|
|
|
## Testing seam: overrides the per-frame wait in _run_input_sequence. Left
|
|
## invalid in production (real `process_frame` awaits). A test sets it to a
|
|
## synchronously-returning Callable so the multi-frame loop runs to completion
|
|
## in one call — the editor test runner invokes tests synchronously and never
|
|
## pumps `process_frame`, so a real frame-await would suspend and record zero
|
|
## assertions. Timing itself (one frame per step) is engine-guaranteed; this
|
|
## seam covers the scheduling/application/reply logic layered on top.
|
|
var _frame_waiter: Callable = Callable()
|
|
|
|
|
|
## Validate + normalize an input_sequence request. Pure (no engine state), so
|
|
## the ordering/cap/shape rules are unit-testable without a running game.
|
|
## Returns {"error": String} or {"steps": Array, "end_frame": int}.
|
|
func _plan_input_sequence(params: Dictionary) -> Dictionary:
|
|
var raw_steps: Variant = params.get("steps", null)
|
|
if not (raw_steps is Array):
|
|
return {"error": "steps must be an array"}
|
|
var steps_arr: Array = raw_steps
|
|
if steps_arr.is_empty():
|
|
return {"error": "steps must not be empty"}
|
|
if steps_arr.size() > MAX_SEQUENCE_STEPS:
|
|
return {"error": "steps exceeds cap of %d (got %d)" % [MAX_SEQUENCE_STEPS, steps_arr.size()]}
|
|
|
|
## Validate field *kinds* rather than coercing them: the server already
|
|
## rejects bad shapes, but this planner is also the backstop for a
|
|
## malformed direct debugger message, so it must not silently turn
|
|
## pressed="false" into true or at_frame="oops" into 0. _is_number accepts
|
|
## int or float (JSON round-trips whole numbers as either) but not bool or
|
|
## string, so this stays consistent with the server without tripping on
|
|
## JSON's number typing.
|
|
var settle_raw: Variant = params.get("settle_frames", 0)
|
|
if not _is_number(settle_raw):
|
|
return {"error": "settle_frames must be a number"}
|
|
var settle_frames := int(settle_raw)
|
|
if settle_frames < 0:
|
|
return {"error": "settle_frames must be >= 0"}
|
|
|
|
var normalized: Array = []
|
|
var prev_frame := -1
|
|
for i in steps_arr.size():
|
|
var raw: Variant = steps_arr[i]
|
|
if not (raw is Dictionary):
|
|
return {"error": "steps[%d] must be an object" % i}
|
|
var step: Dictionary = raw
|
|
if not (step.get("action", "") is String) or str(step.get("action", "")).is_empty():
|
|
return {"error": "steps[%d].action is required" % i}
|
|
var action: String = step["action"]
|
|
var at_frame_raw: Variant = step.get("at_frame", 0)
|
|
if not _is_number(at_frame_raw):
|
|
return {"error": "steps[%d].at_frame must be a number" % i}
|
|
var at_frame := int(at_frame_raw)
|
|
if at_frame < 0:
|
|
return {"error": "steps[%d].at_frame must be >= 0" % i}
|
|
if at_frame < prev_frame:
|
|
return {"error": "steps must be ordered by at_frame (steps[%d]=%d < previous %d)" % [i, at_frame, prev_frame]}
|
|
prev_frame = at_frame
|
|
var pressed_raw: Variant = step.get("pressed", true)
|
|
if not (pressed_raw is bool):
|
|
return {"error": "steps[%d].pressed must be a boolean" % i}
|
|
var strength_raw: Variant = step.get("strength", 1.0)
|
|
if not _is_number(strength_raw):
|
|
return {"error": "steps[%d].strength must be a number" % i}
|
|
normalized.append({
|
|
"at_frame": at_frame,
|
|
"action": action,
|
|
"pressed": pressed_raw,
|
|
"strength": clampf(float(strength_raw), 0.0, 1.0),
|
|
})
|
|
|
|
var end_frame: int = int(normalized[-1]["at_frame"]) + settle_frames
|
|
if end_frame > MAX_SEQUENCE_FRAMES:
|
|
return {"error": "sequence spans %d frames, exceeds cap of %d" % [end_frame, MAX_SEQUENCE_FRAMES]}
|
|
return {"steps": normalized, "end_frame": end_frame}
|
|
|
|
|
|
## Async: apply each step's action on its scheduled frame, awaiting one
|
|
## process_frame per frame, then reply (deferred). Bails out before applying
|
|
## anything if the plan is invalid or any action is unknown to the running
|
|
## game's InputMap — a half-applied timeline leaves inputs in an undefined
|
|
## state, so it's all-or-nothing on the pre-checks.
|
|
func _run_input_sequence(request_id: String, params: Dictionary) -> void:
|
|
var plan := _plan_input_sequence(params)
|
|
if plan.has("error"):
|
|
_reply_input_sequence_error(request_id, plan["error"])
|
|
return
|
|
|
|
var steps: Array = plan["steps"]
|
|
var end_frame: int = plan["end_frame"]
|
|
|
|
## Resolve action names against the *game's* InputMap up front — the server
|
|
## can't see it, so this is the first place unknown actions surface.
|
|
for step in steps:
|
|
if not InputMap.has_action(step["action"]):
|
|
_reply_input_sequence_error(request_id, "Unknown action: %s" % step["action"])
|
|
return
|
|
|
|
var tree := get_tree()
|
|
if tree == null:
|
|
_reply_input_sequence_error(request_id, "No SceneTree available for input sequence")
|
|
return
|
|
|
|
var applied: Array = []
|
|
var step_i := 0
|
|
for f in range(0, end_frame + 1):
|
|
while step_i < steps.size() and int(steps[step_i]["at_frame"]) == f:
|
|
var step: Dictionary = steps[step_i]
|
|
_game_input_action(step)
|
|
applied.append({"at_frame": f, "action": step["action"], "pressed": step["pressed"]})
|
|
step_i += 1
|
|
if f < end_frame:
|
|
if _frame_waiter.is_valid():
|
|
await _frame_waiter.call()
|
|
else:
|
|
await tree.process_frame
|
|
|
|
_reply_input_sequence_ok(request_id, {
|
|
"completed": true,
|
|
"steps_applied": applied.size(),
|
|
"frames_elapsed": end_frame,
|
|
"applied": applied,
|
|
"actions_pressed_at_end": _actions_pressed_at_end(steps),
|
|
})
|
|
|
|
|
|
## Distinct actions the sequence touched that are still held at the end, so the
|
|
## caller knows what it must release (a press with no matching release leaves
|
|
## the action stuck on across the next frames).
|
|
func _actions_pressed_at_end(steps: Array) -> Array:
|
|
var seen := {}
|
|
var pressed: Array = []
|
|
for step in steps:
|
|
var action: String = step["action"]
|
|
if seen.has(action):
|
|
continue
|
|
seen[action] = true
|
|
if Input.is_action_pressed(action):
|
|
pressed.append(action)
|
|
return pressed
|
|
|
|
|
|
func _reply_input_sequence_ok(request_id: String, result: Dictionary) -> void:
|
|
result["source"] = "game"
|
|
result["op"] = "input_sequence"
|
|
_last_game_command_reply = {"kind": "response", "op": "input_sequence", "result": result}
|
|
if EngineDebugger.is_active():
|
|
EngineDebugger.send_message("mcp:game_command_response",
|
|
[request_id, JSON.stringify(_variant_to_json(result))])
|
|
|
|
|
|
func _reply_input_sequence_error(request_id: String, message: String) -> void:
|
|
_last_game_command_reply = {"kind": "error", "op": "input_sequence", "message": message}
|
|
if EngineDebugger.is_active():
|
|
EngineDebugger.send_message("mcp:game_command_error", [request_id, message])
|
|
|
|
|
|
## Resolve a mouse-position param. Absent (null, or an empty {}) falls back to
|
|
## the live cursor position — a deliberate default. A present but wrong-shaped
|
|
## value is rejected instead of silently substituting the cursor, which
|
|
## previously hid caller bugs (#635). Accepts a {x, y} dict or an [x, y] array;
|
|
## returns {position: Vector2} or {error: String}.
|
|
func _resolve_mouse_position(value: Variant) -> Dictionary:
|
|
var viewport := get_viewport()
|
|
var fallback := viewport.get_mouse_position() if viewport != null else Vector2.ZERO
|
|
if value == null:
|
|
return {"position": fallback}
|
|
if value is Dictionary:
|
|
var dict: Dictionary = value
|
|
if dict.is_empty():
|
|
return {"position": fallback}
|
|
# A non-empty dict that carries neither coordinate is a caller mistake,
|
|
# not "use the default" — reject rather than silently substitute.
|
|
if not dict.has("x") and not dict.has("y"):
|
|
return {"error": "position object must have an 'x' and/or 'y' key (got keys %s)" % str(dict.keys())}
|
|
var x_val: Variant = dict.get("x", fallback.x)
|
|
var y_val: Variant = dict.get("y", fallback.y)
|
|
if not _is_number(x_val) or not _is_number(y_val):
|
|
return {"error": "position x/y must be numbers (got x=%s, y=%s)" % [type_string(typeof(x_val)), type_string(typeof(y_val))]}
|
|
return {"position": Vector2(float(x_val), float(y_val))}
|
|
if value is Array:
|
|
var arr: Array = value
|
|
if arr.size() != 2:
|
|
return {"error": "position array must be [x, y] (got %d elements)" % arr.size()}
|
|
if not _is_number(arr[0]) or not _is_number(arr[1]):
|
|
return {"error": "position array elements must be numbers (got [%s, %s])" % [type_string(typeof(arr[0])), type_string(typeof(arr[1]))]}
|
|
return {"position": Vector2(float(arr[0]), float(arr[1]))}
|
|
return {"error": "position must be a {x, y} object or [x, y] array (got %s)" % type_string(typeof(value))}
|
|
|
|
|
|
func _is_number(v: Variant) -> bool:
|
|
return typeof(v) == TYPE_INT or typeof(v) == TYPE_FLOAT
|
|
|
|
|
|
func _mouse_button_index(name: String) -> int:
|
|
match name:
|
|
"right":
|
|
return MOUSE_BUTTON_RIGHT
|
|
"middle":
|
|
return MOUSE_BUTTON_MIDDLE
|
|
"wheel_up":
|
|
return MOUSE_BUTTON_WHEEL_UP
|
|
"wheel_down":
|
|
return MOUSE_BUTTON_WHEEL_DOWN
|
|
return MOUSE_BUTTON_LEFT
|
|
|
|
|
|
## --- game_eval: execute arbitrary GDScript in the running game ---
|
|
|
|
## Wall-clock ceiling for a single game_eval. Evaluated code that awaits
|
|
## something which never completes (a signal that never fires, a timer on a
|
|
## paused tree) would otherwise pin the request open until the dispatcher's
|
|
## 15s deferred budget / the server's 15s command timeout fires it as an
|
|
## opaque INTERNAL_ERROR — with the temp eval Node leaked into the tree.
|
|
## Bounding it here lets us free the node and reply with an actionable
|
|
## message instead. See hi-godot/godot-ai#487.
|
|
##
|
|
## TIMEOUT ORDERING — load-bearing across three files: this value MUST stay
|
|
## below the editor-side fallback timer in
|
|
## `debugger/mcp_debugger_plugin.gd::request_game_eval` (`timeout_sec`,
|
|
## default 10.0), which in turn stays below the dispatcher's `game_eval`
|
|
## budget in `dispatcher.gd` (15000 ms). So: game 8s < editor 10s <
|
|
## dispatcher 15s. Only this game-side guard emits the specific
|
|
## "Eval exceeded 8s" message (both it and the editor backstop now carry the
|
|
## EVAL_HUNG code, #518, but the editor's message can't name the cause).
|
|
## Raise this at/above the editor timer (or drop that timer below this) and
|
|
## the less specific editor message wins the race, silently losing the
|
|
## diagnostic this fix exists to provide. Nothing enforces the order —
|
|
## change one, re-check the other two.
|
|
##
|
|
## NOTE: this catches a hung `await`, not a CPU-bound loop with no `await` —
|
|
## a tight `while true:` with no yield blocks the main thread, so nothing
|
|
## (including this poll) runs until it yields. That case is out of scope.
|
|
const EVAL_TIMEOUT_SEC := 8.0
|
|
|
|
|
|
func _handle_eval(data: Array) -> void:
|
|
var request_id: String = data[0] if data.size() > 0 else ""
|
|
var code: String = data[1] if data.size() > 1 else ""
|
|
|
|
if code.is_empty():
|
|
_reply_eval_error(request_id, "No code provided")
|
|
return
|
|
|
|
## Wrap user code in an execute() coroutine (so it can `await` internally)
|
|
## whose inner function is uniquely named per eval. A runtime error's
|
|
## backtrace then carries `_mcp_run_<token>`, letting us attribute it to
|
|
## THIS eval — not an unrelated background game error, and not a sibling
|
|
## overlapping eval. (#490)
|
|
_eval_token_counter += 1
|
|
var token := str(_eval_token_counter)
|
|
var run_fn := "_mcp_run_%s" % token
|
|
var script_source := (
|
|
"extends Node\n"
|
|
+ "func execute():\n"
|
|
+ "\treturn await %s()\n\n" % run_fn
|
|
+ "func %s():\n" % run_fn
|
|
+ _indent_eval_code(code)
|
|
)
|
|
|
|
## Snapshot the logger's script-error seq BEFORE running so we only attribute
|
|
## errors raised by this eval. In a debug build a parse error aborts reload()
|
|
## and a runtime error aborts execute() — either way this function may never
|
|
## reach its reply: the editor infers a compile error from the missing
|
|
## mcp:eval_compiled beacon, and a runtime error is reported (via the
|
|
## eval_check probe / the in-flight poll loop) once a logged error past this
|
|
## baseline carries this eval's token.
|
|
var baseline: int = _logger.script_error_seq() if _logger != null else 0
|
|
|
|
var script: GDScript = GDScript.new()
|
|
script.source_code = script_source
|
|
## #490: ack BEFORE reload(). A parse error aborts this function at reload()
|
|
## without a return code in a debug build, so this is our only chance to tell
|
|
## the editor "received + about to compile." The editor uses that to tell a
|
|
## real parse error (acked, never compiled) apart from a message it simply
|
|
## hasn't serviced yet (never acked); see mcp_debugger_plugin._on_eval_grace.
|
|
EngineDebugger.send_message("mcp:eval_ack", [request_id])
|
|
## reload() ABORTS this function on a parse error in a debug build (it does
|
|
## not return a non-OK code there), so the lines below only run when the
|
|
## source compiled. Keep reload() INLINE — moving it behind a timer/await
|
|
## poisons subsequent evals (#490). The err branch still matters for the
|
|
## editor process (handler unit tests), where reload() does return.
|
|
var err: int = script.reload()
|
|
if err != OK:
|
|
_reply_eval_error(request_id,
|
|
"Failed to compile GDScript (error %d). Check syntax." % err)
|
|
return
|
|
|
|
## Compiled OK — tell the editor so its grace timer doesn't flag a compile
|
|
## error and so it begins probing for a runtime error.
|
|
EngineDebugger.send_message("mcp:eval_compiled", [request_id])
|
|
|
|
var temp_node := Node.new()
|
|
temp_node.set_script(script)
|
|
temp_node.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
add_child(temp_node)
|
|
|
|
if not temp_node.has_method("execute"):
|
|
temp_node.queue_free()
|
|
_reply_eval_error(request_id, "Internal error: eval wrapper is missing execute().")
|
|
return
|
|
|
|
## Register in-flight BEFORE running: a runtime error aborts execute() (and
|
|
## may unwind this function) before we could record it afterward, and the
|
|
## editor probe / poll loop need the entry to attribute and report the error.
|
|
_inflight_evals[request_id] = {"node": temp_node, "token": token, "baseline": baseline}
|
|
|
|
## Drive execute() as a fire-and-forget coroutine that records its outcome
|
|
## into `holder`, then poll frames until it finishes or the deadline passes
|
|
## (#488's hung-await guard). A plain `await temp_node.execute()` has no
|
|
## escape hatch: if user code never returns, we never reach the reply/cleanup
|
|
## below and the request hangs with the node leaked.
|
|
var holder := {"done": false, "value": null, "abandoned": false}
|
|
_drive_eval(temp_node, holder)
|
|
|
|
var tree := get_tree()
|
|
var deadline_ms := int(EVAL_TIMEOUT_SEC * 1000.0)
|
|
var start_ms := Time.get_ticks_msec()
|
|
while not holder["done"] and (Time.get_ticks_msec() - start_ms) < deadline_ms:
|
|
## #490 focused fast path: a runtime error aborts _drive_eval (holder
|
|
## never completes), so check each frame whether THIS eval's token now
|
|
## appears in a logged error and report it immediately. (Backgrounded,
|
|
## this loop is frozen and the editor probe does the same job.)
|
|
if _try_report_eval_runtime_error(request_id):
|
|
holder["abandoned"] = true
|
|
return
|
|
await tree.process_frame
|
|
|
|
if not holder["done"]:
|
|
## Past the 8s deadline. Disambiguate a runtime error (its token is in a
|
|
## logged error) from a genuine hung await before the generic timeout.
|
|
holder["abandoned"] = true
|
|
if _try_report_eval_runtime_error(request_id):
|
|
return
|
|
_inflight_evals.erase(request_id)
|
|
if is_instance_valid(temp_node):
|
|
remove_child(temp_node)
|
|
_reply_eval_error(request_id,
|
|
("Eval exceeded %ds and was aborted — the code likely awaits "
|
|
+ "something that never completes (a signal that never fires, a timer on "
|
|
+ "a paused tree) or loops forever. Check logs_read(source='game').")
|
|
% int(EVAL_TIMEOUT_SEC),
|
|
ErrorCodes.EVAL_HUNG)
|
|
return
|
|
|
|
## Clean finish.
|
|
_inflight_evals.erase(request_id)
|
|
temp_node.queue_free()
|
|
_reply_eval_response(request_id, holder["value"])
|
|
|
|
|
|
## Run the compiled eval node's execute() and stash the result. Kept
|
|
## separate from _handle_eval so the latter can race it against a deadline
|
|
## via frame polling. If the eval was abandoned (timed out) before this
|
|
## resumes, drop the result and free the now-detached node — _handle_eval
|
|
## has already replied.
|
|
##
|
|
## RESIDUAL LEAK (accepted): if the awaited thing *never* fires, this
|
|
## coroutine never resumes, so the `node` it holds is detached (via
|
|
## _handle_eval's remove_child) but never freed — one orphaned Node per such
|
|
## timeout, for the game-process lifetime. GDScript has no way to cancel a
|
|
## suspended coroutine, so this is the best achievable in-process. It is still
|
|
## strictly better than the pre-#487 behavior, where the node leaked *into*
|
|
## the live tree and the request hung to the 15s ceiling.
|
|
func _drive_eval(node: Node, holder: Dictionary) -> void:
|
|
var value = await node.execute()
|
|
if holder.get("abandoned", false):
|
|
if is_instance_valid(node):
|
|
node.queue_free()
|
|
return
|
|
holder["value"] = value
|
|
holder["done"] = true
|
|
|
|
|
|
## #518: cap on the serialized eval result. Godot's remote-debugger TCP peer
|
|
## silently discards any single message over ~8 MiB, so a bigger reply never
|
|
## reaches the editor and the request rides to the 10s backstop as a phantom
|
|
## "hang". (Results over the editor↔server WebSocket buffer cap of 4 MiB fail
|
|
## there with their own explicit error; this game-side cap only needs to stay
|
|
## under the debugger peer's drop threshold to keep the failure visible.)
|
|
const EVAL_RESULT_MAX_BYTES := 6 * 1024 * 1024
|
|
|
|
## Testing seam: the last eval reply, recorded before hitting the
|
|
## EngineDebugger channel (inactive in the editor-side test harness).
|
|
var _last_eval_reply: Dictionary = {}
|
|
|
|
|
|
## `code` (optional) rides as a third payload element so the editor can map
|
|
## the reply to a specific error code instead of the generic INTERNAL_ERROR;
|
|
## the editor allowlists the value (see mcp_debugger_plugin._on_eval_error).
|
|
func _reply_eval_error(request_id: String, message: String, code: String = "") -> void:
|
|
_last_eval_reply = {"kind": "error", "request_id": request_id,
|
|
"message": message, "code": code}
|
|
var payload := [request_id, message]
|
|
if not code.is_empty():
|
|
payload.append(code)
|
|
if EngineDebugger.is_active():
|
|
EngineDebugger.send_message("mcp:eval_error", payload)
|
|
|
|
|
|
func _reply_eval_response(request_id: String, value: Variant) -> void:
|
|
var serialized := JSON.stringify(_variant_to_json(value))
|
|
var serialized_bytes := serialized.to_utf8_buffer().size()
|
|
if serialized_bytes > EVAL_RESULT_MAX_BYTES:
|
|
_reply_eval_error(request_id,
|
|
("Eval result too large to return (%d bytes serialized, limit %d). "
|
|
+ "Return a smaller slice instead — e.g. counts, node paths, or a "
|
|
+ "truncated substring.") % [serialized_bytes, EVAL_RESULT_MAX_BYTES],
|
|
ErrorCodes.EVAL_RESULT_TOO_LARGE)
|
|
return
|
|
_last_eval_reply = {"kind": "response", "request_id": request_id}
|
|
if EngineDebugger.is_active():
|
|
EngineDebugger.send_message("mcp:eval_response", [request_id, serialized])
|
|
|
|
|
|
## #490: if a logged script error past THIS eval's baseline carries its unique
|
|
## wrapper-function token, a runtime error aborted it before it could reply —
|
|
## report it with the real text + line. Returns true if it reported. Called
|
|
## from the editor's eval_check probe (the reliable path when a backgrounded
|
|
## game's idle loop is frozen — the debugger capture callback still runs) and
|
|
## from _handle_eval's poll loop (the focused fast path). Token + baseline
|
|
## matching means an unrelated background error, or a sibling overlapping
|
|
## eval's error, can never fail this request.
|
|
func _try_report_eval_runtime_error(request_id: String) -> bool:
|
|
if _logger == null:
|
|
return false
|
|
var entry = _inflight_evals.get(request_id)
|
|
if entry == null:
|
|
return false
|
|
var text: String = _logger.find_script_error_since(
|
|
int(entry["baseline"]), "_mcp_run_%s" % str(entry["token"]))
|
|
if text.is_empty():
|
|
return false
|
|
_inflight_evals.erase(request_id)
|
|
var node: Node = entry["node"]
|
|
if node != null and is_instance_valid(node):
|
|
node.queue_free()
|
|
if EngineDebugger.is_active():
|
|
EngineDebugger.send_message("mcp:eval_runtime_error", [request_id, text])
|
|
return true
|
|
|
|
|
|
## #490: answer an editor eval_check probe. The editor polls this once the
|
|
## eval has compiled but not yet replied. This runs in the debugger capture
|
|
## callback, which stays live even when the backgrounded game's _process is
|
|
## frozen — so it's the reliable channel for reporting a runtime error that
|
|
## aborted the eval. Report if one is detected for this request, else stay
|
|
## silent (the editor keeps polling until the real reply or the hang timeout).
|
|
func _handle_eval_check(data: Array) -> void:
|
|
var request_id: String = data[0] if data.size() > 0 else ""
|
|
if request_id.is_empty():
|
|
return
|
|
_try_report_eval_runtime_error(request_id)
|
|
|
|
|
|
func _indent_eval_code(code: String) -> String:
|
|
var lines: PackedStringArray = code.split("\n")
|
|
var out := ""
|
|
for line in lines:
|
|
out += "\t" + line + "\n"
|
|
return out
|
|
|
|
|
|
## Serialize any Godot Variant to a JSON-safe dictionary/array/primitive.
|
|
## Ported from godot-mcp's mcp_interaction_server.gd.
|
|
func _variant_to_json(value: Variant) -> Variant:
|
|
if value == null:
|
|
return null
|
|
if value is bool or value is int or value is float or value is String:
|
|
return value
|
|
if value is Vector2:
|
|
return {"x": value.x, "y": value.y}
|
|
if value is Vector3:
|
|
return {"x": value.x, "y": value.y, "z": value.z}
|
|
if value is Vector4:
|
|
return {"x": value.x, "y": value.y, "z": value.z, "w": value.w}
|
|
if value is Vector2i:
|
|
return {"x": value.x, "y": value.y}
|
|
if value is Vector3i:
|
|
return {"x": value.x, "y": value.y, "z": value.z}
|
|
if value is Vector4i:
|
|
return {"x": value.x, "y": value.y, "z": value.z, "w": value.w}
|
|
if value is Color:
|
|
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
|
|
if value is Quaternion:
|
|
return {"x": value.x, "y": value.y, "z": value.z, "w": value.w}
|
|
if value is Basis:
|
|
return {
|
|
"x": _variant_to_json(value.x),
|
|
"y": _variant_to_json(value.y),
|
|
"z": _variant_to_json(value.z),
|
|
}
|
|
if value is Transform3D:
|
|
return {
|
|
"basis": _variant_to_json(value.basis),
|
|
"origin": _variant_to_json(value.origin),
|
|
}
|
|
if value is Transform2D:
|
|
return {
|
|
"x": _variant_to_json(value.x),
|
|
"y": _variant_to_json(value.y),
|
|
"origin": _variant_to_json(value.origin),
|
|
}
|
|
if value is Rect2:
|
|
return {
|
|
"position": _variant_to_json(value.position),
|
|
"size": _variant_to_json(value.size),
|
|
}
|
|
if value is Rect2i:
|
|
return {
|
|
"position": _variant_to_json(value.position),
|
|
"size": _variant_to_json(value.size),
|
|
}
|
|
if value is AABB:
|
|
return {
|
|
"position": _variant_to_json(value.position),
|
|
"size": _variant_to_json(value.size),
|
|
}
|
|
if value is NodePath or value is StringName:
|
|
return str(value)
|
|
if value is Plane:
|
|
return {
|
|
"normal": _variant_to_json(value.normal),
|
|
"d": value.d,
|
|
}
|
|
if value is Projection:
|
|
return {
|
|
"x": _variant_to_json(value.x),
|
|
"y": _variant_to_json(value.y),
|
|
"z": _variant_to_json(value.z),
|
|
"w": _variant_to_json(value.w),
|
|
}
|
|
## Packed arrays
|
|
if value is PackedByteArray:
|
|
var arr: Array = []
|
|
for item in value: arr.append(item)
|
|
return arr
|
|
if value is PackedInt32Array or value is PackedInt64Array:
|
|
var arr: Array = []
|
|
for item in value: arr.append(item)
|
|
return arr
|
|
if value is PackedFloat32Array or value is PackedFloat64Array:
|
|
var arr: Array = []
|
|
for item in value: arr.append(item)
|
|
return arr
|
|
if value is PackedStringArray:
|
|
var arr: Array = []
|
|
for item in value: arr.append(item)
|
|
return arr
|
|
if value is PackedVector2Array:
|
|
var arr: Array = []
|
|
for item in value: arr.append({"x": item.x, "y": item.y})
|
|
return arr
|
|
if value is PackedVector3Array:
|
|
var arr: Array = []
|
|
for item in value: arr.append({"x": item.x, "y": item.y, "z": item.z})
|
|
return arr
|
|
if value is PackedVector4Array:
|
|
var arr: Array = []
|
|
for item in value: arr.append({"x": item.x, "y": item.y, "z": item.z, "w": item.w})
|
|
return arr
|
|
if value is PackedColorArray:
|
|
var arr: Array = []
|
|
for item in value: arr.append({"r": item.r, "g": item.g, "b": item.b, "a": item.a})
|
|
return arr
|
|
## Generic arrays and dictionaries — recurse
|
|
if value is Array:
|
|
var arr: Array = []
|
|
for item in value:
|
|
arr.append(_variant_to_json(item))
|
|
return arr
|
|
if value is Dictionary:
|
|
var dict: Dictionary = {}
|
|
for key in value.keys():
|
|
dict[str(key)] = _variant_to_json(value[key])
|
|
return dict
|
|
## Fallback: string representation
|
|
return str(value)
|