diff --git a/.gitignore b/.gitignore index 9f02a99..7dffa47 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Config/DefaultEngine.ini b/Config/DefaultEngine.ini index 23c9cfb..0646a97 100644 --- a/Config/DefaultEngine.ini +++ b/Config/DefaultEngine.ini @@ -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 diff --git a/Content/Maps/L_TestFlight.umap b/Content/Maps/L_TestFlight.umap new file mode 100644 index 0000000..a78ea88 --- /dev/null +++ b/Content/Maps/L_TestFlight.umap @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf6564c41ebef8f044ed295e5add2b5a17b111ed18dc41d10da06616192687ef +size 8455 diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/actor_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/actor_actions.py new file mode 100644 index 0000000..cf9c393 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/actor_actions.py @@ -0,0 +1,1065 @@ +# Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved. + +import unreal +import json +import traceback + +ACTOR_ACTIONS_MODULE = "actor_actions" + +# Helper function (consider if it should be private or utility) +def _get_actor_by_label(actor_label: str): + """ + Helper function to find an actor by its label. + Returns the actor or None if not found. + """ + subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + all_actors = subsystem.get_all_level_actors() + for actor in all_actors: + if actor.get_actor_label() == actor_label: + return actor + return None + +def ue_spawn_from_object(asset_path: str = None, location: list = None) -> str: + """ + Spawns an actor from the specified asset path at the given location. + Wrapped in a ScopedEditorTransaction. + + :param asset_path: Path to the asset in the Content Browser + :param location: [x, y, z] coordinates for the actor spawn position + :return: JSON string indicating success or failure and actor label if spawned + """ + if asset_path is None: + return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."}) + if location is None: + return json.dumps({"success": False, "message": "Required parameter 'location' is missing."}) + + transaction_description = "MCP: Spawn Actor from Object" + asset_data = unreal.EditorAssetLibrary.find_asset_data(asset_path) + if not asset_data: + return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"}) + + if len(location) != 3: + return json.dumps({"success": False, "message": "Invalid location format. Expected list of 3 floats."}) + + try: + with unreal.ScopedEditorTransaction(transaction_description) as trans: + vec = unreal.Vector(float(location[0]), float(location[1]), float(location[2])) + asset = unreal.EditorAssetLibrary.load_asset(asset_path) + if not asset: + return json.dumps({"success": False, "message": f"Failed to load asset: {asset_path}"}) + + actor = unreal.get_editor_subsystem(unreal.EditorActorSubsystem).spawn_actor_from_object( + asset, vec + ) + if actor: + return json.dumps({"success": True, "actor_label": actor.get_actor_label(), "actor_path": actor.get_path_name()}) + else: + return json.dumps({"success": False, "message": "Failed to spawn actor. spawn_actor_from_object returned None."}) + except Exception as e: + return json.dumps({"success": False, "message": f"Error during spawn: {str(e)}", "type": e.__name__, "traceback": traceback.format_exc()}) + +def ue_duplicate_selected(offset: list) -> str: + """ + Duplicates all selected actors in the editor and applies a position offset to each duplicate. + + :param offset: [x, y, z] offset to apply to each duplicated actor. + :return: JSON string indicating success or failure and details of duplicated actors. + """ + if len(offset) != 3: + return json.dumps({"success": False, "message": "Invalid offset format. Expected list of 3 floats."}) + + try: + subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + selected_actors = subsystem.get_selected_level_actors() + if not selected_actors: + return json.dumps({"success": False, "message": "No actors selected."}) + + duplicated_actors = [] + for actor in selected_actors: + offset_vector = unreal.Vector(float(offset[0]), float(offset[1]), float(offset[2])) + duplicated_actor = subsystem.duplicate_actor(actor, offset=offset_vector) + if duplicated_actor: + duplicated_actors.append(duplicated_actor.get_actor_label()) + + return json.dumps({ + "success": True, + "message": f"Duplicated {len(duplicated_actors)} actors with offset {offset}.", + "duplicated_actors": duplicated_actors + }) + except Exception as e: + return json.dumps({"success": False, "message": f"Error during duplication: {e}"}) + +def ue_select_all() -> str: + """ + Selects all actors in the current level. + """ + try: + subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + subsystem.select_all(unreal.EditorLevelLibrary.get_editor_world()) + return json.dumps({"success": True, "message": "All actors selected."}) + except Exception as e: + return json.dumps({"success": False, "message": f"Error during selection: {e}"}) + +def ue_invert_selection() -> str: + """ + Inverts the selection of actors in the current level. + """ + try: + subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + subsystem.invert_selection(unreal.EditorLevelLibrary.get_editor_world()) + return json.dumps({"success": True, "message": "Actor selection inverted."}) + except Exception as e: + return json.dumps({"success": False, "message": f"Error during selection inversion: {e}"}) + +def ue_delete_by_label(actor_label: str) -> str: + """ + Deletes an actor with the specified name from the current level. + + :param actor_label: Name of the actor to delete. + :return: JSON string indicating success or failure. + """ + try: + subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + all_actors = subsystem.get_all_level_actors() + deleted_actors = [] + + for actor in all_actors: + if actor.get_actor_label() == actor_label: + if subsystem.destroy_actor(actor): + deleted_actors.append(actor_label) + + if deleted_actors: + return json.dumps({ + "success": True, + "message": f"Deleted actors: {deleted_actors}", + "deleted_actors": deleted_actors + }) + else: + return json.dumps({"success": False, "message": f"No actor found with name: {actor_label}"}) + except Exception as e: + return json.dumps({"success": False, "message": f"Error during actor deletion: {e}"}) + +def ue_list_all_with_locations() -> str: + """ + Lists all actors in the current level along with their world locations. + + :return: JSON string containing actor names and locations. + """ + try: + subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + all_actors = subsystem.get_all_level_actors() + actor_data = [] + + for actor in all_actors: + location = actor.get_actor_location() + actor_data.append({ + "name": actor.get_actor_label(), + "location": [location.x, location.y, location.z] + }) + + return json.dumps({"success": True, "actors": actor_data}) + except Exception as e: + return json.dumps({"success": False, "message": f"Error during actor listing: {str(e)}", "type": e.__name__, "traceback": traceback.format_exc()}) + +def ue_spawn_from_class(class_path: str = None, location: list = None, rotation: list = None) -> str: + """ + Spawns an actor from the specified class path at the given location and rotation + using unreal.EditorLevelLibrary.spawn_actor_from_class. + Wrapped in a ScopedEditorTransaction. + + :param class_path: Path to the actor class (e.g., "/Game/Blueprints/MyActorBP.MyActorBP_C" or "/Script/Engine.StaticMeshActor"). + :param location: [x, y, z] coordinates for the actor spawn position. + :param rotation: Optional [pitch, yaw, roll] for the actor spawn rotation. Defaults to [0.0, 0.0, 0.0]. + :return: JSON string indicating success or failure and actor label/path if spawned. + """ + if class_path is None: + return json.dumps({"success": False, "message": "Required parameter 'class_path' is missing."}) + if location is None: + return json.dumps({"success": False, "message": "Required parameter 'location' is missing."}) + + transaction_description = "MCP: Spawn Actor from Class (EditorLevelLibrary)" + if rotation is None: + rotation = [0.0, 0.0, 0.0] + + if len(location) != 3: + return json.dumps({"success": False, "message": "Invalid location format. Expected list of 3 floats."}) + if len(rotation) != 3: + return json.dumps({"success": False, "message": "Invalid rotation format. Expected list of 3 floats."}) + + try: + with unreal.ScopedEditorTransaction(transaction_description) as trans: + actor_class = unreal.load_class(None, class_path) + + if not actor_class: + return json.dumps({"success": False, "message": f"Failed to load actor class from path: {class_path}. Ensure it's a valid class path (e.g., with _C for Blueprints or /Script/ for native classes)."}) + + vec_location = unreal.Vector(float(location[0]), float(location[1]), float(location[2])) + rot_rotation = unreal.Rotator(float(rotation[2]), float(rotation[0]), float(rotation[1])) + + actor = unreal.EditorLevelLibrary.spawn_actor_from_class( + actor_class, + vec_location, + rot_rotation + ) + + if actor: + return json.dumps({ + "success": True, + "actor_label": actor.get_actor_label(), + "actor_path": actor.get_path_name() + }) + else: + return json.dumps({"success": False, "message": "Failed to spawn actor using EditorLevelLibrary.spawn_actor_from_class. The function returned None."}) + except Exception as e: + return json.dumps({"success": False, "message": f"Error during spawn_actor_from_class (EditorLevelLibrary): {str(e)}", "type": e.__name__, "traceback": traceback.format_exc()}) + +def ue_get_all_details() -> str: + """ + Lists all actors in the current level with detailed information including + label, class, location, rotation, and world-space bounding box. + + :return: JSON string containing a list of actor details. + """ + try: + subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + all_actors = subsystem.get_all_level_actors() + actors_details = [] + + for actor in all_actors: + loc = actor.get_actor_location() + rot = actor.get_actor_rotation() + + bounds_origin, bounds_extent = actor.get_actor_bounds(False) + + detail = { + "label": actor.get_actor_label(), + "class": actor.get_class().get_path_name(), + "location": [loc.x, loc.y, loc.z], + "rotation": [rot.pitch, rot.yaw, rot.roll], + "world_bounds_origin": [bounds_origin.x, bounds_origin.y, bounds_origin.z], + "world_bounds_extent": [bounds_extent.x, bounds_extent.y, bounds_extent.z], + "world_dimensions": [bounds_extent.x * 2, bounds_extent.y * 2, bounds_extent.z * 2] + } + + if isinstance(actor, unreal.StaticMeshActor): + sm_component = actor.static_mesh_component + if hasattr(actor, 'get_static_mesh_component'): + sm_component = actor.get_static_mesh_component() + + if sm_component and sm_component.static_mesh: + detail["static_mesh_asset_path"] = sm_component.static_mesh.get_path_name() + + actors_details.append(detail) + + return json.dumps({"success": True, "actors": actors_details}) + except Exception as e: + return json.dumps({"success": False, "message": f"Error listing all actors details: {str(e)}", "type": e.__name__, "traceback": traceback.format_exc()}) + +def ue_set_transform(actor_label: str = None, location: list = None, rotation: list = None, scale: list = None) -> str: + """ + Sets the transform (location, rotation, scale) of a specified actor. + Any component of the transform not provided will remain unchanged. + This operation is wrapped in a ScopedEditorTransaction. + """ + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + + transaction_description = f"MCP: Set Transform for actor {actor_label}" + try: + actor_to_modify = _get_actor_by_label(actor_label) + if not actor_to_modify: + return json.dumps({"success": False, "message": f"Actor with label \'{actor_label}\' not found."}) + + with unreal.ScopedEditorTransaction(transaction_description) as trans: + modified_properties = [] + if location is not None: + if len(location) == 3: + new_loc = unreal.Vector(float(location[0]), float(location[1]), float(location[2])) + actor_to_modify.set_actor_location(new_loc, False, False) # bSweep, bTeleport + modified_properties.append("location") + else: + return json.dumps({"success": False, "message": "Invalid location format. Expected list of 3 floats."}) + + if rotation is not None: + if len(rotation) == 3: + new_rot = unreal.Rotator(float(rotation[2]), float(rotation[0]), float(rotation[1])) + actor_to_modify.set_actor_rotation(new_rot, False) # bTeleport + modified_properties.append("rotation") + else: + return json.dumps({"success": False, "message": "Invalid rotation format. Expected list of 3 floats."}) + + if scale is not None: + if len(scale) == 3: + new_scale = unreal.Vector(float(scale[0]), float(scale[1]), float(scale[2])) + actor_to_modify.set_actor_scale3d(new_scale) + modified_properties.append("scale") + else: + return json.dumps({"success": False, "message": "Invalid scale format. Expected list of 3 floats."}) + + if not modified_properties: + return json.dumps({"success": True, "message": f"No transform properties provided for actor \'{actor_label}\'. Actor was not modified."}) + + return json.dumps({"success": True, "message": f"Actor \'{actor_label}\' transform updated for: {', '.join(modified_properties)}."}) + + except Exception as e: + return json.dumps({"success": False, "message": f"Error setting transform for actor \'{actor_label}\': {str(e)}", "type": e.__name__, "traceback": traceback.format_exc()}) + +def ue_set_location(actor_label: str = None, location: list = None) -> str: + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + if location is None: + return json.dumps({"success": False, "message": "Required parameter 'location' is missing."}) + return ue_set_transform(actor_label=actor_label, location=location) + +def ue_set_rotation(actor_label: str = None, rotation: list = None) -> str: + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + if rotation is None: + return json.dumps({"success": False, "message": "Required parameter 'rotation' is missing."}) + return ue_set_transform(actor_label=actor_label, rotation=rotation) + +def ue_set_scale(actor_label: str = None, scale: list = None) -> str: + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + if scale is None: + return json.dumps({"success": False, "message": "Required parameter 'scale' is missing."}) + return ue_set_transform(actor_label=actor_label, scale=scale) + +def ue_line_trace( + ray_start: list = None, + ray_end: list = None, + trace_channel: str = 'Visibility', + actors_to_ignore_labels: list = None, + trace_complex: bool = True +) -> str: + """ + Performs a line trace (raycast) and returns hit information without spawning anything. + + :param ray_start: [x, y, z] start of the ray. + :param ray_end: [x, y, z] end of the ray. + :param trace_channel: 'Visibility' or 'Camera'. Defaults to 'Visibility'. + :param actors_to_ignore_labels: Optional list of actor labels to ignore. + :param trace_complex: Whether to use complex collision. Defaults to True. + :return: JSON string with hit details. + """ + if ray_start is None: + return json.dumps({"success": False, "message": "Required parameter 'ray_start' is missing."}) + if ray_end is None: + return json.dumps({"success": False, "message": "Required parameter 'ray_end' is missing."}) + + if len(ray_start) != 3 or len(ray_end) != 3: + return json.dumps({"success": False, "message": "Invalid vector format. Expected lists of 3 floats."}) + + try: + start_loc = unreal.Vector(float(ray_start[0]), float(ray_start[1]), float(ray_start[2])) + end_loc = unreal.Vector(float(ray_end[0]), float(ray_end[1]), float(ray_end[2])) + + actors_to_ignore_objects = [] + if actors_to_ignore_labels: + for label in actors_to_ignore_labels: + actor = _get_actor_by_label(label) + if actor: + actors_to_ignore_objects.append(actor) + + trace_type_query = unreal.TraceTypeQuery.TRACE_TYPE_QUERY1 + if trace_channel.lower() == 'camera': + trace_type_query = unreal.TraceTypeQuery.TRACE_TYPE_QUERY2 + + hit_result = unreal.SystemLibrary.line_trace_single( + world_context_object=unreal.EditorLevelLibrary.get_editor_world(), + start=start_loc, + end=end_loc, + trace_channel=trace_type_query, + trace_complex=trace_complex, + actors_to_ignore=actors_to_ignore_objects, + draw_debug_type=unreal.DrawDebugTrace.FOR_DURATION, + ignore_self=True + ) + + if not hit_result: + return json.dumps({"success": True, "hit": False, "message": "Raycast did not hit any surface."}) + + ( + blocking_hit, + initial_overlap, + time, + distance, + location, + impact_point, + normal, + impact_normal, + phys_mat, + hit_actor, + hit_component, + hit_bone_name, + bone_name, + hit_item, + element_index, + face_index, + trace_start, + trace_end + ) = hit_result.to_tuple() + + if not blocking_hit: + return json.dumps({"success": True, "hit": False, "message": "Raycast did not hit any blocking surface."}) + + result = { + "success": True, + "hit": True, + "location": [location.x, location.y, location.z], + "impact_point": [impact_point.x, impact_point.y, impact_point.z], + "normal": [normal.x, normal.y, normal.z], + "impact_normal": [impact_normal.x, impact_normal.y, impact_normal.z], + "distance": distance, + "hit_actor_label": hit_actor.get_actor_label() if hit_actor else None, + "hit_bone_name": str(hit_bone_name) if hit_bone_name and str(hit_bone_name) != "None" else None, + } + return json.dumps(result) + + except Exception as e: + return json.dumps({"success": False, "message": f"Error during line_trace: {str(e)}", "traceback": traceback.format_exc()}) + +def ue_spawn_on_surface_raycast( + asset_or_class_path: str = None, + ray_start: list = None, + ray_end: list = None, + is_class_path: bool = True, + desired_rotation: list = None, + location_offset: list = None, # New parameter + trace_channel: str = 'Visibility', + actors_to_ignore_labels: list = None +) -> str: + if asset_or_class_path is None: + return json.dumps({"success": False, "message": "Required parameter 'asset_or_class_path' is missing."}) + if ray_start is None: + return json.dumps({"success": False, "message": "Required parameter 'ray_start' is missing."}) + if ray_end is None: + return json.dumps({"success": False, "message": "Required parameter 'ray_end' is missing."}) + + transaction_description = f"MCP: Spawn Actor on Surface via Raycast ({asset_or_class_path})" + + if desired_rotation is None: + desired_rotation = [0.0, 0.0, 0.0] + if location_offset is None: # Default offset + location_offset = [0.0, 0.0, 0.0] + + if len(ray_start) != 3 or len(ray_end) != 3 or len(desired_rotation) != 3 or len(location_offset) != 3: + return json.dumps({"success": False, "message": "Invalid vector/rotator/offset format. Expected lists of 3 floats."}) + + try: + start_loc = unreal.Vector(float(ray_start[0]), float(ray_start[1]), float(ray_start[2])) + end_loc = unreal.Vector(float(ray_end[0]), float(ray_end[1]), float(ray_end[2])) + + actors_to_ignore_objects = [] + if actors_to_ignore_labels: + for label in actors_to_ignore_labels: + actor = _get_actor_by_label(label) + if actor: + actors_to_ignore_objects.append(actor) + + trace_type_query = unreal.TraceTypeQuery.TRACE_TYPE_QUERY1 + if trace_channel.lower() == 'camera': + trace_type_query = unreal.TraceTypeQuery.TRACE_TYPE_QUERY2 + + hit_result = unreal.SystemLibrary.line_trace_single( + world_context_object=unreal.EditorLevelLibrary.get_editor_world(), + start=start_loc, + end=end_loc, + trace_channel=trace_type_query, + trace_complex=True, + actors_to_ignore=actors_to_ignore_objects, + draw_debug_type=unreal.DrawDebugTrace.FOR_DURATION, + ignore_self=True + ) + + if not hit_result: + return json.dumps({"success": False, "message": "Raycast did not hit any surface."}) + + ( + blocking_hit, + initial_overlap, + time, + distance, + location, + impact_point, + normal, + impact_normal, + phys_mat, + hit_actor, + hit_component, + hit_bone_name, + bone_name, + hit_item, + element_index, + face_index, + trace_start, + trace_end + ) = hit_result.to_tuple() + + if not blocking_hit: + return json.dumps({"success": False, "message": "Raycast did not hit any blocking surface."}) + + spawn_location = location + # Apply location offset + spawn_location.x += float(location_offset[0]) + spawn_location.y += float(location_offset[1]) + spawn_location.z += float(location_offset[2]) + + # Apply rotation final + spawn_rotation_final = unreal.Rotator(float(desired_rotation[2]), float(desired_rotation[0]), float(desired_rotation[1])) + + with unreal.ScopedEditorTransaction(transaction_description) as trans: + actor_spawned = None + if is_class_path: + actor_class = unreal.load_class(None, asset_or_class_path) + if not actor_class: + return json.dumps({"success": False, "message": f"Failed to load actor class: {asset_or_class_path}"}) + actor_spawned = unreal.EditorLevelLibrary.spawn_actor_from_class(actor_class, spawn_location, spawn_rotation_final) + else: + asset = unreal.EditorAssetLibrary.load_asset(asset_or_class_path) + if not asset: + return json.dumps({"success": False, "message": f"Failed to load asset: {asset_or_class_path}"}) + actor_spawned = unreal.get_editor_subsystem(unreal.EditorActorSubsystem).spawn_actor_from_object(asset, spawn_location) + + if actor_spawned: + return json.dumps({ + "success": True, + "actor_label": actor_spawned.get_actor_label(), + "actor_path": actor_spawned.get_path_name(), + "location": [spawn_location.x, spawn_location.y, spawn_location.z], + "rotation": [spawn_rotation_final.pitch, spawn_rotation_final.yaw, spawn_rotation_final.roll], + }) + else: + return json.dumps({"success": False, "message": "Failed to spawn actor after raycast hit."}) + + except Exception as e: + return json.dumps({"success": False, "message": f"Error during spawn_actor_on_surface_with_raycast: {str(e)}", "traceback": traceback.format_exc()}) + +def _serialize_ue_value(value): + """Convert an Unreal Engine 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.LinearColor): + return [value.r, value.g, value.b, value.a] + if isinstance(value, unreal.Name): + return str(value) + if isinstance(value, unreal.Text): + return str(value) + # Fallback for enums and other types + return str(value) + +def _convert_value_for_property(current_value, new_value): + """Convert a JSON value to the appropriate Unreal type based on the current property value's type.""" + if isinstance(current_value, unreal.Vector): + if isinstance(new_value, (list, tuple)) and len(new_value) == 3: + return unreal.Vector(float(new_value[0]), float(new_value[1]), float(new_value[2])) + elif isinstance(current_value, unreal.Rotator): + if isinstance(new_value, (list, tuple)) and len(new_value) == 3: + return unreal.Rotator(float(new_value[0]), float(new_value[1]), float(new_value[2])) + elif isinstance(current_value, unreal.LinearColor): + if isinstance(new_value, (list, tuple)) and len(new_value) == 4: + return unreal.LinearColor(float(new_value[0]), float(new_value[1]), float(new_value[2]), float(new_value[3])) + elif isinstance(current_value, unreal.Name): + return unreal.Name(str(new_value)) + elif isinstance(current_value, bool): + return bool(new_value) + elif isinstance(current_value, int): + return int(new_value) + elif isinstance(current_value, float): + return float(new_value) + elif isinstance(current_value, str): + return str(new_value) + # Fallback: return as-is and let UE attempt conversion + return new_value + +def ue_get_property(actor_label: str = None, property_name: str = None) -> str: + """ + Gets a property value from an actor using get_editor_property(). + + :param actor_label: Label of the actor to query. + :param property_name: UE property name to get. + :return: JSON string with the property value. + """ + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + if property_name is None: + return json.dumps({"success": False, "message": "Required parameter 'property_name' is missing."}) + + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor with label '{actor_label}' not found."}) + + value = actor.get_editor_property(property_name) + serialized = _serialize_ue_value(value) + + result = {"success": True, "property_name": property_name, "value": serialized} + # Add type hint when fallback string conversion was used + if not isinstance(value, (type(None), bool, int, float, str)) and isinstance(serialized, str): + if not isinstance(value, (unreal.Vector, unreal.Rotator, unreal.LinearColor, unreal.Name, unreal.Text)): + result["value_type"] = type(value).__name__ + return json.dumps(result) + except Exception as e: + return json.dumps({"success": False, "message": f"Error getting property '{property_name}' on actor '{actor_label}': {str(e)}", "type": type(e).__name__, "traceback": traceback.format_exc()}) + +def ue_set_property(actor_label: str = None, property_name: str = None, value=None) -> str: + """ + Sets a property value on an actor using set_editor_property(). + Wrapped in a ScopedEditorTransaction for Undo support. + + :param actor_label: Label of the actor to modify. + :param property_name: UE property name to set. + :param value: The value to set (str, int, float, bool, list, or None). + :return: JSON string indicating success or failure. + """ + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + if property_name is None: + return json.dumps({"success": False, "message": "Required parameter 'property_name' is missing."}) + + transaction_description = f"MCP: Set Property '{property_name}' on actor '{actor_label}'" + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor with label '{actor_label}' not found."}) + + # Try to read current value to determine the target type + try: + current_value = actor.get_editor_property(property_name) + converted_value = _convert_value_for_property(current_value, value) + except Exception: + # If we can't read the current value, pass the raw value through + converted_value = value + + with unreal.ScopedEditorTransaction(transaction_description) as trans: + actor.set_editor_property(property_name, converted_value) + + return json.dumps({"success": True, "message": f"Property '{property_name}' set on actor '{actor_label}'."}) + except Exception as e: + return json.dumps({"success": False, "message": f"Error setting property '{property_name}' on actor '{actor_label}': {str(e)}", "type": type(e).__name__, "traceback": traceback.format_exc()}) + +def ue_get_in_view_frustum() -> str: + """ + Retrieves a list of actors that are potentially visible within the active editor viewport's frustum. + + This function performs an *approximate* frustum check using a sphere-cone intersection test: + - Actors are represented by their bounding spheres. + - The view frustum is approximated as a cone based on the camera's vertical FOV. + - Camera location and rotation are fetched using `unreal.UnrealEditorSubsystem().get_level_viewport_camera_info()`. + - FOV, Aspect ratio, near plane, and far plane are based on defaults as they cannot be reliably queried + through the subsystem directly. This means actors visible in the horizontal periphery of a wide viewport + or very close/far might be misclassified. + + :return: JSON string containing a list of potentially visible actor details or an error message. + """ + try: + cam_loc = None + cam_rot = None + # Default values. FOV is not returned by UnrealEditorSubsystem.get_level_viewport_camera_info() + v_fov_degrees = 60.0 # Default Vertical FOV + aspect_ratio = 16.0 / 9.0 # Default aspect ratio + near_plane = 10.0 # Default near clip plane + far_plane = 100000.0 # Default far clip plane + + # Get core camera info using UnrealEditorSubsystem + try: + editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem) + if not editor_subsystem: + return json.dumps({"success": False, "message": "Failed to get UnrealEditorSubsystem."}) + + camera_info = editor_subsystem.get_level_viewport_camera_info() + if camera_info: + cam_loc, cam_rot = camera_info + else: + return json.dumps({"success": False, "message": "Failed to obtain camera info from UnrealEditorSubsystem."}) + + except Exception as e: + return json.dumps({"success": False, "message": f"Failed to obtain essential camera info from UnrealEditorSubsystem: {e}"}) + + if cam_loc is None or cam_rot is None: + return json.dumps({"success": False, "message": "Failed to obtain essential camera location and rotation from UnrealEditorSubsystem."}) + + actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + all_actors = actor_subsystem.get_all_level_actors() + visible_actors_details = [] + + cam_forward_vec = cam_rot.get_forward_vector() + v_fov_rad = unreal.MathLibrary.degrees_to_radians(v_fov_degrees) + + for actor in all_actors: + if not actor: continue + + bounds_origin, bounds_extent = actor.get_actor_bounds(False) + actor_bounding_radius = bounds_extent.length() + + vec_to_actor_center = bounds_origin - cam_loc + dist_to_actor_center = vec_to_actor_center.length() + + sphere_closest_to_cam = dist_to_actor_center - actor_bounding_radius + sphere_farthest_from_cam = dist_to_actor_center + actor_bounding_radius + + if sphere_farthest_from_cam < near_plane or sphere_closest_to_cam > far_plane: + continue + + if dist_to_actor_center <= actor_bounding_radius: + pass + elif dist_to_actor_center > 0: + vec_to_actor_center_normalized = vec_to_actor_center.normal() + dot_product = cam_forward_vec.dot(vec_to_actor_center_normalized) + dot_product_clamped = unreal.MathLibrary.clamp(dot_product, -1.0, 1.0) + angle_to_actor_center_rad = unreal.MathLibrary.acos(dot_product_clamped) + + if dist_to_actor_center > actor_bounding_radius: + asin_arg = unreal.MathLibrary.clamp(actor_bounding_radius / dist_to_actor_center, -1.0, 1.0) + angular_radius_of_sphere_rad = unreal.MathLibrary.asin(asin_arg) + else: + angular_radius_of_sphere_rad = unreal.MathLibrary.PI + + if angle_to_actor_center_rad > (v_fov_rad / 2.0) + angular_radius_of_sphere_rad: + continue + else: + pass + + loc = actor.get_actor_location() + rot = actor.get_actor_rotation() + actor_details_dict = { + "label": actor.get_actor_label(), + "class": actor.get_class().get_path_name(), + "location": [loc.x, loc.y, loc.z], + "rotation": [rot.pitch, rot.yaw, rot.roll], + "world_bounds_origin": [bounds_origin.x, bounds_origin.y, bounds_origin.z], + "world_bounds_extent": [bounds_extent.x, bounds_extent.y, bounds_extent.z] + } + if isinstance(actor, unreal.StaticMeshActor): + sm_component = actor.static_mesh_component + if sm_component and sm_component.static_mesh: + actor_details_dict["static_mesh_asset_path"] = sm_component.static_mesh.get_path_name() + + visible_actors_details.append(actor_details_dict) + + return json.dumps({"success": True, "visible_actors": visible_actors_details}) + + except Exception as e: + return json.dumps({"success": False, "message": f"Error in ue_get_in_view_frustum: {str(e)}", "type": type(e).__name__}) + + +# --- Actor hierarchy / folders / tags / components ---------------------------- + +def ue_attach_actor(child_label: str = None, parent_label: str = None, socket_name: str = "") -> str: + """Attaches one actor to another (keeps world transform). Optional socket on the parent.""" + if child_label is None or parent_label is None: + return json.dumps({"success": False, "message": "Required parameters: child_label, parent_label."}) + try: + child = _get_actor_by_label(child_label) + parent = _get_actor_by_label(parent_label) + if not child: + return json.dumps({"success": False, "message": f"Child actor not found: {child_label}"}) + if not parent: + return json.dumps({"success": False, "message": f"Parent actor not found: {parent_label}"}) + rule = unreal.AttachmentRule.KEEP_WORLD + child.attach_to_actor(parent, unreal.Name(socket_name), rule, rule, rule, False) + return json.dumps({"success": True, "child": child_label, "parent": parent_label}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_detach_actor(actor_label: str = None) -> str: + """Detaches an actor from its parent (keeps world transform).""" + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + rule = unreal.DetachmentRule.KEEP_WORLD + actor.detach_from_actor(rule, rule, rule) + return json.dumps({"success": True, "actor_label": actor_label}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_get_attached_actors(actor_label: str = None) -> str: + """Lists the labels of actors attached to the given actor.""" + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + children = [a.get_actor_label() for a in actor.get_attached_actors()] + parent = actor.get_attach_parent_actor() + return json.dumps({"success": True, "actor_label": actor_label, + "attached": children, + "parent": parent.get_actor_label() if parent else None}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_set_actor_folder(actor_label: str = None, folder_path: str = None) -> str: + """Sets the World Outliner folder path of an actor.""" + if actor_label is None or folder_path is None: + return json.dumps({"success": False, "message": "Required parameters: actor_label, folder_path."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + actor.set_folder_path(unreal.Name(folder_path)) + return json.dumps({"success": True, "actor_label": actor_label, "folder_path": folder_path}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_get_actor_folder(actor_label: str = None) -> str: + """Returns the World Outliner folder path of an actor.""" + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + return json.dumps({"success": True, "actor_label": actor_label, + "folder_path": str(actor.get_folder_path())}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_list_actor_components(actor_label: str = None) -> str: + """Lists the components on an actor (name + class).""" + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + comps = [{"name": c.get_name(), "class": c.get_class().get_name()} + for c in actor.get_components_by_class(unreal.ActorComponent)] + return json.dumps({"success": True, "actor_label": actor_label, + "count": len(comps), "components": comps}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_get_actor_tags(actor_label: str = None) -> str: + """Returns the gameplay tags (Actor.Tags) of an actor.""" + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + return json.dumps({"success": True, "actor_label": actor_label, + "tags": [str(t) for t in actor.tags]}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_add_actor_tag(actor_label: str = None, tag: str = None) -> str: + """Adds a tag to an actor (Actor.Tags).""" + if actor_label is None or tag is None: + return json.dumps({"success": False, "message": "Required parameters: actor_label, tag."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + tags = list(actor.tags) + if unreal.Name(tag) not in tags: + tags.append(unreal.Name(tag)) + actor.tags = tags + return json.dumps({"success": True, "actor_label": actor_label, + "tags": [str(t) for t in actor.tags]}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_remove_actor_tag(actor_label: str = None, tag: str = None) -> str: + """Removes a tag from an actor (Actor.Tags).""" + if actor_label is None or tag is None: + return json.dumps({"success": False, "message": "Required parameters: actor_label, tag."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + tags = [t for t in actor.tags if str(t) != tag] + actor.tags = tags + return json.dumps({"success": True, "actor_label": actor_label, + "tags": [str(t) for t in actor.tags]}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_get_actor_bounds(actor_label: str = None) -> str: + """Returns an actor's world-space bounds (origin + box extent).""" + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + origin, extent = actor.get_actor_bounds(False) + return json.dumps({ + "success": True, "actor_label": actor_label, + "origin": [round(origin.x, 3), round(origin.y, 3), round(origin.z, 3)], + "extent": [round(extent.x, 3), round(extent.y, 3), round(extent.z, 3)], + }) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_get_actors_of_class(class_path: str = None) -> str: + """Lists labels of level actors of the given class path (e.g. '/Script/Engine.PointLight').""" + 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}"}) + world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world() + actors = unreal.GameplayStatics.get_all_actors_of_class(world, cls) + return json.dumps({"success": True, "class_path": class_path, + "count": len(actors), "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()}) + + +def ue_get_selected_actors() -> str: + """Lists the currently selected level actors (label + class).""" + try: + sel = unreal.get_editor_subsystem(unreal.EditorActorSubsystem).get_selected_level_actors() + return json.dumps({"success": True, "count": len(sel), + "actors": [{"label": a.get_actor_label(), "class": a.get_class().get_name()} for a in sel]}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_rename_actor(actor_label: str = None, new_label: str = None) -> str: + """Renames an actor (changes its World Outliner label).""" + if actor_label is None or new_label is None: + return json.dumps({"success": False, "message": "Required parameters: actor_label, new_label."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + actor.set_actor_label(new_label) + return json.dumps({"success": True, "old_label": actor_label, "new_label": actor.get_actor_label()}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_set_actor_hidden(actor_label: str = None, hidden: bool = None) -> str: + """Shows/hides an actor in the editor viewport (temporary editor visibility).""" + if actor_label is None or hidden is None: + return json.dumps({"success": False, "message": "Required parameters: actor_label, hidden."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + actor.set_is_temporarily_hidden_in_editor(bool(hidden)) + return json.dumps({"success": True, "actor_label": actor_label, "hidden": bool(hidden)}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_select_actors(actor_labels: list = None) -> str: + """Selects the given actors by label in the editor (replaces current selection).""" + if actor_labels is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_labels' is missing."}) + try: + sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + found, missing = [], [] + for label in actor_labels: + a = _get_actor_by_label(label) + (found.append(a) if a else missing.append(label)) + sub.set_selected_level_actors(found) + return json.dumps({"success": True, "selected": [a.get_actor_label() for a in found], "missing": missing}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_get_transform(actor_label: str = None) -> str: + """Returns an actor's world location, rotation, and scale.""" + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + loc = actor.get_actor_location() + rot = actor.get_actor_rotation() + scale = actor.get_actor_scale3d() + return json.dumps({ + "success": True, "actor_label": actor_label, + "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)], + "scale": [round(scale.x, 3), round(scale.y, 3), round(scale.z, 3)], + }) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def _find_component_on_actor(actor, component_name): + for c in actor.get_components_by_class(unreal.ActorComponent): + if c.get_name() == component_name: + return c + return None + + +def ue_get_component_property(actor_label: str = None, component_name: str = None, property_name: str = None) -> str: + """Reads a property on a named component of a live level actor.""" + if actor_label is None or component_name is None or property_name is None: + return json.dumps({"success": False, "message": "Required parameters: actor_label, component_name, property_name."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + comp = _find_component_on_actor(actor, component_name) + if not comp: + return json.dumps({"success": False, "message": f"Component '{component_name}' not found on '{actor_label}'."}) + value = _serialize_ue_value(comp.get_editor_property(property_name)) + return json.dumps({"success": True, "actor_label": actor_label, "component": component_name, + "property": property_name, "value": value}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_set_component_property(actor_label: str = None, component_name: str = None, + property_name: str = None, value=None) -> str: + """Sets a property on a named component of a live level actor (e.g. PointLightComponent 'intensity').""" + if actor_label is None or component_name is None or property_name is None: + return json.dumps({"success": False, "message": "Required parameters: actor_label, component_name, property_name."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + comp = _find_component_on_actor(actor, component_name) + if not comp: + return json.dumps({"success": False, "message": f"Component '{component_name}' not found on '{actor_label}'."}) + current = comp.get_editor_property(property_name) + comp.set_editor_property(property_name, _convert_value_for_property(current, value)) + return json.dumps({"success": True, "actor_label": actor_label, "component": component_name, + "property": property_name, "value": _serialize_ue_value(comp.get_editor_property(property_name))}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) + + +def ue_duplicate_actor(actor_label: str = None, offset: list = None) -> str: + """Duplicates a specific actor (by label) with an optional [x,y,z] offset.""" + if actor_label is None: + return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."}) + try: + actor = _get_actor_by_label(actor_label) + if not actor: + return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"}) + off = offset or [0.0, 0.0, 0.0] + if len(off) != 3: + return json.dumps({"success": False, "message": "offset must be a list of 3 floats."}) + sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + dup = sub.duplicate_actor(actor, offset=unreal.Vector(float(off[0]), float(off[1]), float(off[2]))) + if not dup: + return json.dumps({"success": False, "message": "Duplication failed."}) + return json.dumps({"success": True, "source": actor_label, "duplicated": dup.get_actor_label()}) + except Exception as e: + return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/anim_blueprint_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/anim_blueprint_actions.py new file mode 100644 index 0000000..7cc0264 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/anim_blueprint_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/animation_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/animation_actions.py new file mode 100644 index 0000000..c7856b6 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/animation_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/asset_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/asset_actions.py new file mode 100644 index 0000000..d28181e --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/asset_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/behavior_tree_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/behavior_tree_actions.py new file mode 100644 index 0000000..0d28c29 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/behavior_tree_actions.py @@ -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}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/blueprint_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/blueprint_actions.py new file mode 100644 index 0000000..e2b4d4b --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/blueprint_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/control_rig_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/control_rig_actions.py new file mode 100644 index 0000000..21db20a --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/control_rig_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/data_table_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/data_table_actions.py new file mode 100644 index 0000000..058c2fa --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/data_table_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/editor_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/editor_actions.py new file mode 100644 index 0000000..5ceda7a --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/editor_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/game_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/game_actions.py new file mode 100644 index 0000000..f1a00bc --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/game_actions.py @@ -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}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/gas_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/gas_actions.py new file mode 100644 index 0000000..7e9a3af --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/gas_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/layer_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/layer_actions.py new file mode 100644 index 0000000..dcc6013 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/layer_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/level_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/level_actions.py new file mode 100644 index 0000000..3383e3a --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/level_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/level_sequence_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/level_sequence_actions.py new file mode 100644 index 0000000..17b8086 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/level_sequence_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/material_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/material_actions.py new file mode 100644 index 0000000..f785124 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/material_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/mcp_unreal_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/mcp_unreal_actions.py new file mode 100644 index 0000000..ec52c23 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/mcp_unreal_actions.py @@ -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 diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/retarget_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/retarget_actions.py new file mode 100644 index 0000000..192ffd0 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/retarget_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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/static_mesh_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/static_mesh_actions.py new file mode 100644 index 0000000..240afc0 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/static_mesh_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/test_umg_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/test_umg_actions.py new file mode 100644 index 0000000..3965b85 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/test_umg_actions.py @@ -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 ===") diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/__init__.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/base.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/base.py new file mode 100644 index 0000000..7159b48 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/base.py @@ -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) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/run_all.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/run_all.py new file mode 100644 index 0000000..dab8019 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/run_all.py @@ -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) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_actor.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_actor.py new file mode 100644 index 0000000..f64d295 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_actor.py @@ -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")) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_anim_blueprint.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_anim_blueprint.py new file mode 100644 index 0000000..59de331 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_anim_blueprint.py @@ -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")) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_animation.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_animation.py new file mode 100644 index 0000000..a873f44 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_animation.py @@ -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) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_asset.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_asset.py new file mode 100644 index 0000000..49907a7 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_asset.py @@ -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"]) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_behavior_tree.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_behavior_tree.py new file mode 100644 index 0000000..fa629ee --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_behavior_tree.py @@ -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) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_blueprint.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_blueprint.py new file mode 100644 index 0000000..a57895d --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_blueprint.py @@ -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")) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_control_rig.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_control_rig.py new file mode 100644 index 0000000..3a81010 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_control_rig.py @@ -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")) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_data_table.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_data_table.py new file mode 100644 index 0000000..3941e5e --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_data_table.py @@ -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) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_editor.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_editor.py new file mode 100644 index 0000000..f4a3b32 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_editor.py @@ -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")) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_game.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_game.py new file mode 100644 index 0000000..20e8ed8 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_game.py @@ -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 diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_gas.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_gas.py new file mode 100644 index 0000000..f8ef957 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_gas.py @@ -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")) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_layer.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_layer.py new file mode 100644 index 0000000..781fee0 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_layer.py @@ -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")) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_level.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_level.py new file mode 100644 index 0000000..f07a5c7 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_level.py @@ -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). diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_level_sequence.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_level_sequence.py new file mode 100644 index 0000000..1177cd4 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_level_sequence.py @@ -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")) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_material.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_material.py new file mode 100644 index 0000000..f7c7c12 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_material.py @@ -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) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_retarget.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_retarget.py new file mode 100644 index 0000000..1fec859 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_retarget.py @@ -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")) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_static_mesh.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_static_mesh.py new file mode 100644 index 0000000..9a3c215 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_static_mesh.py @@ -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) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_texture.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_texture.py new file mode 100644 index 0000000..fda4029 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_texture.py @@ -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")) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_umg.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_umg.py new file mode 100644 index 0000000..73c355a --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_umg.py @@ -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")) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_util.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_util.py new file mode 100644 index 0000000..a960831 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_util.py @@ -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")) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_vision.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_vision.py new file mode 100644 index 0000000..119b9a1 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/tests/test_vision.py @@ -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) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/texture_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/texture_actions.py new file mode 100644 index 0000000..011c029 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/texture_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/umg_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/umg_actions.py new file mode 100644 index 0000000..0e2f4c3 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/umg_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/util_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/util_actions.py new file mode 100644 index 0000000..d999cbd --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/util_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/vision_actions.py b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/vision_actions.py new file mode 100644 index 0000000..027c5f3 --- /dev/null +++ b/Plugins/UnrealMCPython/Content/Python/UnrealMCPython/vision_actions.py @@ -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()}) diff --git a/Plugins/UnrealMCPython/Resources/Icon128.png b/Plugins/UnrealMCPython/Resources/Icon128.png new file mode 100644 index 0000000..26245f6 --- /dev/null +++ b/Plugins/UnrealMCPython/Resources/Icon128.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7239efaeefbd82de33ebe18518e50de075ea4188a468a9e4991396433d2275f +size 12699 diff --git a/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelper.cpp b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelper.cpp new file mode 100644 index 0000000..faccbd4 --- /dev/null +++ b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelper.cpp @@ -0,0 +1,1663 @@ +// Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved. + +#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" + +TArray UMCPythonHelper::GetAllEditedAssets() +{ + if (!GEditor) return {}; + return GEditor->GetEditorSubsystem()->GetAllEditedAssets(); +} + +TArray UMCPythonHelper::GetSelectedBlueprintNodes() +{ + TArray Result; + if (!GEditor) return Result; + auto* Subsystem = GEditor->GetEditorSubsystem(); + for (UObject* Asset : Subsystem->GetAllEditedAssets()) + { + IAssetEditorInstance* AssetEditorInstance = Subsystem->FindEditorForAsset(Asset, false); + FAssetEditorToolkit* AssetEditorToolkit = static_cast(AssetEditorInstance); + if (!AssetEditorToolkit) continue; + TSharedPtr TabManager = AssetEditorToolkit->GetTabManager(); + if (!TabManager.IsValid()) continue; + TSharedPtr Tab = TabManager->GetOwnerTab(); + if (Tab.IsValid() && Tab->IsForeground()) + { + FBlueprintEditor* BlueprintEditor = static_cast(AssetEditorToolkit); + if (BlueprintEditor) + { + FGraphPanelSelectionSet SelectedNodes = BlueprintEditor->GetSelectedNodes(); + for (UObject* Node : SelectedNodes) + { + Result.Add(Node); + } + } + } + } + return Result; +} + +TArray UMCPythonHelper::GetSelectedBlueprintNodeInfos() +{ + TArray Result; + if (!GEditor) return Result; + auto* Subsystem = GEditor->GetEditorSubsystem(); + for (UObject* Asset : Subsystem->GetAllEditedAssets()) + { + IAssetEditorInstance* AssetEditorInstance = Subsystem->FindEditorForAsset(Asset, false); + FAssetEditorToolkit* AssetEditorToolkit = static_cast(AssetEditorInstance); + if (!AssetEditorToolkit) continue; + TSharedPtr TabManager = AssetEditorToolkit->GetTabManager(); + if (!TabManager.IsValid()) continue; + TSharedPtr Tab = TabManager->GetOwnerTab(); + if (Tab.IsValid() && Tab->IsForeground()) + { + FBlueprintEditor* BlueprintEditor = static_cast(AssetEditorToolkit); + if (BlueprintEditor) + { + FGraphPanelSelectionSet SelectedNodes = BlueprintEditor->GetSelectedNodes(); + for (UObject* NodeObj : SelectedNodes) + { + UEdGraphNode* Node = Cast(NodeObj); + if (!Node) continue; + FMCPythonBlueprintNodeInfo NodeInfo; + NodeInfo.NodeName = Node->GetName(); + NodeInfo.NodeTitle = Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); + NodeInfo.NodeComment = Node->NodeComment; + for (UEdGraphPin* Pin : Node->Pins) + { + if (!Pin || Pin->bHidden) continue; + FMCPythonBlueprintPinInfo PinInfo; + FString Friendly = Pin->PinFriendlyName.ToString(); + PinInfo.PinName = Pin->GetName(); + PinInfo.FriendlyName = Friendly; + PinInfo.Direction = (Pin->Direction == EGPD_Input) ? TEXT("In") : TEXT("Out"); + PinInfo.PinType = Pin->PinType.PinCategory.ToString(); + if (Pin->PinType.PinSubCategoryObject.IsValid()) + { + PinInfo.PinSubType = Pin->PinType.PinSubCategoryObject->GetName(); + } + PinInfo.DefaultValue = Pin->DefaultValue; + for (UEdGraphPin* LinkedPin : Pin->LinkedTo) + { + if (LinkedPin && LinkedPin->GetOwningNode()) + { + FMCPythonPinLinkInfo LinkInfo; + LinkInfo.NodeName = LinkedPin->GetOwningNode()->GetName(); + LinkInfo.NodeTitle = LinkedPin->GetOwningNode()->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); + FString LinkedFriendly = LinkedPin->PinFriendlyName.ToString(); + LinkInfo.PinName = LinkedFriendly.IsEmpty() ? LinkedPin->GetName() : LinkedFriendly; + PinInfo.LinkedTo.Add(LinkInfo); + } + } + NodeInfo.Pins.Add(PinInfo); + } + Result.Add(NodeInfo); + } + } + } + } + return Result; +} + +// ─── Blueprint Graph Helpers (internal) ────────────────────────────────────── + +// ─── GetBlueprintGraphInfo ─────────────────────────────────────────────────── + +FString UMCPythonHelper::GetBlueprintGraphInfo(UBlueprint* Blueprint, const FString& GraphName) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + UEdGraph* Graph = FindGraphByName(Blueprint, GraphName); + if (!Graph) + return MakeJsonError(FString::Printf(TEXT("Graph '%s' not found in Blueprint."), *GraphName)); + + TArray> NodesArr; + for (UEdGraphNode* Node : Graph->Nodes) + { + if (!Node) continue; + + TSharedPtr NodeObj = MakeShareable(new FJsonObject()); + NodeObj->SetStringField(TEXT("node_name"), Node->GetName()); + NodeObj->SetStringField(TEXT("node_title"), Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString()); + NodeObj->SetStringField(TEXT("node_class"), Node->GetClass()->GetName()); + if (!Node->NodeComment.IsEmpty()) + NodeObj->SetStringField(TEXT("comment"), Node->NodeComment); + NodeObj->SetNumberField(TEXT("pos_x"), Node->NodePosX); + NodeObj->SetNumberField(TEXT("pos_y"), Node->NodePosY); + + TArray> PinsArr; + for (UEdGraphPin* Pin : Node->Pins) + { + if (!Pin || Pin->bHidden) continue; + TSharedPtr PinObj = MakeShareable(new FJsonObject()); + PinObj->SetStringField(TEXT("pin_name"), Pin->GetName()); + FString Friendly = Pin->PinFriendlyName.ToString(); + if (!Friendly.IsEmpty()) + PinObj->SetStringField(TEXT("friendly_name"), Friendly); + PinObj->SetStringField(TEXT("direction"), (Pin->Direction == EGPD_Input) ? TEXT("Input") : TEXT("Output")); + PinObj->SetStringField(TEXT("type"), Pin->PinType.PinCategory.ToString()); + if (Pin->PinType.PinSubCategoryObject.IsValid()) + PinObj->SetStringField(TEXT("sub_type"), Pin->PinType.PinSubCategoryObject->GetName()); + if (!Pin->DefaultValue.IsEmpty()) + PinObj->SetStringField(TEXT("default_value"), Pin->DefaultValue); + if (Pin->DefaultObject) + PinObj->SetStringField(TEXT("default_object"), Pin->DefaultObject->GetPathName()); + + // Linked pins + if (Pin->LinkedTo.Num() > 0) + { + TArray> LinksArr; + for (UEdGraphPin* Linked : Pin->LinkedTo) + { + if (!Linked || !Linked->GetOwningNode()) continue; + TSharedPtr LinkObj = MakeShareable(new FJsonObject()); + LinkObj->SetStringField(TEXT("node_name"), Linked->GetOwningNode()->GetName()); + LinkObj->SetStringField(TEXT("pin_name"), Linked->GetName()); + LinksArr.Add(MakeShareable(new FJsonValueObject(LinkObj))); + } + PinObj->SetArrayField(TEXT("linked_to"), LinksArr); + } + PinsArr.Add(MakeShareable(new FJsonValueObject(PinObj))); + } + NodeObj->SetArrayField(TEXT("pins"), PinsArr); + NodesArr.Add(MakeShareable(new FJsonValueObject(NodeObj))); + } + + TSharedPtr Result = MakeShareable(new FJsonObject()); + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("graph_name"), GraphName); + Result->SetNumberField(TEXT("node_count"), Graph->Nodes.Num()); + Result->SetArrayField(TEXT("nodes"), NodesArr); + return SerializeJsonObj(Result); +} + +// ─── ListCallableFunctions ─────────────────────────────────────────────────── + +FString UMCPythonHelper::ListCallableFunctions(UBlueprint* Blueprint, const FString& Filter) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + UClass* GenClass = Blueprint->GeneratedClass; + if (!GenClass) + return MakeJsonError(TEXT("Blueprint has no generated class. Compile it first.")); + + TArray> FuncsArr; + FString FilterLower = Filter.ToLower(); + + // Collect from the generated class and all parent classes + for (UClass* Cls = GenClass; Cls; Cls = Cls->GetSuperClass()) + { + for (TFieldIterator FuncIt(Cls, EFieldIteratorFlags::ExcludeSuper); FuncIt; ++FuncIt) + { + UFunction* Func = *FuncIt; + if (!Func || !Func->HasAnyFunctionFlags(FUNC_BlueprintCallable)) + continue; + + FString FuncName = Func->GetName(); + FString ClassName = Cls->GetName(); + + if (!FilterLower.IsEmpty()) + { + if (!FuncName.ToLower().Contains(FilterLower) && !ClassName.ToLower().Contains(FilterLower)) + continue; + } + + TSharedPtr FuncObj = MakeShareable(new FJsonObject()); + FuncObj->SetStringField(TEXT("function_name"), FuncName); + FuncObj->SetStringField(TEXT("class_name"), ClassName); + FuncObj->SetBoolField(TEXT("is_pure"), Func->HasAnyFunctionFlags(FUNC_BlueprintPure)); + FuncObj->SetBoolField(TEXT("is_static"), Func->HasAnyFunctionFlags(FUNC_Static)); + + // Parameters + TArray> ParamsArr; + for (TFieldIterator PropIt(Func); PropIt; ++PropIt) + { + FProperty* Prop = *PropIt; + TSharedPtr ParamObj = MakeShareable(new FJsonObject()); + ParamObj->SetStringField(TEXT("name"), Prop->GetName()); + ParamObj->SetStringField(TEXT("type"), Prop->GetCPPType()); + ParamObj->SetBoolField(TEXT("is_return"), Prop->HasAnyPropertyFlags(CPF_ReturnParm)); + ParamObj->SetBoolField(TEXT("is_output"), Prop->HasAnyPropertyFlags(CPF_OutParm)); + ParamsArr.Add(MakeShareable(new FJsonValueObject(ParamObj))); + } + FuncObj->SetArrayField(TEXT("parameters"), ParamsArr); + FuncsArr.Add(MakeShareable(new FJsonValueObject(FuncObj))); + } + } + + TSharedPtr Result = MakeShareable(new FJsonObject()); + Result->SetBoolField(TEXT("success"), true); + Result->SetNumberField(TEXT("count"), FuncsArr.Num()); + Result->SetArrayField(TEXT("functions"), FuncsArr); + return SerializeJsonObj(Result); +} + +// ─── ListBlueprintVariables ────────────────────────────────────────────────── + +FString UMCPythonHelper::ListBlueprintVariables(UBlueprint* Blueprint) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + TArray> VarsArr; + for (const FBPVariableDescription& Var : Blueprint->NewVariables) + { + TSharedPtr VarObj = MakeShareable(new FJsonObject()); + VarObj->SetStringField(TEXT("name"), Var.VarName.ToString()); + VarObj->SetStringField(TEXT("type"), Var.VarType.PinCategory.ToString()); + if (Var.VarType.PinSubCategoryObject.IsValid()) + VarObj->SetStringField(TEXT("sub_type"), Var.VarType.PinSubCategoryObject->GetName()); + VarObj->SetBoolField(TEXT("is_array"), Var.VarType.IsArray()); + VarObj->SetBoolField(TEXT("is_set"), Var.VarType.IsSet()); + VarObj->SetBoolField(TEXT("is_map"), Var.VarType.IsMap()); + if (!Var.DefaultValue.IsEmpty()) + VarObj->SetStringField(TEXT("default_value"), Var.DefaultValue); + VarsArr.Add(MakeShareable(new FJsonValueObject(VarObj))); + } + + TSharedPtr Result = MakeShareable(new FJsonObject()); + Result->SetBoolField(TEXT("success"), true); + Result->SetNumberField(TEXT("count"), VarsArr.Num()); + Result->SetArrayField(TEXT("variables"), VarsArr); + return SerializeJsonObj(Result); +} + +// ─── Blueprint Node Creation Helpers ───────────────────────────────────────── + +static UEdGraphNode* CreateBPNodeFromJson(UEdGraph* Graph, UBlueprint* Blueprint, const TSharedPtr& NodeJson, FString& OutError) +{ + FString NodeType; + if (!NodeJson->TryGetStringField(TEXT("type"), NodeType)) + { + OutError = TEXT("Node JSON missing 'type' field."); + return nullptr; + } + + double PosXd = 0, PosYd = 0; + NodeJson->TryGetNumberField(TEXT("pos_x"), PosXd); + NodeJson->TryGetNumberField(TEXT("pos_y"), PosYd); + int32 PosX = (int32)PosXd; + int32 PosY = (int32)PosYd; + + UEdGraphNode* NewNode = nullptr; + + if (NodeType == TEXT("CallFunction")) + { + FString TargetClass, FunctionName; + if (!NodeJson->TryGetStringField(TEXT("function_name"), FunctionName)) + { + OutError = TEXT("CallFunction node missing 'function_name'."); + return nullptr; + } + NodeJson->TryGetStringField(TEXT("target"), TargetClass); + + // Find the UFunction + UFunction* TargetFunc = nullptr; + if (!TargetClass.IsEmpty()) + { + UClass* Cls = FindObject(nullptr, *FString::Printf(TEXT("/Script/Engine.%s"), *TargetClass)); + if (!Cls) + Cls = FindFirstObject(*TargetClass, EFindFirstObjectOptions::NativeFirst); + if (Cls) + TargetFunc = Cls->FindFunctionByName(FName(*FunctionName)); + } + + if (!TargetFunc) + { + // Search in the Blueprint's generated class hierarchy + for (UClass* Cls = Blueprint->GeneratedClass; Cls && !TargetFunc; Cls = Cls->GetSuperClass()) + { + TargetFunc = Cls->FindFunctionByName(FName(*FunctionName)); + } + } + + if (!TargetFunc) + { + OutError = FString::Printf(TEXT("Function '%s' not found (target: '%s')."), *FunctionName, *TargetClass); + return nullptr; + } + + FGraphNodeCreator Creator(*Graph); + UK2Node_CallFunction* FuncNode = Creator.CreateNode(false); + FuncNode->SetFromFunction(TargetFunc); + FuncNode->NodePosX = PosX; + FuncNode->NodePosY = PosY; + Creator.Finalize(); + NewNode = FuncNode; + } + else if (NodeType == TEXT("Event")) + { + FString EventName; + if (!NodeJson->TryGetStringField(TEXT("event_name"), EventName)) + { + OutError = TEXT("Event node missing 'event_name'."); + return nullptr; + } + + UClass* EventClass = Blueprint->GeneratedClass ? Blueprint->GeneratedClass : Blueprint->ParentClass; + UFunction* EventFunc = EventClass ? EventClass->FindFunctionByName(FName(*EventName)) : nullptr; + + if (!EventFunc) + { + OutError = FString::Printf(TEXT("Event function '%s' not found in class hierarchy."), *EventName); + return nullptr; + } + + FGraphNodeCreator Creator(*Graph); + UK2Node_Event* EventNode = Creator.CreateNode(false); + EventNode->EventReference.SetExternalMember(FName(*EventName), EventClass); + EventNode->bOverrideFunction = true; + EventNode->NodePosX = PosX; + EventNode->NodePosY = PosY; + Creator.Finalize(); + NewNode = EventNode; + } + else if (NodeType == TEXT("CustomEvent")) + { + FString EventName; + if (!NodeJson->TryGetStringField(TEXT("event_name"), EventName)) + { + OutError = TEXT("CustomEvent node missing 'event_name'."); + return nullptr; + } + + FGraphNodeCreator Creator(*Graph); + UK2Node_CustomEvent* CustomNode = Creator.CreateNode(false); + if (!CustomNode) + { + OutError = FString::Printf(TEXT("Failed to create CustomEvent '%s'."), *EventName); + return nullptr; + } + CustomNode->CustomFunctionName = FName(*EventName); + CustomNode->NodePosX = PosX; + CustomNode->NodePosY = PosY; + Creator.Finalize(); + NewNode = CustomNode; + } + else if (NodeType == TEXT("CastTo")) + { + FString CastClass; + if (!NodeJson->TryGetStringField(TEXT("cast_class"), CastClass)) + { + OutError = TEXT("CastTo node missing 'cast_class'."); + return nullptr; + } + // Try full path first, then common module prefixes + UClass* TargetClass = LoadClass(nullptr, *CastClass); + if (!TargetClass) + TargetClass = LoadClass(nullptr, *FString::Printf(TEXT("/Script/Engine.%s"), *CastClass)); + if (!TargetClass) + TargetClass = LoadClass(nullptr, *FString::Printf(TEXT("/Script/AIModule.%s"), *CastClass)); + if (!TargetClass) + { + OutError = FString::Printf(TEXT("CastTo: class '%s' not found."), *CastClass); + return nullptr; + } + FGraphNodeCreator Creator(*Graph); + UK2Node_DynamicCast* CastNode = Creator.CreateNode(false); + CastNode->TargetType = TargetClass; + CastNode->NodePosX = PosX; + CastNode->NodePosY = PosY; + Creator.Finalize(); + NewNode = CastNode; + } + else if (NodeType == TEXT("Branch")) + { + FGraphNodeCreator Creator(*Graph); + UK2Node_IfThenElse* BranchNode = Creator.CreateNode(false); + BranchNode->NodePosX = PosX; + BranchNode->NodePosY = PosY; + Creator.Finalize(); + NewNode = BranchNode; + } + else if (NodeType == TEXT("Sequence")) + { + FGraphNodeCreator Creator(*Graph); + UK2Node_ExecutionSequence* SeqNode = Creator.CreateNode(false); + SeqNode->NodePosX = PosX; + SeqNode->NodePosY = PosY; + Creator.Finalize(); + NewNode = SeqNode; + } + else if (NodeType == TEXT("VariableGet")) + { + FString VarName; + if (!NodeJson->TryGetStringField(TEXT("variable_name"), VarName)) + { + OutError = TEXT("VariableGet node missing 'variable_name'."); + return nullptr; + } + + FString VarClass; + const bool bHasExternalClass = NodeJson->TryGetStringField(TEXT("variable_class"), VarClass) && !VarClass.IsEmpty(); + + FGraphNodeCreator Creator(*Graph); + UK2Node_VariableGet* GetNode = Creator.CreateNode(false); + + if (bHasExternalClass) + { + UClass* OwnerClass = LoadClass(nullptr, *VarClass); + if (!OwnerClass) + OwnerClass = LoadClass(nullptr, *FString::Printf(TEXT("/Script/Engine.%s"), *VarClass)); + if (OwnerClass) + GetNode->VariableReference.SetExternalMember(FName(*VarName), OwnerClass); + else + GetNode->VariableReference.SetSelfMember(FName(*VarName)); + } + else + { + GetNode->VariableReference.SetSelfMember(FName(*VarName)); + } + + GetNode->NodePosX = PosX; + GetNode->NodePosY = PosY; + Creator.Finalize(); + NewNode = GetNode; + } + else if (NodeType == TEXT("VariableSet")) + { + FString VarName; + if (!NodeJson->TryGetStringField(TEXT("variable_name"), VarName)) + { + OutError = TEXT("VariableSet node missing 'variable_name'."); + return nullptr; + } + + FString VarClass; + const bool bHasExternalClass = NodeJson->TryGetStringField(TEXT("variable_class"), VarClass) && !VarClass.IsEmpty(); + + FGraphNodeCreator Creator(*Graph); + UK2Node_VariableSet* SetNode = Creator.CreateNode(false); + + if (bHasExternalClass) + { + UClass* OwnerClass = LoadClass(nullptr, *VarClass); + if (!OwnerClass) + OwnerClass = LoadClass(nullptr, *FString::Printf(TEXT("/Script/Engine.%s"), *VarClass)); + if (OwnerClass) + SetNode->VariableReference.SetExternalMember(FName(*VarName), OwnerClass); + else + SetNode->VariableReference.SetSelfMember(FName(*VarName)); + } + else + { + SetNode->VariableReference.SetSelfMember(FName(*VarName)); + } + + SetNode->NodePosX = PosX; + SetNode->NodePosY = PosY; + Creator.Finalize(); + NewNode = SetNode; + } + else if (NodeType == TEXT("MacroInstance")) + { + FString MacroName; + if (!NodeJson->TryGetStringField(TEXT("macro_name"), MacroName)) + { + OutError = TEXT("MacroInstance node missing 'macro_name'."); + return nullptr; + } + + // Search for macro graph in the Blueprint and its parents + UEdGraph* MacroGraph = nullptr; + for (UBlueprint* SearchBP = Blueprint; SearchBP && !MacroGraph; SearchBP = Cast(SearchBP->ParentClass->ClassGeneratedBy)) + { + for (UEdGraph* MGraph : SearchBP->MacroGraphs) + { + if (MGraph && MGraph->GetName() == MacroName) + { + MacroGraph = MGraph; + break; + } + } + if (!SearchBP->ParentClass || !SearchBP->ParentClass->ClassGeneratedBy) + break; + } + + // Also search engine-level macros (e.g., ForEachLoop) + if (!MacroGraph) + { + UBlueprint* MacroLibBP = LoadObject(nullptr, TEXT("/Engine/EditorBlueprintResources/StandardMacros.StandardMacros")); + if (MacroLibBP) + { + for (UEdGraph* MGraph : MacroLibBP->MacroGraphs) + { + if (MGraph && MGraph->GetName() == MacroName) + { + MacroGraph = MGraph; + break; + } + } + } + } + + if (!MacroGraph) + { + OutError = FString::Printf(TEXT("Macro '%s' not found."), *MacroName); + return nullptr; + } + + FGraphNodeCreator Creator(*Graph); + UK2Node_MacroInstance* MacroNode = Creator.CreateNode(false); + MacroNode->SetMacroGraph(MacroGraph); + MacroNode->NodePosX = PosX; + MacroNode->NodePosY = PosY; + Creator.Finalize(); + NewNode = MacroNode; + } + else if (NodeType == TEXT("InputKey")) + { + FString KeyName; + if (!NodeJson->TryGetStringField(TEXT("key_name"), KeyName)) + { + OutError = TEXT("InputKey node missing 'key_name'."); + return nullptr; + } + FGraphNodeCreator Creator(*Graph); + UK2Node_InputKey* KeyNode = Creator.CreateNode(false); + KeyNode->InputKey = FKey(*KeyName); + KeyNode->NodePosX = PosX; + KeyNode->NodePosY = PosY; + Creator.Finalize(); + NewNode = KeyNode; + } + else if (NodeType == TEXT("SpawnActor")) + { + FGraphNodeCreator Creator(*Graph); + UK2Node_SpawnActorFromClass* SpawnNode = Creator.CreateNode(false); + SpawnNode->NodePosX = PosX; + SpawnNode->NodePosY = PosY; + Creator.Finalize(); + NewNode = SpawnNode; + } + else + { + OutError = FString::Printf(TEXT("Unknown node type '%s'. Supported: CallFunction, Event, CustomEvent, CastTo, Branch, Sequence, VariableGet, VariableSet, MacroInstance, InputKey, SpawnActor."), *NodeType); + return nullptr; + } + + // Set pin defaults if specified + if (NewNode && NodeJson->HasField(TEXT("pin_defaults"))) + { + const TSharedPtr& PinDefaults = NodeJson->GetObjectField(TEXT("pin_defaults")); + for (auto& Pair : PinDefaults->Values) + { + // *Pair.Key yields const TCHAR* on both UE 5.7 (FString key) and 5.8 (UE::FSharedString key). + UEdGraphPin* Pin = FindPinByName(NewNode, FString(*Pair.Key), EGPD_Input); + if (Pin) + { + FString Value; + if (Pair.Value->TryGetString(Value)) + { + Pin->DefaultValue = Value; + } + } + } + } + + return NewNode; +} + +// ─── AddBlueprintNode UFUNCTION ────────────────────────────────────────────── + +FString UMCPythonHelper::AddBlueprintNode(UBlueprint* Blueprint, const FString& GraphName, const FString& NodeJson) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + UEdGraph* Graph = FindGraphByName(Blueprint, GraphName); + if (!Graph) + return MakeJsonError(FString::Printf(TEXT("Graph '%s' not found."), *GraphName)); + + TSharedPtr JsonObj; + TSharedRef> Reader = TJsonReaderFactory<>::Create(NodeJson); + if (!FJsonSerializer::Deserialize(Reader, JsonObj) || !JsonObj.IsValid()) + return MakeJsonError(TEXT("Failed to parse NodeJson.")); + + FString Error; + UEdGraphNode* NewNode = CreateBPNodeFromJson(Graph, Blueprint, JsonObj, Error); + if (!NewNode) + return MakeJsonError(Error); + + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); + + TSharedPtr Result = MakeShareable(new FJsonObject()); + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("node_name"), NewNode->GetName()); + Result->SetStringField(TEXT("node_title"), NewNode->GetNodeTitle(ENodeTitleType::FullTitle).ToString()); + Result->SetStringField(TEXT("message"), FString::Printf(TEXT("Node '%s' added to graph '%s'."), *NewNode->GetNodeTitle(ENodeTitleType::FullTitle).ToString(), *GraphName)); + + // Return pin info so caller knows how to connect + TArray> PinsArr; + for (UEdGraphPin* Pin : NewNode->Pins) + { + if (!Pin || Pin->bHidden) continue; + TSharedPtr PinObj = MakeShareable(new FJsonObject()); + PinObj->SetStringField(TEXT("pin_name"), Pin->GetName()); + FString Friendly = Pin->PinFriendlyName.ToString(); + if (!Friendly.IsEmpty()) + PinObj->SetStringField(TEXT("friendly_name"), Friendly); + PinObj->SetStringField(TEXT("direction"), (Pin->Direction == EGPD_Input) ? TEXT("Input") : TEXT("Output")); + PinObj->SetStringField(TEXT("type"), Pin->PinType.PinCategory.ToString()); + PinsArr.Add(MakeShareable(new FJsonValueObject(PinObj))); + } + Result->SetArrayField(TEXT("pins"), PinsArr); + return SerializeJsonObj(Result); +} + +// ─── ConnectBlueprintPins UFUNCTION ────────────────────────────────────────── + +FString UMCPythonHelper::ConnectBlueprintPins(UBlueprint* Blueprint, const FString& GraphName, + const FString& SourceNodeName, const FString& SourcePinName, + const FString& TargetNodeName, const FString& TargetPinName) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + UEdGraph* Graph = FindGraphByName(Blueprint, GraphName); + if (!Graph) + return MakeJsonError(FString::Printf(TEXT("Graph '%s' not found."), *GraphName)); + + UEdGraphNode* SourceNode = FindBPNodeByName(Graph, SourceNodeName); + if (!SourceNode) + return MakeJsonError(FString::Printf(TEXT("Source node '%s' not found."), *SourceNodeName)); + + UEdGraphNode* TargetNode = FindBPNodeByName(Graph, TargetNodeName); + if (!TargetNode) + return MakeJsonError(FString::Printf(TEXT("Target node '%s' not found."), *TargetNodeName)); + + UEdGraphPin* SourcePin = FindPinByName(SourceNode, SourcePinName); + if (!SourcePin) + { + TArray PinNames; + for (UEdGraphPin* P : SourceNode->Pins) { if (P && !P->bHidden) PinNames.Add(P->GetName()); } + return MakeJsonError(FString::Printf(TEXT("Pin '%s' not found on node '%s'. Available: %s"), + *SourcePinName, *SourceNodeName, *FString::Join(PinNames, TEXT(", ")))); + } + + UEdGraphPin* TargetPin = FindPinByName(TargetNode, TargetPinName); + if (!TargetPin) + { + TArray PinNames; + for (UEdGraphPin* P : TargetNode->Pins) { if (P && !P->bHidden) PinNames.Add(P->GetName()); } + return MakeJsonError(FString::Printf(TEXT("Pin '%s' not found on node '%s'. Available: %s"), + *TargetPinName, *TargetNodeName, *FString::Join(PinNames, TEXT(", ")))); + } + + // Verify directions are compatible (output -> input) + if (SourcePin->Direction == TargetPin->Direction) + return MakeJsonError(FString::Printf(TEXT("Cannot connect pins with same direction (%s)."), + SourcePin->Direction == EGPD_Input ? TEXT("both Input") : TEXT("both Output"))); + + // Check if connection is allowed by the schema and handle BREAK_OTHERS + const UEdGraphSchema* Schema = Graph->GetSchema(); + if (Schema) + { + FPinConnectionResponse Response = Schema->CanCreateConnection(SourcePin, TargetPin); + if (Response.Response == CONNECT_RESPONSE_DISALLOW) + return MakeJsonError(FString::Printf(TEXT("Connection not allowed: %s"), *Response.Message.ToString())); + // Break existing connections when schema requires it (e.g. exec output already connected) + if (Response.Response == CONNECT_RESPONSE_BREAK_OTHERS_A) + SourcePin->BreakAllPinLinks(); + else if (Response.Response == CONNECT_RESPONSE_BREAK_OTHERS_B) + TargetPin->BreakAllPinLinks(); + } + + SourcePin->MakeLinkTo(TargetPin); + + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); + return MakeJsonSuccess(FString::Printf(TEXT("Connected %s.%s -> %s.%s"), + *SourceNodeName, *SourcePinName, *TargetNodeName, *TargetPinName)); +} + +// ─── RemoveBlueprintNode UFUNCTION ─────────────────────────────────────────── + +FString UMCPythonHelper::RemoveBlueprintNode(UBlueprint* Blueprint, const FString& GraphName, + const FString& NodeName) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + UEdGraph* Graph = FindGraphByName(Blueprint, GraphName); + if (!Graph) + return MakeJsonError(FString::Printf(TEXT("Graph '%s' not found."), *GraphName)); + + UEdGraphNode* Node = FindBPNodeByName(Graph, NodeName); + if (!Node) + return MakeJsonError(FString::Printf(TEXT("Node '%s' not found in graph '%s'."), *NodeName, *GraphName)); + + // Break all pin connections first + for (UEdGraphPin* Pin : Node->Pins) + { + if (Pin) + Pin->BreakAllPinLinks(); + } + + Graph->RemoveNode(Node); + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); + + return MakeJsonSuccess(FString::Printf(TEXT("Node '%s' removed from graph '%s'."), *NodeName, *GraphName)); +} + +// ─── BuildBlueprintGraph UFUNCTION ─────────────────────────────────────────── + +static void LayoutBPGraphNodes(const TMap& NodeMap, + const TArray>& Connections) +{ + // Simple left-to-right layout based on execution flow + // Assign columns based on connection depth + TMap NodeColumns; + TSet Visited; + + // Find nodes with no incoming exec connections (roots) + TSet HasIncoming; + for (auto& ConnVal : Connections) + { + const TSharedPtr& Conn = ConnVal->AsObject(); + if (!Conn.IsValid()) continue; + FString TargetNodeId; + if (Conn->TryGetStringField(TEXT("target_node"), TargetNodeId)) + HasIncoming.Add(TargetNodeId); + } + + // Assign column 0 to roots, then propagate + int32 Col = 0; + for (auto& Pair : NodeMap) + { + if (!HasIncoming.Contains(Pair.Key)) + NodeColumns.Add(Pair.Key, 0); + } + + // Propagate columns through connections + for (auto& ConnVal : Connections) + { + const TSharedPtr& Conn = ConnVal->AsObject(); + if (!Conn.IsValid()) continue; + FString SourceId, TargetId; + Conn->TryGetStringField(TEXT("source_node"), SourceId); + Conn->TryGetStringField(TEXT("target_node"), TargetId); + + int32* SourceCol = NodeColumns.Find(SourceId); + int32 SC = SourceCol ? *SourceCol : 0; + int32* TargetCol = NodeColumns.Find(TargetId); + if (!TargetCol || *TargetCol <= SC) + NodeColumns.Add(TargetId, SC + 1); + } + + // Count nodes per column for Y positioning + TMap ColumnRowCount; + const float XStep = 400.0f; + const float YStep = 200.0f; + + for (auto& Pair : NodeMap) + { + int32* ColPtr = NodeColumns.Find(Pair.Key); + int32 C = ColPtr ? *ColPtr : 0; + int32* RowPtr = ColumnRowCount.Find(C); + int32 Row = RowPtr ? *RowPtr : 0; + + Pair.Value->NodePosX = (int32)(C * XStep); + Pair.Value->NodePosY = (int32)(Row * YStep); + + ColumnRowCount.Add(C, Row + 1); + } +} + +FString UMCPythonHelper::BuildBlueprintGraph(UBlueprint* Blueprint, const FString& GraphName, + const FString& GraphJson) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + UEdGraph* Graph = FindGraphByName(Blueprint, GraphName); + if (!Graph) + return MakeJsonError(FString::Printf(TEXT("Graph '%s' not found."), *GraphName)); + + // Parse JSON + TSharedPtr JsonObj; + TSharedRef> Reader = TJsonReaderFactory<>::Create(GraphJson); + if (!FJsonSerializer::Deserialize(Reader, JsonObj) || !JsonObj.IsValid()) + return MakeJsonError(TEXT("Failed to parse GraphJson.")); + + if (!JsonObj->HasField(TEXT("nodes"))) + return MakeJsonError(TEXT("GraphJson missing 'nodes' array.")); + + const TArray>& NodesArr = JsonObj->GetArrayField(TEXT("nodes")); + TArray> ConnectionsArr; + if (JsonObj->HasField(TEXT("connections"))) + ConnectionsArr = JsonObj->GetArrayField(TEXT("connections")); + + // Remove existing user-created nodes (keep root/entry nodes) + TArray NodesToRemove; + for (UEdGraphNode* Node : Graph->Nodes) + { + if (!Node) continue; + // Keep entry points (function entry, etc.) but remove user nodes + // For EventGraph, we typically remove all non-essential nodes + if (!Node->IsA()) + { + NodesToRemove.Add(Node); + } + } + for (UEdGraphNode* Node : NodesToRemove) + { + for (UEdGraphPin* Pin : Node->Pins) + { + if (Pin) Pin->BreakAllPinLinks(); + } + Graph->RemoveNode(Node); + } + + // Create nodes from JSON + TMap NodeMap; // id -> node + TArray CreationErrors; + + for (auto& NodeVal : NodesArr) + { + const TSharedPtr& NodeObj = NodeVal->AsObject(); + if (!NodeObj.IsValid()) continue; + + FString NodeId; + if (!NodeObj->TryGetStringField(TEXT("id"), NodeId)) + { + CreationErrors.Add(TEXT("Node missing 'id' field.")); + continue; + } + + FString Error; + UEdGraphNode* NewNode = CreateBPNodeFromJson(Graph, Blueprint, NodeObj, Error); + if (NewNode) + { + NodeMap.Add(NodeId, NewNode); + } + else + { + CreationErrors.Add(FString::Printf(TEXT("Node '%s': %s"), *NodeId, *Error)); + } + } + + // Connect pins + TArray ConnectionErrors; + for (auto& ConnVal : ConnectionsArr) + { + const TSharedPtr& ConnObj = ConnVal->AsObject(); + if (!ConnObj.IsValid()) continue; + + FString SourceNodeId, SourcePinName, TargetNodeId, TargetPinName; + ConnObj->TryGetStringField(TEXT("source_node"), SourceNodeId); + ConnObj->TryGetStringField(TEXT("source_pin"), SourcePinName); + ConnObj->TryGetStringField(TEXT("target_node"), TargetNodeId); + ConnObj->TryGetStringField(TEXT("target_pin"), TargetPinName); + + UEdGraphNode** SourceNodePtr = NodeMap.Find(SourceNodeId); + UEdGraphNode** TargetNodePtr = NodeMap.Find(TargetNodeId); + + if (!SourceNodePtr || !*SourceNodePtr) + { + ConnectionErrors.Add(FString::Printf(TEXT("Source node '%s' not found."), *SourceNodeId)); + continue; + } + if (!TargetNodePtr || !*TargetNodePtr) + { + ConnectionErrors.Add(FString::Printf(TEXT("Target node '%s' not found."), *TargetNodeId)); + continue; + } + + UEdGraphPin* SourcePin = FindPinByName(*SourceNodePtr, SourcePinName); + UEdGraphPin* TargetPin = FindPinByName(*TargetNodePtr, TargetPinName); + + if (!SourcePin) + { + ConnectionErrors.Add(FString::Printf(TEXT("Pin '%s' not found on '%s'."), *SourcePinName, *SourceNodeId)); + continue; + } + if (!TargetPin) + { + ConnectionErrors.Add(FString::Printf(TEXT("Pin '%s' not found on '%s'."), *TargetPinName, *TargetNodeId)); + continue; + } + + SourcePin->MakeLinkTo(TargetPin); + } + + // Layout nodes + LayoutBPGraphNodes(NodeMap, ConnectionsArr); + + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); + + // Build result + TSharedPtr Result = MakeShareable(new FJsonObject()); + Result->SetBoolField(TEXT("success"), CreationErrors.Num() == 0 && ConnectionErrors.Num() == 0); + Result->SetNumberField(TEXT("nodes_created"), NodeMap.Num()); + Result->SetNumberField(TEXT("connections_made"), ConnectionsArr.Num() - ConnectionErrors.Num()); + + FString Message = FString::Printf(TEXT("Built graph '%s': %d nodes, %d connections."), + *GraphName, NodeMap.Num(), ConnectionsArr.Num() - ConnectionErrors.Num()); + if (CreationErrors.Num() > 0 || ConnectionErrors.Num() > 0) + Message += TEXT(" Some errors occurred."); + Result->SetStringField(TEXT("message"), Message); + + if (CreationErrors.Num() > 0) + { + TArray> ErrArr; + for (const FString& Err : CreationErrors) + ErrArr.Add(MakeShareable(new FJsonValueString(Err))); + Result->SetArrayField(TEXT("creation_errors"), ErrArr); + } + if (ConnectionErrors.Num() > 0) + { + TArray> ErrArr; + for (const FString& Err : ConnectionErrors) + ErrArr.Add(MakeShareable(new FJsonValueString(Err))); + Result->SetArrayField(TEXT("connection_errors"), ErrArr); + } + + // Return node_id -> node_name mapping for reference + TSharedPtr MapObj = MakeShareable(new FJsonObject()); + for (auto& Pair : NodeMap) + { + MapObj->SetStringField(Pair.Key, Pair.Value->GetName()); + } + Result->SetObjectField(TEXT("node_id_to_name"), MapObj); + + return SerializeJsonObj(Result); +} + +// ─── CompileBlueprint UFUNCTION ────────────────────────────────────────────── + +FString UMCPythonHelper::CompileBlueprint(UBlueprint* Blueprint) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + FKismetEditorUtilities::CompileBlueprint(Blueprint); + + // Check compile status + bool bHasError = (Blueprint->Status == BS_Error); + bool bUpToDate = (Blueprint->Status == BS_UpToDate); + + TSharedPtr Result = MakeShareable(new FJsonObject()); + Result->SetBoolField(TEXT("success"), !bHasError); + + FString StatusStr; + switch (Blueprint->Status) + { + case BS_UpToDate: StatusStr = TEXT("UpToDate"); break; + case BS_Error: StatusStr = TEXT("Error"); break; + case BS_Dirty: StatusStr = TEXT("Dirty"); break; + case BS_BeingCreated: StatusStr = TEXT("BeingCreated"); break; + default: StatusStr = TEXT("Unknown"); break; + } + Result->SetStringField(TEXT("status"), StatusStr); + Result->SetStringField(TEXT("message"), + bHasError ? TEXT("Blueprint compilation failed. Check the output log for details.") + : TEXT("Blueprint compiled successfully.")); + + return SerializeJsonObj(Result); +} + +// ─── SetBlueprintCDOProperty UFUNCTION ─────────────────────────────────────── + +FString UMCPythonHelper::SetBlueprintCDOProperty(UBlueprint* Blueprint, const FString& PropertyName, const FString& ValueStr) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Blueprint is null.")); + + UClass* GenClass = Blueprint->GeneratedClass; + if (!GenClass) + return MakeJsonError(TEXT("Blueprint has no GeneratedClass.")); + + UObject* CDO = GenClass->GetDefaultObject(false); + if (!CDO) + return MakeJsonError(TEXT("Could not get CDO.")); + + FProperty* FoundProp = nullptr; + for (TFieldIterator It(GenClass, EFieldIterationFlags::IncludeSuper); It; ++It) + { + if (It->GetName().Equals(PropertyName, ESearchCase::IgnoreCase)) + { + FoundProp = *It; + break; + } + } + + if (!FoundProp) + return MakeJsonError(FString::Printf(TEXT("Property '%s' not found on '%s' or any parent class."), *PropertyName, *Blueprint->GetName())); + + void* PropAddr = FoundProp->ContainerPtrToValuePtr(CDO); + + auto MarkModified = [&]() + { + CDO->Modify(); + Blueprint->Modify(); + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); + }; + + // TSubclassOf + if (FClassProperty* ClassProp = CastField(FoundProp)) + { + UClass* TargetClass = LoadClass(nullptr, *ValueStr); + if (!TargetClass) + { + UBlueprint* ValBP = Cast(StaticLoadObject(UBlueprint::StaticClass(), nullptr, *ValueStr)); + if (ValBP) TargetClass = ValBP->GeneratedClass; + } + if (!TargetClass) + return MakeJsonError(FString::Printf(TEXT("Could not resolve class from '%s'."), *ValueStr)); + ClassProp->SetPropertyValue(PropAddr, TargetClass); + MarkModified(); + TSharedPtr R = MakeShareable(new FJsonObject()); + R->SetBoolField(TEXT("success"), true); + R->SetStringField(TEXT("property"), PropertyName); + R->SetStringField(TEXT("value"), TargetClass->GetName()); + R->SetStringField(TEXT("message"), FString::Printf(TEXT("ClassProperty '%s' set to '%s'."), *PropertyName, *TargetClass->GetName())); + return SerializeJsonObj(R); + } + + // TSoftClassPtr + if (FSoftClassProperty* SoftClassProp = CastField(FoundProp)) + { + FSoftObjectPath SoftPath(ValueStr); + FSoftObjectPtr SoftPtr(SoftPath); + SoftClassProp->SetPropertyValue(PropAddr, SoftPtr); + MarkModified(); + return MakeJsonSuccess(FString::Printf(TEXT("SoftClassProperty '%s' set to '%s'."), *PropertyName, *ValueStr)); + } + + // UObject* refs (must come after class properties since FClassProperty extends FObjectProperty) + if (FObjectProperty* ObjProp = CastField(FoundProp)) + { + UObject* LoadedObj = StaticLoadObject(ObjProp->PropertyClass, nullptr, *ValueStr); + if (!LoadedObj) + return MakeJsonError(FString::Printf(TEXT("Could not load object from '%s'."), *ValueStr)); + ObjProp->SetObjectPropertyValue(PropAddr, LoadedObj); + MarkModified(); + return MakeJsonSuccess(FString::Printf(TEXT("ObjectProperty '%s' set."), *PropertyName)); + } + + // bool + if (FBoolProperty* BoolProp = CastField(FoundProp)) + { + bool bVal = (ValueStr == TEXT("true") || ValueStr == TEXT("True") || ValueStr == TEXT("1")); + BoolProp->SetPropertyValue(PropAddr, bVal); + MarkModified(); + return MakeJsonSuccess(FString::Printf(TEXT("BoolProperty '%s' set to %s."), *PropertyName, bVal ? TEXT("true") : TEXT("false"))); + } + + // int / float / double + if (FNumericProperty* NumProp = CastField(FoundProp)) + { + if (NumProp->IsFloatingPoint()) + NumProp->SetFloatingPointPropertyValue(PropAddr, FCString::Atod(*ValueStr)); + else + NumProp->SetIntPropertyValue(PropAddr, (int64)FCString::Atoi64(*ValueStr)); + MarkModified(); + return MakeJsonSuccess(FString::Printf(TEXT("NumericProperty '%s' set to '%s'."), *PropertyName, *ValueStr)); + } + + // FString + if (FStrProperty* StrProp = CastField(FoundProp)) + { + StrProp->SetPropertyValue(PropAddr, ValueStr); + MarkModified(); + return MakeJsonSuccess(FString::Printf(TEXT("StrProperty '%s' set."), *PropertyName)); + } + + // FName + if (FNameProperty* NameProp = CastField(FoundProp)) + { + NameProp->SetPropertyValue(PropAddr, FName(*ValueStr)); + MarkModified(); + return MakeJsonSuccess(FString::Printf(TEXT("NameProperty '%s' set."), *PropertyName)); + } + + // FText + if (FTextProperty* TextProp = CastField(FoundProp)) + { + TextProp->SetPropertyValue(PropAddr, FText::FromString(ValueStr)); + MarkModified(); + return MakeJsonSuccess(FString::Printf(TEXT("TextProperty '%s' set."), *PropertyName)); + } + + return MakeJsonError(FString::Printf(TEXT("Unsupported property type '%s' for property '%s'."), + *FoundProp->GetClass()->GetName(), *PropertyName)); +} + +// ─── AddComponentToBlueprint UFUNCTION ─────────────────────────────────────── + +FString UMCPythonHelper::AddComponentToBlueprint(UBlueprint* Blueprint, + const FString& ComponentClassPath, + const FString& ComponentName, + float LocationX, float LocationY, float LocationZ, + float RotationPitch, float RotationYaw, float RotationRoll, + const FString& ParentComponentName) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + USimpleConstructionScript* SCS = Blueprint->SimpleConstructionScript; + if (!SCS) + return MakeJsonError(TEXT("Blueprint has no SimpleConstructionScript.")); + + UClass* CompClass = LoadClass(nullptr, *ComponentClassPath); + if (!CompClass) + return MakeJsonError(FString::Printf(TEXT("Component class not found: %s"), *ComponentClassPath)); + + USCS_Node* NewNode = SCS->CreateNode(CompClass, FName(*ComponentName)); + if (!NewNode) + return MakeJsonError(TEXT("Failed to create SCS node.")); + + if (USceneComponent* SceneComp = Cast(NewNode->ComponentTemplate)) + { + SceneComp->SetRelativeLocation(FVector(LocationX, LocationY, LocationZ)); + SceneComp->SetRelativeRotation(FRotator(RotationPitch, RotationYaw, RotationRoll)); + } + + if (!ParentComponentName.IsEmpty()) + { + USCS_Node* ParentNode = SCS->FindSCSNode(FName(*ParentComponentName)); + if (ParentNode) + ParentNode->AddChildNode(NewNode); + else + SCS->AddNode(NewNode); + } + else + { + const TArray& RootNodes = SCS->GetRootNodes(); + if (RootNodes.Num() > 0) + RootNodes[0]->AddChildNode(NewNode); + else + SCS->AddNode(NewNode); + } + + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); + + TSharedPtr R = MakeShared(); + R->SetBoolField(TEXT("success"), true); + R->SetStringField(TEXT("node_name"), NewNode->GetVariableName().ToString()); + R->SetStringField(TEXT("component_class"), CompClass->GetName()); + R->SetStringField(TEXT("message"), + FString::Printf(TEXT("Added '%s' (%s)."), *ComponentName, *CompClass->GetName())); + return SerializeJsonObj(R); +} + +// ─── ListBlueprintComponents UFUNCTION ─────────────────────────────────────── + +FString UMCPythonHelper::ListBlueprintComponents(UBlueprint* Blueprint) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + USimpleConstructionScript* SCS = Blueprint->SimpleConstructionScript; + if (!SCS) + return MakeJsonError(TEXT("Blueprint has no SimpleConstructionScript.")); + + TArray> ComponentsArr; + for (USCS_Node* Node : SCS->GetAllNodes()) + { + if (!Node) continue; + TSharedPtr Obj = MakeShared(); + Obj->SetStringField(TEXT("variable_name"), Node->GetVariableName().ToString()); + if (Node->ComponentTemplate) + { + Obj->SetStringField(TEXT("class"), Node->ComponentTemplate->GetClass()->GetName()); + Obj->SetBoolField(TEXT("is_native"), false); + } + else + { + Obj->SetStringField(TEXT("class"), TEXT("Unknown")); + Obj->SetBoolField(TEXT("is_native"), false); + } + USCS_Node* Parent = nullptr; + for (USCS_Node* Candidate : SCS->GetAllNodes()) + { + if (Candidate && Candidate->GetChildNodes().Contains(Node)) + { + Parent = Candidate; + break; + } + } + Obj->SetStringField(TEXT("parent"), Parent ? Parent->GetVariableName().ToString() : TEXT("")); + ComponentsArr.Add(MakeShareable(new FJsonValueObject(Obj))); + } + + TSharedPtr R = MakeShared(); + R->SetBoolField(TEXT("success"), true); + R->SetArrayField(TEXT("components"), ComponentsArr); + R->SetNumberField(TEXT("count"), ComponentsArr.Num()); + return SerializeJsonObj(R); +} + +// ─── RemoveComponentFromBlueprint UFUNCTION ─────────────────────────────────── + +FString UMCPythonHelper::RemoveComponentFromBlueprint(UBlueprint* Blueprint, const FString& ComponentName) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + USimpleConstructionScript* SCS = Blueprint->SimpleConstructionScript; + if (!SCS) + return MakeJsonError(TEXT("Blueprint has no SimpleConstructionScript.")); + + USCS_Node* Node = SCS->FindSCSNode(FName(*ComponentName)); + if (!Node) + return MakeJsonError(FString::Printf(TEXT("Component '%s' not found in SCS."), *ComponentName)); + + FString RemovedClass = Node->ComponentTemplate ? Node->ComponentTemplate->GetClass()->GetName() : TEXT("Unknown"); + + SCS->RemoveNodeAndPromoteChildren(Node); + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint); + + TSharedPtr R = MakeShared(); + R->SetBoolField(TEXT("success"), true); + R->SetStringField(TEXT("component_name"), ComponentName); + R->SetStringField(TEXT("class"), RemovedClass); + R->SetStringField(TEXT("message"), FString::Printf(TEXT("Component '%s' (%s) removed."), *ComponentName, *RemovedClass)); + return SerializeJsonObj(R); +} + +// ─── SetComponentProperty UFUNCTION ────────────────────────────────────────── + +FString UMCPythonHelper::SetComponentProperty(UBlueprint* Blueprint, + const FString& ComponentName, const FString& PropertyName, const FString& Value) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + USimpleConstructionScript* SCS = Blueprint->SimpleConstructionScript; + if (!SCS) + return MakeJsonError(TEXT("Blueprint has no SimpleConstructionScript.")); + + USCS_Node* Node = SCS->FindSCSNode(FName(*ComponentName)); + if (!Node) + return MakeJsonError(FString::Printf(TEXT("Component '%s' not found in SCS."), *ComponentName)); + + UObject* Template = Node->ComponentTemplate; + if (!Template) + return MakeJsonError(FString::Printf(TEXT("Component '%s' has no template."), *ComponentName)); + + FProperty* Prop = Template->GetClass()->FindPropertyByName(FName(*PropertyName)); + if (!Prop) + return MakeJsonError(FString::Printf(TEXT("Property '%s' not found on component '%s'."), *PropertyName, *ComponentName)); + + Template->Modify(); + void* ValueAddr = Prop->ContainerPtrToValuePtr(Template); + const TCHAR* ImportResult = Prop->ImportText_Direct(*Value, ValueAddr, Template, PPF_None); + if (!ImportResult) + return MakeJsonError(FString::Printf(TEXT("Failed to set property '%s' to '%s'."), *PropertyName, *Value)); + + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); + + TSharedPtr R = MakeShared(); + R->SetBoolField(TEXT("success"), true); + R->SetStringField(TEXT("component"), ComponentName); + R->SetStringField(TEXT("property"), PropertyName); + R->SetStringField(TEXT("value"), Value); + return SerializeJsonObj(R); +} + +// ─── SetBlueprintNodePosition UFUNCTION ────────────────────────────────────── + +FString UMCPythonHelper::SetBlueprintNodePosition(UBlueprint* Blueprint, + const FString& GraphName, const FString& NodeName, float PosX, float PosY) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + UEdGraph* Graph = FindGraphByName(Blueprint, GraphName); + if (!Graph) + return MakeJsonError(FString::Printf(TEXT("Graph '%s' not found."), *GraphName)); + + UEdGraphNode* Node = FindBPNodeByName(Graph, NodeName); + if (!Node) + return MakeJsonError(FString::Printf(TEXT("Node '%s' not found in graph '%s'."), *NodeName, *GraphName)); + + Node->Modify(); + Node->NodePosX = (int32)PosX; + Node->NodePosY = (int32)PosY; + + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); + + TSharedPtr R = MakeShared(); + R->SetBoolField(TEXT("success"), true); + R->SetStringField(TEXT("node"), NodeName); + R->SetNumberField(TEXT("pos_x"), PosX); + R->SetNumberField(TEXT("pos_y"), PosY); + return SerializeJsonObj(R); +} + +// ─── SetBlueprintNodePinDefault ────────────────────────────────────────────── + +FString UMCPythonHelper::SetBlueprintNodePinDefault(UBlueprint* Blueprint, + const FString& GraphName, const FString& NodeName, + const FString& PinName, const FString& Value) +{ + if (!Blueprint) + return MakeJsonError(TEXT("Invalid Blueprint.")); + + UEdGraph* Graph = FindGraphByName(Blueprint, GraphName); + if (!Graph) + return MakeJsonError(FString::Printf(TEXT("Graph '%s' not found."), *GraphName)); + + UEdGraphNode* Node = FindBPNodeByName(Graph, NodeName); + if (!Node) + return MakeJsonError(FString::Printf(TEXT("Node '%s' not found."), *NodeName)); + + UEdGraphPin* Pin = FindPinByName(Node, PinName, EGPD_Input); + if (!Pin) + { + TArray Names; + for (UEdGraphPin* P : Node->Pins) { if (P && !P->bHidden && P->Direction == EGPD_Input) Names.Add(P->GetName()); } + return MakeJsonError(FString::Printf(TEXT("Input pin '%s' not found. Available: %s"), *PinName, *FString::Join(Names, TEXT(", ")))); + } + + // For object-type pins, try loading the asset + if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Object || + Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_SoftObject || + Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Class || + Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_SoftClass) + { + UObject* Asset = StaticLoadObject(UObject::StaticClass(), nullptr, *Value); + if (!Asset) + return MakeJsonError(FString::Printf(TEXT("Could not load asset: %s"), *Value)); + Pin->DefaultObject = Asset; + Pin->DefaultValue = TEXT(""); + } + else + { + Pin->DefaultValue = Value; + } + + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); + + TSharedPtr R = MakeShared(); + R->SetBoolField(TEXT("success"), true); + R->SetStringField(TEXT("message"), FString::Printf(TEXT("Set pin '%s' on '%s' to '%s'."), *PinName, *NodeName, *Value)); + return SerializeJsonObj(R); +} + + +// ─── SkeletalMesh / Skeleton Helpers ────────────────────────────────────────── + +FString UMCPythonHelper::GetSkeletonBones(USkeletalMesh* Mesh) +{ + TSharedPtr R = MakeShared(); + if (!Mesh) + { + R->SetBoolField(TEXT("success"), false); + R->SetStringField(TEXT("message"), TEXT("SkeletalMesh is null.")); + return SerializeJsonObj(R); + } + + const FReferenceSkeleton& Ref = Mesh->GetRefSkeleton(); + TArray> Bones; + for (int32 i = 0; i < Ref.GetNum(); ++i) + { + TSharedPtr B = MakeShared(); + B->SetStringField(TEXT("name"), Ref.GetBoneName(i).ToString()); + B->SetNumberField(TEXT("index"), i); + const int32 ParentIdx = Ref.GetParentIndex(i); + B->SetStringField(TEXT("parent"), ParentIdx >= 0 ? Ref.GetBoneName(ParentIdx).ToString() : TEXT("")); + Bones.Add(MakeShared(B)); + } + + R->SetBoolField(TEXT("success"), true); + R->SetNumberField(TEXT("bone_count"), Ref.GetNum()); + R->SetArrayField(TEXT("bones"), Bones); + return SerializeJsonObj(R); +} + +FString UMCPythonHelper::AddSkeletalMeshSocket(USkeletalMesh* Mesh, const FString& SocketName, + const FString& BoneName, + float LocationX, float LocationY, float LocationZ, + float RotationPitch, float RotationYaw, float RotationRoll) +{ + TSharedPtr R = MakeShared(); + if (!Mesh) + { + R->SetBoolField(TEXT("success"), false); + R->SetStringField(TEXT("message"), TEXT("SkeletalMesh is null.")); + return SerializeJsonObj(R); + } + if (SocketName.IsEmpty() || BoneName.IsEmpty()) + { + R->SetBoolField(TEXT("success"), false); + R->SetStringField(TEXT("message"), TEXT("SocketName and BoneName are required.")); + return SerializeJsonObj(R); + } + if (Mesh->FindSocket(FName(*SocketName)) != nullptr) + { + R->SetBoolField(TEXT("success"), false); + R->SetStringField(TEXT("message"), FString::Printf(TEXT("Socket '%s' already exists."), *SocketName)); + return SerializeJsonObj(R); + } + if (Mesh->GetRefSkeleton().FindBoneIndex(FName(*BoneName)) == INDEX_NONE) + { + R->SetBoolField(TEXT("success"), false); + R->SetStringField(TEXT("message"), FString::Printf(TEXT("Bone '%s' not found in skeleton."), *BoneName)); + return SerializeJsonObj(R); + } + + Mesh->Modify(); + USkeletalMeshSocket* Socket = NewObject(Mesh); + Socket->SocketName = FName(*SocketName); + Socket->BoneName = FName(*BoneName); + Socket->RelativeLocation = FVector(LocationX, LocationY, LocationZ); + Socket->RelativeRotation = FRotator(RotationPitch, RotationYaw, RotationRoll); + Mesh->AddSocket(Socket, false); + Mesh->MarkPackageDirty(); + + R->SetBoolField(TEXT("success"), true); + R->SetStringField(TEXT("socket_name"), SocketName); + R->SetStringField(TEXT("bone_name"), BoneName); + R->SetStringField(TEXT("message"), FString::Printf(TEXT("Added socket '%s' on bone '%s'."), *SocketName, *BoneName)); + return SerializeJsonObj(R); +} + +FString UMCPythonHelper::RemoveSkeletalMeshSocket(USkeletalMesh* Mesh, const FString& SocketName) +{ + TSharedPtr R = MakeShared(); + if (!Mesh) + { + R->SetBoolField(TEXT("success"), false); + R->SetStringField(TEXT("message"), TEXT("SkeletalMesh is null.")); + return SerializeJsonObj(R); + } + USkeletalMeshSocket* Socket = Mesh->FindSocket(FName(*SocketName)); + if (!Socket) + { + R->SetBoolField(TEXT("success"), false); + R->SetStringField(TEXT("message"), FString::Printf(TEXT("Socket '%s' not found."), *SocketName)); + return SerializeJsonObj(R); + } + + Mesh->Modify(); + TArray>& Sockets = Mesh->GetMeshOnlySocketList(); + Sockets.Remove(Socket); + Mesh->MarkPackageDirty(); + + R->SetBoolField(TEXT("success"), true); + R->SetStringField(TEXT("removed"), SocketName); + R->SetStringField(TEXT("message"), FString::Printf(TEXT("Removed socket '%s'."), *SocketName)); + return SerializeJsonObj(R); +} + +// ─── Response transport (python_call) ───────────────────────────────────────── + +static TOptional GMCPythonSubmittedResult; + +void UMCPythonHelper::SubmitResult(const FString& ResultJson) +{ + GMCPythonSubmittedResult = ResultJson; +} + +bool UMCPythonHelper::ConsumeSubmittedResult(FString& OutResult) +{ + if (GMCPythonSubmittedResult.IsSet()) + { + OutResult = MoveTemp(GMCPythonSubmittedResult.GetValue()); + GMCPythonSubmittedResult.Reset(); + return true; + } + return false; +} + +void UMCPythonHelper::ClearSubmittedResult() +{ + GMCPythonSubmittedResult.Reset(); +} + +// ─── Editor viewport projection ────────────────────────────────────────────── + +static FLevelEditorViewportClient* GetActiveLevelViewportClient() +{ + if (GCurrentLevelEditingViewportClient && GCurrentLevelEditingViewportClient->Viewport) + return GCurrentLevelEditingViewportClient; + if (GEditor) + { + for (FLevelEditorViewportClient* VC : GEditor->GetLevelViewportClients()) + if (VC && VC->Viewport && VC->Viewport->GetSizeXY().X > 0) + return VC; + } + return nullptr; +} + +static TArray> VectorToJsonArray(const FVector& V) +{ + TArray> A; + A.Add(MakeShareable(new FJsonValueNumber(V.X))); + A.Add(MakeShareable(new FJsonValueNumber(V.Y))); + A.Add(MakeShareable(new FJsonValueNumber(V.Z))); + return A; +} + +FString UMCPythonHelper::WorldToScreen(FVector WorldLocation) +{ + FLevelEditorViewportClient* VC = GetActiveLevelViewportClient(); + if (!VC) + return MakeJsonError(TEXT("No active level viewport.")); + + FSceneViewFamilyContext ViewFamily(FSceneViewFamily::ConstructionValues( + VC->Viewport, VC->GetScene(), VC->EngineShowFlags).SetRealtimeUpdate(VC->IsRealtime())); + FSceneView* View = VC->CalcSceneView(&ViewFamily); + if (!View) + return MakeJsonError(TEXT("Could not calculate the scene view.")); + + FVector2D Pixel; + const bool bInFront = View->WorldToPixel(WorldLocation, Pixel); + const FIntPoint Size = VC->Viewport->GetSizeXY(); + const bool bOnScreen = bInFront && Pixel.X >= 0 && Pixel.Y >= 0 && Pixel.X <= Size.X && Pixel.Y <= Size.Y; + + TSharedPtr R = MakeShareable(new FJsonObject()); + R->SetBoolField(TEXT("success"), true); + R->SetNumberField(TEXT("x"), Pixel.X); + R->SetNumberField(TEXT("y"), Pixel.Y); + R->SetBoolField(TEXT("visible"), bInFront); // in front of the camera (not clipped) + R->SetBoolField(TEXT("on_screen"), bOnScreen); // also within the viewport rect + R->SetNumberField(TEXT("viewport_width"), Size.X); + R->SetNumberField(TEXT("viewport_height"), Size.Y); + return SerializeJsonObj(R); +} + +FString UMCPythonHelper::ScreenToWorld(float ScreenX, float ScreenY, float Distance) +{ + FLevelEditorViewportClient* VC = GetActiveLevelViewportClient(); + if (!VC) + return MakeJsonError(TEXT("No active level viewport.")); + + FSceneViewFamilyContext ViewFamily(FSceneViewFamily::ConstructionValues( + VC->Viewport, VC->GetScene(), VC->EngineShowFlags).SetRealtimeUpdate(VC->IsRealtime())); + FSceneView* View = VC->CalcSceneView(&ViewFamily); + if (!View) + return MakeJsonError(TEXT("Could not calculate the scene view.")); + + FVector Origin, Direction; + View->DeprojectFVector2D(FVector2D(ScreenX, ScreenY), Origin, Direction); + const FVector Location = Origin + Direction * Distance; + + TSharedPtr R = MakeShareable(new FJsonObject()); + R->SetBoolField(TEXT("success"), true); + R->SetArrayField(TEXT("location"), VectorToJsonArray(Location)); + R->SetArrayField(TEXT("origin"), VectorToJsonArray(Origin)); + R->SetArrayField(TEXT("direction"), VectorToJsonArray(Direction)); + R->SetNumberField(TEXT("distance"), Distance); + return SerializeJsonObj(R); +} diff --git a/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelperInternal.h b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelperInternal.h new file mode 100644 index 0000000..c1bab38 --- /dev/null +++ b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelperInternal.h @@ -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 Obj = MakeShareable(new FJsonObject()); + Obj->SetBoolField(TEXT("success"), false); + Obj->SetStringField(TEXT("message"), Message); + FString Out; + TSharedRef> W = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(Obj.ToSharedRef(), W); + return Out; +} + +inline FString MakeJsonSuccess(const FString& Message) +{ + TSharedPtr Obj = MakeShareable(new FJsonObject()); + Obj->SetBoolField(TEXT("success"), true); + Obj->SetStringField(TEXT("message"), Message); + FString Out; + TSharedRef> W = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(Obj.ToSharedRef(), W); + return Out; +} + +inline FString SerializeJsonObj(TSharedPtr Obj) +{ + FString Out; + TSharedRef> 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; +} diff --git a/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelper_AnimGraph.cpp b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelper_AnimGraph.cpp new file mode 100644 index 0000000..7c14b99 --- /dev/null +++ b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelper_AnimGraph.cpp @@ -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 +static T* FindNodeOfType(UEdGraph* Graph) +{ + if (!Graph) return nullptr; + for (UEdGraphNode* N : Graph->Nodes) + if (T* Hit = Cast(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 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(StaticLoadObject(UAnimSequence::StaticClass(), nullptr, *AnimSequencePath)); + if (!Seq) + return MakeJsonError(FString::Printf(TEXT("AnimSequence not found: %s"), *AnimSequencePath)); + + UAnimGraphNode_Root* Root = FindNodeOfType(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 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) Value -> bCanEnterTransition. +static bool BuildFloatTransitionRule(UEdGraph* TransitionGraph, const FString& Var, const FString& Op, float Value) +{ + UAnimGraphNode_TransitionResult* Result = FindNodeOfType(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 GetCreator(*TransitionGraph); + UK2Node_VariableGet* GetNode = GetCreator.CreateNode(false); + GetNode->VariableReference.SetSelfMember(FName(*Var)); + GetNode->NodePosX = -500; + GetCreator.Finalize(); + + FGraphNodeCreator 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& Spec) +{ + UEdGraph* AnimGraph = FindGraphByName(AnimBP, TEXT("AnimGraph")); + if (!AnimGraph) + return MakeJsonError(TEXT("AnimGraph not found on this AnimBlueprint.")); + + const TArray>* 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 StateDefs; + for (const TSharedPtr& SV : *StatesJson) + { + const TSharedPtr 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(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> Warnings; + + // State machine node, linked to the Output Pose. + UAnimGraphNode_Root* Root = FindNodeOfType(AnimGraph); + UEdGraphPin* RootIn = Root ? FindPinByName(Root, TEXT("Result"), EGPD_Input) : nullptr; + + FGraphNodeCreator 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 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 StateByName; + int32 Col = 0; + for (const FStateDef& SD : StateDefs) + { + FGraphNodeCreator 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(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>* TransJson = nullptr; + if (Spec->TryGetArrayField(TEXT("transitions"), TransJson)) + { + for (const TSharedPtr& TV : *TransJson) + { + const TSharedPtr 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 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 R = MakeShareable(new FJsonObject()); + R->SetBoolField(TEXT("success"), true); + R->SetStringField(TEXT("state_machine"), SMNode->GetName()); + TArray> 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 Spec; + TSharedRef> Reader = TJsonReaderFactory<>::Create(SpecJson); + if (!FJsonSerializer::Deserialize(Reader, Spec) || !Spec.IsValid()) + return MakeJsonError(TEXT("Failed to parse spec JSON.")); + return BuildStateMachineFromSpec(AnimBP, Spec); +} diff --git a/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelper_BehaviorTree.cpp b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelper_BehaviorTree.cpp new file mode 100644 index 0000000..e020aca --- /dev/null +++ b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelper_BehaviorTree.cpp @@ -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 BTNodeInfoToJson(const FMCPythonBTNodeInfo& Info) +{ + TSharedPtr Obj = MakeShareable(new FJsonObject()); + Obj->SetStringField(TEXT("node_name"), Info.NodeName); + Obj->SetStringField(TEXT("node_class"), Info.NodeClass); + + if (Info.DecoratorClasses.Num() > 0) + { + TArray> DecArr; + for (int32 i = 0; i < Info.DecoratorClasses.Num(); ++i) + { + TSharedPtr 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> SvcArr; + for (int32 i = 0; i < Info.ServiceClasses.Num(); ++i) + { + TSharedPtr 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> 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 ResultObj = MakeShareable(new FJsonObject()); + ResultObj->SetBoolField(TEXT("success"), true); + ResultObj->SetObjectField(TEXT("root"), BTNodeInfoToJson(RootInfo)); + + FString OutputString; + TSharedRef> 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 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 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 PropsObj = MakeShareable(new FJsonObject()); + for (TFieldIterator PropIt(FoundNode->GetClass()); PropIt; ++PropIt) + { + FProperty* Prop = *PropIt; + if (!Prop->HasAnyPropertyFlags(CPF_Edit)) continue; + + FString ValueStr; + const void* ValueAddr = Prop->ContainerPtrToValuePtr(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(FoundNode); + if (CompNode) + { + JsonObj->SetNumberField(TEXT("child_count"), CompNode->Children.Num()); + + TArray> ServicesArr; + for (UBTService* Svc : CompNode->Services) + { + if (Svc) + { + TSharedPtr 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> 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(); + if (!Subsystem) + { + return TEXT("{\"success\":false,\"message\":\"AssetEditorSubsystem not available.\"}"); + } + + for (UObject* Asset : Subsystem->GetAllEditedAssets()) + { + UBehaviorTree* BT = Cast(Asset); + if (!BT) continue; + + IAssetEditorInstance* EditorInstance = Subsystem->FindEditorForAsset(Asset, false); + FAssetEditorToolkit* EditorToolkit = static_cast(EditorInstance); + if (!EditorToolkit) continue; + + TSharedPtr Tab = EditorToolkit->GetTabManager()->GetOwnerTab(); + if (!Tab.IsValid() || !Tab->IsForeground()) continue; + + FBehaviorTreeEditor* BTEditor = static_cast(EditorToolkit); + if (!BTEditor) continue; + + FGraphPanelSelectionSet SelectedNodes = BTEditor->GetSelectedNodes(); + + TArray> NodesArr; + for (UObject* NodeObj : SelectedNodes) + { + UBehaviorTreeGraphNode* GraphNode = Cast(NodeObj); + if (!GraphNode) continue; + + UBTNode* BTNode = Cast(GraphNode->NodeInstance); + if (!BTNode) continue; + + TSharedPtr 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(BTNode)) + NodeType = TEXT("composite"); + else if (Cast(BTNode)) + NodeType = TEXT("task"); + else if (Cast(BTNode)) + NodeType = TEXT("decorator"); + else if (Cast(BTNode)) + NodeType = TEXT("service"); + else + NodeType = TEXT("unknown"); + NodeJson->SetStringField(TEXT("node_type"), NodeType); + + // Serialize EditAnywhere properties + TSharedPtr PropsObj = MakeShareable(new FJsonObject()); + for (TFieldIterator PropIt(BTNode->GetClass()); PropIt; ++PropIt) + { + FProperty* Prop = *PropIt; + if (!Prop->HasAnyPropertyFlags(CPF_Edit)) continue; + + FString ValueStr; + const void* ValueAddr = Prop->ContainerPtrToValuePtr(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 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> 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 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& 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(Node); + + FStructProperty* StructProp = CastField(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(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& 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(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 Creator(*Graph); + GraphNode = Creator.CreateNode(false); + Creator.Finalize(); + } + else if (bIsComposite) + { + FGraphNodeCreator Creator(*Graph); + GraphNode = Creator.CreateNode(false); + Creator.Finalize(); + } + else if (bIsSubtreeTask) + { + FGraphNodeCreator Creator(*Graph); + GraphNode = Creator.CreateNode(false); + Creator.Finalize(); + } + else if (bIsTask) + { + FGraphNodeCreator 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& PropsObj = JsonNode->GetObjectField(TEXT("properties")); + SetBTNodeProperties(RuntimeNode, PropsObj); + } + + // Add decorators as sub-nodes + if (JsonNode->HasField(TEXT("decorators"))) + { + const TArray>& DecoratorsArr = JsonNode->GetArrayField(TEXT("decorators")); + for (const auto& DecVal : DecoratorsArr) + { + const TSharedPtr& 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(BT, DecClass); + if (DecObj->HasField(TEXT("properties"))) + { + SetBTNodeProperties(DecRuntime, DecObj->GetObjectField(TEXT("properties"))); + } + + UBehaviorTreeGraphNode_Decorator* DecGraphNode = + NewObject(Graph); + DecGraphNode->NodeInstance = DecRuntime; + GraphNode->AddSubNode(DecGraphNode, Graph); + } + } + + // Add services as sub-nodes + if (JsonNode->HasField(TEXT("services"))) + { + const TArray>& ServicesArr = JsonNode->GetArrayField(TEXT("services")); + for (const auto& SvcVal : ServicesArr) + { + const TSharedPtr& 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(BT, SvcClass); + if (SvcObj->HasField(TEXT("properties"))) + { + SetBTNodeProperties(SvcRuntime, SvcObj->GetObjectField(TEXT("properties"))); + } + + UBehaviorTreeGraphNode_Service* SvcGraphNode = + NewObject(Graph); + SvcGraphNode->NodeInstance = SvcRuntime; + GraphNode->AddSubNode(SvcGraphNode, Graph); + } + } + + // Recurse for children (only composites have children) + if (bIsComposite && JsonNode->HasField(TEXT("children"))) + { + const TArray>& ChildrenArr = JsonNode->GetArrayField(TEXT("children")); + UEdGraphPin* OutputPin = FindGraphPin(GraphNode, EGPD_Output); + + if (OutputPin) + { + for (const auto& ChildVal : ChildrenArr) + { + const TSharedPtr& 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 JsonObj; + TSharedRef> 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(BehaviorTree->BTGraph); + if (!BTGraph) + { + UBehaviorTreeGraph* NewGraph = NewObject(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(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 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 ResultObj = MakeShareable(new FJsonObject()); + ResultObj->SetBoolField(TEXT("success"), true); + ResultObj->SetStringField(TEXT("message"), TEXT("Behavior tree built successfully from JSON.")); + + FString ResultStr; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&ResultStr); + FJsonSerializer::Serialize(ResultObj.ToSharedRef(), Writer); + return ResultStr; +} + +// ─── ListBTNodeClasses UFUNCTION ───────────────────────────────────────────── + +FString UMCPythonHelper::ListBTNodeClasses() +{ + TArray> Composites, Tasks, Decorators, Services; + + for (TObjectIterator It; It; ++It) + { + UClass* Cls = *It; + if (Cls->HasAnyClassFlags(CLASS_Abstract | CLASS_Deprecated | CLASS_NewerVersionExists)) + continue; + + FString ClassName = Cls->GetName(); + TSharedPtr 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 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> Writer = TJsonWriterFactory<>::Create(&OutputString); + FJsonSerializer::Serialize(Result.ToSharedRef(), Writer); + return OutputString; +} diff --git a/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelper_UMG.cpp b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelper_UMG.cpp new file mode 100644 index 0000000..b3a0719 --- /dev/null +++ b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonHelper_UMG.cpp @@ -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 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(nullptr, **Path); + } + + static FString UmgErrorJson(const FString& Msg) + { + TSharedPtr Obj = MakeShared(); + Obj->SetBoolField(TEXT("success"), false); + Obj->SetStringField(TEXT("message"), Msg); + return SerializeJsonObj(Obj); + } +} + +FString UMCPythonHelper::UmgGetWidgetInfo(UBlueprint* WidgetBP) +{ + UWidgetBlueprint* WB = Cast(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 Root = MakeShared(); + Root->SetBoolField(TEXT("success"), true); + + if (WT->RootWidget) + Root->SetStringField(TEXT("root_widget"), WT->RootWidget->GetName()); + else + Root->SetField(TEXT("root_widget"), MakeShared()); + + TArray> WidgetArr; + WT->ForEachWidget([&](UWidget* W) { + TSharedPtr WObj = MakeShared(); + 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(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(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(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(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(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 Result = MakeShared(); + 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(WidgetBP); + if (!WB || !WB->WidgetTree) return nullptr; + return WB->WidgetTree->FindWidget(FName(*WidgetName)); +} + +FString UMCPythonHelper::UmgRemoveWidget(UBlueprint* WidgetBP, const FString& WidgetName) +{ + UWidgetBlueprint* WB = Cast(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(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 Result = MakeShared(); + 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(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 Result = MakeShared(); + 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(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(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 R = MakeShared(); + 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(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(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 R = MakeShared(); + 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(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(Widget); + Prop->ExportTextItem_Direct(ValueStr, ValueAddr, nullptr, Widget, PPF_None); + + TSharedPtr R = MakeShared(); + 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(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(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 R = MakeShared(); + 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(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(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(Widget->GetParent())) + OldParent->RemoveChild(Widget); + else if (WT->RootWidget == Widget) + WT->RootWidget = nullptr; + NewParent->AddChild(Widget); + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(WB); + TSharedPtr R = MakeShared(); + 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(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(WrapperClass, FName(*WrapperName)); + if (!Wrapper) return UmgErrorJson(FString::Printf(TEXT("Failed to construct wrapper '%s'."), *WrapperName)); + Wrapper->bIsVariable = true; + + if (UPanelWidget* OldParent = Cast(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 R = MakeShared(); + 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(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(NewClass, FName(*NewName)); + if (!NewWidget) return UmgErrorJson(FString::Printf(TEXT("Failed to construct widget '%s'."), *NewName)); + NewWidget->bIsVariable = true; + + if (UPanelWidget* OldParent = Cast(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 R = MakeShared(); + 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(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> Events; + for (TFieldIterator It(W->GetClass()); It; ++It) + Events.Add(MakeShareable(new FJsonValueString(It->GetName()))); + + TSharedPtr R = MakeShared(); + 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(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(W->GetClass(), FName(*EventName))) + { + TArray Avail; + for (TFieldIterator 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(WB->SkeletonGeneratedClass, FName(*WidgetName)); + if (!VarProp) + { + FKismetEditorUtilities::CompileBlueprint(WB); + VarProp = FindFProperty(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 R = MakeShared(); + 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); +} diff --git a/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonTcpServer.cpp b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonTcpServer.cpp new file mode 100644 index 0000000..631858d --- /dev/null +++ b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/MCPythonTcpServer.cpp @@ -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 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 AllLines; + LogContent.ParseIntoArrayLines(AllLines, /*bCullEmpty=*/true); + + TArray 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& 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 JsonObj, FSocket* ClientSocket) + { + HandleLiveCodingCompile(JsonObj, ClientSocket); + }); +} + +void FMCPythonTcpServer::SendJsonResponse(TSharedPtr ResponseJson, FSocket* ClientSocket, bool bCloseSocket) +{ + FString ResultJson; + TSharedRef> 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(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 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 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(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 JsonObj; + TSharedRef> 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* ArgsJsonObjectPtr = nullptr; // Changed from TArray>* + 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 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 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> 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 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> 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 ErrorResponse = MakeShareable(new FJsonObject); + ErrorResponse->SetBoolField(TEXT("success"), false); + ErrorResponse->SetStringField(TEXT("message"), ResultMsg); + FString ErrorJson; + TSharedRef> 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 ErrorResponse = MakeShareable(new FJsonObject); + ErrorResponse->SetBoolField(TEXT("success"), false); + ErrorResponse->SetStringField(TEXT("message"), ResultMsg); + FString ErrorJson; + TSharedRef> 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 ErrorResponse = MakeShareable(new FJsonObject); + ErrorResponse->SetBoolField(TEXT("success"), false); + ErrorResponse->SetStringField(TEXT("message"), ResultMsg); + ErrorResponse->SetStringField(TEXT("raw_data"), Data); + FString ErrorJson; + TSharedRef> 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 JsonObj, FSocket* ClientSocket) +{ + ILiveCodingModule* LiveCoding = FModuleManager::GetModulePtr(TEXT("LiveCoding")); + if (!LiveCoding) + { + TSharedPtr 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 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 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); +} \ No newline at end of file diff --git a/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/UnrealMCPython.cpp b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/UnrealMCPython.cpp new file mode 100644 index 0000000..e6229ca --- /dev/null +++ b/Plugins/UnrealMCPython/Source/UnrealMCPython/Private/UnrealMCPython.cpp @@ -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(); + TcpServer->Start(SERVER_IP, SERVER_PORT); +} + +void FUnrealMCPythonModule::ShutdownModule() +{ + if (TcpServer) + { + TcpServer->Stop(); + TcpServer.Reset(); + } +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FUnrealMCPythonModule, UnrealMCPython) \ No newline at end of file diff --git a/Plugins/UnrealMCPython/Source/UnrealMCPython/Public/MCPythonHelper.h b/Plugins/UnrealMCPython/Source/UnrealMCPython/Public/MCPythonHelper.h new file mode 100644 index 0000000..9769727 --- /dev/null +++ b/Plugins/UnrealMCPython/Source/UnrealMCPython/Public/MCPythonHelper.h @@ -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 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 Pins; +}; + +USTRUCT(BlueprintType) +struct FMCPythonBTNodeInfo +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category="MCPython") + FString NodeName; + + UPROPERTY(BlueprintReadOnly, Category="MCPython") + FString NodeClass; + + UPROPERTY(BlueprintReadOnly, Category="MCPython") + TArray DecoratorClasses; + + UPROPERTY(BlueprintReadOnly, Category="MCPython") + TArray DecoratorNames; + + UPROPERTY(BlueprintReadOnly, Category="MCPython") + TArray ServiceClasses; + + UPROPERTY(BlueprintReadOnly, Category="MCPython") + TArray ServiceNames; + + TArray Children; +}; + +UCLASS() +class UNREALMCPYTHON_API UMCPythonHelper : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() +public: + // 모든 에디터에서 열려있는 에셋 반환 + UFUNCTION(BlueprintCallable, Category="Editor|MCPython", CallInEditor) + static TArray GetAllEditedAssets(); + + // (예시) 선택된 블루프린트 노드 반환 + UFUNCTION(BlueprintCallable, Category="Editor|MCPython", CallInEditor) + static TArray GetSelectedBlueprintNodes(); + + // 선택된 블루프린트 노드의 연결 정보 반환 + UFUNCTION(BlueprintCallable, Category="Editor|MCPython", CallInEditor) + static TArray 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); +}; diff --git a/Plugins/UnrealMCPython/Source/UnrealMCPython/Public/MCPythonTcpServer.h b/Plugins/UnrealMCPython/Source/UnrealMCPython/Public/MCPythonTcpServer.h new file mode 100644 index 0000000..7cb45dc --- /dev/null +++ b/Plugins/UnrealMCPython/Source/UnrealMCPython/Public/MCPythonTcpServer.h @@ -0,0 +1,59 @@ +// Copyright (c) 2025 GenOrca (by zenoengine). All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Interfaces/IPv4/IPv4Endpoint.h" +#include +#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 JsonObj, FSocket* ClientSocket)>; + +class FMCPythonTcpServer +{ +public: + FMCPythonTcpServer(); + ~FMCPythonTcpServer(); + + bool Start(const FString& InIP, uint16 InPort); + void Stop(); + +private: + TSharedPtr TcpListener; + FSocket* ListenSocket = nullptr; + bool bShouldRun = false; + FPythonLogCapture LogCapture; + TMap NativeHandlers; + + void RegisterNativeHandlers(); + bool HandleIncomingConnection(FSocket* ClientSocket, const FIPv4Endpoint& ClientEndpoint); + void ProcessDataOnGameThread(const FString& Data, FSocket* ClientSocket, const FIPv4Endpoint& ClientEndpoint); + void SendJsonResponse(TSharedPtr ResponseJson, FSocket* ClientSocket, bool bCloseSocket = true); + + // Native command handlers + void HandleLiveCodingCompile(TSharedPtr JsonObj, FSocket* ClientSocket); +}; \ No newline at end of file diff --git a/Plugins/UnrealMCPython/Source/UnrealMCPython/Public/UnrealMCPython.h b/Plugins/UnrealMCPython/Source/UnrealMCPython/Public/UnrealMCPython.h new file mode 100644 index 0000000..49f8ae3 --- /dev/null +++ b/Plugins/UnrealMCPython/Source/UnrealMCPython/Public/UnrealMCPython.h @@ -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 TcpServer; +}; diff --git a/Plugins/UnrealMCPython/Source/UnrealMCPython/UnrealMCPython.Build.cs b/Plugins/UnrealMCPython/Source/UnrealMCPython/UnrealMCPython.Build.cs new file mode 100644 index 0000000..6e9ee9f --- /dev/null +++ b/Plugins/UnrealMCPython/Source/UnrealMCPython/UnrealMCPython.Build.cs @@ -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 ... + } + ); + } +} diff --git a/Plugins/UnrealMCPython/UnrealMCPython.uplugin b/Plugins/UnrealMCPython/UnrealMCPython.uplugin new file mode 100644 index 0000000..a3a01a6 --- /dev/null +++ b/Plugins/UnrealMCPython/UnrealMCPython.uplugin @@ -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 + } + ] +} \ No newline at end of file diff --git a/SpaceGame.uproject b/SpaceGame.uproject index 3db1047..b3e9819 100644 --- a/SpaceGame.uproject +++ b/SpaceGame.uproject @@ -10,6 +10,21 @@ "TargetAllowList": [ "Editor" ] + }, + { + "Name": "ModelContextProtocol", + "Enabled": true, + "TargetAllowList": [ + "Editor" + ] + }, + { + "Name": "PythonScriptPlugin", + "Enabled": true + }, + { + "Name": "UnrealMCPython", + "Enabled": true } ] } \ No newline at end of file diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 0000000..7b004e5 --- /dev/null +++ b/mcp-server/.gitignore @@ -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 \ No newline at end of file diff --git a/mcp-server/LICENSE b/mcp-server/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/mcp-server/LICENSE @@ -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. diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..dbfa418 --- /dev/null +++ b/mcp-server/README.md @@ -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? + +

