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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -24,6 +24,13 @@ Saved/
|
||||
Plugins/*/Binaries/
|
||||
Plugins/*/Intermediate/
|
||||
|
||||
# Python bytecode caches (e.g. plugin-bundled Python tools)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Machine-specific MCP client config (absolute local paths)
|
||||
.mcp.json
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
|
||||
|
||||
[/Script/EngineSettings.GameMapsSettings]
|
||||
GameDefaultMap=/Engine/Maps/Templates/OpenWorld
|
||||
GameDefaultMap=/Game/Maps/L_TestFlight
|
||||
EditorStartupMap=/Game/Maps/L_TestFlight
|
||||
|
||||
[/Script/Engine.RendererSettings]
|
||||
r.AllowStaticLighting=False
|
||||
|
||||
BIN
Content/Maps/L_TestFlight.umap
(Stored with Git LFS)
Normal file
BIN
Content/Maps/L_TestFlight.umap
(Stored with Git LFS)
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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()})
|
||||
@@ -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()})
|
||||
@@ -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()})
|
||||
@@ -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})
|
||||
@@ -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()})
|
||||
@@ -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()})
|
||||
@@ -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()})
|
||||
@@ -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()})
|
||||
@@ -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})
|
||||
@@ -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()})
|
||||
@@ -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()})
|
||||
@@ -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()})
|
||||
@@ -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()})
|
||||
@@ -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()})
|
||||
@@ -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
|
||||
@@ -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()})
|
||||
@@ -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()})
|
||||
@@ -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 ===")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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).
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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()})
|
||||
@@ -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()})
|
||||
@@ -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()})
|
||||
@@ -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()})
|
||||
BIN
Plugins/UnrealMCPython/Resources/Icon128.png
(Stored with Git LFS)
Normal file
BIN
Plugins/UnrealMCPython/Resources/Icon128.png
(Stored with Git LFS)
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved.
|
||||
// Shared internal helpers for the split MCPythonHelper_*.cpp translation units.
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Dom/JsonObject.h"
|
||||
#include "Serialization/JsonWriter.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "EdGraph/EdGraph.h"
|
||||
#include "EdGraph/EdGraphNode.h"
|
||||
#include "EdGraph/EdGraphPin.h"
|
||||
#include "Engine/Blueprint.h"
|
||||
|
||||
inline FString MakeJsonError(const FString& Message)
|
||||
{
|
||||
TSharedPtr<FJsonObject> Obj = MakeShareable(new FJsonObject());
|
||||
Obj->SetBoolField(TEXT("success"), false);
|
||||
Obj->SetStringField(TEXT("message"), Message);
|
||||
FString Out;
|
||||
TSharedRef<TJsonWriter<>> W = TJsonWriterFactory<>::Create(&Out);
|
||||
FJsonSerializer::Serialize(Obj.ToSharedRef(), W);
|
||||
return Out;
|
||||
}
|
||||
|
||||
inline FString MakeJsonSuccess(const FString& Message)
|
||||
{
|
||||
TSharedPtr<FJsonObject> Obj = MakeShareable(new FJsonObject());
|
||||
Obj->SetBoolField(TEXT("success"), true);
|
||||
Obj->SetStringField(TEXT("message"), Message);
|
||||
FString Out;
|
||||
TSharedRef<TJsonWriter<>> W = TJsonWriterFactory<>::Create(&Out);
|
||||
FJsonSerializer::Serialize(Obj.ToSharedRef(), W);
|
||||
return Out;
|
||||
}
|
||||
|
||||
inline FString SerializeJsonObj(TSharedPtr<FJsonObject> Obj)
|
||||
{
|
||||
FString Out;
|
||||
TSharedRef<TJsonWriter<>> W = TJsonWriterFactory<>::Create(&Out);
|
||||
FJsonSerializer::Serialize(Obj.ToSharedRef(), W);
|
||||
return Out;
|
||||
}
|
||||
|
||||
inline UEdGraph* FindGraphByName(UBlueprint* Blueprint, const FString& GraphName)
|
||||
{
|
||||
for (UEdGraph* Graph : Blueprint->UbergraphPages)
|
||||
{
|
||||
if (Graph && Graph->GetName() == GraphName)
|
||||
return Graph;
|
||||
}
|
||||
for (UEdGraph* Graph : Blueprint->FunctionGraphs)
|
||||
{
|
||||
if (Graph && Graph->GetName() == GraphName)
|
||||
return Graph;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
inline UEdGraphNode* FindBPNodeByName(UEdGraph* Graph, const FString& NodeName)
|
||||
{
|
||||
for (UEdGraphNode* Node : Graph->Nodes)
|
||||
{
|
||||
if (Node && Node->GetName() == NodeName)
|
||||
return Node;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
inline UEdGraphPin* FindPinByName(UEdGraphNode* Node, const FString& PinName, EEdGraphPinDirection Direction = EGPD_MAX)
|
||||
{
|
||||
for (UEdGraphPin* Pin : Node->Pins)
|
||||
{
|
||||
if (!Pin || Pin->bHidden) continue;
|
||||
if (Direction != EGPD_MAX && Pin->Direction != Direction) continue;
|
||||
|
||||
// Match by internal name
|
||||
if (Pin->GetName() == PinName)
|
||||
return Pin;
|
||||
// Match by friendly name
|
||||
FString Friendly = Pin->PinFriendlyName.ToString();
|
||||
if (!Friendly.IsEmpty() && Friendly == PinName)
|
||||
return Pin;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved.
|
||||
// AnimGraph authoring — split out of MCPythonHelper.cpp.
|
||||
|
||||
#include "MCPythonHelper.h"
|
||||
#include "MCPythonHelperInternal.h"
|
||||
#include "Engine/SCS_Node.h"
|
||||
#include "Engine/SimpleConstructionScript.h"
|
||||
#include "WidgetBlueprint.h"
|
||||
#include "Blueprint/WidgetTree.h"
|
||||
#include "Components/Widget.h"
|
||||
#include "Components/PanelWidget.h"
|
||||
#include "Editor.h"
|
||||
#include "Subsystems/AssetEditorSubsystem.h"
|
||||
#include "Toolkits/AssetEditorToolkit.h"
|
||||
#include "BlueprintEditor.h"
|
||||
#include "BehaviorTree/BehaviorTree.h"
|
||||
#include "BehaviorTree/BlackboardData.h"
|
||||
#include "BehaviorTree/BTCompositeNode.h"
|
||||
#include "BehaviorTree/BTTaskNode.h"
|
||||
#include "BehaviorTree/BTDecorator.h"
|
||||
#include "BehaviorTree/BTService.h"
|
||||
#include "BehaviorTreeEditor.h"
|
||||
#include "BehaviorTreeGraphNode.h"
|
||||
#include "BehaviorTreeGraph.h"
|
||||
#include "BehaviorTreeGraphNode_Root.h"
|
||||
#include "BehaviorTreeGraphNode_Composite.h"
|
||||
#include "BehaviorTreeGraphNode_Task.h"
|
||||
#include "BehaviorTreeGraphNode_Decorator.h"
|
||||
#include "BehaviorTreeGraphNode_Service.h"
|
||||
#include "BehaviorTreeGraphNode_SimpleParallel.h"
|
||||
#include "BehaviorTreeGraphNode_SubtreeTask.h"
|
||||
#include "EdGraphSchema_BehaviorTree.h"
|
||||
#include "EdGraph/EdGraph.h"
|
||||
#include "UObject/UObjectIterator.h"
|
||||
#include "Engine/SkeletalMesh.h"
|
||||
#include "Engine/SkeletalMeshSocket.h"
|
||||
#include "Dom/JsonObject.h"
|
||||
#include "Dom/JsonValue.h"
|
||||
#include "Serialization/JsonWriter.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Serialization/JsonReader.h"
|
||||
// Blueprint graph includes
|
||||
#include "K2Node_Event.h"
|
||||
#include "K2Node_ComponentBoundEvent.h"
|
||||
#include "K2Node_CustomEvent.h"
|
||||
#include "K2Node_CallFunction.h"
|
||||
#include "K2Node_IfThenElse.h"
|
||||
#include "K2Node_ExecutionSequence.h"
|
||||
#include "K2Node_VariableGet.h"
|
||||
#include "K2Node_VariableSet.h"
|
||||
#include "K2Node_MacroInstance.h"
|
||||
#include "K2Node_DynamicCast.h"
|
||||
#include "K2Node_InputKey.h"
|
||||
#include "K2Node_SpawnActorFromClass.h"
|
||||
#include "EdGraphSchema_K2.h"
|
||||
#include "Kismet2/BlueprintEditorUtils.h"
|
||||
#include "Kismet2/KismetEditorUtilities.h"
|
||||
#include "Engine/Blueprint.h"
|
||||
#include "UObject/UnrealType.h"
|
||||
#include "UObject/TextProperty.h"
|
||||
#include "Components/CanvasPanelSlot.h"
|
||||
#include "Components/TextBlock.h"
|
||||
// AnimGraph authoring (editor-only AnimGraph module)
|
||||
#include "Animation/AnimBlueprint.h"
|
||||
#include "Animation/AnimSequence.h"
|
||||
#include "AnimGraphNode_StateMachine.h"
|
||||
#include "AnimGraphNode_SequencePlayer.h"
|
||||
#include "AnimGraphNode_Root.h"
|
||||
#include "AnimGraphNode_StateResult.h"
|
||||
#include "AnimGraphNode_TransitionResult.h"
|
||||
#include "AnimStateNode.h"
|
||||
#include "AnimStateTransitionNode.h"
|
||||
#include "AnimStateEntryNode.h"
|
||||
#include "AnimationStateMachineGraph.h"
|
||||
#include "Kismet/KismetMathLibrary.h"
|
||||
// Editor viewport projection
|
||||
#include "LevelEditorViewport.h"
|
||||
#include "EditorViewportClient.h"
|
||||
#include "SceneView.h"
|
||||
|
||||
// ─── AnimGraph authoring ─────────────────────────────────────────────────────
|
||||
|
||||
static UEdGraphPin* FirstVisiblePin(UEdGraphNode* Node, EEdGraphPinDirection Dir)
|
||||
{
|
||||
if (!Node) return nullptr;
|
||||
for (UEdGraphPin* P : Node->Pins)
|
||||
if (P && !P->bHidden && P->Direction == Dir)
|
||||
return P;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
static T* FindNodeOfType(UEdGraph* Graph)
|
||||
{
|
||||
if (!Graph) return nullptr;
|
||||
for (UEdGraphNode* N : Graph->Nodes)
|
||||
if (T* Hit = Cast<T>(N))
|
||||
return Hit;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Add a Sequence Player playing Seq into Graph and link its pose output to PoseSinkInputPin (if given).
|
||||
static UAnimGraphNode_SequencePlayer* SpawnSequencePlayer(UEdGraph* Graph, UAnimSequence* Seq,
|
||||
int32 X, int32 Y, UEdGraphPin* PoseSinkInputPin)
|
||||
{
|
||||
FGraphNodeCreator<UAnimGraphNode_SequencePlayer> Creator(*Graph);
|
||||
UAnimGraphNode_SequencePlayer* Node = Creator.CreateNode(false);
|
||||
Node->Node.SetSequence(Seq);
|
||||
Node->Node.SetLoopAnimation(true);
|
||||
Node->NodePosX = X;
|
||||
Node->NodePosY = Y;
|
||||
Creator.Finalize();
|
||||
if (PoseSinkInputPin)
|
||||
{
|
||||
if (UEdGraphPin* PoseOut = FirstVisiblePin(Node, EGPD_Output))
|
||||
PoseOut->MakeLinkTo(PoseSinkInputPin);
|
||||
}
|
||||
return Node;
|
||||
}
|
||||
|
||||
FString UMCPythonHelper::AddAnimGraphSequencePlayer(UAnimBlueprint* AnimBP,
|
||||
const FString& AnimSequencePath, bool bLinkToOutputPose)
|
||||
{
|
||||
if (!AnimBP)
|
||||
return MakeJsonError(TEXT("Invalid AnimBlueprint."));
|
||||
|
||||
UEdGraph* AnimGraph = FindGraphByName(AnimBP, TEXT("AnimGraph"));
|
||||
if (!AnimGraph)
|
||||
return MakeJsonError(TEXT("AnimGraph not found on this AnimBlueprint."));
|
||||
|
||||
UAnimSequence* Seq = Cast<UAnimSequence>(StaticLoadObject(UAnimSequence::StaticClass(), nullptr, *AnimSequencePath));
|
||||
if (!Seq)
|
||||
return MakeJsonError(FString::Printf(TEXT("AnimSequence not found: %s"), *AnimSequencePath));
|
||||
|
||||
UAnimGraphNode_Root* Root = FindNodeOfType<UAnimGraphNode_Root>(AnimGraph);
|
||||
UEdGraphPin* RootIn = Root ? FindPinByName(Root, TEXT("Result"), EGPD_Input) : nullptr;
|
||||
|
||||
UAnimGraphNode_SequencePlayer* Player =
|
||||
SpawnSequencePlayer(AnimGraph, Seq, -400, 0, (bLinkToOutputPose && RootIn) ? RootIn : nullptr);
|
||||
|
||||
FKismetEditorUtilities::CompileBlueprint(AnimBP);
|
||||
|
||||
TSharedPtr<FJsonObject> R = MakeShareable(new FJsonObject());
|
||||
R->SetBoolField(TEXT("success"), true);
|
||||
R->SetStringField(TEXT("node_name"), Player->GetName());
|
||||
R->SetStringField(TEXT("sequence"), Seq->GetPathName());
|
||||
R->SetBoolField(TEXT("linked_to_output"), bLinkToOutputPose && RootIn != nullptr);
|
||||
return SerializeJsonObj(R);
|
||||
}
|
||||
|
||||
// Populate a transition's rule graph with: Get(SpeedVar) (Greater|Less) Threshold -> bCanEnterTransition.
|
||||
// Map a comparison operator string to a UKismetMathLibrary double-comparison UFunction.
|
||||
static UFunction* FindFloatCompareFunc(const FString& Op)
|
||||
{
|
||||
FName FnName;
|
||||
if (Op == TEXT(">")) FnName = FName(TEXT("Greater_DoubleDouble"));
|
||||
else if (Op == TEXT("<")) FnName = FName(TEXT("Less_DoubleDouble"));
|
||||
else if (Op == TEXT(">=")) FnName = FName(TEXT("GreaterEqual_DoubleDouble"));
|
||||
else if (Op == TEXT("<=")) FnName = FName(TEXT("LessEqual_DoubleDouble"));
|
||||
else if (Op == TEXT("==")) FnName = FName(TEXT("EqualEqual_DoubleDouble"));
|
||||
else return nullptr;
|
||||
return UKismetMathLibrary::StaticClass()->FindFunctionByName(FnName);
|
||||
}
|
||||
|
||||
// Populate a transition rule graph with: Get(Var) <Op> Value -> bCanEnterTransition.
|
||||
static bool BuildFloatTransitionRule(UEdGraph* TransitionGraph, const FString& Var, const FString& Op, float Value)
|
||||
{
|
||||
UAnimGraphNode_TransitionResult* Result = FindNodeOfType<UAnimGraphNode_TransitionResult>(TransitionGraph);
|
||||
if (!Result) return false;
|
||||
UEdGraphPin* CanEnter = FindPinByName(Result, TEXT("bCanEnterTransition"), EGPD_Input);
|
||||
if (!CanEnter) return false;
|
||||
UFunction* CmpFunc = FindFloatCompareFunc(Op);
|
||||
if (!CmpFunc) return false;
|
||||
|
||||
FGraphNodeCreator<UK2Node_VariableGet> GetCreator(*TransitionGraph);
|
||||
UK2Node_VariableGet* GetNode = GetCreator.CreateNode(false);
|
||||
GetNode->VariableReference.SetSelfMember(FName(*Var));
|
||||
GetNode->NodePosX = -500;
|
||||
GetCreator.Finalize();
|
||||
|
||||
FGraphNodeCreator<UK2Node_CallFunction> CmpCreator(*TransitionGraph);
|
||||
UK2Node_CallFunction* CmpNode = CmpCreator.CreateNode(false);
|
||||
CmpNode->SetFromFunction(CmpFunc);
|
||||
CmpNode->NodePosX = -250;
|
||||
CmpCreator.Finalize();
|
||||
|
||||
UEdGraphPin* VarOut = FirstVisiblePin(GetNode, EGPD_Output);
|
||||
UEdGraphPin* PinA = FindPinByName(CmpNode, TEXT("A"), EGPD_Input);
|
||||
UEdGraphPin* PinB = FindPinByName(CmpNode, TEXT("B"), EGPD_Input);
|
||||
UEdGraphPin* CmpRet = FindPinByName(CmpNode, TEXT("ReturnValue"), EGPD_Output);
|
||||
if (VarOut && PinA) VarOut->MakeLinkTo(PinA);
|
||||
if (PinB) PinB->DefaultValue = FString::SanitizeFloat(Value);
|
||||
if (CmpRet) CmpRet->MakeLinkTo(CanEnter);
|
||||
return VarOut && PinA && CmpRet;
|
||||
}
|
||||
|
||||
// Core builder shared by the generic and the locomotion-convenience UFUNCTIONs.
|
||||
// Spec: { machine_name?, entry?, states:[{name, anim?}], transitions:[{from,to,var?,op?,value?}] }
|
||||
static FString BuildStateMachineFromSpec(UAnimBlueprint* AnimBP, const TSharedPtr<FJsonObject>& Spec)
|
||||
{
|
||||
UEdGraph* AnimGraph = FindGraphByName(AnimBP, TEXT("AnimGraph"));
|
||||
if (!AnimGraph)
|
||||
return MakeJsonError(TEXT("AnimGraph not found on this AnimBlueprint."));
|
||||
|
||||
const TArray<TSharedPtr<FJsonValue>>* StatesJson = nullptr;
|
||||
if (!Spec->TryGetArrayField(TEXT("states"), StatesJson) || StatesJson->Num() == 0)
|
||||
return MakeJsonError(TEXT("Spec must contain a non-empty 'states' array."));
|
||||
|
||||
// Resolve + validate every state's anim up front (fail before mutating the graph).
|
||||
struct FStateDef { FString Name; UAnimSequence* Seq; };
|
||||
TArray<FStateDef> StateDefs;
|
||||
for (const TSharedPtr<FJsonValue>& SV : *StatesJson)
|
||||
{
|
||||
const TSharedPtr<FJsonObject> SO = SV->AsObject();
|
||||
if (!SO.IsValid())
|
||||
return MakeJsonError(TEXT("Each entry in 'states' must be an object."));
|
||||
FString Name, AnimPath;
|
||||
if (!SO->TryGetStringField(TEXT("name"), Name) || Name.IsEmpty())
|
||||
return MakeJsonError(TEXT("Each state needs a non-empty 'name'."));
|
||||
SO->TryGetStringField(TEXT("anim"), AnimPath);
|
||||
UAnimSequence* Seq = nullptr;
|
||||
if (!AnimPath.IsEmpty())
|
||||
{
|
||||
Seq = Cast<UAnimSequence>(StaticLoadObject(UAnimSequence::StaticClass(), nullptr, *AnimPath));
|
||||
if (!Seq)
|
||||
return MakeJsonError(FString::Printf(TEXT("State '%s': AnimSequence not found: %s"), *Name, *AnimPath));
|
||||
}
|
||||
StateDefs.Add({ Name, Seq });
|
||||
}
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Warnings;
|
||||
|
||||
// State machine node, linked to the Output Pose.
|
||||
UAnimGraphNode_Root* Root = FindNodeOfType<UAnimGraphNode_Root>(AnimGraph);
|
||||
UEdGraphPin* RootIn = Root ? FindPinByName(Root, TEXT("Result"), EGPD_Input) : nullptr;
|
||||
|
||||
FGraphNodeCreator<UAnimGraphNode_StateMachine> SMCreator(*AnimGraph);
|
||||
UAnimGraphNode_StateMachine* SMNode = SMCreator.CreateNode(false);
|
||||
SMNode->NodePosX = -350;
|
||||
SMCreator.Finalize();
|
||||
if (RootIn)
|
||||
{
|
||||
if (UEdGraphPin* SMOut = FirstVisiblePin(SMNode, EGPD_Output))
|
||||
{
|
||||
RootIn->BreakAllPinLinks();
|
||||
SMOut->MakeLinkTo(RootIn);
|
||||
}
|
||||
}
|
||||
|
||||
TArray<UEdGraph*> Subs = SMNode->GetSubGraphs();
|
||||
UEdGraph* SMGraph = Subs.Num() ? Subs[0] : nullptr;
|
||||
if (!SMGraph)
|
||||
return MakeJsonError(TEXT("State machine graph was not created."));
|
||||
|
||||
// States — each with a looping sequence player wired to the state result.
|
||||
TMap<FString, UAnimStateNode*> StateByName;
|
||||
int32 Col = 0;
|
||||
for (const FStateDef& SD : StateDefs)
|
||||
{
|
||||
FGraphNodeCreator<UAnimStateNode> Creator(*SMGraph);
|
||||
UAnimStateNode* State = Creator.CreateNode(false);
|
||||
Creator.Finalize();
|
||||
if (UEdGraph* Bound = State->GetBoundGraph())
|
||||
FBlueprintEditorUtils::RenameGraph(Bound, SD.Name);
|
||||
if (SD.Seq)
|
||||
{
|
||||
if (UAnimGraphNode_StateResult* SR = State->GetResultNodeInsideState())
|
||||
{
|
||||
UEdGraphPin* SRIn = FindPinByName(SR, TEXT("Result"), EGPD_Input);
|
||||
SpawnSequencePlayer(State->GetBoundGraph(), SD.Seq, -400, 0, SRIn);
|
||||
}
|
||||
}
|
||||
State->NodePosX = Col * 350;
|
||||
State->NodePosY = 0;
|
||||
++Col;
|
||||
StateByName.Add(SD.Name, State);
|
||||
}
|
||||
|
||||
// Entry -> entry state (defaults to the first state).
|
||||
FString EntryName = StateDefs[0].Name;
|
||||
Spec->TryGetStringField(TEXT("entry"), EntryName);
|
||||
UAnimStateNode** EntryState = StateByName.Find(EntryName);
|
||||
if (!EntryState)
|
||||
return MakeJsonError(FString::Printf(TEXT("Entry state '%s' is not one of the states."), *EntryName));
|
||||
if (UAnimStateEntryNode* Entry = FindNodeOfType<UAnimStateEntryNode>(SMGraph))
|
||||
{
|
||||
UEdGraphPin* EntryOut = FirstVisiblePin(Entry, EGPD_Output);
|
||||
UEdGraphPin* StateIn = FirstVisiblePin(*EntryState, EGPD_Input);
|
||||
if (EntryOut && StateIn) EntryOut->MakeLinkTo(StateIn);
|
||||
else Warnings.Add(MakeShareable(new FJsonValueString(TEXT("Could not connect the entry node."))));
|
||||
}
|
||||
|
||||
// Transitions, each with an optional float-comparison rule.
|
||||
int32 TransCount = 0;
|
||||
const TArray<TSharedPtr<FJsonValue>>* TransJson = nullptr;
|
||||
if (Spec->TryGetArrayField(TEXT("transitions"), TransJson))
|
||||
{
|
||||
for (const TSharedPtr<FJsonValue>& TV : *TransJson)
|
||||
{
|
||||
const TSharedPtr<FJsonObject> TO = TV->AsObject();
|
||||
if (!TO.IsValid()) continue;
|
||||
FString From, To;
|
||||
TO->TryGetStringField(TEXT("from"), From);
|
||||
TO->TryGetStringField(TEXT("to"), To);
|
||||
UAnimStateNode** FromState = StateByName.Find(From);
|
||||
UAnimStateNode** ToState = StateByName.Find(To);
|
||||
if (!FromState || !ToState)
|
||||
return MakeJsonError(FString::Printf(TEXT("Transition references unknown state(s): '%s' -> '%s'."), *From, *To));
|
||||
|
||||
FGraphNodeCreator<UAnimStateTransitionNode> Creator(*SMGraph);
|
||||
UAnimStateTransitionNode* Trans = Creator.CreateNode(false);
|
||||
Creator.Finalize();
|
||||
Trans->CreateConnections(*FromState, *ToState);
|
||||
++TransCount;
|
||||
|
||||
FString Var, Op;
|
||||
if (TO->TryGetStringField(TEXT("var"), Var) && TO->TryGetStringField(TEXT("op"), Op))
|
||||
{
|
||||
double Value = 0.0;
|
||||
TO->TryGetNumberField(TEXT("value"), Value);
|
||||
if (!BuildFloatTransitionRule(Trans->GetBoundGraph(), Var, Op, (float)Value))
|
||||
Warnings.Add(MakeShareable(new FJsonValueString(
|
||||
FString::Printf(TEXT("Rule for %s->%s left at default (bad var/op?)."), *From, *To))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(AnimBP);
|
||||
FKismetEditorUtilities::CompileBlueprint(AnimBP);
|
||||
|
||||
TSharedPtr<FJsonObject> R = MakeShareable(new FJsonObject());
|
||||
R->SetBoolField(TEXT("success"), true);
|
||||
R->SetStringField(TEXT("state_machine"), SMNode->GetName());
|
||||
TArray<TSharedPtr<FJsonValue>> StateNames;
|
||||
for (const FStateDef& SD : StateDefs)
|
||||
StateNames.Add(MakeShareable(new FJsonValueString(SD.Name)));
|
||||
R->SetArrayField(TEXT("states"), StateNames);
|
||||
R->SetNumberField(TEXT("transition_count"), TransCount);
|
||||
R->SetArrayField(TEXT("warnings"), Warnings);
|
||||
return SerializeJsonObj(R);
|
||||
}
|
||||
|
||||
FString UMCPythonHelper::BuildAnimStateMachine(UAnimBlueprint* AnimBP, const FString& SpecJson)
|
||||
{
|
||||
if (!AnimBP)
|
||||
return MakeJsonError(TEXT("Invalid AnimBlueprint."));
|
||||
TSharedPtr<FJsonObject> Spec;
|
||||
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(SpecJson);
|
||||
if (!FJsonSerializer::Deserialize(Reader, Spec) || !Spec.IsValid())
|
||||
return MakeJsonError(TEXT("Failed to parse spec JSON."));
|
||||
return BuildStateMachineFromSpec(AnimBP, Spec);
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
// Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved.
|
||||
// Behavior Tree authoring helpers — split out of MCPythonHelper.cpp.
|
||||
|
||||
#include "MCPythonHelper.h"
|
||||
#include "Engine/SCS_Node.h"
|
||||
#include "Engine/SimpleConstructionScript.h"
|
||||
#include "WidgetBlueprint.h"
|
||||
#include "Blueprint/WidgetTree.h"
|
||||
#include "Components/Widget.h"
|
||||
#include "Components/PanelWidget.h"
|
||||
#include "Editor.h"
|
||||
#include "Subsystems/AssetEditorSubsystem.h"
|
||||
#include "Toolkits/AssetEditorToolkit.h"
|
||||
#include "BlueprintEditor.h"
|
||||
#include "BehaviorTree/BehaviorTree.h"
|
||||
#include "BehaviorTree/BlackboardData.h"
|
||||
#include "BehaviorTree/BTCompositeNode.h"
|
||||
#include "BehaviorTree/BTTaskNode.h"
|
||||
#include "BehaviorTree/BTDecorator.h"
|
||||
#include "BehaviorTree/BTService.h"
|
||||
#include "BehaviorTreeEditor.h"
|
||||
#include "BehaviorTreeGraphNode.h"
|
||||
#include "BehaviorTreeGraph.h"
|
||||
#include "BehaviorTreeGraphNode_Root.h"
|
||||
#include "BehaviorTreeGraphNode_Composite.h"
|
||||
#include "BehaviorTreeGraphNode_Task.h"
|
||||
#include "BehaviorTreeGraphNode_Decorator.h"
|
||||
#include "BehaviorTreeGraphNode_Service.h"
|
||||
#include "BehaviorTreeGraphNode_SimpleParallel.h"
|
||||
#include "BehaviorTreeGraphNode_SubtreeTask.h"
|
||||
#include "EdGraphSchema_BehaviorTree.h"
|
||||
#include "EdGraph/EdGraph.h"
|
||||
#include "UObject/UObjectIterator.h"
|
||||
#include "Engine/SkeletalMesh.h"
|
||||
#include "Engine/SkeletalMeshSocket.h"
|
||||
#include "Dom/JsonObject.h"
|
||||
#include "Dom/JsonValue.h"
|
||||
#include "Serialization/JsonWriter.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Serialization/JsonReader.h"
|
||||
// Blueprint graph includes
|
||||
#include "K2Node_Event.h"
|
||||
#include "K2Node_ComponentBoundEvent.h"
|
||||
#include "K2Node_CustomEvent.h"
|
||||
#include "K2Node_CallFunction.h"
|
||||
#include "K2Node_IfThenElse.h"
|
||||
#include "K2Node_ExecutionSequence.h"
|
||||
#include "K2Node_VariableGet.h"
|
||||
#include "K2Node_VariableSet.h"
|
||||
#include "K2Node_MacroInstance.h"
|
||||
#include "K2Node_DynamicCast.h"
|
||||
#include "K2Node_InputKey.h"
|
||||
#include "K2Node_SpawnActorFromClass.h"
|
||||
#include "EdGraphSchema_K2.h"
|
||||
#include "Kismet2/BlueprintEditorUtils.h"
|
||||
#include "Kismet2/KismetEditorUtilities.h"
|
||||
#include "Engine/Blueprint.h"
|
||||
#include "UObject/UnrealType.h"
|
||||
#include "UObject/TextProperty.h"
|
||||
#include "Components/CanvasPanelSlot.h"
|
||||
#include "Components/TextBlock.h"
|
||||
// AnimGraph authoring (editor-only AnimGraph module)
|
||||
#include "Animation/AnimBlueprint.h"
|
||||
#include "Animation/AnimSequence.h"
|
||||
#include "AnimGraphNode_StateMachine.h"
|
||||
#include "AnimGraphNode_SequencePlayer.h"
|
||||
#include "AnimGraphNode_Root.h"
|
||||
#include "AnimGraphNode_StateResult.h"
|
||||
#include "AnimGraphNode_TransitionResult.h"
|
||||
#include "AnimStateNode.h"
|
||||
#include "AnimStateTransitionNode.h"
|
||||
#include "AnimStateEntryNode.h"
|
||||
#include "AnimationStateMachineGraph.h"
|
||||
#include "Kismet/KismetMathLibrary.h"
|
||||
// Editor viewport projection
|
||||
#include "LevelEditorViewport.h"
|
||||
#include "EditorViewportClient.h"
|
||||
#include "SceneView.h"
|
||||
#include "MCPythonHelperInternal.h"
|
||||
|
||||
// ─── Behavior Tree Helpers (internal) ────────────────────────────────────────
|
||||
|
||||
static FMCPythonBTNodeInfo SerializeBTNode(UBTCompositeNode* Node)
|
||||
{
|
||||
FMCPythonBTNodeInfo Info;
|
||||
if (!Node) return Info;
|
||||
|
||||
Info.NodeName = Node->GetNodeName();
|
||||
Info.NodeClass = Node->GetClass()->GetName();
|
||||
|
||||
// Services on this composite node
|
||||
for (UBTService* Svc : Node->Services)
|
||||
{
|
||||
if (Svc)
|
||||
{
|
||||
Info.ServiceClasses.Add(Svc->GetClass()->GetName());
|
||||
Info.ServiceNames.Add(Svc->GetNodeName());
|
||||
}
|
||||
}
|
||||
|
||||
// Children
|
||||
for (const FBTCompositeChild& Child : Node->Children)
|
||||
{
|
||||
if (Child.ChildComposite)
|
||||
{
|
||||
FMCPythonBTNodeInfo ChildInfo = SerializeBTNode(Child.ChildComposite);
|
||||
// Decorators are stored per-child-connection
|
||||
for (UBTDecorator* Dec : Child.Decorators)
|
||||
{
|
||||
if (Dec)
|
||||
{
|
||||
ChildInfo.DecoratorClasses.Add(Dec->GetClass()->GetName());
|
||||
ChildInfo.DecoratorNames.Add(Dec->GetNodeName());
|
||||
}
|
||||
}
|
||||
Info.Children.Add(ChildInfo);
|
||||
}
|
||||
else if (Child.ChildTask)
|
||||
{
|
||||
FMCPythonBTNodeInfo TaskInfo;
|
||||
TaskInfo.NodeName = Child.ChildTask->GetNodeName();
|
||||
TaskInfo.NodeClass = Child.ChildTask->GetClass()->GetName();
|
||||
|
||||
// Decorators on this child connection
|
||||
for (UBTDecorator* Dec : Child.Decorators)
|
||||
{
|
||||
if (Dec)
|
||||
{
|
||||
TaskInfo.DecoratorClasses.Add(Dec->GetClass()->GetName());
|
||||
TaskInfo.DecoratorNames.Add(Dec->GetNodeName());
|
||||
}
|
||||
}
|
||||
|
||||
// Services on task node
|
||||
for (UBTService* Svc : Child.ChildTask->Services)
|
||||
{
|
||||
if (Svc)
|
||||
{
|
||||
TaskInfo.ServiceClasses.Add(Svc->GetClass()->GetName());
|
||||
TaskInfo.ServiceNames.Add(Svc->GetNodeName());
|
||||
}
|
||||
}
|
||||
|
||||
Info.Children.Add(TaskInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return Info;
|
||||
}
|
||||
|
||||
static UBTNode* FindNodeByName(UBTCompositeNode* Root, const FString& Name)
|
||||
{
|
||||
if (!Root) return nullptr;
|
||||
|
||||
// Check root itself
|
||||
if (Root->GetNodeName() == Name || Root->GetName() == Name)
|
||||
return Root;
|
||||
|
||||
// Check root's services
|
||||
for (UBTService* Svc : Root->Services)
|
||||
{
|
||||
if (Svc && (Svc->GetNodeName() == Name || Svc->GetName() == Name))
|
||||
return Svc;
|
||||
}
|
||||
|
||||
// Check children
|
||||
for (const FBTCompositeChild& Child : Root->Children)
|
||||
{
|
||||
// Check decorators on this child
|
||||
for (UBTDecorator* Dec : Child.Decorators)
|
||||
{
|
||||
if (Dec && (Dec->GetNodeName() == Name || Dec->GetName() == Name))
|
||||
return Dec;
|
||||
}
|
||||
|
||||
if (Child.ChildComposite)
|
||||
{
|
||||
UBTNode* Found = FindNodeByName(Child.ChildComposite, Name);
|
||||
if (Found) return Found;
|
||||
}
|
||||
else if (Child.ChildTask)
|
||||
{
|
||||
if (Child.ChildTask->GetNodeName() == Name || Child.ChildTask->GetName() == Name)
|
||||
return Child.ChildTask;
|
||||
|
||||
// Check task's services
|
||||
for (UBTService* Svc : Child.ChildTask->Services)
|
||||
{
|
||||
if (Svc && (Svc->GetNodeName() == Name || Svc->GetName() == Name))
|
||||
return Svc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ─── JSON serialization for BT tree ─────────────────────────────────────────
|
||||
|
||||
static TSharedPtr<FJsonObject> BTNodeInfoToJson(const FMCPythonBTNodeInfo& Info)
|
||||
{
|
||||
TSharedPtr<FJsonObject> Obj = MakeShareable(new FJsonObject());
|
||||
Obj->SetStringField(TEXT("node_name"), Info.NodeName);
|
||||
Obj->SetStringField(TEXT("node_class"), Info.NodeClass);
|
||||
|
||||
if (Info.DecoratorClasses.Num() > 0)
|
||||
{
|
||||
TArray<TSharedPtr<FJsonValue>> DecArr;
|
||||
for (int32 i = 0; i < Info.DecoratorClasses.Num(); ++i)
|
||||
{
|
||||
TSharedPtr<FJsonObject> DecObj = MakeShareable(new FJsonObject());
|
||||
DecObj->SetStringField(TEXT("class"), Info.DecoratorClasses[i]);
|
||||
if (Info.DecoratorNames.IsValidIndex(i))
|
||||
DecObj->SetStringField(TEXT("name"), Info.DecoratorNames[i]);
|
||||
DecArr.Add(MakeShareable(new FJsonValueObject(DecObj)));
|
||||
}
|
||||
Obj->SetArrayField(TEXT("decorators"), DecArr);
|
||||
}
|
||||
|
||||
if (Info.ServiceClasses.Num() > 0)
|
||||
{
|
||||
TArray<TSharedPtr<FJsonValue>> SvcArr;
|
||||
for (int32 i = 0; i < Info.ServiceClasses.Num(); ++i)
|
||||
{
|
||||
TSharedPtr<FJsonObject> SvcObj = MakeShareable(new FJsonObject());
|
||||
SvcObj->SetStringField(TEXT("class"), Info.ServiceClasses[i]);
|
||||
if (Info.ServiceNames.IsValidIndex(i))
|
||||
SvcObj->SetStringField(TEXT("name"), Info.ServiceNames[i]);
|
||||
SvcArr.Add(MakeShareable(new FJsonValueObject(SvcObj)));
|
||||
}
|
||||
Obj->SetArrayField(TEXT("services"), SvcArr);
|
||||
}
|
||||
|
||||
if (Info.Children.Num() > 0)
|
||||
{
|
||||
TArray<TSharedPtr<FJsonValue>> ChildArr;
|
||||
for (const FMCPythonBTNodeInfo& Child : Info.Children)
|
||||
{
|
||||
ChildArr.Add(MakeShareable(new FJsonValueObject(BTNodeInfoToJson(Child))));
|
||||
}
|
||||
Obj->SetArrayField(TEXT("children"), ChildArr);
|
||||
}
|
||||
|
||||
return Obj;
|
||||
}
|
||||
|
||||
// ─── Behavior Tree UFUNCTION Implementations ────────────────────────────────
|
||||
|
||||
FString UMCPythonHelper::GetBehaviorTreeStructure(UBehaviorTree* BehaviorTree)
|
||||
{
|
||||
if (!BehaviorTree || !BehaviorTree->RootNode)
|
||||
{
|
||||
return TEXT("{\"success\":false,\"message\":\"Invalid BehaviorTree or empty tree.\"}");
|
||||
}
|
||||
|
||||
FMCPythonBTNodeInfo RootInfo = SerializeBTNode(BehaviorTree->RootNode);
|
||||
TSharedPtr<FJsonObject> ResultObj = MakeShareable(new FJsonObject());
|
||||
ResultObj->SetBoolField(TEXT("success"), true);
|
||||
ResultObj->SetObjectField(TEXT("root"), BTNodeInfoToJson(RootInfo));
|
||||
|
||||
FString OutputString;
|
||||
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString);
|
||||
FJsonSerializer::Serialize(ResultObj.ToSharedRef(), Writer);
|
||||
return OutputString;
|
||||
}
|
||||
|
||||
bool UMCPythonHelper::SetBehaviorTreeBlackboard(UBehaviorTree* BehaviorTree, UBlackboardData* BlackboardData)
|
||||
{
|
||||
if (!BehaviorTree) return false;
|
||||
|
||||
BehaviorTree->BlackboardAsset = BlackboardData;
|
||||
BehaviorTree->MarkPackageDirty();
|
||||
return true;
|
||||
}
|
||||
|
||||
FString UMCPythonHelper::GetBehaviorTreeNodeDetails(UBehaviorTree* BehaviorTree, const FString& NodeName)
|
||||
{
|
||||
if (!BehaviorTree || !BehaviorTree->RootNode)
|
||||
{
|
||||
return TEXT("{\"success\":false,\"message\":\"Invalid BehaviorTree or empty tree.\"}");
|
||||
}
|
||||
|
||||
UBTNode* FoundNode = FindNodeByName(BehaviorTree->RootNode, NodeName);
|
||||
if (!FoundNode)
|
||||
{
|
||||
TSharedPtr<FJsonObject> ErrObj = MakeShareable(new FJsonObject());
|
||||
ErrObj->SetBoolField(TEXT("success"), false);
|
||||
ErrObj->SetStringField(TEXT("message"), FString::Printf(TEXT("Node '%s' not found in behavior tree."), *NodeName));
|
||||
FString ErrStr;
|
||||
auto ErrWriter = TJsonWriterFactory<>::Create(&ErrStr);
|
||||
FJsonSerializer::Serialize(ErrObj.ToSharedRef(), ErrWriter);
|
||||
return ErrStr;
|
||||
}
|
||||
|
||||
TSharedPtr<FJsonObject> JsonObj = MakeShareable(new FJsonObject());
|
||||
JsonObj->SetBoolField(TEXT("success"), true);
|
||||
JsonObj->SetStringField(TEXT("node_name"), FoundNode->GetNodeName());
|
||||
JsonObj->SetStringField(TEXT("node_class"), FoundNode->GetClass()->GetName());
|
||||
|
||||
// Serialize EditAnywhere properties
|
||||
TSharedPtr<FJsonObject> PropsObj = MakeShareable(new FJsonObject());
|
||||
for (TFieldIterator<FProperty> PropIt(FoundNode->GetClass()); PropIt; ++PropIt)
|
||||
{
|
||||
FProperty* Prop = *PropIt;
|
||||
if (!Prop->HasAnyPropertyFlags(CPF_Edit)) continue;
|
||||
|
||||
FString ValueStr;
|
||||
const void* ValueAddr = Prop->ContainerPtrToValuePtr<void>(FoundNode);
|
||||
Prop->ExportText_Direct(ValueStr, ValueAddr, nullptr, FoundNode, PPF_None);
|
||||
PropsObj->SetStringField(Prop->GetName(), ValueStr);
|
||||
}
|
||||
JsonObj->SetObjectField(TEXT("properties"), PropsObj);
|
||||
|
||||
// If composite node, include services and child count
|
||||
UBTCompositeNode* CompNode = Cast<UBTCompositeNode>(FoundNode);
|
||||
if (CompNode)
|
||||
{
|
||||
JsonObj->SetNumberField(TEXT("child_count"), CompNode->Children.Num());
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> ServicesArr;
|
||||
for (UBTService* Svc : CompNode->Services)
|
||||
{
|
||||
if (Svc)
|
||||
{
|
||||
TSharedPtr<FJsonObject> SvcObj = MakeShareable(new FJsonObject());
|
||||
SvcObj->SetStringField(TEXT("name"), Svc->GetNodeName());
|
||||
SvcObj->SetStringField(TEXT("class"), Svc->GetClass()->GetName());
|
||||
ServicesArr.Add(MakeShareable(new FJsonValueObject(SvcObj)));
|
||||
}
|
||||
}
|
||||
if (ServicesArr.Num() > 0)
|
||||
{
|
||||
JsonObj->SetArrayField(TEXT("services"), ServicesArr);
|
||||
}
|
||||
}
|
||||
|
||||
FString OutputString;
|
||||
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString);
|
||||
FJsonSerializer::Serialize(JsonObj.ToSharedRef(), Writer);
|
||||
return OutputString;
|
||||
}
|
||||
|
||||
FString UMCPythonHelper::GetSelectedBTNodes()
|
||||
{
|
||||
if (!GEditor)
|
||||
{
|
||||
return TEXT("{\"success\":false,\"message\":\"GEditor is null.\"}");
|
||||
}
|
||||
|
||||
auto* Subsystem = GEditor->GetEditorSubsystem<UAssetEditorSubsystem>();
|
||||
if (!Subsystem)
|
||||
{
|
||||
return TEXT("{\"success\":false,\"message\":\"AssetEditorSubsystem not available.\"}");
|
||||
}
|
||||
|
||||
for (UObject* Asset : Subsystem->GetAllEditedAssets())
|
||||
{
|
||||
UBehaviorTree* BT = Cast<UBehaviorTree>(Asset);
|
||||
if (!BT) continue;
|
||||
|
||||
IAssetEditorInstance* EditorInstance = Subsystem->FindEditorForAsset(Asset, false);
|
||||
FAssetEditorToolkit* EditorToolkit = static_cast<FAssetEditorToolkit*>(EditorInstance);
|
||||
if (!EditorToolkit) continue;
|
||||
|
||||
TSharedPtr<SDockTab> Tab = EditorToolkit->GetTabManager()->GetOwnerTab();
|
||||
if (!Tab.IsValid() || !Tab->IsForeground()) continue;
|
||||
|
||||
FBehaviorTreeEditor* BTEditor = static_cast<FBehaviorTreeEditor*>(EditorToolkit);
|
||||
if (!BTEditor) continue;
|
||||
|
||||
FGraphPanelSelectionSet SelectedNodes = BTEditor->GetSelectedNodes();
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> NodesArr;
|
||||
for (UObject* NodeObj : SelectedNodes)
|
||||
{
|
||||
UBehaviorTreeGraphNode* GraphNode = Cast<UBehaviorTreeGraphNode>(NodeObj);
|
||||
if (!GraphNode) continue;
|
||||
|
||||
UBTNode* BTNode = Cast<UBTNode>(GraphNode->NodeInstance);
|
||||
if (!BTNode) continue;
|
||||
|
||||
TSharedPtr<FJsonObject> NodeJson = MakeShareable(new FJsonObject());
|
||||
NodeJson->SetStringField(TEXT("node_name"), BTNode->GetNodeName());
|
||||
NodeJson->SetStringField(TEXT("node_class"), BTNode->GetClass()->GetName());
|
||||
|
||||
// Classify node type
|
||||
FString NodeType;
|
||||
if (Cast<UBTCompositeNode>(BTNode))
|
||||
NodeType = TEXT("composite");
|
||||
else if (Cast<UBTTaskNode>(BTNode))
|
||||
NodeType = TEXT("task");
|
||||
else if (Cast<UBTDecorator>(BTNode))
|
||||
NodeType = TEXT("decorator");
|
||||
else if (Cast<UBTService>(BTNode))
|
||||
NodeType = TEXT("service");
|
||||
else
|
||||
NodeType = TEXT("unknown");
|
||||
NodeJson->SetStringField(TEXT("node_type"), NodeType);
|
||||
|
||||
// Serialize EditAnywhere properties
|
||||
TSharedPtr<FJsonObject> PropsObj = MakeShareable(new FJsonObject());
|
||||
for (TFieldIterator<FProperty> PropIt(BTNode->GetClass()); PropIt; ++PropIt)
|
||||
{
|
||||
FProperty* Prop = *PropIt;
|
||||
if (!Prop->HasAnyPropertyFlags(CPF_Edit)) continue;
|
||||
|
||||
FString ValueStr;
|
||||
const void* ValueAddr = Prop->ContainerPtrToValuePtr<void>(BTNode);
|
||||
Prop->ExportText_Direct(ValueStr, ValueAddr, nullptr, BTNode, PPF_None);
|
||||
PropsObj->SetStringField(Prop->GetName(), ValueStr);
|
||||
}
|
||||
NodeJson->SetObjectField(TEXT("properties"), PropsObj);
|
||||
|
||||
NodesArr.Add(MakeShareable(new FJsonValueObject(NodeJson)));
|
||||
}
|
||||
|
||||
// Build result
|
||||
TSharedPtr<FJsonObject> ResultObj = MakeShareable(new FJsonObject());
|
||||
ResultObj->SetBoolField(TEXT("success"), true);
|
||||
ResultObj->SetStringField(TEXT("behavior_tree_path"), BT->GetPathName());
|
||||
ResultObj->SetArrayField(TEXT("selected_nodes"), NodesArr);
|
||||
ResultObj->SetNumberField(TEXT("count"), NodesArr.Num());
|
||||
|
||||
FString ResultStr;
|
||||
TSharedRef<TJsonWriter<>> ResultWriter = TJsonWriterFactory<>::Create(&ResultStr);
|
||||
FJsonSerializer::Serialize(ResultObj.ToSharedRef(), ResultWriter);
|
||||
return ResultStr;
|
||||
}
|
||||
|
||||
return TEXT("{\"success\":false,\"message\":\"No Behavior Tree editor is open in the foreground.\"}");
|
||||
}
|
||||
|
||||
// ─── Build BT Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
static UClass* FindBTNodeClass(const FString& ClassName)
|
||||
{
|
||||
for (TObjectIterator<UClass> It; It; ++It)
|
||||
{
|
||||
UClass* Cls = *It;
|
||||
if (Cls->GetName() == ClassName &&
|
||||
Cls->IsChildOf(UBTNode::StaticClass()) &&
|
||||
!Cls->HasAnyClassFlags(CLASS_Abstract))
|
||||
{
|
||||
return Cls;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static void SetBTNodeProperties(UBTNode* Node, const TSharedPtr<FJsonObject>& PropertiesObj)
|
||||
{
|
||||
if (!Node || !PropertiesObj.IsValid()) return;
|
||||
|
||||
for (auto& Pair : PropertiesObj->Values)
|
||||
{
|
||||
FProperty* Prop = Node->GetClass()->FindPropertyByName(FName(*Pair.Key));
|
||||
if (!Prop) continue;
|
||||
|
||||
FString ValueStr;
|
||||
if (Pair.Value->TryGetString(ValueStr))
|
||||
{
|
||||
// Already a string — use as-is
|
||||
}
|
||||
else if (Pair.Value->Type == EJson::Number)
|
||||
{
|
||||
ValueStr = FString::SanitizeFloat(Pair.Value->AsNumber());
|
||||
}
|
||||
else if (Pair.Value->Type == EJson::Boolean)
|
||||
{
|
||||
ValueStr = Pair.Value->AsBool() ? TEXT("true") : TEXT("false");
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
void* ValueAddr = Prop->ContainerPtrToValuePtr<void>(Node);
|
||||
|
||||
FStructProperty* StructProp = CastField<FStructProperty>(Prop);
|
||||
if (StructProp && StructProp->Struct->FindPropertyByName(TEXT("DefaultValue")))
|
||||
{
|
||||
FString WrappedValue = FString::Printf(TEXT("(DefaultValue=%s)"), *ValueStr);
|
||||
Prop->ImportText_Direct(*WrappedValue, ValueAddr, Node, PPF_None);
|
||||
}
|
||||
else
|
||||
{
|
||||
Prop->ImportText_Direct(*ValueStr, ValueAddr, Node, PPF_None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static UEdGraphPin* FindGraphPin(UEdGraphNode* Node, EEdGraphPinDirection Direction)
|
||||
{
|
||||
for (UEdGraphPin* Pin : Node->Pins)
|
||||
{
|
||||
if (Pin->Direction == Direction)
|
||||
return Pin;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static int32 CountSubtreeLeaves(UEdGraphNode* Node)
|
||||
{
|
||||
int32 Total = 0;
|
||||
for (UEdGraphPin* Pin : Node->Pins)
|
||||
{
|
||||
if (Pin->Direction == EGPD_Output)
|
||||
{
|
||||
for (UEdGraphPin* LinkedPin : Pin->LinkedTo)
|
||||
{
|
||||
Total += CountSubtreeLeaves(LinkedPin->GetOwningNode());
|
||||
}
|
||||
}
|
||||
}
|
||||
return FMath::Max(1, Total);
|
||||
}
|
||||
|
||||
static void LayoutBTGraphNodes(UEdGraphNode* Node, float LeftX, float Width, float Y)
|
||||
{
|
||||
const float NodeWidth = 280.0f;
|
||||
const float YStep = 200.0f;
|
||||
|
||||
Node->NodePosX = (int32)(LeftX + Width / 2.0f - NodeWidth / 2.0f);
|
||||
Node->NodePosY = (int32)Y;
|
||||
|
||||
float SubNodeHeight = 0.0f;
|
||||
UBehaviorTreeGraphNode* BTNode = Cast<UBehaviorTreeGraphNode>(Node);
|
||||
if (BTNode)
|
||||
{
|
||||
SubNodeHeight = (BTNode->Decorators.Num() + BTNode->Services.Num()) * 60.0f;
|
||||
}
|
||||
|
||||
float ChildY = Y + YStep + SubNodeHeight;
|
||||
float ChildX = LeftX;
|
||||
|
||||
for (UEdGraphPin* Pin : Node->Pins)
|
||||
{
|
||||
if (Pin->Direction == EGPD_Output)
|
||||
{
|
||||
for (UEdGraphPin* LinkedPin : Pin->LinkedTo)
|
||||
{
|
||||
UEdGraphNode* Child = LinkedPin->GetOwningNode();
|
||||
int32 ChildLeaves = CountSubtreeLeaves(Child);
|
||||
float ChildWidth = ChildLeaves * (NodeWidth + 40.0f);
|
||||
LayoutBTGraphNodes(Child, ChildX, ChildWidth, ChildY);
|
||||
ChildX += ChildWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static bool CheckClassAncestor(UClass* NodeClass, const TCHAR* AncestorName)
|
||||
{
|
||||
for (UClass* C = NodeClass; C; C = C->GetSuperClass())
|
||||
{
|
||||
if (C->GetName() == AncestorName)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static UBehaviorTreeGraphNode* CreateBTGraphNodeRecursive(
|
||||
UBehaviorTreeGraph* Graph,
|
||||
UBehaviorTree* BT,
|
||||
const TSharedPtr<FJsonObject>& JsonNode)
|
||||
{
|
||||
if (!JsonNode.IsValid() || !JsonNode->HasField(TEXT("node_class")))
|
||||
return nullptr;
|
||||
|
||||
FString NodeClassName = JsonNode->GetStringField(TEXT("node_class"));
|
||||
UClass* NodeClass = FindBTNodeClass(NodeClassName);
|
||||
if (!NodeClass)
|
||||
{
|
||||
UE_LOG(LogTemp, Warning, TEXT("BuildBT: Class '%s' not found"), *NodeClassName);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Create runtime node
|
||||
UBTNode* RuntimeNode = NewObject<UBTNode>(BT, NodeClass);
|
||||
|
||||
// Classify node type
|
||||
bool bIsComposite = NodeClass->IsChildOf(UBTCompositeNode::StaticClass());
|
||||
bool bIsTask = NodeClass->IsChildOf(UBTTaskNode::StaticClass());
|
||||
bool bIsSimpleParallel = CheckClassAncestor(NodeClass, TEXT("BTComposite_SimpleParallel"));
|
||||
bool bIsSubtreeTask = CheckClassAncestor(NodeClass, TEXT("BTTask_RunBehavior"))
|
||||
|| CheckClassAncestor(NodeClass, TEXT("BTTask_RunBehaviorDynamic"));
|
||||
|
||||
// Create appropriate graph node
|
||||
UBehaviorTreeGraphNode* GraphNode = nullptr;
|
||||
|
||||
if (bIsSimpleParallel)
|
||||
{
|
||||
FGraphNodeCreator<UBehaviorTreeGraphNode_SimpleParallel> Creator(*Graph);
|
||||
GraphNode = Creator.CreateNode(false);
|
||||
Creator.Finalize();
|
||||
}
|
||||
else if (bIsComposite)
|
||||
{
|
||||
FGraphNodeCreator<UBehaviorTreeGraphNode_Composite> Creator(*Graph);
|
||||
GraphNode = Creator.CreateNode(false);
|
||||
Creator.Finalize();
|
||||
}
|
||||
else if (bIsSubtreeTask)
|
||||
{
|
||||
FGraphNodeCreator<UBehaviorTreeGraphNode_SubtreeTask> Creator(*Graph);
|
||||
GraphNode = Creator.CreateNode(false);
|
||||
Creator.Finalize();
|
||||
}
|
||||
else if (bIsTask)
|
||||
{
|
||||
FGraphNodeCreator<UBehaviorTreeGraphNode_Task> Creator(*Graph);
|
||||
GraphNode = Creator.CreateNode(false);
|
||||
Creator.Finalize();
|
||||
}
|
||||
else
|
||||
{
|
||||
UE_LOG(LogTemp, Warning, TEXT("BuildBT: Unsupported node type for '%s'"), *NodeClassName);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Set NodeInstance
|
||||
GraphNode->NodeInstance = RuntimeNode;
|
||||
|
||||
// Set properties on the runtime node
|
||||
if (JsonNode->HasField(TEXT("properties")))
|
||||
{
|
||||
const TSharedPtr<FJsonObject>& PropsObj = JsonNode->GetObjectField(TEXT("properties"));
|
||||
SetBTNodeProperties(RuntimeNode, PropsObj);
|
||||
}
|
||||
|
||||
// Add decorators as sub-nodes
|
||||
if (JsonNode->HasField(TEXT("decorators")))
|
||||
{
|
||||
const TArray<TSharedPtr<FJsonValue>>& DecoratorsArr = JsonNode->GetArrayField(TEXT("decorators"));
|
||||
for (const auto& DecVal : DecoratorsArr)
|
||||
{
|
||||
const TSharedPtr<FJsonObject>& DecObj = DecVal->AsObject();
|
||||
if (!DecObj.IsValid() || !DecObj->HasField(TEXT("class"))) continue;
|
||||
|
||||
FString DecClassName = DecObj->GetStringField(TEXT("class"));
|
||||
UClass* DecClass = FindBTNodeClass(DecClassName);
|
||||
if (!DecClass || !DecClass->IsChildOf(UBTDecorator::StaticClass()))
|
||||
{
|
||||
UE_LOG(LogTemp, Warning, TEXT("BuildBT: Decorator class '%s' not found or invalid"), *DecClassName);
|
||||
continue;
|
||||
}
|
||||
|
||||
UBTDecorator* DecRuntime = NewObject<UBTDecorator>(BT, DecClass);
|
||||
if (DecObj->HasField(TEXT("properties")))
|
||||
{
|
||||
SetBTNodeProperties(DecRuntime, DecObj->GetObjectField(TEXT("properties")));
|
||||
}
|
||||
|
||||
UBehaviorTreeGraphNode_Decorator* DecGraphNode =
|
||||
NewObject<UBehaviorTreeGraphNode_Decorator>(Graph);
|
||||
DecGraphNode->NodeInstance = DecRuntime;
|
||||
GraphNode->AddSubNode(DecGraphNode, Graph);
|
||||
}
|
||||
}
|
||||
|
||||
// Add services as sub-nodes
|
||||
if (JsonNode->HasField(TEXT("services")))
|
||||
{
|
||||
const TArray<TSharedPtr<FJsonValue>>& ServicesArr = JsonNode->GetArrayField(TEXT("services"));
|
||||
for (const auto& SvcVal : ServicesArr)
|
||||
{
|
||||
const TSharedPtr<FJsonObject>& SvcObj = SvcVal->AsObject();
|
||||
if (!SvcObj.IsValid() || !SvcObj->HasField(TEXT("class"))) continue;
|
||||
|
||||
FString SvcClassName = SvcObj->GetStringField(TEXT("class"));
|
||||
UClass* SvcClass = FindBTNodeClass(SvcClassName);
|
||||
if (!SvcClass || !SvcClass->IsChildOf(UBTService::StaticClass()))
|
||||
{
|
||||
UE_LOG(LogTemp, Warning, TEXT("BuildBT: Service class '%s' not found or invalid"), *SvcClassName);
|
||||
continue;
|
||||
}
|
||||
|
||||
UBTService* SvcRuntime = NewObject<UBTService>(BT, SvcClass);
|
||||
if (SvcObj->HasField(TEXT("properties")))
|
||||
{
|
||||
SetBTNodeProperties(SvcRuntime, SvcObj->GetObjectField(TEXT("properties")));
|
||||
}
|
||||
|
||||
UBehaviorTreeGraphNode_Service* SvcGraphNode =
|
||||
NewObject<UBehaviorTreeGraphNode_Service>(Graph);
|
||||
SvcGraphNode->NodeInstance = SvcRuntime;
|
||||
GraphNode->AddSubNode(SvcGraphNode, Graph);
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse for children (only composites have children)
|
||||
if (bIsComposite && JsonNode->HasField(TEXT("children")))
|
||||
{
|
||||
const TArray<TSharedPtr<FJsonValue>>& ChildrenArr = JsonNode->GetArrayField(TEXT("children"));
|
||||
UEdGraphPin* OutputPin = FindGraphPin(GraphNode, EGPD_Output);
|
||||
|
||||
if (OutputPin)
|
||||
{
|
||||
for (const auto& ChildVal : ChildrenArr)
|
||||
{
|
||||
const TSharedPtr<FJsonObject>& ChildObj = ChildVal->AsObject();
|
||||
if (!ChildObj.IsValid()) continue;
|
||||
|
||||
UBehaviorTreeGraphNode* ChildGraphNode =
|
||||
CreateBTGraphNodeRecursive(Graph, BT, ChildObj);
|
||||
|
||||
if (ChildGraphNode)
|
||||
{
|
||||
UEdGraphPin* ChildInputPin = FindGraphPin(ChildGraphNode, EGPD_Input);
|
||||
if (ChildInputPin)
|
||||
{
|
||||
OutputPin->MakeLinkTo(ChildInputPin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GraphNode;
|
||||
}
|
||||
|
||||
// ─── BuildBehaviorTree UFUNCTION ─────────────────────────────────────────────
|
||||
|
||||
FString UMCPythonHelper::BuildBehaviorTree(UBehaviorTree* BehaviorTree, const FString& TreeStructureJson)
|
||||
{
|
||||
if (!BehaviorTree)
|
||||
{
|
||||
return TEXT("{\"success\":false,\"message\":\"Invalid BehaviorTree asset.\"}");
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
TSharedPtr<FJsonObject> JsonObj;
|
||||
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(TreeStructureJson);
|
||||
if (!FJsonSerializer::Deserialize(Reader, JsonObj) || !JsonObj.IsValid())
|
||||
{
|
||||
return TEXT("{\"success\":false,\"message\":\"Failed to parse JSON input.\"}");
|
||||
}
|
||||
|
||||
// Get BT graph — create if missing (e.g. asset created without factory)
|
||||
UBehaviorTreeGraph* BTGraph = Cast<UBehaviorTreeGraph>(BehaviorTree->BTGraph);
|
||||
if (!BTGraph)
|
||||
{
|
||||
UBehaviorTreeGraph* NewGraph = NewObject<UBehaviorTreeGraph>(BehaviorTree, NAME_None, RF_Transactional);
|
||||
NewGraph->Schema = UEdGraphSchema_BehaviorTree::StaticClass();
|
||||
BehaviorTree->BTGraph = NewGraph;
|
||||
|
||||
const UEdGraphSchema* Schema = NewGraph->GetSchema();
|
||||
if (Schema)
|
||||
{
|
||||
Schema->CreateDefaultNodesForGraph(*NewGraph);
|
||||
}
|
||||
|
||||
BTGraph = NewGraph;
|
||||
}
|
||||
|
||||
// Find root graph node
|
||||
UBehaviorTreeGraphNode_Root* RootGraphNode = nullptr;
|
||||
for (UEdGraphNode* Node : BTGraph->Nodes)
|
||||
{
|
||||
RootGraphNode = Cast<UBehaviorTreeGraphNode_Root>(Node);
|
||||
if (RootGraphNode) break;
|
||||
}
|
||||
|
||||
if (!RootGraphNode)
|
||||
{
|
||||
return TEXT("{\"success\":false,\"message\":\"No root node found in BT graph.\"}");
|
||||
}
|
||||
|
||||
// Remove all existing non-root graph nodes
|
||||
TArray<UEdGraphNode*> NodesToRemove;
|
||||
for (UEdGraphNode* Node : BTGraph->Nodes)
|
||||
{
|
||||
if (Node != RootGraphNode)
|
||||
{
|
||||
NodesToRemove.Add(Node);
|
||||
}
|
||||
}
|
||||
for (UEdGraphNode* Node : NodesToRemove)
|
||||
{
|
||||
BTGraph->RemoveNode(Node);
|
||||
}
|
||||
|
||||
// Clear root pin links and sub-nodes
|
||||
for (UEdGraphPin* Pin : RootGraphNode->Pins)
|
||||
{
|
||||
Pin->BreakAllPinLinks();
|
||||
}
|
||||
RootGraphNode->Decorators.Empty();
|
||||
RootGraphNode->Services.Empty();
|
||||
|
||||
// Create graph nodes from JSON
|
||||
UBehaviorTreeGraphNode* FirstChild = CreateBTGraphNodeRecursive(BTGraph, BehaviorTree, JsonObj);
|
||||
|
||||
if (!FirstChild)
|
||||
{
|
||||
return TEXT("{\"success\":false,\"message\":\"Failed to create root node from JSON. Check node_class names.\"}");
|
||||
}
|
||||
|
||||
// Connect root to first child
|
||||
UEdGraphPin* RootOutput = FindGraphPin(RootGraphNode, EGPD_Output);
|
||||
UEdGraphPin* ChildInput = FindGraphPin(FirstChild, EGPD_Input);
|
||||
if (RootOutput && ChildInput)
|
||||
{
|
||||
RootOutput->MakeLinkTo(ChildInput);
|
||||
}
|
||||
|
||||
// Layout nodes BEFORE UpdateAsset — RebuildChildOrder sorts children by NodePosX
|
||||
float TotalWidth = CountSubtreeLeaves(RootGraphNode) * 320.0f;
|
||||
LayoutBTGraphNodes(RootGraphNode, 0.0f, TotalWidth, 0.0f);
|
||||
|
||||
// Compile graph → runtime tree (uses node positions for child ordering)
|
||||
BTGraph->UpdateAsset();
|
||||
|
||||
BehaviorTree->MarkPackageDirty();
|
||||
|
||||
// Return success
|
||||
TSharedPtr<FJsonObject> ResultObj = MakeShareable(new FJsonObject());
|
||||
ResultObj->SetBoolField(TEXT("success"), true);
|
||||
ResultObj->SetStringField(TEXT("message"), TEXT("Behavior tree built successfully from JSON."));
|
||||
|
||||
FString ResultStr;
|
||||
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&ResultStr);
|
||||
FJsonSerializer::Serialize(ResultObj.ToSharedRef(), Writer);
|
||||
return ResultStr;
|
||||
}
|
||||
|
||||
// ─── ListBTNodeClasses UFUNCTION ─────────────────────────────────────────────
|
||||
|
||||
FString UMCPythonHelper::ListBTNodeClasses()
|
||||
{
|
||||
TArray<TSharedPtr<FJsonValue>> Composites, Tasks, Decorators, Services;
|
||||
|
||||
for (TObjectIterator<UClass> It; It; ++It)
|
||||
{
|
||||
UClass* Cls = *It;
|
||||
if (Cls->HasAnyClassFlags(CLASS_Abstract | CLASS_Deprecated | CLASS_NewerVersionExists))
|
||||
continue;
|
||||
|
||||
FString ClassName = Cls->GetName();
|
||||
TSharedPtr<FJsonValue> NameVal = MakeShareable(new FJsonValueString(ClassName));
|
||||
|
||||
if (Cls->IsChildOf(UBTCompositeNode::StaticClass()))
|
||||
Composites.Add(NameVal);
|
||||
else if (Cls->IsChildOf(UBTTaskNode::StaticClass()))
|
||||
Tasks.Add(NameVal);
|
||||
else if (Cls->IsChildOf(UBTDecorator::StaticClass()))
|
||||
Decorators.Add(NameVal);
|
||||
else if (Cls->IsChildOf(UBTService::StaticClass()))
|
||||
Services.Add(NameVal);
|
||||
}
|
||||
|
||||
TSharedPtr<FJsonObject> Result = MakeShareable(new FJsonObject());
|
||||
Result->SetBoolField(TEXT("success"), true);
|
||||
Result->SetArrayField(TEXT("composites"), Composites);
|
||||
Result->SetArrayField(TEXT("tasks"), Tasks);
|
||||
Result->SetArrayField(TEXT("decorators"), Decorators);
|
||||
Result->SetArrayField(TEXT("services"), Services);
|
||||
|
||||
FString OutputString;
|
||||
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString);
|
||||
FJsonSerializer::Serialize(Result.ToSharedRef(), Writer);
|
||||
return OutputString;
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
// Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved.
|
||||
// UMG Widget Blueprint authoring — split out of MCPythonHelper.cpp.
|
||||
|
||||
#include "MCPythonHelper.h"
|
||||
#include "MCPythonHelperInternal.h"
|
||||
#include "Engine/SCS_Node.h"
|
||||
#include "Engine/SimpleConstructionScript.h"
|
||||
#include "WidgetBlueprint.h"
|
||||
#include "Blueprint/WidgetTree.h"
|
||||
#include "Components/Widget.h"
|
||||
#include "Components/PanelWidget.h"
|
||||
#include "Editor.h"
|
||||
#include "Subsystems/AssetEditorSubsystem.h"
|
||||
#include "Toolkits/AssetEditorToolkit.h"
|
||||
#include "BlueprintEditor.h"
|
||||
#include "BehaviorTree/BehaviorTree.h"
|
||||
#include "BehaviorTree/BlackboardData.h"
|
||||
#include "BehaviorTree/BTCompositeNode.h"
|
||||
#include "BehaviorTree/BTTaskNode.h"
|
||||
#include "BehaviorTree/BTDecorator.h"
|
||||
#include "BehaviorTree/BTService.h"
|
||||
#include "BehaviorTreeEditor.h"
|
||||
#include "BehaviorTreeGraphNode.h"
|
||||
#include "BehaviorTreeGraph.h"
|
||||
#include "BehaviorTreeGraphNode_Root.h"
|
||||
#include "BehaviorTreeGraphNode_Composite.h"
|
||||
#include "BehaviorTreeGraphNode_Task.h"
|
||||
#include "BehaviorTreeGraphNode_Decorator.h"
|
||||
#include "BehaviorTreeGraphNode_Service.h"
|
||||
#include "BehaviorTreeGraphNode_SimpleParallel.h"
|
||||
#include "BehaviorTreeGraphNode_SubtreeTask.h"
|
||||
#include "EdGraphSchema_BehaviorTree.h"
|
||||
#include "EdGraph/EdGraph.h"
|
||||
#include "UObject/UObjectIterator.h"
|
||||
#include "Engine/SkeletalMesh.h"
|
||||
#include "Engine/SkeletalMeshSocket.h"
|
||||
#include "Dom/JsonObject.h"
|
||||
#include "Dom/JsonValue.h"
|
||||
#include "Serialization/JsonWriter.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Serialization/JsonReader.h"
|
||||
// Blueprint graph includes
|
||||
#include "K2Node_Event.h"
|
||||
#include "K2Node_ComponentBoundEvent.h"
|
||||
#include "K2Node_CustomEvent.h"
|
||||
#include "K2Node_CallFunction.h"
|
||||
#include "K2Node_IfThenElse.h"
|
||||
#include "K2Node_ExecutionSequence.h"
|
||||
#include "K2Node_VariableGet.h"
|
||||
#include "K2Node_VariableSet.h"
|
||||
#include "K2Node_MacroInstance.h"
|
||||
#include "K2Node_DynamicCast.h"
|
||||
#include "K2Node_InputKey.h"
|
||||
#include "K2Node_SpawnActorFromClass.h"
|
||||
#include "EdGraphSchema_K2.h"
|
||||
#include "Kismet2/BlueprintEditorUtils.h"
|
||||
#include "Kismet2/KismetEditorUtilities.h"
|
||||
#include "Engine/Blueprint.h"
|
||||
#include "UObject/UnrealType.h"
|
||||
#include "UObject/TextProperty.h"
|
||||
#include "Components/CanvasPanelSlot.h"
|
||||
#include "Components/TextBlock.h"
|
||||
// AnimGraph authoring (editor-only AnimGraph module)
|
||||
#include "Animation/AnimBlueprint.h"
|
||||
#include "Animation/AnimSequence.h"
|
||||
#include "AnimGraphNode_StateMachine.h"
|
||||
#include "AnimGraphNode_SequencePlayer.h"
|
||||
#include "AnimGraphNode_Root.h"
|
||||
#include "AnimGraphNode_StateResult.h"
|
||||
#include "AnimGraphNode_TransitionResult.h"
|
||||
#include "AnimStateNode.h"
|
||||
#include "AnimStateTransitionNode.h"
|
||||
#include "AnimStateEntryNode.h"
|
||||
#include "AnimationStateMachineGraph.h"
|
||||
#include "Kismet/KismetMathLibrary.h"
|
||||
// Editor viewport projection
|
||||
#include "LevelEditorViewport.h"
|
||||
#include "EditorViewportClient.h"
|
||||
#include "SceneView.h"
|
||||
|
||||
// ─── UMG Widget Blueprint Helpers ─────────────────────────────────────────────
|
||||
|
||||
namespace
|
||||
{
|
||||
static UClass* FindUMGWidgetClass(const FString& TypeName)
|
||||
{
|
||||
static const TMap<FString, FString> TypeMap = {
|
||||
{TEXT("CanvasPanel"), TEXT("/Script/UMG.CanvasPanel")},
|
||||
{TEXT("TextBlock"), TEXT("/Script/UMG.TextBlock")},
|
||||
{TEXT("Button"), TEXT("/Script/UMG.Button")},
|
||||
{TEXT("Image"), TEXT("/Script/UMG.Image")},
|
||||
{TEXT("HorizontalBox"), TEXT("/Script/UMG.HorizontalBox")},
|
||||
{TEXT("VerticalBox"), TEXT("/Script/UMG.VerticalBox")},
|
||||
{TEXT("Border"), TEXT("/Script/UMG.Border")},
|
||||
{TEXT("Overlay"), TEXT("/Script/UMG.Overlay")},
|
||||
{TEXT("ScrollBox"), TEXT("/Script/UMG.ScrollBox")},
|
||||
{TEXT("SizeBox"), TEXT("/Script/UMG.SizeBox")},
|
||||
{TEXT("CheckBox"), TEXT("/Script/UMG.CheckBox")},
|
||||
{TEXT("EditableText"), TEXT("/Script/UMG.EditableText")},
|
||||
{TEXT("EditableTextBox"), TEXT("/Script/UMG.EditableTextBox")},
|
||||
{TEXT("ProgressBar"), TEXT("/Script/UMG.ProgressBar")},
|
||||
{TEXT("Slider"), TEXT("/Script/UMG.Slider")},
|
||||
};
|
||||
const FString* Path = TypeMap.Find(TypeName);
|
||||
if (!Path) return nullptr;
|
||||
return LoadObject<UClass>(nullptr, **Path);
|
||||
}
|
||||
|
||||
static FString UmgErrorJson(const FString& Msg)
|
||||
{
|
||||
TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
|
||||
Obj->SetBoolField(TEXT("success"), false);
|
||||
Obj->SetStringField(TEXT("message"), Msg);
|
||||
return SerializeJsonObj(Obj);
|
||||
}
|
||||
}
|
||||
|
||||
FString UMCPythonHelper::UmgGetWidgetInfo(UBlueprint* WidgetBP)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB) return UmgErrorJson(TEXT("Asset is not a WidgetBlueprint."));
|
||||
|
||||
UWidgetTree* WT = WB->WidgetTree;
|
||||
if (!WT) return UmgErrorJson(TEXT("Widget tree is null."));
|
||||
|
||||
TSharedPtr<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
Root->SetBoolField(TEXT("success"), true);
|
||||
|
||||
if (WT->RootWidget)
|
||||
Root->SetStringField(TEXT("root_widget"), WT->RootWidget->GetName());
|
||||
else
|
||||
Root->SetField(TEXT("root_widget"), MakeShared<FJsonValueNull>());
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> WidgetArr;
|
||||
WT->ForEachWidget([&](UWidget* W) {
|
||||
TSharedPtr<FJsonObject> WObj = MakeShared<FJsonObject>();
|
||||
WObj->SetStringField(TEXT("name"), W->GetName());
|
||||
WObj->SetStringField(TEXT("type"), W->GetClass()->GetName());
|
||||
if (UWidget* Parent = W->GetParent())
|
||||
WObj->SetStringField(TEXT("parent"), Parent->GetName());
|
||||
WidgetArr.Add(MakeShared<FJsonValueObject>(WObj));
|
||||
});
|
||||
|
||||
Root->SetArrayField(TEXT("widgets"), WidgetArr);
|
||||
Root->SetNumberField(TEXT("widget_count"), WidgetArr.Num());
|
||||
|
||||
return SerializeJsonObj(Root);
|
||||
}
|
||||
|
||||
FString UMCPythonHelper::UmgAddWidget(UBlueprint* WidgetBP, const FString& WidgetType, const FString& WidgetName, const FString& ParentName)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB) return UmgErrorJson(TEXT("Asset is not a WidgetBlueprint."));
|
||||
|
||||
UWidgetTree* WT = WB->WidgetTree;
|
||||
if (!WT) return UmgErrorJson(TEXT("Widget tree is null."));
|
||||
|
||||
UClass* WidgetClass = FindUMGWidgetClass(WidgetType);
|
||||
if (!WidgetClass)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Unknown widget type '%s'."), *WidgetType));
|
||||
|
||||
WT->Modify();
|
||||
UWidget* NewWidget = WT->ConstructWidget<UWidget>(WidgetClass, FName(*WidgetName));
|
||||
if (!NewWidget)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Failed to construct widget '%s'."), *WidgetName));
|
||||
|
||||
// Mark as variable so Blueprint graph can reference it directly
|
||||
NewWidget->bIsVariable = true;
|
||||
|
||||
FString ActualParent;
|
||||
bool bIsRoot = false;
|
||||
|
||||
if (!ParentName.IsEmpty())
|
||||
{
|
||||
UWidget* ParentWidget = WT->FindWidget(FName(*ParentName));
|
||||
if (!ParentWidget)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Parent widget '%s' not found."), *ParentName));
|
||||
|
||||
UPanelWidget* Panel = Cast<UPanelWidget>(ParentWidget);
|
||||
if (!Panel)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Parent '%s' is not a panel widget."), *ParentName));
|
||||
|
||||
Panel->AddChild(NewWidget);
|
||||
ActualParent = ParentName;
|
||||
}
|
||||
else if (!WT->RootWidget)
|
||||
{
|
||||
WT->RootWidget = NewWidget;
|
||||
bIsRoot = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
UPanelWidget* RootPanel = Cast<UPanelWidget>(WT->RootWidget);
|
||||
if (!RootPanel)
|
||||
return UmgErrorJson(TEXT("Root widget is not a panel. Specify 'parent_name' explicitly."));
|
||||
RootPanel->AddChild(NewWidget);
|
||||
ActualParent = RootPanel->GetName();
|
||||
}
|
||||
|
||||
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(WB);
|
||||
|
||||
TSharedPtr<FJsonObject> Result = MakeShared<FJsonObject>();
|
||||
Result->SetBoolField(TEXT("success"), true);
|
||||
Result->SetStringField(TEXT("widget_name"), NewWidget->GetName());
|
||||
Result->SetStringField(TEXT("widget_type"), WidgetType);
|
||||
Result->SetBoolField(TEXT("is_root"), bIsRoot);
|
||||
if (!ActualParent.IsEmpty())
|
||||
Result->SetStringField(TEXT("parent"), ActualParent);
|
||||
|
||||
return SerializeJsonObj(Result);
|
||||
}
|
||||
|
||||
UWidget* UMCPythonHelper::UmgFindWidget(UBlueprint* WidgetBP, const FString& WidgetName)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB || !WB->WidgetTree) return nullptr;
|
||||
return WB->WidgetTree->FindWidget(FName(*WidgetName));
|
||||
}
|
||||
|
||||
FString UMCPythonHelper::UmgRemoveWidget(UBlueprint* WidgetBP, const FString& WidgetName)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB) return UmgErrorJson(TEXT("Asset is not a WidgetBlueprint."));
|
||||
|
||||
UWidgetTree* WT = WB->WidgetTree;
|
||||
if (!WT) return UmgErrorJson(TEXT("Widget tree is null."));
|
||||
|
||||
UWidget* Widget = WT->FindWidget(FName(*WidgetName));
|
||||
if (!Widget)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Widget '%s' not found."), *WidgetName));
|
||||
|
||||
WT->Modify();
|
||||
|
||||
UPanelWidget* Parent = Cast<UPanelWidget>(Widget->GetParent());
|
||||
if (Parent)
|
||||
{
|
||||
Parent->RemoveChild(Widget);
|
||||
}
|
||||
else if (WT->RootWidget && WT->RootWidget->GetName() == WidgetName)
|
||||
{
|
||||
WT->RootWidget = nullptr;
|
||||
}
|
||||
else
|
||||
{
|
||||
return UmgErrorJson(FString::Printf(TEXT("Cannot remove '%s': not attached to a panel or root."), *WidgetName));
|
||||
}
|
||||
|
||||
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(WB);
|
||||
|
||||
TSharedPtr<FJsonObject> Result = MakeShared<FJsonObject>();
|
||||
Result->SetBoolField(TEXT("success"), true);
|
||||
Result->SetStringField(TEXT("message"), FString::Printf(TEXT("Widget '%s' removed successfully."), *WidgetName));
|
||||
return SerializeJsonObj(Result);
|
||||
}
|
||||
|
||||
// ─── UmgSetWidgetIsVariable UFUNCTION ────────────────────────────────────────
|
||||
|
||||
FString UMCPythonHelper::UmgSetWidgetIsVariable(UBlueprint* WidgetBP, const FString& WidgetName, bool bIsVariable)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB) return UmgErrorJson(TEXT("Asset is not a WidgetBlueprint."));
|
||||
|
||||
UWidgetTree* WT = WB->WidgetTree;
|
||||
if (!WT) return UmgErrorJson(TEXT("Widget tree is null."));
|
||||
|
||||
UWidget* Widget = WT->FindWidget(FName(*WidgetName));
|
||||
if (!Widget)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Widget '%s' not found."), *WidgetName));
|
||||
|
||||
Widget->Modify();
|
||||
Widget->bIsVariable = bIsVariable;
|
||||
|
||||
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(WB);
|
||||
|
||||
TSharedPtr<FJsonObject> Result = MakeShared<FJsonObject>();
|
||||
Result->SetBoolField(TEXT("success"), true);
|
||||
Result->SetStringField(TEXT("widget_name"), WidgetName);
|
||||
Result->SetBoolField(TEXT("is_variable"), bIsVariable);
|
||||
return SerializeJsonObj(Result);
|
||||
}
|
||||
|
||||
// ─── UmgSetSlotLayout UFUNCTION ──────────────────────────────────────────────
|
||||
|
||||
FString UMCPythonHelper::UmgSetSlotLayout(UBlueprint* WidgetBP, const FString& WidgetName,
|
||||
float AnchorMinX, float AnchorMinY, float AnchorMaxX, float AnchorMaxY,
|
||||
float OffsetX, float OffsetY, float SizeX, float SizeY)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB || !WB->WidgetTree)
|
||||
return UmgErrorJson(TEXT("Not a WidgetBlueprint or no WidgetTree."));
|
||||
|
||||
UWidget* Widget = WB->WidgetTree->FindWidget(FName(*WidgetName));
|
||||
if (!Widget)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Widget '%s' not found."), *WidgetName));
|
||||
|
||||
UCanvasPanelSlot* CPS = Cast<UCanvasPanelSlot>(Widget->Slot);
|
||||
if (!CPS)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Widget '%s' is not in a CanvasPanel."), *WidgetName));
|
||||
|
||||
CPS->Modify();
|
||||
FAnchorData Data;
|
||||
Data.Anchors.Minimum = FVector2D(AnchorMinX, AnchorMinY);
|
||||
Data.Anchors.Maximum = FVector2D(AnchorMaxX, AnchorMaxY);
|
||||
Data.Offsets = FMargin(OffsetX, OffsetY, SizeX, SizeY);
|
||||
Data.Alignment = FVector2D(0.5f, 0.5f);
|
||||
CPS->SetLayout(Data);
|
||||
|
||||
FBlueprintEditorUtils::MarkBlueprintAsModified(WB);
|
||||
|
||||
TSharedPtr<FJsonObject> R = MakeShared<FJsonObject>();
|
||||
R->SetBoolField(TEXT("success"), true);
|
||||
R->SetStringField(TEXT("widget"), WidgetName);
|
||||
R->SetStringField(TEXT("message"), FString::Printf(TEXT("Layout set on '%s'."), *WidgetName));
|
||||
return SerializeJsonObj(R);
|
||||
}
|
||||
|
||||
// ─── UmgSetTextStyle UFUNCTION ───────────────────────────────────────────────
|
||||
|
||||
FString UMCPythonHelper::UmgSetTextStyle(UBlueprint* WidgetBP, const FString& WidgetName,
|
||||
int32 FontSize, float ColorR, float ColorG, float ColorB, float ColorA,
|
||||
int32 OutlineSize)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB || !WB->WidgetTree)
|
||||
return UmgErrorJson(TEXT("Not a WidgetBlueprint or no WidgetTree."));
|
||||
|
||||
UWidget* Widget = WB->WidgetTree->FindWidget(FName(*WidgetName));
|
||||
if (!Widget)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Widget '%s' not found."), *WidgetName));
|
||||
|
||||
UTextBlock* TB = Cast<UTextBlock>(Widget);
|
||||
if (!TB)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Widget '%s' is not a TextBlock."), *WidgetName));
|
||||
|
||||
TB->Modify();
|
||||
|
||||
FSlateFontInfo Font = TB->GetFont();
|
||||
Font.Size = FontSize;
|
||||
if (OutlineSize >= 0)
|
||||
Font.OutlineSettings.OutlineSize = OutlineSize;
|
||||
TB->SetFont(Font);
|
||||
|
||||
FLinearColor Color(ColorR, ColorG, ColorB, ColorA);
|
||||
TB->SetColorAndOpacity(FSlateColor(Color));
|
||||
|
||||
FBlueprintEditorUtils::MarkBlueprintAsModified(WB);
|
||||
|
||||
TSharedPtr<FJsonObject> R = MakeShared<FJsonObject>();
|
||||
R->SetBoolField(TEXT("success"), true);
|
||||
R->SetStringField(TEXT("widget"), WidgetName);
|
||||
R->SetStringField(TEXT("message"), FString::Printf(TEXT("Text style set on '%s': size=%d outline=%d."), *WidgetName, FontSize, OutlineSize));
|
||||
return SerializeJsonObj(R);
|
||||
}
|
||||
|
||||
// ─── UmgGetWidgetProperty UFUNCTION ──────────────────────────────────────────
|
||||
|
||||
FString UMCPythonHelper::UmgGetWidgetProperty(UBlueprint* WidgetBP, const FString& WidgetName, const FString& PropertyName)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB || !WB->WidgetTree)
|
||||
return UmgErrorJson(TEXT("Not a WidgetBlueprint or no WidgetTree."));
|
||||
|
||||
UWidget* Widget = WB->WidgetTree->FindWidget(FName(*WidgetName));
|
||||
if (!Widget)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Widget '%s' not found."), *WidgetName));
|
||||
|
||||
FProperty* Prop = Widget->GetClass()->FindPropertyByName(FName(*PropertyName));
|
||||
if (!Prop)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Property '%s' not found on widget '%s'."), *PropertyName, *WidgetName));
|
||||
|
||||
FString ValueStr;
|
||||
const void* ValueAddr = Prop->ContainerPtrToValuePtr<void>(Widget);
|
||||
Prop->ExportTextItem_Direct(ValueStr, ValueAddr, nullptr, Widget, PPF_None);
|
||||
|
||||
TSharedPtr<FJsonObject> R = MakeShared<FJsonObject>();
|
||||
R->SetBoolField(TEXT("success"), true);
|
||||
R->SetStringField(TEXT("widget"), WidgetName);
|
||||
R->SetStringField(TEXT("property"), PropertyName);
|
||||
R->SetStringField(TEXT("value"), ValueStr);
|
||||
R->SetStringField(TEXT("type"), Prop->GetCPPType());
|
||||
return SerializeJsonObj(R);
|
||||
}
|
||||
|
||||
// ─── UmgSetWidgetProperty UFUNCTION ──────────────────────────────────────────
|
||||
|
||||
FString UMCPythonHelper::UmgSetWidgetProperty(UBlueprint* WidgetBP, const FString& WidgetName, const FString& PropertyName, const FString& Value)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB || !WB->WidgetTree)
|
||||
return UmgErrorJson(TEXT("Not a WidgetBlueprint or no WidgetTree."));
|
||||
|
||||
UWidget* Widget = WB->WidgetTree->FindWidget(FName(*WidgetName));
|
||||
if (!Widget)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Widget '%s' not found."), *WidgetName));
|
||||
|
||||
FProperty* Prop = Widget->GetClass()->FindPropertyByName(FName(*PropertyName));
|
||||
if (!Prop)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Property '%s' not found on widget '%s'."), *PropertyName, *WidgetName));
|
||||
|
||||
Widget->Modify();
|
||||
void* ValueAddr = Prop->ContainerPtrToValuePtr<void>(Widget);
|
||||
const TCHAR* ImportResult = Prop->ImportText_Direct(*Value, ValueAddr, Widget, PPF_None);
|
||||
if (!ImportResult)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Failed to set property '%s' to '%s'."), *PropertyName, *Value));
|
||||
|
||||
FBlueprintEditorUtils::MarkBlueprintAsModified(WB);
|
||||
|
||||
TSharedPtr<FJsonObject> R = MakeShared<FJsonObject>();
|
||||
R->SetBoolField(TEXT("success"), true);
|
||||
R->SetStringField(TEXT("widget"), WidgetName);
|
||||
R->SetStringField(TEXT("property"), PropertyName);
|
||||
R->SetStringField(TEXT("value"), Value);
|
||||
return SerializeJsonObj(R);
|
||||
}
|
||||
|
||||
// ─── UMG hierarchy ops (reparent / wrap / replace) ───────────────────────────
|
||||
|
||||
// True if Ancestor is Widget itself or one of its ancestors (for reparent cycle guard).
|
||||
static bool UmgIsAncestorOf(UWidget* Ancestor, UWidget* Widget)
|
||||
{
|
||||
for (UWidget* W = Widget; W; W = W->GetParent())
|
||||
if (W == Ancestor) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
FString UMCPythonHelper::UmgReparentWidget(UBlueprint* WidgetBP, const FString& WidgetName, const FString& NewParentName)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB || !WB->WidgetTree) return UmgErrorJson(TEXT("Not a WidgetBlueprint or no WidgetTree."));
|
||||
UWidgetTree* WT = WB->WidgetTree;
|
||||
|
||||
UWidget* Widget = WT->FindWidget(FName(*WidgetName));
|
||||
if (!Widget) return UmgErrorJson(FString::Printf(TEXT("Widget '%s' not found."), *WidgetName));
|
||||
UWidget* NewParentWidget = WT->FindWidget(FName(*NewParentName));
|
||||
if (!NewParentWidget) return UmgErrorJson(FString::Printf(TEXT("New parent '%s' not found."), *NewParentName));
|
||||
UPanelWidget* NewParent = Cast<UPanelWidget>(NewParentWidget);
|
||||
if (!NewParent) return UmgErrorJson(FString::Printf(TEXT("New parent '%s' is not a panel widget."), *NewParentName));
|
||||
if (Widget == NewParent) return UmgErrorJson(TEXT("Cannot reparent a widget into itself."));
|
||||
if (UmgIsAncestorOf(Widget, NewParent))
|
||||
return UmgErrorJson(TEXT("Cannot reparent: target is an ancestor of the new parent (cycle)."));
|
||||
if (!NewParent->CanHaveMultipleChildren() && NewParent->GetChildrenCount() > 0)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Panel '%s' already holds its single allowed child."), *NewParentName));
|
||||
|
||||
WT->Modify();
|
||||
if (UPanelWidget* OldParent = Cast<UPanelWidget>(Widget->GetParent()))
|
||||
OldParent->RemoveChild(Widget);
|
||||
else if (WT->RootWidget == Widget)
|
||||
WT->RootWidget = nullptr;
|
||||
NewParent->AddChild(Widget);
|
||||
|
||||
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(WB);
|
||||
TSharedPtr<FJsonObject> R = MakeShared<FJsonObject>();
|
||||
R->SetBoolField(TEXT("success"), true);
|
||||
R->SetStringField(TEXT("widget"), WidgetName);
|
||||
R->SetStringField(TEXT("new_parent"), NewParentName);
|
||||
return SerializeJsonObj(R);
|
||||
}
|
||||
|
||||
FString UMCPythonHelper::UmgWrapWidget(UBlueprint* WidgetBP, const FString& WidgetName, const FString& WrapperType, const FString& WrapperName)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB || !WB->WidgetTree) return UmgErrorJson(TEXT("Not a WidgetBlueprint or no WidgetTree."));
|
||||
UWidgetTree* WT = WB->WidgetTree;
|
||||
|
||||
UWidget* Widget = WT->FindWidget(FName(*WidgetName));
|
||||
if (!Widget) return UmgErrorJson(FString::Printf(TEXT("Widget '%s' not found."), *WidgetName));
|
||||
|
||||
UClass* WrapperClass = FindUMGWidgetClass(WrapperType);
|
||||
if (!WrapperClass) return UmgErrorJson(FString::Printf(TEXT("Unknown widget type '%s'."), *WrapperType));
|
||||
if (!WrapperClass->IsChildOf(UPanelWidget::StaticClass()))
|
||||
return UmgErrorJson(FString::Printf(TEXT("Wrapper type '%s' is not a panel widget."), *WrapperType));
|
||||
|
||||
WT->Modify();
|
||||
UPanelWidget* Wrapper = WT->ConstructWidget<UPanelWidget>(WrapperClass, FName(*WrapperName));
|
||||
if (!Wrapper) return UmgErrorJson(FString::Printf(TEXT("Failed to construct wrapper '%s'."), *WrapperName));
|
||||
Wrapper->bIsVariable = true;
|
||||
|
||||
if (UPanelWidget* OldParent = Cast<UPanelWidget>(Widget->GetParent()))
|
||||
{
|
||||
const int32 Index = OldParent->GetChildIndex(Widget);
|
||||
OldParent->ReplaceChildAt(Index, Wrapper); // wrapper takes the widget's slot
|
||||
Wrapper->AddChild(Widget); // widget moves inside the wrapper
|
||||
}
|
||||
else if (WT->RootWidget == Widget)
|
||||
{
|
||||
WT->RootWidget = Wrapper;
|
||||
Wrapper->AddChild(Widget);
|
||||
}
|
||||
else
|
||||
{
|
||||
return UmgErrorJson(FString::Printf(TEXT("Widget '%s' is not attached to a panel or root."), *WidgetName));
|
||||
}
|
||||
|
||||
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(WB);
|
||||
TSharedPtr<FJsonObject> R = MakeShared<FJsonObject>();
|
||||
R->SetBoolField(TEXT("success"), true);
|
||||
R->SetStringField(TEXT("wrapped"), WidgetName);
|
||||
R->SetStringField(TEXT("wrapper"), Wrapper->GetName());
|
||||
R->SetStringField(TEXT("wrapper_type"), WrapperType);
|
||||
return SerializeJsonObj(R);
|
||||
}
|
||||
|
||||
FString UMCPythonHelper::UmgReplaceWidget(UBlueprint* WidgetBP, const FString& WidgetName, const FString& NewType, const FString& NewName)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB || !WB->WidgetTree) return UmgErrorJson(TEXT("Not a WidgetBlueprint or no WidgetTree."));
|
||||
UWidgetTree* WT = WB->WidgetTree;
|
||||
|
||||
UWidget* Widget = WT->FindWidget(FName(*WidgetName));
|
||||
if (!Widget) return UmgErrorJson(FString::Printf(TEXT("Widget '%s' not found."), *WidgetName));
|
||||
|
||||
UClass* NewClass = FindUMGWidgetClass(NewType);
|
||||
if (!NewClass) return UmgErrorJson(FString::Printf(TEXT("Unknown widget type '%s'."), *NewType));
|
||||
|
||||
WT->Modify();
|
||||
UWidget* NewWidget = WT->ConstructWidget<UWidget>(NewClass, FName(*NewName));
|
||||
if (!NewWidget) return UmgErrorJson(FString::Printf(TEXT("Failed to construct widget '%s'."), *NewName));
|
||||
NewWidget->bIsVariable = true;
|
||||
|
||||
if (UPanelWidget* OldParent = Cast<UPanelWidget>(Widget->GetParent()))
|
||||
{
|
||||
const int32 Index = OldParent->GetChildIndex(Widget);
|
||||
OldParent->ReplaceChildAt(Index, NewWidget); // old widget (and its subtree) is discarded
|
||||
}
|
||||
else if (WT->RootWidget == Widget)
|
||||
{
|
||||
WT->RootWidget = NewWidget;
|
||||
}
|
||||
else
|
||||
{
|
||||
return UmgErrorJson(FString::Printf(TEXT("Widget '%s' is not attached to a panel or root."), *WidgetName));
|
||||
}
|
||||
|
||||
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(WB);
|
||||
TSharedPtr<FJsonObject> R = MakeShared<FJsonObject>();
|
||||
R->SetBoolField(TEXT("success"), true);
|
||||
R->SetStringField(TEXT("replaced"), WidgetName);
|
||||
R->SetStringField(TEXT("new_widget"), NewWidget->GetName());
|
||||
R->SetStringField(TEXT("new_type"), NewType);
|
||||
return SerializeJsonObj(R);
|
||||
}
|
||||
|
||||
// ─── UMG event binding (widget delegate -> bound event node) ─────────────────
|
||||
|
||||
FString UMCPythonHelper::UmgListWidgetEvents(UBlueprint* WidgetBP, const FString& WidgetName)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB || !WB->WidgetTree) return UmgErrorJson(TEXT("Not a WidgetBlueprint or no WidgetTree."));
|
||||
UWidget* W = WB->WidgetTree->FindWidget(FName(*WidgetName));
|
||||
if (!W) return UmgErrorJson(FString::Printf(TEXT("Widget '%s' not found."), *WidgetName));
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Events;
|
||||
for (TFieldIterator<FMulticastDelegateProperty> It(W->GetClass()); It; ++It)
|
||||
Events.Add(MakeShareable(new FJsonValueString(It->GetName())));
|
||||
|
||||
TSharedPtr<FJsonObject> R = MakeShared<FJsonObject>();
|
||||
R->SetBoolField(TEXT("success"), true);
|
||||
R->SetStringField(TEXT("widget"), WidgetName);
|
||||
R->SetStringField(TEXT("widget_class"), W->GetClass()->GetName());
|
||||
R->SetArrayField(TEXT("events"), Events);
|
||||
return SerializeJsonObj(R);
|
||||
}
|
||||
|
||||
FString UMCPythonHelper::UmgBindWidgetEvent(UBlueprint* WidgetBP, const FString& WidgetName, const FString& EventName)
|
||||
{
|
||||
UWidgetBlueprint* WB = Cast<UWidgetBlueprint>(WidgetBP);
|
||||
if (!WB || !WB->WidgetTree) return UmgErrorJson(TEXT("Not a WidgetBlueprint or no WidgetTree."));
|
||||
UWidget* W = WB->WidgetTree->FindWidget(FName(*WidgetName));
|
||||
if (!W) return UmgErrorJson(FString::Printf(TEXT("Widget '%s' not found."), *WidgetName));
|
||||
|
||||
// The delegate must exist on the widget class.
|
||||
if (!FindFProperty<FMulticastDelegateProperty>(W->GetClass(), FName(*EventName)))
|
||||
{
|
||||
TArray<FString> Avail;
|
||||
for (TFieldIterator<FMulticastDelegateProperty> It(W->GetClass()); It; ++It) Avail.Add(It->GetName());
|
||||
return UmgErrorJson(FString::Printf(TEXT("Event '%s' not found on %s. Available: %s"),
|
||||
*EventName, *W->GetClass()->GetName(), *FString::Join(Avail, TEXT(", "))));
|
||||
}
|
||||
|
||||
// A bindable widget must be a variable.
|
||||
if (!W->bIsVariable)
|
||||
{
|
||||
W->bIsVariable = true;
|
||||
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(WB);
|
||||
}
|
||||
|
||||
// Resolve the generated variable property; compile ONCE only if it isn't present yet.
|
||||
FObjectProperty* VarProp = FindFProperty<FObjectProperty>(WB->SkeletonGeneratedClass, FName(*WidgetName));
|
||||
if (!VarProp)
|
||||
{
|
||||
FKismetEditorUtilities::CompileBlueprint(WB);
|
||||
VarProp = FindFProperty<FObjectProperty>(WB->SkeletonGeneratedClass, FName(*WidgetName));
|
||||
}
|
||||
if (!VarProp)
|
||||
return UmgErrorJson(FString::Printf(TEXT("Could not resolve the widget variable property for '%s'."), *WidgetName));
|
||||
|
||||
const bool bAlready = FKismetEditorUtilities::FindBoundEventForComponent(WB, FName(*EventName), VarProp->GetFName()) != nullptr;
|
||||
if (!bAlready)
|
||||
{
|
||||
// Mirror UMG's own detail panel: create the node and let the editor recompile on its
|
||||
// deferred tick / on save. A manual CompileBlueprint here triggers mid-task reinstancing
|
||||
// that crashes a subsequent save/delete of the same asset within one game-thread task.
|
||||
FKismetEditorUtilities::CreateNewBoundEventForClass(W->GetClass(), FName(*EventName), WB, VarProp);
|
||||
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(WB);
|
||||
}
|
||||
|
||||
const UK2Node_ComponentBoundEvent* Node =
|
||||
FKismetEditorUtilities::FindBoundEventForComponent(WB, FName(*EventName), VarProp->GetFName());
|
||||
|
||||
TSharedPtr<FJsonObject> R = MakeShared<FJsonObject>();
|
||||
R->SetBoolField(TEXT("success"), Node != nullptr);
|
||||
if (!Node)
|
||||
{
|
||||
R->SetStringField(TEXT("message"), TEXT("Bound event node was not created."));
|
||||
return SerializeJsonObj(R);
|
||||
}
|
||||
R->SetStringField(TEXT("widget"), WidgetName);
|
||||
R->SetStringField(TEXT("event"), EventName);
|
||||
R->SetStringField(TEXT("node"), Node->GetName());
|
||||
R->SetBoolField(TEXT("already_existed"), bAlready);
|
||||
return SerializeJsonObj(R);
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
// Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved.
|
||||
|
||||
#include "MCPythonTcpServer.h"
|
||||
#include "Sockets.h"
|
||||
#include "SocketSubsystem.h"
|
||||
#include "IPAddress.h"
|
||||
#include "Common/TcpListener.h"
|
||||
#include "IPythonScriptPlugin.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Serialization/JsonReader.h"
|
||||
#include "MCPythonHelper.h"
|
||||
#include "Dom/JsonObject.h"
|
||||
#include "ILiveCodingModule.h"
|
||||
#include "HAL/FileManager.h"
|
||||
#include "Misc/FileHelper.h"
|
||||
#include "Misc/Paths.h"
|
||||
#include "Misc/ScopeLock.h"
|
||||
|
||||
DEFINE_LOG_CATEGORY_STATIC(LogMCPython, Log, All);
|
||||
|
||||
namespace
|
||||
{
|
||||
// Accumulates lines emitted under the LogLiveCoding log category while a live
|
||||
// coding compile runs. Live Coding dispatches compiler output from worker threads,
|
||||
// so Serialize must be callable from any thread.
|
||||
class FMCPCompileLogCapture : public FOutputDevice
|
||||
{
|
||||
public:
|
||||
virtual void Serialize(const TCHAR* Message, ELogVerbosity::Type Verbosity, const FName& Category) override
|
||||
{
|
||||
static const FName TargetCategory(TEXT("LogLiveCoding"));
|
||||
if (Category != TargetCategory)
|
||||
return;
|
||||
FScopeLock Guard(&LineLock);
|
||||
CapturedLines.Add(FString(Message));
|
||||
}
|
||||
|
||||
virtual bool CanBeUsedOnAnyThread() const override { return true; }
|
||||
|
||||
// Returns all captured lines joined by newlines and clears the buffer.
|
||||
FString GetAndClear()
|
||||
{
|
||||
FScopeLock Guard(&LineLock);
|
||||
FString Result = FString::Join(CapturedLines, TEXT("\n"));
|
||||
CapturedLines.Reset();
|
||||
return Result;
|
||||
}
|
||||
|
||||
private:
|
||||
FCriticalSection LineLock;
|
||||
TArray<FString> CapturedLines;
|
||||
};
|
||||
|
||||
FString LCCompileResultToString(ELiveCodingCompileResult Result)
|
||||
{
|
||||
switch (Result)
|
||||
{
|
||||
case ELiveCodingCompileResult::Success: return TEXT("Success");
|
||||
case ELiveCodingCompileResult::NoChanges: return TEXT("NoChanges");
|
||||
case ELiveCodingCompileResult::Failure: return TEXT("Failure");
|
||||
case ELiveCodingCompileResult::CompileStillActive: return TEXT("CompileStillActive");
|
||||
case ELiveCodingCompileResult::NotStarted: return TEXT("NotStarted");
|
||||
case ELiveCodingCompileResult::Cancelled: return TEXT("Cancelled");
|
||||
case ELiveCodingCompileResult::InProgress: return TEXT("InProgress");
|
||||
default: return TEXT("Unknown");
|
||||
}
|
||||
}
|
||||
|
||||
// Reads UBT's Log.txt if it was written during this compile (detected by comparing
|
||||
// the file's modification time before and after the compile call) and returns lines
|
||||
// that look like MSVC compiler diagnostics. MSVC writes errors and warnings in the
|
||||
// form "file(line): error/warning/fatal error CNNNN: ..." which is a well-known
|
||||
// public format, independent of any specific engine implementation.
|
||||
FString CollectUBTDiagnostics(const FString& UBTLogFilePath, const FDateTime& TimestampBefore)
|
||||
{
|
||||
const FDateTime TimestampAfter = IFileManager::Get().GetTimeStamp(*UBTLogFilePath);
|
||||
if (TimestampAfter == FDateTime::MinValue() || TimestampAfter == TimestampBefore)
|
||||
return FString();
|
||||
|
||||
FString LogContent;
|
||||
if (!FFileHelper::LoadFileToString(LogContent, *UBTLogFilePath))
|
||||
return FString();
|
||||
|
||||
TArray<FString> AllLines;
|
||||
LogContent.ParseIntoArrayLines(AllLines, /*bCullEmpty=*/true);
|
||||
|
||||
TArray<FString> DiagnosticLines;
|
||||
for (const FString& Line : AllLines)
|
||||
{
|
||||
// Match the standard MSVC diagnostic format: path(row,col): severity CXXXX:
|
||||
const bool bError = Line.Contains(TEXT("): error "));
|
||||
const bool bFatal = Line.Contains(TEXT("): fatal error "));
|
||||
const bool bWarning = Line.Contains(TEXT("): warning "));
|
||||
if (bError || bFatal || bWarning)
|
||||
DiagnosticLines.Add(Line);
|
||||
}
|
||||
|
||||
return FString::Join(DiagnosticLines, TEXT("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert FJsonValue to Python literal string
|
||||
FString ConvertJsonValueToPythonLiteral(const TSharedPtr<FJsonValue>& JsonVal)
|
||||
{
|
||||
if (!JsonVal.IsValid() || JsonVal->Type == EJson::Null) return TEXT("None");
|
||||
|
||||
switch (JsonVal->Type)
|
||||
{
|
||||
case EJson::String:
|
||||
{
|
||||
FString EscapedString = JsonVal->AsString();
|
||||
// Order of replacement is important.
|
||||
// Escape backslashes: "\" -> "\\"
|
||||
EscapedString = EscapedString.Replace(TEXT("\\"), TEXT("\\\\"));
|
||||
// Escape single quotes: ' -> \'
|
||||
EscapedString = EscapedString.Replace(TEXT("\'"), TEXT("\\\'"));
|
||||
// Escape double quotes: \" -> \\\"
|
||||
EscapedString = EscapedString.Replace(TEXT("\""), TEXT("\\\""));
|
||||
// Escape newlines: \n -> \\n
|
||||
EscapedString = EscapedString.Replace(TEXT("\n"), TEXT("\\n"));
|
||||
// Escape carriage returns: \r -> \\r
|
||||
EscapedString = EscapedString.Replace(TEXT("\r"), TEXT("\\r"));
|
||||
// Escape tabs: \t -> \\t
|
||||
EscapedString = EscapedString.Replace(TEXT("\t"), TEXT("\\t"));
|
||||
return FString::Printf(TEXT("\'%s\'"), *EscapedString);
|
||||
}
|
||||
case EJson::Number:
|
||||
return JsonVal->AsString();
|
||||
case EJson::Boolean:
|
||||
return JsonVal->AsBool() ? TEXT("True") : TEXT("False");
|
||||
case EJson::Array:
|
||||
{
|
||||
FString ArrayLiteral = TEXT("[");
|
||||
const auto& Array = JsonVal->AsArray();
|
||||
for (int32 i = 0; i < Array.Num(); ++i) {
|
||||
ArrayLiteral += ConvertJsonValueToPythonLiteral(Array[i]);
|
||||
if (i < Array.Num() - 1) ArrayLiteral += TEXT(", ");
|
||||
}
|
||||
ArrayLiteral += TEXT("]");
|
||||
return ArrayLiteral;
|
||||
}
|
||||
case EJson::Object:
|
||||
{
|
||||
FString DictLiteral = TEXT("{");
|
||||
const auto& Object = JsonVal->AsObject();
|
||||
bool bFirst = true;
|
||||
for (const auto& Pair : Object->Values) {
|
||||
if (!bFirst) DictLiteral += TEXT(", ");
|
||||
|
||||
// UE 5.7: FJsonObject::Values key is FString; UE 5.8: UE::FSharedString.
|
||||
// operator* yields const TCHAR* on both, so this builds on either engine.
|
||||
FString KeyString = *Pair.Key;
|
||||
// Escape key string as well (similar to EJson::String case)
|
||||
KeyString = KeyString.Replace(TEXT("\\"), TEXT("\\\\"));
|
||||
KeyString = KeyString.Replace(TEXT("\'"), TEXT("\\\'"));
|
||||
KeyString = KeyString.Replace(TEXT("\""), TEXT("\\\""));
|
||||
KeyString = KeyString.Replace(TEXT("\n"), TEXT("\\n"));
|
||||
KeyString = KeyString.Replace(TEXT("\r"), TEXT("\\r"));
|
||||
KeyString = KeyString.Replace(TEXT("\t"), TEXT("\\t"));
|
||||
|
||||
DictLiteral += FString::Printf(TEXT("\'%s\': %s"), *KeyString, *ConvertJsonValueToPythonLiteral(Pair.Value));
|
||||
bFirst = false;
|
||||
}
|
||||
DictLiteral += TEXT("}");
|
||||
return DictLiteral;
|
||||
}
|
||||
default:
|
||||
return TEXT("None");
|
||||
}
|
||||
}
|
||||
|
||||
FMCPythonTcpServer::FMCPythonTcpServer()
|
||||
{
|
||||
RegisterNativeHandlers();
|
||||
}
|
||||
FMCPythonTcpServer::~FMCPythonTcpServer() { Stop(); }
|
||||
|
||||
void FMCPythonTcpServer::RegisterNativeHandlers()
|
||||
{
|
||||
NativeHandlers.Add(TEXT("livecoding_compile"), [this](TSharedPtr<FJsonObject> JsonObj, FSocket* ClientSocket)
|
||||
{
|
||||
HandleLiveCodingCompile(JsonObj, ClientSocket);
|
||||
});
|
||||
}
|
||||
|
||||
void FMCPythonTcpServer::SendJsonResponse(TSharedPtr<FJsonObject> ResponseJson, FSocket* ClientSocket, bool bCloseSocket)
|
||||
{
|
||||
FString ResultJson;
|
||||
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&ResultJson);
|
||||
FJsonSerializer::Serialize(ResponseJson.ToSharedRef(), Writer);
|
||||
Writer->Close();
|
||||
|
||||
FTCHARToUTF8 ResultUtf8(*ResultJson);
|
||||
const uint8* DataPtr = (const uint8*)ResultUtf8.Get();
|
||||
int32 TotalSize = ResultUtf8.Length();
|
||||
int32 TotalSent = 0;
|
||||
while (TotalSent < TotalSize)
|
||||
{
|
||||
int32 SentNow = 0;
|
||||
if (!ClientSocket->Send(DataPtr + TotalSent, TotalSize - TotalSent, SentNow))
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (SentNow == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
TotalSent += SentNow;
|
||||
}
|
||||
|
||||
if (bCloseSocket)
|
||||
{
|
||||
ClientSocket->Close();
|
||||
ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->DestroySocket(ClientSocket);
|
||||
}
|
||||
}
|
||||
|
||||
bool FMCPythonTcpServer::Start(const FString& InIP, uint16 InPort)
|
||||
{
|
||||
FIPv4Address IPAddr;
|
||||
FIPv4Address::Parse(InIP, IPAddr);
|
||||
FIPv4Endpoint Endpoint(IPAddr, InPort);
|
||||
|
||||
TcpListener = MakeShared<FTcpListener>(Endpoint, FTimespan::FromMilliseconds(100), false);
|
||||
TcpListener->OnConnectionAccepted().BindRaw(this, &FMCPythonTcpServer::HandleIncomingConnection);
|
||||
|
||||
bShouldRun = true;
|
||||
UE_LOG(LogMCPython, Log, TEXT("TCP server started at %s:%d."), *InIP, InPort);
|
||||
return true;
|
||||
}
|
||||
|
||||
void FMCPythonTcpServer::Stop()
|
||||
{
|
||||
bShouldRun = false;
|
||||
TcpListener.Reset();
|
||||
UE_LOG(LogMCPython, Log, TEXT("TCP server stopped."));
|
||||
}
|
||||
|
||||
bool FMCPythonTcpServer::HandleIncomingConnection(FSocket* ClientSocket, const FIPv4Endpoint& ClientEndpoint)
|
||||
{
|
||||
UE_LOG(LogMCPython, Verbose, TEXT("Incoming connection from %s"), *ClientEndpoint.ToString());
|
||||
|
||||
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [this, ClientSocket, ClientEndpoint]() {
|
||||
TArray<uint8> ReceivedData;
|
||||
|
||||
// Wait (bounded) for the client to actually send something. Liveness
|
||||
// probes connect and close without sending a byte — the old
|
||||
// `while (HasPendingData || ReceivedData.IsEmpty())` loop hot-spun a
|
||||
// background worker forever per such connection, eventually starving
|
||||
// the AnyBackgroundThread pool and freezing ALL request processing
|
||||
// (connections still got accepted/logged by the listener thread, but
|
||||
// nothing was ever handled).
|
||||
if (!ClientSocket->Wait(ESocketWaitConditions::WaitForRead, FTimespan::FromSeconds(5)))
|
||||
{
|
||||
ClientSocket->Close();
|
||||
ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->DestroySocket(ClientSocket);
|
||||
return;
|
||||
}
|
||||
|
||||
uint32 DataSize = 0;
|
||||
while (ClientSocket->HasPendingData(DataSize))
|
||||
{
|
||||
TArray<uint8> Buffer;
|
||||
Buffer.SetNumZeroed(DataSize);
|
||||
int32 BytesRead = 0;
|
||||
if (!ClientSocket->Recv(Buffer.GetData(), Buffer.Num(), BytesRead) || BytesRead <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
Buffer.SetNum(BytesRead);
|
||||
ReceivedData.Append(Buffer);
|
||||
}
|
||||
|
||||
// Connect-and-close probe (or peer reset): nothing to process.
|
||||
if (ReceivedData.IsEmpty())
|
||||
{
|
||||
ClientSocket->Close();
|
||||
ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->DestroySocket(ClientSocket);
|
||||
return;
|
||||
}
|
||||
ReceivedData.Add(NULL);
|
||||
|
||||
FString ReceivedString = FString(UTF8_TO_TCHAR(reinterpret_cast<const char*>(ReceivedData.GetData())));
|
||||
|
||||
AsyncTask(ENamedThreads::GameThread, [this, ReceivedString, ClientSocket, ClientEndpoint]() {
|
||||
ProcessDataOnGameThread(ReceivedString, ClientSocket, ClientEndpoint);
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void FMCPythonTcpServer::ProcessDataOnGameThread(const FString& Data, FSocket* ClientSocket, const FIPv4Endpoint& ClientEndpoint)
|
||||
{
|
||||
UE_LOG(LogMCPython, Verbose, TEXT("Processing Data on Game Thread: %s"), *Data);
|
||||
|
||||
TSharedPtr<FJsonObject> JsonObj;
|
||||
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Data);
|
||||
FString TypeField;
|
||||
FString CodeField;
|
||||
FString ResultMsg;
|
||||
bool bExecSuccess = false;
|
||||
|
||||
if (FJsonSerializer::Deserialize(Reader, JsonObj) && JsonObj.IsValid())
|
||||
{
|
||||
if (JsonObj->TryGetStringField(TEXT("type"), TypeField))
|
||||
{
|
||||
if (TypeField == TEXT("python"))
|
||||
{
|
||||
if (!JsonObj->TryGetStringField(TEXT("code"), CodeField))
|
||||
{
|
||||
ResultMsg = TEXT("Failed: 'code' field missing for type 'python'");
|
||||
CodeField = TEXT("import json; print(json.dumps({'success': False, 'message': 'Error: code field missing'}))");
|
||||
}
|
||||
}
|
||||
else if (TypeField == TEXT("python_call"))
|
||||
{
|
||||
FString ModuleName, FunctionName;
|
||||
if (JsonObj->TryGetStringField(TEXT("module"), ModuleName) &&
|
||||
JsonObj->TryGetStringField(TEXT("function"), FunctionName))
|
||||
{
|
||||
const TSharedPtr<FJsonObject>* ArgsJsonObjectPtr = nullptr; // Changed from TArray<TSharedPtr<FJsonValue>>*
|
||||
JsonObj->TryGetObjectField(TEXT("args"), ArgsJsonObjectPtr); // Changed from TryGetArrayField
|
||||
|
||||
FString PyArgsStringForCall;
|
||||
if (ArgsJsonObjectPtr && ArgsJsonObjectPtr->IsValid()) // Check if the pointer and the object it points to are valid
|
||||
{
|
||||
// Wrap the FJsonObject in an FJsonValueObject to pass to ConvertJsonValueToPythonLiteral
|
||||
TSharedPtr<FJsonValueObject> ArgsJsonValue = MakeShareable(new FJsonValueObject(*ArgsJsonObjectPtr));
|
||||
PyArgsStringForCall = ConvertJsonValueToPythonLiteral(ArgsJsonValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
PyArgsStringForCall = TEXT("{}"); // Default to an empty Python dictionary string if "args" is not a valid object or is missing
|
||||
}
|
||||
|
||||
// Generate a short script to call the execute_action function from the mcp_unreal_actions module
|
||||
// The first argument is the target module name, the second is the target function name, and the third is the argument dictionary.
|
||||
CodeField = FString::Printf(TEXT("import unreal;from UnrealMCPython import mcp_unreal_actions;unreal.MCPythonHelper.submit_result(mcp_unreal_actions.execute_action(\'%s\', \'%s\', %s));"), // result handed back via SubmitResult, NOT print (print echoed every response into the Output Log)
|
||||
*ModuleName,
|
||||
*FunctionName,
|
||||
*PyArgsStringForCall);
|
||||
|
||||
UE_LOG(LogMCPython, Verbose, TEXT("Generated Python Call (via execute_action):\\n%s"), *CodeField);
|
||||
}
|
||||
else
|
||||
{
|
||||
ResultMsg = TEXT("Failed: Missing 'module' or 'function' field for type 'python_call'");
|
||||
CodeField = TEXT("import json; print(json.dumps({'success': False, 'message': 'Error: module/function field missing'}))");
|
||||
}
|
||||
}
|
||||
else if (FNativeCommandHandler* Handler = NativeHandlers.Find(TypeField))
|
||||
{
|
||||
(*Handler)(JsonObj, ClientSocket);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
ResultMsg = FString::Printf(TEXT("Failed: Unsupported type: %s"), *TypeField);
|
||||
FString EscapedTypeField = TypeField.Replace(TEXT("\'"), TEXT("\\\'"));
|
||||
CodeField = FString::Printf(TEXT("import json; print(json.dumps({'success': False, 'message': 'Unsupported type: %s'}))"), *EscapedTypeField);
|
||||
}
|
||||
|
||||
if (IPythonScriptPlugin::Get())
|
||||
{
|
||||
UMCPythonHelper::ClearSubmittedResult();
|
||||
LogCapture.Clear();
|
||||
GLog->AddOutputDevice(&LogCapture);
|
||||
|
||||
FPythonCommandEx PythonCommand;
|
||||
PythonCommand.Command = CodeField;
|
||||
PythonCommand.ExecutionMode = EPythonCommandExecutionMode::ExecuteFile;
|
||||
|
||||
bExecSuccess = IPythonScriptPlugin::Get()->ExecPythonCommandEx(PythonCommand);
|
||||
|
||||
GLog->RemoveOutputDevice(&LogCapture);
|
||||
|
||||
// Prefer the directly-submitted result (clean transport, nothing
|
||||
// echoed to the log). Fall back to the print/log capture for code
|
||||
// paths that still print (error stubs, legacy).
|
||||
FString CapturedLogs;
|
||||
if (!UMCPythonHelper::ConsumeSubmittedResult(CapturedLogs))
|
||||
{
|
||||
CapturedLogs = LogCapture.GetLogs().TrimStartAndEnd();
|
||||
}
|
||||
|
||||
bool bIsJson = false;
|
||||
if (CapturedLogs.StartsWith(TEXT("{")) || CapturedLogs.StartsWith(TEXT("["))) {
|
||||
bIsJson = true;
|
||||
}
|
||||
if (!bIsJson) {
|
||||
TSharedPtr<FJsonObject> ErrorJson = MakeShareable(new FJsonObject);
|
||||
ErrorJson->SetBoolField(TEXT("success"), false);
|
||||
ErrorJson->SetStringField(TEXT("message"), TEXT("Python did not return JSON"));
|
||||
ErrorJson->SetStringField(TEXT("raw_result"), CapturedLogs);
|
||||
FString WrappedJson;
|
||||
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&WrappedJson);
|
||||
FJsonSerializer::Serialize(ErrorJson.ToSharedRef(), Writer);
|
||||
Writer->Close();
|
||||
CapturedLogs = WrappedJson;
|
||||
}
|
||||
|
||||
UE_LOG(LogMCPython, Verbose, TEXT("Python Command Executed. Success: %s. Output Log: %s"),
|
||||
bExecSuccess ? TEXT("True") : TEXT("False"), *CapturedLogs);
|
||||
|
||||
TSharedPtr<FJsonObject> ResponseToClient = MakeShareable(new FJsonObject);
|
||||
ResponseToClient->SetBoolField(TEXT("success"), bExecSuccess); // Overall success of ExecPythonCommandEx
|
||||
|
||||
if (!ResultMsg.IsEmpty()) // If there was a pre-execution error message (e.g. bad JSON input from client)
|
||||
{
|
||||
ResponseToClient->SetStringField(TEXT("message"), ResultMsg);
|
||||
}
|
||||
else if (!bExecSuccess) // Python execution itself failed
|
||||
{
|
||||
if (!CapturedLogs.IsEmpty())
|
||||
{
|
||||
// If execution failed and logs are available, they likely contain the Python error
|
||||
ResponseToClient->SetStringField(TEXT("message"), TEXT("Python execution failed. See result for details."));
|
||||
}
|
||||
else
|
||||
{
|
||||
// If execution failed and no logs, it's a more generic failure
|
||||
ResponseToClient->SetStringField(TEXT("message"), TEXT("Python execution failed with no specific error log."));
|
||||
}
|
||||
}
|
||||
else // bExecSuccess is true
|
||||
{
|
||||
ResponseToClient->SetStringField(TEXT("message"), TEXT("Python command executed successfully."));
|
||||
}
|
||||
|
||||
// The "result" field will contain whatever the Python script printed.
|
||||
ResponseToClient->SetStringField(TEXT("result"), CapturedLogs);
|
||||
|
||||
FString ResultJson;
|
||||
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&ResultJson);
|
||||
FJsonSerializer::Serialize(ResponseToClient.ToSharedRef(), Writer);
|
||||
Writer->Close();
|
||||
|
||||
FTCHARToUTF8 ResultUtf8(*ResultJson);
|
||||
const uint8* DataPtr = (const uint8*)ResultUtf8.Get();
|
||||
int32 TotalSize = ResultUtf8.Length();
|
||||
int32 TotalSent = 0;
|
||||
while (TotalSent < TotalSize)
|
||||
{
|
||||
int32 SentNow = 0;
|
||||
if (!ClientSocket->Send(DataPtr + TotalSent, TotalSize - TotalSent, SentNow))
|
||||
{
|
||||
break; // Error occurred
|
||||
}
|
||||
if (SentNow == 0)
|
||||
{
|
||||
break; // Connection closed or can't send more
|
||||
}
|
||||
TotalSent += SentNow;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ResultMsg = TEXT("Failed: PythonScriptPlugin not found");
|
||||
TSharedPtr<FJsonObject> ErrorResponse = MakeShareable(new FJsonObject);
|
||||
ErrorResponse->SetBoolField(TEXT("success"), false);
|
||||
ErrorResponse->SetStringField(TEXT("message"), ResultMsg);
|
||||
FString ErrorJson;
|
||||
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&ErrorJson);
|
||||
FJsonSerializer::Serialize(ErrorResponse.ToSharedRef(), Writer);
|
||||
Writer->Close();
|
||||
FTCHARToUTF8 ResultUtf8(*ErrorJson);
|
||||
const uint8* DataPtr = (const uint8*)ResultUtf8.Get();
|
||||
int32 TotalSize = ResultUtf8.Length();
|
||||
int32 TotalSent = 0;
|
||||
while (TotalSent < TotalSize)
|
||||
{
|
||||
int32 SentNow = 0;
|
||||
if (!ClientSocket->Send(DataPtr + TotalSent, TotalSize - TotalSent, SentNow))
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (SentNow == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
TotalSent += SentNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ResultMsg = TEXT("Failed: Missing 'type' field in JSON request");
|
||||
TSharedPtr<FJsonObject> ErrorResponse = MakeShareable(new FJsonObject);
|
||||
ErrorResponse->SetBoolField(TEXT("success"), false);
|
||||
ErrorResponse->SetStringField(TEXT("message"), ResultMsg);
|
||||
FString ErrorJson;
|
||||
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&ErrorJson);
|
||||
FJsonSerializer::Serialize(ErrorResponse.ToSharedRef(), Writer);
|
||||
Writer->Close();
|
||||
FTCHARToUTF8 ResultUtf8(*ErrorJson);
|
||||
const uint8* DataPtr = (const uint8*)ResultUtf8.Get();
|
||||
int32 TotalSize = ResultUtf8.Length();
|
||||
int32 TotalSent = 0;
|
||||
while (TotalSent < TotalSize)
|
||||
{
|
||||
int32 SentNow = 0;
|
||||
if (!ClientSocket->Send(DataPtr + TotalSent, TotalSize - TotalSent, SentNow))
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (SentNow == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
TotalSent += SentNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ResultMsg = TEXT("Failed: JSON parse error on received data");
|
||||
TSharedPtr<FJsonObject> ErrorResponse = MakeShareable(new FJsonObject);
|
||||
ErrorResponse->SetBoolField(TEXT("success"), false);
|
||||
ErrorResponse->SetStringField(TEXT("message"), ResultMsg);
|
||||
ErrorResponse->SetStringField(TEXT("raw_data"), Data);
|
||||
FString ErrorJson;
|
||||
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&ErrorJson);
|
||||
FJsonSerializer::Serialize(ErrorResponse.ToSharedRef(), Writer);
|
||||
Writer->Close();
|
||||
FTCHARToUTF8 ResultUtf8(*ErrorJson);
|
||||
const uint8* DataPtr = (const uint8*)ResultUtf8.Get();
|
||||
int32 TotalSize = ResultUtf8.Length();
|
||||
int32 TotalSent = 0;
|
||||
while (TotalSent < TotalSize)
|
||||
{
|
||||
int32 SentNow = 0;
|
||||
if (!ClientSocket->Send(DataPtr + TotalSent, TotalSize - TotalSent, SentNow))
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (SentNow == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
TotalSent += SentNow;
|
||||
}
|
||||
}
|
||||
|
||||
ClientSocket->Close();
|
||||
ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->DestroySocket(ClientSocket);
|
||||
}
|
||||
|
||||
void FMCPythonTcpServer::HandleLiveCodingCompile(TSharedPtr<FJsonObject> JsonObj, FSocket* ClientSocket)
|
||||
{
|
||||
ILiveCodingModule* LiveCoding = FModuleManager::GetModulePtr<ILiveCodingModule>(TEXT("LiveCoding"));
|
||||
if (!LiveCoding)
|
||||
{
|
||||
TSharedPtr<FJsonObject> Response = MakeShareable(new FJsonObject);
|
||||
Response->SetBoolField(TEXT("success"), false);
|
||||
Response->SetStringField(TEXT("message"), TEXT("LiveCoding module is not available."));
|
||||
SendJsonResponse(Response, ClientSocket);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!LiveCoding->IsEnabledForSession())
|
||||
{
|
||||
TSharedPtr<FJsonObject> Response = MakeShareable(new FJsonObject);
|
||||
Response->SetBoolField(TEXT("success"), false);
|
||||
Response->SetStringField(TEXT("message"), TEXT("LiveCoding is not enabled for this session. Enable it in Editor Preferences > Live Coding."));
|
||||
SendJsonResponse(Response, ClientSocket);
|
||||
return;
|
||||
}
|
||||
|
||||
const FString UBTLogPath = FPaths::Combine(FPaths::EngineDir(), TEXT("Programs"), TEXT("UnrealBuildTool"), TEXT("Log.txt"));
|
||||
const FDateTime UBTLogTimestampBefore = IFileManager::Get().GetTimeStamp(*UBTLogPath);
|
||||
|
||||
FMCPCompileLogCapture CompileCapture;
|
||||
GLog->AddOutputDevice(&CompileCapture);
|
||||
|
||||
UE_LOG(LogMCPython, Log, TEXT("LiveCoding compile started (WaitForCompletion)..."));
|
||||
const double StartTime = FPlatformTime::Seconds();
|
||||
|
||||
ELiveCodingCompileResult CompileResult = ELiveCodingCompileResult::NotStarted;
|
||||
const bool bStarted = LiveCoding->Compile(ELiveCodingCompileFlags::WaitForCompletion, &CompileResult);
|
||||
|
||||
GLog->RemoveOutputDevice(&CompileCapture);
|
||||
|
||||
const double ElapsedTime = FPlatformTime::Seconds() - StartTime;
|
||||
const bool bSuccess = bStarted &&
|
||||
(CompileResult == ELiveCodingCompileResult::Success ||
|
||||
CompileResult == ELiveCodingCompileResult::NoChanges);
|
||||
|
||||
UE_LOG(LogMCPython, Log, TEXT("LiveCoding compile finished in %.1fs: %s"),
|
||||
ElapsedTime, *LCCompileResultToString(CompileResult));
|
||||
|
||||
FString Message;
|
||||
switch (CompileResult)
|
||||
{
|
||||
case ELiveCodingCompileResult::Success:
|
||||
Message = FString::Printf(TEXT("Compilation succeeded in %.1f seconds."), ElapsedTime);
|
||||
break;
|
||||
case ELiveCodingCompileResult::NoChanges:
|
||||
Message = FString::Printf(TEXT("Compilation finished in %.1f seconds (no changes detected)."), ElapsedTime);
|
||||
break;
|
||||
case ELiveCodingCompileResult::Failure:
|
||||
Message = FString::Printf(TEXT("Compilation failed in %.1f seconds. See compile_output for details."), ElapsedTime);
|
||||
break;
|
||||
case ELiveCodingCompileResult::Cancelled:
|
||||
Message = TEXT("Compilation was cancelled.");
|
||||
break;
|
||||
case ELiveCodingCompileResult::CompileStillActive:
|
||||
Message = TEXT("A prior compilation is still in progress.");
|
||||
break;
|
||||
case ELiveCodingCompileResult::NotStarted:
|
||||
Message = TEXT("Compilation could not be started (Live Coding monitor failed to launch).");
|
||||
break;
|
||||
default:
|
||||
Message = FString::Printf(TEXT("Compilation ended with result: %s"), *LCCompileResultToString(CompileResult));
|
||||
break;
|
||||
}
|
||||
|
||||
TSharedPtr<FJsonObject> Response = MakeShareable(new FJsonObject);
|
||||
Response->SetBoolField(TEXT("success"), bSuccess);
|
||||
Response->SetStringField(TEXT("compile_result"), LCCompileResultToString(CompileResult));
|
||||
Response->SetStringField(TEXT("message"), Message);
|
||||
Response->SetNumberField(TEXT("elapsed_seconds"), ElapsedTime);
|
||||
|
||||
const FString CapturedLog = CompileCapture.GetAndClear();
|
||||
if (!CapturedLog.IsEmpty())
|
||||
Response->SetStringField(TEXT("compile_output"), CapturedLog);
|
||||
|
||||
const FString Diagnostics = CollectUBTDiagnostics(UBTLogPath, UBTLogTimestampBefore);
|
||||
if (!Diagnostics.IsEmpty())
|
||||
Response->SetStringField(TEXT("compiler_diagnostics"), Diagnostics);
|
||||
|
||||
SendJsonResponse(Response, ClientSocket);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved.
|
||||
|
||||
#include "UnrealMCPython.h"
|
||||
#include "Sockets.h"
|
||||
#include "SocketSubsystem.h"
|
||||
#include "Common/TcpListener.h"
|
||||
#include "IPythonScriptPlugin.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Serialization/JsonReader.h"
|
||||
#include "Dom/JsonObject.h"
|
||||
#include "MCPythonTcpServer.h"
|
||||
|
||||
#define LOCTEXT_NAMESPACE "FUnrealMCPythonModule"
|
||||
|
||||
void FUnrealMCPythonModule::StartupModule()
|
||||
{
|
||||
static const uint16 SERVER_PORT = 12029;
|
||||
static const FString SERVER_IP = TEXT("127.0.0.1");
|
||||
TcpServer = MakeUnique<FMCPythonTcpServer>();
|
||||
TcpServer->Start(SERVER_IP, SERVER_PORT);
|
||||
}
|
||||
|
||||
void FUnrealMCPythonModule::ShutdownModule()
|
||||
{
|
||||
if (TcpServer)
|
||||
{
|
||||
TcpServer->Stop();
|
||||
TcpServer.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
#undef LOCTEXT_NAMESPACE
|
||||
|
||||
IMPLEMENT_MODULE(FUnrealMCPythonModule, UnrealMCPython)
|
||||
@@ -0,0 +1,354 @@
|
||||
// Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "Kismet/BlueprintFunctionLibrary.h"
|
||||
#include "EdGraph/EdGraphNode.h"
|
||||
#include "EdGraph/EdGraphPin.h"
|
||||
#include "BehaviorTree/BehaviorTree.h"
|
||||
#include "BehaviorTree/BlackboardData.h"
|
||||
#include "Components/Widget.h"
|
||||
#include "MCPythonHelper.generated.h"
|
||||
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct FMCPythonPinLinkInfo
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString NodeName;
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString NodeTitle;
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString PinName;
|
||||
};
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct FMCPythonBlueprintPinInfo
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString PinName;
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString FriendlyName;
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString Direction;
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString PinType;
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString PinSubType;
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString DefaultValue;
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
TArray<FMCPythonPinLinkInfo> LinkedTo;
|
||||
};
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct FMCPythonBlueprintNodeInfo
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString NodeName;
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString NodeTitle;
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString NodeComment;
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
TArray<FMCPythonBlueprintPinInfo> Pins;
|
||||
};
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct FMCPythonBTNodeInfo
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString NodeName;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
FString NodeClass;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
TArray<FString> DecoratorClasses;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
TArray<FString> DecoratorNames;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
TArray<FString> ServiceClasses;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category="MCPython")
|
||||
TArray<FString> ServiceNames;
|
||||
|
||||
TArray<FMCPythonBTNodeInfo> Children;
|
||||
};
|
||||
|
||||
UCLASS()
|
||||
class UNREALMCPYTHON_API UMCPythonHelper : public UBlueprintFunctionLibrary
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
// 모든 에디터에서 열려있는 에셋 반환
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython", CallInEditor)
|
||||
static TArray<UObject*> GetAllEditedAssets();
|
||||
|
||||
// (예시) 선택된 블루프린트 노드 반환
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython", CallInEditor)
|
||||
static TArray<UObject*> GetSelectedBlueprintNodes();
|
||||
|
||||
// 선택된 블루프린트 노드의 연결 정보 반환
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython", CallInEditor)
|
||||
static TArray<FMCPythonBlueprintNodeInfo> GetSelectedBlueprintNodeInfos();
|
||||
|
||||
// ─── Behavior Tree Helpers ──────────────────────────────────────────
|
||||
|
||||
/** Get the full tree structure of a Behavior Tree as JSON string (accesses RootNode via C++) */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString GetBehaviorTreeStructure(UBehaviorTree* BehaviorTree);
|
||||
|
||||
/** Set the Blackboard asset on a Behavior Tree (setter not exposed to Python) */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static bool SetBehaviorTreeBlackboard(UBehaviorTree* BehaviorTree, UBlackboardData* BlackboardData);
|
||||
|
||||
/** Get detailed properties of a specific node by name, returned as JSON string */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString GetBehaviorTreeNodeDetails(UBehaviorTree* BehaviorTree, const FString& NodeName);
|
||||
|
||||
/** Get details of selected nodes in the BT editor as JSON */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString GetSelectedBTNodes();
|
||||
|
||||
/** Build a complete Behavior Tree from a JSON structure */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString BuildBehaviorTree(UBehaviorTree* BehaviorTree, const FString& TreeStructureJson);
|
||||
|
||||
/** List all available BT node classes (composites, tasks, decorators, services) */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString ListBTNodeClasses();
|
||||
|
||||
// ─── Blueprint Graph Helpers ──────────────────────────────────────────
|
||||
|
||||
/** Get the full graph info (all nodes, pins, connections) for a Blueprint graph */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString GetBlueprintGraphInfo(UBlueprint* Blueprint, const FString& GraphName);
|
||||
|
||||
/** List callable functions available in a Blueprint context */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString ListCallableFunctions(UBlueprint* Blueprint, const FString& Filter);
|
||||
|
||||
/** List all variables defined in a Blueprint */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString ListBlueprintVariables(UBlueprint* Blueprint);
|
||||
|
||||
/** Add a single node to a Blueprint graph from JSON description */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString AddBlueprintNode(UBlueprint* Blueprint, const FString& GraphName, const FString& NodeJson);
|
||||
|
||||
/** Connect two pins in a Blueprint graph */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString ConnectBlueprintPins(UBlueprint* Blueprint, const FString& GraphName,
|
||||
const FString& SourceNodeName, const FString& SourcePinName,
|
||||
const FString& TargetNodeName, const FString& TargetPinName);
|
||||
|
||||
/** Remove a node from a Blueprint graph */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString RemoveBlueprintNode(UBlueprint* Blueprint, const FString& GraphName,
|
||||
const FString& NodeName);
|
||||
|
||||
/** Build a Blueprint graph from JSON adjacency list (nodes + connections) */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString BuildBlueprintGraph(UBlueprint* Blueprint, const FString& GraphName,
|
||||
const FString& GraphJson);
|
||||
|
||||
/** Compile a Blueprint and return the result */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString CompileBlueprint(UBlueprint* Blueprint);
|
||||
|
||||
/** Set any CDO property including inherited C++ UPROPERTYs (e.g. DefaultPawnClass on GameModeBase BPs).
|
||||
* Uses TFieldIterator with IncludeSuper to bypass the Python set_editor_property limitation. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString SetBlueprintCDOProperty(UBlueprint* Blueprint, const FString& PropertyName, const FString& ValueStr);
|
||||
|
||||
// ─── UMG Widget Blueprint Helpers ─────────────────────────────────────────
|
||||
// UE 5.7 Python bindings mark UWidgetTree::RootWidget, AllWidgets, and
|
||||
// ConstructWidget as protected, so direct Python access is blocked.
|
||||
// These UFUNCTIONs proxy the calls through C++ where the members are accessible.
|
||||
|
||||
/** Get widget tree info (root widget, all widgets) as JSON */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgGetWidgetInfo(UBlueprint* WidgetBP);
|
||||
|
||||
/** Add a widget to the widget tree. ParentName="" means auto-root or root panel. Returns JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgAddWidget(UBlueprint* WidgetBP, const FString& WidgetType, const FString& WidgetName, const FString& ParentName);
|
||||
|
||||
/** Find a widget by name in the widget tree. Returns nullptr if not found. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static UWidget* UmgFindWidget(UBlueprint* WidgetBP, const FString& WidgetName);
|
||||
|
||||
/** Remove a widget from the widget tree. Returns JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgRemoveWidget(UBlueprint* WidgetBP, const FString& WidgetName);
|
||||
|
||||
/** Set bIsVariable on a named widget so Blueprint can access it as a variable. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgSetWidgetIsVariable(UBlueprint* WidgetBP, const FString& WidgetName, bool bIsVariable);
|
||||
|
||||
/** Set CanvasPanelSlot layout (anchors + position/size) in one call.
|
||||
* AnchorMin/Max: 0..1 fractions. OffsetX/Y: pixel offset from anchor. SizeX/Y: pixel size. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgSetSlotLayout(UBlueprint* WidgetBP, const FString& WidgetName,
|
||||
float AnchorMinX, float AnchorMinY, float AnchorMaxX, float AnchorMaxY,
|
||||
float OffsetX, float OffsetY, float SizeX, float SizeY);
|
||||
|
||||
/** Set font size, text color, and outline size on a TextBlock widget in one call. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgSetTextStyle(UBlueprint* WidgetBP, const FString& WidgetName,
|
||||
int32 FontSize, float ColorR, float ColorG, float ColorB, float ColorA,
|
||||
int32 OutlineSize);
|
||||
|
||||
/** Get an editor property value from a widget as a JSON string. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgGetWidgetProperty(UBlueprint* WidgetBP, const FString& WidgetName, const FString& PropertyName);
|
||||
|
||||
/** Set an editor property value on a widget from a string. Supports bool, int, float, and string properties. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgSetWidgetProperty(UBlueprint* WidgetBP, const FString& WidgetName, const FString& PropertyName, const FString& Value);
|
||||
|
||||
/** Move a widget under a different panel parent (preserves the widget; cycle-guarded). Returns JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgReparentWidget(UBlueprint* WidgetBP, const FString& WidgetName, const FString& NewParentName);
|
||||
|
||||
/** Wrap a widget in a newly-created panel of WrapperType, taking the widget's place in the tree. Returns JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgWrapWidget(UBlueprint* WidgetBP, const FString& WidgetName, const FString& WrapperType, const FString& WrapperName);
|
||||
|
||||
/** Replace a widget with a new widget of NewType at the same slot (old subtree is discarded). Returns JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgReplaceWidget(UBlueprint* WidgetBP, const FString& WidgetName, const FString& NewType, const FString& NewName);
|
||||
|
||||
/** List the bindable multicast-delegate events on a widget (e.g. OnClicked). Returns JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgListWidgetEvents(UBlueprint* WidgetBP, const FString& WidgetName);
|
||||
|
||||
/** Create a bound event node in the widget BP's event graph for a widget's delegate. Returns JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString UmgBindWidgetEvent(UBlueprint* WidgetBP, const FString& WidgetName, const FString& EventName);
|
||||
|
||||
/** Add a component to a Blueprint's SCS.
|
||||
* ComponentClassPath e.g. "/Script/Engine.CameraComponent"
|
||||
* ParentComponentName: name of the parent SCS node, or "" to attach to root */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString AddComponentToBlueprint(UBlueprint* Blueprint,
|
||||
const FString& ComponentClassPath,
|
||||
const FString& ComponentName,
|
||||
float LocationX, float LocationY, float LocationZ,
|
||||
float RotationPitch, float RotationYaw, float RotationRoll,
|
||||
const FString& ParentComponentName);
|
||||
|
||||
/** List all SCS components on a Blueprint. Returns JSON array of {name, class, variable_name}. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString ListBlueprintComponents(UBlueprint* Blueprint);
|
||||
|
||||
/** Remove a component by variable name from a Blueprint's SCS.
|
||||
* Promotes children to the removed node's parent (safe remove). */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString RemoveComponentFromBlueprint(UBlueprint* Blueprint, const FString& ComponentName);
|
||||
|
||||
/** Set a property on a component template in a Blueprint's SCS.
|
||||
* ComponentName: the variable name of the component.
|
||||
* PropertyName: the property to set (e.g. "relative_location", "sphere_radius").
|
||||
* Value: string representation (e.g. "(X=0,Y=0,Z=100)" or "50.0"). */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString SetComponentProperty(UBlueprint* Blueprint,
|
||||
const FString& ComponentName,
|
||||
const FString& PropertyName,
|
||||
const FString& Value);
|
||||
|
||||
/** Set the canvas position of a node in a Blueprint graph by node name. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString SetBlueprintNodePosition(UBlueprint* Blueprint,
|
||||
const FString& GraphName,
|
||||
const FString& NodeName,
|
||||
float PosX, float PosY);
|
||||
|
||||
/** Set a pin default on a blueprint node.
|
||||
* For object pins, Value should be an asset path like "/Engine/BasicShapes/Sphere.Sphere".
|
||||
* For numeric/bool pins, Value is the literal string like "3.14" or "true". */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString SetBlueprintNodePinDefault(UBlueprint* Blueprint,
|
||||
const FString& GraphName,
|
||||
const FString& NodeName,
|
||||
const FString& PinName,
|
||||
const FString& Value);
|
||||
|
||||
// ─── SkeletalMesh / Skeleton Helpers ──────────────────────────────────────
|
||||
// Python does not expose reference-skeleton bone enumeration, and
|
||||
// USkeletalMeshSocket::SocketName is read-only via Python reflection.
|
||||
// These proxy the calls through C++.
|
||||
|
||||
/** List reference-skeleton bones of a SkeletalMesh as JSON [{name, index, parent}]. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString GetSkeletonBones(USkeletalMesh* Mesh);
|
||||
|
||||
/** Add a socket to a SkeletalMesh on a bone with a relative transform. Returns JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString AddSkeletalMeshSocket(USkeletalMesh* Mesh, const FString& SocketName,
|
||||
const FString& BoneName,
|
||||
float LocationX, float LocationY, float LocationZ,
|
||||
float RotationPitch, float RotationYaw, float RotationRoll);
|
||||
|
||||
/** Remove a named socket from a SkeletalMesh. Returns JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString RemoveSkeletalMeshSocket(USkeletalMesh* Mesh, const FString& SocketName);
|
||||
|
||||
// ─── Response transport (python_call) ─────────────────────────────────────
|
||||
// The python_call path used to transport results via print() + a GLog capture
|
||||
// device. Because the capture device only ADDS to GLog routing, every response
|
||||
// was also echoed into the Output Log / log file — including megabyte base64
|
||||
// payloads from vision captures, and get_output_log responses re-echoing
|
||||
// themselves into ever-deeper escaping. submit_result hands the JSON straight
|
||||
// to the server instead, so responses never touch the log.
|
||||
//
|
||||
// Thread-safety: a single static slot is sufficient because each request is one
|
||||
// game-thread task — the write (last python statement) and the read (right
|
||||
// after ExecPythonCommandEx returns) are adjacent within that task, and any
|
||||
// re-entrant nested request completes atomically in between, never partially.
|
||||
|
||||
/** Called by generated python_call code to hand the action's JSON result back. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static void SubmitResult(const FString& ResultJson);
|
||||
|
||||
/** C++ side: move the submitted result out (returns false if nothing was submitted). */
|
||||
static bool ConsumeSubmittedResult(FString& OutResult);
|
||||
|
||||
/** C++ side: drop any stale submitted result before executing a call. */
|
||||
static void ClearSubmittedResult();
|
||||
|
||||
// ─── AnimGraph authoring (editor-only AnimGraph module) ───────────────────────
|
||||
// AnimGraph node classes (UAnimGraphNode_*) live in the editor-only AnimGraph
|
||||
// module and are not exposed to Python, and UAnimationGraph::Nodes is protected,
|
||||
// so these operations need C++. Read-only AnimGraph introspection is already
|
||||
// served by GetBlueprintGraphInfo (graph_name="AnimGraph").
|
||||
|
||||
/** Add a Sequence Player node to the AnimGraph, optionally linked to the Output Pose. Returns JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString AddAnimGraphSequencePlayer(UAnimBlueprint* AnimBP, const FString& AnimSequencePath, bool bLinkToOutputPose);
|
||||
|
||||
/** Build an arbitrary state machine in the AnimGraph from a JSON spec
|
||||
({states:[{name,anim?}], entry?, transitions:[{from,to,var?,op?,value?}]}). Returns JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString BuildAnimStateMachine(UAnimBlueprint* AnimBP, const FString& SpecJson);
|
||||
|
||||
// ─── Editor viewport projection ───────────────────────────────────────────────
|
||||
// FEditorViewportClient / FSceneView are not exposed to Python, so world<->screen
|
||||
// projection against the active level editor viewport needs C++.
|
||||
|
||||
/** Project a world location to active-level-viewport pixel coords. Returns JSON {x,y,visible,viewport_*}. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString WorldToScreen(FVector WorldLocation);
|
||||
|
||||
/** Deproject a viewport pixel to a world location at the given distance along the view ray. Returns JSON. */
|
||||
UFUNCTION(BlueprintCallable, Category="Editor|MCPython")
|
||||
static FString ScreenToWorld(float ScreenX, float ScreenY, float Distance);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Interfaces/IPv4/IPv4Endpoint.h"
|
||||
#include <memory>
|
||||
#include "Misc/OutputDeviceRedirector.h"
|
||||
|
||||
class FTcpListener;
|
||||
class FSocket;
|
||||
|
||||
class FPythonLogCapture : public FOutputDevice
|
||||
{
|
||||
public:
|
||||
FPythonLogCapture() : FOutputDevice() {}
|
||||
|
||||
virtual void Serialize(const TCHAR* InData, ELogVerbosity::Type Verbosity, const FName& Category) override
|
||||
{
|
||||
if (Category == FName("LogPython"))
|
||||
{
|
||||
CapturedLogs.Append(InData);
|
||||
CapturedLogs.Append(TEXT("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
void Clear() { CapturedLogs.Empty(); }
|
||||
FString GetLogs() const { return CapturedLogs; }
|
||||
|
||||
private:
|
||||
FString CapturedLogs;
|
||||
};
|
||||
|
||||
using FNativeCommandHandler = TFunction<void(TSharedPtr<FJsonObject> JsonObj, FSocket* ClientSocket)>;
|
||||
|
||||
class FMCPythonTcpServer
|
||||
{
|
||||
public:
|
||||
FMCPythonTcpServer();
|
||||
~FMCPythonTcpServer();
|
||||
|
||||
bool Start(const FString& InIP, uint16 InPort);
|
||||
void Stop();
|
||||
|
||||
private:
|
||||
TSharedPtr<FTcpListener> TcpListener;
|
||||
FSocket* ListenSocket = nullptr;
|
||||
bool bShouldRun = false;
|
||||
FPythonLogCapture LogCapture;
|
||||
TMap<FString, FNativeCommandHandler> NativeHandlers;
|
||||
|
||||
void RegisterNativeHandlers();
|
||||
bool HandleIncomingConnection(FSocket* ClientSocket, const FIPv4Endpoint& ClientEndpoint);
|
||||
void ProcessDataOnGameThread(const FString& Data, FSocket* ClientSocket, const FIPv4Endpoint& ClientEndpoint);
|
||||
void SendJsonResponse(TSharedPtr<FJsonObject> ResponseJson, FSocket* ClientSocket, bool bCloseSocket = true);
|
||||
|
||||
// Native command handlers
|
||||
void HandleLiveCodingCompile(TSharedPtr<FJsonObject> JsonObj, FSocket* ClientSocket);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Modules/ModuleManager.h"
|
||||
#include "MCPythonTcpServer.h"
|
||||
|
||||
class FUnrealMCPythonModule : public IModuleInterface
|
||||
{
|
||||
public:
|
||||
|
||||
/** IModuleInterface implementation */
|
||||
virtual void StartupModule() override;
|
||||
virtual void ShutdownModule() override;
|
||||
|
||||
private:
|
||||
TUniquePtr<FMCPythonTcpServer> TcpServer;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved.
|
||||
|
||||
using UnrealBuildTool;
|
||||
|
||||
public class UnrealMCPython : ModuleRules
|
||||
{
|
||||
public UnrealMCPython(ReadOnlyTargetRules Target) : base(Target)
|
||||
{
|
||||
PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs;
|
||||
|
||||
PublicIncludePaths.AddRange(
|
||||
new string[] {
|
||||
// ... add public include paths required here ...
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
PrivateIncludePaths.AddRange(
|
||||
new string[] {
|
||||
// ... add other private include paths required here ...
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
PublicDependencyModuleNames.AddRange(
|
||||
new string[]
|
||||
{
|
||||
"Core",
|
||||
"CoreUObject",
|
||||
"Engine",
|
||||
"InputCore",
|
||||
"Sockets",
|
||||
"Networking",
|
||||
"Json",
|
||||
"JsonUtilities",
|
||||
"PythonScriptPlugin"
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(
|
||||
new string[]
|
||||
{
|
||||
"CoreUObject",
|
||||
"Engine",
|
||||
"Slate",
|
||||
"SlateCore",
|
||||
"UnrealEd",
|
||||
"EditorSubsystem",
|
||||
"AssetTools",
|
||||
"BlueprintGraph",
|
||||
"Kismet",
|
||||
"AIModule",
|
||||
"GameplayTasks",
|
||||
"AIGraph",
|
||||
"BehaviorTreeEditor",
|
||||
"LiveCoding",
|
||||
"UMG",
|
||||
"UMGEditor",
|
||||
"AnimGraph",
|
||||
"AnimGraphRuntime",
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
DynamicallyLoadedModuleNames.AddRange(
|
||||
new string[]
|
||||
{
|
||||
// ... add any modules that your module loads dynamically here ...
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
32
Plugins/UnrealMCPython/UnrealMCPython.uplugin
Normal file
32
Plugins/UnrealMCPython/UnrealMCPython.uplugin
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"FileVersion": 3,
|
||||
"Version": 5,
|
||||
"VersionName": "2.2.0",
|
||||
"FriendlyName": "UnrealMCPython",
|
||||
"Description": "",
|
||||
"Category": "Other",
|
||||
"CreatedBy": "GenOrca",
|
||||
"CreatedByURL": "",
|
||||
"DocsURL": "",
|
||||
"MarketplaceURL": "",
|
||||
"SupportURL": "",
|
||||
"EngineVersion": "5.8.0",
|
||||
"CanContainContent": true,
|
||||
"Installed": true,
|
||||
"Modules": [
|
||||
{
|
||||
"Name": "UnrealMCPython",
|
||||
"Type": "Editor",
|
||||
"LoadingPhase": "PostEngineInit",
|
||||
"PlatformAllowList": [
|
||||
"Win64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Plugins": [
|
||||
{
|
||||
"Name": "PythonScriptPlugin",
|
||||
"Enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,6 +10,21 @@
|
||||
"TargetAllowList": [
|
||||
"Editor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "ModelContextProtocol",
|
||||
"Enabled": true,
|
||||
"TargetAllowList": [
|
||||
"Editor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "PythonScriptPlugin",
|
||||
"Enabled": true
|
||||
},
|
||||
{
|
||||
"Name": "UnrealMCPython",
|
||||
"Enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
194
mcp-server/.gitignore
vendored
Normal file
194
mcp-server/.gitignore
vendored
Normal file
@@ -0,0 +1,194 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the enitre vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Cursor
|
||||
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||
# refer to https://docs.cursor.com/context/ignore-files
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
201
mcp-server/LICENSE
Normal file
201
mcp-server/LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
72
mcp-server/README.md
Normal file
72
mcp-server/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# unreal-mcp-server
|
||||
|
||||
unreal-mcp-server is a Python-based server that implements the Model Context Protocol (MCP) for Unreal Engine.
|
||||
It enables smooth communication between MCP clients (e.g., Claude, Cursor, Windsurf) and the Unreal Editor, and is intended to be used together with the Unreal-MCPython Plugin.
|
||||
|
||||
- Demo : [Build 3D Scenes in Unreal Engine with Claude AI | Unreal-MCPython Demo](https://youtu.be/V7KyjzFlBLk?si=sOY1dEVGV2hqi4JC)
|
||||
- Fab Link : [Unreal-MCPython: AI Assistant Plugin for Unreal Editor using Python & MCP](https://fab.com/s/aed5f75d50b2)
|
||||
- Github Link : [GenOrca/unreal-mcpython](https://github.com/GenOrca/unreal-mcpython)
|
||||
|
||||
|
||||
## 🎯 Why Choose Unreal-MCPython?
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/GenOrca/Screenshot/refs/heads/main/unreal-mcp/Screenshot%202025-06-02%20025106.png" width="400">
|
||||
<img src="https://raw.githubusercontent.com/GenOrca/Screenshot/refs/heads/main/unreal-mcp/Screenshot%202025-06-02%20025111.png" width="400">
|
||||
<img src="https://raw.githubusercontent.com/GenOrca/Screenshot/refs/heads/main/unreal-mcp/Screenshot%202025-06-02%20025115.png" width="400">
|
||||
<img src="https://raw.githubusercontent.com/GenOrca/Screenshot/refs/heads/main/unreal-mcp/Screenshot%202025-06-02%20025120.png" width="400">
|
||||
</p>
|
||||
|
||||
- 🧠 **Unreal AI integration** - Direct Claude AI assistance in Unreal Engine
|
||||
- 🔗 **Native MCP protocol support** - Seamless communication between AI and UE
|
||||
- 🎮 **Intelligent game development** - AI-powered asset management and scene manipulation
|
||||
- ⚡ **Smart automation** - Context-aware blueprint scripting with AI guidance
|
||||
- 🎨 **Technical artist focused** - AI assistance for complex production pipelines
|
||||
|
||||
## Key Features
|
||||
|
||||
- MCP server for communication with Unreal Engine
|
||||
- 16 namespace dispatcher tools (actor, material, blueprint, animation, asset, …) exposing 191 actions, each callable as `{action, params}`
|
||||
- Supports Python 3.11 and later
|
||||
|
||||
# Installation
|
||||
|
||||
Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/unreal-mcp-server.git
|
||||
cd unreal-mcp-server
|
||||
```
|
||||
|
||||
# Running the Server
|
||||
|
||||
You can start the MCP server with the following command:
|
||||
```bash
|
||||
uv --directory absolute/path/to/unreal-mcp-server run src/unreal_mcp/main.py
|
||||
```
|
||||
|
||||
# Example Configuration (Using Claude, VSCode, Cursor)
|
||||
|
||||
The following is an example configuration for launching the MCP server from Claude, VSCode, or Cursor:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"unreal-mcpython": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"/absolute/path/to/unreal-mcp-server", // e.g., D:/GitHub/unreal-mcp-server
|
||||
"run",
|
||||
"src/unreal_mcp/main.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This configuration approach works similarly across editors like VSCode and Cursor.
|
||||
|
||||
# License
|
||||
|
||||
This project is licensed under the Apache-2.0 License. See the LICENSE file for details.
|
||||
153
mcp-server/generate_catalog.py
Normal file
153
mcp-server/generate_catalog.py
Normal file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Generates the dispatcher action catalog from the plugin's ue_* function signatures.
|
||||
|
||||
The catalog is the single source of truth that list_actions exposes to the LLM.
|
||||
It MUST match the real ue_* signatures exactly, because the dispatcher passes
|
||||
params straight through as ue_func(**params). Hand-transcribing signatures is
|
||||
error-prone, so we extract them via AST instead.
|
||||
|
||||
Usage:
|
||||
python generate_catalog.py # writes src/unreal_mcp/dispatchers/_catalog.py
|
||||
python generate_catalog.py --check # exits 1 if the committed catalog is stale
|
||||
|
||||
Run with --check in CI / validate_tools to catch drift.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MCP_SERVER_DIR = Path(__file__).parent
|
||||
PLUGIN_DIR = MCP_SERVER_DIR.parent / "Plugins" / "UnrealMCPython" / "Content" / "Python" / "UnrealMCPython"
|
||||
OUTPUT = MCP_SERVER_DIR / "src" / "unreal_mcp" / "dispatchers" / "_catalog.py"
|
||||
|
||||
# Domain → plugin action file (module is UnrealMCPython.<file stem>)
|
||||
DOMAINS = [
|
||||
"actor",
|
||||
"anim_blueprint",
|
||||
"animation",
|
||||
"asset",
|
||||
"behavior_tree",
|
||||
"blueprint",
|
||||
"control_rig",
|
||||
"data_table",
|
||||
"editor",
|
||||
"game",
|
||||
"gas",
|
||||
"layer",
|
||||
"level",
|
||||
"level_sequence",
|
||||
"material",
|
||||
"retarget",
|
||||
"static_mesh",
|
||||
"texture",
|
||||
"umg",
|
||||
"util",
|
||||
"vision",
|
||||
]
|
||||
|
||||
# Actions handled by the dispatcher but NOT backed by a ue_* function
|
||||
# (special TCP message types). Documented manually so list_actions shows them.
|
||||
EXTRA_ACTIONS = {
|
||||
"util": {
|
||||
"execute_python": {
|
||||
"params": "code",
|
||||
"doc": "Runs arbitrary Unreal Python code. Full API access; fastest path to prototype new actions.",
|
||||
},
|
||||
"livecoding_compile": {
|
||||
"params": "",
|
||||
"doc": "Triggers C++ Live Coding and waits for the compile result.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _params(fn: ast.FunctionDef) -> str:
|
||||
a = fn.args
|
||||
defaults = a.defaults
|
||||
pad = len(a.args) - len(defaults)
|
||||
parts = []
|
||||
for i, arg in enumerate(a.args):
|
||||
if i >= pad:
|
||||
dv = defaults[i - pad]
|
||||
try:
|
||||
val = ast.literal_eval(dv)
|
||||
except Exception:
|
||||
val = "..."
|
||||
# default None means "required but validated inside" → show name only
|
||||
parts.append(arg.arg if val is None else f"{arg.arg}={val!r}")
|
||||
else:
|
||||
parts.append(arg.arg)
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def _doc(fn: ast.FunctionDef) -> str:
|
||||
d = ast.get_docstring(fn)
|
||||
return d.splitlines()[0].strip() if d else ""
|
||||
|
||||
|
||||
def _extract(domain: str) -> dict:
|
||||
f = PLUGIN_DIR / f"{domain}_actions.py"
|
||||
actions: dict[str, dict] = {}
|
||||
if f.exists():
|
||||
tree = ast.parse(f.read_text(encoding="utf-8"))
|
||||
for n in tree.body:
|
||||
if isinstance(n, ast.FunctionDef) and n.name.startswith("ue_"):
|
||||
action = n.name[3:] # strip ue_
|
||||
actions[action] = {"params": _params(n), "doc": _doc(n)}
|
||||
actions.update(EXTRA_ACTIONS.get(domain, {}))
|
||||
return dict(sorted(actions.items()))
|
||||
|
||||
|
||||
def build() -> dict:
|
||||
return {d: _extract(d) for d in DOMAINS}
|
||||
|
||||
|
||||
def render(catalog: dict) -> str:
|
||||
lines = [
|
||||
"# Copyright (c) 2025 GenOrca. All Rights Reserved.",
|
||||
"#",
|
||||
"# AUTO-GENERATED by generate_catalog.py — do not edit by hand.",
|
||||
"# Regenerate: python generate_catalog.py",
|
||||
"",
|
||||
"CATALOG = {",
|
||||
]
|
||||
for domain, actions in catalog.items():
|
||||
lines.append(f" {domain!r}: {{")
|
||||
for action, info in actions.items():
|
||||
lines.append(f" {action!r}: {{")
|
||||
lines.append(f" {'params'!r}: {info['params']!r},")
|
||||
lines.append(f" {'doc'!r}: {info['doc']!r},")
|
||||
lines.append(" },")
|
||||
lines.append(" },")
|
||||
lines.append("}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
catalog = build()
|
||||
rendered = render(catalog)
|
||||
|
||||
if "--check" in sys.argv:
|
||||
if not OUTPUT.exists():
|
||||
print("FAIL: _catalog.py does not exist. Run generate_catalog.py.")
|
||||
sys.exit(1)
|
||||
current = OUTPUT.read_text(encoding="utf-8")
|
||||
if current.strip() != rendered.strip():
|
||||
print("FAIL: _catalog.py is stale. Run: python generate_catalog.py")
|
||||
sys.exit(1)
|
||||
total = sum(len(a) for a in catalog.values())
|
||||
print(f"OK: catalog in sync ({total} actions across {len(catalog)} domains).")
|
||||
sys.exit(0)
|
||||
|
||||
OUTPUT.write_text(rendered, encoding="utf-8")
|
||||
total = sum(len(a) for a in catalog.values())
|
||||
print(f"Wrote {OUTPUT} ({total} actions across {len(catalog)} domains).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
34
mcp-server/pyproject.toml
Normal file
34
mcp-server/pyproject.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=42", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "unrealmcp"
|
||||
version = "2.2.0"
|
||||
description = "MCP Server for Unreal Engine integration"
|
||||
authors = [
|
||||
{name = "Jinhyung Ahn", email = "zenoengine@gmail.com"}
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = "Apache-2.0"
|
||||
dependencies = [
|
||||
"fastmcp",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
unrealmcp = "unreal_mcp.main:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
21
mcp-server/setup.py
Normal file
21
mcp-server/setup.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="unreal-mcp",
|
||||
version="1.0.0",
|
||||
description="MCP Server for Unreal Engine integration",
|
||||
author="Your Name",
|
||||
author_email="your.email@example.com",
|
||||
packages=find_packages(where="src"),
|
||||
package_dir={"": "src"},
|
||||
python_requires=">=3.8",
|
||||
install_requires=[
|
||||
"fastmcp>=1.0.0",
|
||||
],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"unreal-mcp=unreal_mcp.server:run_server",
|
||||
"unreal-mcp-test=main:test_server"
|
||||
],
|
||||
},
|
||||
)
|
||||
249
mcp-server/src/unreal_mcp/core.py
Normal file
249
mcp-server/src/unreal_mcp/core.py
Normal file
@@ -0,0 +1,249 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
import socket
|
||||
import json
|
||||
import sys
|
||||
|
||||
# Custom Exception classes
|
||||
class ToolInputError(Exception):
|
||||
pass
|
||||
|
||||
class UnrealExecutionError(Exception):
|
||||
def __init__(self, message, details=None):
|
||||
super().__init__(message)
|
||||
self.details = details if details is not None else {}
|
||||
|
||||
|
||||
async def send_unreal_action(action_module: str, params: dict) -> dict:
|
||||
"""
|
||||
Convention-based wrapper for send_to_unreal.
|
||||
Auto-derives action_name from the calling function's name.
|
||||
Convention: caller 'foo_bar' → action 'ue_foo_bar'
|
||||
Also includes standard error handling.
|
||||
"""
|
||||
caller_name = sys._getframe(1).f_code.co_name
|
||||
action_name = f"ue_{caller_name}"
|
||||
try:
|
||||
return await send_to_unreal(action_module, action_name, params)
|
||||
except UnrealExecutionError as e:
|
||||
return {"success": False, "message": str(e), "details": e.details}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"An unexpected error occurred: {str(e)}"}
|
||||
|
||||
def _unwrap_result(response: dict) -> dict:
|
||||
"""
|
||||
The TCP server's python_call path double-wraps the action's JSON return.
|
||||
The C++ server runs `print(execute_action(...))` and captures stdout into a
|
||||
string "result" field, so the wire shape is:
|
||||
|
||||
{"success": <python-exec ok>, "message": ..., "result": "<action json string>"}
|
||||
|
||||
The OUTER "success" is whether Python *ran*, NOT whether the action
|
||||
succeeded. The real action dict (with its own "success", "actor_label", ...)
|
||||
is the JSON string in "result". Unwrap it so callers get the action dict.
|
||||
"""
|
||||
if not isinstance(response, dict):
|
||||
return response
|
||||
inner = response.get("result")
|
||||
if isinstance(inner, str):
|
||||
try:
|
||||
parsed = json.loads(inner)
|
||||
except (ValueError, TypeError):
|
||||
return response
|
||||
if isinstance(parsed, (dict, list)):
|
||||
return parsed
|
||||
return response
|
||||
|
||||
|
||||
# Core send_to_unreal function
|
||||
async def send_to_unreal(action_module: str, action_name: str, params: dict) -> dict:
|
||||
"""
|
||||
Sends a command to the Unreal Engine Python script via socket communication.
|
||||
Args:
|
||||
action_module (str): The Python module in Unreal (e.g., 'actor_actions').
|
||||
action_name (str): The function name in the module (e.g., 'ue_spawn_actor_from_class').
|
||||
params (dict): A dictionary of parameters for the action.
|
||||
Returns:
|
||||
dict: The JSON response from Unreal.
|
||||
Raises:
|
||||
UnrealExecutionError: If any error occurs during socket communication or JSON processing.
|
||||
ToolInputError: If there's an issue with the input that can be determined client-side (though less common here).
|
||||
"""
|
||||
HOST = '127.0.0.1'
|
||||
PORT = 12029
|
||||
command = {
|
||||
"type": "python_call",
|
||||
"module": action_module,
|
||||
"function": action_name,
|
||||
"args": params
|
||||
}
|
||||
response_str = ""
|
||||
try:
|
||||
json_str = json.dumps(command, ensure_ascii=False)
|
||||
message_bytes = json_str.encode('utf-8')
|
||||
|
||||
# Using asyncio for socket communication would be better for a fully async server,
|
||||
# but standard socket is used here as per existing structure.
|
||||
# If FastMCP's .run() uses an async server like uvicorn, this blocking call
|
||||
# will run in a thread pool.
|
||||
with socket.create_connection((HOST, PORT), timeout=30) as sock:
|
||||
sock.sendall(message_bytes)
|
||||
response_buffer = b''
|
||||
while True:
|
||||
chunk = sock.recv(16384)
|
||||
if not chunk:
|
||||
break
|
||||
response_buffer += chunk
|
||||
|
||||
if not response_buffer:
|
||||
raise UnrealExecutionError("No response received from Unreal.", details={"host": HOST, "port": PORT})
|
||||
|
||||
response_str = response_buffer.decode('utf-8')
|
||||
response_json = json.loads(response_str)
|
||||
|
||||
# Outer "success" is python-exec success. False = Python failed to run
|
||||
# (bad module/function, syntax error) → propagate as an error.
|
||||
if isinstance(response_json, dict) and response_json.get("success") is False:
|
||||
raise UnrealExecutionError(
|
||||
response_json.get("message", "Unknown error from Unreal action."),
|
||||
details=response_json.get("details")
|
||||
)
|
||||
|
||||
# Python ran; unwrap the action's real result out of the "result" string.
|
||||
# The action's own success/failure is preserved in the unwrapped dict.
|
||||
return _unwrap_result(response_json)
|
||||
|
||||
except socket.timeout:
|
||||
raise UnrealExecutionError(f"Socket timeout ({HOST}:{PORT}): No response from Unreal.", details={"host": HOST, "port": PORT})
|
||||
except ConnectionRefusedError:
|
||||
raise UnrealExecutionError(f"Connection refused ({HOST}:{PORT}). Ensure Unreal MCPython TCP server is active.", details={"host": HOST, "port": PORT})
|
||||
except json.JSONDecodeError as je:
|
||||
raise UnrealExecutionError(f"Failed to decode JSON response from Unreal: {je}. Raw response: '{response_str}'", details={"host": HOST, "port": PORT, "raw_response": response_str})
|
||||
except socket.error as se:
|
||||
raise UnrealExecutionError(f"Socket error ({HOST}:{PORT}): {se}", details={"host": HOST, "port": PORT})
|
||||
except UnrealExecutionError: # Re-raise if it's already our specific error type
|
||||
raise
|
||||
except Exception as e: # Catch any other unexpected errors
|
||||
raise UnrealExecutionError(f"An unexpected error occurred in send_to_unreal ({HOST}:{PORT}): {type(e).__name__} - {e}", details={"host": HOST, "port": PORT, "error_type": type(e).__name__})
|
||||
|
||||
|
||||
async def send_python_exec(code: str) -> dict:
|
||||
"""
|
||||
Sends raw Python code to the Unreal Engine TCP server for execution.
|
||||
Uses the existing "type": "python" dispatch path.
|
||||
The C++ server executes the code and captures print() output.
|
||||
"""
|
||||
HOST = '127.0.0.1'
|
||||
PORT = 12029
|
||||
TIMEOUT = 30
|
||||
command = {"type": "python", "code": code}
|
||||
response_str = ""
|
||||
try:
|
||||
json_str = json.dumps(command, ensure_ascii=False)
|
||||
message_bytes = json_str.encode('utf-8')
|
||||
|
||||
with socket.create_connection((HOST, PORT), timeout=TIMEOUT) as sock:
|
||||
sock.sendall(message_bytes)
|
||||
response_buffer = b''
|
||||
while True:
|
||||
chunk = sock.recv(16384)
|
||||
if not chunk:
|
||||
break
|
||||
response_buffer += chunk
|
||||
|
||||
if not response_buffer:
|
||||
raise UnrealExecutionError(
|
||||
"No response received from Unreal for Python execution.",
|
||||
details={"host": HOST, "port": PORT}
|
||||
)
|
||||
|
||||
response_str = response_buffer.decode('utf-8')
|
||||
response_json = json.loads(response_str)
|
||||
return response_json
|
||||
|
||||
except socket.timeout:
|
||||
raise UnrealExecutionError(
|
||||
f"Socket timeout ({HOST}:{PORT}): Python execution did not complete within {TIMEOUT}s.",
|
||||
details={"host": HOST, "port": PORT}
|
||||
)
|
||||
except ConnectionRefusedError:
|
||||
raise UnrealExecutionError(
|
||||
f"Connection refused ({HOST}:{PORT}). Ensure Unreal MCPython TCP server is active.",
|
||||
details={"host": HOST, "port": PORT}
|
||||
)
|
||||
except json.JSONDecodeError as je:
|
||||
raise UnrealExecutionError(
|
||||
f"Failed to decode JSON response: {je}. Raw: '{response_str}'",
|
||||
details={"host": HOST, "port": PORT, "raw_response": response_str}
|
||||
)
|
||||
except UnrealExecutionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise UnrealExecutionError(
|
||||
f"Unexpected error during Python execution: {type(e).__name__} - {e}",
|
||||
details={"host": HOST, "port": PORT, "error_type": type(e).__name__}
|
||||
)
|
||||
|
||||
|
||||
async def send_livecoding_compile() -> dict:
|
||||
"""
|
||||
Sends a livecoding_compile command to the Unreal Engine TCP server.
|
||||
Triggers C++ hot-reload via the LiveCoding module.
|
||||
Waits for compilation to complete before returning the result.
|
||||
"""
|
||||
HOST = '127.0.0.1'
|
||||
PORT = 12029
|
||||
TIMEOUT = 180
|
||||
command = {"type": "livecoding_compile"}
|
||||
response_str = ""
|
||||
try:
|
||||
json_str = json.dumps(command, ensure_ascii=False)
|
||||
message_bytes = json_str.encode('utf-8')
|
||||
|
||||
with socket.create_connection((HOST, PORT), timeout=TIMEOUT) as sock:
|
||||
sock.sendall(message_bytes)
|
||||
response_buffer = b''
|
||||
while True:
|
||||
chunk = sock.recv(16384)
|
||||
if not chunk:
|
||||
break
|
||||
response_buffer += chunk
|
||||
|
||||
if not response_buffer:
|
||||
raise UnrealExecutionError(
|
||||
"No response received from Unreal for LiveCoding compile.",
|
||||
details={"host": HOST, "port": PORT}
|
||||
)
|
||||
|
||||
response_str = response_buffer.decode('utf-8')
|
||||
response_json = json.loads(response_str)
|
||||
|
||||
if isinstance(response_json, dict) and response_json.get("success") is False:
|
||||
raise UnrealExecutionError(
|
||||
response_json.get("message", "LiveCoding compile failed."),
|
||||
details=response_json
|
||||
)
|
||||
return response_json
|
||||
|
||||
except socket.timeout:
|
||||
raise UnrealExecutionError(
|
||||
f"Socket timeout ({HOST}:{PORT}): LiveCoding compilation did not complete within {TIMEOUT}s.",
|
||||
details={"host": HOST, "port": PORT}
|
||||
)
|
||||
except ConnectionRefusedError:
|
||||
raise UnrealExecutionError(
|
||||
f"Connection refused ({HOST}:{PORT}). Ensure Unreal MCPython TCP server is active.",
|
||||
details={"host": HOST, "port": PORT}
|
||||
)
|
||||
except json.JSONDecodeError as je:
|
||||
raise UnrealExecutionError(
|
||||
f"Failed to decode JSON response: {je}. Raw: '{response_str}'",
|
||||
details={"host": HOST, "port": PORT, "raw_response": response_str}
|
||||
)
|
||||
except UnrealExecutionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise UnrealExecutionError(
|
||||
f"Unexpected error during LiveCoding compile: {type(e).__name__} - {e}",
|
||||
details={"host": HOST, "port": PORT, "error_type": type(e).__name__}
|
||||
)
|
||||
131
mcp-server/src/unreal_mcp/dispatcher.py
Normal file
131
mcp-server/src/unreal_mcp/dispatcher.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Namespace dispatcher — thin assembler.
|
||||
|
||||
One MCP tool per domain. Each accepts (action, params) and routes to the
|
||||
Unreal TCP server as ue_<action>(**params). action='list_actions' returns the
|
||||
domain's action catalog (param names + one-line docs).
|
||||
|
||||
The catalog is AUTO-GENERATED from the plugin's ue_* signatures
|
||||
(see generate_catalog.py). Param names are therefore guaranteed to match —
|
||||
no hand transcription. This file never grows when actions are added.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from typing import Annotated
|
||||
from pydantic import Field
|
||||
from fastmcp import FastMCP, Image
|
||||
|
||||
from unreal_mcp.core import send_to_unreal, UnrealExecutionError, send_python_exec, send_livecoding_compile
|
||||
from unreal_mcp.dispatchers._catalog import CATALOG
|
||||
|
||||
dispatcher_mcp = FastMCP(
|
||||
name="UnrealMCP",
|
||||
description=(
|
||||
"Unreal Engine MCP via namespace dispatchers. "
|
||||
"Each domain tool accepts (action, params). "
|
||||
"Pass action='list_actions' to any tool to get available actions and parameter docs."
|
||||
)
|
||||
)
|
||||
|
||||
# Domains using standard python_call routing. util and vision have hand-written
|
||||
# handlers (special TCP types / MCP Image return).
|
||||
_SPECIAL_DOMAINS = {"util", "vision"}
|
||||
_STANDARD_DOMAINS = [d for d in CATALOG if d not in _SPECIAL_DOMAINS]
|
||||
|
||||
|
||||
def _module(domain: str) -> str:
|
||||
return f"UnrealMCPython.{domain}_actions"
|
||||
|
||||
|
||||
async def _dispatch(domain: str, action: str, params: dict) -> dict:
|
||||
if action == "list_actions":
|
||||
return {"success": True, "domain": domain, "actions": CATALOG[domain]}
|
||||
if action not in CATALOG[domain]:
|
||||
return {"success": False, "message": f"Unknown action '{action}'. Available: {list(CATALOG[domain])}"}
|
||||
try:
|
||||
return await send_to_unreal(_module(domain), f"ue_{action}", params)
|
||||
except UnrealExecutionError as e:
|
||||
return {"success": False, "message": str(e), "details": getattr(e, "details", {})}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Unexpected error: {e}"}
|
||||
|
||||
|
||||
def _desc(domain: str) -> str:
|
||||
actions = ", ".join(CATALOG[domain])
|
||||
return f"Unreal {domain} tools. Actions: {actions}. Pass action='list_actions' for parameter docs."
|
||||
|
||||
|
||||
def _make_handler(domain: str):
|
||||
async def handler(
|
||||
action: Annotated[str, Field(description="Action name. Use 'list_actions' for full parameter docs.")],
|
||||
params: Annotated[dict, Field(description="Action parameters (keys must match the action's documented params).")] = {}
|
||||
) -> dict:
|
||||
return await _dispatch(domain, action, params)
|
||||
handler.__name__ = domain
|
||||
return handler
|
||||
|
||||
|
||||
for _domain in _STANDARD_DOMAINS:
|
||||
dispatcher_mcp.tool(name=_domain, description=_desc(_domain))(_make_handler(_domain))
|
||||
|
||||
|
||||
# ─── util: special routing (execute_python / livecoding_compile use dedicated TCP types) ──
|
||||
|
||||
@dispatcher_mcp.tool(name="util", description=_desc("util"))
|
||||
async def util(
|
||||
action: Annotated[str, Field(description="Action name. Use 'list_actions' for full parameter docs.")],
|
||||
params: Annotated[dict, Field(description="Action parameters. execute_python: {code}. get_output_log: {line_count, keyword}.")] = {}
|
||||
) -> dict:
|
||||
if action == "list_actions":
|
||||
return {"success": True, "domain": "util", "actions": CATALOG["util"]}
|
||||
|
||||
if action == "execute_python":
|
||||
code = params.get("code", "")
|
||||
if not code:
|
||||
return {"success": False, "message": "params.code is required"}
|
||||
try:
|
||||
return await send_python_exec(code)
|
||||
except UnrealExecutionError as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
if action == "livecoding_compile":
|
||||
try:
|
||||
return await send_livecoding_compile()
|
||||
except UnrealExecutionError as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
if action in CATALOG["util"]:
|
||||
try:
|
||||
return await send_to_unreal(_module("util"), f"ue_{action}", params)
|
||||
except UnrealExecutionError as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
return {"success": False, "message": f"Unknown action '{action}'. Available: {list(CATALOG['util'])}"}
|
||||
|
||||
|
||||
# ─── vision: special routing (returns an MCP Image for captures) ────────────────
|
||||
|
||||
@dispatcher_mcp.tool(name="vision", description=_desc("vision"))
|
||||
async def vision(
|
||||
action: Annotated[str, Field(description="Action name. Use 'list_actions' for full parameter docs.")],
|
||||
params: Annotated[dict, Field(description="Action parameters. capture_viewport: {width, height, fov}.")] = {}
|
||||
) -> "Image | dict":
|
||||
if action == "list_actions":
|
||||
return {"success": True, "domain": "vision", "actions": CATALOG["vision"]}
|
||||
|
||||
if action not in CATALOG["vision"]:
|
||||
return {"success": False, "message": f"Unknown action '{action}'. Available: {list(CATALOG['vision'])}"}
|
||||
|
||||
try:
|
||||
result = await send_to_unreal(_module("vision"), f"ue_{action}", params)
|
||||
except UnrealExecutionError as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Capture actions return base64 PNG in 'image_data' → hand back an MCP Image.
|
||||
if isinstance(result, dict):
|
||||
img_b64 = result.get("image_data")
|
||||
if result.get("success") and img_b64:
|
||||
return Image(data=base64.b64decode(img_b64), format="png")
|
||||
return result
|
||||
0
mcp-server/src/unreal_mcp/dispatchers/__init__.py
Normal file
0
mcp-server/src/unreal_mcp/dispatchers/__init__.py
Normal file
1061
mcp-server/src/unreal_mcp/dispatchers/_catalog.py
Normal file
1061
mcp-server/src/unreal_mcp/dispatchers/_catalog.py
Normal file
File diff suppressed because it is too large
Load Diff
24
mcp-server/src/unreal_mcp/main.py
Normal file
24
mcp-server/src/unreal_mcp/main.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
sys.path.append(os.path.dirname(__file__))
|
||||
|
||||
from unreal_mcp.server import main_mcp, run_server
|
||||
from fastmcp import Client
|
||||
|
||||
async def test_server():
|
||||
print("Testing Unreal MCP Server...")
|
||||
tools = await main_mcp.get_tools()
|
||||
print(f"Available tools: {list(tools.keys())}")
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--test":
|
||||
asyncio.run(test_server())
|
||||
else:
|
||||
run_server()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
13
mcp-server/src/unreal_mcp/server.py
Normal file
13
mcp-server/src/unreal_mcp/server.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
from unreal_mcp.dispatcher import dispatcher_mcp
|
||||
|
||||
# Public name kept stable for main.py and external imports.
|
||||
main_mcp = dispatcher_mcp
|
||||
|
||||
def run_server():
|
||||
"""Entry point function for the Unreal MCP Server"""
|
||||
main_mcp.run(transport="stdio")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server()
|
||||
45
mcp-server/tests/test_core.py
Normal file
45
mcp-server/tests/test_core.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""Unit tests for the TCP response unwrapping (offline, no Unreal)."""
|
||||
|
||||
from unreal_mcp.core import _unwrap_result
|
||||
|
||||
|
||||
def test_unwraps_action_dict_from_result_string():
|
||||
wire = {
|
||||
"success": True, # python-exec success, NOT action success
|
||||
"message": "Python command executed successfully.",
|
||||
"result": '{"success": true, "actor_label": "PointLight_2"}',
|
||||
}
|
||||
out = _unwrap_result(wire)
|
||||
assert out == {"success": True, "actor_label": "PointLight_2"}
|
||||
|
||||
|
||||
def test_unwrapped_inner_failure_is_surfaced():
|
||||
"""Action failure (inner success=False) must survive unwrapping as data."""
|
||||
wire = {
|
||||
"success": True, # python ran fine...
|
||||
"result": '{"success": false, "message": "Actor not found"}',
|
||||
}
|
||||
out = _unwrap_result(wire)
|
||||
assert out["success"] is False
|
||||
assert out["message"] == "Actor not found"
|
||||
|
||||
|
||||
def test_unwraps_list_result():
|
||||
wire = {"success": True, "result": '[1, 2, 3]'}
|
||||
assert _unwrap_result(wire) == [1, 2, 3]
|
||||
|
||||
|
||||
def test_non_json_result_returns_original():
|
||||
wire = {"success": True, "result": "not json at all"}
|
||||
assert _unwrap_result(wire) == wire
|
||||
|
||||
|
||||
def test_missing_result_returns_original():
|
||||
wire = {"success": True, "message": "ok"}
|
||||
assert _unwrap_result(wire) == wire
|
||||
|
||||
|
||||
def test_non_dict_passthrough():
|
||||
assert _unwrap_result("raw") == "raw"
|
||||
88
mcp-server/tests/test_coverage.py
Normal file
88
mcp-server/tests/test_coverage.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Action coverage enforcement (offline, no Unreal needed).
|
||||
|
||||
Every catalog action must be exercised by an in-editor unittest
|
||||
(Plugins/.../tests/test_<domain>.py references "ue_<action>"), OR be listed
|
||||
in KNOWN_UNTESTED below as acknowledged technical debt.
|
||||
|
||||
Why: the dispatcher auto-exposes any ue_* function. Without this gate, a new
|
||||
action could ship with zero behavior test and all other gates stay green.
|
||||
This makes "add an action without a test" a conscious, reviewable choice
|
||||
(you must edit KNOWN_UNTESTED) rather than a silent gap.
|
||||
|
||||
KNOWN_UNTESTED is debt to shrink, not to grow. The stale-entry check fails if
|
||||
an allowlisted action becomes tested or disappears, so the list self-cleans.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from unreal_mcp.dispatchers._catalog import CATALOG
|
||||
|
||||
PLUGIN_TESTS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "Plugins" / "UnrealMCPython" / "Content" / "Python" / "UnrealMCPython" / "tests"
|
||||
)
|
||||
|
||||
# Actions not routed as ue_<action> over TCP — covered by mcp-server pytest instead.
|
||||
SPECIAL = {"util": {"execute_python", "livecoding_compile"}}
|
||||
|
||||
# ── Technical debt: actions with no in-editor behavior test yet. SHRINK over time. ──
|
||||
# Adding a new action? Write a test in test_<domain>.py instead of adding it here.
|
||||
KNOWN_UNTESTED: dict[str, set[str]] = {
|
||||
# PIE start/stop change the editor play mode asynchronously; running them in the
|
||||
# headless suite (which never ticks between calls) would leave the editor in PIE.
|
||||
# Verified manually through the MCP chain instead.
|
||||
"util": {"start_pie", "stop_pie"},
|
||||
# save_current_level can raise a modal Save-As dialog on an untitled level,
|
||||
# which would hang the headless suite; save_all_levels is grouped with it.
|
||||
# Verified manually instead.
|
||||
"level": {"save_current_level", "save_all_levels"},
|
||||
}
|
||||
|
||||
|
||||
def _referenced(domain: str) -> set[str]:
|
||||
tf = PLUGIN_TESTS / f"test_{domain}.py"
|
||||
if not tf.exists():
|
||||
return set()
|
||||
return set(re.findall(r"ue_(\w+)", tf.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def _allowed(domain: str) -> set[str]:
|
||||
return SPECIAL.get(domain, set()) | KNOWN_UNTESTED.get(domain, set())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("domain", sorted(CATALOG))
|
||||
def test_every_action_is_tested_or_allowlisted(domain):
|
||||
referenced = _referenced(domain)
|
||||
untested = [
|
||||
a for a in CATALOG[domain]
|
||||
if a not in referenced and a not in _allowed(domain)
|
||||
]
|
||||
assert not untested, (
|
||||
f"{domain}: these actions have no in-editor test and are not allowlisted: "
|
||||
f"{untested}. Add a test in test_{domain}.py, or (consciously) add them to "
|
||||
f"KNOWN_UNTESTED in test_coverage.py."
|
||||
)
|
||||
|
||||
|
||||
def test_no_stale_allowlist_entries():
|
||||
"""KNOWN_UNTESTED must not contain actions that are now tested or no longer exist."""
|
||||
stale = []
|
||||
for domain, actions in KNOWN_UNTESTED.items():
|
||||
referenced = _referenced(domain)
|
||||
for a in actions:
|
||||
if a not in CATALOG.get(domain, {}):
|
||||
stale.append(f"{domain}.{a} (not in catalog)")
|
||||
elif a in referenced:
|
||||
stale.append(f"{domain}.{a} (now tested — remove from allowlist)")
|
||||
assert not stale, f"Stale KNOWN_UNTESTED entries: {stale}"
|
||||
|
||||
|
||||
def test_plugin_tests_dir_exists():
|
||||
"""Guard: if the layout moves, fail loudly instead of silently passing coverage."""
|
||||
assert PLUGIN_TESTS.is_dir(), f"Plugin tests dir not found: {PLUGIN_TESTS}"
|
||||
177
mcp-server/tests/test_dispatcher.py
Normal file
177
mcp-server/tests/test_dispatcher.py
Normal file
@@ -0,0 +1,177 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Offline routing tests for the namespace dispatcher.
|
||||
|
||||
These do NOT need Unreal running. They mock the TCP layer (send_to_unreal /
|
||||
send_python_exec / send_livecoding_compile) and assert that each domain tool
|
||||
routes (action, params) to the correct module + ue_<action> + params.
|
||||
|
||||
Run:
|
||||
cd mcp-server && uv run pytest
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
import unreal_mcp.dispatcher as disp
|
||||
from unreal_mcp.dispatchers._catalog import CATALOG
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recorder(monkeypatch):
|
||||
"""Replace send_to_unreal with an async recorder; return the call log."""
|
||||
calls = []
|
||||
|
||||
async def fake_send_to_unreal(module, fn, params):
|
||||
calls.append((module, fn, params))
|
||||
return {"success": True, "echo": {"module": module, "fn": fn, "params": params}}
|
||||
|
||||
monkeypatch.setattr(disp, "send_to_unreal", fake_send_to_unreal)
|
||||
return calls
|
||||
|
||||
|
||||
# ─── entrypoint smoke ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_entrypoints_import():
|
||||
"""main.py and server.py must import cleanly (catches removed public names)."""
|
||||
import unreal_mcp.server as server
|
||||
import unreal_mcp.main as main
|
||||
assert hasattr(server, "main_mcp")
|
||||
assert hasattr(server, "run_server")
|
||||
assert callable(main.main)
|
||||
|
||||
|
||||
# ─── tool registration ────────────────────────────────────────────────────────
|
||||
|
||||
def test_domain_tools_match_catalog():
|
||||
"""Exactly one MCP tool per catalog domain — no more, no less."""
|
||||
tools = disp.dispatcher_mcp._tool_manager.list_tools()
|
||||
names = sorted(t.name for t in tools)
|
||||
assert names == sorted(CATALOG.keys())
|
||||
|
||||
|
||||
# ─── standard routing ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_standard_routing_module_fn_params(recorder):
|
||||
result = run(disp._dispatch("actor", "set_location",
|
||||
{"actor_label": "Cube", "location": [0, 0, 100]}))
|
||||
assert result["success"] is True
|
||||
assert recorder == [
|
||||
("UnrealMCPython.actor_actions", "ue_set_location",
|
||||
{"actor_label": "Cube", "location": [0, 0, 100]})
|
||||
]
|
||||
|
||||
|
||||
def _standard_action_pairs():
|
||||
"""Every (domain, action) routed via the standard path (excludes hand-written domains)."""
|
||||
pairs = []
|
||||
for domain in CATALOG:
|
||||
if domain in ("util", "vision"):
|
||||
continue # special-cased handlers
|
||||
for action in CATALOG[domain]:
|
||||
pairs.append((domain, action))
|
||||
return pairs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("domain,action", _standard_action_pairs())
|
||||
def test_every_catalog_action_routes(domain, action, recorder):
|
||||
"""Full coverage: each catalog action routes to its module + ue_<action>."""
|
||||
run(disp._dispatch(domain, action, {}))
|
||||
module, fn, _ = recorder[0]
|
||||
assert module == f"UnrealMCPython.{domain}_actions"
|
||||
assert fn == f"ue_{action}"
|
||||
|
||||
|
||||
def test_params_passed_through_untouched(recorder):
|
||||
payload = {"asset_path": "/Game/BP", "graph_name": "EventGraph", "node_json": {"type": "Branch"}}
|
||||
run(disp._dispatch("blueprint", "add_blueprint_node", payload))
|
||||
assert recorder[0][2] == payload # dispatcher must not mutate/translate params
|
||||
|
||||
|
||||
# ─── list_actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_actions_returns_catalog(recorder):
|
||||
result = run(disp._dispatch("material", "list_actions", {}))
|
||||
assert result["success"] is True
|
||||
assert result["domain"] == "material"
|
||||
assert result["actions"] == CATALOG["material"]
|
||||
assert recorder == [] # list_actions must NOT hit the TCP layer
|
||||
|
||||
|
||||
def test_list_actions_entries_have_params_and_doc():
|
||||
for domain, actions in CATALOG.items():
|
||||
for action, info in actions.items():
|
||||
assert "params" in info and "doc" in info, f"{domain}.{action} missing keys"
|
||||
|
||||
|
||||
# ─── error handling ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_unknown_action_errors_without_tcp(recorder):
|
||||
result = run(disp._dispatch("actor", "fly_to_the_moon", {}))
|
||||
assert result["success"] is False
|
||||
assert "Unknown action" in result["message"]
|
||||
assert recorder == [] # never reaches the TCP layer
|
||||
|
||||
|
||||
# ─── util special routing ─────────────────────────────────────────────────────
|
||||
|
||||
def test_util_execute_python(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_exec(code):
|
||||
seen["code"] = code
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(disp, "send_python_exec", fake_exec)
|
||||
result = run(disp.util.fn(action="execute_python", params={"code": "import unreal"}))
|
||||
assert result["success"] is True
|
||||
assert seen["code"] == "import unreal"
|
||||
|
||||
|
||||
def test_util_execute_python_requires_code(monkeypatch):
|
||||
async def fake_exec(code):
|
||||
raise AssertionError("should not be called when code is missing")
|
||||
|
||||
monkeypatch.setattr(disp, "send_python_exec", fake_exec)
|
||||
result = run(disp.util.fn(action="execute_python", params={}))
|
||||
assert result["success"] is False
|
||||
assert "code is required" in result["message"]
|
||||
|
||||
|
||||
def test_util_livecoding_compile(monkeypatch):
|
||||
called = {"n": 0}
|
||||
|
||||
async def fake_compile():
|
||||
called["n"] += 1
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(disp, "send_livecoding_compile", fake_compile)
|
||||
result = run(disp.util.fn(action="livecoding_compile", params={}))
|
||||
assert result["success"] is True
|
||||
assert called["n"] == 1
|
||||
|
||||
|
||||
def test_util_get_output_log_routes_to_ue_function(recorder):
|
||||
run(disp.util.fn(action="get_output_log", params={"line_count": 20}))
|
||||
assert recorder == [
|
||||
("UnrealMCPython.util_actions", "ue_get_output_log", {"line_count": 20})
|
||||
]
|
||||
|
||||
|
||||
def test_util_list_actions(recorder):
|
||||
result = run(disp.util.fn(action="list_actions", params={}))
|
||||
assert result["actions"] == CATALOG["util"]
|
||||
assert recorder == []
|
||||
|
||||
|
||||
def test_util_unknown_action(recorder):
|
||||
result = run(disp.util.fn(action="nope", params={}))
|
||||
assert result["success"] is False
|
||||
assert "Unknown action" in result["message"]
|
||||
222
mcp-server/tests/test_e2e.py
Normal file
222
mcp-server/tests/test_e2e.py
Normal file
@@ -0,0 +1,222 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
End-to-end tests: MCP server dispatcher -> real TCP -> C++ server -> ue_* -> back.
|
||||
|
||||
Unlike test_dispatcher.py (which mocks the TCP layer), these run the FULL chain
|
||||
with NO mocks. They prove the actual scenario an MCP client triggers:
|
||||
routing + socket round-trip + C++ dispatch + response unwrapping.
|
||||
|
||||
Requires a running Unreal editor with the UnrealMCPython TCP server on :12029.
|
||||
If the port is not reachable, the whole module is skipped (so CI / offline runs
|
||||
stay green; run locally with the editor open to exercise these).
|
||||
|
||||
Run:
|
||||
cd mcp-server && uv run --extra dev pytest tests/test_e2e.py -v
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
import unreal_mcp.dispatcher as disp
|
||||
from unreal_mcp.dispatchers._catalog import CATALOG
|
||||
|
||||
HOST, PORT = "127.0.0.1", 12029
|
||||
|
||||
# Actions excluded from the exhaustive empty-param round-trip:
|
||||
# execute_python — needs {code}; empty-param hits a dispatcher guard, not TCP
|
||||
# livecoding_compile — triggers a real C++ compile (slow, side-effecting)
|
||||
# start_pie/stop_pie — change editor PIE mode (no params to guard); would leave
|
||||
# the editor in PIE during the sweep
|
||||
_EXCLUDE = {
|
||||
("util", "execute_python"),
|
||||
("util", "livecoding_compile"),
|
||||
("util", "start_pie"),
|
||||
("util", "stop_pie"),
|
||||
# save_current_level can raise a modal Save-As dialog on an untitled level.
|
||||
("level", "save_current_level"),
|
||||
("level", "save_all_levels"),
|
||||
# vision returns an MCP Image (not a dict) — covered by a dedicated E2E test below.
|
||||
("vision", "capture_viewport"),
|
||||
}
|
||||
|
||||
|
||||
def _editor_reachable() -> bool:
|
||||
try:
|
||||
with socket.create_connection((HOST, PORT), timeout=0.5):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _editor_reachable(),
|
||||
reason=f"Unreal TCP server not reachable on {HOST}:{PORT} (open the editor to run E2E).",
|
||||
)
|
||||
|
||||
# Editor-crash guard: if the editor was up when the module started but dies
|
||||
# mid-suite, every remaining test must FAIL (not skip, and not "pass" via a
|
||||
# connection-error dict that happens to carry a 'success' key). A green E2E run
|
||||
# must mean the editor survived the whole sweep — otherwise a `pytest && ...`
|
||||
# release chain would happily commit/PR on top of a crash.
|
||||
_EDITOR_WAS_UP = _editor_reachable()
|
||||
_CONNECTION_ERROR_MARKERS = ("Connection refused", "ConnectionReset", "Socket timeout",
|
||||
"No response received", "[WinError")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fail_if_editor_crashed():
|
||||
if _EDITOR_WAS_UP and not _editor_reachable():
|
||||
pytest.fail(f"Unreal editor crashed during the E2E suite "
|
||||
f"({HOST}:{PORT} no longer reachable). Investigate before merging.")
|
||||
yield
|
||||
|
||||
|
||||
def _assert_not_connection_error(r, label):
|
||||
if isinstance(r, dict):
|
||||
msg = str(r.get("message", ""))
|
||||
assert not any(m in msg for m in _CONNECTION_ERROR_MARKERS), \
|
||||
f"{label}: editor connection lost mid-call: {msg}"
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_list_actors_round_trip():
|
||||
"""A read action proves unwrapping: 'actors' is an INNER field of the action result."""
|
||||
r = run(disp._dispatch("actor", "list_all_with_locations", {}))
|
||||
assert r.get("success") is True
|
||||
assert "actors" in r, f"inner field missing — response not unwrapped: {r}"
|
||||
assert isinstance(r["actors"], list)
|
||||
|
||||
|
||||
def test_spawn_and_delete_round_trip():
|
||||
spawn = run(disp._dispatch("actor", "spawn_from_class",
|
||||
{"class_path": "/Script/Engine.PointLight",
|
||||
"location": [0, 0, 700]}))
|
||||
assert spawn.get("success") is True, spawn
|
||||
label = spawn.get("actor_label")
|
||||
assert label, f"actor_label missing — not unwrapped: {spawn}"
|
||||
# cleanup through the same chain
|
||||
deleted = run(disp._dispatch("actor", "delete_by_label", {"actor_label": label}))
|
||||
assert deleted.get("success") is True, deleted
|
||||
|
||||
|
||||
def test_inner_action_failure_is_surfaced():
|
||||
"""Action failure must come back as success=False (not buried in a result string)."""
|
||||
r = run(disp._dispatch("actor", "set_transform",
|
||||
{"actor_label": "NoSuchActor_E2E_XYZ", "location": [0, 0, 0]}))
|
||||
assert r.get("success") is False
|
||||
assert "message" in r
|
||||
|
||||
|
||||
def test_list_actions_offline_path_still_works():
|
||||
"""list_actions must not touch TCP even when the editor is up."""
|
||||
r = run(disp._dispatch("material", "list_actions", {}))
|
||||
assert r["success"] is True
|
||||
assert r["domain"] == "material"
|
||||
|
||||
|
||||
def test_execute_python_round_trip():
|
||||
"""execute_python runs real Unreal Python through the chain."""
|
||||
r = run(disp.util.fn(action="execute_python",
|
||||
params={"code": "print('e2e_marker_42')"}))
|
||||
# send_python_exec returns the raw wrapper; the printed marker is in 'result'.
|
||||
blob = r.get("result", "") + r.get("message", "")
|
||||
assert "e2e_marker_42" in blob, f"execute_python did not echo marker: {r}"
|
||||
|
||||
|
||||
def test_vision_capture_returns_image():
|
||||
"""vision capture_viewport returns an MCP Image (PNG) through the full chain."""
|
||||
from fastmcp import Image
|
||||
r = run(disp.vision.fn(action="capture_viewport", params={"width": 320, "height": 180}))
|
||||
assert isinstance(r, Image), f"expected an Image, got {type(r).__name__}: {r}"
|
||||
# the PNG bytes should carry a valid signature
|
||||
data = getattr(r, "data", b"")
|
||||
assert data[:4] == b"\x89PNG", "capture did not return a PNG"
|
||||
|
||||
|
||||
def test_gltf_import_round_trip():
|
||||
"""glTF import via the deferred-tick path: export the engine Cube to .glb, import it,
|
||||
then poll get_gltf_import_status until done. Each dispatch is its own game-thread task,
|
||||
so the editor ticks between calls and the Interchange async import can run + complete."""
|
||||
import time
|
||||
|
||||
# 1. export /Engine/BasicShapes/Cube to a temp .glb (via execute_python)
|
||||
export_code = (
|
||||
"import unreal, os\n"
|
||||
"glb = os.path.join(unreal.Paths.project_saved_dir(), 'MCP_E2E_gltf.glb').replace(chr(92), '/')\n"
|
||||
"cube = unreal.EditorAssetLibrary.load_asset('/Engine/BasicShapes/Cube')\n"
|
||||
"t = unreal.AssetExportTask(); t.object = cube; t.filename = glb\n"
|
||||
"t.automated = True; t.replace_identical = True; t.prompt = False\n"
|
||||
"ok = unreal.Exporter.run_asset_export_task(t)\n"
|
||||
"print('GLBPATH=' + glb if (ok and os.path.isfile(glb)) else 'GLBFAIL')\n"
|
||||
)
|
||||
r = run(disp.util.fn(action="execute_python", params={"code": export_code}))
|
||||
blob = (r.get("result", "") or "") + (r.get("message", "") or "")
|
||||
assert "GLBPATH=" in blob, f"glb export failed: {blob[:300]}"
|
||||
glb = blob.split("GLBPATH=", 1)[1].split()[0].strip().strip('"')
|
||||
|
||||
dest = "/Game/Tests/MCP_E2E/glb"
|
||||
try:
|
||||
imp = run(disp._dispatch("asset", "import_gltf",
|
||||
{"file_path": glb, "destination_path": dest}))
|
||||
assert imp.get("success") and imp.get("pending"), f"schedule failed: {imp}"
|
||||
|
||||
st = {}
|
||||
for _ in range(40): # ~20s budget for the async Interchange import
|
||||
st = run(disp._dispatch("asset", "get_gltf_import_status",
|
||||
{"destination_path": dest}))
|
||||
_assert_not_connection_error(st, "get_gltf_import_status")
|
||||
if st.get("done"):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
assert st.get("success") and st.get("done"), f"import did not complete: {st}"
|
||||
classes = [a["class"] for a in st["imported_assets"]]
|
||||
assert "StaticMesh" in classes, f"no StaticMesh among imported: {st}"
|
||||
finally:
|
||||
run(disp.util.fn(action="execute_python", params={
|
||||
"code": f"import unreal; unreal.EditorAssetLibrary.delete_directory('{dest}')"}))
|
||||
|
||||
|
||||
# ── exhaustive: every catalog action survives the full chain ───────────────────
|
||||
|
||||
def _all_action_pairs():
|
||||
pairs = []
|
||||
for domain, actions in CATALOG.items():
|
||||
for action in actions:
|
||||
if (domain, action) in _EXCLUDE:
|
||||
continue
|
||||
pairs.append((domain, action))
|
||||
return pairs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("domain,action", _all_action_pairs())
|
||||
def test_every_action_round_trips(domain, action):
|
||||
"""
|
||||
Drive every action through the real chain with empty params and assert we get
|
||||
back an unwrapped dict containing 'success'. This proves routing + TCP +
|
||||
C++ dispatch + result unwrapping work for the action — independent of whether
|
||||
the action *succeeds* with no args (validation failures still return a dict).
|
||||
|
||||
Empty params are safe: execute_action wraps any exception (incl. missing-arg
|
||||
TypeError) as {"success": false, ...}, and ue_* functions validate required
|
||||
params before doing work.
|
||||
"""
|
||||
if domain == "util":
|
||||
r = run(disp.util.fn(action=action, params={}))
|
||||
elif domain == "vision":
|
||||
r = run(disp.vision.fn(action=action, params={}))
|
||||
else:
|
||||
r = run(disp._dispatch(domain, action, {}))
|
||||
assert isinstance(r, dict), f"{domain}.{action} returned non-dict: {r!r}"
|
||||
assert "success" in r, f"chain/unwrap failed for {domain}.{action}: {r!r}"
|
||||
_assert_not_connection_error(r, f"{domain}.{action}")
|
||||
|
||||
|
||||
def test_zzz_editor_survived_suite():
|
||||
"""Last test in the file: the editor must still be alive after the full sweep."""
|
||||
assert _editor_reachable(), "Unreal editor is no longer reachable after the E2E suite (it crashed mid-run)."
|
||||
590
mcp-server/uv.lock
generated
Normal file
590
mcp-server/uv.lock
generated
Normal file
@@ -0,0 +1,590 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/9d/b1e08d36899c12c8b894a44a5583ee157789f26fc4b176f8e4b6217b56e1/authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210", size = 158371, upload-time = "2025-05-23T00:21:45.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/587c189bbab1ccc8c86a03a5d0e13873df916380ef1be461ebe6acebf48d/authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d", size = 239981, upload-time = "2025-05-23T00:21:43.075Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.4.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "1.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "45.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890, upload-time = "2025-06-10T00:03:51.297Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/1c/92637793de053832523b410dbe016d3f5c11b41d0cf6eef8787aabb51d41/cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", size = 7055712, upload-time = "2025-06-10T00:02:38.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335, upload-time = "2025-06-10T00:02:41.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487, upload-time = "2025-06-10T00:02:43.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922, upload-time = "2025-06-10T00:02:45.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433, upload-time = "2025-06-10T00:02:47.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163, upload-time = "2025-06-10T00:02:49.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687, upload-time = "2025-06-10T00:02:50.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623, upload-time = "2025-06-10T00:02:52.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447, upload-time = "2025-06-10T00:02:54.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830, upload-time = "2025-06-10T00:02:56.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d4/994773a261d7ff98034f72c0e8251fe2755eac45e2265db4c866c1c6829c/cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", size = 2932769, upload-time = "2025-06-10T00:02:58.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/42/c80bd0b67e9b769b364963b5252b17778a397cefdd36fa9aa4a5f34c599a/cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", size = 3410441, upload-time = "2025-06-10T00:03:00.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/0b/2488c89f3a30bc821c9d96eeacfcab6ff3accc08a9601ba03339c0fd05e5/cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", size = 7031836, upload-time = "2025-06-10T00:03:01.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746, upload-time = "2025-06-10T00:03:03.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456, upload-time = "2025-06-10T00:03:05.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495, upload-time = "2025-06-10T00:03:09.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540, upload-time = "2025-06-10T00:03:10.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052, upload-time = "2025-06-10T00:03:12.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024, upload-time = "2025-06-10T00:03:13.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442, upload-time = "2025-06-10T00:03:16.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038, upload-time = "2025-06-10T00:03:18.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964, upload-time = "2025-06-10T00:03:20.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/37/1a3cba4c5a468ebf9b95523a5ef5651244693dc712001e276682c278fc00/cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", size = 2924557, upload-time = "2025-06-10T00:03:22.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/4b/3256759723b7e66380397d958ca07c59cfc3fb5c794fb5516758afd05d41/cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", size = 3395508, upload-time = "2025-06-10T00:03:24.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ba/cf442ae99ef363855ed84b39e0fb3c106ac66b7a7703f3c9c9cfe05412cb/cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c", size = 3590512, upload-time = "2025-06-10T00:03:36.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/a7d5bb87d149eb99a5abdc69a41e4e47b8001d767e5f403f78bfaafc7aa7/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4", size = 4146899, upload-time = "2025-06-10T00:03:38.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/11/9361c2c71c42cc5c465cf294c8030e72fb0c87752bacbd7a3675245e3db3/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349", size = 4388900, upload-time = "2025-06-10T00:03:40.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/76/f95b83359012ee0e670da3e41c164a0c256aeedd81886f878911581d852f/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8", size = 4146422, upload-time = "2025-06-10T00:03:41.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/ad/5429fcc4def93e577a5407988f89cf15305e64920203d4ac14601a9dc876/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862", size = 4388475, upload-time = "2025-06-10T00:03:43.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/49/0ab9774f64555a1b50102757811508f5ace451cf5dc0a2d074a4b9deca6a/cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d", size = 3337594, upload-time = "2025-06-10T00:03:45.523Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "2.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "authlib" },
|
||||
{ name = "exceptiongroup" },
|
||||
{ name = "httpx" },
|
||||
{ name = "mcp" },
|
||||
{ name = "openapi-pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/69/8820d3c0e17ed2c7baed3e322191509285fc724c60f9cac5b28037feb5c9/fastmcp-2.7.1.tar.gz", hash = "sha256:489b8480a3e3a96b9eb1847e77f0272b732ad397b2ddad3a25eb185cc99b6c9c", size = 1591616, upload-time = "2025-06-08T01:50:02.349Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/b8/af0bb06d1388b680c64ec7b9767d3718e51e65d91e425c1296446f10a9fc/fastmcp-2.7.1-py3-none-any.whl", hash = "sha256:e75b4c7088338f2532d79f37a2ae654f47bfd7d3d15340233fda25bc168231b6", size = 127618, upload-time = "2025-06-08T01:50:00.945Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "3.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.9.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/df/8fefc0c6c7a5c66914763e3ff3893f9a03435628f6625d5e3b0dc45d73db/mcp-1.9.3.tar.gz", hash = "sha256:587ba38448e81885e5d1b84055cfcc0ca56d35cd0c58f50941cab01109405388", size = 333045, upload-time = "2025-06-05T15:48:25.681Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/45/823ad05504bea55cb0feb7470387f151252127ad5c72f8882e8fe6cf5c0e/mcp-1.9.3-py3-none-any.whl", hash = "sha256:69b0136d1ac9927402ed4cf221d4b8ff875e7132b0b06edd446448766f34f9b9", size = 131063, upload-time = "2025-06-05T15:48:24.171Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openapi-pydantic"
|
||||
version = "0.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232, upload-time = "2025-06-02T17:36:30.03Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "2.3.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/f4/989bc70cb8091eda43a9034ef969b25145291f3601703b82766e5172dfed/sse_starlette-2.3.6.tar.gz", hash = "sha256:0382336f7d4ec30160cf9ca0518962905e1b69b72d6c1c995131e0a703b436e3", size = 18284, upload-time = "2025-05-30T13:34:12.914Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/05/78850ac6e79af5b9508f8841b0f26aa9fd329a1ba00bf65453c2d312bcc8/sse_starlette-2.3.6-py3-none-any.whl", hash = "sha256:d49a8285b182f6e2228e2609c350398b2ca2c36216c2675d875f81e93548f760", size = 10606, upload-time = "2025-05-30T13:34:11.703Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.47.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/d0/0332bd8a25779a0e2082b0e179805ad39afad642938b371ae0882e7f880d/starlette-0.47.0.tar.gz", hash = "sha256:1f64887e94a447fed5f23309fb6890ef23349b7e478faa7b24a851cd4eb844af", size = 2582856, upload-time = "2025-05-29T15:45:27.628Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/81/c60b35fe9674f63b38a8feafc414fca0da378a9dbd5fa1e0b8d23fcc7a9b/starlette-0.47.0-py3-none-any.whl", hash = "sha256:9d052d4933683af40ffd47c7465433570b4949dc937e20ad1d73b34e72f10c37", size = 72796, upload-time = "2025-05-29T15:45:26.305Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unrealmcp"
|
||||
version = "2.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastmcp" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastmcp" },
|
||||
{ name = "pytest", marker = "extra == 'dev'" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.34.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" },
|
||||
]
|
||||
39
mcp-server/validate_tools.py
Normal file
39
mcp-server/validate_tools.py
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Validates that the dispatcher action catalog is in sync with the plugin's
|
||||
ue_* function signatures.
|
||||
|
||||
The catalog (dispatchers/_catalog.py) is auto-generated by generate_catalog.py.
|
||||
This script re-runs the generator in --check mode: if the committed catalog
|
||||
differs from what the current signatures would produce, it fails.
|
||||
|
||||
This catches the bug class where catalog param names drift from the real
|
||||
ue_* signatures (the dispatcher passes params straight through as
|
||||
ue_func(**params), so a name mismatch = runtime failure).
|
||||
|
||||
Usage:
|
||||
python validate_tools.py
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
GENERATOR = Path(__file__).parent / "generate_catalog.py"
|
||||
|
||||
|
||||
def main():
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(GENERATOR), "--check"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
sys.stdout.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user