2026-10-09 12:49:07 +02:00

247 lines
10 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@tool
extends RefCounted
## Debug-log retrieval (editor side): the cached debugger.get_log reader.
##
## Reads the most-recent play session's output from the engine log FILE
## (user://logs/godot.log — written by the game/play process; the editor process
## does not write it, engine !editor guard). The engine truncates that file fresh
## on every launch (RotatedFileLogger rotates the prior log to a timestamped backup
## and reopens the base path with FileAccess::WRITE), so the file is always exactly
## the current session starting at byte 0 — this reader reads the whole file.
## ANSI-strips, text-filters, and limits the lines; when an EditorDebuggerPlugin
## bridge is present, merges its debug_state + error_buffer (falling back to a
## log-line error scan) into one crash-context response.
##
## Owns the two pieces of state this subdomain depends on:
## - the session-started flag + the public mark_session_started() setter the
## Play-control side calls at game.start (so a read before any game.start
## returns "no session yet" rather than a prior editor session's stale log);
## - the injected _debug_bridge (the EditorDebuggerPlugin, shared upstream with
## debug_commands.gd) + set_debug_bridge()/clear_debug_bridge().
## An extracted submodule of the playtest-command group, reached via a `preload` alias.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const Helpers = Modules.CommandHelpers
const LogHelpers = Modules.LogHelpers
# True once game.start has launched a play session this editor session. The engine
# truncates user://logs/godot.log fresh on every launch (RotatedFileLogger ctor →
# rotate_file → FileAccess::WRITE; source-verified 4.2–4.7), so once a session has
# started the whole file IS the current session and the reader reads from byte 0.
# Before the first game.start it stays false, so debugger.get_log reports "no
# session yet" rather than a prior editor session's stale log. The file is written
# by the game/play process (the editor does not write it — engine !editor guard),
# so reading it gives game output even though the Logger API is process-local (4.5+).
static var _game_session_started: bool = false
# Debug bridge reference — injected at register() time. Supplies the
# error_buffer + debug_state fields of the debugger log payload.
static var _debug_bridge: RefCounted = null
# -- Session-start handoff ---------------------------------------------------
## Mark that a play session has started — debugger.get_log then reads the whole
## godot.log. The engine truncates that file fresh on every launch, so the file is
## exactly the current session (read from byte 0). Called by Play-control
## (game.start) the moment a play session launches.
static func mark_session_started() -> void:
_game_session_started = true
# -- Debug-bridge injection ---------------------------------------------------
static func set_debug_bridge(bridge: RefCounted) -> void:
_debug_bridge = bridge
static func clear_debug_bridge() -> void:
_debug_bridge = null
# -- Commands -----------------------------------------------------------------
## Editor-side debugger.get_log — returns cached log entries from the most
## recent game session. Reads from the LOG FILE (written by the game/play
## process — the editor does not write it) rather than the in-memory LogBuffer
## (which is process-local and only captures editor output on 4.5+).
## When a debug bridge is available, merges the error buffer and debug state
## into the response — gives the server a single call for full crash context.
static func cmd_debugger_get_log(parameters: Dictionary) -> Dictionary:
var limit: int = max(1, int(parameters.get("limit", 200)))
var text_filter: String = str(parameters.get("text_filter", ""))
var tf := Helpers.compile_text_filter(parameters)
var text_regex: RegEx = tf[0]
if tf[1] != null:
return tf[1]
var regex_warning: String = tf[2]
if not _game_session_started:
var response := MCPToolkitSuccess.ok(_empty_log_page(
"No game session recorded yet (game_start was never called this editor session)"))
_merge_debug_bridge_data(response)
return response
# Read the current session's log file (the engine truncated it fresh at launch).
var log_path: String = LogHelpers.resolve_log_path()
if not FileAccess.file_exists(log_path):
var response := MCPToolkitSuccess.ok(_empty_log_page(
"Log file not found. Enable debug/file_logging/enable_file_logging in ProjectSettings and restart."))
_merge_debug_bridge_data(response)
return response
var file := FileAccess.open(log_path, FileAccess.READ)
if file == null:
return MCPToolkitError.fail("LOG_BUSY",
"log file exists but could not be read (err %d)" % FileAccess.get_open_error(),
MCPToolkitError.log_busy_hint(Modules.LogBuffer.uses_logger_api()))
var file_len: int = file.get_length()
if file_len <= 0:
file.close()
var response := MCPToolkitSuccess.ok(_empty_log_page(
"No game output yet (log file is empty)."))
_merge_debug_bridge_data(response)
return response
# The engine truncates godot.log fresh on every launch (see mark_session_started),
# so the whole file is this session — read it all from byte 0.
var new_bytes := file.get_buffer(file_len)
file.close()
var new_text := new_bytes.get_string_from_utf8()
var all_lines := new_text.split("\n", false)
# Strip ANSI from all lines once (used for both filtering and error scan).
var stripped_lines: Array = []
for line in all_lines:
var stripped := LogHelpers.strip_ansi(line.strip_edges())
if not stripped.is_empty():
stripped_lines.append(stripped)
# Apply text filter.
var filtered: Array = []
for stripped in stripped_lines:
if text_filter != "":
if text_regex != null:
if not text_regex.search(stripped):
continue
else:
if stripped.findn(text_filter) < 0:
continue
filtered.append(stripped)
# Apply limit (take last N lines).
# Capture the pre-slice filtered count for total_lines before the
# slice reassigns `filtered` to the capped tail.
var total_lines := filtered.size()
var truncated := filtered.size() > limit
if truncated:
filtered = filtered.slice(filtered.size() - limit)
# Capped-tail pagination (oldest lines drop first, no cursor) — advise raising
# limit / text_filter rather than a cursor.
var hint := ""
if truncated:
hint = "more lines remain — raise limit or narrow with text_filter (capped tail: the oldest lines drop first, no cursor)."
var response := MCPToolkitSuccess.ok(Modules.Pagination.build(
{"lines": filtered}, "lines", total_lines, filtered.size(), truncated,
"", 0, hint, {"source": "cache"}))
# Pass unfiltered lines so error scan always has full context.
_merge_debug_bridge_data(response, stripped_lines)
if not regex_warning.is_empty():
response["warning"] = regex_warning
return response
# -- Helpers ------------------------------------------------------------------
## The empty cached-log page returned when no session output is available.
## [param note] explains which of the no-output conditions applies. Stamps the
## standard pagination invariants (empty, nothing more) so the shape matches a
## populated read.
static func _empty_log_page(note: String) -> Dictionary:
return Modules.Pagination.build({"lines": []}, "lines", 0, 0, false, "", 0, "",
{"source": "cache", "note": note})
## Shared error-entry shape for debug_state/error_buffer responses.
## timestamp_ms + entry_type vary by source (live capture vs log scan); all
## other fields are pass-through. Explicit types so the builder never :=-infers
## from a Variant — message/source/function are str()-typed at the call sites.
static func make_error_entry(timestamp_ms: int, message: String, source: String,
function: String, line: int, entry_type: String) -> Dictionary:
return {
"timestamp_ms": timestamp_ms,
"message": message,
"source": source,
"function": function,
"line": line,
"type": entry_type,
}
## Merge debug bridge error buffer + state into a debugger.get_log response.
## all_lines: unfiltered log lines — error scan needs adjacent "at:" lines
## that text_filter might exclude.
static func _merge_debug_bridge_data(response: Dictionary,
all_lines: Array = []) -> void:
if _debug_bridge == null:
return
response["debug_state"] = _debug_bridge.get_debug_state()
var buf: Array = _debug_bridge.get_error_buffer()
# Fallback: if _capture didn't fire (Godot's built-in debugger handles
# "error" messages before plugins see them), scan log lines for errors.
if buf.is_empty() and not all_lines.is_empty():
buf = _scan_lines_for_errors(all_lines)
if not buf.is_empty():
response["error_buffer"] = buf
## Scan log lines for error patterns and build synthetic error_buffer entries.
## Godot error lines follow the pattern:
## "USER SCRIPT ERROR: <message>" (script errors)
## "SCRIPT ERROR: <message>" (engine script errors)
## " at: <function> (<file>:<line>)" (source location, follows the error)
## This fallback populates error_buffer when _capture doesn't fire.
static func _scan_lines_for_errors(lines: Array) -> Array:
var errors: Array = []
var i := 0
while i < lines.size():
var line: String = str(lines[i])
var msg := ""
if line.begins_with("USER SCRIPT ERROR:"):
msg = line.substr(len("USER SCRIPT ERROR:")).strip_edges()
elif line.begins_with("SCRIPT ERROR:"):
msg = line.substr(len("SCRIPT ERROR:")).strip_edges()
elif line.begins_with("ERROR:"):
msg = line.substr(len("ERROR:")).strip_edges()
if not msg.is_empty():
var source := ""
var func_name := ""
var source_line := 0
# Check the next line for " at: func (file:line)" pattern.
if i + 1 < lines.size():
var next: String = str(lines[i + 1]).strip_edges()
if next.begins_with("at:"):
var at_info := next.substr(len("at:")).strip_edges()
var paren_open := at_info.rfind("(")
var paren_close := at_info.rfind(")")
if paren_open >= 0 and paren_close > paren_open:
func_name = at_info.left(paren_open).strip_edges()
var loc := at_info.substr(paren_open + 1,
paren_close - paren_open - 1)
var colon := loc.rfind(":")
if colon >= 0:
source = loc.left(colon)
source_line = int(loc.substr(colon + 1))
i += 1 # Skip the "at:" line
errors.append(make_error_entry(
0, msg, source, func_name, source_line, "log_scan"))
i += 1
return errors