fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation
This commit is contained in:
@@ -14,20 +14,24 @@ var _debugger_plugin: McpDebuggerPlugin
|
||||
var _game_log_buffer: McpGameLogBuffer
|
||||
var _editor_log_buffer: McpEditorLogBuffer
|
||||
var _debugger_errors_root: Node
|
||||
var _debugger_search_root_cache: Node
|
||||
var _surfaced_error_tracker
|
||||
|
||||
|
||||
func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_plugin: McpDebuggerPlugin = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, debugger_errors_root: Node = null) -> void:
|
||||
func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_plugin: McpDebuggerPlugin = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, debugger_errors_root: Node = null, surfaced_error_tracker = null) -> void:
|
||||
_log_buffer = log_buffer
|
||||
_connection = connection
|
||||
_debugger_plugin = debugger_plugin
|
||||
_game_log_buffer = game_log_buffer
|
||||
_editor_log_buffer = editor_log_buffer
|
||||
_debugger_errors_root = debugger_errors_root
|
||||
_surfaced_error_tracker = surfaced_error_tracker
|
||||
if _surfaced_error_tracker == null:
|
||||
_surfaced_error_tracker = McpSurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer, _debugger_errors_root)
|
||||
|
||||
|
||||
func get_editor_state(_params: Dictionary) -> Dictionary:
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var game_status := _current_game_status()
|
||||
var data := {
|
||||
"godot_version": Engine.get_version_info().get("string", "unknown"),
|
||||
"project_name": ProjectSettings.get_setting("application/config/name", ""),
|
||||
@@ -38,6 +42,9 @@ func get_editor_state(_params: Dictionary) -> Dictionary:
|
||||
## false between Play→Stop cycles. Lets capture-source=game callers
|
||||
## poll for a real ready signal instead of guessing with sleep().
|
||||
"game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(),
|
||||
"game_status": game_status,
|
||||
"helper_live": bool(game_status.get("helper_live", false)),
|
||||
"session_active": bool(game_status.get("session_active", false)),
|
||||
}
|
||||
## Half-installed addon tree from a failed self-update rollback. When
|
||||
## non-empty, the agent / dock paint the operator-facing recovery copy
|
||||
@@ -72,6 +79,7 @@ func get_logs(params: Dictionary) -> Dictionary:
|
||||
var include_details: bool = bool(params.get("include_details", false))
|
||||
var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null
|
||||
var since_cursor: int = maxi(0, int(params.get("since_cursor", 0)))
|
||||
var since_run_id := "" if params.get("since_run_id", null) == null else str(params.get("since_run_id", ""))
|
||||
if not source in VALID_LOG_SOURCES:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
@@ -82,7 +90,7 @@ func get_logs(params: Dictionary) -> Dictionary:
|
||||
"plugin":
|
||||
return _get_plugin_logs(count, offset)
|
||||
"game":
|
||||
return _get_game_logs(count, offset, include_details)
|
||||
return _get_game_logs(count, offset, include_details, since_run_id)
|
||||
"editor":
|
||||
return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor)
|
||||
"all":
|
||||
@@ -90,6 +98,17 @@ func get_logs(params: Dictionary) -> Dictionary:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
|
||||
|
||||
|
||||
func _current_game_status() -> Dictionary:
|
||||
if _debugger_plugin == null:
|
||||
return McpDebuggerPlugin.with_liveness_flags({
|
||||
"status": "stopped",
|
||||
"active": false,
|
||||
"ready": false,
|
||||
"helper_expected": true,
|
||||
})
|
||||
return _debugger_plugin.get_game_status()
|
||||
|
||||
|
||||
func _get_plugin_logs(count: int, offset: int) -> Dictionary:
|
||||
var all_lines := _log_buffer.get_recent(_log_buffer.total_count())
|
||||
var page: Array[Dictionary] = []
|
||||
@@ -107,7 +126,10 @@ func _get_plugin_logs(count: int, offset: int) -> Dictionary:
|
||||
}
|
||||
|
||||
|
||||
func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionary:
|
||||
func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id: String = "") -> Dictionary:
|
||||
var game_status := _current_game_status()
|
||||
var helper_live := bool(game_status.get("helper_live", false))
|
||||
var session_active := bool(game_status.get("session_active", false))
|
||||
if _game_log_buffer == null:
|
||||
return {
|
||||
"data": {
|
||||
@@ -117,23 +139,75 @@ func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionar
|
||||
"returned_count": 0,
|
||||
"offset": offset,
|
||||
"run_id": "",
|
||||
"is_running": false,
|
||||
"current_run_id": "",
|
||||
"is_running": session_active,
|
||||
"helper_live": helper_live,
|
||||
"session_active": session_active,
|
||||
"game_status": game_status,
|
||||
"dropped_count": 0,
|
||||
"stale_run_id": false,
|
||||
}
|
||||
}
|
||||
var page := _entries_for_response(_game_log_buffer.get_range(offset, count), include_details)
|
||||
return {
|
||||
"data": {
|
||||
"source": "game",
|
||||
"lines": page,
|
||||
"total_count": _game_log_buffer.total_count(),
|
||||
"returned_count": page.size(),
|
||||
"offset": offset,
|
||||
"run_id": _game_log_buffer.run_id(),
|
||||
"is_running": EditorInterface.is_playing_scene(),
|
||||
"dropped_count": _game_log_buffer.dropped_count(),
|
||||
}
|
||||
var current_run_id := _game_log_buffer.run_id()
|
||||
var target_run_id := since_run_id if not since_run_id.is_empty() else current_run_id
|
||||
var stale_run_id := not since_run_id.is_empty() and since_run_id != current_run_id
|
||||
var run_page := _game_log_buffer.get_run_page(target_run_id, offset, count)
|
||||
var page := _entries_for_response(run_page.get("entries", []), include_details)
|
||||
var data := {
|
||||
"source": "game",
|
||||
"lines": page,
|
||||
"total_count": int(run_page.get("total_count", 0)),
|
||||
"returned_count": page.size(),
|
||||
"offset": offset,
|
||||
"run_id": target_run_id,
|
||||
"current_run_id": current_run_id,
|
||||
"is_running": session_active,
|
||||
"helper_live": helper_live,
|
||||
"session_active": session_active,
|
||||
"game_status": game_status,
|
||||
"dropped_count": _game_log_buffer.dropped_count(),
|
||||
"stale_run_id": stale_run_id,
|
||||
}
|
||||
_merge_editor_errors_hint(data, game_status)
|
||||
return {"data": data}
|
||||
|
||||
|
||||
## #641: boot-time parse errors happen while autoload scripts compile — before
|
||||
## the game helper's logger attaches via OS.add_logger — so they can NEVER
|
||||
## appear in the game buffer. They surface only through the editor scope
|
||||
## (Errors-tab rows + editor logger). Cross-reference them here so an
|
||||
## empty/clean game log is not mistaken for a clean launch.
|
||||
func _merge_editor_errors_hint(data: Dictionary, game_status: Dictionary) -> void:
|
||||
if _debugger_plugin == null:
|
||||
return
|
||||
## A since_run_id read of a prior run must not carry the CURRENT run's
|
||||
## editor errors — the hint interprets the run being read.
|
||||
if bool(data.get("stale_run_id", false)):
|
||||
return
|
||||
## run_token == 0 means no tracked run ever started this session; the
|
||||
## run-start cursor would be 0 and every retained editor error would be
|
||||
## misattributed to "this run".
|
||||
if int(game_status.get("run_token", 0)) <= 0:
|
||||
return
|
||||
## One-shot read — force the scan so rows that landed after the last
|
||||
## gated scan (and before the deferred timers fire) make the FIRST
|
||||
## logs_read(source='game') response, not just a later one.
|
||||
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(
|
||||
int(game_status.get("editor_log_cursor", 0)), true)
|
||||
if str(errors_info.get("scope", "none")) != "run":
|
||||
return
|
||||
var errors: Array = errors_info.get("errors", [])
|
||||
if errors.is_empty():
|
||||
return
|
||||
data["editor_errors_count"] = errors.size()
|
||||
data["editor_errors_hint"] = (
|
||||
"%d editor-side error%s from this run (first: %s) missing from the game log — boot-time parse/load errors occur before the game helper's logger attaches. Read logs_read(source='editor', include_details=true)."
|
||||
% [errors.size(), "s" if errors.size() != 1 else "", _format_editor_error_summary(errors[0])]
|
||||
)
|
||||
|
||||
|
||||
func _format_editor_error_summary(entry: Dictionary) -> String:
|
||||
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
|
||||
|
||||
|
||||
func _get_editor_logs(count: int, offset: int, include_details: bool, has_since_cursor: bool = false, since_cursor: int = 0) -> Dictionary:
|
||||
@@ -211,21 +285,24 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
|
||||
combined.append({"source": "plugin", "level": "info", "text": line})
|
||||
for entry in _collect_editor_log_entries():
|
||||
combined.append(entry)
|
||||
var run_id := ""
|
||||
var current_run_id := ""
|
||||
var dropped := 0
|
||||
if _game_log_buffer != null:
|
||||
for entry in _game_log_buffer.get_range(0, _game_log_buffer.total_count()):
|
||||
run_id = _game_log_buffer.run_id()
|
||||
current_run_id = run_id
|
||||
dropped = _game_log_buffer.dropped_count()
|
||||
var run_page := _game_log_buffer.get_run_page(run_id, 0, McpGameLogBuffer.MAX_LINES)
|
||||
for entry in run_page.get("entries", []):
|
||||
combined.append(entry)
|
||||
var stop := mini(combined.size(), offset + count)
|
||||
var page: Array[Dictionary] = []
|
||||
for i in range(mini(offset, combined.size()), stop):
|
||||
page.append(combined[i])
|
||||
page = _entries_for_response(page, include_details)
|
||||
var run_id := ""
|
||||
var dropped := 0
|
||||
if _game_log_buffer != null:
|
||||
run_id = _game_log_buffer.run_id()
|
||||
dropped = _game_log_buffer.dropped_count()
|
||||
if _editor_log_buffer != null:
|
||||
dropped += _editor_log_buffer.dropped_count()
|
||||
var game_status := _current_game_status()
|
||||
return {
|
||||
"data": {
|
||||
"source": "all",
|
||||
@@ -234,7 +311,11 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
|
||||
"returned_count": page.size(),
|
||||
"offset": offset,
|
||||
"run_id": run_id,
|
||||
"is_running": EditorInterface.is_playing_scene(),
|
||||
"current_run_id": current_run_id,
|
||||
"is_running": bool(game_status.get("session_active", false)),
|
||||
"helper_live": bool(game_status.get("helper_live", false)),
|
||||
"session_active": bool(game_status.get("session_active", false)),
|
||||
"game_status": game_status,
|
||||
"dropped_count": dropped,
|
||||
}
|
||||
}
|
||||
@@ -256,208 +337,7 @@ func _entries_for_response(entries: Array[Dictionary], include_details: bool) ->
|
||||
|
||||
|
||||
func _collect_editor_log_entries() -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = []
|
||||
if _editor_log_buffer != null:
|
||||
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
|
||||
entries.append(entry)
|
||||
for entry in _read_debugger_error_entries():
|
||||
if not _has_equivalent_log_entry(entries, entry):
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
func _read_debugger_error_entries() -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = []
|
||||
for tree in _locate_debugger_error_trees():
|
||||
for entry in _entries_from_debugger_error_tree(tree):
|
||||
if not _has_equivalent_log_entry(entries, entry):
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
func _locate_debugger_error_trees() -> Array[Tree]:
|
||||
var trees: Array[Tree] = []
|
||||
if _debugger_plugin == null and _debugger_errors_root == null:
|
||||
return trees
|
||||
var root: Node = _debugger_errors_root
|
||||
if root == null:
|
||||
root = _debugger_search_root()
|
||||
if root == null:
|
||||
return trees
|
||||
_collect_debugger_error_trees(root, trees)
|
||||
return trees
|
||||
|
||||
|
||||
func _debugger_search_root() -> Node:
|
||||
## logs_read is a polling tool, so per-call discovery must not recurse the
|
||||
## entire editor UI. EditorDebuggerNode is the bottom-panel container that
|
||||
## owns every ScriptEditorDebugger session tab and lives for the editor's
|
||||
## lifetime — find it once from the base control, then scan only its
|
||||
## subtree on later calls. The error Trees themselves can't be cached:
|
||||
## they are identified by their content, and an emptied tree is
|
||||
## indistinguishable from any other Tree.
|
||||
if is_instance_valid(_debugger_search_root_cache):
|
||||
return _debugger_search_root_cache
|
||||
_debugger_search_root_cache = null
|
||||
var base := EditorInterface.get_base_control()
|
||||
if base == null:
|
||||
return null
|
||||
_debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode")
|
||||
if _debugger_search_root_cache == null:
|
||||
return base
|
||||
return _debugger_search_root_cache
|
||||
|
||||
|
||||
static func _find_first_of_class(node: Node, klass: String) -> Node:
|
||||
if node.get_class() == klass:
|
||||
return node
|
||||
for child in node.get_children():
|
||||
var found := _find_first_of_class(child, klass)
|
||||
if found != null:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void:
|
||||
if node is Tree and _tree_has_debugger_errors(node as Tree):
|
||||
out.append(node as Tree)
|
||||
for child in node.get_children():
|
||||
if child is Node:
|
||||
_collect_debugger_error_trees(child as Node, out)
|
||||
|
||||
|
||||
static func _tree_has_debugger_errors(tree: Tree) -> bool:
|
||||
var root := tree.get_root()
|
||||
if root == null:
|
||||
return false
|
||||
var item := root.get_first_child()
|
||||
while item != null:
|
||||
if _is_debugger_error_item(item):
|
||||
return true
|
||||
item = item.get_next()
|
||||
return false
|
||||
|
||||
|
||||
static func _entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = []
|
||||
var root := tree.get_root()
|
||||
if root == null:
|
||||
return entries
|
||||
var item := root.get_first_child()
|
||||
while item != null:
|
||||
if _is_debugger_error_item(item):
|
||||
entries.append(_entry_from_debugger_error_item(item))
|
||||
item = item.get_next()
|
||||
return entries
|
||||
|
||||
|
||||
static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary:
|
||||
var title := item.get_text(1)
|
||||
var loc := _location_from_metadata(item.get_metadata(0))
|
||||
var function := _function_from_title(title)
|
||||
return {
|
||||
"source": "editor",
|
||||
"level": "warn" if item.has_meta("_is_warning") else "error",
|
||||
"text": title,
|
||||
"path": str(loc.get("path", "")),
|
||||
"line": int(loc.get("line", 0)),
|
||||
"function": function,
|
||||
"details": _details_from_debugger_error_item(item, loc, function),
|
||||
}
|
||||
|
||||
|
||||
static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary:
|
||||
var children: Array[Dictionary] = []
|
||||
var child := item.get_first_child()
|
||||
while child != null:
|
||||
var child_loc := _location_from_metadata(child.get_metadata(0))
|
||||
children.append({
|
||||
"label": child.get_text(0),
|
||||
"text": child.get_text(1),
|
||||
"path": str(child_loc.get("path", "")),
|
||||
"line": int(child_loc.get("line", 0)),
|
||||
})
|
||||
child = child.get_next()
|
||||
return {
|
||||
"debugger_tab": "Errors",
|
||||
"time": item.get_text(0),
|
||||
"message": item.get_text(1),
|
||||
"error_type_name": "warning" if item.has_meta("_is_warning") else "error",
|
||||
"source": {
|
||||
"path": str(loc.get("path", "")),
|
||||
"line": int(loc.get("line", 0)),
|
||||
"function": function,
|
||||
},
|
||||
"resolved": {
|
||||
"path": str(loc.get("path", "")),
|
||||
"line": int(loc.get("line", 0)),
|
||||
"function": function,
|
||||
},
|
||||
"children": children,
|
||||
"frames": _frames_from_error_children(children),
|
||||
}
|
||||
|
||||
|
||||
static func _is_debugger_error_item(item: TreeItem) -> bool:
|
||||
return item.has_meta("_is_warning") or item.has_meta("_is_error")
|
||||
|
||||
|
||||
## ScriptEditorDebugger lays out an error item's children flat, in order: an
|
||||
## optional "<X Error>" row, one "<X Source>" row, then one row per stack
|
||||
## frame. Only frame 0 carries the "<Stack Trace>" label (TTR-translated);
|
||||
## later frames have an empty label. Every frame row carries [path, line]
|
||||
## metadata, but so can the Error/Source rows, so metadata alone can't
|
||||
## identify frames — the frame run has to be found first.
|
||||
static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var start := -1
|
||||
for i in children.size():
|
||||
if str(children[i].label).contains("Stack Trace"):
|
||||
start = i
|
||||
break
|
||||
if start < 0:
|
||||
## Non-English editor locale: the "<Stack Trace>" label is translated.
|
||||
## Frames past the first are the only rows with an empty label and a
|
||||
## real location; back up one row to recover the labeled first frame
|
||||
## (rows before the frame run always have a non-empty label).
|
||||
for i in children.size():
|
||||
if str(children[i].label).is_empty() and not str(children[i].path).is_empty():
|
||||
start = maxi(i - 1, 0)
|
||||
break
|
||||
if start < 0:
|
||||
return []
|
||||
var frames: Array[Dictionary] = []
|
||||
for i in range(start, children.size()):
|
||||
if str(children[i].path).is_empty():
|
||||
continue
|
||||
frames.append({
|
||||
"path": children[i].path,
|
||||
"line": children[i].line,
|
||||
"function": _function_from_frame_text(children[i].text),
|
||||
})
|
||||
return frames
|
||||
|
||||
|
||||
static func _location_from_metadata(meta: Variant) -> Dictionary:
|
||||
if meta is Array and meta.size() >= 2:
|
||||
return {"path": str(meta[0]), "line": int(meta[1])}
|
||||
return {"path": "", "line": 0}
|
||||
|
||||
|
||||
static func _function_from_title(title: String) -> String:
|
||||
var colon := title.find(": ")
|
||||
if colon <= 0:
|
||||
return ""
|
||||
return title.substr(0, colon)
|
||||
|
||||
|
||||
static func _function_from_frame_text(text: String) -> String:
|
||||
var marker := text.find(" @ ")
|
||||
if marker < 0:
|
||||
return ""
|
||||
var fn := text.substr(marker + 3).strip_edges()
|
||||
if fn.ends_with("()"):
|
||||
fn = fn.substr(0, fn.length() - 2)
|
||||
return fn
|
||||
return _surfaced_error_tracker.collect_editor_log_entries()
|
||||
|
||||
|
||||
static func _slice_entries(entries: Array[Dictionary], offset: int, count: int) -> Array[Dictionary]:
|
||||
@@ -468,23 +348,6 @@ static func _slice_entries(entries: Array[Dictionary], offset: int, count: int)
|
||||
return page
|
||||
|
||||
|
||||
static func _has_equivalent_log_entry(entries: Array[Dictionary], candidate: Dictionary) -> bool:
|
||||
var key := _log_entry_key(candidate)
|
||||
for entry in entries:
|
||||
if _log_entry_key(entry) == key:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
static func _log_entry_key(entry: Dictionary) -> String:
|
||||
return "%s|%s|%s|%s" % [
|
||||
str(entry.get("level", "")),
|
||||
str(entry.get("text", "")),
|
||||
str(entry.get("path", "")),
|
||||
str(entry.get("line", 0)),
|
||||
]
|
||||
|
||||
|
||||
## Map of human-readable monitor names to Performance.Monitor enum values.
|
||||
const MONITORS := {
|
||||
"time/fps": Performance.TIME_FPS,
|
||||
@@ -591,8 +454,30 @@ func take_screenshot(params: Dictionary) -> Dictionary:
|
||||
return McpDispatcher.DEFERRED_RESPONSE
|
||||
"cinematic":
|
||||
return _take_cinematic_screenshot(max_resolution)
|
||||
"viewport_2d":
|
||||
viewport = EditorInterface.get_editor_viewport_2d()
|
||||
if viewport == null:
|
||||
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No 2D viewport available")
|
||||
var scene_root_2d := EditorInterface.get_edited_scene_root()
|
||||
if scene_root_2d == null:
|
||||
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY,
|
||||
"No scene open — open a scene first")
|
||||
if not view_target.is_empty() or coverage or custom_elevation != null or custom_azimuth != null or custom_fov != null:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"view_target, coverage, elevation, azimuth, and fov are not supported with source='viewport_2d'"
|
||||
)
|
||||
## Capture the 2D editor viewport directly; no view_target/coverage for 2D.
|
||||
RenderingServer.force_draw(false)
|
||||
var image_2d: Image = viewport.get_texture().get_image()
|
||||
if image_2d == null or image_2d.is_empty():
|
||||
return _empty_image_error(
|
||||
"viewport_2d",
|
||||
"Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet."
|
||||
)
|
||||
return _finalize_image(image_2d, "viewport_2d", max_resolution)
|
||||
_:
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'cinematic', or 'game'" % source)
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'viewport_2d', 'cinematic', or 'game'" % source)
|
||||
|
||||
## Handle view_target: temporarily reposition the editor's own camera to
|
||||
## frame one or more target nodes, force a render, capture, then restore.
|
||||
@@ -845,12 +730,14 @@ static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary:
|
||||
return {}
|
||||
var root_type := scene_root.get_class()
|
||||
var hint: String
|
||||
if scene_root is Node2D or scene_root is Control:
|
||||
var is_2d_scene := scene_root is CanvasItem
|
||||
if is_2d_scene:
|
||||
hint = (
|
||||
"The 3D viewport is empty because the current scene is 2D (%s root) with no Node3D descendants. "
|
||||
+ "Options: (a) open a 3D scene, "
|
||||
+ "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
|
||||
+ "(c) call scene_get_hierarchy first to inspect what's available."
|
||||
+ "(c) use source=\"viewport_2d\" to capture the 2D editor viewport directly, "
|
||||
+ "(d) call scene_get_hierarchy first to inspect what's available."
|
||||
) % root_type
|
||||
else:
|
||||
hint = (
|
||||
@@ -859,7 +746,10 @@ static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary:
|
||||
+ "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
|
||||
+ "(c) call scene_get_hierarchy first to inspect what's available."
|
||||
) % root_type
|
||||
return _make_viewport_not_3d_error(root_type, hint)
|
||||
var err := _make_viewport_not_3d_error(root_type, hint)
|
||||
if is_2d_scene:
|
||||
err["error"]["data"]["suggestion"] = "use source='viewport_2d' for 2D scenes"
|
||||
return err
|
||||
|
||||
|
||||
## True if scene_root is itself a Node3D or owns any Node3D descendant.
|
||||
@@ -1029,37 +919,7 @@ func clear_logs(params: Dictionary) -> Dictionary:
|
||||
|
||||
|
||||
func _clear_debugger_error_trees() -> int:
|
||||
var cleared := 0
|
||||
for tree in _locate_debugger_error_trees():
|
||||
cleared += _entries_from_debugger_error_tree(tree).size()
|
||||
if not _press_debugger_clear_button(tree):
|
||||
## No Clear button near this tree (synthetic roots in tests).
|
||||
## A raw clear is acceptable there; the real panel always routes
|
||||
## through the button below.
|
||||
tree.clear()
|
||||
return cleared
|
||||
|
||||
|
||||
## Clear via ScriptEditorDebugger's own Clear button so the engine runs
|
||||
## _clear_errors_list() — clearing the Tree directly leaves error_count/
|
||||
## warning_count, the "Errors (N)" tab badge, the errors_cleared signal, and
|
||||
## the toolbar button states out of sync with the emptied tree. The button is
|
||||
## identified by its pressed-connection target, not its (translated) label.
|
||||
static func _press_debugger_clear_button(tree: Tree) -> bool:
|
||||
var parent := tree.get_parent()
|
||||
if parent == null:
|
||||
return false
|
||||
var stack: Array[Node] = [parent]
|
||||
while not stack.is_empty():
|
||||
var node: Node = stack.pop_back()
|
||||
if node is BaseButton:
|
||||
for conn in node.get_signal_connection_list("pressed"):
|
||||
if str(conn.get("callable", "")).contains("_clear_errors_list"):
|
||||
node.emit_signal("pressed")
|
||||
return true
|
||||
for child in node.get_children():
|
||||
stack.push_back(child)
|
||||
return false
|
||||
return _surfaced_error_tracker.clear_debugger_error_trees()
|
||||
|
||||
|
||||
func reload_plugin(_params: Dictionary) -> Dictionary:
|
||||
|
||||
@@ -5,6 +5,32 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
## Handles file read/write operations and reimport within the Godot project.
|
||||
|
||||
## Bounds for the deferred scan wait. `write_file`/`reimport` register single
|
||||
## files with `update_file()` (cheap, no global-class rebuild); `scan_filesystem`
|
||||
## is the heavier, explicit "rebuild the class registry" path agents call after
|
||||
## adding `class_name` scripts headlessly (no window focus to trigger it).
|
||||
## Kept under the dispatcher's "scan_filesystem" deferred timeout (30s) so we
|
||||
## always send a real reply before a DEFERRED_TIMEOUT is synthesised.
|
||||
const _SCAN_START_GRACE_MSEC := 750
|
||||
const _SCAN_SETTLE_MAX_MSEC := 28000
|
||||
|
||||
## Shared single-flight latch for scan_filesystem. `is_scanning()` alone can't
|
||||
## enforce single-flight: `EditorFileSystem.scan()` doesn't flip `is_scanning()`
|
||||
## for a frame or two (hence _SCAN_START_GRACE_MSEC), so a second request landing
|
||||
## in that window would observe `false` and stack another scan() — the exact
|
||||
## stacked-worker SIGABRT this op exists to avoid (dsarno/godot#6). The latch is
|
||||
## set before the first scan() and cleared when its settle coroutine finishes;
|
||||
## concurrent requests coalesce onto the running scan instead of starting one.
|
||||
## `static` so it's shared across handler instances; it resets on plugin reload
|
||||
## (script re-parse), which self-heals any latch orphaned by a mid-await teardown.
|
||||
static var _scan_in_flight := false
|
||||
|
||||
var _connection: McpConnection
|
||||
|
||||
|
||||
func _init(connection: McpConnection = null) -> void:
|
||||
_connection = connection
|
||||
|
||||
|
||||
func read_file(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
@@ -110,3 +136,108 @@ func reimport(params: Dictionary) -> Dictionary:
|
||||
"reason": "Reimport is a file system operation",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
## Force a full EditorFileSystem scan and wait for it to settle. This is the
|
||||
## headless equivalent of the editor regaining window focus: `update_file()`
|
||||
## (used by write_file/reimport/script_create) registers a single file with the
|
||||
## resource pipeline but does NOT rebuild the global `class_name` table, so a
|
||||
## freshly-created `class_name MyThing extends Resource` stays invisible to
|
||||
## `ClassDB`/`ProjectSettings.get_global_class_list()` until a scan runs. Agents
|
||||
## driving the editor without focus call this once after a batch of script
|
||||
## creates to make new types instantiable/referenceable. See issue #83.
|
||||
func scan_filesystem(params: Dictionary) -> Dictionary:
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs == null:
|
||||
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
|
||||
|
||||
var request_id: String = params.get("_request_id", "")
|
||||
# Async path: a scan can't be awaited on the calling frame without freezing
|
||||
# the editor, so hand control back to the dispatcher (DEFERRED_RESPONSE) and
|
||||
# push the real reply from a static coroutine once the scan settles — by
|
||||
# which point new class_names are registered.
|
||||
if _connection != null and not request_id.is_empty():
|
||||
_finish_scan_deferred(_connection, request_id, efs)
|
||||
return McpDispatcher.DEFERRED_RESPONSE
|
||||
|
||||
# Synchronous fallback: batch_execute (no request_id) and unit-test contexts
|
||||
# (no connection) can't await, so kick a single-flight scan and return
|
||||
# immediately without the settle confirmation. Respect the latch so we don't
|
||||
# stack onto a deferred scan; don't set it (there's no coroutine here to
|
||||
# clear it — the brief is_scanning() window covers the rest).
|
||||
var already := _scan_in_flight or efs.is_scanning()
|
||||
if not already:
|
||||
efs.scan()
|
||||
return {
|
||||
"data": {
|
||||
"scan_completed": false,
|
||||
"scan_settle": "not_waited",
|
||||
"was_already_scanning": already,
|
||||
"global_class_count": ProjectSettings.get_global_class_list().size(),
|
||||
# Present in both paths for a consistent response shape; the sync
|
||||
# path doesn't await, so it can't measure a delta.
|
||||
"global_classes_registered_delta": 0,
|
||||
"undoable": false,
|
||||
"reason": "Filesystem scan is an editor operation",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
## `static` is load-bearing for the same reason as ScriptHandler's deferred
|
||||
## finish: the coroutine must outlive the handler RefCounted, which can be freed
|
||||
## mid-await (e.g. an editor_reload_plugin fired during the scan). Parameterise
|
||||
## everything; reference no instance state.
|
||||
static func _finish_scan_deferred(
|
||||
connection: McpConnection,
|
||||
request_id: String,
|
||||
efs: EditorFileSystem,
|
||||
) -> void:
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
var tree := connection.get_tree()
|
||||
if tree == null:
|
||||
return
|
||||
var classes_before := ProjectSettings.get_global_class_list().size()
|
||||
# Single-flight via the shared `_scan_in_flight` latch (NOT is_scanning(),
|
||||
# which lags scan() by a frame or two — see the latch declaration). Only the
|
||||
# request that sets the latch calls scan(); concurrent requests coalesce and
|
||||
# just await the running scan. This is what actually prevents the stacked
|
||||
# scan() SIGABRT (dsarno/godot#6), even within the start-grace window.
|
||||
var was_already_scanning := _scan_in_flight or efs.is_scanning()
|
||||
var we_started := not was_already_scanning
|
||||
if we_started:
|
||||
_scan_in_flight = true
|
||||
efs.scan()
|
||||
# Hand back a frame so _dispatch() registers this request as deferred before
|
||||
# the coroutine can push a reply (mirrors _finish_create_script_deferred).
|
||||
await tree.process_frame
|
||||
var deadline_ms := Time.get_ticks_msec() + _SCAN_SETTLE_MAX_MSEC
|
||||
var start_grace_ms := Time.get_ticks_msec() + _SCAN_START_GRACE_MSEC
|
||||
var saw_scanning := efs.is_scanning()
|
||||
while Time.get_ticks_msec() < deadline_ms:
|
||||
if efs.is_scanning():
|
||||
saw_scanning = true
|
||||
elif saw_scanning or Time.get_ticks_msec() > start_grace_ms:
|
||||
# Either the scan ran and finished, or it never flipped is_scanning()
|
||||
# within the grace window (a no-op scan because nothing changed).
|
||||
break
|
||||
await tree.process_frame
|
||||
# Clear the latch in all paths (no try/finally in GDScript): do it before the
|
||||
# is_instance_valid early-return so a freed connection can't orphan it.
|
||||
if we_started:
|
||||
_scan_in_flight = false
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
var completed := not efs.is_scanning()
|
||||
var classes_after := ProjectSettings.get_global_class_list().size()
|
||||
connection.send_deferred_response(request_id, {
|
||||
"data": {
|
||||
"scan_completed": completed,
|
||||
"scan_settle": "settled" if completed else "timeout",
|
||||
"was_already_scanning": was_already_scanning,
|
||||
"global_class_count": classes_after,
|
||||
"global_classes_registered_delta": classes_after - classes_before,
|
||||
"undoable": false,
|
||||
"reason": "Filesystem scan is an editor operation",
|
||||
}
|
||||
})
|
||||
|
||||
@@ -18,11 +18,13 @@ func list_actions(params: Dictionary) -> Dictionary:
|
||||
## See #213.
|
||||
var user_authored := _read_user_authored_actions()
|
||||
var actions: Array[Dictionary] = []
|
||||
var seen := {}
|
||||
for action_name in InputMap.get_actions():
|
||||
var name_str := str(action_name)
|
||||
var is_user_action := user_authored.has(name_str)
|
||||
if not include_builtin and not is_user_action:
|
||||
continue
|
||||
seen[name_str] = true
|
||||
var events: Array[Dictionary] = []
|
||||
for event in InputMap.action_get_events(action_name):
|
||||
events.append(_serialize_event(event))
|
||||
@@ -31,6 +33,25 @@ func list_actions(params: Dictionary) -> Dictionary:
|
||||
"events": events,
|
||||
"event_count": events.size(),
|
||||
"is_builtin": not is_user_action,
|
||||
"loaded_in_input_map": true,
|
||||
})
|
||||
for action_name in user_authored.keys():
|
||||
var name_str := str(action_name)
|
||||
if seen.has(name_str):
|
||||
continue
|
||||
var setting: Dictionary = user_authored.get(name_str, {})
|
||||
var events: Array[Dictionary] = []
|
||||
for event in setting.get("events", []):
|
||||
if event is InputEvent:
|
||||
events.append(_serialize_event(event))
|
||||
else:
|
||||
events.append({"type": type_string(typeof(event)), "string": str(event)})
|
||||
actions.append({
|
||||
"name": name_str,
|
||||
"events": events,
|
||||
"event_count": events.size(),
|
||||
"is_builtin": false,
|
||||
"loaded_in_input_map": false,
|
||||
})
|
||||
return {"data": {"actions": actions, "count": actions.size()}}
|
||||
|
||||
@@ -43,7 +64,8 @@ func _read_user_authored_actions() -> Dictionary:
|
||||
return {}
|
||||
var result: Dictionary = {}
|
||||
for key in cfg.get_section_keys("input"):
|
||||
result[key] = true
|
||||
var value = cfg.get_value("input", key, {})
|
||||
result[key] = value if value is Dictionary else {}
|
||||
return result
|
||||
|
||||
|
||||
@@ -54,9 +76,9 @@ func add_action(params: Dictionary) -> Dictionary:
|
||||
if action.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
|
||||
|
||||
if deadzone < 0.0 or deadzone > 1.0:
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone)
|
||||
var deadzone_error := _validate_deadzone(deadzone)
|
||||
if deadzone_error.has("error"):
|
||||
return deadzone_error
|
||||
|
||||
if InputMap.has_action(action):
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Action '%s' already exists" % action)
|
||||
@@ -85,28 +107,53 @@ func add_action(params: Dictionary) -> Dictionary:
|
||||
}
|
||||
|
||||
|
||||
func ensure_action(params: Dictionary) -> Dictionary:
|
||||
var action: String = params.get("action", "")
|
||||
var deadzone: float = params.get("deadzone", 0.5)
|
||||
|
||||
if action.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
|
||||
|
||||
var deadzone_error := _validate_deadzone(deadzone)
|
||||
if deadzone_error.has("error"):
|
||||
return deadzone_error
|
||||
|
||||
var result := _ensure_action_state(action, deadzone)
|
||||
if result.has("error"):
|
||||
return result
|
||||
return {"data": result}
|
||||
|
||||
|
||||
func remove_action(params: Dictionary) -> Dictionary:
|
||||
var action: String = params.get("action", "")
|
||||
if action.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
|
||||
|
||||
if not InputMap.has_action(action):
|
||||
var key := "input/%s" % action
|
||||
var was_loaded := InputMap.has_action(action)
|
||||
var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
|
||||
|
||||
## An action can live in the editor process's InputMap, in project.godot,
|
||||
## or both. Actions persisted by a previous editor session exist only on
|
||||
## disk (`loaded_in_input_map: false` in list_actions) — those must still
|
||||
## be removable. #632
|
||||
if not was_loaded and old_setting == null:
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Action '%s' not found" % action)
|
||||
|
||||
var key := "input/%s" % action
|
||||
var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
|
||||
InputMap.erase_action(action)
|
||||
if was_loaded:
|
||||
InputMap.erase_action(action)
|
||||
|
||||
if old_setting != null:
|
||||
ProjectSettings.clear(key)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
var dz: float = old_setting.get("deadzone", 0.5) if old_setting is Dictionary else 0.5
|
||||
InputMap.add_action(action, dz)
|
||||
if old_setting is Dictionary:
|
||||
for ev in old_setting.get("events", []):
|
||||
if ev is InputEvent:
|
||||
InputMap.action_add_event(action, ev)
|
||||
if was_loaded:
|
||||
var dz: float = old_setting.get("deadzone", 0.5) if old_setting is Dictionary else 0.5
|
||||
InputMap.add_action(action, dz)
|
||||
if old_setting is Dictionary:
|
||||
for ev in old_setting.get("events", []):
|
||||
if ev is InputEvent:
|
||||
InputMap.action_add_event(action, ev)
|
||||
ProjectSettings.set_setting(key, old_setting)
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to save project settings while removing action '%s': %s (error %d)" % [action, error_string(err), err])
|
||||
@@ -115,6 +162,7 @@ func remove_action(params: Dictionary) -> Dictionary:
|
||||
"data": {
|
||||
"action": action,
|
||||
"removed": true,
|
||||
"was_loaded": was_loaded,
|
||||
"undoable": false,
|
||||
"reason": "Input actions are saved to project.godot",
|
||||
}
|
||||
@@ -157,6 +205,112 @@ func bind_event(params: Dictionary) -> Dictionary:
|
||||
}
|
||||
|
||||
|
||||
func ensure_binding(params: Dictionary) -> Dictionary:
|
||||
var action: String = params.get("action", "")
|
||||
var event_type: String = params.get("event_type", "")
|
||||
var deadzone: float = params.get("deadzone", 0.5)
|
||||
|
||||
if action.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
|
||||
if event_type.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type")
|
||||
|
||||
var deadzone_error := _validate_deadzone(deadzone)
|
||||
if deadzone_error.has("error"):
|
||||
return deadzone_error
|
||||
|
||||
var event_or_error = _create_event(event_type, params)
|
||||
if event_or_error is Dictionary:
|
||||
return event_or_error
|
||||
var event: InputEvent = event_or_error
|
||||
|
||||
var ensured := _ensure_action_state(action, deadzone)
|
||||
if ensured.has("error"):
|
||||
return ensured
|
||||
|
||||
for existing in InputMap.action_get_events(action):
|
||||
if _events_match(existing, event):
|
||||
return {
|
||||
"data": {
|
||||
"action": action,
|
||||
"event": _serialize_event(existing),
|
||||
"already_bound": true,
|
||||
"action_created": ensured.get("created", false),
|
||||
"undoable": false,
|
||||
"reason": "Input binding already exists",
|
||||
}
|
||||
}
|
||||
|
||||
InputMap.action_add_event(action, event)
|
||||
var err := _save_action_events(action)
|
||||
if err != OK:
|
||||
InputMap.action_erase_event(action, event)
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err])
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"action": action,
|
||||
"event": _serialize_event(event),
|
||||
"already_bound": false,
|
||||
"action_created": ensured.get("created", false),
|
||||
"undoable": false,
|
||||
"reason": "Input bindings are saved to project.godot",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func _validate_deadzone(deadzone: float) -> Dictionary:
|
||||
if deadzone < 0.0 or deadzone > 1.0:
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone)
|
||||
return {}
|
||||
|
||||
|
||||
func _ensure_action_state(action: String, deadzone: float) -> Dictionary:
|
||||
var key := "input/%s" % action
|
||||
var user_authored := _read_user_authored_actions()
|
||||
var existed_in_input_map := InputMap.has_action(action)
|
||||
var existed_in_project := user_authored.has(action) or ProjectSettings.has_setting(key)
|
||||
var old_setting = user_authored.get(action, null) if user_authored.has(action) else null
|
||||
if old_setting == null and ProjectSettings.has_setting(key):
|
||||
old_setting = ProjectSettings.get_setting(key)
|
||||
|
||||
if not existed_in_input_map:
|
||||
var dz := deadzone
|
||||
if old_setting is Dictionary:
|
||||
dz = float(old_setting.get("deadzone", deadzone))
|
||||
InputMap.add_action(action, dz)
|
||||
if old_setting is Dictionary:
|
||||
for ev in old_setting.get("events", []):
|
||||
if ev is InputEvent:
|
||||
InputMap.action_add_event(action, ev)
|
||||
|
||||
if not existed_in_project:
|
||||
var err := _save_action_events(action)
|
||||
if err != OK:
|
||||
if not existed_in_input_map:
|
||||
InputMap.erase_action(action)
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to save project settings while ensuring action '%s': %s (error %d)" % [action, error_string(err), err])
|
||||
|
||||
var stored_deadzone := deadzone
|
||||
if ProjectSettings.has_setting(key):
|
||||
var stored = ProjectSettings.get_setting(key)
|
||||
if stored is Dictionary:
|
||||
stored_deadzone = float(stored.get("deadzone", deadzone))
|
||||
return {
|
||||
"action": action,
|
||||
"deadzone": stored_deadzone,
|
||||
"created": not existed_in_input_map and not existed_in_project,
|
||||
"already_exists": existed_in_input_map or existed_in_project,
|
||||
"loaded_in_input_map": true,
|
||||
"persisted": true,
|
||||
"undoable": false,
|
||||
"reason": "Input actions are saved to project.godot",
|
||||
}
|
||||
|
||||
|
||||
## Returns an InputEvent on success, or a Dictionary error on failure.
|
||||
## Caller must check ``result is Dictionary`` before treating it as an event.
|
||||
func _create_event(event_type: String, params: Dictionary):
|
||||
@@ -257,6 +411,32 @@ func _serialize_event(event: InputEvent) -> Dictionary:
|
||||
return {"type": event.get_class(), "string": str(event)}
|
||||
|
||||
|
||||
func _events_match(a: InputEvent, b: InputEvent) -> bool:
|
||||
if a is InputEventKey and b is InputEventKey:
|
||||
return _key_events_match(a as InputEventKey, b as InputEventKey)
|
||||
return _serialize_event(a) == _serialize_event(b)
|
||||
|
||||
|
||||
func _key_events_match(a: InputEventKey, b: InputEventKey) -> bool:
|
||||
if a.ctrl_pressed != b.ctrl_pressed:
|
||||
return false
|
||||
if a.alt_pressed != b.alt_pressed:
|
||||
return false
|
||||
if a.shift_pressed != b.shift_pressed:
|
||||
return false
|
||||
if a.meta_pressed != b.meta_pressed:
|
||||
return false
|
||||
var a_codes := [a.keycode, a.physical_keycode]
|
||||
var b_codes := [b.keycode, b.physical_keycode]
|
||||
for a_code in a_codes:
|
||||
if int(a_code) == KEY_NONE:
|
||||
continue
|
||||
for b_code in b_codes:
|
||||
if int(b_code) != KEY_NONE and int(a_code) == int(b_code):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _save_action_events(action: String) -> int:
|
||||
var events: Array = []
|
||||
for event in InputMap.action_get_events(action):
|
||||
@@ -267,6 +447,8 @@ func _save_action_events(action: String) -> int:
|
||||
var deadzone: float = 0.5
|
||||
if old_setting is Dictionary:
|
||||
deadzone = old_setting.get("deadzone", 0.5)
|
||||
elif InputMap.has_action(action):
|
||||
deadzone = InputMap.action_get_deadzone(action)
|
||||
ProjectSettings.set_setting(key, {
|
||||
"deadzone": deadzone,
|
||||
"events": events,
|
||||
|
||||
@@ -239,16 +239,10 @@ func set_property(params: Dictionary) -> Dictionary:
|
||||
# properties. Mirrors resource_create's inline-assign path but
|
||||
# avoids a separate tool call for the common case.
|
||||
var type_str: String = value.get("__class__", "")
|
||||
var class_err := ResourceHandler._validate_resource_class(type_str)
|
||||
if class_err != null:
|
||||
return class_err
|
||||
var instance := ClassDB.instantiate(type_str)
|
||||
if instance == null or not (instance is Resource):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to instantiate %s as a Resource" % type_str
|
||||
)
|
||||
var res: Resource = instance
|
||||
var made := ResourceHandler._instantiate_resource(type_str)
|
||||
if made is Dictionary:
|
||||
return made
|
||||
var res: Resource = made
|
||||
var remaining: Dictionary = (value as Dictionary).duplicate()
|
||||
remaining.erase("__class__")
|
||||
if not remaining.is_empty():
|
||||
@@ -528,6 +522,7 @@ func _set_owner_recursive(node: Node, owner: Node) -> void:
|
||||
## is optional — the coercer defaults it to 1.0 when absent.
|
||||
const VECTOR2_KEYS: Array[String] = ["x", "y"]
|
||||
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
|
||||
const VECTOR4_KEYS: Array[String] = ["x", "y", "z", "w"]
|
||||
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
|
||||
|
||||
|
||||
@@ -556,6 +551,8 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
|
||||
ok = value is PackedVector2Array
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
ok = value is PackedVector3Array
|
||||
TYPE_PACKED_VECTOR4_ARRAY:
|
||||
ok = value is PackedVector4Array
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
ok = value is PackedColorArray
|
||||
TYPE_PACKED_INT32_ARRAY:
|
||||
@@ -568,8 +565,31 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
|
||||
ok = value is PackedFloat64Array
|
||||
TYPE_PACKED_STRING_ARRAY:
|
||||
ok = value is PackedStringArray
|
||||
TYPE_VECTOR2I: ok = value is Vector2i
|
||||
TYPE_VECTOR3I: ok = value is Vector3i
|
||||
TYPE_VECTOR4: ok = value is Vector4
|
||||
TYPE_VECTOR4I: ok = value is Vector4i
|
||||
TYPE_QUATERNION: ok = value is Quaternion
|
||||
TYPE_RECT2: ok = value is Rect2
|
||||
TYPE_RECT2I: ok = value is Rect2i
|
||||
TYPE_AABB: ok = value is AABB
|
||||
TYPE_PLANE: ok = value is Plane
|
||||
TYPE_BASIS: ok = value is Basis
|
||||
TYPE_TRANSFORM2D: ok = value is Transform2D
|
||||
TYPE_TRANSFORM3D: ok = value is Transform3D
|
||||
TYPE_PROJECTION: ok = value is Projection
|
||||
_:
|
||||
return null
|
||||
# null / untyped-TYPE_NIL / already-correct-type are handled by
|
||||
# Godot's setter; anything else would silently no-op, so error.
|
||||
if value == null or target_type == TYPE_NIL or typeof(value) == target_type:
|
||||
return null
|
||||
var unsupported := ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Cannot write %s to a %s property; godot-ai has no coercion for that type" % [
|
||||
type_string(typeof(value)), type_string(target_type),
|
||||
],
|
||||
)
|
||||
return ErrorCodes.prefix_message(unsupported, prefix)
|
||||
if ok:
|
||||
return null
|
||||
var dict_err := _check_dict_coerce_failed(value, target_type)
|
||||
@@ -597,6 +617,8 @@ static func _shape_hint(target_type: int) -> String:
|
||||
return "[{\"x\":0,\"y\":0}, ...]"
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
return "[{\"x\":0,\"y\":0,\"z\":0}, ...]"
|
||||
TYPE_PACKED_VECTOR4_ARRAY:
|
||||
return "[{\"x\":0,\"y\":0,\"z\":0,\"w\":0}, ...]"
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]"
|
||||
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
|
||||
@@ -605,6 +627,24 @@ static func _shape_hint(target_type: int) -> String:
|
||||
return "[float, ...]"
|
||||
TYPE_PACKED_STRING_ARRAY:
|
||||
return "[\"...\", ...]"
|
||||
TYPE_VECTOR2I:
|
||||
return "{\"x\":0,\"y\":0}"
|
||||
TYPE_VECTOR3I:
|
||||
return "{\"x\":0,\"y\":0,\"z\":0}"
|
||||
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
|
||||
return "{\"x\":0,\"y\":0,\"z\":0,\"w\":0}"
|
||||
TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
|
||||
return "{\"position\":{...},\"size\":{...}}"
|
||||
TYPE_PLANE:
|
||||
return "{\"normal\":{...},\"d\":0}"
|
||||
TYPE_BASIS:
|
||||
return "{\"x\":{...},\"y\":{...},\"z\":{...}}"
|
||||
TYPE_TRANSFORM2D:
|
||||
return "{\"x\":{...},\"y\":{...},\"origin\":{...}}"
|
||||
TYPE_TRANSFORM3D:
|
||||
return "{\"basis\":{...},\"origin\":{...}}"
|
||||
TYPE_PROJECTION:
|
||||
return "{\"x\":{...},\"y\":{...},\"z\":{...},\"w\":{...}}"
|
||||
var keys: Array[String] = []
|
||||
match target_type:
|
||||
TYPE_VECTOR2: keys = VECTOR2_KEYS
|
||||
@@ -666,7 +706,15 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
|
||||
if value is Dictionary and value.has_all(COLOR_KEYS):
|
||||
return Color(value["r"], value["g"], value["b"], value.get("a", 1.0))
|
||||
if value is String:
|
||||
return Color(value)
|
||||
# Color(String) silently returns black for an unparseable string
|
||||
# (e.g. "Color(1,1,1,1)" or a typo). Validate with the two-sentinel
|
||||
# from_string idiom (see theme_handler/material_values); on failure
|
||||
# fall through so the value stays a String and _check_coerced flags
|
||||
# it as an error instead of writing black.
|
||||
var col_a := Color.from_string(value, Color(0, 0, 0, 0))
|
||||
var col_b := Color.from_string(value, Color(1, 1, 1, 1))
|
||||
if col_a == col_b:
|
||||
return col_a
|
||||
TYPE_BOOL:
|
||||
if value is float or value is int:
|
||||
return bool(value)
|
||||
@@ -717,6 +765,17 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
|
||||
else:
|
||||
return value
|
||||
return out
|
||||
TYPE_PACKED_VECTOR4_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedVector4Array()
|
||||
for item in value:
|
||||
if item is Vector4:
|
||||
out.append(item)
|
||||
elif item is Dictionary and item.has_all(VECTOR4_KEYS):
|
||||
out.append(Vector4(item["x"], item["y"], item["z"], item["w"]))
|
||||
else:
|
||||
return value
|
||||
return out
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedColorArray()
|
||||
@@ -757,6 +816,72 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
|
||||
else:
|
||||
return value
|
||||
return out
|
||||
TYPE_VECTOR2I:
|
||||
if value is Dictionary and value.has_all(VECTOR2_KEYS):
|
||||
return Vector2i(int(value["x"]), int(value["y"]))
|
||||
TYPE_VECTOR3I:
|
||||
if value is Dictionary and value.has_all(VECTOR3_KEYS):
|
||||
return Vector3i(int(value["x"]), int(value["y"]), int(value["z"]))
|
||||
TYPE_VECTOR4:
|
||||
if value is Dictionary and value.has_all(VECTOR4_KEYS):
|
||||
return Vector4(value["x"], value["y"], value["z"], value["w"])
|
||||
TYPE_VECTOR4I:
|
||||
if value is Dictionary and value.has_all(VECTOR4_KEYS):
|
||||
return Vector4i(int(value["x"]), int(value["y"]), int(value["z"]), int(value["w"]))
|
||||
TYPE_QUATERNION:
|
||||
if value is Dictionary and value.has_all(VECTOR4_KEYS):
|
||||
return Quaternion(value["x"], value["y"], value["z"], value["w"])
|
||||
TYPE_RECT2:
|
||||
if value is Dictionary and value.has("position") and value.has("size"):
|
||||
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2)
|
||||
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2)
|
||||
if p is Vector2 and s is Vector2:
|
||||
return Rect2(p, s)
|
||||
TYPE_RECT2I:
|
||||
if value is Dictionary and value.has("position") and value.has("size"):
|
||||
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2I)
|
||||
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2I)
|
||||
if p is Vector2i and s is Vector2i:
|
||||
return Rect2i(p, s)
|
||||
TYPE_AABB:
|
||||
if value is Dictionary and value.has("position") and value.has("size"):
|
||||
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR3)
|
||||
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR3)
|
||||
if p is Vector3 and s is Vector3:
|
||||
return AABB(p, s)
|
||||
TYPE_PLANE:
|
||||
if value is Dictionary and value.has("normal") and value.has("d"):
|
||||
var n: Variant = _coerce_value(value["normal"], TYPE_VECTOR3)
|
||||
if n is Vector3:
|
||||
return Plane(n, float(value["d"]))
|
||||
TYPE_BASIS:
|
||||
if value is Dictionary and value.has_all(["x", "y", "z"]):
|
||||
var bx: Variant = _coerce_value(value["x"], TYPE_VECTOR3)
|
||||
var by: Variant = _coerce_value(value["y"], TYPE_VECTOR3)
|
||||
var bz: Variant = _coerce_value(value["z"], TYPE_VECTOR3)
|
||||
if bx is Vector3 and by is Vector3 and bz is Vector3:
|
||||
return Basis(bx, by, bz)
|
||||
TYPE_TRANSFORM2D:
|
||||
if value is Dictionary and value.has_all(["x", "y", "origin"]):
|
||||
var tx: Variant = _coerce_value(value["x"], TYPE_VECTOR2)
|
||||
var ty: Variant = _coerce_value(value["y"], TYPE_VECTOR2)
|
||||
var to_: Variant = _coerce_value(value["origin"], TYPE_VECTOR2)
|
||||
if tx is Vector2 and ty is Vector2 and to_ is Vector2:
|
||||
return Transform2D(tx, ty, to_)
|
||||
TYPE_TRANSFORM3D:
|
||||
if value is Dictionary and value.has("basis") and value.has("origin"):
|
||||
var b: Variant = _coerce_value(value["basis"], TYPE_BASIS)
|
||||
var o: Variant = _coerce_value(value["origin"], TYPE_VECTOR3)
|
||||
if b is Basis and o is Vector3:
|
||||
return Transform3D(b, o)
|
||||
TYPE_PROJECTION:
|
||||
if value is Dictionary and value.has_all(VECTOR4_KEYS):
|
||||
var px: Variant = _coerce_value(value["x"], TYPE_VECTOR4)
|
||||
var py: Variant = _coerce_value(value["y"], TYPE_VECTOR4)
|
||||
var pz: Variant = _coerce_value(value["z"], TYPE_VECTOR4)
|
||||
var pw: Variant = _coerce_value(value["w"], TYPE_VECTOR4)
|
||||
if px is Vector4 and py is Vector4 and pz is Vector4 and pw is Vector4:
|
||||
return Projection(px, py, pz, pw)
|
||||
# PackedByteArray intentionally unhandled — needs design decision
|
||||
# (base64 string vs. raw int list); JSON has no native byte type.
|
||||
return value
|
||||
|
||||
@@ -6,14 +6,17 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
## Handles project settings and filesystem search commands.
|
||||
|
||||
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
|
||||
const RUN_READY_WAIT_SEC := 3.0
|
||||
|
||||
var _connection: McpConnection
|
||||
var _debugger_plugin
|
||||
var _editor_log_buffer
|
||||
|
||||
|
||||
func _init(connection: McpConnection = null, debugger_plugin = null) -> void:
|
||||
func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void:
|
||||
_connection = connection
|
||||
_debugger_plugin = debugger_plugin
|
||||
_editor_log_buffer = editor_log_buffer
|
||||
|
||||
|
||||
func get_project_setting(params: Dictionary) -> Dictionary:
|
||||
@@ -81,16 +84,15 @@ func run_project(params: Dictionary) -> Dictionary:
|
||||
# stop-not-running case in telemetry). Surface state via was_already_running
|
||||
# so a caller wanting a *different* scene can detect and stop+restart.
|
||||
if EditorInterface.is_playing_scene():
|
||||
return {
|
||||
"data": {
|
||||
"mode": mode,
|
||||
"scene": params.get("scene", ""),
|
||||
"autosave": autosave,
|
||||
"was_already_running": true,
|
||||
"undoable": false,
|
||||
"reason": "Project was already running; no action taken",
|
||||
}
|
||||
}
|
||||
return _run_project_current_liveness_response(
|
||||
_run_project_base_data(
|
||||
mode,
|
||||
str(params.get("scene", "")),
|
||||
autosave,
|
||||
true,
|
||||
"Project was already running; no action taken"
|
||||
)
|
||||
)
|
||||
|
||||
var validation_error: Variant = null
|
||||
if mode == "custom":
|
||||
@@ -125,7 +127,7 @@ func run_project(params: Dictionary) -> Dictionary:
|
||||
restore_setting = true
|
||||
|
||||
if _debugger_plugin != null:
|
||||
_debugger_plugin.begin_game_run()
|
||||
_debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected())
|
||||
|
||||
match mode:
|
||||
"main":
|
||||
@@ -142,18 +144,225 @@ func run_project(params: Dictionary) -> Dictionary:
|
||||
if _connection:
|
||||
_connection.pause_processing = false
|
||||
|
||||
var base_data := _run_project_base_data(
|
||||
mode,
|
||||
str(params.get("scene", "")),
|
||||
autosave,
|
||||
false,
|
||||
"Play/stop is a runtime action"
|
||||
)
|
||||
var request_id: String = params.get("_request_id", "")
|
||||
if _connection != null and _debugger_plugin != null and not request_id.is_empty():
|
||||
_finish_run_project_deferred(request_id, base_data)
|
||||
return McpDispatcher.DEFERRED_RESPONSE
|
||||
|
||||
return _run_project_current_liveness_response(base_data)
|
||||
|
||||
|
||||
func _editor_log_cursor() -> int:
|
||||
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
|
||||
|
||||
|
||||
func _game_helper_autoload_expected() -> bool:
|
||||
return ProjectSettings.has_setting("autoload/_mcp_game_helper")
|
||||
|
||||
|
||||
func _run_project_base_data(
|
||||
mode: String,
|
||||
scene: String,
|
||||
autosave: bool,
|
||||
was_already_running: bool,
|
||||
reason: String
|
||||
) -> Dictionary:
|
||||
return {
|
||||
"data": {
|
||||
"mode": mode,
|
||||
"scene": params.get("scene", ""),
|
||||
"autosave": autosave,
|
||||
"was_already_running": false,
|
||||
"undoable": false,
|
||||
"reason": "Play/stop is a runtime action",
|
||||
}
|
||||
"mode": mode,
|
||||
"scene": scene,
|
||||
"autosave": autosave,
|
||||
"was_already_running": was_already_running,
|
||||
"undoable": false,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary:
|
||||
if _debugger_plugin == null:
|
||||
return {"data": base_data}
|
||||
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
||||
## One-shot read — force a Debugger-tab scan so boot errors that landed
|
||||
## after the last gated scan are in this response (#641).
|
||||
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
|
||||
return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info))
|
||||
|
||||
|
||||
func _finish_run_project_deferred(request_id: String, base_data: Dictionary) -> void:
|
||||
var tree := _connection.get_tree()
|
||||
while true:
|
||||
await tree.process_frame
|
||||
if not is_instance_valid(_connection):
|
||||
return
|
||||
var pre_status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
||||
if (
|
||||
not EditorInterface.is_playing_scene()
|
||||
and int(pre_status.get("elapsed_msec", 0)) > 100
|
||||
and str(pre_status.get("status", "stopped")) == "launching"
|
||||
):
|
||||
_debugger_plugin.end_game_run()
|
||||
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
||||
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
|
||||
var decision := _run_project_liveness_decision(status, errors_info)
|
||||
if not bool(decision.get("resolve", false)):
|
||||
continue
|
||||
## #641: the loop above polls with gated (cheap) scans; boot parse
|
||||
## errors can land in the Errors tab in the same frames the run goes
|
||||
## live. Re-gather once with a forced scan before replying so the
|
||||
## response reports them instead of leaving them to a later
|
||||
## logs_read. Rebuilding the decision with strictly-more errors can
|
||||
## only keep it resolved (errors never un-resolve a decision).
|
||||
errors_info = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
|
||||
decision = _run_project_liveness_decision(status, errors_info)
|
||||
_connection.send_deferred_response(request_id, _run_project_response(base_data, decision))
|
||||
return
|
||||
|
||||
|
||||
func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary:
|
||||
var data := base_data.duplicate(true)
|
||||
var game_status: Dictionary = decision.get("game_status", {})
|
||||
data["game_status"] = game_status
|
||||
data["helper_live"] = bool(game_status.get("helper_live", false))
|
||||
data["session_active"] = bool(game_status.get("session_active", false))
|
||||
if bool(data.get("was_already_running", false)):
|
||||
data["reason"] = _run_project_already_running_message(decision)
|
||||
else:
|
||||
data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action"))
|
||||
data["recent_errors"] = decision.get("recent_errors", [])
|
||||
data["recent_errors_scope"] = decision.get("recent_errors_scope", "none")
|
||||
data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false)
|
||||
data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false)
|
||||
data.merge(McpDebuggerPlugin.split_errors_by_scope(data["recent_errors"], data["recent_errors_scope"]), true)
|
||||
return {"data": data}
|
||||
|
||||
|
||||
func _run_project_already_running_message(decision: Dictionary) -> String:
|
||||
var state := str(decision.get("liveness_status", "unknown"))
|
||||
match state:
|
||||
"live":
|
||||
var live_errors: Array = decision.get("recent_errors", [])
|
||||
if not live_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
|
||||
return (
|
||||
"Project was already running; the Godot AI game helper is live, but %d editor error%s surfaced during this run (first: %s). Check logs_read(source='editor', include_details=true)."
|
||||
% [live_errors.size(), "s" if live_errors.size() != 1 else "", _format_editor_error_summary(live_errors[0])]
|
||||
)
|
||||
return "Project was already running; the Godot AI game helper is live."
|
||||
"not_live":
|
||||
var errors: Array = decision.get("recent_errors", [])
|
||||
var scope := str(decision.get("recent_errors_scope", "none"))
|
||||
if not errors.is_empty() and scope == "run":
|
||||
return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
|
||||
if not errors.is_empty():
|
||||
return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
|
||||
return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state."
|
||||
"break":
|
||||
var break_errors: Array = decision.get("recent_errors", [])
|
||||
if not break_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
|
||||
return "Project was already running but the game is parked at a debugger break: %s. Call project_manage(op='stop') to end the run, fix the error, and relaunch." % _format_editor_error_summary(break_errors[0])
|
||||
if not break_errors.is_empty():
|
||||
return "Project was already running but the game is parked at a debugger break. A recent editor error may be related, but may predate this run: %s. Call project_manage(op='stop') to end the run." % _format_editor_error_summary(break_errors[0])
|
||||
return "Project was already running but the game is parked at a debugger break. Call project_manage(op='stop') to end the run; the break reason is in the editor's Debugger panel."
|
||||
"no_helper":
|
||||
return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness."
|
||||
"launching":
|
||||
return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly."
|
||||
"stopped":
|
||||
return "Project was already marked playing by the editor, but no active game liveness run exists."
|
||||
_:
|
||||
return "Project was already running; current liveness status is %s." % state
|
||||
|
||||
|
||||
func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary:
|
||||
var enriched_status := McpDebuggerPlugin.with_liveness_flags(status)
|
||||
var state := str(status.get("status", "stopped"))
|
||||
var recent_errors: Array = errors_info.get("errors", [])
|
||||
var errors_scope := str(errors_info.get("scope", "none"))
|
||||
var truncated := bool(errors_info.get("truncated", false))
|
||||
var correlated_error := not recent_errors.is_empty() and errors_scope == "run"
|
||||
var elapsed_msec := int(status.get("elapsed_msec", 0))
|
||||
var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0)))
|
||||
var decision := {
|
||||
"resolve": false,
|
||||
"game_status": enriched_status,
|
||||
"liveness_status": state,
|
||||
"recent_errors": recent_errors,
|
||||
"recent_errors_scope": errors_scope,
|
||||
"recent_errors_may_predate_run": errors_scope == "retained_recent",
|
||||
"recent_errors_truncated": truncated,
|
||||
"message": "",
|
||||
}
|
||||
if state == "live":
|
||||
decision["resolve"] = true
|
||||
if correlated_error:
|
||||
## #641: "live" only means the helper autoload registered — scripts
|
||||
## can still have failed to parse or load during boot (a broken
|
||||
## node script does not stop the game from running). Surface those
|
||||
## errors in the success message so agents don't read a clean
|
||||
## launch into a run that silently lost scripts.
|
||||
decision["message"] = (
|
||||
"Game launched and the Godot AI game helper is live, but %d editor error%s surfaced during startup (first: %s) — likely a script that failed to parse or load. Check logs_read(source='editor', include_details=true)."
|
||||
% [recent_errors.size(), "s" if recent_errors.size() != 1 else "", _format_editor_error_summary(recent_errors[0])]
|
||||
)
|
||||
if truncated:
|
||||
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
|
||||
else:
|
||||
decision["message"] = "Game launched and the Godot AI game helper is live."
|
||||
elif state == "break":
|
||||
## #645: the game process is parked in a remote-debugger break. A
|
||||
## boot-time parse error (GDScriptLanguage::debug_break_parse) produces
|
||||
## no Errors-tab row, no Logger entry, and no game-log line — the
|
||||
## synthesized break record is the only evidence, and it lands a
|
||||
## moment after the break signal (stack frames arrive async). Wait for
|
||||
## it (correlated_error) before resolving; the ready window is the
|
||||
## fallback if synthesis never lands.
|
||||
var break_info: Dictionary = status.get("break", {})
|
||||
var break_reason := str(break_info.get("reason", ""))
|
||||
if bool(break_info.get("pre_live", true)):
|
||||
decision["resolve"] = correlated_error or elapsed_msec >= ready_wait_msec
|
||||
var summary := break_reason
|
||||
if correlated_error:
|
||||
summary = _format_editor_error_summary(recent_errors[0])
|
||||
if summary.is_empty():
|
||||
summary = "script parse/load error (reason not captured)"
|
||||
decision["message"] = "Game hit a script error during startup and is frozen at a debugger break before the Godot AI game helper registered: %s. The run cannot continue; call project_manage(op='stop'), fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % summary
|
||||
else:
|
||||
var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else ""
|
||||
decision["resolve"] = true
|
||||
decision["message"] = "Game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix
|
||||
elif correlated_error:
|
||||
decision["resolve"] = true
|
||||
decision["liveness_status"] = "not_live"
|
||||
decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
||||
if truncated:
|
||||
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
|
||||
elif state == "not_live":
|
||||
decision["resolve"] = true
|
||||
if not recent_errors.is_empty():
|
||||
decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
||||
else:
|
||||
decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state."
|
||||
elif state == "no_helper":
|
||||
decision["resolve"] = true
|
||||
decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable."
|
||||
elif state == "stopped":
|
||||
decision["resolve"] = true
|
||||
decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live."
|
||||
elif state == "launching" and elapsed_msec >= ready_wait_msec:
|
||||
decision["resolve"] = true
|
||||
decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0)
|
||||
return decision
|
||||
|
||||
|
||||
func _format_editor_error_summary(entry: Dictionary) -> String:
|
||||
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
|
||||
|
||||
|
||||
func stop_project(params: Dictionary) -> Dictionary:
|
||||
# Idempotent: a project that's already stopped satisfies the caller's intent.
|
||||
# Returning INVALID_PARAMS here was the largest single source of fleet-wide
|
||||
|
||||
@@ -178,19 +178,10 @@ func create_resource(params: Dictionary) -> Dictionary:
|
||||
return home_err
|
||||
var has_file_target := not resource_path.is_empty()
|
||||
|
||||
var class_err := _validate_resource_class(type_str)
|
||||
if class_err != null:
|
||||
return class_err
|
||||
|
||||
var instance := ClassDB.instantiate(type_str)
|
||||
if instance == null:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % type_str)
|
||||
if not (instance is Resource):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Instantiated %s but result is not a Resource (got %s)" % [type_str, instance.get_class()]
|
||||
)
|
||||
var res: Resource = instance
|
||||
var made := _instantiate_resource(type_str)
|
||||
if made is Dictionary:
|
||||
return made
|
||||
var res: Resource = made
|
||||
|
||||
if not properties.is_empty():
|
||||
var apply_err := _apply_resource_properties(res, properties)
|
||||
@@ -226,6 +217,75 @@ static func _validate_resource_class(type_str: String) -> Variant:
|
||||
return null
|
||||
|
||||
|
||||
## Build the "Unknown resource type" error with a steer toward op="scan". A type
|
||||
## that reaches here is neither an engine built-in (ClassDB) nor a registered
|
||||
## project class (the global script-class registry). In an agent-driven workflow
|
||||
## the most common cause is a `class_name` script just made via script_create
|
||||
## that isn't registered yet — the global class table only rebuilds on a
|
||||
## filesystem scan (normally an editor-focus event). Point the caller at the one
|
||||
## cheap call that fixes that, so it doesn't fall back to a full plugin reload.
|
||||
## See #614 for the headless scan op.
|
||||
static func _unknown_resource_type_error(type_str: String) -> Dictionary:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
(
|
||||
"Unknown resource type: %s — not an engine built-in or a registered project class. "
|
||||
+ "If you just created it with script_create, the global class table is stale until a "
|
||||
+ "scan: call filesystem_manage(op=\"scan\"), then retry. Otherwise check the spelling."
|
||||
) % type_str
|
||||
)
|
||||
|
||||
|
||||
## Resolve a resource type name to a fresh instance. Handles engine built-ins
|
||||
## (ClassDB) and project `class_name` Resources (the global script-class
|
||||
## registry). Returns a Resource on success, or an error dict on failure.
|
||||
static func _instantiate_resource(type_str: String) -> Variant:
|
||||
if ClassDB.class_exists(type_str):
|
||||
var class_err: Variant = _validate_resource_class(type_str)
|
||||
if class_err != null:
|
||||
return class_err
|
||||
var built_in := ClassDB.instantiate(type_str)
|
||||
if built_in == null or not (built_in is Resource):
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
|
||||
return built_in
|
||||
for entry in ProjectSettings.get_global_class_list():
|
||||
if entry.get("class", "") == type_str:
|
||||
var script_path: String = entry.get("path", "")
|
||||
var scr: Variant = load(script_path)
|
||||
# Reject non-Resource script classes BEFORE constructing them:
|
||||
# scr.new() runs _init(), and an @tool class_name extending a
|
||||
# non-RefCounted type (e.g. Node) would otherwise build — and leak —
|
||||
# an orphan instance this path never frees. get_instance_base_type()
|
||||
# resolves to the native base, so multi-level custom Resource
|
||||
# hierarchies (B extends A extends Resource) still pass.
|
||||
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
|
||||
if base_or_err is Dictionary:
|
||||
return base_or_err
|
||||
var base_type: StringName = base_or_err
|
||||
if not ClassDB.is_parent_class(base_type, "Resource"):
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
|
||||
if not scr.can_instantiate():
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated in the editor (abstract, or a non-@tool script — add @tool to instantiate it here)" % type_str)
|
||||
# Reject scripts whose _init() requires arguments BEFORE scr.new():
|
||||
# scr.new() passes no args, so a required-arg _init raises and aborts
|
||||
# this handler mid-call, null-cascading into a generic "malformed
|
||||
# result" error instead of a clean rejection. get_script_method_list()
|
||||
# reports the effective (incl. inherited) _init; required args =
|
||||
# args - default_args. Statically detectable only — a _init that runs
|
||||
# but throws still falls through to scr.new() and the dispatcher catch.
|
||||
for method in scr.get_script_method_list():
|
||||
if method.get("name", "") == "_init":
|
||||
var required_args: int = (method.get("args", []) as Array).size() - (method.get("default_args", []) as Array).size()
|
||||
if required_args > 0:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated: its _init() requires arguments" % type_str)
|
||||
break
|
||||
var made: Variant = scr.new()
|
||||
if made == null or not (made is Resource):
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
|
||||
return made
|
||||
return _unknown_resource_type_error(type_str)
|
||||
|
||||
|
||||
## Apply a dict of property values to a freshly-instantiated Resource,
|
||||
## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed.
|
||||
## Returns null on success or an error dict on failure.
|
||||
@@ -240,9 +300,17 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
|
||||
if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR:
|
||||
valid.append(prop.name)
|
||||
valid.sort()
|
||||
# Name the script's class_name (e.g. MyTestResource) rather than the
|
||||
# native base (Resource) so the hint names the type the agent created,
|
||||
# and point at the real MCP verb — resource_manage(op="get_info") now
|
||||
# answers for project class_name Resources too.
|
||||
var type_label := res.get_class()
|
||||
var res_script: Variant = res.get_script()
|
||||
if res_script is Script and not String(res_script.get_global_name()).is_empty():
|
||||
type_label = String(res_script.get_global_name())
|
||||
var err := ErrorCodes.make(
|
||||
ErrorCodes.PROPERTY_NOT_ON_CLASS,
|
||||
"Property '%s' not found on %s. Call resource_get_info('%s') to list available properties." % [key, res.get_class(), res.get_class()]
|
||||
"Property '%s' not found on %s. Call resource_manage(op=\"get_info\", params={\"type\": \"%s\"}) to list available properties." % [key, type_label, type_label]
|
||||
)
|
||||
err["error"]["data"] = {"valid_properties": valid}
|
||||
return err
|
||||
@@ -270,16 +338,15 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
|
||||
# resource_create/environment_create callers can populate
|
||||
# sub-resource slots (ShaderMaterial.shader, etc.) in one shot.
|
||||
var sub_type: String = v.get("__class__", "")
|
||||
var class_err := _validate_resource_class(sub_type)
|
||||
if class_err != null:
|
||||
return class_err
|
||||
var sub_instance := ClassDB.instantiate(sub_type)
|
||||
if sub_instance == null or not (sub_instance is Resource):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to instantiate %s as a Resource for property '%s'" % [sub_type, key]
|
||||
)
|
||||
var sub_res: Resource = sub_instance
|
||||
# Resolve via the shared helper so the nested shortcut accepts both
|
||||
# engine built-ins (ClassDB) and project `class_name` Resources,
|
||||
# exactly like the top-level resource_create path.
|
||||
var sub_made := _instantiate_resource(sub_type)
|
||||
if sub_made is Dictionary:
|
||||
# Preserve the property-slot context the inline path used to add.
|
||||
sub_made["error"]["message"] = "%s (for property '%s')" % [sub_made["error"]["message"], key]
|
||||
return sub_made
|
||||
var sub_res: Resource = sub_made
|
||||
var remaining: Dictionary = (v as Dictionary).duplicate()
|
||||
remaining.erase("__class__")
|
||||
if not remaining.is_empty():
|
||||
@@ -361,7 +428,13 @@ func get_resource_info(params: Dictionary) -> Dictionary:
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
|
||||
|
||||
if not ClassDB.class_exists(type_str):
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
|
||||
# Project class_name Resources aren't in ClassDB; resolve them through the
|
||||
# global script-class registry so get_info answers for the same custom
|
||||
# types resource_create can make. Read-only — never instantiates.
|
||||
var custom_info: Variant = _custom_resource_info(type_str)
|
||||
if custom_info != null:
|
||||
return custom_info
|
||||
return _unknown_resource_type_error(type_str)
|
||||
if ClassDB.is_parent_class(type_str, "Node"):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
@@ -396,3 +469,82 @@ func get_resource_info(params: Dictionary) -> Dictionary:
|
||||
data["concrete_subclasses"] = class_info.concrete_inheritors
|
||||
|
||||
return {"data": data}
|
||||
|
||||
|
||||
## Resolve a loaded global-class script to its native base type, or an error if
|
||||
## the script failed to load (not a Script) or to compile (empty base type).
|
||||
## Shared by the create and get_info custom-Resource paths so both report a
|
||||
## compile failure rather than a misleading "is not a Resource type (extends )".
|
||||
static func _script_base_type_or_error(scr: Variant, type_str: String, script_path: String) -> Variant:
|
||||
if not (scr is Script):
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script class %s from %s" % [type_str, script_path])
|
||||
var base_type: StringName = scr.get_instance_base_type()
|
||||
if String(base_type).is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s failed to compile or parse (script %s)" % [type_str, script_path])
|
||||
return base_type
|
||||
|
||||
|
||||
## get_info for a project `class_name` Resource (not in ClassDB). Returns an info
|
||||
## dict, an error dict (for a class_name whose native base is not a Resource), or
|
||||
## null if `type_str` is not a registered global class. Read-only: resolves
|
||||
## properties from the script + its native base WITHOUT instantiating (no _init()).
|
||||
static func _custom_resource_info(type_str: String) -> Variant:
|
||||
for entry in ProjectSettings.get_global_class_list():
|
||||
if entry.get("class", "") != type_str:
|
||||
continue
|
||||
var script_path: String = entry.get("path", "")
|
||||
var scr: Variant = load(script_path)
|
||||
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
|
||||
if base_or_err is Dictionary:
|
||||
return base_or_err
|
||||
var base_type: StringName = base_or_err
|
||||
if not ClassDB.is_parent_class(base_type, "Resource"):
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
|
||||
var can_instantiate: bool = scr.can_instantiate()
|
||||
# Inherited (native) properties come from the engine base via ClassDB...
|
||||
var class_info := ClassIntrospection.build(String(base_type), {
|
||||
"sections": ["properties"],
|
||||
"include_inherited": true,
|
||||
"limit": 0,
|
||||
})
|
||||
var props: Array = []
|
||||
for native_prop in class_info.properties:
|
||||
props.append(native_prop)
|
||||
# ...and the script's own (and inherited script) exported properties come
|
||||
# from the Script itself, so we never construct the resource. A real
|
||||
# default isn't available without instantiating, so script props carry an
|
||||
# explicit null — keeping one uniform key set across the array (native
|
||||
# props carry their real default).
|
||||
for raw_prop in scr.get_script_property_list():
|
||||
var prop: Dictionary = raw_prop
|
||||
var usage := int(prop.get("usage", 0))
|
||||
if not (usage & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
props.append({
|
||||
"name": str(prop.get("name", "")),
|
||||
"type": type_string(int(prop.get("type", TYPE_NIL))),
|
||||
"class_name": str(prop.get("class_name", "")),
|
||||
"hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
|
||||
"hint_string": str(prop.get("hint_string", "")),
|
||||
"usage": usage,
|
||||
"default": null,
|
||||
})
|
||||
props.sort_custom(func(a, b): return a.name < b.name)
|
||||
# parent_class is the immediate script parent when there is one (so a
|
||||
# multi-level chain B -> A -> Resource reports A), else the native base.
|
||||
var parent_name := String(base_type)
|
||||
var base_script: Variant = scr.get_base_script()
|
||||
if base_script is Script and not String(base_script.get_global_name()).is_empty():
|
||||
parent_name = String(base_script.get_global_name())
|
||||
return {"data": {
|
||||
"type": type_str,
|
||||
"parent_class": parent_name,
|
||||
"can_instantiate": can_instantiate,
|
||||
# is_abstract reflects real abstractness (the @abstract annotation),
|
||||
# NOT editor-instantiability — a non-@tool concrete Resource has
|
||||
# can_instantiate()==false in-editor but is not abstract.
|
||||
"is_abstract": scr.is_abstract(),
|
||||
"properties": props,
|
||||
"property_count": props.size(),
|
||||
}}
|
||||
return null
|
||||
|
||||
@@ -143,9 +143,17 @@ func create_scene(params: Dictionary) -> Dictionary:
|
||||
}
|
||||
|
||||
|
||||
## How long open_scene waits for the editor to actually switch to the
|
||||
## requested scene before replying switched=false. Tab switches normally land
|
||||
## within a few frames; keep this under the dispatcher's 4500 ms deferred
|
||||
## default so the coroutine always answers before DEFERRED_TIMEOUT fires.
|
||||
const _OPEN_SETTLE_MAX_MSEC := 3000
|
||||
|
||||
|
||||
## Open an existing scene by file path.
|
||||
func open_scene(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var force_reload: bool = params.get("force_reload", false)
|
||||
if path.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
|
||||
|
||||
@@ -156,16 +164,91 @@ func open_scene(params: Dictionary) -> Dictionary:
|
||||
if not ResourceLoader.exists(path):
|
||||
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % path)
|
||||
|
||||
EditorInterface.open_scene_from_path(path)
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": path,
|
||||
"undoable": false,
|
||||
"reason": "Scene navigation cannot be undone via editor undo",
|
||||
}
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var current_path := scene_root.scene_file_path if scene_root else ""
|
||||
## Instance id of the root at call time. A completed open OR reload always
|
||||
## replaces the edited-scene root with a NEW instance, so this is the
|
||||
## reliable completion signal — unlike scene_file_path, which is unchanged
|
||||
## across a force_reload of the already-open scene (#633 review).
|
||||
var prev_root_id := scene_root.get_instance_id() if scene_root else 0
|
||||
var payload := {
|
||||
"path": path,
|
||||
"force_reload": force_reload,
|
||||
"reloaded_from_disk": false,
|
||||
"previous_scene_path": current_path,
|
||||
"undoable": false,
|
||||
"reason": "Scene navigation cannot be undone via editor undo",
|
||||
}
|
||||
|
||||
if current_path == path and not force_reload:
|
||||
## Already the edited scene — nothing switches, reply immediately.
|
||||
payload["switched"] = true
|
||||
payload["settle"] = "already_current"
|
||||
return {"data": payload}
|
||||
|
||||
if force_reload and current_path == path:
|
||||
EditorInterface.reload_scene_from_path(path)
|
||||
payload["reloaded_from_disk"] = true
|
||||
else:
|
||||
EditorInterface.open_scene_from_path(path)
|
||||
|
||||
## The tab switch completes asynchronously; replying now lets an immediate
|
||||
## follow-up write land on the PREVIOUS scene (#633 — a scene_save issued
|
||||
## right after open_scene saved the old scene). Defer the reply until the
|
||||
## edited scene actually is `path` AND its root is a fresh instance, so
|
||||
## success means "the editor is now editing the (re)loaded scene".
|
||||
var request_id: String = params.get("_request_id", "")
|
||||
if _connection != null and not request_id.is_empty():
|
||||
_finish_open_scene_deferred(_connection, request_id, path, prev_root_id, payload)
|
||||
return McpDispatcher.DEFERRED_RESPONSE
|
||||
|
||||
## Synchronous fallback (batch_execute and unit-test contexts can't await):
|
||||
## preserve the old reply-immediately behavior, flagged as not waited on.
|
||||
payload["switched"] = false
|
||||
payload["settle"] = "not_waited"
|
||||
return {"data": payload}
|
||||
|
||||
|
||||
## `static` is load-bearing (same reason as FilesystemHandler's deferred scan
|
||||
## finish): the coroutine must outlive this RefCounted handler, which can be
|
||||
## freed mid-await by an editor_reload_plugin. Parameterise everything;
|
||||
## reference no instance state.
|
||||
static func _finish_open_scene_deferred(
|
||||
connection: McpConnection,
|
||||
request_id: String,
|
||||
path: String,
|
||||
prev_root_id: int,
|
||||
payload: Dictionary,
|
||||
) -> void:
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
var tree := connection.get_tree()
|
||||
if tree == null:
|
||||
return
|
||||
# Hand back a frame so _dispatch() registers this request as deferred
|
||||
# before the coroutine can push a reply.
|
||||
await tree.process_frame
|
||||
var deadline_ms := Time.get_ticks_msec() + _OPEN_SETTLE_MAX_MSEC
|
||||
while Time.get_ticks_msec() < deadline_ms:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
# Require BOTH the target path AND a fresh root instance: a
|
||||
# force_reload keeps scene_file_path == path across the reload, so the
|
||||
# instance swap is what proves the (re)load actually completed rather
|
||||
# than the coroutine settling on the stale pre-reload root.
|
||||
if root != null and root.scene_file_path == path and root.get_instance_id() != prev_root_id:
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
payload["switched"] = true
|
||||
payload["settle"] = "settled"
|
||||
connection.send_deferred_response(request_id, {"data": payload})
|
||||
return
|
||||
await tree.process_frame
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
payload["switched"] = false
|
||||
payload["settle"] = "timeout"
|
||||
connection.send_deferred_response(request_id, {"data": payload})
|
||||
|
||||
|
||||
## Save the currently edited scene.
|
||||
## Pauses WebSocket processing during save to prevent re-entrant _process()
|
||||
|
||||
@@ -3,7 +3,7 @@ extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_capture.gd")
|
||||
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
|
||||
const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd")
|
||||
|
||||
## Handles script creation, reading, attaching, detaching, and symbol inspection.
|
||||
|
||||
@@ -63,6 +63,33 @@ func create_script(params: Dictionary) -> Dictionary:
|
||||
}
|
||||
_attach_gdscript_diagnostics(data, path, content)
|
||||
|
||||
# A freshly-declared `class_name` is NOT in the global class table until a
|
||||
# filesystem scan runs — update_file() below registers the file with the
|
||||
# resource pipeline but not the class registry (see the scan() comment).
|
||||
# Surface that precisely (only when the class isn't already registered) so a
|
||||
# headless caller knows to follow up with filesystem_manage(op="scan")
|
||||
# instead of hitting a confusing "Unknown type" / "Unknown resource type" on
|
||||
# the very next call. We don't scan here — a scan() per create is the exact
|
||||
# SIGABRT race documented below; the explicit op is single-flight.
|
||||
# Skip the hint when the script failed to parse: a scan won't register a
|
||||
# class from a broken script, so pointing at op="scan" would steer the caller
|
||||
# away from the real fix (the parse error already attached above).
|
||||
var declared_class := _extract_class_name(content)
|
||||
if (
|
||||
not declared_class.is_empty()
|
||||
and not _script_has_error_diagnostics(data)
|
||||
and not _class_name_registered(declared_class)
|
||||
):
|
||||
data["class_name"] = declared_class
|
||||
data["class_registration"] = "scan_required"
|
||||
data["class_registration_hint"] = (
|
||||
"New class_name '%s' isn't in the global class table yet. " % declared_class
|
||||
+ "Call filesystem_manage(op=\"scan\") if it won't resolve on the next "
|
||||
+ "call (e.g. resource_manage op=\"create\", or used as a type in another "
|
||||
+ "script). The editor also registers it on its next filesystem scan or "
|
||||
+ "when its window regains focus."
|
||||
)
|
||||
|
||||
# Register just this file with the editor instead of a full recursive
|
||||
# scan(). A scan() per write stacks `update_scripts_classes` /
|
||||
# `update_script_paths_documentation` WorkerThreadPool tasks under concurrent
|
||||
@@ -142,6 +169,48 @@ static func _finish_create_script_deferred(
|
||||
connection.send_deferred_response(request_id, {"data": payload})
|
||||
|
||||
|
||||
## Extract the `class_name` a script declares, or "" if none. A cheap line scan
|
||||
## (no full parse) for create_script's "scan_required" hint. Stops at the first
|
||||
## space/tab or comma so all three valid forms yield just the name:
|
||||
## `class_name Foo`, `class_name Foo extends Bar`, and the icon form
|
||||
## `class_name Foo, "res://icon.svg"`.
|
||||
static func _extract_class_name(content: String) -> String:
|
||||
for raw_line in content.split("\n"):
|
||||
var line := raw_line.strip_edges()
|
||||
if line.begins_with("class_name "):
|
||||
var rest := line.substr(11).strip_edges()
|
||||
var cut := rest.length()
|
||||
for i in rest.length():
|
||||
var ch := rest[i]
|
||||
if ch == " " or ch == "\t" or ch == ",":
|
||||
cut = i
|
||||
break
|
||||
return rest.substr(0, cut)
|
||||
return ""
|
||||
|
||||
|
||||
## True if create_script's diagnostics captured a parse error for this script.
|
||||
## Used to suppress the "scan_required" hint when the class can't register
|
||||
## anyway — see create_script.
|
||||
static func _script_has_error_diagnostics(data: Dictionary) -> bool:
|
||||
for diag in data.get("diagnostics", []):
|
||||
if diag is Dictionary and diag.get("level", "") == "error":
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## True if `cn` is already usable as a type — an engine built-in (ClassDB) or an
|
||||
## already-registered project global class. A brand-new class_name returns false
|
||||
## until a filesystem scan registers it.
|
||||
static func _class_name_registered(cn: String) -> bool:
|
||||
if ClassDB.class_exists(cn):
|
||||
return true
|
||||
for entry in ProjectSettings.get_global_class_list():
|
||||
if entry.get("class", "") == cn:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func read_script(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
|
||||
@@ -204,21 +273,16 @@ static func _validate_gdscript_source(content: String) -> Dictionary:
|
||||
}
|
||||
|
||||
|
||||
static func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
|
||||
if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger") and OS.has_method("remove_logger")):
|
||||
return _empty_diagnostics_capture()
|
||||
var logger_script := LoggerLoader.build(LoggerLoader.VALIDATION_LOGGER_PATH)
|
||||
if logger_script == null:
|
||||
return _empty_diagnostics_capture()
|
||||
func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
|
||||
var buffer := McpEditorLogBuffer.new()
|
||||
var logger = logger_script.new(buffer)
|
||||
var logger := ValidationLogger.new(buffer)
|
||||
var capture := DiagnosticsCapture.capture_this_file(buffer, path, func() -> Dictionary:
|
||||
OS.call("add_logger", logger)
|
||||
OS.add_logger(logger)
|
||||
# ResourceLoader.load() reports parse failure instead of throwing, and
|
||||
# a failed GDScript parse does not execute user code; remove immediately
|
||||
# after the synchronous load to keep the private capture window tiny.
|
||||
ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
|
||||
OS.call("remove_logger", logger)
|
||||
OS.remove_logger(logger)
|
||||
return {}
|
||||
)
|
||||
return capture
|
||||
|
||||
@@ -152,7 +152,7 @@ func connect_signal(params: Dictionary) -> Dictionary:
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method])
|
||||
|
||||
_undo_redo.create_action("MCP: Connect signal %s" % signal_name)
|
||||
_undo_redo.add_do_method(source, "connect", signal_name, callable)
|
||||
_undo_redo.add_do_method(source, "connect", signal_name, callable, Object.CONNECT_PERSIST)
|
||||
_undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
@@ -174,9 +174,19 @@ func disconnect_signal(params: Dictionary) -> Dictionary:
|
||||
if not source.is_connected(signal_name, callable):
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method])
|
||||
|
||||
# Capture the connection's current flags so undo restores it exactly as it
|
||||
# was, not unconditionally as CONNECT_PERSIST. Hardcoding PERSIST here would
|
||||
# silently promote a runtime-only connection into one that serializes on the
|
||||
# next save. (The connection still exists at this point — checked above.)
|
||||
var reconnect_flags := 0
|
||||
for conn in source.get_signal_connection_list(signal_name):
|
||||
if conn.get("callable", Callable()) == callable:
|
||||
reconnect_flags = int(conn.get("flags", 0))
|
||||
break
|
||||
|
||||
_undo_redo.create_action("MCP: Disconnect signal %s" % signal_name)
|
||||
_undo_redo.add_do_method(source, "disconnect", signal_name, callable)
|
||||
_undo_redo.add_undo_method(source, "connect", signal_name, callable)
|
||||
_undo_redo.add_undo_method(source, "connect", signal_name, callable, reconnect_flags)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {"data": _signal_response(source, signal_name, target, method, scene_root)}
|
||||
|
||||
@@ -30,7 +30,11 @@ func run_tests(params: Dictionary) -> Dictionary:
|
||||
discovery.errors.size(),
|
||||
", ".join(discovery.errors),
|
||||
]
|
||||
return {"data": {"error": msg, "total": 0, "load_errors": discovery.errors}}
|
||||
var no_suites := {"error": msg, "total": 0, "load_errors": discovery.errors}
|
||||
## Keep the edited_scene annotation on the no-suites error payload too,
|
||||
## so the response contract is consistent across every return path.
|
||||
_annotate_edited_scene(no_suites)
|
||||
return {"data": no_suites}
|
||||
|
||||
var ctx := {
|
||||
"undo_redo": _undo_redo,
|
||||
@@ -40,9 +44,32 @@ func run_tests(params: Dictionary) -> Dictionary:
|
||||
var results := _runner.run_suites(suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter)
|
||||
if not discovery.errors.is_empty():
|
||||
results["load_errors"] = discovery.errors
|
||||
_annotate_edited_scene(results)
|
||||
return {"data": results}
|
||||
|
||||
|
||||
## Many suites assume the project's main scene is the edited scene (they read
|
||||
## /Main/... nodes directly). Running with another scene open produces a flood
|
||||
## of phantom failures that look like real regressions. Surface the edited
|
||||
## scene and a warning when it differs from run/main_scene so the failures are
|
||||
## attributable at a glance instead of costing a debugging round (#635).
|
||||
func _annotate_edited_scene(results: Dictionary) -> void:
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var edited := scene_root.scene_file_path if scene_root else ""
|
||||
results["edited_scene"] = edited
|
||||
var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", ""))
|
||||
if main_scene.is_empty() or edited == main_scene:
|
||||
return
|
||||
if int(results.get("failed", 0)) <= 0:
|
||||
return
|
||||
results["scene_warning"] = (
|
||||
"Edited scene is '%s' but the project main scene is '%s'. Many suites "
|
||||
% [edited if not edited.is_empty() else "<none>", main_scene]
|
||||
+ "assume the main scene is open and will report phantom failures "
|
||||
+ "otherwise. If these failures are unexpected, scene_open('%s') and re-run." % main_scene
|
||||
)
|
||||
|
||||
|
||||
func get_test_results(params: Dictionary) -> Dictionary:
|
||||
var verbose: bool = params.get("verbose", false)
|
||||
return {"data": _runner.get_results(verbose)}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## TileMap / TileMapLayer authoring — set, fill, clear, and read tile cells
|
||||
## directly in the editor scene with full undo/redo support.
|
||||
##
|
||||
## All ops target TileMapLayer nodes in the currently edited scene by
|
||||
## scene-relative path (e.g. "/LavaLake20x20/Ground").
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
const MAX_RECT_FILL_CELLS := 4096
|
||||
|
||||
var _undo_redo: EditorUndoRedoManager
|
||||
|
||||
|
||||
func _init(undo_redo: EditorUndoRedoManager) -> void:
|
||||
_undo_redo = undo_redo
|
||||
|
||||
|
||||
## Set a single tile cell.
|
||||
## params: {path, source_id, atlas_col, atlas_row, map_x, map_y}
|
||||
## Returns: {map_x, map_y, source_id, atlas_col, atlas_row}
|
||||
func set_cell(params: Dictionary) -> Dictionary:
|
||||
var layer := _resolve_layer(params)
|
||||
if layer.has("error"): return layer
|
||||
var node: TileMapLayer = layer.node
|
||||
var pos := Vector2i(params.get("map_x", 0), params.get("map_y", 0))
|
||||
var src := int(params.get("source_id", 0))
|
||||
var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
|
||||
var prev := _capture_cell_state(node, pos)
|
||||
_undo_redo.create_action("MCP: TileMap set_cell")
|
||||
_undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
|
||||
_undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev)
|
||||
_undo_redo.commit_action()
|
||||
return {"data": {"map_x": pos.x, "map_y": pos.y, "source_id": src,
|
||||
"atlas_col": atlas.x, "atlas_row": atlas.y, "undoable": true}}
|
||||
|
||||
|
||||
## Fill a rectangular region with one tile type in a single undo action.
|
||||
## params: {path, source_id, atlas_col, atlas_row, rect_x, rect_y, rect_w, rect_h}
|
||||
## Returns: {cells_filled, rect: {x, y, w, h}}
|
||||
func set_cells_rect(params: Dictionary) -> Dictionary:
|
||||
var layer := _resolve_layer(params)
|
||||
if layer.has("error"): return layer
|
||||
var node: TileMapLayer = layer.node
|
||||
var src := int(params.get("source_id", 0))
|
||||
var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
|
||||
var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0))
|
||||
var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1))
|
||||
if rw <= 0 or rh <= 0:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"rect_w and rect_h must be > 0 (got %d x %d)" % [rw, rh]
|
||||
)
|
||||
var cell_count := rw * rh
|
||||
if cell_count > MAX_RECT_FILL_CELLS:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"Rect too large: %d cells exceeds max %d" % [cell_count, MAX_RECT_FILL_CELLS]
|
||||
)
|
||||
var cells: Array[Vector2i] = []
|
||||
var snapshot: Array[Dictionary] = []
|
||||
for x in range(rx, rx + rw):
|
||||
for y in range(ry, ry + rh):
|
||||
var pos := Vector2i(x, y)
|
||||
cells.append(pos)
|
||||
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
|
||||
_undo_redo.create_action("MCP: TileMap set_cells_rect %dx%d" % [rw, rh])
|
||||
for pos in cells:
|
||||
_undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
|
||||
_undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot)
|
||||
_undo_redo.commit_action()
|
||||
return {"data": {"cells_filled": cells.size(),
|
||||
"rect": {"x": rx, "y": ry, "w": rw, "h": rh}, "undoable": true}}
|
||||
|
||||
|
||||
## Remove all tiles from a TileMapLayer.
|
||||
## params: {path}
|
||||
## Returns: {cleared: true}
|
||||
func clear_layer(params: Dictionary) -> Dictionary:
|
||||
var layer := _resolve_layer(params)
|
||||
if layer.has("error"): return layer
|
||||
var node: TileMapLayer = layer.node
|
||||
var snapshot := _capture_used_cells_snapshot(node)
|
||||
_undo_redo.create_action("MCP: TileMap clear")
|
||||
_undo_redo.add_do_method(node, "clear")
|
||||
_undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot)
|
||||
_undo_redo.commit_action()
|
||||
return {"data": {"cleared": true, "undoable": true}}
|
||||
|
||||
|
||||
## Return all used cell coordinates.
|
||||
## params: {path}
|
||||
## Returns: {cells: [{x, y}, ...], count: int}
|
||||
func get_used_cells(params: Dictionary) -> Dictionary:
|
||||
var layer := _resolve_layer(params)
|
||||
if layer.has("error"): return layer
|
||||
var node: TileMapLayer = layer.node
|
||||
var cells := node.get_used_cells()
|
||||
var result: Array = []
|
||||
for c in cells:
|
||||
result.append({"x": c.x, "y": c.y})
|
||||
return {"data": {"cells": result, "count": result.size()}}
|
||||
|
||||
|
||||
## Resolve a TileMapLayer node from params["path"] in the currently edited
|
||||
## scene. Returns {"node": TileMapLayer} on success, or an error dict.
|
||||
func _resolve_layer(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var scene_file: String = params.get("scene_file", "")
|
||||
var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
if not node is TileMapLayer:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||
"Node is not a TileMapLayer: %s" % path)
|
||||
return {"node": node}
|
||||
|
||||
|
||||
func _capture_cell_state(node: TileMapLayer, pos: Vector2i) -> Dictionary:
|
||||
var source_id := node.get_cell_source_id(pos)
|
||||
if source_id == -1:
|
||||
return {"has_tile": false}
|
||||
var atlas: Vector2i = node.get_cell_atlas_coords(pos)
|
||||
var alternative := node.get_cell_alternative_tile(pos)
|
||||
return {
|
||||
"has_tile": true,
|
||||
"source_id": source_id,
|
||||
"atlas_col": atlas.x,
|
||||
"atlas_row": atlas.y,
|
||||
"alternative": alternative,
|
||||
}
|
||||
|
||||
|
||||
func _capture_used_cells_snapshot(node: TileMapLayer) -> Array[Dictionary]:
|
||||
var snapshot: Array[Dictionary] = []
|
||||
for pos in node.get_used_cells():
|
||||
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
|
||||
return snapshot
|
||||
|
||||
|
||||
func _restore_cells_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
|
||||
node.clear()
|
||||
for entry in snapshot:
|
||||
_restore_cell_state(node, entry.pos, entry.state)
|
||||
|
||||
|
||||
func _restore_rect_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
|
||||
for entry in snapshot:
|
||||
_restore_cell_state(node, entry.pos, entry.state)
|
||||
|
||||
|
||||
func _restore_cell_state(node: TileMapLayer, pos: Vector2i, state: Dictionary) -> void:
|
||||
if not state.get("has_tile", false):
|
||||
node.erase_cell(pos)
|
||||
return
|
||||
node.set_cell(
|
||||
pos,
|
||||
int(state.get("source_id", -1)),
|
||||
Vector2i(int(state.get("atlas_col", -1)), int(state.get("atlas_row", -1))),
|
||||
int(state.get("alternative", 0))
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cm8s7a0ey2q6k
|
||||
@@ -0,0 +1,174 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## TileSet management — atlas inspection helpers.
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
pass
|
||||
|
||||
|
||||
## Query all occupied atlas tile positions for a single source.
|
||||
##
|
||||
## params:
|
||||
## tileset_path — res:// path to the TileSet resource (required, non-empty)
|
||||
## source_id — raw TileSet source id (required)
|
||||
##
|
||||
## Returns:
|
||||
## {"data": {"tiles": [{"col": int, "row": int}, ...], "count": int}}
|
||||
## on success (including empty sources, where tiles=[] and count=0)
|
||||
## ErrorCodes.make(code, message) on any validation or load failure
|
||||
##
|
||||
## Error codes:
|
||||
## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
|
||||
## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
|
||||
## WRONG_TYPE — loaded resource is not a TileSet, or source is
|
||||
## not a TileSetAtlasSource
|
||||
## VALUE_OUT_OF_RANGE — source_id not present in TileSet
|
||||
##
|
||||
## This method is read-only: it never calls ResourceSaver or modifies any resource.
|
||||
func get_atlas_tiles(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_atlas_source(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var src: TileSetAtlasSource = resolved.src
|
||||
|
||||
var tiles: Array = []
|
||||
for i in range(src.get_tiles_count()):
|
||||
var v: Vector2i = src.get_tile_id(i)
|
||||
tiles.append({"col": v.x, "row": v.y})
|
||||
|
||||
return {"data": {"tiles": tiles, "count": tiles.size()}}
|
||||
|
||||
|
||||
## Return the atlas texture of a TileSetAtlasSource as a Base64-encoded PNG.
|
||||
##
|
||||
## params:
|
||||
## tileset_path — res:// path to the TileSet resource (required, non-empty)
|
||||
## source_id — raw TileSet source id (required)
|
||||
## max_size — optional int; if > 0, the image is scaled so its longest
|
||||
## edge is at most max_size pixels (default 0 = full res)
|
||||
##
|
||||
## Returns:
|
||||
## {"data": {"image_base64": String, "width": int, "height": int,
|
||||
## "original_width": int, "original_height": int, "format": "png"}}
|
||||
## on success
|
||||
## ErrorCodes.make(code, message) on any validation or load failure
|
||||
##
|
||||
## Error codes:
|
||||
## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
|
||||
## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
|
||||
## WRONG_TYPE — loaded resource is not a TileSet, or source is
|
||||
## not a TileSetAtlasSource, or texture is null
|
||||
## VALUE_OUT_OF_RANGE — source_id not present in TileSet
|
||||
##
|
||||
## This method is read-only: it never calls ResourceSaver or modifies anything.
|
||||
func get_atlas_image(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_atlas_source(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var source_id: int = resolved.source_id
|
||||
var src: TileSetAtlasSource = resolved.src
|
||||
|
||||
var tex: Texture2D = src.texture
|
||||
if tex == null:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Source %d has no texture assigned" % source_id
|
||||
)
|
||||
|
||||
var img: Image = tex.get_image()
|
||||
if img == null:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Could not retrieve image data from texture of source %d" % source_id
|
||||
)
|
||||
if img.is_compressed():
|
||||
var decompress_err := img.decompress()
|
||||
if decompress_err != OK:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Could not decompress texture of source %d: %s" % [source_id, error_string(decompress_err)]
|
||||
)
|
||||
|
||||
var original_width: int = img.get_width()
|
||||
var original_height: int = img.get_height()
|
||||
|
||||
var max_size: int = params.get("max_size", 0)
|
||||
if max_size > 0:
|
||||
var longest_edge: int = max(original_width, original_height)
|
||||
if longest_edge > max_size:
|
||||
var scale: float = float(max_size) / float(longest_edge)
|
||||
var new_w: int = max(1, int(original_width * scale))
|
||||
var new_h: int = max(1, int(original_height * scale))
|
||||
img.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
|
||||
|
||||
var png_bytes: PackedByteArray = img.save_png_to_buffer()
|
||||
if png_bytes.is_empty():
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"PNG encoding produced empty output for source %d" % source_id
|
||||
)
|
||||
var b64: String = Marshalls.raw_to_base64(png_bytes)
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"image_base64": b64,
|
||||
"width": img.get_width(),
|
||||
"height": img.get_height(),
|
||||
"original_width": original_width,
|
||||
"original_height": original_height,
|
||||
"format": "png",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func _resolve_atlas_source(params: Dictionary) -> Dictionary:
|
||||
var tileset_path: String = params.get("tileset_path", "")
|
||||
if tileset_path.is_empty():
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.MISSING_REQUIRED_PARAM,
|
||||
"'tileset_path' parameter is required and must not be empty"
|
||||
)
|
||||
|
||||
if not params.has("source_id"):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.MISSING_REQUIRED_PARAM,
|
||||
"'source_id' parameter is required"
|
||||
)
|
||||
|
||||
if not ResourceLoader.exists(tileset_path):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.RESOURCE_NOT_FOUND,
|
||||
"TileSet resource not found: %s" % tileset_path
|
||||
)
|
||||
|
||||
var ts = load(tileset_path)
|
||||
if not ts is TileSet:
|
||||
var loaded_type := "null" if ts == null else ts.get_class()
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Resource at '%s' is not a TileSet (got %s)" % [tileset_path, loaded_type]
|
||||
)
|
||||
|
||||
var source_id: int = int(params.get("source_id", -999))
|
||||
if source_id < 0 or not ts.has_source(source_id):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"source_id %d does not exist in TileSet" % source_id
|
||||
)
|
||||
|
||||
var src = ts.get_source(source_id)
|
||||
if not src is TileSetAtlasSource:
|
||||
var source_type: String = "null" if src == null else src.get_class()
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Source %d is not a TileSetAtlasSource (got %s)" % [source_id, source_type]
|
||||
)
|
||||
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"src": src,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://de3v4m1pnk7tr
|
||||
Reference in New Issue
Block a user