first commit

This commit is contained in:
Leonid Pershin
2026-08-10 20:39:06 +03:00
commit f16f60445c
281 changed files with 43576 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
@tool
extends Control
## Runtime helper attached by control_draw_recipe.
## Reads an array of op dicts from node metadata under key "_ops" and dispatches
## each to a CanvasItem draw call in _draw(). The ops list is set by the handler
## via set_meta; this script is deterministic — re-setting meta + queue_redraw
## is enough to update the visuals.
const META_KEY := "_ops"
func _ready() -> void:
queue_redraw()
func _draw() -> void:
if not has_meta(META_KEY):
return
var ops: Variant = get_meta(META_KEY)
if typeof(ops) != TYPE_ARRAY:
return
for op in ops:
if typeof(op) != TYPE_DICTIONARY:
continue
match op.get("draw", ""):
"line":
draw_line(
op.from,
op.to,
op.color,
float(op.get("width", 1.0)),
bool(op.get("antialiased", false))
)
"rect":
# Godot warns if `width` is passed when `filled` is true —
# width has no effect on filled rects. Split the call so we
# only pass width when stroking an outline.
var filled := bool(op.get("filled", true))
if filled:
draw_rect(op.rect, op.color, true)
else:
draw_rect(
op.rect,
op.color,
false,
float(op.get("width", 1.0))
)
"arc":
draw_arc(
op.center,
float(op.radius),
float(op.start_angle),
float(op.end_angle),
int(op.get("point_count", 32)),
op.color,
float(op.get("width", 1.0)),
bool(op.get("antialiased", false))
)
"circle":
draw_circle(op.center, float(op.radius), op.color)
"polyline":
draw_polyline(
op.points,
op.color,
float(op.get("width", 1.0)),
bool(op.get("antialiased", false))
)
"polygon":
var colors: PackedColorArray = (
op.colors if op.has("colors") else PackedColorArray([op.color])
)
draw_polygon(op.points, colors)
"string":
var font: Font = get_theme_default_font()
if font == null:
continue
draw_string(
font,
op.position,
str(op.text),
int(op.get("align", HORIZONTAL_ALIGNMENT_LEFT)),
float(op.get("max_width", -1.0)),
int(op.get("font_size", 16)),
op.color
)
@@ -0,0 +1 @@
uid://da3fqfqv6gtgm
+139
View File
@@ -0,0 +1,139 @@
@tool
extends Logger
## Editor-process Logger subclass.
##
## 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.
##
## Why only `_log_error` and not `_log_message`:
## `_log_message(msg, error)` covers print() and printerr(), which is the
## firehose path — running editors print thousands of internal info lines
## a session. The issue (#231) explicitly asks to filter so the buffer
## isn't drowned. Errors and warnings flow through `_log_error` (parse
## errors, push_error/push_warning, runtime errors), which is what
## debugging callers actually need. If we discover @tool printerr() is a
## valuable source later, _log_message can be added behind the same filter.
##
## Logger virtuals can be called from any thread (e.g. async script
## loaders push parse errors off the main thread). McpEditorLogBuffer is
## mutex-protected so we can append directly without an intermediate queue.
const ADDON_PATH_MARKER := "/addons/godot_ai/"
## Resolve McpLogBacktrace by path, not by the `McpLogBacktrace` class_name.
## 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")
## Constructor-injected so the hot path doesn't need a per-call null check.
var _buffer
func _init(buffer = null) -> void:
_buffer = buffer
func _log_error(
function: String,
file: String,
line: int,
code: String,
rationale: String,
_editor_notify: bool,
error_type: int,
script_backtraces: Array,
) -> void:
if _buffer == null:
return
## Cheap reject for the firehose: when `file` is already non-user (the
## bulk of editor-internal C++ chatter), there's no backtrace to remap
## from, and the message doesn't name a project resource, the resolved
## path can only stay non-user — drop without paying for resolve_error's
## call frame + dict allocation.
var message := rationale if not rationale.is_empty() else code
var message_res_path := _extract_user_res_path(message)
if not _is_user_script(file) and script_backtraces.is_empty() and message_res_path.is_empty():
return
var resolved := _LogBacktrace.resolve_error(
function, file, line, code, rationale, error_type, script_backtraces,
)
if not _is_user_script(resolved.path):
if message_res_path.is_empty():
return
resolved.path = message_res_path
resolved.line = 0
resolved.function = function
_update_resolved_details(resolved)
if _is_in_godot_ai_addon(resolved.path):
return
if not message_res_path.is_empty() and _is_in_godot_ai_addon(message_res_path):
return
var details: Dictionary = resolved.get("details", {})
_buffer.append(resolved.level, resolved.message, resolved.path, resolved.line, resolved.function, details)
static func _update_resolved_details(resolved: Dictionary) -> void:
var details: Dictionary = resolved.get("details", {})
if details.is_empty():
return
details["resolved"] = {
"path": resolved.get("path", ""),
"line": resolved.get("line", 0),
"function": resolved.get("function", ""),
}
resolved["details"] = details
## Predicate broken out so tests can drive the path-filter logic without
## constructing real Logger calls.
static func _is_user_script(path: String) -> bool:
if path.is_empty():
return false
## Match .gd / .cs (case-insensitively to handle .GD on case-insensitive
## filesystems). C# scripts compile elsewhere but the parser path can
## still surface .cs files for assembly load failures.
var lower := path.to_lower()
return lower.ends_with(".gd") or lower.ends_with(".cs")
## Path-substring check works for both `res://addons/godot_ai/foo.gd` and
## globalized absolute paths (`/Users/.../addons/godot_ai/foo.gd`) that
## Godot can also report depending on where the error originated.
static func _is_in_godot_ai_addon(path: String) -> bool:
if path.begins_with("res://addons/godot_ai/"):
return true
return path.find(ADDON_PATH_MARKER) >= 0
## Some engine-origin errors have no ScriptBacktrace even though they are
## project-relevant, notably ResourceLoader failures:
## `Failed loading resource: res://does/not/exist.tres.`. Capture these by
## extracting a named `res://` path from the message while keeping editor
## internals and this addon's own resources filtered.
static func _extract_user_res_path(message: String) -> String:
var start := message.find("res://")
if start < 0:
return ""
var end := message.length()
var quote_end := message.find("'", start)
if quote_end >= 0:
end = mini(end, quote_end)
quote_end = message.find("\"", start)
if quote_end >= 0:
end = mini(end, quote_end)
quote_end = message.find("`", start)
if quote_end >= 0:
end = mini(end, quote_end)
var path := message.substr(start, end - start).strip_edges()
while not path.is_empty() and path.substr(path.length() - 1, 1) in [".", ",", ";", ":", ")"]:
path = path.substr(0, path.length() - 1)
if path.is_empty() or _is_in_godot_ai_addon(path):
return ""
return path
@@ -0,0 +1 @@
uid://cxfregddqj5b8
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://gfybkdtsclti
+142
View File
@@ -0,0 +1,142 @@
@tool
extends Logger
## Game-process Logger subclass.
##
## 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
## and the host (game_helper.gd) flushes once per frame from the main
## thread, where EngineDebugger.send_message is safe to call.
## `McpLogBacktrace` is published as a `class_name` on log_backtrace.gd, but a
## freshly-launched game subprocess (no prior editor scan; e.g. CI launching
## `--headless --path`) hits this autoload before the global class_name table
## is populated, and parsing this script fails with
## "Identifier 'McpLogBacktrace' not declared in the current scope". Using
## `const preload` resolves the path at parse time and is independent of the
## class_name registry — matches the project convention in CLAUDE.md
## ("Internals … skip class_name entirely and load via const preload").
const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd")
var _pending: Array = []
var _mutex := Mutex.new()
## #490: a monotonic sequence + a small ring of recent GDScript runtime
## (script-type) errors, each with its text AND the function names in its
## backtrace. game_helper uses this to attribute a runtime error to the
## *specific* eval that raised it: each eval's wrapper has a uniquely named
## inner function, and game_helper asks find_script_error_since() whether any
## error past its pre-eval baseline carries that function in its stack. This
## avoids failing an eval on an unrelated background game error that merely
## advanced a global counter, and keeps overlapping evals from cross-
## attributing. Gated on ERROR_TYPE_SCRIPT (2) so push_error()/push_warning()
## (types 0/1) never count. Mutex-guarded: _log_error can fire from any thread.
const _ERROR_TYPE_SCRIPT := 2
const _MAX_RECENT_SCRIPT_ERRORS := 64
var _script_error_seq: int = 0
var _recent_script_errors: Array = []
func _log_message(message: String, error: bool) -> void:
## `error` is true for printerr(), false for print().
var level := "error" if error else "info"
_append(level, message)
func _log_error(
function: String,
file: String,
line: int,
code: String,
rationale: String,
_editor_notify: bool,
error_type: int,
script_backtraces: Array,
) -> void:
## EngineDebugger's payload shape is `[level, text]` — the source
## location has nowhere structured to land for the game side, so we
## inline it into `text`. editor_logger keeps the resolved fields
## as structured columns instead.
var resolved := _LogBacktrace.resolve_error(
function, file, line, code, rationale, error_type, script_backtraces,
)
var loc := ""
if not resolved.path.is_empty():
loc = "%s:%d @ %s" % [resolved.path, resolved.line, resolved.function] if not resolved.function.is_empty() else "%s:%d" % [resolved.path, resolved.line]
var text: String = "%s (%s)" % [resolved.message, loc] if not loc.is_empty() else resolved.message
var details: Dictionary = resolved.get("details", {})
_append(resolved.level, text, details)
if error_type == _ERROR_TYPE_SCRIPT:
## 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: RefCounted in script_backtraces:
if bt != null and bt.get_frame_count() > 0:
for i: int in bt.get_frame_count():
funcs.append(bt.get_frame_function(i))
break
_mutex.lock()
_script_error_seq += 1
_recent_script_errors.append({"seq": _script_error_seq, "text": text, "funcs": funcs})
if _recent_script_errors.size() > _MAX_RECENT_SCRIPT_ERRORS:
_recent_script_errors.remove_at(0)
_mutex.unlock()
func _append(level: String, text: String, details: Dictionary = {}) -> void:
_mutex.lock()
if details.is_empty():
_pending.append([level, text])
else:
_pending.append([level, text, details.duplicate(true)])
_mutex.unlock()
## Drain the pending queue and return entries as [[level, text], ...].
## Called from the main thread by game_helper each frame.
func drain() -> Array:
_mutex.lock()
var out := _pending
_pending = []
_mutex.unlock()
return out
func has_pending() -> bool:
_mutex.lock()
var any := not _pending.is_empty()
_mutex.unlock()
return any
## #490: monotonic count of script-type runtime errors seen this run.
## game_helper snapshots this before an eval to use as the `since_seq`
## baseline for find_script_error_since(). Mutex-guarded.
func script_error_seq() -> int:
_mutex.lock()
var v := _script_error_seq
_mutex.unlock()
return v
## #490: text of the most recent script error with seq > since_seq whose
## backtrace includes `function_name`, or "" if none. Lets game_helper
## attribute a runtime error to the exact eval whose uniquely named wrapper
## function appears in the stack — ignoring unrelated game errors and errors
## from before the eval started. Mutex-guarded.
func find_script_error_since(since_seq: int, function_name: String) -> String:
_mutex.lock()
var found := ""
for i in range(_recent_script_errors.size() - 1, -1, -1):
var rec: Dictionary = _recent_script_errors[i]
if int(rec["seq"]) <= since_seq:
break
if (rec["funcs"] as PackedStringArray).has(function_name):
found = rec["text"]
break
_mutex.unlock()
return found
@@ -0,0 +1 @@
uid://dwcs6d1y7vhqi
@@ -0,0 +1,43 @@
@tool
extends Logger
## Short-lived Logger used only for per-write validation loads.
##
## Unlike editor_logger.gd this deliberately has no addon feedback-loop filter:
## the caller attaches it around one ResourceLoader.load() call, reads its
## private buffer, and immediately removes it. The shared editor logger should
## still drop these validation-load errors so logs_read(source="editor") stays
## clean.
const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd")
var _buffer
func _init(buffer = null) -> void:
_buffer = buffer
func _log_error(
function: String,
file: String,
line: int,
code: String,
rationale: String,
_editor_notify: bool,
error_type: int,
script_backtraces: Array,
) -> void:
if _buffer == null:
return
var resolved := _LogBacktrace.resolve_error(
function,
file,
line,
code,
rationale,
error_type,
script_backtraces,
)
var details: Dictionary = resolved.get("details", {})
_buffer.append(resolved.level, resolved.message, resolved.path, resolved.line, resolved.function, details)
@@ -0,0 +1 @@
uid://b2ff3aot6t2l4