Add empty test level and Unreal MCP tooling

- L_TestFlight: empty, zero-gravity level for Phase 0 flight testing,
  set as editor/game default map.
- Enable UnrealMCPython + Python Editor Script Plugin, third-party
  MCP plugin (GenOrca/unreal-mcp v2.2.0) giving direct editor control
  (actors, blueprints, levels, materials, etc.) alongside Epic's
  built-in Unreal MCP plugin.
- Vendor the mcp-server Python source used to bridge to the plugin.
- .mcp.json intentionally gitignored (machine-specific absolute paths).
This commit is contained in:
Joshua Deville
2026-07-08 14:56:57 -04:00
parent 43494a614b
commit 496320cc8c
82 changed files with 19577 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
Python action functions for Animation Blueprint authoring in Unreal Engine.
This domain owns what is *unique* to Animation Blueprints — creating one bound to
a Skeleton, and skeleton-aware introspection. An AnimBlueprint is a UBlueprint, so
generic graph work is intentionally NOT duplicated here:
- compile -> use `blueprint compile_blueprint`
- read AnimGraph -> use `blueprint get_blueprint_graph_info` with graph_name="AnimGraph"
- add/remove vars -> use `blueprint add_variable` / `set_variable_flags`
AnimGraph node *authoring* (sequence players, state machines) lives in the editor-only
AnimGraph C++ module and is not exposed to Python (UAnimationGraph::Nodes is protected),
so ue_add_anim_graph_sequence_player and ue_build_anim_state_machine are backed by
dedicated MCPythonHelper C++ UFUNCTIONs.
"""
import unreal
import json
import traceback
def _split_object_path(asset_path: str):
asset_path = asset_path.rstrip("/")
idx = asset_path.rfind("/")
return asset_path[idx + 1:], asset_path[:idx]
def _load_anim_blueprint(asset_path: str):
bp = unreal.EditorAssetLibrary.load_asset(asset_path)
if not bp:
raise FileNotFoundError(f"AnimBlueprint not found at path: {asset_path}")
if not isinstance(bp, unreal.AnimBlueprint):
raise TypeError(f"Asset at {asset_path} is not an AnimBlueprint, but {type(bp).__name__}.")
return bp
def _is_anim_instance_class(cls) -> bool:
"""True if cls is AnimInstance or a subclass — checked via its class-default object."""
try:
return isinstance(unreal.get_default_object(cls), unreal.AnimInstance)
except Exception:
return False
def ue_create_anim_blueprint(asset_path: str = None, skeleton_path: str = None,
parent_class_path: str = "/Script/Engine.AnimInstance") -> str:
"""Creates an Animation Blueprint bound to a Skeleton (parent defaults to AnimInstance)."""
if asset_path is None or skeleton_path is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, skeleton_path."})
try:
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
skeleton = unreal.EditorAssetLibrary.load_asset(skeleton_path)
if not skeleton:
return json.dumps({"success": False, "message": f"Skeleton not found: {skeleton_path}"})
if not isinstance(skeleton, unreal.Skeleton):
return json.dumps({"success": False,
"message": f"Asset at {skeleton_path} is not a Skeleton, but {type(skeleton).__name__}."})
parent = unreal.load_class(None, parent_class_path)
if not parent:
return json.dumps({"success": False, "message": f"Parent class not found: {parent_class_path}"})
if not _is_anim_instance_class(parent):
return json.dumps({"success": False,
"message": f"Parent class '{parent_class_path}' is not an AnimInstance subclass."})
name, package = _split_object_path(asset_path)
factory = unreal.AnimBlueprintFactory()
factory.set_editor_property("target_skeleton", skeleton)
factory.set_editor_property("parent_class", parent)
bp = unreal.AssetToolsHelpers.get_asset_tools().create_asset(name, package, unreal.AnimBlueprint, factory)
if not bp:
return json.dumps({"success": False, "message": f"Failed to create AnimBlueprint at {asset_path}."})
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return json.dumps({"success": True, "asset_path": asset_path,
"skeleton": skeleton.get_path_name(), "parent_class": parent_class_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_anim_blueprint_info(asset_path: str = None) -> str:
"""Returns an AnimBlueprint's target skeleton, generated class, and graph names."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
bp = _load_anim_blueprint(asset_path)
bel = unreal.BlueprintEditorLibrary
gen = bel.generated_class(bp)
skeleton = bp.get_editor_property("target_skeleton")
graphs = [g for g in ("AnimGraph", "EventGraph") if bel.find_graph(bp, unreal.Name(g))]
return json.dumps({
"success": True,
"asset_path": asset_path,
"generated_class": gen.get_name() if gen else None,
"target_skeleton": skeleton.get_path_name() if skeleton else None,
"graphs": graphs,
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# ─── AnimGraph node authoring (backed by the MCPythonHelper C++ AnimGraph helpers) ──
def ue_add_anim_graph_sequence_player(asset_path: str = None, anim_sequence_path: str = None,
link_to_output_pose: bool = True) -> str:
"""Adds a looping Sequence Player to the AnimGraph, optionally wired to the Output Pose."""
if asset_path is None or anim_sequence_path is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, anim_sequence_path."})
try:
bp = _load_anim_blueprint(asset_path)
if not unreal.EditorAssetLibrary.does_asset_exist(anim_sequence_path):
return json.dumps({"success": False, "message": f"AnimSequence not found: {anim_sequence_path}"})
result_json = unreal.MCPythonHelper.add_anim_graph_sequence_player(
bp, anim_sequence_path, link_to_output_pose)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_build_anim_state_machine(asset_path: str = None, spec: dict = None) -> str:
"""Builds an arbitrary AnimGraph state machine from a spec: states[{name,anim?}], entry?, transitions[{from,to,var?,op?,value?}]."""
if asset_path is None or spec is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, spec."})
try:
bp = _load_anim_blueprint(asset_path)
states = spec.get("states") or []
if not states:
return json.dumps({"success": False, "message": "spec.states must be a non-empty list."})
# Validate referenced anims up front (clearer error than a C++ load failure).
for s in states:
anim = s.get("anim")
if anim and not unreal.EditorAssetLibrary.does_asset_exist(anim):
return json.dumps({"success": False,
"message": f"State '{s.get('name')}': AnimSequence not found: {anim}"})
# Auto-create every float variable referenced by a transition rule.
transitions = spec.get("transitions") or []
needed_vars = {t["var"] for t in transitions if t.get("var")}
if needed_vars:
bel = unreal.BlueprintEditorLibrary
pin = bel.get_basic_type_by_name(unreal.Name("real"))
for v in needed_vars:
bel.add_member_variable(bp, unreal.Name(v), pin) # no-op if it already exists
bel.compile_blueprint(bp)
result_json = unreal.MCPythonHelper.build_anim_state_machine(bp, json.dumps(spec))
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,352 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
Python action functions for Animation Sequence editing in Unreal Engine.
Covers AnimSequence introspection and authoring (notify tracks, sync markers,
float curves) via unreal.AnimationLibrary. AnimBlueprint graph editing and IK
retargeting need C++ / extra plugins and are out of scope here.
"""
import unreal
import json
import traceback
AL = unreal.AnimationLibrary
_RCT_FLOAT = unreal.RawCurveTrackTypes.RCT_FLOAT
def _load_anim_sequence(asset_path: str):
if not asset_path:
raise ValueError("AnimSequence path cannot be empty.")
seq = unreal.EditorAssetLibrary.load_asset(asset_path)
if not seq:
raise FileNotFoundError(f"AnimSequence not found at path: {asset_path}")
if not isinstance(seq, unreal.AnimSequence):
raise TypeError(f"Asset at {asset_path} is not an AnimSequence, but {type(seq).__name__}")
return seq
# --- Introspection ------------------------------------------------------------
def ue_get_anim_sequence_info(asset_path: str = None) -> str:
"""Returns length, frame count, approximate fps, and skeleton path of an AnimSequence."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
seq = _load_anim_sequence(asset_path)
length = AL.get_sequence_length(seq)
frames = AL.get_num_frames(seq)
fps = round((frames - 1) / length, 2) if length > 0 and frames > 1 else None
skel = seq.get_skeleton()
return json.dumps({
"success": True,
"asset_path": asset_path,
"length_seconds": round(length, 4),
"num_frames": frames,
"fps": fps,
"skeleton": skel.get_path_name() if skel else None,
"notify_track_count": len(AL.get_animation_notify_track_names(seq)),
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_notify_tracks(asset_path: str = None) -> str:
"""Lists the notify track names on an AnimSequence."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
seq = _load_anim_sequence(asset_path)
return json.dumps({"success": True, "asset_path": asset_path,
"tracks": [str(n) for n in AL.get_animation_notify_track_names(seq)]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_notifies(asset_path: str = None) -> str:
"""Lists notify event names on an AnimSequence."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
seq = _load_anim_sequence(asset_path)
return json.dumps({"success": True, "asset_path": asset_path,
"notifies": [str(n) for n in AL.get_animation_notify_event_names(seq)]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_curves(asset_path: str = None) -> str:
"""Lists float animation curve names on an AnimSequence."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
seq = _load_anim_sequence(asset_path)
return json.dumps({"success": True, "asset_path": asset_path,
"curves": [str(n) for n in AL.get_animation_curve_names(seq, _RCT_FLOAT)]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_sync_markers(asset_path: str = None) -> str:
"""Lists sync markers (name + time) on an AnimSequence."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
seq = _load_anim_sequence(asset_path)
markers = []
for m in AL.get_animation_sync_markers(seq):
markers.append({
"name": str(getattr(m, "marker_name", "")),
"time": round(float(getattr(m, "time", 0.0)), 4),
})
return json.dumps({"success": True, "asset_path": asset_path, "sync_markers": markers})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- Notify tracks ------------------------------------------------------------
def ue_add_notify_track(asset_path: str = None, track_name: str = None) -> str:
"""Adds a notify track to an AnimSequence."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if track_name is None:
return json.dumps({"success": False, "message": "Required parameter 'track_name' is missing."})
try:
seq = _load_anim_sequence(asset_path)
AL.add_animation_notify_track(seq, track_name)
unreal.EditorAssetLibrary.save_loaded_asset(seq)
return json.dumps({"success": True, "asset_path": asset_path, "track_name": track_name,
"tracks": [str(n) for n in AL.get_animation_notify_track_names(seq)]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_remove_notify_track(asset_path: str = None, track_name: str = None) -> str:
"""Removes a notify track (and its notifies) from an AnimSequence."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if track_name is None:
return json.dumps({"success": False, "message": "Required parameter 'track_name' is missing."})
try:
seq = _load_anim_sequence(asset_path)
if not AL.is_valid_anim_notify_track_name(seq, track_name):
return json.dumps({"success": False, "message": f"Notify track '{track_name}' does not exist.",
"tracks": [str(n) for n in AL.get_animation_notify_track_names(seq)]})
AL.remove_animation_notify_track(seq, track_name)
unreal.EditorAssetLibrary.save_loaded_asset(seq)
return json.dumps({"success": True, "asset_path": asset_path, "removed": track_name,
"tracks": [str(n) for n in AL.get_animation_notify_track_names(seq)]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- Sync markers -------------------------------------------------------------
def ue_add_sync_marker(asset_path: str = None, track_name: str = None,
marker_name: str = None, time_seconds: float = None) -> str:
"""Adds a sync marker at a time (seconds) on a notify track. Creates the track if needed."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if track_name is None or marker_name is None or time_seconds is None:
return json.dumps({"success": False, "message": "Required: track_name, marker_name, time_seconds."})
try:
seq = _load_anim_sequence(asset_path)
if not AL.is_valid_anim_notify_track_name(seq, track_name):
AL.add_animation_notify_track(seq, track_name)
AL.add_animation_sync_marker(seq, marker_name, float(time_seconds), track_name)
unreal.EditorAssetLibrary.save_loaded_asset(seq)
return json.dumps({"success": True, "asset_path": asset_path, "track_name": track_name,
"marker_name": marker_name, "time_seconds": float(time_seconds)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- Curves -------------------------------------------------------------------
def ue_add_float_curve(asset_path: str = None, curve_name: str = None,
time_seconds: float = None, value: float = None) -> str:
"""Adds a float curve to an AnimSequence, optionally with an initial key at time_seconds=value."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if curve_name is None:
return json.dumps({"success": False, "message": "Required parameter 'curve_name' is missing."})
try:
seq = _load_anim_sequence(asset_path)
if not AL.does_curve_exist(seq, curve_name, _RCT_FLOAT):
AL.add_curve(seq, curve_name, _RCT_FLOAT, False)
if time_seconds is not None and value is not None:
AL.add_float_curve_key(seq, curve_name, float(time_seconds), float(value))
unreal.EditorAssetLibrary.save_loaded_asset(seq)
return json.dumps({"success": True, "asset_path": asset_path, "curve_name": curve_name,
"curves": [str(n) for n in AL.get_animation_curve_names(seq, _RCT_FLOAT)]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_remove_curve(asset_path: str = None, curve_name: str = None) -> str:
"""Removes a float curve from an AnimSequence."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if curve_name is None:
return json.dumps({"success": False, "message": "Required parameter 'curve_name' is missing."})
try:
seq = _load_anim_sequence(asset_path)
if not AL.does_curve_exist(seq, curve_name, _RCT_FLOAT):
return json.dumps({"success": False, "message": f"Curve '{curve_name}' does not exist.",
"curves": [str(n) for n in AL.get_animation_curve_names(seq, _RCT_FLOAT)]})
AL.remove_curve(seq, curve_name, False)
unreal.EditorAssetLibrary.save_loaded_asset(seq)
return json.dumps({"success": True, "asset_path": asset_path, "removed": curve_name,
"curves": [str(n) for n in AL.get_animation_curve_names(seq, _RCT_FLOAT)]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- SkeletalMesh / Skeleton queries ------------------------------------------
def _load_skeletal_mesh(asset_path: str):
sm = unreal.EditorAssetLibrary.load_asset(asset_path)
if not sm:
raise FileNotFoundError(f"SkeletalMesh not found at path: {asset_path}")
if not isinstance(sm, unreal.SkeletalMesh):
raise TypeError(f"Asset at {asset_path} is not a SkeletalMesh, but {type(sm).__name__}")
return sm
def ue_get_skeletal_mesh_info(asset_path: str = None) -> str:
"""Returns the skeleton path, socket count, and material-slot count of a SkeletalMesh."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
sm = _load_skeletal_mesh(asset_path)
skel = sm.skeleton
return json.dumps({
"success": True,
"asset_path": asset_path,
"skeleton": skel.get_path_name() if skel else None,
"num_sockets": sm.num_sockets(),
"num_materials": len(sm.materials),
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_sockets(asset_path: str = None) -> str:
"""Lists sockets on a SkeletalMesh (name, bone, relative location/rotation)."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
sm = _load_skeletal_mesh(asset_path)
sockets = []
for i in range(sm.num_sockets()):
s = sm.get_socket_by_index(i)
loc = s.relative_location
rot = s.relative_rotation
sockets.append({
"name": str(s.socket_name),
"bone": str(s.bone_name),
"relative_location": [round(loc.x, 3), round(loc.y, 3), round(loc.z, 3)],
"relative_rotation": [round(rot.pitch, 3), round(rot.yaw, 3), round(rot.roll, 3)],
})
return json.dumps({"success": True, "asset_path": asset_path,
"num_sockets": len(sockets), "sockets": sockets})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_find_socket(asset_path: str = None, socket_name: str = None) -> str:
"""Returns details of a named socket on a SkeletalMesh, or success=False if not found."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if socket_name is None:
return json.dumps({"success": False, "message": "Required parameter 'socket_name' is missing."})
try:
sm = _load_skeletal_mesh(asset_path)
s = sm.find_socket(unreal.Name(socket_name))
if not s:
return json.dumps({"success": False, "message": f"Socket '{socket_name}' not found."})
loc = s.relative_location
rot = s.relative_rotation
return json.dumps({
"success": True, "asset_path": asset_path,
"name": str(s.socket_name), "bone": str(s.bone_name),
"relative_location": [round(loc.x, 3), round(loc.y, 3), round(loc.z, 3)],
"relative_rotation": [round(rot.pitch, 3), round(rot.yaw, 3), round(rot.roll, 3)],
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_skeleton_info(asset_path: str = None) -> str:
"""Returns curve metadata names for a Skeleton asset."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
skel = unreal.EditorAssetLibrary.load_asset(asset_path)
if not skel:
return json.dumps({"success": False, "message": f"Skeleton not found at path: {asset_path}"})
if not isinstance(skel, unreal.Skeleton):
return json.dumps({"success": False, "message": f"Asset at {asset_path} is not a Skeleton, but {type(skel).__name__}"})
return json.dumps({
"success": True, "asset_path": asset_path,
"curve_names": [str(n) for n in skel.get_curve_meta_data_names()],
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- SkeletalMesh bones / socket editing (via MCPythonHelper C++) --------------
# Bone enumeration and socket creation are not available through stock Python
# (socket_name is read-only on a bare SkeletalMeshSocket), so these proxy the
# C++ helper, which returns a JSON string directly.
def ue_list_bones(asset_path: str = None) -> str:
"""Lists reference-skeleton bones (name, index, parent) of a SkeletalMesh."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
sm = _load_skeletal_mesh(asset_path)
return unreal.MCPythonHelper.get_skeleton_bones(sm)
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_socket(asset_path: str = None, socket_name: str = None, bone_name: str = None,
location: list = None, rotation: list = None) -> str:
"""Adds a socket on a bone of a SkeletalMesh. location=[x,y,z], rotation=[pitch,yaw,roll]."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if socket_name is None or bone_name is None:
return json.dumps({"success": False, "message": "Required parameters: socket_name, bone_name."})
try:
sm = _load_skeletal_mesh(asset_path)
loc = location or [0.0, 0.0, 0.0]
rot = rotation or [0.0, 0.0, 0.0]
if len(loc) != 3 or len(rot) != 3:
return json.dumps({"success": False, "message": "location and rotation must be lists of 3 floats."})
result = unreal.MCPythonHelper.add_skeletal_mesh_socket(
sm, socket_name, bone_name,
float(loc[0]), float(loc[1]), float(loc[2]),
float(rot[0]), float(rot[1]), float(rot[2]))
if json.loads(result).get("success"):
unreal.EditorAssetLibrary.save_loaded_asset(sm)
return result
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_remove_socket(asset_path: str = None, socket_name: str = None) -> str:
"""Removes a named socket from a SkeletalMesh."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if socket_name is None:
return json.dumps({"success": False, "message": "Required parameter 'socket_name' is missing."})
try:
sm = _load_skeletal_mesh(asset_path)
result = unreal.MCPythonHelper.remove_skeletal_mesh_socket(sm, socket_name)
if json.loads(result).get("success"):
unreal.EditorAssetLibrary.save_loaded_asset(sm)
return result
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,490 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
import unreal
import json
import os
import traceback
ASSET_ACTIONS_MODULE = "asset_actions"
def ue_find_by_query(name : str = None, asset_type : str = None) -> str:
"""
Returns a JSON list of asset paths under '/Game' matching the given query dict.
Supported keys: 'name' (substring match), 'asset_type' (Unreal class name, e.g. 'StaticMesh')
At least one of name or asset_type must be provided.
"""
if name is None and asset_type is None: # This check is specific to this function's logic
return json.dumps({"success": False, "message": "At least one of 'name' or 'asset_type' must be provided for ue_find_by_query.", "assets": []})
assets = unreal.EditorAssetLibrary.list_assets('/Game', recursive=True)
matches = []
for asset_path in assets:
asset_data = unreal.EditorAssetLibrary.find_asset_data(asset_path)
current_asset_type_str = ""
if hasattr(asset_data, 'asset_class_str') and asset_data.asset_class_str:
current_asset_type_str = str(asset_data.asset_class_str)
elif hasattr(asset_data, 'asset_class') and asset_data.asset_class:
current_asset_type_str = str(asset_data.asset_class)
else:
# Fallback if asset class information is not directly available or named differently
# This might happen with certain asset types or engine versions
# unreal.log_warning(f"Could not determine asset class for {asset_path}")
pass # Continue checking name if type is indeterminable but name is specified
name_match = True
if name is not None:
name_match = name.lower() in asset_path.lower()
type_match = True
if asset_type is not None:
if not current_asset_type_str: # If type couldn't be determined, it can't match a specified type
type_match = False
else:
type_match = asset_type.lower() == current_asset_type_str.lower()
if name_match and type_match:
matches.append(asset_path)
return json.dumps({"success": True, "assets": matches, "message": f"{len(matches)} assets found matching query."})
def ue_get_static_mesh_details(asset_path: str = None) -> str:
"""
Retrieves the bounding box and dimensions of a static mesh asset.
:param asset_path: Path to the static mesh asset (e.g., "/Game/Meshes/MyCube.MyCube").
:return: JSON string with asset details including bounding box and dimensions.
"""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
static_mesh = unreal.EditorAssetLibrary.load_asset(asset_path)
if not static_mesh or not isinstance(static_mesh, unreal.StaticMesh):
return json.dumps({"success": False, "message": f"Asset is not a StaticMesh or could not be loaded: {asset_path}"})
bounds = static_mesh.get_bounding_box() # This returns a Box type object
min_point = bounds.min
max_point = bounds.max
dimensions = {
"x": max_point.x - min_point.x,
"y": max_point.y - min_point.y,
"z": max_point.z - min_point.z
}
details = {
"asset_path": asset_path,
"bounding_box_min": {"x": min_point.x, "y": min_point.y, "z": min_point.z},
"bounding_box_max": {"x": max_point.x, "y": max_point.y, "z": max_point.z},
"dimensions": dimensions
}
return json.dumps({"success": True, "details": details})
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_get_static_mesh_details for {asset_path}: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
# --- Asset management (EditorAssetLibrary) ------------------------------------
def ue_duplicate_asset(source_path: str = None, dest_path: str = None) -> str:
"""Duplicates an asset to a new content-browser path."""
if source_path is None or dest_path is None:
return json.dumps({"success": False, "message": "Required parameters: source_path, dest_path."})
try:
if not unreal.EditorAssetLibrary.does_asset_exist(source_path):
return json.dumps({"success": False, "message": f"Source asset not found: {source_path}"})
if unreal.EditorAssetLibrary.does_asset_exist(dest_path):
return json.dumps({"success": False, "message": f"Destination already exists: {dest_path}"})
new_asset = unreal.EditorAssetLibrary.duplicate_asset(source_path, dest_path)
if not new_asset:
return json.dumps({"success": False, "message": f"Failed to duplicate to {dest_path}."})
return json.dumps({"success": True, "source_path": source_path, "dest_path": dest_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_rename_asset(source_path: str = None, dest_path: str = None) -> str:
"""Renames/moves an asset to a new content-browser path."""
if source_path is None or dest_path is None:
return json.dumps({"success": False, "message": "Required parameters: source_path, dest_path."})
try:
if not unreal.EditorAssetLibrary.does_asset_exist(source_path):
return json.dumps({"success": False, "message": f"Source asset not found: {source_path}"})
ok = unreal.EditorAssetLibrary.rename_asset(source_path, dest_path)
return json.dumps({"success": bool(ok), "source_path": source_path, "dest_path": dest_path,
"message": "Renamed." if ok else "rename_asset returned False."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_delete_asset(asset_path: str = None) -> str:
"""Deletes an asset from the content browser."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
if not unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
ok = unreal.EditorAssetLibrary.delete_asset(asset_path)
return json.dumps({"success": bool(ok), "asset_path": asset_path,
"message": "Deleted." if ok else "delete_asset returned False."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_save_asset(asset_path: str = None) -> str:
"""Saves an asset to disk."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
if not unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
ok = unreal.EditorAssetLibrary.save_asset(asset_path)
return json.dumps({"success": bool(ok), "asset_path": asset_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_asset_exists(asset_path: str = None) -> str:
"""Returns whether an asset exists at the given path."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
return json.dumps({"success": True, "asset_path": asset_path,
"exists": bool(unreal.EditorAssetLibrary.does_asset_exist(asset_path))})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_asset_info(asset_path: str = None) -> str:
"""Returns class and package info for an asset."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
data = unreal.EditorAssetLibrary.find_asset_data(asset_path)
if not data or not data.is_valid():
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
return json.dumps({
"success": True,
"asset_path": asset_path,
"asset_name": str(data.asset_name),
"asset_class": str(data.asset_class_path.asset_name) if hasattr(data, "asset_class_path") else "",
"package_name": str(data.package_name),
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_assets(directory_path: str = None, recursive: bool = True) -> str:
"""Lists asset paths under a content directory."""
if directory_path is None:
return json.dumps({"success": False, "message": "Required parameter 'directory_path' is missing."})
try:
if not unreal.EditorAssetLibrary.does_directory_exist(directory_path):
return json.dumps({"success": False, "message": f"Directory not found: {directory_path}"})
assets = [str(a) for a in unreal.EditorAssetLibrary.list_assets(directory_path, recursive=bool(recursive))]
return json.dumps({"success": True, "directory_path": directory_path,
"count": len(assets), "assets": assets})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_find_referencers(asset_path: str = None) -> str:
"""Lists packages that reference the given asset."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
if not unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
refs = [str(r) for r in unreal.EditorAssetLibrary.find_package_referencers_for_asset(asset_path, False)]
return json.dumps({"success": True, "asset_path": asset_path,
"count": len(refs), "referencers": refs})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_make_directory(directory_path: str = None) -> str:
"""Creates a content-browser directory."""
if directory_path is None:
return json.dumps({"success": False, "message": "Required parameter 'directory_path' is missing."})
try:
ok = unreal.EditorAssetLibrary.make_directory(directory_path)
return json.dumps({"success": bool(ok), "directory_path": directory_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_delete_directory(directory_path: str = None) -> str:
"""Deletes a content-browser directory and its assets."""
if directory_path is None:
return json.dumps({"success": False, "message": "Required parameter 'directory_path' is missing."})
try:
if not unreal.EditorAssetLibrary.does_directory_exist(directory_path):
return json.dumps({"success": False, "message": f"Directory not found: {directory_path}"})
ok = unreal.EditorAssetLibrary.delete_directory(directory_path)
return json.dumps({"success": bool(ok), "directory_path": directory_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- Dependencies + metadata tags ---------------------------------------------
def _package_of(asset_path: str) -> str:
return asset_path.split(".")[0]
def ue_get_dependencies(asset_path: str = None) -> str:
"""Lists packages that the given asset depends on (references)."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
if not unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
ar = unreal.AssetRegistryHelpers.get_asset_registry()
opt = unreal.AssetRegistryDependencyOptions(
include_soft_package_references=True, include_hard_package_references=True)
deps = ar.get_dependencies(unreal.Name(_package_of(asset_path)), opt) or []
deps = [str(d) for d in deps]
return json.dumps({"success": True, "asset_path": asset_path,
"count": len(deps), "dependencies": deps})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_metadata_tag(asset_path: str = None, tag: str = None) -> str:
"""Reads a metadata tag value on an asset (empty string if unset)."""
if asset_path is None or tag is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, tag."})
try:
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
if not asset:
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
return json.dumps({"success": True, "asset_path": asset_path, "tag": tag,
"value": unreal.EditorAssetLibrary.get_metadata_tag(asset, unreal.Name(tag))})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_metadata_tag(asset_path: str = None, tag: str = None, value: str = None) -> str:
"""Sets a metadata tag value on an asset."""
if asset_path is None or tag is None or value is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, tag, value."})
try:
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
if not asset:
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
unreal.EditorAssetLibrary.set_metadata_tag(asset, unreal.Name(tag), value)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return json.dumps({"success": True, "asset_path": asset_path, "tag": tag, "value": value})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_remove_metadata_tag(asset_path: str = None, tag: str = None) -> str:
"""Removes a metadata tag from an asset."""
if asset_path is None or tag is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, tag."})
try:
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
if not asset:
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
unreal.EditorAssetLibrary.remove_metadata_tag(asset, unreal.Name(tag))
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return json.dumps({"success": True, "asset_path": asset_path, "removed_tag": tag})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- File import / export (FBX, textures) --------------------------------------
# IMPORTANT: imports pin a LEGACY factory (FbxFactory / TextureFactory) on the
# AssetImportTask. Routing through Interchange (the default when no factory is
# set) crashes the editor from this TCP-handler context with a TaskGraph
# RecursionGuard assertion (re-entrant task-graph pumping inside InterchangeEngine).
def ue_import_fbx(file_path: str = None, destination_path: str = None,
destination_name: str = "", as_skeletal: bool = False,
import_materials: bool = False, import_textures: bool = False,
import_animations: bool = False) -> str:
"""Imports an FBX file as a Static/Skeletal mesh using the legacy FBX importer."""
if file_path is None or destination_path is None:
return json.dumps({"success": False, "message": "Required parameters: file_path, destination_path."})
try:
if not os.path.isfile(file_path):
return json.dumps({"success": False, "message": f"File not found: {file_path}"})
ui = unreal.FbxImportUI()
ui.automated_import_should_detect_type = False
ui.mesh_type_to_import = (unreal.FBXImportType.FBXIT_SKELETAL_MESH if as_skeletal
else unreal.FBXImportType.FBXIT_STATIC_MESH)
ui.import_mesh = True
ui.import_as_skeletal = bool(as_skeletal)
ui.import_materials = bool(import_materials)
ui.import_textures = bool(import_textures)
ui.import_animations = bool(import_animations)
task = unreal.AssetImportTask()
task.filename = file_path
task.destination_path = destination_path
if destination_name:
task.destination_name = destination_name
task.automated = True
task.replace_existing = True
task.save = True
task.options = ui
task.factory = unreal.FbxFactory() # legacy importer — bypass Interchange (crash)
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
paths = [str(p).split(".")[0] for p in task.imported_object_paths]
if not paths:
return json.dumps({"success": False, "message": "Import produced no assets (see Output Log)."})
return json.dumps({"success": True, "file_path": file_path, "imported_assets": paths})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def _gltf_status_path(destination_path: str) -> str:
safe = destination_path.strip("/").replace("/", "_").replace("\\", "_")
return os.path.join(unreal.Paths.project_saved_dir(), f"_mcp_gltf_{safe}.json").replace("\\", "/")
def ue_import_gltf(file_path: str = None, destination_path: str = None) -> str:
"""Imports a .glb/.gltf via Interchange, deferred to the editor tick. Poll get_gltf_import_status for the result."""
# glTF import goes through Interchange, which is async (task-graph based). Driving it
# synchronously from the MCP game-thread task re-enters the task graph and crashes
# (RecursionGuard assertion). There is no legacy synchronous glTF factory in UE 5.7
# (unlike FBX), so we schedule the import on the next editor tick — outside our task —
# and report the result via a status sidecar polled by get_gltf_import_status.
if file_path is None or destination_path is None:
return json.dumps({"success": False, "message": "Required parameters: file_path, destination_path."})
try:
if not os.path.isfile(file_path):
return json.dumps({"success": False, "message": f"File not found: {file_path}"})
status_path = _gltf_status_path(destination_path)
if os.path.isfile(status_path):
os.remove(status_path)
unreal.EditorAssetLibrary.make_directory(destination_path)
handle = [None]
fired = [False]
def _run(delta_seconds):
if fired[0]:
return
fired[0] = True
try:
task = unreal.AssetImportTask()
task.filename = file_path
task.destination_path = destination_path
task.automated = True
task.replace_existing = True
task.save = True
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
res = {"success": True}
except Exception as e:
res = {"success": False, "error": str(e), "traceback": traceback.format_exc()}
try:
with open(status_path, "w", encoding="utf-8") as f:
json.dump(res, f)
except Exception:
pass
if handle[0] is not None:
unreal.unregister_slate_post_tick_callback(handle[0])
handle[0] = unreal.register_slate_post_tick_callback(_run)
return json.dumps({"success": True, "pending": True, "destination_path": destination_path,
"message": "glTF import scheduled on the editor tick. Poll 'get_gltf_import_status' with this destination_path."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_gltf_import_status(destination_path: str = None) -> str:
"""Polls a scheduled glTF import; returns done + imported assets once Interchange finishes."""
if destination_path is None:
return json.dumps({"success": False, "message": "Required parameter 'destination_path' is missing."})
try:
status_path = _gltf_status_path(destination_path)
if not os.path.isfile(status_path):
return json.dumps({"success": True, "done": False, "pending": True,
"message": "Import tick has not fired yet; retry shortly."})
with open(status_path, encoding="utf-8") as f:
res = json.load(f)
if not res.get("success"):
os.remove(status_path)
return json.dumps({"success": False, "done": True,
"message": res.get("error", "glTF import failed."),
"traceback": res.get("traceback")})
created = (unreal.EditorAssetLibrary.list_assets(destination_path, recursive=True)
if unreal.EditorAssetLibrary.does_directory_exist(destination_path) else [])
if not created:
# import_asset_tasks was called but Interchange is still finishing asynchronously
return json.dumps({"success": True, "done": False, "pending": True,
"message": "Interchange still importing; retry shortly."})
assets = [{"path": str(p).split(".")[0],
"class": str(unreal.EditorAssetLibrary.find_asset_data(p).asset_class_path.asset_name)}
for p in created]
os.remove(status_path)
return json.dumps({"success": True, "done": True, "destination_path": destination_path,
"imported_assets": assets, "count": len(assets)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_import_texture(file_path: str = None, destination_path: str = None,
destination_name: str = "") -> str:
"""Imports an image file (PNG/JPG/TGA...) as a Texture2D using the legacy texture importer."""
if file_path is None or destination_path is None:
return json.dumps({"success": False, "message": "Required parameters: file_path, destination_path."})
try:
if not os.path.isfile(file_path):
return json.dumps({"success": False, "message": f"File not found: {file_path}"})
task = unreal.AssetImportTask()
task.filename = file_path
task.destination_path = destination_path
if destination_name:
task.destination_name = destination_name
task.automated = True
task.replace_existing = True
task.save = True
task.factory = unreal.TextureFactory() # legacy importer — bypass Interchange (crash)
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
paths = [str(p).split(".")[0] for p in task.imported_object_paths]
if not paths:
return json.dumps({"success": False, "message": "Import produced no assets (see Output Log)."})
return json.dumps({"success": True, "file_path": file_path, "imported_assets": paths})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
_FBX_EXPORTERS = {
"StaticMesh": "StaticMeshExporterFBX",
"SkeletalMesh": "SkeletalMeshExporterFBX",
"AnimSequence": "AnimSequenceExporterFBX",
}
def ue_export_fbx(asset_path: str = None, file_path: str = None) -> str:
"""Exports a StaticMesh, SkeletalMesh, or AnimSequence asset to an FBX file."""
if asset_path is None or file_path is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, file_path."})
try:
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
if not asset:
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
cls = asset.get_class().get_name()
exporter_name = _FBX_EXPORTERS.get(cls)
if not exporter_name:
return json.dumps({"success": False,
"message": f"Unsupported asset class '{cls}' for FBX export.",
"supported": list(_FBX_EXPORTERS)})
task = unreal.AssetExportTask()
task.object = asset
task.filename = file_path
task.automated = True
task.prompt = False
task.exporter = getattr(unreal, exporter_name)()
task.options = unreal.FbxExportOption()
ok = unreal.Exporter.run_asset_export_task(task)
if not ok or not os.path.isfile(file_path):
return json.dumps({"success": False, "message": "Export failed (see Output Log)."})
return json.dumps({"success": True, "asset_path": asset_path, "file_path": file_path,
"file_size": os.path.getsize(file_path)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,714 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
import unreal
import json
import traceback
BT_ACTIONS_MODULE = "behavior_tree_actions"
# ─── Helpers ──────────────────────────────────────────────────────────────────
# Blackboard key type string → full class path for load_class fallback
_BB_KEY_TYPE_MAP = {
"Bool": "/Script/AIModule.BlackboardKeyType_Bool",
"Int": "/Script/AIModule.BlackboardKeyType_Int",
"Float": "/Script/AIModule.BlackboardKeyType_Float",
"String": "/Script/AIModule.BlackboardKeyType_String",
"Name": "/Script/AIModule.BlackboardKeyType_Name",
"Vector": "/Script/AIModule.BlackboardKeyType_Vector",
"Rotator": "/Script/AIModule.BlackboardKeyType_Rotator",
"Object": "/Script/AIModule.BlackboardKeyType_Object",
"Class": "/Script/AIModule.BlackboardKeyType_Class",
"Enum": "/Script/AIModule.BlackboardKeyType_Enum",
}
def _bt_node_info_to_dict(info):
"""Convert FMCPythonBTNodeInfo (C++ USTRUCT) to a Python dict."""
d = {
"node_name": str(info.node_name),
"node_class": str(info.node_class),
}
if info.decorator_classes:
d["decorators"] = [
{"class": str(info.decorator_classes[i]), "name": str(info.decorator_names[i])}
for i in range(len(info.decorator_classes))
]
if info.service_classes:
d["services"] = [
{"class": str(info.service_classes[i]), "name": str(info.service_names[i])}
for i in range(len(info.service_classes))
]
if info.children:
d["children"] = [_bt_node_info_to_dict(child) for child in info.children]
return d
def _load_asset(asset_path, expected_class=None):
"""Load an asset and optionally verify its class. Returns (asset, error_json_str)."""
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
if asset is None:
return None, json.dumps({
"success": False,
"message": f"Asset not found or failed to load: {asset_path}"
})
if expected_class is not None and not isinstance(asset, expected_class):
return None, json.dumps({
"success": False,
"message": f"Asset at '{asset_path}' is {type(asset).__name__}, expected {expected_class.__name__}."
})
return asset, None
def _get_bt_blackboard(bt):
"""Get the Blackboard asset linked to a BehaviorTree (read-only)."""
try:
return bt.get_blackboard_asset()
except Exception:
pass
for name in ['blackboard_asset', 'BlackboardAsset']:
try:
return bt.get_editor_property(name)
except Exception:
pass
return None
def _get_node_class_name(node):
"""Get a readable class name for a BT node."""
if node is None:
return "None"
return type(node).__name__
def _get_bb_key_type_name(key_type_obj):
"""Extract a human-readable type name from a Blackboard key type object."""
if key_type_obj is None:
return "Unknown"
prefix = "BlackboardKeyType_"
# Try type(obj).__name__
class_name = type(key_type_obj).__name__
if class_name.startswith(prefix) and len(class_name) > len(prefix):
return class_name[len(prefix):]
# Try get_class().get_name() (UE reflection)
try:
ue_class = key_type_obj.get_class()
if ue_class:
ue_name = str(ue_class.get_name())
if ue_name.startswith(prefix) and len(ue_name) > len(prefix):
return ue_name[len(prefix):]
if ue_name != "BlackboardKeyType":
return ue_name
except Exception:
pass
# Try get_name()
try:
obj_name = str(key_type_obj.get_name())
if obj_name.startswith(prefix):
return obj_name[len(prefix):]
if obj_name and obj_name != "None":
return obj_name
except Exception:
pass
return class_name
def _serialize_value(value):
"""Convert a UE value to a JSON-safe Python type."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, unreal.Vector):
return [value.x, value.y, value.z]
if isinstance(value, unreal.Rotator):
return [value.pitch, value.yaw, value.roll]
if isinstance(value, unreal.Name):
return str(value)
return str(value)
def _split_asset_path(asset_path):
"""Split '/Game/AI/BT_Enemy' into ('/Game/AI', 'BT_Enemy')."""
parts = asset_path.rsplit('/', 1)
if len(parts) == 2:
return parts[0], parts[1]
return '/Game', asset_path
def _create_bb_key_type_instance(key_type):
"""Create a BlackboardKeyType instance via direct class or load_class fallback."""
class_simple_name = "BlackboardKeyType_" + key_type
class_path = _BB_KEY_TYPE_MAP.get(key_type)
# Direct class access
try:
cls = getattr(unreal, class_simple_name, None)
if cls is not None:
return cls(), None
except Exception:
pass
# load_class + new_object
if class_path:
try:
cls = unreal.load_class(None, class_path)
if cls is not None:
return unreal.new_object(cls), None
except Exception:
pass
return None, (
f"Cannot create key type '{key_type}': "
f"class '{class_simple_name}' is not accessible in this UE version's Python API."
)
# ─── Read Actions ─────────────────────────────────────────────────────────────
def ue_list_behavior_trees() -> str:
"""Lists all Behavior Tree assets under /Game."""
try:
all_assets = unreal.EditorAssetLibrary.list_assets('/Game', recursive=True)
results = []
for asset_path in all_assets:
try:
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
except Exception:
continue
if asset is None or not isinstance(asset, unreal.BehaviorTree):
continue
asset_name = str(asset_path).rsplit('/', 1)[-1].split('.')[0]
entry = {
"asset_path": str(asset_path),
"asset_name": asset_name,
}
try:
bb = _get_bt_blackboard(asset)
if bb is not None:
entry["blackboard_path"] = bb.get_path_name()
except Exception:
pass
results.append(entry)
return json.dumps({
"success": True,
"behavior_trees": results,
"count": len(results),
"message": f"Found {len(results)} Behavior Tree asset(s)."
})
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_list_behavior_trees: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
def ue_get_behavior_tree_structure(asset_path: str = None) -> str:
"""Returns the full tree structure of a Behavior Tree asset as JSON."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
bt, err = _load_asset(asset_path, unreal.BehaviorTree)
if err:
return err
# Get blackboard info
bb_path = None
try:
bb = _get_bt_blackboard(bt)
if bb is not None:
bb_path = bb.get_path_name()
except Exception:
pass
# Call C++ helper — returns JSON string with full tree
result_json = unreal.MCPythonHelper.get_behavior_tree_structure(bt)
result = json.loads(result_json)
if not result.get("success", False):
return result_json
# Merge blackboard info into the result
result["asset_path"] = asset_path
result["blackboard_path"] = bb_path
result["tree"] = [result.pop("root")] if "root" in result else []
result["message"] = f"Behavior Tree structure retrieved ({len(result['tree'])} root node(s))."
return json.dumps(result)
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_get_behavior_tree_structure: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
def ue_get_blackboard_data(asset_path: str = None) -> str:
"""Reads all keys from a Blackboard asset."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
bb, err = _load_asset(asset_path, unreal.BlackboardData)
if err:
return err
# Get parent blackboard
parent_path = None
for pp in ['parent', 'Parent']:
try:
parent = bb.get_editor_property(pp)
if parent is not None:
parent_path = parent.get_path_name()
break
except Exception:
pass
# Read keys
keys_data = []
for kp in ['keys', 'Keys']:
try:
keys = bb.get_editor_property(kp)
if keys is not None:
for key in keys:
key_info = {}
for enp in ['entry_name', 'EntryName']:
try:
key_info["key_name"] = str(key.get_editor_property(enp))
break
except Exception:
pass
for ktp in ['key_type', 'KeyType']:
try:
key_type = key.get_editor_property(ktp)
if key_type is not None:
key_info["key_type"] = _get_bb_key_type_name(key_type)
break
except Exception:
pass
if "key_type" not in key_info:
key_info["key_type"] = "Unknown"
for isp in ['is_instance_synced', 'bIsInstanceSynced', 'instance_synced']:
try:
key_info["instance_synced"] = bool(key.get_editor_property(isp))
break
except Exception:
pass
keys_data.append(key_info)
break
except Exception as keys_err:
unreal.log_warning(f"Could not read keys with prop '{kp}': {keys_err}")
return json.dumps({
"success": True,
"asset_path": asset_path,
"parent_path": parent_path,
"keys": keys_data,
"key_count": len(keys_data),
"message": f"Blackboard has {len(keys_data)} key(s)."
})
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_get_blackboard_data: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
def ue_get_bt_node_details(asset_path: str = None, node_name: str = None) -> str:
"""Retrieves detailed properties of a specific node in a Behavior Tree."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if node_name is None:
return json.dumps({"success": False, "message": "Required parameter 'node_name' is missing."})
try:
bt, err = _load_asset(asset_path, unreal.BehaviorTree)
if err:
return err
# Call C++ helper — returns JSON string with node details
details_json = unreal.MCPythonHelper.get_behavior_tree_node_details(bt, node_name)
return details_json
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_get_bt_node_details: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
def ue_get_selected_bt_nodes() -> str:
"""Returns details of selected nodes in the currently open BT editor."""
try:
result_json = unreal.MCPythonHelper.get_selected_bt_nodes()
return result_json
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_get_selected_bt_nodes: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
# ─── Write Actions ────────────────────────────────────────────────────────────
def ue_create_behavior_tree(asset_path: str = None, blackboard_path: str = None) -> str:
"""Creates a new empty Behavior Tree asset."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset already exists at '{asset_path}'."})
package_path, asset_name = _split_asset_path(asset_path)
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
bt = None
try:
factory = unreal.BehaviorTreeFactory()
bt = asset_tools.create_asset(asset_name, package_path, unreal.BehaviorTree, factory)
except Exception:
try:
bt = asset_tools.create_asset(asset_name, package_path, unreal.BehaviorTree, None)
except Exception:
pass
if bt is None:
return json.dumps({"success": False, "message": f"Failed to create Behavior Tree at '{asset_path}'."})
unreal.EditorAssetLibrary.save_asset(bt.get_path_name())
result = {
"success": True,
"asset_path": bt.get_path_name(),
"message": f"Behavior Tree created at '{bt.get_path_name()}'.",
}
# Link Blackboard via C++ helper
if blackboard_path is not None:
try:
bb = unreal.EditorAssetLibrary.load_asset(blackboard_path)
if bb is not None and isinstance(bb, unreal.BlackboardData):
linked = unreal.MCPythonHelper.set_behavior_tree_blackboard(bt, bb)
if linked:
result["blackboard_linked"] = True
result["blackboard_path"] = bb.get_path_name()
unreal.EditorAssetLibrary.save_asset(bt.get_path_name())
else:
result["blackboard_linked"] = False
result["blackboard_link_note"] = "Failed to link Blackboard via C++ helper."
else:
result["blackboard_linked"] = False
result["blackboard_link_note"] = f"Blackboard not found at '{blackboard_path}'."
except Exception as bb_err:
result["blackboard_linked"] = False
result["blackboard_link_note"] = f"Error linking Blackboard: {str(bb_err)}"
return json.dumps(result)
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_create_behavior_tree: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
def ue_create_blackboard(asset_path: str = None, parent_path: str = None) -> str:
"""Creates a new Blackboard Data asset."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset already exists at '{asset_path}'."})
package_path, asset_name = _split_asset_path(asset_path)
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
bb = None
try:
factory = unreal.BlackboardDataFactory()
bb = asset_tools.create_asset(asset_name, package_path, unreal.BlackboardData, factory)
except Exception:
try:
bb = asset_tools.create_asset(asset_name, package_path, unreal.BlackboardData, None)
except Exception:
pass
if bb is None:
return json.dumps({"success": False, "message": f"Failed to create Blackboard at '{asset_path}'."})
if parent_path is not None:
try:
parent_bb = unreal.EditorAssetLibrary.load_asset(parent_path)
if parent_bb is not None and isinstance(parent_bb, unreal.BlackboardData):
for pp in ['parent', 'Parent']:
try:
bb.set_editor_property(pp, parent_bb)
break
except Exception:
pass
else:
unreal.log_warning(f"Parent Blackboard not found or invalid: {parent_path}")
except Exception as parent_err:
unreal.log_warning(f"Could not set parent blackboard: {parent_err}")
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
return json.dumps({
"success": True,
"asset_path": bb.get_path_name(),
"message": f"Blackboard created at '{bb.get_path_name()}'."
})
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_create_blackboard: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
def ue_add_blackboard_key(asset_path: str = None, key_name: str = None,
key_type: str = None, instance_synced: bool = False) -> str:
"""Adds a new key to a Blackboard asset."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if key_name is None:
return json.dumps({"success": False, "message": "Required parameter 'key_name' is missing."})
if key_type is None:
return json.dumps({"success": False, "message": "Required parameter 'key_type' is missing."})
if key_type not in _BB_KEY_TYPE_MAP:
return json.dumps({
"success": False,
"message": f"Invalid key_type '{key_type}'. Supported types: {', '.join(_BB_KEY_TYPE_MAP.keys())}"
})
try:
bb, err = _load_asset(asset_path, unreal.BlackboardData)
if err:
return err
# Check if key already exists
existing_keys = None
for kp in ['keys', 'Keys']:
try:
existing_keys = bb.get_editor_property(kp)
break
except Exception:
pass
if existing_keys is not None:
for key in existing_keys:
for enp in ['entry_name', 'EntryName']:
try:
if str(key.get_editor_property(enp)) == key_name:
return json.dumps({
"success": False,
"message": f"Key '{key_name}' already exists in Blackboard."
})
break
except Exception:
pass
# Create the new key entry
new_entry = unreal.BlackboardEntry()
name_set = False
for enp in ['entry_name', 'EntryName']:
try:
new_entry.set_editor_property(enp, key_name)
name_set = True
break
except Exception:
pass
if not name_set:
return json.dumps({"success": False, "message": "Failed to set entry name on BlackboardEntry."})
key_type_obj, key_err = _create_bb_key_type_instance(key_type)
if key_err:
return json.dumps({"success": False, "message": key_err})
type_set = False
for ktp in ['key_type', 'KeyType']:
try:
new_entry.set_editor_property(ktp, key_type_obj)
type_set = True
break
except Exception:
pass
if not type_set:
return json.dumps({
"success": False,
"message": f"Failed to set key type on BlackboardEntry. Key type object: {type(key_type_obj).__name__}"
})
for isp in ['is_instance_synced', 'bIsInstanceSynced', 'instance_synced']:
try:
new_entry.set_editor_property(isp, instance_synced)
break
except Exception:
pass
# Add to keys array
added = False
for kp in ['keys', 'Keys']:
try:
keys = list(bb.get_editor_property(kp) or [])
keys.append(new_entry)
bb.set_editor_property(kp, keys)
added = True
break
except Exception:
pass
if not added:
return json.dumps({"success": False, "message": "Failed to add key to Blackboard keys array."})
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
return json.dumps({
"success": True,
"asset_path": asset_path,
"key_name": key_name,
"key_type": key_type,
"instance_synced": instance_synced,
"message": f"Key '{key_name}' ({key_type}) added to Blackboard."
})
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_add_blackboard_key: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
def ue_remove_blackboard_key(asset_path: str = None, key_name: str = None) -> str:
"""Removes a key from a Blackboard asset."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if key_name is None:
return json.dumps({"success": False, "message": "Required parameter 'key_name' is missing."})
try:
bb, err = _load_asset(asset_path, unreal.BlackboardData)
if err:
return err
removed = False
for kp in ['keys', 'Keys']:
try:
keys = bb.get_editor_property(kp)
if keys is None or len(keys) == 0:
return json.dumps({"success": False, "message": "Blackboard has no keys to remove."})
new_keys = []
found = False
for key in keys:
existing_name = None
for enp in ['entry_name', 'EntryName']:
try:
existing_name = str(key.get_editor_property(enp))
break
except Exception:
pass
if existing_name == key_name:
found = True
else:
new_keys.append(key)
if not found:
return json.dumps({"success": False, "message": f"Key '{key_name}' not found in Blackboard."})
bb.set_editor_property(kp, new_keys)
removed = True
break
except Exception:
pass
if not removed:
return json.dumps({"success": False, "message": "Failed to modify Blackboard keys array."})
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
return json.dumps({
"success": True,
"asset_path": asset_path,
"key_name": key_name,
"message": f"Key '{key_name}' removed from Blackboard."
})
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_remove_blackboard_key: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
def ue_set_blackboard_to_behavior_tree(bt_path: str = None, bb_path: str = None) -> str:
"""Links a Blackboard asset to a Behavior Tree."""
if bt_path is None:
return json.dumps({"success": False, "message": "Required parameter 'bt_path' is missing."})
if bb_path is None:
return json.dumps({"success": False, "message": "Required parameter 'bb_path' is missing."})
try:
bt, err = _load_asset(bt_path, unreal.BehaviorTree)
if err:
return err
bb, err = _load_asset(bb_path, unreal.BlackboardData)
if err:
return err
# Call C++ helper to set BlackboardAsset directly
success = unreal.MCPythonHelper.set_behavior_tree_blackboard(bt, bb)
if success:
unreal.EditorAssetLibrary.save_asset(bt_path)
return json.dumps({
"success": True,
"bt_path": bt_path,
"bb_path": bb_path,
"message": "Blackboard linked to Behavior Tree successfully."
})
else:
return json.dumps({
"success": False,
"message": "Failed to set Blackboard on Behavior Tree."
})
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_set_blackboard_to_behavior_tree: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
def ue_build_behavior_tree(asset_path: str = None, tree_structure: dict = None) -> str:
"""Builds a complete Behavior Tree from a JSON structure."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if tree_structure is None:
return json.dumps({"success": False, "message": "Required parameter 'tree_structure' is missing."})
try:
bt, err = _load_asset(asset_path, unreal.BehaviorTree)
if err:
return err
# Convert dict to JSON string for C++ helper
tree_json = json.dumps(tree_structure)
# Call C++ helper to build the tree
result_json = unreal.MCPythonHelper.build_behavior_tree(bt, tree_json)
return result_json
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_build_behavior_tree: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
def ue_list_bt_node_classes() -> str:
"""Lists all available BT node classes (composites, tasks, decorators, services)."""
try:
result_json = unreal.MCPythonHelper.list_bt_node_classes()
return result_json
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_list_bt_node_classes: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})

View File

@@ -0,0 +1,517 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
import unreal
import json
import traceback
from collections import deque
def _load_asset(asset_path, expected_class=None):
"""Load an asset and optionally verify its class. Returns (asset, error_json_str)."""
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
if asset is None:
return None, json.dumps({
"success": False,
"message": f"Asset not found or failed to load: {asset_path}"
})
if expected_class is not None and not isinstance(asset, expected_class):
return None, json.dumps({
"success": False,
"message": f"Asset at '{asset_path}' is {type(asset).__name__}, expected {expected_class.__name__}."
})
return asset, None
# ─── Read Actions ─────────────────────────────────────────────────────────────
def ue_get_selected_bp_nodes() -> str:
"""Returns information about currently selected blueprint nodes in the editor."""
try:
nodes = unreal.MCPythonHelper.get_selected_blueprint_nodes()
node_infos = []
for node in nodes:
node_info = {
"name": node.get_name() if hasattr(node, 'get_name') else str(node),
"class": node.get_class().get_name() if hasattr(node, 'get_class') else str(type(node)),
"object_path": node.get_path_name() if hasattr(node, 'get_path_name') else None
}
node_infos.append(node_info)
return json.dumps({
"success": True,
"selected_nodes_count": len(node_infos),
"selected_nodes": node_infos
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_selected_bp_node_infos() -> str:
"""Returns compact blueprint node info optimized for LLM token efficiency."""
try:
node_infos = unreal.MCPythonHelper.get_selected_blueprint_node_infos()
name_to_id = {}
for i, n in enumerate(node_infos):
name_to_id[n.node_name] = i
def link_to_dict(link):
d = {}
if link.node_name in name_to_id:
d["node"] = name_to_id[link.node_name]
else:
d["node"] = link.node_title
if link.pin_name:
d["pin"] = link.pin_name
return d
def pin_to_dict(pin):
name = pin.friendly_name if pin.friendly_name else pin.pin_name
d = {"name": name, "dir": pin.direction}
ptype = pin.pin_type
if pin.pin_sub_type:
ptype += ":" + pin.pin_sub_type
d["type"] = ptype
if pin.default_value:
d["default"] = pin.default_value
linked = list(pin.linked_to)
if linked:
d["linked"] = [link_to_dict(l) for l in linked]
return d
def node_to_dict(node, idx):
d = {"id": idx, "title": node.node_title}
if node.node_comment:
d["comment"] = node.node_comment
d["pins"] = [pin_to_dict(p) for p in node.pins]
return d
nodes = [node_to_dict(n, i) for i, n in enumerate(node_infos)]
return json.dumps({
"success": True,
"nodes": nodes
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_blueprint_graph_info(asset_path: str = None, graph_name: str = "EventGraph") -> str:
"""Returns the full graph info for a Blueprint graph."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
result_json = unreal.MCPythonHelper.get_blueprint_graph_info(bp, graph_name)
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_callable_functions(asset_path: str = None, filter: str = "") -> str:
"""Lists callable functions available in a Blueprint context."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
result_json = unreal.MCPythonHelper.list_callable_functions(bp, filter)
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_blueprint_variables(asset_path: str = None) -> str:
"""Lists all variables defined in a Blueprint."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
result_json = unreal.MCPythonHelper.list_blueprint_variables(bp)
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# ─── Write Actions ────────────────────────────────────────────────────────────
def ue_add_blueprint_node(asset_path: str = None, graph_name: str = "EventGraph",
node_json: dict = None) -> str:
"""Adds a single node to a Blueprint graph."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if node_json is None:
return json.dumps({"success": False, "message": "Required parameter 'node_json' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
node_json_str = json.dumps(node_json)
result_json = unreal.MCPythonHelper.add_blueprint_node(bp, graph_name, node_json_str)
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_connect_blueprint_pins(asset_path: str = None, graph_name: str = "EventGraph",
source_node: str = None, source_pin: str = None,
target_node: str = None, target_pin: str = None) -> str:
"""Connects two pins in a Blueprint graph."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
for name, val in [("source_node", source_node), ("source_pin", source_pin),
("target_node", target_node), ("target_pin", target_pin)]:
if val is None:
return json.dumps({"success": False, "message": f"Required parameter '{name}' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
result_json = unreal.MCPythonHelper.connect_blueprint_pins(
bp, graph_name, source_node, source_pin, target_node, target_pin)
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_remove_blueprint_node(asset_path: str = None, graph_name: str = "EventGraph",
node_name: str = None) -> str:
"""Removes a node from a Blueprint graph."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if node_name is None:
return json.dumps({"success": False, "message": "Required parameter 'node_name' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
result_json = unreal.MCPythonHelper.remove_blueprint_node(bp, graph_name, node_name)
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_build_blueprint_graph(asset_path: str = None, graph_name: str = "EventGraph",
graph_structure: dict = None) -> str:
"""Builds a Blueprint graph from JSON adjacency list."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if graph_structure is None:
return json.dumps({"success": False, "message": "Required parameter 'graph_structure' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
graph_json_str = json.dumps(graph_structure)
result_json = unreal.MCPythonHelper.build_blueprint_graph(bp, graph_name, graph_json_str)
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_compile_blueprint(asset_path: str = None) -> str:
"""Compiles a Blueprint and returns the result."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
result_json = unreal.MCPythonHelper.compile_blueprint(bp)
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# ─── Component Management ──────────────────────────────────────────────────────
def ue_list_blueprint_components(asset_path: str = None) -> str:
"""Lists all SCS components on a Blueprint."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
return unreal.MCPythonHelper.list_blueprint_components(bp)
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_component_to_blueprint(asset_path: str = None,
component_class_path: str = None,
component_name: str = None,
location_x: float = 0.0, location_y: float = 0.0, location_z: float = 0.0,
rotation_pitch: float = 0.0, rotation_yaw: float = 0.0, rotation_roll: float = 0.0,
parent_component_name: str = "") -> str:
"""Adds a component to a Blueprint's SCS."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if component_class_path is None:
return json.dumps({"success": False, "message": "Required parameter 'component_class_path' is missing."})
if component_name is None:
return json.dumps({"success": False, "message": "Required parameter 'component_name' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
result = unreal.MCPythonHelper.add_component_to_blueprint(
bp, component_class_path, component_name,
location_x, location_y, location_z,
rotation_pitch, rotation_yaw, rotation_roll,
parent_component_name or ""
)
unreal.EditorAssetLibrary.save_asset(bp.get_path_name(), only_if_is_dirty=False)
return result
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_remove_component_from_blueprint(asset_path: str = None, component_name: str = None) -> str:
"""Removes a component by variable name from a Blueprint's SCS."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if component_name is None:
return json.dumps({"success": False, "message": "Required parameter 'component_name' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
result = unreal.MCPythonHelper.remove_component_from_blueprint(bp, component_name)
unreal.EditorAssetLibrary.save_asset(bp.get_path_name(), only_if_is_dirty=False)
return result
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_component_property(asset_path: str = None, component_name: str = None,
property_name: str = None, value: str = None) -> str:
"""Sets a property on a component template in a Blueprint's SCS."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if component_name is None:
return json.dumps({"success": False, "message": "Required parameter 'component_name' is missing."})
if property_name is None:
return json.dumps({"success": False, "message": "Required parameter 'property_name' is missing."})
if value is None:
return json.dumps({"success": False, "message": "Required parameter 'value' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
result = unreal.MCPythonHelper.set_component_property(bp, component_name, property_name, value)
unreal.EditorAssetLibrary.save_asset(bp.get_path_name(), only_if_is_dirty=False)
return result
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# ─── Graph Auto-Layout ─────────────────────────────────────────────────────────
def ue_set_blueprint_node_position(asset_path: str = None, graph_name: str = "EventGraph",
node_name: str = None, pos_x: float = 0.0, pos_y: float = 0.0) -> str:
"""Sets the canvas position of a node in a Blueprint graph."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if node_name is None:
return json.dumps({"success": False, "message": "Required parameter 'node_name' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
return unreal.MCPythonHelper.set_blueprint_node_position(bp, graph_name, node_name, pos_x, pos_y)
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_auto_layout_graph(asset_path: str = None, graph_name: str = "EventGraph",
x_step: float = 380.0, y_step: float = 200.0) -> str:
"""Auto-lays out all nodes in a Blueprint graph using DAG topological sort."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
bp, err = _load_asset(asset_path, unreal.Blueprint)
if err:
return err
graph_info_str = unreal.MCPythonHelper.get_blueprint_graph_info(bp, graph_name)
graph_info = json.loads(graph_info_str)
if not graph_info.get("success"):
return graph_info_str
nodes = graph_info.get("nodes", [])
if not nodes:
return json.dumps({"success": True, "message": "No nodes to lay out.", "positioned": 0})
node_names = [n["node_name"] for n in nodes]
name_set = set(node_names)
in_degree = {n: 0 for n in node_names}
successors = {n: [] for n in node_names}
ENTRY_TYPES = {"K2Node_Event", "K2Node_CustomEvent", "K2Node_InputKey",
"K2Node_InputAction", "K2Node_FunctionEntry"}
for node in nodes:
node_name = node["node_name"]
for pin in node.get("pins", []):
if pin.get("direction") != "Output":
continue
pin_type = pin.get("type", "")
if pin_type not in ("exec", ""):
continue
for link in pin.get("linked_to", []):
target = link.get("node_name", "")
if target in name_set and target != node_name:
if target not in successors[node_name]:
successors[node_name].append(target)
in_degree[target] += 1
forced_entry = set()
for node in nodes:
node_class = node.get("node_class", node.get("node_name", ""))
for et in ENTRY_TYPES:
if et in node_class or et in node.get("node_name", ""):
forced_entry.add(node["node_name"])
break
for node in nodes:
n = node["node_name"]
if in_degree[n] == 0:
for pin in node.get("pins", []):
if pin.get("direction") == "Output" and pin.get("type") in ("exec", ""):
forced_entry.add(n)
break
column = {}
queue = deque()
for n in node_names:
if in_degree[n] == 0 or n in forced_entry:
column[n] = 0
queue.append(n)
while queue:
n = queue.popleft()
for s in successors[n]:
if column.get(s, -1) < column[n] + 1:
column[s] = column[n] + 1
in_degree[s] -= 1
if in_degree[s] <= 0 and s not in column:
queue.append(s)
for n in node_names:
if n not in column:
column[n] = 0
col_row = {}
positions = {}
for node in nodes:
n = node["node_name"]
c = column[n]
r = col_row.get(c, 0)
positions[n] = (c * x_step, r * y_step)
col_row[c] = r + 1
errors = []
positioned = 0
for n, (px, py) in positions.items():
result_str = unreal.MCPythonHelper.set_blueprint_node_position(bp, graph_name, n, px, py)
result = json.loads(result_str)
if result.get("success"):
positioned += 1
else:
errors.append(f"{n}: {result.get('message', '?')}")
return json.dumps({
"success": True,
"positioned": positioned,
"total": len(node_names),
"errors": errors,
"message": f"Auto-layout complete: {positioned}/{len(node_names)} nodes positioned.",
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_create_blueprint(asset_path: str = None, parent_class_path: str = "/Script/Engine.Actor") -> str:
"""Creates a Blueprint asset with the given parent class (default Actor)."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
parent = unreal.load_class(None, parent_class_path)
if not parent:
return json.dumps({"success": False, "message": f"Parent class not found: {parent_class_path}"})
asset_path = asset_path.rstrip("/")
idx = asset_path.rfind("/")
name, package = asset_path[idx + 1:], asset_path[:idx]
factory = unreal.BlueprintFactory()
factory.set_editor_property("parent_class", parent)
bp = unreal.AssetToolsHelpers.get_asset_tools().create_asset(name, package, unreal.Blueprint, factory)
if not bp:
return json.dumps({"success": False, "message": f"Failed to create Blueprint at {asset_path}."})
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return json.dumps({"success": True, "asset_path": asset_path, "parent_class": parent_class_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
_BP_VAR_TYPES = {"int", "byte", "bool", "real", "name", "string", "text"}
def ue_add_variable(asset_path: str = None, variable_name: str = None, variable_type: str = "real") -> str:
"""Adds a member variable to a Blueprint. variable_type: int, byte, bool, real (float), name, string, text."""
if asset_path is None or variable_name is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, variable_name."})
vt = (variable_type or "real").lower()
if vt == "float":
vt = "real"
if vt not in _BP_VAR_TYPES:
return json.dumps({"success": False, "message": f"Unsupported variable_type '{variable_type}'.",
"valid_types": sorted(_BP_VAR_TYPES | {"float"})})
try:
bp = unreal.EditorAssetLibrary.load_asset(asset_path)
if not bp or not isinstance(bp, unreal.Blueprint):
return json.dumps({"success": False, "message": f"Not a Blueprint: {asset_path}"})
bel = unreal.BlueprintEditorLibrary
pin = bel.get_basic_type_by_name(unreal.Name(vt))
ok = bel.add_member_variable(bp, unreal.Name(variable_name), pin)
if not ok:
return json.dumps({"success": False, "message": f"add_member_variable failed for '{variable_name}'."})
bel.compile_blueprint(bp)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return json.dumps({"success": True, "asset_path": asset_path,
"variable_name": variable_name, "variable_type": vt})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_variable_flags(asset_path: str = None, variable_name: str = None,
instance_editable: bool = None, expose_on_spawn: bool = None) -> str:
"""Sets a Blueprint variable's 'Instance Editable' and/or 'Expose On Spawn' flags."""
if asset_path is None or variable_name is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, variable_name."})
if instance_editable is None and expose_on_spawn is None:
return json.dumps({"success": False, "message": "Provide instance_editable and/or expose_on_spawn."})
try:
bp = unreal.EditorAssetLibrary.load_asset(asset_path)
if not bp or not isinstance(bp, unreal.Blueprint):
return json.dumps({"success": False, "message": f"Not a Blueprint: {asset_path}"})
bel = unreal.BlueprintEditorLibrary
name = unreal.Name(variable_name)
applied = {}
if instance_editable is not None:
bel.set_blueprint_variable_instance_editable(bp, name, bool(instance_editable))
applied["instance_editable"] = bool(instance_editable)
if expose_on_spawn is not None:
bel.set_blueprint_variable_expose_on_spawn(bp, name, bool(expose_on_spawn))
applied["expose_on_spawn"] = bool(expose_on_spawn)
bel.compile_blueprint(bp)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return json.dumps({"success": True, "asset_path": asset_path,
"variable_name": variable_name, "applied": applied})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,191 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
Control Rig authoring: create rigs, build the element hierarchy, add RigVM
unit nodes, and recompile.
All actions require the ControlRig plugin (built-in, usually enabled by
default). The dependency is soft: actions guard at call time. Adding controls
(RigControlSettings/Value structs) is deferred to a follow-up.
"""
import unreal
import json
import traceback
def _plugin_missing():
if not hasattr(unreal, "ControlRigBlueprint"):
return json.dumps({"success": False,
"message": "Requires the ControlRig plugin. Enable it in Edit > Plugins and restart."})
return None
def _load_rig(asset_path: str):
rig = unreal.EditorAssetLibrary.load_asset(asset_path)
if not rig:
raise FileNotFoundError(f"Control Rig not found at path: {asset_path}")
if not isinstance(rig, unreal.ControlRigBlueprint):
raise TypeError(f"Asset at {asset_path} is not a ControlRigBlueprint, but {type(rig).__name__}")
return rig
def _parent_key(rig, parent_name: str, parent_type: str):
if not parent_name:
return unreal.RigElementKey()
etype = {"bone": unreal.RigElementType.BONE,
"null": unreal.RigElementType.NULL,
"control": unreal.RigElementType.CONTROL}.get((parent_type or "bone").lower())
if etype is None:
raise ValueError(f"Unknown parent_type '{parent_type}' (use bone, null, or control).")
return unreal.RigElementKey(type=etype, name=parent_name)
def ue_create_control_rig(asset_path: str = None, skeletal_mesh_path: str = None) -> str:
"""Creates a Control Rig at asset_path; with a skeletal mesh, imports its bones and sets it as preview (requires the ControlRig plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
rig = unreal.ControlRigBlueprintFactory.create_new_control_rig_asset(asset_path)
if not rig:
return json.dumps({"success": False, "message": f"Failed to create Control Rig at {asset_path}."})
imported = 0
if skeletal_mesh_path:
mesh = unreal.EditorAssetLibrary.load_asset(skeletal_mesh_path)
if not mesh or not isinstance(mesh, unreal.SkeletalMesh):
return json.dumps({"success": False, "message": f"Not a SkeletalMesh: {skeletal_mesh_path}"})
# Import bones BEFORE set_preview_mesh (which itself populates the
# hierarchy, making a later import report 0 new keys), then count
# bones from the hierarchy so the number is reliable either way.
rig.get_hierarchy_controller().import_bones(mesh.skeleton)
rig.set_preview_mesh(mesh)
imported = sum(1 for k in rig.hierarchy.get_all_keys()
if getattr(k.type, "name", "") == "BONE")
rig.recompile_vm()
unreal.EditorAssetLibrary.save_loaded_asset(rig)
return json.dumps({"success": True, "asset_path": asset_path,
"skeletal_mesh": skeletal_mesh_path, "imported_bones": imported})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_control_rig_info(asset_path: str = None) -> str:
"""Returns element counts by type and the preview mesh of a Control Rig (requires the ControlRig plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
rig = _load_rig(asset_path)
counts = {}
names = {}
for k in rig.hierarchy.get_all_keys():
t = getattr(k.type, "name", str(k.type))
counts[t] = counts.get(t, 0) + 1
names.setdefault(t, []).append(str(k.name))
mesh = rig.get_preview_mesh()
return json.dumps({
"success": True,
"asset_path": asset_path,
"element_counts": counts,
"elements": {t: v[:50] for t, v in names.items()},
"preview_mesh": mesh.get_path_name() if mesh else None,
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_rig_bone(asset_path: str = None, bone_name: str = None,
parent_name: str = "", parent_type: str = "bone",
location: list = None) -> str:
"""Adds a bone to a Control Rig hierarchy under an optional parent (requires the ControlRig plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None or bone_name is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, bone_name."})
try:
rig = _load_rig(asset_path)
parent = _parent_key(rig, parent_name, parent_type)
tf = unreal.Transform()
if location:
if len(location) != 3:
return json.dumps({"success": False, "message": "location must be a list of 3 floats."})
tf.translation = unreal.Vector(float(location[0]), float(location[1]), float(location[2]))
key = rig.get_hierarchy_controller().add_bone(bone_name, parent, tf, True)
if not str(key.name):
return json.dumps({"success": False, "message": "add_bone returned an invalid key (bad parent?)."})
unreal.EditorAssetLibrary.save_loaded_asset(rig)
return json.dumps({"success": True, "asset_path": asset_path, "bone": str(key.name)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_rig_null(asset_path: str = None, null_name: str = None,
parent_name: str = "", parent_type: str = "bone",
location: list = None) -> str:
"""Adds a null (group transform) to a Control Rig hierarchy (requires the ControlRig plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None or null_name is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, null_name."})
try:
rig = _load_rig(asset_path)
parent = _parent_key(rig, parent_name, parent_type)
tf = unreal.Transform()
if location:
if len(location) != 3:
return json.dumps({"success": False, "message": "location must be a list of 3 floats."})
tf.translation = unreal.Vector(float(location[0]), float(location[1]), float(location[2]))
key = rig.get_hierarchy_controller().add_null(null_name, parent, tf, True)
if not str(key.name):
return json.dumps({"success": False, "message": "add_null returned an invalid key (bad parent?)."})
unreal.EditorAssetLibrary.save_loaded_asset(rig)
return json.dumps({"success": True, "asset_path": asset_path, "null": str(key.name)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_unit_node(asset_path: str = None, struct_path: str = None,
method: str = "Execute", pos_x: float = 0.0, pos_y: float = 0.0) -> str:
"""Adds a RigVM unit node by struct path (e.g. '/Script/ControlRig.RigUnit_GetTransform') to a Control Rig graph (requires the ControlRig plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None or struct_path is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, struct_path."})
try:
rig = _load_rig(asset_path)
controller = rig.get_controller()
node = controller.add_unit_node_from_struct_path(
struct_path, method or "Execute", unreal.Vector2D(float(pos_x), float(pos_y)))
if not node:
return json.dumps({"success": False, "message": f"Could not add unit node for '{struct_path}'."})
rig.recompile_vm()
unreal.EditorAssetLibrary.save_loaded_asset(rig)
return json.dumps({"success": True, "asset_path": asset_path,
"node": node.get_node_path(), "struct_path": struct_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_recompile_control_rig(asset_path: str = None) -> str:
"""Recompiles a Control Rig's VM and saves it (requires the ControlRig plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
rig = _load_rig(asset_path)
rig.recompile_vm()
unreal.EditorAssetLibrary.save_loaded_asset(rig)
return json.dumps({"success": True, "asset_path": asset_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,150 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""Python action functions for DataTable assets (read/write rows via DataTableFunctionLibrary)."""
import unreal
import json
import traceback
DFL = unreal.DataTableFunctionLibrary
def _load_data_table(asset_path: str):
if not asset_path:
raise ValueError("DataTable path cannot be empty.")
dt = unreal.EditorAssetLibrary.load_asset(asset_path)
if not dt:
raise FileNotFoundError(f"DataTable not found at path: {asset_path}")
if not isinstance(dt, unreal.DataTable):
raise TypeError(f"Asset at {asset_path} is not a DataTable, but {type(dt).__name__}")
return dt
def _split_asset_path(asset_path: str):
asset_path = asset_path.rstrip("/")
idx = asset_path.rfind("/")
return asset_path[idx + 1:], asset_path[:idx]
def ue_create_data_table(asset_path: str = None, row_struct_path: str = None) -> str:
"""Creates a DataTable asset with the given row struct (e.g. '/Script/MyModule.MyRow' or a UserDefinedStruct path)."""
if asset_path is None or row_struct_path is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, row_struct_path."})
try:
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
struct = unreal.load_object(None, row_struct_path)
if not struct:
return json.dumps({"success": False, "message": f"Row struct not found: {row_struct_path}"})
name, package = _split_asset_path(asset_path)
factory = unreal.DataTableFactory()
factory.set_editor_property("struct", struct)
dt = unreal.AssetToolsHelpers.get_asset_tools().create_asset(name, package, unreal.DataTable, factory)
if not dt:
return json.dumps({"success": False, "message": f"Failed to create DataTable at {asset_path}."})
unreal.EditorAssetLibrary.save_loaded_asset(dt)
return json.dumps({"success": True, "asset_path": asset_path, "row_struct": row_struct_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_row_names(asset_path: str = None) -> str:
"""Lists the row names of a DataTable."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
dt = _load_data_table(asset_path)
names = [str(n) for n in DFL.get_data_table_row_names(dt)]
return json.dumps({"success": True, "asset_path": asset_path, "count": len(names), "row_names": names})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_column_names(asset_path: str = None) -> str:
"""Lists the column (property) names of a DataTable's row struct."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
dt = _load_data_table(asset_path)
cols = [str(c) for c in DFL.get_data_table_column_export_names(dt)]
struct = dt.get_row_struct()
return json.dumps({"success": True, "asset_path": asset_path, "columns": cols,
"row_struct": struct.get_path_name() if struct else None})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_rows_as_json(asset_path: str = None) -> str:
"""Returns all rows of a DataTable as a JSON string (under the 'rows' field)."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
dt = _load_data_table(asset_path)
return json.dumps({"success": True, "asset_path": asset_path,
"rows": DFL.export_data_table_to_json_string(dt)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_export_to_csv(asset_path: str = None) -> str:
"""Returns all rows of a DataTable as a CSV string."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
dt = _load_data_table(asset_path)
return json.dumps({"success": True, "asset_path": asset_path,
"csv": DFL.export_data_table_to_csv_string(dt)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_does_row_exist(asset_path: str = None, row_name: str = None) -> str:
"""Returns whether a row exists in a DataTable."""
if asset_path is None or row_name is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, row_name."})
try:
dt = _load_data_table(asset_path)
return json.dumps({"success": True, "asset_path": asset_path, "row_name": row_name,
"exists": bool(DFL.does_data_table_row_exist(dt, unreal.Name(row_name)))})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_remove_row(asset_path: str = None, row_name: str = None) -> str:
"""Removes a row from a DataTable by name."""
if asset_path is None or row_name is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, row_name."})
try:
dt = _load_data_table(asset_path)
if not DFL.does_data_table_row_exist(dt, unreal.Name(row_name)):
return json.dumps({"success": False, "message": f"Row '{row_name}' does not exist."})
# remove_data_table_row returns None; confirm via does_row_exist.
DFL.remove_data_table_row(dt, unreal.Name(row_name))
still = DFL.does_data_table_row_exist(dt, unreal.Name(row_name))
if still:
return json.dumps({"success": False, "message": f"Row '{row_name}' was not removed."})
unreal.EditorAssetLibrary.save_loaded_asset(dt)
return json.dumps({"success": True, "asset_path": asset_path, "removed": row_name})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_rows_from_json(asset_path: str = None, json_string: str = None) -> str:
"""Replaces a DataTable's rows from a JSON string (array of row objects with a 'Name' key)."""
if asset_path is None or json_string is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, json_string."})
try:
dt = _load_data_table(asset_path)
# Return type varies by engine version: bool (ok) or a list of problem strings.
result = DFL.fill_data_table_from_json_string(dt, json_string)
if isinstance(result, bool):
if not result:
return json.dumps({"success": False, "message": "fill_data_table_from_json_string returned False."})
else:
problems = [str(p) for p in result] if result else []
if problems:
return json.dumps({"success": False, "message": "Fill reported problems.", "problems": problems})
unreal.EditorAssetLibrary.save_loaded_asset(dt)
names = [str(n) for n in DFL.get_data_table_row_names(dt)]
return json.dumps({"success": True, "asset_path": asset_path, "row_count": len(names)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,704 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
import unreal
import json
import traceback
from typing import List, Dict, Optional, Any # Modified import
def ue_get_selected_assets() -> str:
"""Gets the set of currently selected assets."""
try:
selected_assets = unreal.EditorUtilityLibrary.get_selected_assets()
serialized_assets = []
for asset in selected_assets:
serialized_assets.append({
"asset_name": asset.get_name(),
"asset_path": asset.get_path_name(),
"asset_class": asset.get_class().get_name(),
})
return json.dumps({"success": True, "selected_assets": serialized_assets})
except Exception as e:
return json.dumps({"success": False, "message": str(e)})
# Helper function to load MaterialInterface assets (can be Material or MaterialInstance)
def _load_material_interface(material_path: str):
# Helper implementation (ensure this is robust or use existing if available)
material = unreal.EditorAssetLibrary.load_asset(material_path)
if not material:
raise FileNotFoundError(f"Material asset not found at path: {material_path}")
if not isinstance(material, unreal.MaterialInterface): # Allows Material or MaterialInstance
raise TypeError(f"Asset at {material_path} is not a MaterialInterface, but {type(material).__name__}")
return material
# Helper function to load StaticMesh assets
def _load_static_mesh(mesh_path: str):
# Helper implementation (ensure this is robust or use existing if available)
mesh = unreal.EditorAssetLibrary.load_asset(mesh_path)
if not mesh:
raise FileNotFoundError(f"StaticMesh asset not found at path: {mesh_path}")
if not isinstance(mesh, unreal.StaticMesh):
raise TypeError(f"Asset at {mesh_path} is not a StaticMesh, but {type(mesh).__name__}")
return mesh
# Helper function to get material paths from a mesh component
def _get_component_material_paths(component: unreal.MeshComponent) -> List[str]:
material_paths = []
if component:
for i in range(component.get_num_materials()):
material = component.get_material(i)
if material:
material_paths.append(material.get_path_name())
else:
material_paths.append("") # Represent empty slot
return material_paths
# Helper function to get actors by their paths
def _get_actors_by_paths(actor_paths: List[str]) -> List[unreal.Actor]:
actors = []
all_level_actors = unreal.EditorLevelLibrary.get_all_level_actors()
for path in actor_paths:
actor = next((a for a in all_level_actors if a.get_path_name() == path), None)
if actor:
actors.append(actor)
else:
unreal.log_warning(f"MCP: Actor not found at path: {path}")
return actors
# Helper to create a map of actor paths to their unique material asset paths
def _get_materials_map_for_actors(actors_list: List[unreal.Actor]) -> Dict[str, List[str]]:
materials_map = {}
if not actors_list:
return materials_map
for actor in actors_list:
if not actor: continue
actor_path_name = actor.get_path_name()
actor_material_paths = set()
mesh_components = actor.get_components_by_class(unreal.MeshComponent.static_class())
for comp in mesh_components:
if comp:
for i in range(comp.get_num_materials()):
material = comp.get_material(i)
if material:
actor_material_paths.add(material.get_path_name())
materials_map[actor_path_name] = sorted(list(actor_material_paths))
return materials_map
# Helper to get static mesh path from a static mesh component
def _get_component_mesh_path(component: unreal.StaticMeshComponent) -> str:
if component and hasattr(component, 'static_mesh') and component.static_mesh:
return component.static_mesh.get_path_name()
return ""
# Helper to create a map of actor paths to their unique static mesh asset paths
def _get_meshes_map_for_actors(actors_list: List[unreal.Actor]) -> Dict[str, List[str]]:
meshes_map = {}
if not actors_list:
return meshes_map
for actor in actors_list:
if not actor: continue
actor_path_name = actor.get_path_name()
actor_mesh_paths = set()
sm_components = actor.get_components_by_class(unreal.StaticMeshComponent.static_class())
for comp in sm_components:
mesh_path = _get_component_mesh_path(comp)
if mesh_path:
actor_mesh_paths.add(mesh_path)
meshes_map[actor_path_name] = sorted(list(actor_mesh_paths))
return meshes_map
# Helper to create a map of actor paths to their unique skeletal mesh asset paths
def _get_skeletal_meshes_map_for_actors(actors_list: List[unreal.Actor]) -> Dict[str, List[str]]:
skeletal_meshes_map = {}
if not actors_list:
return skeletal_meshes_map
for actor in actors_list:
if not actor:
continue
actor_path_name = actor.get_path_name()
actor_skel_mesh_paths = set()
skel_components = actor.get_components_by_class(unreal.SkeletalMeshComponent.static_class())
for comp in skel_components:
if hasattr(comp, 'skeletal_mesh') and comp.skeletal_mesh:
actor_skel_mesh_paths.add(comp.skeletal_mesh.get_path_name())
skeletal_meshes_map[actor_path_name] = sorted(list(actor_skel_mesh_paths))
return skeletal_meshes_map
# Base helper for replacing meshes
def _replace_meshes_on_actors_components_base(
actors: List[unreal.Actor],
mesh_to_be_replaced_path: str, # Can be empty/None to replace any mesh
new_mesh_path: str
) -> Dict[str, Any]:
"""Base logic for replacing static meshes on components of given actors."""
mesh_to_replace = None
# Only load if a specific mesh is targeted for replacement
if mesh_to_be_replaced_path and mesh_to_be_replaced_path.lower() not in ["", "none", "any"]:
mesh_to_replace = _load_static_mesh(mesh_to_be_replaced_path) # Uses existing helper
# If _load_static_mesh raises, it will be caught by the calling ue_ function's try-except
new_mesh = _load_static_mesh(new_mesh_path) # Uses existing helper
# If _load_static_mesh raises, it will be caught by the calling ue_ function's try-except
if mesh_to_replace and new_mesh and mesh_to_replace.get_path_name() == new_mesh.get_path_name():
return {"success": True, "message": "Mesh to be replaced is the same as the new mesh. No changes made.", "changed_actors_count": 0, "changed_components_count": 0}
changed_actors_count = 0
changed_components_count = 0
details = {"actors_affected": []}
with unreal.ScopedEditorTransaction("Replace Static Meshes on Components") as trans:
for actor in actors:
if not actor: continue
actor_path = actor.get_path_name()
actor_changed_this_iteration = False
actor_details = {"actor_path": actor_path, "components_changed": []}
static_mesh_components = actor.get_components_by_class(unreal.StaticMeshComponent.static_class())
for component in static_mesh_components:
if not component or component.get_owner() != actor:
continue
component_path = component.get_path_name()
current_mesh = component.static_mesh
should_replace = False
# Case 1: Replace any mesh (mesh_to_be_replaced_path is empty/None/Any)
if not mesh_to_be_replaced_path or mesh_to_be_replaced_path.lower() in ["", "none", "any"]:
if current_mesh != new_mesh : # Avoid replacing with itself if no specific mesh to replace
should_replace = True
# Case 2: Replace a specific mesh
elif mesh_to_replace and current_mesh and current_mesh.get_path_name() == mesh_to_replace.get_path_name():
if current_mesh.get_path_name() != new_mesh.get_path_name(): # Don't replace if it's already the new mesh
should_replace = True
# Case 3: Component has no mesh, and we want to set one (mesh_to_be_replaced_path is empty/None/Any)
elif (not mesh_to_be_replaced_path or mesh_to_be_replaced_path.lower() in ["", "none", "any"]) and not current_mesh:
should_replace = True
if should_replace:
if component.set_static_mesh(new_mesh):
changed_components_count += 1
actor_changed_this_iteration = True
actor_details["components_changed"].append({"component_path": component_path, "previous_mesh": current_mesh.get_path_name() if current_mesh else None})
else:
unreal.log_warning(f"MCP: Failed to set static mesh on component {component_path} for actor {actor_path}")
if actor_changed_this_iteration:
changed_actors_count += 1
details["actors_affected"].append(actor_details)
if changed_actors_count > 0:
unreal.EditorLevelLibrary.refresh_all_level_editors()
return {
"success": True,
"message": f"Mesh replacement processed. Actors processed: {len(actors)}.",
"changed_actors_count": changed_actors_count,
"changed_components_count": changed_components_count,
"details": details
}
def ue_replace_mtl_on_selected(material_to_be_replaced_path: str, new_material_path: str) -> str:
try:
material_to_replace = _load_material_interface(material_to_be_replaced_path)
new_material = _load_material_interface(new_material_path)
selected_actors = unreal.EditorLevelLibrary.get_selected_level_actors()
if not selected_actors:
return json.dumps({"success": False, "message": "No actors selected."})
mesh_components = []
for actor in selected_actors:
components = actor.get_components_by_class(unreal.MeshComponent.static_class())
mesh_components.extend(c for c in components if c)
if not mesh_components:
return json.dumps({"success": False, "message": "No mesh components found on selected actors."})
initial_materials_map = {}
for comp in mesh_components:
initial_materials_map[comp.get_path_name()] = _get_component_material_paths(comp)
unreal.EditorLevelLibrary.replace_mesh_components_materials(
mesh_components,
material_to_replace,
new_material
)
num_components_affected = 0
affected_component_paths = []
for comp in mesh_components:
current_materials = _get_component_material_paths(comp)
original_materials = initial_materials_map.get(comp.get_path_name(), [])
component_changed_here = False
for slot_idx, original_mat_path in enumerate(original_materials):
if original_mat_path == material_to_replace.get_path_name():
if slot_idx < len(current_materials) and current_materials[slot_idx] == new_material.get_path_name():
component_changed_here = True
break
if component_changed_here:
num_components_affected += 1
affected_component_paths.append(comp.get_path_name())
if num_components_affected > 0:
return json.dumps({
"success": True,
"message": f"Successfully replaced material '{material_to_be_replaced_path}' with '{new_material_path}' on {num_components_affected} mesh component(s) across {len(selected_actors)} selected actor(s).",
"affected_actors_count": len(selected_actors),
"affected_components_count": num_components_affected,
"affected_component_paths": affected_component_paths
})
else:
return json.dumps({
"success": False,
"message": f"Failed to replace material. Target material '{material_to_be_replaced_path}' not found or not replaced on any mesh components of selected actors."
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_replace_mtl_on_specified(actor_paths: List[str], material_to_be_replaced_path: str, new_material_path: str) -> str:
try:
material_to_replace = _load_material_interface(material_to_be_replaced_path)
new_material = _load_material_interface(new_material_path)
actors_to_process = _get_actors_by_paths(actor_paths)
if not actors_to_process:
return json.dumps({"success": False, "message": "No valid actors found from the provided paths."})
all_mesh_components_in_actors = []
for actor in actors_to_process:
components = actor.get_components_by_class(unreal.MeshComponent.static_class())
all_mesh_components_in_actors.extend(c for c in components if c)
if not all_mesh_components_in_actors:
return json.dumps({"success": False, "message": "No mesh components found on the specified actors."})
initial_materials_map = {}
for comp in all_mesh_components_in_actors:
initial_materials_map[comp.get_path_name()] = _get_component_material_paths(comp)
unreal.EditorLevelLibrary.replace_mesh_components_materials_on_actors(
actors_to_process,
material_to_replace,
new_material
)
num_components_affected = 0
affected_component_paths = []
for comp in all_mesh_components_in_actors:
current_materials = _get_component_material_paths(comp)
original_materials = initial_materials_map.get(comp.get_path_name(), [])
component_changed_here = False
for slot_idx, original_mat_path in enumerate(original_materials):
if original_mat_path == material_to_replace.get_path_name():
if slot_idx < len(current_materials) and current_materials[slot_idx] == new_material.get_path_name():
component_changed_here = True
break
if component_changed_here:
num_components_affected += 1
affected_component_paths.append(comp.get_path_name())
if num_components_affected > 0:
return json.dumps({
"success": True,
"message": f"Successfully replaced material '{material_to_be_replaced_path}' with '{new_material_path}' on {num_components_affected} mesh component(s) across {len(actors_to_process)} specified actor(s).",
"affected_actors_count": len(actors_to_process),
"affected_components_count": num_components_affected,
"affected_component_paths": affected_component_paths
})
else:
return json.dumps({
"success": False,
"message": f"Failed to replace material. Target material '{material_to_be_replaced_path}' not found or not replaced on any mesh components of specified actors."
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_replace_mesh_on_selected(mesh_to_be_replaced_path: str, new_mesh_path: str) -> str:
"""Replaces static meshes on components of selected actors using Unreal's batch API if available."""
try:
# Check if mesh_to_be_replaced_path exists (if specified)
if mesh_to_be_replaced_path and mesh_to_be_replaced_path.lower() not in ["", "none", "any"]:
try:
_ = _load_static_mesh(mesh_to_be_replaced_path)
except FileNotFoundError:
return json.dumps({
"success": False,
"message": f"The mesh_to_be_replaced_path '{mesh_to_be_replaced_path}' does not exist as a StaticMesh asset.",
"error_type": "MeshToReplaceNotFound"
})
selected_actors = unreal.EditorLevelLibrary.get_selected_level_actors()
if not selected_actors:
return json.dumps({"success": True, "message": "No actors selected.", "changed_actors_count": 0, "changed_components_count": 0})
mesh_to_replace = None
if mesh_to_be_replaced_path and mesh_to_be_replaced_path.lower() not in ["", "none", "any"]:
mesh_to_replace = _load_static_mesh(mesh_to_be_replaced_path)
new_mesh = _load_static_mesh(new_mesh_path)
# Gather all static mesh components from selected actors
all_mesh_components = []
for actor in selected_actors:
comps = actor.get_components_by_class(unreal.StaticMeshComponent.static_class())
all_mesh_components.extend(c for c in comps if c)
if not all_mesh_components:
return json.dumps({"success": False, "message": "No static mesh components found on selected actors."})
# Save initial mesh paths for change detection
initial_meshes_map = {comp.get_path_name(): comp.static_mesh.get_path_name() if comp.static_mesh else "" for comp in all_mesh_components}
# Use Unreal's batch API if available
if hasattr(unreal.EditorLevelLibrary, "replace_mesh_components_meshes_on_actors"):
unreal.EditorLevelLibrary.replace_mesh_components_meshes_on_actors(
selected_actors,
mesh_to_replace,
new_mesh
)
else:
# Fallback to manual replacement if batch API is not available
for comp in all_mesh_components:
current_mesh = comp.static_mesh
should_replace = False
if not mesh_to_replace:
if current_mesh != new_mesh:
should_replace = True
elif current_mesh and current_mesh.get_path_name() == mesh_to_replace.get_path_name():
if current_mesh.get_path_name() != new_mesh.get_path_name():
should_replace = True
elif not current_mesh and not mesh_to_replace:
should_replace = True
if should_replace:
comp.set_static_mesh(new_mesh)
# Detect changes
changed_components_count = 0
affected_component_paths = []
for comp in all_mesh_components:
before = initial_meshes_map.get(comp.get_path_name(), "")
after = comp.static_mesh.get_path_name() if comp.static_mesh else ""
if mesh_to_replace:
if before == mesh_to_replace.get_path_name() and after == new_mesh.get_path_name():
changed_components_count += 1
affected_component_paths.append(comp.get_path_name())
else:
if before != after and after == new_mesh.get_path_name():
changed_components_count += 1
affected_component_paths.append(comp.get_path_name())
if changed_components_count > 0:
return json.dumps({
"success": True,
"message": f"Successfully replaced mesh on {changed_components_count} static mesh component(s) across {len(selected_actors)} selected actor(s).",
"affected_actors_count": len(selected_actors),
"affected_components_count": changed_components_count,
"affected_component_paths": affected_component_paths
})
else:
return json.dumps({
"success": False,
"message": f"Failed to replace mesh. Target mesh '{mesh_to_be_replaced_path}' not found or not replaced on any static mesh components of selected actors."
})
except FileNotFoundError as e:
unreal.log_error(f"MCP: Asset loading error in ue_replace_mesh_on_selected: {e}")
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
except TypeError as e:
unreal.log_error(f"MCP: Asset type error in ue_replace_mesh_on_selected: {e}")
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
except Exception as e:
unreal.log_error(f"MCP: Error in ue_replace_mesh_on_selected: {e}\n{traceback.format_exc()}")
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_replace_mesh_on_specified(actor_paths: List[str], mesh_to_be_replaced_path: str, new_mesh_path: str) -> str:
"""Replaces static meshes on components of specified actors using Unreal's batch API if available."""
try:
# Check if mesh_to_be_replaced_path exists (if specified)
if mesh_to_be_replaced_path and mesh_to_be_replaced_path.lower() not in ["", "none", "any"]:
try:
_ = _load_static_mesh(mesh_to_be_replaced_path)
except FileNotFoundError:
return json.dumps({
"success": False,
"message": f"The mesh_to_be_replaced_path '{mesh_to_be_replaced_path}' does not exist as a StaticMesh asset.",
"error_type": "MeshToReplaceNotFound"
})
actors_to_process = _get_actors_by_paths(actor_paths)
if not actors_to_process:
return json.dumps({"success": False, "message": "No valid actors found from the provided paths."})
mesh_to_replace = None
if mesh_to_be_replaced_path and mesh_to_be_replaced_path.lower() not in ["", "none", "any"]:
mesh_to_replace = _load_static_mesh(mesh_to_be_replaced_path)
new_mesh = _load_static_mesh(new_mesh_path)
# Gather all static mesh components from specified actors
all_mesh_components = []
for actor in actors_to_process:
comps = actor.get_components_by_class(unreal.StaticMeshComponent.static_class())
all_mesh_components.extend(c for c in comps if c)
if not all_mesh_components:
# Get the actual actor types and names for better diagnosis
actor_info = [{"name": actor.get_name(), "class": actor.get_class().get_name()} for actor in actors_to_process]
actors_materials_info = _get_materials_map_for_actors(actors_to_process)
actors_meshes_info = _get_meshes_map_for_actors(actors_to_process)
actors_skel_meshes_info = _get_skeletal_meshes_map_for_actors(actors_to_process)
return json.dumps({
"success": False,
"message": "No static mesh components found on specified actors.",
"specified_actors_info": actor_info,
"current_materials": actors_materials_info,
"current_meshes": actors_meshes_info,
"current_skeletal_meshes": actors_skel_meshes_info
})
# Save initial mesh paths for change detection
initial_meshes_map = {comp.get_path_name(): comp.static_mesh.get_path_name() if comp.static_mesh else "" for comp in all_mesh_components}
# Use Unreal's batch API if available
if hasattr(unreal.EditorLevelLibrary, "replace_mesh_components_meshes_on_actors"):
unreal.EditorLevelLibrary.replace_mesh_components_meshes_on_actors(
actors_to_process,
mesh_to_replace,
new_mesh
)
else:
# Fallback to manual replacement if batch API is not available
for comp in all_mesh_components:
current_mesh = comp.static_mesh
should_replace = False
if not mesh_to_replace:
if current_mesh != new_mesh:
should_replace = True
elif current_mesh and current_mesh.get_path_name() == mesh_to_replace.get_path_name():
if current_mesh.get_path_name() != new_mesh.get_path_name():
should_replace = True
elif not current_mesh and not mesh_to_replace:
should_replace = True
if should_replace:
comp.set_static_mesh(new_mesh)
# Detect changes
changed_components_count = 0
affected_component_paths = []
unchanged_components_info = []
for comp in all_mesh_components:
before = initial_meshes_map.get(comp.get_path_name(), "")
after = comp.static_mesh.get_path_name() if comp.static_mesh else ""
owner_actor = comp.get_owner()
owner_name = owner_actor.get_name() if owner_actor else "Unknown"
if mesh_to_replace:
if before == mesh_to_replace.get_path_name() and after == new_mesh.get_path_name():
changed_components_count += 1
affected_component_paths.append(comp.get_path_name())
else:
is_candidate = before == mesh_to_replace.get_path_name()
unchanged_components_info.append({
"component_path": comp.get_path_name(),
"component_name": comp.get_name(),
"actor_name": owner_name,
"current_mesh": before,
"is_candidate": is_candidate,
"reason": (
"Component is a candidate for replacement (matches mesh_to_be_replaced_path) but was not changed"
if is_candidate else
("Current mesh doesn't match the mesh to be replaced" if before != mesh_to_replace.get_path_name() else "Failed to set new mesh")
)
})
else:
if before != after and after == new_mesh.get_path_name():
changed_components_count += 1
affected_component_paths.append(comp.get_path_name())
else:
unchanged_components_info.append({
"component_path": comp.get_path_name(),
"component_name": comp.get_name(),
"actor_name": owner_name,
"current_mesh": before,
"is_candidate": False,
"reason": "Component already has the target mesh" if before == new_mesh.get_path_name() else "Failed to set new mesh"
})
unchanged_components_info = unchanged_components_info if 'unchanged_components_info' in locals() else []
if changed_components_count > 0:
return json.dumps({
"success": True,
"message": f"Successfully replaced mesh on {changed_components_count} static mesh component(s) across {len(actors_to_process)} specified actor(s).",
"affected_actors_count": len(actors_to_process),
"affected_components_count": changed_components_count,
"affected_component_paths": affected_component_paths,
"unchanged_components": unchanged_components_info
})
else:
actors_materials_info = _get_materials_map_for_actors(actors_to_process)
actors_meshes_info = _get_meshes_map_for_actors(actors_to_process)
actors_skel_meshes_info = _get_skeletal_meshes_map_for_actors(actors_to_process)
unchanged_components_info = unchanged_components_info if 'unchanged_components_info' in locals() else []
return json.dumps({
"success": False,
"message": f"Failed to replace mesh. Target mesh '{mesh_to_be_replaced_path}' not found or not replaced on any static mesh components of specified actors.",
"current_materials": actors_materials_info,
"current_meshes": actors_meshes_info,
"current_skeletal_meshes": actors_skel_meshes_info,
"unchanged_components": unchanged_components_info
})
except FileNotFoundError as e:
unreal.log_error(f"MCP: Asset loading error in ue_replace_mesh_on_specified: {e}")
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
except TypeError as e:
unreal.log_error(f"MCP: Asset type error in ue_replace_mesh_on_specified: {e}")
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
except Exception as e:
unreal.log_error(f"MCP: Error in ue_replace_mesh_on_specified: {e}\n{traceback.format_exc()}")
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_replace_selected_with_bp(blueprint_asset_path: str) -> str:
"""Replaces the currently selected actors with new actors spawned from a specified Blueprint asset path using Unreal's official API."""
import unreal
import json
import traceback
try:
selected_actors = unreal.EditorLevelLibrary.get_selected_level_actors()
if not selected_actors:
return json.dumps({"success": False, "message": "No actors selected."})
# Check if the blueprint asset exists
blueprint = unreal.EditorAssetLibrary.load_asset(blueprint_asset_path)
if not blueprint:
return json.dumps({"success": False, "message": f"Blueprint asset not found at path: {blueprint_asset_path}"})
# Use the official API
unreal.EditorLevelLibrary.replace_selected_actors(blueprint_asset_path)
return json.dumps({
"success": True,
"message": f"Replaced {len(selected_actors)} actors with Blueprint '{blueprint_asset_path}' using official API.",
"replaced_actors_count": len(selected_actors)
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- Actor merging / proxy geometry --------------------------------------------
def _actors_by_labels(labels):
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
by_label = {a.get_actor_label(): a for a in sub.get_all_level_actors()}
found, missing = [], []
for l in labels:
(found.append(by_label[l]) if l in by_label else missing.append(l))
return found, missing
def _merged_mesh_path(actor):
try:
mesh = actor.static_mesh_component.static_mesh
return mesh.get_path_name().split(".")[0] if mesh else None
except Exception:
return None
def ue_merge_actors(actor_labels: list = None, base_package_name: str = None,
destroy_source_actors: bool = False) -> str:
"""Merges static mesh actors into ONE new static mesh asset + actor (geometry is baked together)."""
if not actor_labels or base_package_name is None:
return json.dumps({"success": False, "message": "Required: actor_labels (non-empty), base_package_name."})
try:
actors, missing = _actors_by_labels(actor_labels)
if missing:
return json.dumps({"success": False, "message": f"Actors not found: {missing}"})
sms = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem)
opts = unreal.EditorScriptingMergeStaticMeshActorsOptions()
opts.base_package_name = base_package_name
opts.destroy_source_actors = bool(destroy_source_actors)
merged = sms.merge_static_mesh_actors(actors, opts)
if not merged:
return json.dumps({"success": False, "message": "merge_static_mesh_actors returned None (are these StaticMesh actors?)."})
return json.dumps({"success": True, "merged_actor": merged.get_actor_label(),
"mesh_asset": _merged_mesh_path(merged),
"source_destroyed": bool(destroy_source_actors)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_join_actors(actor_labels: list = None, new_actor_label: str = "") -> str:
"""Joins static mesh actors into one actor with multiple components (no new mesh asset is baked)."""
if not actor_labels:
return json.dumps({"success": False, "message": "Required parameter 'actor_labels' is missing."})
try:
actors, missing = _actors_by_labels(actor_labels)
if missing:
return json.dumps({"success": False, "message": f"Actors not found: {missing}"})
sms = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem)
opts = unreal.EditorScriptingJoinStaticMeshActorsOptions()
if new_actor_label:
opts.new_actor_label = new_actor_label
joined = sms.join_static_mesh_actors(actors, opts)
if not joined:
return json.dumps({"success": False, "message": "join_static_mesh_actors returned None."})
return json.dumps({"success": True, "joined_actor": joined.get_actor_label()})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_create_proxy_actor(actor_labels: list = None, base_package_name: str = None,
screen_size: int = 300, destroy_source_actors: bool = False) -> str:
"""Bakes static mesh actors into ONE simplified proxy mesh (Proxy Geometry tool) and spawns it."""
if not actor_labels or base_package_name is None:
return json.dumps({"success": False, "message": "Required: actor_labels (non-empty), base_package_name."})
try:
actors, missing = _actors_by_labels(actor_labels)
if missing:
return json.dumps({"success": False, "message": f"Actors not found: {missing}"})
sms = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem)
opts = unreal.EditorScriptingCreateProxyMeshActorOptions()
opts.base_package_name = base_package_name
opts.destroy_source_actors = bool(destroy_source_actors)
settings = opts.mesh_proxy_settings
settings.set_editor_property("screen_size", int(screen_size))
opts.mesh_proxy_settings = settings
proxy = sms.create_proxy_mesh_actor(actors, opts)
if not proxy:
return json.dumps({"success": False, "message": "create_proxy_mesh_actor returned None (ProxyLOD plugin enabled? StaticMesh actors?)."})
return json.dumps({"success": True, "proxy_actor": proxy.get_actor_label(),
"mesh_asset": _merged_mesh_path(proxy),
"source_destroyed": bool(destroy_source_actors)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# ─── Asset editor control ─────────────────────────────────────────────────────
def ue_open_editor_for_asset(asset_path: str = None) -> str:
"""Opens the asset-specific editor (Blueprint, Material, etc.) for an asset."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
if not asset:
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
unreal.get_editor_subsystem(unreal.AssetEditorSubsystem).open_editor_for_assets([asset])
return json.dumps({"success": True, "asset_path": asset_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_open_assets() -> str:
"""Lists assets that currently have an editor open."""
try:
assets = unreal.MCPythonHelper.get_all_edited_assets()
open_assets = [{"asset_name": a.get_name(), "asset_path": a.get_path_name(),
"asset_class": a.get_class().get_name()} for a in assets if a]
return json.dumps({"success": True, "count": len(open_assets), "open_assets": open_assets})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_close_asset_editor(asset_path: str = None) -> str:
"""Closes any open editor for the given asset."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
if not asset:
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
unreal.get_editor_subsystem(unreal.AssetEditorSubsystem).close_all_editors_for_asset(asset)
return json.dumps({"success": True, "asset_path": asset_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,297 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
import unreal
import json
import traceback
def _split_asset_path(asset_path):
"""Split '/Game/Input/IA_Jump' into ('/Game/Input', 'IA_Jump')."""
parts = asset_path.rsplit('/', 1)
if len(parts) == 2:
return parts[0], parts[1]
return '/Game', asset_path
def ue_set_game_mode(game_mode_class_path: str = None) -> str:
"""Sets the GameMode Override on the current level's World Settings."""
try:
world = unreal.EditorLevelLibrary.get_editor_world()
if world is None:
return json.dumps({"success": False, "message": "No editor world available."})
world_settings = world.get_world_settings()
if world_settings is None:
return json.dumps({"success": False, "message": "Could not get WorldSettings."})
GAME_MODE_PROPS = ['default_game_mode', 'game_mode_override', 'GameModeOverride']
# Clear override if path is None or empty
if not game_mode_class_path:
set_ok = False
for prop_name in GAME_MODE_PROPS:
try:
world_settings.set_editor_property(prop_name, None)
set_ok = True
break
except Exception:
pass
if not set_ok:
return json.dumps({
"success": False,
"message": "Failed to clear GameMode. Property name may differ in this UE version."
})
return json.dumps({
"success": True,
"message": "GameMode cleared.",
"game_mode": None
})
# Load the class
loaded_class = unreal.load_class(None, game_mode_class_path)
if loaded_class is None:
return json.dumps({
"success": False,
"message": f"Could not load class: {game_mode_class_path}. "
"Ensure the path is correct (Blueprint paths need '_C' suffix)."
})
# Set the GameMode
set_ok = False
used_prop = None
for prop_name in GAME_MODE_PROPS:
try:
world_settings.set_editor_property(prop_name, loaded_class)
set_ok = True
used_prop = prop_name
break
except Exception:
pass
if not set_ok:
return json.dumps({
"success": False,
"message": "Failed to set GameMode. Property name may differ in this UE version."
})
# Verify
verified_name = None
try:
current = world_settings.get_editor_property(used_prop)
if current:
verified_name = str(current.get_name())
except Exception:
pass
return json.dumps({
"success": True,
"message": f"GameMode set to '{game_mode_class_path}'.",
"game_mode": game_mode_class_path,
"verified_class_name": verified_name
})
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_set_game_mode: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
def ue_add_input_action(asset_path: str = None, value_type: str = "Bool") -> str:
"""Creates a new Enhanced Input Action asset."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset already exists at '{asset_path}'."})
package_path, asset_name = _split_asset_path(asset_path)
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
# Try to create InputAction asset
ia = None
# Attempt 1: With factory
try:
factory = unreal.InputActionFactory()
ia = asset_tools.create_asset(asset_name, package_path, unreal.InputAction, factory)
except Exception:
pass
# Attempt 2: Without factory
if ia is None:
try:
ia = asset_tools.create_asset(asset_name, package_path, unreal.InputAction, None)
except Exception:
pass
if ia is None:
return json.dumps({
"success": False,
"message": f"Failed to create InputAction at '{asset_path}'. "
"Enhanced Input plugin may not be enabled or InputAction "
"class may not be accessible via Python."
})
# Set value type if not default Bool
if value_type and value_type != "Bool":
type_set = False
# Try multiple approaches to set value type
for prop_name in ['value_type', 'ValueType']:
for enum_name in [value_type.upper(), value_type, value_type.lower()]:
try:
enum_val = getattr(unreal.InputActionValueType, enum_name, None)
if enum_val is not None:
ia.set_editor_property(prop_name, enum_val)
type_set = True
break
except Exception:
pass
if type_set:
break
if not type_set:
unreal.log_warning(
f"InputAction created but could not set value_type to '{value_type}'. "
"Defaulting to Bool."
)
unreal.EditorAssetLibrary.save_asset(ia.get_path_name())
return json.dumps({
"success": True,
"asset_path": ia.get_path_name(),
"value_type": value_type,
"message": f"InputAction created at '{ia.get_path_name()}'."
})
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_add_input_action: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
def ue_add_input_mapping(mapping_context_path: str = None,
action_path: str = None,
key_name: str = None) -> str:
"""Creates/updates an InputMappingContext with a key-to-action mapping."""
if mapping_context_path is None:
return json.dumps({"success": False, "message": "Required parameter 'mapping_context_path' is missing."})
if action_path is None:
return json.dumps({"success": False, "message": "Required parameter 'action_path' is missing."})
if key_name is None:
return json.dumps({"success": False, "message": "Required parameter 'key_name' is missing."})
try:
# Load or create the InputMappingContext
imc = None
created_imc = False
if unreal.EditorAssetLibrary.does_asset_exist(mapping_context_path):
imc = unreal.EditorAssetLibrary.load_asset(mapping_context_path)
if imc is None:
return json.dumps({
"success": False,
"message": f"Failed to load asset at '{mapping_context_path}'."
})
else:
# Create new IMC
package_path, asset_name = _split_asset_path(mapping_context_path)
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
try:
factory = unreal.InputMappingContextFactory()
imc = asset_tools.create_asset(asset_name, package_path, unreal.InputMappingContext, factory)
except Exception:
pass
if imc is None:
try:
imc = asset_tools.create_asset(asset_name, package_path, unreal.InputMappingContext, None)
except Exception:
pass
if imc is None:
return json.dumps({
"success": False,
"message": f"Failed to create InputMappingContext at '{mapping_context_path}'. "
"Enhanced Input plugin may not be enabled."
})
created_imc = True
# Load the InputAction
ia = unreal.EditorAssetLibrary.load_asset(action_path)
if ia is None:
return json.dumps({
"success": False,
"message": f"InputAction not found at '{action_path}'."
})
# Create Key object (UE 5.7+: Key() takes no args, set key_name via property)
key = unreal.Key()
key.set_editor_property('key_name', key_name)
mapping_added = False
# Construct EnhancedActionKeyMapping and append to mappings array
try:
mapping = unreal.EnhancedActionKeyMapping()
for prop_name in ['action', 'Action']:
try:
mapping.set_editor_property(prop_name, ia)
break
except Exception:
pass
for prop_name in ['key', 'Key']:
try:
mapping.set_editor_property(prop_name, key)
break
except Exception:
pass
# Try DefaultKeyMappings first (UE 5.7+), then mappings as fallback
for mp in ['default_key_mappings', 'DefaultKeyMappings', 'mappings', 'Mappings']:
try:
mappings = list(imc.get_editor_property(mp) or [])
mappings.append(mapping)
imc.set_editor_property(mp, mappings)
mapping_added = True
break
except Exception:
pass
except Exception:
pass
if not mapping_added:
if created_imc:
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
return json.dumps({
"success": False,
"message": f"InputMappingContext created at '{imc.get_path_name()}' "
f"but failed to add key mapping '{key_name}' -> '{action_path}'. "
f"The Enhanced Input mapping API may not be fully exposed in Python. "
f"Use execute_python tool to explore available methods on the IMC object.",
"imc_created": True,
"imc_path": imc.get_path_name()
})
return json.dumps({
"success": False,
"message": f"Failed to add key mapping '{key_name}' -> '{action_path}' "
f"to '{mapping_context_path}'. "
"The Enhanced Input mapping API may not be fully exposed in Python."
})
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
return json.dumps({
"success": True,
"mapping_context_path": imc.get_path_name(),
"action_path": action_path,
"key_name": key_name,
"imc_created": created_imc,
"message": f"Mapped '{key_name}' -> '{action_path}' in '{imc.get_path_name()}'."
})
except Exception as e:
tb_str = traceback.format_exc()
unreal.log_error(f"Error in ue_add_input_mapping: {str(e)}\n{tb_str}")
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})

View File

@@ -0,0 +1,410 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
Gameplay Ability System (GAS) authoring: GameplayAbility / GameplayEffect
blueprints, effect modifiers, ability tags & costs, and GameplayTags config.
All actions require the GameplayAbilities plugin (soft runtime dependency —
guarded per action). Property-level notes that shape the implementation:
- GameplayModifierInfo.attribute is edit-defaults-only, so modifiers are
assembled through struct import_text (which bypasses per-property edit
checks) and assigned to the CDO's `modifiers` array wholesale.
- GameplayTag/GameplayTagContainer import_text silently drops tags that are
not registered in the project's tag table, so tag setters report which tags
actually resolved.
- GameplayTagsManager is not exposed to Python; tag registration goes through
Config/DefaultGameplayTags.ini and needs an editor restart to take effect.
"""
import unreal
import json
import os
import re
import traceback
_OP_MAP = {
"add_base": "AddBase",
"add_final": "AddFinal",
"multiply_additive": "MultiplyAdditive",
"multiply_compound": "MultiplyCompound",
"divide_additive": "DivideAdditive",
"override": "Override",
}
_DURATION_MAP = {
"instant": "INSTANT",
"has_duration": "HAS_DURATION",
"infinite": "INFINITE",
}
def _plugin_missing():
if not hasattr(unreal, "GameplayAbility"):
return json.dumps({"success": False,
"message": "Requires the GameplayAbilities plugin. Enable it in Edit > Plugins and restart."})
return None
def _split_asset_path(asset_path: str):
asset_path = asset_path.rstrip("/")
idx = asset_path.rfind("/")
return asset_path[idx + 1:], asset_path[:idx]
def _load_cdo(asset_path: str, expected_class, label: str):
bp = unreal.EditorAssetLibrary.load_asset(asset_path)
if not bp:
raise FileNotFoundError(f"Blueprint not found at path: {asset_path}")
if not isinstance(bp, unreal.Blueprint):
raise TypeError(f"Asset at {asset_path} is not a Blueprint, but {type(bp).__name__}")
gen = bp.generated_class()
cdo = unreal.get_default_object(gen)
if not isinstance(cdo, expected_class):
raise TypeError(f"Blueprint at {asset_path} is not a {label} (CDO is {type(cdo).__name__}).")
return bp, cdo
def _create_gas_blueprint(asset_path: str, parent_class):
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
raise FileExistsError(f"Asset already exists: {asset_path}")
name, package = _split_asset_path(asset_path)
factory = unreal.BlueprintFactory()
factory.set_editor_property("parent_class", parent_class)
bp = unreal.AssetToolsHelpers.get_asset_tools().create_asset(name, package, unreal.Blueprint, factory)
if not bp:
raise RuntimeError(f"Failed to create blueprint at {asset_path}.")
return bp
def _tag_container_from(tags):
"""Build a GameplayTagContainer from tag name strings.
Returns (container, resolved, unresolved) — unregistered tags do not import."""
container = unreal.GameplayTagContainer()
resolved, unresolved = [], []
for name in tags:
tag = unreal.GameplayTag()
tag.import_text(str(name))
# Unregistered tags import with TagName 'None' (the literal string).
imported = str(tag.get_editor_property("tag_name"))
if imported and imported != "None":
resolved.append(str(name))
else:
unresolved.append(str(name))
if resolved:
inner = ",".join(f'(TagName="{t}")' for t in resolved)
container.import_text(f"(GameplayTags=({inner}))")
return container, resolved, unresolved
def _tags_ini_path():
return os.path.join(unreal.Paths.project_config_dir(), "DefaultGameplayTags.ini")
# --- GameplayAbility ------------------------------------------------------------
def ue_create_ability_blueprint(asset_path: str = None, parent_class_path: str = None) -> str:
"""Creates a GameplayAbility blueprint; parent_class_path may point at a custom GA subclass (requires the GameplayAbilities plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
parent = unreal.GameplayAbility
if parent_class_path:
parent = unreal.load_class(None, parent_class_path)
if not parent:
return json.dumps({"success": False, "message": f"Could not load class: {parent_class_path}"})
bp = _create_gas_blueprint(asset_path, parent)
cdo = unreal.get_default_object(bp.generated_class())
if not isinstance(cdo, unreal.GameplayAbility):
unreal.EditorAssetLibrary.delete_asset(asset_path)
return json.dumps({"success": False, "message": f"{parent_class_path} is not a GameplayAbility subclass."})
unreal.EditorAssetLibrary.save_loaded_asset(bp)
parent_path = (parent.get_path_name() if isinstance(parent, unreal.Class)
else parent.static_class().get_path_name())
return json.dumps({"success": True, "asset_path": asset_path,
"parent_class": parent_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_ability_info(asset_path: str = None) -> str:
"""Returns parent class, ability tags, and cost/cooldown effect classes of a GameplayAbility blueprint (requires the GameplayAbilities plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
bp, cdo = _load_cdo(asset_path, unreal.GameplayAbility, "GameplayAbility")
tags = re.findall(r'TagName="([^"]+)"', cdo.get_editor_property("ability_tags").export_text())
cost = cdo.get_editor_property("cost_gameplay_effect_class")
cooldown = cdo.get_editor_property("cooldown_gameplay_effect_class")
return json.dumps({
"success": True,
"asset_path": asset_path,
"parent_class": bp.generated_class().get_path_name(),
"ability_tags": tags,
"cost_effect": cost.get_path_name() if cost else None,
"cooldown_effect": cooldown.get_path_name() if cooldown else None,
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_ability_tags(asset_path: str = None, tags: list = None) -> str:
"""Sets the AbilityTags container on a GameplayAbility; unregistered tags are reported, not silently dropped (requires the GameplayAbilities plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None or tags is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, tags (list of tag names)."})
try:
bp, cdo = _load_cdo(asset_path, unreal.GameplayAbility, "GameplayAbility")
container, resolved, unresolved = _tag_container_from(tags)
cdo.set_editor_property("ability_tags", container)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
result = {"success": True, "asset_path": asset_path,
"resolved_tags": resolved, "unresolved_tags": unresolved}
if unresolved:
result["message"] = ("Some tags are not registered in the project tag table "
"(add them via add_gameplay_tag and restart the editor).")
return json.dumps(result)
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_ability_costs(asset_path: str = None, cost_effect_path: str = None,
cooldown_effect_path: str = None) -> str:
"""Wires cost and/or cooldown GameplayEffect blueprints onto a GameplayAbility (requires the GameplayAbilities plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None or (cost_effect_path is None and cooldown_effect_path is None):
return json.dumps({"success": False,
"message": "Required: asset_path and at least one of cost_effect_path / cooldown_effect_path."})
try:
bp, cdo = _load_cdo(asset_path, unreal.GameplayAbility, "GameplayAbility")
applied = {}
for prop, path in (("cost_gameplay_effect_class", cost_effect_path),
("cooldown_gameplay_effect_class", cooldown_effect_path)):
if path:
_, effect_cdo = _load_cdo(path, unreal.GameplayEffect, "GameplayEffect")
cdo.set_editor_property(prop, effect_cdo.get_class())
applied[prop] = path
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return json.dumps({"success": True, "asset_path": asset_path, "applied": applied})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- GameplayEffect -------------------------------------------------------------
def ue_create_effect_blueprint(asset_path: str = None, duration_policy: str = "instant",
duration_seconds: float = None) -> str:
"""Creates a GameplayEffect blueprint with a duration policy: instant, has_duration (+seconds), or infinite (requires the GameplayAbilities plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
key = (duration_policy or "instant").lower()
if key not in _DURATION_MAP:
return json.dumps({"success": False, "message": f"Unknown duration_policy '{duration_policy}'.",
"valid_policies": list(_DURATION_MAP)})
try:
bp = _create_gas_blueprint(asset_path, unreal.GameplayEffect)
cdo = unreal.get_default_object(bp.generated_class())
cdo.set_editor_property("duration_policy",
getattr(unreal.GameplayEffectDurationType, _DURATION_MAP[key]))
if key == "has_duration" and duration_seconds is not None:
mm = unreal.GameplayEffectModifierMagnitude()
mm.import_text(f"(MagnitudeCalculationType=ScalableFloat,"
f"ScalableFloatMagnitude=(Value={float(duration_seconds)}))")
cdo.set_editor_property("duration_magnitude", mm)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return json.dumps({"success": True, "asset_path": asset_path,
"duration_policy": key, "duration_seconds": duration_seconds})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_effect_info(asset_path: str = None) -> str:
"""Returns duration policy/seconds and decoded modifiers of a GameplayEffect blueprint (requires the GameplayAbilities plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
bp, cdo = _load_cdo(asset_path, unreal.GameplayEffect, "GameplayEffect")
policy = cdo.get_editor_property("duration_policy")
policy_name = getattr(policy, "name", str(policy)).lower()
duration = None
if policy_name == "has_duration":
m = re.search(r"Value=([\d.]+)", cdo.get_editor_property("duration_magnitude").export_text())
duration = float(m.group(1)) if m else None
rev_op = {v: k for k, v in _OP_MAP.items()}
modifiers = []
for mod in cdo.get_editor_property("modifiers"):
text = mod.export_text()
attr = re.search(r'AttributeName="([^"]+)"', text)
attr_set = re.search(r"Attribute=([^,)]+):", text)
op = re.search(r"ModifierOp=(\w+)", text)
mag = re.search(r"ScalableFloatMagnitude=\(Value=([\d.-]+)", text)
modifiers.append({
"attribute": attr.group(1) if attr else None,
"attribute_set": attr_set.group(1) if attr_set else None,
"op": rev_op.get(op.group(1), op.group(1)) if op else "add_base",
"magnitude": float(mag.group(1)) if mag else None,
})
return json.dumps({
"success": True,
"asset_path": asset_path,
"duration_policy": policy_name,
"duration_seconds": duration,
"modifiers": modifiers,
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_effect_duration(asset_path: str = None, duration_policy: str = None,
duration_seconds: float = None) -> str:
"""Changes a GameplayEffect's duration policy (instant / has_duration+seconds / infinite) (requires the GameplayAbilities plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None or duration_policy is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, duration_policy."})
key = duration_policy.lower()
if key not in _DURATION_MAP:
return json.dumps({"success": False, "message": f"Unknown duration_policy '{duration_policy}'.",
"valid_policies": list(_DURATION_MAP)})
try:
bp, cdo = _load_cdo(asset_path, unreal.GameplayEffect, "GameplayEffect")
cdo.set_editor_property("duration_policy",
getattr(unreal.GameplayEffectDurationType, _DURATION_MAP[key]))
if key == "has_duration" and duration_seconds is not None:
mm = unreal.GameplayEffectModifierMagnitude()
mm.import_text(f"(MagnitudeCalculationType=ScalableFloat,"
f"ScalableFloatMagnitude=(Value={float(duration_seconds)}))")
cdo.set_editor_property("duration_magnitude", mm)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return json.dumps({"success": True, "asset_path": asset_path,
"duration_policy": key, "duration_seconds": duration_seconds})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_effect_modifier(asset_path: str = None, attribute_set_path: str = None,
attribute_name: str = None, op: str = "add_base",
magnitude: float = 1.0) -> str:
"""Appends an attribute modifier (e.g. Health add_base +25) to a GameplayEffect. attribute_set_path is the AttributeSet class path (requires the GameplayAbilities plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None or attribute_set_path is None or attribute_name is None:
return json.dumps({"success": False,
"message": "Required: asset_path, attribute_set_path, attribute_name."})
op_key = (op or "add_base").lower()
if op_key not in _OP_MAP:
return json.dumps({"success": False, "message": f"Unknown op '{op}'.", "valid_ops": list(_OP_MAP)})
try:
attr_set_class = unreal.load_class(None, attribute_set_path)
if not attr_set_class:
return json.dumps({"success": False, "message": f"AttributeSet class not found: {attribute_set_path}"})
try:
unreal.get_default_object(attr_set_class).get_editor_property(attribute_name)
except Exception:
return json.dumps({"success": False,
"message": f"Attribute '{attribute_name}' not found on {attribute_set_path}."})
bp, cdo = _load_cdo(asset_path, unreal.GameplayEffect, "GameplayEffect")
# GameplayModifierInfo.attribute is edit-defaults-only, so the whole
# struct is assembled via import_text instead of per-property setters.
mod = unreal.GameplayModifierInfo()
mod.import_text(
f'(Attribute=(AttributeName="{attribute_name}",'
f'Attribute={attribute_set_path}:{attribute_name}),'
f'ModifierOp={_OP_MAP[op_key]},'
f'ModifierMagnitude=(MagnitudeCalculationType=ScalableFloat,'
f'ScalableFloatMagnitude=(Value={float(magnitude)})))')
mods = list(cdo.get_editor_property("modifiers"))
mods.append(mod)
cdo.set_editor_property("modifiers", mods)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return json.dumps({"success": True, "asset_path": asset_path,
"attribute": attribute_name, "op": op_key,
"magnitude": float(magnitude), "modifier_count": len(mods)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_clear_effect_modifiers(asset_path: str = None) -> str:
"""Removes all modifiers from a GameplayEffect blueprint (requires the GameplayAbilities plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
bp, cdo = _load_cdo(asset_path, unreal.GameplayEffect, "GameplayEffect")
cdo.set_editor_property("modifiers", [])
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return json.dumps({"success": True, "asset_path": asset_path, "modifier_count": 0})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- GameplayTags ---------------------------------------------------------------
def ue_list_gameplay_tags(prefix: str = "") -> str:
"""Lists gameplay tags registered in Config/DefaultGameplayTags.ini, optionally filtered by prefix (requires the GameplayAbilities plugin)."""
guard = _plugin_missing()
if guard:
return guard
try:
ini = _tags_ini_path()
tags = []
if os.path.isfile(ini):
with open(ini, "r", encoding="utf-8") as f:
tags = re.findall(r'\+GameplayTagList=\(Tag="([^"]+)"', f.read())
if prefix:
tags = [t for t in tags if t.startswith(prefix)]
return json.dumps({"success": True, "count": len(tags), "tags": sorted(tags),
"ini_path": ini})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_gameplay_tag(tag: str = None, comment: str = "") -> str:
"""Registers a gameplay tag in Config/DefaultGameplayTags.ini — takes effect after an editor restart (requires the GameplayAbilities plugin)."""
guard = _plugin_missing()
if guard:
return guard
if not tag:
return json.dumps({"success": False, "message": "Required parameter 'tag' is missing."})
if not re.fullmatch(r"[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*", tag):
return json.dumps({"success": False,
"message": f"Invalid tag '{tag}' (use Dot.Separated.Alphanumerics)."})
try:
ini = _tags_ini_path()
section = "[/Script/GameplayTags.GameplayTagsSettings]"
entry = f'+GameplayTagList=(Tag="{tag}",DevComment="{comment}")'
content = ""
if os.path.isfile(ini):
with open(ini, "r", encoding="utf-8") as f:
content = f.read()
if f'Tag="{tag}"' in content:
return json.dumps({"success": False, "message": f"Tag '{tag}' is already registered."})
if section in content:
content = content.replace(section, f"{section}\n{entry}", 1)
else:
content = (content.rstrip() + f"\n\n{section}\n{entry}\n") if content.strip() else f"{section}\n{entry}\n"
with open(ini, "w", encoding="utf-8") as f:
f.write(content)
return json.dumps({"success": True, "tag": tag, "ini_path": ini,
"message": "Tag registered in config. Restart the editor for it to become usable."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,93 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""Python action functions for editor Layers (LayersSubsystem)."""
import unreal
import json
import traceback
def _layers():
return unreal.get_editor_subsystem(unreal.LayersSubsystem)
def _actor_by_label(actor_label: str):
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
for a in sub.get_all_level_actors():
if a.get_actor_label() == actor_label:
return a
return None
def ue_list_layers() -> str:
"""Lists all layer names in the current world."""
try:
# add_all_layer_names_to() takes no args and returns the Array[Name].
names = _layers().add_all_layer_names_to()
return json.dumps({"success": True, "layers": [str(n) for n in names]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_create_layer(layer_name: str = None) -> str:
"""Creates a new (empty) layer."""
if layer_name is None:
return json.dumps({"success": False, "message": "Required parameter 'layer_name' is missing."})
try:
_layers().create_layer(unreal.Name(layer_name))
return json.dumps({"success": True, "layer_name": layer_name})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_delete_layer(layer_name: str = None) -> str:
"""Deletes a layer."""
if layer_name is None:
return json.dumps({"success": False, "message": "Required parameter 'layer_name' is missing."})
try:
ls = _layers()
if not ls.is_layer(unreal.Name(layer_name)):
return json.dumps({"success": False, "message": f"Layer '{layer_name}' does not exist."})
ls.delete_layer(unreal.Name(layer_name))
return json.dumps({"success": True, "deleted": layer_name})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_actor_to_layer(actor_label: str = None, layer_name: str = None) -> str:
"""Adds an actor to a layer (creates the layer if needed)."""
if actor_label is None or layer_name is None:
return json.dumps({"success": False, "message": "Required parameters: actor_label, layer_name."})
try:
actor = _actor_by_label(actor_label)
if not actor:
return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"})
ok = _layers().add_actor_to_layer(actor, unreal.Name(layer_name))
return json.dumps({"success": bool(ok), "actor_label": actor_label, "layer_name": layer_name})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_remove_actor_from_layer(actor_label: str = None, layer_name: str = None) -> str:
"""Removes an actor from a layer."""
if actor_label is None or layer_name is None:
return json.dumps({"success": False, "message": "Required parameters: actor_label, layer_name."})
try:
actor = _actor_by_label(actor_label)
if not actor:
return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"})
ok = _layers().remove_actor_from_layer(actor, unreal.Name(layer_name))
return json.dumps({"success": bool(ok), "actor_label": actor_label, "layer_name": layer_name})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_actors_in_layer(layer_name: str = None) -> str:
"""Lists the labels of actors assigned to a layer."""
if layer_name is None:
return json.dumps({"success": False, "message": "Required parameter 'layer_name' is missing."})
try:
actors = _layers().get_actors_from_layer(unreal.Name(layer_name))
return json.dumps({"success": True, "layer_name": layer_name,
"actors": [a.get_actor_label() for a in actors]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,144 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
import unreal
import json
import traceback
def ue_create_level(level_path: str = None) -> str:
"""Creates a new empty level and saves it at the given content-browser path."""
if not level_path:
return json.dumps({"success": False, "message": "Required parameter 'level_path' is missing."})
try:
success = unreal.EditorLevelLibrary.new_level(level_path)
if not success:
return json.dumps({"success": False, "message": f"EditorLevelLibrary.new_level() returned False for '{level_path}'."})
return json.dumps({"success": True, "level_path": level_path, "message": f"Level created at '{level_path}'."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_load_level(level_path: str = None) -> str:
"""Opens (loads) an existing level in the editor."""
if not level_path:
return json.dumps({"success": False, "message": "Required parameter 'level_path' is missing."})
try:
success = unreal.EditorLevelLibrary.load_level(level_path)
if not success:
return json.dumps({"success": False, "message": f"EditorLevelLibrary.load_level() returned False for '{level_path}'."})
return json.dumps({"success": True, "level_path": level_path, "message": f"Level '{level_path}' loaded."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_level_actors(class_filter: str = None) -> str:
"""
Lists all actors in the current level.
Optional class_filter is a partial class name (e.g. 'StaticMeshActor', 'BP_Bird').
"""
try:
actors = unreal.EditorLevelLibrary.get_all_level_actors()
result = []
for actor in actors:
class_name = actor.get_class().get_name()
if class_filter and class_filter.lower() not in class_name.lower():
continue
result.append({
"name": actor.get_name(),
"label": actor.get_actor_label(),
"class": class_name,
"location": {
"x": actor.get_actor_location().x,
"y": actor.get_actor_location().y,
"z": actor.get_actor_location().z,
},
"path": actor.get_path_name(),
})
return json.dumps({"success": True, "actors": result, "count": len(result)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_world_settings(gravity: float = None, time_dilation: float = None) -> str:
"""
Modifies WorldSettings of the current level.
gravity sets GlobalGravityZ (negative = downward, e.g. -980).
time_dilation sets the global time dilation multiplier.
"""
try:
world = unreal.EditorLevelLibrary.get_editor_world()
if world is None:
return json.dumps({"success": False, "message": "No editor world available."})
ws = world.get_world_settings()
if ws is None:
return json.dumps({"success": False, "message": "Could not get WorldSettings."})
applied = {}
if gravity is not None:
for prop in ['global_gravity_z', 'GlobalGravityZ']:
try:
# Must also enable custom gravity
for flag in ['global_gravity', 'bGlobalGravity', 'override_world_gravity', 'bOverrideWorldGravity']:
try:
ws.set_editor_property(flag, True)
except Exception:
pass
ws.set_editor_property(prop, gravity)
applied['gravity'] = gravity
break
except Exception:
pass
if time_dilation is not None:
for prop in ['world_to_meters', 'TimeDilation', 'time_dilation']:
try:
ws.set_editor_property(prop, time_dilation)
applied['time_dilation'] = time_dilation
break
except Exception:
pass
if not applied:
return json.dumps({"success": False, "message": "No settings were applied. Check property names for this UE version."})
unreal.EditorLevelLibrary.save_current_level()
return json.dumps({"success": True, "applied": applied, "message": "World settings updated."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- Current level info / saving ----------------------------------------------
def ue_get_current_level_path() -> str:
"""Returns the path of the currently open editor world/level."""
try:
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
if not world:
return json.dumps({"success": False, "message": "No editor world is open."})
return json.dumps({"success": True, "level_path": world.get_path_name(),
"level_name": world.get_name()})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_save_current_level() -> str:
"""Saves the currently open level. Returns success=False for an unsaved/untitled level."""
try:
ok = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).save_current_level()
return json.dumps({"success": bool(ok),
"message": "Saved current level." if ok else "save_current_level returned False (untitled or unsaved level)."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_save_all_levels() -> str:
"""Saves all dirty levels."""
try:
ok = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).save_all_dirty_levels()
return json.dumps({"success": bool(ok),
"message": "Saved all dirty levels." if ok else "save_all_dirty_levels returned False (nothing to save)."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,399 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
Python action functions for Level Sequence (cinematic) editing in Unreal Engine.
Bindings are referenced by name (the World Outliner / display name). Several
LevelSequence methods are deprecated in favor of the Level Sequence Editor
Subsystem, but the direct methods work headlessly and return properly-named
bindings, whereas the subsystem variants require a focused editor sequence and
return unnamed bindings. We use the direct methods and suppress the Python
DeprecationWarning so it never corrupts the JSON response.
"""
import unreal
import json
import traceback
import warnings
import contextlib
@contextlib.contextmanager
def _suppress():
"""Suppress the DeprecationWarning that some LevelSequence methods emit
(it would otherwise corrupt the JSON response on the wire)."""
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
yield
def _load_sequence(asset_path: str):
if not asset_path:
raise ValueError("Sequence path cannot be empty.")
seq = unreal.EditorAssetLibrary.load_asset(asset_path)
if not seq:
raise FileNotFoundError(f"Level Sequence not found at path: {asset_path}")
if not isinstance(seq, unreal.LevelSequence):
raise TypeError(f"Asset at {asset_path} is not a LevelSequence, but {type(seq).__name__}")
return seq
def _fps(seq) -> float:
rate = seq.get_display_rate()
return float(rate.numerator) / float(rate.denominator or 1)
def _find_binding(seq, binding_name: str):
binding = seq.find_binding_by_name(binding_name)
if not binding or not binding.is_valid():
names = [b.get_name() for b in seq.get_bindings()]
raise ValueError(f"Binding '{binding_name}' not found. Available: {names}")
return binding
def _spawnable_names(seq):
return {b.get_name() for b in seq.get_spawnables()}
def _split_asset_path(asset_path: str):
asset_path = asset_path.rstrip("/")
idx = asset_path.rfind("/")
return asset_path[idx + 1:], asset_path[:idx]
def _get_or_create_transform_section(seq, binding):
"""
Return the first MovieScene3DTransformTrack section on a binding, creating it
if needed. A freshly created section is zero-length, so ensure it spans at
least the sequence's playback range — otherwise keys land outside the section
and are invisible/unusable in Sequencer.
"""
section = None
for track in binding.get_tracks():
if isinstance(track, unreal.MovieScene3DTransformTrack):
sections = track.get_sections()
section = sections[0] if sections else track.add_section()
break
if section is None:
track = binding.add_track(unreal.MovieScene3DTransformTrack)
section = track.add_section()
start = seq.get_playback_start_seconds()
end = seq.get_playback_end_seconds()
if section.get_end_frame_seconds() - section.get_start_frame_seconds() < (end - start):
section.set_range_seconds(start, end)
return section
# --- Actions ------------------------------------------------------------------
def ue_create_level_sequence(asset_path: str = None, fps: float = 30.0, duration_seconds: float = 5.0) -> str:
"""Creates a Level Sequence asset with the given frame rate and playback duration."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
name, package = _split_asset_path(asset_path)
with _suppress():
tools = unreal.AssetToolsHelpers.get_asset_tools()
seq = tools.create_asset(name, package, unreal.LevelSequence, unreal.LevelSequenceFactoryNew())
if not seq:
return json.dumps({"success": False, "message": f"Failed to create level sequence at '{asset_path}'."})
seq.set_display_rate(unreal.FrameRate(int(fps), 1))
seq.set_playback_start_seconds(0.0)
seq.set_playback_end_seconds(float(duration_seconds))
unreal.EditorAssetLibrary.save_loaded_asset(seq)
return json.dumps({"success": True, "asset_path": asset_path, "fps": fps,
"duration_seconds": duration_seconds,
"message": f"Level Sequence created at '{asset_path}'."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_sequence_info(asset_path: str = None) -> str:
"""Returns frame rate, playback range (seconds), and the bindings of a Level Sequence."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
with _suppress():
seq = _load_sequence(asset_path)
spawnable = _spawnable_names(seq)
bindings = [{"name": b.get_name(),
"type": "spawnable" if b.get_name() in spawnable else "possessable",
"track_count": len(b.get_tracks())}
for b in seq.get_bindings()]
info = {
"success": True,
"asset_path": asset_path,
"fps": _fps(seq),
"playback_start_seconds": seq.get_playback_start_seconds(),
"playback_end_seconds": seq.get_playback_end_seconds(),
"bindings": bindings,
}
return json.dumps(info)
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_playback_range(asset_path: str = None, start_seconds: float = None, end_seconds: float = None) -> str:
"""Sets the playback range (in seconds) of a Level Sequence."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if start_seconds is None or end_seconds is None:
return json.dumps({"success": False, "message": "Required parameters 'start_seconds' and 'end_seconds' are missing."})
try:
with _suppress():
seq = _load_sequence(asset_path)
seq.set_playback_start_seconds(float(start_seconds))
seq.set_playback_end_seconds(float(end_seconds))
unreal.EditorAssetLibrary.save_loaded_asset(seq)
return json.dumps({"success": True, "asset_path": asset_path,
"playback_start_seconds": float(start_seconds),
"playback_end_seconds": float(end_seconds)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_spawnable_from_class(asset_path: str = None, class_path: str = None) -> str:
"""Adds a spawnable binding from a class path (e.g. '/Script/CinematicCamera.CineCameraActor'). Returns the binding name."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if class_path is None:
return json.dumps({"success": False, "message": "Required parameter 'class_path' is missing."})
try:
actor_class = unreal.load_class(None, class_path)
if not actor_class:
return json.dumps({"success": False, "message": f"Could not load class: {class_path}"})
with _suppress():
seq = _load_sequence(asset_path)
binding = seq.add_spawnable_from_class(actor_class)
if not binding or not binding.is_valid():
return json.dumps({"success": False, "message": f"Failed to add spawnable for {class_path}."})
binding_name = binding.get_name()
unreal.EditorAssetLibrary.save_loaded_asset(seq)
return json.dumps({"success": True, "asset_path": asset_path, "binding_name": binding_name,
"message": f"Added spawnable '{binding_name}'."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_possessable(asset_path: str = None, actor_label: str = None) -> str:
"""Adds a possessable binding for an existing level actor (by World Outliner label). Returns the binding name."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if actor_label is None:
return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."})
try:
es = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
actor = next((a for a in es.get_all_level_actors() if a.get_actor_label() == actor_label), None)
if not actor:
return json.dumps({"success": False, "message": f"Actor not found in level: '{actor_label}'."})
with _suppress():
seq = _load_sequence(asset_path)
binding = seq.add_possessable(actor)
if not binding or not binding.is_valid():
return json.dumps({"success": False, "message": f"Failed to add possessable for '{actor_label}'."})
binding_name = binding.get_name()
unreal.EditorAssetLibrary.save_loaded_asset(seq)
return json.dumps({"success": True, "asset_path": asset_path, "binding_name": binding_name,
"message": f"Added possessable '{binding_name}'."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_remove_binding(asset_path: str = None, binding_name: str = None) -> str:
"""Removes a binding (spawnable or possessable) from a Level Sequence by name."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if binding_name is None:
return json.dumps({"success": False, "message": "Required parameter 'binding_name' is missing."})
try:
with _suppress():
seq = _load_sequence(asset_path)
binding = _find_binding(seq, binding_name)
binding.remove()
unreal.EditorAssetLibrary.save_loaded_asset(seq)
remaining = [b.get_name() for b in seq.get_bindings()]
return json.dumps({"success": True, "asset_path": asset_path,
"removed": binding_name, "remaining_bindings": remaining})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_transform_track(asset_path: str = None, binding_name: str = None) -> str:
"""Adds a 3D transform track (with one section) to a binding."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if binding_name is None:
return json.dumps({"success": False, "message": "Required parameter 'binding_name' is missing."})
try:
with _suppress():
seq = _load_sequence(asset_path)
binding = _find_binding(seq, binding_name)
_get_or_create_transform_section(seq, binding)
unreal.EditorAssetLibrary.save_loaded_asset(seq)
return json.dumps({"success": True, "asset_path": asset_path, "binding_name": binding_name,
"message": f"Added transform track to '{binding_name}'."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_transform_keyframe(asset_path: str = None, binding_name: str = None, time_seconds: float = None,
location: list = None, rotation: list = None, scale: list = None) -> str:
"""
Adds a keyframe at time_seconds on a binding's transform track. Provide any of
location [x,y,z], rotation [pitch,yaw,roll], scale [x,y,z]. A transform track
is created if the binding doesn't have one.
"""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if binding_name is None:
return json.dumps({"success": False, "message": "Required parameter 'binding_name' is missing."})
if time_seconds is None:
return json.dumps({"success": False, "message": "Required parameter 'time_seconds' is missing."})
if location is None and rotation is None and scale is None:
return json.dumps({"success": False, "message": "Provide at least one of location / rotation / scale."})
try:
with _suppress():
seq = _load_sequence(asset_path)
binding = _find_binding(seq, binding_name)
section = _get_or_create_transform_section(seq, binding)
# Extend the section so the key is inside it (otherwise it's not visible).
if float(time_seconds) > section.get_end_frame_seconds():
section.set_range_seconds(section.get_start_frame_seconds(), float(time_seconds))
elif float(time_seconds) < section.get_start_frame_seconds():
section.set_range_seconds(float(time_seconds), section.get_end_frame_seconds())
frame = unreal.FrameNumber(int(round(float(time_seconds) * _fps(seq))))
# Channel order is fixed: 0-2 Location XYZ, 3-5 Rotation XYZ, 6-8 Scale XYZ.
chans = section.get_all_channels()
keyed = []
for offset, values, label in ((0, location, "location"), (3, rotation, "rotation"), (6, scale, "scale")):
if values is None:
continue
if len(values) != 3:
return json.dumps({"success": False, "message": f"{label} must be a list of 3 floats."})
for i in range(3):
chans[offset + i].add_key(frame, float(values[i]))
keyed.append(label)
section_range = [round(section.get_start_frame_seconds(), 4),
round(section.get_end_frame_seconds(), 4)]
unreal.EditorAssetLibrary.save_loaded_asset(seq)
return json.dumps({"success": True, "asset_path": asset_path, "binding_name": binding_name,
"frame": int(round(float(time_seconds) * _fps(seq))), "keyed": keyed,
"section_range_seconds": section_range})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- Sequencer editor (camera, anim tracks, open/close) ------------------------
def ue_open_in_sequencer(asset_path: str = None) -> str:
"""Opens a Level Sequence in the Sequencer editor (and focuses it)."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
with _suppress():
seq = _load_sequence(asset_path)
ok = unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(seq)
return json.dumps({"success": bool(ok), "asset_path": asset_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_close_sequencer() -> str:
"""Closes the Sequencer editor if one is open."""
try:
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
return json.dumps({"success": True})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_camera(asset_path: str = None, spawnable: bool = True) -> str:
"""Adds a CineCamera to a Level Sequence with a Camera Cut track bound to it (official create_camera path)."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
with _suppress():
seq = _load_sequence(asset_path)
lsb = unreal.LevelSequenceEditorBlueprintLibrary
if not lsb.open_level_sequence(seq):
return json.dumps({"success": False, "message": "Could not open the sequence in Sequencer (create_camera requires a focused sequence)."})
try:
sub = unreal.get_editor_subsystem(unreal.LevelSequenceEditorSubsystem)
binding, _cam_actor = sub.create_camera(spawnable=bool(spawnable))
binding_name = binding.get_name()
# Make sure the camera-cut section spans the playback range.
start = seq.get_playback_start_seconds()
end = seq.get_playback_end_seconds()
for track in seq.get_tracks():
if isinstance(track, unreal.MovieSceneCameraCutTrack):
for sec in track.get_sections():
sec.set_range_seconds(start, end)
unreal.EditorAssetLibrary.save_loaded_asset(seq)
finally:
lsb.close_level_sequence()
return json.dumps({"success": True, "asset_path": asset_path,
"camera_binding": binding_name, "camera_cut_track": True,
"spawnable": bool(spawnable)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_anim_track(asset_path: str = None, binding_name: str = None, anim_path: str = None,
start_seconds: float = None, end_seconds: float = None) -> str:
"""Adds a skeletal-animation track playing anim_path on a binding (defaults to the playback range)."""
if asset_path is None or binding_name is None or anim_path is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, binding_name, anim_path."})
try:
with _suppress():
seq = _load_sequence(asset_path)
binding = _find_binding(seq, binding_name)
anim = unreal.EditorAssetLibrary.load_asset(anim_path)
if not anim or not isinstance(anim, unreal.AnimSequenceBase):
return json.dumps({"success": False, "message": f"Not an animation asset: {anim_path}"})
track = binding.add_track(unreal.MovieSceneSkeletalAnimationTrack)
sec = track.add_section()
s = seq.get_playback_start_seconds() if start_seconds is None else float(start_seconds)
e = seq.get_playback_end_seconds() if end_seconds is None else float(end_seconds)
sec.set_range_seconds(s, e)
params = sec.get_editor_property("params")
params.set_editor_property("animation", anim)
sec.set_editor_property("params", params)
unreal.EditorAssetLibrary.save_loaded_asset(seq)
return json.dumps({"success": True, "asset_path": asset_path, "binding_name": binding_name,
"anim_path": anim_path, "range_seconds": [round(s, 3), round(e, 3)]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_convert_binding(asset_path: str = None, binding_name: str = None, to: str = "spawnable") -> str:
"""Converts a binding between possessable and spawnable. to='spawnable' or 'possessable'."""
if asset_path is None or binding_name is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, binding_name."})
mode = (to or "").lower()
if mode not in ("spawnable", "possessable"):
return json.dumps({"success": False, "message": "Parameter 'to' must be 'spawnable' or 'possessable'."})
try:
with _suppress():
seq = _load_sequence(asset_path)
binding = _find_binding(seq, binding_name)
lsb = unreal.LevelSequenceEditorBlueprintLibrary
if not lsb.open_level_sequence(seq):
return json.dumps({"success": False, "message": "Could not open the sequence in Sequencer (conversion requires a focused sequence)."})
try:
sub = unreal.get_editor_subsystem(unreal.LevelSequenceEditorSubsystem)
if mode == "spawnable":
result = sub.convert_to_spawnable(binding)
names = [b.get_name() for b in (result or [])]
else:
result = sub.convert_to_possessable(binding)
names = [result.get_name()] if result else []
unreal.EditorAssetLibrary.save_loaded_asset(seq)
finally:
lsb.close_level_sequence()
if not names:
return json.dumps({"success": False, "message": f"Conversion to {mode} returned no binding."})
return json.dumps({"success": True, "asset_path": asset_path, "converted_to": mode, "bindings": names})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,781 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
Defines Python action functions for material editing to be executed within Unreal Engine.
"""
import unreal
import json
import traceback
from typing import Optional
# --- Helper Functions for Material Editing ---
def _get_material_asset(material_path: str):
"""Helper to load a material asset."""
if not material_path:
raise ValueError("Material path cannot be empty.")
material = unreal.EditorAssetLibrary.load_asset(material_path)
if not material:
raise FileNotFoundError(f"Material asset not found at path: {material_path}")
if not isinstance(material, unreal.Material):
raise TypeError(f"Asset at {material_path} is not a Material, but {type(material).__name__}")
return material
def _get_material_instance_asset(instance_path: str):
"""Helper to load a material instance constant asset."""
if not instance_path:
raise ValueError("Material instance path cannot be empty.")
instance = unreal.EditorAssetLibrary.load_asset(instance_path)
if not instance:
raise FileNotFoundError(f"Material instance asset not found at path: {instance_path}")
if not isinstance(instance, unreal.MaterialInstanceConstant):
raise TypeError(f"Asset at {instance_path} is not a MaterialInstanceConstant, but {type(instance).__name__}")
return instance
def _get_expression_class(class_name: str):
"""Helper to get an Unreal MaterialExpression class by name."""
try:
# Common prefix for many material expressions if not found directly
if not hasattr(unreal, class_name) and not class_name.startswith("MaterialExpression"):
full_class_name = f"MaterialExpression{class_name}"
else:
full_class_name = class_name
expression_class = getattr(unreal, full_class_name)
if not issubclass(expression_class, unreal.MaterialExpression):
raise TypeError(f"{full_class_name} is not a MaterialExpression class.")
return expression_class
except AttributeError:
raise ValueError(f"MaterialExpression class like '{class_name}' or '{full_class_name}' not found in 'unreal' module.")
def _find_material_expression_by_name_or_type(material: unreal.Material, expression_identifier: str, expression_class_name: str = None):
"""
Finds a material expression within a material by its description (name) or by its class type.
"""
if not material or not isinstance(material, unreal.Material):
raise ValueError("Invalid material provided.")
target_class = None
if expression_class_name:
try:
target_class = _get_expression_class(expression_class_name)
except (ValueError, TypeError):
pass
it = unreal.ObjectIterator()
for x in it:
if isinstance(x, unreal.MaterialExpression) and x.get_path_name().startswith(material.get_path_name()):
if hasattr(x, 'desc') and x.desc == expression_identifier:
if target_class and not isinstance(x, target_class):
continue
return x
if target_class and isinstance(x, target_class):
if expression_identifier == expression_class_name or expression_identifier == target_class.__name__:
return x
if expression_identifier == x.get_name():
if target_class and not isinstance(x, target_class):
continue
return x
raise ValueError(f"MaterialExpression identified by '{expression_identifier}' (intended class: {expression_class_name or 'any'}) not found in material '{material.get_path_name()}'.")
# Friendly material-property name → unreal.MaterialProperty enum
_MATERIAL_PROPERTY_MAP = {
"BaseColor": "MP_BASE_COLOR",
"Metallic": "MP_METALLIC",
"Specular": "MP_SPECULAR",
"Roughness": "MP_ROUGHNESS",
"Anisotropy": "MP_ANISOTROPY",
"EmissiveColor": "MP_EMISSIVE_COLOR",
"Opacity": "MP_OPACITY",
"OpacityMask": "MP_OPACITY_MASK",
"Normal": "MP_NORMAL",
"Tangent": "MP_TANGENT",
"WorldPositionOffset": "MP_WORLD_POSITION_OFFSET",
"SubsurfaceColor": "MP_SUBSURFACE_COLOR",
"AmbientOcclusion": "MP_AMBIENT_OCCLUSION",
"Refraction": "MP_REFRACTION",
}
def _split_asset_path(asset_path: str):
"""'/Game/Foo/MyAsset' → ('MyAsset', '/Game/Foo')."""
asset_path = asset_path.rstrip("/")
idx = asset_path.rfind("/")
return asset_path[idx + 1:], asset_path[:idx]
# --- Material Editing Actions ---
def ue_create_expression(material_path: str = None, expression_class_name: str = None, node_pos_x: int = 0, node_pos_y: int = 0) -> str:
"""
Creates a new material expression node within the supplied material.
Returns JSON string.
"""
if material_path is None:
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
if expression_class_name is None:
return json.dumps({"success": False, "message": "Required parameter 'expression_class_name' is missing."})
transaction_description = "MCP: Create Material Expression"
try:
material = _get_material_asset(material_path)
expression_class = _get_expression_class(expression_class_name)
with unreal.ScopedEditorTransaction(transaction_description) as trans:
new_expression = unreal.MaterialEditingLibrary.create_material_expression(
material, expression_class, node_pos_x, node_pos_y
)
if not new_expression:
return json.dumps({
"success": False,
"message": f"Failed to create MaterialExpression '{expression_class_name}' in '{material_path}'."
})
if hasattr(new_expression, 'desc') and not new_expression.desc:
new_expression.desc = expression_class_name
unreal.MaterialEditingLibrary.recompile_material(material)
unreal.EditorAssetLibrary.save_loaded_asset(material)
return json.dumps({
"success": True,
"message": f"Successfully created MaterialExpression '{expression_class_name}' in '{material_path}'.",
"expression_name": new_expression.get_name(),
"expression_desc": new_expression.desc if hasattr(new_expression, 'desc') else "N/A",
"expression_class": new_expression.__class__.__name__
})
except Exception as e:
return json.dumps({
"success": False,
"message": f"Error creating material expression: {str(e)}",
"traceback": traceback.format_exc()
})
def ue_connect_expressions(
material_path: str = None,
from_expression_identifier: str = None,
from_output_name: str = None,
to_expression_identifier: str = None,
to_input_name: str = None,
from_expression_class_name: str = None,
to_expression_class_name: str = None
) -> str:
"""
Creates a connection between two material expressions.
Returns JSON string.
"""
if material_path is None:
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
if from_expression_identifier is None:
return json.dumps({"success": False, "message": "Required parameter 'from_expression_identifier' is missing."})
if from_output_name is None:
return json.dumps({"success": False, "message": "Required parameter 'from_output_name' is missing."})
if to_expression_identifier is None:
return json.dumps({"success": False, "message": "Required parameter 'to_expression_identifier' is missing."})
if to_input_name is None:
return json.dumps({"success": False, "message": "Required parameter 'to_input_name' is missing."})
transaction_description = "MCP: Connect Material Expressions"
try:
material = _get_material_asset(material_path)
from_expression = _find_material_expression_by_name_or_type(material, from_expression_identifier, from_expression_class_name)
to_expression = _find_material_expression_by_name_or_type(material, to_expression_identifier, to_expression_class_name)
with unreal.ScopedEditorTransaction(transaction_description) as trans:
success = unreal.MaterialEditingLibrary.connect_material_expressions(
from_expression, from_output_name, to_expression, to_input_name
)
if not success:
return json.dumps({
"success": False,
"message": f"Failed to connect '{from_expression_identifier}(Output: {from_output_name})' to '{to_expression_identifier}(Input: {to_input_name})' in '{material_path}'. Check pin names and compatibility."
})
unreal.MaterialEditingLibrary.recompile_material(material)
unreal.EditorAssetLibrary.save_loaded_asset(material)
return json.dumps({
"success": True,
"message": f"Successfully connected '{from_expression_identifier}(Output: {from_output_name})' to '{to_expression_identifier}(Input: {to_input_name})' in '{material_path}'."
})
except Exception as e:
return json.dumps({
"success": False,
"message": f"Error connecting material expressions: {str(e)}",
"traceback": traceback.format_exc()
})
def ue_recompile(material_path: str = None) -> str:
"""
Triggers a recompile of a material or material instance's parent. Saves the asset.
Returns JSON string.
"""
if material_path is None:
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
try:
asset_to_process = unreal.EditorAssetLibrary.load_asset(material_path)
if not asset_to_process:
raise FileNotFoundError(f"Asset not found at path: {material_path}")
target_material_to_recompile = None
asset_to_save = asset_to_process
message_detail = ""
if isinstance(asset_to_process, unreal.Material):
target_material_to_recompile = asset_to_process
message_detail = f"material '{material_path}'"
elif isinstance(asset_to_process, unreal.MaterialInstance):
parent_material = asset_to_process.parent
if parent_material:
target_material_to_recompile = parent_material
message_detail = f"parent of material instance '{material_path}'"
else:
unreal.EditorAssetLibrary.save_loaded_asset(asset_to_process)
return json.dumps({
"success": True,
"message": f"Material instance '{material_path}' has no parent to recompile. Instance saved."
})
else:
raise TypeError(f"Asset at {material_path} is not a Material or MaterialInstance, but {type(asset_to_process).__name__}")
if target_material_to_recompile:
unreal.MaterialEditingLibrary.recompile_material(target_material_to_recompile)
unreal.EditorAssetLibrary.save_loaded_asset(asset_to_save)
return json.dumps({
"success": True,
"message": f"Successfully recompiled {message_detail} and saved '{material_path}'."
})
except Exception as e:
return json.dumps({
"success": False,
"message": f"Error processing {message_detail} '{material_path}' for recompile: {str(e)}",
"traceback": traceback.format_exc()
})
def ue_get_mi_scalar_param(instance_path: str = None, parameter_name: str = None) -> str:
"""
Gets the current scalar (float) parameter value from a Material Instance Constant.
Returns JSON string.
"""
if instance_path is None:
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
if parameter_name is None:
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
try:
instance = _get_material_instance_asset(instance_path)
ue_parameter_name = unreal.Name(parameter_name)
param_value = instance.get_scalar_parameter_value(ue_parameter_name)
if param_value is None:
return json.dumps({
"success": False,
"message": f"Scalar parameter '{parameter_name}' not found in instance '{instance_path}'.",
"parameter_name": parameter_name,
"instance_path": instance_path,
"value": None
})
return json.dumps({
"success": True,
"parameter_name": parameter_name,
"value": param_value,
"instance_path": instance_path
})
except Exception as e:
return json.dumps({
"success": False,
"message": f"Error getting scalar parameter '{parameter_name}' from '{instance_path}': {str(e)}",
"traceback": traceback.format_exc()
})
def ue_set_mi_scalar_param(instance_path: str = None, parameter_name: str = None, value: float = None) -> str:
"""
Sets the scalar (float) parameter value for a Material Instance Constant.
Returns JSON string.
"""
if instance_path is None:
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
if parameter_name is None:
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
if value is None:
return json.dumps({"success": False, "message": "Required parameter 'value' is missing."})
transaction_description = "MCP: Set Material Instance Scalar Parameter"
try:
instance = _get_material_instance_asset(instance_path)
ue_parameter_name = unreal.Name(parameter_name)
with unreal.ScopedEditorTransaction(transaction_description) as trans:
unreal.MaterialEditingLibrary.set_material_instance_scalar_parameter_value(instance, ue_parameter_name, float(value))
unreal.MaterialEditingLibrary.update_material_instance(instance)
unreal.EditorAssetLibrary.save_loaded_asset(instance)
return json.dumps({
"success": True,
"message": f"Successfully set scalar parameter '{parameter_name}' to {value} for instance '{instance_path}'.",
"instance_path": instance_path,
"parameter_name": parameter_name,
"new_value": value
})
except Exception as e:
return json.dumps({
"success": False,
"message": f"Error setting scalar parameter '{parameter_name}' for '{instance_path}': {str(e)}",
"traceback": traceback.format_exc()
})
def ue_get_mi_vector_param(instance_path: str = None, parameter_name: str = None) -> str:
"""Gets a vector parameter from a Material Instance. Returns JSON string."""
if instance_path is None:
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
if parameter_name is None:
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
try:
instance = _get_material_instance_asset(instance_path)
ue_parameter_name = unreal.Name(parameter_name)
param_value = unreal.MaterialEditingLibrary.get_material_instance_vector_parameter_value(instance, ue_parameter_name)
value_list = [param_value.r, param_value.g, param_value.b, param_value.a]
return json.dumps({"success": True, "parameter_name": parameter_name, "value": value_list, "instance_path": instance_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_mi_vector_param(instance_path: str = None, parameter_name: str = None, value: list = None) -> str:
"""Sets a vector parameter on a Material Instance. Expects value as [R,G,B,A]. Returns JSON string."""
if instance_path is None:
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
if parameter_name is None:
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
if value is None:
return json.dumps({"success": False, "message": "Required parameter 'value' is missing."})
transaction_description = "MCP: Set Material Instance Vector Parameter"
try:
instance = _get_material_instance_asset(instance_path)
ue_parameter_name = unreal.Name(parameter_name)
if not isinstance(value, list) or len(value) != 4:
raise ValueError("Vector value must be a list of 4 floats [R, G, B, A].")
with unreal.ScopedEditorTransaction(transaction_description) as trans:
linear_color_value = unreal.LinearColor(float(value[0]), float(value[1]), float(value[2]), float(value[3]))
unreal.MaterialEditingLibrary.set_material_instance_vector_parameter_value(instance, ue_parameter_name, linear_color_value)
unreal.MaterialEditingLibrary.update_material_instance(instance)
unreal.EditorAssetLibrary.save_loaded_asset(instance)
return json.dumps({"success": True, "message": f"Vector parameter '{parameter_name}' set.", "new_value": value})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_mi_texture_param(instance_path: str = None, parameter_name: str = None) -> str:
"""Gets a texture parameter from a Material Instance. Returns JSON string with texture path."""
if instance_path is None:
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
if parameter_name is None:
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
try:
instance = _get_material_instance_asset(instance_path)
ue_parameter_name = unreal.Name(parameter_name)
param_value = unreal.MaterialEditingLibrary.get_material_instance_texture_parameter_value(instance, ue_parameter_name)
texture_path = param_value.get_path_name() if param_value else None
return json.dumps({"success": True, "parameter_name": parameter_name, "value": texture_path, "instance_path": instance_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def _get_mi_texture_param_names(instance_path):
"""
Gets all texture parameter names for a given material instance
"""
try:
instance = unreal.load_asset(instance_path)
texture_param_names = unreal.MaterialEditingLibrary.get_texture_parameter_names(instance)
return list(texture_param_names)
except Exception as e:
return []
def ue_set_mi_texture_param(instance_path: str = None, parameter_name: str = None, texture_path: Optional[str] = None) -> str:
"""Sets a texture parameter on a Material Instance. Provide texture asset path. Returns JSON string."""
if instance_path is None:
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
try:
instance = unreal.load_asset(instance_path)
available_params = unreal.MaterialEditingLibrary.get_texture_parameter_names(instance)
available_params = [str(name) for name in available_params]
except Exception as e:
available_params = []
if parameter_name is None:
return json.dumps({
"success": False,
"message": "Required parameter 'parameter_name' is missing.",
"available_parameters": available_params
})
if parameter_name not in available_params:
return json.dumps({
"success": False,
"message": f"Texture parameter '{parameter_name}' does not exist.",
"available_parameters": available_params
})
try:
instance = _get_material_instance_asset(instance_path)
ue_parameter_name = unreal.Name(parameter_name)
texture_asset = None
if texture_path:
texture_asset = unreal.EditorAssetLibrary.load_asset(texture_path)
if not texture_asset:
return json.dumps({
"success": False,
"message": f"Texture asset not found at path: {texture_path}",
"available_parameters": available_params
})
if not isinstance(texture_asset, unreal.Texture):
return json.dumps({
"success": False,
"message": f"Asset at {texture_path} is not a Texture, but {type(texture_asset).__name__}",
"available_parameters": available_params
})
with unreal.ScopedEditorTransaction("MCP: Set Material Instance Texture Parameter") as trans:
unreal.MaterialEditingLibrary.set_material_instance_texture_parameter_value(instance, ue_parameter_name, texture_asset)
unreal.MaterialEditingLibrary.update_material_instance(instance)
unreal.EditorAssetLibrary.save_loaded_asset(instance)
return json.dumps({
"success": True,
"message": f"Texture parameter '{parameter_name}' set.",
"new_value": texture_path,
"available_parameters": available_params
})
except Exception as e:
return json.dumps({
"success": False,
"message": str(e),
"traceback": traceback.format_exc(),
"available_parameters": available_params
})
def _get_mi_static_switch_params(instance_path):
"""
Gets all available static switch parameter names for a material instance
"""
try:
instance = _get_material_instance_asset(instance_path)
param_names = unreal.MaterialEditingLibrary.get_static_switch_parameter_names(instance)
return [str(name) for name in param_names]
except Exception as e:
print(f"Error getting static switch parameters: {e}")
return []
def ue_get_mi_static_switch(instance_path: str = None, parameter_name: str = None) -> str:
"""Gets a static switch parameter from a Material Instance. Returns JSON string."""
if instance_path is None:
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
if parameter_name is None:
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
available_params = _get_mi_static_switch_params(instance_path)
if parameter_name not in available_params:
return json.dumps({
"success": False,
"message": f"Static switch parameter '{parameter_name}' not found.",
"available_parameters": available_params
})
try:
instance = _get_material_instance_asset(instance_path)
ue_parameter_name = unreal.Name(parameter_name)
param_value = unreal.MaterialEditingLibrary.get_material_instance_static_switch_parameter_value(instance, ue_parameter_name)
return json.dumps({
"success": True,
"parameter_name": parameter_name,
"value": param_value,
"instance_path": instance_path,
"available_parameters": available_params
})
except Exception as e:
return json.dumps({
"success": False,
"message": f"Error getting static switch parameter '{parameter_name}': {str(e)}",
"traceback": traceback.format_exc(),
"available_parameters": available_params
})
def ue_set_mi_static_switch(instance_path: str = None, parameter_name: str = None, value: bool = None) -> str:
"""Sets a static switch parameter on a Material Instance. Returns JSON string."""
if instance_path is None:
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
if parameter_name is None:
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
if value is None:
return json.dumps({"success": False, "message": "Required parameter 'value' is missing."})
available_params = _get_mi_static_switch_params(instance_path)
if parameter_name not in available_params:
return json.dumps({
"success": False,
"message": f"Static switch parameter '{parameter_name}' not found.",
"available_parameters": available_params
})
transaction_description = "MCP: Set Material Instance Static Switch Parameter"
try:
instance = _get_material_instance_asset(instance_path)
ue_parameter_name = unreal.Name(parameter_name)
with unreal.ScopedEditorTransaction(transaction_description) as trans:
unreal.MaterialEditingLibrary.set_material_instance_static_switch_parameter_value(instance, ue_parameter_name, bool(value))
unreal.MaterialEditingLibrary.update_material_instance(instance)
unreal.EditorAssetLibrary.save_loaded_asset(instance)
return json.dumps({
"success": True,
"message": f"Static switch parameter '{parameter_name}' set to {value}",
"new_value": value,
"available_parameters": available_params
})
except Exception as e:
return json.dumps({
"success": False,
"message": f"Error setting static switch parameter '{parameter_name}': {str(e)}",
"traceback": traceback.format_exc(),
"available_parameters": available_params
})
# --- Asset creation -----------------------------------------------------------
def ue_create_material(material_path: str = None) -> str:
"""Creates a new Material asset at the given content-browser path."""
if material_path is None:
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
try:
if unreal.EditorAssetLibrary.does_asset_exist(material_path):
return json.dumps({"success": False, "message": f"Asset already exists: {material_path}"})
name, package = _split_asset_path(material_path)
tools = unreal.AssetToolsHelpers.get_asset_tools()
mat = tools.create_asset(name, package, unreal.Material, unreal.MaterialFactoryNew())
if not mat:
return json.dumps({"success": False, "message": f"Failed to create material at '{material_path}'."})
unreal.EditorAssetLibrary.save_loaded_asset(mat)
return json.dumps({"success": True, "material_path": material_path,
"message": f"Material created at '{material_path}'."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_create_material_instance(instance_path: str = None, parent_path: str = None) -> str:
"""Creates a Material Instance Constant, optionally parented to parent_path."""
if instance_path is None:
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
try:
if unreal.EditorAssetLibrary.does_asset_exist(instance_path):
return json.dumps({"success": False, "message": f"Asset already exists: {instance_path}"})
name, package = _split_asset_path(instance_path)
tools = unreal.AssetToolsHelpers.get_asset_tools()
mi = tools.create_asset(name, package, unreal.MaterialInstanceConstant,
unreal.MaterialInstanceConstantFactoryNew())
if not mi:
return json.dumps({"success": False, "message": f"Failed to create material instance at '{instance_path}'."})
if parent_path:
parent = unreal.EditorAssetLibrary.load_asset(parent_path)
if not parent or not isinstance(parent, unreal.MaterialInterface):
return json.dumps({"success": False, "message": f"Parent is not a material: {parent_path}"})
unreal.MaterialEditingLibrary.set_material_instance_parent(mi, parent)
unreal.MaterialEditingLibrary.update_material_instance(mi)
unreal.EditorAssetLibrary.save_loaded_asset(mi)
return json.dumps({"success": True, "instance_path": instance_path, "parent_path": parent_path,
"message": f"Material instance created at '{instance_path}'."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- Graph authoring ----------------------------------------------------------
def ue_connect_property(material_path: str = None, from_expression_identifier: str = None,
from_output_name: str = "", property_name: str = None,
from_expression_class_name: str = None) -> str:
"""Connects an expression output to a material property (e.g. BaseColor, Metallic, Roughness, Normal)."""
if material_path is None:
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
if from_expression_identifier is None:
return json.dumps({"success": False, "message": "Required parameter 'from_expression_identifier' is missing."})
if property_name is None:
return json.dumps({"success": False, "message": "Required parameter 'property_name' is missing.",
"valid_properties": list(_MATERIAL_PROPERTY_MAP)})
if property_name not in _MATERIAL_PROPERTY_MAP:
return json.dumps({"success": False, "message": f"Unknown property '{property_name}'.",
"valid_properties": list(_MATERIAL_PROPERTY_MAP)})
try:
material = _get_material_asset(material_path)
from_expression = _find_material_expression_by_name_or_type(
material, from_expression_identifier, from_expression_class_name)
prop = getattr(unreal.MaterialProperty, _MATERIAL_PROPERTY_MAP[property_name])
with unreal.ScopedEditorTransaction("MCP: Connect Material Property"):
ok = unreal.MaterialEditingLibrary.connect_material_property(from_expression, from_output_name, prop)
if not ok:
return json.dumps({"success": False,
"message": f"Failed to connect '{from_expression_identifier}' to '{property_name}'."})
unreal.MaterialEditingLibrary.recompile_material(material)
unreal.EditorAssetLibrary.save_loaded_asset(material)
return json.dumps({"success": True,
"message": f"Connected '{from_expression_identifier}' (out '{from_output_name}') to '{property_name}'."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_delete_expression(material_path: str = None, expression_identifier: str = None,
expression_class_name: str = None) -> str:
"""Deletes a material expression node identified by name/desc/type."""
if material_path is None:
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
if expression_identifier is None:
return json.dumps({"success": False, "message": "Required parameter 'expression_identifier' is missing."})
try:
material = _get_material_asset(material_path)
expression = _find_material_expression_by_name_or_type(material, expression_identifier, expression_class_name)
with unreal.ScopedEditorTransaction("MCP: Delete Material Expression"):
# delete_material_expression returns None regardless of outcome, so
# confirm by comparing the expression count before/after.
before = unreal.MaterialEditingLibrary.get_num_material_expressions(material)
unreal.MaterialEditingLibrary.delete_material_expression(material, expression)
after = unreal.MaterialEditingLibrary.get_num_material_expressions(material)
unreal.MaterialEditingLibrary.recompile_material(material)
unreal.EditorAssetLibrary.save_loaded_asset(material)
if after >= before:
return json.dumps({"success": False,
"message": f"Expression '{expression_identifier}' was not removed (count {before}->{after})."})
return json.dumps({"success": True,
"message": f"Deleted expression '{expression_identifier}' (count {before}->{after})."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_expression_property(material_path: str = None, expression_identifier: str = None,
property_name: str = None, value=None,
expression_class_name: str = None) -> str:
"""
Sets an editor property on a material expression (e.g. 'r' on a Constant,
'parameter_name'/'default_value' on a ScalarParameter). A 3/4-element list is
coerced to a LinearColor; 'parameter_name' is coerced to an unreal.Name.
"""
if material_path is None:
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
if expression_identifier is None:
return json.dumps({"success": False, "message": "Required parameter 'expression_identifier' is missing."})
if property_name is None:
return json.dumps({"success": False, "message": "Required parameter 'property_name' is missing."})
try:
material = _get_material_asset(material_path)
expression = _find_material_expression_by_name_or_type(material, expression_identifier, expression_class_name)
coerced = value
if property_name == "parameter_name":
coerced = unreal.Name(str(value))
elif isinstance(value, list) and len(value) in (3, 4):
r, g, b = float(value[0]), float(value[1]), float(value[2])
a = float(value[3]) if len(value) == 4 else 1.0
coerced = unreal.LinearColor(r, g, b, a)
with unreal.ScopedEditorTransaction("MCP: Set Material Expression Property"):
expression.set_editor_property(property_name, coerced)
unreal.MaterialEditingLibrary.recompile_material(material)
unreal.EditorAssetLibrary.save_loaded_asset(material)
return json.dumps({"success": True,
"message": f"Set '{property_name}' on '{expression_identifier}'."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_layout_expressions(material_path: str = None) -> str:
"""Auto-lays out all expression nodes in a material graph."""
if material_path is None:
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
try:
material = _get_material_asset(material_path)
with unreal.ScopedEditorTransaction("MCP: Layout Material Expressions"):
unreal.MaterialEditingLibrary.layout_material_expressions(material)
unreal.EditorAssetLibrary.save_loaded_asset(material)
return json.dumps({"success": True, "message": f"Laid out expressions in '{material_path}'."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- Read / introspection -----------------------------------------------------
def ue_get_material_info(material_path: str = None) -> str:
"""Returns expression count and a list of expressions (name, class, position) for a material."""
if material_path is None:
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
try:
material = _get_material_asset(material_path)
count = unreal.MaterialEditingLibrary.get_num_material_expressions(material)
expressions = []
mat_path = material.get_path_name()
for x in unreal.ObjectIterator():
if isinstance(x, unreal.MaterialExpression) and x.get_path_name().startswith(mat_path):
try:
pos = unreal.MaterialEditingLibrary.get_material_expression_node_position(x)
pos_xy = [int(pos.x), int(pos.y)]
except Exception:
pos_xy = None
expressions.append({
"name": x.get_name(),
"desc": x.desc if hasattr(x, "desc") else "",
"class": x.__class__.__name__,
"position": pos_xy,
})
return json.dumps({"success": True, "material_path": material_path,
"expression_count": count, "expressions": expressions})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_parameters(material_path: str = None) -> str:
"""Lists scalar / vector / texture / static-switch parameter names for a material or material instance."""
if material_path is None:
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
try:
asset = unreal.EditorAssetLibrary.load_asset(material_path)
if not asset or not isinstance(asset, unreal.MaterialInterface):
return json.dumps({"success": False, "message": f"Asset is not a material/instance: {material_path}"})
mel = unreal.MaterialEditingLibrary
return json.dumps({
"success": True,
"material_path": material_path,
"scalar": [str(n) for n in mel.get_scalar_parameter_names(asset)],
"vector": [str(n) for n in mel.get_vector_parameter_names(asset)],
"texture": [str(n) for n in mel.get_texture_parameter_names(asset)],
"static_switch": [str(n) for n in mel.get_static_switch_parameter_names(asset)],
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_instance_parent(instance_path: str = None, parent_path: str = None) -> str:
"""Reparents a Material Instance Constant to a new parent material/instance."""
if instance_path is None or parent_path is None:
return json.dumps({"success": False, "message": "Required parameters: instance_path, parent_path."})
try:
instance = _get_material_instance_asset(instance_path)
parent = unreal.EditorAssetLibrary.load_asset(parent_path)
if not parent or not isinstance(parent, unreal.MaterialInterface):
return json.dumps({"success": False, "message": f"Parent is not a material/instance: {parent_path}"})
unreal.MaterialEditingLibrary.set_material_instance_parent(instance, parent)
unreal.MaterialEditingLibrary.update_material_instance(instance)
unreal.EditorAssetLibrary.save_loaded_asset(instance)
return json.dumps({"success": True, "instance_path": instance_path, "parent_path": parent_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,111 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
Core dispatcher for executing dynamic Python commands received from the MCP server.
All specific action functions have been moved to their respective modules:
- util_actions.py
- asset_actions.py
- actor_actions.py
- material_actions.py
"""
import unreal # type: ignore # Suppress linter warning, 'unreal' module is available in UE Python environment
import json
import importlib
import traceback
# Core dispatcher for executing dynamic Python commands received from the MCP server
def execute_action(module_name: str, function_name: str, params: dict) -> str: # Changed args_list: list to params: dict
"""
Dynamically imports and executes the specified function from the given module.
It reloads the module on each call to ensure the latest version is used.
Args:
module_name (str): Name of the module containing the function (e.g., "util_actions", "actor_actions").
function_name (str): Name of the function to call (e.g., "ue_print_message").
params (dict): Dictionary of parameters to pass to the target function.
Returns:
str: JSON-formatted string representing the function's result or an error.
"""
try:
# Ensure the module name is valid and does not try to escape the intended directory
# This is a basic check; more robust sandboxing might be needed depending on security requirements.
if ".." in module_name or "/" in module_name or "\\" in module_name:
raise ValueError(f"Invalid module name: {module_name}. Contains restricted characters.")
# Dynamically import the module.
# Assuming these modules are in the Python path accessible by Unreal.
# For plugins, this usually means Content/Python or subdirectories.
target_module = importlib.import_module(module_name)
# Reload the module to pick up any changes without restarting Unreal.
# This is crucial for development and live updates.
importlib.reload(target_module)
target_function = getattr(target_module, function_name)
# Execute the function
# params is now expected to be a dictionary directly.
# Unpack the dictionary as keyword arguments to the target function.
result_json_str = target_function(**params)
# Validate if the result is indeed a JSON string (basic check)
try:
json.loads(result_json_str) # Try to parse it to ensure it's valid JSON
except json.JSONDecodeError as je:
# If the function didn't return a valid JSON string, wrap this error.
error_detail = f"Function '{module_name}.{function_name}' did not return a valid JSON string. Error: {je}. Returned: {result_json_str[:200]}"
return json.dumps({
"success": False,
"message": error_detail,
"traceback": traceback.format_exc(),
"type": "InvalidReturnFormat"
})
except TypeError as te:
# If result_json_str is not a string-like object (e.g. None)
error_detail = f"Function '{module_name}.{function_name}' returned a non-string type. Error: {te}. Returned type: {type(result_json_str).__name__}"
return json.dumps({
"success": False,
"message": error_detail,
"traceback": traceback.format_exc(),
"type": "InvalidReturnType"
})
return result_json_str # Return the JSON string as is
except ImportError:
return json.dumps({
"success": False,
"message": f"Could not import module '{module_name}'. Ensure it exists and is in Python path.",
"traceback": traceback.format_exc(),
"type": "ImportError"
})
except AttributeError:
return json.dumps({
"success": False,
"message": f"Function '{function_name}' not found in module '{module_name}'.",
"traceback": traceback.format_exc(),
"type": "AttributeError"
})
except ValueError as ve: # Catch specific ValueError from module name check
return json.dumps({
"success": False,
"message": str(ve),
"traceback": traceback.format_exc(),
"type": "ValueError"
})
except Exception as e:
# Catch all other exceptions during function execution
return json.dumps({
"success": False,
"message": f"Exception during execution of '{module_name}.{function_name}': {str(e)}",
"traceback": traceback.format_exc(), # Include traceback for debugging
"type": type(e).__name__
})
# All specific ue_... action functions have been moved to their respective files:
# - util_actions.py
# - asset_actions.py
# - actor_actions.py
# - material_actions.py

View File

@@ -0,0 +1,201 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
IK Rig / IK Retargeter authoring and batch animation retargeting.
All actions require the IKRig plugin (a built-in engine plugin, usually enabled
by default). The dependency is soft: each action guards at call time and returns
an actionable error when the plugin is disabled.
"""
import unreal
import json
import traceback
def _plugin_missing():
if not hasattr(unreal, "IKRigController"):
return json.dumps({"success": False,
"message": "Requires the IKRig plugin. Enable it in Edit > Plugins and restart."})
return None
def _load_typed(asset_path, cls, label):
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
if not asset:
raise FileNotFoundError(f"{label} not found at path: {asset_path}")
if not isinstance(asset, cls):
raise TypeError(f"Asset at {asset_path} is not a {label}, but {type(asset).__name__}")
return asset
def _split_asset_path(asset_path: str):
asset_path = asset_path.rstrip("/")
idx = asset_path.rfind("/")
return asset_path[idx + 1:], asset_path[:idx]
def ue_create_ik_rig(asset_path: str = None, skeletal_mesh_path: str = None,
retarget_root: str = None) -> str:
"""Creates an IK Rig for a skeletal mesh, optionally setting the retarget root bone (requires the IKRig plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None or skeletal_mesh_path is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, skeletal_mesh_path."})
try:
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
mesh = _load_typed(skeletal_mesh_path, unreal.SkeletalMesh, "SkeletalMesh")
name, package = _split_asset_path(asset_path)
rig = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
name, package, unreal.IKRigDefinition, unreal.IKRigDefinitionFactory())
if not rig:
return json.dumps({"success": False, "message": f"Failed to create IK Rig at {asset_path}."})
rc = unreal.IKRigController.get_controller(rig)
if not rc.set_skeletal_mesh(mesh):
return json.dumps({"success": False, "message": "set_skeletal_mesh failed (incompatible mesh?)."})
root_set = None
if retarget_root:
root_set = bool(rc.set_retarget_root(retarget_root))
unreal.EditorAssetLibrary.save_loaded_asset(rig)
return json.dumps({"success": True, "asset_path": asset_path,
"skeletal_mesh": skeletal_mesh_path, "retarget_root_set": root_set})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_retarget_chain(ik_rig_path: str = None, chain_name: str = None,
start_bone: str = None, end_bone: str = None, goal_name: str = "") -> str:
"""Adds a retarget chain (e.g. 'Spine': spine_01..spine_03) to an IK Rig (requires the IKRig plugin)."""
guard = _plugin_missing()
if guard:
return guard
if ik_rig_path is None or chain_name is None or start_bone is None or end_bone is None:
return json.dumps({"success": False, "message": "Required: ik_rig_path, chain_name, start_bone, end_bone."})
try:
rig = _load_typed(ik_rig_path, unreal.IKRigDefinition, "IKRigDefinition")
rc = unreal.IKRigController.get_controller(rig)
created = rc.add_retarget_chain(chain_name, start_bone, end_bone, goal_name or "")
if not str(created):
return json.dumps({"success": False, "message": "add_retarget_chain returned an empty name (check bone names)."})
unreal.EditorAssetLibrary.save_loaded_asset(rig)
return json.dumps({"success": True, "ik_rig_path": ik_rig_path, "chain_name": str(created),
"chain_count": len(rc.get_retarget_chains())})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_ik_rig_info(ik_rig_path: str = None) -> str:
"""Returns the skeletal mesh, retarget root, and chains of an IK Rig (requires the IKRig plugin)."""
guard = _plugin_missing()
if guard:
return guard
if ik_rig_path is None:
return json.dumps({"success": False, "message": "Required parameter 'ik_rig_path' is missing."})
try:
rig = _load_typed(ik_rig_path, unreal.IKRigDefinition, "IKRigDefinition")
rc = unreal.IKRigController.get_controller(rig)
mesh = rc.get_skeletal_mesh()
chains = []
for ch in rc.get_retarget_chains():
cname = str(getattr(ch, "chain_name", ""))
chains.append({
"name": cname,
"start_bone": str(rc.get_retarget_chain_start_bone(cname)),
"end_bone": str(rc.get_retarget_chain_end_bone(cname)),
})
return json.dumps({
"success": True,
"ik_rig_path": ik_rig_path,
"skeletal_mesh": mesh.get_path_name() if mesh else None,
"retarget_root": str(rc.get_retarget_root()),
"chains": chains,
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_create_retargeter(asset_path: str = None, source_ik_rig_path: str = None,
target_ik_rig_path: str = None, auto_map: bool = True) -> str:
"""Creates an IK Retargeter wired to source/target IK Rigs, with optional fuzzy chain auto-mapping (requires the IKRig plugin)."""
guard = _plugin_missing()
if guard:
return guard
if asset_path is None or source_ik_rig_path is None or target_ik_rig_path is None:
return json.dumps({"success": False, "message": "Required: asset_path, source_ik_rig_path, target_ik_rig_path."})
try:
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
src = _load_typed(source_ik_rig_path, unreal.IKRigDefinition, "IKRigDefinition")
tgt = _load_typed(target_ik_rig_path, unreal.IKRigDefinition, "IKRigDefinition")
name, package = _split_asset_path(asset_path)
rtg = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
name, package, unreal.IKRetargeter, unreal.IKRetargetFactory())
if not rtg:
return json.dumps({"success": False, "message": f"Failed to create IK Retargeter at {asset_path}."})
tc = unreal.IKRetargeterController.get_controller(rtg)
tc.set_ik_rig(unreal.RetargetSourceOrTarget.SOURCE, src)
tc.set_ik_rig(unreal.RetargetSourceOrTarget.TARGET, tgt)
if auto_map:
tc.auto_map_chains(unreal.AutoMapChainType.FUZZY, True)
unreal.EditorAssetLibrary.save_loaded_asset(rtg)
return json.dumps({"success": True, "asset_path": asset_path,
"source_ik_rig": source_ik_rig_path, "target_ik_rig": target_ik_rig_path,
"auto_mapped": bool(auto_map)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_auto_map_chains(retargeter_path: str = None, mode: str = "FUZZY", force: bool = True) -> str:
"""Re-runs chain mapping on an IK Retargeter. mode: FUZZY, EXACT, or CLEAR (requires the IKRig plugin)."""
guard = _plugin_missing()
if guard:
return guard
if retargeter_path is None:
return json.dumps({"success": False, "message": "Required parameter 'retargeter_path' is missing."})
key = (mode or "FUZZY").upper()
map_type = getattr(unreal.AutoMapChainType, key, None)
if map_type is None:
return json.dumps({"success": False, "message": f"Unknown mode '{mode}'.", "valid_modes": ["FUZZY", "EXACT", "CLEAR"]})
try:
rtg = _load_typed(retargeter_path, unreal.IKRetargeter, "IKRetargeter")
tc = unreal.IKRetargeterController.get_controller(rtg)
tc.auto_map_chains(map_type, bool(force))
unreal.EditorAssetLibrary.save_loaded_asset(rtg)
return json.dumps({"success": True, "retargeter_path": retargeter_path, "mode": key})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_batch_retarget(retargeter_path: str = None, anim_paths: list = None,
source_mesh_path: str = None, target_mesh_path: str = None,
search: str = "", replace: str = "", prefix: str = "",
suffix: str = "_Retargeted") -> str:
"""Duplicates and retargets animations through an IK Retargeter; returns the new asset paths (requires the IKRig plugin)."""
guard = _plugin_missing()
if guard:
return guard
if retargeter_path is None or not anim_paths or source_mesh_path is None or target_mesh_path is None:
return json.dumps({"success": False,
"message": "Required: retargeter_path, anim_paths (non-empty), source_mesh_path, target_mesh_path."})
try:
rtg = _load_typed(retargeter_path, unreal.IKRetargeter, "IKRetargeter")
src_mesh = _load_typed(source_mesh_path, unreal.SkeletalMesh, "SkeletalMesh")
tgt_mesh = _load_typed(target_mesh_path, unreal.SkeletalMesh, "SkeletalMesh")
asset_data = []
missing = []
for p in anim_paths:
ad = unreal.EditorAssetLibrary.find_asset_data(p)
(asset_data.append(ad) if ad and ad.is_valid() else missing.append(p))
if missing:
return json.dumps({"success": False, "message": f"Animations not found: {missing}"})
out = unreal.IKRetargetBatchOperation.duplicate_and_retarget(
asset_data, src_mesh, tgt_mesh, rtg,
search=search or "", replace=replace or "", prefix=prefix or "", suffix=suffix or "")
paths = [str(a.package_name) for a in (out or [])]
if not paths:
return json.dumps({"success": False, "message": "duplicate_and_retarget produced no assets (check chain mapping)."})
return json.dumps({"success": True, "retargeter_path": retargeter_path,
"count": len(paths), "retargeted_assets": paths})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,250 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""Python action functions for StaticMesh assets (info, materials, collision)."""
import unreal
import json
import traceback
SMS = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem)
_SHAPES = {
"BOX": unreal.ScriptCollisionShapeType.BOX,
"SPHERE": unreal.ScriptCollisionShapeType.SPHERE,
"CAPSULE": unreal.ScriptCollisionShapeType.CAPSULE,
"NDOP10_X": unreal.ScriptCollisionShapeType.NDOP10_X,
"NDOP10_Y": unreal.ScriptCollisionShapeType.NDOP10_Y,
"NDOP10_Z": unreal.ScriptCollisionShapeType.NDOP10_Z,
"NDOP18": unreal.ScriptCollisionShapeType.NDOP18,
"NDOP26": unreal.ScriptCollisionShapeType.NDOP26,
}
def _load_static_mesh(asset_path: str):
if not asset_path:
raise ValueError("StaticMesh path cannot be empty.")
sm = unreal.EditorAssetLibrary.load_asset(asset_path)
if not sm:
raise FileNotFoundError(f"StaticMesh not found at path: {asset_path}")
if not isinstance(sm, unreal.StaticMesh):
raise TypeError(f"Asset at {asset_path} is not a StaticMesh, but {type(sm).__name__}")
return sm
def ue_get_static_mesh_info(asset_path: str = None) -> str:
"""Returns LOD/section/triangle/vertex/material counts and Nanite state of a StaticMesh."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
sm = _load_static_mesh(asset_path)
try:
nanite = bool(SMS.get_nanite_settings(sm).enabled)
except Exception:
nanite = None
return json.dumps({
"success": True,
"asset_path": asset_path,
"num_lods": SMS.get_lod_count(sm),
"num_materials": SMS.get_number_materials(sm),
"num_sections_lod0": sm.get_num_sections(0),
"num_triangles_lod0": sm.get_num_triangles(0),
"num_vertices_lod0": sm.get_num_vertices(0),
"nanite_enabled": nanite,
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_static_mesh_materials(asset_path: str = None) -> str:
"""Lists the material slots of a StaticMesh (slot index + material path)."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
sm = _load_static_mesh(asset_path)
slots = []
for i in range(SMS.get_number_materials(sm)):
mat = sm.get_material(i)
slots.append({"slot": i, "material": mat.get_path_name() if mat else None})
return json.dumps({"success": True, "asset_path": asset_path,
"num_materials": len(slots), "materials": slots})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_static_mesh_material(asset_path: str = None, slot_index: int = None, material_path: str = None) -> str:
"""Assigns a material to a StaticMesh material slot."""
if asset_path is None or slot_index is None or material_path is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, slot_index, material_path."})
try:
sm = _load_static_mesh(asset_path)
if slot_index < 0 or slot_index >= SMS.get_number_materials(sm):
return json.dumps({"success": False, "message": f"slot_index {slot_index} out of range (0..{SMS.get_number_materials(sm)-1})."})
mat = unreal.EditorAssetLibrary.load_asset(material_path)
if not mat or not isinstance(mat, unreal.MaterialInterface):
return json.dumps({"success": False, "message": f"Not a material: {material_path}"})
sm.set_material(slot_index, mat)
unreal.EditorAssetLibrary.save_loaded_asset(sm)
return json.dumps({"success": True, "asset_path": asset_path, "slot": slot_index, "material": material_path})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_collision_info(asset_path: str = None) -> str:
"""Returns collision complexity and simple/convex collision counts of a StaticMesh."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
sm = _load_static_mesh(asset_path)
return json.dumps({
"success": True, "asset_path": asset_path,
"collision_complexity": str(SMS.get_collision_complexity(sm)),
"simple_collision_count": SMS.get_simple_collision_count(sm),
"convex_collision_count": SMS.get_convex_collision_count(sm),
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_simple_collision(asset_path: str = None, shape: str = "BOX") -> str:
"""Adds a simple collision primitive to a StaticMesh. shape: BOX, SPHERE, CAPSULE, NDOP10_X/Y/Z, NDOP18, NDOP26."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
shape_key = (shape or "BOX").upper()
if shape_key not in _SHAPES:
return json.dumps({"success": False, "message": f"Unknown shape '{shape}'.", "valid_shapes": list(_SHAPES)})
try:
sm = _load_static_mesh(asset_path)
before = SMS.get_simple_collision_count(sm)
SMS.add_simple_collisions(sm, _SHAPES[shape_key])
after = SMS.get_simple_collision_count(sm)
unreal.EditorAssetLibrary.save_loaded_asset(sm)
return json.dumps({"success": True, "asset_path": asset_path, "shape": shape_key,
"simple_collision_count": after, "added": after - before})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- LODs -----------------------------------------------------------------
def ue_set_lods(asset_path: str = None, lod_settings: list = None,
auto_compute_screen_size: bool = False) -> str:
"""Generates LODs from reduction settings: lod_settings=[{percent_triangles, screen_size}, ...] (LOD0 first)."""
if asset_path is None or not lod_settings:
return json.dumps({"success": False, "message": "Required parameters: asset_path, lod_settings (non-empty list)."})
try:
sm = _load_static_mesh(asset_path)
opts = unreal.StaticMeshReductionOptions()
settings = []
for i, ls in enumerate(lod_settings):
s = unreal.StaticMeshReductionSettings()
s.percent_triangles = float(ls.get("percent_triangles", 1.0))
s.screen_size = float(ls.get("screen_size", 1.0))
settings.append(s)
opts.reduction_settings = settings
opts.auto_compute_lod_screen_size = bool(auto_compute_screen_size)
count = SMS.set_lods(sm, opts)
if count <= 0:
return json.dumps({"success": False, "message": f"set_lods returned {count}."})
unreal.EditorAssetLibrary.save_loaded_asset(sm)
return json.dumps({"success": True, "asset_path": asset_path, "lod_count": SMS.get_lod_count(sm),
"screen_sizes": [round(x, 4) for x in SMS.get_lod_screen_sizes(sm)]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_lod_screen_sizes(asset_path: str = None) -> str:
"""Returns the screen-size threshold of each LOD on a StaticMesh."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
sm = _load_static_mesh(asset_path)
return json.dumps({"success": True, "asset_path": asset_path,
"lod_count": SMS.get_lod_count(sm),
"screen_sizes": [round(x, 4) for x in SMS.get_lod_screen_sizes(sm)]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_remove_lods(asset_path: str = None) -> str:
"""Removes all LODs except LOD 0 from a StaticMesh."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
sm = _load_static_mesh(asset_path)
ok = SMS.remove_lods(sm)
if not ok:
return json.dumps({"success": False, "message": "remove_lods returned False (mesh may only have LOD 0)."})
unreal.EditorAssetLibrary.save_loaded_asset(sm)
return json.dumps({"success": True, "asset_path": asset_path, "lod_count": SMS.get_lod_count(sm)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_lod_from_static_mesh(asset_path: str = None, lod_index: int = None,
source_path: str = None, source_lod_index: int = 0,
reuse_existing_material_slots: bool = True) -> str:
"""Adds/sets a LOD on a StaticMesh using geometry from another StaticMesh's LOD."""
if asset_path is None or lod_index is None or source_path is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, lod_index, source_path."})
try:
dst = _load_static_mesh(asset_path)
src = _load_static_mesh(source_path)
result = SMS.set_lod_from_static_mesh(dst, int(lod_index), src, int(source_lod_index),
bool(reuse_existing_material_slots))
if result < 0:
return json.dumps({"success": False, "message": f"set_lod_from_static_mesh returned {result} (see Output Log)."})
unreal.EditorAssetLibrary.save_loaded_asset(dst)
return json.dumps({"success": True, "asset_path": asset_path, "lod_set_at": result,
"lod_count": SMS.get_lod_count(dst)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- Collision (continued) --------------------------------------------------
def ue_set_convex_collision(asset_path: str = None, hull_count: int = 4,
max_hull_verts: int = 16, hull_precision: int = 100000) -> str:
"""Replaces simple collision with auto-generated convex decomposition collision."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
sm = _load_static_mesh(asset_path)
ok = SMS.set_convex_decomposition_collisions(sm, int(hull_count), int(max_hull_verts), int(hull_precision))
if not ok:
return json.dumps({"success": False, "message": "set_convex_decomposition_collisions returned False."})
unreal.EditorAssetLibrary.save_loaded_asset(sm)
return json.dumps({"success": True, "asset_path": asset_path,
"convex_collision_count": SMS.get_convex_collision_count(sm)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_remove_collisions(asset_path: str = None) -> str:
"""Removes all simple/convex collision from a StaticMesh."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
sm = _load_static_mesh(asset_path)
ok = SMS.remove_collisions(sm)
if not ok:
return json.dumps({"success": False, "message": "remove_collisions returned False."})
unreal.EditorAssetLibrary.save_loaded_asset(sm)
return json.dumps({"success": True, "asset_path": asset_path,
"simple_collision_count": SMS.get_simple_collision_count(sm),
"convex_collision_count": SMS.get_convex_collision_count(sm)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_lod_for_collision(asset_path: str = None, lod_index: int = None) -> str:
"""Sets which LOD's geometry is used for complex collision on a StaticMesh."""
if asset_path is None or lod_index is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, lod_index."})
try:
sm = _load_static_mesh(asset_path)
if int(lod_index) < 0 or int(lod_index) >= SMS.get_lod_count(sm):
return json.dumps({"success": False, "message": f"lod_index {lod_index} out of range (0..{SMS.get_lod_count(sm)-1})."})
sm.set_editor_property("lod_for_collision", int(lod_index))
unreal.EditorAssetLibrary.save_loaded_asset(sm)
return json.dumps({"success": True, "asset_path": asset_path, "lod_for_collision": int(lod_index)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,100 @@
# UMG Actions Test Script
# Paste each section into the Unreal Python console (Output Log) to verify functionality.
# Run sections in order: 1 → 2 → 3 → 4 → 5
import importlib, json, unreal
def run(module_func, **kwargs):
"""Reload module and call a ue_* function, printing the result."""
mod = importlib.import_module("UnrealMCPython.umg_actions")
importlib.reload(mod)
fn = getattr(mod, module_func)
result = fn(**kwargs)
parsed = json.loads(result)
print(json.dumps(parsed, indent=2))
return parsed
TEST_PATH = "/Game/Tests"
TEST_NAME = "TestWidget_MCP"
FULL_PATH = f"{TEST_PATH}/{TEST_NAME}.{TEST_NAME}"
# ─── 1. Create Widget Blueprint ───────────────────────────────────────────────
print("=== 1. create_widget_blueprint ===")
r = run("ue_create_widget_blueprint", name=TEST_NAME, path=TEST_PATH)
assert r["success"], f"FAIL: {r}"
print("PASS")
# ─── 2. Get Info (empty) ──────────────────────────────────────────────────────
print("\n=== 2. get_widget_blueprint_info (empty) ===")
r = run("ue_get_widget_blueprint_info", asset_path=FULL_PATH)
assert r["success"], f"FAIL: {r}"
print(f" root: {r['root_widget']}, widgets: {r['widget_count']}")
print("PASS")
# ─── 3. Add CanvasPanel as root ───────────────────────────────────────────────
print("\n=== 3. add_widget: CanvasPanel (root) ===")
r = run("ue_add_widget", asset_path=FULL_PATH, widget_type="CanvasPanel", widget_name="RootCanvas")
assert r["success"], f"FAIL: {r}"
assert r["is_root"], "Expected is_root=True"
print("PASS")
# ─── 4. Add TextBlock under CanvasPanel ───────────────────────────────────────
print("\n=== 4. add_widget: TextBlock under RootCanvas ===")
r = run("ue_add_widget", asset_path=FULL_PATH, widget_type="TextBlock",
widget_name="TitleText", parent_name="RootCanvas")
assert r["success"], f"FAIL: {r}"
print("PASS")
# ─── 5. Add Button under CanvasPanel ─────────────────────────────────────────
print("\n=== 5. add_widget: Button under RootCanvas ===")
r = run("ue_add_widget", asset_path=FULL_PATH, widget_type="Button",
widget_name="StartButton", parent_name="RootCanvas")
assert r["success"], f"FAIL: {r}"
print("PASS")
# ─── 6. Set TextBlock properties ─────────────────────────────────────────────
print("\n=== 6. set_widget_properties: TitleText ===")
r = run("ue_set_widget_properties", asset_path=FULL_PATH, widget_name="TitleText",
properties={
"text": "Hello UMG!",
"font_size": 32,
"color_and_opacity": [1.0, 1.0, 0.0, 1.0],
"slot_position": [100.0, 50.0],
"slot_size": [400.0, 60.0],
})
assert r["success"], f"FAIL errors: {r.get('errors')}"
print(f" set: {r['set']}")
print("PASS")
# ─── 7. Set Button slot position ─────────────────────────────────────────────
print("\n=== 7. set_widget_properties: StartButton slot ===")
r = run("ue_set_widget_properties", asset_path=FULL_PATH, widget_name="StartButton",
properties={
"slot_position": [150.0, 150.0],
"slot_size": [200.0, 60.0],
})
assert r["success"], f"FAIL errors: {r.get('errors')}"
print("PASS")
# ─── 8. Get Info (populated) ─────────────────────────────────────────────────
print("\n=== 8. get_widget_blueprint_info (populated) ===")
r = run("ue_get_widget_blueprint_info", asset_path=FULL_PATH)
assert r["success"], f"FAIL: {r}"
assert r["widget_count"] == 3, f"Expected 3 widgets, got {r['widget_count']}"
for w in r["widgets"]:
print(f" {w['type']}: {w['name']} (parent: {w.get('parent', 'ROOT')})")
print("PASS")
# ─── 9. Remove TextBlock ─────────────────────────────────────────────────────
print("\n=== 9. remove_widget: TitleText ===")
r = run("ue_remove_widget", asset_path=FULL_PATH, widget_name="TitleText")
assert r["success"], f"FAIL: {r}"
print("PASS")
# ─── 10. Compile ─────────────────────────────────────────────────────────────
print("\n=== 10. compile_widget_blueprint ===")
r = run("ue_compile_widget_blueprint", asset_path=FULL_PATH)
assert r["success"], f"FAIL: {r}"
print("PASS")
print("\n=== ALL TESTS PASSED ===")

View File

@@ -0,0 +1,37 @@
import unittest
import importlib
import json
import unreal
TEST_ROOT = "/Game/Tests/MCP"
class MCPTestCase(unittest.TestCase):
def call(self, module_name, func_name, **kwargs):
mod = importlib.import_module(f"UnrealMCPython.{module_name}")
importlib.reload(mod)
return json.loads(getattr(mod, func_name)(**kwargs))
def assertSuccess(self, result, msg=None):
self.assertTrue(result.get("success"), msg or f"Expected success=True: {result}")
def delete_asset(self, path):
try:
if unreal.EditorAssetLibrary.does_asset_exist(path):
unreal.EditorAssetLibrary.delete_asset(path)
except Exception:
pass
def delete_actor_by_label(self, label):
try:
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
for actor in sub.get_all_level_actors():
if actor.get_actor_label() == label:
sub.destroy_actor(actor)
break
except Exception:
pass
def ensure_test_dir(self):
unreal.EditorAssetLibrary.make_directory(TEST_ROOT)

View File

@@ -0,0 +1,63 @@
"""
Run all MCP unittest suites inside the Unreal Python environment.
Usage — single line in the Unreal Python console (Output Log):
import runpy; runpy.run_module("UnrealMCPython.tests.run_all", run_name="__main__")
Or via MCP execute_python tool (multi-line is fine there):
import runpy
runpy.run_module("UnrealMCPython.tests.run_all", run_name="__main__")
"""
import sys
import importlib
import unittest
_MODULES = [
"UnrealMCPython.tests.test_util",
"UnrealMCPython.tests.test_actor",
"UnrealMCPython.tests.test_anim_blueprint",
"UnrealMCPython.tests.test_animation",
"UnrealMCPython.tests.test_asset",
"UnrealMCPython.tests.test_level",
"UnrealMCPython.tests.test_level_sequence",
"UnrealMCPython.tests.test_material",
"UnrealMCPython.tests.test_blueprint",
"UnrealMCPython.tests.test_behavior_tree",
"UnrealMCPython.tests.test_data_table",
"UnrealMCPython.tests.test_umg",
"UnrealMCPython.tests.test_editor",
"UnrealMCPython.tests.test_game",
"UnrealMCPython.tests.test_static_mesh",
"UnrealMCPython.tests.test_layer",
"UnrealMCPython.tests.test_texture",
"UnrealMCPython.tests.test_retarget",
"UnrealMCPython.tests.test_control_rig",
"UnrealMCPython.tests.test_gas",
"UnrealMCPython.tests.test_vision",
]
suite = unittest.TestSuite()
loader = unittest.TestLoader()
for mod_name in _MODULES:
try:
mod = importlib.import_module(mod_name)
importlib.reload(mod)
suite.addTests(loader.loadTestsFromModule(mod))
except Exception as e:
print(f"[LOAD ERROR] {mod_name}: {e}")
runner = unittest.TextTestRunner(verbosity=2, stream=sys.stdout)
result = runner.run(suite)
total = result.testsRun
fails = len(result.failures)
errors = len(result.errors)
skipped = len(result.skipped)
passed = total - fails - errors - skipped
print(f"\n{'='*60}")
print(f"Results: {passed} passed | {fails} failed | {errors} errors | {skipped} skipped / {total} total")
print('='*60)

View File

@@ -0,0 +1,339 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase
_CLASS_PATH = "/Script/Engine.PointLight"
class TestActorActions(MCPTestCase):
def setUp(self):
self._actor_label = None
r = self.call("actor_actions", "ue_spawn_from_class",
class_path=_CLASS_PATH, location=[0, 0, 500])
if r.get("success"):
self._actor_label = r["actor_label"]
def tearDown(self):
if self._actor_label:
self.delete_actor_by_label(self._actor_label)
# ── spawn ──────────────────────────────────────────────────────────────────
def test_spawn_from_class(self):
self.assertIsNotNone(self._actor_label, "Actor was not spawned in setUp")
def test_spawn_missing_class(self):
r = self.call("actor_actions", "ue_spawn_from_class",
class_path="/Script/Engine.NonExistentClass123", location=[0, 0, 0])
self.assertFalse(r.get("success"))
# ── list / query ───────────────────────────────────────────────────────────
def test_list_all_with_locations(self):
r = self.call("actor_actions", "ue_list_all_with_locations")
self.assertSuccess(r)
self.assertIsInstance(r["actors"], list)
labels = [a["name"] for a in r["actors"]]
self.assertIn(self._actor_label, labels)
def test_get_all_details(self):
r = self.call("actor_actions", "ue_get_all_details")
self.assertSuccess(r)
self.assertIsInstance(r["actors"], list)
def test_get_in_view_frustum(self):
r = self.call("actor_actions", "ue_get_in_view_frustum")
self.assertSuccess(r)
self.assertIn("visible_actors", r)
# ── transform ─────────────────────────────────────────────────────────────
def test_set_location(self):
r = self.call("actor_actions", "ue_set_location",
actor_label=self._actor_label, location=[100, 200, 300])
self.assertSuccess(r)
def test_set_rotation(self):
r = self.call("actor_actions", "ue_set_rotation",
actor_label=self._actor_label, rotation=[0, 45, 0])
self.assertSuccess(r)
def test_set_scale(self):
r = self.call("actor_actions", "ue_set_scale",
actor_label=self._actor_label, scale=[2, 2, 2])
self.assertSuccess(r)
def test_set_transform_full(self):
r = self.call("actor_actions", "ue_set_transform",
actor_label=self._actor_label,
location=[50, 50, 50], rotation=[0, 0, 0], scale=[1, 1, 1])
self.assertSuccess(r)
def test_set_transform_unknown_actor(self):
r = self.call("actor_actions", "ue_set_transform",
actor_label="NonExistentActor_XYZ123", location=[0, 0, 0])
self.assertFalse(r.get("success"))
# ── property ──────────────────────────────────────────────────────────────
def test_get_property(self):
r = self.call("actor_actions", "ue_get_property",
actor_label=self._actor_label, property_name="can_be_damaged")
self.assertSuccess(r)
self.assertIn("value", r)
def test_set_property(self):
r = self.call("actor_actions", "ue_set_property",
actor_label=self._actor_label,
property_name="can_be_damaged", value=False)
self.assertSuccess(r)
# ── selection ─────────────────────────────────────────────────────────────
def test_select_all(self):
r = self.call("actor_actions", "ue_select_all")
self.assertSuccess(r)
def test_invert_selection(self):
self.call("actor_actions", "ue_select_all")
r = self.call("actor_actions", "ue_invert_selection")
self.assertSuccess(r)
# ── delete ────────────────────────────────────────────────────────────────
def test_delete_by_label(self):
r = self.call("actor_actions", "ue_spawn_from_class",
class_path=_CLASS_PATH, location=[9999, 9999, 9999])
self.assertSuccess(r)
extra_label = r["actor_label"]
r = self.call("actor_actions", "ue_delete_by_label", actor_label=extra_label)
self.assertSuccess(r)
# ── raycast ───────────────────────────────────────────────────────────────
def test_line_trace_no_hit(self):
r = self.call("actor_actions", "ue_line_trace",
ray_start=[0, 0, 100000], ray_end=[0, 0, 200000])
self.assertSuccess(r)
self.assertFalse(r.get("hit"))
# ── spawn from object ───────────────────────────────────────────────────────
def test_spawn_from_object(self):
r = self.call("actor_actions", "ue_spawn_from_object",
asset_path="/Engine/BasicShapes/Cube", location=[1500, 1500, 0])
self.assertSuccess(r)
self.assertIn("actor_label", r)
self.delete_actor_by_label(r["actor_label"])
def test_spawn_from_object_missing_asset(self):
r = self.call("actor_actions", "ue_spawn_from_object",
asset_path="/Game/DoesNotExist_XYZ123", location=[0, 0, 0])
self.assertFalse(r.get("success"))
# ── duplicate ────────────────────────────────────────────────────────────────
def _select_setup_actor(self):
import unreal
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
actor = next((a for a in sub.get_all_level_actors()
if a.get_actor_label() == self._actor_label), None)
self.assertIsNotNone(actor, "setUp actor not found in level")
sub.set_selected_level_actors([actor])
def test_duplicate_selected(self):
if not self._actor_label:
self.skipTest("Actor not spawned in setUp")
self._select_setup_actor()
r = self.call("actor_actions", "ue_duplicate_selected", offset=[200, 0, 0])
self.assertSuccess(r)
self.assertIsInstance(r.get("duplicated_actors"), list)
self.assertGreaterEqual(len(r["duplicated_actors"]), 1)
for label in r["duplicated_actors"]:
self.delete_actor_by_label(label)
def test_duplicate_selected_none_selected(self):
import unreal
unreal.get_editor_subsystem(unreal.EditorActorSubsystem).set_selected_level_actors([])
r = self.call("actor_actions", "ue_duplicate_selected", offset=[0, 0, 0])
self.assertFalse(r.get("success"))
# ── spawn on surface (raycast) ─────────────────────────────────────────────
def test_spawn_on_surface_raycast_no_hit(self):
# Raycast through empty space high above the level → no surface to hit.
r = self.call("actor_actions", "ue_spawn_on_surface_raycast",
asset_or_class_path=_CLASS_PATH,
ray_start=[0, 0, 500000], ray_end=[0, 0, 600000])
self.assertFalse(r.get("success"))
def test_spawn_on_surface_raycast_hit(self):
# Place a cube as a surface, then raycast straight down onto it.
floor = self.call("actor_actions", "ue_spawn_from_object",
asset_path="/Engine/BasicShapes/Cube", location=[4000, 4000, 0])
if not floor.get("success"):
self.skipTest("Could not spawn floor cube for raycast surface")
try:
r = self.call("actor_actions", "ue_spawn_on_surface_raycast",
asset_or_class_path=_CLASS_PATH,
ray_start=[4000, 4000, 1000], ray_end=[4000, 4000, -1000])
self.assertSuccess(r)
self.assertIn("actor_label", r)
self.delete_actor_by_label(r["actor_label"])
finally:
self.delete_actor_by_label(floor["actor_label"])
# ── folders / tags / components / bounds ─────────────────────────────────────
def test_set_get_actor_folder(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
r = self.call("actor_actions", "ue_set_actor_folder",
actor_label=self._actor_label, folder_path="MCP/TestFolder")
self.assertSuccess(r)
r = self.call("actor_actions", "ue_get_actor_folder", actor_label=self._actor_label)
self.assertSuccess(r)
self.assertEqual(r["folder_path"], "MCP/TestFolder")
def test_actor_tags(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
r = self.call("actor_actions", "ue_add_actor_tag",
actor_label=self._actor_label, tag="MCP_Tag")
self.assertSuccess(r)
self.assertIn("MCP_Tag", r["tags"])
r = self.call("actor_actions", "ue_get_actor_tags", actor_label=self._actor_label)
self.assertIn("MCP_Tag", r["tags"])
r = self.call("actor_actions", "ue_remove_actor_tag",
actor_label=self._actor_label, tag="MCP_Tag")
self.assertSuccess(r)
self.assertNotIn("MCP_Tag", r["tags"])
def test_list_actor_components(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
r = self.call("actor_actions", "ue_list_actor_components", actor_label=self._actor_label)
self.assertSuccess(r)
self.assertGreater(r["count"], 0)
def test_get_actor_bounds(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
r = self.call("actor_actions", "ue_get_actor_bounds", actor_label=self._actor_label)
self.assertSuccess(r)
self.assertEqual(len(r["extent"]), 3)
def test_get_actor_bounds_unknown(self):
r = self.call("actor_actions", "ue_get_actor_bounds", actor_label="NoSuchActor_XYZ")
self.assertFalse(r.get("success"))
# ── attach / detach ──────────────────────────────────────────────────────────
def test_attach_and_detach(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
spawn = self.call("actor_actions", "ue_spawn_from_class",
class_path=_CLASS_PATH, location=[300, 300, 300])
self.assertSuccess(spawn)
child = spawn["actor_label"]
try:
r = self.call("actor_actions", "ue_attach_actor",
child_label=child, parent_label=self._actor_label)
self.assertSuccess(r)
r = self.call("actor_actions", "ue_get_attached_actors", actor_label=self._actor_label)
self.assertSuccess(r)
self.assertIn(child, r["attached"])
r = self.call("actor_actions", "ue_detach_actor", actor_label=child)
self.assertSuccess(r)
r = self.call("actor_actions", "ue_get_attached_actors", actor_label=self._actor_label)
self.assertNotIn(child, r["attached"])
finally:
self.delete_actor_by_label(child)
def test_attach_unknown_parent(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
r = self.call("actor_actions", "ue_attach_actor",
child_label=self._actor_label, parent_label="NoSuchActor_XYZ")
self.assertFalse(r.get("success"))
def test_get_actors_of_class(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
r = self.call("actor_actions", "ue_get_actors_of_class",
class_path="/Script/Engine.PointLight")
self.assertSuccess(r)
self.assertIn(self._actor_label, r["actors"])
def test_get_actors_of_class_invalid(self):
r = self.call("actor_actions", "ue_get_actors_of_class",
class_path="/Script/Engine.NopeXYZ123")
self.assertFalse(r.get("success"))
def test_get_selected_actors(self):
self.call("actor_actions", "ue_select_all")
r = self.call("actor_actions", "ue_get_selected_actors")
self.assertSuccess(r)
self.assertIsInstance(r["actors"], list)
def test_rename_actor(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
new = self._actor_label + "_Renamed"
r = self.call("actor_actions", "ue_rename_actor",
actor_label=self._actor_label, new_label=new)
self.assertSuccess(r)
self.assertEqual(r["new_label"], new)
self._actor_label = new # so tearDown deletes the right one
def test_set_actor_hidden(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
r = self.call("actor_actions", "ue_set_actor_hidden",
actor_label=self._actor_label, hidden=True)
self.assertSuccess(r)
self.call("actor_actions", "ue_set_actor_hidden",
actor_label=self._actor_label, hidden=False)
def test_select_actors(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
r = self.call("actor_actions", "ue_select_actors", actor_labels=[self._actor_label])
self.assertSuccess(r)
self.assertIn(self._actor_label, r["selected"])
def test_select_actors_missing(self):
r = self.call("actor_actions", "ue_select_actors", actor_labels=["NoSuchActor_XYZ"])
self.assertSuccess(r)
self.assertIn("NoSuchActor_XYZ", r["missing"])
def test_get_transform(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
r = self.call("actor_actions", "ue_get_transform", actor_label=self._actor_label)
self.assertSuccess(r)
self.assertEqual(len(r["location"]), 3)
self.assertEqual(len(r["rotation"]), 3)
self.assertEqual(len(r["scale"]), 3)
def test_get_set_component_property(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
comps = self.call("actor_actions", "ue_list_actor_components",
actor_label=self._actor_label)["components"]
light = next((c["name"] for c in comps if "Light" in c["class"]), comps[0]["name"])
r = self.call("actor_actions", "ue_set_component_property",
actor_label=self._actor_label, component_name=light,
property_name="intensity", value=12345.0)
self.assertSuccess(r)
r = self.call("actor_actions", "ue_get_component_property",
actor_label=self._actor_label, component_name=light,
property_name="intensity")
self.assertSuccess(r)
self.assertAlmostEqual(r["value"], 12345.0, places=1)
def test_get_component_property_unknown(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
r = self.call("actor_actions", "ue_get_component_property",
actor_label=self._actor_label, component_name="NoSuchComp",
property_name="intensity")
self.assertFalse(r.get("success"))
def test_duplicate_actor(self):
self.assertIsNotNone(self._actor_label, "no setUp actor")
r = self.call("actor_actions", "ue_duplicate_actor",
actor_label=self._actor_label, offset=[150, 0, 0])
self.assertSuccess(r)
self.delete_actor_by_label(r["duplicated"])
def test_duplicate_actor_unknown(self):
r = self.call("actor_actions", "ue_duplicate_actor", actor_label="NoSuchActor_XYZ")
self.assertFalse(r.get("success"))

View File

@@ -0,0 +1,177 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
# Engine assets that ship with the editor: a Skeleton to bind, and a SkeletalMesh
# used as a deliberately-wrong (non-Skeleton) asset for the type-guard test.
_SKELETON = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/TutorialTPP_Skeleton"
_NON_SKELETON = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/TutorialTPP"
# Two AnimSequences that ship on the TutorialTPP skeleton — used for the AnimGraph builders.
_IDLE_ANIM = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/Tutorial_Idle"
_WALK_ANIM = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/Tutorial_Walk_Fwd"
_ABP_PATH = f"{TEST_ROOT}/MCP_TestAnimBP"
class TestAnimBlueprintActions(MCPTestCase):
def setUp(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_SKELETON):
self.skipTest("Engine TutorialTPP_Skeleton not available")
self.ensure_test_dir()
self.delete_asset(_ABP_PATH)
def tearDown(self):
self.delete_asset(_ABP_PATH)
# ── create + introspection happy path ─────────────────────────────────────────
def test_create_binds_skeleton_and_info_reports_it(self):
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
asset_path=_ABP_PATH, skeleton_path=_SKELETON)
self.assertSuccess(r)
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(_ABP_PATH))
info = self.call("anim_blueprint_actions", "ue_get_anim_blueprint_info", asset_path=_ABP_PATH)
self.assertSuccess(info)
# the skeleton must actually be bound (the whole point of an AnimBlueprint)
self.assertEqual(info["target_skeleton"].split(".")[0], _SKELETON)
# an AnimBlueprint always owns both an AnimGraph and an EventGraph
self.assertIn("AnimGraph", info["graphs"])
self.assertIn("EventGraph", info["graphs"])
self.assertTrue(info["generated_class"].endswith("_C"))
def test_create_with_custom_anim_instance_parent(self):
# AnimInstance itself is a valid explicit parent; proves parent validation accepts it.
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
asset_path=_ABP_PATH, skeleton_path=_SKELETON,
parent_class_path="/Script/Engine.AnimInstance")
self.assertSuccess(r)
self.assertEqual(r["parent_class"], "/Script/Engine.AnimInstance")
# ── create guards (each a distinct failure mode) ──────────────────────────────
def test_create_requires_skeleton_param(self):
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint", asset_path=_ABP_PATH)
self.assertFalse(r.get("success"))
def test_create_rejects_missing_skeleton_asset(self):
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
asset_path=_ABP_PATH, skeleton_path=f"{TEST_ROOT}/NoSuchSkeleton_XYZ")
self.assertFalse(r.get("success"))
def test_create_rejects_non_skeleton_asset(self):
# pointing skeleton_path at a SkeletalMesh must be caught by the type guard,
# not silently accepted (which would produce a broken AnimBlueprint).
if not unreal.EditorAssetLibrary.does_asset_exist(_NON_SKELETON):
self.skipTest("Engine TutorialTPP mesh not available")
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
asset_path=_ABP_PATH, skeleton_path=_NON_SKELETON)
self.assertFalse(r.get("success"))
self.assertIn("Skeleton", r.get("message", ""))
def test_create_rejects_non_anim_instance_parent(self):
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
asset_path=_ABP_PATH, skeleton_path=_SKELETON,
parent_class_path="/Script/Engine.Actor")
self.assertFalse(r.get("success"))
self.assertIn("AnimInstance", r.get("message", ""))
def test_create_rejects_duplicate(self):
first = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
asset_path=_ABP_PATH, skeleton_path=_SKELETON)
self.assertSuccess(first)
again = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
asset_path=_ABP_PATH, skeleton_path=_SKELETON)
self.assertFalse(again.get("success"))
self.assertIn("already exists", again.get("message", ""))
# ── info guards ───────────────────────────────────────────────────────────────
def test_info_requires_asset_path(self):
r = self.call("anim_blueprint_actions", "ue_get_anim_blueprint_info")
self.assertFalse(r.get("success"))
def test_info_rejects_non_anim_blueprint(self):
# the Skeleton asset exists but is not an AnimBlueprint — type guard must reject it.
r = self.call("anim_blueprint_actions", "ue_get_anim_blueprint_info", asset_path=_SKELETON)
self.assertFalse(r.get("success"))
self.assertIn("AnimBlueprint", r.get("message", ""))
# ── AnimGraph node authoring (C++ helper) ─────────────────────────────────────
def _make_abp(self):
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
asset_path=_ABP_PATH, skeleton_path=_SKELETON)
self.assertSuccess(r)
def _anim_graph_node_classes(self):
info = self.call("blueprint_actions", "ue_get_blueprint_graph_info",
asset_path=_ABP_PATH, graph_name="AnimGraph")
self.assertSuccess(info)
return [n["node_class"] for n in info["nodes"]]
def test_add_sequence_player_links_to_output(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_IDLE_ANIM):
self.skipTest("Engine Tutorial_Idle anim not available")
self._make_abp()
r = self.call("anim_blueprint_actions", "ue_add_anim_graph_sequence_player",
asset_path=_ABP_PATH, anim_sequence_path=_IDLE_ANIM, link_to_output_pose=True)
self.assertSuccess(r)
self.assertTrue(r["linked_to_output"])
# the node must actually exist in the AnimGraph
self.assertIn("AnimGraphNode_SequencePlayer", self._anim_graph_node_classes())
def test_add_sequence_player_rejects_missing_anim(self):
self._make_abp()
r = self.call("anim_blueprint_actions", "ue_add_anim_graph_sequence_player",
asset_path=_ABP_PATH, anim_sequence_path=f"{TEST_ROOT}/NoSuchAnim_XYZ")
self.assertFalse(r.get("success"))
# ── generic spec-driven state machine ─────────────────────────────────────────
def test_build_generic_three_state_machine(self):
for a in (_IDLE_ANIM, _WALK_ANIM):
if not unreal.EditorAssetLibrary.does_asset_exist(a):
self.skipTest("Engine tutorial locomotion anims not available")
self._make_abp()
spec = {
"machine_name": "Locomotion", "entry": "Idle",
"states": [
{"name": "Idle", "anim": _IDLE_ANIM},
{"name": "Walk", "anim": _WALK_ANIM},
{"name": "Run", "anim": _WALK_ANIM},
],
"transitions": [
{"from": "Idle", "to": "Walk", "var": "Speed", "op": ">", "value": 10},
{"from": "Walk", "to": "Idle", "var": "Speed", "op": "<", "value": 10},
{"from": "Walk", "to": "Run", "var": "Speed", "op": ">", "value": 300},
{"from": "Run", "to": "Walk", "var": "Speed", "op": "<", "value": 300},
],
}
r = self.call("anim_blueprint_actions", "ue_build_anim_state_machine",
asset_path=_ABP_PATH, spec=spec)
self.assertSuccess(r)
self.assertEqual(r["states"], ["Idle", "Walk", "Run"])
self.assertEqual(r["transition_count"], 4)
self.assertEqual(r.get("warnings", []), []) # all 4 speed rules wired, no fallbacks
self.assertIn("AnimGraphNode_StateMachine", self._anim_graph_node_classes())
def test_build_generic_rejects_unknown_transition_state(self):
self._make_abp()
spec = {"states": [{"name": "A", "anim": _IDLE_ANIM}],
"transitions": [{"from": "A", "to": "DoesNotExist"}]}
r = self.call("anim_blueprint_actions", "ue_build_anim_state_machine",
asset_path=_ABP_PATH, spec=spec)
self.assertFalse(r.get("success"))
def test_build_generic_rejects_empty_states(self):
self._make_abp()
r = self.call("anim_blueprint_actions", "ue_build_anim_state_machine",
asset_path=_ABP_PATH, spec={"states": []})
self.assertFalse(r.get("success"))
def test_build_generic_rejects_missing_anim(self):
self._make_abp()
r = self.call("anim_blueprint_actions", "ue_build_anim_state_machine",
asset_path=_ABP_PATH,
spec={"states": [{"name": "Idle", "anim": f"{TEST_ROOT}/NoSuchAnim_XYZ"}]})
self.assertFalse(r.get("success"))

View File

@@ -0,0 +1,202 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
# A reliably-present engine AnimSequence we duplicate into /Game to edit safely.
_SRC_ANIM = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/Tutorial_Idle"
_ANIM_NAME = "MCP_TestAnim"
_ANIM_PATH = f"{TEST_ROOT}/{_ANIM_NAME}"
# Engine skeletal mesh that ships with 7 sockets — used read-only for socket queries.
_TPP_MESH = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/TutorialTPP"
class TestAnimationActions(MCPTestCase):
def setUp(self):
self._anim_path = None
self.ensure_test_dir()
self.delete_asset(_ANIM_PATH)
if unreal.EditorAssetLibrary.does_asset_exist(_SRC_ANIM):
seq = unreal.EditorAssetLibrary.duplicate_asset(_SRC_ANIM, _ANIM_PATH)
if seq:
self._anim_path = _ANIM_PATH
def tearDown(self):
if self._anim_path:
self.delete_asset(self._anim_path)
def _skip_if_no_anim(self):
if not self._anim_path:
self.skipTest("Test AnimSequence not available (engine source missing)")
# ── introspection ───────────────────────────────────────────────────────────
def test_get_anim_sequence_info(self):
self._skip_if_no_anim()
r = self.call("animation_actions", "ue_get_anim_sequence_info", asset_path=self._anim_path)
self.assertSuccess(r)
self.assertGreater(r["num_frames"], 0)
self.assertGreater(r["length_seconds"], 0)
self.assertIsNotNone(r["skeleton"])
def test_list_notify_tracks(self):
self._skip_if_no_anim()
r = self.call("animation_actions", "ue_list_notify_tracks", asset_path=self._anim_path)
self.assertSuccess(r)
self.assertIsInstance(r["tracks"], list)
def test_list_notifies(self):
self._skip_if_no_anim()
r = self.call("animation_actions", "ue_list_notifies", asset_path=self._anim_path)
self.assertSuccess(r)
self.assertIsInstance(r["notifies"], list)
def test_list_curves(self):
self._skip_if_no_anim()
r = self.call("animation_actions", "ue_list_curves", asset_path=self._anim_path)
self.assertSuccess(r)
self.assertIsInstance(r["curves"], list)
def test_list_sync_markers(self):
self._skip_if_no_anim()
r = self.call("animation_actions", "ue_list_sync_markers", asset_path=self._anim_path)
self.assertSuccess(r)
self.assertIsInstance(r["sync_markers"], list)
# ── notify tracks ────────────────────────────────────────────────────────────
def test_add_and_remove_notify_track(self):
self._skip_if_no_anim()
r = self.call("animation_actions", "ue_add_notify_track",
asset_path=self._anim_path, track_name="MCP_Track")
self.assertSuccess(r)
self.assertIn("MCP_Track", r["tracks"])
r = self.call("animation_actions", "ue_remove_notify_track",
asset_path=self._anim_path, track_name="MCP_Track")
self.assertSuccess(r)
self.assertNotIn("MCP_Track", r["tracks"])
def test_remove_notify_track_unknown(self):
self._skip_if_no_anim()
r = self.call("animation_actions", "ue_remove_notify_track",
asset_path=self._anim_path, track_name="NoSuchTrack_XYZ")
self.assertFalse(r.get("success"))
# ── sync markers ─────────────────────────────────────────────────────────────
def test_add_sync_marker(self):
self._skip_if_no_anim()
r = self.call("animation_actions", "ue_add_sync_marker",
asset_path=self._anim_path, track_name="MCP_SyncTrack",
marker_name="MCP_Marker", time_seconds=0.5)
self.assertSuccess(r)
markers = self.call("animation_actions", "ue_list_sync_markers", asset_path=self._anim_path)
names = [m["name"] for m in markers["sync_markers"]]
self.assertIn("MCP_Marker", names)
# ── curves ───────────────────────────────────────────────────────────────────
def test_add_and_remove_float_curve(self):
self._skip_if_no_anim()
r = self.call("animation_actions", "ue_add_float_curve",
asset_path=self._anim_path, curve_name="MCP_Curve",
time_seconds=0.0, value=1.0)
self.assertSuccess(r)
self.assertIn("MCP_Curve", r["curves"])
r = self.call("animation_actions", "ue_remove_curve",
asset_path=self._anim_path, curve_name="MCP_Curve")
self.assertSuccess(r)
self.assertNotIn("MCP_Curve", r["curves"])
def test_remove_curve_unknown(self):
self._skip_if_no_anim()
r = self.call("animation_actions", "ue_remove_curve",
asset_path=self._anim_path, curve_name="NoSuchCurve_XYZ")
self.assertFalse(r.get("success"))
# ── skeletal mesh / socket queries (read-only engine assets) ─────────────────
def test_get_skeletal_mesh_info(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
self.skipTest("Engine TutorialTPP mesh not available")
r = self.call("animation_actions", "ue_get_skeletal_mesh_info", asset_path=_TPP_MESH)
self.assertSuccess(r)
self.assertIsNotNone(r["skeleton"])
self.assertGreater(r["num_sockets"], 0)
def test_list_sockets(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
self.skipTest("Engine TutorialTPP mesh not available")
r = self.call("animation_actions", "ue_list_sockets", asset_path=_TPP_MESH)
self.assertSuccess(r)
self.assertEqual(r["num_sockets"], len(r["sockets"]))
self.assertIn("bone", r["sockets"][0])
def test_find_socket(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
self.skipTest("Engine TutorialTPP mesh not available")
listed = self.call("animation_actions", "ue_list_sockets", asset_path=_TPP_MESH)
name = listed["sockets"][0]["name"]
r = self.call("animation_actions", "ue_find_socket", asset_path=_TPP_MESH, socket_name=name)
self.assertSuccess(r)
self.assertEqual(r["name"], name)
def test_find_socket_unknown(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
self.skipTest("Engine TutorialTPP mesh not available")
r = self.call("animation_actions", "ue_find_socket",
asset_path=_TPP_MESH, socket_name="NoSuchSocket_XYZ")
self.assertFalse(r.get("success"))
def test_get_skeleton_info(self):
if not self._anim_path:
self.skipTest("Test AnimSequence not available")
skel_path = self.call("animation_actions", "ue_get_anim_sequence_info",
asset_path=self._anim_path)["skeleton"].split(".")[0]
r = self.call("animation_actions", "ue_get_skeleton_info", asset_path=skel_path)
self.assertSuccess(r)
self.assertIn("curve_names", r)
# ── bones + socket editing (C++ helper; needs the plugin built with these UFUNCTIONs) ──
def test_list_bones(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
self.skipTest("Engine TutorialTPP mesh not available")
r = self.call("animation_actions", "ue_list_bones", asset_path=_TPP_MESH)
self.assertSuccess(r)
self.assertGreater(r["bone_count"], 0)
self.assertIn("name", r["bones"][0])
def test_add_and_remove_socket(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
self.skipTest("Engine TutorialTPP mesh not available")
dup = f"{TEST_ROOT}/MCP_TestSkelMesh"
self.delete_asset(dup)
mesh = unreal.EditorAssetLibrary.duplicate_asset(_TPP_MESH, dup)
self.assertIsNotNone(mesh, "could not duplicate skeletal mesh")
try:
bone = self.call("animation_actions", "ue_list_bones", asset_path=dup)["bones"][0]["name"]
r = self.call("animation_actions", "ue_add_socket", asset_path=dup,
socket_name="MCP_Socket", bone_name=bone, location=[0, 0, 10])
self.assertSuccess(r)
socks = self.call("animation_actions", "ue_list_sockets", asset_path=dup)
self.assertIn("MCP_Socket", [s["name"] for s in socks["sockets"]])
r = self.call("animation_actions", "ue_remove_socket", asset_path=dup, socket_name="MCP_Socket")
self.assertSuccess(r)
socks = self.call("animation_actions", "ue_list_sockets", asset_path=dup)
self.assertNotIn("MCP_Socket", [s["name"] for s in socks["sockets"]])
finally:
self.delete_asset(dup)
def test_add_socket_invalid_bone(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
self.skipTest("Engine TutorialTPP mesh not available")
dup = f"{TEST_ROOT}/MCP_TestSkelMesh2"
self.delete_asset(dup)
mesh = unreal.EditorAssetLibrary.duplicate_asset(_TPP_MESH, dup)
self.assertIsNotNone(mesh)
try:
r = self.call("animation_actions", "ue_add_socket", asset_path=dup,
socket_name="MCP_Bad", bone_name="NoSuchBone_XYZ", location=[0, 0, 0])
self.assertFalse(r.get("success"))
finally:
self.delete_asset(dup)

View File

@@ -0,0 +1,237 @@
from UnrealMCPython.tests.base import MCPTestCase
class TestAssetActions(MCPTestCase):
def test_find_by_type(self):
r = self.call("asset_actions", "ue_find_by_query", asset_type="StaticMesh")
self.assertSuccess(r)
self.assertIsInstance(r["assets"], list)
def test_find_by_name(self):
r = self.call("asset_actions", "ue_find_by_query", name="Cube")
self.assertSuccess(r)
self.assertIsInstance(r["assets"], list)
def test_find_by_name_and_type(self):
r = self.call("asset_actions", "ue_find_by_query",
name="Cube", asset_type="StaticMesh")
self.assertSuccess(r)
def test_find_missing_params_fails(self):
r = self.call("asset_actions", "ue_find_by_query")
self.assertFalse(r.get("success"))
def test_get_static_mesh_details_invalid(self):
r = self.call("asset_actions", "ue_get_static_mesh_details",
asset_path="/Game/DoesNotExist/FakeMesh")
self.assertFalse(r.get("success"))
# ── asset management ─────────────────────────────────────────────────────────
_SRC = "/Engine/BasicShapes/Cube"
_DIR = "/Game/Tests/MCP_AssetMgmt"
def _dup(self, name):
import unreal
dst = f"{self._DIR}/{name}"
if unreal.EditorAssetLibrary.does_asset_exist(dst):
unreal.EditorAssetLibrary.delete_asset(dst)
return dst
def test_asset_exists(self):
r = self.call("asset_actions", "ue_asset_exists", asset_path=self._SRC)
self.assertSuccess(r)
self.assertTrue(r["exists"])
r = self.call("asset_actions", "ue_asset_exists", asset_path="/Game/Nope_XYZ")
self.assertFalse(r["exists"])
def test_get_asset_info(self):
r = self.call("asset_actions", "ue_get_asset_info", asset_path=self._SRC)
self.assertSuccess(r)
self.assertEqual(r["asset_class"], "StaticMesh")
def test_duplicate_and_delete_asset(self):
import unreal
self.call("asset_actions", "ue_make_directory", directory_path=self._DIR)
dst = self._dup("DupCube")
try:
r = self.call("asset_actions", "ue_duplicate_asset", source_path=self._SRC, dest_path=dst)
self.assertSuccess(r)
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(dst))
r = self.call("asset_actions", "ue_delete_asset", asset_path=dst)
self.assertSuccess(r)
self.assertFalse(unreal.EditorAssetLibrary.does_asset_exist(dst))
finally:
if unreal.EditorAssetLibrary.does_asset_exist(dst):
unreal.EditorAssetLibrary.delete_asset(dst)
def test_rename_asset(self):
import unreal
self.call("asset_actions", "ue_make_directory", directory_path=self._DIR)
src = self._dup("RenameSrc")
dst = self._dup("RenameDst")
unreal.EditorAssetLibrary.duplicate_asset(self._SRC, src)
try:
r = self.call("asset_actions", "ue_rename_asset", source_path=src, dest_path=dst)
self.assertSuccess(r)
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(dst))
finally:
for p in (src, dst):
if unreal.EditorAssetLibrary.does_asset_exist(p):
unreal.EditorAssetLibrary.delete_asset(p)
def test_list_assets(self):
r = self.call("asset_actions", "ue_list_assets",
directory_path="/Engine/BasicShapes", recursive=True)
self.assertSuccess(r)
self.assertGreater(r["count"], 0)
def test_find_referencers(self):
r = self.call("asset_actions", "ue_find_referencers", asset_path=self._SRC)
self.assertSuccess(r)
self.assertIn("referencers", r)
def test_make_and_delete_directory(self):
import unreal
d = "/Game/Tests/MCP_TempDir"
r = self.call("asset_actions", "ue_make_directory", directory_path=d)
self.assertSuccess(r)
self.assertTrue(unreal.EditorAssetLibrary.does_directory_exist(d))
r = self.call("asset_actions", "ue_delete_directory", directory_path=d)
self.assertSuccess(r)
def test_delete_asset_missing(self):
r = self.call("asset_actions", "ue_delete_asset", asset_path="/Game/Nope_XYZ123")
self.assertFalse(r.get("success"))
def test_save_asset(self):
import unreal
self.call("asset_actions", "ue_make_directory", directory_path=self._DIR)
dst = self._dup("SaveCube")
unreal.EditorAssetLibrary.duplicate_asset(self._SRC, dst)
try:
r = self.call("asset_actions", "ue_save_asset", asset_path=dst)
self.assertSuccess(r)
finally:
if unreal.EditorAssetLibrary.does_asset_exist(dst):
unreal.EditorAssetLibrary.delete_asset(dst)
def test_save_asset_missing(self):
r = self.call("asset_actions", "ue_save_asset", asset_path="/Game/Nope_XYZ123")
self.assertFalse(r.get("success"))
def test_get_dependencies(self):
r = self.call("asset_actions", "ue_get_dependencies", asset_path=self._SRC)
self.assertSuccess(r)
self.assertIn("dependencies", r)
def test_get_dependencies_missing(self):
r = self.call("asset_actions", "ue_get_dependencies", asset_path="/Game/Nope_XYZ")
self.assertFalse(r.get("success"))
def test_metadata_tag_roundtrip(self):
import unreal
self.call("asset_actions", "ue_make_directory", directory_path=self._DIR)
dst = self._dup("MetaCube")
unreal.EditorAssetLibrary.duplicate_asset(self._SRC, dst)
try:
r = self.call("asset_actions", "ue_set_metadata_tag",
asset_path=dst, tag="MCP_Tag", value="hello")
self.assertSuccess(r)
r = self.call("asset_actions", "ue_get_metadata_tag", asset_path=dst, tag="MCP_Tag")
self.assertEqual(r["value"], "hello")
r = self.call("asset_actions", "ue_remove_metadata_tag", asset_path=dst, tag="MCP_Tag")
self.assertSuccess(r)
r = self.call("asset_actions", "ue_get_metadata_tag", asset_path=dst, tag="MCP_Tag")
self.assertEqual(r["value"], "")
finally:
if unreal.EditorAssetLibrary.does_asset_exist(dst):
unreal.EditorAssetLibrary.delete_asset(dst)
# ── file import / export ─────────────────────────────────────────────────────
def test_export_import_fbx_roundtrip(self):
import unreal, os, tempfile
fbx = os.path.join(tempfile.gettempdir(), "mcp_test_roundtrip.fbx")
imported = None
try:
r = self.call("asset_actions", "ue_export_fbx",
asset_path="/Engine/BasicShapes/Cube", file_path=fbx)
self.assertSuccess(r)
self.assertGreater(r["file_size"], 0)
r = self.call("asset_actions", "ue_import_fbx",
file_path=fbx, destination_path="/Game/Tests/MCP",
destination_name="MCP_RoundtripCube")
self.assertSuccess(r)
imported = r["imported_assets"][0]
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(imported))
finally:
if imported:
self.delete_asset(imported)
if os.path.exists(fbx):
os.remove(fbx)
def test_import_texture(self):
import unreal, os, tempfile, struct, zlib
def png_bytes():
w = h = 2
raw = b""
for _ in range(h):
raw += b"\x00" + (b"\xff\x00\x00") * w
def chunk(t, d):
c = t + d
return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
return (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
p = os.path.join(tempfile.gettempdir(), "mcp_test_tex.png")
with open(p, "wb") as f:
f.write(png_bytes())
imported = None
try:
r = self.call("asset_actions", "ue_import_texture",
file_path=p, destination_path="/Game/Tests/MCP",
destination_name="MCP_ImportedTex")
self.assertSuccess(r)
imported = r["imported_assets"][0]
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(imported))
finally:
if imported:
self.delete_asset(imported)
os.remove(p)
def test_export_fbx_unsupported(self):
import os, tempfile
r = self.call("asset_actions", "ue_export_fbx",
asset_path="/Engine/BasicShapes/BasicShapeMaterial",
file_path=os.path.join(tempfile.gettempdir(), "nope.fbx"))
self.assertFalse(r.get("success"))
def test_import_fbx_missing_file(self):
r = self.call("asset_actions", "ue_import_fbx",
file_path="C:/no/such/file.fbx", destination_path="/Game/Tests/MCP")
self.assertFalse(r.get("success"))
# ── glTF import (deferred-tick; happy path is covered by test_e2e) ────────────
def test_import_gltf_missing_param(self):
r = self.call("asset_actions", "ue_import_gltf", file_path="C:/x.glb")
self.assertFalse(r.get("success")) # destination_path missing
def test_import_gltf_file_not_found(self):
r = self.call("asset_actions", "ue_import_gltf",
file_path="C:/no/such/model.glb", destination_path="/Game/Tests/MCP/glb_guard")
self.assertFalse(r.get("success"))
def test_get_gltf_import_status_missing_param(self):
r = self.call("asset_actions", "ue_get_gltf_import_status")
self.assertFalse(r.get("success"))
def test_get_gltf_import_status_no_import(self):
# nothing scheduled for this path → valid response, not done yet (pending)
r = self.call("asset_actions", "ue_get_gltf_import_status",
destination_path="/Game/Tests/MCP/never_imported_xyz")
self.assertSuccess(r)
self.assertFalse(r["done"])

View File

@@ -0,0 +1,128 @@
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
_BT_PATH = f"{TEST_ROOT}/MCP_TestBT"
_BB_PATH = f"{TEST_ROOT}/MCP_TestBB"
class TestBehaviorTreeActions(MCPTestCase):
def setUp(self):
self._bt_path = None
self._bb_path = None
self.ensure_test_dir()
r = self.call("behavior_tree_actions", "ue_create_blackboard",
asset_path=_BB_PATH)
if r.get("success"):
self._bb_path = _BB_PATH
r = self.call("behavior_tree_actions", "ue_create_behavior_tree",
asset_path=_BT_PATH)
if r.get("success"):
self._bt_path = _BT_PATH
def tearDown(self):
if self._bt_path:
self.delete_asset(self._bt_path)
if self._bb_path:
self.delete_asset(self._bb_path)
def _skip_if_no_bt(self):
if not self._bt_path:
self.skipTest("BehaviorTree not created in setUp")
def _skip_if_no_bb(self):
if not self._bb_path:
self.skipTest("Blackboard not created in setUp")
# ── list / read ───────────────────────────────────────────────────────────
def test_list_behavior_trees(self):
r = self.call("behavior_tree_actions", "ue_list_behavior_trees")
self.assertSuccess(r)
def test_list_bt_node_classes(self):
r = self.call("behavior_tree_actions", "ue_list_bt_node_classes")
self.assertSuccess(r)
def test_get_behavior_tree_structure(self):
self._skip_if_no_bt()
# Build a minimal tree first so the BT is non-empty
self.call("behavior_tree_actions", "ue_build_behavior_tree",
asset_path=self._bt_path,
tree_structure={"node_class": "BTComposite_Selector", "children": []})
r = self.call("behavior_tree_actions", "ue_get_behavior_tree_structure",
asset_path=self._bt_path)
self.assertSuccess(r)
def test_get_blackboard_data(self):
self._skip_if_no_bb()
r = self.call("behavior_tree_actions", "ue_get_blackboard_data",
asset_path=self._bb_path)
self.assertSuccess(r)
# ── blackboard keys ───────────────────────────────────────────────────────
def test_add_and_remove_blackboard_key(self):
self._skip_if_no_bb()
r = self.call("behavior_tree_actions", "ue_add_blackboard_key",
asset_path=self._bb_path,
key_name="TestFloat", key_type="Float")
self.assertSuccess(r)
r = self.call("behavior_tree_actions", "ue_remove_blackboard_key",
asset_path=self._bb_path, key_name="TestFloat")
self.assertSuccess(r)
def test_add_multiple_key_types(self):
self._skip_if_no_bb()
for key_type in ("Bool", "Int", "Vector", "String"):
r = self.call("behavior_tree_actions", "ue_add_blackboard_key",
asset_path=self._bb_path,
key_name=f"Test_{key_type}", key_type=key_type)
self.assertSuccess(r, f"Failed to add {key_type} key")
# ── link ──────────────────────────────────────────────────────────────────
def test_set_blackboard_to_behavior_tree(self):
self._skip_if_no_bt()
self._skip_if_no_bb()
r = self.call("behavior_tree_actions", "ue_set_blackboard_to_behavior_tree",
bt_path=self._bt_path, bb_path=self._bb_path)
self.assertSuccess(r)
# ── build ─────────────────────────────────────────────────────────────────
def test_build_behavior_tree(self):
self._skip_if_no_bt()
tree = {
"node_class": "BTComposite_Selector",
"children": [
{"node_class": "BTTask_Wait", "properties": {"wait_time": 1.0}},
]
}
r = self.call("behavior_tree_actions", "ue_build_behavior_tree",
asset_path=self._bt_path, tree_structure=tree)
self.assertSuccess(r)
# ── node details / selection ────────────────────────────────────────────────
def test_get_bt_node_details(self):
self._skip_if_no_bt()
# Build a tree, then read a node's details by its name from the structure.
self.call("behavior_tree_actions", "ue_build_behavior_tree",
asset_path=self._bt_path,
tree_structure={"node_class": "BTComposite_Selector", "children": []})
st = self.call("behavior_tree_actions", "ue_get_behavior_tree_structure",
asset_path=self._bt_path)
self.assertSuccess(st)
node_name = st["tree"][0]["node_name"]
r = self.call("behavior_tree_actions", "ue_get_bt_node_details",
asset_path=self._bt_path, node_name=node_name)
self.assertSuccess(r)
def test_get_selected_bt_nodes(self):
# No BT editor open in a headless test run → returns a structured failure.
# We only assert the action runs end-to-end and returns a bool success.
r = self.call("behavior_tree_actions", "ue_get_selected_bt_nodes")
self.assertIsInstance(r.get("success"), bool)

View File

@@ -0,0 +1,228 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
_BP_NAME = "MCP_TestBlueprint"
_BP_PATH = f"{TEST_ROOT}/{_BP_NAME}"
class TestBlueprintActions(MCPTestCase):
def setUp(self):
self._bp_path = None
self.ensure_test_dir()
tools = unreal.AssetToolsHelpers.get_asset_tools()
factory = unreal.BlueprintFactory()
factory.set_editor_property('parent_class', unreal.Actor)
bp = tools.create_asset(_BP_NAME, TEST_ROOT, unreal.Blueprint, factory)
if bp:
self._bp_path = _BP_PATH
unreal.EditorAssetLibrary.save_loaded_asset(bp)
def tearDown(self):
if self._bp_path:
self.delete_asset(self._bp_path)
def _skip_if_no_bp(self):
if not self._bp_path:
self.skipTest("Blueprint not created in setUp")
# ── read ──────────────────────────────────────────────────────────────────
def test_get_graph_info(self):
self._skip_if_no_bp()
r = self.call("blueprint_actions", "ue_get_blueprint_graph_info",
asset_path=self._bp_path)
self.assertSuccess(r)
self.assertIn("nodes", r)
def test_list_callable_functions(self):
self._skip_if_no_bp()
r = self.call("blueprint_actions", "ue_list_callable_functions",
asset_path=self._bp_path)
self.assertSuccess(r)
def test_list_blueprint_variables(self):
self._skip_if_no_bp()
r = self.call("blueprint_actions", "ue_list_blueprint_variables",
asset_path=self._bp_path)
self.assertSuccess(r)
def test_list_blueprint_components(self):
self._skip_if_no_bp()
r = self.call("blueprint_actions", "ue_list_blueprint_components",
asset_path=self._bp_path)
self.assertSuccess(r)
# ── write ─────────────────────────────────────────────────────────────────
def test_add_node_and_compile(self):
self._skip_if_no_bp()
# K2_GetActorLocation lives in Actor, which is the parent class;
# no "target" needed — the code searches the Blueprint's class hierarchy.
node_json = {
"type": "CallFunction",
"function_name": "K2_GetActorLocation"
}
r = self.call("blueprint_actions", "ue_add_blueprint_node",
asset_path=self._bp_path,
graph_name="EventGraph",
node_json=node_json)
self.assertSuccess(r)
r = self.call("blueprint_actions", "ue_compile_blueprint",
asset_path=self._bp_path)
self.assertSuccess(r)
def test_add_component(self):
self._skip_if_no_bp()
r = self.call("blueprint_actions", "ue_add_component_to_blueprint",
asset_path=self._bp_path,
component_class_path="/Script/Engine.StaticMeshComponent",
component_name="TestMeshComp")
self.assertSuccess(r)
def test_add_and_remove_component(self):
self._skip_if_no_bp()
r = self.call("blueprint_actions", "ue_add_component_to_blueprint",
asset_path=self._bp_path,
component_class_path="/Script/Engine.PointLightComponent",
component_name="TestLightComp")
self.assertSuccess(r)
r = self.call("blueprint_actions", "ue_remove_component_from_blueprint",
asset_path=self._bp_path,
component_name="TestLightComp")
self.assertSuccess(r)
def test_auto_layout_graph(self):
self._skip_if_no_bp()
r = self.call("blueprint_actions", "ue_auto_layout_graph",
asset_path=self._bp_path, graph_name="EventGraph")
self.assertSuccess(r)
# ── selection queries ───────────────────────────────────────────────────────
def test_get_selected_bp_nodes(self):
r = self.call("blueprint_actions", "ue_get_selected_bp_nodes")
self.assertSuccess(r)
self.assertIn("selected_nodes", r)
def test_get_selected_bp_node_infos(self):
r = self.call("blueprint_actions", "ue_get_selected_bp_node_infos")
self.assertSuccess(r)
self.assertIn("nodes", r)
# ── node position / remove ──────────────────────────────────────────────────
def _add_node(self, **node_json):
r = self.call("blueprint_actions", "ue_add_blueprint_node",
asset_path=self._bp_path, graph_name="EventGraph",
node_json=node_json)
self.assertSuccess(r)
return r["node_name"]
def test_set_blueprint_node_position(self):
self._skip_if_no_bp()
node = self._add_node(type="CallFunction", function_name="K2_GetActorLocation")
r = self.call("blueprint_actions", "ue_set_blueprint_node_position",
asset_path=self._bp_path, graph_name="EventGraph",
node_name=node, pos_x=320.0, pos_y=128.0)
self.assertSuccess(r)
def test_remove_blueprint_node(self):
self._skip_if_no_bp()
node = self._add_node(type="CallFunction", function_name="K2_GetActorLocation")
r = self.call("blueprint_actions", "ue_remove_blueprint_node",
asset_path=self._bp_path, graph_name="EventGraph",
node_name=node)
self.assertSuccess(r)
# ── connect pins ────────────────────────────────────────────────────────────
def test_connect_blueprint_pins(self):
self._skip_if_no_bp()
event = self._add_node(type="Event", event_name="ReceiveBeginPlay")
setter = self._add_node(type="CallFunction", function_name="K2_SetActorLocation")
r = self.call("blueprint_actions", "ue_connect_blueprint_pins",
asset_path=self._bp_path, graph_name="EventGraph",
source_node=event, source_pin="then",
target_node=setter, target_pin="execute")
self.assertSuccess(r)
# ── build whole graph ───────────────────────────────────────────────────────
def test_build_blueprint_graph(self):
self._skip_if_no_bp()
structure = {
"nodes": [
{"id": "evt", "type": "Event", "event_name": "ReceiveBeginPlay"},
{"id": "loc", "type": "CallFunction", "function_name": "K2_GetActorLocation"},
],
"connections": [],
}
r = self.call("blueprint_actions", "ue_build_blueprint_graph",
asset_path=self._bp_path, graph_name="EventGraph",
graph_structure=structure)
self.assertSuccess(r)
# ── component property ──────────────────────────────────────────────────────
def test_set_component_property(self):
self._skip_if_no_bp()
self.call("blueprint_actions", "ue_add_component_to_blueprint",
asset_path=self._bp_path,
component_class_path="/Script/Engine.PointLightComponent",
component_name="PropLightComp")
r = self.call("blueprint_actions", "ue_set_component_property",
asset_path=self._bp_path, component_name="PropLightComp",
property_name="Intensity", value="5000.0")
self.assertSuccess(r)
def test_create_blueprint(self):
import unreal
from UnrealMCPython.tests.base import TEST_ROOT
path = f"{TEST_ROOT}/MCP_CreatedBP"
self.delete_asset(path)
try:
r = self.call("blueprint_actions", "ue_create_blueprint",
asset_path=path, parent_class_path="/Script/Engine.Actor")
self.assertSuccess(r)
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(path))
finally:
self.delete_asset(path)
def test_create_blueprint_bad_parent(self):
from UnrealMCPython.tests.base import TEST_ROOT
r = self.call("blueprint_actions", "ue_create_blueprint",
asset_path=f"{TEST_ROOT}/MCP_BadBP", parent_class_path="/Script/Engine.NopeXYZ")
self.assertFalse(r.get("success"))
def test_add_variable(self):
self._skip_if_no_bp()
r = self.call("blueprint_actions", "ue_add_variable",
asset_path=self._bp_path, variable_name="MyFloatVar", variable_type="float")
self.assertSuccess(r)
variables = self.call("blueprint_actions", "ue_list_blueprint_variables",
asset_path=self._bp_path)
self.assertSuccess(variables)
def test_add_variable_bad_type(self):
self._skip_if_no_bp()
r = self.call("blueprint_actions", "ue_add_variable",
asset_path=self._bp_path, variable_name="X", variable_type="notatype")
self.assertFalse(r.get("success"))
def test_set_variable_flags(self):
self._skip_if_no_bp()
self.call("blueprint_actions", "ue_add_variable",
asset_path=self._bp_path, variable_name="FlagVar", variable_type="bool")
r = self.call("blueprint_actions", "ue_set_variable_flags",
asset_path=self._bp_path, variable_name="FlagVar",
instance_editable=True, expose_on_spawn=True)
self.assertSuccess(r)
self.assertTrue(r["applied"]["instance_editable"])
def test_set_variable_flags_none(self):
self._skip_if_no_bp()
r = self.call("blueprint_actions", "ue_set_variable_flags",
asset_path=self._bp_path, variable_name="X")
self.assertFalse(r.get("success"))

View File

@@ -0,0 +1,73 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
_MESH = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/TutorialTPP"
_CR_PATH = f"{TEST_ROOT}/MCP_TestCR"
class TestControlRigActions(MCPTestCase):
def setUp(self):
if not hasattr(unreal, "ControlRigBlueprint"):
self.skipTest("ControlRig plugin not enabled")
self.ensure_test_dir()
self.delete_asset(_CR_PATH)
def tearDown(self):
self.delete_asset(_CR_PATH)
def test_create_empty_and_info(self):
r = self.call("control_rig_actions", "ue_create_control_rig", asset_path=_CR_PATH)
self.assertSuccess(r)
r = self.call("control_rig_actions", "ue_get_control_rig_info", asset_path=_CR_PATH)
self.assertSuccess(r)
self.assertIn("element_counts", r)
def test_create_from_mesh_imports_bones(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_MESH):
self.skipTest("Engine TutorialTPP mesh not available")
r = self.call("control_rig_actions", "ue_create_control_rig",
asset_path=_CR_PATH, skeletal_mesh_path=_MESH)
self.assertSuccess(r)
self.assertGreater(r["imported_bones"], 0)
info = self.call("control_rig_actions", "ue_get_control_rig_info", asset_path=_CR_PATH)
self.assertGreater(info["element_counts"].get("BONE", 0), 0)
self.assertIsNotNone(info["preview_mesh"])
def test_add_bone_and_null(self):
self.call("control_rig_actions", "ue_create_control_rig", asset_path=_CR_PATH)
r = self.call("control_rig_actions", "ue_add_rig_bone",
asset_path=_CR_PATH, bone_name="RootBone", location=[0, 0, 10])
self.assertSuccess(r)
r = self.call("control_rig_actions", "ue_add_rig_bone",
asset_path=_CR_PATH, bone_name="ChildBone",
parent_name="RootBone", parent_type="bone")
self.assertSuccess(r)
r = self.call("control_rig_actions", "ue_add_rig_null",
asset_path=_CR_PATH, null_name="GroupNull")
self.assertSuccess(r)
info = self.call("control_rig_actions", "ue_get_control_rig_info", asset_path=_CR_PATH)
self.assertEqual(info["element_counts"].get("BONE"), 2)
self.assertEqual(info["element_counts"].get("NULL"), 1)
def test_add_unit_node_and_recompile(self):
self.call("control_rig_actions", "ue_create_control_rig", asset_path=_CR_PATH)
r = self.call("control_rig_actions", "ue_add_unit_node",
asset_path=_CR_PATH, struct_path="/Script/ControlRig.RigUnit_GetTransform")
self.assertSuccess(r)
self.assertTrue(r["node"])
r = self.call("control_rig_actions", "ue_recompile_control_rig", asset_path=_CR_PATH)
self.assertSuccess(r)
def test_add_unit_node_bad_struct(self):
self.call("control_rig_actions", "ue_create_control_rig", asset_path=_CR_PATH)
r = self.call("control_rig_actions", "ue_add_unit_node",
asset_path=_CR_PATH, struct_path="/Script/ControlRig.RigUnit_NopeXYZ")
self.assertFalse(r.get("success"))
def test_bad_parent_type(self):
self.call("control_rig_actions", "ue_create_control_rig", asset_path=_CR_PATH)
r = self.call("control_rig_actions", "ue_add_rig_bone",
asset_path=_CR_PATH, bone_name="X",
parent_name="Y", parent_type="nonsense")
self.assertFalse(r.get("success"))

View File

@@ -0,0 +1,104 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
# A DataTable that ships with the NNEDenoiser plugin (10 rows) — duplicated into
# /Game so write actions never touch read-only plugin assets.
_SRC_DT = "/NNEDenoiser/NNEDIM_ColorAlbedoNormal_Default"
_DT_NAME = "MCP_TestDataTable"
_DT_PATH = f"{TEST_ROOT}/{_DT_NAME}"
class TestDataTableActions(MCPTestCase):
def setUp(self):
self._dt_path = None
self.ensure_test_dir()
self.delete_asset(_DT_PATH)
if unreal.EditorAssetLibrary.does_asset_exist(_SRC_DT):
dt = unreal.EditorAssetLibrary.duplicate_asset(_SRC_DT, _DT_PATH)
if dt:
self._dt_path = _DT_PATH
def tearDown(self):
if self._dt_path:
self.delete_asset(self._dt_path)
def _skip_if_no_dt(self):
if not self._dt_path:
self.skipTest("Source DataTable (NNEDenoiser) not available")
def test_get_row_names(self):
self._skip_if_no_dt()
r = self.call("data_table_actions", "ue_get_row_names", asset_path=self._dt_path)
self.assertSuccess(r)
self.assertGreater(r["count"], 0)
def test_get_column_names(self):
self._skip_if_no_dt()
r = self.call("data_table_actions", "ue_get_column_names", asset_path=self._dt_path)
self.assertSuccess(r)
self.assertIsInstance(r["columns"], list)
self.assertIsNotNone(r["row_struct"])
def test_get_rows_as_json(self):
self._skip_if_no_dt()
r = self.call("data_table_actions", "ue_get_rows_as_json", asset_path=self._dt_path)
self.assertSuccess(r)
self.assertIn("rows", r)
self.assertTrue(r["rows"].strip().startswith("["))
def test_export_to_csv(self):
self._skip_if_no_dt()
r = self.call("data_table_actions", "ue_export_to_csv", asset_path=self._dt_path)
self.assertSuccess(r)
self.assertIn("csv", r)
def test_does_row_exist(self):
self._skip_if_no_dt()
names = self.call("data_table_actions", "ue_get_row_names", asset_path=self._dt_path)["row_names"]
r = self.call("data_table_actions", "ue_does_row_exist",
asset_path=self._dt_path, row_name=names[0])
self.assertSuccess(r)
self.assertTrue(r["exists"])
r = self.call("data_table_actions", "ue_does_row_exist",
asset_path=self._dt_path, row_name="NoSuchRow_XYZ")
self.assertFalse(r["exists"])
def test_remove_row(self):
self._skip_if_no_dt()
names = self.call("data_table_actions", "ue_get_row_names", asset_path=self._dt_path)["row_names"]
before = len(names)
r = self.call("data_table_actions", "ue_remove_row",
asset_path=self._dt_path, row_name=names[0])
self.assertSuccess(r)
after = self.call("data_table_actions", "ue_get_row_names", asset_path=self._dt_path)["count"]
self.assertEqual(after, before - 1)
def test_remove_row_missing(self):
self._skip_if_no_dt()
r = self.call("data_table_actions", "ue_remove_row",
asset_path=self._dt_path, row_name="NoSuchRow_XYZ")
self.assertFalse(r.get("success"))
def test_set_rows_from_json(self):
self._skip_if_no_dt()
original = self.call("data_table_actions", "ue_get_rows_as_json", asset_path=self._dt_path)["rows"]
r = self.call("data_table_actions", "ue_set_rows_from_json",
asset_path=self._dt_path, json_string=original)
self.assertSuccess(r)
self.assertGreater(r["row_count"], 0)
def test_create_data_table(self):
self._skip_if_no_dt()
# Reuse the source table's row struct to create a fresh, empty DataTable.
struct_path = self.call("data_table_actions", "ue_get_column_names",
asset_path=self._dt_path)["row_struct"]
new_path = f"{TEST_ROOT}/MCP_CreatedDT"
self.delete_asset(new_path)
try:
r = self.call("data_table_actions", "ue_create_data_table",
asset_path=new_path, row_struct_path=struct_path)
self.assertSuccess(r)
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(new_path))
finally:
self.delete_asset(new_path)

View File

@@ -0,0 +1,205 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
_CLASS_PATH = "/Script/Engine.PointLight"
class TestEditorActions(MCPTestCase):
def setUp(self):
self._actor_label = None
self._actor_path = None
r = self.call("actor_actions", "ue_spawn_from_class",
class_path=_CLASS_PATH, location=[0, 0, 600])
if r.get("success"):
self._actor_label = r["actor_label"]
self._actor_path = r.get("actor_path")
def tearDown(self):
if self._actor_label:
self.delete_actor_by_label(self._actor_label)
# ── get selected assets ───────────────────────────────────────────────────
def test_get_selected_assets(self):
r = self.call("editor_actions", "ue_get_selected_assets")
self.assertSuccess(r)
self.assertIn("selected_assets", r)
self.assertIsInstance(r["selected_assets"], list)
# ── material replace (specified) ──────────────────────────────────────────
def test_replace_mtl_on_specified_no_mesh_actor(self):
# PointLight has no mesh component — operation should succeed but change 0 slots
if not self._actor_path:
self.skipTest("Actor not spawned in setUp")
r = self.call("editor_actions", "ue_replace_mtl_on_specified",
actor_paths=[self._actor_path],
material_to_be_replaced_path="/Game/DoesNotExist/OldMat",
new_material_path="/Game/DoesNotExist/NewMat")
# No mesh to replace on, so result depends on implementation;
# at minimum, the call should not crash the editor
self.assertIsInstance(r.get("success"), bool)
# ── mesh replace (specified) ──────────────────────────────────────────────
def test_replace_mesh_on_specified_no_mesh_actor(self):
if not self._actor_path:
self.skipTest("Actor not spawned in setUp")
r = self.call("editor_actions", "ue_replace_mesh_on_specified",
actor_paths=[self._actor_path],
mesh_to_be_replaced_path="/Game/DoesNotExist/OldMesh",
new_mesh_path="/Game/DoesNotExist/NewMesh")
self.assertIsInstance(r.get("success"), bool)
# ── replace on selected ─────────────────────────────────────────────────────
def _select_setup_actor(self):
import unreal
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
actor = next((a for a in sub.get_all_level_actors()
if a.get_actor_label() == self._actor_label), None)
self.assertIsNotNone(actor, "setUp actor not found")
sub.set_selected_level_actors([actor])
def test_replace_mtl_on_selected_no_mesh_actor(self):
if not self._actor_label:
self.skipTest("Actor not spawned in setUp")
self._select_setup_actor()
r = self.call("editor_actions", "ue_replace_mtl_on_selected",
material_to_be_replaced_path="/Game/DoesNotExist/OldMat",
new_material_path="/Game/DoesNotExist/NewMat")
self.assertIsInstance(r.get("success"), bool)
def test_replace_mesh_on_selected_no_mesh_actor(self):
if not self._actor_label:
self.skipTest("Actor not spawned in setUp")
self._select_setup_actor()
r = self.call("editor_actions", "ue_replace_mesh_on_selected",
mesh_to_be_replaced_path="/Game/DoesNotExist/OldMesh",
new_mesh_path="/Game/DoesNotExist/NewMesh")
self.assertIsInstance(r.get("success"), bool)
# ── replace selected with blueprint ─────────────────────────────────────────
def test_replace_selected_with_bp_invalid_path(self):
# Use an invalid BP path so the setUp actor is not actually replaced
# (keeps the test session/cleanup intact). Proves the action runs.
if not self._actor_label:
self.skipTest("Actor not spawned in setUp")
self._select_setup_actor()
r = self.call("editor_actions", "ue_replace_selected_with_bp",
blueprint_asset_path="/Game/DoesNotExist/BP_Nope")
self.assertFalse(r.get("success"))
# ── merge / join / proxy ─────────────────────────────────────────────────────
def _spawn_cubes(self, n=2):
labels = []
for i in range(n):
r = self.call("actor_actions", "ue_spawn_from_object",
asset_path="/Engine/BasicShapes/Cube", location=[i * 150, 0, 2200])
self.assertSuccess(r)
labels.append(r["actor_label"])
return labels
def test_merge_actors(self):
import unreal
labels = self._spawn_cubes()
merged_actor = None
mesh_asset = None
try:
r = self.call("editor_actions", "ue_merge_actors",
actor_labels=labels,
base_package_name="/Game/Tests/MCP/MCP_Merged")
self.assertSuccess(r)
merged_actor = r["merged_actor"]
mesh_asset = r["mesh_asset"]
self.assertIsNotNone(mesh_asset)
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(mesh_asset))
finally:
for l in labels + ([merged_actor] if merged_actor else []):
self.delete_actor_by_label(l)
if mesh_asset:
self.delete_asset(mesh_asset)
def test_join_actors(self):
labels = self._spawn_cubes()
joined = None
try:
r = self.call("editor_actions", "ue_join_actors",
actor_labels=labels, new_actor_label="MCP_Joined")
self.assertSuccess(r)
joined = r["joined_actor"]
finally:
for l in labels + ([joined] if joined else []):
self.delete_actor_by_label(l)
def test_create_proxy_actor(self):
import unreal
labels = self._spawn_cubes()
proxy = None
mesh_asset = None
try:
r = self.call("editor_actions", "ue_create_proxy_actor",
actor_labels=labels,
base_package_name="/Game/Tests/MCP/MCP_Proxy",
screen_size=300)
self.assertSuccess(r)
proxy = r["proxy_actor"]
mesh_asset = r["mesh_asset"]
finally:
for l in labels + ([proxy] if proxy else []):
self.delete_actor_by_label(l)
if mesh_asset:
self.delete_asset(mesh_asset)
def test_merge_actors_missing(self):
r = self.call("editor_actions", "ue_merge_actors",
actor_labels=["NoSuchActor_XYZ"],
base_package_name="/Game/Tests/MCP/Nope")
self.assertFalse(r.get("success"))
class TestEditorAssetControl(MCPTestCase):
_ASSET = f"{TEST_ROOT}/MCP_OpenEditorTest"
def setUp(self):
self.ensure_test_dir()
self.delete_asset(self._ASSET)
factory = unreal.BlueprintFactory()
factory.set_editor_property("parent_class", unreal.Actor)
name = self._ASSET.rsplit("/", 1)[1]
self._bp = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
name, TEST_ROOT, unreal.Blueprint, factory)
self.assertIsNotNone(self._bp, "could not create test blueprint")
def tearDown(self):
try:
self.call("editor_actions", "ue_close_asset_editor", asset_path=self._ASSET)
finally:
self.delete_asset(self._ASSET)
def _open_paths(self):
opened = self.call("editor_actions", "ue_get_open_assets")
self.assertSuccess(opened)
return [a["asset_path"].split(".")[0] for a in opened["open_assets"]]
def test_open_lists_then_close_removes(self):
r = self.call("editor_actions", "ue_open_editor_for_asset", asset_path=self._ASSET)
self.assertSuccess(r)
self.assertIn(self._ASSET, self._open_paths())
r = self.call("editor_actions", "ue_close_asset_editor", asset_path=self._ASSET)
self.assertSuccess(r)
self.assertNotIn(self._ASSET, self._open_paths())
def test_open_editor_rejects_missing_asset(self):
r = self.call("editor_actions", "ue_open_editor_for_asset",
asset_path=f"{TEST_ROOT}/NoSuchAsset_XYZ")
self.assertFalse(r.get("success"))
def test_open_editor_missing_param(self):
r = self.call("editor_actions", "ue_open_editor_for_asset")
self.assertFalse(r.get("success"))

View File

@@ -0,0 +1,72 @@
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
_IA_PATH = f"{TEST_ROOT}/IA_TestJump"
_IMC_PATH = f"{TEST_ROOT}/IMC_TestDefault"
class TestGameActions(MCPTestCase):
def setUp(self):
self._ia_path = None
self._imc_path = None
self.ensure_test_dir()
def tearDown(self):
# Clear GameMode override
self.call("game_actions", "ue_set_game_mode", game_mode_class_path=None)
# Clean up created input assets
if self._ia_path:
self.delete_asset(self._ia_path)
if self._imc_path:
self.delete_asset(self._imc_path)
# ── game mode ─────────────────────────────────────────────────────────────
def test_clear_game_mode(self):
r = self.call("game_actions", "ue_set_game_mode",
game_mode_class_path=None)
self.assertSuccess(r)
def test_set_game_mode_invalid_path(self):
r = self.call("game_actions", "ue_set_game_mode",
game_mode_class_path="/Game/DoesNotExist/BP_FakeMode_C")
self.assertFalse(r.get("success"))
def test_set_game_mode_engine_class(self):
r = self.call("game_actions", "ue_set_game_mode",
game_mode_class_path="/Script/Engine.GameModeBase")
self.assertSuccess(r)
# ── input action ──────────────────────────────────────────────────────────
def test_add_input_action_bool(self):
r = self.call("game_actions", "ue_add_input_action",
asset_path=_IA_PATH, value_type="Bool")
self.assertSuccess(r)
if r.get("success"):
self._ia_path = _IA_PATH
def test_add_input_action_axis2d(self):
ia_path = f"{TEST_ROOT}/IA_TestLook"
r = self.call("game_actions", "ue_add_input_action",
asset_path=ia_path, value_type="Axis2D")
self.assertSuccess(r)
if r.get("success"):
self.delete_asset(ia_path)
# ── input mapping ─────────────────────────────────────────────────────────
def test_add_input_mapping(self):
# Create the input action first
r = self.call("game_actions", "ue_add_input_action",
asset_path=_IA_PATH, value_type="Bool")
if r.get("success"):
self._ia_path = _IA_PATH
r = self.call("game_actions", "ue_add_input_mapping",
mapping_context_path=_IMC_PATH,
action_path=_IA_PATH,
key_name="SpaceBar")
self.assertSuccess(r)
if r.get("success"):
self._imc_path = _IMC_PATH

View File

@@ -0,0 +1,130 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
_GA_PATH = f"{TEST_ROOT}/MCP_TestGA"
_GE_PATH = f"{TEST_ROOT}/MCP_TestGE"
_TEST_ATTR_SET = "/Script/GameplayAbilities.AbilitySystemTestAttributeSet"
class TestGasActions(MCPTestCase):
def setUp(self):
if not hasattr(unreal, "GameplayAbility"):
self.skipTest("GameplayAbilities plugin not enabled")
self.ensure_test_dir()
for p in (_GA_PATH, _GE_PATH):
self.delete_asset(p)
def tearDown(self):
for p in (_GA_PATH, _GE_PATH):
self.delete_asset(p)
# ── abilities ────────────────────────────────────────────────────────────────
def test_create_ability_and_info(self):
r = self.call("gas_actions", "ue_create_ability_blueprint", asset_path=_GA_PATH)
self.assertSuccess(r)
r = self.call("gas_actions", "ue_get_ability_info", asset_path=_GA_PATH)
self.assertSuccess(r)
self.assertEqual(r["ability_tags"], [])
self.assertIsNone(r["cost_effect"])
def test_create_ability_bad_parent(self):
r = self.call("gas_actions", "ue_create_ability_blueprint",
asset_path=_GA_PATH, parent_class_path="/Script/Engine.Actor")
self.assertFalse(r.get("success"))
def test_set_ability_costs(self):
self.call("gas_actions", "ue_create_ability_blueprint", asset_path=_GA_PATH)
self.call("gas_actions", "ue_create_effect_blueprint", asset_path=_GE_PATH)
r = self.call("gas_actions", "ue_set_ability_costs",
asset_path=_GA_PATH, cost_effect_path=_GE_PATH,
cooldown_effect_path=_GE_PATH)
self.assertSuccess(r)
info = self.call("gas_actions", "ue_get_ability_info", asset_path=_GA_PATH)
self.assertIsNotNone(info["cost_effect"])
self.assertIsNotNone(info["cooldown_effect"])
def test_set_ability_tags_unregistered(self):
self.call("gas_actions", "ue_create_ability_blueprint", asset_path=_GA_PATH)
r = self.call("gas_actions", "ue_set_ability_tags",
asset_path=_GA_PATH, tags=["MCP.NoSuchTag.Xyz"])
self.assertSuccess(r)
self.assertIn("MCP.NoSuchTag.Xyz", r["unresolved_tags"])
# ── effects ──────────────────────────────────────────────────────────────────
def test_create_effect_with_duration(self):
r = self.call("gas_actions", "ue_create_effect_blueprint",
asset_path=_GE_PATH, duration_policy="has_duration",
duration_seconds=8.0)
self.assertSuccess(r)
info = self.call("gas_actions", "ue_get_effect_info", asset_path=_GE_PATH)
self.assertSuccess(info)
self.assertEqual(info["duration_policy"], "has_duration")
self.assertAlmostEqual(info["duration_seconds"], 8.0, places=2)
def test_set_effect_duration_bad_policy(self):
self.call("gas_actions", "ue_create_effect_blueprint", asset_path=_GE_PATH)
r = self.call("gas_actions", "ue_set_effect_duration",
asset_path=_GE_PATH, duration_policy="nonsense")
self.assertFalse(r.get("success"))
def test_add_and_clear_modifier(self):
self.call("gas_actions", "ue_create_effect_blueprint", asset_path=_GE_PATH)
r = self.call("gas_actions", "ue_add_effect_modifier",
asset_path=_GE_PATH, attribute_set_path=_TEST_ATTR_SET,
attribute_name="Health", op="add_base", magnitude=25.0)
self.assertSuccess(r)
self.assertEqual(r["modifier_count"], 1)
info = self.call("gas_actions", "ue_get_effect_info", asset_path=_GE_PATH)
self.assertEqual(len(info["modifiers"]), 1)
mod = info["modifiers"][0]
self.assertEqual(mod["attribute"], "Health")
self.assertEqual(mod["op"], "add_base")
self.assertAlmostEqual(mod["magnitude"], 25.0, places=2)
r = self.call("gas_actions", "ue_clear_effect_modifiers", asset_path=_GE_PATH)
self.assertSuccess(r)
info = self.call("gas_actions", "ue_get_effect_info", asset_path=_GE_PATH)
self.assertEqual(info["modifiers"], [])
def test_add_modifier_bad_op(self):
self.call("gas_actions", "ue_create_effect_blueprint", asset_path=_GE_PATH)
r = self.call("gas_actions", "ue_add_effect_modifier",
asset_path=_GE_PATH, attribute_set_path=_TEST_ATTR_SET,
attribute_name="Health", op="nonsense")
self.assertFalse(r.get("success"))
def test_add_modifier_bad_attribute(self):
self.call("gas_actions", "ue_create_effect_blueprint", asset_path=_GE_PATH)
r = self.call("gas_actions", "ue_add_effect_modifier",
asset_path=_GE_PATH, attribute_set_path=_TEST_ATTR_SET,
attribute_name="NoSuchAttr_XYZ")
self.assertFalse(r.get("success"))
# ── tags (ini-based) ─────────────────────────────────────────────────────────
def test_add_and_list_gameplay_tag(self):
import os
tag = "MCP.Test.TempTag"
ini = os.path.join(unreal.Paths.project_config_dir(), "DefaultGameplayTags.ini")
before = open(ini, encoding="utf-8").read() if os.path.isfile(ini) else None
try:
r = self.call("gas_actions", "ue_add_gameplay_tag", tag=tag, comment="mcp test")
self.assertSuccess(r)
r = self.call("gas_actions", "ue_list_gameplay_tags", prefix="MCP.Test")
self.assertSuccess(r)
self.assertIn(tag, r["tags"])
r = self.call("gas_actions", "ue_add_gameplay_tag", tag=tag)
self.assertFalse(r.get("success")) # duplicate
finally:
if before is None:
if os.path.isfile(ini):
os.remove(ini)
else:
with open(ini, "w", encoding="utf-8") as f:
f.write(before)
def test_add_gameplay_tag_invalid(self):
r = self.call("gas_actions", "ue_add_gameplay_tag", tag="bad tag !!")
self.assertFalse(r.get("success"))

View File

@@ -0,0 +1,57 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase
_LAYER = "MCP_TestLayer"
_CLASS_PATH = "/Script/Engine.PointLight"
class TestLayerActions(MCPTestCase):
def setUp(self):
self._actor_label = None
r = self.call("actor_actions", "ue_spawn_from_class",
class_path=_CLASS_PATH, location=[0, 0, 550])
if r.get("success"):
self._actor_label = r["actor_label"]
def tearDown(self):
self.call("layer_actions", "ue_delete_layer", layer_name=_LAYER)
if self._actor_label:
self.delete_actor_by_label(self._actor_label)
def test_list_layers(self):
r = self.call("layer_actions", "ue_list_layers")
self.assertSuccess(r)
self.assertIsInstance(r["layers"], list)
def test_create_and_delete_layer(self):
r = self.call("layer_actions", "ue_create_layer", layer_name=_LAYER)
self.assertSuccess(r)
layers = self.call("layer_actions", "ue_list_layers")["layers"]
self.assertIn(_LAYER, layers)
r = self.call("layer_actions", "ue_delete_layer", layer_name=_LAYER)
self.assertSuccess(r)
def test_delete_layer_missing(self):
r = self.call("layer_actions", "ue_delete_layer", layer_name="NoSuchLayer_XYZ")
self.assertFalse(r.get("success"))
def test_add_and_remove_actor_in_layer(self):
if not self._actor_label:
self.skipTest("Actor not spawned in setUp")
self.call("layer_actions", "ue_create_layer", layer_name=_LAYER)
r = self.call("layer_actions", "ue_add_actor_to_layer",
actor_label=self._actor_label, layer_name=_LAYER)
self.assertSuccess(r)
actors = self.call("layer_actions", "ue_get_actors_in_layer", layer_name=_LAYER)
self.assertIn(self._actor_label, actors["actors"])
r = self.call("layer_actions", "ue_remove_actor_from_layer",
actor_label=self._actor_label, layer_name=_LAYER)
self.assertSuccess(r)
actors = self.call("layer_actions", "ue_get_actors_in_layer", layer_name=_LAYER)
self.assertNotIn(self._actor_label, actors["actors"])
def test_add_actor_unknown(self):
r = self.call("layer_actions", "ue_add_actor_to_layer",
actor_label="NoSuchActor_XYZ", layer_name=_LAYER)
self.assertFalse(r.get("success"))

View File

@@ -0,0 +1,69 @@
from UnrealMCPython.tests.base import MCPTestCase
class TestLevelActions(MCPTestCase):
def setUp(self):
self._gravity_changed = False
def tearDown(self):
# Restore default gravity if we changed it
if self._gravity_changed:
self.call("level_actions", "ue_set_world_settings", gravity=-980.0)
def test_list_level_actors(self):
r = self.call("level_actions", "ue_list_level_actors")
self.assertSuccess(r)
self.assertIn("actors", r)
self.assertIsInstance(r["actors"], list)
self.assertIn("count", r)
def test_list_level_actors_with_class_filter(self):
r = self.call("level_actions", "ue_list_level_actors", class_filter="Light")
self.assertSuccess(r)
for actor in r["actors"]:
self.assertIn("Light", actor["class"])
def test_set_world_gravity(self):
self._gravity_changed = True
r = self.call("level_actions", "ue_set_world_settings", gravity=-500.0)
self.assertSuccess(r)
self.assertIn("gravity", r.get("applied", {}))
def test_set_world_time_dilation(self):
r = self.call("level_actions", "ue_set_world_settings", time_dilation=1.0)
self.assertSuccess(r)
def test_set_world_settings_no_params(self):
r = self.call("level_actions", "ue_set_world_settings")
self.assertFalse(r.get("success"))
# ── create / load (guard paths only) ────────────────────────────────────────
#
# NOTE: create_level (new_level) and load_level switch/replace the editor's
# OPEN level. Exercising their happy path in the shared in-editor suite is
# destructive — creating a temp level, then deleting it while it is the open
# level, destabilized the editor and reset the TCP server during development.
# So we only assert the parameter-guard path here (runs the action through the
# full chain without mutating the editor session). The empty-param round-trip
# is also covered by the E2E suite.
def test_create_level_missing_path(self):
r = self.call("level_actions", "ue_create_level")
self.assertFalse(r.get("success"))
def test_load_level_missing_path(self):
r = self.call("level_actions", "ue_load_level")
self.assertFalse(r.get("success"))
# ── current level ────────────────────────────────────────────────────────────
def test_get_current_level_path(self):
r = self.call("level_actions", "ue_get_current_level_path")
self.assertSuccess(r)
self.assertIn("level_path", r)
self.assertTrue(r["level_path"])
# save_current_level / save_all_levels are not auto-tested: on an unsaved
# (untitled) level save_current_level can raise a modal Save-As dialog that
# would hang the headless suite. Verified manually instead (see KNOWN_UNTESTED).

View File

@@ -0,0 +1,191 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
_SEQ_NAME = "MCP_TestSequence"
_SEQ_PATH = f"{TEST_ROOT}/{_SEQ_NAME}"
_CAM_CLASS = "/Script/CinematicCamera.CineCameraActor"
class TestLevelSequenceActions(MCPTestCase):
def setUp(self):
self._seq_path = None
self.ensure_test_dir()
self.delete_asset(_SEQ_PATH)
r = self.call("level_sequence_actions", "ue_create_level_sequence",
asset_path=_SEQ_PATH, fps=30.0, duration_seconds=5.0)
if r.get("success"):
self._seq_path = _SEQ_PATH
def tearDown(self):
if self._seq_path:
self.delete_asset(self._seq_path)
def _skip_if_no_seq(self):
if not self._seq_path:
self.skipTest("Level Sequence not created in setUp")
def _add_camera(self):
r = self.call("level_sequence_actions", "ue_add_spawnable_from_class",
asset_path=self._seq_path, class_path=_CAM_CLASS)
self.assertSuccess(r)
return r["binding_name"]
# ── create / info ───────────────────────────────────────────────────────────
def test_create_level_sequence(self):
self.assertIsNotNone(self._seq_path, "Sequence was not created in setUp")
def test_get_sequence_info(self):
self._skip_if_no_seq()
r = self.call("level_sequence_actions", "ue_get_sequence_info", asset_path=self._seq_path)
self.assertSuccess(r)
self.assertEqual(r["fps"], 30.0)
self.assertAlmostEqual(r["playback_end_seconds"], 5.0, places=2)
self.assertIsInstance(r["bindings"], list)
def test_set_playback_range(self):
self._skip_if_no_seq()
r = self.call("level_sequence_actions", "ue_set_playback_range",
asset_path=self._seq_path, start_seconds=1.0, end_seconds=8.0)
self.assertSuccess(r)
info = self.call("level_sequence_actions", "ue_get_sequence_info", asset_path=self._seq_path)
self.assertAlmostEqual(info["playback_end_seconds"], 8.0, places=2)
# ── bindings ─────────────────────────────────────────────────────────────────
def test_add_spawnable_from_class(self):
self._skip_if_no_seq()
name = self._add_camera()
info = self.call("level_sequence_actions", "ue_get_sequence_info", asset_path=self._seq_path)
names = [b["name"] for b in info["bindings"]]
self.assertIn(name, names)
def test_add_possessable(self):
self._skip_if_no_seq()
spawn = self.call("actor_actions", "ue_spawn_from_class",
class_path="/Script/Engine.PointLight", location=[0, 0, 400])
self.assertSuccess(spawn)
label = spawn["actor_label"]
try:
r = self.call("level_sequence_actions", "ue_add_possessable",
asset_path=self._seq_path, actor_label=label)
self.assertSuccess(r)
finally:
self.delete_actor_by_label(label)
def test_remove_binding(self):
self._skip_if_no_seq()
name = self._add_camera()
r = self.call("level_sequence_actions", "ue_remove_binding",
asset_path=self._seq_path, binding_name=name)
self.assertSuccess(r)
self.assertNotIn(name, r["remaining_bindings"])
def test_remove_binding_unknown(self):
self._skip_if_no_seq()
r = self.call("level_sequence_actions", "ue_remove_binding",
asset_path=self._seq_path, binding_name="NoSuchBinding_XYZ")
self.assertFalse(r.get("success"))
# ── tracks / keyframes ───────────────────────────────────────────────────────
def test_add_transform_track(self):
self._skip_if_no_seq()
name = self._add_camera()
r = self.call("level_sequence_actions", "ue_add_transform_track",
asset_path=self._seq_path, binding_name=name)
self.assertSuccess(r)
def test_add_transform_keyframe(self):
self._skip_if_no_seq()
name = self._add_camera()
r = self.call("level_sequence_actions", "ue_add_transform_keyframe",
asset_path=self._seq_path, binding_name=name, time_seconds=2.0,
location=[100.0, 200.0, 300.0], rotation=[0.0, 45.0, 0.0])
self.assertSuccess(r)
self.assertIn("location", r["keyed"])
self.assertIn("rotation", r["keyed"])
# The section must span the keyed time, or the keys are invisible in Sequencer.
start, end = r["section_range_seconds"]
self.assertGreater(end, start)
self.assertGreaterEqual(end, 2.0)
def test_keyframe_beyond_range_extends_section(self):
self._skip_if_no_seq()
name = self._add_camera()
# Key past the 5s playback end → section must extend to include it.
r = self.call("level_sequence_actions", "ue_add_transform_keyframe",
asset_path=self._seq_path, binding_name=name, time_seconds=8.0,
location=[0.0, 0.0, 0.0])
self.assertSuccess(r)
self.assertGreaterEqual(r["section_range_seconds"][1], 8.0)
def test_add_transform_keyframe_no_channels(self):
self._skip_if_no_seq()
name = self._add_camera()
r = self.call("level_sequence_actions", "ue_add_transform_keyframe",
asset_path=self._seq_path, binding_name=name, time_seconds=1.0)
self.assertFalse(r.get("success"))
# ── camera / anim / sequencer editor ─────────────────────────────────────────
def test_add_camera_with_cut_track(self):
self._skip_if_no_seq()
r = self.call("level_sequence_actions", "ue_add_camera",
asset_path=self._seq_path, spawnable=True)
self.assertSuccess(r)
self.assertTrue(r["camera_binding"])
self.assertTrue(r["camera_cut_track"])
info = self.call("level_sequence_actions", "ue_get_sequence_info", asset_path=self._seq_path)
self.assertIn(r["camera_binding"], [b["name"] for b in info["bindings"]])
def test_add_anim_track(self):
self._skip_if_no_seq()
r = self.call("level_sequence_actions", "ue_add_spawnable_from_class",
asset_path=self._seq_path, class_path="/Script/Engine.SkeletalMeshActor")
self.assertSuccess(r)
name = r["binding_name"]
r = self.call("level_sequence_actions", "ue_add_anim_track",
asset_path=self._seq_path, binding_name=name,
anim_path="/Engine/Tutorial/SubEditors/TutorialAssets/Character/Tutorial_Idle")
self.assertSuccess(r)
self.assertEqual(len(r["range_seconds"]), 2)
def test_add_anim_track_bad_anim(self):
self._skip_if_no_seq()
name = self._add_camera()
r = self.call("level_sequence_actions", "ue_add_anim_track",
asset_path=self._seq_path, binding_name=name,
anim_path="/Engine/BasicShapes/Cube")
self.assertFalse(r.get("success"))
def test_open_and_close_sequencer(self):
self._skip_if_no_seq()
r = self.call("level_sequence_actions", "ue_open_in_sequencer", asset_path=self._seq_path)
self.assertSuccess(r)
r = self.call("level_sequence_actions", "ue_close_sequencer")
self.assertSuccess(r)
def test_convert_binding_to_spawnable(self):
self._skip_if_no_seq()
spawn = self.call("actor_actions", "ue_spawn_from_class",
class_path="/Script/Engine.PointLight", location=[0, 0, 450])
self.assertSuccess(spawn)
label = spawn["actor_label"]
try:
r = self.call("level_sequence_actions", "ue_add_possessable",
asset_path=self._seq_path, actor_label=label)
self.assertSuccess(r)
r = self.call("level_sequence_actions", "ue_convert_binding",
asset_path=self._seq_path, binding_name=r["binding_name"], to="spawnable")
self.assertSuccess(r)
self.assertTrue(r["bindings"])
finally:
self.delete_actor_by_label(label)
def test_convert_binding_bad_mode(self):
self._skip_if_no_seq()
r = self.call("level_sequence_actions", "ue_convert_binding",
asset_path=self._seq_path, binding_name="X", to="nonsense")
self.assertFalse(r.get("success"))

View File

@@ -0,0 +1,272 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
_MAT_NAME = "MCP_TestMaterial"
_MAT_PATH = f"{TEST_ROOT}/{_MAT_NAME}"
_MI_NAME = "MCP_TestMI"
_MI_PATH = f"{TEST_ROOT}/{_MI_NAME}"
class TestMaterialActions(MCPTestCase):
def setUp(self):
self._mat_path = None
self._mi_path = None
self.ensure_test_dir()
tools = unreal.AssetToolsHelpers.get_asset_tools()
mat = tools.create_asset(_MAT_NAME, TEST_ROOT, unreal.Material, unreal.MaterialFactoryNew())
if not mat:
return
self._mat_path = _MAT_PATH
# Add a ScalarParameter and VectorParameter so the MI has testable params
scalar = unreal.MaterialEditingLibrary.create_material_expression(
mat, unreal.MaterialExpressionScalarParameter, 0, 0)
if scalar:
scalar.set_editor_property('parameter_name', unreal.Name('TestScalar'))
scalar.set_editor_property('default_value', 0.5)
vec = unreal.MaterialEditingLibrary.create_material_expression(
mat, unreal.MaterialExpressionVectorParameter, 0, 150)
if vec:
vec.set_editor_property('parameter_name', unreal.Name('TestVector'))
sw = unreal.MaterialEditingLibrary.create_material_expression(
mat, unreal.MaterialExpressionStaticBoolParameter, 0, 300)
if sw:
sw.set_editor_property('parameter_name', unreal.Name('TestSwitch'))
tex = unreal.MaterialEditingLibrary.create_material_expression(
mat, unreal.MaterialExpressionTextureSampleParameter2D, 0, 450)
if tex:
tex.set_editor_property('parameter_name', unreal.Name('TestTexture'))
unreal.MaterialEditingLibrary.recompile_material(mat)
unreal.EditorAssetLibrary.save_loaded_asset(mat)
try:
mi = tools.create_asset(_MI_NAME, TEST_ROOT,
unreal.MaterialInstanceConstant,
unreal.MaterialInstanceConstantFactoryNew())
if mi:
# The factory has no 'initial_parent' in this engine version;
# set the parent on the instance directly, then refresh it.
mi.set_editor_property('parent', mat)
self._mi_path = _MI_PATH
unreal.MaterialEditingLibrary.update_material_instance(mi)
unreal.EditorAssetLibrary.save_loaded_asset(mi)
except Exception:
pass
def tearDown(self):
if self._mi_path:
self.delete_asset(self._mi_path)
if self._mat_path:
self.delete_asset(self._mat_path)
# ── expressions ───────────────────────────────────────────────────────────
def test_create_expression(self):
if not self._mat_path:
self.skipTest("Material not created in setUp")
r = self.call("material_actions", "ue_create_expression",
material_path=self._mat_path,
expression_class_name="Constant", node_pos_x=200, node_pos_y=0)
self.assertSuccess(r)
self.assertIn("expression_class", r)
def test_recompile(self):
if not self._mat_path:
self.skipTest("Material not created in setUp")
r = self.call("material_actions", "ue_recompile",
material_path=self._mat_path)
self.assertSuccess(r)
def test_create_expression_invalid_class(self):
if not self._mat_path:
self.skipTest("Material not created in setUp")
r = self.call("material_actions", "ue_create_expression",
material_path=self._mat_path,
expression_class_name="NonExistentExpression999")
self.assertFalse(r.get("success"))
# ── MI scalar ─────────────────────────────────────────────────────────────
def test_set_get_mi_scalar_param(self):
if not self._mi_path:
self.skipTest("MaterialInstance not created in setUp")
r = self.call("material_actions", "ue_set_mi_scalar_param",
instance_path=self._mi_path,
parameter_name="TestScalar", value=0.75)
self.assertSuccess(r)
r = self.call("material_actions", "ue_get_mi_scalar_param",
instance_path=self._mi_path, parameter_name="TestScalar")
self.assertSuccess(r)
self.assertAlmostEqual(r["value"], 0.75, places=3)
# ── MI vector ─────────────────────────────────────────────────────────────
def test_set_get_mi_vector_param(self):
if not self._mi_path:
self.skipTest("MaterialInstance not created in setUp")
color = [1.0, 0.0, 0.5, 1.0]
r = self.call("material_actions", "ue_set_mi_vector_param",
instance_path=self._mi_path,
parameter_name="TestVector", value=color)
self.assertSuccess(r)
r = self.call("material_actions", "ue_get_mi_vector_param",
instance_path=self._mi_path, parameter_name="TestVector")
self.assertSuccess(r)
self.assertEqual(len(r["value"]), 4)
# ── MI static switch ────────────────────────────────────────────────────────
def test_set_get_mi_static_switch(self):
if not self._mi_path:
self.skipTest("MaterialInstance not created in setUp")
r = self.call("material_actions", "ue_set_mi_static_switch",
instance_path=self._mi_path,
parameter_name="TestSwitch", value=True)
self.assertSuccess(r)
r = self.call("material_actions", "ue_get_mi_static_switch",
instance_path=self._mi_path, parameter_name="TestSwitch")
self.assertSuccess(r)
self.assertEqual(r["value"], True)
# ── MI texture ──────────────────────────────────────────────────────────────
def test_set_get_mi_texture_param(self):
if not self._mi_path:
self.skipTest("MaterialInstance not created in setUp")
r = self.call("material_actions", "ue_set_mi_texture_param",
instance_path=self._mi_path, parameter_name="TestTexture",
texture_path="/Engine/EngineResources/DefaultTexture")
self.assertSuccess(r)
r = self.call("material_actions", "ue_get_mi_texture_param",
instance_path=self._mi_path, parameter_name="TestTexture")
self.assertSuccess(r)
# ── connect expressions ─────────────────────────────────────────────────────
def test_connect_expressions(self):
if not self._mat_path:
self.skipTest("Material not created in setUp")
a = self.call("material_actions", "ue_create_expression",
material_path=self._mat_path,
expression_class_name="Constant", node_pos_x=-600, node_pos_y=0)
self.assertSuccess(a)
b = self.call("material_actions", "ue_create_expression",
material_path=self._mat_path,
expression_class_name="Multiply", node_pos_x=-300, node_pos_y=0)
self.assertSuccess(b)
r = self.call("material_actions", "ue_connect_expressions",
material_path=self._mat_path,
from_expression_identifier=a["expression_name"], from_output_name="",
to_expression_identifier=b["expression_name"], to_input_name="A")
self.assertSuccess(r)
# ── asset creation ──────────────────────────────────────────────────────────
def test_create_material(self):
import unreal
path = f"{TEST_ROOT}/MCP_CreatedMat"
try:
r = self.call("material_actions", "ue_create_material", material_path=path)
self.assertSuccess(r)
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(path))
finally:
self.delete_asset(path)
def test_create_material_instance(self):
import unreal
path = f"{TEST_ROOT}/MCP_CreatedMI"
try:
r = self.call("material_actions", "ue_create_material_instance",
instance_path=path, parent_path=self._mat_path)
self.assertSuccess(r)
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(path))
finally:
self.delete_asset(path)
# ── graph authoring ─────────────────────────────────────────────────────────
def test_connect_property(self):
if not self._mat_path:
self.skipTest("Material not created in setUp")
c = self.call("material_actions", "ue_create_expression",
material_path=self._mat_path,
expression_class_name="Constant3Vector", node_pos_x=-400, node_pos_y=0)
self.assertSuccess(c)
r = self.call("material_actions", "ue_connect_property",
material_path=self._mat_path,
from_expression_identifier=c["expression_name"],
from_output_name="", property_name="BaseColor")
self.assertSuccess(r)
def test_connect_property_invalid_name(self):
if not self._mat_path:
self.skipTest("Material not created in setUp")
r = self.call("material_actions", "ue_connect_property",
material_path=self._mat_path,
from_expression_identifier="whatever", property_name="NotAProperty")
self.assertFalse(r.get("success"))
def test_set_expression_property(self):
if not self._mat_path:
self.skipTest("Material not created in setUp")
c = self.call("material_actions", "ue_create_expression",
material_path=self._mat_path,
expression_class_name="Constant", node_pos_x=-600, node_pos_y=0)
self.assertSuccess(c)
r = self.call("material_actions", "ue_set_expression_property",
material_path=self._mat_path,
expression_identifier=c["expression_name"],
property_name="r", value=0.5)
self.assertSuccess(r)
def test_delete_expression(self):
if not self._mat_path:
self.skipTest("Material not created in setUp")
c = self.call("material_actions", "ue_create_expression",
material_path=self._mat_path,
expression_class_name="Constant", node_pos_x=-800, node_pos_y=0)
self.assertSuccess(c)
r = self.call("material_actions", "ue_delete_expression",
material_path=self._mat_path,
expression_identifier=c["expression_name"])
self.assertSuccess(r)
def test_layout_expressions(self):
if not self._mat_path:
self.skipTest("Material not created in setUp")
r = self.call("material_actions", "ue_layout_expressions",
material_path=self._mat_path)
self.assertSuccess(r)
# ── introspection ───────────────────────────────────────────────────────────
def test_get_material_info(self):
if not self._mat_path:
self.skipTest("Material not created in setUp")
r = self.call("material_actions", "ue_get_material_info",
material_path=self._mat_path)
self.assertSuccess(r)
self.assertIn("expression_count", r)
self.assertIsInstance(r["expressions"], list)
def test_list_parameters(self):
if not self._mat_path:
self.skipTest("Material not created in setUp")
r = self.call("material_actions", "ue_list_parameters",
material_path=self._mat_path)
self.assertSuccess(r)
self.assertIn("TestScalar", r["scalar"])
self.assertIn("TestVector", r["vector"])
def test_set_instance_parent(self):
if not self._mi_path or not self._mat_path:
self.skipTest("MI/Material not created in setUp")
r = self.call("material_actions", "ue_set_instance_parent",
instance_path=self._mi_path, parent_path=self._mat_path)
self.assertSuccess(r)

View File

@@ -0,0 +1,87 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
_MESH = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/TutorialTPP"
_ANIM = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/Tutorial_Idle"
_RIG_PATH = f"{TEST_ROOT}/MCP_TestIKRig"
_RTG_PATH = f"{TEST_ROOT}/MCP_TestRTG"
class TestRetargetActions(MCPTestCase):
def setUp(self):
if not hasattr(unreal, "IKRigController"):
self.skipTest("IKRig plugin not enabled")
if not unreal.EditorAssetLibrary.does_asset_exist(_MESH):
self.skipTest("Engine TutorialTPP mesh not available")
self.ensure_test_dir()
for p in (_RTG_PATH, _RIG_PATH):
self.delete_asset(p)
def tearDown(self):
for p in (_RTG_PATH, _RIG_PATH):
self.delete_asset(p)
def _make_rig(self):
r = self.call("retarget_actions", "ue_create_ik_rig",
asset_path=_RIG_PATH, skeletal_mesh_path=_MESH, retarget_root="pelvis")
self.assertSuccess(r)
r = self.call("retarget_actions", "ue_add_retarget_chain",
ik_rig_path=_RIG_PATH, chain_name="Spine",
start_bone="spine_01", end_bone="spine_03")
self.assertSuccess(r)
return _RIG_PATH
def test_create_ik_rig_and_info(self):
self._make_rig()
r = self.call("retarget_actions", "ue_get_ik_rig_info", ik_rig_path=_RIG_PATH)
self.assertSuccess(r)
self.assertEqual(r["retarget_root"], "pelvis")
self.assertEqual(r["chains"][0]["name"], "Spine")
self.assertEqual(r["chains"][0]["start_bone"], "spine_01")
def test_create_ik_rig_bad_mesh(self):
r = self.call("retarget_actions", "ue_create_ik_rig",
asset_path=_RIG_PATH, skeletal_mesh_path="/Engine/BasicShapes/Cube")
self.assertFalse(r.get("success"))
def test_add_chain_missing_params(self):
r = self.call("retarget_actions", "ue_add_retarget_chain", ik_rig_path=_RIG_PATH)
self.assertFalse(r.get("success"))
def test_create_retargeter_and_automap(self):
rig = self._make_rig()
r = self.call("retarget_actions", "ue_create_retargeter",
asset_path=_RTG_PATH, source_ik_rig_path=rig,
target_ik_rig_path=rig, auto_map=True)
self.assertSuccess(r)
r = self.call("retarget_actions", "ue_auto_map_chains",
retargeter_path=_RTG_PATH, mode="EXACT", force=True)
self.assertSuccess(r)
r = self.call("retarget_actions", "ue_auto_map_chains",
retargeter_path=_RTG_PATH, mode="NONSENSE")
self.assertFalse(r.get("success"))
def test_batch_retarget(self):
rig = self._make_rig()
self.call("retarget_actions", "ue_create_retargeter",
asset_path=_RTG_PATH, source_ik_rig_path=rig,
target_ik_rig_path=rig, auto_map=True)
r = self.call("retarget_actions", "ue_batch_retarget",
retargeter_path=_RTG_PATH, anim_paths=[_ANIM],
source_mesh_path=_MESH, target_mesh_path=_MESH,
suffix="_MCPRT")
self.assertSuccess(r)
self.assertEqual(r["count"], 1)
for p in r["retargeted_assets"]:
self.delete_asset(p)
def test_batch_retarget_missing_anim(self):
rig = self._make_rig()
self.call("retarget_actions", "ue_create_retargeter",
asset_path=_RTG_PATH, source_ik_rig_path=rig,
target_ik_rig_path=rig)
r = self.call("retarget_actions", "ue_batch_retarget",
retargeter_path=_RTG_PATH, anim_paths=["/Game/NoSuchAnim_XYZ"],
source_mesh_path=_MESH, target_mesh_path=_MESH)
self.assertFalse(r.get("success"))

View File

@@ -0,0 +1,130 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
_SRC = "/Engine/BasicShapes/Cube"
_DEFAULT_MAT = "/Engine/BasicShapes/BasicShapeMaterial"
class TestStaticMeshActions(MCPTestCase):
def test_get_static_mesh_info(self):
r = self.call("static_mesh_actions", "ue_get_static_mesh_info", asset_path=_SRC)
self.assertSuccess(r)
self.assertGreaterEqual(r["num_lods"], 1)
self.assertGreater(r["num_triangles_lod0"], 0)
self.assertGreater(r["num_vertices_lod0"], 0)
def test_get_static_mesh_info_invalid(self):
r = self.call("static_mesh_actions", "ue_get_static_mesh_info",
asset_path="/Game/DoesNotExist_XYZ")
self.assertFalse(r.get("success"))
def test_list_materials(self):
r = self.call("static_mesh_actions", "ue_list_static_mesh_materials", asset_path=_SRC)
self.assertSuccess(r)
self.assertGreaterEqual(r["num_materials"], 1)
def test_get_collision_info(self):
r = self.call("static_mesh_actions", "ue_get_collision_info", asset_path=_SRC)
self.assertSuccess(r)
self.assertIn("simple_collision_count", r)
def test_set_material(self):
self.ensure_test_dir()
dst = f"{TEST_ROOT}/MCP_SMCopy"
self.delete_asset(dst)
unreal.EditorAssetLibrary.duplicate_asset(_SRC, dst)
try:
r = self.call("static_mesh_actions", "ue_set_static_mesh_material",
asset_path=dst, slot_index=0, material_path=_DEFAULT_MAT)
self.assertSuccess(r)
finally:
self.delete_asset(dst)
def test_add_simple_collision(self):
self.ensure_test_dir()
dst = f"{TEST_ROOT}/MCP_SMCol"
self.delete_asset(dst)
unreal.EditorAssetLibrary.duplicate_asset(_SRC, dst)
try:
before = self.call("static_mesh_actions", "ue_get_collision_info",
asset_path=dst)["simple_collision_count"]
r = self.call("static_mesh_actions", "ue_add_simple_collision",
asset_path=dst, shape="SPHERE")
self.assertSuccess(r)
self.assertGreater(r["simple_collision_count"], before)
finally:
self.delete_asset(dst)
def test_add_simple_collision_bad_shape(self):
r = self.call("static_mesh_actions", "ue_add_simple_collision",
asset_path=_SRC, shape="NOTASHAPE")
self.assertFalse(r.get("success"))
# ── LODs ─────────────────────────────────────────────────────────────────────
def _dup_mesh(self, name):
self.ensure_test_dir()
dst = f"{TEST_ROOT}/{name}"
self.delete_asset(dst)
unreal.EditorAssetLibrary.duplicate_asset(_SRC, dst)
return dst
def test_set_and_remove_lods(self):
dst = self._dup_mesh("MCP_SMLod")
try:
r = self.call("static_mesh_actions", "ue_set_lods", asset_path=dst,
lod_settings=[{"percent_triangles": 1.0, "screen_size": 1.0},
{"percent_triangles": 0.5, "screen_size": 0.5}])
self.assertSuccess(r)
self.assertEqual(r["lod_count"], 2)
r = self.call("static_mesh_actions", "ue_get_lod_screen_sizes", asset_path=dst)
self.assertSuccess(r)
self.assertEqual(len(r["screen_sizes"]), 2)
r = self.call("static_mesh_actions", "ue_remove_lods", asset_path=dst)
self.assertSuccess(r)
self.assertEqual(r["lod_count"], 1)
finally:
self.delete_asset(dst)
def test_set_lods_missing(self):
r = self.call("static_mesh_actions", "ue_set_lods", asset_path=_SRC)
self.assertFalse(r.get("success"))
def test_set_lod_from_static_mesh(self):
dst = self._dup_mesh("MCP_SMLodCopy")
try:
r = self.call("static_mesh_actions", "ue_set_lod_from_static_mesh",
asset_path=dst, lod_index=1,
source_path="/Engine/BasicShapes/Sphere", source_lod_index=0)
self.assertSuccess(r)
self.assertEqual(r["lod_count"], 2)
finally:
self.delete_asset(dst)
# ── convex / collision management ───────────────────────────────────────────
def test_convex_and_remove_collisions(self):
dst = self._dup_mesh("MCP_SMConvex")
try:
r = self.call("static_mesh_actions", "ue_set_convex_collision",
asset_path=dst, hull_count=2, max_hull_verts=16, hull_precision=100000)
self.assertSuccess(r)
self.assertGreaterEqual(r["convex_collision_count"], 1)
r = self.call("static_mesh_actions", "ue_remove_collisions", asset_path=dst)
self.assertSuccess(r)
self.assertEqual(r["convex_collision_count"], 0)
finally:
self.delete_asset(dst)
def test_set_lod_for_collision(self):
dst = self._dup_mesh("MCP_SMLodCol")
try:
r = self.call("static_mesh_actions", "ue_set_lod_for_collision",
asset_path=dst, lod_index=0)
self.assertSuccess(r)
r = self.call("static_mesh_actions", "ue_set_lod_for_collision",
asset_path=dst, lod_index=99)
self.assertFalse(r.get("success"))
finally:
self.delete_asset(dst)

View File

@@ -0,0 +1,55 @@
import unreal
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
_SRC = "/Engine/EngineResources/DefaultTexture"
class TestTextureActions(MCPTestCase):
def test_get_texture_info(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_SRC):
self.skipTest("Engine DefaultTexture not available")
r = self.call("texture_actions", "ue_get_texture_info", asset_path=_SRC)
self.assertSuccess(r)
self.assertGreater(r["width"], 0)
self.assertGreater(r["height"], 0)
self.assertIsInstance(r["srgb"], bool)
def test_get_texture_info_invalid(self):
r = self.call("texture_actions", "ue_get_texture_info", asset_path="/Game/Nope_XYZ")
self.assertFalse(r.get("success"))
def _dup(self, name):
self.ensure_test_dir()
dst = f"{TEST_ROOT}/{name}"
self.delete_asset(dst)
unreal.EditorAssetLibrary.duplicate_asset(_SRC, dst)
return dst
def test_set_texture_srgb(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_SRC):
self.skipTest("Engine DefaultTexture not available")
dst = self._dup("MCP_TexSrgb")
try:
r = self.call("texture_actions", "ue_set_texture_srgb", asset_path=dst, srgb=False)
self.assertSuccess(r)
info = self.call("texture_actions", "ue_get_texture_info", asset_path=dst)
self.assertFalse(info["srgb"])
finally:
self.delete_asset(dst)
def test_set_texture_compression(self):
if not unreal.EditorAssetLibrary.does_asset_exist(_SRC):
self.skipTest("Engine DefaultTexture not available")
dst = self._dup("MCP_TexComp")
try:
r = self.call("texture_actions", "ue_set_texture_compression",
asset_path=dst, compression="TC_NORMALMAP")
self.assertSuccess(r)
finally:
self.delete_asset(dst)
def test_set_texture_compression_invalid(self):
r = self.call("texture_actions", "ue_set_texture_compression",
asset_path=_SRC, compression="NOTACOMPRESSION")
self.assertFalse(r.get("success"))

View File

@@ -0,0 +1,243 @@
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
_WBP_NAME = "MCP_TestWidget"
_WBP_PATH = f"{TEST_ROOT}/{_WBP_NAME}"
_WBP_FULL = f"{_WBP_PATH}.{_WBP_NAME}"
class TestUMGActions(MCPTestCase):
def setUp(self):
self._wbp_path = None
self.ensure_test_dir()
r = self.call("umg_actions", "ue_create_widget_blueprint",
name=_WBP_NAME, path=TEST_ROOT)
if r.get("success"):
self._wbp_path = _WBP_FULL
def tearDown(self):
if self._wbp_path:
self.delete_asset(self._wbp_path)
def _skip_if_no_wbp(self):
if not self._wbp_path:
self.skipTest("WidgetBlueprint not created in setUp")
# ── create / info ─────────────────────────────────────────────────────────
def test_create_widget_blueprint(self):
self.assertIsNotNone(self._wbp_path, "Widget Blueprint was not created in setUp")
def test_get_widget_blueprint_info_empty(self):
self._skip_if_no_wbp()
r = self.call("umg_actions", "ue_get_widget_blueprint_info",
asset_path=self._wbp_path)
self.assertSuccess(r)
self.assertIn("widget_count", r)
# ── add widgets ───────────────────────────────────────────────────────────
def test_add_canvas_panel_root(self):
self._skip_if_no_wbp()
r = self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="CanvasPanel", widget_name="RootCanvas")
self.assertSuccess(r)
self.assertTrue(r.get("is_root"))
def test_add_text_block_under_canvas(self):
self._skip_if_no_wbp()
self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="CanvasPanel", widget_name="RootCanvas")
r = self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="TextBlock", widget_name="TitleText",
parent_name="RootCanvas")
self.assertSuccess(r)
def test_add_and_remove_widget(self):
self._skip_if_no_wbp()
self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="CanvasPanel", widget_name="RootCanvas")
self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="Button", widget_name="RemoveBtn",
parent_name="RootCanvas")
r = self.call("umg_actions", "ue_remove_widget",
asset_path=self._wbp_path, widget_name="RemoveBtn")
self.assertSuccess(r)
# ── properties ────────────────────────────────────────────────────────────
def test_set_widget_properties(self):
self._skip_if_no_wbp()
self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="CanvasPanel", widget_name="RootCanvas")
self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="TextBlock", widget_name="Label",
parent_name="RootCanvas")
r = self.call("umg_actions", "ue_set_widget_properties",
asset_path=self._wbp_path, widget_name="Label",
properties={"text": "Hello", "font_size": 24,
"slot_position": [100.0, 50.0],
"slot_size": [300.0, 60.0]})
self.assertSuccess(r)
def test_set_slot_layout(self):
self._skip_if_no_wbp()
self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="CanvasPanel", widget_name="RootCanvas")
self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="Image", widget_name="BgImage",
parent_name="RootCanvas")
r = self.call("umg_actions", "ue_set_slot_layout",
asset_path=self._wbp_path, widget_name="BgImage",
anchor_min_x=0.0, anchor_min_y=0.0,
anchor_max_x=1.0, anchor_max_y=1.0,
offset_x=0.0, offset_y=0.0,
size_x=800.0, size_y=600.0)
self.assertSuccess(r)
def test_set_get_widget_property(self):
self._skip_if_no_wbp()
self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="CanvasPanel", widget_name="RootCanvas")
self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="TextBlock", widget_name="PropText",
parent_name="RootCanvas")
r = self.call("umg_actions", "ue_set_widget_property",
asset_path=self._wbp_path, widget_name="PropText",
property_name="ToolTipText", value="hello tip")
self.assertSuccess(r)
r = self.call("umg_actions", "ue_get_widget_property",
asset_path=self._wbp_path, widget_name="PropText",
property_name="ToolTipText")
self.assertSuccess(r)
self.assertIn("value", r)
def test_set_text_style(self):
self._skip_if_no_wbp()
self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="CanvasPanel", widget_name="RootCanvas")
self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="TextBlock", widget_name="StyledText",
parent_name="RootCanvas")
r = self.call("umg_actions", "ue_set_text_style",
asset_path=self._wbp_path, widget_name="StyledText",
font_size=30, color_r=1.0, color_g=0.0, color_b=0.0, color_a=1.0)
self.assertSuccess(r)
# ── compile ───────────────────────────────────────────────────────────────
def test_compile_widget_blueprint(self):
self._skip_if_no_wbp()
self.call("umg_actions", "ue_add_widget",
asset_path=self._wbp_path,
widget_type="CanvasPanel", widget_name="RootCanvas")
r = self.call("umg_actions", "ue_compile_widget_blueprint",
asset_path=self._wbp_path)
self.assertSuccess(r)
# ── hierarchy ops: reparent / wrap / replace ─────────────────────────────────
def _root_canvas_with(self, *widgets):
self.call("umg_actions", "ue_add_widget", asset_path=self._wbp_path,
widget_type="CanvasPanel", widget_name="Root")
for wtype, wname in widgets:
self.call("umg_actions", "ue_add_widget", asset_path=self._wbp_path,
widget_type=wtype, widget_name=wname, parent_name="Root")
def test_reparent_widget_and_cycle_guard(self):
self._skip_if_no_wbp()
self._root_canvas_with(("VerticalBox", "VBox"), ("Button", "Btn"))
r = self.call("umg_actions", "ue_reparent_widget", asset_path=self._wbp_path,
widget_name="Btn", new_parent_name="VBox")
self.assertSuccess(r)
# Btn now lives under VBox — reparenting VBox into Btn must be rejected (cycle)
r2 = self.call("umg_actions", "ue_reparent_widget", asset_path=self._wbp_path,
widget_name="VBox", new_parent_name="Btn")
self.assertFalse(r2.get("success"))
def test_reparent_missing_param(self):
self._skip_if_no_wbp()
r = self.call("umg_actions", "ue_reparent_widget",
asset_path=self._wbp_path, widget_name="Btn")
self.assertFalse(r.get("success"))
def test_wrap_widget(self):
self._skip_if_no_wbp()
self._root_canvas_with(("Button", "Btn"))
r = self.call("umg_actions", "ue_wrap_widget", asset_path=self._wbp_path,
widget_name="Btn", wrapper_type="VerticalBox", wrapper_name="Wrapper")
self.assertSuccess(r)
self.assertEqual(r["wrapper_type"], "VerticalBox")
# tree must still compile after the structural change
c = self.call("umg_actions", "ue_compile_widget_blueprint", asset_path=self._wbp_path)
self.assertSuccess(c)
def test_wrap_rejects_non_panel_wrapper(self):
self._skip_if_no_wbp()
self._root_canvas_with(("Button", "Btn"))
r = self.call("umg_actions", "ue_wrap_widget", asset_path=self._wbp_path,
widget_name="Btn", wrapper_type="TextBlock", wrapper_name="BadWrap")
self.assertFalse(r.get("success"))
def test_replace_widget(self):
self._skip_if_no_wbp()
self._root_canvas_with(("Button", "OldBtn"))
r = self.call("umg_actions", "ue_replace_widget", asset_path=self._wbp_path,
widget_name="OldBtn", new_type="Image", new_name="NewImg")
self.assertSuccess(r)
self.assertEqual(r["new_type"], "Image")
# the old widget must be gone — operating on it now fails
gone = self.call("umg_actions", "ue_reparent_widget", asset_path=self._wbp_path,
widget_name="OldBtn", new_parent_name="Root")
self.assertFalse(gone.get("success"))
# ── event binding ────────────────────────────────────────────────────────────
def test_list_widget_events(self):
self._skip_if_no_wbp()
self._root_canvas_with(("Button", "Btn"))
r = self.call("umg_actions", "ue_list_widget_events",
asset_path=self._wbp_path, widget_name="Btn")
self.assertSuccess(r)
# a Button exposes OnClicked among its multicast delegates
self.assertIn("OnClicked", r["events"])
def test_bind_widget_event(self):
self._skip_if_no_wbp()
self._root_canvas_with(("Button", "Btn"))
r = self.call("umg_actions", "ue_bind_widget_event",
asset_path=self._wbp_path, widget_name="Btn", event_name="OnClicked")
self.assertSuccess(r)
self.assertEqual(r["event"], "OnClicked")
self.assertTrue(r["node"])
# binding the same event again is idempotent (reports the existing node)
again = self.call("umg_actions", "ue_bind_widget_event",
asset_path=self._wbp_path, widget_name="Btn", event_name="OnClicked")
self.assertSuccess(again)
self.assertTrue(again["already_existed"])
def test_bind_widget_event_unknown_event(self):
self._skip_if_no_wbp()
self._root_canvas_with(("Button", "Btn"))
r = self.call("umg_actions", "ue_bind_widget_event",
asset_path=self._wbp_path, widget_name="Btn", event_name="OnNopeEvent_XYZ")
self.assertFalse(r.get("success"))
def test_bind_widget_event_missing_param(self):
self._skip_if_no_wbp()
r = self.call("umg_actions", "ue_bind_widget_event",
asset_path=self._wbp_path, widget_name="Btn")
self.assertFalse(r.get("success"))

View File

@@ -0,0 +1,175 @@
from UnrealMCPython.tests.base import MCPTestCase
class TestUtilActions(MCPTestCase):
def test_get_output_log_default(self):
r = self.call("util_actions", "ue_get_output_log", line_count=10)
self.assertSuccess(r)
self.assertIn("log", r)
self.assertIn("total_lines", r)
self.assertIn("returned_lines", r)
def test_get_output_log_with_keyword(self):
r = self.call("util_actions", "ue_get_output_log", line_count=20, keyword="LogMCPython")
self.assertSuccess(r)
self.assertIn("log", r)
def test_get_output_log_with_context(self):
# keyword guaranteed to exist: every editor log starts with a LogInit banner
r = self.call("util_actions", "ue_get_output_log",
line_count=5, keyword="LogInit", context_lines=2)
self.assertSuccess(r)
self.assertIn("log", r)
# context expands each match, so returned_lines must exceed the bare match count
bare = self.call("util_actions", "ue_get_output_log",
line_count=5, keyword="LogInit")
self.assertSuccess(bare)
self.assertGreaterEqual(r["returned_lines"], bare["returned_lines"])
def test_get_output_log_tail_matches_line_count(self):
r = self.call("util_actions", "ue_get_output_log", line_count=3)
self.assertSuccess(r)
self.assertEqual(r["returned_lines"], 3)
self.assertEqual(len(r["log"].splitlines()), 3)
def test_print_message(self):
r = self.call("util_actions", "ue_print_message", message="MCP unittest ping")
self.assertSuccess(r)
self.assertEqual(r["received_message"], "MCP unittest ping")
def test_print_message_missing_param(self):
r = self.call("util_actions", "ue_print_message")
self.assertFalse(r.get("success"))
# ── editor control ───────────────────────────────────────────────────────────
def test_execute_console_command(self):
r = self.call("util_actions", "ue_execute_console_command", command="stat none")
self.assertSuccess(r)
def test_execute_console_command_missing(self):
r = self.call("util_actions", "ue_execute_console_command")
self.assertFalse(r.get("success"))
def test_save_all_dirty(self):
r = self.call("util_actions", "ue_save_all_dirty")
self.assertIn("success", r) # may save nothing if clean; must not error
def test_get_and_set_viewport_camera(self):
cur = self.call("util_actions", "ue_get_viewport_camera")
if not cur.get("success") and "No active level viewport" in cur.get("message", ""):
self.skipTest("No active level viewport (e.g. editor launched without a focused viewport)")
self.assertSuccess(cur)
self.assertEqual(len(cur["location"]), 3)
try:
r = self.call("util_actions", "ue_set_viewport_camera",
location=[500.0, 500.0, 500.0], rotation=[0.0, 90.0, 0.0])
self.assertSuccess(r)
finally:
self.call("util_actions", "ue_set_viewport_camera",
location=cur["location"], rotation=cur["rotation"])
def test_set_viewport_camera_missing(self):
r = self.call("util_actions", "ue_set_viewport_camera")
self.assertFalse(r.get("success"))
# ── world<->screen projection ────────────────────────────────────────────────
def _skip_if_no_viewport(self, r):
if not r.get("success") and "No active level viewport" in r.get("message", ""):
self.skipTest("No active level viewport for projection")
def test_screen_world_round_trips_back_to_pixel(self):
# A deprojected world point lies on the view ray of its pixel, so re-projecting it
# must return the same pixel exactly — the clean projection invariant.
probe = self.call("util_actions", "ue_screen_to_world", x=200.0, y=200.0, distance=500.0)
self._skip_if_no_viewport(probe)
self.assertSuccess(probe)
self.assertEqual(len(probe["location"]), 3)
self.assertEqual(len(probe["direction"]), 3)
back = self.call("util_actions", "ue_world_to_screen", location=probe["location"])
self.assertSuccess(back)
self.assertTrue(back["visible"])
self.assertGreater(back["viewport_width"], 0)
self.assertAlmostEqual(back["x"], 200.0, delta=1.0)
self.assertAlmostEqual(back["y"], 200.0, delta=1.0)
def test_world_to_screen_rejects_bad_location(self):
r = self.call("util_actions", "ue_world_to_screen", location=[1.0, 2.0])
self.assertFalse(r.get("success"))
def test_screen_to_world_missing_param(self):
r = self.call("util_actions", "ue_screen_to_world", x=10.0)
self.assertFalse(r.get("success"))
def test_is_in_pie(self):
r = self.call("util_actions", "ue_is_in_pie")
self.assertSuccess(r)
self.assertIsInstance(r["in_pie"], bool)
def test_list_class_properties(self):
r = self.call("util_actions", "ue_list_class_properties",
class_path="/Script/Engine.PointLight")
self.assertSuccess(r)
self.assertGreater(r["count"], 0)
def test_list_class_properties_invalid(self):
r = self.call("util_actions", "ue_list_class_properties",
class_path="/Script/Engine.NopeXYZ123")
self.assertFalse(r.get("success"))
def test_get_cvar(self):
r = self.call("util_actions", "ue_get_cvar", name="r.ScreenPercentage")
self.assertSuccess(r)
self.assertIn("value", r)
def test_get_cvar_missing(self):
r = self.call("util_actions", "ue_get_cvar")
self.assertFalse(r.get("success"))
def test_set_cvar_round_trips(self):
name = "r.ScreenPercentage"
before = self.call("util_actions", "ue_get_cvar", name=name)["value"]
try:
r = self.call("util_actions", "ue_set_cvar", name=name, value="73")
self.assertSuccess(r)
got = self.call("util_actions", "ue_get_cvar", name=name)["value"]
self.assertEqual(float(got), 73.0)
finally:
self.call("util_actions", "ue_set_cvar", name=name, value=before)
def test_set_cvar_missing_param(self):
r = self.call("util_actions", "ue_set_cvar", name="r.ScreenPercentage")
self.assertFalse(r.get("success"))
def test_set_log_verbosity(self):
try:
r = self.call("util_actions", "ue_set_log_verbosity",
category="LogMCPython", verbosity="Verbose")
self.assertSuccess(r)
finally:
self.call("util_actions", "ue_set_log_verbosity",
category="LogMCPython", verbosity="Log")
def test_set_log_verbosity_invalid(self):
r = self.call("util_actions", "ue_set_log_verbosity",
category="LogMCPython", verbosity="NopeLevel")
self.assertFalse(r.get("success"))
def test_get_project_info(self):
r = self.call("util_actions", "ue_get_project_info")
self.assertSuccess(r)
self.assertIn("engine_version", r)
self.assertTrue(r["project_dir"])
def test_list_enum_values(self):
r = self.call("util_actions", "ue_list_enum_values",
enum_name="TextureCompressionSettings")
self.assertSuccess(r)
self.assertIn("TC_DEFAULT", r["values"])
def test_list_enum_values_unknown(self):
r = self.call("util_actions", "ue_list_enum_values", enum_name="NopeEnumXYZ")
self.assertFalse(r.get("success"))

View File

@@ -0,0 +1,64 @@
import base64
from UnrealMCPython.tests.base import MCPTestCase
class TestVisionActions(MCPTestCase):
def test_capture_viewport(self):
r = self.call("vision_actions", "ue_capture_viewport", width=320, height=180)
self.assertSuccess(r)
self.assertIn("image_data", r)
png = base64.b64decode(r["image_data"])
self.assertEqual(png[:4], b"\x89PNG", "image_data is not a PNG")
self.assertEqual(r["width"], 320)
self.assertEqual(len(r["camera_location"]), 3)
def test_capture_viewport_default_size(self):
r = self.call("vision_actions", "ue_capture_viewport")
self.assertSuccess(r)
self.assertGreater(len(r["image_data"]), 0)
def test_capture_from(self):
r = self.call("vision_actions", "ue_capture_from",
location=[600, 600, 400], rotation=[-20, -135, 0],
width=320, height=180)
self.assertSuccess(r)
import base64
self.assertEqual(base64.b64decode(r["image_data"])[:4], b"\x89PNG")
def test_capture_from_missing(self):
r = self.call("vision_actions", "ue_capture_from")
self.assertFalse(r.get("success"))
def test_capture_actors(self):
spawn = self.call("actor_actions", "ue_spawn_from_object",
asset_path="/Engine/BasicShapes/Cube", location=[0, 0, 100])
self.assertSuccess(spawn)
label = spawn["actor_label"]
try:
r = self.call("vision_actions", "ue_capture_actors",
actor_labels=[label], width=320, height=180)
self.assertSuccess(r)
self.assertIn(label, r["framed_actors"])
import base64
self.assertEqual(base64.b64decode(r["image_data"])[:4], b"\x89PNG")
finally:
self.delete_actor_by_label(label)
def test_capture_actors_unknown(self):
r = self.call("vision_actions", "ue_capture_actors", actor_labels=["NoSuchActor_XYZ"])
self.assertFalse(r.get("success"))
def test_capture_actors_no_annotate(self):
spawn = self.call("actor_actions", "ue_spawn_from_object",
asset_path="/Engine/BasicShapes/Cube", location=[0, 0, 100])
self.assertSuccess(spawn)
label = spawn["actor_label"]
try:
r = self.call("vision_actions", "ue_capture_actors",
actor_labels=[label], width=320, height=180, annotate=False)
self.assertSuccess(r)
import base64
self.assertEqual(base64.b64decode(r["image_data"])[:4], b"\x89PNG")
finally:
self.delete_actor_by_label(label)

View File

@@ -0,0 +1,75 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""Python action functions for Texture assets (info + import settings)."""
import unreal
import json
import traceback
def _load_texture(asset_path: str):
if not asset_path:
raise ValueError("Texture path cannot be empty.")
tex = unreal.EditorAssetLibrary.load_asset(asset_path)
if not tex:
raise FileNotFoundError(f"Texture not found at path: {asset_path}")
if not isinstance(tex, unreal.Texture2D):
raise TypeError(f"Asset at {asset_path} is not a Texture2D, but {type(tex).__name__}")
return tex
def _save(tex):
if hasattr(tex, "update_resource"):
tex.update_resource()
unreal.EditorAssetLibrary.save_loaded_asset(tex)
def ue_get_texture_info(asset_path: str = None) -> str:
"""Returns size, memory, sRGB, and compression settings of a Texture2D."""
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
tex = _load_texture(asset_path)
return json.dumps({
"success": True,
"asset_path": asset_path,
"width": tex.blueprint_get_size_x(),
"height": tex.blueprint_get_size_y(),
"memory_size": tex.blueprint_get_memory_size(),
"srgb": bool(tex.get_editor_property("srgb")),
"compression_settings": str(tex.get_editor_property("compression_settings")).split(".")[-1].split(":")[0].rstrip(">").strip(),
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_texture_srgb(asset_path: str = None, srgb: bool = None) -> str:
"""Sets the sRGB flag on a Texture2D."""
if asset_path is None or srgb is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, srgb."})
try:
tex = _load_texture(asset_path)
tex.set_editor_property("srgb", bool(srgb))
_save(tex)
return json.dumps({"success": True, "asset_path": asset_path, "srgb": bool(srgb)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_texture_compression(asset_path: str = None, compression: str = None) -> str:
"""Sets the compression settings of a Texture2D (e.g. 'TC_DEFAULT', 'TC_NORMALMAP', 'TC_MASKS', 'TC_GRAYSCALE')."""
if asset_path is None or compression is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, compression."})
key = compression.upper()
if not key.startswith("TC_"):
key = "TC_" + key
enum_val = getattr(unreal.TextureCompressionSettings, key, None)
if enum_val is None:
valid = [v for v in dir(unreal.TextureCompressionSettings) if v.startswith("TC_")]
return json.dumps({"success": False, "message": f"Unknown compression '{compression}'.", "valid": valid})
try:
tex = _load_texture(asset_path)
tex.set_editor_property("compression_settings", enum_val)
_save(tex)
return json.dumps({"success": True, "asset_path": asset_path, "compression": key})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,407 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
import unreal
import json
import traceback
VISIBILITY_MAP = {
"visible": unreal.SlateVisibility.VISIBLE,
"collapsed": unreal.SlateVisibility.COLLAPSED,
"hidden": unreal.SlateVisibility.HIDDEN,
"hit_test_invisible": unreal.SlateVisibility.HIT_TEST_INVISIBLE,
"self_hit_test_invisible": unreal.SlateVisibility.SELF_HIT_TEST_INVISIBLE,
}
SUPPORTED_WIDGET_TYPES = [
"CanvasPanel", "TextBlock", "Button", "Image",
"HorizontalBox", "VerticalBox", "Border", "Overlay",
"ScrollBox", "SizeBox", "CheckBox", "EditableText",
"EditableTextBox", "ProgressBar", "Slider",
]
def _load_widget_blueprint(asset_path):
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
if asset is None:
return None, json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
if not isinstance(asset, unreal.WidgetBlueprint):
return None, json.dumps({
"success": False,
"message": f"Asset at '{asset_path}' is {type(asset).__name__}, expected WidgetBlueprint."
})
return asset, None
# ─── Actions ──────────────────────────────────────────────────────────────────
def ue_create_widget_blueprint(name: str = None, path: str = None, parent_class: str = "UserWidget") -> str:
if name is None:
return json.dumps({"success": False, "message": "Required parameter 'name' is missing."})
if path is None:
return json.dumps({"success": False, "message": "Required parameter 'path' is missing."})
try:
parent_cls = unreal.load_class(None, f"/Script/UMG.{parent_class}")
if parent_cls is None:
parent_cls = unreal.UserWidget
factory = unreal.WidgetBlueprintFactory()
factory.set_editor_property("parent_class", parent_cls)
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
widget_bp = asset_tools.create_asset(name, path, unreal.WidgetBlueprint, factory)
if widget_bp is None:
return json.dumps({"success": False, "message": f"Failed to create Widget Blueprint '{name}' at '{path}'."})
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name())
return json.dumps({
"success": True,
"asset_path": widget_bp.get_path_name(),
"message": f"Widget Blueprint '{name}' created successfully."
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_widget_blueprint_info(asset_path: str = None) -> str:
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
widget_bp, err = _load_widget_blueprint(asset_path)
if err:
return err
result_json = unreal.MCPythonHelper.umg_get_widget_info(widget_bp)
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_add_widget(asset_path: str = None, widget_type: str = None,
widget_name: str = None, parent_name: str = None) -> str:
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if widget_type is None:
return json.dumps({"success": False, "message": "Required parameter 'widget_type' is missing."})
if widget_name is None:
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
if widget_type not in SUPPORTED_WIDGET_TYPES:
return json.dumps({
"success": False,
"message": f"Unknown widget type '{widget_type}'. Supported: {SUPPORTED_WIDGET_TYPES}"
})
try:
widget_bp, err = _load_widget_blueprint(asset_path)
if err:
return err
result_json = unreal.MCPythonHelper.umg_add_widget(
widget_bp, widget_type, widget_name, parent_name or ""
)
# Save after successful add
parsed = json.loads(result_json)
if parsed.get("success"):
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name())
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_widget_properties(asset_path: str = None, widget_name: str = None, properties: dict = None) -> str:
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if widget_name is None:
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
if properties is None:
return json.dumps({"success": False, "message": "Required parameter 'properties' is missing."})
try:
widget_bp, err = _load_widget_blueprint(asset_path)
if err:
return err
widget = unreal.MCPythonHelper.umg_find_widget(widget_bp, widget_name)
if widget is None:
return json.dumps({"success": False, "message": f"Widget '{widget_name}' not found in blueprint."})
set_ok = {}
errors = {}
for key, value in properties.items():
if key.startswith("slot_"):
continue
try:
if key == "text":
widget.set_editor_property("text", unreal.Text.cast(str(value)))
elif key == "hint_text":
widget.set_editor_property("hint_text", unreal.Text.cast(str(value)))
elif key == "tool_tip_text":
widget.set_editor_property("tool_tip_text", unreal.Text.cast(str(value)))
elif key == "visibility":
vis = VISIBILITY_MAP.get(str(value).lower())
if vis is None:
errors[key] = f"Unknown visibility '{value}'. Valid: {list(VISIBILITY_MAP.keys())}"
continue
widget.set_editor_property("visibility", vis)
elif key in ("color_and_opacity", "background_color") and isinstance(value, list):
r, g, b = float(value[0]), float(value[1]), float(value[2])
a = float(value[3]) if len(value) > 3 else 1.0
slate_color = unreal.SlateColor()
slate_color.set_editor_property("specified_color", unreal.LinearColor(r=r, g=g, b=b, a=a))
widget.set_editor_property(key, slate_color)
elif key == "font_size" and isinstance(widget, unreal.TextBlock):
font_info = widget.get_editor_property("font")
font_info.set_editor_property("size", int(value))
widget.set_editor_property("font", font_info)
elif key == "percent":
widget.set_editor_property("percent", float(value))
elif key == "value":
widget.set_editor_property("value", float(value))
else:
widget.set_editor_property(key, value)
set_ok[key] = "ok"
except Exception as prop_err:
errors[key] = str(prop_err)
# Slot properties
slot_props = {k[5:]: v for k, v in properties.items() if k.startswith("slot_")}
if slot_props:
parent = widget.get_parent()
slot = getattr(widget, "slot", None)
if slot is None or parent is None:
for k in slot_props:
errors[f"slot_{k}"] = "Widget has no parent slot."
else:
is_canvas_slot = isinstance(slot, unreal.CanvasPanelSlot)
for slot_key, slot_val in slot_props.items():
try:
if slot_key in ("position", "size", "alignment", "z_order") and not is_canvas_slot:
errors[f"slot_{slot_key}"] = (
f"'{slot.get_class().get_name()}' does not support slot_{slot_key}. "
"These properties only apply to CanvasPanel children."
)
continue
if slot_key == "position" and isinstance(slot_val, list):
slot.set_position(unreal.Vector2D(float(slot_val[0]), float(slot_val[1])))
elif slot_key == "size" and isinstance(slot_val, list):
slot.set_size(unreal.Vector2D(float(slot_val[0]), float(slot_val[1])))
elif slot_key == "alignment" and isinstance(slot_val, list):
slot.set_alignment(unreal.Vector2D(float(slot_val[0]), float(slot_val[1])))
elif slot_key == "z_order":
slot.set_z_order(int(slot_val))
else:
slot.set_editor_property(slot_key, slot_val)
set_ok[f"slot_{slot_key}"] = "ok"
except Exception as slot_err:
errors[f"slot_{slot_key}"] = str(slot_err)
if len(errors) == 0:
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name())
return json.dumps({
"success": len(errors) == 0,
"set": set_ok,
"errors": errors
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_remove_widget(asset_path: str = None, widget_name: str = None) -> str:
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if widget_name is None:
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
try:
widget_bp, err = _load_widget_blueprint(asset_path)
if err:
return err
result_json = unreal.MCPythonHelper.umg_remove_widget(widget_bp, widget_name)
parsed = json.loads(result_json)
if parsed.get("success"):
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name())
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_compile_widget_blueprint(asset_path: str = None) -> str:
if asset_path is None:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
try:
widget_bp, err = _load_widget_blueprint(asset_path)
if err:
return err
result_json = unreal.MCPythonHelper.compile_blueprint(widget_bp)
return result_json
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def _umg_call_and_save(asset_path, fn):
widget_bp, err = _load_widget_blueprint(asset_path)
if err:
return err
result_json = fn(widget_bp)
if json.loads(result_json).get("success"):
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name())
return result_json
def ue_reparent_widget(asset_path: str = None, widget_name: str = None, new_parent_name: str = None) -> str:
"""Moves a widget under a different panel parent (cycle-guarded)."""
if asset_path is None or widget_name is None or new_parent_name is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, widget_name, new_parent_name."})
try:
return _umg_call_and_save(asset_path,
lambda bp: unreal.MCPythonHelper.umg_reparent_widget(bp, widget_name, new_parent_name))
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_wrap_widget(asset_path: str = None, widget_name: str = None,
wrapper_type: str = None, wrapper_name: str = None) -> str:
"""Wraps a widget in a new panel (wrapper_type, e.g. 'VerticalBox') that takes its place."""
if asset_path is None or widget_name is None or wrapper_type is None or wrapper_name is None:
return json.dumps({"success": False,
"message": "Required parameters: asset_path, widget_name, wrapper_type, wrapper_name."})
try:
return _umg_call_and_save(asset_path,
lambda bp: unreal.MCPythonHelper.umg_wrap_widget(bp, widget_name, wrapper_type, wrapper_name))
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_replace_widget(asset_path: str = None, widget_name: str = None,
new_type: str = None, new_name: str = None) -> str:
"""Replaces a widget with a new widget of new_type at the same slot (old subtree discarded)."""
if asset_path is None or widget_name is None or new_type is None or new_name is None:
return json.dumps({"success": False,
"message": "Required parameters: asset_path, widget_name, new_type, new_name."})
try:
return _umg_call_and_save(asset_path,
lambda bp: unreal.MCPythonHelper.umg_replace_widget(bp, widget_name, new_type, new_name))
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_widget_events(asset_path: str = None, widget_name: str = None) -> str:
"""Lists the bindable multicast-delegate events on a widget (e.g. OnClicked, OnHovered)."""
if asset_path is None or widget_name is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, widget_name."})
try:
widget_bp, err = _load_widget_blueprint(asset_path)
if err:
return err
return unreal.MCPythonHelper.umg_list_widget_events(widget_bp, widget_name)
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_bind_widget_event(asset_path: str = None, widget_name: str = None, event_name: str = None) -> str:
"""Creates a bound event node in the widget BP's event graph for a widget delegate (e.g. Button OnClicked)."""
if asset_path is None or widget_name is None or event_name is None:
return json.dumps({"success": False, "message": "Required parameters: asset_path, widget_name, event_name."})
try:
return _umg_call_and_save(asset_path,
lambda bp: unreal.MCPythonHelper.umg_bind_widget_event(bp, widget_name, event_name))
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_slot_layout(asset_path: str = None, widget_name: str = None,
anchor_min_x: float = 0.5, anchor_min_y: float = 0.5,
anchor_max_x: float = 0.5, anchor_max_y: float = 0.5,
offset_x: float = 0.0, offset_y: float = 0.0,
size_x: float = 100.0, size_y: float = 40.0) -> str:
"""Sets CanvasPanelSlot layout (anchors + offset + size) on a widget."""
if not asset_path:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if not widget_name:
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
try:
widget_bp, err = _load_widget_blueprint(asset_path)
if err:
return err
result = unreal.MCPythonHelper.umg_set_slot_layout(
widget_bp, widget_name,
anchor_min_x, anchor_min_y, anchor_max_x, anchor_max_y,
offset_x, offset_y, size_x, size_y
)
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name(), only_if_is_dirty=False)
return result
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_text_style(asset_path: str = None, widget_name: str = None,
font_size: int = 24,
color_r: float = 1.0, color_g: float = 1.0,
color_b: float = 1.0, color_a: float = 1.0,
outline_size: int = 0) -> str:
"""Sets font size, text color, and outline size on a TextBlock widget."""
if not asset_path:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if not widget_name:
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
try:
widget_bp, err = _load_widget_blueprint(asset_path)
if err:
return err
result = unreal.MCPythonHelper.umg_set_text_style(
widget_bp, widget_name, font_size,
color_r, color_g, color_b, color_a,
outline_size
)
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name(), only_if_is_dirty=False)
return result
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_widget_property(asset_path: str = None, widget_name: str = None,
property_name: str = None) -> str:
"""Gets the value of a C++ UPROPERTY on a named widget."""
if not asset_path:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if not widget_name:
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
if not property_name:
return json.dumps({"success": False, "message": "Required parameter 'property_name' is missing."})
try:
widget_bp, err = _load_widget_blueprint(asset_path)
if err:
return err
return unreal.MCPythonHelper.umg_get_widget_property(widget_bp, widget_name, property_name)
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_widget_property(asset_path: str = None, widget_name: str = None,
property_name: str = None, value: str = None) -> str:
"""Sets a C++ UPROPERTY on a named widget from a string value."""
if not asset_path:
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
if not widget_name:
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
if not property_name:
return json.dumps({"success": False, "message": "Required parameter 'property_name' is missing."})
if value is None:
return json.dumps({"success": False, "message": "Required parameter 'value' is missing."})
try:
widget_bp, err = _load_widget_blueprint(asset_path)
if err:
return err
result = unreal.MCPythonHelper.umg_set_widget_property(widget_bp, widget_name, property_name, value)
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name(), only_if_is_dirty=False)
return result
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,312 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
import unreal
import json
import os
import glob
import traceback
from collections import deque
def ue_print_message(message: str = None) -> str:
"""
Logs a message to the Unreal log and returns a JSON success response.
"""
if message is None:
return json.dumps({"success": False, "message": "Required parameter 'message' is missing."})
unreal.log(f"MCP Message: {message}")
return json.dumps({
"received_message": message,
"success": True,
"source": "ue_print_message"
})
def _tail_log(path: str, line_count: int):
"""Last line_count lines via backward block reads (never loads the whole file)."""
with open(path, 'rb') as f:
f.seek(0, os.SEEK_END)
file_size = f.tell()
pos = file_size
data = b""
while pos > 0 and data.count(b"\n") <= line_count:
step = min(65536, pos)
pos -= step
f.seek(pos)
data = f.read(step) + data
# total_lines: cheap forward newline count (no decode, no line list)
f.seek(0)
total = 0
last_byte = b"\n"
while True:
chunk = f.read(1 << 20)
if not chunk:
break
total += chunk.count(b"\n")
last_byte = chunk[-1:]
if file_size and last_byte != b"\n":
total += 1
lines = data.decode('utf-8', errors='replace').splitlines(keepends=True)
if pos > 0:
lines = lines[1:] # the first decoded line may start mid-line — drop it
lines = lines[-line_count:]
return "".join(lines), total, len(lines)
def _grep_log(path: str, keyword: str, line_count: int, context_lines: int):
"""Stream-scan for keyword; keep the last line_count matched blocks with context."""
kw = keyword.lower()
prev = deque(maxlen=context_lines) if context_lines else None
blocks = deque(maxlen=line_count) # each block: [(line_no, line), ...]
pending_after = 0
total = 0
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for i, line in enumerate(f):
total = i + 1
if kw in line.lower():
if pending_after > 0 and blocks:
blocks[-1].append((i, line)) # within a previous match's context: merge
else:
block = list(prev) if prev else []
block.append((i, line))
blocks.append(block)
pending_after = context_lines
if prev is not None:
prev.clear()
elif pending_after > 0:
blocks[-1].append((i, line))
pending_after -= 1
elif prev is not None:
prev.append((i, line))
parts = []
returned = 0
last_no = None
for block in blocks:
if context_lines and last_no is not None and block[0][0] > last_no + 1:
parts.append("--\n")
parts.extend(l for _, l in block)
returned += len(block)
last_no = block[-1][0]
return "".join(parts), total, returned
def ue_get_output_log(line_count: int = 50, keyword: str = None, context_lines: int = 0) -> str:
"""Returns recent lines from the UE output log file; optional keyword filter with context_lines around each match."""
try:
log_dir = unreal.Paths.project_log_dir()
log_files = glob.glob(os.path.join(log_dir, "*.log"))
if not log_files:
return json.dumps({"success": False, "message": "No log files found"})
latest_log = max(log_files, key=os.path.getmtime)
line_count = max(1, int(line_count))
context_lines = max(0, int(context_lines))
if keyword:
log_text, total, returned = _grep_log(latest_log, keyword, line_count, context_lines)
else:
log_text, total, returned = _tail_log(latest_log, line_count)
return json.dumps({
"success": True,
"log_file": os.path.basename(latest_log),
"total_lines": total,
"returned_lines": returned,
"log": log_text
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
# --- Editor control -----------------------------------------------------------
def ue_execute_console_command(command: str = None) -> str:
"""Executes an editor console command (e.g. 'stat fps', 'r.ScreenPercentage 50')."""
if command is None:
return json.dumps({"success": False, "message": "Required parameter 'command' is missing."})
try:
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
unreal.SystemLibrary.execute_console_command(world, command)
return json.dumps({"success": True, "command": command})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_save_all_dirty() -> str:
"""Saves all dirty packages (modified maps and content)."""
try:
ok = unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
return json.dumps({"success": bool(ok), "message": "Saved dirty packages." if ok else "save_dirty_packages returned False."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_viewport_camera() -> str:
"""Returns the level viewport camera location and rotation."""
try:
info = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_level_viewport_camera_info()
if not info:
return json.dumps({"success": False, "message": "No active level viewport."})
loc, rot = info
return json.dumps({
"success": True,
"location": [round(loc.x, 3), round(loc.y, 3), round(loc.z, 3)],
"rotation": [round(rot.pitch, 3), round(rot.yaw, 3), round(rot.roll, 3)],
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_viewport_camera(location: list = None, rotation: list = None) -> str:
"""Sets the level viewport camera. location=[x,y,z], rotation=[pitch,yaw,roll]."""
if location is None or rotation is None:
return json.dumps({"success": False, "message": "Required parameters: location, rotation."})
if len(location) != 3 or len(rotation) != 3:
return json.dumps({"success": False, "message": "location and rotation must be lists of 3 floats."})
try:
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
ues.set_level_viewport_camera_info(
unreal.Vector(float(location[0]), float(location[1]), float(location[2])),
unreal.Rotator(float(rotation[0]), float(rotation[1]), float(rotation[2])))
return json.dumps({"success": True, "location": location, "rotation": rotation})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_world_to_screen(location: list = None) -> str:
"""Projects a world location to active level-viewport pixel coords (editor viewport, no PIE needed)."""
if location is None or len(location) != 3:
return json.dumps({"success": False, "message": "Required parameter 'location' must be a list of 3 floats."})
try:
loc = unreal.Vector(float(location[0]), float(location[1]), float(location[2]))
return unreal.MCPythonHelper.world_to_screen(loc)
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_screen_to_world(x: float = None, y: float = None, distance: float = 1000.0) -> str:
"""Deprojects a viewport pixel (x, y) to a world location at 'distance' along the view ray."""
if x is None or y is None:
return json.dumps({"success": False, "message": "Required parameters: x, y."})
try:
return unreal.MCPythonHelper.screen_to_world(float(x), float(y), float(distance))
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_is_in_pie() -> str:
"""Returns whether Play-In-Editor is currently active."""
try:
active = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).is_in_play_in_editor()
return json.dumps({"success": True, "in_pie": bool(active)})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_start_pie() -> str:
"""Starts Play-In-Editor (asynchronous; begins on the next frame)."""
try:
unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).editor_request_begin_play()
return json.dumps({"success": True, "message": "Requested PIE begin."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_stop_pie() -> str:
"""Stops Play-In-Editor."""
try:
unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).editor_request_end_play()
return json.dumps({"success": True, "message": "Requested PIE end."})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_class_properties(class_path: str = None) -> str:
"""Lists the editor-settable property names of a UClass (for discovering what set_property accepts)."""
if class_path is None:
return json.dumps({"success": False, "message": "Required parameter 'class_path' is missing."})
try:
cls = unreal.load_class(None, class_path)
if not cls:
return json.dumps({"success": False, "message": f"Class not found: {class_path}"})
cdo = unreal.get_default_object(cls)
props = sorted(p for p in dir(cdo)
if not p.startswith("_") and not callable(getattr(type(cdo), p, None)))
return json.dumps({"success": True, "class_path": class_path,
"count": len(props), "properties": props})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_cvar(name: str = None) -> str:
"""Reads the current value of a console variable (CVar) as a string, e.g. 'r.ScreenPercentage'."""
if name is None:
return json.dumps({"success": False, "message": "Required parameter 'name' is missing."})
try:
value = unreal.SystemLibrary.get_console_variable_string_value(name)
return json.dumps({"success": True, "name": name, "value": value})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_set_cvar(name: str = None, value: str = None) -> str:
"""Sets a console variable, e.g. name='r.ScreenPercentage', value='75'. Reads it back to confirm."""
if name is None or value is None:
return json.dumps({"success": False, "message": "Required parameters: name, value."})
try:
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
unreal.SystemLibrary.execute_console_command(world, f"{name} {value}")
new_value = unreal.SystemLibrary.get_console_variable_string_value(name)
return json.dumps({"success": True, "name": name, "requested": str(value), "value": new_value})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
_LOG_VERBOSITIES = {"NoLogging", "Fatal", "Error", "Warning", "Display",
"Log", "Verbose", "VeryVerbose", "All", "Default"}
def ue_set_log_verbosity(category: str = None, verbosity: str = None) -> str:
"""Sets a log category's verbosity via the 'Log' console command (e.g. 'LogBlueprint', 'Verbose')."""
if category is None or verbosity is None:
return json.dumps({"success": False, "message": "Required parameters: category, verbosity."})
if verbosity not in _LOG_VERBOSITIES:
return json.dumps({"success": False, "message": f"Invalid verbosity '{verbosity}'.",
"valid": sorted(_LOG_VERBOSITIES)})
try:
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
unreal.SystemLibrary.execute_console_command(world, f"Log {category} {verbosity}")
return json.dumps({"success": True, "category": category, "verbosity": verbosity})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_get_project_info() -> str:
"""Returns project name, directories, and engine version."""
try:
return json.dumps({
"success": True,
"project_name": unreal.SystemLibrary.get_game_name(),
"project_dir": unreal.Paths.project_dir(),
"content_dir": unreal.Paths.project_content_dir(),
"engine_version": unreal.SystemLibrary.get_engine_version(),
})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_list_enum_values(enum_name: str = None) -> str:
"""Lists the values of an Unreal enum by name (e.g. 'TextureCompressionSettings', 'CollisionTraceFlag')."""
if enum_name is None:
return json.dumps({"success": False, "message": "Required parameter 'enum_name' is missing."})
try:
short = enum_name.split(".")[-1]
cls = getattr(unreal, short, None)
if cls is None:
return json.dumps({"success": False, "message": f"Enum '{enum_name}' not found in 'unreal' module."})
values = [v for v in dir(cls) if not v.startswith("_") and isinstance(getattr(cls, v, None), cls)]
if not values:
return json.dumps({"success": False, "message": f"'{enum_name}' is not an enum or has no values."})
return json.dumps({"success": True, "enum": short, "count": len(values), "values": values})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})

View File

@@ -0,0 +1,192 @@
# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
Vision: capture the level viewport / scene as a PNG.
Captures use a transient SceneCapture2D rendered to an RGBA8 render target and
exported to PNG. This works regardless of editor focus (unlike
take_high_res_screenshot, which only fires when the viewport renders a frame),
and captures the 3D scene only — no editor UI. Because the capture uses its own
camera, capture_from / capture_actors can look anywhere without disturbing the
user's viewport.
Actions return the PNG base64-encoded in 'image_data'; the dispatcher's vision
handler decodes it into an MCP Image.
"""
import unreal
import json
import os
import math
import base64
import tempfile
import traceback
def _project(loc, rot, fov, width, height, world_pt):
"""Project a world point to (x, y) pixel coords for the given camera, or None if behind."""
fwd = unreal.MathLibrary.get_forward_vector(rot)
right = unreal.MathLibrary.get_right_vector(rot)
up = unreal.MathLibrary.get_up_vector(rot)
rel = world_pt - loc
cx = rel.x * fwd.x + rel.y * fwd.y + rel.z * fwd.z # depth along view
if cx <= 1.0:
return None
rx = rel.x * right.x + rel.y * right.y + rel.z * right.z
ry = rel.x * up.x + rel.y * up.y + rel.z * up.z
aspect = float(width) / float(height)
tan_h = math.tan(math.radians(fov) / 2.0) # fov_angle is horizontal
tan_v = tan_h / aspect
sx = ((rx / cx) / tan_h * 0.5 + 0.5) * width
sy = (1.0 - ((ry / cx) / tan_v * 0.5 + 0.5)) * height
return (sx, sy)
def _capture_scene(world, loc, rot, width, height, fov, annotations=None):
"""Render the scene from (loc, rot) to a PNG and return its base64 string.
annotations: optional list of {"label": str, "world": unreal.Vector} drawn on top.
"""
width = max(64, min(int(width), 4096))
height = max(64, min(int(height), 4096))
cap_actor = None
out_path = None
try:
rt = unreal.RenderingLibrary.create_render_target2d(
world, width, height, unreal.TextureRenderTargetFormat.RTF_RGBA8)
eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
cap_actor = eas.spawn_actor_from_class(unreal.SceneCapture2D, loc, rot)
comp = cap_actor.capture_component2d
comp.set_editor_property("texture_target", rt)
comp.set_editor_property("capture_source", unreal.SceneCaptureSource.SCS_FINAL_COLOR_LDR)
comp.set_editor_property("fov_angle", float(fov))
comp.capture_scene()
if annotations:
font = unreal.EditorAssetLibrary.load_asset("/Engine/EngineFonts/Roboto")
canvas, _size, ctx = unreal.RenderingLibrary.begin_draw_canvas_to_render_target(world, rt)
yellow = unreal.LinearColor(1.0, 0.95, 0.1, 1.0)
for ann in annotations:
scr = _project(loc, rot, fov, width, height, ann["world"])
if not scr:
continue
pos = unreal.Vector2D(scr[0], scr[1])
canvas.draw_box(unreal.Vector2D(scr[0] - 5, scr[1] - 5), unreal.Vector2D(10, 10), 2.0, yellow)
canvas.draw_text(font, ann["label"], unreal.Vector2D(scr[0], scr[1] + 10),
scale=unreal.Vector2D(1.3, 1.3),
render_color=yellow, centre_x=True, outlined=True)
unreal.RenderingLibrary.end_draw_canvas_to_render_target(world, ctx)
out_dir = tempfile.gettempdir()
out_name = f"mcp_viewport_{os.getpid()}.png"
unreal.RenderingLibrary.export_render_target(world, rt, out_dir, out_name)
out_path = os.path.join(out_dir, out_name)
with open(out_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8"), width, height
finally:
try:
if cap_actor:
unreal.get_editor_subsystem(unreal.EditorActorSubsystem).destroy_actor(cap_actor)
except Exception:
pass
try:
if out_path and os.path.exists(out_path):
os.remove(out_path)
except Exception:
pass
def _actor_by_label(label):
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
for a in sub.get_all_level_actors():
if a.get_actor_label() == label:
return a
return None
def ue_capture_viewport(width: int = 1280, height: int = 720, fov: float = 90.0) -> str:
"""Captures the active level viewport (3D scene only) as a PNG, returned base64 in 'image_data'."""
try:
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
world = ues.get_editor_world()
if not world:
return json.dumps({"success": False, "message": "No editor world is open."})
info = ues.get_level_viewport_camera_info()
loc, rot = info if info else (unreal.Vector(0, 0, 300), unreal.Rotator(0, 0, 0))
img, w, h = _capture_scene(world, loc, rot, width, height, fov)
return json.dumps({"success": True, "image_data": img, "width": w, "height": h,
"camera_location": [round(loc.x, 2), round(loc.y, 2), round(loc.z, 2)],
"camera_rotation": [round(rot.pitch, 2), round(rot.yaw, 2), round(rot.roll, 2)]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_capture_from(location: list = None, rotation: list = None,
width: int = 1280, height: int = 720, fov: float = 90.0) -> str:
"""Captures the scene from an explicit camera pose. location=[x,y,z], rotation=[pitch,yaw,roll]."""
if location is None or rotation is None:
return json.dumps({"success": False, "message": "Required parameters: location, rotation."})
if len(location) != 3 or len(rotation) != 3:
return json.dumps({"success": False, "message": "location and rotation must be lists of 3 floats."})
try:
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
if not world:
return json.dumps({"success": False, "message": "No editor world is open."})
loc = unreal.Vector(float(location[0]), float(location[1]), float(location[2]))
rot = unreal.Rotator(float(rotation[0]), float(rotation[1]), float(rotation[2]))
img, w, h = _capture_scene(world, loc, rot, width, height, fov)
return json.dumps({"success": True, "image_data": img, "width": w, "height": h,
"camera_location": location, "camera_rotation": rotation})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
def ue_capture_actors(actor_labels: list = None, width: int = 1280, height: int = 720,
fov: float = 60.0, padding: float = 1.6, annotate: bool = True) -> str:
"""Frames the given actors (by label) from an elevated 3/4 view and captures them.
With annotate=True, each actor's label is drawn on the image so it can be identified.
"""
if not actor_labels:
return json.dumps({"success": False, "message": "Required parameter 'actor_labels' is missing."})
try:
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
if not world:
return json.dumps({"success": False, "message": "No editor world is open."})
mins = [None, None, None]
maxs = [None, None, None]
found = []
annotations = []
for label in actor_labels:
actor = _actor_by_label(label)
if not actor:
continue
found.append(label)
origin, extent = actor.get_actor_bounds(False)
annotations.append({"label": label, "world": unreal.Vector(origin.x, origin.y, origin.z)})
lo = [origin.x - extent.x, origin.y - extent.y, origin.z - extent.z]
hi = [origin.x + extent.x, origin.y + extent.y, origin.z + extent.z]
for i in range(3):
mins[i] = lo[i] if mins[i] is None else min(mins[i], lo[i])
maxs[i] = hi[i] if maxs[i] is None else max(maxs[i], hi[i])
if not found:
return json.dumps({"success": False, "message": f"No actors found: {actor_labels}"})
center = unreal.Vector((mins[0] + maxs[0]) / 2, (mins[1] + maxs[1]) / 2, (mins[2] + maxs[2]) / 2)
radius = max(8.0, 0.5 * math.sqrt(
(maxs[0] - mins[0]) ** 2 + (maxs[1] - mins[1]) ** 2 + (maxs[2] - mins[2]) ** 2))
dist = (radius / math.tan(math.radians(fov) / 2.0)) * float(padding)
d = unreal.Vector(1.0, -1.0, 0.7)
d = d.normal()
cam = unreal.Vector(center.x + d.x * dist, center.y + d.y * dist, center.z + d.z * dist)
rot = unreal.MathLibrary.find_look_at_rotation(cam, center)
img, w, h = _capture_scene(world, cam, rot, width, height, fov,
annotations=annotations if annotate else None)
return json.dumps({"success": True, "image_data": img, "width": w, "height": h,
"framed_actors": found,
"camera_location": [round(cam.x, 2), round(cam.y, 2), round(cam.z, 2)],
"camera_rotation": [round(rot.pitch, 2), round(rot.yaw, 2), round(rot.roll, 2)]})
except Exception as e:
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})