fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation
This commit is contained in:
+7
-19
@@ -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
|
||||
@@ -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:
|
||||
|
||||
+6
-13
@@ -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 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). 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user