@tool class_name McpStructuredLogRing extends RefCounted ## Head-indexed circular buffer of structured log entries shared by ## game_log_buffer and editor_log_buffer. ## ## Once `_max_lines` (set in subclass `_init`) is reached, new appends ## overwrite the oldest slot at `_head`, keeping append O(1) on overflow ## — the previous slice() approach reallocated the full retained array ## on every drop, which a chatty game would pay for thousands of times ## per second. ## ## Lockless. Subclasses needing thread-safety (editor_log_buffer is ## written from any thread a Godot Logger virtual can fire on) wrap each ## public method with their own Mutex around the `_*_unlocked` helpers. ## Keeping the base lockless means the hot game-side path (single thread, ## called from _process) doesn't pay an unused mutex cost. ## ## Entry shape is owned by subclasses — `_append_entry` takes a ## ready-built Dictionary so each buffer can carry the fields it needs ## (game: `source/level/text`; editor: adds `path/line/function`). const VALID_LEVELS := ["info", "warn", "error"] var _max_lines: int var _storage: Array[Dictionary] = [] ## Next write position within `_storage`. While filling (before first ## wrap) equals `_storage.size()`; once full, points at the oldest entry ## (the one about to be overwritten). var _head := 0 var _dropped_count := 0 ## Monotonic number of entries appended since this ring was created. Unlike ## `_storage.size()` and `_dropped_count`, this intentionally survives clear() ## so callers can use it as a stable "next entry to read" cursor. var _appended_total := 0 func _init(max_lines: int) -> void: _max_lines = max_lines ## Append `entry` to the ring, evicting the oldest slot when full. ## Subclasses build the dict with their per-source shape and pass it in. func _append_entry(entry: Dictionary) -> void: if _storage.size() < _max_lines: _storage.append(entry) _head = _storage.size() % _max_lines else: ## Full — overwrite oldest in place, advance head, count the drop. _storage[_head] = entry _head = (_head + 1) % _max_lines _dropped_count += 1 _appended_total += 1 ## Lockless slice. Subclasses with a mutex wrap their `get_range` / ## `get_recent` overrides around this; the lockless base implementations ## of those public methods just delegate here. func _get_range_unlocked(offset: int, count: int) -> Array[Dictionary]: var size := _storage.size() var start := maxi(0, offset) var stop := mini(size, start + count) var out: Array[Dictionary] = [] for i in range(start, stop): out.append(_storage[_logical_to_physical(i)]) return out func get_range(offset: int, count: int) -> Array[Dictionary]: return _get_range_unlocked(offset, count) func get_recent(count: int) -> Array[Dictionary]: var size := _storage.size() var start := maxi(0, size - count) return _get_range_unlocked(start, size - start) ## Lockless cursor read. The cursor is the next sequence to read: calling ## get_since(appended_total()) after a snapshot returns only later appends. func _get_since_unlocked(since_seq: int, limit: int = -1) -> Dictionary: var size := _storage.size() var oldest_seq := _appended_total - size var start_seq := mini(maxi(since_seq, oldest_seq), _appended_total) var start := start_seq - oldest_seq var available := maxi(0, size - start) var count := available if limit >= 0: count = mini(available, limit) var entries := _get_range_unlocked(start, count) var next_cursor := start_seq + entries.size() return { "cursor": since_seq, "oldest_cursor": oldest_seq, "next_cursor": next_cursor, "appended_total": _appended_total, "truncated": since_seq < oldest_seq, "has_more": next_cursor < _appended_total, "entries": entries, } func get_since(since_seq: int, limit: int = -1) -> Dictionary: return _get_since_unlocked(since_seq, limit) ## Lockless accessors. Subclasses with a mutex use these under their lock ## so the field reads stay encapsulated in the base instead of leaking ## `_storage` / `_dropped_count` reach-through into the subclass. func _total_count_unlocked() -> int: return _storage.size() func _dropped_count_unlocked() -> int: return _dropped_count func _appended_total_unlocked() -> int: return _appended_total func total_count() -> int: return _total_count_unlocked() func dropped_count() -> int: return _dropped_count_unlocked() func appended_total() -> int: return _appended_total_unlocked() ## Translate a logical index (0 = oldest retained) to a physical ## `_storage` slot. Before the first wrap, storage-order is logical- ## order. After wrapping, the oldest entry lives at `_head`. func _logical_to_physical(logical: int) -> int: if _storage.size() < _max_lines: return logical return (_head + logical) % _max_lines ## Reset the ring to empty. Subclasses with a mutex wrap this with their ## lock; subclasses that surface `clear` to callers (McpEditorLogBuffer) ## return the prior size from their wrapper. func _clear_storage() -> void: _storage.clear() _head = 0 _dropped_count = 0 ## Coerce unknown levels to "info" so a misbehaving sender can't poison ## downstream filters with arbitrary strings. static func _coerce_level(level: String) -> String: return level if level in VALID_LEVELS else "info"