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

272 lines
9.5 KiB
Python

"""C++ class scaffold — generate .h/.cpp templates under a project module.
This does NOT compile the code — pair with live_coding.compile or a regular
hot-reload after the files appear. UE will pick up new files on next build.
Ops:
list_modules {} — read Source/*/Build.cs
create_class {name, parent_class, module?(default: primary game module), subfolder?, header_only?:false, public?:true, api_macro?:auto}
list_classes {module?, subfolder?} — list .h files under a module
read_class {name, module?} — return .h+.cpp text
"""
from __future__ import annotations
import json
import os
import re
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _project_dir():
if unreal is not None:
return unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()).rstrip("/\\")
return os.getcwd()
def _source_dir():
return os.path.join(_project_dir(), "Source")
def _project_name():
"""Project name = the .uproject base name (also the conventional primary module)."""
if unreal is not None:
try:
up = unreal.Paths.get_project_file_path() # e.g. .../MyGame.uproject
return os.path.splitext(os.path.basename(up))[0]
except Exception:
pass
return os.path.basename(_project_dir().rstrip("/\\"))
def _default_module():
"""Primary game module to scaffold into. Prefer the Runtime module declared in
the .uproject; fall back to the module whose folder matches the project name;
else the first Source module; else the project name."""
proj = _project_name()
# 1. Read the .uproject Modules list for a Runtime/Game module.
try:
up = unreal.Paths.get_project_file_path() if unreal is not None else None
if up and os.path.isfile(up):
with open(up, "r", encoding="utf-8") as f:
data = json.load(f)
mods = data.get("Modules") or []
runtime = [m for m in mods if str(m.get("Type", "")).lower() in ("runtime", "")]
if runtime:
return runtime[0].get("Name") or proj
if mods:
return mods[0].get("Name") or proj
except Exception:
pass
# 2. Folder matching the project name.
src = _source_dir()
if os.path.isdir(os.path.join(src, proj)):
return proj
# 3. First Source module with a Build.cs.
try:
for name in sorted(os.listdir(src)):
if os.path.isfile(os.path.join(src, name, f"{name}.Build.cs")):
return name
except Exception:
pass
return proj
def list_modules(args):
out = []
src = _source_dir()
if not os.path.isdir(src):
return {"ok": False, "error": f"no Source dir at {src}"}
for name in os.listdir(src):
p = os.path.join(src, name)
if os.path.isdir(p):
build_cs = os.path.join(p, f"{name}.Build.cs")
if os.path.isfile(build_cs):
out.append({"name": name, "build_cs": build_cs,
"has_public": os.path.isdir(os.path.join(p, "Public")),
"has_private": os.path.isdir(os.path.join(p, "Private"))})
return {"ok": True, "modules": out}
def _api_macro(module):
return f"{module.upper()}_API"
_HEADER_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
#pragma once
#include "CoreMinimal.h"
#include "{base_header}"
#include "{name}.generated.h"
UCLASS()
class {api} {name} : public {parent}
{{
\tGENERATED_BODY()
public:
\t{name}();
}};
"""
_CPP_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
#include "{include_path}{name}.h"
{name}::{name}()
{{
\tPrimaryActorTick.bCanEverTick = false;
}}
"""
# Map of common parents -> include they need
_PARENT_HEADERS = {
"AActor": "GameFramework/Actor.h",
"APawn": "GameFramework/Pawn.h",
"ACharacter": "GameFramework/Character.h",
"APlayerController": "GameFramework/PlayerController.h",
"AGameModeBase": "GameFramework/GameModeBase.h",
"AAIController": "AIController.h",
"UObject": "UObject/NoExportTypes.h",
"UActorComponent": "Components/ActorComponent.h",
"USceneComponent": "Components/SceneComponent.h",
"UPrimitiveComponent": "Components/PrimitiveComponent.h",
"UStaticMeshComponent": "Components/StaticMeshComponent.h",
"USkeletalMeshComponent":"Components/SkeletalMeshComponent.h",
"UAnimInstance": "Animation/AnimInstance.h",
"UUserWidget": "Blueprint/UserWidget.h",
"UDataAsset": "Engine/DataAsset.h",
"UInterface": "UObject/Interface.h",
}
def create_class(args):
name = args["name"]
parent = args["parent_class"] # e.g. AActor / UObject / UActorComponent
module = args.get("module") or _default_module()
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
header_only = bool(args.get("header_only"))
public = bool(args.get("public", True))
if not re.match(r"^[AU][A-Z]\w+$", name):
return {"ok": False, "error": "class name must start with A or U followed by PascalCase (e.g. AMyActor, UMyComp)"}
src_mod = os.path.join(_source_dir(), module)
if not os.path.isdir(src_mod):
return {"ok": False, "error": f"module not found: {module}"}
pub_dir = os.path.join(src_mod, "Public", subfolder) if public else os.path.join(src_mod, subfolder)
priv_dir = os.path.join(src_mod, "Private", subfolder) if public else os.path.join(src_mod, subfolder)
os.makedirs(pub_dir, exist_ok=True)
if not header_only:
os.makedirs(priv_dir, exist_ok=True)
base_header = args.get("base_header") or _PARENT_HEADERS.get(parent, "CoreMinimal.h")
api = args.get("api_macro") or _api_macro(module)
h_path = os.path.join(pub_dir, f"{name}.h")
c_path = os.path.join(priv_dir, f"{name}.cpp")
if os.path.exists(h_path):
return {"ok": False, "error": f"file exists: {h_path}"}
h_txt = _HEADER_TPL.format(name=name, parent=parent, api=api, base_header=base_header)
with open(h_path, "w", encoding="utf-8", newline="\n") as f:
f.write(h_txt)
out = {"ok": True, "header": h_path}
if not header_only:
include_rel = (subfolder + "/") if subfolder else ""
c_txt = _CPP_TPL.format(name=name, include_path=include_rel)
with open(c_path, "w", encoding="utf-8", newline="\n") as f:
f.write(c_txt)
out["cpp"] = c_path
out["note"] = "Files written. Run live_coding.compile or rebuild from VS to register the class."
return out
def list_classes(args):
module = args.get("module") or _default_module()
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
mod_root = os.path.join(_source_dir(), module)
if not os.path.isdir(mod_root):
return {"ok": False, "error": f"module not found: {mod_root}"}
scan_root = os.path.join(mod_root, subfolder) if subfolder else mod_root
out = []
for dirpath, _, fnames in os.walk(scan_root):
for fn in fnames:
if fn.endswith(".h"):
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, mod_root).replace("\\", "/")
# Skip the module's own header (e.g. <Module>.h)
if rel == f"{module}.h":
continue
out.append(rel)
return {"ok": True, "module": module, "headers": sorted(out), "count": len(out)}
def read_class(args):
module = args.get("module") or _default_module()
name = args["name"]
found = {"header": None, "cpp": None}
for dirpath, _, fnames in os.walk(os.path.join(_source_dir(), module)):
for fn in fnames:
if fn == f"{name}.h":
found["header"] = os.path.join(dirpath, fn)
elif fn == f"{name}.cpp":
found["cpp"] = os.path.join(dirpath, fn)
out = {"ok": True}
for k, p in found.items():
if p and os.path.isfile(p):
try:
with open(p, "r", encoding="utf-8") as f:
out[k] = {"path": p, "content": f.read()}
except Exception as e:
out[k] = {"path": p, "error": str(e)}
if not (found["header"] or found["cpp"]):
return {"ok": False, "error": f"class {name} not found under Source/{module}"}
return out
OPS = {
"list_modules": list_modules,
"create_class": create_class,
"list_classes": list_classes,
"read_class": read_class,
}
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-CPP] " + txt[:1500])
except Exception:
pass
return txt