Editor and runtime screenshot: collapsed-viewport remediation
A screenshot that cannot produce a usable frame now distinguishes and acts on the cause instead of returning a misleading tiny/blank PNG.
Godot clamps every viewport to a hard 2x2 floor (Size2i new_size = p_size.max(Size2i(2, 2)), viewport.cpp), and the editor’s central viewport is a SubViewport (get_editor_viewport_2d() == the scene-root viewport). So when the editor lays out no 2D/3D canvas — because a non-2D/3D screen (Script / AssetLib / Output) is the active main screen, or because the window is minimized and composites nothing — a capture succeeds but bottoms out at a ~81-byte 2x2 PNG. This is a valid success, not headless (headless short-circuits with HEADLESS_UNSUPPORTED before any capture, so any returned PNG rules headless out) and not a tool bug — but a blank frame is worthless, and an earlier reactive warning field still handed one back. The running game’s window never collapses (it is a real OS window, full dims windowed/unfocused/minimized), so only the minimized case has a runtime analogue.
Decision
Detect the cause up front and act on it:
- A wrong editor main screen (2x2 floor, not minimized) is auto-healed — switch to the 2D/3D main screen (keyed on the target node’s type: 3D for a
Node3D, else 2D), re-capture across a bounded frame loop, and disclose via a success-sideremediation: ["switched_main_screen"]field. There is no getter for the prior main-screen name (editor_interface.h), so the switch is one-way; theremediationfield is the disclosure rather than a fragile restore. - Unrecoverable states return first-class error codes instead of a tiny/blank PNG: a minimized editor →
EDITOR_VIEWPORT_UNAVAILABLE; a minimized game window →RUNTIME_WINDOW_MINIMIZED. Distinct codes (not one shared code) because the two failures carry different retry params, so a 1:1 code→tool→param mapping helps a grading agent. - Both minimized cases are detected up front via
DisplayServer.window_get_mode, before anyawait frame_post_draw— a suspended-render window never fires that signal, so an un-guarded await would hang the handler until the server’s 30 s call timeout (which reads as a dead editor). This replaces a latent hang with an immediate, diagnostic signal — net-safer. - An opt-in
force_foreground_*param (default off) on each tool un-minimizes + raises + focuses the window before capturing (window_set_mode(WINDOWED, 0)only-if-minimized so a maximized window is not un-maximized, +window_move_to_foreground(0)+Window.grab_focus(); the 4.6+-deprecatedWindow.move_to_foreground()is deliberately avoided). Default-off so an interactive user’s window is never yanked and parallel game instances don’t fight for focus; the terminal-driven agent opts in. - The runtime handler returns the last-drawn frame when a bounded
frame_post_drawawait lapses on a non-minimized (idle, redraw-on-demand) window, because a compositing window’s last frame is its current state — the stale-frame risk only ever applied to the minimized/suspended case, which is handled up front. This also fixes a pre-existing hang where an idle redraw-on-demand game never firedframe_post_draw.
The collapse decision is extracted into a pure, headless-unit-tested classify_capture(width, height) static, so the usable-vs-collapsed judgement is verifiable without an editor (the reason the original behavior went unverified). The window-mode read stays at the call site (before any capture/await); the classifier only decides from dimensions.
Consequences
- Both screenshot tools are trustworthy for agent use: a capture is either a usable frame (optionally with a
remediationdisclosure) or an actionable error code with a cause-specific hint — never a silent blank. - The wire contract gains 2 error codes (
EDITOR_VIEWPORT_UNAVAILABLE,RUNTIME_WINDOW_MINIMIZED), 2 params (force_foreground_editor,force_foreground_game), and 1 success field (remediation: string[]), recorded indocs/dev/contract.mdC3/C4/C8. The new codes are additive (no existing code repurposed), and theforce_foreground_*levers are default-off, so interactive and parallel runs are behaviorally unchanged. - The
remediationfield must be carried through the server’s screenshot mapper (buildScreenshotResult), the one non-REFLECT success path — it would otherwise be dropped by the image+text re-shape. - The DisplayServer / EditorInterface APIs relied on (
set_main_screen_editor,window_get_mode/window_set_mode/WINDOW_MODE_MINIMIZED/window_move_to_foreground,Window.grab_focus,RenderingServer.frame_post_draw) all exist on the 4.2 floor through 4.7, source-verified on the 4.2 and 4.7 worktrees.
Amendment — embedded playtests and the runtime path
The original premise above (“the running game’s window never collapses — it is a real OS window”) does not hold for an embedded playtest. Since Godot 4.4 the editor’s Game view runs the game embedded (default on desktop: Windows and X11 in 4.4, macOS added in 4.5); an embedded game is an owner-linked top-level popup that the engine hard-gates to WINDOWED mode and whose z-order the editor re-asserts every frame. Two consequences for the runtime screenshot:
window_get_mode(0)never reportsWINDOW_MODE_MINIMIZEDfor an embedded game — minimizing the editor hides the owned popup without setting its minimize flag. So the up-front mode-based short-circuit and theforce_foreground_gamelever were both structurally blind to the embedded case. The lever is additionally inert there:window_set_moderejects any non-WINDOWED mode when embedded, andwindow_move_to_foregroundcan’t win against the editor’s per-frame z-order control.- The runtime handler is now capability-based, not mode-based. It attempts a bounded
frame_post_drawcapture first and returns the fresh frame whenever the window still composites (the embedded case — it keeps rendering into the Game dock even while the editor is minimized). It signalsRUNTIME_WINDOW_MINIMIZEDonly when the bounded await lapses andDisplayServer.window_can_draw(0)is false — the genuine render-suspended case (a top-level game minimized, or fully occluded on macOS, which suspends on occlusion rather than minimize-mode).window_can_drawis preferred overwindow_get_mode == MINIMIZEDbecause it is the occlusion-aware superset.RUNTIME_WINDOW_MINIMIZEDstays emittable (top-level minimized / macOS-occluded), so the wire contract is unchanged — this is a behavioral correction, not a code repurpose. force_foreground_gamenow honors the request regardless of the mode read, but discloses truthfully. Embedding is detected withEngine.is_embedded_in_editor()(4.4+;Engine.has_method-guarded, since the method and the embed feature landed together in 4.4 and embedding is impossible on 4.2/4.3). Embedded → the lever can’t work, so it emits noforegrounded_gameremediation and instead returns ahintexplaining the game is already composited in the Game dock. Top-level → it does the real un-minimize + foreground and claimsforegrounded_gamefor both an un-minimize and a raise-from-background. (Window.is_embedded()is a false friend — it means subwindow-embedded-within-a-Viewport, not game-embedded-in-editor — and is not used.)
Amendment — image_response_mode and the disk-persist lean response
Both capture tools now take an optional image_response_mode: "inline" | "disk" | "both" (default "inline") that selects how the PNG comes back:
- inline (default) embeds the base64 PNG in the response, byte-identical to the prior shape (
{image_base64, mime_type, width, height, bytes}). - disk persists the PNG and returns only a lean envelope —
{path, width, height, bytes, mime_type}, noimage_base64— wherepathis the globalized absolute file path. This is the escape hatch for a capture too large for the WebSocket buffer (a full-size 3D viewport routinely exceeds it) and for conserving the agent’s context tokens. - both returns the inline shape plus
path.
An optional save_path (.png) names the destination; when omitted, disk/both auto-name under user://screenshots/. The save-path allowlist differs by context: editor.screenshot accepts res:// or user://screenshots/; runtime.screenshot (which also gains save_path) accepts user://screenshots/ only, because the game process has no res:// write surface. A save_path supplied in inline mode is validated but not persisted, so the guard is deterministic across modes and the response stays byte-identical.
Design points:
- No auto-fallback to disk on oversize. An over-buffer inline capture returns
RESPONSE_TOO_LARGE(C4) with a tailored hint that namesimage_response_mode:"disk"as the fix (alongside a smaller size, or raisingmcp_toolkit/limits/ws_buffer_kb). The caller chooses the mode; the tool never silently changes what it returns. There is noallow_large/max_bytesbypass — the buffer ceiling stays a hard, disclosed limit. - Shaping lives in a runtime-safe helper.
contract/screenshot_response.gd(RefCounted, noclass_name, preloads onlysecurity/file_guard.gd) owns the mode parse, save-path validation viaFileGuard, the persist, and the inline / disk / both shaping. Both editor handlers reach it via theModulesaggregator; the runtime autoload preloads it directly. It names noEditor*symbol, so it stays in the runtime autoload’s export-clean static graph (godotengine/godot#91713).
Amendment — image_detail proportional inline downscale (supersedes size)
Both capture tools now take an optional image_detail: "full" | "mid" | "low" (default "full", toolkit-owned) — the single universal inline-resolution control, replacing editor.screenshot’s node-focus-only size param.
- What it does.
image_detailcaps the inline returned image’s long edge —full= native,mid≈ 1024 px,low≈ 512 px — proportional, aspect-preserving, and shrink-only (never upscales; a frame already within the cap is returned untouched). It applies to botheditor.screenshotandruntime.screenshot, on the standard and node-focused paths alike. - Why
sizeis retired.sizewas_capture_node-only (a standard capture never read it), forced an exact WxH that could distort the aspect ratio or upscale a frame (a latent footgun), and duplicated no equivalent on the runtime tool.image_detail’s proportional, aspect-preserving, shrink-only long-edge cap is strictly better and uniform across both tools and both paths. Pre-1.0 ⇒ no back-compat shim. Node framing staysnode_path’s job — the “Node-focus does not reframe a 2D node” limitation below is unchanged. - Inline-only + mandatory disclosure. The cap applies to the inline image only;
image_response_mode:"disk"/"both"always persists the full-res PNG regardless ofimage_detail. Every response echoes the appliedimage_detailplus areturned"WxH"(the returned inline image’s dims for inline/both, the full-res saved-file dims for disk) so a size reduction is never silent, and disk/both add ahintdisclosing the saved file is full resolution (so an agent can read it for pixel detail instead of re-capturing). - Mechanics. The capture handler encodes a full-res buffer (for the disk write) and, when the level shrinks an inline-bearing capture, a separately-downscaled buffer (for the inline base64) via
Image.resize(..., INTERPOLATE_LANCZOS)(present 4.2→4.7; sharpest for text).bothmode therefore carries a downscaled inline over a full-res disk copy. Classification (classify_capture/window_can_draw) runs on the native dims before the resize; the send-pathguard_response_sizesees the final inline buffer, so theRESPONSE_TOO_LARGEcheck is automatic. The purecontract/screenshot_response.gdshaper owns theimage_detail_dimscalculator + the disclosure threading (it never mutates anImage). A badimage_detailvalue →INVALID_PARAMS. - Provisional caps.
mid1024 px /low512 px are tuned targets —midfor readable HUD text,lowfor gross layout/motion only (“not for reading text”) — ratified by a live fidelity spot-check.
Known limitations
- The
force_foreground_*levers un-minimize to a windowed state, not a prior maximized one. Restoring the exact pre-minimize window mode after un-minimizing is not fixable via the public API (source-verified 4.5 + 4.7): there is no getter for a “was maximized before minimize” flag,window_set_mode(WINDOWED)maps toSW_NORMAL+maximized=false(it un-maximizes), and noSW_RESTOREequivalent is exposed. This holds for both the editor (force_foreground_editor) and the top-level game (force_foreground_game) paths. By design there is no agent-facing hint about it — a foregrounded capture succeeds; the window simply lands windowed. Documented so a future reader does not treat it as a bug or try to “fix” the mode restore. - Node-focus does not reframe a 2D node.
editor_screenshot node_path:<node>selects the target node but cannot pan/zoom the 2D viewport camera around it — it captures the current 2D view, so a node away from the view origin renders off-centre or out of frame. A 3D node-focused capture gets the engine’s internal camera framing; 2D has no equivalent public reframe. Framing a specific 2D node is out of reach of the public editor API today.