Files
unreal-engine-mcp-system-pl…/ue_side/pcg.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

1238 lines
46 KiB
Python

"""PCG (Procedural Content Generation) graph MCP ops.
Pure-Python worker — unlike the Voxel worker, PCG's data model
(UPCGGraph / UPCGNode / UPCGSettings) is engine-exposed to Python, so no C++
shim is required.
"All workers" = every UPCGSettings subclass bound into the `unreal` module.
This is enumerated dynamically (see `_settings_registry`), which means engine
PCG nodes AND PCGExtendedToolkit (PCGEx) nodes are both supported automatically
without a hardcoded table — any plugin that ships UPCGSettings subclasses gets
picked up for free.
Node identity: PCG nodes have no stable public GUID exposed to Python, so a node
is referenced by:
* "input" / "output" -> the graph's special I/O nodes
* an integer index -> position in get_nodes()
* a UObject name (node.get_name())
* a settings class short name / title (first match)
Ops (authoring):
api {} introspect live PCG reflection (method names, etc.)
create_graph {path} new empty UPCGGraph asset
list_node_types {filter?, limit?} catalogue of spawnable workers (all UPCGSettings subclasses)
read_graph {asset_path} dump nodes + pins + edges
add_node {asset_path, node, x?, y?, title?} spawn a worker (node = class name/path/title)
delete_node {asset_path, node}
connect {asset_path, from:[node,pin?], to:[node,pin?]}
disconnect {asset_path, from:[node,pin?], to:[node,pin?]}
list_node_properties {asset_path, node} editor properties of a node's settings
set_node_property {asset_path, node, property, value}
move_nodes {asset_path, moves:[{node,x,y}]} best-effort editor positions
save {asset_path}
open_asset {asset_path}
Ops (declarative authoring + layout):
apply_graph {asset_path, nodes:[{id,type,x?,y?,title?,properties?}], edges:[{from:[id,pin?],to:[id,pin?]}], outputs?:[{from:[id,pin?],pin?}], clear_existing?, auto_layout?, save?}
build a whole graph in one call (auto-laid-out by default)
auto_layout/tidy_graph/layout {asset_path} re-flow existing nodes into edge-depth columns
Ops (parameters + subgraph):
list_params {asset_path} dump graph UserParameters bag (read-only — UE blocks Python editing)
set_subgraph {asset_path, node, subgraph} point a Subgraph node at a target PCG graph asset
Ops (level / runtime):
spawn_volume {graph?, location?, rotation?, scale?, name?, generate?} spawn a PCG Volume actor, assign graph, generate
set_actor_graph {actor, graph, generate?} assign a graph to an existing actor's PCGComponent
generate {actor, force?} (re)generate a PCG actor's component
cleanup {actor} clear an actor's generated content
list_components {} level actors carrying a PCGComponent + their graph/state
Ops (presets):
scaffold {path|asset_path, template, create?, save?} build a working graph from a named template
(template='__list__' lists: surface_scatter, points_grid_spawn, density_filter_scatter)
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
try:
import graph_layout # shared deterministic layout helper
except Exception: # noqa: BLE001 - layout is optional; ops degrade gracefully
graph_layout = None
# --------------------------------------------------------------------------- #
# low-level helpers
# --------------------------------------------------------------------------- #
def _pcg_base():
base = getattr(unreal, "PCGSettings", None)
if base is None:
raise RuntimeError(
"unreal.PCGSettings not found - is the PCG plugin enabled and the "
"Python API loaded? (Project uses PCGExtendedToolkit which depends on it.)"
)
return base
def _load_graph(path: str):
asset = unreal.EditorAssetLibrary.load_asset(path)
if asset is None:
raise RuntimeError(f"asset not found: {path!r}")
if not isinstance(asset, unreal.PCGGraph):
raise RuntimeError(
f"asset is not a UPCGGraph: {path!r} ({asset.get_class().get_name()})")
return asset
def _save(asset):
try:
unreal.EditorAssetLibrary.save_loaded_asset(asset)
except Exception:
pass
def _short(cls) -> str:
"""'PCGDensityFilterSettings' from a class object. Note: a bound unreal
class object's get_name()/get_path_name() are unbound (need an instance),
so use the Python type __name__."""
name = getattr(cls, "__name__", None)
if name:
return name
try:
return cls.get_name()
except Exception:
return str(cls)
def _class_path(cls) -> str:
"""'/Script/PCG.PCGSurfaceSamplerSettings' — derived via the CDO because a
bound class object's get_path_name() is unbound in the unreal Python API."""
try:
return unreal.get_default_object(cls).get_class().get_path_name()
except Exception:
return _short(cls)
def _title_for(cls) -> str:
"""Best-effort human title from the settings CDO."""
try:
cdo = unreal.get_default_object(cls)
for getter in ("get_default_node_title", "get_default_node_name"):
fn = getattr(cdo, getter, None)
if fn:
try:
val = str(fn())
if val:
return val
except Exception:
pass
except Exception:
pass
name = _short(cls)
# strip the conventional PCG/PCGEx prefix + Settings suffix for readability
base = name[:-8] if name.endswith("Settings") else name
return base
# --------------------------------------------------------------------------- #
# node-type catalogue ("all workers")
# --------------------------------------------------------------------------- #
def _settings_registry() -> dict:
"""{class_object: {name, title, category, plugin}} for every bound
UPCGSettings subclass. Dynamic — picks up PCG + PCGEx + any plugin."""
base = _pcg_base()
out = {}
for attr in dir(unreal):
obj = getattr(unreal, attr, None)
try:
if not isinstance(obj, type) or obj is base:
continue
if not issubclass(obj, base):
continue
except TypeError:
continue
name = _short(obj)
plugin = "PCGEx" if name.startswith("PCGEx") else "PCG"
out[obj] = {"name": name, "title": _title_for(obj), "plugin": plugin}
return out
def _resolve_settings_class(ref: str):
"""ref -> settings UClass. Accepts short name, /Script path, or title."""
ref = (ref or "").strip()
if not ref:
raise RuntimeError("node type ref is empty")
# 1) explicit /Script path
if ref.startswith("/Script/"):
cls = unreal.load_class(None, ref)
if cls is not None:
return cls
# 2) direct attribute on unreal module (exact or with Settings suffix)
for cand in (ref, ref + "Settings", "PCG" + ref, "PCG" + ref + "Settings",
"PCGEx" + ref, "PCGEx" + ref + "Settings"):
obj = getattr(unreal, cand, None)
if isinstance(obj, type):
return obj
# 3) registry lookup by name / title (case-insensitive)
low = ref.lower()
reg = _settings_registry()
for cls, meta in reg.items():
if meta["name"].lower() == low or meta["title"].lower() == low:
return cls
for cls, meta in reg.items(): # loose contains match
if low in meta["name"].lower() or low in meta["title"].lower():
return cls
raise RuntimeError(f"unknown PCG node type: {ref!r} (try list_node_types)")
# --------------------------------------------------------------------------- #
# node referencing within a graph
# --------------------------------------------------------------------------- #
def _graph_nodes(graph) -> list:
# UPCGGraph exposes 'nodes' as a property (no get_nodes()).
for src in ("nodes",):
try:
val = graph.get_editor_property(src)
if val is not None:
return list(val)
except Exception:
pass
fn = getattr(graph, "get_nodes", None)
if callable(fn):
try:
return list(fn())
except Exception:
pass
return []
def _io_node(graph, which: str):
fn = getattr(graph, "get_input_node" if which == "input" else "get_output_node", None)
return fn() if fn else None
def _resolve_node(graph, ref):
"""ref -> UPCGNode. Accepts 'input'/'output', int index, UObject name,
or settings class short-name/title (first match)."""
if ref is None:
raise RuntimeError("node ref is None")
if isinstance(ref, str) and ref.lower() in ("input", "output"):
node = _io_node(graph, ref.lower())
if node is None:
raise RuntimeError(f"graph has no {ref} node")
return node
nodes = _graph_nodes(graph)
# integer index
try:
idx = int(ref)
if 0 <= idx < len(nodes):
return nodes[idx]
except (TypeError, ValueError):
pass
low = str(ref).lower()
# exact UObject name
for n in nodes:
try:
if n.get_name().lower() == low:
return n
except Exception:
pass
# settings class short name / title
for n in nodes:
meta = _node_settings_meta(n)
if meta and (meta["class"].lower() == low or meta["title"].lower() == low):
return n
for n in nodes: # loose contains
meta = _node_settings_meta(n)
if meta and (low in meta["class"].lower() or low in meta["title"].lower()):
return n
raise RuntimeError(f"node not found in graph: {ref!r} (try read_graph)")
def _node_settings(node):
for getter in ("get_settings", "default_settings"):
fn = getattr(node, getter, None)
if callable(fn):
try:
s = fn()
if s is not None:
return s
except Exception:
pass
# property fallbacks
for prop in ("settings_interface", "default_settings"):
try:
s = node.get_editor_property(prop)
if s is not None:
# settings_interface wraps the settings
inner = getattr(s, "get_settings", None)
return inner() if callable(inner) else s
except Exception:
pass
return None
def _node_settings_meta(node):
s = _node_settings(node)
if s is None:
return None
cls = s.get_class()
return {"class": _short(cls), "title": _title_for(cls)}
def _pins(node, which: str) -> list:
# UPCGNode exposes 'input_pins'/'output_pins' as properties.
prop = "input_pins" if which == "in" else "output_pins"
try:
val = node.get_editor_property(prop)
if val is not None:
return list(val)
except Exception:
pass
fn = getattr(node, "get_input_pins" if which == "in" else "get_output_pins", None)
if callable(fn):
try:
return list(fn())
except Exception:
pass
return []
def _pin_label(pin) -> str:
try:
props = pin.get_editor_property("properties")
return str(props.get_editor_property("label"))
except Exception:
pass
for getter in ("get_pin_label", "get_name"):
fn = getattr(pin, getter, None)
if callable(fn):
try:
return str(fn())
except Exception:
pass
return ""
# --------------------------------------------------------------------------- #
# ops
# --------------------------------------------------------------------------- #
def api(args: dict) -> dict:
"""Dump live reflection so callers can confirm exposed method names. Handy
because PCG Python signatures shift between engine versions."""
def members(name):
cls = getattr(unreal, name, None)
if cls is None:
return None
return sorted(m for m in dir(cls) if not m.startswith("_"))
return {
"ok": True,
"PCGGraph": members("PCGGraph"),
"PCGNode": members("PCGNode"),
"PCGSettings": members("PCGSettings"),
"PCGPin": members("PCGPin"),
"settings_class_count": len(_settings_registry()),
}
def create_graph(args: dict) -> dict:
path = str(args["path"]).rstrip("/")
pkg_dir, _, name = path.rpartition("/")
if not name or not pkg_dir:
raise RuntimeError(f"bad path: {path!r} (expected e.g. /Game/PCG/MyGraph)")
tools = unreal.AssetToolsHelpers.get_asset_tools()
factory = None
for fname in ("PCGGraphFactory", "PCGGraphFactory_New"):
fcls = getattr(unreal, fname, None)
if fcls is not None:
factory = fcls()
break
asset = tools.create_asset(name, pkg_dir, unreal.PCGGraph, factory)
if asset is None:
raise RuntimeError(f"failed to create PCG graph at {path}")
_save(asset)
return {"ok": True, "asset": asset.get_path_name()}
def list_node_types(args: dict) -> dict:
flt = str(args.get("filter") or "").lower()
limit = int(args.get("limit") or 0)
reg = _settings_registry()
items = []
for cls, meta in reg.items():
if flt and flt not in meta["name"].lower() and flt not in meta["title"].lower():
continue
items.append({
"name": meta["name"],
"title": meta["title"],
"plugin": meta["plugin"],
"path": _class_path(cls),
})
items.sort(key=lambda d: (d["plugin"], d["name"]))
total = len(items)
if limit > 0:
items = items[:limit]
return {"ok": True, "count": total, "returned": len(items), "node_types": items}
def read_graph(args: dict) -> dict:
graph = _load_graph(args["asset_path"])
nodes = _graph_nodes(graph)
out_nodes = []
name_to_idx = {}
for i, n in enumerate(nodes):
try:
uname = n.get_name()
except Exception:
uname = f"node_{i}"
name_to_idx[uname] = i
meta = _node_settings_meta(n) or {"class": "?", "title": "?"}
out_nodes.append({
"index": i,
"name": uname,
"class": meta["class"],
"title": meta["title"],
"in_pins": [_pin_label(p) for p in _pins(n, "in")],
"out_pins": [_pin_label(p) for p in _pins(n, "out")],
})
# special I/O nodes (not always in get_nodes)
io = {}
for which in ("input", "output"):
node = _io_node(graph, which)
if node is not None:
io[which] = {
"in_pins": [_pin_label(p) for p in _pins(node, "in")],
"out_pins": [_pin_label(p) for p in _pins(node, "out")],
}
# edges: the same UPCGEdge object sits in both the source output-pin's and
# the destination input-pin's `edges` array. PCGEdge's InputPin/OutputPin
# aren't reflection-accessible, so we pair the two sides by edge identity.
all_nodes = list(nodes)
for which in ("input", "output"):
n = _io_node(graph, which)
if n is not None and n not in all_nodes:
all_nodes.append(n)
edges = _collect_edges(all_nodes)
return {"ok": True, "asset": graph.get_path_name(),
"node_count": len(out_nodes), "nodes": out_nodes,
"io": io, "edges": edges}
def _pin_edge_objs(pin) -> list:
try:
return list(pin.get_editor_property("edges") or [])
except Exception:
return []
def _obj_key(obj) -> str:
for getter in ("get_path_name", "get_name"):
fn = getattr(obj, getter, None)
if callable(fn):
try:
return str(fn())
except Exception:
pass
return str(id(obj))
def _is_output_pin(pin, fallback_which: str) -> bool:
fn = getattr(pin, "is_output_pin", None)
if callable(fn):
try:
return bool(fn())
except Exception:
pass
return fallback_which == "out"
def _collect_edges(all_nodes) -> list:
"""Rebuild edges by pairing the shared UPCGEdge object found on both the
source output pin and the destination input pin."""
by_key = {}
for n in all_nodes:
try:
nname = n.get_name()
except Exception:
continue
for which in ("in", "out"):
for pin in _pins(n, which):
label = _pin_label(pin)
side = "out" if _is_output_pin(pin, which) else "in"
for e in _pin_edge_objs(pin):
rec = by_key.setdefault(_obj_key(e), {})
rec[side] = {"node": nname, "pin": label}
edges = []
for rec in by_key.values():
if "out" in rec and "in" in rec:
edges.append({
"from_node": rec["out"]["node"], "from_pin": rec["out"]["pin"],
"to_node": rec["in"]["node"], "to_pin": rec["in"]["pin"],
})
else: # only one side seen (endpoint outside enumerated nodes)
known = rec.get("out") or rec.get("in")
edges.append({"from_node": known["node"], "from_pin": known["pin"],
"to_node": "?", "to_pin": "?", "partial": True}
if known else {"partial": True})
return edges
def add_node(args: dict) -> dict:
graph = _load_graph(args["asset_path"])
cls = _resolve_settings_class(str(args.get("node") or args.get("type") or ""))
node, _settings = _add_node_inner(graph, cls)
if node is None:
raise RuntimeError(f"failed to add node {_short(cls)}")
_apply_position(node, args)
title = args.get("title")
if title:
_set_node_title(node, str(title))
_save(graph)
return {"ok": True, "name": node.get_name(), "class": _short(cls),
"in_pins": [_pin_label(p) for p in _pins(node, "in")],
"out_pins": [_pin_label(p) for p in _pins(node, "out")]}
def _apply_position(node, args):
if "x" not in args and "y" not in args:
return
x, y = float(args.get("x", 0)), float(args.get("y", 0))
fn = getattr(node, "set_node_position", None)
if callable(fn):
# UPCGNode.set_node_position takes two floats (its getter returns an
# (x, y) tuple). Fall back to a single Vector2D for other node types.
for call in (lambda: fn(x, y), lambda: fn(unreal.Vector2D(x, y))):
try:
call()
return
except Exception:
continue
for px, py in (("node_pos_x", "node_pos_y"), ("position_x", "position_y")):
try:
node.set_editor_property(px, int(x))
node.set_editor_property(py, int(y))
return
except Exception:
continue
def _set_node_title(node, title):
for prop in ("node_title", "node_comment", "author_generated_node_name"):
try:
node.set_editor_property(prop, title)
return
except Exception:
continue
def delete_node(args: dict) -> dict:
graph = _load_graph(args["asset_path"])
node = _resolve_node(graph, args.get("node"))
name = node.get_name()
fn = getattr(graph, "remove_node", None)
if not callable(fn):
raise RuntimeError("UPCGGraph.remove_node not exposed to Python")
fn(node)
_save(graph)
return {"ok": True, "removed": name}
def _pin_or_default(node, which: str, label):
"""Resolve a pin label; default to the single/first pin when omitted."""
pins = _pins(node, which)
if not pins:
raise RuntimeError(f"node {node.get_name()!r} has no {which} pins")
if label in (None, ""):
return _pin_label(pins[0])
low = str(label).lower()
for p in pins:
if _pin_label(p).lower() == low:
return _pin_label(p)
# not found -> surface available labels
raise RuntimeError(
f"{which} pin {label!r} not on {node.get_name()!r}; "
f"available: {[_pin_label(p) for p in pins]}")
def connect(args: dict) -> dict:
graph = _load_graph(args["asset_path"])
f = args.get("from") or []
t = args.get("to") or []
from_node = _resolve_node(graph, f[0])
to_node = _resolve_node(graph, t[0])
from_pin = _pin_or_default(from_node, "out", f[1] if len(f) > 1 else None)
to_pin = _pin_or_default(to_node, "in", t[1] if len(t) > 1 else None)
fn = (getattr(graph, "add_labeled_edge", None)
or getattr(graph, "add_edge", None))
if not callable(fn):
raise RuntimeError("UPCGGraph exposes no add_labeled_edge/add_edge")
fn(from_node, unreal.Name(from_pin), to_node, unreal.Name(to_pin))
_save(graph)
return {"ok": True, "from": [from_node.get_name(), from_pin],
"to": [to_node.get_name(), to_pin]}
def disconnect(args: dict) -> dict:
graph = _load_graph(args["asset_path"])
f = args.get("from") or []
t = args.get("to") or []
from_node = _resolve_node(graph, f[0])
to_node = _resolve_node(graph, t[0])
from_pin = _pin_or_default(from_node, "out", f[1] if len(f) > 1 else None)
to_pin = _pin_or_default(to_node, "in", t[1] if len(t) > 1 else None)
fn = getattr(graph, "remove_edge", None)
if not callable(fn):
raise RuntimeError("UPCGGraph.remove_edge not exposed to Python")
ok = bool(fn(from_node, unreal.Name(from_pin), to_node, unreal.Name(to_pin)))
_save(graph)
return {"ok": ok, "from": [from_node.get_name(), from_pin],
"to": [to_node.get_name(), to_pin]}
def list_node_properties(args: dict) -> dict:
graph = _load_graph(args["asset_path"])
node = _resolve_node(graph, args.get("node"))
settings = _node_settings(node)
if settings is None:
raise RuntimeError(f"node {node.get_name()!r} has no settings object")
# Classify method vs editor-property at the CLASS level: getattr on the
# type returns the descriptor without invoking the getter, so we never
# trigger "property is protected and cannot be read" on the instance.
cls = type(settings)
props = []
for m in dir(settings):
if m.startswith("_"):
continue
try:
attr = getattr(cls, m, None)
except Exception:
attr = None
if callable(attr):
continue # it's a method, not a property
props.append(m)
return {"ok": True, "node": node.get_name(),
"settings_class": _short(settings.get_class()), "properties": props}
def _num_list(value):
"""Parse value (list or JSON string) into a list of floats, else None."""
if isinstance(value, str):
try:
value = json.loads(value.strip())
except Exception:
return None
if isinstance(value, list) and value and all(isinstance(n, (int, float)) for n in value):
return [float(n) for n in value]
return None
def _coerce_value(value):
"""MCP args may arrive stringified (the `value` field is untyped). Parse
JSON-ish strings to native types, and turn [x,y,z] number lists into the
matching unreal vector so struct properties (rotation/scale/offset) work."""
if isinstance(value, str):
try:
value = json.loads(value.strip())
except Exception:
return value # plain string (enum name, attribute name, etc.)
if isinstance(value, list) and value and all(isinstance(n, (int, float)) for n in value):
nums = [float(n) for n in value]
if len(nums) == 2:
return unreal.Vector2D(*nums)
if len(nums) == 3:
return unreal.Vector(*nums)
if len(nums) == 4:
return unreal.Vector4(*nums)
return value
def set_node_property(args: dict) -> dict:
graph = _load_graph(args["asset_path"])
node = _resolve_node(graph, args.get("node"))
settings = _node_settings(node)
if settings is None:
raise RuntimeError(f"node {node.get_name()!r} has no settings object")
prop = str(args["property"])
raw = args.get("value")
value = _coerce_value(raw)
used = value
try:
settings.set_editor_property(prop, value)
except TypeError:
# A 3-number list is ambiguous: Vector vs Rotator (pitch,yaw,roll).
# _coerce_value picked Vector; retry the other struct kinds.
nums = _num_list(raw)
ok = False
if nums and len(nums) == 3:
for ctor in (unreal.Rotator, unreal.Vector):
try:
used = ctor(*nums)
settings.set_editor_property(prop, used)
ok = True
break
except Exception:
continue
if not ok:
raise
_save(graph)
return {"ok": True, "node": node.get_name(), "property": prop, "value": str(used)}
def move_nodes(args: dict) -> dict:
graph = _load_graph(args["asset_path"])
moved = 0
for m in args.get("moves") or []:
try:
node = _resolve_node(graph, m["node"])
_apply_position(node, m)
moved += 1
except Exception:
continue
if moved:
_save(graph)
return {"ok": moved > 0, "moved": moved}
def save(args: dict) -> dict:
graph = _load_graph(args["asset_path"])
_save(graph)
return {"ok": True, "asset": graph.get_path_name()}
def open_asset(args: dict) -> dict:
graph = _load_graph(args["asset_path"])
sub = unreal.get_editor_subsystem(unreal.AssetEditorSubsystem)
sub.open_editor_for_assets([graph])
return {"ok": True, "asset": graph.get_path_name()}
def _set_one_property(settings, prop: str, raw):
"""Coerce + assign one editor property on a settings object. Mirrors
set_node_property's logic so apply_graph and set_node_property agree."""
value = _coerce_value(raw)
try:
settings.set_editor_property(prop, value)
return value
except TypeError:
nums = _num_list(raw)
if nums and len(nums) == 3:
for ctor in (unreal.Rotator, unreal.Vector):
try:
used = ctor(*nums)
settings.set_editor_property(prop, used)
return used
except Exception:
continue
raise
# --------------------------------------------------------------------------- #
# declarative whole-graph authoring
# --------------------------------------------------------------------------- #
def apply_graph(args: dict) -> dict:
"""Build a graph in one call. Spec:
nodes: [{id, type|class, x?, y?, title?, properties?:{}}]
edges: [{from:[id, pin?], to:[id, pin?]}]
outputs: [{from:[id, pin?], pin?}] -> wire id to the graph Output node
clear_existing?, auto_layout?(default True), save?(default True)
'id' is a caller-chosen local handle reused by edges/outputs. 'input'/'output'
refer to the graph's I/O nodes. Returns {spawned, edges, errors, warnings}."""
graph = _load_graph(args["asset_path"])
result = {"ok": False, "asset": graph.get_path_name(),
"spawned": {}, "edges": [], "errors": [], "warnings": []}
if args.get("clear_existing"):
for n in list(_graph_nodes(graph)):
try:
graph.remove_node(n)
except Exception as exc: # noqa: BLE001
result["warnings"].append(f"clear: {exc}")
raw_nodes = list(args.get("nodes") or [])
raw_edges = list(args.get("edges") or [])
# deterministic layout: honour caller x/y, snap into readable columns
specs = raw_nodes
if args.get("auto_layout", True) and graph_layout is not None:
lay_nodes, lay_edges = [], []
for spec in raw_nodes:
x = spec.get("x", (spec.get("position") or [0, 0])[0] if spec.get("position") else 0)
y = spec.get("y", (spec.get("position") or [0, 0])[1] if spec.get("position") else 0)
lay_nodes.append({"id": spec.get("id"), "class": spec.get("type") or spec.get("class") or "",
"position": [float(x), float(y)]})
for e in raw_edges:
try:
lay_edges.append({"from": [e["from"][0]], "to": [e["to"][0]]})
except Exception:
pass
laid = graph_layout.normalize_specs(lay_nodes, lay_edges, domain="pcg")
pos_by_id = {s.get("id"): s.get("position") for s in laid}
specs = []
for spec in raw_nodes:
s = dict(spec)
p = pos_by_id.get(spec.get("id"))
if p:
s["x"], s["y"] = p[0], p[1]
specs.append(s)
local_map = {}
for spec in specs:
local_id = spec.get("id")
try:
ref = str(spec.get("type") or spec.get("class") or "")
cls = _resolve_settings_class(ref)
node, settings = _add_node_inner(graph, cls)
if settings is None:
settings = _node_settings(node)
_apply_position(node, spec)
if spec.get("title"):
_set_node_title(node, str(spec["title"]))
applied = {}
for pname, pval in (spec.get("properties") or {}).items():
try:
applied[pname] = str(_set_one_property(settings, pname, pval))
except Exception as exc: # noqa: BLE001
result["warnings"].append(f"node {local_id} prop {pname}: {exc}")
key = local_id or node.get_name()
local_map[key] = node
result["spawned"][key] = {"name": node.get_name(), "class": _short(cls),
"properties": applied,
"in_pins": [_pin_label(p) for p in _pins(node, "in")],
"out_pins": [_pin_label(p) for p in _pins(node, "out")]}
except Exception as exc: # noqa: BLE001
result["errors"].append(f"node {local_id or spec.get('type') or spec.get('class')}: {exc}")
def resolve(ref):
if isinstance(ref, str) and ref in local_map:
return local_map[ref]
return _resolve_node(graph, ref)
for e in raw_edges:
try:
f, t = e["from"], e["to"]
fn_node, tn_node = resolve(f[0]), resolve(t[0])
fp = _pin_or_default(fn_node, "out", f[1] if len(f) > 1 else None)
tp = _pin_or_default(tn_node, "in", t[1] if len(t) > 1 else None)
graph.add_edge(fn_node, unreal.Name(fp), tn_node, unreal.Name(tp))
result["edges"].append({"from": [fn_node.get_name(), fp], "to": [tn_node.get_name(), tp]})
except Exception as exc: # noqa: BLE001
result["errors"].append(f"edge {e}: {exc}")
for o in args.get("outputs") or []:
try:
src = o.get("from") or o
src_node = resolve(src[0])
sp = _pin_or_default(src_node, "out", src[1] if len(src) > 1 else None)
out_node = _io_node(graph, "output")
tp = _pin_or_default(out_node, "in", o.get("pin"))
graph.add_edge(src_node, unreal.Name(sp), out_node, unreal.Name(tp))
result["edges"].append({"from": [src_node.get_name(), sp], "to": ["output", tp]})
except Exception as exc: # noqa: BLE001
result["errors"].append(f"output {o}: {exc}")
if args.get("save", True):
_save(graph)
result["ok"] = not result["errors"]
result["node_count"] = len(_graph_nodes(graph))
return result
def _add_node_inner(graph, cls):
"""Spawn one node; returns (node, settings). Shared by add_node/apply_graph."""
fn = getattr(graph, "add_node_of_type", None)
if callable(fn):
res = fn(cls)
if isinstance(res, (tuple, list)):
return res[0], (res[1] if len(res) > 1 else None)
return res, None
settings = unreal.new_object(cls, graph)
adder = getattr(graph, "add_node", None) or getattr(graph, "add_node_copy", None)
if not callable(adder):
raise RuntimeError("UPCGGraph exposes no add_node/add_node_of_type")
return adder(settings), settings
# --------------------------------------------------------------------------- #
# layout
# --------------------------------------------------------------------------- #
def auto_layout(args: dict) -> dict:
"""Re-flow existing nodes into edge-depth columns (left-to-right by data flow)."""
if graph_layout is None:
return {"ok": False, "error": "graph_layout helper unavailable"}
graph = _load_graph(args["asset_path"])
dump = read_graph({"asset_path": args["asset_path"]})
lay_nodes = [{"name": n["name"], "class": n["class"]} for n in dump.get("nodes", [])]
lay_edges = [{"from": [e.get("from_node")], "to": [e.get("to_node")]}
for e in dump.get("edges", []) if not e.get("partial")]
moves = graph_layout.tidy_existing_nodes(lay_nodes, lay_edges, domain="pcg")
applied = 0
for m in moves:
try:
node = _resolve_node(graph, m["guid"])
_apply_position(node, {"x": m["x"], "y": m["y"]})
applied += 1
except Exception:
continue
_save(graph)
return {"ok": True, "moved": applied, "count": len(moves)}
# --------------------------------------------------------------------------- #
# graph parameters (read-only: UE does not expose FInstancedPropertyBag editing)
# --------------------------------------------------------------------------- #
def list_params(args: dict) -> dict:
"""Dump the graph's UserParameters property bag. Note: UE5.7 does NOT expose
property-bag *editing* to Python, so params must be authored in-editor; this
op reports what already exists (name/type/default) so graphs can be wired."""
graph = _load_graph(args["asset_path"])
try:
bag = graph.get_editor_property("user_parameters")
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": f"no user_parameters: {exc}"}
info = {"ok": True, "asset": graph.get_path_name(), "params": []}
try:
info["raw"] = str(bag.export_text())
except Exception:
pass
return info
# --------------------------------------------------------------------------- #
# subgraph wiring
# --------------------------------------------------------------------------- #
def set_subgraph(args: dict) -> dict:
"""Point a Subgraph node at a target PCG graph asset.
{asset_path, node, subgraph: '/Game/.../OtherGraph'}"""
graph = _load_graph(args["asset_path"])
node = _resolve_node(graph, args.get("node"))
settings = _node_settings(node)
if settings is None:
raise RuntimeError(f"node {node.get_name()!r} has no settings")
target = unreal.EditorAssetLibrary.load_asset(str(args["subgraph"]))
if target is None:
raise RuntimeError(f"subgraph asset not found: {args.get('subgraph')!r}")
last = None
for prop in ("subgraph_instance", "subgraph", "subgraph_override"):
try:
settings.set_editor_property(prop, target)
_save(graph)
return {"ok": True, "node": node.get_name(), "property": prop,
"subgraph": target.get_path_name()}
except Exception as exc: # noqa: BLE001
last = exc
raise RuntimeError(f"could not assign subgraph (tried subgraph_instance/subgraph/subgraph_override): {last}")
# --------------------------------------------------------------------------- #
# level / runtime integration (PCG component + volume)
# --------------------------------------------------------------------------- #
def _ess():
return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
def _find_actor(name: str):
for a in _ess().get_all_level_actors():
if not a:
continue
try:
if a.get_actor_label() == name or a.get_name() == name or a.get_path_name() == name:
return a
except Exception:
continue
return None
def _pcg_component(actor):
"""Return the actor's UPCGComponent (PCGVolume.pcg_component, or a component
search for arbitrary actors)."""
try:
c = actor.get_editor_property("pcg_component")
if c is not None:
return c
except Exception:
pass
try:
return actor.get_component_by_class(unreal.PCGComponent)
except Exception:
return None
def _graph_output_stats(comp):
"""Best-effort point/output count after generation."""
try:
out = comp.get_generated_graph_output()
items = list(out) if out is not None else []
return {"output_data": len(items)}
except Exception:
return {}
def spawn_volume(args: dict) -> dict:
"""Spawn a PCG Volume actor, assign a graph, optionally generate.
{graph?, location?, rotation?, scale?, name?, generate?(default True)}"""
cls = getattr(unreal, "PCGVolume", None) or unreal.load_class(None, "/Script/PCG.PCGVolume")
if cls is None:
return {"ok": False, "error": "PCGVolume class unavailable"}
loc = _vec(args.get("location"))
rot = _rot(args.get("rotation"))
actor = _ess().spawn_actor_from_class(cls, loc, rot)
if actor is None:
return {"ok": False, "error": "spawn returned None"}
scale = args.get("scale")
if scale is not None:
actor.set_actor_scale3d(_vec(scale, (1, 1, 1)))
if args.get("name"):
actor.set_actor_label(str(args["name"]))
comp = _pcg_component(actor)
res = {"ok": True, "actor": actor.get_actor_label(), "path": actor.get_path_name(),
"has_component": comp is not None}
if comp is not None and args.get("graph"):
g = _load_graph(args["graph"])
comp.set_graph(g)
res["graph"] = g.get_path_name()
if args.get("generate", True):
comp.generate(True)
res["generated"] = True
res.update(_graph_output_stats(comp))
return res
def set_actor_graph(args: dict) -> dict:
"""Assign a PCG graph to an existing actor's component. {actor, graph, generate?}"""
actor = _find_actor(str(args["actor"]))
if actor is None:
return {"ok": False, "error": f"actor not found: {args.get('actor')!r}"}
comp = _pcg_component(actor)
if comp is None:
return {"ok": False, "error": "actor has no PCGComponent"}
g = _load_graph(args["graph"])
comp.set_graph(g)
res = {"ok": True, "actor": actor.get_actor_label(), "graph": g.get_path_name()}
if args.get("generate", False):
comp.generate(True)
res["generated"] = True
res.update(_graph_output_stats(comp))
return res
def generate(args: dict) -> dict:
"""Generate (or regenerate) a PCG actor's component. {actor, force?(True)}"""
actor = _find_actor(str(args["actor"]))
if actor is None:
return {"ok": False, "error": f"actor not found: {args.get('actor')!r}"}
comp = _pcg_component(actor)
if comp is None:
return {"ok": False, "error": "actor has no PCGComponent"}
comp.generate(bool(args.get("force", True)))
res = {"ok": True, "actor": actor.get_actor_label(), "generated": True}
res.update(_graph_output_stats(comp))
return res
def cleanup(args: dict) -> dict:
"""Clear generated content on a PCG actor's component. {actor}"""
actor = _find_actor(str(args["actor"]))
if actor is None:
return {"ok": False, "error": f"actor not found: {args.get('actor')!r}"}
comp = _pcg_component(actor)
if comp is None:
return {"ok": False, "error": "actor has no PCGComponent"}
remove = bool(args.get("remove_components", True))
try:
comp.cleanup(remove) # UE5.7: cleanup(remove_components)
except TypeError:
comp.cleanup() # older signature fallback
return {"ok": True, "actor": actor.get_actor_label(), "cleaned": True}
def list_components(args: dict) -> dict:
"""Enumerate level actors that carry a PCGComponent + their graph/state."""
found = []
for a in _ess().get_all_level_actors():
comp = _pcg_component(a) if a else None
if comp is None:
continue
entry = {"actor": a.get_actor_label(), "path": a.get_path_name(),
"class": a.get_class().get_name()}
try:
gi = comp.get_editor_property("graph_instance")
g = gi.get_mutable_pcg_graph() if gi is not None else None
entry["graph"] = g.get_path_name() if g is not None else None
except Exception:
entry["graph"] = None
try:
entry["generated"] = bool(comp.get_editor_property("generated"))
except Exception:
pass
found.append(entry)
return {"ok": True, "count": len(found), "components": found}
# --------------------------------------------------------------------------- #
# scaffolding presets — build a working graph end-to-end in one call
# --------------------------------------------------------------------------- #
_TEMPLATES = {
"surface_scatter": {
"desc": "Input(surface) -> Surface Sampler -> Transform Points -> Static Mesh Spawner -> Output",
"nodes": [
{"id": "sampler", "type": "PCGSurfaceSamplerSettings", "x": 0, "y": 0},
{"id": "transform", "type": "PCGTransformPointsSettings", "x": 1, "y": 0},
{"id": "spawner", "type": "PCGStaticMeshSpawnerSettings", "x": 2, "y": 0},
],
"edges": [
{"from": ["input"], "to": ["sampler"]},
{"from": ["sampler"], "to": ["transform"]},
{"from": ["transform"], "to": ["spawner"]},
],
"outputs": [{"from": ["spawner"]}],
},
"points_grid_spawn": {
"desc": "Create Points Grid -> Static Mesh Spawner -> Output",
"nodes": [
{"id": "grid", "type": "PCGCreatePointsGridSettings", "x": 0, "y": 0},
{"id": "spawner", "type": "PCGStaticMeshSpawnerSettings", "x": 1, "y": 0},
],
"edges": [{"from": ["grid"], "to": ["spawner"]}],
"outputs": [{"from": ["spawner"]}],
},
"density_filter_scatter": {
"desc": "Input -> Surface Sampler -> Density Filter -> Static Mesh Spawner -> Output",
"nodes": [
{"id": "sampler", "type": "PCGSurfaceSamplerSettings", "x": 0, "y": 0},
{"id": "filter", "type": "PCGDensityFilterSettings", "x": 1, "y": 0},
{"id": "spawner", "type": "PCGStaticMeshSpawnerSettings", "x": 2, "y": 0},
],
"edges": [
{"from": ["input"], "to": ["sampler"]},
{"from": ["sampler"], "to": ["filter"]},
{"from": ["filter"], "to": ["spawner"]},
],
"outputs": [{"from": ["spawner"]}],
},
}
def scaffold(args: dict) -> dict:
"""Create (or populate) a PCG graph from a named template, fully wired.
{path|asset_path, template, create?(True), save?(True)}.
template='__list__' lists available templates."""
template = str(args.get("template") or "").strip()
if template in ("", "__list__", "list"):
return {"ok": True, "templates": {k: v["desc"] for k, v in _TEMPLATES.items()}}
tpl = _TEMPLATES.get(template)
if tpl is None:
return {"ok": False, "error": f"unknown template {template!r}",
"available": list(_TEMPLATES)}
path = args.get("path") or args.get("asset_path")
if not path:
return {"ok": False, "error": "path (or asset_path) required"}
asset_path = path
if args.get("create", True) and not unreal.EditorAssetLibrary.does_asset_exist(path):
created = create_graph({"path": path})
asset_path = created.get("asset", path)
res = apply_graph({"asset_path": asset_path, "clear_existing": args.get("clear_existing", False),
"nodes": tpl["nodes"], "edges": tpl["edges"], "outputs": tpl["outputs"],
"auto_layout": True, "save": args.get("save", True)})
res["template"] = template
res["asset"] = asset_path
return res
def _vec(v, default=(0, 0, 0)):
if v is None:
v = default
if isinstance(v, str):
try:
v = json.loads(v)
except Exception:
v = default
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
def _rot(v, default=(0, 0, 0)):
if v is None:
v = default
if isinstance(v, str):
try:
v = json.loads(v)
except Exception:
v = default
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
OPS = {
"api": api,
"create_graph": create_graph,
"list_node_types": list_node_types,
"read_graph": read_graph,
"add_node": add_node,
"delete_node": delete_node,
"connect": connect,
"disconnect": disconnect,
"list_node_properties": list_node_properties,
"set_node_property": set_node_property,
"move_nodes": move_nodes,
"save": save,
"open_asset": open_asset,
# declarative authoring + layout
"apply_graph": apply_graph,
"auto_layout": auto_layout,
"tidy_graph": auto_layout,
"layout": auto_layout,
# parameters + subgraph
"list_params": list_params,
"set_subgraph": set_subgraph,
# level / runtime
"spawn_volume": spawn_volume,
"set_actor_graph": set_actor_graph,
"generate": generate,
"cleanup": cleanup,
"list_components": list_components,
# presets
"scaffold": scaffold,
}
def entrypoint(payload_json: str) -> str:
try:
payload = json.loads(payload_json)
except Exception as exc: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {exc}"}, {})
fn = OPS.get(payload.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {payload.get('op')!r}",
"available": list(OPS)}, payload)
try:
return _tee(fn(payload), payload)
except Exception as exc: # noqa: BLE001
return _tee({"ok": False, "error": str(exc),
"traceback": traceback.format_exc()}, payload)
def _tee(result: dict, payload: dict) -> str:
text = json.dumps(result)
try:
if unreal is not None:
saved_dir = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(saved_dir, exist_ok=True)
rid = payload.get("__rid") or "last"
with open(saved_dir + f"{rid}.json", "w", encoding="utf-8") as handle:
handle.write(text)
with open(saved_dir + "last.json", "w", encoding="utf-8") as handle:
handle.write(text)
unreal.log("[MCP-PCG] " + text[:1500])
except Exception:
pass
return text