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>
This commit is contained in:
302
ue_side/network.py
Normal file
302
ue_side/network.py
Normal file
@ -0,0 +1,302 @@
|
||||
"""Network ops for Blueprint assets.
|
||||
|
||||
Ops:
|
||||
audit_bp {bp_path}
|
||||
set_actor_flags {bp_path, replicates?, replicate_movement?, net_load_on_client?,
|
||||
always_relevant?, only_relevant_to_owner?, use_owner_relevancy?,
|
||||
net_cull_distance_squared?, net_update_frequency?,
|
||||
min_net_update_frequency?, net_priority?, net_dormancy?}
|
||||
set_component_flags {bp_path, component, replicated?, net_addressable?}
|
||||
set_custom_event_rpc {bp_path, function?, event?, guid?, mode, reliable?}
|
||||
apply_profile {bp_path, profile}
|
||||
|
||||
RPC modes for set_custom_event_rpc:
|
||||
none | server | client | multicast
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
ACTOR_PROP_MAP = {
|
||||
"replicates": "replicates",
|
||||
"replicate_movement": "replicate_movement",
|
||||
"net_load_on_client": "net_load_on_client",
|
||||
"always_relevant": "always_relevant",
|
||||
"only_relevant_to_owner": "only_relevant_to_owner",
|
||||
"use_owner_relevancy": "net_use_owner_relevancy",
|
||||
"net_cull_distance_squared": "net_cull_distance_squared",
|
||||
"net_update_frequency": "net_update_frequency",
|
||||
"min_net_update_frequency": "min_net_update_frequency",
|
||||
"net_priority": "net_priority",
|
||||
"net_dormancy": "net_dormancy",
|
||||
}
|
||||
|
||||
|
||||
def _load_bp(path: str):
|
||||
bp = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not isinstance(bp, unreal.Blueprint):
|
||||
raise RuntimeError(f"not a Blueprint: {path!r}")
|
||||
return bp
|
||||
|
||||
|
||||
def _generated_class(bp_path: str):
|
||||
cls_path = f"{bp_path}.{bp_path.rsplit('/', 1)[-1]}_C"
|
||||
cls = unreal.load_class(None, cls_path)
|
||||
if cls is None:
|
||||
raise RuntimeError(f"generated class not found: {cls_path}")
|
||||
return cls
|
||||
|
||||
|
||||
def _cdo(bp_path: str):
|
||||
return unreal.get_default_object(_generated_class(bp_path))
|
||||
|
||||
|
||||
def _lib():
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
if L is None:
|
||||
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
|
||||
return L
|
||||
|
||||
|
||||
def _find_graph(bp, name: str):
|
||||
L = _lib()
|
||||
if name in (None, "", "EventGraph"):
|
||||
return L.find_event_graph(bp)
|
||||
return L.find_function_graph(bp, name)
|
||||
|
||||
|
||||
def _get_prop(obj, prop):
|
||||
try:
|
||||
return obj.get_editor_property(prop)
|
||||
except Exception:
|
||||
return getattr(obj, prop)
|
||||
|
||||
|
||||
def _set_prop(obj, prop, value):
|
||||
try:
|
||||
obj.set_editor_property(prop, value)
|
||||
except Exception:
|
||||
setattr(obj, prop, value)
|
||||
|
||||
|
||||
def _save_compile(bp):
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
messages = []
|
||||
try:
|
||||
if L:
|
||||
messages = list(L.compile_with_messages(bp))
|
||||
else:
|
||||
unreal.KismetEditorUtilities.compile_blueprint(bp)
|
||||
except Exception as e:
|
||||
messages = [f"WARN||compile failed from network.py: {e}"]
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return messages
|
||||
|
||||
|
||||
def _read_actor_flags(cdo):
|
||||
out = {}
|
||||
for public_name, prop in ACTOR_PROP_MAP.items():
|
||||
try:
|
||||
v = _get_prop(cdo, prop)
|
||||
out[public_name] = str(v) if public_name == "net_dormancy" else v
|
||||
except Exception as e:
|
||||
out[public_name] = {"error": str(e)}
|
||||
return out
|
||||
|
||||
|
||||
def audit_bp(args: dict) -> dict:
|
||||
bp_path = args["bp_path"]
|
||||
cdo = _cdo(bp_path)
|
||||
result = {
|
||||
"ok": True,
|
||||
"bp_path": bp_path,
|
||||
"actor": _read_actor_flags(cdo),
|
||||
"components": [],
|
||||
"custom_events": [],
|
||||
"notes": [],
|
||||
}
|
||||
|
||||
for name in args.get("components") or []:
|
||||
item = {"name": name}
|
||||
try:
|
||||
comp = _get_prop(cdo, name)
|
||||
item["class"] = comp.get_class().get_name()
|
||||
for prop in ("replicates", "is_replicated", "net_addressable"):
|
||||
try:
|
||||
item[prop] = _get_prop(comp, prop)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
item["error"] = str(e)
|
||||
result["components"].append(item)
|
||||
|
||||
try:
|
||||
import apply_graph
|
||||
raw = apply_graph.read_graph(bp_path, args.get("function") or "EventGraph")
|
||||
for node in raw.get("graph", {}).get("nodes", []):
|
||||
if node.get("class") == "K2Node_CustomEvent":
|
||||
result["custom_events"].append({
|
||||
"guid": node.get("guid"),
|
||||
"title": node.get("title"),
|
||||
"target": node.get("target"),
|
||||
})
|
||||
result["notes"].append("Custom event RPC flags require set_custom_event_rpc; read_graph does not expose them.")
|
||||
except Exception as e:
|
||||
result["notes"].append(f"custom event scan failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def set_actor_flags(args: dict) -> dict:
|
||||
bp_path = args["bp_path"]
|
||||
bp = _load_bp(bp_path)
|
||||
cdo = _cdo(bp_path)
|
||||
changed = {}
|
||||
errors = {}
|
||||
for public_name, prop in ACTOR_PROP_MAP.items():
|
||||
if public_name not in args:
|
||||
continue
|
||||
try:
|
||||
_set_prop(cdo, prop, args[public_name])
|
||||
changed[public_name] = args[public_name]
|
||||
except Exception as e:
|
||||
errors[public_name] = str(e)
|
||||
messages = _save_compile(bp)
|
||||
return {"ok": not errors, "bp_path": bp_path, "changed": changed, "errors": errors, "compile_messages": messages}
|
||||
|
||||
|
||||
def _component_from_cdo(cdo, name: str):
|
||||
try:
|
||||
return _get_prop(cdo, name)
|
||||
except Exception:
|
||||
pass
|
||||
getter = getattr(cdo, "get_component_by_class", None)
|
||||
if getter:
|
||||
# Fallback is intentionally conservative: caller should pass the BP component variable name.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def set_component_flags(args: dict) -> dict:
|
||||
bp_path = args["bp_path"]
|
||||
bp = _load_bp(bp_path)
|
||||
cdo = _cdo(bp_path)
|
||||
comp_name = args["component"]
|
||||
comp = _component_from_cdo(cdo, comp_name)
|
||||
if comp is None:
|
||||
return {"ok": False, "error": f"component not found on CDO by name: {comp_name}"}
|
||||
|
||||
changed = {}
|
||||
errors = {}
|
||||
for public_name, candidates in {
|
||||
"replicated": ("replicates", "is_replicated"),
|
||||
"net_addressable": ("net_addressable",),
|
||||
}.items():
|
||||
if public_name not in args:
|
||||
continue
|
||||
done = False
|
||||
for prop in candidates:
|
||||
try:
|
||||
_set_prop(comp, prop, args[public_name])
|
||||
changed[public_name] = args[public_name]
|
||||
done = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not done:
|
||||
errors[public_name] = f"no writable property among {candidates}"
|
||||
messages = _save_compile(bp)
|
||||
return {"ok": not errors, "bp_path": bp_path, "component": comp_name, "changed": changed, "errors": errors, "compile_messages": messages}
|
||||
|
||||
|
||||
def set_custom_event_rpc(args: dict) -> dict:
|
||||
bp_path = args["bp_path"]
|
||||
bp = _load_bp(bp_path)
|
||||
graph = _find_graph(bp, args.get("function") or "EventGraph")
|
||||
if graph is None:
|
||||
return {"ok": False, "error": "graph not found"}
|
||||
|
||||
target = args.get("guid") or args.get("event")
|
||||
if not target:
|
||||
return {"ok": False, "error": "set_custom_event_rpc requires guid or event"}
|
||||
mode = args.get("mode") or "none"
|
||||
reliable = bool(args.get("reliable", False))
|
||||
|
||||
L = _lib()
|
||||
setter = getattr(L, "set_custom_event_network_flags", None)
|
||||
if setter is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "MCPGraphLibrary.set_custom_event_network_flags is unavailable. Rebuild/restart UE after the plugin C++ update.",
|
||||
"requested": {"target": target, "mode": mode, "reliable": reliable},
|
||||
}
|
||||
ok = bool(setter(bp, graph, target, mode, reliable))
|
||||
messages = _save_compile(bp) if ok else []
|
||||
return {"ok": ok, "bp_path": bp_path, "target": target, "mode": mode, "reliable": reliable, "compile_messages": messages}
|
||||
|
||||
|
||||
def apply_profile(args: dict) -> dict:
|
||||
profile = args["profile"]
|
||||
bp_path = args["bp_path"]
|
||||
if profile == "replicated_world_actor":
|
||||
return set_actor_flags({
|
||||
"bp_path": bp_path,
|
||||
"replicates": True,
|
||||
"net_load_on_client": True,
|
||||
"replicate_movement": bool(args.get("replicate_movement", False)),
|
||||
})
|
||||
if profile == "always_relevant_world_actor":
|
||||
return set_actor_flags({
|
||||
"bp_path": bp_path,
|
||||
"replicates": True,
|
||||
"net_load_on_client": True,
|
||||
"always_relevant": True,
|
||||
"replicate_movement": bool(args.get("replicate_movement", False)),
|
||||
})
|
||||
return {"ok": False, "error": f"unknown profile {profile!r}", "available": ["replicated_world_actor", "always_relevant_world_actor"]}
|
||||
|
||||
|
||||
OPS = {
|
||||
"audit_bp": audit_bp,
|
||||
"set_actor_flags": set_actor_flags,
|
||||
"set_component_flags": set_component_flags,
|
||||
"set_custom_event_rpc": set_custom_event_rpc,
|
||||
"apply_profile": apply_profile,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
fn = OPS.get(p.get("op"))
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {p.get('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: dict, p: dict) -> str:
|
||||
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 + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-NETWORK] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
Reference in New Issue
Block a user