Skip to content

Commit d57e05e

Browse files
authored
editor_screenshot: stop returning INTERNAL_ERROR on 2D/empty viewports (#456)
* editor_screenshot: stop returning INTERNAL_ERROR on 2D/empty viewports Telemetry showed editor_screenshot returning INTERNAL_ERROR on 63 unique installs / 152 hits in 24h, persisting across every plugin version 2.5.0 -> 2.5.6. The per-uuid distribution is broad and shallow (25 uuids hit once, 32 hit 2-4 times, only 1 looped 11x), so LLMs try the default source once, get an unhelpful "Failed to capture image from viewport" with no signal that the failure is caller-side, and give up. Two changes ship together: 1. Pre-check the edited scene root before pulling the 3D viewport texture. When source="viewport" and the root is Node2D, Control, plain Node, or null (no scene open), return EDITOR_NOT_READY with `error.data = {editor_state: "viewport_not_3d", scene_root_type, hint}` carrying an actionable hint (open a 3D scene, switch to source="cinematic" if a Camera3D exists, or call scene_get_hierarchy first). The 3D viewport's texture is empty for 2D-rooted scenes, and the previous empty-image guard returned INTERNAL_ERROR — same shape we use for genuine server bugs, with no caller-actionable signal. 2. Reclassify the three remaining empty-image returns that AREN'T caller-side debugger-bridge wiring failures: - "Failed to capture image from viewport" (main path) - "Framed viewport rendered an empty image" (view_target path) - "Coverage sweep rendered no images" - "Cinematic render produced an empty image" These are precondition failures (headless mode, viewport not drawn yet, scene-camera misframed) and now return EDITOR_NOT_READY + `error.data = {editor_state: "viewport_empty", source, hint}`. The two debugger-bridge wiring errors for source="game" stay INTERNAL_ERROR — those are genuine server-side failures. Tool docstring updated to surface the 2D/no-scene constraint and Camera3D requirement up front. Tests: 6 new GDScript test cases for viewport_screenshot_precheck covering Node3D root (passes), Node2D / Control / plain Node / null roots (all return the structured EDITOR_NOT_READY), and Node3D subclass (passes). 2 new Python handler tests verify the viewport_not_3d and viewport_empty error.data shapes propagate through GodotCommandError to the MCP client. * editor_screenshot: address review feedback on #456 - Fix #XXX placeholder -> #456 in test header. - Drop redundant `hint` key from `error.data` in both viewport_not_3d and viewport_empty error helpers. The hint copy already lives in `error.message`, and GodotCommandError's string form appends every `data` key -- keeping the hint in both surfaced the same paragraph twice to the LLM. Tests now assert on `error.message` for the hint content. - Make `_empty_image_error` static for consistency with the other error-builder helpers; none reference self.
1 parent 7b83c97 commit d57e05e

4 files changed

Lines changed: 232 additions & 6 deletions

File tree

plugin/addons/godot_ai/handlers/editor_handler.gd

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,16 @@ func take_screenshot(params: Dictionary) -> Dictionary:
279279
viewport = EditorInterface.get_editor_viewport_3d()
280280
if viewport == null:
281281
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No 3D viewport available")
282+
## The 3D viewport's texture is empty when the edited scene
283+
## has no Node3D content (2D-only scene, or no scene open),
284+
## and the empty-image guard further down used to surface
285+
## that as INTERNAL_ERROR — leaving callers with no signal
286+
## that the failure was caller-side. Reject up front with a
287+
## structured hint so the LLM can pick a sensible next step
288+
## (open a 3D scene, switch to source="cinematic", etc.).
289+
var precheck := viewport_screenshot_precheck(EditorInterface.get_edited_scene_root())
290+
if precheck.has("error"):
291+
return precheck
282292
"game":
283293
if not EditorInterface.is_playing_scene():
284294
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Game is not running — use source='viewport' or start the project first")
@@ -385,7 +395,10 @@ func take_screenshot(params: Dictionary) -> Dictionary:
385395
## Consistent with single-shot path: error if no frames rendered
386396
## (e.g. headless mode where force_draw produces no output).
387397
if images.is_empty():
388-
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Coverage sweep rendered no images")
398+
return _empty_image_error(
399+
"viewport",
400+
"Coverage sweep rendered no images. The 3D viewport produced no output across any of the preset angles — typically because the editor is in headless mode (force_draw has no rendered output) or the 3D viewport has not drawn a frame yet."
401+
)
389402

390403
var aabb_center := combined_aabb.get_center()
391404
var aabb_size := combined_aabb.size
@@ -423,7 +436,10 @@ func take_screenshot(params: Dictionary) -> Dictionary:
423436
RenderingServer.camera_set_transform(cam_rid, saved_xform)
424437

425438
if image == null or image.is_empty():
426-
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Framed viewport rendered an empty image")
439+
return _empty_image_error(
440+
"viewport",
441+
"Framed viewport rendered an empty image after repositioning the camera onto the view_target. The 3D viewport produced no output — typically headless mode or the 3D viewport has not drawn a frame yet."
442+
)
427443

428444
var result := _finalize_image(image, "viewport", max_resolution)
429445
result.data["view_target"] = view_target
@@ -445,7 +461,10 @@ func take_screenshot(params: Dictionary) -> Dictionary:
445461
var image: Image = viewport.get_texture().get_image()
446462

447463
if image == null or image.is_empty():
448-
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to capture image from %s" % source)
464+
return _empty_image_error(
465+
source,
466+
"Captured an empty image from %s. The 3D viewport produced no output — typically headless mode or the 3D viewport has not drawn a frame yet." % source
467+
)
449468

450469
return _finalize_image(image, source, max_resolution)
451470

@@ -507,13 +526,81 @@ func _take_cinematic_screenshot(max_resolution: int) -> Dictionary:
507526
sub_vp.queue_free()
508527

509528
if image == null or image.is_empty():
510-
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Cinematic render produced an empty image")
529+
return _empty_image_error(
530+
"cinematic",
531+
"Cinematic render produced an empty image. The SubViewport returned no texture — typically headless mode (force_draw has no rendered output) or the scene's Camera3D is positioned so nothing visible is in frame."
532+
)
511533

512534
var result := _finalize_image(image, "cinematic", max_resolution)
513535
result.data["camera_path"] = McpScenePath.from_node(scene_camera, scene_root)
514536
return result
515537

516538

539+
## Reject a `source="viewport"` screenshot before we ever pull the
540+
## texture if the edited scene has no Node3D content. The 3D viewport
541+
## returns an empty (or stale) image in that case; surfacing it as
542+
## INTERNAL_ERROR ("Failed to capture image from viewport") gave LLM
543+
## callers no signal that the right move is to switch source or open a
544+
## 3D scene. 152 hits / 63 uuids in 24h across plugin versions 2.5.0 ->
545+
## 2.5.6 traced back to this. Returns `{}` on success.
546+
##
547+
## Caller passes `EditorInterface.get_edited_scene_root()`; the static
548+
## form lets tests exercise the branches with a synthetic scene root
549+
## without driving the editor.
550+
static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary:
551+
if scene_root == null:
552+
return _make_viewport_not_3d_error(
553+
"",
554+
"The editor 3D viewport is empty because no scene is open. Open a scene with `scene_open` first."
555+
)
556+
if scene_root is Node3D:
557+
return {}
558+
## Anything that isn't a Node3D — Node2D, Control, plain Node — leaves
559+
## the 3D viewport with nothing to render. Report the real root type
560+
## so the caller can tell why the default source failed.
561+
var root_type := scene_root.get_class()
562+
var hint: String
563+
if scene_root is Node2D or scene_root is Control:
564+
hint = (
565+
"The 3D viewport is empty because the current scene is 2D (%s root). "
566+
+ "Options: (a) open a 3D scene, "
567+
+ "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
568+
+ "(c) call scene_get_hierarchy first to inspect what's available."
569+
) % root_type
570+
else:
571+
hint = (
572+
"The 3D viewport is empty because the current scene root (%s) has no Node3D content. "
573+
+ "Options: (a) open a scene whose root is a Node3D, "
574+
+ "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
575+
+ "(c) call scene_get_hierarchy first to inspect what's available."
576+
) % root_type
577+
return _make_viewport_not_3d_error(root_type, hint)
578+
579+
580+
static func _make_viewport_not_3d_error(scene_root_type: String, hint: String) -> Dictionary:
581+
## `hint` becomes `error.message`; not duplicated into `data` because
582+
## `GodotCommandError`'s string form already appends every `data` key
583+
## as a suffix on the agent-visible error.
584+
var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, hint)
585+
err["error"]["data"] = {
586+
"editor_state": "viewport_not_3d",
587+
"scene_root_type": scene_root_type,
588+
}
589+
return err
590+
591+
592+
## Reached only when the precheck passed but the texture still came
593+
## back empty — headless rendering, a freshly opened editor whose 3D
594+
## viewport hasn't drawn a frame, or a SubViewport that lost its target.
595+
static func _empty_image_error(source: String, hint: String) -> Dictionary:
596+
var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, hint)
597+
err["error"]["data"] = {
598+
"editor_state": "viewport_empty",
599+
"source": source,
600+
}
601+
return err
602+
603+
517604
## Return the Camera3D that would be active if the scene were running.
518605
## Preference: a descendant with `current=true`, else the first Camera3D
519606
## found in a depth-first walk.

