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>
308 lines
10 KiB
Python
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
|