+ + + + +

+ +- 🧠 **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. diff --git a/mcp-server/generate_catalog.py b/mcp-server/generate_catalog.py new file mode 100644 index 0000000..e61dbff --- /dev/null +++ b/mcp-server/generate_catalog.py @@ -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.) +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() diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml new file mode 100644 index 0000000..8923455 --- /dev/null +++ b/mcp-server/pyproject.toml @@ -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"] \ No newline at end of file diff --git a/mcp-server/setup.py b/mcp-server/setup.py new file mode 100644 index 0000000..ff091ff --- /dev/null +++ b/mcp-server/setup.py @@ -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" + ], + }, +) \ No newline at end of file diff --git a/mcp-server/src/unreal_mcp/core.py b/mcp-server/src/unreal_mcp/core.py new file mode 100644 index 0000000..7092ce6 --- /dev/null +++ b/mcp-server/src/unreal_mcp/core.py @@ -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": , "message": ..., "result": ""} + + 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__} + ) diff --git a/mcp-server/src/unreal_mcp/dispatcher.py b/mcp-server/src/unreal_mcp/dispatcher.py new file mode 100644 index 0000000..1c5085c --- /dev/null +++ b/mcp-server/src/unreal_mcp/dispatcher.py @@ -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_(**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 diff --git a/mcp-server/src/unreal_mcp/dispatchers/__init__.py b/mcp-server/src/unreal_mcp/dispatchers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp-server/src/unreal_mcp/dispatchers/_catalog.py b/mcp-server/src/unreal_mcp/dispatchers/_catalog.py new file mode 100644 index 0000000..fe95a47 --- /dev/null +++ b/mcp-server/src/unreal_mcp/dispatchers/_catalog.py @@ -0,0 +1,1061 @@ +# Copyright (c) 2025 GenOrca. All Rights Reserved. +# +# AUTO-GENERATED by generate_catalog.py — do not edit by hand. +# Regenerate: python generate_catalog.py + +CATALOG = { + 'actor': { + 'add_actor_tag': { + 'params': 'actor_label, tag', + 'doc': 'Adds a tag to an actor (Actor.Tags).', + }, + 'attach_actor': { + 'params': "child_label, parent_label, socket_name=''", + 'doc': 'Attaches one actor to another (keeps world transform). Optional socket on the parent.', + }, + 'delete_by_label': { + 'params': 'actor_label', + 'doc': 'Deletes an actor with the specified name from the current level.', + }, + 'detach_actor': { + 'params': 'actor_label', + 'doc': 'Detaches an actor from its parent (keeps world transform).', + }, + 'duplicate_actor': { + 'params': 'actor_label, offset', + 'doc': 'Duplicates a specific actor (by label) with an optional [x,y,z] offset.', + }, + 'duplicate_selected': { + 'params': 'offset', + 'doc': 'Duplicates all selected actors in the editor and applies a position offset to each duplicate.', + }, + 'get_actor_bounds': { + 'params': 'actor_label', + 'doc': "Returns an actor's world-space bounds (origin + box extent).", + }, + 'get_actor_folder': { + 'params': 'actor_label', + 'doc': 'Returns the World Outliner folder path of an actor.', + }, + 'get_actor_tags': { + 'params': 'actor_label', + 'doc': 'Returns the gameplay tags (Actor.Tags) of an actor.', + }, + 'get_actors_of_class': { + 'params': 'class_path', + 'doc': "Lists labels of level actors of the given class path (e.g. '/Script/Engine.PointLight').", + }, + 'get_all_details': { + 'params': '', + 'doc': 'Lists all actors in the current level with detailed information including', + }, + 'get_attached_actors': { + 'params': 'actor_label', + 'doc': 'Lists the labels of actors attached to the given actor.', + }, + 'get_component_property': { + 'params': 'actor_label, component_name, property_name', + 'doc': 'Reads a property on a named component of a live level actor.', + }, + 'get_in_view_frustum': { + 'params': '', + 'doc': "Retrieves a list of actors that are potentially visible within the active editor viewport's frustum.", + }, + 'get_property': { + 'params': 'actor_label, property_name', + 'doc': 'Gets a property value from an actor using get_editor_property().', + }, + 'get_selected_actors': { + 'params': '', + 'doc': 'Lists the currently selected level actors (label + class).', + }, + 'get_transform': { + 'params': 'actor_label', + 'doc': "Returns an actor's world location, rotation, and scale.", + }, + 'invert_selection': { + 'params': '', + 'doc': 'Inverts the selection of actors in the current level.', + }, + 'line_trace': { + 'params': "ray_start, ray_end, trace_channel='Visibility', actors_to_ignore_labels, trace_complex=True", + 'doc': 'Performs a line trace (raycast) and returns hit information without spawning anything.', + }, + 'list_actor_components': { + 'params': 'actor_label', + 'doc': 'Lists the components on an actor (name + class).', + }, + 'list_all_with_locations': { + 'params': '', + 'doc': 'Lists all actors in the current level along with their world locations.', + }, + 'remove_actor_tag': { + 'params': 'actor_label, tag', + 'doc': 'Removes a tag from an actor (Actor.Tags).', + }, + 'rename_actor': { + 'params': 'actor_label, new_label', + 'doc': 'Renames an actor (changes its World Outliner label).', + }, + 'select_actors': { + 'params': 'actor_labels', + 'doc': 'Selects the given actors by label in the editor (replaces current selection).', + }, + 'select_all': { + 'params': '', + 'doc': 'Selects all actors in the current level.', + }, + 'set_actor_folder': { + 'params': 'actor_label, folder_path', + 'doc': 'Sets the World Outliner folder path of an actor.', + }, + 'set_actor_hidden': { + 'params': 'actor_label, hidden', + 'doc': 'Shows/hides an actor in the editor viewport (temporary editor visibility).', + }, + 'set_component_property': { + 'params': 'actor_label, component_name, property_name, value', + 'doc': "Sets a property on a named component of a live level actor (e.g. PointLightComponent 'intensity').", + }, + 'set_location': { + 'params': 'actor_label, location', + 'doc': '', + }, + 'set_property': { + 'params': 'actor_label, property_name, value', + 'doc': 'Sets a property value on an actor using set_editor_property().', + }, + 'set_rotation': { + 'params': 'actor_label, rotation', + 'doc': '', + }, + 'set_scale': { + 'params': 'actor_label, scale', + 'doc': '', + }, + 'set_transform': { + 'params': 'actor_label, location, rotation, scale', + 'doc': 'Sets the transform (location, rotation, scale) of a specified actor.', + }, + 'spawn_from_class': { + 'params': 'class_path, location, rotation', + 'doc': 'Spawns an actor from the specified class path at the given location and rotation', + }, + 'spawn_from_object': { + 'params': 'asset_path, location', + 'doc': 'Spawns an actor from the specified asset path at the given location.', + }, + 'spawn_on_surface_raycast': { + 'params': "asset_or_class_path, ray_start, ray_end, is_class_path=True, desired_rotation, location_offset, trace_channel='Visibility', actors_to_ignore_labels", + 'doc': '', + }, + }, + 'anim_blueprint': { + 'add_anim_graph_sequence_player': { + 'params': 'asset_path, anim_sequence_path, link_to_output_pose=True', + 'doc': 'Adds a looping Sequence Player to the AnimGraph, optionally wired to the Output Pose.', + }, + 'build_anim_state_machine': { + 'params': 'asset_path, spec', + 'doc': 'Builds an arbitrary AnimGraph state machine from a spec: states[{name,anim?}], entry?, transitions[{from,to,var?,op?,value?}].', + }, + 'create_anim_blueprint': { + 'params': "asset_path, skeleton_path, parent_class_path='/Script/Engine.AnimInstance'", + 'doc': 'Creates an Animation Blueprint bound to a Skeleton (parent defaults to AnimInstance).', + }, + 'get_anim_blueprint_info': { + 'params': 'asset_path', + 'doc': "Returns an AnimBlueprint's target skeleton, generated class, and graph names.", + }, + }, + 'animation': { + 'add_float_curve': { + 'params': 'asset_path, curve_name, time_seconds, value', + 'doc': 'Adds a float curve to an AnimSequence, optionally with an initial key at time_seconds=value.', + }, + 'add_notify_track': { + 'params': 'asset_path, track_name', + 'doc': 'Adds a notify track to an AnimSequence.', + }, + 'add_socket': { + 'params': 'asset_path, socket_name, bone_name, location, rotation', + 'doc': 'Adds a socket on a bone of a SkeletalMesh. location=[x,y,z], rotation=[pitch,yaw,roll].', + }, + 'add_sync_marker': { + 'params': 'asset_path, track_name, marker_name, time_seconds', + 'doc': 'Adds a sync marker at a time (seconds) on a notify track. Creates the track if needed.', + }, + 'find_socket': { + 'params': 'asset_path, socket_name', + 'doc': 'Returns details of a named socket on a SkeletalMesh, or success=False if not found.', + }, + 'get_anim_sequence_info': { + 'params': 'asset_path', + 'doc': 'Returns length, frame count, approximate fps, and skeleton path of an AnimSequence.', + }, + 'get_skeletal_mesh_info': { + 'params': 'asset_path', + 'doc': 'Returns the skeleton path, socket count, and material-slot count of a SkeletalMesh.', + }, + 'get_skeleton_info': { + 'params': 'asset_path', + 'doc': 'Returns curve metadata names for a Skeleton asset.', + }, + 'list_bones': { + 'params': 'asset_path', + 'doc': 'Lists reference-skeleton bones (name, index, parent) of a SkeletalMesh.', + }, + 'list_curves': { + 'params': 'asset_path', + 'doc': 'Lists float animation curve names on an AnimSequence.', + }, + 'list_notifies': { + 'params': 'asset_path', + 'doc': 'Lists notify event names on an AnimSequence.', + }, + 'list_notify_tracks': { + 'params': 'asset_path', + 'doc': 'Lists the notify track names on an AnimSequence.', + }, + 'list_sockets': { + 'params': 'asset_path', + 'doc': 'Lists sockets on a SkeletalMesh (name, bone, relative location/rotation).', + }, + 'list_sync_markers': { + 'params': 'asset_path', + 'doc': 'Lists sync markers (name + time) on an AnimSequence.', + }, + 'remove_curve': { + 'params': 'asset_path, curve_name', + 'doc': 'Removes a float curve from an AnimSequence.', + }, + 'remove_notify_track': { + 'params': 'asset_path, track_name', + 'doc': 'Removes a notify track (and its notifies) from an AnimSequence.', + }, + 'remove_socket': { + 'params': 'asset_path, socket_name', + 'doc': 'Removes a named socket from a SkeletalMesh.', + }, + }, + 'asset': { + 'asset_exists': { + 'params': 'asset_path', + 'doc': 'Returns whether an asset exists at the given path.', + }, + 'delete_asset': { + 'params': 'asset_path', + 'doc': 'Deletes an asset from the content browser.', + }, + 'delete_directory': { + 'params': 'directory_path', + 'doc': 'Deletes a content-browser directory and its assets.', + }, + 'duplicate_asset': { + 'params': 'source_path, dest_path', + 'doc': 'Duplicates an asset to a new content-browser path.', + }, + 'export_fbx': { + 'params': 'asset_path, file_path', + 'doc': 'Exports a StaticMesh, SkeletalMesh, or AnimSequence asset to an FBX file.', + }, + 'find_by_query': { + 'params': 'name, asset_type', + 'doc': "Returns a JSON list of asset paths under '/Game' matching the given query dict.", + }, + 'find_referencers': { + 'params': 'asset_path', + 'doc': 'Lists packages that reference the given asset.', + }, + 'get_asset_info': { + 'params': 'asset_path', + 'doc': 'Returns class and package info for an asset.', + }, + 'get_dependencies': { + 'params': 'asset_path', + 'doc': 'Lists packages that the given asset depends on (references).', + }, + 'get_gltf_import_status': { + 'params': 'destination_path', + 'doc': 'Polls a scheduled glTF import; returns done + imported assets once Interchange finishes.', + }, + 'get_metadata_tag': { + 'params': 'asset_path, tag', + 'doc': 'Reads a metadata tag value on an asset (empty string if unset).', + }, + 'get_static_mesh_details': { + 'params': 'asset_path', + 'doc': 'Retrieves the bounding box and dimensions of a static mesh asset.', + }, + 'import_fbx': { + 'params': "file_path, destination_path, destination_name='', as_skeletal=False, import_materials=False, import_textures=False, import_animations=False", + 'doc': 'Imports an FBX file as a Static/Skeletal mesh using the legacy FBX importer.', + }, + 'import_gltf': { + 'params': 'file_path, destination_path', + 'doc': 'Imports a .glb/.gltf via Interchange, deferred to the editor tick. Poll get_gltf_import_status for the result.', + }, + 'import_texture': { + 'params': "file_path, destination_path, destination_name=''", + 'doc': 'Imports an image file (PNG/JPG/TGA...) as a Texture2D using the legacy texture importer.', + }, + 'list_assets': { + 'params': 'directory_path, recursive=True', + 'doc': 'Lists asset paths under a content directory.', + }, + 'make_directory': { + 'params': 'directory_path', + 'doc': 'Creates a content-browser directory.', + }, + 'remove_metadata_tag': { + 'params': 'asset_path, tag', + 'doc': 'Removes a metadata tag from an asset.', + }, + 'rename_asset': { + 'params': 'source_path, dest_path', + 'doc': 'Renames/moves an asset to a new content-browser path.', + }, + 'save_asset': { + 'params': 'asset_path', + 'doc': 'Saves an asset to disk.', + }, + 'set_metadata_tag': { + 'params': 'asset_path, tag, value', + 'doc': 'Sets a metadata tag value on an asset.', + }, + }, + 'behavior_tree': { + 'add_blackboard_key': { + 'params': 'asset_path, key_name, key_type, instance_synced=False', + 'doc': 'Adds a new key to a Blackboard asset.', + }, + 'build_behavior_tree': { + 'params': 'asset_path, tree_structure', + 'doc': 'Builds a complete Behavior Tree from a JSON structure.', + }, + 'create_behavior_tree': { + 'params': 'asset_path, blackboard_path', + 'doc': 'Creates a new empty Behavior Tree asset.', + }, + 'create_blackboard': { + 'params': 'asset_path, parent_path', + 'doc': 'Creates a new Blackboard Data asset.', + }, + 'get_behavior_tree_structure': { + 'params': 'asset_path', + 'doc': 'Returns the full tree structure of a Behavior Tree asset as JSON.', + }, + 'get_blackboard_data': { + 'params': 'asset_path', + 'doc': 'Reads all keys from a Blackboard asset.', + }, + 'get_bt_node_details': { + 'params': 'asset_path, node_name', + 'doc': 'Retrieves detailed properties of a specific node in a Behavior Tree.', + }, + 'get_selected_bt_nodes': { + 'params': '', + 'doc': 'Returns details of selected nodes in the currently open BT editor.', + }, + 'list_behavior_trees': { + 'params': '', + 'doc': 'Lists all Behavior Tree assets under /Game.', + }, + 'list_bt_node_classes': { + 'params': '', + 'doc': 'Lists all available BT node classes (composites, tasks, decorators, services).', + }, + 'remove_blackboard_key': { + 'params': 'asset_path, key_name', + 'doc': 'Removes a key from a Blackboard asset.', + }, + 'set_blackboard_to_behavior_tree': { + 'params': 'bt_path, bb_path', + 'doc': 'Links a Blackboard asset to a Behavior Tree.', + }, + }, + 'blueprint': { + 'add_blueprint_node': { + 'params': "asset_path, graph_name='EventGraph', node_json", + 'doc': 'Adds a single node to a Blueprint graph.', + }, + 'add_component_to_blueprint': { + 'params': "asset_path, component_class_path, component_name, location_x=0.0, location_y=0.0, location_z=0.0, rotation_pitch=0.0, rotation_yaw=0.0, rotation_roll=0.0, parent_component_name=''", + 'doc': "Adds a component to a Blueprint's SCS.", + }, + 'add_variable': { + 'params': "asset_path, variable_name, variable_type='real'", + 'doc': 'Adds a member variable to a Blueprint. variable_type: int, byte, bool, real (float), name, string, text.', + }, + 'auto_layout_graph': { + 'params': "asset_path, graph_name='EventGraph', x_step=380.0, y_step=200.0", + 'doc': 'Auto-lays out all nodes in a Blueprint graph using DAG topological sort.', + }, + 'build_blueprint_graph': { + 'params': "asset_path, graph_name='EventGraph', graph_structure", + 'doc': 'Builds a Blueprint graph from JSON adjacency list.', + }, + 'compile_blueprint': { + 'params': 'asset_path', + 'doc': 'Compiles a Blueprint and returns the result.', + }, + 'connect_blueprint_pins': { + 'params': "asset_path, graph_name='EventGraph', source_node, source_pin, target_node, target_pin", + 'doc': 'Connects two pins in a Blueprint graph.', + }, + 'create_blueprint': { + 'params': "asset_path, parent_class_path='/Script/Engine.Actor'", + 'doc': 'Creates a Blueprint asset with the given parent class (default Actor).', + }, + 'get_blueprint_graph_info': { + 'params': "asset_path, graph_name='EventGraph'", + 'doc': 'Returns the full graph info for a Blueprint graph.', + }, + 'get_selected_bp_node_infos': { + 'params': '', + 'doc': 'Returns compact blueprint node info optimized for LLM token efficiency.', + }, + 'get_selected_bp_nodes': { + 'params': '', + 'doc': 'Returns information about currently selected blueprint nodes in the editor.', + }, + 'list_blueprint_components': { + 'params': 'asset_path', + 'doc': 'Lists all SCS components on a Blueprint.', + }, + 'list_blueprint_variables': { + 'params': 'asset_path', + 'doc': 'Lists all variables defined in a Blueprint.', + }, + 'list_callable_functions': { + 'params': "asset_path, filter=''", + 'doc': 'Lists callable functions available in a Blueprint context.', + }, + 'remove_blueprint_node': { + 'params': "asset_path, graph_name='EventGraph', node_name", + 'doc': 'Removes a node from a Blueprint graph.', + }, + 'remove_component_from_blueprint': { + 'params': 'asset_path, component_name', + 'doc': "Removes a component by variable name from a Blueprint's SCS.", + }, + 'set_blueprint_node_position': { + 'params': "asset_path, graph_name='EventGraph', node_name, pos_x=0.0, pos_y=0.0", + 'doc': 'Sets the canvas position of a node in a Blueprint graph.', + }, + 'set_component_property': { + 'params': 'asset_path, component_name, property_name, value', + 'doc': "Sets a property on a component template in a Blueprint's SCS.", + }, + 'set_variable_flags': { + 'params': 'asset_path, variable_name, instance_editable, expose_on_spawn', + 'doc': "Sets a Blueprint variable's 'Instance Editable' and/or 'Expose On Spawn' flags.", + }, + }, + 'control_rig': { + 'add_rig_bone': { + 'params': "asset_path, bone_name, parent_name='', parent_type='bone', location", + 'doc': 'Adds a bone to a Control Rig hierarchy under an optional parent (requires the ControlRig plugin).', + }, + 'add_rig_null': { + 'params': "asset_path, null_name, parent_name='', parent_type='bone', location", + 'doc': 'Adds a null (group transform) to a Control Rig hierarchy (requires the ControlRig plugin).', + }, + 'add_unit_node': { + 'params': "asset_path, struct_path, method='Execute', pos_x=0.0, pos_y=0.0", + 'doc': "Adds a RigVM unit node by struct path (e.g. '/Script/ControlRig.RigUnit_GetTransform') to a Control Rig graph (requires the ControlRig plugin).", + }, + 'create_control_rig': { + 'params': 'asset_path, skeletal_mesh_path', + 'doc': 'Creates a Control Rig at asset_path; with a skeletal mesh, imports its bones and sets it as preview (requires the ControlRig plugin).', + }, + 'get_control_rig_info': { + 'params': 'asset_path', + 'doc': 'Returns element counts by type and the preview mesh of a Control Rig (requires the ControlRig plugin).', + }, + 'recompile_control_rig': { + 'params': 'asset_path', + 'doc': "Recompiles a Control Rig's VM and saves it (requires the ControlRig plugin).", + }, + }, + 'data_table': { + 'create_data_table': { + 'params': 'asset_path, row_struct_path', + 'doc': "Creates a DataTable asset with the given row struct (e.g. '/Script/MyModule.MyRow' or a UserDefinedStruct path).", + }, + 'does_row_exist': { + 'params': 'asset_path, row_name', + 'doc': 'Returns whether a row exists in a DataTable.', + }, + 'export_to_csv': { + 'params': 'asset_path', + 'doc': 'Returns all rows of a DataTable as a CSV string.', + }, + 'get_column_names': { + 'params': 'asset_path', + 'doc': "Lists the column (property) names of a DataTable's row struct.", + }, + 'get_row_names': { + 'params': 'asset_path', + 'doc': 'Lists the row names of a DataTable.', + }, + 'get_rows_as_json': { + 'params': 'asset_path', + 'doc': "Returns all rows of a DataTable as a JSON string (under the 'rows' field).", + }, + 'remove_row': { + 'params': 'asset_path, row_name', + 'doc': 'Removes a row from a DataTable by name.', + }, + 'set_rows_from_json': { + 'params': 'asset_path, json_string', + 'doc': "Replaces a DataTable's rows from a JSON string (array of row objects with a 'Name' key).", + }, + }, + 'editor': { + 'close_asset_editor': { + 'params': 'asset_path', + 'doc': 'Closes any open editor for the given asset.', + }, + 'create_proxy_actor': { + 'params': 'actor_labels, base_package_name, screen_size=300, destroy_source_actors=False', + 'doc': 'Bakes static mesh actors into ONE simplified proxy mesh (Proxy Geometry tool) and spawns it.', + }, + 'get_open_assets': { + 'params': '', + 'doc': 'Lists assets that currently have an editor open.', + }, + 'get_selected_assets': { + 'params': '', + 'doc': 'Gets the set of currently selected assets.', + }, + 'join_actors': { + 'params': "actor_labels, new_actor_label=''", + 'doc': 'Joins static mesh actors into one actor with multiple components (no new mesh asset is baked).', + }, + 'merge_actors': { + 'params': 'actor_labels, base_package_name, destroy_source_actors=False', + 'doc': 'Merges static mesh actors into ONE new static mesh asset + actor (geometry is baked together).', + }, + 'open_editor_for_asset': { + 'params': 'asset_path', + 'doc': 'Opens the asset-specific editor (Blueprint, Material, etc.) for an asset.', + }, + 'replace_mesh_on_selected': { + 'params': 'mesh_to_be_replaced_path, new_mesh_path', + 'doc': "Replaces static meshes on components of selected actors using Unreal's batch API if available.", + }, + 'replace_mesh_on_specified': { + 'params': 'actor_paths, mesh_to_be_replaced_path, new_mesh_path', + 'doc': "Replaces static meshes on components of specified actors using Unreal's batch API if available.", + }, + 'replace_mtl_on_selected': { + 'params': 'material_to_be_replaced_path, new_material_path', + 'doc': '', + }, + 'replace_mtl_on_specified': { + 'params': 'actor_paths, material_to_be_replaced_path, new_material_path', + 'doc': '', + }, + 'replace_selected_with_bp': { + 'params': 'blueprint_asset_path', + 'doc': "Replaces the currently selected actors with new actors spawned from a specified Blueprint asset path using Unreal's official API.", + }, + }, + 'game': { + 'add_input_action': { + 'params': "asset_path, value_type='Bool'", + 'doc': 'Creates a new Enhanced Input Action asset.', + }, + 'add_input_mapping': { + 'params': 'mapping_context_path, action_path, key_name', + 'doc': 'Creates/updates an InputMappingContext with a key-to-action mapping.', + }, + 'set_game_mode': { + 'params': 'game_mode_class_path', + 'doc': "Sets the GameMode Override on the current level's World Settings.", + }, + }, + 'gas': { + 'add_effect_modifier': { + 'params': "asset_path, attribute_set_path, attribute_name, op='add_base', magnitude=1.0", + 'doc': '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).', + }, + 'add_gameplay_tag': { + 'params': "tag, comment=''", + 'doc': 'Registers a gameplay tag in Config/DefaultGameplayTags.ini — takes effect after an editor restart (requires the GameplayAbilities plugin).', + }, + 'clear_effect_modifiers': { + 'params': 'asset_path', + 'doc': 'Removes all modifiers from a GameplayEffect blueprint (requires the GameplayAbilities plugin).', + }, + 'create_ability_blueprint': { + 'params': 'asset_path, parent_class_path', + 'doc': 'Creates a GameplayAbility blueprint; parent_class_path may point at a custom GA subclass (requires the GameplayAbilities plugin).', + }, + 'create_effect_blueprint': { + 'params': "asset_path, duration_policy='instant', duration_seconds", + 'doc': 'Creates a GameplayEffect blueprint with a duration policy: instant, has_duration (+seconds), or infinite (requires the GameplayAbilities plugin).', + }, + 'get_ability_info': { + 'params': 'asset_path', + 'doc': 'Returns parent class, ability tags, and cost/cooldown effect classes of a GameplayAbility blueprint (requires the GameplayAbilities plugin).', + }, + 'get_effect_info': { + 'params': 'asset_path', + 'doc': 'Returns duration policy/seconds and decoded modifiers of a GameplayEffect blueprint (requires the GameplayAbilities plugin).', + }, + 'list_gameplay_tags': { + 'params': "prefix=''", + 'doc': 'Lists gameplay tags registered in Config/DefaultGameplayTags.ini, optionally filtered by prefix (requires the GameplayAbilities plugin).', + }, + 'set_ability_costs': { + 'params': 'asset_path, cost_effect_path, cooldown_effect_path', + 'doc': 'Wires cost and/or cooldown GameplayEffect blueprints onto a GameplayAbility (requires the GameplayAbilities plugin).', + }, + 'set_ability_tags': { + 'params': 'asset_path, tags', + 'doc': 'Sets the AbilityTags container on a GameplayAbility; unregistered tags are reported, not silently dropped (requires the GameplayAbilities plugin).', + }, + 'set_effect_duration': { + 'params': 'asset_path, duration_policy, duration_seconds', + 'doc': "Changes a GameplayEffect's duration policy (instant / has_duration+seconds / infinite) (requires the GameplayAbilities plugin).", + }, + }, + 'layer': { + 'add_actor_to_layer': { + 'params': 'actor_label, layer_name', + 'doc': 'Adds an actor to a layer (creates the layer if needed).', + }, + 'create_layer': { + 'params': 'layer_name', + 'doc': 'Creates a new (empty) layer.', + }, + 'delete_layer': { + 'params': 'layer_name', + 'doc': 'Deletes a layer.', + }, + 'get_actors_in_layer': { + 'params': 'layer_name', + 'doc': 'Lists the labels of actors assigned to a layer.', + }, + 'list_layers': { + 'params': '', + 'doc': 'Lists all layer names in the current world.', + }, + 'remove_actor_from_layer': { + 'params': 'actor_label, layer_name', + 'doc': 'Removes an actor from a layer.', + }, + }, + 'level': { + 'create_level': { + 'params': 'level_path', + 'doc': 'Creates a new empty level and saves it at the given content-browser path.', + }, + 'get_current_level_path': { + 'params': '', + 'doc': 'Returns the path of the currently open editor world/level.', + }, + 'list_level_actors': { + 'params': 'class_filter', + 'doc': 'Lists all actors in the current level.', + }, + 'load_level': { + 'params': 'level_path', + 'doc': 'Opens (loads) an existing level in the editor.', + }, + 'save_all_levels': { + 'params': '', + 'doc': 'Saves all dirty levels.', + }, + 'save_current_level': { + 'params': '', + 'doc': 'Saves the currently open level. Returns success=False for an unsaved/untitled level.', + }, + 'set_world_settings': { + 'params': 'gravity, time_dilation', + 'doc': 'Modifies WorldSettings of the current level.', + }, + }, + 'level_sequence': { + 'add_anim_track': { + 'params': 'asset_path, binding_name, anim_path, start_seconds, end_seconds', + 'doc': 'Adds a skeletal-animation track playing anim_path on a binding (defaults to the playback range).', + }, + 'add_camera': { + 'params': 'asset_path, spawnable=True', + 'doc': 'Adds a CineCamera to a Level Sequence with a Camera Cut track bound to it (official create_camera path).', + }, + 'add_possessable': { + 'params': 'asset_path, actor_label', + 'doc': 'Adds a possessable binding for an existing level actor (by World Outliner label). Returns the binding name.', + }, + 'add_spawnable_from_class': { + 'params': 'asset_path, class_path', + 'doc': "Adds a spawnable binding from a class path (e.g. '/Script/CinematicCamera.CineCameraActor'). Returns the binding name.", + }, + 'add_transform_keyframe': { + 'params': 'asset_path, binding_name, time_seconds, location, rotation, scale', + 'doc': "Adds a keyframe at time_seconds on a binding's transform track. Provide any of", + }, + 'add_transform_track': { + 'params': 'asset_path, binding_name', + 'doc': 'Adds a 3D transform track (with one section) to a binding.', + }, + 'close_sequencer': { + 'params': '', + 'doc': 'Closes the Sequencer editor if one is open.', + }, + 'convert_binding': { + 'params': "asset_path, binding_name, to='spawnable'", + 'doc': "Converts a binding between possessable and spawnable. to='spawnable' or 'possessable'.", + }, + 'create_level_sequence': { + 'params': 'asset_path, fps=30.0, duration_seconds=5.0', + 'doc': 'Creates a Level Sequence asset with the given frame rate and playback duration.', + }, + 'get_sequence_info': { + 'params': 'asset_path', + 'doc': 'Returns frame rate, playback range (seconds), and the bindings of a Level Sequence.', + }, + 'open_in_sequencer': { + 'params': 'asset_path', + 'doc': 'Opens a Level Sequence in the Sequencer editor (and focuses it).', + }, + 'remove_binding': { + 'params': 'asset_path, binding_name', + 'doc': 'Removes a binding (spawnable or possessable) from a Level Sequence by name.', + }, + 'set_playback_range': { + 'params': 'asset_path, start_seconds, end_seconds', + 'doc': 'Sets the playback range (in seconds) of a Level Sequence.', + }, + }, + 'material': { + 'connect_expressions': { + 'params': 'material_path, from_expression_identifier, from_output_name, to_expression_identifier, to_input_name, from_expression_class_name, to_expression_class_name', + 'doc': 'Creates a connection between two material expressions.', + }, + 'connect_property': { + 'params': "material_path, from_expression_identifier, from_output_name='', property_name, from_expression_class_name", + 'doc': 'Connects an expression output to a material property (e.g. BaseColor, Metallic, Roughness, Normal).', + }, + 'create_expression': { + 'params': 'material_path, expression_class_name, node_pos_x=0, node_pos_y=0', + 'doc': 'Creates a new material expression node within the supplied material.', + }, + 'create_material': { + 'params': 'material_path', + 'doc': 'Creates a new Material asset at the given content-browser path.', + }, + 'create_material_instance': { + 'params': 'instance_path, parent_path', + 'doc': 'Creates a Material Instance Constant, optionally parented to parent_path.', + }, + 'delete_expression': { + 'params': 'material_path, expression_identifier, expression_class_name', + 'doc': 'Deletes a material expression node identified by name/desc/type.', + }, + 'get_material_info': { + 'params': 'material_path', + 'doc': 'Returns expression count and a list of expressions (name, class, position) for a material.', + }, + 'get_mi_scalar_param': { + 'params': 'instance_path, parameter_name', + 'doc': 'Gets the current scalar (float) parameter value from a Material Instance Constant.', + }, + 'get_mi_static_switch': { + 'params': 'instance_path, parameter_name', + 'doc': 'Gets a static switch parameter from a Material Instance. Returns JSON string.', + }, + 'get_mi_texture_param': { + 'params': 'instance_path, parameter_name', + 'doc': 'Gets a texture parameter from a Material Instance. Returns JSON string with texture path.', + }, + 'get_mi_vector_param': { + 'params': 'instance_path, parameter_name', + 'doc': 'Gets a vector parameter from a Material Instance. Returns JSON string.', + }, + 'layout_expressions': { + 'params': 'material_path', + 'doc': 'Auto-lays out all expression nodes in a material graph.', + }, + 'list_parameters': { + 'params': 'material_path', + 'doc': 'Lists scalar / vector / texture / static-switch parameter names for a material or material instance.', + }, + 'recompile': { + 'params': 'material_path', + 'doc': "Triggers a recompile of a material or material instance's parent. Saves the asset.", + }, + 'set_expression_property': { + 'params': 'material_path, expression_identifier, property_name, value, expression_class_name', + 'doc': "Sets an editor property on a material expression (e.g. 'r' on a Constant,", + }, + 'set_instance_parent': { + 'params': 'instance_path, parent_path', + 'doc': 'Reparents a Material Instance Constant to a new parent material/instance.', + }, + 'set_mi_scalar_param': { + 'params': 'instance_path, parameter_name, value', + 'doc': 'Sets the scalar (float) parameter value for a Material Instance Constant.', + }, + 'set_mi_static_switch': { + 'params': 'instance_path, parameter_name, value', + 'doc': 'Sets a static switch parameter on a Material Instance. Returns JSON string.', + }, + 'set_mi_texture_param': { + 'params': 'instance_path, parameter_name, texture_path', + 'doc': 'Sets a texture parameter on a Material Instance. Provide texture asset path. Returns JSON string.', + }, + 'set_mi_vector_param': { + 'params': 'instance_path, parameter_name, value', + 'doc': 'Sets a vector parameter on a Material Instance. Expects value as [R,G,B,A]. Returns JSON string.', + }, + }, + 'retarget': { + 'add_retarget_chain': { + 'params': "ik_rig_path, chain_name, start_bone, end_bone, goal_name=''", + 'doc': "Adds a retarget chain (e.g. 'Spine': spine_01..spine_03) to an IK Rig (requires the IKRig plugin).", + }, + 'auto_map_chains': { + 'params': "retargeter_path, mode='FUZZY', force=True", + 'doc': 'Re-runs chain mapping on an IK Retargeter. mode: FUZZY, EXACT, or CLEAR (requires the IKRig plugin).', + }, + 'batch_retarget': { + 'params': "retargeter_path, anim_paths, source_mesh_path, target_mesh_path, search='', replace='', prefix='', suffix='_Retargeted'", + 'doc': 'Duplicates and retargets animations through an IK Retargeter; returns the new asset paths (requires the IKRig plugin).', + }, + 'create_ik_rig': { + 'params': 'asset_path, skeletal_mesh_path, retarget_root', + 'doc': 'Creates an IK Rig for a skeletal mesh, optionally setting the retarget root bone (requires the IKRig plugin).', + }, + 'create_retargeter': { + 'params': 'asset_path, source_ik_rig_path, target_ik_rig_path, auto_map=True', + 'doc': 'Creates an IK Retargeter wired to source/target IK Rigs, with optional fuzzy chain auto-mapping (requires the IKRig plugin).', + }, + 'get_ik_rig_info': { + 'params': 'ik_rig_path', + 'doc': 'Returns the skeletal mesh, retarget root, and chains of an IK Rig (requires the IKRig plugin).', + }, + }, + 'static_mesh': { + 'add_simple_collision': { + 'params': "asset_path, shape='BOX'", + 'doc': 'Adds a simple collision primitive to a StaticMesh. shape: BOX, SPHERE, CAPSULE, NDOP10_X/Y/Z, NDOP18, NDOP26.', + }, + 'get_collision_info': { + 'params': 'asset_path', + 'doc': 'Returns collision complexity and simple/convex collision counts of a StaticMesh.', + }, + 'get_lod_screen_sizes': { + 'params': 'asset_path', + 'doc': 'Returns the screen-size threshold of each LOD on a StaticMesh.', + }, + 'get_static_mesh_info': { + 'params': 'asset_path', + 'doc': 'Returns LOD/section/triangle/vertex/material counts and Nanite state of a StaticMesh.', + }, + 'list_static_mesh_materials': { + 'params': 'asset_path', + 'doc': 'Lists the material slots of a StaticMesh (slot index + material path).', + }, + 'remove_collisions': { + 'params': 'asset_path', + 'doc': 'Removes all simple/convex collision from a StaticMesh.', + }, + 'remove_lods': { + 'params': 'asset_path', + 'doc': 'Removes all LODs except LOD 0 from a StaticMesh.', + }, + 'set_convex_collision': { + 'params': 'asset_path, hull_count=4, max_hull_verts=16, hull_precision=100000', + 'doc': 'Replaces simple collision with auto-generated convex decomposition collision.', + }, + 'set_lod_for_collision': { + 'params': 'asset_path, lod_index', + 'doc': "Sets which LOD's geometry is used for complex collision on a StaticMesh.", + }, + 'set_lod_from_static_mesh': { + 'params': 'asset_path, lod_index, source_path, source_lod_index=0, reuse_existing_material_slots=True', + 'doc': "Adds/sets a LOD on a StaticMesh using geometry from another StaticMesh's LOD.", + }, + 'set_lods': { + 'params': 'asset_path, lod_settings, auto_compute_screen_size=False', + 'doc': 'Generates LODs from reduction settings: lod_settings=[{percent_triangles, screen_size}, ...] (LOD0 first).', + }, + 'set_static_mesh_material': { + 'params': 'asset_path, slot_index, material_path', + 'doc': 'Assigns a material to a StaticMesh material slot.', + }, + }, + 'texture': { + 'get_texture_info': { + 'params': 'asset_path', + 'doc': 'Returns size, memory, sRGB, and compression settings of a Texture2D.', + }, + 'set_texture_compression': { + 'params': 'asset_path, compression', + 'doc': "Sets the compression settings of a Texture2D (e.g. 'TC_DEFAULT', 'TC_NORMALMAP', 'TC_MASKS', 'TC_GRAYSCALE').", + }, + 'set_texture_srgb': { + 'params': 'asset_path, srgb', + 'doc': 'Sets the sRGB flag on a Texture2D.', + }, + }, + 'umg': { + 'add_widget': { + 'params': 'asset_path, widget_type, widget_name, parent_name', + 'doc': '', + }, + 'bind_widget_event': { + 'params': 'asset_path, widget_name, event_name', + 'doc': "Creates a bound event node in the widget BP's event graph for a widget delegate (e.g. Button OnClicked).", + }, + 'compile_widget_blueprint': { + 'params': 'asset_path', + 'doc': '', + }, + 'create_widget_blueprint': { + 'params': "name, path, parent_class='UserWidget'", + 'doc': '', + }, + 'get_widget_blueprint_info': { + 'params': 'asset_path', + 'doc': '', + }, + 'get_widget_property': { + 'params': 'asset_path, widget_name, property_name', + 'doc': 'Gets the value of a C++ UPROPERTY on a named widget.', + }, + 'list_widget_events': { + 'params': 'asset_path, widget_name', + 'doc': 'Lists the bindable multicast-delegate events on a widget (e.g. OnClicked, OnHovered).', + }, + 'remove_widget': { + 'params': 'asset_path, widget_name', + 'doc': '', + }, + 'reparent_widget': { + 'params': 'asset_path, widget_name, new_parent_name', + 'doc': 'Moves a widget under a different panel parent (cycle-guarded).', + }, + 'replace_widget': { + 'params': 'asset_path, widget_name, new_type, new_name', + 'doc': 'Replaces a widget with a new widget of new_type at the same slot (old subtree discarded).', + }, + 'set_slot_layout': { + 'params': 'asset_path, widget_name, anchor_min_x=0.5, anchor_min_y=0.5, anchor_max_x=0.5, anchor_max_y=0.5, offset_x=0.0, offset_y=0.0, size_x=100.0, size_y=40.0', + 'doc': 'Sets CanvasPanelSlot layout (anchors + offset + size) on a widget.', + }, + 'set_text_style': { + 'params': 'asset_path, widget_name, font_size=24, color_r=1.0, color_g=1.0, color_b=1.0, color_a=1.0, outline_size=0', + 'doc': 'Sets font size, text color, and outline size on a TextBlock widget.', + }, + 'set_widget_properties': { + 'params': 'asset_path, widget_name, properties', + 'doc': '', + }, + 'set_widget_property': { + 'params': 'asset_path, widget_name, property_name, value', + 'doc': 'Sets a C++ UPROPERTY on a named widget from a string value.', + }, + 'wrap_widget': { + 'params': 'asset_path, widget_name, wrapper_type, wrapper_name', + 'doc': "Wraps a widget in a new panel (wrapper_type, e.g. 'VerticalBox') that takes its place.", + }, + }, + 'util': { + 'execute_console_command': { + 'params': 'command', + 'doc': "Executes an editor console command (e.g. 'stat fps', 'r.ScreenPercentage 50').", + }, + 'execute_python': { + 'params': 'code', + 'doc': 'Runs arbitrary Unreal Python code. Full API access; fastest path to prototype new actions.', + }, + 'get_cvar': { + 'params': 'name', + 'doc': "Reads the current value of a console variable (CVar) as a string, e.g. 'r.ScreenPercentage'.", + }, + 'get_output_log': { + 'params': 'line_count=50, keyword, context_lines=0', + 'doc': 'Returns recent lines from the UE output log file; optional keyword filter with context_lines around each match.', + }, + 'get_project_info': { + 'params': '', + 'doc': 'Returns project name, directories, and engine version.', + }, + 'get_viewport_camera': { + 'params': '', + 'doc': 'Returns the level viewport camera location and rotation.', + }, + 'is_in_pie': { + 'params': '', + 'doc': 'Returns whether Play-In-Editor is currently active.', + }, + 'list_class_properties': { + 'params': 'class_path', + 'doc': 'Lists the editor-settable property names of a UClass (for discovering what set_property accepts).', + }, + 'list_enum_values': { + 'params': 'enum_name', + 'doc': "Lists the values of an Unreal enum by name (e.g. 'TextureCompressionSettings', 'CollisionTraceFlag').", + }, + 'livecoding_compile': { + 'params': '', + 'doc': 'Triggers C++ Live Coding and waits for the compile result.', + }, + 'print_message': { + 'params': 'message', + 'doc': 'Logs a message to the Unreal log and returns a JSON success response.', + }, + 'save_all_dirty': { + 'params': '', + 'doc': 'Saves all dirty packages (modified maps and content).', + }, + 'screen_to_world': { + 'params': 'x, y, distance=1000.0', + 'doc': "Deprojects a viewport pixel (x, y) to a world location at 'distance' along the view ray.", + }, + 'set_cvar': { + 'params': 'name, value', + 'doc': "Sets a console variable, e.g. name='r.ScreenPercentage', value='75'. Reads it back to confirm.", + }, + 'set_log_verbosity': { + 'params': 'category, verbosity', + 'doc': "Sets a log category's verbosity via the 'Log' console command (e.g. 'LogBlueprint', 'Verbose').", + }, + 'set_viewport_camera': { + 'params': 'location, rotation', + 'doc': 'Sets the level viewport camera. location=[x,y,z], rotation=[pitch,yaw,roll].', + }, + 'start_pie': { + 'params': '', + 'doc': 'Starts Play-In-Editor (asynchronous; begins on the next frame).', + }, + 'stop_pie': { + 'params': '', + 'doc': 'Stops Play-In-Editor.', + }, + 'world_to_screen': { + 'params': 'location', + 'doc': 'Projects a world location to active level-viewport pixel coords (editor viewport, no PIE needed).', + }, + }, + 'vision': { + 'capture_actors': { + 'params': 'actor_labels, width=1280, height=720, fov=60.0, padding=1.6, annotate=True', + 'doc': 'Frames the given actors (by label) from an elevated 3/4 view and captures them.', + }, + 'capture_from': { + 'params': 'location, rotation, width=1280, height=720, fov=90.0', + 'doc': 'Captures the scene from an explicit camera pose. location=[x,y,z], rotation=[pitch,yaw,roll].', + }, + 'capture_viewport': { + 'params': 'width=1280, height=720, fov=90.0', + 'doc': "Captures the active level viewport (3D scene only) as a PNG, returned base64 in 'image_data'.", + }, + }, +} diff --git a/mcp-server/src/unreal_mcp/main.py b/mcp-server/src/unreal_mcp/main.py new file mode 100644 index 0000000..c184135 --- /dev/null +++ b/mcp-server/src/unreal_mcp/main.py @@ -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() \ No newline at end of file diff --git a/mcp-server/src/unreal_mcp/server.py b/mcp-server/src/unreal_mcp/server.py new file mode 100644 index 0000000..d80f468 --- /dev/null +++ b/mcp-server/src/unreal_mcp/server.py @@ -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() diff --git a/mcp-server/tests/test_core.py b/mcp-server/tests/test_core.py new file mode 100644 index 0000000..9bc08af --- /dev/null +++ b/mcp-server/tests/test_core.py @@ -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" diff --git a/mcp-server/tests/test_coverage.py b/mcp-server/tests/test_coverage.py new file mode 100644 index 0000000..8eeb294 --- /dev/null +++ b/mcp-server/tests/test_coverage.py @@ -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_.py references "ue_"), 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_ 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_.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}" diff --git a/mcp-server/tests/test_dispatcher.py b/mcp-server/tests/test_dispatcher.py new file mode 100644 index 0000000..a0aac4d --- /dev/null +++ b/mcp-server/tests/test_dispatcher.py @@ -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_ + 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_.""" + 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"] diff --git a/mcp-server/tests/test_e2e.py b/mcp-server/tests/test_e2e.py new file mode 100644 index 0000000..c2befda --- /dev/null +++ b/mcp-server/tests/test_e2e.py @@ -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)." diff --git a/mcp-server/uv.lock b/mcp-server/uv.lock new file mode 100644 index 0000000..03ebe0b --- /dev/null +++ b/mcp-server/uv.lock @@ -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" }, +] diff --git a/mcp-server/validate_tools.py b/mcp-server/validate_tools.py new file mode 100644 index 0000000..b86daa2 --- /dev/null +++ b/mcp-server/validate_tools.py @@ -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()