src/godot_ai/tools/editor.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,19 @@ async def editor_screenshot(
129129
):
130130
"""Capture a screenshot of the Godot editor viewport or running game.
131131
132+
Picking a source: the default ``"viewport"`` only works for a 3D-rooted
133+
scene — the editor's 3D viewport is what gets captured. A 2D scene
134+
(Node2D/Control root) or an editor with no scene open returns
135+
``EDITOR_NOT_READY`` carrying ``error.data = {editor_state:
136+
"viewport_not_3d", scene_root_type, hint}``; switch to ``"cinematic"``
137+
if the scene has a Camera3D, or open a 3D scene first.
138+
132139
Sources:
133-
- "viewport" (default): editor 3D viewport.
140+
- "viewport" (default): editor 3D viewport. Requires a 3D-rooted scene
141+
currently being edited; see above for the 2D / no-scene error shape.
134142
- "cinematic": render edited scene through its current Camera3D
135-
(no editor gizmos). INVALID_PARAMS if no current Camera3D.
143+
(no editor gizmos). Requires a Camera3D in the scene; NODE_NOT_FOUND
144+
if none is marked ``current``.
136145
- "game": running game's framebuffer (only when project is running).
137146
138147
``include_image=True`` (default) returns an MCP ImageContent block.

test_project/tests/test_editor.gd

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,71 @@ func test_screenshot_game_not_playing() -> void:
128128
assert_is_error(result)
129129

130130

131+
# ----- viewport_screenshot_precheck (#456: stop returning INTERNAL_ERROR on 2D) -----
132+
133+
func test_viewport_precheck_passes_for_node3d_root() -> void:
134+
## Happy path: scene_root is Node3D, precheck returns empty dict so
135+
## take_screenshot falls through to its normal capture path.
136+
var root := Node3D.new()
137+
var result := EditorHandler.viewport_screenshot_precheck(root)
138+
root.free()
139+
assert_eq(result, {}, "Node3D root should return {} (no error)")
140+
141+
142+
func test_viewport_precheck_rejects_node2d_root() -> void:
143+
var root := Node2D.new()
144+
var result := EditorHandler.viewport_screenshot_precheck(root)
145+
root.free()
146+
assert_is_error(result, ErrorCodes.EDITOR_NOT_READY)
147+
assert_has_key(result.error, "data")
148+
assert_eq(result.error.data.editor_state, "viewport_not_3d")
149+
assert_eq(result.error.data.scene_root_type, "Node2D")
150+
## The message must mention the 2D nature, cinematic alternative, and
151+
## scene_get_hierarchy so the LLM can actually act on it.
152+
assert_contains(result.error.message, "Node2D")
153+
assert_contains(result.error.message, "cinematic")
154+
assert_contains(result.error.message, "scene_get_hierarchy")
155+
156+
157+
func test_viewport_precheck_rejects_control_root() -> void:
158+
var root := Control.new()
159+
var result := EditorHandler.viewport_screenshot_precheck(root)
160+
root.free()
161+
assert_is_error(result, ErrorCodes.EDITOR_NOT_READY)
162+
assert_eq(result.error.data.editor_state, "viewport_not_3d")
163+
assert_eq(result.error.data.scene_root_type, "Control")
164+
165+
166+
func test_viewport_precheck_rejects_plain_node_root() -> void:
167+
## A scene rooted at a plain Node has no Node3D content and no 2D
168+
## either — still no 3D viewport content, so still rejected, but the
169+
## hint phrasing is the generic non-3D form rather than the 2D one.
170+
var root := Node.new()
171+
var result := EditorHandler.viewport_screenshot_precheck(root)
172+
root.free()
173+
assert_is_error(result, ErrorCodes.EDITOR_NOT_READY)
174+
assert_eq(result.error.data.editor_state, "viewport_not_3d")
175+
assert_eq(result.error.data.scene_root_type, "Node")
176+
assert_contains(result.error.message, "no Node3D content")
177+
178+
179+
func test_viewport_precheck_rejects_null_scene() -> void:
180+
var result := EditorHandler.viewport_screenshot_precheck(null)
181+
assert_is_error(result, ErrorCodes.EDITOR_NOT_READY)
182+
assert_eq(result.error.data.editor_state, "viewport_not_3d")
183+
assert_eq(result.error.data.scene_root_type, "")
184+
assert_contains(result.error.message, "no scene is open")
185+
186+
187+
func test_viewport_precheck_passes_for_node3d_subclass() -> void:
188+
## Camera3D extends Node3D — a scene rooted at any Node3D subclass
189+
## should pass the precheck (the 3D viewport will render it).
190+
var root := Camera3D.new()
191+
var result := EditorHandler.viewport_screenshot_precheck(root)
192+
root.free()
193+
assert_eq(result, {}, "Node3D subclass root should pass")
194+
195+
131196
func test_debugger_plugin_capture_prefix() -> void:
132197
var plugin := McpDebuggerPlugin.new()
133198
assert_true(plugin._has_capture("mcp"), "Should accept 'mcp' prefix")

tests/unit/test_runtime_handlers.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3369,6 +3369,71 @@ async def test_editor_screenshot_handler_cinematic_surfaces_camera_path():
33693369
assert result["camera_path"] == "/Main/Camera3D"
33703370

33713371

3372+
async def test_editor_screenshot_handler_relays_viewport_not_3d_error():
3373+
"""When the plugin rejects a viewport screenshot on a 2D scene with the
3374+
new structured EDITOR_NOT_READY + data.editor_state=viewport_not_3d, the
3375+
handler must surface the data dict on the raised GodotCommandError so
3376+
LLM callers see the hint and can switch source or open a 3D scene.
3377+
Fixes the 152-hit / 63-uuid INTERNAL_ERROR cluster on editor_screenshot.
3378+
"""
3379+
from godot_ai.godot_client.client import GodotCommandError
3380+
3381+
class ViewportNot3DClient:
3382+
async def send(self, command, params=None, session_id=None, timeout=5.0):
3383+
raise GodotCommandError(
3384+
code="EDITOR_NOT_READY",
3385+
message=(
3386+
"The 3D viewport is empty because the current scene is 2D "
3387+
"(Node2D root). Options: (a) open a 3D scene, "
3388+
"(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
3389+
"(c) call scene_get_hierarchy first to inspect what's available."
3390+
),
3391+
data={
3392+
"editor_state": "viewport_not_3d",
3393+
"scene_root_type": "Node2D",
3394+
},
3395+
)
3396+
3397+
runtime = DirectRuntime(registry=SessionRegistry(), client=ViewportNot3DClient())
3398+
with pytest.raises(GodotCommandError) as excinfo:
3399+
await editor_handlers.editor_screenshot(runtime, include_image=False)
3400+
err = excinfo.value
3401+
assert err.code == "EDITOR_NOT_READY"
3402+
assert err.data["editor_state"] == "viewport_not_3d"
3403+
assert err.data["scene_root_type"] == "Node2D"
3404+
## Hint copy lives in `message` (not duplicated into `data`); the LLM
3405+
## still sees it via str(err) since GodotCommandError formats the
3406+
## message + data suffix.
3407+
assert "scene_get_hierarchy" in err.message
3408+
3409+
3410+
async def test_editor_screenshot_handler_relays_viewport_empty_error():
3411+
"""The fallback empty-image guards (post-precheck) now return
3412+
EDITOR_NOT_READY + data.editor_state=viewport_empty instead of
3413+
INTERNAL_ERROR. Verify the data dict propagates through the handler.
3414+
"""
3415+
from godot_ai.godot_client.client import GodotCommandError
3416+
3417+
class ViewportEmptyClient:
3418+
async def send(self, command, params=None, session_id=None, timeout=5.0):
3419+
raise GodotCommandError(
3420+
code="EDITOR_NOT_READY",
3421+
message="Captured an empty image from viewport.",
3422+
data={
3423+
"editor_state": "viewport_empty",
3424+
"source": "viewport",
3425+
},
3426+
)
3427+
3428+
runtime = DirectRuntime(registry=SessionRegistry(), client=ViewportEmptyClient())
3429+
with pytest.raises(GodotCommandError) as excinfo:
3430+
await editor_handlers.editor_screenshot(runtime, include_image=False)
3431+
err = excinfo.value
3432+
assert err.code == "EDITOR_NOT_READY"
3433+
assert err.data["editor_state"] == "viewport_empty"
3434+
assert err.data["source"] == "viewport"
3435+
3436+
33723437
# ---------------------------------------------------------------------------
33733438
# Performance monitor handler tests
33743439
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)