Files
unreal-engine-mcp-system-pl…/ue_side/live_coding.py
Bonchellon eba71c4ca8 Unified, portable Unreal Engine MCP system plugin
Merge of the two project copies into one self-contained plugin (the superset:
variable_op + variables.py, full pcg_op runtime/declarative/preset ops, the
CreateWidgetFloatAnimation widget tool, and full Voxel graph authoring).

Made fully project- and machine-agnostic — no hardcoded paths:
- New src/projectPaths.js auto-detects the host .uproject (walk-up), project
  name, Editor build target, log file, and engine install (EngineAssociation
  via launcher manifest/registry, else installed-engine scan). All overridable
  via UE_* env vars.
- Rewired buildOrchestrator/insights/launcher/insightsExporter/server.js and the
  Python workers (cpp_scaffold, live_coding, apply_graph, console) off the old
  C:/Github/ihy, IHY*, E:/UE_Versions and UE_5.7 literals onto the resolver.
- Voxel made optional: Build.cs auto-detects the Voxel plugin (env
  UE_MCP_WITH_VOXEL override) and the C++ compiles to stubs under WITH_MCP_VOXEL,
  so the module builds in projects without Voxel; .uplugin marks Voxel optional.
- De-branded the agent-gateway and docs; scrubbed a leaked API key; excluded
  node_modules/Binaries/Intermediate/__pycache__/secrets from the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:07:24 +03:00

308 lines
10 KiB
Python

"""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/<ProjectName>.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\<Engine>\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