643 lines
23 KiB
GDScript
Vendored
643 lines
23 KiB
GDScript
Vendored
@tool
|
|
class_name McpSurfacedErrorTracker
|
|
extends RefCounted
|
|
|
|
## Central source for "errors the agent should know exist".
|
|
##
|
|
## Editor log cursors only cover McpEditorLogBuffer. Runtime errors from the
|
|
## game subprocess can land solely in the Debugger Errors tab, so this tracker
|
|
## promotes visible Debugger-tab rows into a monotonic sequence before the
|
|
## dispatcher stamps a watermark on each response envelope.
|
|
|
|
const MAX_PROMOTED_DEBUGGER_ENTRIES := 500
|
|
const MAX_PROMOTED_DEBUGGER_KEYS := 5000
|
|
const DEBUGGER_REFRESH_MIN_INTERVAL_MS := 250
|
|
const DEBUGGER_SCAN_AFTER_STOP_MS := 5000
|
|
## #641: delays for the self-scheduled forced scans armed on run stop (and on
|
|
## game-helper hello, via McpDebuggerPlugin). Two ticks: an early one for rows
|
|
## the remote debugger delivers right around the event, and a late one past
|
|
## Godot's per-frame Errors-tab insertion throttle for error floods.
|
|
const DEFERRED_SCAN_DELAYS_SEC: Array[float] = [1.0, 5.0]
|
|
## #635: cap on accounted per-key row-time signatures. The live Errors tab is
|
|
## itself bounded, so this only guards a pathological flood of same-keyed rows
|
|
## with distinct time texts; past the cap the set resets to the current scan.
|
|
const MAX_ACCOUNTED_ROW_TIMES_PER_KEY := 512
|
|
|
|
var _editor_log_buffer
|
|
var _game_log_buffer
|
|
var _debugger_errors_root: Node
|
|
var _debugger_search_root_cache: Node
|
|
var _promoted_debugger_keys: Dictionary = {}
|
|
## #635: per-key set of Errors-tab row time texts already promoted, so a row
|
|
## observed after a clear+repopulate that no scan saw as empty still counts as
|
|
## new (see the re-promotion comment in refresh_debugger_errors).
|
|
var _promoted_debugger_row_times: Dictionary = {}
|
|
var _promoted_debugger_key_order: Array[String] = []
|
|
var _promoted_debugger_entries: Array[Dictionary] = []
|
|
var _debugger_promoted_total := 0
|
|
var _run_seq := 0
|
|
var _oldest_retained_debugger_sequence := 1
|
|
var _last_debugger_refresh_msec := -DEBUGGER_REFRESH_MIN_INTERVAL_MS
|
|
var _debugger_scan_active := false
|
|
var _debugger_scan_until_msec := 0
|
|
var _deferred_scans_scheduled_total := 0
|
|
|
|
|
|
func _init(editor_log_buffer = null, game_log_buffer = null, debugger_errors_root: Node = null) -> void:
|
|
_editor_log_buffer = editor_log_buffer
|
|
_game_log_buffer = game_log_buffer
|
|
_debugger_errors_root = debugger_errors_root
|
|
|
|
|
|
func note_game_run_started(sticky_scan: bool = true) -> void:
|
|
_run_seq += 1
|
|
_debugger_scan_active = sticky_scan
|
|
_debugger_scan_until_msec = 0
|
|
if not sticky_scan:
|
|
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
|
|
refresh_debugger_errors(true)
|
|
|
|
|
|
func note_game_run_stopped() -> void:
|
|
_debugger_scan_active = false
|
|
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
|
|
schedule_deferred_scans()
|
|
|
|
|
|
## #641: promotion into the watermark used to depend on a tool call arriving
|
|
## while the scan gate was open (run active, or within DEBUGGER_SCAN_AFTER_STOP_MS
|
|
## of stop). Boot parse errors that landed in the Errors tab with no tool call
|
|
## in that window were never promoted, so the agent never got the
|
|
## new_errors_since_last_call hint. These editor-side timers force a scan
|
|
## regardless of tool-call cadence; the next stamped response then carries the
|
|
## already-promoted count even after the gate closes. Scans are content-keyed
|
|
## and idempotent, so a timer firing after an unrelated new run is harmless.
|
|
func schedule_deferred_scans(delays: Array = DEFERRED_SCAN_DELAYS_SEC) -> void:
|
|
var tree := Engine.get_main_loop() as SceneTree
|
|
if tree == null:
|
|
return
|
|
for delay in delays:
|
|
var timer := tree.create_timer(maxf(0.05, float(delay)))
|
|
timer.timeout.connect(_on_deferred_scan_timeout)
|
|
_deferred_scans_scheduled_total += 1
|
|
|
|
|
|
func deferred_scans_scheduled_total() -> int:
|
|
return _deferred_scans_scheduled_total
|
|
|
|
|
|
func _on_deferred_scan_timeout() -> void:
|
|
refresh_debugger_errors(true)
|
|
|
|
|
|
func refresh_debugger_errors(force: bool = true) -> void:
|
|
var now := Time.get_ticks_msec()
|
|
if not force and not _should_scan_debugger_for_cached_watermark(now):
|
|
return
|
|
_last_debugger_refresh_msec = now
|
|
var current_by_key: Dictionary = {}
|
|
for entry in _raw_debugger_error_entries():
|
|
if str(entry.get("level", "")) != "error":
|
|
continue
|
|
var key := _log_entry_key(entry)
|
|
var info: Dictionary = current_by_key.get(key, {"count": 0, "entry": entry, "times": {}})
|
|
info["count"] = int(info.get("count", 0)) + 1
|
|
var time_text := _row_time_text(entry)
|
|
if not time_text.is_empty():
|
|
(info["times"] as Dictionary)[time_text] = true
|
|
current_by_key[key] = info
|
|
for key in _promoted_debugger_keys.keys():
|
|
if not current_by_key.has(key):
|
|
_promoted_debugger_keys[key] = 0
|
|
for key in current_by_key.keys():
|
|
var info: Dictionary = current_by_key[key]
|
|
var current := int(info.get("count", 0))
|
|
var stored := int(_promoted_debugger_keys.get(key, 0))
|
|
## #635: a count increase alone misses rows observed after a run
|
|
## boundary. Godot clears the Errors tab at run start; when the new run
|
|
## re-fires an error identical to one promoted before the clear, and no
|
|
## scan happened to observe the tab empty in between, the per-key count
|
|
## never dips — so the row kept its pre-run sequence and run-scoping
|
|
## (editor_entries_since against the run-start cursor) misclassified an
|
|
## in-run error as retained_recent. Each Errors-tab row carries its own
|
|
## time text; an unaccounted (key, time) signature is a row we have not
|
|
## promoted yet, so it earns a fresh sequence even at an equal or lower
|
|
## count. Boundary condition: rows with an empty time text, or a
|
|
## repopulated row whose time text is byte-identical to a pre-clear row,
|
|
## fall back to count-only dedup and can still be missed.
|
|
var unseen_times := _unaccounted_row_times(key, info.get("times", {}))
|
|
var delta := current - stored
|
|
if delta <= 0 and not unseen_times.is_empty():
|
|
delta = mini(unseen_times.size(), current)
|
|
if delta <= 0:
|
|
if current != stored:
|
|
_promoted_debugger_keys[key] = current
|
|
continue
|
|
if not _promoted_debugger_keys.has(key):
|
|
_promoted_debugger_key_order.append(key)
|
|
_promoted_debugger_keys[key] = current
|
|
_account_row_times(key, info.get("times", {}))
|
|
_debugger_promoted_total += delta
|
|
var source_entry: Dictionary = info.get("entry", {})
|
|
var promoted := source_entry.duplicate(true)
|
|
promoted["_debugger_key"] = key
|
|
promoted["_debugger_occurrences"] = current
|
|
promoted["_debugger_sequence"] = _debugger_promoted_total
|
|
_remove_promoted_debugger_entry(key)
|
|
_promoted_debugger_entries.append(promoted)
|
|
_trim_promoted_debugger_entries()
|
|
_trim_promoted_debugger_key_counts()
|
|
|
|
|
|
## #645: promote an error record that has no Errors-tab row to scrape — e.g. a
|
|
## boot-time parse error that parked the game in a remote-debugger break before
|
|
## any surface got a record. The entry joins the same promoted sequence as
|
|
## scraped Debugger rows, so run-scoping (editor_entries_since), the retained
|
|
## fallback, and the response watermark all see it with no extra plumbing.
|
|
## Re-recording the same key later (the same script still broken on the next
|
|
## run) re-promotes it with a fresh sequence, mirroring how re-appearing
|
|
## Errors-tab rows behave; scan reconciliation zeroes the key's count once the
|
|
## break ends since the row never exists in the live tab.
|
|
func record_synthetic_error(entry: Dictionary) -> void:
|
|
var key := _log_entry_key(entry)
|
|
var occurrences := int(_promoted_debugger_keys.get(key, 0)) + 1
|
|
if not _promoted_debugger_keys.has(key):
|
|
_promoted_debugger_key_order.append(key)
|
|
_promoted_debugger_keys[key] = occurrences
|
|
_debugger_promoted_total += 1
|
|
var promoted := entry.duplicate(true)
|
|
promoted["_debugger_key"] = key
|
|
promoted["_debugger_occurrences"] = occurrences
|
|
promoted["_debugger_sequence"] = _debugger_promoted_total
|
|
promoted["_debugger_synthetic"] = true
|
|
_remove_promoted_debugger_entry(key)
|
|
_promoted_debugger_entries.append(promoted)
|
|
_trim_promoted_debugger_entries()
|
|
_trim_promoted_debugger_key_counts()
|
|
|
|
|
|
## Monotonicity contract (#767): run_seq and the session-scoped components
|
|
## (editor_ring, debugger_promoted, editor_ring_warn) must NEVER decrease
|
|
## within an editor session, and the per-run components (game_error_warn,
|
|
## game_warn) must never decrease within a run — they may reset only when
|
|
## run_seq increments in the same stamp (the run boundary that rotates the
|
|
## game buffer's counters). Released servers diff consecutive stamps
|
|
## (websocket.py::_sync_error_watermark_for_session) and treat any other
|
|
## decrease as a counter reset, counting the FULL current value as new — one
|
|
## dip makes every old server out there over-report errors. Any future
|
|
## hold/classification feature must therefore DEFER an increment until its
|
|
## entry is released, never subtract an already-stamped one: a stamp like
|
|
## `raw_total - currently_held_entries` is exactly the regression this
|
|
## guards against. Producer-side coverage:
|
|
## test_editor.gd::test_surfaced_error_tracker_watermark_components_never_decrease.
|
|
func watermark(force_debugger_scan: bool = false) -> Dictionary:
|
|
refresh_debugger_errors(force_debugger_scan)
|
|
return {
|
|
"run_seq": _run_seq,
|
|
"editor_ring": _error_appended_total(),
|
|
"debugger_promoted": _debugger_promoted_total,
|
|
## Historically misnamed: carries game-process ERROR counts only.
|
|
"game_error_warn": _game_error_total(),
|
|
## Warn-level components, parallel to the error counts above. The server
|
|
## diffs these into `new_warnings_since_last_call` so a warning-only run
|
|
## surfaces instead of reading as clean. Debugger Errors-tab warning rows
|
|
## are not promoted here yet (buffers cover push_warning from the game and
|
|
## editor parse/@tool warnings) — tracked as a follow-up.
|
|
"editor_ring_warn": _warn_appended_total(),
|
|
"game_warn": _game_warn_total(),
|
|
}
|
|
|
|
|
|
static func stamp_watermark(response: Dictionary, tracker) -> void:
|
|
if tracker == null:
|
|
return
|
|
if not tracker.has_method("watermark"):
|
|
return
|
|
response["error_watermark"] = tracker.watermark()
|
|
|
|
|
|
func debugger_promoted_total(force_debugger_scan: bool = true) -> int:
|
|
refresh_debugger_errors(force_debugger_scan)
|
|
return _debugger_promoted_total
|
|
|
|
|
|
func collect_editor_log_entries() -> Array[Dictionary]:
|
|
refresh_debugger_errors(true)
|
|
var entries: Array[Dictionary] = []
|
|
var seen_keys: Dictionary = {}
|
|
if _editor_log_buffer != null:
|
|
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
|
|
seen_keys[_log_entry_key(entry)] = true
|
|
entries.append(entry)
|
|
for entry in read_debugger_error_entries():
|
|
var key := _log_entry_key(entry)
|
|
if seen_keys.has(key):
|
|
continue
|
|
seen_keys[key] = true
|
|
entries.append(entry)
|
|
## #645: synthesized break records have no live Errors-tab row to scrape —
|
|
## merge them from the promoted list so logs_read(source="editor") shows
|
|
## the record that run/game responses point at.
|
|
for entry in _promoted_debugger_entries:
|
|
if not bool(entry.get("_debugger_synthetic", false)):
|
|
continue
|
|
var key := _log_entry_key(entry)
|
|
if seen_keys.has(key):
|
|
continue
|
|
seen_keys[key] = true
|
|
entries.append(_strip_promotion_bookkeeping(entry))
|
|
return entries
|
|
|
|
|
|
static func _strip_promotion_bookkeeping(entry: Dictionary) -> Dictionary:
|
|
var clean := entry.duplicate(true)
|
|
for key in ["_debugger_key", "_debugger_occurrences", "_debugger_sequence", "_debugger_synthetic"]:
|
|
clean.erase(key)
|
|
return clean
|
|
|
|
|
|
func editor_entries_since(editor_cursor: int, debugger_cursor: int, force_debugger_scan: bool = true) -> Dictionary:
|
|
refresh_debugger_errors(force_debugger_scan)
|
|
var entries: Array[Dictionary] = []
|
|
var seen_keys: Dictionary = {}
|
|
var truncated := false
|
|
if _editor_log_buffer != null:
|
|
var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, editor_cursor), -1)
|
|
truncated = bool(captured.get("truncated", false))
|
|
for entry in captured.get("entries", []):
|
|
seen_keys[_log_entry_key(entry)] = true
|
|
entries.append(entry)
|
|
if debugger_cursor < _oldest_retained_debugger_sequence - 1:
|
|
truncated = true
|
|
for entry in _promoted_debugger_entries:
|
|
if int(entry.get("_debugger_sequence", 0)) <= debugger_cursor:
|
|
continue
|
|
var key := _log_entry_key(entry)
|
|
if seen_keys.has(key):
|
|
continue
|
|
seen_keys[key] = true
|
|
entries.append(entry)
|
|
return {
|
|
"entries": entries,
|
|
"truncated": truncated,
|
|
}
|
|
|
|
|
|
func retained_recent_editor_entries() -> Array[Dictionary]:
|
|
## There is no shared timestamp across the editor logger ring and Godot's
|
|
## Debugger Errors tree. Preserve the pre-PR fallback contract: newest
|
|
## buffered editor entries first, then debugger-only rows that were not in
|
|
## the ring, so stale Debugger rows cannot outrank newer ring entries.
|
|
var entries: Array[Dictionary] = []
|
|
var seen_keys: Dictionary = {}
|
|
if _editor_log_buffer != null:
|
|
entries = _editor_log_buffer.get_recent(_editor_log_buffer.total_count())
|
|
entries.reverse()
|
|
for entry in entries:
|
|
seen_keys[_log_entry_key(entry)] = true
|
|
for entry in collect_editor_log_entries():
|
|
var key := _log_entry_key(entry)
|
|
if seen_keys.has(key):
|
|
continue
|
|
seen_keys[key] = true
|
|
entries.append(entry)
|
|
return entries
|
|
|
|
|
|
func read_debugger_error_entries() -> Array[Dictionary]:
|
|
var entries: Array[Dictionary] = []
|
|
var seen_keys: Dictionary = {}
|
|
for entry in _raw_debugger_error_entries():
|
|
var key := _log_entry_key(entry)
|
|
if seen_keys.has(key):
|
|
continue
|
|
seen_keys[key] = true
|
|
entries.append(entry)
|
|
return entries
|
|
|
|
|
|
func locate_debugger_error_trees() -> Array[Tree]:
|
|
var trees: Array[Tree] = []
|
|
var root: Node = _debugger_errors_root
|
|
## #641: a deferred-scan timer can outlive an injected root (tests,
|
|
## teardown). A freed root must not fall through to the live editor UI —
|
|
## that would promote unrelated real errors into a tracker scoped to the
|
|
## dead root — so treat it as "nothing to scan".
|
|
if root != null and not is_instance_valid(root):
|
|
return trees
|
|
if root == null:
|
|
root = _debugger_search_root()
|
|
if root == null:
|
|
return trees
|
|
_collect_debugger_error_trees(root, trees)
|
|
return trees
|
|
|
|
|
|
func clear_debugger_error_trees() -> int:
|
|
var cleared := 0
|
|
for tree in locate_debugger_error_trees():
|
|
cleared += entries_from_debugger_error_tree(tree).size()
|
|
if not _press_debugger_clear_button(tree):
|
|
## Synthetic roots in tests do not have Godot's Clear button.
|
|
tree.clear()
|
|
return cleared
|
|
|
|
|
|
func _debugger_search_root() -> Node:
|
|
if is_instance_valid(_debugger_search_root_cache):
|
|
return _debugger_search_root_cache
|
|
_debugger_search_root_cache = null
|
|
var base := EditorInterface.get_base_control()
|
|
if base == null:
|
|
return null
|
|
_debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode")
|
|
if _debugger_search_root_cache == null:
|
|
return base
|
|
return _debugger_search_root_cache
|
|
|
|
|
|
static func _find_first_of_class(node: Node, klass: String) -> Node:
|
|
if node.get_class() == klass:
|
|
return node
|
|
for child in node.get_children():
|
|
var found := _find_first_of_class(child, klass)
|
|
if found != null:
|
|
return found
|
|
return null
|
|
|
|
|
|
static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void:
|
|
if node is Tree and _tree_has_debugger_errors(node as Tree):
|
|
out.append(node as Tree)
|
|
for child in node.get_children():
|
|
if child is Node:
|
|
_collect_debugger_error_trees(child as Node, out)
|
|
|
|
|
|
static func _tree_has_debugger_errors(tree: Tree) -> bool:
|
|
var root := tree.get_root()
|
|
if root == null:
|
|
return false
|
|
var item := root.get_first_child()
|
|
while item != null:
|
|
if _is_debugger_error_item(item):
|
|
return true
|
|
item = item.get_next()
|
|
return false
|
|
|
|
|
|
static func _press_debugger_clear_button(tree: Tree) -> bool:
|
|
var parent := tree.get_parent()
|
|
if parent == null:
|
|
return false
|
|
var stack: Array[Node] = [parent]
|
|
while not stack.is_empty():
|
|
var node: Node = stack.pop_back()
|
|
if node is BaseButton:
|
|
for conn in node.get_signal_connection_list("pressed"):
|
|
if str(conn.get("callable", "")).contains("_clear_errors_list"):
|
|
node.emit_signal("pressed")
|
|
return true
|
|
for child in node.get_children():
|
|
stack.push_back(child)
|
|
return false
|
|
|
|
|
|
static func entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]:
|
|
var entries: Array[Dictionary] = []
|
|
var root := tree.get_root()
|
|
if root == null:
|
|
return entries
|
|
var item := root.get_first_child()
|
|
while item != null:
|
|
if _is_debugger_error_item(item):
|
|
entries.append(_entry_from_debugger_error_item(item))
|
|
item = item.get_next()
|
|
return entries
|
|
|
|
|
|
static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary:
|
|
var title := item.get_text(1)
|
|
var loc := _location_from_metadata(item.get_metadata(0))
|
|
var function := _function_from_title(title)
|
|
return {
|
|
"source": "editor",
|
|
"level": "warn" if item.has_meta("_is_warning") else "error",
|
|
"text": title,
|
|
"path": str(loc.get("path", "")),
|
|
"line": int(loc.get("line", 0)),
|
|
"function": function,
|
|
"details": _details_from_debugger_error_item(item, loc, function),
|
|
}
|
|
|
|
|
|
static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary:
|
|
var children: Array[Dictionary] = []
|
|
var child := item.get_first_child()
|
|
while child != null:
|
|
var child_loc := _location_from_metadata(child.get_metadata(0))
|
|
children.append({
|
|
"label": child.get_text(0),
|
|
"text": child.get_text(1),
|
|
"path": str(child_loc.get("path", "")),
|
|
"line": int(child_loc.get("line", 0)),
|
|
})
|
|
child = child.get_next()
|
|
return {
|
|
"debugger_tab": "Errors",
|
|
"time": item.get_text(0),
|
|
"message": item.get_text(1),
|
|
"error_type_name": "warning" if item.has_meta("_is_warning") else "error",
|
|
"source": {
|
|
"path": str(loc.get("path", "")),
|
|
"line": int(loc.get("line", 0)),
|
|
"function": function,
|
|
},
|
|
"resolved": {
|
|
"path": str(loc.get("path", "")),
|
|
"line": int(loc.get("line", 0)),
|
|
"function": function,
|
|
},
|
|
"children": children,
|
|
"frames": _frames_from_error_children(children),
|
|
}
|
|
|
|
|
|
static func _is_debugger_error_item(item: TreeItem) -> bool:
|
|
return item.has_meta("_is_warning") or item.has_meta("_is_error")
|
|
|
|
|
|
static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]:
|
|
var start := -1
|
|
for i in children.size():
|
|
if str(children[i].label).contains("Stack Trace"):
|
|
start = i
|
|
break
|
|
if start < 0:
|
|
for i in children.size():
|
|
if str(children[i].label).is_empty() and not str(children[i].path).is_empty():
|
|
start = maxi(i - 1, 0)
|
|
break
|
|
if start < 0:
|
|
return []
|
|
var frames: Array[Dictionary] = []
|
|
for i in range(start, children.size()):
|
|
if str(children[i].path).is_empty():
|
|
continue
|
|
frames.append({
|
|
"path": children[i].path,
|
|
"line": children[i].line,
|
|
"function": _function_from_frame_text(children[i].text),
|
|
})
|
|
return frames
|
|
|
|
|
|
static func _location_from_metadata(meta: Variant) -> Dictionary:
|
|
if meta is Array and meta.size() >= 2:
|
|
return {"path": str(meta[0]), "line": int(meta[1])}
|
|
return {"path": "", "line": 0}
|
|
|
|
|
|
static func _function_from_title(title: String) -> String:
|
|
var colon := title.find(": ")
|
|
if colon <= 0:
|
|
return ""
|
|
return title.substr(0, colon)
|
|
|
|
|
|
static func _function_from_frame_text(text: String) -> String:
|
|
var marker := text.find(" @ ")
|
|
if marker < 0:
|
|
return ""
|
|
var fn := text.substr(marker + 3).strip_edges()
|
|
if fn.ends_with("()"):
|
|
fn = fn.substr(0, fn.length() - 2)
|
|
return fn
|
|
|
|
|
|
## Shared one-line rendering of a compact editor-error entry for messages and
|
|
## hints ("text (path:line)"). Single home so the debugger plugin, project
|
|
## handler, and editor handler can't drift apart.
|
|
static func format_editor_error_summary(entry: Dictionary) -> String:
|
|
var text := str(entry.get("text", "editor error"))
|
|
var path := str(entry.get("path", ""))
|
|
var line := int(entry.get("line", 0))
|
|
if not path.is_empty() and line > 0:
|
|
return "%s (%s:%d)" % [text, path, line]
|
|
if not path.is_empty():
|
|
return "%s (%s)" % [text, path]
|
|
return text
|
|
|
|
|
|
static func _log_entry_key(entry: Dictionary) -> String:
|
|
return "%s|%s|%s|%s" % [
|
|
str(entry.get("level", "")),
|
|
str(entry.get("text", "")),
|
|
str(entry.get("path", "")),
|
|
str(entry.get("line", 0)),
|
|
]
|
|
|
|
|
|
func _error_appended_total() -> int:
|
|
if _editor_log_buffer == null:
|
|
return 0
|
|
if _editor_log_buffer.has_method("error_appended_total"):
|
|
return int(_editor_log_buffer.call("error_appended_total"))
|
|
return 0
|
|
|
|
|
|
func _game_error_total() -> int:
|
|
if _game_log_buffer == null:
|
|
return 0
|
|
if _game_log_buffer.has_method("error_total"):
|
|
return int(_game_log_buffer.call("error_total"))
|
|
return 0
|
|
|
|
|
|
func _warn_appended_total() -> int:
|
|
if _editor_log_buffer == null:
|
|
return 0
|
|
if _editor_log_buffer.has_method("warn_appended_total"):
|
|
return int(_editor_log_buffer.call("warn_appended_total"))
|
|
return 0
|
|
|
|
|
|
func _game_warn_total() -> int:
|
|
if _game_log_buffer == null:
|
|
return 0
|
|
if _game_log_buffer.has_method("warn_total"):
|
|
return int(_game_log_buffer.call("warn_total"))
|
|
return 0
|
|
|
|
|
|
func _should_scan_debugger_for_cached_watermark(now_msec: int) -> bool:
|
|
if not _debugger_scan_active and now_msec > _debugger_scan_until_msec:
|
|
return false
|
|
return now_msec - _last_debugger_refresh_msec >= DEBUGGER_REFRESH_MIN_INTERVAL_MS
|
|
|
|
|
|
func _trim_promoted_debugger_entries() -> void:
|
|
while _promoted_debugger_entries.size() > MAX_PROMOTED_DEBUGGER_ENTRIES:
|
|
_promoted_debugger_entries.pop_front()
|
|
if _promoted_debugger_entries.is_empty():
|
|
_oldest_retained_debugger_sequence = _debugger_promoted_total + 1
|
|
else:
|
|
_oldest_retained_debugger_sequence = int(_promoted_debugger_entries[0].get("_debugger_sequence", 1))
|
|
|
|
|
|
func _trim_promoted_debugger_key_counts() -> void:
|
|
while _promoted_debugger_key_order.size() > MAX_PROMOTED_DEBUGGER_KEYS:
|
|
var key := _promoted_debugger_key_order.pop_front()
|
|
_promoted_debugger_keys.erase(key)
|
|
_promoted_debugger_row_times.erase(key)
|
|
|
|
|
|
## #635: per-row time text from a scraped Errors-tab entry (column 0 of the
|
|
## row, carried in details.time). Empty when the entry has no details — e.g.
|
|
## synthetic records — which keeps those on count-only dedup.
|
|
static func _row_time_text(entry: Dictionary) -> String:
|
|
var details: Variant = entry.get("details", {})
|
|
if details is Dictionary:
|
|
return str((details as Dictionary).get("time", ""))
|
|
return ""
|
|
|
|
|
|
func _unaccounted_row_times(key: String, times: Dictionary) -> Array:
|
|
var accounted: Dictionary = _promoted_debugger_row_times.get(key, {})
|
|
var unseen := []
|
|
for time_text in times.keys():
|
|
if not accounted.has(time_text):
|
|
unseen.append(time_text)
|
|
return unseen
|
|
|
|
|
|
func _account_row_times(key: String, times: Dictionary) -> void:
|
|
if times.is_empty():
|
|
return
|
|
var accounted: Dictionary = _promoted_debugger_row_times.get(key, {})
|
|
for time_text in times.keys():
|
|
accounted[time_text] = true
|
|
## Enforce the bound AFTER merging: a pre-merge `>` check let the set
|
|
## reach the cap and keep growing (and a batch of new times could jump
|
|
## past it). Past the cap, reset to just this scan's times — the live
|
|
## Errors tab is itself bounded, so this only fires under a pathological
|
|
## same-key flood, where "recent scan only" is an acceptable memory of
|
|
## what was promoted (worst case: a re-observed ancient row re-promotes).
|
|
if accounted.size() > MAX_ACCOUNTED_ROW_TIMES_PER_KEY:
|
|
accounted = times.duplicate()
|
|
_promoted_debugger_row_times[key] = accounted
|
|
|
|
|
|
func _remove_promoted_debugger_entry(key: String) -> void:
|
|
for i in range(_promoted_debugger_entries.size() - 1, -1, -1):
|
|
if str(_promoted_debugger_entries[i].get("_debugger_key", "")) == key:
|
|
_promoted_debugger_entries.remove_at(i)
|
|
return
|
|
|
|
|
|
func _raw_debugger_error_entries() -> Array[Dictionary]:
|
|
var entries: Array[Dictionary] = []
|
|
for tree in locate_debugger_error_trees():
|
|
entries.append_array(entries_from_debugger_error_tree(tree))
|
|
return entries
|