fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation

This commit is contained in:
2026-07-10 17:36:26 +08:00
parent 2b4c9d9dcb
commit b30709de3d
84 changed files with 5291 additions and 982 deletions
@@ -3,20 +3,12 @@ extends Logger
## Editor-process Logger subclass.
##
## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger
## class which Godot only exposes from 4.5+. This file lives in the
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan
## skips it entirely — on Godot < 4.5 it is never parsed, so it emits no
## "Could not find base class Logger" error (it used to, before #475's
## follow-up). plugin.gd builds it from source at runtime via
## `logger_loader.gd` and only calls OS.add_logger() after gating on
## ClassDB.class_exists("Logger"), so the `extends Logger` parse only ever
## happens on 4.5+ where it resolves. Registered from plugin.gd::_enter_tree
## NOTE: deliberately no `class_name`. Registered from plugin.gd::_enter_tree
## so we can intercept editor-process script errors — parse errors, @tool
## runtime errors, EditorPlugin errors, push_error/push_warning — and
## surface them via `logs_read(source="editor")`. Without this, the LLM
## sees nothing in `logs_read` while the same errors show in red lines in
## Godot's Output panel.
## runtime errors, EditorPlugin errors, push_error/push_warning — and surface
## them via `logs_read(source="editor")`. Without this, the LLM sees nothing
## in `logs_read` while the same errors show in red lines in Godot's Output
## panel.
##
## Why only `_log_error` and not `_log_message`:
## `_log_message(msg, error)` covers print() and printerr(), which is the
@@ -34,17 +26,13 @@ extends Logger
const ADDON_PATH_MARKER := "/addons/godot_ai/"
## Resolve McpLogBacktrace by path, not by the `McpLogBacktrace` class_name.
## This script is compiled from source at runtime by logger_loader.gd; a bare
## class_name reference depends on the global class-name table being populated
## A bare class_name reference depends on the global class-name table being populated
## at compile time, which isn't guaranteed on a cold editor enable mid-scan.
## `const preload` resolves at compile time independent of the registry —
## matches game_logger.gd's deliberate choice for the same reason.
const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd")
## McpEditorLogBuffer — untyped because this script is loaded dynamically and
## McpEditorLogBuffer's class_name isn't yet registered on the parser at the
## time `extends Logger` resolves. Constructor-injected so the hot path
## doesn't need a per-call null check.
## Constructor-injected so the hot path doesn't need a per-call null check.
var _buffer
@@ -0,0 +1 @@
uid://cxfregddqj5b8
+99 -27
View File
@@ -23,13 +23,25 @@ const CAPTURE_PREFIX := "mcp"
## Cap per-frame flush so a runaway print loop can't blow the debugger's
## packet budget in a single send. Surplus stays queued for the next frame.
const FLUSH_BATCH_LIMIT := 200
## How long take_screenshot waits for the game's first real presentation
## before reading the viewport texture back. The "mcp" capture registers in
## this autoload's _ready(), which runs BEFORE the main scene enters the tree
## and before the renderer has presented anything — so a request arriving
## right after mcp:hello would otherwise read back the clear-color
## framebuffer (observed as a uniform RGB(77,77,77) PNG on GitHub's
## GPU-less paravirtualized macOS runners, where the first present lags
## seconds behind boot). MUST stay below the editor-side reply timer
## (DEFAULT_TIMEOUT_SEC = 8.0 in debugger/mcp_debugger_plugin.gd) so a
## game that genuinely can't render falls through to the existing
## texture/image error replies before the editor gives up with its
## generic timeout.
const FIRST_FRAME_WAIT_SEC := 6.0
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
const GameLogger := preload("res://addons/godot_ai/runtime/game_logger.gd")
var _registered := false
## Untyped because the McpGameLogger script is loaded dynamically (it
## extends Logger, which only exists in Godot 4.5+).
var _logger
## Captures game-process print, warning, and error output for the editor.
var _logger: Logger
var _logger_attached := false
## Entries drained from the logger but not yet sent over the debugger
## channel. Holds the tail of one drain() so we can bleed it out across
@@ -64,17 +76,10 @@ func _ready() -> void:
_registered = true
## Capture print() / printerr() / push_error() / push_warning() and
## ferry them to the editor in mcp:log_batch messages flushed from
## _process. Logger subclassing was added in Godot 4.5 — gate on
## ClassDB so the rest of the helper still loads on older engines.
## game_logger.gd lives in the `.gdignore`'d runtime/loggers/ folder so
## it never parse-errors during a < 4.5 editor scan; LoggerLoader
## compiles it from source at runtime, only past this gate.
if ClassDB.class_exists("Logger") and OS.has_method("add_logger"):
var logger_script := LoggerLoader.build(LoggerLoader.GAME_LOGGER_PATH)
if logger_script != null:
_logger = logger_script.new()
OS.call("add_logger", _logger)
_logger_attached = true
## _process.
_logger = GameLogger.new()
OS.add_logger(_logger)
_logger_attached = true
## Routed to the editor's Output panel via Godot's remote-stdout
## forwarder — handy when diagnosing why capture timed out.
print("[godot_ai game_helper] registered mcp capture (debugger active=%s, logger=%s)"
@@ -109,16 +114,16 @@ func _exit_tree() -> void:
if _registered:
EngineDebugger.unregister_message_capture(CAPTURE_PREFIX)
_registered = false
if _logger_attached and _logger != null and OS.has_method("remove_logger"):
OS.call("remove_logger", _logger)
if _logger_attached and _logger != null:
OS.remove_logger(_logger)
_logger_attached = false
_logger = null
## Dispatched for messages prefixed "mcp:" on the debugger channel.
## Different Godot versions pass either the tail ("take_screenshot") or the
## full message ("mcp:take_screenshot") to the capture callable — accept
## both forms so this works across 4.2/4.3/4.4/4.5.
## Godot passes the full message ("mcp:take_screenshot") to the capture
## callable; trim defensively so tests can still call the helper with either
## form.
func _on_debug_message(message: String, data: Array) -> bool:
var action := message.trim_prefix("mcp:")
match action:
@@ -141,11 +146,25 @@ func _handle_take_screenshot(data: Array) -> void:
var request_id: String = data[0] if data.size() > 0 else ""
var max_resolution: int = int(data[1]) if data.size() > 1 else 0
var viewport := get_tree().root
var tree := get_tree()
var viewport := tree.root if tree != null else null
if viewport == null:
_reply_error(request_id, "No game root viewport available")
return
## Wait (bounded — see FIRST_FRAME_WAIT_SEC) until the main scene is in
## the tree and at least one frame has been drawn after this request, so
## the readback never precedes the first real present. Past the deadline,
## fall through anyway: current_scene stays null under a custom main
## loop, and frames_drawn never advances in a render-less game — both
## are handled by the texture/image error replies below.
var deadline := Time.get_ticks_msec() + int(FIRST_FRAME_WAIT_SEC * 1000.0)
while tree.current_scene == null and Time.get_ticks_msec() < deadline:
await tree.process_frame
var frames_at_request := Engine.get_frames_drawn()
while Engine.get_frames_drawn() <= frames_at_request and Time.get_ticks_msec() < deadline:
await tree.process_frame
var texture := viewport.get_texture()
if texture == null:
_reply_error(request_id, "Root viewport has no texture (headless?)")
@@ -217,6 +236,8 @@ func _handle_game_command(data: Array) -> void:
result = _game_input_mouse(json.data)
"input_gamepad":
result = _game_input_gamepad(json.data)
"input_action":
result = _game_input_action(json.data)
"input_state":
result = _game_input_state(json.data)
_:
@@ -458,7 +479,10 @@ func _game_input_key(params: Dictionary) -> Dictionary:
func _game_input_mouse(params: Dictionary) -> Dictionary:
var event := str(params.get("event", "button"))
var pos := _dict_to_vector2(params.get("position", {}))
var pos_result := _resolve_mouse_position(params.get("position"))
if pos_result.has("error"):
return {"sent": false, "event": event, "error": pos_result.error}
var pos: Vector2 = pos_result.position
match event:
"motion":
var motion := InputEventMouseMotion.new()
@@ -504,6 +528,27 @@ func _game_input_gamepad(params: Dictionary) -> Dictionary:
return {"sent": false, "error": "Invalid gamepad control: %s" % control}
func _game_input_action(params: Dictionary) -> Dictionary:
var action := str(params.get("action", ""))
if action.is_empty():
return {"sent": false, "error": "Missing action"}
if not InputMap.has_action(action):
return {"sent": false, "action": action, "error": "Unknown action: %s" % action}
var pressed := bool(params.get("pressed", true))
var strength := clampf(float(params.get("strength", 1.0)), 0.0, 1.0)
if pressed:
Input.action_press(action, strength)
else:
Input.action_release(action)
return {
"sent": true,
"action": action,
"pressed": pressed,
"strength": strength,
"delivery": "action_state",
}
func _game_input_state(params: Dictionary) -> Dictionary:
var actions: Array = params.get("actions", [])
if actions.is_empty():
@@ -515,14 +560,41 @@ func _game_input_state(params: Dictionary) -> Dictionary:
return {"actions": states}
func _dict_to_vector2(value: Variant) -> Vector2:
## Resolve a mouse-position param. Absent (null, or an empty {}) falls back to
## the live cursor position — a deliberate default. A present but wrong-shaped
## value is rejected instead of silently substituting the cursor, which
## previously hid caller bugs (#635). Accepts a {x, y} dict or an [x, y] array;
## returns {position: Vector2} or {error: String}.
func _resolve_mouse_position(value: Variant) -> Dictionary:
var viewport := get_viewport()
var fallback := viewport.get_mouse_position() if viewport != null else Vector2.ZERO
if value == null:
return {"position": fallback}
if value is Dictionary:
if value.is_empty() or (not value.has("x") and not value.has("y")):
return fallback
return Vector2(float(value.get("x", fallback.x)), float(value.get("y", fallback.y)))
return fallback
var dict: Dictionary = value
if dict.is_empty():
return {"position": fallback}
# A non-empty dict that carries neither coordinate is a caller mistake,
# not "use the default" — reject rather than silently substitute.
if not dict.has("x") and not dict.has("y"):
return {"error": "position object must have an 'x' and/or 'y' key (got keys %s)" % str(dict.keys())}
var x_val: Variant = dict.get("x", fallback.x)
var y_val: Variant = dict.get("y", fallback.y)
if not _is_number(x_val) or not _is_number(y_val):
return {"error": "position x/y must be numbers (got x=%s, y=%s)" % [type_string(typeof(x_val)), type_string(typeof(y_val))]}
return {"position": Vector2(float(x_val), float(y_val))}
if value is Array:
var arr: Array = value
if arr.size() != 2:
return {"error": "position array must be [x, y] (got %d elements)" % arr.size()}
if not _is_number(arr[0]) or not _is_number(arr[1]):
return {"error": "position array elements must be numbers (got [%s, %s])" % [type_string(typeof(arr[0])), type_string(typeof(arr[1]))]}
return {"position": Vector2(float(arr[0]), float(arr[1]))}
return {"error": "position must be a {x, y} object or [x, y] array (got %s)" % type_string(typeof(value))}
func _is_number(v: Variant) -> bool:
return typeof(v) == TYPE_INT or typeof(v) == TYPE_FLOAT
func _mouse_button_index(name: String) -> int:
@@ -3,17 +3,10 @@ extends Logger
## Game-process Logger subclass.
##
## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger
## class which Godot only exposes from 4.5+. This file lives in the
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan
## skips it entirelyon Godot < 4.5 it is never parsed, so it emits no
## "Could not find base class Logger" error (it used to, before #475's
## follow-up). game_helper.gd builds it from source at runtime via
## `logger_loader.gd` and only calls OS.add_logger() after gating on
## ClassDB.class_exists("Logger"). Registered from inside the running game
## so we can intercept print(), printerr(), push_error(), and
## push_warning() and ferry them back to the editor over the
## EngineDebugger channel — the same bridge PR #76 uses for screenshots.
## NOTE: deliberately no `class_name`. Registered from inside the running
## game so we can intercept print(), printerr(), push_error(), and
## push_warning() and ferry them back to the editor over the EngineDebugger
## channel — the same bridge PR #76 uses for screenshots.
##
## Logger virtuals can be called from any thread (e.g. async loaders push
## errors off the main thread). We accumulate into _pending under a Mutex
@@ -81,9 +74,9 @@ func _log_error(
## Collect every function name in the first non-empty backtrace so
## game_helper can match its eval's uniquely named wrapper function.
var funcs := PackedStringArray()
for bt in script_backtraces:
for bt: RefCounted in script_backtraces:
if bt != null and bt.get_frame_count() > 0:
for i in bt.get_frame_count():
for i: int in bt.get_frame_count():
funcs.append(bt.get_frame_function(i))
break
_mutex.lock()
@@ -0,0 +1 @@
uid://dwcs6d1y7vhqi
-57
View File
@@ -1,57 +0,0 @@
@tool
extends RefCounted
## Runtime builder for the `extends Logger` scripts in `runtime/loggers/`.
##
## `Logger` is a Godot 4.5+ class. A `.gd` file that statically declares
## `extends Logger` is rejected by the parser on Godot < 4.5 — and Godot's
## editor filesystem scan parses *every* `.gd` under the project, so just
## shipping `editor_logger.gd` / `game_logger.gd` printed two
## `Parse Error: Could not find base class "Logger"` lines on every 4.3/4.4
## editor startup (#475 follow-up). They were functionally harmless (the
## scripts are only ever instanced behind a `ClassDB.class_exists("Logger")`
## gate) but they were real red error text we shouldn't ship.
##
## Fix: the two logger scripts live in `runtime/loggers/`, which carries a
## `.gdignore` so the editor scan skips the folder entirely — no parse, no
## error, on any engine. This loader reads the source off disk with
## `FileAccess` (unaffected by `.gdignore`, which only governs the resource
## importer) and compiles it at runtime via `GDScript.new()`. Callers gate
## on `ClassDB.class_exists("Logger")` first, so `build()` only ever runs on
## 4.5+, where `extends Logger` resolves cleanly.
##
## This script itself does NOT extend Logger, so it parses on every engine
## and is safe to `preload` from `plugin.gd` and `game_helper.gd`.
const EDITOR_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/editor_logger.gd"
const GAME_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/game_logger.gd"
const VALIDATION_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/validation_logger.gd"
## Compile a `.gdignore`'d logger script from its on-disk source. Returns the
## ready-to-instance GDScript, or null if the file is missing (e.g. excluded
## from an exported game) or fails to compile. Callers must already have
## confirmed `ClassDB.class_exists("Logger")` — building an `extends Logger`
## script on an engine without the class will fail the reload() and return
## null, which the gated callers treat as "logging unavailable".
static func build(path: String) -> GDScript:
if not FileAccess.file_exists(path):
return null
var source := FileAccess.get_file_as_string(path)
if source.is_empty():
return null
var script := GDScript.new()
script.source_code = source
## Deliberately do NOT set `script.resource_path`: this builds a fresh
## anonymous GDScript every call, and a reload cycle (editor_reload_plugin,
## self-update disable→enable) calls build() again for the same path. Two
## live Resources sharing one non-empty resource_path trips Godot's
## "Another resource is loaded from path ..." error and leaves the new
## script with an empty path anyway — re-introducing red console text on
## every reload, the exact thing this folder's .gdignore set out to remove.
## game_helper.gd::_handle_eval compiles from source the same way and also
## omits resource_path. The script still resolves its absolute preloads /
## class_names fine without a path.
if script.reload() != OK:
return null
return script
@@ -1 +0,0 @@
uid://d3plpedkpvec6
@@ -0,0 +1 @@
uid://b2ff3aot6t2l4