"""Live Coding control — status, compile trigger, error parsing. Why this exists: the AI repeatedly hits "Live Coding is open but won't build / won't recompile". This module wraps ILiveCodingModule via Python where exposed, falls back to console commands otherwise, and scrapes the latest LiveCoding log for compile errors so the AI can iterate without screenshots. Ops: status {} — is_enabled, has_started_compile, pending_changes (best-effort) compile {} — kick off a Live Coding patch enable {} disable {} get_errors {tail?:200} — read last LiveCoding.log lines, filter for errors open_lc_console{} — opens the LC window """ from __future__ import annotations import json import os import re import traceback try: import unreal # type: ignore except ImportError: unreal = None def _project_log_path(): """The main editor log: Saved/Logs/.log. Project name is derived from the .uproject so this is correct in any project, not just one.""" if unreal is None: return None try: up = unreal.Paths.get_project_file_path() name = os.path.splitext(os.path.basename(up))[0] return unreal.Paths.project_saved_dir() + f"Logs/{name}.log" except Exception: return None def _world(): return unreal.EditorLevelLibrary.get_editor_world() def _exec(cmd): unreal.SystemLibrary.execute_console_command(_world(), cmd) def _lc_log_path(): """Search a wide range of locations for LiveCoding.log + LiveCoding-backup-*.log. Returns newest file by mtime.""" candidates = [] def _add_dir(d): if not d or not os.path.isdir(d): return try: for fn in os.listdir(d): if fn.lower().startswith("livecoding") and fn.lower().endswith(".log"): candidates.append(os.path.join(d, fn)) except Exception: pass # Project Saved/Logs try: _add_dir(unreal.Paths.project_saved_dir() + "Logs") except Exception: pass # Engine Saved/Logs (project-relative) try: _add_dir(unreal.Paths.engine_saved_dir() + "Logs") except Exception: pass # Engine Programs / LiveCodingConsole — this is where LC actually writes its server log. # unreal.Paths.engine_dir() gives the engine root (relative or absolute). try: engine_root = unreal.Paths.convert_relative_path_to_full(unreal.Paths.engine_dir()) _add_dir(os.path.join(engine_root, "Programs", "LiveCodingConsole", "Saved", "Logs")) except Exception: pass # %LOCALAPPDATA%\UnrealEngine\\Saved\Logs appdata = os.environ.get("LOCALAPPDATA", "") if appdata: ue_root = os.path.join(appdata, "UnrealEngine") if os.path.isdir(ue_root): for sub in os.listdir(ue_root): _add_dir(os.path.join(ue_root, sub, "Saved", "Logs")) # %APPDATA%\Unreal Engine\AutomationTool\Logs (sometimes UBT writes here) appdata_roam = os.environ.get("APPDATA", "") if appdata_roam: _add_dir(os.path.join(appdata_roam, "Unreal Engine", "AutomationTool", "Logs")) # Programs/UnrealBuildTool/Log.txt — different file but useful for compile errors # We only care about LiveCoding here; UBT log is separate. if not candidates: return None candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) return candidates[0] def status(args): out = {"ok": True} try: lcs = getattr(unreal, "LiveCodingSubsystem", None) sub = unreal.get_editor_subsystem(lcs) if lcs else None if sub is not None: for attr in ("is_enabled", "has_started_compile", "is_compiling"): if hasattr(sub, attr): out[attr] = bool(getattr(sub, attr)()) else: out["note"] = "LiveCodingSubsystem not exposed in Python; using log scrape + console" except Exception as e: out["query_error"] = str(e) log = _lc_log_path() out["lc_log_path"] = log out["lc_log_exists"] = bool(log) # Always also expose the main project log as the fallback error source. fb = _project_log_path() if fb: out["fallback_log"] = fb return out def compile(args): try: lcs = getattr(unreal, "LiveCodingSubsystem", None) sub = unreal.get_editor_subsystem(lcs) if lcs else None if sub is not None and hasattr(sub, "compile"): sub.compile() return {"ok": True, "via": "subsystem"} except Exception as e: return {"ok": False, "error": str(e)} try: _exec("LiveCoding.Compile") return {"ok": True, "via": "console"} except Exception as e: return {"ok": False, "error": str(e)} def enable(args): try: _exec("LiveCoding.Enable") except Exception as e: return {"ok": False, "error": str(e)} return {"ok": True} def disable(args): try: _exec("LiveCoding.Disable") except Exception as e: return {"ok": False, "error": str(e)} return {"ok": True} def open_lc_console(args): try: _exec("LiveCoding.Console") except Exception as e: return {"ok": False, "error": str(e)} return {"ok": True} _ERR_RX = re.compile(r"\b(error\s+[A-Z]\d{3,5}|fatal error|undefined reference|cannot open|LINK\s*:\s*fatal|: error:|: warning:)\b", re.I) _LC_CATEGORY_RX = re.compile(r"Log(LiveCoding|LiveCodingServer|Compile|HotReload|UnrealBuildTool)\s*:", re.I) _LC_COMPILE_START_RX = re.compile(r"(Manual recompile triggered|Creating patch|Compiling|Starting compile)", re.I) _LC_COMPILE_END_RX = re.compile(r"Finished\s*\(([\d.]+)s\)", re.I) _LC_NO_CHANGES_RX = re.compile(r"No changes detected|nothing to compile", re.I) def get_errors(args): tail = int(args.get("tail") or 200) log = _lc_log_path() sources_used = [] if log is not None: sources_used.append(log) try: with open(log, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() errs = [l.rstrip() for l in lines if _ERR_RX.search(l)] if errs: return {"ok": True, "source": "LiveCoding.log", "log_path": log, "total_lines": len(lines), "errors_count": len(errs), "errors": errs[-tail:]} except Exception as e: sources_used.append(f"(read error: {e})") # Fallback: project log, filtered by LiveCoding/Compile/HotReload/UBT categories fallback = _project_log_path() if fallback and os.path.isfile(fallback): sources_used.append(fallback) try: with open(fallback, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() relevant = [l.rstrip() for l in lines if _LC_CATEGORY_RX.search(l) or _ERR_RX.search(l)] errs = [l for l in relevant if _ERR_RX.search(l) or "Error" in l or "Fatal" in l] return {"ok": True, "source": "project_log_fallback", "log_path": fallback, "total_lines": len(lines), "lc_lines": len(relevant), "errors_count": len(errs), "lc_recent": relevant[-tail:], "errors": errs[-tail:], "sources_searched": sources_used} except Exception as e: return {"ok": False, "error": f"fallback read failed: {e}", "sources_searched": sources_used} return {"ok": False, "error": "no LiveCoding.log and no fallback project log", "sources_searched": sources_used} def last_compile(args): """Find the most recent compile cycle in LiveCodingConsole.log. Returns: status ('success'|'failed'|'in_progress'|'no_recent'), duration_s, timestamp, lines for the cycle, and any errors. """ log = _lc_log_path() if log is None: return {"ok": False, "error": "no LC log found"} try: with open(log, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() except Exception as e: return {"ok": False, "error": str(e)} # Walk backwards: find the last "Creating patch" / "Manual recompile" marker start_idx = -1 for i in range(len(lines) - 1, -1, -1): if _LC_COMPILE_START_RX.search(lines[i]): start_idx = i break if start_idx == -1: return {"ok": True, "log_path": log, "status": "no_recent", "note": "no compile cycle found in log"} cycle = [l.rstrip() for l in lines[start_idx:]] timestamp_match = re.search(r"\[([\d.\-:]+)\]", cycle[0]) timestamp = timestamp_match.group(1) if timestamp_match else None end_match = None for l in cycle: m = _LC_COMPILE_END_RX.search(l) if m: end_match = m break errors = [l for l in cycle if _ERR_RX.search(l)] if errors: status_str = "failed" elif end_match: status_str = "success" else: status_str = "in_progress" return { "ok": True, "log_path": log, "status": status_str, "timestamp": timestamp, "duration_s": float(end_match.group(1)) if end_match else None, "errors_count": len(errors), "errors": errors, "cycle_lines": cycle, } OPS = { "status": status, "compile": compile, "enable": enable, "disable": disable, "open_lc_console": open_lc_console, "get_errors": get_errors, "last_compile": last_compile, } def entrypoint(payload_json): try: p = json.loads(payload_json) except Exception as e: return _tee({"ok": False, "error": f"bad json: {e}"}, {}) op = p.get("op"); fn = OPS.get(op) if not fn: return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p) try: return _tee(fn(p), p) except Exception as e: return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p) def _tee(result, p): txt = json.dumps(result) try: if unreal is not None: sd = unreal.Paths.project_saved_dir() + "MCP/" os.makedirs(sd, exist_ok=True) rid = p.get("__rid") or "last" with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt) if rid != "last": with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt) unreal.log("[MCP-LC] " + txt[:1500]) except Exception: pass return txt