"""Collect recent game / Proton / Steam logs to enrich an AI diagnostic (M14). Reads logs that already exist on disk — no change to how the game is launched. Two reliable sources: Proton's per-app log (``~/steam-.log``, written when ``PROTON_LOG=1``) and Steam's own console log. Each is tail-read and size-bounded so the AI prompt stays small. The text is fed to the AI alongside the findings so it can see *when* something went wrong (a vkd3d/DXVK error, a crash line, the exit code) rather than only the sensor summary. """ from __future__ import annotations import os import re import time from pathlib import Path # Steam keeps logs under its install root; ~/.steam/steam usually symlinks to the real one. _STEAM_LOG_DIRS = ("~/.steam/steam/logs", "~/.local/share/Steam/logs", "~/.steam/root/logs") _STEAM_LOG_FILES = ("console-linux.txt", "console_log.txt", "stderr.txt") _TS = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]") def _line_epoch(line: str) -> float | None: m = _TS.match(line) if not m: return None try: return time.mktime(time.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")) except ValueError: return None def _since_filter(text: str, since: float) -> str: """Keep lines from the first timestamp >= `since` onward (logs are chronological). Untimestamped lines before the window are dropped; once inside the window every line is kept (so multi-line entries survive). This scopes a long-lived Steam log to one session. """ out: list[str] = [] including = False for line in text.splitlines(): epoch = _line_epoch(line) if epoch is not None and epoch >= since: including = True if including: out.append(line) return "\n".join(out) def _tail(path: Path, max_bytes: int) -> str: """Last ``max_bytes`` of a file, decoded leniently (empty string on error).""" try: size = path.stat().st_size with path.open("rb") as fh: if size > max_bytes: fh.seek(size - max_bytes) return fh.read().decode("utf-8", "replace") except OSError: return "" def _proton_logs() -> list[Path]: try: logs = list(Path.home().glob("steam-*.log")) except OSError: return [] return sorted(logs, key=lambda p: p.stat().st_mtime, reverse=True) def _steam_console() -> Path | None: for directory in _STEAM_LOG_DIRS: base = Path(os.path.expanduser(directory)) for name in _STEAM_LOG_FILES: candidate = base / name if candidate.exists(): return candidate return None def available() -> bool: return bool(_proton_logs() or _steam_console()) def collect(since: float | None = None, max_bytes: int = 8000) -> str: """Recent Proton + Steam log tails as one labelled text block ('' if none). With ``since`` (epoch), scope to that session: skip a Proton log not written during/after the session (a stale per-app log from an earlier game), and keep only Steam-console lines timestamped at/after ``since`` — so we don't feed the model an unrelated past session. """ sections: list[str] = [] protons = _proton_logs() if protons: log = protons[0] fresh = since is None or _mtime(log) >= since tail = _tail(log, max_bytes).strip() if fresh else "" if tail: sections.append(f"--- Proton log ({log.name}) ---\n{tail}") console = _steam_console() if console: raw = _tail(console, 40000 if since else max_bytes) if since is not None: raw = _since_filter(raw, since) raw = raw.strip()[-max_bytes:].strip() if raw: sections.append(f"--- Steam log ({console.name}) ---\n{raw}") return "\n\n".join(sections) def _mtime(path: Path) -> float: try: return path.stat().st_mtime except OSError: return 0.0